From 46935005f768e89782d6f11e0954440d7a887aae Mon Sep 17 00:00:00 2001 From: charles Date: Thu, 12 Mar 2026 22:55:14 -0700 Subject: [PATCH] add: more samples and make them work --- rpgrt/src/lib.rs | 53 ++++++++++ samples/3np1.rpg | 44 ++++++++ samples/3np1.rpg.golden | 8 ++ samples/fizzbuzz.rpg | 18 ++++ samples/fizzbuzz.rpg.golden | 100 ++++++++++++++++++ src/codegen.rs | 122 +++++++++++++++++++++- src/lower.rs | 67 ++++++++---- tests/samples.rs | 203 ++++++++++++++++++++++++------------ 8 files changed, 525 insertions(+), 90 deletions(-) create mode 100644 samples/3np1.rpg create mode 100644 samples/3np1.rpg.golden create mode 100644 samples/fizzbuzz.rpg create mode 100644 samples/fizzbuzz.rpg.golden diff --git a/rpgrt/src/lib.rs b/rpgrt/src/lib.rs index 1c2c144..214307e 100644 --- a/rpgrt/src/lib.rs +++ b/rpgrt/src/lib.rs @@ -156,6 +156,59 @@ pub extern "C" fn rpg_dsply_f64(f: f64) { let _ = out.flush(); } +// ───────────────────────────────────────────────────────────────────────────── +// rpg_dsply_read — display a prompt and read an integer response from stdin +// ───────────────────────────────────────────────────────────────────────────── + +/// Display `prompt` (a null-terminated C string) with a `DSPLY` prefix, then +/// read one line from stdin and parse it as a signed 64-bit integer. The +/// parsed value is written through `response`. +/// +/// This implements the three-operand form of the `DSPLY` opcode: +/// ```rpg +/// Dsply prompt ' ' response_var; +/// ``` +/// where the third operand is the variable that receives the operator's reply. +/// +/// If stdin is exhausted (EOF) or the line cannot be parsed as an integer the +/// response is left unchanged. +/// +/// # Safety +/// +/// * `prompt` must be a valid null-terminated C string (or null, treated as +/// an empty string). +/// * `response` must be a valid, aligned, writable pointer to an `i64`. +#[no_mangle] +pub unsafe extern "C" fn rpg_dsply_read( + prompt: *const std::os::raw::c_char, + response: *mut i64, +) { + use std::io::BufRead; + + // Display the prompt. + let text = if prompt.is_null() { + std::borrow::Cow::Borrowed("") + } else { + unsafe { CStr::from_ptr(prompt).to_string_lossy() } + }; + { + let stdout = io::stdout(); + let mut out = stdout.lock(); + let _ = writeln!(out, "DSPLY {}", text); + let _ = out.flush(); + } + + // Read one line from stdin and parse it as i64. + let stdin = io::stdin(); + let mut line = String::new(); + if stdin.lock().read_line(&mut line).is_ok() { + let trimmed = line.trim(); + if let Ok(n) = trimmed.parse::() { + unsafe { *response = n; } + } + } +} + // ───────────────────────────────────────────────────────────────────────────── // rpg_halt — abnormal termination // ───────────────────────────────────────────────────────────────────────────── diff --git a/samples/3np1.rpg b/samples/3np1.rpg new file mode 100644 index 0000000..0c86f27 --- /dev/null +++ b/samples/3np1.rpg @@ -0,0 +1,44 @@ +**FREE +Ctl-Opt Main(ThreeNPlusOne); + +Dcl-Proc ThreeNPlusOne; + Dcl-S n Packed(10); + Dcl-S counter Int(10) Inz(0); + Dcl-S input_prompt VarChar(50) Inz('Enter a positive integer (or 0 to exit):'); + + // Use an infinite loop, exit when user enters 0 + Dow (1 = 1); + Dsply input_prompt ' ' n; + + If n = 0; + Leave; + EndIf; + + If n < 0; + input_prompt = 'Positive integers only. Enter a number:'; + Iter; + EndIf; + + // Start sequence calculation + input_prompt = 'Enter a positive integer (or 0 to exit):'; // Reset prompt + counter = 0; + Dsply ('Sequence for ' + %Char(n) + ':'); + + Dow n > 1; + If %Rem(n:2) = 0; + // n is even, divide by 2 + n = n / 2; + Else; + // n is odd, multiply by 3 and add 1 + n = (n * 3) + 1; + EndIf; + + counter = counter + 1; + Dsply %Char(n); + EndDo; + + Dsply ('Reached 1 in ' + %Char(counter) + ' iterations.'); + Dsply ' '; // Add a blank line for readability + EndDo; + +End-Proc ThreeNPlusOne; diff --git a/samples/3np1.rpg.golden b/samples/3np1.rpg.golden new file mode 100644 index 0000000..3d073bb --- /dev/null +++ b/samples/3np1.rpg.golden @@ -0,0 +1,8 @@ +DSPLY +DSPLY Sequence for 8: +DSPLY 4 +DSPLY 2 +DSPLY 1 +DSPLY Reached 1 in 3 iterations. +DSPLY +DSPLY diff --git a/samples/fizzbuzz.rpg b/samples/fizzbuzz.rpg new file mode 100644 index 0000000..44ebbed --- /dev/null +++ b/samples/fizzbuzz.rpg @@ -0,0 +1,18 @@ +**FREE +Ctl-Opt Main(FizzBuzz); + +Dcl-Proc FizzBuzz; + Dcl-S num Int(10); + + For num = 1 To 100; + If %Rem(num:3) = 0 And %Rem(num:5) = 0; + Dsply ('num - ' + %Char(num) + ' FIZZBUZZ'); + ElseIf %Rem(num:3) = 0; + Dsply ('num - ' + %Char(num) + ' FIZZ'); + ElseIf %Rem(num:5) = 0; + Dsply ('num - ' + %Char(num) + ' BUZZ'); + Else; + Dsply ('num - ' + %Char(num)); + EndIf; + EndFor; +End-Proc FizzBuzz; diff --git a/samples/fizzbuzz.rpg.golden b/samples/fizzbuzz.rpg.golden new file mode 100644 index 0000000..d9dc20c --- /dev/null +++ b/samples/fizzbuzz.rpg.golden @@ -0,0 +1,100 @@ +DSPLY num - 1 +DSPLY num - 2 +DSPLY num - 3 FIZZ +DSPLY num - 4 +DSPLY num - 5 BUZZ +DSPLY num - 6 FIZZ +DSPLY num - 7 +DSPLY num - 8 +DSPLY num - 9 FIZZ +DSPLY num - 10 BUZZ +DSPLY num - 11 +DSPLY num - 12 FIZZ +DSPLY num - 13 +DSPLY num - 14 +DSPLY num - 15 FIZZBUZZ +DSPLY num - 16 +DSPLY num - 17 +DSPLY num - 18 FIZZ +DSPLY num - 19 +DSPLY num - 20 BUZZ +DSPLY num - 21 FIZZ +DSPLY num - 22 +DSPLY num - 23 +DSPLY num - 24 FIZZ +DSPLY num - 25 BUZZ +DSPLY num - 26 +DSPLY num - 27 FIZZ +DSPLY num - 28 +DSPLY num - 29 +DSPLY num - 30 FIZZBUZZ +DSPLY num - 31 +DSPLY num - 32 +DSPLY num - 33 FIZZ +DSPLY num - 34 +DSPLY num - 35 BUZZ +DSPLY num - 36 FIZZ +DSPLY num - 37 +DSPLY num - 38 +DSPLY num - 39 FIZZ +DSPLY num - 40 BUZZ +DSPLY num - 41 +DSPLY num - 42 FIZZ +DSPLY num - 43 +DSPLY num - 44 +DSPLY num - 45 FIZZBUZZ +DSPLY num - 46 +DSPLY num - 47 +DSPLY num - 48 FIZZ +DSPLY num - 49 +DSPLY num - 50 BUZZ +DSPLY num - 51 FIZZ +DSPLY num - 52 +DSPLY num - 53 +DSPLY num - 54 FIZZ +DSPLY num - 55 BUZZ +DSPLY num - 56 +DSPLY num - 57 FIZZ +DSPLY num - 58 +DSPLY num - 59 +DSPLY num - 60 FIZZBUZZ +DSPLY num - 61 +DSPLY num - 62 +DSPLY num - 63 FIZZ +DSPLY num - 64 +DSPLY num - 65 BUZZ +DSPLY num - 66 FIZZ +DSPLY num - 67 +DSPLY num - 68 +DSPLY num - 69 FIZZ +DSPLY num - 70 BUZZ +DSPLY num - 71 +DSPLY num - 72 FIZZ +DSPLY num - 73 +DSPLY num - 74 +DSPLY num - 75 FIZZBUZZ +DSPLY num - 76 +DSPLY num - 77 +DSPLY num - 78 FIZZ +DSPLY num - 79 +DSPLY num - 80 BUZZ +DSPLY num - 81 FIZZ +DSPLY num - 82 +DSPLY num - 83 +DSPLY num - 84 FIZZ +DSPLY num - 85 BUZZ +DSPLY num - 86 +DSPLY num - 87 FIZZ +DSPLY num - 88 +DSPLY num - 89 +DSPLY num - 90 FIZZBUZZ +DSPLY num - 91 +DSPLY num - 92 +DSPLY num - 93 FIZZ +DSPLY num - 94 +DSPLY num - 95 BUZZ +DSPLY num - 96 FIZZ +DSPLY num - 97 +DSPLY num - 98 +DSPLY num - 99 FIZZ +DSPLY num - 100 BUZZ diff --git a/src/codegen.rs b/src/codegen.rs index ca46119..3825d57 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -316,6 +316,15 @@ impl<'ctx> Codegen<'ctx> { // i8* rpg_concat(i8* a, i8* b) — concatenate two C strings let concat_ty = i8_ptr.fn_type(&[i8_ptr.into(), i8_ptr.into()], false); self.module.add_function("rpg_concat", concat_ty, None); + + // void rpg_dsply_read(const char *prompt, i64 *response) + // Three-operand DSPLY: display prompt and read an i64 from stdin. + let i64_ptr = self.context.ptr_type(AddressSpace::default()); + let dsply_read_ty = void_t.fn_type( + &[i8_ptr.into(), i64_ptr.into()], + false, + ); + self.module.add_function("rpg_dsply_read", dsply_read_ty, None); } // ── Global declarations ───────────────────────────────────────────────── @@ -474,6 +483,49 @@ impl<'ctx> Codegen<'ctx> { self.builder.build_alloca(arr_ty, name).unwrap() } + /// Allocate an `i64` slot in the **entry block** of `func`, regardless of + /// where the builder is currently positioned. + /// + /// LLVM's `mem2reg` pass (and general best-practice) requires that all + /// `alloca` instructions live in the function entry block. When code is + /// generated inside a loop or branch the builder's insertion point is not + /// the entry block; calling `build_alloca` there produces an alloca that is + /// re-executed on every iteration, creating a new stack slot each time and + /// losing any value stored in a previous iteration. + /// + /// This helper saves the current insertion point, moves to the first + /// instruction of the entry block (so the alloca is prepended before any + /// existing code), emits the alloca, then restores the original position. + fn alloca_i64_in_entry( + &self, + func: inkwell::values::FunctionValue<'ctx>, + name: &str, + ) -> PointerValue<'ctx> { + let i64_t = self.context.i64_type(); + let entry_bb = func.get_first_basic_block().expect("function has no entry block"); + + // Remember where we are now. + let saved_bb = self.builder.get_insert_block(); + + // Position at the very start of the entry block so the alloca is + // placed before any other instructions (branches, stores, etc.). + match entry_bb.get_first_instruction() { + Some(first) => self.builder.position_before(&first), + None => self.builder.position_at_end(entry_bb), + } + + let ptr = self.builder.build_alloca(i64_t, name).unwrap(); + + // Restore the builder's original position. + // We always generate code at the end of the current block, so + // position_at_end on the saved block is sufficient. + if let Some(bb) = saved_bb { + self.builder.position_at_end(bb); + } + + ptr + } + /// Allocate storage for an array of `n` elements of type `ty`. fn alloca_for_type_dim(&self, ty: &TypeSpec, name: &str, n: u64) -> PointerValue<'ctx> { let elem_size = ty.byte_size().unwrap_or(8) as u32; @@ -777,6 +829,69 @@ impl<'ctx> Codegen<'ctx> { let dsply = self.module.get_function("rpg_dsply") .ok_or_else(|| CodegenError::new("rpg_dsply not declared"))?; + // ── Three-operand form: DSPLY expr msgq response ────────────────── + // When a response variable is present we display the prompt and then + // read an integer from stdin into the response variable. + if let Some(resp_name) = &d.response { + // Coerce the prompt expression to a C string pointer. + let prompt_ptr = match &d.expr { + Expression::Variable(qname) => { + let name = qname.leaf(); + if let Some((ptr, _)) = self.resolve_var(name, state) { + ptr.into() + } else { + self.intern_string("").into() + } + } + Expression::Literal(Literal::String(s)) => { + self.intern_string(s).into() + } + other => { + if let Ok(BasicValueEnum::PointerValue(ptr)) = + self.gen_expression(other, state) + { + ptr.into() + } else { + self.intern_string("").into() + } + } + }; + + // Resolve the response variable; allocate a fresh i64 slot in the + // function entry block if not already present. Using + // alloca_i64_in_entry ensures the slot is created once regardless + // of how many times the DSPLY statement is executed (e.g. inside a + // loop), so the stored value persists across iterations. + let resp_ptr: inkwell::values::PointerValue = + if let Some((ptr, TypeSpec::Int(n))) = state.locals.get(resp_name) { + if matches!(n.as_ref(), Expression::Literal(Literal::Integer(20))) { + *ptr + } else { + let p = self.alloca_i64_in_entry(state.function, resp_name); + state.locals.insert( + resp_name.clone(), + (p, TypeSpec::Int(Box::new(Expression::Literal(Literal::Integer(20))))), + ); + p + } + } else { + let p = self.alloca_i64_in_entry(state.function, resp_name); + state.locals.insert( + resp_name.clone(), + (p, TypeSpec::Int(Box::new(Expression::Literal(Literal::Integer(20))))), + ); + p + }; + + if let Some(read_fn) = self.module.get_function("rpg_dsply_read") { + self.builder + .build_call(read_fn, &[prompt_ptr, resp_ptr.into()], "dsply_read") + .ok(); + } + return Ok(()); + } + + // ── One-operand form: DSPLY expr ────────────────────────────────── match &d.expr { Expression::Variable(qname) => { // Look up the variable, then pass ptr + len. @@ -1029,11 +1144,12 @@ impl<'ctx> Codegen<'ctx> { *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() + // Allocate a fresh 8-byte slot in the entry block so it is + // not re-created on every loop iteration. + self.alloca_i64_in_entry(func, &f.var) } } - _ => self.builder.build_alloca(i64_t, &f.var).unwrap(), + _ => self.alloca_i64_in_entry(func, &f.var), }; let start = self.gen_expression(&f.start, state)?; let start_i = self.coerce_to_i64(start); diff --git a/src/lower.rs b/src/lower.rs index 473bb8b..eee8ca6 100644 --- a/src/lower.rs +++ b/src/lower.rs @@ -1264,8 +1264,12 @@ impl Parser { self.advance(); self.expect(&Token::LParen)?; let digits = self.parse_expression()?; - self.expect(&Token::Colon)?; - let decimals = self.parse_expression()?; + // Decimal positions are optional — `Packed(10)` means `Packed(10:0)`. + let decimals = if self.eat(&Token::Colon) { + self.parse_expression()? + } else { + Expression::Literal(Literal::Integer(0)) + }; self.expect(&Token::RParen)?; Ok(TypeSpec::Packed(Box::new(digits), Box::new(decimals))) } @@ -1273,8 +1277,12 @@ impl Parser { self.advance(); self.expect(&Token::LParen)?; let digits = self.parse_expression()?; - self.expect(&Token::Colon)?; - let decimals = self.parse_expression()?; + // Decimal positions are optional — `Zoned(10)` means `Zoned(10:0)`. + let decimals = if self.eat(&Token::Colon) { + self.parse_expression()? + } else { + Expression::Literal(Literal::Integer(0)) + }; self.expect(&Token::RParen)?; Ok(TypeSpec::Zoned(Box::new(digits), Box::new(decimals))) } @@ -1282,8 +1290,12 @@ impl Parser { self.advance(); self.expect(&Token::LParen)?; let digits = self.parse_expression()?; - self.expect(&Token::Colon)?; - let decimals = self.parse_expression()?; + // Decimal positions are optional — `Bindec(10)` means `Bindec(10:0)`. + let decimals = if self.eat(&Token::Colon) { + self.parse_expression()? + } else { + Expression::Literal(Literal::Integer(0)) + }; self.expect(&Token::RParen)?; Ok(TypeSpec::Bindec(Box::new(digits), Box::new(decimals))) } @@ -1627,28 +1639,21 @@ impl Parser { fn parse_dsply(&mut self) -> Result { self.advance(); // KwDsply - // Two forms: + // Three forms: // DSPLY expr; - // DSPLY (expr : msgq : response); + // DSPLY (expr : msgq : response); ← parenthesised colon-separated + // DSPLY expr msgq response; ← space-separated (no parens) if self.peek() == &Token::LParen { - // peek ahead — if the next token after '(' looks like an expression - // followed by ':' it's the three-arg form self.advance(); // ( let expr = self.parse_expression()?; let mut msg_q = None; let mut response = None; if self.eat(&Token::Colon) { - if let Token::Identifier(s) = self.peek().clone() { - self.advance(); - msg_q = Some(s); - } else { - self.eat(&Token::Colon); - } + // Accept any name-like token for msgq / response, including + // tokens that collide with keywords (e.g. a variable `n`). + msg_q = self.try_parse_ident_or_name().map(|s| s.to_lowercase()); if self.eat(&Token::Colon) { - if let Token::Identifier(s) = self.peek().clone() { - self.advance(); - response = Some(s); - } + response = self.try_parse_ident_or_name().map(|s| s.to_lowercase()); } } self.eat(&Token::RParen); @@ -1656,8 +1661,28 @@ impl Parser { Ok(Statement::Dsply(DsplyStmt { expr, msg_q, response })) } else { let expr = self.parse_expression()?; + // Space-separated msgq and response operands (no parentheses): + // DSPLY prompt ' ' response_var; + // DSPLY prompt msgq response_var; + // After the expression, a string literal or another identifier + // signals the optional msgq operand, followed by the response var. + let mut msg_q = None; + let mut response = None; + match self.peek().clone() { + Token::StringLit(s) => { + self.advance(); + msg_q = Some(s); + // Optional response variable follows the msgq. + response = self.try_parse_ident_or_name().map(|s| s.to_lowercase()); + } + Token::Identifier(_) => { + msg_q = self.try_parse_ident_or_name().map(|s| s.to_lowercase()); + response = self.try_parse_ident_or_name().map(|s| s.to_lowercase()); + } + _ => {} + } self.eat_semicolon(); - Ok(Statement::Dsply(DsplyStmt { expr, msg_q: None, response: None })) + Ok(Statement::Dsply(DsplyStmt { expr, msg_q, response })) } } diff --git a/tests/samples.rs b/tests/samples.rs index d4e86b0..aec6d35 100644 --- a/tests/samples.rs +++ b/tests/samples.rs @@ -3,15 +3,22 @@ //! //! Each test follows the same three-step pipeline: //! 1. `rust-langrpg -o .rpg` — compile -//! 2. `` — execute +//! 2. `` — execute (optionally with stdin) //! 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")`. +//! `#[test]` that calls either: +//! * `run_sample("your_file.rpg")` — no stdin input +//! * `run_sample_stdin("your_file.rpg", b"input\n")` — bytes fed to stdin -use std::{fs, path::PathBuf, process::Command}; +use std::{ + fs, + io::Write as _, + path::PathBuf, + process::{Command, Stdio}, +}; /// Path to the freshly-built compiler binary, injected by Cargo. const BIN: &str = env!("CARGO_BIN_EXE_rust-langrpg"); @@ -23,12 +30,28 @@ const SAMPLES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/samples"); // Shared driver // ───────────────────────────────────────────────────────────────────────────── -/// Compile `sample_name` from `samples/`, run the resulting binary, and assert +/// Compile `sample_name` from `samples/`, run it with no stdin, 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) { + run_sample_inner(sample_name, &[]); +} + +/// Like [`run_sample`] but feeds `stdin_bytes` to the program's standard input. +/// +/// Use this for programs that read operator responses via the `DSPLY … response` +/// opcode. Pass every line the program will request, each terminated by `\n`. +/// +/// # Example +/// +/// ```no_run +/// run_sample_stdin("3np1.rpg", b"8\n0\n"); +/// ``` +fn run_sample_stdin(sample_name: &str, stdin_bytes: &[u8]) { + run_sample_inner(sample_name, stdin_bytes); +} + +/// Internal driver shared by [`run_sample`] and [`run_sample_stdin`]. +fn run_sample_inner(sample_name: &str, stdin_bytes: &[u8]) { let src = PathBuf::from(SAMPLES_DIR).join(sample_name); let golden_path = PathBuf::from(SAMPLES_DIR).join(format!("{}.golden", sample_name)); @@ -46,9 +69,11 @@ fn run_sample(sample_name: &str) { // ── 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, "_"); + // Sanitise the name so it is safe to use in a file path (e.g. "for.rpg" + // becomes "for_rpg") and prefix so artefacts are 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) @@ -73,9 +98,35 @@ fn run_sample(sample_name: &str) { // ── 2. Execute ──────────────────────────────────────────────────────────── - let run_out = Command::new(&exe) - .output() - .unwrap_or_else(|e| panic!("failed to run compiled binary for '{}': {e}", sample_name)); + let run_out = if stdin_bytes.is_empty() { + // No input needed — let stdin inherit /dev/null so the process never + // blocks waiting for a read that will never come. + Command::new(&exe) + .stdin(Stdio::null()) + .output() + .unwrap_or_else(|e| panic!("failed to run '{}': {e}", sample_name)) + } else { + // Pipe the provided bytes to the program's stdin. + let mut child = Command::new(&exe) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| panic!("failed to spawn '{}': {e}", sample_name)); + + // Write all input at once; the programs we test read only a handful of + // lines so this never fills the OS pipe buffer. + child + .stdin + .take() + .expect("stdin was piped") + .write_all(stdin_bytes) + .unwrap_or_else(|e| panic!("failed to write stdin for '{}': {e}", sample_name)); + + child + .wait_with_output() + .unwrap_or_else(|e| panic!("failed to wait on '{}': {e}", sample_name)) + }; assert!( run_out.status.success(), @@ -88,66 +139,73 @@ fn run_sample(sample_name: &str) { // ── 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()) - }); + 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 '{}': {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. + // Normalise both sides: trim trailing whitespace per line and ignore a + // lone 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 mut lines: Vec = s.lines().map(|l| l.trim_end().to_string()).collect(); + if lines.last().map(|l| l.is_empty()).unwrap_or(false) { + lines.pop(); + } + lines }; - let actual_lines = normalise(&actual_raw); + 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); + if actual_lines == expected_lines { + return; } + + // ── Build a readable diff-style failure message ─────────────────────────── + + 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'); + } + + // Point at the first diverging line to make the failure easy to locate. + 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: expected {}, got {}\n", + expected_lines.len(), + actual_lines.len(), + )); + } + + panic!("{}", msg); } // ───────────────────────────────────────────────────────────────────────────── @@ -168,3 +226,16 @@ fn sample_for() { fn sample_fib() { run_sample("fib.rpg"); } + +#[test] +fn sample_fizzbuzz() { + run_sample("fizzbuzz.rpg"); +} + +#[test] +fn sample_3np1() { + // Test the Collatz (3n+1) program with a starting value of 8. + // The sequence 8 → 4 → 2 → 1 takes 3 steps. + // A second input of 0 tells the outer loop to exit. + run_sample_stdin("3np1.rpg", b"8\n0\n"); +}