add: more samples and make them work

This commit is contained in:
2026-03-12 22:55:14 -07:00
parent 6c4118c489
commit 46935005f7
8 changed files with 525 additions and 90 deletions

View File

@@ -156,6 +156,59 @@ pub extern "C" fn rpg_dsply_f64(f: f64) {
let _ = out.flush(); 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::<i64>() {
unsafe { *response = n; }
}
}
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// rpg_halt — abnormal termination // rpg_halt — abnormal termination
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

44
samples/3np1.rpg Normal file
View File

@@ -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;

8
samples/3np1.rpg.golden Normal file
View File

@@ -0,0 +1,8 @@
DSPLY
DSPLY Sequence for 8:
DSPLY 4
DSPLY 2
DSPLY 1
DSPLY Reached 1 in 3 iterations.
DSPLY
DSPLY

18
samples/fizzbuzz.rpg Normal file
View File

@@ -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;

100
samples/fizzbuzz.rpg.golden Normal file
View File

@@ -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

View File

@@ -316,6 +316,15 @@ impl<'ctx> Codegen<'ctx> {
// i8* rpg_concat(i8* a, i8* b) — concatenate two C strings // 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); let concat_ty = i8_ptr.fn_type(&[i8_ptr.into(), i8_ptr.into()], false);
self.module.add_function("rpg_concat", concat_ty, None); 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 ───────────────────────────────────────────────── // ── Global declarations ─────────────────────────────────────────────────
@@ -474,6 +483,49 @@ impl<'ctx> Codegen<'ctx> {
self.builder.build_alloca(arr_ty, name).unwrap() 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`. /// 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> { 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; 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") let dsply = self.module.get_function("rpg_dsply")
.ok_or_else(|| CodegenError::new("rpg_dsply not declared"))?; .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 { match &d.expr {
Expression::Variable(qname) => { Expression::Variable(qname) => {
// Look up the variable, then pass ptr + len. // Look up the variable, then pass ptr + len.
@@ -1029,11 +1144,12 @@ impl<'ctx> Codegen<'ctx> {
*ptr *ptr
} else { } else {
// Declared as a narrower int (e.g. INT(10) = 4 bytes). // Declared as a narrower int (e.g. INT(10) = 4 bytes).
// Allocate a fresh 8-byte slot; locals will be updated below. // Allocate a fresh 8-byte slot in the entry block so it is
self.builder.build_alloca(i64_t, &f.var).unwrap() // 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 = self.gen_expression(&f.start, state)?;
let start_i = self.coerce_to_i64(start); let start_i = self.coerce_to_i64(start);

View File

@@ -1264,8 +1264,12 @@ impl Parser {
self.advance(); self.advance();
self.expect(&Token::LParen)?; self.expect(&Token::LParen)?;
let digits = self.parse_expression()?; let digits = self.parse_expression()?;
self.expect(&Token::Colon)?; // Decimal positions are optional — `Packed(10)` means `Packed(10:0)`.
let decimals = self.parse_expression()?; let decimals = if self.eat(&Token::Colon) {
self.parse_expression()?
} else {
Expression::Literal(Literal::Integer(0))
};
self.expect(&Token::RParen)?; self.expect(&Token::RParen)?;
Ok(TypeSpec::Packed(Box::new(digits), Box::new(decimals))) Ok(TypeSpec::Packed(Box::new(digits), Box::new(decimals)))
} }
@@ -1273,8 +1277,12 @@ impl Parser {
self.advance(); self.advance();
self.expect(&Token::LParen)?; self.expect(&Token::LParen)?;
let digits = self.parse_expression()?; let digits = self.parse_expression()?;
self.expect(&Token::Colon)?; // Decimal positions are optional — `Zoned(10)` means `Zoned(10:0)`.
let decimals = self.parse_expression()?; let decimals = if self.eat(&Token::Colon) {
self.parse_expression()?
} else {
Expression::Literal(Literal::Integer(0))
};
self.expect(&Token::RParen)?; self.expect(&Token::RParen)?;
Ok(TypeSpec::Zoned(Box::new(digits), Box::new(decimals))) Ok(TypeSpec::Zoned(Box::new(digits), Box::new(decimals)))
} }
@@ -1282,8 +1290,12 @@ impl Parser {
self.advance(); self.advance();
self.expect(&Token::LParen)?; self.expect(&Token::LParen)?;
let digits = self.parse_expression()?; let digits = self.parse_expression()?;
self.expect(&Token::Colon)?; // Decimal positions are optional — `Bindec(10)` means `Bindec(10:0)`.
let decimals = self.parse_expression()?; let decimals = if self.eat(&Token::Colon) {
self.parse_expression()?
} else {
Expression::Literal(Literal::Integer(0))
};
self.expect(&Token::RParen)?; self.expect(&Token::RParen)?;
Ok(TypeSpec::Bindec(Box::new(digits), Box::new(decimals))) Ok(TypeSpec::Bindec(Box::new(digits), Box::new(decimals)))
} }
@@ -1627,28 +1639,21 @@ impl Parser {
fn parse_dsply(&mut self) -> Result<Statement, LowerError> { fn parse_dsply(&mut self) -> Result<Statement, LowerError> {
self.advance(); // KwDsply self.advance(); // KwDsply
// Two forms: // Three forms:
// DSPLY expr; // 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 { 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(); // ( self.advance(); // (
let expr = self.parse_expression()?; let expr = self.parse_expression()?;
let mut msg_q = None; let mut msg_q = None;
let mut response = None; let mut response = None;
if self.eat(&Token::Colon) { if self.eat(&Token::Colon) {
if let Token::Identifier(s) = self.peek().clone() { // Accept any name-like token for msgq / response, including
self.advance(); // tokens that collide with keywords (e.g. a variable `n`).
msg_q = Some(s); msg_q = self.try_parse_ident_or_name().map(|s| s.to_lowercase());
} else {
self.eat(&Token::Colon);
}
if self.eat(&Token::Colon) { if self.eat(&Token::Colon) {
if let Token::Identifier(s) = self.peek().clone() { response = self.try_parse_ident_or_name().map(|s| s.to_lowercase());
self.advance();
response = Some(s);
}
} }
} }
self.eat(&Token::RParen); self.eat(&Token::RParen);
@@ -1656,8 +1661,28 @@ impl Parser {
Ok(Statement::Dsply(DsplyStmt { expr, msg_q, response })) Ok(Statement::Dsply(DsplyStmt { expr, msg_q, response }))
} else { } else {
let expr = self.parse_expression()?; 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(); self.eat_semicolon();
Ok(Statement::Dsply(DsplyStmt { expr, msg_q: None, response: None })) Ok(Statement::Dsply(DsplyStmt { expr, msg_q, response }))
} }
} }

View File

@@ -3,15 +3,22 @@
//! //!
//! Each test follows the same three-step pipeline: //! Each test follows the same three-step pipeline:
//! 1. `rust-langrpg -o <tmp> <sample>.rpg` — compile //! 1. `rust-langrpg -o <tmp> <sample>.rpg` — compile
//! 2. `<tmp>` — execute //! 2. `<tmp>` — execute (optionally with stdin)
//! 3. compare stdout with `<sample>.rpg.golden` //! 3. compare stdout with `<sample>.rpg.golden`
//! //!
//! # Adding a new sample //! # Adding a new sample
//! //!
//! Drop the `.rpg` source and a `.rpg.golden` file into `samples/` and add a //! 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. /// Path to the freshly-built compiler binary, injected by Cargo.
const BIN: &str = env!("CARGO_BIN_EXE_rust-langrpg"); 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 // 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/<sample_name>.golden` line-for-line. /// that its stdout matches `samples/<sample_name>.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) { 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 src = PathBuf::from(SAMPLES_DIR).join(sample_name);
let golden_path = PathBuf::from(SAMPLES_DIR).join(format!("{}.golden", 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 ──────────────────────────────────────────────────────────── // ── 1. Compile ────────────────────────────────────────────────────────────
// Sanitise the stem so it is safe to embed in a file name (e.g. "for.rpg" // Sanitise the name so it is safe to use in a file path (e.g. "for.rpg"
// "for_rpg") and prefix with a fixed string to make it easy to identify. // becomes "for_rpg") and prefix so artefacts are easy to identify.
let safe_stem = sample_name.replace('.', "_").replace(std::path::MAIN_SEPARATOR, "_"); 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 exe = std::env::temp_dir().join(format!("rpg_sample_test_{}.out", safe_stem));
let compile_out = Command::new(BIN) let compile_out = Command::new(BIN)
@@ -73,9 +98,35 @@ fn run_sample(sample_name: &str) {
// ── 2. Execute ──────────────────────────────────────────────────────────── // ── 2. Execute ────────────────────────────────────────────────────────────
let run_out = Command::new(&exe) let run_out = if stdin_bytes.is_empty() {
.output() // No input needed — let stdin inherit /dev/null so the process never
.unwrap_or_else(|e| panic!("failed to run compiled binary for '{}': {e}", sample_name)); // 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!( assert!(
run_out.status.success(), run_out.status.success(),
@@ -88,66 +139,73 @@ fn run_sample(sample_name: &str) {
// ── 3. Compare against golden ───────────────────────────────────────────── // ── 3. Compare against golden ─────────────────────────────────────────────
let actual_raw = String::from_utf8_lossy(&run_out.stdout); let actual_raw = String::from_utf8_lossy(&run_out.stdout);
let expected_raw = fs::read_to_string(&golden_path).unwrap_or_else(|e| { let expected_raw = fs::read_to_string(&golden_path)
panic!("could not read golden file '{}': {e}", golden_path.display()) .unwrap_or_else(|e| panic!("could not read golden '{}': {e}", golden_path.display()));
});
// Normalise both sides: trim trailing whitespace from every line and ignore // Normalise both sides: trim trailing whitespace per line and ignore a
// a trailing blank line, so golden files don't need a precise final newline. // lone trailing blank line so golden files don't need a precise final newline.
let normalise = |s: &str| -> Vec<String> { let normalise = |s: &str| -> Vec<String> {
s.lines().map(|l| l.trim_end().to_string()).collect() let mut lines: Vec<String> = 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); let expected_lines = normalise(&expected_raw);
if actual_lines != expected_lines { if actual_lines == expected_lines {
let mut msg = format!( return;
"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);
} }
// ── 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() { fn sample_fib() {
run_sample("fib.rpg"); 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");
}