1import { isMacPlatform } from "./lib/utils";
2
3export type TerminalLinkKind = "url" | "path";
4
5export interface TerminalLinkMatch {
6 kind: TerminalLinkKind;
7 text: string;
8 start: number;
9 end: number;
10}
11
12export interface TerminalBufferLineLike {
13 readonly isWrapped?: boolean;
14 translateToString(trimRight?: boolean): string;
15}
16
17export interface WrappedTerminalLinkLineSegment {
18 bufferLineNumber: number;
19 text: string;
20 startIndex: number;
21 endIndex: number;
22}
23
24export interface WrappedTerminalLinkLine {
25 text: string;
26 segments: ReadonlyArray<WrappedTerminalLinkLineSegment>;
27}
28
29const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu;
30// The default PowerShell prompt delimits its unquoted path with `PS ` and `>`.
31const POWERSHELL_PROMPT_PATH_PATTERN = /^PS ((?:[A-Za-z]:[\\/]|\\\\|\/)[^>\r\n]*)>/u;
32// Bare paths start only at a segment boundary: hover detection runs this on
33// every frame, so separator-free output must not be rescanned at every suffix.
34// For the same reason `]` needs its `[`: trimming a long run of `]` is quadratic.
35// Parentheses count only before a slash, so `src/foo.ts(3,10)` stays a compiler location.
36const FILE_PATH_PATTERN =
37 /(?:~[\\/]|\.{1,2}[\\/]|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|(?<![\p{L}\p{M}\p{N}._-])[\p{L}\p{M}\p{N}._-]+(?:[\\/](?:[\p{L}\p{M}\p{N}._$+@-]|\[{1,2}[\p{L}\p{M}\p{N}._-]+\]{1,2}|\([\p{L}\p{M}\p{N}._-]+\)(?=[\\/]))+)+(?::\d+){0,2}/gu;
38const QUOTED_FILE_PATH_PATTERN =
39 /(["'`])(?<path>(?:~[\\/]|\.{1,2}[\\/]|\/|[A-Za-z]:[\\/]|\\\\|[\p{L}\p{M}\p{N}._ -]+[\\/])[^\r\n]*?|[\p{L}\p{M}\p{N}._ -]+\.[\p{L}\p{M}\p{N}_-]+(?::\d+){0,2})\1(?<position>(?::\d+){0,2})/gu;
40const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;
41// Paths also drop a trailing colon: compilers end `file:line:col:` with one.
42const TRAILING_PATH_PUNCTUATION_PATTERN = /[.,;:!?]+$/;
43// A question mark is legal in filenames; only exclude query-bearing request targets.
44const REQUEST_ROUTE_PATTERN =
45 /(?:^|[\s"'`])(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE|proxying)\s+["'`]?(?:\/|[A-Za-z0-9_-]+\/)[^\s?]*\?\S*/g;
46// The tail of `redis://host/0` or `file:///a.mjs` is not a path. One-letter schemes stay drives,
47// a diff's `+` or `-` may lead, and the lookbehind keeps runs from being rescanned at every suffix.
48const SCHEME_URL_PATTERN = /(?<![A-Za-z\d+.-])[+-]?[A-Za-z][A-Za-z\d+.-]+:\/\/[^\s"'`<>]+/gu;
49// Nor is the tail of an SCP-style Git remote such as `git@github.com:acme/widgets.git`. Only a
50// path wholly inside the tail is dropped, so `src/contact@support.vue:12:3` keeps its link.
51const SCP_REMOTE_PATTERN = /(?<![\w.@-])[\w.-]+@[\w.-]+:[^\s"'`<>]+/gu;
52
53function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string {
54 let output = value.replace(
55 kind === "path" ? TRAILING_PATH_PUNCTUATION_PATTERN : TRAILING_PUNCTUATION_PATTERN,
56 "",
57 );
58 if (output.length === 0) return output;
59
60 const trimUnbalanced = (open: string, close: string) => {
61 while (output.endsWith(close)) {
62 const opens = output.split(open).length - 1;
63 const closes = output.split(close).length - 1;
64 if (opens >= closes) return;
65 output = output.slice(0, -1);
66 }
67 };
68
69 trimUnbalanced("(", ")");
70 trimUnbalanced("[", "]");
71 trimUnbalanced("{", "}");
72 return output;
73}
74
75function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean {
76 return a.start < b.end && b.start < a.end;
77}
78
79function collectMatches(
80 line: string,
81 kind: TerminalLinkKind,
82 pattern: RegExp,
83 existing: TerminalLinkMatch[],
84): TerminalLinkMatch[] {
85 const matches: TerminalLinkMatch[] = [];
86 pattern.lastIndex = 0;
87
88 for (const rawMatch of line.matchAll(pattern)) {
89 const quotedPath = rawMatch.groups?.path;
90 const raw =
91 quotedPath === undefined ? rawMatch[0] : quotedPath + (rawMatch.groups?.position ?? "");
92 const start = rawMatch.index + (quotedPath === undefined ? 0 : 1);
93 if (start < 0 || raw.length === 0) continue;
94
95 const trimmed = quotedPath === undefined ? trimClosingDelimiters(raw, kind) : raw;
96 if (trimmed.length === 0) continue;
97 if (kind === "path" && isTerminalUrl(trimmed)) continue;
98
99 const candidate: TerminalLinkMatch = {
100 kind,
101 text: trimmed,
102 start,
103 // A position outside quotes spans the closing quote in the terminal buffer.
104 end: start + trimmed.length + (rawMatch.groups?.position ? 1 : 0),
105 };
106
107 const collides = [...existing, ...matches].some((other) => overlaps(candidate, other));
108 if (collides) continue;
109
110 matches.push(candidate);
111 }
112
113 return matches;
114}
115
116export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
117 const promptPath = POWERSHELL_PROMPT_PATH_PATTERN.exec(line)?.[1];
118 const promptMatches: TerminalLinkMatch[] = promptPath
119 ? [{ kind: "path", text: promptPath, start: 3, end: 3 + promptPath.length }]
120 : [];
121 const nonPathRanges = Array.from(
122 [...line.matchAll(REQUEST_ROUTE_PATTERN), ...line.matchAll(SCHEME_URL_PATTERN)],
123 (match) => ({ start: match.index, end: match.index + match[0].length }),
124 );
125 const remoteTails = Array.from(line.matchAll(SCP_REMOTE_PATTERN), (match) => ({
126 start: match.index + match[0].indexOf(":") + 1,
127 end: match.index + match[0].length,
128 }));
129 const urlMatches = collectMatches(line, "url", URL_PATTERN, promptMatches);
130 const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, [
131 ...promptMatches,
132 ...urlMatches,
133 ]);
134 const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, [
135 ...promptMatches,
136 ...urlMatches,
137 ...quotedMatches,
138 ]);
139 return [...promptMatches, ...urlMatches, ...quotedMatches, ...pathMatches]
140 .filter(
141 (match) =>
142 match.kind === "url" ||
143 (!nonPathRanges.some((range) => overlaps(match, range)) &&
144 !remoteTails.some((tail) => match.start >= tail.start && match.end <= tail.end)),
145 )
146 .toSorted((a, b) => a.start - b.start);
147}
148
149export function isTerminalUrl(value: string): boolean {
150 return /^https?:\/\//iu.test(value);
151}
152
153export function collectWrappedTerminalLinkLine(
154 bufferLineNumber: number,
155 getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined,
156): WrappedTerminalLinkLine | null {
157 const anchorLine = getLine(bufferLineNumber - 1);
158 if (!anchorLine) return null;
159
160 let startBufferLineNumber = bufferLineNumber;
161 let startLine = anchorLine;
162
163 while (startBufferLineNumber > 1 && startLine.isWrapped) {
164 const previousLine = getLine(startBufferLineNumber - 2);
165 if (!previousLine) return null;
166 startBufferLineNumber -= 1;
167 startLine = previousLine;
168 }
169
170 const segments: WrappedTerminalLinkLineSegment[] = [];
171 let nextStartIndex = 0;
172 let currentBufferLineNumber = startBufferLineNumber;
173
174 while (true) {
175 const currentLine = getLine(currentBufferLineNumber - 1);
176 if (!currentLine) break;
177
178 const nextLine = getLine(currentBufferLineNumber);
179 const hasWrappedContinuation = nextLine?.isWrapped === true;
180 const text = currentLine.translateToString(!hasWrappedContinuation);
181
182 segments.push({
183 bufferLineNumber: currentBufferLineNumber,
184 text,
185 startIndex: nextStartIndex,
186 endIndex: nextStartIndex + text.length,
187 });
188 nextStartIndex += text.length;
189
190 if (!hasWrappedContinuation) break;
191 currentBufferLineNumber += 1;
192 }
193
194 return {
195 text: segments.map((segment) => segment.text).join(""),
196 segments,
197 };
198}
199
200export function isTerminalLinkActivation(
201 event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
202 platform = typeof navigator === "undefined" ? "" : navigator.platform,
203): boolean {
204 if (platform.length === 0) return false;
205 return isMacPlatform(platform)
206 ? event.metaKey && !event.ctrlKey
207 : event.ctrlKey && !event.metaKey;
208}