diff --git a/fib.rpg b/samples/fib.rpg similarity index 100% rename from fib.rpg rename to samples/fib.rpg diff --git a/samples/fib.rpg.golden b/samples/fib.rpg.golden new file mode 100644 index 0000000..797638c --- /dev/null +++ b/samples/fib.rpg.golden @@ -0,0 +1,11 @@ +DSPLY Fibonacci Sequence: +DSPLY 0 +DSPLY 1 +DSPLY 1 +DSPLY 2 +DSPLY 3 +DSPLY 5 +DSPLY 8 +DSPLY 13 +DSPLY 21 +DSPLY 34 diff --git a/samples/for.rpg b/samples/for.rpg new file mode 100644 index 0000000..62691fb --- /dev/null +++ b/samples/for.rpg @@ -0,0 +1,13 @@ +**FREE +Ctl-Opt Main(For); + +Dcl-Proc For; + dcl-s num int(10); + + for num = 1 to 3; + dsply ('i = ' + %char(num)); + endfor; + for num = 5 downto 1 by 1; + dsply ('i = ' + %char(num)); + endfor; +End-Proc For; diff --git a/samples/for.rpg.golden b/samples/for.rpg.golden new file mode 100644 index 0000000..379e4d0 --- /dev/null +++ b/samples/for.rpg.golden @@ -0,0 +1,8 @@ +DSPLY i = 1 +DSPLY i = 2 +DSPLY i = 3 +DSPLY i = 5 +DSPLY i = 4 +DSPLY i = 3 +DSPLY i = 2 +DSPLY i = 1 diff --git a/hello.rpg b/samples/hello.rpg similarity index 100% rename from hello.rpg rename to samples/hello.rpg diff --git a/samples/hello.rpg.golden b/samples/hello.rpg.golden new file mode 100644 index 0000000..f25a6d0 --- /dev/null +++ b/samples/hello.rpg.golden @@ -0,0 +1 @@ +DSPLY Hello, World! diff --git a/src/codegen.rs b/src/codegen.rs index 266bc2e..ca46119 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -692,10 +692,13 @@ impl<'ctx> Codegen<'ctx> { self.builder.position_at_end(bb); // Call the RPG entry procedure. - // Try the bare name first (CTL-OPT MAIN procedures are not renamed), - // then the `rpg_` prefix used for EXPORT-ed procedures. - let callee = self.module.get_function(rpg_entry) - .or_else(|| self.module.get_function(&format!("rpg_{}", rpg_entry))); + // Try the `rpg_` prefix first (used for EXPORT-ed procedures) so that + // a procedure named "main" resolves to @rpg_main rather than the @main + // wrapper we just created (which would cause infinite recursion). + // Fall back to the bare name for CTL-OPT MAIN procedures, which are + // not exported and therefore not prefixed. + let callee = self.module.get_function(&format!("rpg_{}", rpg_entry)) + .or_else(|| self.module.get_function(rpg_entry)); if let Some(rpg_fn) = callee { self.builder.build_call(rpg_fn, &[], "call_rpg").ok(); } @@ -1011,14 +1014,33 @@ impl<'ctx> Codegen<'ctx> { let func = state.function; let i64_t = self.context.i64_type(); - // Allocate loop variable. - let loop_var = self.builder.build_alloca(i64_t, &f.var).unwrap(); + // Reuse the existing i64 alloca when the variable has already been + // upgraded by a prior FOR loop (registered as Int(20), 8 bytes). + // If the variable comes from a DCL-S INT(10) declaration it only has a + // 4-byte slot, which is too small to hold an i64 — in that case (and + // when there is no existing local at all) allocate a fresh 8-byte slot. + // Both cases update state.locals so that the rest of the procedure + // reads/writes through the same pointer for the rest of its lifetime. + let loop_var = match state.locals.get(&f.var) { + Some((ptr, TypeSpec::Int(n))) => { + // Already upgraded to Int(20) (8 bytes) by a previous FOR — + // reuse it directly so all loops share the same variable. + if matches!(n.as_ref(), Expression::Literal(Literal::Integer(20))) { + *ptr + } else { + // Declared as a narrower int (e.g. INT(10) = 4 bytes). + // Allocate a fresh 8-byte slot; locals will be updated below. + self.builder.build_alloca(i64_t, &f.var).unwrap() + } + } + _ => self.builder.build_alloca(i64_t, &f.var).unwrap(), + }; let start = self.gen_expression(&f.start, state)?; let start_i = self.coerce_to_i64(start); self.builder.build_store(loop_var, start_i).ok(); - // Store the loop variable with Int(20) so that byte_size() returns 8, - // matching the i64 alloca above. (Int(10) would give 4 bytes, causing - // a 32-bit load from an 8-byte slot.) + // Update the type to Int(20) so that byte_size() returns 8 and all + // subsequent loads/stores use a full i64 slot. (Int(10) gives 4 bytes, + // which would cause a width mismatch with the i64 alloca above.) state.locals.insert(f.var.clone(), (loop_var, TypeSpec::Int(Box::new(Expression::Literal(Literal::Integer(20)))))); let cond_bb = self.context.append_basic_block(func, "for_cond"); @@ -1830,7 +1852,7 @@ mod tests { #[test] fn ir_hello_world() { - let src = include_str!("../hello.rpg"); + let src = include_str!("../samples/hello.rpg"); let prog = lower(src).expect("lower hello.rpg"); let ir = emit_ir(&prog).expect("emit_ir hello.rpg"); // The IR must contain the dsply call and both main functions. @@ -1853,6 +1875,71 @@ END-PROC; assert!(ir.contains("for_cond") || ir.contains("br"), "FOR loop should emit branches:\n{}", ir); } + /// Two FOR loops on the same DCL-S variable (one ascending, one downto) + /// must share a single alloca — matching the semantics of for.rpg. + #[test] + fn ir_for_two_loops_same_var() { + let src = r#" +**FREE +Ctl-Opt Main(For); + +Dcl-Proc For; + dcl-s num int(10); + + for num = 1 to 3; + dsply ('i = ' + %char(num)); + endfor; + for num = 5 downto 1 by 1; + dsply ('i = ' + %char(num)); + endfor; +End-Proc For; +"#; + let ir = get_ir(src); + + // Both loops must be present (two for_cond labels). + let for_cond_count = ir.matches("for_cond").count(); + assert!( + for_cond_count >= 2, + "expected at least two for_cond blocks (one per loop), got {}:\n{}", + for_cond_count, + &ir[..ir.len().min(3000)] + ); + + // The ascending loop uses sle (signed less-or-equal). + assert!( + ir.contains("icmp sle"), + "ascending loop should use 'icmp sle':\n{}", + &ir[..ir.len().min(3000)] + ); + + // The downto loop uses sge (signed greater-or-equal). + assert!( + ir.contains("icmp sge"), + "downto loop should use 'icmp sge':\n{}", + &ir[..ir.len().min(3000)] + ); + + // The decrement instruction must appear for the downto loop. + assert!( + ir.contains("sub i64"), + "downto loop should emit 'sub i64' for decrement:\n{}", + &ir[..ir.len().min(3000)] + ); + + // There must be exactly one i64 alloca for 'num' — both loops share it. + // Count lines that both allocate i64 and mention 'num'. + let num_i64_allocas = ir + .lines() + .filter(|l| l.contains("alloca i64") && l.contains("num")) + .count(); + assert_eq!( + num_i64_allocas, 1, + "both FOR loops must share a single i64 alloca for 'num', found {}:\n{}", + num_i64_allocas, + &ir[..ir.len().min(3000)] + ); + } + #[test] fn ir_if_stmt() { let src = r#" diff --git a/src/lower.rs b/src/lower.rs index b9dd36d..473bb8b 100644 --- a/src/lower.rs +++ b/src/lower.rs @@ -1105,12 +1105,11 @@ impl Parser { fn parse_paren_ident(&mut self) -> Option { if self.peek() != &Token::LParen { return None; } self.advance(); // ( - let name = if let Token::Identifier(s) = self.peek().clone() { - self.advance(); - Some(s) - } else { - None - }; + // Use try_parse_ident_or_name so that names which collide with keywords + // (e.g. `For` in `Ctl-Opt Main(For)`) are accepted. Lowercase the + // result so it matches how procedure names are stored by token_as_name / + // expect_name (which always return lowercase strings for keyword tokens). + let name = self.try_parse_ident_or_name().map(|s| s.to_lowercase()); self.eat(&Token::RParen); name } @@ -2500,7 +2499,7 @@ impl Parser { fn expect_ident(&mut self) -> Result { match self.advance() { - Token::Identifier(s) => Ok(s), + Token::Identifier(s) => Ok(s.to_lowercase()), tok => Err(LowerError::new(format!("expected identifier, got {:?}", tok))), } } @@ -2572,7 +2571,7 @@ impl Parser { /// lowercase or mixed-case spelling that the source would have used. fn token_as_name(tok: &Token) -> Option { match tok { - Token::Identifier(s) => Some(s.clone()), + Token::Identifier(s) => Some(s.to_lowercase()), // Statement / declaration keywords that are commonly used as names. Token::KwMain => Some("main".into()), @@ -2814,7 +2813,7 @@ mod tests { fn lower_dcl_c() { let p = lower_ok("DCL-C MAX_SIZE CONST(100);"); if let Declaration::Constant(c) = &p.declarations[0] { - assert_eq!(c.name, "MAX_SIZE"); + assert_eq!(c.name, "max_size"); } } @@ -2840,7 +2839,7 @@ mod tests { #[test] fn lower_hello_rpg() { - let hello = include_str!("../hello.rpg"); + let hello = include_str!("../samples/hello.rpg"); let p = lower_ok(hello); assert!(!p.procedures.is_empty(), "should have at least one procedure"); let proc = p.procedures.iter().find(|p| p.name == "main").expect("main proc"); diff --git a/src/main.rs b/src/main.rs index da57ac9..fcd9de3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -326,7 +326,7 @@ mod tests { /// to LLVM IR without errors. #[test] fn hello_rpg_emits_ir() { - let src = include_str!("../hello.rpg"); + let src = include_str!("../samples/hello.rpg"); let prog = lower(src.trim()).expect("lower hello.rpg"); let ir = emit_ir(&prog).expect("emit_ir hello.rpg"); diff --git a/tests/hello_rpg.rs b/tests/hello_rpg.rs index 928a49a..d2b18da 100644 --- a/tests/hello_rpg.rs +++ b/tests/hello_rpg.rs @@ -11,7 +11,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_rust-langrpg"); /// Absolute path to hello.rpg, resolved at compile time relative to the crate /// root so the test works regardless of the working directory. -const HELLO_RPG: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/hello.rpg"); +const HELLO_RPG: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/samples/hello.rpg"); // ───────────────────────────────────────────────────────────────────────────── // Helper diff --git a/tests/samples.rs b/tests/samples.rs new file mode 100644 index 0000000..d4e86b0 --- /dev/null +++ b/tests/samples.rs @@ -0,0 +1,170 @@ +//! Integration tests — compile every `.rpg` sample and validate stdout +//! against the accompanying `.rpg.golden` file. +//! +//! Each test follows the same three-step pipeline: +//! 1. `rust-langrpg -o .rpg` — compile +//! 2. `` — execute +//! 3. compare stdout with `.rpg.golden` +//! +//! # Adding a new sample +//! +//! Drop the `.rpg` source and a `.rpg.golden` file into `samples/` and add a +//! one-line `#[test]` that calls `run_sample("your_file.rpg")`. + +use std::{fs, path::PathBuf, process::Command}; + +/// Path to the freshly-built compiler binary, injected by Cargo. +const BIN: &str = env!("CARGO_BIN_EXE_rust-langrpg"); + +/// Absolute path to the `samples/` directory, resolved at compile time. +const SAMPLES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/samples"); + +// ───────────────────────────────────────────────────────────────────────────── +// Shared driver +// ───────────────────────────────────────────────────────────────────────────── + +/// Compile `sample_name` from `samples/`, run the resulting binary, and assert +/// that its stdout matches `samples/.golden` line-for-line. +/// +/// Temp binaries are written to the OS temp directory with a name derived from +/// the sample so parallel test execution doesn't cause collisions. +fn run_sample(sample_name: &str) { + let src = PathBuf::from(SAMPLES_DIR).join(sample_name); + let golden_path = PathBuf::from(SAMPLES_DIR).join(format!("{}.golden", sample_name)); + + assert!( + src.exists(), + "source file not found: {}", + src.display() + ); + assert!( + golden_path.exists(), + "golden file not found: {}\n\ + Create it with the expected stdout to enable this test.", + golden_path.display() + ); + + // ── 1. Compile ──────────────────────────────────────────────────────────── + + // Sanitise the stem so it is safe to embed in a file name (e.g. "for.rpg" + // → "for_rpg") and prefix with a fixed string to make it easy to identify. + let safe_stem = sample_name.replace('.', "_").replace(std::path::MAIN_SEPARATOR, "_"); + let exe = std::env::temp_dir().join(format!("rpg_sample_test_{}.out", safe_stem)); + + let compile_out = Command::new(BIN) + .args(["-o", exe.to_str().unwrap(), src.to_str().unwrap()]) + .output() + .unwrap_or_else(|e| panic!("failed to spawn compiler for '{}': {e}", sample_name)); + + assert!( + compile_out.status.success(), + "compilation of '{}' failed (exit {})\nstderr:\n{}", + sample_name, + compile_out.status, + String::from_utf8_lossy(&compile_out.stderr), + ); + + assert!( + exe.exists(), + "compiler exited 0 for '{}' but no executable was produced at '{}'", + sample_name, + exe.display(), + ); + + // ── 2. Execute ──────────────────────────────────────────────────────────── + + let run_out = Command::new(&exe) + .output() + .unwrap_or_else(|e| panic!("failed to run compiled binary for '{}': {e}", sample_name)); + + assert!( + run_out.status.success(), + "binary for '{}' exited non-zero ({})\nstdout:\n{}\nstderr:\n{}", + sample_name, + run_out.status, + String::from_utf8_lossy(&run_out.stdout), + String::from_utf8_lossy(&run_out.stderr), + ); + + // ── 3. Compare against golden ───────────────────────────────────────────── + + let actual_raw = String::from_utf8_lossy(&run_out.stdout); + let expected_raw = fs::read_to_string(&golden_path).unwrap_or_else(|e| { + panic!("could not read golden file '{}': {e}", golden_path.display()) + }); + + // Normalise both sides: trim trailing whitespace from every line and ignore + // a trailing blank line, so golden files don't need a precise final newline. + let normalise = |s: &str| -> Vec { + s.lines().map(|l| l.trim_end().to_string()).collect() + }; + + let actual_lines = normalise(&actual_raw); + let expected_lines = normalise(&expected_raw); + + if actual_lines != expected_lines { + let mut msg = format!( + "stdout mismatch for '{}'\n\n\ + ── expected ({} line{}) ──────────────────────\n", + sample_name, + expected_lines.len(), + if expected_lines.len() == 1 { "" } else { "s" }, + ); + for line in &expected_lines { + msg.push_str(" "); + msg.push_str(line); + msg.push('\n'); + } + msg.push_str(&format!( + "\n── actual ({} line{}) ────────────────────────\n", + actual_lines.len(), + if actual_lines.len() == 1 { "" } else { "s" }, + )); + for line in &actual_lines { + msg.push_str(" "); + msg.push_str(line); + msg.push('\n'); + } + + // Highlight the first diverging line to make failures easy to spot. + for (i, (exp, act)) in expected_lines.iter().zip(actual_lines.iter()).enumerate() { + if exp != act { + msg.push_str(&format!( + "\nfirst difference at line {}:\n expected: {:?}\n actual: {:?}\n", + i + 1, + exp, + act, + )); + break; + } + } + if actual_lines.len() != expected_lines.len() { + msg.push_str(&format!( + "\nline count differs: expected {}, got {}\n", + expected_lines.len(), + actual_lines.len(), + )); + } + + panic!("{}", msg); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// One #[test] per sample +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sample_hello() { + run_sample("hello.rpg"); +} + +#[test] +fn sample_for() { + run_sample("for.rpg"); +} + +#[test] +fn sample_fib() { + run_sample("fib.rpg"); +}