import { readFileSync, existsSync, readdirSync, statSync, lstatSync } from 'fs'
import { join, relative, resolve, basename } from 'path'
/**
* Directory ignore system.
*
* Consolidates the previously-scattered skip logic in traverse/lang/doc-cruft/server
* into one matcher that all scan sites share.
*
* Pattern sources (in precedence order):
* 1. Built-in defaults (node_modules, .git, etc.)
* 2. .codetreeignore at rootDir (gitignore subset)
* 3. Nested .codetree-private markers (any dir containing this file is fully hidden)
* 4. extraPatterns (from --ignore CLI flag)
*
* Usage:
* const ignore = buildIgnoreMatcher(rootDir, ['*.secrets'])
* if (ignore(absolutePath)) continue // skip
*/
const BUILTIN_PATTERNS = [
'node_modules',
'.git',
'.pear-git',
'.claude',
'vendor', // codetree's own vendor dir + pear-git's
'thirdparty', // C++ convention
'third_party', // alternate spelling (Google / Chromium / etc.)
'deps', // common dep bundle name
'build',
'dist',
'.DS_Store'
]
const PRIVATE_MARKER = '.codetree-private'
const IGNORE_FILE = '.codetreeignore'
const PRIVATE_SCAN_MAX_DEPTH = 8
export function buildIgnoreMatcher (rootDir, extraPatterns = []) {
const root = resolve(rootDir)
// Collect patterns from all sources
const patterns = [...BUILTIN_PATTERNS]
const ignoreFile = join(root, IGNORE_FILE)
if (existsSync(ignoreFile)) {
try {
const content = readFileSync(ignoreFile, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
patterns.push(trimmed)
}
} catch {}
}
for (const p of extraPatterns) {
if (p && !p.startsWith('#')) patterns.push(p.trim())
}
const compiled = patterns.map(p => compilePattern(p, root)).filter(Boolean)
// Pre-scan for .codetree-private markers — any dir containing this file becomes a
// full-subtree exclusion prefix.
const privatePrefixes = findPrivateMarkers(root)
return function ignore (absPath) {
// Exclude anything under a privately-marked directory
for (const prefix of privatePrefixes) {
if (absPath === prefix || absPath.startsWith(prefix + '/')) return true
}
// Check each compiled pattern
const rel = relative(root, absPath)
if (rel === '' || rel.startsWith('..')) return false // outside the scan root
const name = basename(absPath)
for (const c of compiled) {
if (c.test(rel, name)) return true
}
return false
}
}
// --- Pattern compilation ---
//
// Supports a practical subset of gitignore:
// name → matches a dir/file named "name" at ANY depth
// name/ → matches a DIR named "name" at any depth
// /name → matches a dir/file named "name" only at rootDir
// path/to/name → matches at that exact relative path (anchored to rootDir)
// name*.ext → simple glob: * in name segment matches anything except /
// # comment → skipped by caller
// (negation !foo → not yet supported in v1)
function compilePattern (rawPattern, root) {
let pattern = rawPattern.trim()
if (!pattern) return null
if (pattern.startsWith('!')) return null // v1: no negation
if (pattern.startsWith('#')) return null
const dirOnly = pattern.endsWith('/')
if (dirOnly) pattern = pattern.slice(0, -1)
const anchored = pattern.startsWith('/')
if (anchored) pattern = pattern.slice(1)
// No slash (and not anchored) → name-only pattern, matches at any depth
const nameOnly = !anchored && !pattern.includes('/')
const regex = globToRegex(pattern)
return {
raw: rawPattern,
dirOnly,
test (relPath, name) {
// For a dirOnly pattern we can't reliably tell from the relPath whether the
// target is a directory; callers that know should pass the dirOnly check.
// In practice dirOnly is mostly a documentation hint here; real behavior is
// that dir exclusion propagates via prefix matching below.
if (nameOnly) {
// Match if ANY segment of relPath equals the pattern
const segments = relPath.split('/')
for (const seg of segments) {
if (regex.test(seg)) return true
}
return false
}
if (anchored) {
// Exact match against relPath, or prefix match if target is under this path
if (regex.test(relPath)) return true
if (relPath.startsWith(pattern + '/')) return true
return false
}
// Path with slash but not anchored → match against any suffix of relPath
if (regex.test(relPath)) return true
const segs = relPath.split('/')
for (let i = 1; i < segs.length; i++) {
const suffix = segs.slice(i).join('/')
if (regex.test(suffix)) return true
}
return false
}
}
}
function globToRegex (pattern) {
// Escape regex specials, then re-enable * as wildcard (not crossing /)
let re = ''
for (const ch of pattern) {
if (ch === '*') re += '[^/]*'
else if (ch === '?') re += '[^/]'
else if ('.+^$(){}|\\[]'.includes(ch)) re += '\\' + ch
else re += ch
}
return new RegExp('^' + re + '$')
}
// --- .codetree-private discovery ---
function findPrivateMarkers (root) {
const prefixes = []
scan(root, 0)
return prefixes
function scan (dir, depth) {
if (depth > PRIVATE_SCAN_MAX_DEPTH) return
let entries
try { entries = readdirSync(dir) } catch { return }
// Check this dir first
if (entries.includes(PRIVATE_MARKER)) {
prefixes.push(dir)
return // don't recurse into a private dir — the whole subtree is excluded
}
// Quick built-in prune before recursing (avoids descending into node_modules etc.)
for (const entry of entries) {
if (BUILTIN_PATTERNS.includes(entry)) continue
if (entry.startsWith('.')) continue
const full = join(dir, entry)
let stat
try { stat = lstatSync(full) } catch { continue }
if (!stat.isDirectory() || stat.isSymbolicLink()) continue
scan(full, depth + 1)
}
}
}