OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / lang.js

592 lines · javascript ref:
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { join, dirname, resolve, extname, relative } from 'path'

import { parseFile as parseJS } from './parsers/js.js'
import { parseFile as parseGDScript } from './parsers/gdscript.js'
import { parseFile as parseCpp } from './parsers/cpp.js'
import { parseFile as parseGLSL } from './parsers/glsl.js'

/**
 * Language registry.
 *
 * The single source of truth for all language-specific behavior.
 * Replaces hardcoded assumptions that were previously scattered across
 * traverse.js and tree.js.
 *
 * Adding a new language = add one descriptor to LANGUAGES below.
 */

// --- JS language descriptor ---

const JS = {
  id: 'js',
  extensions: ['.js', '.mjs', '.cjs'],
  projectMarkers: ['package.json'],
  submoduleMarkers: ['package.json'],
  defaultEntries: ['index.js', 'index.mjs'],

  parse: parseJS,

  extractImports (source) {
    const specs = []

    // ESM: import ... from 'specifier'
    const esmPattern = /(?:import|export)\s+.*?\s+from\s+['"]([^'"]+)['"]/g
    let match
    while ((match = esmPattern.exec(source)) !== null) specs.push(match[1])

    // ESM: import 'specifier' (side-effect)
    const sideEffect = /import\s+['"]([^'"]+)['"]/g
    while ((match = sideEffect.exec(source)) !== null) {
      if (!specs.includes(match[1])) specs.push(match[1])
    }

    // CJS: require('specifier')
    const cjsPattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
    while ((match = cjsPattern.exec(source)) !== null) {
      if (!specs.includes(match[1])) specs.push(match[1])
    }

    return specs
  },

  // Resolve a relative import to an absolute file path (or null).
  // For JS, only relative imports participate in the local graph.
  resolveImport (fromFile, specifier /* ctx unused for JS */) {
    if (!specifier.startsWith('.')) return null

    const dir = dirname(fromFile)
    const target = resolve(dir, specifier)

    // Exact file
    if (existsSync(target) && statSync(target).isFile()) return target

    // Try extensions
    for (const ext of ['.js', '.mjs', '.cjs', '.json']) {
      const withExt = target + ext
      if (existsSync(withExt) && statSync(withExt).isFile()) return withExt
    }

    // Try index.js in directory
    if (existsSync(target) && statSync(target).isDirectory()) {
      const idx = join(target, 'index.js')
      if (existsSync(idx)) return idx
    }

    return null
  }
}

// --- GDScript language descriptor ---

const GDScript = {
  id: 'gdscript',
  extensions: ['.gd'],
  projectMarkers: ['project.godot'],
  submoduleMarkers: ['plugin.cfg', 'project.godot'],
  defaultEntries: [],  // Godot projects have no single entry point

  parse: parseGDScript,

  extractImports (source) {
    // preload("res://path") or preload('res://path')
    const specs = []
    const preloadPattern = /preload\s*\(\s*['"]([^'"]+)['"]\s*\)/g
    let match
    while ((match = preloadPattern.exec(source)) !== null) {
      specs.push(match[1])
    }
    // load("res://path") — runtime load, still a real dependency
    const loadPattern = /\bload\s*\(\s*['"]([^'"]+)['"]\s*\)/g
    while ((match = loadPattern.exec(source)) !== null) {
      if (!specs.includes(match[1])) specs.push(match[1])
    }
    return specs
  },

  // Resolve a GDScript import.
  // - class_name: specifiers map through the registry to an absolute path
  // - res:// paths resolve to projectRoot + path
  // - relative paths resolve from the including file's directory
  resolveImport (fromFile, specifier, ctx) {
    // Implicit class_name references — look up in the registry
    if (specifier.startsWith('class_name:')) {
      const className = specifier.slice('class_name:'.length)
      const regPath = ctx && ctx.classNameRegistry && ctx.classNameRegistry[className]
      if (regPath) {
        const full = resolve(ctx.rootDir, regPath)
        if (existsSync(full) && statSync(full).isFile()) return full
      }
      return null
    }

    if (specifier.startsWith('res://')) {
      const rest = specifier.slice('res://'.length)
      const root = (ctx && ctx.rootDir) || dirname(fromFile)
      const full = resolve(root, rest)
      if (existsSync(full) && statSync(full).isFile()) return full
      return null
    }

    if (specifier.startsWith('.') || !specifier.includes('://')) {
      const dir = dirname(fromFile)
      const target = resolve(dir, specifier)
      if (existsSync(target) && statSync(target).isFile()) return target
    }

    return null
  },

  // GDScript-specific: find implicit class_name references in the source.
  // Returns an array of synthetic `class_name:X` specifiers for class names
  // that exist in the project's registry. Called after extractImports().
  extractImplicitImports (source, ctx) {
    if (!ctx || !ctx.classNameRegistry) return []
    const registry = ctx.classNameRegistry
    const found = new Set()

    // Patterns that reveal a type reference to a global class_name:
    //   extends ClassName
    //   var x: ClassName    / const x: ClassName
    //   : ClassName =       / -> ClassName :   (function return type)
    //   ClassName.new()
    //   ClassName.STATIC_MEMBER
    //   func f(arg: ClassName, ...)
    //
    // Capture any identifier that starts with a capital letter after the relevant
    // keyword or punctuation, then filter by the registry.
    const patterns = [
      /\bextends\s+([A-Z][\w]*)\b/g,
      /:\s*([A-Z][\w]*)(?:\s*[=,)\]}\n])/g,
      /->\s*([A-Z][\w]*)\s*:/g,
      /\b([A-Z][\w]*)\s*\.\s*new\s*\(/g
    ]

    for (const p of patterns) {
      let m
      while ((m = p.exec(source)) !== null) {
        const name = m[1]
        if (registry[name]) found.add(name)
      }
    }

    return [...found].map(name => 'class_name:' + name)
  }
}

