diff options
Diffstat (limited to 'packages/core/src/utils/getFolderStructure.ts')
| -rw-r--r-- | packages/core/src/utils/getFolderStructure.ts | 325 |
1 files changed, 325 insertions, 0 deletions
diff --git a/packages/core/src/utils/getFolderStructure.ts b/packages/core/src/utils/getFolderStructure.ts new file mode 100644 index 00000000..6d921811 --- /dev/null +++ b/packages/core/src/utils/getFolderStructure.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import { Dirent } from 'fs'; +import * as path from 'path'; +import { getErrorMessage, isNodeError } from './errors.js'; + +const MAX_ITEMS = 200; +const TRUNCATION_INDICATOR = '...'; +const DEFAULT_IGNORED_FOLDERS = new Set(['node_modules', '.git', 'dist']); + +// --- Interfaces --- + +/** Options for customizing folder structure retrieval. */ +interface FolderStructureOptions { + /** Maximum number of files and folders combined to display. Defaults to 200. */ + maxItems?: number; + /** Set of folder names to ignore completely. Case-sensitive. */ + ignoredFolders?: Set<string>; + /** Optional regex to filter included files by name. */ + fileIncludePattern?: RegExp; +} + +// Define a type for the merged options where fileIncludePattern remains optional +type MergedFolderStructureOptions = Required< + Omit<FolderStructureOptions, 'fileIncludePattern'> +> & { + fileIncludePattern?: RegExp; +}; + +/** Represents the full, unfiltered information about a folder and its contents. */ +interface FullFolderInfo { + name: string; + path: string; + files: string[]; + subFolders: FullFolderInfo[]; + totalChildren: number; // Number of files and subfolders included from this folder during BFS scan + totalFiles: number; // Number of files included from this folder during BFS scan + isIgnored?: boolean; // Flag to easily identify ignored folders later + hasMoreFiles?: boolean; // Indicates if files were truncated for this specific folder + hasMoreSubfolders?: boolean; // Indicates if subfolders were truncated for this specific folder +} + +// --- Interfaces --- + +// --- Helper Functions --- + +async function readFullStructure( + rootPath: string, + options: MergedFolderStructureOptions, +): Promise<FullFolderInfo | null> { + const rootName = path.basename(rootPath); + const rootNode: FullFolderInfo = { + name: rootName, + path: rootPath, + files: [], + subFolders: [], + totalChildren: 0, + totalFiles: 0, + }; + + const queue: Array<{ folderInfo: FullFolderInfo; currentPath: string }> = [ + { folderInfo: rootNode, currentPath: rootPath }, + ]; + let currentItemCount = 0; + // Count the root node itself as one item if we are not just listing its content + + const processedPaths = new Set<string>(); // To avoid processing same path if symlinks create loops + + while (queue.length > 0) { + const { folderInfo, currentPath } = queue.shift()!; + + if (processedPaths.has(currentPath)) { + continue; + } + processedPaths.add(currentPath); + + if (currentItemCount >= options.maxItems) { + // If the root itself caused us to exceed, we can't really show anything. + // Otherwise, this folder won't be processed further. + // The parent that queued this would have set its own hasMoreSubfolders flag. + continue; + } + + let entries: Dirent[]; + try { + const rawEntries = await fs.readdir(currentPath, { withFileTypes: true }); + // Sort entries alphabetically by name for consistent processing order + entries = rawEntries.sort((a, b) => a.name.localeCompare(b.name)); + } catch (error: unknown) { + if ( + isNodeError(error) && + (error.code === 'EACCES' || error.code === 'ENOENT') + ) { + console.warn( + `Warning: Could not read directory ${currentPath}: ${error.message}`, + ); + if (currentPath === rootPath && error.code === 'ENOENT') { + return null; // Root directory itself not found + } + // For other EACCES/ENOENT on subdirectories, just skip them. + continue; + } + throw error; + } + + const filesInCurrentDir: string[] = []; + const subFoldersInCurrentDir: FullFolderInfo[] = []; + + // Process files first in the current directory + for (const entry of entries) { + if (entry.isFile()) { + if (currentItemCount >= options.maxItems) { + folderInfo.hasMoreFiles = true; + break; + } + const fileName = entry.name; + if ( + !options.fileIncludePattern || + options.fileIncludePattern.test(fileName) + ) { + filesInCurrentDir.push(fileName); + currentItemCount++; + folderInfo.totalFiles++; + folderInfo.totalChildren++; + } + } + } + folderInfo.files = filesInCurrentDir; + + // Then process directories and queue them + for (const entry of entries) { + if (entry.isDirectory()) { + // Check if adding this directory ITSELF would meet or exceed maxItems + // (currentItemCount refers to items *already* added before this one) + if (currentItemCount >= options.maxItems) { + folderInfo.hasMoreSubfolders = true; + break; // Already at limit, cannot add this folder or any more + } + // If adding THIS folder makes us hit the limit exactly, and it might have children, + // it's better to show '...' for the parent, unless this is the very last item slot. + // This logic is tricky. Let's try a simpler: if we can't add this item, mark and break. + + const subFolderName = entry.name; + const subFolderPath = path.join(currentPath, subFolderName); + + if (options.ignoredFolders.has(subFolderName)) { + const ignoredSubFolder: FullFolderInfo = { + name: subFolderName, + path: subFolderPath, + files: [], + subFolders: [], + totalChildren: 0, + totalFiles: 0, + isIgnored: true, + }; + subFoldersInCurrentDir.push(ignoredSubFolder); + currentItemCount++; // Count the ignored folder itself + folderInfo.totalChildren++; // Also counts towards parent's children + continue; + } + + const subFolderNode: FullFolderInfo = { + name: subFolderName, + path: subFolderPath, + files: [], + subFolders: [], + totalChildren: 0, + totalFiles: 0, + }; + subFoldersInCurrentDir.push(subFolderNode); + currentItemCount++; + folderInfo.totalChildren++; // Counts towards parent's children + + // Add to queue for processing its children later + queue.push({ folderInfo: subFolderNode, currentPath: subFolderPath }); + } + } + folderInfo.subFolders = subFoldersInCurrentDir; + } + + return rootNode; +} + +/** + * Reads the directory structure using BFS, respecting maxItems. + * @param node The current node in the reduced structure. + * @param indent The current indentation string. + * @param isLast Sibling indicator. + * @param builder Array to build the string lines. + */ +function formatStructure( + node: FullFolderInfo, + currentIndent: string, + isLastChildOfParent: boolean, + isProcessingRootNode: boolean, + builder: string[], +): void { + const connector = isLastChildOfParent ? '└───' : '├───'; + + // The root node of the structure (the one passed initially to getFolderStructure) + // is not printed with a connector line itself, only its name as a header. + // Its children are printed relative to that conceptual root. + // Ignored root nodes ARE printed with a connector. + if (!isProcessingRootNode || node.isIgnored) { + builder.push( + `${currentIndent}${connector}${node.name}/${node.isIgnored ? TRUNCATION_INDICATOR : ''}`, + ); + } + + // Determine the indent for the children of *this* node. + // If *this* node was the root of the whole structure, its children start with no indent before their connectors. + // Otherwise, children's indent extends from the current node's indent. + const indentForChildren = isProcessingRootNode + ? '' + : currentIndent + (isLastChildOfParent ? ' ' : '│ '); + + // Render files of the current node + const fileCount = node.files.length; + for (let i = 0; i < fileCount; i++) { + const isLastFileAmongSiblings = + i === fileCount - 1 && + node.subFolders.length === 0 && + !node.hasMoreSubfolders; + const fileConnector = isLastFileAmongSiblings ? '└───' : '├───'; + builder.push(`${indentForChildren}${fileConnector}${node.files[i]}`); + } + if (node.hasMoreFiles) { + const isLastIndicatorAmongSiblings = + node.subFolders.length === 0 && !node.hasMoreSubfolders; + const fileConnector = isLastIndicatorAmongSiblings ? '└───' : '├───'; + builder.push(`${indentForChildren}${fileConnector}${TRUNCATION_INDICATOR}`); + } + + // Render subfolders of the current node + const subFolderCount = node.subFolders.length; + for (let i = 0; i < subFolderCount; i++) { + const isLastSubfolderAmongSiblings = + i === subFolderCount - 1 && !node.hasMoreSubfolders; + // Children are never the root node being processed initially. + formatStructure( + node.subFolders[i], + indentForChildren, + isLastSubfolderAmongSiblings, + false, + builder, + ); + } + if (node.hasMoreSubfolders) { + builder.push(`${indentForChildren}└───${TRUNCATION_INDICATOR}`); + } +} + +// --- Main Exported Function --- + +/** + * Generates a string representation of a directory's structure, + * limiting the number of items displayed. Ignored folders are shown + * followed by '...' instead of their contents. + * + * @param directory The absolute or relative path to the directory. + * @param options Optional configuration settings. + * @returns A promise resolving to the formatted folder structure string. + */ +export async function getFolderStructure( + directory: string, + options?: FolderStructureOptions, +): Promise<string> { + const resolvedPath = path.resolve(directory); + const mergedOptions: MergedFolderStructureOptions = { + maxItems: options?.maxItems ?? MAX_ITEMS, + ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS, + fileIncludePattern: options?.fileIncludePattern, + }; + + try { + // 1. Read the structure using BFS, respecting maxItems + const structureRoot = await readFullStructure(resolvedPath, mergedOptions); + + if (!structureRoot) { + return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`; + } + + // 2. Format the structure into a string + const structureLines: string[] = []; + // Pass true for isRoot for the initial call + formatStructure(structureRoot, '', true, true, structureLines); + + // 3. Build the final output string + const displayPath = resolvedPath.replace(/\\/g, '/'); + + let disclaimer = ''; + // Check if truncation occurred anywhere or if ignored folders are present. + // A simple check: if any node indicates more files/subfolders, or is ignored. + let truncationOccurred = false; + function checkForTruncation(node: FullFolderInfo) { + if (node.hasMoreFiles || node.hasMoreSubfolders || node.isIgnored) { + truncationOccurred = true; + } + if (!truncationOccurred) { + for (const sub of node.subFolders) { + checkForTruncation(sub); + if (truncationOccurred) break; + } + } + } + checkForTruncation(structureRoot); + + if (truncationOccurred) { + disclaimer = `Folders or files indicated with ${TRUNCATION_INDICATOR} contain more items not shown, were ignored, or the display limit (${mergedOptions.maxItems} items) was reached.`; + } + + const summary = + `Showing up to ${mergedOptions.maxItems} items (files + folders). ${disclaimer}`.trim(); + + return `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`; + } catch (error: unknown) { + console.error(`Error getting folder structure for ${resolvedPath}:`, error); + return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`; + } +} |
