diff --git a/app/src/lib/filePathLinks.test.ts b/app/src/lib/filePathLinks.test.ts new file mode 100644 index 0000000..eccddf5 --- /dev/null +++ b/app/src/lib/filePathLinks.test.ts @@ -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"); + }); +}); diff --git a/app/src/lib/filePathLinks.ts b/app/src/lib/filePathLinks.ts new file mode 100644 index 0000000..c5f64a9 --- /dev/null +++ b/app/src/lib/filePathLinks.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; +}