Skip to content
Source
Sign inSign up
Open History to see the last commit

fileLinks.ts

179 lines6.0 KB

1import {
2 fileBasename,
3 isAbsolutePath,
4 isWindowsAbsolutePath,
5 stripSlashPrefixedWindowsDrive,
6} from "./path.ts";
7​
8const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+)|-(\d+))?$/;
9​
10const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+)|-L?(\d+))?$/i;
11​
12/** Keeps paths percent-encoded to avoid double decoding; non-localhost authorities become UNC shares. */
13export function parseFileUrlHref(
14 href: string,
15): { readonly path: string; readonly hash: string } | null {
16 try {
17 const parsed = new URL(href);
18 if (parsed.protocol.toLowerCase() !== "file:") return null;
19​
20 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
21 const path = uncHostname
22 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
23 : parsed.pathname;
24 if (path.length === 0) return null;
25 return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash };
26 } catch {
27 return null;
28 }
30​
31export interface FilePathPosition {
32 readonly path: string;
33 readonly line?: number;
34 readonly column?: number;
35 /** The last line of a range such as `:42-64`. Editors open at `line`. */
36 readonly endLine?: number;
38​
39export function splitFilePathPosition(path: string, hash = ""): FilePathPosition {
40 const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN);
41 const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN);
42 if (!match?.[1]) return { path };
43​
44 const line = Number.parseInt(match[1], 10);
45 const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10);
46 const endLine = match[3] === undefined ? undefined : Number.parseInt(match[3], 10);
47 return {
48 path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path,
49 ...(line > 0 ? { line } : {}),
50 ...(column !== undefined && column > 0 ? { column } : {}),
51 ...(line > 0 && endLine !== undefined && endLine > line ? { endLine } : {}),
52 };
54​
55export function formatFilePathPosition(position: FilePathPosition): string {
56 if (!position.line) return position.path;
57 return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;
59​
60function isWindowsPathStyle(value: string): boolean {
61 return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value);
63​
64export 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(/^\/+/, "")}`;
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[\\/][^\\/]+)/i);
84 if (windowsUser?.[1]) {
85 return windowsUser[1];
86 }
87​
88 return undefined;
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("~/") || (isWindowsPathStyle(cwd) && 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 });
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);
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 suffixCounts = new Map<string, number>();
139 for (const segments of parentSegmentsByPath.values()) {
140 for (let depth = 1; depth <= segments.length; depth += 1) {
141 const suffix = segments.slice(-depth).join("/");
142 suffixCounts.set(suffix, (suffixCounts.get(suffix) ?? 0) + 1);
143 }
144 }
145​
146 for (const filePath of uniquePaths) {
147 const segments = parentSegmentsByPath.get(filePath) ?? [];
148 let resolvedDepth = segments.length;
149 for (let depth = 1; depth <= segments.length; depth += 1) {
150 const candidate = segments.slice(-depth).join("/");
151 if (suffixCounts.get(candidate) === 1) {
152 resolvedDepth = depth;
153 break;
154 }
155 }
156 if (segments.length === 0) continue;
157 const suffixDepth = Math.min(segments.length, Math.max(resolvedDepth, 2));
158 suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/"));
159 }
160 }
161​
162 return suffixByPath;
164​
165export function fileLinkLabel(
166 file: FilePathPosition,
167 parentSuffixByPath: ReadonlyMap<string, string>,
168): string {
169 const suffix = parentSuffixByPath.get(file.path.replaceAll("\\", "/"));
170 return [
171 fileBasename(file.path),
172 suffix,
173 file.line
174 ? `L${file.line}${file.endLine ? `–${file.endLine}` : file.column ? `:C${file.column}` : ""}`
175 : undefined,
176 ]
177 .filter(Boolean)
178 .join(" · ");