| 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 | |
| 9 | export type LinkKind = "url" | "path" | "osc8"; |
| 10 | |
| 11 | export 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 | |
| 24 | export 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 | |
| 37 | const URL_RE = /\b(?:https?|file|ftp):\/\/[^\s<>"'`]+/gi; |
| 38 | |
| 39 | const 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. */ |
| 43 | const TRAILING = /[.,:;!?)\]}'"]+$/; |
| 44 | |
| 45 | const DEFAULT_LIMIT = 64; |
| 46 | |
| 47 | export 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 | } |