| 1 | export function isWindowsDrivePath(value: string): boolean { |
| 2 | return /^[a-zA-Z]:([/\\]|$)/.test(value); |
| 3 | } |
| 4 | |
| 5 | export function isUncPath(value: string): boolean { |
| 6 | return value.startsWith("\\\\"); |
| 7 | } |
| 8 | |
| 9 | export function isWindowsAbsolutePath(value: string): boolean { |
| 10 | return isUncPath(value) || isWindowsDrivePath(value); |
| 11 | } |
| 12 | |
| 13 | export 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 | |
| 24 | function 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 | |
| 32 | function 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 | |
| 45 | export function normalizeProjectPathForDispatch(value: string): string { |
| 46 | return trimTrailingPathSeparators(value.trim()); |
| 47 | } |
| 48 | |
| 49 | export 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. |
| 58 | const 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 | */ |
| 65 | export 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 | |
| 78 | const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; |
| 79 | |
| 80 | /** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */ |
| 81 | export function stripSlashPrefixedWindowsDrive(path: string): string { |
| 82 | return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path; |
| 83 | } |
| 84 | |
| 85 | export 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 | |
| 94 | export 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 | |
| 111 | export 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. */ |
| 117 | export 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 | } |