use std::path::{Path, PathBuf};
use std::process::Command;
use gdck_config::FormatConfig;
const CASES: &[(&str, &str)] = &[
(
"nested call ending in a multiline lambda",
"extends Node\n\nvar _flag := false\n\n\nfunc _ready() -> void:\n\
\tvar box := VBoxContainer.new()\n\
\tbox.add_child(make_button(\"a long label here to force the formatter to wrap\", func() -> void:\n\
\t\t_flag = true\n\
\t\tprint(\"done\")))\n\n\n\
func make_button(label: String, cb: Callable) -> Button:\n\treturn Button.new()\n",
),
(
"single-level call ending in a multiline lambda",
"extends Node\n\nvar _flag := false\n\n\nfunc _ready() -> void:\n\
\tvar timer := Timer.new()\n\
\ttimer.timeout.connect(func() -> void:\n\
\t\t_flag = true\n\
\t\tprint(\"a fairly long line here to encourage the formatter to wrap this\"))\n",
),
(
"lambda inside an array",
"extends Node\n\n\nfunc _ready() -> void:\n\
\tvar callables := [func() -> void:\n\
\t\tprint(\"a long enough body that the array has to break open somewhere\")]\n\
\tprint(callables.size())\n",
),
(
"standalone annotations around a run of statements",
"extends Node\n\n\nfunc halve(total: int) -> int:\n\
\t@warning_ignore_start(\"integer_division\")\n\
\tvar halved := total / 2\n\
\t@warning_ignore_restore(\"integer_division\")\n\
\treturn halved\n",
),
(
"standalone annotations grouping exported properties",
"extends Node\n\n\
@export_category(\"Stats\")\n\
@export_group(\"Health\", \"health_\")\n\
var health_max := 10\n\
@export_subgroup(\"Regen\")\n\
var health_regen := 1.0\n",
),
(
"lambda inside a parenthesised expression",
"extends Node\n\n\nfunc _ready() -> void:\n\
\tassert((func() -> bool:\n\
\t\tvar ok := 1 + 1 == 2\n\
\t\treturn ok).call())\n",
),
(
"property with both accessors set to methods",
"extends Node\n\nvar _p := 0\n\nvar p:\n\
\tset = __set,\n\
\tget = __get\n\n\n\
func __get() -> int:\n\treturn _p\n\n\n\
func __set(value: int) -> void:\n\t_p = value\n",
),
(
"property with both accessors written as blocks",
"extends Node\n\nvar _p := 0\n\nvar p:\n\
\tset(value):\n\t\t_p = value\n\
\tget:\n\t\treturn _p\n",
),
(
"lambda followed by another argument",
"extends Node\n\n\nfunc _ready() -> void:\n\
\tvar t := Timer.new()\n\
\tt.timeout.connect(func() -> void:\n\
\t\tprint(\"body\"), CONNECT_ONE_SHOT)\n",
),
];
fn godot() -> Option<PathBuf> {
let raw = std::env::var("GDCK_GODOT").ok()?;
let path = PathBuf::from(&raw);
assert!(
path.is_file(),
"GDCK_GODOT is set to {raw:?}, which is not a file. Point it at a Godot \
binary, or unset it to skip this test."
);
Some(path)
}
fn resolve_from_workspace_root(raw: &str) -> PathBuf {
let path = PathBuf::from(raw);
if path.is_absolute() {
return path;
}
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join(path)
}
struct Scratch {
root: PathBuf,
}
impl Scratch {
fn new(name: &str) -> Self {
let root = std::env::temp_dir().join(format!("gdck-godot-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("should create the scratch directory");
Self { root }
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn godot_errors(binary: &Path, dir: &Path, name: &str) -> Vec<String> {
let output = Command::new(binary)
.current_dir(dir)
.args(["--headless", "--check-only", "--script", name])
.output()
.expect("should run Godot");
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stdout));
text.lines()
.filter(|line| line.contains("SCRIPT ERROR") || line.contains("Parse Error"))
.map(str::trim)
.map(str::to_owned)
.collect()
}
fn regression(binary: &Path, scratch: &Path, label: &str, source: &str) -> Option<String> {
let before_path = scratch.join("before.gd");
std::fs::write(&before_path, source).expect("should write");
if !godot_errors(binary, scratch, "before.gd").is_empty() {
return None;
}
let Ok(formatted) = gdck_format::format_source(source, &FormatConfig::default()) else {
return None;
};
let after_path = scratch.join("after.gd");
std::fs::write(&after_path, &formatted).expect("should write");
let errors = godot_errors(binary, scratch, "after.gd");
if errors.is_empty() {
return None;
}
Some(format!(
"{label}: Godot accepts this file but rejects what `gdck format` makes of it.\n\
--- Godot said ---\n{}\n--- formatted output ---\n{}",
errors.join("\n"),
formatted
))
}
#[test]
fn formatted_output_is_accepted_by_godot() {
let Some(binary) = godot() else {
eprintln!("GDCK_GODOT not set; skipping Godot conformance test");
return;
};
let scratch = Scratch::new("cases");
let failures: Vec<String> = CASES
.iter()
.filter_map(|(label, source)| regression(&binary, &scratch.root, label, source))
.collect();
eprintln!("checked {} constructs against Godot", CASES.len());
assert!(
failures.is_empty(),
"{} of {} constructs broke:\n\n{}",
failures.len(),
CASES.len(),
failures.join("\n\n")
);
}
#[test]
fn formatted_corpus_is_accepted_by_godot() {
let Some(binary) = godot() else {
eprintln!("GDCK_GODOT not set; skipping Godot corpus conformance test");
return;
};
let Ok(root) = std::env::var("GDCK_CORPUS") else {
eprintln!("GDCK_CORPUS not set; skipping Godot corpus conformance test");
return;
};
let root = resolve_from_workspace_root(&root);
let mut paths = Vec::new();
collect_gd_files(&root, &mut paths);
paths.sort();
assert!(
!paths.is_empty(),
"no .gd files found under {}",
root.display()
);
let scratch = Scratch::new("corpus");
let mut checked = 0;
let mut skipped = 0;
let mut failures = Vec::new();
for path in &paths {
let Ok(source) = std::fs::read_to_string(path) else {
continue;
};
let label = path.display().to_string();
if let Some(failure) = regression(&binary, &scratch.root, &label, &source) {
failures.push(failure);
checked += 1;
} else {
checked += 1;
skipped += 1;
}
}
eprintln!("ran {checked} corpus files past Godot ({skipped} produced nothing to report)");
assert!(
failures.is_empty(),
"{} corpus files were broken by formatting:\n\n{}",
failures.len(),
failures.join("\n\n")
);
}
fn collect_gd_files(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() {
collect_gd_files(&path, out);
} else if path.extension().is_some_and(|extension| extension == "gd") {
out.push(path);
}
}
}