import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { classifyBangCommand, completionPrefix, cursorOffset, parseCompletionsOutput, stripBangPrefix, wrapNuAutocompleteProvider, } from "../index.js"; describe("nu-bang interactive classifier", () => { function interactive(command: string): boolean { return classifyBangCommand(command).interactive; } test("forces interactive mode with the i prefix and strips it", () => { expect(classifyBangCommand("i cargo run")).toEqual({ command: "cargo run", interactive: true, }); expect(classifyBangCommand("i\tcargo run").command).toBe("cargo run"); expect(classifyBangCommand("i")).toEqual({ command: "i", interactive: false, }); expect(interactive("into int")).toBe(false); }); test("streams ordinary commands", () => { for (const command of [ "ls | where size > 1kb", "git status", "git log --oneline", "git rebase main", "git add -A", 'git commit -m "msg"', 'git commit -am "msg"', "git commit --message=msg", "git commit --amend --no-edit", "git commit -F notes.txt", "open people.json | get name", "[1 2 3] | each {|less| $less }", "lessons | first", "", ]) { expect(interactive(command), command).toBe(false); } }); test("detects editors, pagers, and terminal UIs", () => { for (const command of [ "vim notes.md", "^nvim .", " hx src/main.rs", "less README.md", "git log | less", "open people.json | to json | ^less -R", "lazygit", "htop", "ssh host", "fzf", ]) { expect(interactive(command), command).toBe(true); } }); test("detects editor-opening git subcommands", () => { for (const command of [ "git commit", "git commit -a", "git commit --amend", "git commit -S", "git rebase -i HEAD~3", "git rebase --interactive main", "git add -p", "git add --patch src", "git add -i", "git stash -p", "git reset -p HEAD~1", "git checkout -p", "git restore --patch .", "git difftool", "git mergetool", "^git commit", ]) { expect(interactive(command), command).toBe(true); } }); }); 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); }); });