codingest 0.2.32

codingest — standalone extraction of KGLite's code-tree component: parse polyglot codebases into kglite knowledge graphs (tree-sitter parsers, call/type/route edges, docs pass, multi-rev merge)
Documentation
//! Language parsers: one file per language grammar.
//!
//! `LanguageParser` is the dispatch trait — `parse_file` handles a single
//! source file, `parse_directory` walks the tree in parallel via rayon.

pub mod agc;
pub mod cpp;
pub mod csharp;
pub mod css;
pub mod dart;
pub mod go;
pub mod html;
pub mod java;
pub mod julia;
pub mod php;
pub mod python;
pub mod r;
pub mod registry;
pub mod rust_lang;
pub mod shared;
pub mod swift;
pub mod typescript;

use crate::models::ParseResult;
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use walkdir::WalkDir;

pub use registry::EXTENSION_MAP;

/// Stack size for the parser worker pool.
///
/// Tree-sitter is recursive-descent. On macOS, std::thread (and therefore
/// rayon) workers default to ~2 MB stacks while the main thread gets 8 MB.
/// Pathological-but-valid inputs in the wild — e.g. dotnet/runtime's
/// `JIT/Regression/JitBlue/GitHub_10215.cs`, a regression test that is
/// literally a chain of thousands of `+` operators designed to stress
/// recursive tree walkers — overflow the worker stack and SIGBUS the
/// process. 16 MB clears every real-world case we've encountered without
/// meaningfully affecting RSS (stacks are demand-paged).
const PARSER_THREAD_STACK_SIZE: usize = 16 * 1024 * 1024;

/// Shared rayon pool used by every language parser's `parse_directory`.
/// Lazily initialised on first use, then reused across languages and across
/// repeat `build()` calls in the same process.
pub(crate) fn parser_pool() -> &'static rayon::ThreadPool {
    static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
    POOL.get_or_init(|| {
        rayon::ThreadPoolBuilder::new()
            .stack_size(PARSER_THREAD_STACK_SIZE)
            .thread_name(|i| format!("kglite-parser-{i}"))
            .build()
            .expect("failed to build kglite parser thread pool")
    })
}

/// Look up a language identifier for a file path by extension.
pub fn language_for_path(path: &Path) -> Option<&'static str> {
    let ext = path.extension()?.to_str()?;
    registry::language_for_extension(ext)
}

/// Abstract language parser. One implementor per grammar.
pub trait LanguageParser: Sync {
    /// Canonical language identifier (e.g. `"python"`, `"rust"`).
    fn language_name(&self) -> &'static str;

    /// File extensions (without leading dot) this parser handles.
    fn file_extensions(&self) -> &'static [&'static str];

    /// Parse a single source file at `filepath` (absolute) relative to `src_root`.
    fn parse_file(&self, filepath: &Path, src_root: &Path) -> ParseResult;

    /// Combine the per-file results of ONE (source root, language) slice into
    /// that slice's single result, in the order the files were dispatched.
    ///
    /// The default is a plain in-order merge, which is exactly what an
    /// order-preserving parallel reduce over `merge` produced before this hook
    /// existed (`ParseResult::merge` only appends, so it is associative).
    ///
    /// The hook exists because a parser may need a **cross-file post-pass**
    /// over its own slice — AGC promotes `role_hint` and synthesises
    /// ALIAS_OF / POINTS_TO edges by resolving names across the files it just
    /// parsed (see `agc::AgcParser::finalize`). The build orchestrator
    /// dispatches every file of every root/language through one flat parallel
    /// worklist, then hands each slice back here, so such a post-pass keeps
    /// seeing exactly the files of its own root and language — never a wider
    /// set, which would change what its cross-file resolution can reach.
    fn finalize(&self, per_file: Vec<ParseResult>) -> ParseResult {
        let mut combined = ParseResult::new();
        for result in per_file {
            combined.merge(result);
        }
        combined
    }

    /// Parse a pre-collected slice of file paths in parallel, then `finalize`
    /// it. Kept as the single-language entry point for tests and callers that
    /// hold one slice; `build()` uses one flat cross-language worklist and
    /// calls `parse_file` + `finalize` directly.
    fn parse_files(&self, files: &[PathBuf], src_root: &Path) -> ParseResult {
        let per_file: Vec<ParseResult> = parser_pool().install(|| {
            files
                .par_iter()
                .map(|fp| self.parse_file(fp, src_root))
                .collect()
        });
        self.finalize(per_file)
    }

    /// Parse every matching file under `src_root`. Convenience wrapper that
    /// walks the tree and delegates to `parse_files`. Build-time orchestrator
    /// uses a single shared walk instead — this entry point is kept for
    /// callers that want to parse a single language end-to-end.
    fn parse_directory(&self, src_root: &Path, verbose: bool) -> ParseResult {
        let files: Vec<PathBuf> = WalkDir::new(src_root)
            .into_iter()
            .filter_map(Result::ok)
            .filter(|e| e.file_type().is_file())
            .filter_map(|entry| {
                let path = entry.path();
                let ext = path.extension()?.to_str()?;
                if self.file_extensions().contains(&ext) {
                    Some(path.to_path_buf())
                } else {
                    None
                }
            })
            .collect();
        if verbose {
            eprintln!("  Found {} {} files", files.len(), self.language_name());
        }
        self.parse_files(&files, src_root)
    }
}

/// Detect every language present under `src_root`. Sorted for deterministic output.
pub fn detect_languages(src_root: &Path) -> Vec<&'static str> {
    let mut langs: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
    for entry in WalkDir::new(src_root).into_iter().filter_map(Result::ok) {
        if entry.file_type().is_file() {
            if let Some(lang) = language_for_path(entry.path()) {
                langs.insert(lang);
            }
        }
    }
    langs.into_iter().collect()
}

/// Get a parser instance for the given language identifier.
///
/// Returns `None` if the language is unknown. Individual parsers are
/// slotted in as modules are ported; unimplemented languages return `None`.
pub fn get_parser(language: &str) -> Option<Box<dyn LanguageParser + Send + Sync>> {
    registry::parser(language)
}

/// Auto-detect languages under `src_root` and return parser instances for each.
pub fn get_parsers_for_directory(src_root: &Path) -> Vec<Box<dyn LanguageParser + Send + Sync>> {
    detect_languages(src_root)
        .into_iter()
        .filter_map(get_parser)
        .collect()
}