1import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "./path.ts";
2import {
3 type FilePathPosition,
4 formatFilePathPosition,
5 parseFileUrlHref,
6 resolvePathLinkTarget,
7 splitFilePathPosition,
8} from "./fileLinks.ts";
9
10const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
11
12const RELATIVE_FILE_PATH_PATTERN =
13 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
14
15const RELATIVE_FILE_NAME_PATTERN =
16 /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
17
18const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
19
20const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
21
22const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
23
24const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
25
26const PATH_SEPARATOR_PATTERN = /[\\/]/;
27
28const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
29// A final dot between digits marks a version or model id (`glm-5.3`,
30// `Qwen2.5-Coder`), not an extension. `ls.1` and `libfoo.so.1` stay files.
31const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/;
32const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
33
34// Standard OS and dev-container roots; deliberately excludes app-route-ish
35// prefixes like /app/ or /chat/ so SPA routes never read as files.
36const POSIX_FILE_ROOT_PREFIXES = [
37 "/Users/",
38 "/home/",
39 "/tmp/",
40 "/var/",
41 "/etc/",
42 "/opt/",
43 "/mnt/",
44 "/Volumes/",
45 "/private/",
46 "/root/",
47 "/usr/",
48 "/bin/",
49 "/sbin/",
50 "/lib/",
51 "/lib64/",
52 "/srv/",
53 "/dev/",
54 "/proc/",
55 "/sys/",
56 "/run/",
57 "/boot/",
58 "/media/",
59 "/workspace/",
60 "/workspaces/",
61] as const;
62
63// `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`.
64const EXTENSIONLESS_FILE_NAMES = new Set([
65 "Makefile",
66 "makefile",
67 "GNUmakefile",
68 "Dockerfile",
69 "Containerfile",
70 "Justfile",
71 "justfile",
72 "Rakefile",
73 "Gemfile",
74 "Procfile",
75 "Brewfile",
76 "Caddyfile",
77 "Vagrantfile",
78 "Jenkinsfile",
79 "Podfile",
80 "Fastfile",
81 "BUILD",
82 "WORKSPACE",
83 "LICENSE",
84 "LICENCE",
85 "COPYING",
86 "NOTICE",
87 "AUTHORS",
88 "CONTRIBUTORS",
89 "CHANGELOG",
90 "README",
91 "CODEOWNERS",
92]);
93
94const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);
95
96// These allowlists avoid classifying dotted directories such as `conf.d/`
97// or filenames such as `Makefile.in:12` as hosts.
98const GENERIC_HOSTNAME_TLDS = new Set([
99 "com",
100 "net",
101 "org",
102 "io",
103 "dev",
104 "app",
105 "ai",
106 "co",
107 "edu",
108 "gov",
109 "mil",
110 "info",
111 "biz",
112 "xyz",
113 "me",
114 "tv",
115 "cc",
116 "gg",
117 "chat",
118 "cloud",
119 "site",
120 "online",
121 "tech",
122 "store",
123 "link",
124]);
125
126// Country codes also name file extensions. A :line suffix makes `.pl`
127// and `.pt` files more likely than hostnames.
128const COUNTRY_HOSTNAME_TLDS = new Set([
129 "uk",
130 "de",
131 "fr",
132 "nl",
133 "se",
134 "no",
135 "fi",
136 "dk",
137 "pl",
138 "ch",
139 "at",
140 "be",
141 "es",
142 "it",
143 "pt",
144 "eu",
145 "us",
146 "ca",
147 "au",
148 "nz",
149 "jp",
150 "kr",
151 "cn",
152 "br",
153 "ru",
154 "mx",
155 "ie",
156 "cz",
157 "tr",
158 "sg",
159 "hk",
160]);
161
162function looksLikeHostname(segment: string, hasPosition: boolean): boolean {
163 if (segment.startsWith(".")) return false;
164 const lowered = segment.toLowerCase();
165 if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;
166 if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;
167 const labels = lowered.split(".");
168 const lastLabel = labels.at(-1);
169 if (labels.length < 2 || lastLabel === undefined) return false;
170 if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;
171 return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);
172}
173
174/**
175 * Picks path-shaped inline code for the client's markdown file-link resolver.
176 * It does not resolve paths or turn plain prose and fenced code into links.
177 */
178export function inlineCodeFilePathCandidate(codeText: string): string | null {
179 const trimmed = codeText.trim();
180 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
181
182 const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/");
183 const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);
184 if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;
185
186 const hasExplicitPathShape =
187 RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||
188 candidate.startsWith("/") ||
189 isWindowsAbsolutePath(candidate);
190 if (!hasExplicitPathShape) {
191 const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
192 const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
193 if (looksLikeHostname(firstSegment, hasPosition)) return null;
194 const basename =
195 withoutPosition
196 .replace(/[/\\]+$/, "")
197 .split(/[\\/]/)
198 .at(-1) ?? "";
199 if (VERSION_SUFFIX_PATTERN.test(basename)) return null;
200 if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null;
201 }
202 return candidate;
203}
204
205export function safeDecodeURIComponent(value: string): string {
206 try {
207 return decodeURIComponent(value);
208 } catch {
209 return value;
210 }
211}
212
213export function normalizeMarkdownLinkDestination(value: string): string {
214 const trimmed = value.trim();
215 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
216}
217
218export function splitMarkdownLinkSearchAndHash(value: string): {
219 readonly path: string;
220 readonly hash: string;
221} {
222 const hashIndex = value.indexOf("#");
223 const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
224 const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
225 const queryIndex = pathWithSearch.indexOf("?");
226 return {
227 path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch,
228 hash,
229 };
230}
231
232/** Keeps filename and destination-path labels compact without discarding prose. */
233export function isMarkdownFileLinkLabel(label: string, href: string): boolean {
234 const destination = parseMarkdownFileLink(href);
235 if (!destination) return false;
236 const normalize = (path: string) =>
237 path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
238 const labelPosition = splitFilePathPosition(label.trim());
239 if (
240 (labelPosition.line !== undefined && labelPosition.line !== destination.line) ||
241 (labelPosition.column !== undefined && labelPosition.column !== destination.column)
242 ) {
243 return false;
244 }
245 let labelPath = normalize(labelPosition.path);
246 let destinationPath = normalize(destination.path);
247 if (labelPath.length === 0) return true;
248 if (isWindowsAbsolutePath(destination.path)) {
249 labelPath = labelPath.toLowerCase();
250 destinationPath = destinationPath.toLowerCase();
251 }
252 return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`);
253}
254
255export function isRelativeFilePath(path: string): boolean {
256 return (
257 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
258 (!path.startsWith("/") && !isWindowsAbsolutePath(path))
259 );
260}
261
262function looksLikePosixFilesystemPath(path: string): boolean {
263 if (!path.startsWith("/")) return false;
264 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
265 if (POSITION_SUFFIX_PATTERN.test(path)) return true;
266 const basename = path.slice(path.lastIndexOf("/") + 1);
267 return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename);
268}
269
270/**
271 * Decides whether a decoded link destination is a file path rather than a route
272 * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L`
273 * anchor never turns `/chat/settings` into a file.
274 */
275function looksLikeFilePath(path: string, authoredPath: string): boolean {
276 if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;
277 if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath);
278 if (EXTENSIONLESS_FILE_NAMES.has(path)) return true;
279 return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path);
280}
281
282function hasExternalScheme(path: string): boolean {
283 if (isWindowsAbsolutePath(path)) return false;
284 const match = path.match(EXTERNAL_SCHEME_PATTERN);
285 if (!match) return false;
286 const rest = match[2] ?? "";
287 if (rest.startsWith("//")) return true;
288 return !POSITION_ONLY_PATTERN.test(rest);
289}
290
291export function parseMarkdownFileLink(href: string): FilePathPosition | null {
292 const normalized = normalizeMarkdownLinkDestination(href);
293 if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) {
294 return null;
295 }
296
297 const source =
298 (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ??
299 splitMarkdownLinkSearchAndHash(normalized);
300 // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded.
301 const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim()));
302 const hash = safeDecodeURIComponent(source.hash.trim());
303 if (path.length === 0 || hasExternalScheme(path)) return null;
304
305 const position = splitFilePathPosition(path, hash);
306 return looksLikeFilePath(position.path, path) ? position : null;
307}
308
309const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/;
310
311const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g;
312
313export function extractInlineCodeSpans(text: string): string[] {
314 const spans: string[] = [];
315 const segments = text.split(FENCED_CODE_SEGMENT_PATTERN);
316 for (let index = 0; index < segments.length; index += 2) {
317 for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) {
318 const span = match[1]?.trim();
319 if (span) spans.push(span);
320 }
321 }
322 return spans;
323}
324
325/** Reuses delimiter searches when successive candidates fall in the same run. */
326function markdownRunEndFinder(markdown: string, delimiter: RegExp) {
327 let runStart = -1;
328 let runEnd = -1;
329 return (start: number) => {
330 if (start >= runStart && start <= runEnd) return runEnd;
331 delimiter.lastIndex = start;
332 runStart = start;
333 runEnd = delimiter.exec(markdown)?.index ?? markdown.length;
334 return runEnd;
335 };
336}
337
338function markdownLinkTailScanner(markdown: string) {
339 const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
340 const titleEnd = markdownRunEndFinder(markdown, /["']/g);
341 const titleWhitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
342 return (start: number) => {
343 const end = whitespaceEnd(start);
344 if (markdown[end] === ")") return end + 1;
345 if (end === start || (markdown[end] !== '"' && markdown[end] !== "'")) return null;
346 const quoteEnd = titleEnd(end + 1);
347 if (quoteEnd === markdown.length) return null;
348 const close = titleWhitespaceEnd(quoteEnd + 1);
349 return markdown[close] === ")" ? close + 1 : null;
350 };
351}
352
353function markdownLinkDestinationScanner(markdown: string) {
354 const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
355 const angleEnd = markdownRunEndFinder(markdown, /[>\n]/g);
356 const bareEnd = markdownRunEndFinder(markdown, /[\s)]/g);
357 // Separate caches keep each branch's searches moving forward even when an
358 // angle destination fails and falls back to an ordinary destination.
359 const angleTail = markdownLinkTailScanner(markdown);
360 const bareTail = markdownLinkTailScanner(markdown);
361 return (start: number) => {
362 if (markdown[start] !== "(") return null;
363 const hrefStart = whitespaceEnd(start + 1);
364 if (markdown[hrefStart] === "<") {
365 const hrefEnd = angleEnd(hrefStart + 1);
366 if (hrefEnd > hrefStart + 1 && markdown[hrefEnd] === ">") {
367 const end = angleTail(hrefEnd + 1);
368 if (end !== null) return { hrefStart: hrefStart + 1, hrefEnd, end };
369 }
370 }
371 const hrefEnd = bareEnd(hrefStart);
372 if (hrefEnd === hrefStart) return null;
373 const end = bareTail(hrefEnd);
374 return end === null ? null : { hrefStart, hrefEnd, end };
375 };
376}
377
378export function extractMarkdownLinkHrefs(markdown: string): string[] {
379 const hrefs: string[] = [];
380 const scanDestination = markdownLinkDestinationScanner(markdown);
381 let cursor = 0;
382 while (cursor < markdown.length) {
383 const labelStart = markdown.indexOf("[", cursor);
384 if (labelStart === -1) break;
385 const labelEnd = markdown.indexOf("]", labelStart + 1);
386 if (labelEnd === -1) break;
387
388 // Nested opening brackets share this closing bracket. On failure, skip the
389 // whole label instead of rescanning its suffix from each opening bracket.
390 cursor = labelEnd + 1;
391 const destination = scanDestination(cursor);
392 if (!destination) continue;
393 cursor = destination.end;
394 const href = markdown.slice(destination.hrefStart, destination.hrefEnd).trim();
395 if (href) hrefs.push(href);
396 }
397 return hrefs;
398}
399
400/**
401 * `baseDir` anchors relative links; it defaults to the workspace root and is the
402 * file's own directory when rendering a markdown file. `cwd` stays the workspace
403 * root so the result still knows whether the target is inside it.
404 */
405export function resolveMarkdownFileLinkTarget(
406 href: string | undefined,
407 cwd?: string,
408 baseDir: string | undefined = cwd,
409): string | null {
410 if (!href) return null;
411 const target = parseMarkdownFileLink(href);
412 if (!target) return null;
413
414 const pathWithPosition = formatFilePathPosition(target);
415 if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition;
416 if (!baseDir) return null;
417 return resolvePathLinkTarget(pathWithPosition, baseDir);
418}
419
420export function isWindowsDrivePathHref(href: string): boolean {
421 return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href));
422}