// --- C++ language descriptor ---

const Cpp = {
  id: 'cpp',
  extensions: ['.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.hh', '.hxx'],
  projectMarkers: ['SConstruct', 'CMakeLists.txt'],
  submoduleMarkers: ['SConstruct', 'CMakeLists.txt'],
  defaultEntries: [],

  parse: parseCpp,

  extractImports (source) {
    // #include "local.h" — quoted only. System <...> includes are skipped.
    const specs = []
    const pattern = /^\s*#\s*include\s+"([^"]+)"/gm
    let match
    while ((match = pattern.exec(source)) !== null) {
      specs.push(match[1])
    }
    return specs
  },

  resolveImport (fromFile, specifier, ctx) {
    // Try relative to the including file's directory first
    const fromDir = dirname(fromFile)
    const relTarget = resolve(fromDir, specifier)
    if (existsSync(relTarget) && statSync(relTarget).isFile()) return relTarget

    // Then try the pre-built header map (convention-based include paths)
    if (ctx && ctx.headerMap) {
      if (specifier.includes('/')) {
        // path-qualified specifier: try each include root + specifier
        for (const root of ctx.includeRoots || []) {
          const full = resolve(root, specifier)
          if (existsSync(full) && statSync(full).isFile()) return full
        }
      } else {
        // bare filename: direct lookup in header map
        const abs = ctx.headerMap[specifier]
        if (abs && existsSync(abs) && statSync(abs).isFile()) return abs
      }
    }

    return null
  }
}

// --- GLSL language descriptor ---
// GLSL shaders have no project structure of their own — they piggyback on whatever
// project contains them (Godot .gdshader or C++ GDExtension .glsl). No projectMarkers.

const GLSL = {
  id: 'glsl',
  extensions: ['.glsl', '.gdshader', '.vert', '.frag', '.comp'],
  projectMarkers: [],         // shaders never define a project alone
  submoduleMarkers: [],
  defaultEntries: [],

  parse: parseGLSL,

  extractImports (source) {
    // #include is uncommon but possible (Godot gdshader, some glsl preprocessors)
    const specs = []
    const pattern = /^\s*#\s*include\s+["<]([^">]+)[">]/gm
    let match
    while ((match = pattern.exec(source)) !== null) {
      specs.push(match[1])
    }
    return specs
  },

  resolveImport (fromFile, specifier) {
    if (specifier.startsWith('.') || !specifier.includes('://')) {
      const target = resolve(dirname(fromFile), specifier)
      if (existsSync(target) && statSync(target).isFile()) return target
    }
    return null
  }
}

// --- Language registry ---
// Order matters for detectProject(): most-specific markers first.
// project.godot, SConstruct and CMakeLists.txt are more distinctive than package.json —
// a Godot or GDExtension project may also have package.json (for dev tools) and we want
// to classify it by its primary nature.

const LANGUAGES = [GDScript, Cpp, JS, GLSL]

const EXT_TO_LANG = (() => {
  const map = new Map()
  for (const lang of LANGUAGES) {
    for (const ext of lang.extensions) map.set(ext, lang)
  }
  return map
})()

// --- Public API ---

/**
 * Map a file path to its language descriptor, or null if no registered language claims it.
 */
export function getLang (filePath) {
  const ext = extname(filePath)
  return EXT_TO_LANG.get(ext) || null
}

/**
 * True if any registered language claims this file's extension.
 * Replaces hardcoded .js/.mjs/.cjs checks.
 */
