use std::fs;
use std::path::Path;
use std::process::Command;
const EXPECTED_DIR: &str = "tests/expected_rust";
const TARGET_FUNCTIONS: &[&str] = &[
"Perl_CvDEPTH",
"Perl_cx_topblock",
"OP_CLASS",
"CvDEPTH",
"CvGV",
"CvSTASH",
"CopFILE",
"CopFILEAV",
"CopLABEL",
"HvFILL",
"PerlIO_seek",
"PerlIO_tell",
"AMG_CALLunary",
"newSVpvs",
"sv_upgrade",
"SvOK_off",
"SvIOK_only_UV",
];
fn extract_function(output: &str, fn_name: &str) -> Option<String> {
let lines: Vec<&str> = output.lines().collect();
let mut result = Vec::new();
let mut in_function = false;
let mut brace_count = 0;
let mut seen_open_brace = false;
for (i, line) in lines.iter().enumerate() {
if line.contains(&format!("pub unsafe fn {}(", fn_name))
|| line.contains(&format!("pub unsafe fn {}<", fn_name))
{
in_function = true;
let mut start = i;
for j in (0..i).rev() {
let prev = lines[j].trim();
if prev.starts_with("///")
|| prev.starts_with("#[inline]")
|| prev.starts_with("#[allow")
{
start = j;
} else if !prev.is_empty() {
break;
}
}
for k in start..i {
result.push(lines[k].to_string());
}
}
if in_function {
result.push(line.to_string());
let open_braces = line.chars().filter(|&c| c == '{').count() as i32;
let close_braces = line.chars().filter(|&c| c == '}').count() as i32;
brace_count += open_braces;
brace_count -= close_braces;
if open_braces > 0 {
seen_open_brace = true;
}
if seen_open_brace && brace_count == 0 {
break;
}
}
}
if result.is_empty() {
None
} else {
Some(result.join("\n"))
}
}
fn load_expected(fn_name: &str) -> Result<String, String> {
let path = Path::new(EXPECTED_DIR).join(format!("{}.rs", fn_name));
fs::read_to_string(&path)
.map(|s| s.trim().to_string())
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))
}
fn generate_rust_code() -> Result<String, String> {
let output = Command::new("cargo")
.args([
"run", "--",
"--auto",
"--gen-rust",
"samples/xs-wrapper.h",
"--bindings", "samples/bindings.rs",
])
.output()
.map_err(|e| format!("Failed to run cargo: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if output.stdout.is_empty() {
return Err(format!("cargo run failed: {}", stderr));
}
}
String::from_utf8(output.stdout)
.map_err(|e| format!("Invalid UTF-8 output: {}", e))
}
fn normalize_with_rustfmt(s: &str) -> String {
let trimmed = s.trim();
let wrapped = format!(
"#![allow(unused, non_snake_case, non_camel_case_types)]\nmod __dummy {{\n{}\n}}\n",
trimmed
);
let mut child = match Command::new("rustfmt")
.args(["--edition", "2024", "--quiet"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(_) => return normalize_whitespace(trimmed),
};
use std::io::Write;
if let Some(ref mut stdin) = child.stdin {
let _ = stdin.write_all(wrapped.as_bytes());
}
drop(child.stdin.take());
let output = match child.wait_with_output() {
Ok(o) => o,
Err(_) => return normalize_whitespace(trimmed),
};
if !output.status.success() {
return normalize_whitespace(trimmed);
}
let formatted = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = formatted.lines().collect();
let start = lines.iter().position(|l| l.contains("mod __dummy"));
let end = lines.iter().rposition(|l| l.trim() == "}");
match (start, end) {
(Some(s), Some(e)) if s < e => {
let body_start = s + 1;
let inner: Vec<&str> = lines[body_start..e].to_vec();
let min_indent = inner
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
inner
.iter()
.map(|l| {
if l.len() > min_indent {
&l[min_indent..]
} else {
l.trim()
}
})
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
_ => normalize_whitespace(trimmed),
}
}
fn normalize_whitespace(s: &str) -> String {
s.lines()
.map(|line| line.trim_end())
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
#[test]
fn test_rust_codegen_regression() {
let generated = generate_rust_code().expect("Failed to generate Rust code");
let mut failures = Vec::new();
let mut successes = Vec::new();
for fn_name in TARGET_FUNCTIONS {
let expected = match load_expected(fn_name) {
Ok(e) => e,
Err(e) => {
failures.push(format!("{}: {}", fn_name, e));
continue;
}
};
let actual = match extract_function(&generated, fn_name) {
Some(a) => a,
None => {
failures.push(format!("{}: Function not found in generated output", fn_name));
continue;
}
};
let expected_normalized = normalize_with_rustfmt(&expected);
let actual_normalized = normalize_with_rustfmt(&actual);
if expected_normalized != actual_normalized {
failures.push(format!(
"{}: Output mismatch\n--- Expected (rustfmt normalized) ---\n{}\n--- Actual (rustfmt normalized) ---\n{}",
fn_name, expected_normalized, actual_normalized
));
} else {
successes.push(fn_name.to_string());
}
}
if !successes.is_empty() {
println!("\n=== Passed ({}) ===", successes.len());
for name in &successes {
println!(" ✓ {}", name);
}
}
if !failures.is_empty() {
println!("\n=== Failed ({}) ===", failures.len());
for failure in &failures {
println!("\n{}", failure);
}
panic!(
"Rust codegen regression test failed: {} of {} functions",
failures.len(),
TARGET_FUNCTIONS.len()
);
}
}
#[cfg(test)]
mod individual_tests {
use super::*;
#[allow(dead_code)]
fn test_single_function(fn_name: &str) {
let generated = generate_rust_code().expect("Failed to generate Rust code");
let expected = load_expected(fn_name).expect("Failed to load expected output");
let actual = extract_function(&generated, fn_name)
.expect(&format!("Function {} not found in generated output", fn_name));
let expected_normalized = normalize_with_rustfmt(&expected);
let actual_normalized = normalize_with_rustfmt(&actual);
assert_eq!(
expected_normalized, actual_normalized,
"Output mismatch for {}\n--- Expected ---\n{}\n--- Actual ---\n{}",
fn_name, expected_normalized, actual_normalized
);
}
}