import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { completionPrefix, cursorOffset, parseCompletionsOutput, stripBangPrefix, wrapNuAutocompleteProvider, } from "../index.js"; describe("nu-bang helpers", () => { test("strips bang prefixes", () => { expect(stripBangPrefix("!ls")?.command).toBe("ls"); expect(stripBangPrefix("!!ls")?.command).toBe("ls"); expect(stripBangPrefix("!!ls")?.prefixLength).toBe(2); expect(stripBangPrefix("ls")).toBeUndefined(); }); test("computes cursor offset after prefix", () => { expect(cursorOffset(["!ls | wh"], 0, 8, 1)).toBe(7); expect(cursorOffset(["!!ls", "| where"], 1, 7, 2)).toBe(10); expect(cursorOffset(["!"], 0, 1, 1)).toBeUndefined(); }); test("extracts completion prefix", () => { expect(completionPrefix("ls | wh", 7)).toBe("wh"); expect(completionPrefix("$env.PA", 7)).toBe("PA"); expect(completionPrefix("$env.", 5)).toBe(""); expect(completionPrefix("open people.j", 13)).toBe("people.j"); expect(completionPrefix("open .e", 7)).toBe(".e"); expect(completionPrefix("open my\\ f", 10)).toBe("my\\ f"); expect(completionPrefix('open "my f', 10)).toBe('"my f'); expect(completionPrefix("open 'my f", 10)).toBe("'my f"); expect(completionPrefix("open `my f", 10)).toBe("`my f"); }); test("salvages malformed completion JSON", () => { expect( parseCompletionsOutput('{"completions": ["PATH", ""ODD_NAME"", "HOME"]}'), ).toEqual(["PATH", "ODD_NAME", "HOME"]); expect( parseCompletionsOutput('{"completions": ["a,b", ""ODD_NAME""]}'), ).toEqual(["a,b", "ODD_NAME"]); }); test("delegates non-bang autocomplete suggestions", async () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions(lines, cursorLine, cursorCol) { expect(lines).toEqual(["/re"]); expect(cursorLine).toBe(0); expect(cursorCol).toBe(3); return { prefix: "re", items: [{ value: "/reload", label: "reload" }], }; }, applyCompletion() { throw new Error("not used"); }, }, process.cwd(), ); await expect( provider.getSuggestions(["/re"], 0, 3, { signal: new AbortController().signal, }), ).resolves.toEqual({ prefix: "re", items: [{ value: "/reload", label: "reload" }], }); }); test("applies dotted path completions without duplicating prefix", () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions() { return null; }, applyCompletion() { throw new Error("not used"); }, }, process.cwd(), ); const prefix = completionPrefix("open people.j", 13); expect( provider.applyCompletion( ["!open people.j"], 0, 14, { value: "people.json", label: "people.json" }, prefix, ), ).toEqual({ lines: ["!open people.json"], cursorLine: 0, cursorCol: 17, }); }); test("applies quoted path completions without leaving stale quotes", () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions() { return null; }, applyCompletion() { throw new Error("not used"); }, }, process.cwd(), ); const prefix = completionPrefix('open "my f', 10); expect( provider.applyCompletion( ['!open "my f'], 0, 11, { value: "`my file.txt`", label: "`my file.txt`" }, prefix, ), ).toEqual({ lines: ["!open `my file.txt`"], cursorLine: 0, cursorCol: 19, }); }); test("removes existing closing quote when applying quoted path completions", () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions() { return null; }, applyCompletion() { throw new Error("not used"); }, }, process.cwd(), ); expect( provider.applyCompletion( ['!open "my f"'], 0, 11, { value: "`my file.txt`", label: "`my file.txt`" }, completionPrefix('open "my f', 10), ), ).toEqual({ lines: ["!open `my file.txt`"], cursorLine: 0, cursorCol: 19, }); expect( provider.applyCompletion( ["!open 'my f'"], 0, 11, { value: "`my file.txt`", label: "`my file.txt`" }, completionPrefix("open 'my f", 10), ), ).toEqual({ lines: ["!open `my file.txt`"], cursorLine: 0, cursorCol: 19, }); expect( provider.applyCompletion( ["!open `my f`"], 0, 11, { value: "`my file.txt`", label: "`my file.txt`" }, completionPrefix("open `my f", 10), ), ).toEqual({ lines: ["!open `my file.txt`"], cursorLine: 0, cursorCol: 19, }); }); test("applies escaped-space path completions without duplicating prefix", () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions() { return null; }, applyCompletion() { throw new Error("not used"); }, }, process.cwd(), ); const prefix = completionPrefix("open my\\ f", 10); expect( provider.applyCompletion( ["!open my\\ f"], 0, 11, { value: "`my file.txt`", label: "`my file.txt`" }, prefix, ), ).toEqual({ lines: ["!open `my file.txt`"], cursorLine: 0, cursorCol: 19, }); }); test("delegates non-bang completion application", () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions() { return null; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { expect(lines).toEqual(["/re"]); expect(cursorLine).toBe(0); expect(cursorCol).toBe(3); expect(item.value).toBe("/reload"); expect(prefix).toBe("re"); return { lines: ["/reload"], cursorLine: 0, cursorCol: 7 }; }, }, process.cwd(), ); expect( provider.applyCompletion( ["/re"], 0, 3, { value: "/reload", label: "reload" }, "re", ), ).toEqual({ lines: ["/reload"], cursorLine: 0, cursorCol: 7, }); }); test("delegates fallback-origin bang completion application", async () => { let applyCalls = 0; const fallbackItem = { value: "/reload", label: "reload" }; const provider = wrapNuAutocompleteProvider( { async getSuggestions(lines, cursorLine, cursorCol) { expect(lines).toEqual(["!bad"]); expect(cursorLine).toBe(0); expect(cursorCol).toBe(4); return { prefix: "/re", items: [fallbackItem] }; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { applyCalls++; expect(lines).toEqual(["!bad"]); expect(cursorLine).toBe(0); expect(cursorCol).toBe(4); expect(item).toBe(fallbackItem); expect(prefix).toBe("/re"); return { lines: ["!/reload"], cursorLine: 0, cursorCol: 8 }; }, }, join(process.cwd(), "missing-nu-bang-cwd"), ); const suggestions = await provider.getSuggestions(["!bad"], 0, 4, { signal: new AbortController().signal, }); expect(suggestions?.items[0]).toBe(fallbackItem); expect( provider.applyCompletion( ["!bad"], 0, 4, fallbackItem, suggestions?.prefix ?? "", ), ).toEqual({ lines: ["!/reload"], cursorLine: 0, cursorCol: 8, }); expect(applyCalls).toBe(1); }); test("does not invoke fallback suggestions for already-aborted bang completion failures", async () => { let fallbackCalls = 0; const controller = new AbortController(); controller.abort(); const provider = wrapNuAutocompleteProvider( { async getSuggestions() { fallbackCalls++; return { prefix: "x", items: [{ value: "fallback", label: "fallback" }], }; }, applyCompletion() { throw new Error("not used"); }, }, join(process.cwd(), "missing-nu-bang-cwd"), ); await expect( provider.getSuggestions(["!bad"], 0, 4, { signal: controller.signal }), ).resolves.toBeNull(); expect(fallbackCalls).toBe(0); }); test("allows tab completion for bang input", () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions() { return null; }, applyCompletion() { throw new Error("not used"); }, shouldTriggerFileCompletion(lines) { return lines[0] === "@"; }, }, process.cwd(), ); expect(provider.shouldTriggerFileCompletion?.(["!open "], 0, 6)).toBe(true); expect(provider.shouldTriggerFileCompletion?.(["@"], 0, 1)).toBe(true); expect(provider.shouldTriggerFileCompletion?.(["nope"], 0, 4)).toBe(false); }); });