feat(search): find a Spring endpoint by its request path (#820)

Search Everywhere lists Spring Web endpoints matched by request path (context path tolerated) or handler method name, and Enter opens the handler. The host scans Java/Kotlin sources that name a mapping; http-client parses and indexes them and hands the entries to search through an endpoint contribution, so search does not import http-client. The index is invalidated when a Java/Kotlin file changes.

Route source path assertions compare by path component so they hold on Windows.

Co-authored-by: misswell <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-02 22:18:11 +08:00
1 джерело 8ea8c98a39
коміт 4e9442b499
17 files changed
+587 -6

No files matched your search

+2
Переглянути файл
@@ -28,6 +28,7 @@ import {
menuGroupItems,
springEditorExtension,
} from "./spring/editor";
import { registerSpringEndpointProvider } from "./spring/routeIndex";
const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin";
@@ -112,6 +113,7 @@ async function newRequest(directory?: string) {
export function activate(): () => void {
const disposers = [
registerSpringEndpointProvider(),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) resetHttpClient();
}),
+78
Переглянути файл
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { commands, emitLocalIdeEvent } from "@/platform/ipc";
import { ipcPath } from "@/platform/paths";
import { searchEndpoints } from "@/workbench/contributions";
import { clearRouteIndex, isRouteSource, registerSpringEndpointProvider, routeIndex } from "./routeIndex";
const scanSources = vi.spyOn(commands, "springRouteSources");
const source = (method = "searchMenus", url = "/menu/search") => ({
path: "/p/DeepLinkController.java",
text: `@RestController
@RequestMapping("/deeplink")
class DeepLinkController {
@PostMapping("${url}")
String ${method}() { return "ok"; }
}`,
});
let dispose: (() => void) | undefined;
afterEach(() => {
dispose?.();
dispose = undefined;
clearRouteIndex();
scanSources.mockReset();
});
describe("the request-path index follows the workspace", () => {
it("is invalidated by a change to a mapping source", () => {
for (const [path, expected] of [
["/p/A.java", true],
["/p/B.kt", true],
["/p/C.kts", true],
["/p/pom.xml", false],
["/p/notes.md", false],
] as const) {
expect(isRouteSource(path), path).toBe(expected);
}
});
});
describe("the Spring endpoint provider", () => {
it("scans once per root with method coordinates and rescans for another root", async () => {
scanSources.mockResolvedValue({ status: "ok", data: [source()] });
expect(await routeIndex(null)).toEqual([]);
const first = await routeIndex("/p");
expect(first).toEqual([
{
method: "POST",
url: "/deeplink/menu/search",
controller: "DeepLinkController",
methodName: "searchMenus",
path: "/p/DeepLinkController.java",
rel: "DeepLinkController.java",
line: 4,
col: 11,
},
]);
expect(await routeIndex("/p")).toBe(first);
expect(scanSources).toHaveBeenCalledTimes(1);
expect(await routeIndex("/other")).toEqual([]);
expect(scanSources).toHaveBeenCalledTimes(2);
});
it.each(["created", "modified", "removed"] as const)("invalidates on %s mapping sources", async (kind) => {
scanSources.mockResolvedValue({ status: "ok", data: [source()] });
dispose = registerSpringEndpointProvider();
expect((await searchEndpoints("/p"))[0]?.methodName).toBe("searchMenus");
emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [ipcPath("/p/pom.xml")], removed: [] });
await searchEndpoints("/p");
expect(scanSources).toHaveBeenCalledTimes(1);
scanSources.mockResolvedValue({ status: "ok", data: [source("listMenus", "/menu/list")] });
emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed: [], [kind]: [ipcPath("/p/A.java")] });
expect((await searchEndpoints("/p"))[0]?.url).toBe("/deeplink/menu/list");
expect(scanSources).toHaveBeenCalledTimes(2);
dispose();
expect(await searchEndpoints("/p")).toEqual([]);
});
});
+80
Переглянути файл
@@ -0,0 +1,80 @@
/** Spring request-path index, cached per project until a mapping source changes. */
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions";
import { springControllers } from "./endpoints";
export function registerSpringEndpointProvider(): () => void {
clearRouteIndex();
const unregister = registerEndpointProvider({ id: "http.spring", endpoints: routeIndex });
const unsubscribe = onIdeEvent("fsChanged", (e) => {
if ([...e.created, ...e.modified, ...e.removed].some((p) => isRouteSource(String(p)))) clearRouteIndex();
});
return () => {
unsubscribe();
unregister();
clearRouteIndex();
};
}
let cached: { root: string; promise: Promise<EndpointEntry[]> } | null = null;
/** Whether `path` is a source a mapping can live in (a Java or Kotlin file). */
export function isRouteSource(path: string): boolean {
return /\.(java|kt|kts)$/i.test(path);
}
/** Forgets the index, so the next search rescans the project. */
export function clearRouteIndex() {
cached = null;
}
/** The project's routes, scanned once per project and kept in memory. */
export function routeIndex(root: string | null): Promise<EndpointEntry[]> {
if (!root) return Promise.resolve([]);
if (cached?.root === root) return cached.promise;
const promise = scan(root);
cached = { root, promise };
return promise;
}
async function scan(root: string): Promise<EndpointEntry[]> {
const sources = await call(commands.springRouteSources());
const routes: EndpointEntry[] = [];
for (const source of sources) {
if (!source.path.startsWith(root)) continue;
for (const controller of springControllers(source.text)) {
for (const endpoint of controller.endpoints) {
const at = lineCol(source.text, endpoint.offset);
// A mapping without a path answers nothing to look up by URL; the method
// itself is still reachable through the symbol index.
for (const url of endpoint.paths) {
routes.push({
method: endpoint.httpMethod,
url,
controller: controller.name,
methodName: endpoint.methodName,
path: source.path,
rel: source.path.slice(root.length + 1),
line: at.line,
col: at.col,
});
}
}
}
}
return routes;
}
function lineCol(text: string, offset: number): { line: number; col: number } {
let line = 0;
let col = 0;
for (let i = 0; i < offset && i < text.length; i++) {
if (text.charCodeAt(i) === 10 /* \n */) {
line++;
col = 0;
} else {
col++;
}
}
return { line, col };
}
+27
Переглянути файл
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { FileMatches, SearchQuery } from "@/platform/ipc";
import { SeController, type SeDeps, type SeSnapshot } from "../everywhere/controller";
import { buildRows } from "../everywhere/model";
function deferred<T>() {
let resolve!: (v: T) => void;
@@ -195,3 +196,29 @@ describe("SeController", () => {
expect(searchText.mock.calls.at(-1)![1].scope).toEqual({ kind: "fileSet", set: "test" });
});
});
describe("Endpoints in the All tab", () => {
it("answers a request path with the controller method that serves it", async () => {
const routes = [
{
method: "POST",
url: "/deeplink/menu/search",
controller: "DeepLinkController",
methodName: "searchMenus",
path: "/p/tg-tenant-server/src/main/java/DeepLinkController.java",
rel: "tg-tenant-server/src/main/java/DeepLinkController.java",
line: 45,
col: 4,
},
];
const { c, last } = setup({ root: () => "/p", routes: async () => routes });
c.run({ query: "/tg-tenant/deeplink/menu/search", tab: "all", includeNonProject: false, extra: {} });
await flush();
const section = last().sections.get("endpoints");
expect(section?.items).toHaveLength(1);
expect(section?.items[0]).toMatchObject({ kind: "symbol", container: "DeepLinkController.searchMenus", line: 45 });
// The All tab mixes its sections into one list; an endpoint has to be in it.
const rows = buildRows(last().sections, { headers: false, mix: "/tg-tenant/deeplink/menu/search" });
expect(rows.map((r) => (r.kind === "symbol" ? r.container : r.kind))).toContain("DeepLinkController.searchMenus");
});
});
+1 -1
Переглянути файл
@@ -250,7 +250,7 @@ describe("items and rows", () => {
it("sectionsFor picks sources per tab and empty query", () => {
expect(sectionsFor("all", true)).toEqual(["recent", "recentActions"]);
expect(sectionsFor("classes", true)).toEqual([]);
expect(sectionsFor("all", false)).toEqual(["classes", "files", "symbols", "actions", "text"]);
expect(sectionsFor("all", false)).toEqual(["classes", "files", "symbols", "endpoints", "actions", "text"]);
expect(sectionsFor("text", false)).toEqual(["text"]);
});
});
+61
Переглянути файл
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { EndpointEntry } from "@/workbench/contributions";
import { matchRoutes } from "../everywhere/routes";
const route = (over: Partial<EndpointEntry> = {}): EndpointEntry => ({
method: "POST",
url: "/deeplink/menu/search",
controller: "DeepLinkController",
methodName: "searchMenus",
path: "/p/tg-tenant-server/src/main/java/DeepLinkController.java",
rel: "tg-tenant-server/src/main/java/DeepLinkController.java",
line: 45,
col: 4,
...over,
});
const routes: EndpointEntry[] = [
route(),
route({
method: "GET",
url: "/deeplink/menu/{menuId}",
methodName: "getMenuDetail",
line: 52,
col: 4,
}),
];
describe("endpoints in Search Everywhere", () => {
it("finds a controller method by the request path it answers", () => {
for (const query of [
"/deeplink/menu/search",
"deeplink/menu/search",
"/tg-tenant/deeplink/menu/search",
"POST /tg-tenant/deeplink/menu/search",
"post /deeplink/menu/search",
]) {
const { items } = matchRoutes(routes, query, 5);
expect(items[0]?.container, query).toBe("DeepLinkController.searchMenus");
expect(items[0]?.display, query).toBe("POST /deeplink/menu/search");
}
});
it("honours an HTTP method prefix", () => {
expect(matchRoutes(routes, "GET /deeplink/menu/x", 5).items).toEqual([]);
expect(matchRoutes(routes, "GET /deeplink/menu", 5).items[0]?.container).toBe("DeepLinkController.getMenuDetail");
});
it("finds it by controller or method name too", () => {
expect(matchRoutes(routes, "searchMenus", 5).items[0]?.container).toBe("DeepLinkController.searchMenus");
expect(matchRoutes(routes, "DeepLinkController.getMenuDetail", 5).items[0]?.container).toBe(
"DeepLinkController.getMenuDetail",
);
expect(matchRoutes(routes, "deeplink", 5).items).toHaveLength(2);
});
it("says nothing for an empty or unrelated query", () => {
expect(matchRoutes(routes, "", 5).items).toEqual([]);
expect(matchRoutes(routes, " ", 5).items).toEqual([]);
expect(matchRoutes(routes, "unrelated-thing", 5).items).toEqual([]);
});
});
+15 -1
Переглянути файл
@@ -16,8 +16,9 @@ import type {
SymbolMatch,
WorkspaceSymbol,
} from "@/platform/ipc";
import type { SettingsSearchEntry } from "@/workbench/contributions";
import type { EndpointEntry, SettingsSearchEntry } from "@/workbench/contributions";
import { parseLocationQuery } from "../common/query";
import { matchRoutes } from "./routes";
import { passesFilter, type ResolvedFilter } from "./filters";
import {
ALL_TAB_LIMIT,
@@ -56,6 +57,8 @@ export interface SeDeps {
now(): number;
/** Whether `path` is in a root-based named scope ("Project Test Files" …). */
inFileSet?(set: ProjectFileSet, path: string): boolean;
/** Endpoints of the project, for request-path matches. */
routes?(): Promise<EndpointEntry[]>;
}
export interface SeRequest {
@@ -257,6 +260,9 @@ export class SeController {
case "symbols":
await this.startSymbols(id, gen, text, limit, req);
break;
case "endpoints":
await this.startEndpoints(id, gen, text, limit, req);
break;
case "actions": {
const hits = matchActions(this.deps.actions(), text, {
isEnabled: (a) => this.deps.isEnabled(a),
@@ -284,6 +290,14 @@ export class SeController {
}
}
/** Project endpoints by request path (`/menu/search`), ranked in memory. */
private async startEndpoints(id: SectionId, gen: number, text: string, limit: number, req: SeRequest) {
const routes = (await this.deps.routes?.().catch((): EndpointEntry[] => [])) ?? [];
const { items, hasMore } = matchRoutes(routes, text, limit);
const keep = this.scopeFilter(req);
this.set(id, gen, keep ? items.filter((i) => keep(i.path)) : items, hasMore, false);
}
private async startSymbols(id: "classes" | "symbols", gen: number, text: string, limit: number, req: SeRequest) {
const typesOnly = id === "classes";
const opts = {
+2 -1
Переглянути файл
@@ -2,7 +2,7 @@ import { allActions, type Action } from "@/platform/actions";
import { evaluateWhen, type ContextKeys } from "@/platform/context";
import { call, commands, nextRequestId, type LanguageInfo } from "@/platform/ipc";
import { inFileSet } from "@/platform/projectRoots";
import { searchableSettings } from "@/workbench/contributions";
import { searchEndpoints, searchableSettings } from "@/workbench/contributions";
import { projectRoot } from "../common/open";
import { cancelRequest, streamSearch } from "../common/textSearch";
import type { SeDeps } from "./controller";
@@ -33,6 +33,7 @@ export function ipcDeps(ctx: ContextKeys): SeDeps {
root: projectRoot,
now: () => performance.now(),
inFileSet: (set, path) => inFileSet(set, path),
routes: () => searchEndpoints(projectRoot()),
};
}
+8 -3
Переглянути файл
@@ -10,7 +10,7 @@ import { basename, isTypeKind, relDir, symbolDisplayName } from "../common/open"
import { byScore, matchText } from "../common/rank";
export type SeTab = "all" | "classes" | "files" | "symbols" | "actions" | "text";
export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "actions" | "text";
export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "endpoints" | "actions" | "text";
export const TABS: ReadonlyArray<{ id: SeTab; label: string }> = [
{ id: "all", label: "All" },
@@ -27,6 +27,7 @@ export const SECTION_ORDER: readonly SectionId[] = [
"classes",
"files",
"symbols",
"endpoints",
"actions",
"text",
];
@@ -37,6 +38,7 @@ export const SECTION_TITLES: Record<SectionId, string> = {
classes: "Classes",
files: "Files",
symbols: "Symbols",
endpoints: "Endpoints",
actions: "Actions",
text: "Text",
};
@@ -55,7 +57,7 @@ export function sectionsFor(tab: SeTab, emptyQuery: boolean): SectionId[] {
return [];
}
}
return tab === "all" ? ["classes", "files", "symbols", "actions", "text"] : [tab];
return tab === "all" ? ["classes", "files", "symbols", "endpoints", "actions", "text"] : [tab];
}
export const ALL_TAB_LIMIT = 6;
@@ -90,6 +92,8 @@ export interface SymbolItem {
indices: number[];
/** From the language server rather than the index. */
lsp?: boolean;
/** Pre-computed relevance (endpoints: the request path matched `n` segments up). */
score?: number;
}
export interface ActionItem {
@@ -376,7 +380,7 @@ export function textItems(path: string, rel: string, matches: readonly LineMatch
// Rows
/** Sections the All tab mixes into one relevance-ordered list. */
const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "actions", "text"];
const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "endpoints", "actions", "text"];
/**
* Weight of a text hit: a literal substring in a line ranks below name
@@ -391,6 +395,7 @@ export function itemWeight(item: SeItem, query: string): number {
case "file":
return name(item.name) ?? (name(item.rel) ?? 0) - 200;
case "symbol":
if (item.score !== undefined) return item.score;
return name(item.name) ?? (item.container ? name(`${item.container}.${item.name}`) : undefined) ?? 0;
case "action":
case "setting":
+79
Переглянути файл
@@ -0,0 +1,79 @@
/** Request-path and controller-method ranking for Search Everywhere. */
import type { EndpointEntry } from "@/workbench/contributions";
import { matchText } from "../common/rank";
import type { SymbolItem } from "./model";
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"]);
const strip = (s: string) => s.replace(/^\/+|\/+$/g, "");
function item(r: EndpointEntry, indices: number[], score: number): SymbolItem {
const display = `${r.method} ${r.url}`;
return {
kind: "symbol",
key: `r:${r.path}:${r.line}:${r.method}:${r.url}`,
name: display,
display,
symbolKind: "method",
container: `${r.controller}.${r.methodName}`,
path: r.path,
rel: r.rel,
line: r.line,
col: r.col,
indices,
score,
};
}
/**
* Routes matching `query`: a request path (`/deeplink/menu/search`, with or without
* the servlet context path), an optional `POST ` prefix, or the controller / method
* name. Scores: exact path, then a path suffix (a context path in front of it), then
* a substring, then the symbol names, then a fuzzy path hit.
*/
export function matchRoutes(
routes: readonly EndpointEntry[],
query: string,
limit: number,
): { items: SymbolItem[]; hasMore: boolean } {
const text = query.trim();
if (!text || !routes.length) return { items: [], hasMore: false };
const words = text.split(/\s+/);
const head = (words[0] ?? "").toUpperCase();
const method = HTTP_METHODS.has(head) ? head : null;
const rest = (method ? words.slice(1).join(" ") : text).trim();
const wantPath = strip(rest).toLowerCase();
const wantName = rest.toLowerCase();
const hits: { item: SymbolItem; score: number }[] = [];
for (const r of routes) {
if (method && r.method !== method) continue;
const target = strip(r.url).toLowerCase();
const qualified = `${r.controller}.${r.methodName}`.toLowerCase();
let score = 0;
let indices: number[] = [];
if (wantPath) {
if (target === wantPath) score = 1000;
else if (wantPath.includes("/") && (target.endsWith(`/${wantPath}`) || wantPath.endsWith(`/${target}`)))
score = 900;
else if (wantPath.length >= 3 && target.length >= 3 && (target.includes(wantPath) || wantPath.includes(target)))
score = 700;
if (score) {
const at = `${r.method} ${r.url}`.toLowerCase().lastIndexOf(wantPath);
if (at >= 0) indices = Array.from({ length: wantPath.length }, (_, i) => at + i);
}
}
if (!score && wantName) {
if (r.methodName.toLowerCase() === wantName) score = 800;
else if (qualified === wantName || qualified.includes(wantName)) score = 600;
else if (wantName.length >= 3 && target.includes(wantName)) score = 500;
else {
const fuzzy = matchText(target, wantName);
if (fuzzy) score = 300 + fuzzy.score;
}
}
if (score) hits.push({ item: item(r, indices, score), score });
}
hits.sort((a, b) => b.score - a.score || a.item.rel.localeCompare(b.item.rel) || a.item.line - b.item.line);
return { items: hits.slice(0, limit).map((h) => h.item), hasMore: hits.length > limit };
}