import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; export interface TerminalLinkMatch { kind: TerminalLinkKind; text: string; start: number; end: number; } export interface TerminalLinkBufferPosition { x: number; y: number; } export interface TerminalLinkBufferRange { start: TerminalLinkBufferPosition; end: TerminalLinkBufferPosition; } export interface TerminalBufferLineLike { readonly isWrapped?: boolean; translateToString(trimRight?: boolean): string; } export interface WrappedTerminalLinkLineSegment { bufferLineNumber: number; text: string; startIndex: number; endIndex: number; } export interface WrappedTerminalLinkLine { text: string; segments: ReadonlyArray; } const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; const FILE_PATH_PATTERN = /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; function trimClosingDelimiters(value: string): string { let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); if (output.length === 0) return output; const trimUnbalanced = (open: string, close: string) => { while (output.endsWith(close)) { const opens = output.split(open).length - 1; const closes = output.split(close).length - 1; if (opens >= closes) return; output = output.slice(0, -1); } }; trimUnbalanced("(", ")"); trimUnbalanced("[", "]"); trimUnbalanced("{", "}"); return output; } function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean { return a.start < b.end && b.start < a.end; } function collectMatches( line: string, kind: TerminalLinkKind, pattern: RegExp, existing: TerminalLinkMatch[], ): TerminalLinkMatch[] { const matches: TerminalLinkMatch[] = []; pattern.lastIndex = 0; for (const rawMatch of line.matchAll(pattern)) { const raw = rawMatch[0]; const start = rawMatch.index ?? -1; if (start < 0 || raw.length === 0) continue; const trimmed = trimClosingDelimiters(raw); if (trimmed.length === 0) continue; if (kind === "path" && /^https?:\/\//i.test(trimmed)) continue; const candidate: TerminalLinkMatch = { kind, text: trimmed, start, end: start + trimmed.length, }; const collides = [...existing, ...matches].some((other) => overlaps(candidate, other)); if (collides) continue; matches.push(candidate); } return matches; } function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } function isWindowsPathStyle(value: string): boolean { return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); } function joinPath(base: string, next: string, separator: "/" | "\\"): string { const cleanBase = base.replace(/[\\/]+$/, ""); if (separator === "\\") { return `${cleanBase}\\${next.replaceAll("/", "\\")}`; } return `${cleanBase}/${next.replace(/^\/+/, "")}`; } function inferHomeFromCwd(cwd: string): string | undefined { const posixUser = cwd.match(/^\/Users\/([^/]+)/); if (posixUser?.[1]) { return `/Users/${posixUser[1]}`; } const posixHome = cwd.match(/^\/home\/([^/]+)/); if (posixHome?.[1]) { return `/home/${posixHome[1]}`; } const windowsUser = cwd.match(/^([A-Za-z]:\\Users\\[^\\]+)/); if (windowsUser?.[1]) { return windowsUser[1]; } return undefined; } export function splitPathAndPosition(value: string): { path: string; line: string | undefined; column: string | undefined; } { let path = value; let column: string | undefined; let line: string | undefined; const columnMatch = path.match(/:(\d+)$/); if (!columnMatch?.[1]) { return { path, line: undefined, column: undefined }; } column = columnMatch[1]; path = path.slice(0, -columnMatch[0].length); const lineMatch = path.match(/:(\d+)$/); if (lineMatch?.[1]) { line = lineMatch[1]; path = path.slice(0, -lineMatch[0].length); } else { line = column; column = undefined; } return { path, line, column }; } export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const urlMatches = collectMatches(line, "url", URL_PATTERN, []); const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start); } export function collectWrappedTerminalLinkLine( bufferLineNumber: number, getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined, ): WrappedTerminalLinkLine | null { const anchorLine = getLine(bufferLineNumber - 1); if (!anchorLine) return null; let startBufferLineNumber = bufferLineNumber; let startLine = anchorLine; while (startBufferLineNumber > 1 && startLine.isWrapped) { const previousLine = getLine(startBufferLineNumber - 2); if (!previousLine) return null; startBufferLineNumber -= 1; startLine = previousLine; } const segments: WrappedTerminalLinkLineSegment[] = []; let nextStartIndex = 0; let currentBufferLineNumber = startBufferLineNumber; while (true) { const currentLine = getLine(currentBufferLineNumber - 1); if (!currentLine) break; const nextLine = getLine(currentBufferLineNumber); const hasWrappedContinuation = nextLine?.isWrapped === true; const text = currentLine.translateToString(!hasWrappedContinuation); segments.push({ bufferLineNumber: currentBufferLineNumber, text, startIndex: nextStartIndex, endIndex: nextStartIndex + text.length, }); nextStartIndex += text.length; if (!hasWrappedContinuation) break; currentBufferLineNumber += 1; } return { text: segments.map((segment) => segment.text).join(""), segments, }; } function resolveCharacterPosition( segments: ReadonlyArray, characterIndex: number, ): TerminalLinkBufferPosition { for (const segment of segments) { if (characterIndex < segment.endIndex) { return { x: characterIndex - segment.startIndex + 1, y: segment.bufferLineNumber, }; } } const lastSegment = segments[segments.length - 1]; return { x: Math.max(lastSegment?.text.length ?? 0, 1), y: lastSegment?.bufferLineNumber ?? 1, }; } export function resolveWrappedTerminalLinkRange( wrappedLine: WrappedTerminalLinkLine, match: Pick, ): TerminalLinkBufferRange { return { start: resolveCharacterPosition(wrappedLine.segments, match.start), end: resolveCharacterPosition(wrappedLine.segments, match.end - 1), }; } export function wrappedTerminalLinkRangeIntersectsBufferLine( range: TerminalLinkBufferRange, bufferLineNumber: number, ): boolean { return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y; } export function isTerminalLinkActivation( event: Pick, platform = typeof navigator === "undefined" ? "" : navigator.platform, ): boolean { if (platform.length === 0) return false; return isMacPlatform(platform) ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; } export function resolvePathLinkTarget(rawPath: string, cwd: string): string { const { path, line, column } = splitPathAndPosition(rawPath); let resolvedPath = path; if (path.startsWith("~/")) { const home = inferHomeFromCwd(cwd); if (home) { const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/"; resolvedPath = joinPath(home, path.slice(2), separator); } } else if (!isAbsolutePath(path)) { const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; resolvedPath = joinPath(cwd, path, separator); } if (!line) return resolvedPath; return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; }