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