1import { splitFilePathPosition } from "./fileLinks.ts";
2import { parseFileUrlHref } from "./fileLinks.ts";
3import { workspaceRelativeFilePath, fileBasename } from "./path.ts";
4import { describe, expect, it } from "vite-plus/test";
5import {
6 extractMarkdownLinkHrefs,
7 resolveMarkdownFileLinkTarget,
8 isWindowsDrivePathHref,
9} from "./markdownLinks.ts";
10
11import {
12 inlineCodeFilePathCandidate,
13 isMarkdownFileLinkLabel,
14 parseMarkdownFileLink,
15} from "./markdownLinks.ts";
16
17describe("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
40describe("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
67describe("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
93describe("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
107describe("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
172describe("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
184describe("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
214describe("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
224describe("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
261describe("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});