Files
openidea/src/features/code-insight/syntaxUsages.test.ts
T
l0ng-ai 8ec35c94ec fix(lsp): Find usages in unopened files outside the language server's scope (#89)
Reference-based features only asked the language server, which analyses its configured scope (e.g. Pyright's `include`) plus open files, so usages in unopened files outside that scope were silently missing.

Find Usages, Show Usages, usage counts, Safe Delete of a symbol, Rename and incoming calls now also search project files that contain the symbol's name (whole word, same language, .gitignore/excludes respected, unsaved edits included, up to 300 files, cancellable). Candidates the first answer didn't cover are opened in the server temporarily, queried and closed again. Files the user has open are never touched, and diagnostics for the temporary files are dropped. An empty result while the server is still indexing is retried once indexing ends. When results may still be incomplete (indexing, file cap), the usages UI says so. `lsp_references` takes a RequestId and is cancelled by newer searches. Not covered yet: the Safe Delete scan when deleting a file or directory.

Fixes #83

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 17:41:20 +08:00

94 lines
4.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { IdeError, Locals, Range } from "@/bindings";
type R<T> = Promise<{ status: "ok"; data: T } | { status: "error"; error: IdeError }>;
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const err = (kind: IdeError["kind"]) => Promise.resolve({ status: "error" as const, error: { kind, message: kind } as IdeError });
const cmd = {
lspReferences: vi.fn((): R<unknown> => err("unavailable")),
syntaxSupport: vi.fn(() => ok({ tree: true, locals: true })),
syntaxLocals: vi.fn((): R<Locals> => ok({ status: "noIdentifier" })),
syntaxIdentifiers: vi.fn((): R<unknown> => ok([])),
syntaxIdentifiersInFiles: vi.fn((): R<unknown> => ok([])),
fsReadText: vi.fn(() => ok("a\nb\nc")),
};
const filesMentioning = vi.fn(async () => ({ paths: ["/p/b.ts"], limitHit: false }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } };
});
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
const { _addEditor } = await import("@/editor/api");
const { fetchUsages, incompleteNote } = await import("./usages");
const { LOCAL_NOTE, TEXT_NOTE } = await import("./syntaxUsages");
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
const view = new EditorView({ state: EditorState.create({ doc: "let x = 1;\nx + y;" }) });
_addEditor({ view, ctx: { doc: 3, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false } });
const caret = { doc: 3, path: "/p/a.ts", line: 1, col: 0, word: "x", view };
beforeEach(() => {
for (const f of Object.values(cmd)) f.mockClear();
filesMentioning.mockClear();
});
describe("Find Usages fallback", () => {
it("uses the server when it answers, even with nothing", async () => {
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null }));
expect(await fetchUsages(caret)).toEqual({ groups: [], note: null });
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
});
it("searches project-wide under the given request and says when results may be incomplete", async () => {
const usage = { path: "/p/a.ts", range: r(1, 0, 1) };
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" }));
const res = await fetchUsages(caret, 42 as never);
expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true);
expect(res.groups[0]!.usages.length).toBe(1);
expect(res.note).toBe(incompleteNote("indexing", "x"));
expect(incompleteNote("tooManyFiles", "x")).toBe("May be incomplete: too many files mention 'x' to search them all");
});
it("lists a certain local's references when the server is not ready", async () => {
cmd.syntaxLocals.mockReturnValueOnce(
ok({
status: "local",
symbol: { name: "x", kind: "var", definition: r(0, 4, 5), scope: r(0, 0, 20), references: [r(1, 0, 1)], certain: true, conflicts: [] },
}),
);
const res = await fetchUsages(caret);
expect(res.note).toBe(LOCAL_NOTE);
expect(res.groups.map((g) => [g.path, g.usages.map((u) => [u.line, u.col, u.text])])).toEqual([["/p/a.ts", [[1, 0, "x + y;"]]]]);
expect(filesMentioning).not.toHaveBeenCalled();
});
it("searches the project for identifier occurrences of non-locals", async () => {
cmd.lspReferences.mockReturnValueOnce(err("internal"));
cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "y", range: r(1, 4, 5), reason: "unresolved" }));
cmd.syntaxIdentifiersInFiles.mockReturnValueOnce(
ok([
{ path: "/p/b.ts", occurrences: [{ range: r(2, 0, 1), kind: "identifier", qualified: false }] },
{ path: "/p/a.ts", occurrences: [{ range: r(1, 4, 5), kind: "identifier", qualified: false }] },
]),
);
const res = await fetchUsages(caret);
expect(res.note).toBe(TEXT_NOTE);
expect(cmd.syntaxIdentifiersInFiles.mock.calls[0]).toEqual(expect.arrayContaining([["/p/b.ts", "/p/a.ts"], "y"]));
expect(res.groups.map((g) => g.path)).toEqual(["/p/a.ts", "/p/b.ts"]);
});
it("keeps the server error when the language has no grammar", async () => {
cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: false, locals: false }));
const other = new EditorView({ state: EditorState.create({ doc: "x" }) });
_addEditor({ view: other, ctx: { doc: 4, path: "/p/a.txt", language: "plaintext", uiMode: null, readOnly: false } });
await expect(fetchUsages({ doc: 4, path: "/p/a.txt", line: 0, col: 0, word: "x", view: other })).rejects.toMatchObject({
kind: "unavailable",
});
});
});