From 4e9442b499079d85794d207729f4d8b4d7f369dd Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 22:18:11 +0800 Subject: [PATCH] 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 <28730815+misswell@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/mod.rs | 1 + crates/ide-host/src/ops/routes.rs | 134 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/nav.rs | 10 ++ src/bindings.ts | 7 + src/features/http-client/index.ts | 2 + .../http-client/spring/routeIndex.test.ts | 78 ++++++++++ src/features/http-client/spring/routeIndex.ts | 80 +++++++++++ .../search/__tests__/controller.test.ts | 27 ++++ .../search/__tests__/everywhereModel.test.ts | 2 +- src/features/search/__tests__/routes.test.ts | 61 ++++++++ src/features/search/everywhere/controller.ts | 16 ++- src/features/search/everywhere/deps.ts | 3 +- src/features/search/everywhere/model.ts | 11 +- src/features/search/everywhere/routes.ts | 79 +++++++++++ src/workbench/contributions.ts | 35 +++++ src/workbench/endpointProviders.test.ts | 46 ++++++ 17 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 crates/ide-host/src/ops/routes.rs create mode 100644 src/features/http-client/spring/routeIndex.test.ts create mode 100644 src/features/http-client/spring/routeIndex.ts create mode 100644 src/features/search/__tests__/routes.test.ts create mode 100644 src/features/search/everywhere/routes.ts create mode 100644 src/workbench/endpointProviders.test.ts diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 93a0b28f..c29dc4e9 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -22,6 +22,7 @@ pub mod notebook; pub mod project; mod python; pub mod refactor; +pub mod routes; pub mod run; pub mod sdk; pub mod search; diff --git a/crates/ide-host/src/ops/routes.rs b/crates/ide-host/src/ops/routes.rs new file mode 100644 index 00000000..cbf2965e --- /dev/null +++ b/crates/ide-host/src/ops/routes.rs @@ -0,0 +1,134 @@ +//! Spring Web mappings, source side: the project's Java and Kotlin files that could +//! declare a route, with their text. The UI owns the annotation parser and the index +//! it feeds, so this side stays a cheap scan. + +use crate::Host; +use ide_core::IdeResult; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Files handed out at most, and bytes in total: a project's controllers fit well +/// inside these, and the UI indexes them once. +const MAX_FILES: usize = 1024; +const MAX_BYTES: usize = 48 << 20; +/// A single source bigger than this is not a controller. +const MAX_FILE_BYTES: u64 = 4 << 20; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RouteSource { + pub path: String, + pub text: String, +} + +/// The files Spring mappings can live in: Java and Kotlin sources. +fn declares_mappings(path: &Path) -> bool { + matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts")) +} + +/// Files under `root` whose text names a mapping: `ignore` rules and `excludes` +/// decide what counts, and the UI ranks the matches itself. +pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec { + let found: Mutex> = Mutex::new(Vec::new()); + let bytes = AtomicUsize::new(0); + ide_vfs::walk_files(root, excludes, |path| { + if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES { + return; + } + if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + // Only a file that names a mapping annotation is worth parsing. + if !text.contains("Mapping") { + return; + } + let Ok(mut found) = found.lock() else { return }; + if found.len() >= MAX_FILES { + return; + } + bytes.fetch_add(text.len(), Ordering::Relaxed); + found.push(RouteSource { path: path.to_string_lossy().into_owned(), text }); + }); + let mut files = found.into_inner().unwrap_or_default(); + files.sort_by(|a, b| a.path.cmp(&b.path)); + files +} + +/// The folders to scan: the project folder itself first, then content roots outside it +/// (`content_roots` lists only the ones outside, so the project folder is never implied — +/// forgetting it scans nothing). +fn scan_roots(root: &Path, content_roots: Vec) -> Vec { + let mut roots = vec![root.to_path_buf()]; + roots.extend(content_roots); + roots +} + +/// Every mapping source of the project, for the request-path index. +pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + let excludes = project.search_excludes(); + let mut out = Vec::new(); + for root in scan_roots(&project.root, project.content_roots()) { + out.extend(sources_in(&root, &excludes)); + } + out.sort_by(|a, b| a.path.cmp(&b.path)); + out.dedup_by(|a, b| a.path == b.path); + match out.len() { + 0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()), + n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root), + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_project_folder_is_scanned_even_without_other_content_roots() { + let root = Path::new("/p/demo"); + assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]); + assert_eq!( + scan_roots(root, vec![PathBuf::from("/p/elsewhere")]), + [PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")] + ); + } + + #[test] + fn only_java_and_kotlin_sources_are_read() { + for (name, expected) in + [("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)] + { + assert_eq!(declares_mappings(Path::new(name)), expected, "{name}"); + } + } + + #[test] + fn files_that_name_no_mapping_are_left_out() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap(); + std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap(); + std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap(); + let files = sources_in(tmp.path(), &[]); + assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>()); + assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path); + assert!(files[0].text.contains("@GetMapping")); + } + + #[test] + fn excluded_folders_are_skipped() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("module/src")).unwrap(); + std::fs::create_dir_all(tmp.path().join("build")).unwrap(); + std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap(); + std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap(); + let files = sources_in(tmp.path(), &["build/**".to_owned()]); + assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>()); + assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4457d138..1ef4d111 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -182,6 +182,7 @@ pub fn builder() -> tauri_specta::Builder { caches::caches_schedule_invalidation, caches::app_restart, nav::index_status, + nav::spring_route_sources, nav::find_files, nav::find_symbols, nav::mark_recent, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index 669fe850..dd1e1d5f 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -22,6 +22,16 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim ide_host::ops::nav::find_files(&state.host, window.label(), query, limit) } +/// Spring Web mapping sources for the request-path index of Search Everywhere. +#[tauri::command] +#[specta::specta] +pub fn spring_route_sources( + window: Window, + state: State<'_, AppState>, +) -> IdeResult> { + ide_host::ops::routes::spring_route_sources(&state.host, window.label()) +} + #[tauri::command] #[specta::specta] pub fn find_symbols( diff --git a/src/bindings.ts b/src/bindings.ts index 7c4a2d95..e76c5885 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -310,6 +310,8 @@ export const commands = { /** Quits through every window's unsaved-changes check, then starts again. */ appRestart: () => __TAURI_INVOKE("app_restart"), indexStatus: () => typedError(__TAURI_INVOKE("index_status")), + /** Spring Web mapping sources for the request-path index of Search Everywhere. */ + springRouteSources: () => typedError(__TAURI_INVOKE("spring_route_sources")), findFiles: (query: string, limit: number) => typedError(__TAURI_INVOKE("find_files", { query, limit })), findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError(__TAURI_INVOKE("find_symbols", { query, filter, limit })), markRecent: (path: string) => typedError(__TAURI_INVOKE("mark_recent", { path })), @@ -5312,6 +5314,11 @@ export type RevisionChange = { export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded"; +export type RouteSource = { + path: string, + text: string, +}; + export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize; export type RunConfig_Deserialize = { diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index c4ec7c32..a745a95a 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -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(); }), diff --git a/src/features/http-client/spring/routeIndex.test.ts b/src/features/http-client/spring/routeIndex.test.ts new file mode 100644 index 00000000..6299973a --- /dev/null +++ b/src/features/http-client/spring/routeIndex.test.ts @@ -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([]); + }); +}); diff --git a/src/features/http-client/spring/routeIndex.ts b/src/features/http-client/spring/routeIndex.ts new file mode 100644 index 00000000..edbb8404 --- /dev/null +++ b/src/features/http-client/spring/routeIndex.ts @@ -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 } | 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 { + 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 { + 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 }; +} diff --git a/src/features/search/__tests__/controller.test.ts b/src/features/search/__tests__/controller.test.ts index 65ba0ae3..dbd9061b 100644 --- a/src/features/search/__tests__/controller.test.ts +++ b/src/features/search/__tests__/controller.test.ts @@ -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() { 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"); + }); +}); diff --git a/src/features/search/__tests__/everywhereModel.test.ts b/src/features/search/__tests__/everywhereModel.test.ts index 1d2eae94..f0616a02 100644 --- a/src/features/search/__tests__/everywhereModel.test.ts +++ b/src/features/search/__tests__/everywhereModel.test.ts @@ -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"]); }); }); diff --git a/src/features/search/__tests__/routes.test.ts b/src/features/search/__tests__/routes.test.ts new file mode 100644 index 00000000..efd0d1ec --- /dev/null +++ b/src/features/search/__tests__/routes.test.ts @@ -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 => ({ + 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([]); + }); +}); diff --git a/src/features/search/everywhere/controller.ts b/src/features/search/everywhere/controller.ts index 9c32a6ed..b63c8979 100644 --- a/src/features/search/everywhere/controller.ts +++ b/src/features/search/everywhere/controller.ts @@ -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; } 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 = { diff --git a/src/features/search/everywhere/deps.ts b/src/features/search/everywhere/deps.ts index 73bff101..f3c5c9e3 100644 --- a/src/features/search/everywhere/deps.ts +++ b/src/features/search/everywhere/deps.ts @@ -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()), }; } diff --git a/src/features/search/everywhere/model.ts b/src/features/search/everywhere/model.ts index 3b085f0c..6f12712d 100644 --- a/src/features/search/everywhere/model.ts +++ b/src/features/search/everywhere/model.ts @@ -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 = { 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": diff --git a/src/features/search/everywhere/routes.ts b/src/features/search/everywhere/routes.ts new file mode 100644 index 00000000..f6c68a0f --- /dev/null +++ b/src/features/search/everywhere/routes.ts @@ -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 }; +} diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 74f44beb..6331f20a 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1317,6 +1317,41 @@ export function runAnythingProviderList(): RunAnythingProvider[] { ); } +// --------------------------------------------------------------------------- +// Project endpoints supplied by framework features for request-path search. + +export interface EndpointEntry { + /** `GET`, `POST`, … */ + method: string; + /** Class-level and method-level mapping joined; `{var}` kept. */ + url: string; + controller: string; + methodName: string; + path: string; + rel: string; + line: number; + col: number; +} + +export interface EndpointProvider { + id: string; + endpoints(root: string): Promise; +} + +const endpointProviders = new Map(); + +export function registerEndpointProvider(p: EndpointProvider): () => void { + endpointProviders.set(p.id, p); + return () => { + if (endpointProviders.get(p.id) === p) endpointProviders.delete(p.id); + }; +} + +export async function searchEndpoints(root: string | null): Promise { + if (!root) return []; + return (await Promise.all([...endpointProviders.values()].map((p) => p.endpoints(root)))).flat(); +} + // --------------------------------------------------------------------------- // Status bar info text (IDEA's status bar message, e.g. "3 of 12" after Next // Occurrence). Cleared by the next message or after a while. diff --git a/src/workbench/endpointProviders.test.ts b/src/workbench/endpointProviders.test.ts new file mode 100644 index 00000000..7884faff --- /dev/null +++ b/src/workbench/endpointProviders.test.ts @@ -0,0 +1,46 @@ +import { expect, it, vi } from "vitest"; +import { registerEndpointProvider, searchEndpoints, type EndpointEntry } from "./contributions"; + +const endpoint: EndpointEntry = { + method: "GET", + url: "/menu/list", + controller: "MenuController", + methodName: "listMenus", + path: "/p/MenuController.java", + rel: "MenuController.java", + line: 4, + col: 11, +}; + +it("collects endpoints from all providers for the requested root", async () => { + const first = vi.fn(async () => [endpoint]); + const second = vi.fn(async () => [{ ...endpoint, url: "/other" }]); + const disposers = [ + registerEndpointProvider({ id: "test.first", endpoints: first }), + registerEndpointProvider({ id: "test.second", endpoints: second }), + ]; + try { + expect(await searchEndpoints(null)).toEqual([]); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + expect(await searchEndpoints("/p")).toEqual([endpoint, { ...endpoint, url: "/other" }]); + expect(first).toHaveBeenCalledWith("/p"); + expect(second).toHaveBeenCalledWith("/p"); + } finally { + disposers.forEach((dispose) => dispose()); + } + expect(await searchEndpoints("/p")).toEqual([]); +}); + +it("keeps a replacement provider registered when its predecessor is disposed", async () => { + const disposeOld = registerEndpointProvider({ id: "test.replaced", endpoints: async () => [] }); + const disposeNew = registerEndpointProvider({ id: "test.replaced", endpoints: async () => [endpoint] }); + try { + disposeOld(); + expect(await searchEndpoints("/p")).toEqual([endpoint]); + } finally { + disposeOld(); + disposeNew(); + } + expect(await searchEndpoints("/p")).toEqual([]); +});