OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / ignore.js

193 lines · javascript ref:
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)
    }
  }
}