Files
pi-config/extensions/plannotator/generated/resolve-file.ts

511 lines
15 KiB
TypeScript

// @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<string[] | null>; startedAt: number }
>();
function fileListCacheKey(projectRoot: string, kind: string): string {
return `${projectRoot}|${kind}`;
}
function startCodeWalk(projectRoot: string): Promise<string[] | null> {
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<string[] | null> {
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 `<baseDir>/<input>` 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 `/<input>`
* 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<ResolveResult> {
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);
}