1// Link detection over a row's text. Pure: no DOM, no wasm, no workspace
2// knowledge. The renderer turns the returned ranges into spans; the embedder
3// decides what a path means (see `validate`) and what activating one does.
4//
5// Regex detection produces URLs and file paths with the `:line:col` suffix
6// emitted by compilers, linters, and test runners. The renderer adds canonical
7// OSC 8 identity from cells without asking this text-only detector to infer it.
8
9export type LinkKind = "url" | "path" | "osc8";
10
11export interface DetectedLink {
12 kind: LinkKind;
13 /** Offsets into the row text (JS string indices, not columns). */
14 start: number;
15 end: number;
16 /** Visible link text; regex matches have trailing punctuation removed. */
17 text: string;
18 /** Path without its position suffix, visible URL, or exact OSC 8 URI. */
19 target: string;
20 line: number | null;
21 column: number | null;
22}
23
24export interface DetectLinksOptions {
25 /**
26 * Decide whether a path-shaped match is really a link — the workbench passes
27 * a workspace probe. ***Required for path detection: without it only URLs are
28 * returned.*** Path shape alone is not evidence (`e.g.` and `1.2.3` match the
29 * same grammar as `a/b.ts`), and a terminal that underlines prose is worse
30 * than one that underlines nothing.
31 */
32 validate?: (target: string) => boolean;
33 /** Cap on matches per row — a pathological line should not stall a paint. */
34 limit?: number;
35}
36
37const URL_RE = /\b(?:https?|file|ftp):\/\/[^\s<>"'`]+/gi;
38
39const PATH_RE =
40 /(?:~\/|\.{1,2}[\\/]|[A-Za-z]:[\\/]|\\\\|\/)[^\s"'`<>]+|(?:[\w.@+-]+[\\/])+[\w.@+-]+(?::\d+){0,2}|[\w.@+-]+\.[A-Za-z]\w{0,9}(?::\d+){0,2}/g;
41
42/** Characters a URL or path may end with in prose but that are not part of it. */
43const TRAILING = /[.,:;!?)\]}'"]+$/;
44
45const DEFAULT_LIMIT = 64;
46
47export function detectLinks(text: string, options: DetectLinksOptions = {}): DetectedLink[] {
48 const limit = options.limit ?? DEFAULT_LIMIT;
49 const links: DetectedLink[] = [];
50 const taken: Array<[number, number]> = [];
51
52 const overlaps = (start: number, end: number) => taken.some(([s, e]) => start < e && end > s);
53
54 URL_RE.lastIndex = 0;
55 for (let m = URL_RE.exec(text); m !== null; m = URL_RE.exec(text)) {
56 if (links.length >= limit) break;
57 const raw = m[0];
58 const trimmed = raw.replace(TRAILING, "");
59 if (trimmed.length === 0) continue;
60 const start = m.index;
61 const end = start + trimmed.length;
62 taken.push([start, end]);
63 links.push({
64 kind: "url",
65 start,
66 end,
67 text: trimmed,
68 target: trimmed,
69 line: null,
70 column: null,
71 });
72 }
73
74 const validate = options.validate;
75 if (validate === undefined) return links;
76
77 PATH_RE.lastIndex = 0;
78 for (let m = PATH_RE.exec(text); m !== null; m = PATH_RE.exec(text)) {
79 if (links.length >= limit) break;
80 let raw = m[0];
81 // Strip a trailing position-less punctuation tail, then re-parse position:
82 // `src/a.ts:12:3.` -> `src/a.ts:12:3`.
83 raw = raw.replace(TRAILING, "");
84 if (raw.length === 0) continue;
85 const start = m.index;
86 const end = start + raw.length;
87 if (overlaps(start, end)) continue;
88
89 const position = /^(.*?):(\d+)(?::(\d+))?$/.exec(raw);
90 const target = position ? position[1]! : raw;
91 const line = position ? Number(position[2]) : null;
92 const column = position?.[3] !== undefined ? Number(position[3]) : null;
93 if (target.length === 0) continue;
94 if (!validate(target)) continue;
95
96 taken.push([start, end]);
97 links.push({ kind: "path", start, end, text: raw, target, line, column });
98 }
99
100 links.sort((a, b) => a.start - b.start);
101 return links;
102}