export function isSourceFile (filePath) {
  return EXT_TO_LANG.has(extname(filePath))
}

/**
 * Detect what type of project lives at rootDir by scanning for marker files.
 * Returns { type, languages: [langId,...] } — the primary project type plus
 * any other languages whose files are present.
 */
export function detectProject (rootDir) {
  const markers = []

  for (const lang of LANGUAGES) {
    for (const marker of lang.projectMarkers || []) {
      if (existsSync(join(rootDir, marker))) {
        markers.push({ type: lang.id, marker })
      }
    }
  }

  // Primary type: first marker found.
  const primary = markers[0] || { type: 'unknown', marker: null }

  return {
    type: primary.type,
    marker: primary.marker,
    languages: [...new Set(markers.map(m => m.type))]
  }
}

/**
 * Find module boundaries within a project.
 *
 * For JS projects this means: directories containing package.json.
 * Other languages will plug in their own boundary detection.
 *
 * Returns: array of directory paths (absolute) representing module roots.
 */
export function findModuleBoundaries (rootDir, projectType, opts = {}) {
  const maxDepth = opts.maxDepth || 2
  const ignore = opts.ignore || (() => false)
  const roots = []

  let markers
  const lang = LANGUAGES.find(l => l.id === projectType)
  if (lang) {
    markers = lang.submoduleMarkers || lang.projectMarkers || []
  } else {
    // Unknown root (e.g. a parent directory of several projects). Scan for
    // any registered project marker — this makes codetree useful for monorepos
    // that aren't themselves a single project.
    markers = []
    for (const l of LANGUAGES) {
      for (const m of l.projectMarkers || []) markers.push(m)
    }
  }

  if (markers.length === 0) return [rootDir]

  // Root is always a module if it has any marker
  if (markers.some(m => existsSync(join(rootDir, m)))) {
    roots.push(rootDir)
  }

  scanForModuleMarkers(rootDir, markers, roots, 0, maxDepth, ignore)
  return roots
}

function scanForModuleMarkers (dir, markers, roots, depth, maxDepth, ignore) {
  if (depth >= maxDepth) return

  let entries
  try { entries = readdirSync(dir) } catch { return }

  for (const entry of entries) {
    // test/ directories aren't module roots — they're tests inside a module.
    // Other skips come from the ignore matcher.
    if (entry === 'test') continue
    const full = join(dir, entry)
    if (ignore(full)) continue

    let stat
    try { stat = statSync(full) } catch { continue }
    if (!stat.isDirectory()) continue

    if (markers.some(m => existsSync(join(full, m)))) {
      roots.push(full)
    }

    scanForModuleMarkers(full, markers, roots, depth + 1, maxDepth, ignore)
  }
}

/**
 * Build a resolution context for a project.
 *
 * This is a language-specific data structure needed for import resolution.
 * For JS: import maps (from package.json) — currently not populated here because
 *         JS resolution uses only relative paths.
 * For GDScript (future): class_name registry.
 * For C++ (future): header name → absolute path map.
 */
export function buildResolutionContext (rootDir, projectType, ignore, opts = {}) {
  const ig = ignore || (() => false)
  const ctx = { projectType, rootDir, ignore: ig, shallow: !!opts.shallow }

  // Shallow scans don't resolve internal imports — skip the expensive project-wide
  // pre-scans (class_name registry, C++ header map). These are the single biggest
  // cost in buildResolutionContext for large projects.
  if (opts.shallow) return ctx

  if (projectType === 'gdscript') {
    // Scan every .gd file in the project for `class_name X` declarations.
    // This builds the global class registry that GDScript uses for implicit imports
    // (type references like `extends Foo` or `var x: Foo` don't need an explicit preload
    // when Foo is a class_name somewhere else in the project).
    ctx.classNameRegistry = buildGDScriptClassNameRegistry(rootDir, ig)
  }

  if (projectType === 'cpp') {
    // Build header-name → absolute-path map by scanning conventional include roots.
    // This is a convention-based alternative to parsing SConstruct/CMakeLists.txt for CPPPATH.
    const { headerMap, includeRoots } = buildCppHeaderMap(rootDir, ig)
    ctx.headerMap = headerMap
    ctx.includeRoots = includeRoots
  }

  return ctx
}

// --- C++ header map ---

function buildCppHeaderMap (rootDir, ignore) {
  const headerMap = {}
  const includeRoots = []

  // Always consider the root itself
  includeRoots.push(rootDir)

  // Find conventional include roots
  scanForIncludeRoots(rootDir, rootDir, includeRoots, 0, 3, ignore)

  // Walk each include root and build the header map
  for (const root of includeRoots) {
    scanForHeaders(root, root, headerMap, 0, 6, ignore)
  }

  return { headerMap, includeRoots }
}

