path-utils.ts

 1import { accessSync, constants } from "node:fs";
 2import * as os from "node:os";
 3import { isAbsolute, resolve as resolvePath } from "node:path";
 4
 5const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
 6
 7function normalizeAtPrefix(filePath: string): string {
 8	return filePath.startsWith("@") ? filePath.slice(1) : filePath;
 9}
10
11function fileExists(path: string): boolean {
12	try {
13		accessSync(path, constants.F_OK);
14		return true;
15	} catch {
16		return false;
17	}
18}
19
20function tryMacOSScreenshotPath(filePath: string): string {
21	return filePath.replace(/ (AM|PM)\./g, "\u202F$1.");
22}
23
24function tryNFDVariant(filePath: string): string {
25	return filePath.normalize("NFD");
26}
27
28function tryCurlyQuoteVariant(filePath: string): string {
29	return filePath.replace(/'/g, "\u2019");
30}
31
32export function expandPath(filePath: string): string {
33	let result = filePath.replace(UNICODE_SPACES, " ");
34	result = normalizeAtPrefix(result);
35
36	if (result === "~") {
37		return os.homedir();
38	}
39	if (result.startsWith("~/")) {
40		return resolvePath(os.homedir(), result.slice(2));
41	}
42
43	return result;
44}
45
46export function resolveToCwd(filePath: string, cwd: string): string {
47	const expanded = expandPath(filePath);
48	if (isAbsolute(expanded)) {
49		return expanded;
50	}
51	return resolvePath(cwd, expanded);
52}
53
54export function resolveReadPath(filePath: string, cwd: string): string {
55	const resolved = resolveToCwd(filePath, cwd);
56
57	if (fileExists(resolved)) {
58		return resolved;
59	}
60
61	const variants = [tryNFDVariant, tryMacOSScreenshotPath, tryCurlyQuoteVariant];
62	for (const variant of variants) {
63		const candidate = variant(resolved);
64		if (candidate !== resolved && fileExists(candidate)) {
65			return candidate;
66		}
67	}
68
69	return resolved;
70}