feat(viewer): pure file-path matcher for terminal text
Adds findFilePathLinks, a pure matcher that finds file paths (with optional :line, :line:col, :start-end and #L42/#L40-L50 suffixes) in a line of terminal text, skipping anything inside a URL and stripping markdown/quote wrapping from the matched span. Per pre-flight ruling P7, a slash-having token still requires the basename to carry a real extension (any extension) or be a known extensionless basename -- a bare and/or or src/components is no longer treated as a path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { findFilePathLinks } from "./filePathLinks";
|
||||||
|
|
||||||
|
const one = (text: string) => {
|
||||||
|
const m = findFilePathLinks(text);
|
||||||
|
expect(m, text).toHaveLength(1);
|
||||||
|
return m[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("findFilePathLinks — what is a path", () => {
|
||||||
|
it.each([
|
||||||
|
["src/foo.ts", "src/foo.ts"],
|
||||||
|
["/workspace/x/README.md", "/workspace/x/README.md"],
|
||||||
|
["./scripts/build.sh", "./scripts/build.sh"],
|
||||||
|
["../other/Cargo.toml", "../other/Cargo.toml"],
|
||||||
|
["Makefile", "Makefile"],
|
||||||
|
["Dockerfile", "Dockerfile"],
|
||||||
|
["CLAUDE.md", "CLAUDE.md"],
|
||||||
|
[".gitignore", ".gitignore"],
|
||||||
|
["app/src-tauri/src/lib.rs", "app/src-tauri/src/lib.rs"],
|
||||||
|
["my-dir/some_file.test.tsx", "my-dir/some_file.test.tsx"],
|
||||||
|
])("matches %s", (text, path) => {
|
||||||
|
expect(one(text).path).toBe(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"1.2.3",
|
||||||
|
"v2.11.0",
|
||||||
|
"example.com",
|
||||||
|
"claude.ai",
|
||||||
|
"e.g.",
|
||||||
|
"https://example.com/a/b.ts",
|
||||||
|
"http://localhost:1420/viewer.html",
|
||||||
|
"foo",
|
||||||
|
"a.b",
|
||||||
|
"10.0.0.1",
|
||||||
|
"and/or",
|
||||||
|
"src/components",
|
||||||
|
])("does not match %s", (text) => {
|
||||||
|
expect(findFilePathLinks(text)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a slash-less token only with a known source/doc extension", () => {
|
||||||
|
expect(one("index.ts").path).toBe("index.ts");
|
||||||
|
expect(one("notes.md").path).toBe("notes.md");
|
||||||
|
expect(findFilePathLinks("archive.xyz")).toEqual([]);
|
||||||
|
// With a slash, any extension will do.
|
||||||
|
expect(one("dist/archive.xyz").path).toBe("dist/archive.xyz");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findFilePathLinks — line and column suffixes", () => {
|
||||||
|
it("parses :line", () => {
|
||||||
|
expect(one("src/foo.ts:42")).toMatchObject({ path: "src/foo.ts", line: 42 });
|
||||||
|
});
|
||||||
|
it("parses :line:col", () => {
|
||||||
|
expect(one("src/foo.ts:42:7")).toMatchObject({ path: "src/foo.ts", line: 42, col: 7 });
|
||||||
|
});
|
||||||
|
it("parses :start-end", () => {
|
||||||
|
expect(one("app/src/lib/urlRelay.ts:139-150")).toMatchObject({ path: "app/src/lib/urlRelay.ts", line: 139, endLine: 150 });
|
||||||
|
});
|
||||||
|
it("parses #L42 and #L40-L50", () => {
|
||||||
|
expect(one("README.md#L42")).toMatchObject({ path: "README.md", line: 42 });
|
||||||
|
expect(one("README.md#L40-L50")).toMatchObject({ path: "README.md", line: 40, endLine: 50 });
|
||||||
|
});
|
||||||
|
it("does not read a trailing colon as a line", () => {
|
||||||
|
expect(one("Edited src/foo.ts:")).toMatchObject({ path: "src/foo.ts", line: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findFilePathLinks — markdown wrapping and offsets", () => {
|
||||||
|
it.each([
|
||||||
|
["`src/foo.ts`", 1, 11],
|
||||||
|
["(src/foo.ts)", 1, 11],
|
||||||
|
["[src/foo.ts]", 1, 11],
|
||||||
|
['"src/foo.ts"', 1, 11],
|
||||||
|
["'src/foo.ts'", 1, 11],
|
||||||
|
["see src/foo.ts.", 4, 14],
|
||||||
|
["see src/foo.ts, then", 4, 14],
|
||||||
|
["see src/foo.ts;", 4, 14],
|
||||||
|
])("strips wrapping in %s", (text, start, end) => {
|
||||||
|
expect(one(text)).toMatchObject({ path: "src/foo.ts", start, end });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the :line suffix inside the span", () => {
|
||||||
|
// "at `" is 4 characters; the span covers `src/foo.ts:42` (13 chars).
|
||||||
|
expect(one("at `src/foo.ts:42`")).toMatchObject({ path: "src/foo.ts", line: 42, start: 4, end: 17 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds several paths in one line, in order", () => {
|
||||||
|
const m = findFilePathLinks("Read src/a.ts and src/b.rs:3, wrote docs/c.md");
|
||||||
|
expect(m.map((x) => x.path)).toEqual(["src/a.ts", "src/b.rs", "docs/c.md"]);
|
||||||
|
expect(m[1].line).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips anything inside a URL", () => {
|
||||||
|
expect(findFilePathLinks("see https://github.com/o/r/blob/main/src/foo.ts:12 now")).toEqual([]);
|
||||||
|
expect(one("see https://x.io/a and src/foo.ts").path).toBe("src/foo.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a Claude tool header like ⏺ Read(src/foo.ts) except for the path", () => {
|
||||||
|
expect(one("⏺ Read(src/foo.ts)").path).toBe("src/foo.ts");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Finds file paths in a line of terminal text.
|
||||||
|
*
|
||||||
|
* Pure: the xterm glue (`components/terminal/filePathLinkProvider.ts`) turns
|
||||||
|
* buffer rows into a string and string offsets back into cells; this decides
|
||||||
|
* what a path is. Deliberately conservative — a false link is an annoying
|
||||||
|
* underline, a missed one is a copy-paste — so a token needs either a `/` or
|
||||||
|
* a known extension, and never sits inside a URL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FilePathMatch {
|
||||||
|
/** Indices into the input; `end` exclusive. Covers path + suffix, not wrapping. */
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
path: string;
|
||||||
|
line?: number;
|
||||||
|
col?: number;
|
||||||
|
endLine?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extensions that make a slash-less token (`index.ts`, `notes.md`) a path. */
|
||||||
|
const KNOWN_EXTENSIONS = new Set([
|
||||||
|
"md", "markdown", "txt", "rst", "json", "jsonc", "yaml", "yml", "toml", "ini", "cfg", "conf",
|
||||||
|
"env", "lock", "js", "jsx", "mjs", "cjs", "ts", "tsx", "rs", "py", "rb", "go", "java", "kt",
|
||||||
|
"c", "h", "cc", "cpp", "hpp", "cs", "php", "swift", "scala", "lua", "sh", "bash", "zsh",
|
||||||
|
"fish", "ps1", "html", "htm", "xml", "svelte", "vue", "css", "scss", "sass", "less", "sql",
|
||||||
|
"graphql", "proto", "diff", "patch", "csv", "tsv", "log", "svg", "png", "jpg", "jpeg", "gif",
|
||||||
|
"webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Extensionless names that are files by convention. */
|
||||||
|
const KNOWN_BASENAMES = new Set([
|
||||||
|
"Makefile", "Dockerfile", "Rakefile", "Gemfile", "Procfile", "Vagrantfile", "LICENSE",
|
||||||
|
"README", "CHANGELOG", "PKGBUILD",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A candidate token: path characters, optionally starting with `/`, `./`, `../`
|
||||||
|
* or `.` (dotfile). Excludes the wrapping characters the surrounding markdown
|
||||||
|
* leaves (`(`, `)`, `[`, `]`, backtick, quotes) and whitespace.
|
||||||
|
*/
|
||||||
|
const TOKEN = /(?:\.{1,2}\/|\/)?[A-Za-z0-9_.\-~+@]+(?:\/[A-Za-z0-9_.\-~+@]+)*\/?/g;
|
||||||
|
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi;
|
||||||
|
const LINE_SUFFIX = /^(?::(\d+)(?::(\d+))?(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)/;
|
||||||
|
const VERSION_LIKE = /^v?\d+(\.\d+)+$/;
|
||||||
|
const TRAILING_PUNCT = /[.,;:]+$/;
|
||||||
|
|
||||||
|
function isPathLike(token: string): boolean {
|
||||||
|
if (VERSION_LIKE.test(token)) return false;
|
||||||
|
const base = token.slice(token.lastIndexOf("/") + 1);
|
||||||
|
if (base === "" || base === "." || base === "..") return false;
|
||||||
|
if (KNOWN_BASENAMES.has(base)) return true;
|
||||||
|
|
||||||
|
const hasSlash = token.includes("/");
|
||||||
|
const dot = base.lastIndexOf(".");
|
||||||
|
|
||||||
|
if (dot === 0) {
|
||||||
|
// Dotfile (.gitignore, .env). With a slash the name itself counts as
|
||||||
|
// "having an extension"; without one it must be a known dotfile.
|
||||||
|
if (hasSlash) return true;
|
||||||
|
return KNOWN_EXTENSIONS.has(base.slice(1).toLowerCase()) || base === ".gitignore" || base === ".env";
|
||||||
|
}
|
||||||
|
if (dot < 0) return false; // no extension at all — never a path
|
||||||
|
// A real extension. With a slash any extension will do; without one it
|
||||||
|
// must be a known source/doc extension.
|
||||||
|
if (hasSlash) return true;
|
||||||
|
return KNOWN_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlSpans(text: string): Array<[number, number]> {
|
||||||
|
const spans: Array<[number, number]> = [];
|
||||||
|
for (const m of text.matchAll(URL_SCHEME)) {
|
||||||
|
const start = m.index ?? 0;
|
||||||
|
// A URL runs to the next whitespace or closing bracket/quote.
|
||||||
|
const rest = text.slice(start);
|
||||||
|
const len = rest.search(/[\s)\]'"`>]/);
|
||||||
|
spans.push([start, len < 0 ? text.length : start + len]);
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFilePathLinks(text: string): FilePathMatch[] {
|
||||||
|
const urls = urlSpans(text);
|
||||||
|
const insideUrl = (i: number) => urls.some(([s, e]) => i >= s && i < e);
|
||||||
|
const out: FilePathMatch[] = [];
|
||||||
|
|
||||||
|
for (const m of text.matchAll(TOKEN)) {
|
||||||
|
const start = m.index ?? 0;
|
||||||
|
let token = m[0];
|
||||||
|
if (insideUrl(start)) continue;
|
||||||
|
|
||||||
|
// Trailing sentence punctuation is not part of the name.
|
||||||
|
const trimmed = token.replace(TRAILING_PUNCT, "");
|
||||||
|
if (trimmed !== token) token = trimmed;
|
||||||
|
if (token.endsWith("/")) token = token.slice(0, -1);
|
||||||
|
if (!token || !isPathLike(token)) continue;
|
||||||
|
|
||||||
|
let end = start + token.length;
|
||||||
|
// `line`/`col`/`endLine` are set explicitly to `undefined` (rather than
|
||||||
|
// left absent) so callers that assert on them with `toMatchObject` see
|
||||||
|
// the key, not a missing property.
|
||||||
|
const match: FilePathMatch = { start, end, path: token, line: undefined, col: undefined, endLine: undefined };
|
||||||
|
|
||||||
|
// The suffix sits right after the *trimmed* token: `TOKEN` may have
|
||||||
|
// consumed a trailing `.` that `TRAILING_PUNCT` then removed, so search
|
||||||
|
// from `start + token.length`, not from the end of the raw match.
|
||||||
|
const after = text.slice(start + token.length);
|
||||||
|
const s = LINE_SUFFIX.exec(after);
|
||||||
|
if (s) {
|
||||||
|
if (s[1] !== undefined) {
|
||||||
|
match.line = Number(s[1]);
|
||||||
|
if (s[2] !== undefined) match.col = Number(s[2]);
|
||||||
|
if (s[3] !== undefined) match.endLine = Number(s[3]);
|
||||||
|
} else if (s[4] !== undefined) {
|
||||||
|
match.line = Number(s[4]);
|
||||||
|
if (s[5] !== undefined) match.endLine = Number(s[5]);
|
||||||
|
}
|
||||||
|
end += s[0].length;
|
||||||
|
match.end = end;
|
||||||
|
}
|
||||||
|
out.push(match);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user