fix(spring): a route links the method it annotates, including a package-private one (#3009)

The Spring extractor found a mapping's handler with a pattern that wanted
`public`, `private` or `protected` (or Kotlin's `fun`) before the name, in
the next 600 characters. A package-private handler, like Spring Boot 3's
`@GetMapping List<Article> list()`, matched nothing, so its route linked the
next method declared with a modifier, or nothing at all, and a method-level
`@RequestMapping(value = "/feed", method = GET)` on such a method made no
route. A generic Kotlin `fun <T> find()` handler fell through the same way.
Separately, a route bound by name alone took the wrong overload when a tall
annotation stack (`@Operation`, `@ApiResponses`) above the mapping left the
next overload's declaration nearer the annotation.

The extractor now reads the declaration the annotation is written on: past
it and every annotation stacked with it (arguments read as balanced groups,
strings and text blocks included), the modifiers (none at all for a
package-private method), type parameters and the return type (void, a
primitive, a qualified or nested generic type, an array) to `name(`; for
Kotlin, past its modifiers to `fun`, type parameters and `name(`. A field,
a constructor or a class under the annotation gives no handler. The mapping
annotation's own arguments are read the same balanced way. At resolution,
the route's handler reference binds to the method of that name whose span
(which starts at its first annotation) holds the annotation's line, the
innermost such method; any other case resolves by name as before.

Validation, kernel engine, main 01e0ac3a vs this commit:
- realworld-java21 93e018e: Spring routes linked 4/19 -> 19/19; the 15 new
  edges each go to the method the mapping is written on.
- spring-petclinic 500158f (17 routes), spring-petclinic-kotlin 817dba3
  (18), eladmin 55fbf70 (133), mall dcaa93b (246), dolphinscheduler
  21968bc (239): same targets, only the edge metadata changes (exact-match
  0.7/0.9 -> framework 0.95).
- Controls jsoup 0c508f1 (Java, no Spring), requests 3c7baaa (Python),
  cobra adbc881 (Go): dump-graph byte-identical.
- Index time, median of 3 interleaved: mall 12.3 s vs 12.6 s, eladmin
  3.02 s vs 3.03 s (noise on a shared machine).
- New tests fail on main's java.ts (20 failing) and pass with the fix, in
  both engines; dropping the positional binding fails only the overload
  test.

Reimplements PR #851 by @Liurchen, who found the package-private gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-29 22:06:35 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8e854d11e5
commit 3d19da2f59
4 changed files with 487 additions and 16 deletions
+1
View File
@@ -130,6 +130,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Rust, a name a `use` renames, like `consume()` after `use crate::util::take as consume;`, now links to the item its path names, and a `use` written inside a function or a block counts only there, ahead of the file's own `use`s and items, so `fn first() { use crate::util::take; take() }` reaches util's `take` and the next function's own `use` its own. A call to a parameter, a closure or a local that shares a function's name, like `take()` inside `fn run(take: fn())` or after `let take = || {};`, no longer links to that function, while `let test = test::<i8>;` still calls `test`, and a `use` of an enum's variant, like `use crate::models::Mode::Fast;`, links `Fast(…)` to that enum's variant. A path through a module, like `super::helper()`, `crate::config()`, `super::shared::helper()` inside a `mod tests { … }` or `tokio::task::yield_now()`, now reaches the item that module declares or passes on with a `pub use`, in your own crate or another crate of the workspace, and its `use` links to it; that includes binaries whose manifest gives them a path of their own, like ripgrep's, and packages whose manifest lists `name` after other fields, like axum-core's. A `use` written inside a string no longer counts. Re-index Rust projects after upgrading. Thanks @danusha2345. (#2308)
- Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326)
- In Play projects, a route or a call like `MediaType.parse(...)` now links to a method of the class or object it names, and a call to an overloaded one to the overload its arguments fit. Before, it went to the first method of that name anywhere in the class's file, often a nested or sibling class's. When a Scala and a Java type share a name, as Play's own `RequestAuthority` does, each language's calls now reach their own. Thanks @danusha2345.
- In Spring projects, a route now links to the method its mapping annotation is written on when that method has no access modifier, like `@GetMapping List<Article> list()` in a package-private controller, and a method-level `@RequestMapping(value = "/feed", method = GET)` on such a method now makes a route. Before, such a handler was skipped, so its route linked the next method declared `public`, `private` or `protected`, or nothing, and the handler looked unused. An overloaded handler under a tall stack of annotations, like `@Operation` and `@ApiResponses` above `@GetMapping`, now links the overload it annotates rather than its neighbour. Re-index Spring projects after upgrading. Thanks @Liurchen.
- In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300)
- In JavaScript and TypeScript, a function exported with its name in quotes, like `exports["getItems"] = async (req, res) => {…}` or `module.exports['deleteItem'] = function (req, res) {…}`, is now a symbol named after the export, as `exports.getItems = …` already was. Before, it had no symbol, so nothing could find it or link to it, and its calls counted as the file's own. Re-index JavaScript and TypeScript projects after upgrading. Thanks @johnhldiver-beep.
- In JavaScript and TypeScript files with Windows (CRLF) line endings, as a Windows checkout usually has them, a store exported on a later line, like `const useStore = create(…)` followed by `export default useStore;`, now has its actions indexed, as it already did with Unix line endings. Before, those actions were missing from the graph on Windows, so their callers and impact came back empty. Re-index JavaScript and TypeScript projects after upgrading.
+87
View File
@@ -1170,6 +1170,93 @@ class OwnerController {
expect(nodes[0].name).toBe('GET /owners/{ownerId}');
expect(references[0].referenceName).toBe('showOwner');
});
// #851: a package-private handler (no access modifier) used to be skipped,
// so the next method with a modifier took its route.
it('links each package-private handler to its own name, not the next public method (#851)', () => {
const src = `@RestController
@RequestMapping("/api/articles")
class ArticleController {
@GetMapping
List<String> list() { return List.of(); }
@GetMapping("/{slug}")
String get(@PathVariable String slug) { return slug; }
@PostMapping
void create(@RequestBody String body) { }
@DeleteMapping("/{slug}")
public void delete(@PathVariable String slug) { }
@RequestMapping(value = "/feed", method = RequestMethod.GET)
ResponseEntity<String> feed() { return null; }
}`;
const { nodes, references } = springResolver.extract!('ArticleController.java', src);
expect(nodes.map(n => n.name)).toEqual([
'GET /api/articles', 'GET /api/articles/{slug}', 'POST /api/articles', 'DELETE /api/articles/{slug}', 'GET /api/articles/feed',
]);
expect(references.map(r => [r.fromNodeId, r.referenceName])).toEqual(
nodes.map((n, i) => [n.id, ['list', 'get', 'create', 'delete', 'feed'][i]]),
);
});
it.each([
['List<String> generic()', 'generic'],
['void none(@RequestBody String body)', 'none'],
['int primitive()', 'primitive'],
['String[][] array()', 'array'],
['org.example.dto.Page<Item> qualified()', 'qualified'],
['ResponseEntity<Map<String, List<Integer>>> nested()', 'nested'],
['static <T extends Comparable<T>> java.util.Map<String, java.util.List<T>>[] typeParams()', 'typeParams'],
['public @ResponseBody int[] annotatedType()', 'annotatedType'],
['final synchronized Object modifiers()', 'modifiers'],
['default ResponseEntity<Void> fromInterface()', 'fromInterface'],
['@Operation(summary = "List (all) items", tags = {"a)"})\n @ApiResponses({ @ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "404") })\n List<Item> stacked()', 'stacked'],
])('reads the handler declared under the mapping: %s (#851)', (declaration, name) => {
const src = `@RestController
@RequestMapping("/base")
class ItemController {
@GetMapping("/x")
${declaration} { return null; }
public void next() { }
}`;
const { references } = springResolver.extract!('ItemController.java', src);
expect(references.map(r => r.referenceName)).toEqual([name]);
});
it.each([
['fun plain(): String', 'plain'],
['internal suspend fun modified(): String', 'modified'],
['fun <T> generic(): List<T>', 'generic'],
['@ResponseBody\n override fun stacked(): String', 'stacked'],
])('reads the Kotlin handler declared under the mapping: %s (#851)', (declaration, name) => {
const src = `@RestController
@RequestMapping("/base")
class ItemController {
@GetMapping("/x")
${declaration} = "ok"
fun next(): String = "next"
}`;
const { references } = springResolver.extract!('ItemController.kt', src);
expect(references.map(r => r.referenceName)).toEqual([name]);
});
it.each([
['private final ItemService service;', 'a field'],
['ItemController(ItemService service) { }', 'a constructor'],
])('links no later method when the mapping is on %s (%s, #851)', (declaration) => {
const src = `@RestController
class ItemController {
@GetMapping("/x")
${declaration}
public String next() { return "next"; }
}`;
expect(springResolver.extract!('ItemController.java', src).references).toEqual([]);
});
});
describe('springResolver.resolve — DI heuristics are gated to Java/Kotlin non-inheritance refs', () => {
+187
View File
@@ -0,0 +1,187 @@
/**
* A Spring route links the method its mapping annotation is written on (#851).
*
* The handler used to be found by a pattern that wanted `public`, `private` or
* `protected` (or Kotlin's `fun`) before the name, so a package-private
* method was skipped and the next method with a modifier took its route:
* realworld-java21's controllers, which declare no modifiers, had most routes
* pointing at the wrong method or at nothing. A method-level
* `@RequestMapping(value = …, method = GET)` on such a method made no route.
*
* The route is also bound by position: of two overloads, the one the
* annotation sits on, even when a tall annotation stack above it leaves the
* other overload's declaration nearer the annotation.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getKernel, resetKernelForTests } from '../src/extraction/kernel';
const kernelBuilt = fs.existsSync(path.join(__dirname, '../codegraph-kernel/prebuilds',
`${process.platform}-${process.arch}`, 'codegraph-kernel.node'));
const POM = '<project><dependencies><dependency><groupId>org.springframework.boot</groupId>'
+ '<artifactId>spring-boot-starter-web</artifactId></dependency></dependencies></project>\n';
const ARTICLES = 'src/main/java/com/example/ArticleController.java';
const ARTICLES_SRC = `package com.example;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/articles")
class ArticleController {
@GetMapping
List<String> list() {
return List.of();
}
@GetMapping("/{slug}")
String get(@PathVariable String slug) {
return slug;
}
@PostMapping
void create(@RequestBody String body) {
}
@DeleteMapping("/{slug}")
public void delete(@PathVariable String slug) {
}
@RequestMapping(value = "/feed", method = RequestMethod.GET)
ResponseEntity<String> feed() {
return null;
}
}
`;
const SEARCH = 'src/main/java/com/example/SearchController.java';
const SEARCH_SRC = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class SearchController {
@Operation(summary = "Find all")
@ApiResponses({
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "404")
})
@GetMapping("/find")
public String find() {
return "all";
}
@GetMapping("/find/{id}")
public String find(@PathVariable int id) {
return "one";
}
}
`;
const OWNERS = 'src/main/kotlin/com/example/OwnerController.kt';
const OWNERS_SRC = `package com.example
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/owners")
class OwnerController {
@GetMapping("/{ownerId}")
@ResponseBody
fun showOwner(@PathVariable ownerId: Int): String = "owner"
@GetMapping("/find")
fun <T> find(): List<T> = emptyList()
@PostMapping
internal fun create(@RequestBody body: String): String {
return body
}
}
`;
/** The 1-based line of the first line of `src` that contains `needle`. */
function lineOf(src: string, needle: string): number {
const at = src.split(/\r?\n/).findIndex((l) => l.includes(needle));
expect(at, needle).toBeGreaterThanOrEqual(0);
return at + 1;
}
describe.each(['native', 'wasm'].filter((backend) => backend === 'wasm' || kernelBuilt))(
'Spring routes link the method they annotate (%s, #851)', (backend) => {
let dir: string;
let cg: CodeGraph | undefined;
beforeEach(() => {
vi.stubEnv('CODEGRAPH_KERNEL', backend === 'wasm' ? '0' : '1');
vi.stubEnv('CODEGRAPH_KERNEL_LANGS', 'all');
resetKernelForTests();
if (backend === 'native') expect(getKernel()).not.toBeNull();
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-851-'));
});
afterEach(() => {
cg?.close();
cg = undefined;
fs.rmSync(dir, { recursive: true, force: true });
vi.unstubAllEnvs();
resetKernelForTests();
});
async function index(files: Record<string, string>): Promise<CodeGraph> {
for (const [rel, body] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
}
cg = await CodeGraph.init(dir, { index: true });
return cg;
}
/** Each route of `file` → its handler, as `name@startLine` (`-` for none). */
function handlers(g: CodeGraph, file: string): Record<string, string> {
const out: Record<string, string> = {};
for (const route of g.getNodesInFile(file).filter((n) => n.kind === 'route')) {
const targets = g.getOutgoingEdges(route.id)
.filter((e) => e.kind === 'references')
.map((e) => g.getNode(e.target)!)
.map((t) => `${t.kind}:${t.name}@${t.startLine}`);
out[route.name] = targets.length > 0 ? targets.join(',') : '-';
}
return out;
}
it('binds every route of a package-private controller to its own method', async () => {
const g = await index({ 'pom.xml': POM, [ARTICLES]: ARTICLES_SRC });
expect(handlers(g, ARTICLES)).toEqual({
'GET /api/articles': `method:list@${lineOf(ARTICLES_SRC, 'List<String> list()') - 1}`,
'GET /api/articles/{slug}': `method:get@${lineOf(ARTICLES_SRC, '@GetMapping("/{slug}")')}`,
'POST /api/articles': `method:create@${lineOf(ARTICLES_SRC, '@PostMapping')}`,
'DELETE /api/articles/{slug}': `method:delete@${lineOf(ARTICLES_SRC, '@DeleteMapping')}`,
'GET /api/articles/feed': `method:feed@${lineOf(ARTICLES_SRC, '@RequestMapping(value = "/feed"')}`,
});
});
it('binds each overload to the route written on it, under a tall annotation stack', async () => {
const g = await index({ 'pom.xml': POM, [SEARCH]: SEARCH_SRC });
expect(handlers(g, SEARCH)).toEqual({
'GET /find': `method:find@${lineOf(SEARCH_SRC, '@Operation')}`,
'GET /find/{id}': `method:find@${lineOf(SEARCH_SRC, '@GetMapping("/find/{id}")')}`,
});
});
it('binds Kotlin handlers, generic and with modifiers', async () => {
const g = await index({ 'pom.xml': POM, [OWNERS]: OWNERS_SRC });
expect(handlers(g, OWNERS)).toEqual({
'GET /owners/{ownerId}': `method:showOwner@${lineOf(OWNERS_SRC, '@GetMapping("/{ownerId}")')}`,
'GET /owners/find': `method:find@${lineOf(OWNERS_SRC, '@GetMapping("/find")')}`,
'POST /owners': `method:create@${lineOf(OWNERS_SRC, '@PostMapping')}`,
});
});
});
+212 -16
View File
@@ -59,6 +59,13 @@ export const springResolver: FrameworkResolver = {
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// A route's handler is the method its mapping annotation is written on,
// told apart from an overload by position: under a tall `@Operation` /
// `@ApiResponses` stack, the next overload's declaration can be nearer the
// annotation than the method it annotates (#851).
const handler = routeHandlerAt(ref, context);
if (handler) return { original: ref, targetNodeId: handler.id, confidence: 0.95, resolvedBy: 'framework' };
// Spring config-key references — `@Value("${key}")` (single leaf) and
// `@ConfigurationProperties(prefix="X")` (entire subtree, marked with the
// `:prefix` suffix in extractSpringValueBindings). Lookup goes through
@@ -237,16 +244,16 @@ export const springResolver: FrameworkResolver = {
GetMapping: 'GET', PostMapping: 'POST', PutMapping: 'PUT', PatchMapping: 'PATCH', DeleteMapping: 'DELETE',
};
// Verb-specific method mappings — always method-level, BARE or with a path.
const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b\s*(\([^)]*\))?/g;
const mappingRegex = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b/g;
let match: RegExpExecArray | null;
while ((match = mappingRegex.exec(safe)) !== null) {
const annotation = readAnnotation(safe, match.index, lang);
if (!annotation) continue;
const method = VERB[match[1]!]!;
const paths = parseMappingPaths((match[2] || '').replace(/^\(|\)$/g, ''), consts);
const paths = parseMappingPaths(annotation.args, consts);
const line = safe.slice(0, match.index).split('\n').length;
// Method it decorates: first declared method after (skip stacked annotations;
// Java puts the return type before the name). Bounded so we don't grab a far one.
const tail = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
const methodMatch = tail.match(/\bfun\s+(\w+)\s*\(|\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
// The method it decorates: the declaration it is written on (#851).
const handler = annotatedMethodName(safe, match.index, lang);
for (const routePath of classPrefixes.flatMap(prefix => paths.map(sub => joinPath(prefix, sub)))) {
const routeNode: Node = {
id: `route:${filePath}:${line}:${method}:${routePath}`,
@@ -257,16 +264,16 @@ export const springResolver: FrameworkResolver = {
startLine: line,
endLine: line,
startColumn: 0,
endColumn: match[0].length,
endColumn: annotation.end - match.index,
language: lang,
updatedAt: now,
};
nodes.push(routeNode);
if (methodMatch) {
if (handler) {
references.push({
fromNodeId: routeNode.id,
referenceName: (methodMatch[1] ?? methodMatch[2])!,
referenceName: handler,
referenceKind: 'references',
line,
column: 0,
@@ -280,13 +287,16 @@ export const springResolver: FrameworkResolver = {
// Method-level @RequestMapping (older style: `@RequestMapping(value="/x",
// method=RequestMethod.GET)` on a method). The class-level @RequestMapping is
// the prefix (handled above) — skip it here so it isn't double-counted.
const reqRe = /@RequestMapping\b\s*(\([^)]*\))?/g;
const reqRe = /@RequestMapping\b/g;
while ((match = reqRe.exec(safe)) !== null) {
const args = (match[1] || '').replace(/^\(|\)$/g, '');
const after = safe.slice(match.index + match[0].length, match.index + match[0].length + 600);
const annotation = readAnnotation(safe, match.index, lang);
if (!annotation) continue;
const args = annotation.args;
const after = safe.slice(annotation.end, annotation.end + 600);
if (/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+|open\s+|data\s+|sealed\s+)*class\b/.test(after)) continue; // class-level prefix
const methodMatch = after.match(/\bfun\s+(\w+)\s*\(|\b(?:public|private|protected)\s+[^;{=]*?\s+(\w+)\s*\(/);
if (!methodMatch) continue;
// Only on a method: the declaration it is written on (#851).
const handler = annotatedMethodName(safe, match.index, lang);
if (!handler) continue;
const verbM = args.match(/method\s*=\s*[{\[]?\s*(?:RequestMethod\.)?(\w+)/);
const method = verbM ? verbM[1]!.toUpperCase() : 'ANY';
const paths = parseMappingPaths(args, consts);
@@ -297,12 +307,12 @@ export const springResolver: FrameworkResolver = {
kind: 'route',
name: `${method} ${routePath}`,
qualifiedName: `${filePath}::route:${routePath}`,
filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: lang, updatedAt: now,
filePath, startLine: line, endLine: line, startColumn: 0, endColumn: annotation.end - match.index, language: lang, updatedAt: now,
};
nodes.push(routeNode);
references.push({
fromNodeId: routeNode.id,
referenceName: (methodMatch[1] ?? methodMatch[2])!,
referenceName: handler,
referenceKind: 'references',
line, column: 0, filePath, language: lang,
});
@@ -554,6 +564,192 @@ function joinPath(prefix: string, sub: string): string {
return '/' + parts.join('/');
}
/** Java modifiers a method may carry before its type parameters and return type. */
const JAVA_METHOD_MODIFIERS = new Set([
'public', 'protected', 'private', 'static', 'final', 'abstract', 'synchronized', 'native', 'strictfp', 'default',
]);
/** Kotlin modifiers a function may carry before `fun`. */
const KOTLIN_FUN_MODIFIERS = new Set([
'public', 'protected', 'private', 'internal', 'open', 'override', 'final', 'abstract',
'suspend', 'inline', 'operator', 'infix', 'tailrec', 'external', 'actual', 'expect',
]);
/** Words that begin a declaration other than a method where a Java return type would stand. */
const NOT_A_RETURN_TYPE = new Set(['class', 'interface', 'enum', 'record']);
const IDENTIFIER = /[A-Za-z_$][\w$]*/y;
function readIdentifier(s: string, i: number): string | null {
IDENTIFIER.lastIndex = i;
return IDENTIFIER.exec(s)?.[0] ?? null;
}
function skipSpace(s: string, i: number): number {
while (i < s.length && /\s/.test(s[i]!)) i++;
return i;
}
/** The index past the string or char literal (or `"""` text block) opening at `i`; -1 when it never closes. */
function skipQuoted(s: string, i: number): number {
const quote = s[i]!;
if (s.startsWith('"""', i)) {
const end = s.indexOf('"""', i + 3);
return end < 0 ? -1 : end + 3;
}
for (let j = i + 1; j < s.length; j++) {
if (s[j] === '\\') j++;
else if (s[j] === quote) return j + 1;
else if (s[j] === '\n') return -1;
}
return -1;
}
/** The index past the bracketed group opening at `i` — nested groups and literals inside it included; -1 when it never closes. */
function skipGroup(s: string, i: number): number {
const open = s[i]!;
const close = open === '(' ? ')' : open === '[' ? ']' : open === '{' ? '}' : '>';
let depth = 0;
for (let j = i; j < s.length; j++) {
const c = s[j]!;
if (c === '"' || (c === "'" && open !== '<')) {
const end = skipQuoted(s, j);
if (end < 0) return -1;
j = end - 1;
} else if (c === open) {
depth++;
} else if (c === close && --depth === 0) {
return j + 1;
}
}
return -1;
}
/**
* The annotation at `at` — `@Name`, `@a.b.Name`, Kotlin's `@field:Name` or
* `@[A B]` — with its arguments read as a balanced group, so a `)` in a string
* or a nested annotation doesn't end it. `end` is past the arguments, or past
* the whitespace after a bare name. Null when no annotation is there, or its
* arguments never close.
*/
function readAnnotation(s: string, at: number, lang: 'java' | 'kotlin'): { args: string; end: number } | null {
if (s[at] !== '@') return null;
let i = at + 1;
if (lang === 'kotlin' && s[i] === '[') {
const end = skipGroup(s, i);
return end < 0 ? null : { args: '', end };
}
const readName = (from: number): number => {
let j = from;
for (;;) {
const part = readIdentifier(s, j);
if (!part) return -1;
j += part.length;
if (s[j] !== '.') return j;
j++;
}
};
i = readName(i);
if (i < 0) return null;
// A Kotlin use-site target: `@field:Name`, `@get:Name`.
if (lang === 'kotlin' && s[i] === ':') {
i = readName(i + 1);
if (i < 0) return null;
}
const next = skipSpace(s, i);
if (s[next] !== '(') return { args: '', end: next };
const end = skipGroup(s, next);
return end < 0 ? null : { args: s.slice(next + 1, end - 1), end };
}
/**
* The name of the method the annotation at `at` is written on (#851): read
* past it and every annotation stacked with it, the modifiers — none at all
* for a package-private Java method — the type parameters and the return type
* (`void`, a primitive, a qualified or nested generic type, an array) to
* `name(`; for Kotlin, past the modifiers to `fun`, its type parameters, and
* `name(`. Null when what the annotation is written on is no method: a field,
* a constructor, a class.
*/
function annotatedMethodName(s: string, at: number, lang: 'java' | 'kotlin'): string | null {
const modifiers = lang === 'kotlin' ? KOTLIN_FUN_MODIFIERS : JAVA_METHOD_MODIFIERS;
// Annotations and modifiers come in any order (`public @ResponseBody String x()`).
const skipAnnotationsAndModifiers = (from: number): number => {
let i = from;
for (;;) {
i = skipSpace(s, i);
if (s[i] === '@') {
const annotation = readAnnotation(s, i, lang);
if (!annotation) return -1;
i = annotation.end;
continue;
}
const word = readIdentifier(s, i);
if (!word || !modifiers.has(word)) return i;
i += word.length;
}
};
let i = skipAnnotationsAndModifiers(at);
if (i < 0) return null;
if (lang === 'kotlin') {
if (readIdentifier(s, i) !== 'fun') return null;
i = skipSpace(s, i + 3);
if (s[i] === '<') {
i = skipGroup(s, i);
if (i < 0) return null;
i = skipSpace(s, i);
}
} else {
if (s[i] === '<') {
i = skipGroup(s, i);
if (i < 0) return null;
}
// Annotations on the return type: `<T> @Nullable T x()`.
i = skipAnnotationsAndModifiers(i);
if (i < 0) return null;
const type = readIdentifier(s, i);
if (!type || NOT_A_RETURN_TYPE.has(type)) return null;
i += type.length;
// Its qualifiers, type arguments and array dimensions.
for (;;) {
const j = skipSpace(s, i);
if (s[j] === '<') {
i = skipGroup(s, j);
if (i < 0) return null;
} else if (s[j] === '.') {
const k = skipSpace(s, j + 1);
const part = readIdentifier(s, k);
if (!part) return null;
i = k + part.length;
} else if (s[j] === '[' && s[skipSpace(s, j + 1)] === ']') {
i = skipSpace(s, j + 1) + 1;
} else {
break;
}
}
i = skipSpace(s, i);
}
const name = readIdentifier(s, i);
if (!name) return null;
return s[skipSpace(s, i + name.length)] === '(' ? name : null;
}
/**
* The method a Spring route's handler reference names, found where the
* route's mapping annotation is written: the innermost method of that name
* whose span — which starts at its first annotation — holds the annotation's
* line. Null for any other reference, and when no such method is indexed.
*/
function routeHandlerAt(ref: UnresolvedRef, context: ResolutionContext): Node | null {
if (ref.referenceKind !== 'references' || (ref.language !== 'java' && ref.language !== 'kotlin')) return null;
if (!ref.fromNodeId.startsWith(`route:${ref.filePath}:${ref.line}:`)) return null;
let handler: Node | null = null;
for (const node of context.getNodesInFile(ref.filePath)) {
if ((node.kind !== 'method' && node.kind !== 'function') || node.name !== ref.referenceName) continue;
if (node.startLine > ref.line || node.endLine < ref.line) continue;
if (!handler || node.startLine > handler.startLine) handler = node;
}
return handler;
}
/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */
function resolveByNameAndKind(
ref: UnresolvedRef,