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);
}
}
}
#[derive(Default)]
struct ProjectStats {
files: usize,
total_diags: usize,
parse_errors: usize,
panics: Vec<PathBuf>,
}
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());
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
}
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());
}
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);
}
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);
}
}
}
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");
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());
}
if ci && (parse_errors > 0 || !panics.is_empty()) {
eprintln!(
"CORPUS GATE FAILED: {parse_errors} parse errors, {} panics",
panics.len()
);
std::process::exit(1);
}
}