gdscript-ide 0.5.4

The public AnalysisHost/Analysis API for gdscript-analyzer — engine-neutral, protocol-neutral (POD + byte offsets).
Documentation
//! Ad-hoc corpus runner: load every `.gd` file under a directory through the public
//! [`AnalysisHost`] / [`gdscript_ide::Analysis`] API and report its diagnostics (parse +
//! the Phase-2 §5 type diagnostics). It exercises the full salsa pipeline end to end
//! (`apply_change` → tracked `parse`/`analyze_file` → features), so it doubles as the
//! zero-behaviour-change regression check across the Phase-3 migrations: 0 panics and a
//! stable diagnostic count on a real project.
//!
//! `--project` loads every file into ONE host so the global `class_name` registry is populated
//! and cross-file references resolve — validating project-scale fidelity (M1+). `--per-project`
//! discovers every `project.godot` under the root and loads each sub-project into its OWN host —
//! the *faithful* validation (one `project.godot`, one `class_name` namespace), whereas `--project`
//! merges everything (cross-project collisions expected; the robustness stress test).
//!
//! Usage: `cargo run -p gdscript-ide --example corpus -- <dir> [--show] [--project|--per-project] [--ci]`

use std::path::{Path, PathBuf};

use gdscript_base::{FileId, LineIndex};
use gdscript_ide::{AnalysisHost, Change};

fn collect_gd(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
            if matches!(name, ".godot" | ".git" | "node_modules" | "out" | "target") {
                continue;
            }
            collect_gd(&path, out);
        } else if path.extension().and_then(|e| e.to_str()) == Some("gd") {
            out.push(path);
        }
    }
}

/// The per-project analysis result.
#[derive(Default)]
struct ProjectStats {
    files: usize,
    total_diags: usize,
    parse_errors: usize,
    panics: Vec<PathBuf>,
}

/// Analyze ONE project: load every `.gd` under `root` into a single host (so the global `class_name`
/// registry + cross-file resolution work), supply `root/project.godot` if present, then collect each
/// file's diagnostics (catching panics). The shared core of `--project` and `--per-project`.
fn analyze_project(root: &Path, show: bool) -> ProjectStats {
    let mut files = Vec::new();
    collect_gd(root, &mut files);
    files.sort();

    let mut host = AnalysisHost::new();
    let mut change = Change::new();
    let mut loaded = Vec::new();
    for (i, path) in files.iter().enumerate() {
        if let Ok(src) = std::fs::read_to_string(path) {
            let id = FileId(u32::try_from(i).expect("< 4B files"));
            change.change_file(id, src.as_str());
            // The `res://` path = the file's path relative to the project root, forward-slashed — so
            // `preload("res://…")`/`extends "res://…"` resolve cross-file.
            let rel = path.strip_prefix(root).unwrap_or(path);
            let res_path = format!("res://{}", rel.to_string_lossy().replace('\\', "/"));
            change.set_file_path(id, res_path);
            loaded.push((id, path.clone(), src));
        }
    }
    if let Ok(cfg) = std::fs::read_to_string(root.join("project.godot")) {
        change.set_project_config(cfg.as_str());
    }
    host.apply_change(change);
    let analysis = host.analysis();

    let mut stats = ProjectStats {
        files: loaded.len(),
        ..ProjectStats::default()
    };
    for (id, path, src) in &loaded {
        let id = *id;
        let snap = analysis.clone();
        let run = std::panic::AssertUnwindSafe(move || snap.diagnostics(id).unwrap());
        match std::panic::catch_unwind(run) {
            Ok(d) if d.is_empty() => {}
            Ok(d) => {
                stats.total_diags += d.len();
                stats.parse_errors += d.iter().filter(|x| x.code == "GDSCRIPT_SYNTAX").count();
                if show {
                    let idx = LineIndex::new(src);
                    println!("\n{}  ({} diag)", path.display(), d.len());
                    for diag in &d {
                        let lc = idx.line_col(diag.range.start);
                        let line_text = src.lines().nth(lc.line as usize).unwrap_or("");
                        println!(
                            "  {}:{}  [{}] {}",
                            lc.line + 1,
                            lc.col + 1,
                            diag.code,
                            diag.message
                        );
                        println!("      | {}", line_text.trim_end());
                    }
                }
            }
            Err(_) => stats.panics.push(path.clone()),
        }
    }
    stats
}

/// `--project`: merge EVERYTHING under `dir` into one host (the cross-project robustness stress test).
fn run_project(dir: &str, show: bool, ci: bool) {
    let stats = analyze_project(Path::new(dir), show);
    println!(
        "\n=== corpus (PROJECT mode — merged, cross-file active): {dir} ===\n  files:       {}\n  diagnostics: {}\n  parse errors:{}\n  panics:      {}",
        stats.files,
        stats.total_diags,
        stats.parse_errors,
        stats.panics.len()
    );
    for p in &stats.panics {
        println!("  PANIC: {}", p.display());
    }
    gate_or_exit(ci, stats.parse_errors, stats.panics.len());
}

