src / list-files.ts
import type { Options } from "globby";
import * as os from "os";
import * as path from "path";
function normalizePath(p: string): string {
let normalized = path.normalize(p);
if (
normalized.length > 1 &&
(normalized.endsWith("/") || normalized.endsWith("\\"))
) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
export function arePathsEqual(path1?: string, path2?: string): boolean {
if (!path1 && !path2) return true;
if (!path1 || !path2) return false;
path1 = normalizePath(path1);
path2 = normalizePath(path2);
if (process.platform === "win32") {
return path1.toLowerCase() === path2.toLowerCase();
}
return path1 === path2;
}
const DEFAULT_IGNORE_DIRECTORIES = [
"node_modules", "__pycache__", "env", "venv",
"target/dependency", "build/dependencies", "dist", "out",
"bundle", "vendor", "tmp", "temp", "deps", "Pods",
];
function isRestrictedPath(absolutePath: string): boolean {
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/";
if (arePathsEqual(absolutePath, root)) return true;
if (arePathsEqual(absolutePath, os.homedir())) return true;
return false;
}
function isTargetingHiddenDirectory(absolutePath: string): boolean {
return path.basename(absolutePath).startsWith(".");
}
function buildIgnorePatterns(absolutePath: string): string[] {
const isTargetHidden = isTargetingHiddenDirectory(absolutePath);
const patterns = [...DEFAULT_IGNORE_DIRECTORIES];
if (!isTargetHidden) patterns.push(".*");
return patterns.map((dir) => `**/${dir}/**`);
}
export async function listFiles(
dirPath: string,
recursive: boolean,
limit: number
): Promise<[string[], boolean]> {
const { globby } = await import("globby");
const absolutePath = path.resolve(dirPath);
if (isRestrictedPath(absolutePath)) return [[], false];
const options: Options = {
cwd: dirPath,
dot: true,
absolute: true,
markDirectories: true,
gitignore: recursive,
ignore: recursive ? buildIgnorePatterns(absolutePath) : undefined,
onlyFiles: false,
suppressErrors: true,
};
const filePaths = recursive
? await globbyLevelByLevel(limit, options)
: (await globby("*", options)).slice(0, limit);
return [filePaths, filePaths.length >= limit];
}
async function globbyLevelByLevel(limit: number, options?: Options) {
const { globby } = await import("globby");
const results: Set<string> = new Set();
const queue: string[] = ["*"];
const globbingProcess = async () => {
while (queue.length > 0 && results.size < limit) {
const pattern = queue.shift()!;
const filesAtLevel = await globby(pattern, options);
for (const file of filesAtLevel) {
if (results.size >= limit) break;
results.add(file);
if (file.endsWith("/")) {
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)");
queue.push(`${escapedFile}*`);
}
}
}
return Array.from(results).slice(0, limit);
};
const timeoutPromise = new Promise<string[]>((_, reject) => {
setTimeout(() => reject(new Error("Globbing timeout")), 10_000);
});
try {
return await Promise.race([globbingProcess(), timeoutPromise]);
} catch (_error) {
console.warn("Globbing timed out, returning partial results");
return Array.from(results);
}
}
export function formatFilesList(
absolutePath: string,
files: string[],
didHitLimit: boolean
): string {
const sorted = files
.map((file) => {
const relativePath = path.relative(absolutePath, file).replace(/\\/g, "/");
return file.endsWith("/") ? relativePath + "/" : relativePath;
})
.sort((a, b) => {
const aParts = a.split("/");
const bParts = b.split("/");
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
if (aParts[i] !== bParts[i]) {
if (i + 1 === aParts.length && i + 1 < bParts.length) return -1;
if (i + 1 === bParts.length && i + 1 < aParts.length) return 1;
return aParts[i].localeCompare(bParts[i], undefined, { numeric: true, sensitivity: "base" });
}
}
return aParts.length - bParts.length;
});
if (didHitLimit) {
return `${sorted.join("\n")}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)`;
} else if (sorted.length === 0 || (sorted.length === 1 && sorted[0] === "")) {
return "No files found.";
} else {
return sorted.join("\n");
}
}