Files
openidea/src/editor/refactor/safeDelete.test.ts
T

505 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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, Pos, Range, StructuralRanges, SyntaxNode } from "@/bindings";
// --- backend fakes ---------------------------------------------------------
type Answer<T> = () => { status: "ok"; data: T } | { status: "error"; error: { kind: string; message: string } };
const ok =
<T>(data: T) =>
() => ({ status: "ok" as const, data });
const unavailable = () => ({ status: "error" as const, error: { kind: "unavailable", message: "no server" } });
let goto: Answer<Location[]> = ok([]);
let outline: Answer<OutlineItem[]> = ok([]);
/** Called with the position searched from. */
let references: (pos: Pos) => ReturnType<Answer<Location[]>> = ok([]);
const lspGoto = vi.fn(async () => goto());
const docOutline = vi.fn(async () => outline());
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: [] }));
const lspApplyCodeAction = vi.fn(async () => ({ status: "ok", data: null }));
const docOpen = vi.fn(async (path: string) => ({ status: "ok", data: { id: 99, path } }));
const docClose = vi.fn(async () => ({ status: "ok", data: null }));
const languages = vi.fn(async () => [
{ id: "rust", name: "Rust", lineComment: "//", blockComment: ["/*", "*/"] },
{ id: "python", name: "Python", lineComment: "#", blockComment: null },
]);
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: {
...actual.commands,
lspGoto,
docOutline,
lspReferences,
lspCodeActions,
lspApplyCodeAction,
docOpen,
docClose,
languages,
},
};
});
let locals: Locals = { status: "noIdentifier" };
let statement: SyntaxNode | null = null;
let identifiers: { range: Range; qualified: boolean }[] = [];
vi.mock("@/editor/structure", () => ({
structure: {
locals: vi.fn(async () => locals),
structuralRanges: vi.fn(async (): Promise<Partial<StructuralRanges>> => ({ statement })),
identifiers: vi.fn(async () => identifiers),
},
}));
const showHint = vi.fn();
vi.mock("@/editor/lsp/hint", () => ({ showHint }));
const executeAction = vi.fn();
vi.mock("@/platform/actions", () => ({ executeAction }));
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
},
}));
let answer = "cancel";
const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false }));
let confirmAnswer: { checked: boolean } | null = { checked: false };
const confirmChecked = vi.fn(
async (_opts: { title: string; message: string; checkbox?: string; checked?: boolean }) => confirmAnswer,
);
vi.mock("@/workbench/messages", () => ({ ask, confirmChecked }));
const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage, textOccurrences } =
await import("./safeDelete");
const { closePopup, useLspPopup } = await import("@/editor/lsp/popupStore");
const { lspDoc } = await import("@/editor/lsp/lsp");
// --- helpers ----------------------------------------------------------------
const doc = (s: string) => Text.of(s.split("\n"));
const posOf = (text: string, off: number) => {
const line = doc(text).lineAt(off);
return { line: line.number - 1, col: off - line.from };
};
const rangeOf = (text: string, from: number, to: number): Range => ({ start: posOf(text, from), end: posOf(text, to) });
/** Removes `deletionRange` of the `needle` span from `text`. */
function cut(text: string, needle: string, comments?: { line: string | null; block: [string, string] | null }) {
const from = text.indexOf(needle);
const r = deletionRange(doc(text), from, from + needle.length, comments);
return r && text.slice(0, r.from) + text.slice(r.to);
}
let view: EditorView | null = null;
function editor(text: string, caretNeedle: string, language = "rust") {
view = new EditorView({
state: EditorState.create({
doc: text,
selection: { anchor: text.indexOf(caretNeedle) + 1 },
extensions: [history(), lspDoc.of({ doc: 1, path: "/p/a.rs", language, uiMode: null, readOnly: false })],
}),
});
return view;
}
function item(text: string, name: string, decl: string, kind: OutlineItem["kind"] = "function"): OutlineItem {
const from = text.indexOf(decl);
const at = text.indexOf(name, from);
return {
name,
detail: null,
kind,
range: rangeOf(text, from, from + decl.length),
selection_range: rangeOf(text, at, at + name.length),
children: [],
};
}
beforeEach(() => {
goto = ok([]);
outline = ok([]);
references = ok([]);
locals = { status: "noIdentifier" };
statement = null;
identifiers = [];
confirmAnswer = { checked: false };
});
afterEach(() => {
view?.destroy();
view = null;
closePopup();
vi.clearAllMocks();
});
// --- tests --------------------------------------------------------------------
describe("deletionRange", () => {
it("removes whole lines plus the trailing blank line", () => {
expect(cut("fn a() {}\n\nfn unused() {}\n\nfn b() {}\n", "fn unused() {}")).toBe("fn a() {}\n\nfn b() {}\n");
});
it("takes the doc comment and annotations directly above", () => {
const text = "fn a() {}\n\n/**\n * Docs.\n */\n#[inline]\nfn unused() {}\n\nfn b() {}\n";
expect(cut(text, "fn unused() {}")).toBe("fn a() {}\n\nfn b() {}\n");
const java = "class A {\n // keep\n\n /// doc\n @Deprecated\n void unused() {}\n\n void b() {}\n}\n";
expect(cut(java, "void unused() {}")).toBe("class A {\n // keep\n\n void b() {}\n}\n");
});
it("takes the blank line above when the declaration ends its block", () => {
expect(cut("fn f() {\n a();\n\n let x = 1;\n}\n", "let x = 1;")).toBe("fn f() {\n a();\n}\n");
});
it("handles the last line of the document", () => {
expect(cut("fn a() {}\n\nfn unused() {}", "fn unused() {}")).toBe("fn a() {}");
expect(cut("fn a() {}\nfn unused() {}", "fn unused() {}")).toBe("fn a() {}");
});
it("uses the language's comment syntax (Python comments and decorators)", () => {
const py = "import x\n\n# helper\n@cache\ndef unused():\n pass\n\ndef b():\n pass\n";
expect(cut(py, "def unused():\n pass", { line: "#", block: null })).toBe("import x\n\ndef b():\n pass\n");
});
it("accepts a trailing separator or comment but refuses shared lines", () => {
expect(cut("let a = 1;\nlet b = 2; // note\nlet c = 3;\n", "let b = 2")).toBe("let a = 1;\nlet c = 3;\n");
expect(cut("struct S {\n a: i32,\n b: i32,\n}\n", "a: i32")).toBe("struct S {\n b: i32,\n}\n");
expect(cut("let a = 1; let b = 2;\n", "let b = 2;")).toBeNull();
expect(cut("f(a, b);\n", "a")).toBeNull();
});
});
describe("outlineItemAt", () => {
it("finds the innermost item whose name contains the position", () => {
const text = "impl S {\n fn m() {}\n}\n";
const m = item(text, "m", "fn m() {}", "method");
const s = { ...item(text, "S", "impl S {\n fn m() {}\n}", "struct"), children: [m] };
expect(outlineItemAt([s], posOf(text, text.indexOf("m()")))).toBe(m);
expect(outlineItemAt([s], posOf(text, text.indexOf("S")))).toBe(s);
expect(outlineItemAt([s], posOf(text, text.indexOf("{}")))).toBeNull();
});
});
describe("localDeclaration", () => {
const text =
"fn f(p: i32) {\n let x = 1;\n let (a, b) = t;\n let m: HashMap<K, V> = m();\n for i in v {}\n}\n";
const sym = (name: string, kind: LocalSymbol["kind"] = "var", after = "{"): LocalSymbol => {
const at = text.indexOf(` ${name}`, text.indexOf(after)) + 1;
return {
name,
kind,
definition: rangeOf(text, at, at + name.length),
scope: rangeOf(text, 0, text.length),
references: [],
certain: true,
conflicts: [],
};
};
const stmt = (kind: string, needle: string): SyntaxNode => {
const at = text.indexOf(needle);
return {
kind,
range: rangeOf(text, at, at + needle.length),
field: null,
named: true,
error: false,
missing: false,
hasError: false,
};
};
it("returns the declaring statement of a single local", () => {
expect(localDeclaration(doc(text), sym("x"), stmt("let_declaration", "let x = 1;"))).toEqual(
stmt("let_declaration", "let x = 1;").range,
);
expect(
localDeclaration(doc(text), sym("m"), stmt("let_declaration", "let m: HashMap<K, V> = m();")),
).not.toBeTypeOf("string");
});
it("refuses parameters, destructuring, loop variables and ambiguous locals", () => {
expect(localDeclaration(doc(text), sym("p", "parameter", "f"), null)).toMatch(/Change Signature/);
expect(localDeclaration(doc(text), sym("b"), stmt("let_declaration", "let (a, b) = t;"))).toMatch(/other names/);
expect(localDeclaration(doc(text), sym("i"), stmt("for_expression", "for i in v {}"))).toMatch(
/separate statement/,
);
expect(localDeclaration(doc(text), { ...sym("x"), certain: false }, stmt("let_declaration", "let x = 1;"))).toMatch(
/more than once/,
);
});
});
describe("textOccurrences", () => {
it("checks Unicode identifier boundaries without shifting UTF-16 offsets", () => {
const text = "𐐀foo αfoo $foo _foo foo 𐐀 foo foo𐐀";
const hits = textOccurrences(text, "foo", [], { from: -1, to: -1 });
expect(hits).toEqual([21, 28].map((from) => ({ from, to: from + 3 })));
});
it("finds whole-word mentions that are not identifier tokens, outside the declaration", () => {
const text = 'foo(); // foo and food\nlet s = "foo";\nfn foo() {}';
const ids = [0, text.lastIndexOf("foo")].map((from) => ({ from, to: from + 3 }));
const decl = text.indexOf("fn foo");
const hits = textOccurrences(text, "foo", ids, { from: decl, to: text.length });
expect(hits.map((h) => text.slice(h.from - 3, h.to))).toEqual(["// foo", '= "foo']);
});
});
describe("usagesMessage", () => {
it("says how many usages and where", () => {
const loc = (path: string): Location => ({ path, range: rangeOf("x", 0, 1) });
expect(usagesMessage("Function 'f'", [loc("/a")])).toEqual({
message: "1 usage found",
detail: "Function 'f' has 1 usage that is not safe to delete.",
});
expect(usagesMessage("The 2 files", [loc("/a"), loc("/b")], true).detail).toBe(
"The 2 files have 2 usages that are not safe to delete (in 2 files).",
);
});
});
describe("safeDelete in the editor", () => {
const text = "fn used() {}\n\nfn unused() {\n unused();\n}\n\nfn main() {}\n";
it("deletes an unused declaration as one undo step, ignoring recursive calls", async () => {
const v = editor(text, "unused() {");
outline = ok([item(text, "used", "fn used() {}"), item(text, "unused", "fn unused() {\n unused();\n}")]);
const self = text.indexOf("unused();");
references = ok([{ path: "/p/a.rs", range: rangeOf(text, self, self + 6) }]);
await safeDelete(v);
expect(v.state.doc.toString()).toBe("fn used() {}\n\nfn main() {}\n");
expect(lspCodeActions).not.toHaveBeenCalled();
expect(lspApplyCodeAction).not.toHaveBeenCalled();
undo(v);
expect(v.state.doc.toString()).toBe(text);
});
it("asks when usages exist: View Usages / Delete Anyway / Cancel", async () => {
const usage: Location = { path: "/p/b.rs", range: { start: { line: 3, col: 4 }, end: { line: 3, col: 8 } } };
outline = ok([item(text, "used", "fn used() {}")]);
references = ok([usage]);
const run = async (button: string) => {
const v = editor(text, "used() {}");
answer = button;
ask.mockClear();
await safeDelete(v);
const request = ask.mock.calls[0]![0];
expect(request.title).toBe("Usages Detected");
expect(request.body).toBe("1 usage found\nFunction 'used' has 1 usage that is not safe to delete.");
expect(request.buttons.map((b) => b.label)).toEqual(["View Usages", "Delete Anyway"]);
return v;
};
expect((await run("cancel")).state.doc.toString()).toBe(text);
expect(executeAction).not.toHaveBeenCalled();
expect((await run("view")).state.doc.toString()).toBe(text);
expect(executeAction).toHaveBeenCalledWith("find.showUsageResults", { symbol: "used", locations: [usage] });
expect((await run("delete")).state.doc.toString()).toBe("fn unused() {\n unused();\n}\n\nfn main() {}\n");
});
it("confirms first ('Delete Function …?') and searches nothing when cancelled", async () => {
const v = editor(text, "unused() {");
outline = ok([item(text, "unused", "fn unused() {\n unused();\n}")]);
confirmAnswer = null;
await safeDelete(v);
expect(confirmChecked).toHaveBeenCalledTimes(1);
const opts = confirmChecked.mock.calls[0]![0];
expect(opts.title).toBe("Safe Delete");
expect(opts.message).toBe("Delete Function 'unused'?");
expect(opts.checkbox).toBe("Search in comments and strings");
expect(lspReferences).not.toHaveBeenCalled();
expect(v.state.doc.toString()).toBe(text);
});
it("with 'Search in comments and strings' previews mentions before deleting", async () => {
const src = "// calls unused\nfn unused() {}\n\nfn main() {}\n";
const v = editor(src, "unused() {");
outline = ok([item(src, "unused", "fn unused() {}")]);
const decl = src.indexOf("unused() {");
identifiers = [{ range: rangeOf(src, decl, decl + 6), qualified: false }];
confirmAnswer = { checked: true };
await safeDelete(v);
const popup = useLspPopup.getState().popup;
expect(popup?.kind).toBe("preview");
if (popup?.kind !== "preview") return;
expect(popup.occurrences).toEqual([{ line: 0, text: "// calls unused", from: 9, to: 15 }]);
expect(v.state.doc.toString()).toBe(src);
popup.onConfirm();
expect(v.state.doc.toString()).not.toContain("fn unused");
});
it("refuses a declaration in another file instead of doing something else", async () => {
const v = editor(text, "main");
goto = ok([{ path: "/p/other.rs", range: rangeOf(text, 0, 1) }]);
await safeDelete(v);
expect(showHint).toHaveBeenCalledWith(
v,
"Cannot perform refactoring. 'main' is declared in other.rs; Safe Delete it there",
);
expect(v.state.doc.toString()).toBe(text);
});
it("without a language server deletes only locals, using the syntax references", async () => {
const body = "fn f() {\n let x = 1;\n let y = 2;\n g(y);\n}\n";
goto = unavailable;
references = unavailable;
const x = body.indexOf("x");
locals = {
status: "local",
symbol: {
name: "x",
kind: "var",
definition: rangeOf(body, x, x + 1),
scope: rangeOf(body, 0, body.length),
references: [],
certain: true,
conflicts: [],
},
};
const at = body.indexOf("let x");
statement = {
kind: "let_declaration",
range: rangeOf(body, at, at + 10),
field: null,
named: true,
error: false,
missing: false,
hasError: false,
};
const v = editor(body, " x =");
v.dispatch({ selection: { anchor: x } });
await safeDelete(v);
expect(v.state.doc.toString()).toBe("fn f() {\n let y = 2;\n g(y);\n}\n");
expect(lspReferences).not.toHaveBeenCalled();
});
it("without a language server refuses top-level declarations with the reason", async () => {
goto = unavailable;
outline = ok([item(text, "used", "fn used() {}")]);
locals = { status: "notLocal", name: "used", range: rangeOf(text, 3, 7), reason: "topLevel" };
const v = editor(text, "used() {}");
await safeDelete(v);
expect(showHint).toHaveBeenCalledWith(
v,
"Cannot perform refactoring. The language server for Rust is not available; only local declarations can be deleted safely without it",
);
expect(v.state.doc.toString()).toBe(text);
});
it("needs a symbol at the caret", async () => {
const v = editor("fn a() {}\n\n\n", "\n\n");
v.dispatch({ selection: { anchor: 11 } });
await safeDelete(v);
expect(showHint).toHaveBeenCalledWith(v, "Cannot perform refactoring. Caret should be on a symbol");
});
});
describe("fileUsages", () => {
const text = "pub fn a() {}\npub struct B;\n";
it("collects references to top-level symbols from other files", async () => {
outline = ok([item(text, "a", "pub fn a() {}"), item(text, "B", "pub struct B;", "struct")]);
const r = (path: string, line: number): Location => ({
path,
range: { start: { line, col: 0 }, end: { line, col: 1 } },
});
references = ok([r("/p/a.rs", 0), r("/p/main.rs", 4), r("/p/main.rs", 4), r("/p/gone.rs", 1)]);
const res = await fileUsages(["/p/a.rs", "/p/gone.rs"]);
expect(res).toEqual({ status: "ok", usages: [r("/p/main.rs", 4)] });
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<string, Location[]>) {
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;
expect((await fileUsages(["/p/a.rs"])).status).toBe("unsupported");
});
it("is cancellable", async () => {
const abort = new AbortController();
abort.abort();
await expect(fileUsages(["/p/a.rs"], abort.signal)).rejects.toMatchObject({ kind: "cancelled" });
});
});