/// `--per-project`: discover every `project.godot` under `root` and analyze each sub-project in its
/// OWN host — the faithful single-namespace validation (no cross-project `class_name` collisions).
fn run_per_project(root: &str, show: bool, ci: bool) {
    let mut projects = Vec::new();
    find_projects(Path::new(root), &mut projects);
    projects.sort();

    let (mut files, mut diags, mut parse_errors, mut panics) = (0usize, 0usize, 0usize, 0usize);
    for proj in &projects {
        let stats = analyze_project(proj, show);
        files += stats.files;
        diags += stats.total_diags;
        parse_errors += stats.parse_errors;
        panics += stats.panics.len();
        for p in &stats.panics {
            println!("  PANIC ({}): {}", proj.display(), p.display());
        }
    }
    println!(
        "\n=== corpus (PER-PROJECT — {} projects): {root} ===\n  files:       {files}\n  diagnostics: {diags}\n  parse errors:{parse_errors}\n  panics:      {panics}",
        projects.len()
    );
    gate_or_exit(ci, parse_errors, panics);
}

/// Collect every directory under `dir` that holds a `project.godot` (a self-contained Godot project);
/// a project's own subtree is not descended into (projects do not nest).
fn find_projects(dir: &Path, out: &mut Vec<PathBuf>) {
    if dir.join("project.godot").is_file() {
        out.push(dir.to_path_buf());
        return;
    }
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
            if matches!(name, ".godot" | ".git" | "node_modules" | "out" | "target") {
                continue;
            }
            find_projects(&path, out);
        }
    }
}

/// In `--ci`, exit non-zero on any parse error or panic.
fn gate_or_exit(ci: bool, parse_errors: usize, panics: usize) {
    if ci && (parse_errors > 0 || panics > 0) {
        eprintln!("CORPUS GATE FAILED: {parse_errors} parse errors, {panics} panics");
        std::process::exit(1);
    }
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let dir = args
        .first()
        .cloned()
        .expect("usage: corpus <dir> [--show] [--project]");
    let show = args.iter().any(|a| a == "--show");
    let project = args.iter().any(|a| a == "--project");
    let per_project = args.iter().any(|a| a == "--per-project");
    // `--ci`: exit non-zero on any panic or any GDSCRIPT_SYNTAX parse error (type diagnostics —
    // UNSAFE_*, etc. — are the intended value-prop warnings and never fail the gate).
    let ci = args.iter().any(|a| a == "--ci");

    if per_project {
        run_per_project(&dir, show, ci);
        return;
    }
    if project {
        run_project(&dir, show, ci);
        return;
    }

    let mut files = Vec::new();
    collect_gd(Path::new(&dir), &mut files);
    files.sort();

    let (mut total, mut clean, mut with_diags, mut total_diags) = (0usize, 0usize, 0usize, 0usize);
    let mut parse_errors = 0usize;
    let mut panics = Vec::new();

    for path in &files {
        let Ok(src) = std::fs::read_to_string(path) else {
            continue;
        };
        total += 1;
        let src_for_parse = src.clone();
        let result = std::panic::catch_unwind(move || {
            let mut host = AnalysisHost::new();
            let mut change = Change::new();
            change.change_file(FileId(0), src_for_parse.as_str());
            host.apply_change(change);
            host.analysis().diagnostics(FileId(0)).unwrap()
        });
        match result {
            Ok(diags) if diags.is_empty() => clean += 1,
            Ok(diags) => {
                with_diags += 1;
                total_diags += diags.len();
                parse_errors += diags.iter().filter(|d| d.code == "GDSCRIPT_SYNTAX").count();
                if show {
                    let idx = LineIndex::new(&src);
                    println!("\n{}  ({} diag)", path.display(), diags.len());
                    for d in &diags {
                        let lc = idx.line_col(d.range.start);
                        let line_text = src.lines().nth(lc.line as usize).unwrap_or("");
                        println!(
                            "  {}:{}  [{}] {}",
                            lc.line + 1,
                            lc.col + 1,
                            d.code,
                            d.message
                        );
                        println!("      | {}", line_text.trim_end());
                    }
                }
            }
            Err(_) => panics.push(path.clone()),
        }
    }

    println!(
        "\n=== corpus: {dir} ===\n  files:       {total}\n  clean:       {clean}\n  with diags:  {with_diags} ({total_diags} diagnostics)\n  parse errors:{parse_errors}\n  panics:      {}",
        panics.len()
    );
    for p in &panics {
        println!("  PANIC: {}", p.display());
    }
    // The CI gate: any panic or any GDSCRIPT_SYNTAX parse error over real code is a regression.
    if ci && (parse_errors > 0 || !panics.is_empty()) {
        eprintln!(
            "CORPUS GATE FAILED: {parse_errors} parse errors, {} panics",
            panics.len()
        );
        std::process::exit(1);
    }
}