function scanForIncludeRoots (dir, rootDir, roots, depth, maxDepth, ignore) {
  if (depth >= maxDepth) return

  let entries
  try { entries = readdirSync(dir) } catch { return }

  for (const entry of entries) {
    if (entry.startsWith('.')) continue
    const full = join(dir, entry)
    if (ignore(full)) continue

    let stat
    try { stat = statSync(full) } catch { continue }
    if (!stat.isDirectory()) continue

    // Standard C++ include conventions
    if (entry === 'include' || entry === 'src' || entry === 'thirdparty') {
      roots.push(full)
      // Also look for <lib>/include patterns inside these
      const subInclude = join(full, 'include')
      try {
        if (existsSync(subInclude) && statSync(subInclude).isDirectory()) {
          roots.push(subInclude)
        }
      } catch {}
    }

    // Recurse into thirdparty/{lib}/include patterns
    if (depth < maxDepth - 1) {
      scanForIncludeRoots(full, rootDir, roots, depth + 1, maxDepth, ignore)
    }
  }
}

const HEADER_EXTS = new Set(['.h', '.hpp', '.hh', '.hxx'])

function scanForHeaders (dir, root, headerMap, depth, maxDepth, ignore) {
  if (depth >= maxDepth) return

  let entries
  try { entries = readdirSync(dir) } catch { return }

  for (const entry of entries) {
    if (entry.startsWith('.')) continue
    const full = join(dir, entry)
    if (ignore(full)) continue

    let stat
    try { stat = statSync(full) } catch { continue }

    if (stat.isDirectory()) {
      scanForHeaders(full, root, headerMap, depth + 1, maxDepth, ignore)
    } else {
      const ext = extname(entry)
      if (HEADER_EXTS.has(ext)) {
        // Bare filename is the key; if collision, prefer first found (innermost include root wins)
        if (!headerMap[entry]) headerMap[entry] = full
      }
    }
  }
}

function buildGDScriptClassNameRegistry (rootDir, ignore) {
  const registry = {}
  scanForGDClassNames(rootDir, rootDir, registry, 0, 10, ignore)
  return registry
}

function scanForGDClassNames (dir, rootDir, registry, depth, maxDepth, ignore) {
  if (depth >= maxDepth) return

  let entries
  try { entries = readdirSync(dir) } catch { return }

  for (const entry of entries) {
    if (entry.startsWith('.')) continue
    const full = join(dir, entry)
    if (ignore(full)) continue

    let stat
    try { stat = statSync(full) } catch { continue }

    if (stat.isDirectory()) {
      scanForGDClassNames(full, rootDir, registry, depth + 1, maxDepth, ignore)
    } else if (entry.endsWith('.gd')) {
      // Only read the first ~500 bytes — class_name must be at the top per GDScript convention
      try {
        const fd = readFileSync(full, 'utf-8')
        const head = fd.slice(0, 500)
        const match = head.match(/^\s*class_name\s+([A-Za-z_][\w]*)/m)
        if (match) {
          registry[match[1]] = relative(rootDir, full)
        }
      } catch {}
    }
  }
}

/**
 * Read a project's metadata file (language-appropriate).
 * For JS: package.json. For Godot: project.godot. For C++: SConstruct (skipped).
 */
export function readProjectMeta (dir, projectType) {
  if (projectType === 'js') {
    const pkgPath = join(dir, 'package.json')
    try { return JSON.parse(readFileSync(pkgPath, 'utf-8')) }
    catch { return null }
  }
  return null
}

/**
 * Resolve a project's entry point (language-appropriate).
 */
export function resolveEntry (pkgDir, meta, projectType) {
  const lang = LANGUAGES.find(l => l.id === projectType)
  if (!lang) return null

  if (projectType === 'js' && meta) {
    // exports field first
    if (meta.exports) {
      const entry = resolveJSExportsEntry(meta.exports)
      if (entry) {
        const full = resolve(pkgDir, entry)
        if (existsSync(full)) return full
      }
    }
    // main field
    if (meta.main) {
      const full = resolve(pkgDir, meta.main)
      if (existsSync(full)) return full
    }
  }

  // Fall back to language default entry names
  for (const name of lang.defaultEntries || []) {
    const full = resolve(pkgDir, name)
    if (existsSync(full)) return full
  }

  return null
}

function resolveJSExportsEntry (exports) {
  if (typeof exports === 'string') return exports
  if (typeof exports === 'object' && exports !== null) {
    const dot = exports['.']
    if (!dot) return null
    if (typeof dot === 'string') return dot
    if (typeof dot === 'object') {
      return dot.bare || dot.import || dot.require || dot.default || null
    }
  }
  return null
}