UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

293 lines (292 loc) • 12.2 kB
/** * 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 * as os from "node:os"; import * as path from "node:path"; 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) { 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) { // 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) { const resolved = win32.resolve(filePath); let current = resolved; const nonExistentParts = []; // 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) { 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) { 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) { return normalizeFilePath(filePath); } /** * Cheap, syntactic-only Map key normalization: slash-fold + (on Windows) * lowercase. No `realpathSync` / filesystem I/O. * * `normalizeMapKey` (via `normalizeFilePath`) calls `realpathSync.native()` to * get canonical on-disk casing — correct for maps that key long-lived state * shared across call sites (e.g. LSP/read-guard caches), but expensive when * the *point* of the cache is to avoid filesystem calls in the first place: * for a candidate path that does NOT exist (the common case for sibling-probe * memos), `normalizeFilePath` walks up the directory tree doing its own * `existsSync` calls to resolve the nearest existing ancestor — measured at * ~11x slower than the single `existsSync` probe such a cache is trying to * save (refs #191). * * Safe to use ONLY for ephemeral, single-process, single-walk caches whose * keys are produced by this process's own `path.join`/`path.resolve` calls * within the same run (so separators and casing are already consistent * modulo simple slash direction) — never for state shared across processes, * persisted, or compared against externally-supplied paths where symlink / * real-casing resolution actually matters. */ export function normalizeEphemeralMapKey(filePath) { const slashed = filePath.replace(/\\/g, "/"); return process.platform === "win32" ? slashed.toLowerCase() : slashed; } /** * Compare two file paths for equality, handling Windows case-insensitivity * and mixed separators (backslash vs forward slash). */ export function pathsEqual(a, b) { return normalizeFilePath(a) === normalizeFilePath(b); } /** * Check if `child` is under `parent` directory. * Separator-agnostic and case-insensitive on Windows. */ /** * Yield each directory from `startDir` up to (and including) the filesystem * root. Terminates when `path.dirname(current) === current` so it works on * Windows drive roots and POSIX `/` alike. * * Single source of truth for the half-dozen "walk up the directory tree * looking for X" loops that have accumulated across the codebase. Callers * that need an "is there a file named Y anywhere on the way up" check * should use `findNearestContaining` instead. */ export function* walkUpDirs(startDir) { let current = path.resolve(startDir); while (true) { yield current; const parent = path.dirname(current); if (parent === current) return; current = parent; } } /** * Walk up from `startDir` and return the first directory that contains any * of `candidates` on disk. Returns `undefined` if none match. * * @example * findNearestContaining("/repo/pkg/src", ["package.json", "tsconfig.json"]); * // → "/repo/pkg" if pkg/package.json exists, "/repo" if only /repo/package.json */ export function findNearestContaining(startDir, candidates) { for (const dir of walkUpDirs(startDir)) { for (const name of candidates) { if (existsSync(path.join(dir, name))) return dir; } } return undefined; } /** * Walk up from `startDir` looking for a directory containing any of * `markers`, the same containment-aware climb `knip-client.ts` and * `dead-code-client.ts` each used to hand-roll independently (refs #625): * * - Never resolves at or above `$HOME` (via `isAtOrAboveHomeDir`) — a * marker found there has escaped the user's workspace. * - If `options.boundaries` is given and one is found before any `marker`, * stops and returns `null` rather than continuing past it. * - Depth-capped at 64 climbs, matching the callers' existing safety bound * (guards a pathological symlink loop; real depths are ~10). * - Returns `null` — never `startDir` — when nothing is found. Callers * must treat `null` as "no project here", not fall back to the start * directory (a `null`-swallowing fallback was the #250/#296 bug class: * scanning $HOME wholesale from a bare cwd). * * For a plain "find nearest containing directory" with no boundary concept, * use `findNearestContaining` instead. Distinct from `startup-scan.ts`'s * `findNearestProjectRoot` (fixed marker list, no boundaries, no home-check — * that caller applies `isAtOrAboveHomeDir` itself afterward); named * differently here to avoid confusion between the two. */ export function findNearestMarkerRoot(startDir, markers, options = {}) { const boundaries = options.boundaries ?? []; const homeDir = path.resolve(options.homeDir ?? os.homedir()); let current = path.resolve(startDir); for (let depth = 0; depth < 64; depth++) { if (isAtOrAboveHomeDir(current, homeDir)) return null; if (markers.some((m) => existsSync(path.join(current, m)))) return current; if (boundaries.some((m) => existsSync(path.join(current, m)))) return null; const parent = path.dirname(current); if (parent === current) return null; current = parent; } return null; } /** * True when `dir` is the home directory OR an ancestor of it (`/home`, * `C:\Users`, the filesystem root, …). A project-root search that climbs to * such a directory has escaped the user's workspace — walking down from it * scans unrelated trees (the #250 runaway). Use this as the single shared * ceiling on any upward project-root resolution, instead of an exact * `=== os.homedir()` check (which a marker found *above* `$HOME` slips past). * A normal project *under* home (e.g. `~/code/app`) is NOT at-or-above home, * so it still resolves fine. Refs #253. */ export function isAtOrAboveHomeDir(dir, homeDir = os.homedir()) { const resolvedDir = path.resolve(dir); const resolvedHome = path.resolve(homeDir); if (resolvedDir === resolvedHome) return true; // `dir` is an ancestor of home ⇢ home lies inside dir ⇢ the relative path // from dir to home has no leading `..` and is not absolute (cross-drive on // Windows yields an absolute rel, correctly treated as "not above"). const rel = path.relative(resolvedDir, resolvedHome); return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); } export function isUnderDir(child, parent) { 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, projectRoot) { 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)); }