diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts index a596e7160..eb2803fff 100644 --- a/src/editor/refactor/safeDelete.test.ts +++ b/src/editor/refactor/safeDelete.test.ts @@ -3,7 +3,7 @@ import { EditorState, Text } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { history, undo } from "@codemirror/commands"; import type { MessageRequest } from "@/workbench/messages"; -import type { LocalSymbol, Location, Locals, OutlineItem, Range, StructuralRanges, SyntaxNode } from "@/bindings"; +import type { LocalSymbol, Location, Locals, OutlineItem, Pos, Range, StructuralRanges, SyntaxNode } from "@/bindings"; // --- backend fakes --------------------------------------------------------- @@ -15,11 +15,12 @@ const unavailable = () => ({ status: "error" as const, error: { kind: "unavailab let goto: Answer = ok([]); let outline: Answer = ok([]); -let references: Answer = ok([]); +/** Called with the position searched from. */ +let references: (pos: Pos) => ReturnType> = ok([]); const lspGoto = vi.fn(async () => goto()); const docOutline = vi.fn(async () => outline()); -const lspReferences = vi.fn(async () => { - const r = references(); +const lspReferences = vi.fn(async (_request: number, _doc: number, pos: Pos) => { + const r = references(pos); return r.status === "ok" ? { status: "ok" as const, data: { locations: r.data, incomplete: null } } : r; }); const lspCodeActions = vi.fn(async () => ({ status: "ok", data: [] })); @@ -411,6 +412,78 @@ describe("fileUsages", () => { expect(docClose).toHaveBeenCalledTimes(2); }); + // #1656: packages and namespaces are shared with other files; their members are what the file declares. + describe("searches members of packages and namespaces, not the container", () => { + const at = (path: string, line: number): Location => ({ + path, + range: { start: { line, col: 0 }, end: { line, col: 1 } }, + }); + const within = (parent: OutlineItem, ...children: OutlineItem[]): OutlineItem => ({ ...parent, children }); + /** References per searched name; `searched` records the order. */ + function serve(text: string, refs: Record) { + const searched: string[] = []; + references = (pos) => { + const off = doc(text).line(pos.line + 1).from + pos.col; + const name = Object.keys(refs).find((n) => text.startsWith(n, off)) ?? "?"; + searched.push(name); + return { status: "ok", data: refs[name] ?? [] }; + }; + return searched; + } + + it("Java: the package's other classes imported elsewhere are not usages", async () => { + const text = "package com.example.config;\n\npublic class MessageSourceConfig {}\n"; + outline = ok([ + item(text, "com.example.config", "package com.example.config;", "package"), + item(text, "MessageSourceConfig", "public class MessageSourceConfig {}", "class"), + ]); + // References to the package: an import of its sibling class CommunicationConfig. + const searched = serve(text, { + "com.example.config": [at("/p/server/CommandSender.java", 2)], + MessageSourceConfig: [], + }); + expect(await fileUsages(["/p/config/MessageSourceConfig.java"])).toEqual({ status: "ok", usages: [] }); + expect(searched).toEqual(["MessageSourceConfig"]); + }); + + it("C++: a class inside a namespace is searched", async () => { + const text = "namespace config {\nclass Used {};\nnamespace detail {\nint helper();\n}\n}\n"; + const ns = item( + text, + "config", + "namespace config {\nclass Used {};\nnamespace detail {\nint helper();\n}\n}", + "namespace", + ); + const detail = item(text, "detail", "namespace detail {\nint helper();\n}", "namespace"); + outline = ok([ + within( + ns, + item(text, "Used", "class Used {};", "class"), + within(detail, item(text, "helper", "int helper();")), + ), + ]); + const searched = serve(text, { Used: [at("/p/main.cpp", 4)], helper: [at("/p/main.cpp", 5)] }); + expect(await fileUsages(["/p/config/used.h"])).toEqual({ + status: "ok", + usages: [at("/p/main.cpp", 4), at("/p/main.cpp", 5)], + }); + expect(searched).toEqual(["Used", "helper"]); + }); + + it("modules a file declares (TS namespace, Rust mod) are still searched", async () => { + const text = "export namespace Utils {\n export const version = 1;\n}\n"; + outline = ok([ + within( + item(text, "Utils", "export namespace Utils {\n export const version = 1;\n}", "module"), + item(text, "version", "version = 1", "variable"), + ), + ]); + const searched = serve(text, { Utils: [at("/p/main.ts", 5)] }); + expect(await fileUsages(["/p/lib/ns.ts"])).toEqual({ status: "ok", usages: [at("/p/main.ts", 5)] }); + expect(searched).toEqual(["Utils"]); + }); + }); + it("is unsupported when the language server can't search", async () => { outline = ok([item(text, "a", "pub fn a() {}")]); references = unavailable; diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index 215af366b..b7f3f6513 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -507,9 +507,20 @@ export type FileUsages = /** The language server can't tell (none running, no document symbols…). */ | { status: "unsupported"; reason: string }; -/** At most this many top-level symbols per file are searched. */ +/** At most this many symbols per file are searched. */ const MAX_SYMBOLS = 60; +/** + * The symbols a file declares at its top level. Packages and namespaces are shared + * with other files (IDEA searches a Java file's classes, not its package), so their + * members stand in for them. + */ +function declaredSymbols(items: readonly OutlineItem[]): OutlineItem[] { + return items.flatMap((it) => + it.kind === "package" || it.kind === "namespace" ? declaredSymbols(it.children) : [it], + ); +} + async function withDoc(path: string, fn: (doc: number) => Promise): Promise { const open = editorsFor(path)[0]; if (open) return fn(open.ctx.doc); @@ -522,7 +533,7 @@ async function withDoc(path: string, fn: (doc: number) => Promise): Promis } /** - * References from outside `paths` to the top-level symbols the files declare. + * References from outside `paths` to the symbols the files declare ({@link declaredSymbols}). * Unsupported when no reference search could run for any of them. */ export async function fileUsages(paths: readonly string[], signal?: AbortSignal): Promise { @@ -536,7 +547,7 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal) try { await withDoc(path, async (doc) => { const outline = await call(commands.docOutline(doc)); - for (const item of outline.slice(0, MAX_SYMBOLS)) { + for (const item of declaredSymbols(outline).slice(0, MAX_SYMBOLS)) { if (signal?.aborted) return; let refs: Location[]; try {