/** * Path utilities for pi-lens * * Handles cross-platform path normalization, particularly * Windows case-insensitivity issues when using paths as Map keys. * * Approach (inspired by OpenCode's Filesystem.normalizePath): * - On Windows: try realpathSync.native() for canonical casing * - Falls back to lowercase for files that don't exist yet * - On non-Windows: return path as-is (case-sensitive filesystem) * - Always convert backslashes to forward slashes for Map key consistency */ import { existsSync, realpathSync } from "node:fs"; import { dirname, win32 } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; /** * Detect if a path is a Windows path (has drive letter or UNC prefix). */ function isWindowsPath(filePath: string): boolean { return /^[A-Za-z]:/.test(filePath) || filePath.startsWith("\\\\"); } /** * Normalize a file path for consistent Map key usage. * * On Windows: * - If the file exists: uses realpathSync.native() to get the canonical * filesystem path (actual casing, resolved symlinks) * - If the file doesn't exist: resolves the path and lowercases * (needed for new files where we haven't written yet) * * On non-Windows: returns path as-is (case-sensitive filesystem). * * Always converts backslashes to forward slashes for consistent Map keys. */ export function normalizeFilePath(filePath: string): string { // Convert backslashes to forward slashes first const normalized = filePath.replace(/\\/g, "/"); if (process.platform !== "win32" && !isWindowsPath(normalized)) { return normalized; } // Windows: try realpathSync.native() for canonical casing // This resolves symlinks and returns the actual filesystem casing try { const canonical = realpathSync.native(filePath); return canonical.replace(/\\/g, "/"); } catch { // File doesn't exist yet (new file) — resolve path and lowercase // We need to walk up the directory tree to find the nearest existing // parent, resolve its casing, then append the non-existent parts try { return resolveNonExisting(filePath); } catch { // Last resort: just lowercase the resolved path const resolved = win32.normalize(win32.resolve(filePath)); return resolved.replace(/\\/g, "/").toLowerCase(); } } } /** * Resolve a non-existing path by finding the nearest existing parent, * getting its canonical casing, then appending the non-existent parts lowercased. * * Example: C:\Users\Foo\newdir\file.ts * - C:\Users\Foo exists → realpathSync gives C:\Users\Foo * - newdir\file.ts doesn't exist → lowercased * - Result: C:/Users/Foo/newdir/file.ts */ function resolveNonExisting(filePath: string): string { const resolved = win32.resolve(filePath); let current = resolved; const nonExistentParts: string[] = []; // Walk up until we find an existing directory while (true) { if (existsSync(current)) { // Found existing ancestor — get its canonical casing const canonical = realpathSync.native(current); if (nonExistentParts.length === 0) { return canonical.replace(/\\/g, "/"); } // Append non-existent parts (lowercased for consistency) const tail = nonExistentParts.reverse().join("/").toLowerCase(); const base = canonical.replace(/\\/g, "/"); return base.endsWith("/") ? base + tail : `${base}/${tail}`; } const parent = dirname(current); if (parent === current) { // Reached filesystem root without finding existing dir // Fall back to full lowercase throw new Error("No existing parent found"); } nonExistentParts.push(win32.basename(current)); current = parent; } } /** * Convert a file:// URI to a normalized path. * Handles URL decoding and Windows drive letter normalization. */ export function uriToPath(uri: string): string { try { const filePath = fileURLToPath(uri); return normalizeFilePath(filePath); } catch { // Not a valid file:// URI, treat as plain path return normalizeFilePath(uri); } } /** * Convert a path to a file:// URI. * Does NOT normalize the path - URIs preserve original casing. */ export function pathToUri(filePath: string): string { return pathToFileURL(filePath).href; } /** * Normalize a Map key lookup for file paths. * Use this when getting/setting values in Maps that use file paths as keys. */ export function normalizeMapKey(filePath: string): string { return normalizeFilePath(filePath); } /** * Compare two file paths for equality, handling Windows case-insensitivity * and mixed separators (backslash vs forward slash). */ export function pathsEqual(a: string, b: string): boolean { return normalizeFilePath(a) === normalizeFilePath(b); } /** * Check if `child` is under `parent` directory. * Separator-agnostic and case-insensitive on Windows. */ export function isUnderDir(child: string, parent: string): boolean { const normChild = normalizeFilePath(child); const normParent = normalizeFilePath(parent); // Ensure parent ends with / for prefix matching const parentPrefix = normParent.endsWith("/") ? normParent : `${normParent}/`; return normChild === normParent || normChild.startsWith(parentPrefix); } const VENDOR_DIR_NAMES = new Set([ "node_modules", "vendor", "vendors", "third_party", "third-party", ]); /** * Returns true when a file should be treated as external/vendor and excluded * from pipelines (LSP, diagnostics, complexity, read-guard, etc.). * * Cases: * 1. Outside the project root entirely (e.g. global npm packages, system files) * 2. Inside the project but under a vendor directory (node_modules, vendor, third_party, etc.) */ export function isExternalOrVendorFile( filePath: string, projectRoot: string, ): boolean { if (!isUnderDir(filePath, projectRoot)) return true; const normalized = normalizeFilePath(filePath); const rootNorm = normalizeFilePath(projectRoot); const rel = normalized.startsWith(rootNorm + "/") ? normalized.slice(rootNorm.length + 1) : normalized; return rel.split("/").some((seg) => VENDOR_DIR_NAMES.has(seg)); }