import { formatFilePathPosition, splitFilePathPosition, } from "@t3tools/client-runtime/markdown-links"; import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; export interface TerminalLinkMatch { kind: TerminalLinkKind; text: string; start: number; end: number; } 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"'`<>]+/giu; 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" && isTerminalUrl(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("\\\\"); } export 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 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 isTerminalUrl(value: string): boolean { return /^https?:\/\//iu.test(value); } 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, }; } 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 position = splitFilePathPosition(rawPath); const { path } = position; 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); } return formatFilePathPosition({ ...position, path: resolvedPath }); }