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
}