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");
  }
}