// @generated — DO NOT EDIT. Source: packages/shared/resolve-file.ts /** * Smart markdown file resolution. * * Resolves a user-provided path to an absolute file path using three strategies: * 1. Exact path (absolute or relative to cwd) * 2. Case-insensitive relative path search within project root * 3. Case-insensitive bare filename search within project root * * Used by both the CLI (`plannotator annotate`) and the `/api/doc` endpoint. */ import { homedir } from "os"; import { isAbsolute, join, resolve, win32 } from "path"; import { existsSync, readdirSync, type Dirent } from "fs"; const MARKDOWN_PATH_REGEX = /\.mdx?$/i; import { CODE_FILE_REGEX as CODE_FILE_BASENAME_REGEX } from "./code-file"; export { CODE_FILE_REGEX, isCodeFilePath } from "./code-file"; const WINDOWS_DRIVE_PATH_PATTERNS = [ /^\/cygdrive\/([a-zA-Z])\/(.+)$/, /^\/([a-zA-Z])\/(.+)$/, ]; const IGNORED_DIRS = [ "node_modules/", ".git/", "dist/", "build/", ".next/", "__pycache__/", ".obsidian/", ".trash/", ]; const CODE_IGNORED_DIRS = [ ...IGNORED_DIRS, ".turbo/", ".cache/", "target/", "vendor/", "coverage/", ".venv/", ".pytest_cache/", ]; export type ResolveResult = | { kind: "found"; path: string } | { kind: "not_found"; input: string } | { kind: "ambiguous"; input: string; matches: string[] } | { kind: "unavailable"; input: string }; function normalizeSeparators(input: string): string { return input.replace(/\\/g, "/"); } function stripTrailingSlashes(input: string): string { return input.replace(/\/+$/, ""); } export function expandHomePath(input: string, home = homedir()): string { if (input === "~") { return home; } if (input.startsWith("~/") || input.startsWith("~\\")) { return join(home, input.slice(2)); } return input; } export function stripWrappingQuotes(input: string): string { if (input.length < 2) { return input; } const first = input[0]; const last = input[input.length - 1]; if ((first === '"' && last === '"') || (first === "'" && last === "'")) { return input.slice(1, -1); } return input; } export function normalizeUserPathInput( input: string, platform = process.platform, ): string { const trimmedInput = input.trim(); const unquotedInput = stripWrappingQuotes(trimmedInput); const expandedInput = expandHomePath(unquotedInput); if (platform !== "win32") { return expandedInput; } for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) { const match = expandedInput.match(pattern); if (!match) { continue; } const [, driveLetter, rest] = match; return `${driveLetter.toUpperCase()}:/${rest}`; } return expandedInput; } function isAbsoluteNormalizedUserPath( input: string, platform = process.platform, ): boolean { if (hasWindowsDriveLetter(input)) { return true; } return platform === "win32" ? win32.isAbsolute(input) : isAbsolute(input); } export function isAbsoluteUserPath( input: string, platform = process.platform, ): boolean { return isAbsoluteNormalizedUserPath(normalizeUserPathInput(input, platform), platform); } export function resolveUserPath( input: string, baseDir = process.cwd(), platform = process.platform, ): string { const normalizedInput = normalizeUserPathInput(input, platform); if (!normalizedInput) { return ""; } return isAbsoluteNormalizedUserPath(normalizedInput, platform) ? resolveAbsolutePath(normalizedInput, platform) : resolve(baseDir, normalizedInput); } function normalizeComparablePath(input: string): string { return stripTrailingSlashes(normalizeSeparators(resolveUserPath(input))); } export function isWithinProjectRoot(candidate: string, projectRoot: string): boolean { const normalizedCandidate = normalizeComparablePath(candidate); const normalizedProjectRoot = normalizeComparablePath(projectRoot); return ( normalizedCandidate === normalizedProjectRoot || normalizedCandidate.startsWith(`${normalizedProjectRoot}/`) ); } function getLowercaseBasename(input: string): string { const normalizedInput = normalizeSeparators(input); return normalizedInput.split("/").pop()!.toLowerCase(); } function getLookupKey(input: string, isBareFilename: boolean): string { return isBareFilename ? getLowercaseBasename(input) : input.toLowerCase(); } function resolveAbsolutePath( input: string, platform = process.platform, ): string { // Use win32.resolve for Windows paths regardless of reported platform return platform === "win32" || hasWindowsDriveLetter(input) ? win32.resolve(input) : resolve(input); } function isSearchableMarkdownPath(input: string): boolean { return MARKDOWN_PATH_REGEX.test(input.trim()); } /** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */ function hasWindowsDriveLetter(input: string): boolean { return /^[a-zA-Z]:[/\\]/.test(input); } /** Cross-platform file existence check using Node fs (more reliable than Bun.file in compiled exes) */ function fileExists(filePath: string): boolean { try { return existsSync(filePath); } catch { return false; } } /** Recursively walk a directory collecting files matching `fileMatcher`, skipping ignored dirs. */ function walkFiles( dir: string, root: string, results: string[], ignoredDirs: string[], fileMatcher: (name: string) => boolean, ): void { const entries = readdirSync(dir, { withFileTypes: true }) as Dirent[]; for (const entry of entries) { if (entry.isDirectory()) { if (ignoredDirs.some((d) => d === entry.name + "/")) continue; try { walkFiles(join(dir, entry.name), root, results, ignoredDirs, fileMatcher); } catch { /* skip dirs we can't read */ } } else if (entry.isFile() && fileMatcher(entry.name)) { const relative = join(dir, entry.name) .slice(root.length + 1) .replace(/\\/g, "/"); results.push(relative); } } } function walkMarkdownFiles(dir: string, root: string, results: string[], ignoredDirs: string[]): void { try { walkFiles(dir, root, results, ignoredDirs, (name) => /\.mdx?$/i.test(name)); } catch { /* fail soft for markdown — preserves existing behavior */ } } // --- Code-file resolution (async, cached) --- const FILE_LIST_CACHE_TTL_MS = 30_000; const fileListCache = new Map< string, { promise: Promise; startedAt: number } >(); function fileListCacheKey(projectRoot: string, kind: string): string { return `${projectRoot}|${kind}`; } function startCodeWalk(projectRoot: string): Promise { return Promise.resolve().then(() => { try { const results: string[] = []; walkFiles(projectRoot, projectRoot, results, CODE_IGNORED_DIRS, (name) => CODE_FILE_BASENAME_REGEX.test(name), ); return results; } catch { return null; } }); } /** * Trigger (or return the in-flight) walk of `projectRoot` for code files. * Cached for `FILE_LIST_CACHE_TTL_MS`. Storing a Promise (not a value) makes * concurrent callers piggyback on the same walk — first arrival wins. * * Returns `null` (wrapped in Promise) when the walk fails (perms, etc). */ export function warmFileListCache( projectRoot: string, kind: "code", ): Promise { const key = fileListCacheKey(projectRoot, kind); const entry = fileListCache.get(key); if (entry && Date.now() - entry.startedAt < FILE_LIST_CACHE_TTL_MS) { return entry.promise; } const promise = startCodeWalk(projectRoot); fileListCache.set(key, { promise, startedAt: Date.now() }); return promise; } /** * Resolve a code-file path within a project root. * * Strategies: * 1. Absolute path → use as-is. * 2. Exact relative from project root. * 3. If `baseDir` provided, literal `/` existence check — * lets out-of-tree linked docs resolve their own relative references * (e.g. `../script.ts` in `~/notes/foo.md` finds `~/script.ts`). * 4. Case-insensitive suffix match against the cached file list: * - bare basename input → match any file with that basename; * - input with `/` → match files whose path equals or ends with `/` * on a segment boundary (so `editor/App.tsx` matches `packages/editor/App.tsx` * but not `myeditor/App.tsx`). * * `..` segments in the input are honored: only `./` is stripped before suffix * matching. `../foo.ts` without a `baseDir` correctly falls through to * not_found rather than fabricating a match against `foo.ts` somewhere in cwd. */ export async function resolveCodeFile( input: string, projectRoot: string, baseDir?: string, ): Promise { const originalInput = input.trim(); const unquotedInput = stripWrappingQuotes(originalInput); const normalizedInput = normalizeUserPathInput(unquotedInput); const searchInput = normalizeSeparators(normalizedInput); if (!searchInput) { return { kind: "not_found", input: originalInput }; } if (isAbsoluteNormalizedUserPath(normalizedInput)) { const absolutePath = resolveAbsolutePath(normalizedInput); if (fileExists(absolutePath)) { return { kind: "found", path: absolutePath }; } return { kind: "not_found", input: originalInput }; } const fromRoot = resolve(projectRoot, searchInput); if (isWithinProjectRoot(fromRoot, projectRoot) && fileExists(fromRoot)) { return { kind: "found", path: fromRoot }; } if (baseDir) { const fromBase = resolve(baseDir, searchInput); if (fileExists(fromBase)) { return { kind: "found", path: fromBase }; } } const fileList = await warmFileListCache(projectRoot, "code"); if (fileList === null) { return { kind: "unavailable", input: originalInput }; } // Strip leading `./` so suffix matching works on inputs like // `./editor/App.tsx` — file list entries never carry that segment. // `../` is intentionally NOT stripped: `..` is meaningful (escape parent), // not noise. If we can't honor it via baseDir, the input has no // suffix-match equivalent in the in-tree file list. const cleanedInput = searchInput.replace(/^(?:\.\/)+/, ""); if (!cleanedInput || cleanedInput.startsWith("../")) { return { kind: "not_found", input: originalInput }; } const target = cleanedInput.toLowerCase(); const isBareFilename = !cleanedInput.includes("/"); const matches: string[] = []; for (const f of fileList) { const fl = f.toLowerCase(); if (isBareFilename) { const base = fl.split("/").pop(); if (base === target) matches.push(resolve(projectRoot, f)); } else { if (fl === target || fl.endsWith("/" + target)) { matches.push(resolve(projectRoot, f)); } } } if (matches.length === 1) { return { kind: "found", path: matches[0] }; } if (matches.length > 1) { return { kind: "ambiguous", input: originalInput, matches }; } return { kind: "not_found", input: originalInput }; } /** * Resolve a markdown file path within a project root. * * @param input - User-provided path (absolute, relative, or bare filename) * @param projectRoot - Project root directory to search within */ function resolveMarkdownFileCore( input: string, projectRoot: string, ): ResolveResult { const normalizedInput = normalizeUserPathInput(input); const searchInput = normalizeSeparators(normalizedInput); const isBareFilename = !searchInput.includes("/"); const targetLookupKey = getLookupKey(searchInput, isBareFilename); // Restrict to markdown files if (!isSearchableMarkdownPath(normalizedInput)) { return { kind: "not_found", input }; } // 1. Absolute path — use as-is (no project root restriction; // the user explicitly typed the full path) if (isAbsoluteNormalizedUserPath(normalizedInput)) { const absolutePath = resolveAbsolutePath(normalizedInput); if (fileExists(absolutePath)) { return { kind: "found", path: absolutePath }; } return { kind: "not_found", input }; } // 2. Exact relative path from project root const fromRoot = resolve(projectRoot, searchInput); if (isWithinProjectRoot(fromRoot, projectRoot) && fileExists(fromRoot)) { return { kind: "found", path: fromRoot }; } // 3. Case-insensitive search (only scan markdown files) const allFiles: string[] = []; walkMarkdownFiles(projectRoot, projectRoot, allFiles, IGNORED_DIRS); const matches: string[] = []; for (const match of allFiles) { const normalizedMatch = normalizeSeparators(match); const matchLookupKey = getLookupKey(normalizedMatch, isBareFilename); if (matchLookupKey === targetLookupKey) { const full = resolve(projectRoot, normalizedMatch); if (isWithinProjectRoot(full, projectRoot)) { matches.push(full); } } } if (matches.length === 1) { return { kind: "found", path: matches[0] }; } if (matches.length > 1) { const projectRootPrefix = `${normalizeComparablePath(projectRoot)}/`; const relative = matches.map((match) => normalizeComparablePath(match).replace(projectRootPrefix, ""), ); return { kind: "ambiguous", input, matches: relative }; } return { kind: "not_found", input }; } /** * Resolve a markdown file path within a project root. * * @param input - User-provided path (absolute, relative, or bare filename) * @param projectRoot - Project root directory to search within */ export function resolveMarkdownFile( input: string, projectRoot: string, ): ResolveResult { const originalInput = input.trim(); const unquotedInput = stripWrappingQuotes(originalInput); const primary = resolveMarkdownFileCore(unquotedInput, projectRoot); if (primary.kind === "found") { return primary; } if (primary.kind === "ambiguous") { return { ...primary, input: originalInput }; } if (!unquotedInput.startsWith("@")) { return { kind: "not_found", input: originalInput }; } const normalizedInput = unquotedInput.replace(/^@+/, ""); if (!normalizedInput) { return { kind: "not_found", input: originalInput }; } const fallback = resolveMarkdownFileCore(normalizedInput, projectRoot); if (fallback.kind === "found") { return fallback; } if (fallback.kind === "ambiguous") { return { ...fallback, input: originalInput }; } return { kind: "not_found", input: originalInput }; } /** * Check if a directory contains at least one file matching the given extensions. * Used to validate folder annotation targets. * * @param dirPath - Directory to search * @param excludedDirs - Directory names to skip (with trailing slash, e.g. "node_modules/") * @param extensions - Regex to match file extensions (default: markdown only) */ export function hasMarkdownFiles( dirPath: string, excludedDirs: string[] = IGNORED_DIRS, extensions: RegExp = /\.mdx?$/i, ): boolean { function walk(dir: string): boolean { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; } for (const entry of entries) { if (entry.isDirectory()) { if (excludedDirs.some((d) => d === entry.name + "/")) continue; if (walk(join(dir, entry.name))) return true; } else if (entry.isFile() && extensions.test(entry.name)) { return true; } } return false; } return walk(dirPath); }