fix(tui): preserve autocomplete punctuation context

This commit is contained in:
Armin Ronacher
2026-05-06 10:54:54 +02:00
parent 1dd339c35e
commit 6e49c70096
2 changed files with 81 additions and 8 deletions
+23 -7
View File
@@ -57,22 +57,38 @@ function findLastDelimiter(text: string): number {
}
function isExplicitPathPrefixStart(prefix: string): boolean {
return prefix.startsWith(".") || prefix.startsWith("/") || prefix.startsWith("~/") || prefix === "~";
return (
prefix.startsWith("./") ||
prefix.startsWith("../") ||
prefix.startsWith("/") ||
prefix.startsWith("~/") ||
prefix === "~"
);
}
function isLeadingPunctuationRun(token: string, endIndex: number): boolean {
for (let i = 0; i <= endIndex; i += 1) {
if (!AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[i] ?? "")) {
return false;
}
}
return true;
}
function isLeadingDotfilePrefixStart(token: string, punctuationIndex: number, suffix: string): boolean {
return suffix.startsWith(".") && !suffix.startsWith("./") && isLeadingPunctuationRun(token, punctuationIndex);
}
function findPathPrefixStartInToken(token: string): number {
let tokenStart = 0;
while (AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[tokenStart] ?? "")) {
tokenStart += 1;
}
for (let i = tokenStart; i < token.length; i += 1) {
for (let i = 0; i < token.length; i += 1) {
if (!AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[i] ?? "")) {
continue;
}
const suffix = token.slice(i + 1);
if (isExplicitPathPrefixStart(suffix)) {
if (isExplicitPathPrefixStart(suffix) || isLeadingDotfilePrefixStart(token, i, suffix)) {
tokenStart = i + 1;
}
}
@@ -496,7 +512,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
const token = text.slice(tokenStart);
for (let i = 0; i < token.length; i += 1) {
for (let i = token.length - 1; i >= 0; i -= 1) {
const absoluteIndex = tokenStart + i;
if (token[i] === "@" && isTokenStart(text, absoluteIndex)) {
return text.slice(absoluteIndex);
+58 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, it, test } from "node:test";
@@ -114,6 +114,38 @@ describe("CombinedAutocompleteProvider", () => {
});
});
describe("@ prefix extraction", () => {
let rootDir = "";
let baseDir = "";
let fakeFdPath = "";
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-at-"));
baseDir = join(rootDir, "cwd");
mkdirSync(baseDir, { recursive: true });
fakeFdPath = join(rootDir, "fake-fd.js");
writeFileSync(fakeFdPath, '#!/usr/bin/env node\nprocess.stdout.write("old.ts\\nmain.ts\\n");\n');
chmodSync(fakeFdPath, 0o755);
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
test("uses the nearest @ after punctuation", async () => {
const provider = new CombinedAutocompleteProvider([], baseDir, fakeFdPath);
const line = "(@old),(@mai";
const result = await getSuggestions(provider, [line], 0, line.length);
assert.notEqual(result, null, "Should return suggestions for the current @ prefix");
assert.strictEqual(result?.prefix, "@mai");
assert.deepStrictEqual(
result?.items.map((item) => item.value),
["@main.ts"],
);
});
});
describe("fd @ file suggestions", { skip: !isFdInstalled }, () => {
let rootDir = "";
let baseDir = "";
@@ -495,6 +527,7 @@ describe("CombinedAutocompleteProvider", () => {
test("does not split path prefixes containing punctuation", async () => {
setupFolder(baseDir, {
files: {
"(foo).txt": "content",
"foo(bar).txt": "content",
"name[part].txt": "content",
},
@@ -508,6 +541,30 @@ describe("CombinedAutocompleteProvider", () => {
assert.strictEqual(parenResult?.prefix, "./foo(");
assert.ok(parenResult?.items.some((item) => item.value === "./foo(bar).txt"));
const completedParenLine = "./foo(bar).t";
const completedParenResult = await getSuggestions(
provider,
[completedParenLine],
0,
completedParenLine.length,
true,
);
assert.notEqual(completedParenResult, null, "Should preserve punctuation before file extensions");
assert.strictEqual(completedParenResult?.prefix, "./foo(bar).t");
assert.ok(completedParenResult?.items.some((item) => item.value === "./foo(bar).txt"));
const leadingPunctuationLine = "(foo).t";
const leadingPunctuationResult = await getSuggestions(
provider,
[leadingPunctuationLine],
0,
leadingPunctuationLine.length,
true,
);
assert.notEqual(leadingPunctuationResult, null, "Should preserve leading punctuation in filenames");
assert.strictEqual(leadingPunctuationResult?.prefix, "(foo).t");
assert.ok(leadingPunctuationResult?.items.some((item) => item.value === "(foo).txt"));
const bracketLine = "./name[";
const bracketResult = await getSuggestions(provider, [bracketLine], 0, bracketLine.length, true);
assert.notEqual(bracketResult, null, "Should return suggestions for path containing brackets");