import { fileBasename, isAbsolutePath, isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive, } from "./path.ts"; const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; /** Keeps paths percent-encoded to avoid double decoding; non-localhost authorities become UNC shares. */ export function parseFileUrlHref( href: string, ): { readonly path: string; readonly hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") return null; const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; const path = uncHostname ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` : parsed.pathname; if (path.length === 0) return null; return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash }; } catch { return null; } } export interface FilePathPosition { readonly path: string; readonly line?: number; readonly column?: number; } export function splitFilePathPosition(path: string, hash = ""): FilePathPosition { const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN); const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN); if (!match?.[1]) return { path }; const line = Number.parseInt(match[1], 10); const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); return { path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path, ...(line > 0 ? { line } : {}), ...(column !== undefined && column > 0 ? { column } : {}), }; } export function formatFilePathPosition(position: FilePathPosition): string { if (!position.line) return position.path; return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; } function isWindowsPathStyle(value: string): boolean { return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); } export 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[\\/][^\\/]+)/i); if (windowsUser?.[1]) { return windowsUser[1]; } return undefined; } export function resolvePathLinkTarget(rawPath: string, cwd: string): string { const position = splitFilePathPosition(rawPath); const { path } = position; let resolvedPath = path; if (path.startsWith("~/") || (isWindowsPathStyle(cwd) && 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 }); } function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); const segments = normalized.split("/").filter((segment) => segment.length > 0); return segments.slice(0, -1); } export function buildFileLinkParentSuffixByPath( filePaths: ReadonlyArray, ): Map { const groups = new Map>(); for (const filePath of filePaths) { const normalizedPath = filePath.replaceAll("\\", "/"); const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); const basename = pathSegments[pathSegments.length - 1]; if (!basename) continue; const group = groups.get(basename) ?? new Set(); group.add(normalizedPath); groups.set(basename, group); } const suffixByPath = new Map(); for (const group of groups.values()) { const uniquePaths = [...group]; if (uniquePaths.length < 2) continue; const parentSegmentsByPath = new Map( uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), ); const minUniqueDepthByPath = new Map(); for (const filePath of uniquePaths) { const segments = parentSegmentsByPath.get(filePath) ?? []; let resolvedDepth = segments.length; for (let depth = 1; depth <= segments.length; depth += 1) { const candidate = segments.slice(-depth).join("/"); const collision = uniquePaths.some((otherPath) => { if (otherPath === filePath) return false; const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; return otherSegments.slice(-depth).join("/") === candidate; }); if (!collision) { resolvedDepth = depth; break; } } minUniqueDepthByPath.set(filePath, resolvedDepth); } for (const filePath of uniquePaths) { const segments = parentSegmentsByPath.get(filePath) ?? []; if (segments.length === 0) continue; const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); } } return suffixByPath; } export function fileLinkLabel( file: FilePathPosition, parentSuffixByPath: ReadonlyMap, ): string { const suffix = parentSuffixByPath.get(file.path.replaceAll("\\", "/")); return [ fileBasename(file.path), suffix, file.line ? `L${file.line}${file.column ? `:C${file.column}` : ""}` : undefined, ] .filter(Boolean) .join(" ยท "); }