| 1 | import { |
| 2 | fileBasename, |
| 3 | isAbsolutePath, |
| 4 | isWindowsAbsolutePath, |
| 5 | stripSlashPrefixedWindowsDrive, |
| 6 | } from "./path.ts"; |
| 7 | |
| 8 | const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; |
| 9 | |
| 10 | const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; |
| 11 | |
| 12 | /** |
| 13 | * Turns a `file:` URL into a host path, still percent-encoded so callers that |
| 14 | * decode every destination in one place do not decode file URLs twice. A |
| 15 | * non-localhost authority becomes a UNC share. |
| 16 | */ |
| 17 | export function parseFileUrlHref( |
| 18 | href: string, |
| 19 | ): { readonly path: string; readonly hash: string } | null { |
| 20 | try { |
| 21 | const parsed = new URL(href); |
| 22 | if (parsed.protocol.toLowerCase() !== "file:") return null; |
| 23 | |
| 24 | const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; |
| 25 | const path = uncHostname |
| 26 | ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` |
| 27 | : parsed.pathname; |
| 28 | if (path.length === 0) return null; |
| 29 | return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash }; |
| 30 | } catch { |
| 31 | return null; |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | export interface FilePathPosition { |
| 36 | readonly path: string; |
| 37 | readonly line?: number; |
| 38 | readonly column?: number; |
| 39 | } |
| 40 | |
| 41 | export function splitFilePathPosition(path: string, hash = ""): FilePathPosition { |
| 42 | const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN); |
| 43 | const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN); |
| 44 | if (!match?.[1]) return { path }; |
| 45 | |
| 46 | const line = Number.parseInt(match[1], 10); |
| 47 | const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); |
| 48 | return { |
| 49 | path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path, |
| 50 | ...(line > 0 ? { line } : {}), |
| 51 | ...(column !== undefined && column > 0 ? { column } : {}), |
| 52 | }; |
| 53 | } |
| 54 | |
| 55 | export function formatFilePathPosition(position: FilePathPosition): string { |
| 56 | if (!position.line) return position.path; |
| 57 | return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; |
| 58 | } |
| 59 | |
| 60 | function isWindowsPathStyle(value: string): boolean { |
| 61 | return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); |
| 62 | } |
| 63 | |
| 64 | function joinPath(base: string, next: string, separator: "/" | "\\"): string { |
| 65 | const cleanBase = base.replace(/[\\/]+$/, ""); |
| 66 | if (separator === "\\") { |
| 67 | return `${cleanBase}\\${next.replaceAll("/", "\\")}`; |
| 68 | } |
| 69 | return `${cleanBase}/${next.replace(/^\/+/, "")}`; |
| 70 | } |
| 71 | |
| 72 | function inferHomeFromCwd(cwd: string): string | undefined { |
| 73 | const posixUser = cwd.match(/^\/Users\/([^/]+)/); |
| 74 | if (posixUser?.[1]) { |
| 75 | return `/Users/${posixUser[1]}`; |
| 76 | } |
| 77 | |
| 78 | const posixHome = cwd.match(/^\/home\/([^/]+)/); |
| 79 | if (posixHome?.[1]) { |
| 80 | return `/home/${posixHome[1]}`; |
| 81 | } |
| 82 | |
| 83 | const windowsUser = cwd.match(/^([A-Za-z]:[\\/]Users[\\/][^\\/]+)/); |
| 84 | if (windowsUser?.[1]) { |
| 85 | return windowsUser[1]; |
| 86 | } |
| 87 | |
| 88 | return undefined; |
| 89 | } |
| 90 | |
| 91 | export function resolvePathLinkTarget(rawPath: string, cwd: string): string { |
| 92 | const position = splitFilePathPosition(rawPath); |
| 93 | const { path } = position; |
| 94 | |
| 95 | let resolvedPath = path; |
| 96 | if (path.startsWith("~/")) { |
| 97 | const home = inferHomeFromCwd(cwd); |
| 98 | if (home) { |
| 99 | const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/"; |
| 100 | resolvedPath = joinPath(home, path.slice(2), separator); |
| 101 | } |
| 102 | } else if (!isAbsolutePath(path)) { |
| 103 | const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; |
| 104 | resolvedPath = joinPath(cwd, path, separator); |
| 105 | } |
| 106 | |
| 107 | return formatFilePathPosition({ ...position, path: resolvedPath }); |
| 108 | } |
| 109 | |
| 110 | function pathParentSegments(path: string): string[] { |
| 111 | const normalized = path.replaceAll("\\", "/"); |
| 112 | const segments = normalized.split("/").filter((segment) => segment.length > 0); |
| 113 | return segments.slice(0, -1); |
| 114 | } |
| 115 | |
| 116 | export function buildFileLinkParentSuffixByPath( |
| 117 | filePaths: ReadonlyArray<string>, |
| 118 | ): Map<string, string> { |
| 119 | const groups = new Map<string, Set<string>>(); |
| 120 | for (const filePath of filePaths) { |
| 121 | const normalizedPath = filePath.replaceAll("\\", "/"); |
| 122 | const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); |
| 123 | const basename = pathSegments[pathSegments.length - 1]; |
| 124 | if (!basename) continue; |
| 125 | const group = groups.get(basename) ?? new Set<string>(); |
| 126 | group.add(normalizedPath); |
| 127 | groups.set(basename, group); |
| 128 | } |
| 129 | |
| 130 | const suffixByPath = new Map<string, string>(); |
| 131 | for (const group of groups.values()) { |
| 132 | const uniquePaths = [...group]; |
| 133 | if (uniquePaths.length < 2) continue; |
| 134 | |
| 135 | const parentSegmentsByPath = new Map( |
| 136 | uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), |
| 137 | ); |
| 138 | const minUniqueDepthByPath = new Map<string, number>(); |
| 139 | |
| 140 | for (const filePath of uniquePaths) { |
| 141 | const segments = parentSegmentsByPath.get(filePath) ?? []; |
| 142 | let resolvedDepth = segments.length; |
| 143 | for (let depth = 1; depth <= segments.length; depth += 1) { |
| 144 | const candidate = segments.slice(-depth).join("/"); |
| 145 | const collision = uniquePaths.some((otherPath) => { |
| 146 | if (otherPath === filePath) return false; |
| 147 | const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; |
| 148 | return otherSegments.slice(-depth).join("/") === candidate; |
| 149 | }); |
| 150 | if (!collision) { |
| 151 | resolvedDepth = depth; |
| 152 | break; |
| 153 | } |
| 154 | } |
| 155 | minUniqueDepthByPath.set(filePath, resolvedDepth); |
| 156 | } |
| 157 | |
| 158 | for (const filePath of uniquePaths) { |
| 159 | const segments = parentSegmentsByPath.get(filePath) ?? []; |
| 160 | if (segments.length === 0) continue; |
| 161 | const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; |
| 162 | const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); |
| 163 | suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | return suffixByPath; |
| 168 | } |
| 169 | |
| 170 | /** Visible text of a file chip, shared by Markdown rendering and thread search. */ |
| 171 | export function fileLinkLabel( |
| 172 | file: FilePathPosition, |
| 173 | parentSuffixByPath: ReadonlyMap<string, string>, |
| 174 | ): string { |
| 175 | const suffix = parentSuffixByPath.get(file.path.replaceAll("\\", "/")); |
| 176 | return [ |
| 177 | fileBasename(file.path), |
| 178 | suffix, |
| 179 | file.line ? `L${file.line}${file.column ? `:C${file.column}` : ""}` : undefined, |
| 180 | ] |
| 181 | .filter(Boolean) |
| 182 | .join(" · "); |
| 183 | } |