UNPKG

phind-cli

Version:

A modern, intuitive, cross-platform command-line tool for finding files and directories recursively, designed with developers in mind.

374 lines 21.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DirectoryTraverser = void 0; // src/traverser.ts const promises_1 = __importDefault(require("fs/promises")); const path_1 = __importDefault(require("path")); const micromatch_1 = __importDefault(require("micromatch")); class DirectoryTraverser { // --- END: Add collected results array --- constructor(options, basePath) { // --- START: Add collected results array --- this.collectedResults = []; this.options = options; this.basePath = path_1.default.resolve(basePath); this.baseMicromatchOptions = { nocase: this.options.ignoreCase, dot: true }; // Pre-calculate include patterns that are not the default '*' for override logic this.nonDefaultIncludePatterns = options.includePatterns.filter(p => p !== '*'); // --- START: Initialize OutputMode --- this.outputMode = options.outputMode ?? 'print'; // Default to print // --- END: Initialize OutputMode --- } /** * Checks if an item matches any pattern in a list. * [ ... matchesAnyPattern implementation remains unchanged ... ] */ matchesAnyPattern(name, fullPath, relativePath, patterns) { if (!patterns || patterns.length === 0) { return false; } // Normalize paths for consistent matching, always using forward slashes const absPathNormalized = path_1.default.normalize(fullPath).replace(/\\/g, '/'); // Ensure relative path is calculated correctly for the root case as well const relPathNormalized = this.options.relativePaths ? (path_1.default.normalize(fullPath) === path_1.default.normalize(this.basePath) ? '.' : path_1.default.normalize(relativePath).replace(/\\/g, '/')) : ''; // Only calculate if needed const basePathsToTest = [ name, // Base name (e.g., 'file.txt', 'node_modules') absPathNormalized // Absolute path (e.g., '/User/project/src/file.txt') ]; if (this.options.relativePaths && relPathNormalized && !basePathsToTest.includes(relPathNormalized)) { basePathsToTest.push(relPathNormalized); // Relative path (e.g., '.', 'src/file.txt') } // --- FIX START: Check each pattern individually for /** --- for (const pattern of patterns) { let currentPathsToTest = [...basePathsToTest]; // Copy base paths for this pattern // If pattern ends with /**, it should match contents, not the dir itself. // Remove the path representation that *is* the directory base from the list // of paths we test against *this specific pattern*. if (pattern.endsWith('/**')) { const patternBase = pattern.substring(0, pattern.length - 3); // e.g., 'dir1' or '/abs/path/dir1' // Filter out paths that are exactly the base of the globstar pattern currentPathsToTest = currentPathsToTest.filter(p => { // Normalize the path being tested FOR THIS COMPARISON ONLY const normalizedTestPath = path_1.default.normalize(p).replace(/\\/g, '/'); // Normalize the pattern base FOR THIS COMPARISON ONLY const normalizedPatternBase = path_1.default.normalize(patternBase).replace(/\\/g, '/'); return normalizedTestPath !== normalizedPatternBase; }); // If filtering removed all paths, this pattern cannot match if (currentPathsToTest.length === 0) { continue; // Skip to the next pattern } } // Special handling for '.' and '.*' include patterns if (pattern === '.' && !this.options.relativePaths) { continue; // Skip "." if relative paths are disabled } if (pattern === '.*' && name !== '.' && !name.startsWith('.')) { continue; //If pattern is '.*' and name doesnt starts with '.' } // Now check if *any* of the potentially filtered paths match the current pattern if (micromatch_1.default.some(currentPathsToTest, [pattern], this.baseMicromatchOptions)) { return true; // Found a match with this pattern } } // --- FIX END --- return false; // No pattern matched after applying the /** filter logic } /** Calculates the relative path string based on options. */ calculateRelativePath(fullPath) { if (!this.options.relativePaths) { return ''; } if (path_1.default.normalize(fullPath) === path_1.default.normalize(this.basePath)) { return '.'; // The starting directory itself is always '.' } let relPath = path_1.default.relative(this.basePath, fullPath); // Normalize separators FIRST for reliable startsWith check and consistent output relPath = relPath.replace(/\\/g, '/'); // Prepend './' if it's not empty, not '.', and doesn't already start with '../' if (relPath && relPath !== '.' && !relPath.startsWith('..')) { relPath = './' + relPath; } else if (!relPath) { // Fallback if path.relative somehow returns empty for non-base paths // This case is less likely but provides a fallback. relPath = path_1.default.basename(fullPath).replace(/\\/g, '/'); if (relPath && relPath !== '.' && !relPath.startsWith('..')) { relPath = './' + relPath; } } // Paths starting with '../' or just '.' are returned as is (after slash normalization) return relPath; } /** Prepares a list of "explicit" include patterns used for overriding directory pruning. */ getExplicitIncludePatternsForDirectoryOverride() { // Filter out broad patterns that shouldn't override specific default excludes const specificNonDefaultIncludes = this.nonDefaultIncludePatterns.filter(p => p !== '*' && p !== '.*' && p !== '**'); if (specificNonDefaultIncludes.length === 0) { return []; } const derivedPatterns = specificNonDefaultIncludes.map(p => { // If pattern targets content (e.g., dir/file, dir/**), derive the dir name itself if (p.includes('/') || p.includes(path_1.default.sep)) { const base = p.split(/\/|\\/)[0]; if (base && !base.includes('*')) return base; // Return first path segment if non-glob } if (p.endsWith('/**')) return p.substring(0, p.length - 3); if (p.endsWith('/')) return p.substring(0, p.length - 1); return null; }).filter((p) => p !== null && !p.includes('*')); // Only non-glob derived patterns // Combine specific original non-default patterns with derived patterns for directory name matching return [...new Set([...specificNonDefaultIncludes, ...derivedPatterns])]; } // ======================================================================== // == START shouldPrune CHANGES == // ======================================================================== /** Checks if a directory should be pruned. */ shouldPrune(name, fullPath, relativePath) { // 1. Check if excluded by any pattern in the *effective* exclude list const isExcluded = this.matchesAnyPattern(name, fullPath, relativePath, this.options.excludePatterns); if (!isExcluded) { return false; // Not excluded, definitely don't prune } // --- Item IS excluded. Check for explicit include override --- // Override Check 1: Does the directory ITSELF match an explicit non-default include pattern? const explicitDirIncludes = this.getExplicitIncludePatternsForDirectoryOverride(); if (explicitDirIncludes.length > 0) { if (this.matchesAnyPattern(name, fullPath, relativePath, explicitDirIncludes)) { // console.log(`DEBUG: [Prune Override 1] Not pruning "${name}" because it (directory) is explicitly included by name/path.`); return false; // Directory itself is explicitly included, DO NOT prune } } // --- REFINED Override Check 2: --- // If the directory is excluded ONLY by a DEFAULT pattern, AND there exists a non-default // include pattern that appears to target something *inside* this directory, DO NOT prune. const isExcludedByDefault = this.matchesAnyPattern(name, fullPath, relativePath, this.options.defaultExcludes); const cliAndGlobalExcludes = this.options.excludePatterns.filter(p => !this.options.defaultExcludes.includes(p)); const isExcludedByCliOrGlobal = this.matchesAnyPattern(name, fullPath, relativePath, cliAndGlobalExcludes); if (isExcludedByDefault && !isExcludedByCliOrGlobal && this.nonDefaultIncludePatterns.length > 0) { const normFullPathPrefix = path_1.default.normalize(fullPath).replace(/\\/g, '/') + '/'; // Normalize and add trailing slash const normRelativePathPrefix = this.options.relativePaths ? path_1.default.normalize(relativePath).replace(/\\/g, '/') + '/' : ''; // Check if any non-default include pattern starts with the path of the directory being considered const targetsContentInside = this.nonDefaultIncludePatterns.some(p => { const normPattern = path_1.default.normalize(p).replace(/\\/g, '/'); // Check absolute path prefix, OR relative path prefix if applicable // Ensure pattern isn't just the directory path itself (already handled by Override 1) // Check if pattern length is greater than prefix length to ensure it's targeting *inside* return (normPattern.startsWith(normFullPathPrefix) && normPattern.length > normFullPathPrefix.length) || (this.options.relativePaths && normRelativePathPrefix && normPattern.startsWith(normRelativePathPrefix) && normPattern.length > normRelativePathPrefix.length); }); if (targetsContentInside) { // console.log(`DEBUG: [Prune Override 2 - Refined] Not pruning "${name}" because a non-default include targets content inside.`); return false; // Found an include targeting content inside, DO NOT prune } } // --- END REFINED Override Check 2 --- // console.log(`DEBUG: Pruning "${name}" as it's excluded and not overridden.`); return true; // Excluded and not overridden. PRUNE. } // ======================================================================== // == END shouldPrune CHANGES == // ======================================================================== /** Checks if an item (file or directory) should be printed based on all filters. */ shouldPrintItem(name, fullPath, relativePath, isDirectory, isFile) { // --- ADDED: Prevent printing '.' when relativePaths is true --- // The '.' represents the base for relative paths, not an item to list itself. if (name === '.' && this.options.relativePaths && path_1.default.normalize(fullPath) === path_1.default.normalize(this.basePath)) { // console.log(`DEBUG: Not printing "." for the starting directory in relative mode.`); return false; } // --- END ADD --- // 1. Type Check (remains the same) const { matchType } = this.options; if (matchType) { if (matchType === 'f' && !isFile) return false; if (matchType === 'd' && !isDirectory) return false; } // 2. Include Check: Item must match at least one include pattern. const isIncluded = this.matchesAnyPattern(name, fullPath, relativePath, this.options.includePatterns); if (!isIncluded) { // console.log(`DEBUG: Not printing "${name}" (doesn't match includes)`); return false; } // 3. Exclude Check: Check against the combined exclude patterns. const isExcluded = this.matchesAnyPattern(name, fullPath, relativePath, this.options.excludePatterns); // --- Decision Logic --- if (isExcluded) { // Item is excluded. Override ONLY IF it matches a *specific* non-default include // pattern that effectively targets this item, particularly for overriding default excludes. // Broad patterns like '*' or '.*' should NOT override specific default excludes like '.git'. // Check if it's excluded *specifically* by a default exclude pattern. const isExcludedByDefault = this.matchesAnyPattern(name, fullPath, relativePath, this.options.defaultExcludes); if (isExcludedByDefault && this.nonDefaultIncludePatterns.length > 0) { // Check if any non-default include *specifically* targets this item. // This requires a more refined check. We look for patterns that essentially // match the item's name or a path segment identical to a default exclude. // This prevents broad patterns like '*.js' or '.*' from overriding '.git'. const specificTargetingIncludes = this.nonDefaultIncludePatterns.filter(p => { const normPattern = path_1.default.normalize(p).replace(/\\/g, '/'); // Does the pattern exactly match the name? (e.g., pattern '.git' matches name '.git') if (normPattern === name) return true; // Does the pattern exactly match the relative path? (e.g., pattern 'node_modules' matches relative path 'node_modules') if (this.options.relativePaths && normPattern === relativePath) return true; // Does the pattern exactly match the absolute path? const normFullPath = path_1.default.normalize(fullPath).replace(/\\/g, '/'); if (normPattern === normFullPath) return true; // Allow 'node_modules/**' or '.git/**' to override the default exclude FOR CONTENTS if (p.endsWith('/**')) { const patternBase = p.substring(0, p.length - 3); const normPatternBase = path_1.default.normalize(patternBase).replace(/\\/g, '/'); // Check if the item's path *starts with* the base of the /** pattern // And ensure the item is *not* the base directory itself if ((relativePath.startsWith(normPatternBase + '/') || normFullPath.startsWith(normPatternBase + '/')) && (relativePath !== normPatternBase && normFullPath !== normPatternBase)) { return true; } } // Add more sophisticated logic here if needed, e.g., pattern 'dist' overriding default exclude 'dist' return false; }); if (specificTargetingIncludes.length > 0) { // Check if it's ALSO excluded by a CLI/Global pattern. If so, the CLI/Global exclude should still win. const cliAndGlobalExcludes = this.options.excludePatterns.filter(p => !this.options.defaultExcludes.includes(p)); const isExcludedByCliOrGlobal = this.matchesAnyPattern(name, fullPath, relativePath, cliAndGlobalExcludes); if (!isExcludedByCliOrGlobal) { // console.log(`DEBUG: Printing "${name}" because it matches a SPECIFIC non-default include [${specificTargetingIncludes.join(', ')}] and is NOT excluded by CLI/Global (overriding default exclusion).`); return true; // Explicitly targeted by specific non-default include, override default exclusion - PRINT } else { // console.log(`DEBUG: Not printing "${name}" despite specific include, because it IS excluded by CLI/Global pattern.`); } } } // If not excluded by default (meaning excluded by CLI/Global) OR // if excluded by default but not specifically overridden above, then DO NOT PRINT. // console.log(`DEBUG: Not printing "${name}" (excluded, and not specifically included to override).`); return false; } else { // Item is included and not excluded - PRINT // console.log(`DEBUG: Printing "${name}" (included and not excluded).`); return true; } } // End shouldPrintItem // --- START: Modify handleResult --- /** Handles printing or collecting a found item. */ handleResult(displayPath) { if (this.outputMode === 'collect') { this.collectedResults.push(displayPath); } else { console.log(displayPath); // Default behavior } } // --- END: Modify handleResult --- /** Main traversal method */ async traverse(startPath, currentDepth = 0) { const resolvedStartPath = path_1.default.resolve(startPath); let canReadEntries = false; let isStartDir = false; if (currentDepth === 0) { // Handle the starting path itself try { const stats = await promises_1.default.stat(resolvedStartPath); const isDirectory = stats.isDirectory(); const isFile = stats.isFile(); isStartDir = isDirectory; const dirName = path_1.default.basename(resolvedStartPath); const relativePathForStart = this.calculateRelativePath(resolvedStartPath); const displayPath = this.options.relativePaths ? relativePathForStart : resolvedStartPath; if (this.shouldPrintItem(dirName, resolvedStartPath, relativePathForStart, isDirectory, isFile)) { // --- Use handleResult --- this.handleResult(displayPath); } if (isDirectory) { canReadEntries = true; } } catch (err) { console.error(`Error accessing start path ${resolvedStartPath.replace(/\\/g, '/')}: ${err.message}`); return; } } else { // We wouldn't be called at depth > 0 unless the parent was a directory canReadEntries = true; } // Stop recursion checks if (currentDepth >= this.options.maxDepth) { return; } if (!canReadEntries) { return; } // Read Directory Entries let entries; try { entries = await promises_1.default.readdir(resolvedStartPath, { withFileTypes: true }); } catch (err) { // Log errors appropriately (avoid duplicate logging for start path) if (currentDepth > 0 || !isStartDir) { if (err.code === 'EACCES' || err.code === 'EPERM') { console.error(`Permission error reading directory ${resolvedStartPath.replace(/\\/g, '/')}: ${err.message}`); } else { console.error(`Error reading directory ${resolvedStartPath.replace(/\\/g, '/')}: ${err.message}`); } } return; // Stop processing this directory on error } // Process Each Entry for (const dirent of entries) { const entryName = dirent.name; const entryFullPath = path_1.default.join(resolvedStartPath, entryName); const entryRelativePath = this.calculateRelativePath(entryFullPath); const displayPath = this.options.relativePaths ? entryRelativePath : entryFullPath; const isDirectory = dirent.isDirectory(); const isFile = dirent.isFile(); // --- Pruning Check --- if (isDirectory && this.shouldPrune(entryName, entryFullPath, entryRelativePath)) { // console.log(`DEBUG: Pruning directory: ${displayPath}`); continue; } // --- Print Check --- // Rely on shouldPrintItem to handle the '.' case correctly now if (this.shouldPrintItem(entryName, entryFullPath, entryRelativePath, isDirectory, isFile)) { // --- Use handleResult --- this.handleResult(displayPath); } // --- Recurse --- if (isDirectory) { // Depth check for *next* level happens at the start of the recursive call await this.traverse(entryFullPath, currentDepth + 1); } } } // End traverse // --- START: Add getCollectedResults method --- /** Returns the collected results if outputMode was 'collect'. */ getCollectedResults() { if (this.outputMode !== 'collect') { console.warn("Attempted to get collected results when outputMode was not 'collect'."); return []; } return this.collectedResults; } } // End class exports.DirectoryTraverser = DirectoryTraverser; //# sourceMappingURL=traverser.js.map