1export function isWindowsDrivePath(value: string): boolean {
2 return /^[a-zA-Z]:([/\\]|$)/.test(value);
3}
4
5export function isUncPath(value: string): boolean {
6 return value.startsWith("\\\\");
7}
8
9export function isWindowsAbsolutePath(value: string): boolean {
10 return isUncPath(value) || isWindowsDrivePath(value);
11}
12
13export function isExplicitRelativePath(value: string): boolean {
14 return (
15 value === "." ||
16 value === ".." ||
17 value.startsWith("./") ||
18 value.startsWith("../") ||
19 value.startsWith(".\\") ||
20 value.startsWith("..\\")
21 );
22}
23
24function isRootPath(value: string): boolean {
25 // The drive separator is required: a bare `C:` is not the drive root (it
26 // means "current directory on C:"), and treating it as already-canonical
27 // would leave it as `C:` while `C:\` and `C:/` normalize to the drive root,
28 // so the same location would fail project identity/dedup comparisons.
29 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]$/.test(value);
30}
31
32function trimTrailingPathSeparators(value: string): string {
33 if (value.length === 0 || isRootPath(value)) {
34 return value;
35 }
36 const trimmed = value.startsWith("/")
37 ? value.replace(/\/+$/g, "")
38 : value.replace(/[\\/]+$/g, "");
39 if (trimmed.length === 0) {
40 return value;
41 }
42 return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
43}
44
45export function normalizeProjectPathForDispatch(value: string): string {
46 return trimTrailingPathSeparators(value.trim());
47}
48
49export function normalizeProjectPathForComparison(value: string): string {
50 const normalized = normalizeProjectPathForDispatch(value);
51 if (isWindowsDrivePath(normalized) || isUncPath(normalized)) {
52 return normalized.replaceAll("/", "\\").toLowerCase();
53 }
54 return normalized;
55}
56
57// Windows refuses these as file names, with or without an extension.
58const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
59
60/**
61 * Folder name for a project started from just a name ("Pinball Stats" becomes
62 * "pinball-stats"). The server uses it for `projects.createNew`, and clients
63 * use it to show the path before the server makes it.
64 */
65export function newProjectFolderName(name: string): string {
66 const slug = name
67 .normalize("NFKD")
68 .replace(/[̀-ͯ]/g, "")
69 .toLowerCase()
70 .replace(/[^a-z0-9]+/g, "-")
71 .replace(/^-+/, "")
72 .slice(0, 64)
73 .replace(/-+$/, "");
74 if (slug.length === 0) return "project";
75 return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug;
76}
77
78const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/;
79
80/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */
81export function stripSlashPrefixedWindowsDrive(path: string): string {
82 return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path;
83}
84
85export function fileBasename(path: string): string {
86 // A trailing separator is a valid way to write a directory. Trim it before
87 // taking the final segment so the label is never empty.
88 const trimmed = path.replace(/[/\\]+$/, "");
89 if (trimmed.length === 0) return path;
90 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
91 return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;
92}
93
94export function workspaceRelativeFilePath(
95 path: string,
96 workspaceRoot: string | null | undefined,
97): string | null {
98 if (!workspaceRoot) return null;
99 const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/"));
100 const normalizedRoot = stripSlashPrefixedWindowsDrive(
101 workspaceRoot.replaceAll("\\", "/"),
102 ).replace(/\/+$/, "");
103 const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot));
104 const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;
105 const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
106 if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return ".";
107 if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
108 return normalizedPath.slice(normalizedRoot.length + 1);
109}
110
111export function isAbsolutePath(value: string): boolean {
112 return value.startsWith("/") || isWindowsAbsolutePath(value);
113}
114
115/** Resolve a literal filesystem path without interpreting file-link positions.
116 * Home-relative paths stay intact for the server to expand. */
117export function resolveWorkspaceFilePath(path: string, workspaceRoot: string): string {
118 if (isAbsolutePath(path) || path.startsWith("~/") || path.startsWith("~\\")) return path;
119 const base = workspaceRoot.replace(/[/\\]+$/, "");
120 return isWindowsAbsolutePath(workspaceRoot)
121 ? `${base}\\${path.replaceAll("/", "\\")}`
122 : `${base}/${path}`;
123}