| 1 | import { splitFilePathPosition } from "./fileLinks.ts"; |
| 2 | import { parseFileUrlHref } from "./fileLinks.ts"; |
| 3 | import { workspaceRelativeFilePath, fileBasename } from "./path.ts"; |
| 4 | import { describe, expect, it } from "vite-plus/test"; |
| 5 | import { |
| 6 | extractMarkdownLinkHrefs, |
| 7 | resolveMarkdownFileLinkTarget, |
| 8 | isWindowsDrivePathHref, |
| 9 | } from "./markdownLinks.ts"; |
| 10 | |
| 11 | import { |
| 12 | inlineCodeFilePathCandidate, |
| 13 | isMarkdownFileLinkLabel, |
| 14 | parseMarkdownFileLink, |
| 15 | } from "./markdownLinks.ts"; |
| 16 | |
| 17 | describe("isMarkdownFileLinkLabel", () => { |
| 18 | it.each([ |
| 19 | ["validates the input", "/repo/src/example.ts:12", false], |
| 20 | ["read src/example.ts", "/repo/src/example.ts:12", false], |
| 21 | ["example.ts?why this matters", "/repo/src/example.ts", false], |
| 22 | ["example.ts", "/repo/src/example.ts:12", true], |
| 23 | ["example.ts:12", "/repo/src/example.ts:12", true], |
| 24 | ["example.ts:99", "/repo/src/example.ts:12", false], |
| 25 | ["example.ts:12:2", "/repo/src/example.ts:12:2", true], |
| 26 | ["example.ts:12:3", "/repo/src/example.ts:12:2", false], |
| 27 | ["example.ts:12", "/repo/src/example.ts", false], |
| 28 | ["src/example.ts:12", "/repo/src/example.ts:12", true], |
| 29 | ["./src/example.ts", "/repo/src/example.ts", true], |
| 30 | ["/repo/src/example.ts", "/repo/src/example.ts", true], |
| 31 | ["src/", "/home/me/project/src/", true], |
| 32 | ["EXAMPLE.TS", "C:/repo/src/example.ts:12", true], |
| 33 | ["file name.ts", "file:///repo/file%20name.ts", true], |
| 34 | ["", "/repo/src/example.ts", true], |
| 35 | ])("classifies %s for %s", (label, href, expected) => { |
| 36 | expect(isMarkdownFileLinkLabel(label, href)).toBe(expected); |
| 37 | }); |
| 38 | }); |
| 39 | |
| 40 | describe("inlineCodeFilePathCandidate", () => { |
| 41 | it.each([ |
| 42 | ["src\\main.ts", "src/main.ts"], |
| 43 | ["C:\\Users\\demo\\image.png", "C:\\Users\\demo\\image.png"], |
| 44 | ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], |
| 45 | ["conf.d/nginx.conf", "conf.d/nginx.conf"], |
| 46 | ["script.pl:10", "script.pl:10"], |
| 47 | ["node.meta", null], |
| 48 | ["Recorded evidence here: /tmp/image.png", null], |
| 49 | ["origin/main", null], |
| 50 | ["127.0.0.1:3000", null], |
| 51 | ["example.com/index.html", null], |
| 52 | ["example.pl/index.html", null], |
| 53 | ["z-ai/glm-5.3", null], |
| 54 | ["z-ai/glm-5.3:12", null], |
| 55 | ["python/3.12", null], |
| 56 | ["Qwen/Qwen2.5-Coder", null], |
| 57 | ["meta-llama/Llama-3.1-8B", null], |
| 58 | ["share/man/ls.1", "share/man/ls.1"], |
| 59 | ["usr/lib/libfoo.so.1", "usr/lib/libfoo.so.1"], |
| 60 | ["vendor/jquery-3.6.0.min.js", "vendor/jquery-3.6.0.min.js"], |
| 61 | ["./models/glm-5.3", "./models/glm-5.3"], |
| 62 | ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => { |
| 63 | expect(inlineCodeFilePathCandidate(source)).toBe(candidate); |
| 64 | }); |
| 65 | }); |
| 66 | |
| 67 | describe("parseFileUrlHref", () => { |
| 68 | it.each([ |
| 69 | ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], |
| 70 | [ |
| 71 | "file:///D:/Programme/t3code/OpenInPicker.tsx#L69", |
| 72 | "D:/Programme/t3code/OpenInPicker.tsx", |
| 73 | "#L69", |
| 74 | ], |
| 75 | ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""], |
| 76 | ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""], |
| 77 | ])("parses %s", (href, path, hash) => { |
| 78 | expect(parseFileUrlHref(href)).toEqual({ path, hash }); |
| 79 | }); |
| 80 | |
| 81 | it("keeps percent-encoding so the caller decodes once", () => { |
| 82 | expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe( |
| 83 | "/Users/julius/project/file%2520name.md", |
| 84 | ); |
| 85 | expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png"); |
| 86 | }); |
| 87 | |
| 88 | it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => { |
| 89 | expect(parseFileUrlHref(href)).toBeNull(); |
| 90 | }); |
| 91 | }); |
| 92 | |
| 93 | describe("splitFilePathPosition", () => { |
| 94 | it.each([ |
| 95 | ["src/main.ts", "", { path: "src/main.ts" }], |
| 96 | ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }], |
| 97 | ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }], |
| 98 | ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }], |
| 99 | ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }], |
| 100 | ["src/main.ts:0", "", { path: "src/main.ts" }], |
| 101 | ["src/main.ts", "#section", { path: "src/main.ts" }], |
| 102 | ])("splits %s%s", (path, hash, expected) => { |
| 103 | expect(splitFilePathPosition(path, hash)).toEqual(expected); |
| 104 | }); |
| 105 | }); |
| 106 | |
| 107 | describe("parseMarkdownFileLink", () => { |
| 108 | // Both clients consume this table, so a path the web app recognizes is one |
| 109 | // the mobile app recognizes too. |
| 110 | it.each([ |
| 111 | ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"], |
| 112 | ["/home/me/notes.md", "/home/me/notes.md"], |
| 113 | ["/usr/local/bin/tool", "/usr/local/bin/tool"], |
| 114 | ["/workspace/Makefile", "/workspace/Makefile"], |
| 115 | ["/tmp/favicons/", "/tmp/favicons/"], |
| 116 | ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"], |
| 117 | ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"], |
| 118 | ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], |
| 119 | ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"], |
| 120 | ["</D:/Programme/t3code/ChatMarkdown.tsx:1>", "D:/Programme/t3code/ChatMarkdown.tsx"], |
| 121 | ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"], |
| 122 | ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"], |
| 123 | ["file://localhost/home/me/notes.md", "/home/me/notes.md"], |
| 124 | ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"], |
| 125 | ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"], |
| 126 | ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"], |
| 127 | ["./scripts/deploy", "./scripts/deploy"], |
| 128 | ["~/notes/today.md", "~/notes/today.md"], |
| 129 | ["AGENTS.md", "AGENTS.md"], |
| 130 | ["script.ts:10", "script.ts"], |
| 131 | ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"], |
| 132 | ])("recognizes %s as a file", (href, path) => { |
| 133 | expect(parseMarkdownFileLink(href)?.path).toBe(path); |
| 134 | }); |
| 135 | |
| 136 | it.each([ |
| 137 | "", |
| 138 | "#anchor", |
| 139 | "//cdn.example.com/clip.mp4", |
| 140 | "https://example.com/docs", |
| 141 | "mailto:someone@example.com", |
| 142 | "javascript:alert(1)", |
| 143 | "/chat/settings", |
| 144 | "/chat/settings#L3", |
| 145 | "/app#L1", |
| 146 | "readme", |
| 147 | "TODO:12", |
| 148 | ])("does not treat %s as a file", (href) => { |
| 149 | expect(parseMarkdownFileLink(href)).toBeNull(); |
| 150 | }); |
| 151 | |
| 152 | it("accepts conventional extensionless names with or without a position", () => { |
| 153 | expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" }); |
| 154 | expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 }); |
| 155 | expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" }); |
| 156 | }); |
| 157 | |
| 158 | it("reads positions from suffixes and line anchors", () => { |
| 159 | expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({ |
| 160 | path: "/Users/julius/project/src/main.ts", |
| 161 | line: 42, |
| 162 | column: 7, |
| 163 | }); |
| 164 | expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({ |
| 165 | path: "\\\\server\\share\\src\\main.ts", |
| 166 | line: 42, |
| 167 | column: 7, |
| 168 | }); |
| 169 | }); |
| 170 | }); |
| 171 | |
| 172 | describe("fileBasename", () => { |
| 173 | it.each([ |
| 174 | ["/tmp/favicons/", "favicons"], |
| 175 | ["C:\\Users\\kelchm\\.claude\\", ".claude"], |
| 176 | ["/tmp/", "tmp"], |
| 177 | ["AGENTS.md", "AGENTS.md"], |
| 178 | ["/", "/"], |
| 179 | ])("labels %s as %s", (path, basename) => { |
| 180 | expect(fileBasename(path)).toBe(basename); |
| 181 | }); |
| 182 | }); |
| 183 | |
| 184 | describe("workspaceRelativeFilePath", () => { |
| 185 | it.each([ |
| 186 | ["/repo/project", "/repo/project", "."], |
| 187 | ["/repo/project/", "/repo/project/", "."], |
| 188 | ["/", "/", "."], |
| 189 | ["C:/USERS/mike/project", "c:/users/MIKE/project", "."], |
| 190 | ["C:/", "c:/", "."], |
| 191 | ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"], |
| 192 | ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"], |
| 193 | ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], |
| 194 | ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], |
| 195 | ["/Repo/Project/src/main.ts", "/repo/project", null], |
| 196 | ["/tmp/case/project/probe.txt", "/tmp/case/Project", null], |
| 197 | ["//tmp/case/project/probe.txt", "//tmp/case/Project", null], |
| 198 | ["/tmp/case/Project/probe.txt", "/tmp/case/Project", "probe.txt"], |
| 199 | ["C:/USERS/mike/t3code/main.ts", "c:/users/MIKE/t3code", "main.ts"], |
| 200 | ["/C:/USERS/mike/t3code/main.ts", "/c:/users/MIKE/t3code", "main.ts"], |
| 201 | ["\\\\server\\share\\PROJECT\\main.ts", "\\\\Server\\Share\\Project", "main.ts"], |
| 202 | ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], |
| 203 | ["C:/Users/MIKE/main.ts", "c:/", "Users/MIKE/main.ts"], |
| 204 | ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], |
| 205 | ["/tmp/repo/file.ts ", "/tmp/repo", "file.ts "], |
| 206 | ["/tmp/report.ts", "/repo/project", null], |
| 207 | ["/repo/project-two/a.ts", "/repo/project", null], |
| 208 | ["/repo/project/a.ts", undefined, null], |
| 209 | ])("relates %s to %s", (path, workspaceRoot, relativePath) => { |
| 210 | expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); |
| 211 | }); |
| 212 | }); |
| 213 | |
| 214 | describe("isWindowsDrivePathHref", () => { |
| 215 | it.each([ |
| 216 | ["C:\\repo\\image.png", true], |
| 217 | ["C:%5Crepo%5Cimage.png", true], |
| 218 | ["https://example.com/image.png", false], |
| 219 | ])("classifies %s as %s", (href, expected) => { |
| 220 | expect(isWindowsDrivePathHref(href)).toBe(expected); |
| 221 | }); |
| 222 | }); |
| 223 | |
| 224 | describe("extractMarkdownLinkHrefs", () => { |
| 225 | it.each([ |
| 226 | ["[".repeat(32_000), []], |
| 227 | ["[](a".repeat(16_000), []], |
| 228 | ["[](<a".repeat(16_000), []], |
| 229 | ["[x](https://e.com/".repeat(8_000), []], |
| 230 | ["[a](<foo)bar> invalid [b](ok)", ["<foo", "ok"]], |
| 231 | ["[a](foo \"mixed title') [b](< >)", ["foo"]], |
| 232 | ["[a](<one [b](two)> invalid [c](ok)", ["two", "ok"]], |
| 233 | ["```text\n" + "[unfinished item\n".repeat(8_000) + "```", []], |
| 234 | ["[[[nested](src/main.ts) [unfinished", ["src/main.ts"]], |
| 235 | ["[outer [inner] invalid [valid](src/main.ts)", ["src/main.ts"]], |
| 236 | ["[bad](missing title [valid](src/main.ts)", ["src/main.ts"]], |
| 237 | ["```text\n[link](src/main.ts)\n```", ["src/main.ts"]], |
| 238 | ["[empty]() [valid](src/main.ts 'title')", ["src/main.ts"]], |
| 239 | ['[angle](<src/My File.ts> "title")', ["src/My File.ts"]], |
| 240 | ])("extracts links from incomplete and nested text", (markdown, hrefs) => { |
| 241 | expect(extractMarkdownLinkHrefs(markdown)).toEqual(hrefs); |
| 242 | }); |
| 243 | |
| 244 | it("extracts angle-bracketed paths containing spaces", () => { |
| 245 | expect( |
| 246 | extractMarkdownLinkHrefs( |
| 247 | "[Open the Bike Receipts folder](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts>)", |
| 248 | ), |
| 249 | ).toEqual(["/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts"]); |
| 250 | }); |
| 251 | |
| 252 | it("preserves ordinary destinations and ignores link titles", () => { |
| 253 | expect( |
| 254 | extractMarkdownLinkHrefs( |
| 255 | '[source](apps/web/src/markdown-links.ts "implementation") and [docs](https://example.com)', |
| 256 | ), |
| 257 | ).toEqual(["apps/web/src/markdown-links.ts", "https://example.com"]); |
| 258 | }); |
| 259 | }); |
| 260 | |
| 261 | describe("resolveMarkdownFileLinkTarget", () => { |
| 262 | it("resolves absolute posix file paths", () => { |
| 263 | expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe( |
| 264 | "/Users/julius/project/AGENTS.md", |
| 265 | ); |
| 266 | }); |
| 267 | |
| 268 | it("resolves relative file paths against cwd", () => { |
| 269 | expect(resolveMarkdownFileLinkTarget("src/processRunner.ts:71", "/Users/julius/project")).toBe( |
| 270 | "/Users/julius/project/src/processRunner.ts:71", |
| 271 | ); |
| 272 | }); |
| 273 | |
| 274 | it("does not treat filename line references as external schemes", () => { |
| 275 | expect(resolveMarkdownFileLinkTarget("script.ts:10", "/Users/julius/project")).toBe( |
| 276 | "/Users/julius/project/script.ts:10", |
| 277 | ); |
| 278 | }); |
| 279 | |
| 280 | it("resolves bare file names against cwd", () => { |
| 281 | expect(resolveMarkdownFileLinkTarget("AGENTS.md", "/Users/julius/project")).toBe( |
| 282 | "/Users/julius/project/AGENTS.md", |
| 283 | ); |
| 284 | }); |
| 285 | |
| 286 | it("maps #L line anchors to editor line suffixes", () => { |
| 287 | expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe( |
| 288 | "/Users/julius/project/src/main.ts:42:7", |
| 289 | ); |
| 290 | }); |
| 291 | |
| 292 | it("ignores external urls", () => { |
| 293 | expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull(); |
| 294 | expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull(); |
| 295 | }); |
| 296 | |
| 297 | it("does not double-decode file URLs", () => { |
| 298 | expect(resolveMarkdownFileLinkTarget("file:///Users/julius/project/file%2520name.md")).toBe( |
| 299 | "/Users/julius/project/file%20name.md", |
| 300 | ); |
| 301 | }); |
| 302 | |
| 303 | it("resolves file uri authorities as windows UNC paths", () => { |
| 304 | expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg")).toBe( |
| 305 | "\\\\server\\share\\workspace-image.svg", |
| 306 | ); |
| 307 | }); |
| 308 | |
| 309 | it("resolves a localhost file uri as a local path", () => { |
| 310 | expect(resolveMarkdownFileLinkTarget("file://localhost/home/me/notes.md")).toBe( |
| 311 | "/home/me/notes.md", |
| 312 | ); |
| 313 | }); |
| 314 | |
| 315 | it("keeps an encoded final space in the absolute target", () => { |
| 316 | expect(resolveMarkdownFileLinkTarget("/tmp/repo/file.ts%20", "/tmp/repo")).toBe( |
| 317 | "/tmp/repo/file.ts ", |
| 318 | ); |
| 319 | }); |
| 320 | |
| 321 | it("normalizes slash-prefixed windows drive paths before resolving", () => { |
| 322 | expect( |
| 323 | resolveMarkdownFileLinkTarget( |
| 324 | "/D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69", |
| 325 | ), |
| 326 | ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69"); |
| 327 | }); |
| 328 | |
| 329 | it("resolves angle-bracketed windows drive paths", () => { |
| 330 | expect( |
| 331 | resolveMarkdownFileLinkTarget( |
| 332 | "</D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1>", |
| 333 | ), |
| 334 | ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); |
| 335 | }); |
| 336 | |
| 337 | it("does not treat app routes as file links, even with a line anchor", () => { |
| 338 | expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); |
| 339 | expect(resolveMarkdownFileLinkTarget("/chat/settings#L3", "/repo")).toBeNull(); |
| 340 | }); |
| 341 | |
| 342 | it("decodes an encoded drive colon in a file uri before dropping its slash", () => { |
| 343 | expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe( |
| 344 | "c:/Users/x/shot.png", |
| 345 | ); |
| 346 | }); |
| 347 | }); |