1import {
2 fileBasename,
3 isAbsolutePath,
4 isWindowsAbsolutePath,
5 stripSlashPrefixedWindowsDrive,
6} from "./path.ts";
7
8const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/;
9
10const 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 */
17export 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
35export interface FilePathPosition {
36 readonly path: string;
37 readonly line?: number;
38 readonly column?: number;
39}
40
41export 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
55export function formatFilePathPosition(position: FilePathPosition): string {
56 if (!position.line) return position.path;
57 return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;
58}
59
60function isWindowsPathStyle(value: string): boolean {
61 return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value);
62}
63
64function 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
72function 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
91export 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
110function 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
116export 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. */
171export 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}