use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use luna_core::runtime::Value;
use luna_core::version::LuaVersion;
use luna_core::vm::Vm;
const DIALECTS: &[(&str, LuaVersion, &str, &str)] = &[
("5.1", LuaVersion::Lua51, "PUC_LUA_51", "PUC_LUAC_51"),
("5.2", LuaVersion::Lua52, "PUC_LUA_52", "PUC_LUAC_52"),
("5.3", LuaVersion::Lua53, "PUC_LUA_53", "PUC_LUAC_53"),
("5.4", LuaVersion::Lua54, "PUC_LUA_54", "PUC_LUAC_54"),
("5.5", LuaVersion::Lua55, "PUC_LUA_55", "PUC_LUAC_55"),
];
fn require_all() -> bool {
std::env::var_os("LUNA_DIFF_PUC_REQUIRE_ALL").is_some()
}
fn fixtures(dialect: &str) -> Vec<PathBuf> {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/diff_puc")
.join(dialect);
let mut out: Vec<PathBuf> = std::fs::read_dir(&dir)
.map(|d| d.flatten().map(|e| e.path()).collect())
.unwrap_or_default();
out.retain(|p| p.extension().is_some_and(|x| x == "lua"));
out.sort();
out
}
#[derive(PartialEq, Debug)]
enum Outcome {
Output(String),
Error(bool, String),
}
fn normalize(s: &str) -> String {
s.replace("\r\n", "\n").trim_end_matches('\n').to_string()
}
fn normalize_err(text: &str) -> (bool, String) {
let t = text.trim_end();
if let Some(colon2) = t.find(": ") {
let head = &t[..colon2];
if let Some(colon1) = head.rfind(':') {
let (chunk, line) = head.split_at(colon1);
if !chunk.is_empty() && line[1..].bytes().all(|c| c.is_ascii_digit()) {
return (true, t[colon2 + 2..].to_string());
}
}
}
(false, t.to_string())
}
fn run_puc(bin: &str, args: &[&Path], stdin: &[u8]) -> Outcome {
let mut child = Command::new(bin)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("cannot run `{bin}`: {e}"));
child
.stdin
.take()
.expect("piped stdin")
.write_all(stdin)
.expect("write stdin");
let out = child.wait_with_output().expect("PUC wait");
if out.status.success() && out.stderr.is_empty() {
return Outcome::Output(normalize(&String::from_utf8_lossy(&out.stdout)));
}
let stderr = String::from_utf8_lossy(&out.stderr);
let first = stderr.lines().next().unwrap_or("");
let msg = first.split_once(": ").map_or(first, |(_, m)| m);
let (pos, text) = normalize_err(msg);
Outcome::Error(pos, text)
}
fn temp_file(tag: &str, bytes: &[u8]) -> PathBuf {
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"luna-puc-dump-{}-{seq}-{tag}.luac",
std::process::id()
));
std::fs::write(&path, bytes).expect("write temp chunk");
path
}
fn luna_dump(version: LuaVersion, source: &str, strip: bool) -> Result<Vec<u8>, String> {
let mut vm = Vm::new(version);
let f = vm
.load(source.as_bytes(), b"=stdin")
.map_err(|e| format!("luna does not compile it: {}", e.msg_str()))?;
let dump = vm.eval("return string.dump").expect("string.dump")[0];
let r = vm
.call_value(dump, &[Value::Closure(f), Value::Bool(strip)])
.map_err(|e| format!("string.dump failed: {}", vm.error_display(&e)))?;
match r.first() {
Some(Value::Str(s)) => Ok(s.as_bytes().to_vec()),
other => Err(format!("string.dump returned {other:?}")),
}
}
fn run_on_luna(version: LuaVersion, bytes: &[u8]) -> Result<Outcome, String> {
let mut vm = Vm::new(version);
vm.eval(
r#"
_G.__luna_diff_puc_buf = ""
function print(...)
local t = {}
for i = 1, select('#', ...) do t[i] = tostring(select(i, ...)) end
_G.__luna_diff_puc_buf = _G.__luna_diff_puc_buf .. table.concat(t, '\t') .. '\n'
end
io.write = function(...)
local t = {}
for i = 1, select('#', ...) do t[i] = tostring(select(i, ...)) end
_G.__luna_diff_puc_buf = _G.__luna_diff_puc_buf .. table.concat(t)
end
"#,
)
.expect("capture preamble");
let f = vm
.load(bytes, b"=dump")
.map_err(|e| format!("luna refuses its own dump: {}", e.msg_str()))?;
if let Err(e) = vm.call_value(Value::Closure(f), &[]) {
let (pos, text) = normalize_err(&vm.error_display(&e));
return Ok(Outcome::Error(pos, text));
}
match vm
.eval("return _G.__luna_diff_puc_buf")
.expect("buffer")
.first()
{
Some(Value::Str(s)) => Ok(Outcome::Output(normalize(&String::from_utf8_lossy(
s.as_bytes(),
)))),
other => Err(format!("expected the capture buffer, got {other:?}")),
}
}
fn each_fixture(
name: &str,
min: LuaVersion,
check: impl Fn(&Path, LuaVersion, &str, &str, &str) -> Result<(), String>,
) {
let mut report = Vec::new();
let mut failed = Vec::new();
for &(dialect, version, lua_key, luac_key) in DIALECTS.iter().filter(|d| d.1 >= min) {
let (Ok(lua), luac) = (std::env::var(lua_key), std::env::var(luac_key)) else {
assert!(!require_all(), "[{name}] {lua_key} must be set");
eprintln!("[{name}] {dialect}: SKIPPED — {lua_key} not set");
continue;
};
let luac = luac.unwrap_or_default();
let all = fixtures(dialect);
let mut pass = 0;
for f in &all {
let source = std::fs::read_to_string(f).expect("read fixture");
match check(f, version, &lua, &luac, &source) {
Ok(()) => pass += 1,
Err(e) => failed.push(format!("{}: {e}", f.display())),
}
}
report.push(format!("{dialect} {pass}/{}", all.len()));
}
eprintln!("[{name}] passed: {}", report.join(", "));
assert!(
failed.is_empty(),
"[{name}] {} fixtures fail:\n{}",
failed.len(),
failed.join("\n")
);
}
#[test]
fn dump_runs_on_puc() {
each_fixture(
"dump_runs_on_puc",
LuaVersion::Lua51,
|_, version, lua, _, source| {
let want = run_puc(lua, &[Path::new("-")], source.as_bytes());
let bytes = luna_dump(version, source, false)?;
let chunk = temp_file("run", &bytes);
let got = run_puc(lua, &[&chunk], b"");
let _ = std::fs::remove_file(&chunk); if got != want {
return Err(format!(
"\n--- PUC source ---\n{want:?}\n--- PUC dump ---\n{got:?}"
));
}
Ok(())
},
);
}
#[test]
fn dump_round_trips_through_luna() {
each_fixture(
"dump_round_trips_through_luna",
LuaVersion::Lua51,
|_, version, lua, _, source| {
let want = run_puc(lua, &[Path::new("-")], source.as_bytes());
let bytes = luna_dump(version, source, false)?;
let got = std::panic::catch_unwind(|| run_on_luna(version, &bytes))
.map_err(|_| "luna panicked".to_string())??;
if got != want {
return Err(format!(
"\n--- PUC source ---\n{want:?}\n--- luna ---\n{got:?}"
));
}
Ok(())
},
);
}
#[test]
fn stripped_dump_runs_on_puc() {
each_fixture(
"stripped_dump_runs_on_puc",
LuaVersion::Lua53,
|f, version, lua, luac, source| {
if luac.is_empty() {
return Err("no luac for this dialect".to_string());
}
let stripped = temp_file("luac", b"");
let st = Command::new(luac)
.arg("-s")
.arg("-o")
.arg(&stripped)
.arg(f)
.status()
.map_err(|e| format!("cannot run luac: {e}"))?;
assert!(st.success(), "luac -s failed on {}", f.display());
let want = run_puc(lua, &[&stripped], b"");
let _ = std::fs::remove_file(&stripped); let bytes = luna_dump(version, source, true)?;
let chunk = temp_file("strip", &bytes);
let got = run_puc(lua, &[&chunk], b"");
let _ = std::fs::remove_file(&chunk); if got != want {
return Err(format!(
"\n--- PUC luac -s ---\n{want:?}\n--- PUC dump ---\n{got:?}"
));
}
Ok(())
},
);
}