import * as path from 'node:path';
import {
DEFAULT_EXCLUDE_PATTERNS,
DEFAULT_SEARCH_TIMEOUT_MS,
} from '../constants.js';
import { createTimedAbortSignal } from '../fs-helpers.js';
import { isSensitivePath } from '../path-policy.js';
import {
isPathWithinDirectories,
normalizePath,
validateExistingDirectory,
validateExistingPathDetailed,
} from '../path-validation.js';
import { isIgnoredByGitignore, loadRootGitignore } from './gitignore.js';
import { globEntries } from './glob-engine.js';
type TreeEntryType = 'file' | 'directory' | 'symlink' | 'other';
export interface TreeEntry {
name: string;
type: TreeEntryType;
relativePath: string;
children?: TreeEntry[];
}
export interface TreeOptions {
maxDepth?: number;
maxEntries?: number;
includeHidden?: boolean;
includeIgnored?: boolean;
timeoutMs?: number;
signal?: AbortSignal;
}
interface NormalizedOptions {
maxDepth: number;
maxEntries: number;
includeHidden: boolean;
includeIgnored: boolean;
timeoutMs: number;
}
export interface TreeResult {
root: string;
tree: TreeEntry;
truncated: boolean;
totalEntries: number;
}
function toSafeNonNegativeInt(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
const asInt = Math.floor(value);
return asInt >= 0 ? asInt : fallback;
}
function toSafePositiveInt(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
const asInt = Math.floor(value);
return asInt > 0 ? asInt : fallback;
}
function toSafeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value !== 'boolean') return fallback;
return value;
}
function normalizeOptions(options: TreeOptions): NormalizedOptions {
return {
maxDepth: toSafeNonNegativeInt(options.maxDepth, 5),
maxEntries: toSafeNonNegativeInt(options.maxEntries, 1000),
includeHidden: toSafeBoolean(options.includeHidden, false),
includeIgnored: toSafeBoolean(options.includeIgnored, false),
timeoutMs: toSafePositiveInt(options.timeoutMs, DEFAULT_SEARCH_TIMEOUT_MS),
};
}
function resolveEntryType(dirent: {
isDirectory(): boolean;
isSymbolicLink(): boolean;
isFile(): boolean;
}): TreeEntryType {
if (dirent.isDirectory()) return 'directory';
if (dirent.isSymbolicLink()) return 'symlink';
if (dirent.isFile()) return 'file';
return 'other';
}
function ensureParentNodes(
rootNode: TreeEntry,
nodeByPath: Map<string, TreeEntry>,
relativePath: string
): TreeEntry {
const normalized = relativePath.replace(/\\/gu, '/');
if (normalized.length === 0 || normalized === '.') return rootNode;
const segments = normalized.split('/').filter((seg) => seg.length > 0);
let current = rootNode;
let currentPath = '';
for (const segment of segments.slice(0, Math.max(0, segments.length - 1))) {
currentPath =
currentPath.length === 0 ? segment : `${currentPath}/${segment}`;
let child = nodeByPath.get(currentPath);
if (!child) {
child = {
name: segment,
type: 'directory',
relativePath: currentPath,
children: [],
};
nodeByPath.set(currentPath, child);
current.children ??= [];
current.children.push(child);
}
current = child;
}
return current;
}
function sortTree(node: TreeEntry): void {
if (!node.children) return;
node.children.sort((a, b) => {
const typeRank = (t: TreeEntryType): number => {
if (t === 'directory') return 0;
if (t === 'file') return 1;
return 2;
};
const diff = typeRank(a.type) - typeRank(b.type);
if (diff !== 0) return diff;
return a.name.localeCompare(b.name);
});
for (const child of node.children) {
sortTree(child);
}
}
function getStopReason(
signal: AbortSignal,
totalEntries: number,
maxEntries: number
): 'aborted' | 'maxEntries' | undefined {
if (signal.aborted) {
return 'aborted';
}
if (totalEntries >= maxEntries) {
return 'maxEntries';
}
return undefined;
}
async function resolveTreeEntry(
entry: {
path: string;
dirent: {
isDirectory(): boolean;
isSymbolicLink(): boolean;
isFile(): boolean;
};
},
root: string,
rootNormalized: string,
gitignoreMatcher: Awaited<ReturnType<typeof loadRootGitignore>>,
signal: AbortSignal
): Promise<{
type: TreeEntryType;
relativePosix: string;
name: string;
} | null> {
const type = resolveEntryType(entry.dirent);
if (type !== 'symlink') {
const normalized = normalizePath(entry.path);
if (!isPathWithinDirectories(normalized, [rootNormalized])) {
return null;
}
if (isSensitivePath(entry.path, normalized)) {
return null;
}
} else {
try {
const validated = await validateExistingPathDetailed(entry.path, signal);
if (isSensitivePath(validated.requestedPath, validated.resolvedPath)) {
return null;
}
} catch {
return null;
}
}
if (
gitignoreMatcher &&
isIgnoredByGitignore(gitignoreMatcher, root, entry.path, {
isDirectory: type === 'directory',
})
) {
return null;
}
const relative = path.relative(root, entry.path) || path.basename(entry.path);
const relativePosix = relative.replace(/\\/gu, '/');
const name = path.basename(entry.path);
return { type, relativePosix, name };
}
function upsertChildNode(
parent: TreeEntry,
nodeByPath: Map<string, TreeEntry>,
resolved: { type: TreeEntryType; relativePosix: string; name: string }
): void {
const ensureDirectoryShape = (node: TreeEntry): void => {
if (node.type === 'directory') {
node.children ??= [];
} else {
delete node.children;
}
};
const maybeUpdateType = (
existing: TreeEntry,
nextType: TreeEntryType
): void => {
if (existing.type === nextType) return;
const preservePopulatedDirectory =
existing.type === 'directory' &&
Array.isArray(existing.children) &&
existing.children.length > 0;
if (preservePopulatedDirectory) return;
existing.type = nextType;
ensureDirectoryShape(existing);
};
const attachChild = (child: TreeEntry): void => {
parent.children ??= [];
if (!parent.children.includes(child)) {
parent.children.push(child);
}
};
const existing = nodeByPath.get(resolved.relativePosix);
if (existing) {
// Avoid duplicate directory nodes when a child file is encountered before the directory entry.
// Prefer preserving an existing populated directory node over overwriting it.
maybeUpdateType(existing, resolved.type);
existing.name = resolved.name;
existing.relativePath = resolved.relativePosix;
ensureDirectoryShape(existing);
attachChild(existing);
return;
}
const node: TreeEntry = {
name: resolved.name,
type: resolved.type,
relativePath: resolved.relativePosix,
...(resolved.type === 'directory' ? { children: [] as TreeEntry[] } : {}),
};
nodeByPath.set(resolved.relativePosix, node);
attachChild(node);
}
export function formatTreeAscii(tree: TreeEntry): string {
const lines: string[] = [];
const walk = (
node: TreeEntry,
prefix: string,
isLast: boolean,
isRoot: boolean
): void => {
let connector = '';
let linePrefix = '';
if (!isRoot) {
connector = isLast ? '└── ' : '├── ';
linePrefix = prefix;
}
lines.push(`${linePrefix}${connector}${node.name}`);
if (!node.children || node.children.length === 0) return;
let nextPrefix = '';
if (!isRoot) {
const continuation = isLast ? ' ' : '│ ';
nextPrefix = `${prefix}${continuation}`;
}
const count = node.children.length;
node.children.forEach((child, index) => {
walk(child, nextPrefix, index === count - 1, false);
});
};
walk(tree, '', true, true);
return lines.join('\n');
}
export async function treeDirectory(
dirPath: string,
options: TreeOptions = {}
): Promise<TreeResult> {
const normalized = normalizeOptions(options);
const { signal, cleanup } = createTimedAbortSignal(
options.signal,
normalized.timeoutMs
);
const root = await validateExistingDirectory(dirPath, signal);
const rootNormalized = normalizePath(root);
try {
const excludePatterns = normalized.includeIgnored
? []
: DEFAULT_EXCLUDE_PATTERNS;
const gitignoreMatcher = normalized.includeIgnored
? null
: await loadRootGitignore(root, signal);
const rootNode: TreeEntry = {
name: path.basename(root) || root,
type: 'directory',
relativePath: '.',
children: [],
};
const nodeByPath = new Map<string, TreeEntry>();
let totalEntries = 0;
let truncated = false;
const stream = globEntries({
cwd: root,
pattern: '**/*',
excludePatterns,
includeHidden: normalized.includeHidden,
baseNameMatch: false,
caseSensitiveMatch: true,
maxDepth: normalized.maxDepth,
followSymbolicLinks: false,
onlyFiles: false,
stats: false,
suppressErrors: true,
});
for await (const entry of stream) {
const stopReason = getStopReason(
signal,
totalEntries,
normalized.maxEntries
);
if (stopReason) {
truncated = true;
break;
}
const resolved = await resolveTreeEntry(
entry,
root,
rootNormalized,
gitignoreMatcher,
signal
);
if (!resolved) {
continue;
}
const parent = ensureParentNodes(
rootNode,
nodeByPath,
resolved.relativePosix
);
upsertChildNode(parent, nodeByPath, resolved);
totalEntries += 1;
}
sortTree(rootNode);
return {
root,
tree: rootNode,
truncated,
totalEntries,
};
} finally {
cleanup();
}
}