From 27d1a471755b38e4f107ba544b82516ca44b9b12 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Mon, 17 Aug 2026 05:48:43 +0000 Subject: [PATCH 1/8] A DEF* default must not rename a name the compiler refuses `DEFINT A-Z` made `ON ERROR GOTO 100` compile clean again. The default-type pass renames every unsuffixed name a DEF* range covers, so `ERROR` became `ERROR%`, and `unsupported_reason` looks up the unsuffixed spelling and missed. The statement then went back to being a computed GOTO on a variable that is always zero -- the precise silent fall-through the UNSUPPORTED table was written to prevent, restored by a feature added four commits after it. Measured before the fix: ON ERROR GOTO 100 / END / 100 END -> refused DEFINT A-Z + the same -> compiled, ran, exit 0 DEFINT A-Z + PRINT ERR -> compiled, printed 0 `defaulted` already declined to rename a procedure or a builtin, for the same reason in each case: the name does not denote a variable. A name this compiler knows it does not provide belongs in that guard too, and now is. The test covers every route the refusal is reached by -- a bare name in an expression, a statement-position call, and ON ERROR -- under three different DEF* types, because the hole was in the renaming rather than in any one name. Co-Authored-By: Claude Opus 5 (1M context) --- src/sema.rs | 9 ++++++++- tests/errors/mod.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/sema.rs b/src/sema.rs index 300dd84..4b82063 100644 --- a/src/sema.rs +++ b/src/sema.rs @@ -447,7 +447,14 @@ fn defaulted(name: &str, table: &[DataType; 26], procs: &HashSet) -> Opt if name.ends_with(['%', '&', '!', '#', '$']) { return None; // an explicit suffix always wins } - if procs.contains(name) || builtin(name).is_some() { + // A procedure, a builtin, and a GW-BASIC name this compiler refuses are + // all names that do not denote a variable, so none of them takes a default + // type. The third was missing: `DEFINT A-Z` renamed `ERROR` to `ERROR%`, + // `unsupported_reason` looks the unsuffixed spelling up and missed, and + // `ON ERROR GOTO 100` went back to compiling as a computed GOTO on a + // variable that is always zero -- the exact silent fall-through the + // UNSUPPORTED table exists to prevent. + if procs.contains(name) || builtin(name).is_some() || unsupported_reason(name).is_some() { return None; } let first = name.chars().next()?; diff --git a/tests/errors/mod.rs b/tests/errors/mod.rs index 671a05f..7b594f7 100644 --- a/tests/errors/mod.rs +++ b/tests/errors/mod.rs @@ -945,6 +945,40 @@ fn test_unsupported_diagnostics_explain_themselves() { } } +/// A `DEF*` default must not rename a name the compiler refuses. +/// +/// `apply_default_types` rewrites every unsuffixed name a `DEF*` range covers, +/// and `unsupported_reason` matches the unsuffixed spelling -- so under +/// `DEFINT A-Z` the table was consulted for `ERROR%` and missed. That is not a +/// cosmetic gap: `ON ERROR GOTO 100` went back to compiling as a computed GOTO +/// on a variable that is always zero, which is the precise silent +/// fall-through the table was written to prevent, restored by a feature added +/// four commits later. +#[test] +fn test_a_def_type_does_not_defeat_the_refusals() { + // Every shape the refusal is reached through: a bare name in an + // expression, a statement-position call, and the ON ERROR special case. + let cases = [ + ("DEFINT A-Z\nON ERROR GOTO 100\nEND\n100 END\n", "ERROR"), + ("DEFINT A-Z\nPRINT ERR\n", "ERR"), + ("DEFINT A-Z\nPRINT ERL\n", "ERL"), + ("DEFINT A-Z\nPRINT CSRLIN\n", "CSRLIN"), + ("DEFSTR A-Z\nPRINT ERR\n", "ERR"), + ("DEFLNG A-Z\nPRINT CSRLIN\n", "CSRLIN"), + ]; + + for (source, name) in cases { + let err = compile_only(source) + .expect_err(&format!("{} must stay refused under a DEF* default", name)); + assert!( + err.contains("not supported"), + "{name} was accepted under a DEF* default: {}", + err.stderr + ); + assert!(err.is_clean_rejection()); + } +} + /// `NAME` was documented as unimplemented but missing from the table, so it /// fell through to "unknown subroutine" -- the generic message the table exists /// to replace. Nothing else distinguishes a name this compiler knows about and From 6be56ad6a0f34d9ce68a6f1118b1174de6d4ca8b Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Mon, 17 Aug 2026 05:51:25 +0000 Subject: [PATCH 2/8] Report the BASIC line number a listing actually wrote A program whose line 110 failed said "in 4". Codegen kept one line number, `current_line`, and it is the lexer's physical source line -- while the `_line_NNN` labels a program branches to come from `StmtKind::Label(n)`. Two numbering systems, and every diagnostic quoted the one the programmer cannot see. LANGREF:1309 has claimed otherwise since it was written. Track the most recent BASIC line beside the physical one and prefer it. A program with no line numbers has none to prefer, so it keeps reporting the source line, which is the only number its author can act on and the style everything in examples/ is written in; a statement ahead of the first line number does the same. LANGREF now states which of the two you get. Reset per function, because main is rendered into a scratch buffer before the procedures and shares the field with them. This is a prerequisite for ERL rather than a tidy-up: ERL returns the BASIC line, so the number in the message and the number the handler reads have to be the same one. Verified across the four shapes that differ: a line-numbered listing (110), a failure inside a GOSUB'd subroutine (110), a SUB carrying its own numbers (900), and an unnumbered program (source line 2, unchanged). Co-Authored-By: Claude Opus 5 (1M context) --- LANGREF.md | 5 +++++ src/codegen.rs | 36 +++++++++++++++++++++++++++++----- tests/errors/mod.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/LANGREF.md b/LANGREF.md index 4f26433..330f522 100644 --- a/LANGREF.md +++ b/LANGREF.md @@ -1312,6 +1312,11 @@ line it happened on to standard error, and exits with status 1: ?Subscript out of range in 42 ``` +The number is the **BASIC line number**, as in GW-BASIC -- the number the +listing itself branches to. A program written without line numbers has none to +quote, so it reports the source line instead, which is the only number its +author can act on. A statement ahead of the first line number does the same. + Checked: array subscripts (against every dimension, and against the lower bound when `OPTION BASE 1` is in effect), use of an array before its `DIM` has run, division by zero for `/`, `\` and `MOD`, a `\` or `MOD` whose quotient diff --git a/src/codegen.rs b/src/codegen.rs index 50a6edd..06fce5b 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -584,8 +584,15 @@ pub struct CodeGen { symbols: Symbols, /// Code generation options (currently just whether checks are emitted). opts: Options, - /// BASIC line of the statement being compiled, for runtime diagnostics. + /// Physical source line of the statement being compiled. current_line: u32, + /// The BASIC line number most recently reached, or 0 before the first one. + /// + /// Separate from `current_line`, which is the lexer's physical line. A + /// listing branches to the numbers it wrote, so those are the numbers a + /// diagnostic has to quote and `ERL` has to return; reporting the physical + /// line named something the programmer could not see. + basic_line: u32, /// Error trampolines needed so far, keyed by (error, line) so that sites /// sharing both share one trampoline. Ordered for reproducible output. error_sites: BTreeMap<(RtError, u32), String>, @@ -1421,6 +1428,9 @@ impl CodeGen { /// Emit `main`: prologue, module-level statements, epilogue. fn gen_main(&mut self, program: &Program) { + // Main is rendered before the procedures but shares the field with + // them, so each function starts again with no line number reached. + self.basic_line = 0; self.reserve_array_descriptors(&SemaScope::Module); self.emit_label("main"); self.emit(" push rbp"); @@ -1584,10 +1594,24 @@ impl CodeGen { self.emit_label(&skip); } + /// The line number a runtime diagnostic should quote. + /// + /// The BASIC line number once one has been reached, since that is what the + /// programmer wrote and what `ERL` reports. A program with no line numbers + /// -- the style everything in examples/ uses -- has only the physical + /// source line, and that is more useful than nothing. + fn report_line(&self) -> u32 { + if self.basic_line != 0 { + self.basic_line + } else { + self.current_line + } + } + /// Label of the trampoline that raises `kind` at the current line, /// creating it if this is the first site to need it. fn error_label(&mut self, kind: RtError) -> String { - let line = self.current_line; + let line = self.report_line(); self.error_sites .entry((kind, line)) .or_insert_with(|| format!(".Lerr_{}_{}", kind.tag(), line)) @@ -2429,6 +2453,7 @@ impl CodeGen { fn gen_procedure(&mut self, name: &str, params: &[Param], body: &[Stmt], is_function: bool) { self.current_proc = Some(name.to_string()); + self.basic_line = 0; // see gen_main self.proc_vars.clear(); self.proc_arrays.clear(); self.proc_types.clear(); @@ -2622,6 +2647,7 @@ impl CodeGen { } match &stmt.kind { StmtKind::Label(n) => { + self.basic_line = *n; self.emit_label(&format!("_line_{}", n)); } @@ -3640,7 +3666,7 @@ impl CodeGen { }; self.emit_arg_file_num(0, &fnum); self.emit_arg_file_num(1, &rec); - self.emit_arg_imm(2, self.current_line as i64); + self.emit_arg_imm(2, self.report_line() as i64); if *is_put { self.emit(" call _rt_file_put"); } else { @@ -3671,7 +3697,7 @@ impl CodeGen { self.emit_arg_file_num(0, &fnum); self.emit_arg_file_num(1, &start); self.emit_arg_file_num(2, &end); - self.emit_arg_imm(3, self.current_line as i64); + self.emit_arg_imm(3, self.report_line() as i64); if *is_unlock { self.emit(" call _rt_unlock"); } else { @@ -6030,7 +6056,7 @@ impl CodeGen { self.gen_expr(&args[0]); self.emit_arg_reg(0, "rax"); // string ptr self.emit_arg_reg(1, "rdx"); // string len - self.emit_arg_imm(2, self.current_line as i64); + self.emit_arg_imm(2, self.report_line() as i64); let rt = match upper_name.as_str() { "CVI" => "_rt_cvi", "CVL" => "_rt_cvl", diff --git a/tests/errors/mod.rs b/tests/errors/mod.rs index 7b594f7..f89fc0e 100644 --- a/tests/errors/mod.rs +++ b/tests/errors/mod.rs @@ -424,6 +424,53 @@ fn test_runtime_error_reports_line() { ); } +/// A line-numbered listing reports its BASIC line number, not the source line. +/// +/// These are two different numbering systems and the error path used the wrong +/// one: `current_line` is the lexer's physical line, while the `_line_NNN` +/// labels a program branches to come from `StmtKind::Label(n)`. A listing whose +/// line 110 failed said "in 4", so the number in the message named nothing the +/// programmer could see, and disagreed with LANGREF's own example. +#[test] +fn test_error_reports_the_basic_line_number() { + let run = compile_and_run_raw( + "REM a header comment\nREM and another\n100 DIM A(3)\n110 PRINT A(99)\n", + "", + ) + .expect("should compile"); + assert!( + run.stderr.contains("in 110"), + "expected the BASIC line 110, got {:?}", + run.stderr + ); +} + +/// A program written without line numbers still reports its source line. +/// +/// GW-BASIC has nothing else to say here and reports nothing; a source line is +/// what a programmer can act on, and it is the style everything in examples/ +/// is written in. +#[test] +fn test_error_reports_the_source_line_without_line_numbers() { + let run = compile_and_run_raw("DIM A(2)\nPRINT \"x\"\nA(9) = 1\n", "").expect("should compile"); + assert!( + run.stderr.contains("in 3"), + "expected the source line 3, got {:?}", + run.stderr + ); +} + +/// A statement ahead of the first line number has no BASIC line to report. +#[test] +fn test_error_before_the_first_line_number() { + let run = compile_and_run_raw("DIM A(2)\nA(9) = 1\n100 END\n", "").expect("should compile"); + assert!( + run.stderr.contains("in 2"), + "nothing numbered has run yet, so the source line stands: {:?}", + run.stderr + ); +} + /// Runtime diagnostics go to stderr, leaving the program's own output clean. #[test] fn test_runtime_errors_go_to_stderr() { From 9ca2bd0d5bc7a29b637f17f92f8e41a6b09514c1 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Mon, 17 Aug 2026 05:57:37 +0000 Subject: [PATCH 3/8] Give every runtime error GW-BASIC's number, and add ERROR n The numbers are the interface error trapping is built on: a listing writes `IF ERR = 53 THEN` and means "file not found". Nothing here had numbers at all, so they come first, and `ERROR n` is the half of the feature that is fully meaningful before any handler exists -- it raises by number and aborts, exactly as GW-BASIC does when nothing is trapping. Both trees gain a table of (message, number) pairs rather than a third argument to `_rt_error`. That argument would have to be threaded through thirteen call sites per tree plus every trampoline codegen emits, and a helper here may take only four arguments because Win64 passes four in registers. A linear scan on the way to exit costs nothing. `ERROR` stays contextual rather than reserved: it is a statement only when something follows it to be the number, so a bare `ERROR` still reaches the UNSUPPORTED table and `ON ERROR GOTO` is still refused with a reason until it is written. The two wildcard-free matches in sema and the one in codegen each refused to compile until the new statement said what it was, which is what they are for. ERR and ERL are deliberately not in this commit. They would read 0 until trapping exists, and a program branching on `IF ERR = 53` would take the wrong arm with no diagnostic -- the silent wrong answer this compiler refuses elsewhere. They land with the trap that gives them values. Verified under both runtimes: the Win64 tree is only ever built in CI, so `ERROR 53` was also run through an ms_abi model of it, where it agrees with System V. That first disagreed only because the model's own sprintf stub could not handle `_rt_error`'s "%s in %lld"; the stub was wrong, not the runtime. Co-Authored-By: Claude Opus 5 (1M context) --- LANGREF.md | 47 +++++++++++------ src/codegen.rs | 12 +++++ src/parser.rs | 27 ++++++++++ src/runtime/sysv/error.s | 57 +++++++++++++++++++++ src/runtime/win64-native/error.s | 56 ++++++++++++++++++++ src/sema.rs | 4 ++ tests/errors/mod.rs | 87 ++++++++++++++++++++++++++++++++ 7 files changed, 275 insertions(+), 15 deletions(-) diff --git a/LANGREF.md b/LANGREF.md index 330f522..acc679f 100644 --- a/LANGREF.md +++ b/LANGREF.md @@ -1326,21 +1326,38 @@ allocation failure, a random-access operation on a file not opened `FOR RANDOM`, `FIELD` widths that overrun the record, a `CV` conversion given too few bytes, and a `LOCK` another process already holds. -The message names the fault: - -| Message | Cause | -|--------------------------|--------------------------------------------------| -| `Subscript out of range` | A subscript outside a dimension's bounds | -| `Array used before DIM` | An array reached before its `DIM` ran | -| `Division by zero` | A zero divisor in `/`, `\` or `MOD` | -| `Overflow` | A `\` or `MOD` whose quotient does not fit | -| `Illegal function call` | `SQR` of a negative, `LOG` of a non-positive | -| `Bad file number` | A file number outside 1 to 15 | -| `GOSUB stack overflow` | `GOSUB` nested past the return stack's depth | -| `Out of memory` | A string or array allocation that failed | -| `Bad file mode` | `FIELD`, `GET` or `PUT` on a non-random file | -| `FIELD overflow` | `FIELD` widths exceeding the record length | -| `Permission denied` | A `LOCK` someone else already holds | +The message names the fault, and each carries GW-BASIC's number for it: + +| No. | Message | Cause | +|-----|--------------------------|----------------------------------------------| +| 5 | `Illegal function call` | `SQR` of a negative, `LOG` of a non-positive | +| 6 | `Overflow` | A `\` or `MOD` whose quotient does not fit | +| 7 | `Out of memory` | A string or array allocation that failed | +| 7 | `GOSUB stack overflow` | `GOSUB` nested past the return stack's depth | +| 9 | `Subscript out of range` | A subscript outside a dimension's bounds | +| 9 | `Array used before DIM` | An array reached before its `DIM` ran | +| 11 | `Division by zero` | A zero divisor in `/`, `\` or `MOD` | +| 50 | `FIELD overflow` | `FIELD` widths exceeding the record length | +| 52 | `Bad file number` | A file number outside 1 to 15 | +| 53 | `File not found` | Opening a file that is not there | +| 54 | `Bad file mode` | `FIELD`, `GET` or `PUT` on a non-random file | +| 55 | `File already open` | `OPEN` on a file number already in use | +| 62 | `Input past end of file` | Reading past the end of an input file | +| 70 | `Permission denied` | A `LOCK` someone else already holds | + +### ERROR + +`ERROR n` raises the error numbered `n`, exactly as if the runtime had raised +it. The number must be 1 to 255; one the table above does not list is raised as +`Unprintable error`, which is GW-BASIC's own wording. + +```basic +10 IF Total < 0 THEN ERROR 5 +20 PRINT "fine" +``` + +Two numbers appear twice above. `ERROR 7` and `ERROR 9` raise the first message +listed for each -- `Out of memory` and `Subscript out of range`. Checks are on by default. Compiling with `--unsafe` removes them, which is worth doing only for code already known to be correct: diff --git a/src/codegen.rs b/src/codegen.rs index 06fce5b..51c1028 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -3473,6 +3473,18 @@ impl CodeGen { // reads as undimensioned again and a later DIM allocates afresh. // free(NULL) is defined, so erasing an array that was never // dimensioned is harmless. + // `ERROR n` -- raise the error GW-BASIC numbers n. The range is + // GW-BASIC's: 0 and 256 are not error numbers, and a code the + // table does not know still raises, as "Unprintable error". + StmtKind::RaiseError(expr) => { + self.gen_expr_to_long(expr); + self.emit(" movsxd r10, eax"); + self.emit_arg_range_check("r10", 1, Some(255)); + self.emit_arg_reg(0, "r10"); + self.emit_arg_imm(1, self.report_line() as i64); + self.emit(" call _rt_error_num"); + } + StmtKind::Erase(names) => { for name in names { let Some(loc) = self.lookup_array(name).map(|i| i.loc.clone()) else { diff --git a/src/parser.rs b/src/parser.rs index adf31ec..72d2b3f 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -237,6 +237,8 @@ pub enum StmtKind { Beep, /// `ERASE a, b` -- release arrays so they can be dimensioned again. Erase(Vec), + /// `ERROR n` -- raise the error GW-BASIC numbers `n`. + RaiseError(Expr), /// `LOCATE [row][, col]` -- move the cursor. /// /// Either part may be omitted, in which case that coordinate is left where @@ -983,6 +985,24 @@ impl Parser { matches!(self.peek_at(1), Token::Ident(_)) } + /// True if the token after the current one could begin an expression. + /// + /// `ERROR` is a statement only when something follows it to be the error + /// number. Left contextual rather than reserved so that a bare `ERROR` + /// still reaches the UNSUPPORTED table, which is what keeps + /// `ON ERROR GOTO` refused with a reason until it is written. + fn next_starts_a_value(&self) -> bool { + matches!( + self.peek_at(1), + Token::Integer(_) + | Token::Float(_) + | Token::Ident(_) + | Token::LParen + | Token::Minus + | Token::Plus + ) + } + /// Source line of the current token, or 0 when unknown (no line map). fn cur_line(&self) -> u32 { self.lines.get(self.pos).copied().unwrap_or(0) @@ -1371,6 +1391,7 @@ impl Parser { "UNLOCK" if self.next_is(Token::Hash) => self.parse_lock(true), "CALL" if self.next_is_ident() => self.parse_call(), "ERASE" if self.next_is_ident() => self.parse_erase(), + "ERROR" if self.next_starts_a_value() => self.parse_raise_error(), "LSET" if self.next_is_ident() => self.parse_set_field(false), "RSET" if self.next_is_ident() => self.parse_set_field(true), _ => self.parse_assignment_or_call(), @@ -2650,6 +2671,12 @@ impl Parser { /// Contextual rather than reserved, like CALL above it: LANGREF's own /// `ON ... GOSUB Draw, Erase` example uses the word as a label, and a /// reserved ERASE would take that name away from every program. + /// `ERROR n` -- raise an error by GW-BASIC's number for it. + fn parse_raise_error(&mut self) -> PResult { + self.advance(); // consume ERROR + Ok(StmtKind::RaiseError(self.parse_expression()?)) + } + fn parse_erase(&mut self) -> PResult { self.advance(); // consume ERASE let mut names = Vec::new(); diff --git a/src/runtime/sysv/error.s b/src/runtime/sysv/error.s index 884338d..f798a00 100644 --- a/src/runtime/sysv/error.s +++ b/src/runtime/sysv/error.s @@ -33,8 +33,65 @@ _err_notfound: .asciz "File not found" _err_alreadyopen: .asciz "File already open" _err_pastend: .asciz "Input past end of file" +_err_unprintable: .asciz "Unprintable error" + +# GW-BASIC's own error numbers, so that a listing's `IF ERR = 53` means what it +# meant in 1983. Pairs of (message, number), terminated by a zero message. +# +# A table rather than a third argument to _rt_error: that argument would have to +# be threaded through thirteen call sites in this tree alone, plus every +# trampoline codegen emits, and a helper here may take only four arguments +# because Win64 passes only four in registers. Errors are not a hot path, so a +# linear scan on the way to exit costs nothing. +# +# Searched by number as well as by message, for the ERROR statement -- so where +# two messages share a number the first one listed is the one ERROR n raises. +.p2align 3 +_err_codes: + .quad _err_domain, 5 + .quad _err_overflow, 6 + .quad _err_memory, 7 + .quad _err_gosub, 7 + .quad _err_subscript, 9 + .quad _err_undim, 9 + .quad _err_div0, 11 + .quad _err_fieldovf, 50 + .quad _err_badfile, 52 + .quad _err_notfound, 53 + .quad _err_badmode, 54 + .quad _err_alreadyopen, 55 + .quad _err_pastend, 62 + .quad _err_permission, 70 + .quad 0, 0 + .text +# _rt_error_num - Raise the error with GW-BASIC number `n` (ERROR statement) +# +# Arguments: +# rdi = error number +# rsi = BASIC line number, or 0 when unknown +# +# Returns: never -- tail-calls _rt_error with the matching message. +.globl _rt_error_num +_rt_error_num: + lea rax, [rip + _err_codes] +.Lnum_scan: + mov rcx, QWORD PTR [rax] # message, or 0 at the end + test rcx, rcx + jz .Lnum_unknown + cmp QWORD PTR [rax + 8], rdi + je .Lnum_found + add rax, 16 + jmp .Lnum_scan +.Lnum_found: + mov rdi, rcx + jmp _rt_error +.Lnum_unknown: + lea rdi, [rip + _err_unprintable] + jmp _rt_error + + # _rt_error - Report a runtime error and terminate # Arguments: # rdi = message pointer (NUL-terminated) diff --git a/src/runtime/win64-native/error.s b/src/runtime/win64-native/error.s index c47ce5b..1e812b4 100644 --- a/src/runtime/win64-native/error.s +++ b/src/runtime/win64-native/error.s @@ -35,6 +35,37 @@ _err_notfound: .asciz "File not found" _err_alreadyopen: .asciz "File already open" _err_pastend: .asciz "Input past end of file" +_err_unprintable: .asciz "Unprintable error" + +# GW-BASIC's own error numbers, so that a listing's `IF ERR = 53` means what it +# meant in 1983. Pairs of (message, number), terminated by a zero message. +# +# A table rather than a third argument to _rt_error: that argument would have to +# be threaded through thirteen call sites in this tree alone, plus every +# trampoline codegen emits, and a helper here may take only four arguments +# because Win64 passes only four in registers. Errors are not a hot path, so a +# linear scan on the way to exit costs nothing. +# +# Searched by number as well as by message, for the ERROR statement -- so where +# two messages share a number the first one listed is the one ERROR n raises. +.p2align 3 +_err_codes: + .quad _err_domain, 5 + .quad _err_overflow, 6 + .quad _err_memory, 7 + .quad _err_gosub, 7 + .quad _err_subscript, 9 + .quad _err_undim, 9 + .quad _err_div0, 11 + .quad _err_fieldovf, 50 + .quad _err_badfile, 52 + .quad _err_notfound, 53 + .quad _err_badmode, 54 + .quad _err_alreadyopen, 55 + .quad _err_pastend, 62 + .quad _err_permission, 70 + .quad 0, 0 + # Zero-filled scratch, so .bss rather than .data -- see data_defs.s. The # .text below restores the section for the code that follows. .bss @@ -44,6 +75,31 @@ _err_bytes_written: .skip 8 .text +# _rt_error_num - Raise the error with GW-BASIC number `n` (ERROR statement) +# +# Arguments (Win64): +# rcx = error number +# rdx = BASIC line number, or 0 when unknown +# +# Returns: never -- tail-calls _rt_error with the matching message. +.globl _rt_error_num +_rt_error_num: + lea rax, [rip + _err_codes] +.Lnum_scan: + mov r8, QWORD PTR [rax] # message, or 0 at the end + test r8, r8 + jz .Lnum_unknown + cmp QWORD PTR [rax + 8], rcx + je .Lnum_found + add rax, 16 + jmp .Lnum_scan +.Lnum_found: + mov rcx, r8 + jmp _rt_error +.Lnum_unknown: + lea rcx, [rip + _err_unprintable] + jmp _rt_error + # _rt_error - Report a runtime error and terminate # Arguments (Win64): # rcx = message pointer (NUL-terminated) diff --git a/src/sema.rs b/src/sema.rs index 4b82063..3f4db40 100644 --- a/src/sema.rs +++ b/src/sema.rs @@ -559,6 +559,9 @@ fn rewrite_names(stmts: &mut [Stmt], table: &[DataType; 26], procs: &HashSet {} StmtKind::Input { vars, .. } | StmtKind::Read(vars) => { vars.iter_mut().for_each(|v| lvalue(v, table, procs)) } @@ -733,6 +736,7 @@ fn for_each_expr_mut(stmt: &mut Stmt, f: &mut impl FnMut(&mut Expr)) { bg.iter_mut().for_each(&mut *f); } StmtKind::Randomize(seed) => seed.iter_mut().for_each(&mut *f), + StmtKind::RaiseError(e) => f(e), StmtKind::Const { value, .. } => f(value), StmtKind::FieldAssign { target, value } => { lvalue(target, f); diff --git a/tests/errors/mod.rs b/tests/errors/mod.rs index f89fc0e..6cfff8e 100644 --- a/tests/errors/mod.rs +++ b/tests/errors/mod.rs @@ -424,6 +424,93 @@ fn test_runtime_error_reports_line() { ); } +/// `ERROR n` raises the numbered error, with GW-BASIC's own numbering. +/// +/// The numbers are the interface: a listing writes `IF ERR = 53 THEN` and +/// means "file not found". Raising by number is the half of that which works +/// without a handler, and it is what pins the table. +#[test] +fn test_error_statement_raises_by_number() { + for (source, message) in [ + ("ERROR 5\n", "Illegal function call"), + ("ERROR 6\n", "Overflow"), + ("ERROR 7\n", "Out of memory"), + ("ERROR 9\n", "Subscript out of range"), + ("ERROR 11\n", "Division by zero"), + ("ERROR 50\n", "FIELD overflow"), + ("ERROR 52\n", "Bad file number"), + ("ERROR 53\n", "File not found"), + ("ERROR 54\n", "Bad file mode"), + ("ERROR 55\n", "File already open"), + ("ERROR 62\n", "Input past end of file"), + ("ERROR 70\n", "Permission denied"), + ] { + let run = compile_and_run_raw(source, "").expect("should compile"); + assert!( + run.stderr.contains(message), + "ERROR should raise {message:?}, got {:?}", + run.stderr + ); + assert_eq!(run.exit_code, Some(1), "a raised error still aborts"); + } +} + +/// A number the table does not know is still an error, and says so. +#[test] +fn test_error_statement_with_an_unknown_number() { + let run = compile_and_run_raw("ERROR 200\n", "").expect("should compile"); + assert!( + run.stderr.contains("Unprintable error"), + "GW-BASIC's own wording for a code it has no message for: {:?}", + run.stderr + ); + assert_eq!(run.exit_code, Some(1)); +} + +/// The raised error carries the line, like any other. +#[test] +fn test_error_statement_reports_its_line() { + let run = compile_and_run_raw("10 PRINT \"x\"\n20 ERROR 11\n", "").expect("should compile"); + assert!( + run.stderr.contains("in 20"), + "expected the BASIC line, got {:?}", + run.stderr + ); +} + +/// The operand is evaluated, not just a literal. +#[test] +fn test_error_statement_takes_an_expression() { + let run = compile_and_run_raw("N = 50\nERROR N + 3\n", "").expect("should compile"); + assert!( + run.stderr.contains("File not found"), + "53 should come out of the expression: {:?}", + run.stderr + ); +} + +/// GW-BASIC restricts the code to 1..255, and so does this. +#[test] +fn test_error_statement_rejects_a_code_out_of_range() { + for source in ["ERROR 0\n", "ERROR 256\n", "ERROR -1\n"] { + let run = compile_and_run_raw(source, "").expect("should compile"); + assert!( + run.stderr.contains("Illegal function call"), + "{source:?} should be refused at run time, got {:?}", + run.stderr + ); + } +} + +/// `ERROR` is still not a value, and `ON ERROR` is still refused. +#[test] +fn test_error_is_a_statement_not_a_name() { + let err = compile_only("PRINT ERROR\n").expect_err("ERROR is not a value"); + assert!(err.contains("not supported"), "got: {}", err.stderr); + let err = compile_only("ON ERROR GOTO 100\nEND\n100 END\n").expect_err("not implemented yet"); + assert!(err.contains("not supported"), "got: {}", err.stderr); +} + /// A line-numbered listing reports its BASIC line number, not the source line. /// /// These are two different numbering systems and the error path used the wrong From 25cb36bc560440101f63f363bce8cfbf6ea72f64 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Mon, 17 Aug 2026 07:55:21 +0000 Subject: [PATCH 4/8] Diagnose a bad numeric operand instead of panicking on it Both reported by Copilot against the ERROR statement, both real, and both wider than reported. `check_stmt` ends in a wildcard, so nothing forced an arm for a new statement and the operands of four of them were never checked at all -- not just ERROR, but LOCATE, COLOR and RANDOMIZE, added in the last two batches. Measured, the compiler panicked with exit 101: ERROR NoSuchFn(1) codegen.rs:6405 "sema checked the array is declared" A$="x" : ERROR A$ codegen.rs:1330 "Cannot implicitly convert to/from String" A$="x" : LOCATE A$,1 codegen.rs:1330 the same A$="x" : RANDOMIZE A$ codegen.rs:1330 the same The first message is the finding in one line: codegen asserts a check that sema never performed. A builtin's arguments were always checked, which is why `SQR(A$)` has always said so properly; a statement's operands were not. One `check_numeric_operand` now does both halves for all four. Separately, the token test that decides whether `ERROR` leads a statement was written as a list of the tokens an expression may begin with, and had already rotted: it was missing `NOT` and a string literal, so `ERROR NOT 0` -- an ordinary GW-BASIC expression -- was refused as though the statement did not exist, and `ERROR "boom"` blamed the wrong thing. Inverted to ask whether the statement has ended, a set of four tokens that does not grow, so anything else goes to the expression parser and gets a real diagnostic. A token added later needs no edit. `ERROR` alone, `PRINT ERROR` and `ON ERROR GOTO` all still reach the UNSUPPORTED table, which is what holds the line until trapping is written. Co-Authored-By: Claude Opus 5 (1M context) --- src/parser.rs | 31 +++++++++++--------- src/sema.rs | 32 +++++++++++++++++++++ tests/errors/mod.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/parser.rs b/src/parser.rs index 72d2b3f..f39c455 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -985,21 +985,24 @@ impl Parser { matches!(self.peek_at(1), Token::Ident(_)) } - /// True if the token after the current one could begin an expression. + /// True if something follows the current token for it to take as an operand. /// - /// `ERROR` is a statement only when something follows it to be the error - /// number. Left contextual rather than reserved so that a bare `ERROR` - /// still reaches the UNSUPPORTED table, which is what keeps - /// `ON ERROR GOTO` refused with a reason until it is written. - fn next_starts_a_value(&self) -> bool { - matches!( + /// `ERROR` is a statement only when a number follows it. Left contextual + /// rather than reserved so that a bare `ERROR` still reaches the + /// UNSUPPORTED table, which is what keeps `ON ERROR GOTO` refused with a + /// reason until it is written. + /// + /// Phrased as "the statement has not ended" rather than as a list of the + /// tokens an expression may start with. That list was written out once and + /// was already missing `NOT` and a string literal, so `ERROR NOT 0` -- + /// a perfectly ordinary GW-BASIC expression -- was refused as though the + /// statement did not exist. This way anything else is handed to the + /// expression parser, which either accepts it or says what is wrong with + /// it, and a token added later needs no edit here. + fn next_is_an_operand(&self) -> bool { + !matches!( self.peek_at(1), - Token::Integer(_) - | Token::Float(_) - | Token::Ident(_) - | Token::LParen - | Token::Minus - | Token::Plus + Token::Newline | Token::Colon | Token::Eof | Token::Eq ) } @@ -1391,7 +1394,7 @@ impl Parser { "UNLOCK" if self.next_is(Token::Hash) => self.parse_lock(true), "CALL" if self.next_is_ident() => self.parse_call(), "ERASE" if self.next_is_ident() => self.parse_erase(), - "ERROR" if self.next_starts_a_value() => self.parse_raise_error(), + "ERROR" if self.next_is_an_operand() => self.parse_raise_error(), "LSET" if self.next_is_ident() => self.parse_set_field(false), "RSET" if self.next_is_ident() => self.parse_set_field(true), _ => self.parse_assignment_or_call(), diff --git a/src/sema.rs b/src/sema.rs index 3f4db40..61f6637 100644 --- a/src/sema.rs +++ b/src/sema.rs @@ -1640,6 +1640,22 @@ impl Analyzer { // exist, so `ERASE TOTLA` for `ERASE TOTAL` compiled clean and // erased nothing. The lookup is the one every array use gets -- // the enclosing procedure first, then the module. + StmtKind::RaiseError(n) => self.check_numeric_operand(n, scope, line, "ERROR"), + StmtKind::Locate { row, col } => { + for e in row.iter().chain(col.iter()) { + self.check_numeric_operand(e, scope, line, "LOCATE"); + } + } + StmtKind::Color { fg, bg } => { + for e in fg.iter().chain(bg.iter()) { + self.check_numeric_operand(e, scope, line, "COLOR"); + } + } + StmtKind::Randomize(seed) => { + for e in seed.iter() { + self.check_numeric_operand(e, scope, line, "RANDOMIZE"); + } + } StmtKind::Erase(names) => { for name in names { if self.symbols.lookup_array(scope, name).is_none() { @@ -2170,6 +2186,22 @@ impl Analyzer { } } + /// Check an expression a statement will use as a number. + /// + /// Two jobs, and both were missing for every statement that takes a bare + /// numeric operand. Without `check_expr` the operand's names are never + /// resolved, so `LOCATE NoSuchFn(1), 1` reached the codegen line that + /// says "sema checked the array is declared" -- it had not -- and panicked. + /// Without the string test, `LOCATE A$, 1` reached the implicit-conversion + /// panic instead. A builtin's arguments were always checked (`SQR(A$)` + /// says so properly); a statement's were not. + fn check_numeric_operand(&mut self, expr: &Expr, scope: &Scope, line: u32, what: &str) { + self.check_expr(expr, scope, line); + if self.expr_is_string(expr, scope) == Some(true) { + self.error(line, format!("{} needs a number, not a string", what)); + } + } + fn check_expr(&mut self, expr: &Expr, scope: &Scope, line: u32) { match expr { Expr::Literal(_) => {} diff --git a/tests/errors/mod.rs b/tests/errors/mod.rs index 6cfff8e..962a67a 100644 --- a/tests/errors/mod.rs +++ b/tests/errors/mod.rs @@ -502,6 +502,75 @@ fn test_error_statement_rejects_a_code_out_of_range() { } } +/// A bad numeric operand is diagnosed, not a compiler panic. +/// +/// `check_stmt` has a wildcard, so nothing forced an arm for a new statement +/// and the operands of four of them were never checked at all. `ERROR +/// NoSuchFn(1)` reached the codegen line that asserts "sema checked the array +/// is declared" -- it had not -- and `LOCATE A$, 1` reached the +/// implicit-String-conversion panic. A builtin's arguments were always +/// checked, which is why `SQR(A$)` says so properly; a statement's were not. +/// Turning panics into diagnostics is the point of that pass. +#[test] +fn test_numeric_operands_are_checked_not_panicked_on() { + for (source, expect) in [ + ("A$ = \"x\"\nERROR A$\n", "ERROR needs a number"), + ("ERROR \"boom\"\n", "ERROR needs a number"), + ("ERROR NoSuchFn(1)\n", "unknown function or array"), + // The same hole, and the same fix, for every statement that takes a + // bare numeric operand. All four were added in the last two batches. + ("A$ = \"x\"\nLOCATE A$, 1\n", "LOCATE needs a number"), + ("A$ = \"x\"\nCOLOR 1, A$\n", "COLOR needs a number"), + ("A$ = \"x\"\nRANDOMIZE A$\n", "RANDOMIZE needs a number"), + ("LOCATE NoSuchFn(1), 1\n", "unknown function or array"), + ("RANDOMIZE NoSuchFn(1)\n", "unknown function or array"), + ] { + let err = + compile_only(source).expect_err(&format!("{} must be refused", source.escape_debug())); + assert!( + err.is_clean_rejection(), + "must be diagnosed, not panic: {}", + err.stderr + ); + assert!( + err.contains(expect), + "expected {expect:?} in the diagnostic, got: {}", + err.stderr + ); + } +} + +/// Any expression may be the error number, including one that starts with NOT. +/// +/// The token test that decides whether `ERROR` leads a statement listed the +/// tokens an expression can start with, and missed `NOT` and a string literal. +/// `ERROR NOT 0` was then refused as though the statement did not exist. +#[test] +fn test_error_statement_accepts_any_expression_shape() { + // NOT 0 is -1, which is out of the 1..255 range, so the run reports the + // range rather than the parse -- which is the point: it parsed. + let run = compile_and_run_raw("ERROR NOT 0\n", "").expect("should compile"); + assert!( + run.stderr.contains("Illegal function call"), + "NOT 0 should reach the range check, got {:?}", + run.stderr + ); + + let run = compile_and_run_raw("N = 10\nERROR NOT N\n", "").expect("should compile"); + assert!( + run.stderr.contains("Illegal function call"), + "{:?}", + run.stderr + ); + + let run = compile_and_run_raw("ERROR (5)\n", "").expect("should compile"); + assert!( + run.stderr.contains("Illegal function call"), + "{:?}", + run.stderr + ); +} + /// `ERROR` is still not a value, and `ON ERROR` is still refused. #[test] fn test_error_is_a_statement_not_a_name() { From b4c6ab9130da14a091ca31a813181e9b9ee7204b Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Mon, 17 Aug 2026 08:21:57 +0000 Subject: [PATCH 5/8] ON ERROR GOTO: make the one-way error path resumable `_rt_error` was documented "Returns: never" and meant it -- no `ret` at all, straight into exit(1). It is reached from thirteen places inside each runtime tree with live frames, some two deep, some holding a lock structure on the stack. Trapping abandons all of them, so this is a longjmp in all but name. The context is captured once, in main's prologue, by a helper in each runtime tree rather than by codegen: the callee-saved set differs between the ABIs -- Win64 counts rdi and rsi where System V does not -- and the tree that knows that should be the one that writes it down. The values saved are the ones the C runtime handed main, because main's prologue has not touched a callee-saved register yet, so putting them back leaves main's own `leave; ret` exactly as clean as it is without trapping. Saving only rsp and rbp would have left the abandoned helper's rbx and r12-r15 in place all the way back to the startup code -- an ABI violation of the kind that cost four CI rounds last week. The globals live in error.s's .bss in both trees, never in codegen's output. `_rt_error` is assembled into every program and its preamble reads them, so a codegen-emitted symbol would fail to link in every program that does not trap. Three things the mechanism needs that are not obvious: ERL is stored per statement, not taken from the line `_rt_error` is passed. Seven of the file helpers' error sites have no line to pass and say so in their own comments -- and those are exactly the errors ON ERROR is used to catch. It is also snapshotted at dispatch, because the handler is ordinary module-level code whose own statements overwrite the live line: measured, a handler on line 100 reported ERL 100 for an error on line 30. FOR-loop register promotion is switched off in a trapping program, and that is load-bearing rather than a concession. A promoted counter's register is gone after the unwind and its memory copy is written back only at the loop's exit label, so a handler would read a stale value; the promotion's stack saves are also the only thing in codegen that moves rsp across a statement boundary, which is what lets one captured rsp be correct everywhere. Two latent corruptions become reachable the moment a program can carry on, and are closed here. `gen_array_alloc` stored the new bounds before the allocation, so a caught Out of memory left them beside the old element pointer and every later subscript passed its bounds check into a stale block. `.Lrandom_nomem` left the freed record buffer in the table, which `_rt_random_prepare` tests for NULL to decide "Bad file mode", so a caught failure meant GET read freed memory. Neither is testable here -- Linux overcommit will not fail a 16 GB calloc -- so both are defensive, and both are wrong today regardless. A GOSUB in progress deliberately survives, since the GOSUB stack is independent of rsp; a handler can RETURN from a subroutine the error interrupted, as in GW-BASIC. A GOSUB *inside a procedure* cannot, so sema refuses it in a trapping program: its return address is a label in a frame the unwind discards. ON ERROR inside a procedure, a handler that is not at module level, and --unsafe are refused for related reasons, each with a note saying which. ERR and ERL join as zero-argument builtins now that they have values to report. ERROR stays in the UNSUPPORTED table: it is a statement, never a name. Verified under both runtimes. The Win64 tree is only ever built in CI, so the trap was also run through an ms_abi model of it -- including the deep case, where the error comes from inside `_rt_file_open` with six callee-saved registers pushed and eighty bytes allocated. All fourteen examples still run clean there. The Windows probe step gains five trapping programs, since unwinding out of hand-written assembly is precisely what the Linux job cannot exercise. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/TestingCI.yml | 7 + LANGREF.md | 60 ++++++- src/codegen.rs | 105 +++++++++-- src/main.rs | 2 +- src/parser.rs | 21 +++ src/runtime/sysv/error.s | 108 +++++++++++- src/runtime/sysv/file.s | 6 + src/runtime/win64-native/error.s | 120 ++++++++++++- src/runtime/win64-native/file.s | 6 + src/sema.rs | 124 ++++++++++++- tests/common/mod.rs | 29 ++++ tests/errors/mod.rs | 290 +++++++++++++++++++++++++++++-- tests/runtime/mod.rs | 12 +- 13 files changed, 841 insertions(+), 49 deletions(-) diff --git a/.github/workflows/TestingCI.yml b/.github/workflows/TestingCI.yml index 2dc4130..5b35b93 100644 --- a/.github/workflows/TestingCI.yml +++ b/.github/workflows/TestingCI.yml @@ -122,6 +122,13 @@ jobs: 'EQV' = 'PRINT 1 EQV 1' 'ERASE' = "DIM A(3)`nERASE A" 'SYSTEM' = 'SYSTEM' + # Trapping unwinds out of hand-written assembly, which is the one + # thing the Linux job cannot exercise at all. + 'ERROR n trapped' = "10 ON ERROR GOTO 100`n20 ERROR 62`n30 END`n100 PRINT ERR`n110 END" + 'ON ERROR' = "10 ON ERROR GOTO 100`n20 PRINT 1 / 0`n30 END`n100 PRINT ERR; ERL`n110 END" + 'ON ERROR in file'= "10 ON ERROR GOTO 100`n20 OPEN `"nope.txt`" FOR INPUT AS #1`n30 END`n100 PRINT ERR`n110 END" + 'ON ERROR in proc'= "10 ON ERROR GOTO 100`n20 CALL B`n30 END`n100 PRINT ERR`n110 END`nSUB B`nPRINT 1 / 0`nEND SUB" + 'ON ERROR GOTO 0' = "10 ON ERROR GOTO 100`n20 ON ERROR GOTO 0`n30 END`n100 END" } $failed = @() foreach ($p in $probes.GetEnumerator()) { diff --git a/LANGREF.md b/LANGREF.md index acc679f..84e4514 100644 --- a/LANGREF.md +++ b/LANGREF.md @@ -639,6 +639,52 @@ The selector is truncated to an integer. A value that matches nothing -- zero, negative, or past the end of the list -- runs no subroutine and continues with the next statement. +### ON ERROR GOTO + +`ON ERROR GOTO n` installs an error handler. From then on a runtime error +jumps to line `n` instead of stopping the program, with `ERR` holding the +error's number and `ERL` the line it happened on. + +```basic +10 ON ERROR GOTO 900 +20 OPEN "data.txt" FOR INPUT AS #1 +30 CLOSE #1 +40 PRINT "read it" +50 END +900 PRINT "cannot open it: error"; ERR; "at line"; ERL +910 END +``` + +`ON ERROR GOTO 0` puts the fatal path back: + +```basic +10 ON ERROR GOTO 900 +20 ON ERROR GOTO 0 +30 END +900 END +``` + +The rules, which are GW-BASIC's: + +- An error raised **inside the handler** is not trapped -- it stops the + program. Without that, a handler that faults would call itself forever. +- Trapping stays suspended while the handler runs. Executing an `ON ERROR` + statement again re-arms it, so a handler that ends in `GOTO` rather than + returning must re-arm to keep trapping. +- The handler must be **module-level** code, and `ON ERROR` itself may not + appear inside a `SUB` or `FUNCTION`: a trapped error unwinds out of every + procedure before the handler runs. +- A `GOSUB` in progress survives, so a handler can `RETURN` from a subroutine + the error interrupted. For the same reason a `GOSUB` *inside* a procedure is + refused in a program that traps -- its return address would point into a + frame the unwind discarded. +- `--unsafe` and `ON ERROR` are refused together. `--unsafe` removes the checks + that raise most trappable errors, so the handler would look right and never + run. + +A program that never uses `ON ERROR` is compiled exactly as before, and pays +nothing for the feature. + ### DIM Declare arrays: @@ -972,6 +1018,16 @@ A$ = "hello" MID$(A$, 1, 1) = "J" ' A$ is now "Jello" ``` +### Error Functions + +| Function | Returns | +|----------|--------------------------------------------------------------| +| `ERR` | The number of the trapped error, 0 before any is trapped | +| `ERL` | The line it happened on, 0 if the program has no line numbers | + +Both are only meaningful inside an `ON ERROR` handler. `ERL` reports the BASIC +line number, as the error messages do. + ### Type Conversion Functions | Function | Description | @@ -1383,8 +1439,8 @@ The reason says whether waiting will help. Each of these is practical on both Linux and Windows and simply has not been written. Programs using them are refused today. -- **Error trapping** -- `ON ERROR GOTO`, `RESUME`, `RESUME NEXT`, `ERR`, `ERL`, `ERROR` -- **Console control** -- `WIDTH`, `CSRLIN`, `VIEW PRINT`, `INKEY$`, `BEEP`, `SLEEP` +- **Error trapping** -- `RESUME`, `RESUME NEXT` (`ON ERROR GOTO`, `ERR`, `ERL` and `ERROR` are implemented) +- **Console control** -- `WIDTH`, `CSRLIN`, `VIEW PRINT`, `INKEY$`, `SLEEP` - **Operating system** -- `SHELL`, `ENVIRON$`, `KILL`, `NAME`, `FILES`, `CHDIR`, `MKDIR`, `RMDIR` - **Odds and ends** -- `INPUT$`, `SHARED`, `STATIC` diff --git a/src/codegen.rs b/src/codegen.rs index 51c1028..6fec894 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -268,6 +268,8 @@ enum Builtin { static RT_BUILTINS: LazyLock> = LazyLock::new(|| { HashMap::from([ ("TIMER", Builtin::Call0("_rt_timer")), + ("ERR", Builtin::Call0("_rt_err")), + ("ERL", Builtin::Call0("_rt_erl")), ("DATE$", Builtin::Call0("_rt_date")), ("TIME$", Builtin::Call0("_rt_time")), ("VAL", Builtin::CallStr("_rt_val")), @@ -628,7 +630,13 @@ pub struct CodeGen { /// Array descriptors currently held in registers, innermost loop last. hoisted_arrays: Vec, gosub_used: bool, // whether GOSUB is used (need return stack) - expr_depth: u32, // current expression nesting depth + /// Whether the program contains `ON ERROR`, and so pays for trapping. + /// + /// Everything trapping costs is behind this: the context capture, the + /// per-statement line store, and -- load-bearing rather than a + /// concession -- switching off FOR-loop register promotion. + traps: bool, + expr_depth: u32, // current expression nesting depth } impl CodeGen { @@ -1204,7 +1212,7 @@ impl CodeGen { // Built-in functions that return integers match upper.as_str() { "LEN" | "ASC" | "INSTR" | "CINT" | "CLNG" => DataType::Long, - "EOF" | "LBOUND" | "UBOUND" | "POS" | "FRE" => DataType::Long, + "EOF" | "LBOUND" | "UBOUND" | "POS" | "FRE" | "ERR" | "ERL" => DataType::Long, // CSNG converts to SINGLE; saying Double here made its result print // with a Double's digits. "CSNG" => DataType::Single, @@ -1455,6 +1463,16 @@ impl CodeGen { // on Windows. Both answer to the same call. self.emit(" call _rt_platform_init"); + // Record what a trapped error has to restore. Taken here because the + // callee-saved registers still hold the C runtime's values, so putting + // them back leaves main's own `leave; ret` as clean as it is without + // trapping. The register set differs between the ABIs, so the saving + // lives in each runtime tree rather than here. + if self.traps { + self.emit(" # ON ERROR: what the handler starts from"); + self.emit(" call _rt_trap_capture"); + } + // Generate main body for stmt in &program.statements { match stmt.kind { @@ -1494,6 +1512,7 @@ impl CodeGen { // Both forms push a return address, so both need the GOSUB stack // emitted; without this, ON ... GOSUB alone failed to link. StmtKind::Gosub(_) | StmtKind::OnGosub { .. } => self.gosub_used = true, + StmtKind::OnError(_) => self.traps = true, // Record where each label sits in the DATA stream, so RESTORE can // resume from it. StmtKind::Label(n) => { @@ -1594,6 +1613,17 @@ impl CodeGen { self.emit_label(&skip); } + /// The assembly label a branch target names. + /// + /// One place rather than the four that spelled it out, now that ON ERROR + /// is a fifth. + fn branch_label(target: &GotoTarget) -> String { + match target { + GotoTarget::Line(n) => format!("_line_{}", n), + GotoTarget::Label(s) => format!("_label_{}", mangle(s)), + } + } + /// The line number a runtime diagnostic should quote. /// /// The BASIC line number once one has been reached, since that is what the @@ -1756,6 +1786,16 @@ impl CodeGen { end: &Expr, step: Option<&Expr>, ) -> bool { + // A trapped error abandons every frame between the failure and main, + // so a promoted counter's register is gone and its memory copy is + // stale -- it is written back only at the loop's exit label. The + // promotion's `sub rsp, 16` save slots are also the one thing in + // codegen that moves rsp across a statement boundary, which is what + // lets the trap restore a single captured rsp. Both reasons say the + // same thing: a program that traps keeps its loop variables in memory. + if self.traps { + return false; + } self.expr_is_call_free(start) && self.expr_is_call_free(end) && step.is_none_or(|s| self.expr_is_call_free(s)) @@ -2645,6 +2685,19 @@ impl CodeGen { if stmt.line != 0 { self.current_line = stmt.line; } + // ERL, recorded per statement rather than read from the line + // `_rt_error` is passed: seven of the file helpers' error sites have + // no line to pass and say so in their own comments, and those are + // exactly the errors ON ERROR is used to catch. Labels are skipped + // because they emit no code that could fail, and a store ahead of the + // label would be jumped over anyway. + if self.traps && !matches!(stmt.kind, StmtKind::Label(_) | StmtKind::LabelName(_)) { + emit!( + self, + " mov QWORD PTR [rip + _err_line], {}", + self.report_line() + ); + } match &stmt.kind { StmtKind::Label(n) => { self.basic_line = *n; @@ -3216,18 +3269,12 @@ impl CodeGen { } StmtKind::Goto(target) => { - let label = match target { - GotoTarget::Line(n) => format!("_line_{}", n), - GotoTarget::Label(s) => format!("_label_{}", mangle(s)), - }; + let label = Self::branch_label(target); emit!(self, " jmp {}", label); } StmtKind::Gosub(target) => { - let label = match target { - GotoTarget::Line(n) => format!("_line_{}", n), - GotoTarget::Label(s) => format!("_label_{}", mangle(s)), - }; + let label = Self::branch_label(target); let ret_label = self.new_label("gosub_ret"); // Check for stack overflow before push self.emit(" mov rcx, QWORD PTR [rip + _gosub_sp]"); @@ -3262,10 +3309,7 @@ impl CodeGen { } // Create jump table for (i, target) in targets.iter().enumerate() { - let label = match target { - GotoTarget::Line(n) => format!("_line_{}", n), - GotoTarget::Label(s) => format!("_label_{}", mangle(s)), - }; + let label = Self::branch_label(target); emit!(self, " cmp rax, {}", i + 1); emit!(self, " je {}", label); } @@ -3309,10 +3353,7 @@ impl CodeGen { self.emit(" mov QWORD PTR [rip + _gosub_sp], rcx"); for (i, target) in targets.iter().enumerate() { - let label = match target { - GotoTarget::Line(n) => format!("_line_{}", n), - GotoTarget::Label(s) => format!("_label_{}", mangle(s)), - }; + let label = Self::branch_label(target); emit!(self, " cmp r8, {}", i + 1); emit!(self, " je {}", label); } @@ -3473,6 +3514,25 @@ impl CodeGen { // reads as undimensioned again and a later DIM allocates afresh. // free(NULL) is defined, so erasing an array that was never // dimensioned is harmless. + // `ON ERROR GOTO n` arms the handler; `GOTO 0` disarms it. Both + // clear the in-handler flag, so re-arming is also how a program + // that left its handler by GOTO starts trapping again. + StmtKind::OnError(target) => { + match target { + Some(t) => { + let label = Self::branch_label(t); + // Through a register: an address as an immediate is an + // absolute relocation against .text, which does not + // survive a position-independent link. GOSUB's return + // address is taken the same way. + emit!(self, " lea rax, [rip + {}]", label); + self.emit(" mov QWORD PTR [rip + _err_handler], rax"); + } + None => self.emit(" mov QWORD PTR [rip + _err_handler], 0"), + } + self.emit(" mov QWORD PTR [rip + _err_active], 0"); + } + // `ERROR n` -- raise the error GW-BASIC numbers n. The range is // GW-BASIC's: 0 and 256 are not error numbers, and a code the // table does not know still raises, as "Unprintable error". @@ -6342,6 +6402,15 @@ impl CodeGen { } if self.opts.checks { + // The new bounds are already in the descriptor, so a *caught* Out + // of memory would leave them there beside the old element pointer: + // every later `A(I)` would then pass the bounds check and write + // past the end of the old block. Clearing the pointer first makes + // the undim check turn that into "Array used before DIM" instead. + // Only reachable once ON ERROR exists -- before that the error was + // fatal and nothing could observe the descriptor again. + emit!(self, " mov {}, 0", loc.q(0)); + // A null result would otherwise be written into the descriptor and // dereferenced on first use. self.emit(" test rax, rax"); diff --git a/src/main.rs b/src/main.rs index 225c0d5..5b67a66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -121,7 +121,7 @@ fn main() { // Semantic analysis: reject bad programs here, with a source line, rather // than letting them reach codegen and become a panic or a linker error. - let (symbols, diagnostics) = sema::analyze(&mut program); + let (symbols, diagnostics) = sema::analyze(&mut program, !args.no_checks); if !diagnostics.is_empty() { report( input_file, diff --git a/src/parser.rs b/src/parser.rs index f39c455..bde40ff 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -239,6 +239,9 @@ pub enum StmtKind { Erase(Vec), /// `ERROR n` -- raise the error GW-BASIC numbers `n`. RaiseError(Expr), + /// `ON ERROR GOTO n` -- install an error handler; `None` is `GOTO 0`, + /// which removes it and puts the fatal path back. + OnError(Option), /// `LOCATE [row][, col]` -- move the cursor. /// /// Either part may be omitted, in which case that coordinate is left where @@ -2353,6 +2356,24 @@ impl Parser { /// the one keyword and in whether the subroutine comes back. fn parse_on_goto(&mut self) -> PResult { self.advance(); // consume ON + + // `ON ERROR GOTO n` is a different statement that happens to share a + // first word. Recognised positionally, like ERASE and LSET, so ERROR + // stays available to the UNSUPPORTED table everywhere else. + if matches!(self.peek(), Token::Ident(n) if n.eq_ignore_ascii_case("ERROR")) { + self.advance(); // consume ERROR + if !matches!(self.advance(), Token::Goto) { + return err("ON ERROR must be followed by GOTO"); + } + // GW-BASIC spells "stop trapping" as GOTO 0, and there is no line + // 0 to check against, so it is folded away here. + if matches!(self.peek(), Token::Integer(0)) { + self.advance(); + return Ok(StmtKind::OnError(None)); + } + return Ok(StmtKind::OnError(Some(self.parse_goto_target()?))); + } + let expr = self.parse_expression()?; let is_gosub = match self.advance() { Token::Goto => false, diff --git a/src/runtime/sysv/error.s b/src/runtime/sysv/error.s index f798a00..1b2bea1 100644 --- a/src/runtime/sysv/error.s +++ b/src/runtime/sysv/error.s @@ -64,8 +64,64 @@ _err_codes: .quad _err_permission, 70 .quad 0, 0 + +# Error-trapping state. Always defined, never conditional: _rt_error is +# assembled into every program and its preamble reads these, so a program with +# no ON ERROR would otherwise fail to link. Zero means "not trapping", which is +# what .bss gives for free. +.bss +.p2align 3 +_err_handler: .skip 8 # where to jump, 0 = trapping off +_err_active: .skip 8 # nonzero while a handler runs +_err_code: .skip 8 # what ERR returns +_err_line: .skip 8 # the line now running; codegen stores it per statement +_err_erl: .skip 8 # what ERL returns: _err_line as it was when trapped +# rsp, rbx, rbp, r12, r13, r14, r15 -- and rdi, rsi on Win64, where they are +# callee-saved too. Sized the same in both trees so the offsets agree. +_err_ctx: .skip 80 + .text +# _rt_trap_capture - Record what a trapped error must restore +# +# Called once from main's prologue when the program contains ON ERROR. Taking +# it here rather than in codegen keeps the register set -- which differs +# between the ABIs -- in the tree that knows about it. +# +# The values saved are the ones the C runtime handed main, because main's +# prologue has not touched a callee-saved register yet. Restoring them on a +# trap therefore leaves main's eventual `leave; ret` exactly as clean as it is +# without trapping. +# +# Arguments: none Returns: nothing +.globl _rt_trap_capture +_rt_trap_capture: + lea rax, [rsp + 8] # the caller's rsp, past our return address + mov QWORD PTR [rip + _err_ctx + 0], rax + mov QWORD PTR [rip + _err_ctx + 8], rbx + mov QWORD PTR [rip + _err_ctx + 16], rbp + mov QWORD PTR [rip + _err_ctx + 24], r12 + mov QWORD PTR [rip + _err_ctx + 32], r13 + mov QWORD PTR [rip + _err_ctx + 40], r14 + mov QWORD PTR [rip + _err_ctx + 48], r15 + ret + +# _rt_err - ERR: the number of the error that was trapped +# +# Arguments: none Returns: eax = error number, 0 if none has been trapped +.globl _rt_err +_rt_err: + mov rax, QWORD PTR [rip + _err_code] + ret + +# _rt_erl - ERL: the line the trapped error happened on +# +# Arguments: none Returns: eax = line number, 0 if none has been trapped +.globl _rt_erl +_rt_erl: + mov rax, QWORD PTR [rip + _err_erl] + ret + # _rt_error_num - Raise the error with GW-BASIC number `n` (ERROR statement) # # Arguments: @@ -97,9 +153,59 @@ _rt_error_num: # rdi = message pointer (NUL-terminated) # rsi = BASIC line number, or 0 when unknown # -# Returns: never (exit code 1) +# Returns: never unless a handler is armed, in which case it does not return +# *here* either -- it abandons every frame between this one and main and jumps +# to the handler. .globl _rt_error _rt_error: + # Trapping only when ON ERROR armed one and no handler is already running: + # GW-BASIC does not trap an error raised inside a handler, which is also + # what stops handler -> error -> handler from looping forever. + mov rax, QWORD PTR [rip + _err_handler] + test rax, rax + jz .Lerr_fatal + cmp QWORD PTR [rip + _err_active], 0 + jne .Lerr_fatal + + # ERR is the number this message carries. Unknown text reports 0 rather + # than inventing a code. + lea rcx, [rip + _err_codes] +.Ltrap_scan: + mov rdx, QWORD PTR [rcx] + test rdx, rdx + jz .Ltrap_unknown + cmp rdx, rdi + je .Ltrap_found + add rcx, 16 + jmp .Ltrap_scan +.Ltrap_found: + mov rdx, QWORD PTR [rcx + 8] + jmp .Ltrap_store +.Ltrap_unknown: + xor edx, edx +.Ltrap_store: + mov QWORD PTR [rip + _err_code], rdx + # Snapshot the line as well. The handler is ordinary module-level code, so + # its own statements overwrite _err_line the moment it starts running -- + # measured: a handler on line 100 reported ERL 100 for an error on line 30. + mov rdx, QWORD PTR [rip + _err_line] + mov QWORD PTR [rip + _err_erl], rdx + mov QWORD PTR [rip + _err_active], 1 + + # Abandon every frame between here and main. ERL is already set: codegen + # stores the line at each statement, which is the only way the file + # helpers can report one -- seven of their error sites have no line to + # pass and say so in their own comments. + mov rbx, QWORD PTR [rip + _err_ctx + 8] + mov rbp, QWORD PTR [rip + _err_ctx + 16] + mov r12, QWORD PTR [rip + _err_ctx + 24] + mov r13, QWORD PTR [rip + _err_ctx + 32] + mov r14, QWORD PTR [rip + _err_ctx + 40] + mov r15, QWORD PTR [rip + _err_ctx + 48] + mov rsp, QWORD PTR [rip + _err_ctx + 0] + jmp rax + +.Lerr_fatal: push rbp mov rbp, rsp push rbx diff --git a/src/runtime/sysv/file.s b/src/runtime/sysv/file.s index 5380224..c8f6054 100644 --- a/src/runtime/sysv/file.s +++ b/src/runtime/sysv/file.s @@ -896,6 +896,12 @@ _rt_file_open_random: test rdi, rdi jz .Lrandom_alloc call free + # Clear the slot before the allocation below can fail. A trapped + # `Out of memory` would otherwise leave the table holding the pointer just + # freed, and _rt_random_prepare tests that slot for NULL to decide "Bad + # file mode" -- so a dangling pointer passes and GET reads freed memory. + lea rax, [rip + _file_recbuf] + mov QWORD PTR [rax + rbx*8], 0 .Lrandom_alloc: mov rdi, r14 diff --git a/src/runtime/win64-native/error.s b/src/runtime/win64-native/error.s index 1e812b4..db47289 100644 --- a/src/runtime/win64-native/error.s +++ b/src/runtime/win64-native/error.s @@ -73,8 +73,71 @@ _err_codes: _err_buf: .skip 128 _err_bytes_written: .skip 8 + +# Error-trapping state. Always defined, never conditional: _rt_error is +# assembled into every program and its preamble reads these, so a program with +# no ON ERROR would otherwise fail to link. Zero means "not trapping", which is +# what .bss gives for free. +.bss +.p2align 3 +_err_handler: .skip 8 # where to jump, 0 = trapping off +_err_active: .skip 8 # nonzero while a handler runs +_err_code: .skip 8 # what ERR returns +_err_line: .skip 8 # the line now running; codegen stores it per statement +_err_erl: .skip 8 # what ERL returns: _err_line as it was when trapped +# rsp, rbx, rbp, r12, r13, r14, r15, rdi, rsi. Win64 calls rdi and rsi +# callee-saved where System V does not, so this tree saves two more; the +# offsets are otherwise the same in both. +_err_ctx: .skip 80 + .text +# _rt_trap_capture - Record what a trapped error must restore +# +# Called once from main's prologue when the program contains ON ERROR. Taking +# it here rather than in codegen keeps the register set -- which differs +# between the ABIs -- in the tree that knows about it. +# +# The values saved are the ones the C runtime handed main, because main's +# prologue has not touched a callee-saved register yet. Restoring them on a +# trap therefore leaves main's eventual `leave; ret` exactly as clean as it is +# without trapping. +# +# xmm6-xmm15 are callee-saved here too, but nothing in this tree touches them +# and codegen's only user of xmm6+ is FOR-loop register promotion, which is +# switched off in a program that traps. +# +# Arguments: none Returns: nothing +.globl _rt_trap_capture +_rt_trap_capture: + lea rax, [rsp + 8] # the caller's rsp, past our return address + mov QWORD PTR [rip + _err_ctx + 0], rax + mov QWORD PTR [rip + _err_ctx + 8], rbx + mov QWORD PTR [rip + _err_ctx + 16], rbp + mov QWORD PTR [rip + _err_ctx + 24], r12 + mov QWORD PTR [rip + _err_ctx + 32], r13 + mov QWORD PTR [rip + _err_ctx + 40], r14 + mov QWORD PTR [rip + _err_ctx + 48], r15 + mov QWORD PTR [rip + _err_ctx + 56], rdi + mov QWORD PTR [rip + _err_ctx + 64], rsi + ret + +# _rt_err - ERR: the number of the error that was trapped +# +# Arguments: none Returns: eax = error number, 0 if none has been trapped +.globl _rt_err +_rt_err: + mov rax, QWORD PTR [rip + _err_code] + ret + +# _rt_erl - ERL: the line the trapped error happened on +# +# Arguments: none Returns: eax = line number, 0 if none has been trapped +.globl _rt_erl +_rt_erl: + mov rax, QWORD PTR [rip + _err_erl] + ret + # _rt_error_num - Raise the error with GW-BASIC number `n` (ERROR statement) # # Arguments (Win64): @@ -105,9 +168,64 @@ _rt_error_num: # rcx = message pointer (NUL-terminated) # rdx = BASIC line number, or 0 when unknown # -# Returns: never (ExitProcess(1)) +# Returns: never unless a handler is armed, in which case it does not return +# *here* either -- it abandons every frame between this one and main and jumps +# to the handler. .globl _rt_error _rt_error: + # Trapping only when ON ERROR armed one and no handler is already running: + # GW-BASIC does not trap an error raised inside a handler, which is also + # what stops handler -> error -> handler from looping forever. + # + # Ahead of the sprintf below, which would otherwise scribble _err_buf on + # the way to a handler that never wanted it. + mov rax, QWORD PTR [rip + _err_handler] + test rax, rax + jz .Lerr_fatal + cmp QWORD PTR [rip + _err_active], 0 + jne .Lerr_fatal + + # ERR is the number this message carries. Unknown text reports 0 rather + # than inventing a code. + lea r8, [rip + _err_codes] +.Ltrap_scan: + mov r9, QWORD PTR [r8] + test r9, r9 + jz .Ltrap_unknown + cmp r9, rcx + je .Ltrap_found + add r8, 16 + jmp .Ltrap_scan +.Ltrap_found: + mov r9, QWORD PTR [r8 + 8] + jmp .Ltrap_store +.Ltrap_unknown: + xor r9d, r9d +.Ltrap_store: + mov QWORD PTR [rip + _err_code], r9 + # Snapshot the line as well. The handler is ordinary module-level code, so + # its own statements overwrite _err_line the moment it starts running -- + # measured: a handler on line 100 reported ERL 100 for an error on line 30. + mov r9, QWORD PTR [rip + _err_line] + mov QWORD PTR [rip + _err_erl], r9 + mov QWORD PTR [rip + _err_active], 1 + + # Abandon every frame between here and main. ERL is already set: codegen + # stores the line at each statement, which is the only way the file + # helpers can report one -- seven of their error sites have no line to + # pass and say so in their own comments. + mov rbx, QWORD PTR [rip + _err_ctx + 8] + mov rbp, QWORD PTR [rip + _err_ctx + 16] + mov r12, QWORD PTR [rip + _err_ctx + 24] + mov r13, QWORD PTR [rip + _err_ctx + 32] + mov r14, QWORD PTR [rip + _err_ctx + 40] + mov r15, QWORD PTR [rip + _err_ctx + 48] + mov rdi, QWORD PTR [rip + _err_ctx + 56] + mov rsi, QWORD PTR [rip + _err_ctx + 64] + mov rsp, QWORD PTR [rip + _err_ctx + 0] + jmp rax + +.Lerr_fatal: push rbp mov rbp, rsp push rbx diff --git a/src/runtime/win64-native/file.s b/src/runtime/win64-native/file.s index cfd4cf6..1315edb 100644 --- a/src/runtime/win64-native/file.s +++ b/src/runtime/win64-native/file.s @@ -944,6 +944,12 @@ _rt_file_open_random: test rcx, rcx jz .Lwrandom_alloc call free + # Clear the slot before the allocation below can fail. A trapped + # `Out of memory` would otherwise leave the table holding the pointer just + # freed, and _rt_random_prepare tests that slot for NULL to decide "Bad + # file mode" -- so a dangling pointer passes and GET reads freed memory. + lea rax, [rip + _file_recbuf] + mov QWORD PTR [rax + rbx*8], 0 .Lwrandom_alloc: mov rcx, r14 diff --git a/src/sema.rs b/src/sema.rs index 61f6637..7ac9e53 100644 --- a/src/sema.rs +++ b/src/sema.rs @@ -92,6 +92,11 @@ const BUILTINS: &[(&str, usize, usize)] = &[ ("UCASE$", 1, 1), ("TAN", 1, 1), ("TIMER", 0, 1), + // Both take no argument at all, so a bare mention of either is a call -- + // see `is_zero_arg_builtin`, without which they would read as variables + // nobody ever wrote. + ("ERR", 0, 0), + ("ERL", 0, 0), ("VAL", 1, 1), ]; @@ -109,8 +114,6 @@ const BUILTINS: &[(&str, usize, usize)] = &[ /// two are meant to be read together. const UNSUPPORTED: &[(&str, &str)] = &[ // Planned: implementable on both platforms, not written yet. - ("ERR", "error trapping is not implemented yet"), - ("ERL", "error trapping is not implemented yet"), ("ERROR", "error trapping is not implemented yet"), ("RESUME", "error trapping is not implemented yet"), ( @@ -290,6 +293,16 @@ pub struct Symbols { pub labels: HashSet, /// Numeric line-number labels available as branch targets. pub lines: HashSet, + /// The subset of both that sit at module level. + /// + /// `labels` and `lines` are one flat program-wide set, so a target inside + /// a `SUB` is indistinguishable from a module-level one. That is harmless + /// for `GOTO`, which cannot cross a frame boundary at run time anyway + /// because sema rejects it earlier -- but an `ON ERROR` handler is jumped + /// to *after* the unwind has restored main's frame, so a handler inside a + /// procedure would run with the wrong `rbp` and read main's locals. + pub module_labels: HashSet, + pub module_lines: HashSet, /// CONST names and their folded values. pub consts: HashMap, /// Lowest legal subscript, set by OPTION BASE. Defaults to 0. @@ -366,10 +379,13 @@ pub struct Diagnostic { /// which. The parser used to guess from the DIM statements it had read so far, /// which meant the same source produced a different AST depending on whether /// its DIM came earlier or later in the file, and ignored scope entirely. -pub fn analyze(program: &mut Program) -> (Symbols, Vec) { +pub fn analyze(program: &mut Program, checks: bool) -> (Symbols, Vec) { apply_default_types(program); - let mut a = Analyzer::default(); + let mut a = Analyzer { + checks, + ..Default::default() + }; walk_stmts(&program.statements, &mut |stmt| { if let StmtKind::Erase(names) = &stmt.kind { a.erased.extend(names.iter().cloned()); @@ -378,6 +394,7 @@ pub fn analyze(program: &mut Program) -> (Symbols, Vec) { a.collect(&program.statements, &Scope::Module); a.check_name_collisions(); a.check_return_has_a_gosub(&program.statements); + a.check_gosub_scope_when_trapping(&program.statements); a.resolve_array_accesses(&mut program.statements, &Scope::Module); a.check(&program.statements, &Scope::Module); (a.symbols, a.diagnostics) @@ -562,6 +579,9 @@ fn rewrite_names(stmts: &mut [Stmt], table: &[DataType; 26], procs: &HashSet {} + // A handler is a branch target, and a label is not a variable -- + // the same reason GOTO and GOSUB are left alone here. + StmtKind::OnError(_) => {} StmtKind::Input { vars, .. } | StmtKind::Read(vars) => { vars.iter_mut().for_each(|v| lvalue(v, table, procs)) } @@ -737,6 +757,7 @@ fn for_each_expr_mut(stmt: &mut Stmt, f: &mut impl FnMut(&mut Expr)) { } StmtKind::Randomize(seed) => seed.iter_mut().for_each(&mut *f), StmtKind::RaiseError(e) => f(e), + StmtKind::OnError(_) => {} StmtKind::Const { value, .. } => f(value), StmtKind::FieldAssign { target, value } => { lvalue(target, f); @@ -829,6 +850,8 @@ struct Analyzer { loops: Vec, /// Whether the walk is currently inside a procedure body. in_proc: bool, + /// Whether runtime checks are compiled in (false under `--unsafe`). + checks: bool, /// Whether an OPTION BASE has already been seen. seen_option_base: bool, } @@ -862,11 +885,17 @@ impl Analyzer { match &stmt.kind { StmtKind::Label(n) => { self.symbols.lines.insert(*n); + if scope == &Scope::Module { + self.symbols.module_lines.insert(*n); + } } StmtKind::LabelName(name) => { if !self.symbols.labels.insert(name.clone()) { self.error(stmt.line, format!("duplicate label '{}'", name)); } + if scope == &Scope::Module { + self.symbols.module_labels.insert(name.clone()); + } } StmtKind::TypeDef { name, fields } => { let upper = name.to_uppercase(); @@ -1136,6 +1165,54 @@ impl Analyzer { } } + /// A trapping program may not GOSUB from inside a procedure. + /// + /// The GOSUB stack is deliberately left alone by a trap, because that is + /// what lets a handler -- and later RESUME -- return correctly from a + /// subroutine the error interrupted. That is right for a module-level + /// GOSUB and unsafe for one inside a procedure: its return address is a + /// label in a frame the unwind has discarded, so a later RETURN would jump + /// into dead code with main's `rbp`. + /// + /// Refused rather than worked around. Vintage listings, which are what + /// error trapping is for, have no procedures at all. + fn check_gosub_scope_when_trapping(&mut self, stmts: &[Stmt]) { + let mut traps = false; + walk_stmts(stmts, &mut |stmt| { + if matches!(stmt.kind, StmtKind::OnError(_)) { + traps = true; + } + }); + if !traps { + return; + } + let mut offenders = Vec::new(); + for stmt in stmts { + let (StmtKind::Sub { name, body, .. } | StmtKind::Function { name, body, .. }) = + &stmt.kind + else { + continue; + }; + walk_stmts(body, &mut |s| { + if matches!(s.kind, StmtKind::Gosub(_) | StmtKind::OnGosub { .. }) { + offenders.push((s.line, name.clone())); + } + }); + } + for (line, name) in offenders { + self.error_with_note( + line, + format!( + "GOSUB inside '{}' is not allowed in a program that uses ON ERROR", + name + ), + "a trapped error unwinds out of the procedure, leaving the GOSUB's return \ + address pointing into a frame that no longer exists" + .to_string(), + ); + } + } + /// Refuse a name declared as an array and also as a procedure or builtin. /// /// `A(1)` reaches the compiler as one shape and has to become one thing. @@ -1641,6 +1718,45 @@ impl Analyzer { // erased nothing. The lookup is the one every array use gets -- // the enclosing procedure first, then the module. StmtKind::RaiseError(n) => self.check_numeric_operand(n, scope, line, "ERROR"), + StmtKind::OnError(target) => { + // `--unsafe` removes the checks that raise most of what a + // handler exists to catch, so the pair would leave a handler + // that looks right and never runs -- the silent wrong answer + // this compiler refuses everywhere else. + if !self.checks { + self.error_with_note( + line, + "ON ERROR cannot be used with --unsafe".to_string(), + "--unsafe removes the runtime checks the handler would trap".to_string(), + ); + } + // A trapped error unwinds to main's frame before it jumps, so + // both the statement and its handler have to belong to main. + if let Scope::Proc(name) = scope { + self.error_with_note( + line, + format!("ON ERROR is not allowed inside '{}'", name), + "a trapped error unwinds to module level, so the handler belongs there" + .to_string(), + ); + } + let Some(target) = target else { + return; // GOTO 0 names no line to check + }; + self.check_target(target, line, "ON ERROR GOTO"); + let at_module_scope = match target { + GotoTarget::Line(n) => self.symbols.module_lines.contains(n), + GotoTarget::Label(name) => self.symbols.module_labels.contains(name), + }; + if !at_module_scope { + self.error_with_note( + line, + "an ON ERROR handler must be at module level".to_string(), + "the handler is entered after the error unwinds out of every procedure" + .to_string(), + ); + } + } StmtKind::Locate { row, col } => { for e in row.iter().chain(col.iter()) { self.check_numeric_operand(e, scope, line, "LOCATE"); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d3ca9a2..71beca6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -108,6 +108,35 @@ pub fn compile_only(source: &str) -> Result<(), CompileError> { } } +/// Compile only, with extra compiler flags. +/// +/// For the combinations a flag makes illegal -- `--unsafe` removes the checks +/// `ON ERROR` exists to trap, so the two together are refused. +pub fn compile_only_flags(source: &str, flags: &[&str]) -> Result<(), CompileError> { + let tmp = TempDir::new().expect("failed to create temp dir"); + let bas_file = tmp.path().join("test.bas"); + let exe_file = tmp.path().join("test"); + fs::write(&bas_file, source).expect("failed to write source"); + + let out = Command::new(env!("CARGO_BIN_EXE_xbasic64")) + .arg(&bas_file) + .args(flags) + .arg("-o") + .arg(&exe_file) + .output() + .expect("failed to run compiler"); + + if out.status.success() { + Ok(()) + } else { + Err(CompileError { + stdout: String::from_utf8_lossy(&out.stdout).to_string(), + stderr: String::from_utf8_lossy(&out.stderr).to_string(), + exit_code: out.status.code(), + }) + } +} + /// Compile and run, returning `Ok` **regardless of the program's exit status**. /// /// `Err` is reserved for compilation failure. This is the primitive the other diff --git a/tests/errors/mod.rs b/tests/errors/mod.rs index 962a67a..7bfaf7c 100644 --- a/tests/errors/mod.rs +++ b/tests/errors/mod.rs @@ -574,10 +574,18 @@ fn test_error_statement_accepts_any_expression_shape() { /// `ERROR` is still not a value, and `ON ERROR` is still refused. #[test] fn test_error_is_a_statement_not_a_name() { - let err = compile_only("PRINT ERROR\n").expect_err("ERROR is not a value"); - assert!(err.contains("not supported"), "got: {}", err.stderr); - let err = compile_only("ON ERROR GOTO 100\nEND\n100 END\n").expect_err("not implemented yet"); - assert!(err.contains("not supported"), "got: {}", err.stderr); + // It stays in the UNSUPPORTED table for exactly this: recognised as a + // statement only when an operand follows it, so every other mention still + // gets a reason rather than becoming a variable that reads as zero. + for source in ["PRINT ERROR\n", "ERROR\n", "X = ERROR + 1\n"] { + let err = compile_only(source).expect_err("ERROR is not a value"); + assert!( + err.contains("not supported"), + "{source:?} got: {}", + err.stderr + ); + assert!(err.is_clean_rejection()); + } } /// A line-numbered listing reports its BASIC line number, not the source line. @@ -1060,8 +1068,6 @@ fn test_zero_arg_builtins_are_not_assignable() { fn test_unimplemented_gwbasic_names_are_diagnosed() { let cases = [ ("A$ = INKEY$\n", "INKEY$"), - ("PRINT ERR\n", "ERR"), - ("PRINT ERL\n", "ERL"), ("PRINT CSRLIN\n", "CSRLIN"), ("A$ = INPUT$(3)\n", "INPUT$"), ("PRINT PEEK(0)\n", "PEEK"), @@ -1090,16 +1096,19 @@ fn test_unimplemented_gwbasic_names_are_diagnosed() { } } -/// `ON ERROR GOTO` is the dangerous one: it parses as a computed GOTO on a -/// variable named ERROR, which is always zero, so the handler never runs and -/// nothing says so. -#[test] -fn test_on_error_goto_is_refused() { - let err = compile_only("ON ERROR GOTO 100\nPRINT \"x\"\nEND\n100 END\n") - .expect_err("ON ERROR GOTO must be refused rather than falling through"); +/// `ON ERROR GOTO` still cannot fall through silently. +/// +/// It once parsed as a computed GOTO on a variable named ERROR, which is +/// always zero, so the handler never ran and nothing said so. It is a real +/// statement now, and the property that mattered is unchanged: a handler +/// naming a line the program does not have is refused, not ignored. +#[test] +fn test_on_error_goto_a_missing_line_is_refused() { + let err = compile_only("ON ERROR GOTO 100\nPRINT \"x\"\nEND\n") + .expect_err("a handler must name a line that exists"); assert!( - err.contains("not supported"), - "expected an explanation, got: {}", + err.contains("ON ERROR GOTO"), + "expected the statement named, got: {}", err.stderr ); assert!(err.is_clean_rejection()); @@ -1162,11 +1171,9 @@ fn test_a_def_type_does_not_defeat_the_refusals() { // Every shape the refusal is reached through: a bare name in an // expression, a statement-position call, and the ON ERROR special case. let cases = [ - ("DEFINT A-Z\nON ERROR GOTO 100\nEND\n100 END\n", "ERROR"), - ("DEFINT A-Z\nPRINT ERR\n", "ERR"), - ("DEFINT A-Z\nPRINT ERL\n", "ERL"), + ("DEFINT A-Z\nPRINT ERROR\n", "ERROR"), ("DEFINT A-Z\nPRINT CSRLIN\n", "CSRLIN"), - ("DEFSTR A-Z\nPRINT ERR\n", "ERR"), + ("DEFSTR A-Z\nPRINT INKEY$\n", "INKEY$"), ("DEFLNG A-Z\nPRINT CSRLIN\n", "CSRLIN"), ]; @@ -1863,3 +1870,248 @@ fn test_dim_suffix_must_agree_with_its_as_clause() { compile_only("DIM X% AS INTEGER\nDIM Y AS DOUBLE\nDIM S$ AS STRING * 4\nDIM N AS LONG\n") .expect("a suffix that agrees, or none at all, is legal"); } + +// --------------------------------------------------------------------------- +// Error trapping: ON ERROR GOTO, ERR, ERL +// +// The mechanism is a longjmp in all but name. `_rt_error` never returned, and +// is reached from thirteen places inside each runtime tree with live frames -- +// some two deep, some holding a lock structure on the stack. Trapping abandons +// all of them, so what the handler starts with has to be captured up front. + +/// The handler runs, and ERR and ERL say what happened and where. +#[test] +fn test_on_error_traps_and_reports() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 100 +20 PRINT "before" +30 PRINT 1 / 0 +40 PRINT "not reached" +50 END +100 PRINT "handled"; ERR; ERL +110 END +"#, + ) + .unwrap(); + assert_eq!( + out.trim().lines().collect::>(), + &["before", "handled1130"] + ); +} + +/// An error raised from deep inside a runtime helper is trapped too. +/// +/// This is the case the whole design is for: `ON ERROR` round an `OPEN` is the +/// commonest vintage idiom, and those errors come from hand-written assembly +/// several frames down, not from a codegen trampoline. +#[test] +fn test_on_error_traps_an_error_raised_inside_a_helper() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 100 +20 OPEN "no-such-file-here.txt" FOR INPUT AS #1 +30 PRINT "not reached" +40 END +100 PRINT "trapped"; ERR +110 END +"#, + ) + .unwrap(); + assert_eq!(out.trim(), "trapped53"); +} + +/// `ERROR n` is trapped like any other error, which is how a program tests its +/// own handler. +#[test] +fn test_on_error_traps_the_error_statement() { + let out = + compile_and_run("10 ON ERROR GOTO 100\n20 ERROR 62\n30 END\n100 PRINT ERR\n110 END\n") + .unwrap(); + assert_eq!(out.trim(), "62"); +} + +/// `ON ERROR GOTO 0` puts the fatal path back. +#[test] +fn test_on_error_goto_zero_disarms() { + let run = compile_and_run_raw( + "10 ON ERROR GOTO 100\n20 ON ERROR GOTO 0\n30 PRINT 1 / 0\n40 END\n100 PRINT \"no\"\n110 END\n", + "", + ) + .expect("should compile"); + assert_eq!(run.exit_code, Some(1), "disarmed, so the error is fatal"); + assert!(run.stderr.contains("Division by zero"), "{:?}", run.stderr); + assert!(!run.stdout.contains("no"), "the handler must not run"); +} + +/// An error inside the handler is fatal rather than looping through it. +#[test] +fn test_error_inside_the_handler_is_fatal() { + let run = compile_and_run_raw( + "10 ON ERROR GOTO 100\n20 PRINT 1 / 0\n30 END\n100 PRINT \"in handler\"\n110 PRINT 1 / 0\n120 END\n", + "", + ) + .expect("should compile"); + assert_eq!(run.exit_code, Some(1)); + assert!( + run.stdout.contains("in handler"), + "the handler did run once" + ); + assert!(run.stderr.contains("Division by zero")); +} + +/// The handler can carry on with the program, and the variables it reads are +/// the ones the program actually wrote. +/// +/// The unwind abandons every intervening frame, so anything the compiler was +/// holding in a register has to be back in memory by then. +#[test] +fn test_state_survives_the_unwind() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 200 +20 T = 0 +30 FOR I = 1 TO 5 +40 T = T + I +50 NEXT I +60 PRINT 1 / 0 +70 END +200 PRINT "T="; T; "I="; I +210 END +"#, + ) + .unwrap(); + assert_eq!(out.trim(), "T=15I=6", "the loop's own variables are intact"); +} + +/// An error inside a SUB unwinds to the module-level handler. +#[test] +fn test_error_inside_a_procedure_is_trapped() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 100 +20 CALL Boom +30 PRINT "not reached" +40 END +100 PRINT "trapped"; ERR +110 END +SUB Boom + PRINT 1 / 0 +END SUB +"#, + ) + .unwrap(); + assert_eq!(out.trim(), "trapped11"); +} + +/// A program that never traps carries none of the machinery. +#[test] +fn test_no_cost_without_on_error() { + let asm = crate::common::compile_to_asm("PRINT 1\nFOR I = 1 TO 3\nPRINT I\nNEXT I\n").unwrap(); + for symbol in ["_err_handler", "_err_ctx", "_err_line", "_rt_trap_capture"] { + assert!( + !asm.contains(symbol), + "{symbol} should not appear in a program with no ON ERROR" + ); + } +} + +/// `--unsafe` removes the checks a handler exists to catch, so the pair is +/// refused rather than leaving a handler that looks right and never runs. +#[test] +fn test_on_error_with_unsafe_is_refused() { + let err = crate::common::compile_only_flags( + "10 ON ERROR GOTO 100\n20 PRINT 1 / 0\n30 END\n100 END\n", + &["--unsafe"], + ) + .expect_err("ON ERROR plus --unsafe must be refused"); + assert!( + err.contains("--unsafe"), + "expected the reason, got: {}", + err.stderr + ); + assert!(err.is_clean_rejection()); +} + +/// The handler must be module-level code, not a line inside a procedure. +#[test] +fn test_on_error_scope_is_checked() { + let err = compile_only("SUB S\n10 ON ERROR GOTO 20\n20 END\nEND SUB\nCALL S\n") + .expect_err("ON ERROR inside a procedure must be refused"); + assert!(err.is_clean_rejection(), "got: {}", err.stderr); + + let err = compile_only("10 ON ERROR GOTO 900\n20 END\nSUB S\n900 PRINT 1\nEND SUB\n") + .expect_err("a handler inside a procedure must be refused"); + assert!(err.is_clean_rejection(), "got: {}", err.stderr); +} + +/// A GOSUB interrupted by a trapped error still returns correctly. +/// +/// The GOSUB stack is a separate software stack, independent of `rsp`, so the +/// unwind does not disturb it -- deliberately, because that is what lets a +/// handler carry on from a subroutine the error interrupted, as GW-BASIC does. +#[test] +fn test_gosub_survives_a_trap() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 200 +20 GOSUB 100 +30 PRINT "back" +40 END +100 PRINT "in sub" +110 PRINT 1 / 0 +120 RETURN +200 PRINT "trapped" +210 RETURN +"#, + ) + .unwrap(); + assert_eq!( + out.trim().lines().collect::>(), + &["in sub", "trapped", "back"], + "the handler's RETURN goes back to the statement after the GOSUB" + ); +} + +/// ...but a GOSUB inside a procedure cannot, so it is refused. +/// +/// Its return address is a label in a frame the unwind discards, so a later +/// RETURN would jump into dead code with main's frame pointer. +#[test] +fn test_gosub_inside_a_procedure_is_refused_when_trapping() { + let src = "10 ON ERROR GOTO 100\n20 CALL S\n30 END\n100 END\n\ + SUB S\n GOSUB 500\n EXIT SUB\n500 PRINT 1\n RETURN\nEND SUB\n"; + let err = compile_only(src).expect_err("GOSUB in a procedure must be refused when trapping"); + assert!(err.contains("GOSUB inside"), "got: {}", err.stderr); + assert!(err.is_clean_rejection()); + + // The same program without ON ERROR is fine: nothing unwinds. + let ok = "10 CALL S\n20 END\nSUB S\n GOSUB 500\n EXIT SUB\n500 PRINT 1\n RETURN\nEND SUB\n"; + compile_only(ok).expect("a GOSUB in a procedure is fine when nothing traps"); +} + +/// A trapping program keeps its loop variables in memory. +/// +/// FOR-loop register promotion is switched off when a program traps, and that +/// is load-bearing rather than a concession: a promoted counter's register is +/// gone after the unwind and its memory copy is written back only at the +/// loop's exit label, so a handler would read a stale value. The promotion's +/// stack saves are also the one thing that moves `rsp` across a statement +/// boundary, which is what lets the trap restore a single captured `rsp`. +#[test] +fn test_trapping_disables_register_promotion() { + let hot = "FOR I% = 1 TO 10\nS% = S% + I%\nNEXT I%\nPRINT S%\n"; + let plain = crate::common::compile_to_asm(hot).unwrap(); + assert!( + plain.contains("save a counter register"), + "the loop should promote when nothing traps" + ); + + let trapping = + crate::common::compile_to_asm(&format!("10 ON ERROR GOTO 100\n{hot}90 END\n100 END\n")) + .unwrap(); + assert!( + !trapping.contains("save a counter register"), + "a trapping program must keep its counter in memory" + ); +} diff --git a/tests/runtime/mod.rs b/tests/runtime/mod.rs index 1199d92..b330930 100644 --- a/tests/runtime/mod.rs +++ b/tests/runtime/mod.rs @@ -289,10 +289,16 @@ fn test_helpers_preserve_callee_saved_registers() { "xmm11", "xmm12", "xmm13", "xmm14", "xmm15", ]; - // `_rt_random_prepare` returns four values in callee-saved registers and - // says so: it is reached only from GET and PUT, which save them for it. // A helper listed here has a private convention its own comment states. - const PRIVATE_CONVENTION: &[&str] = &["_rt_random_prepare"]; + // + // `_rt_random_prepare` returns four values in callee-saved registers: it is + // reached only from GET and PUT, which save them for it. + // + // `_rt_error` writes the whole callee-saved set on its trapping path, which + // is the opposite of clobbering it -- the values come from `_err_ctx` and + // are main's, and the path ends in a jump to the handler rather than a + // return, so there is no caller left to preserve anything for. + const PRIVATE_CONVENTION: &[&str] = &["_rt_random_prepare", "_rt_error"]; let mut problems = Vec::new(); From 2441cc9419e96cbbbb08666506738d17b0753e57 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Mon, 17 Aug 2026 10:37:05 +0000 Subject: [PATCH 6/8] RESUME, RESUME NEXT and RESUME Each module-level statement gets a label before its marker store and one after its code, and two .quad tables collect them. RESUME jumps through the first, RESUME NEXT through the second. Two tables rather than one plus arithmetic, because "the statement after this one" is a position in the emitted code and not the next index. That is what makes every awkward case fall out for free: after the last statement of a FOR body comes the increment and the back-jump, so RESUME NEXT continues the loop; after the last statement of an IF branch comes that branch's exit jump; after the last statement of the program comes main's epilogue. The nested test -- an IF inside a FOR inside a WHILE -- passed first try, which is the claim. The marker store sits *after* its label so a resumed statement re-establishes its own index, and `_err_resume` is a snapshot taken at dispatch: the handler is ordinary module-level code, so its own statements overwrite `_err_stmt` the moment it starts running. ERL needed the same snapshot in the previous commit and for the same reason. gen_stmt became a wrapper. Several of its arms return early and would have skipped a closing label written at the bottom -- and the closing label is the one RESUME NEXT needs. A statement inside a procedure gets no resume point, and `_err_depth` counts the nesting so the trap can mark such an error unresumable. A counter rather than a saved slot because the trap resets it, so the unwind skipping every pending decrement costs nothing. `X = F(1) / D` still resumes correctly: the depth is back to zero by the time the division runs, which is the case that rules out simply setting a sentinel on entry. RESUME's own two failures are untrappable, so `_rt_error` splits: `_rt_fatal` is today's body, reached directly by those trampolines. A handler that trapped its own failing RESUME would be re-entered by it forever. Sema refuses RESUME with no ON ERROR anywhere in the program -- modelled on `check_return_has_a_gosub`, because otherwise the failure is a link error against a table that a non-trapping program never emits -- as well as RESUME inside a procedure and a target that is not at module level. examples/errtrap.bas shows all three forms and is deliberately cwd-independent: an earlier draft fell back to a file that exists only in examples/, which from the repository root would have failed again and looped through the handler forever. It opens a file that is not there, and creates nothing. Verified under both runtimes; the Win64 model prints the example's four lines identically. The Windows probe step gains three RESUME programs. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/TestingCI.yml | 3 + LANGREF.md | 40 ++++- examples/errtrap.bas | 27 ++++ src/codegen.rs | 126 +++++++++++++++- src/lexer.rs | 2 + src/parser.rs | 36 +++++ src/runtime/sysv/error.s | 34 ++++- src/runtime/win64-native/error.s | 34 ++++- src/sema.rs | 55 ++++++- tests/errors/mod.rs | 245 ++++++++++++++++++++++++++++++- 10 files changed, 590 insertions(+), 12 deletions(-) create mode 100644 examples/errtrap.bas diff --git a/.github/workflows/TestingCI.yml b/.github/workflows/TestingCI.yml index 5b35b93..0c99e74 100644 --- a/.github/workflows/TestingCI.yml +++ b/.github/workflows/TestingCI.yml @@ -129,6 +129,9 @@ jobs: 'ON ERROR in file'= "10 ON ERROR GOTO 100`n20 OPEN `"nope.txt`" FOR INPUT AS #1`n30 END`n100 PRINT ERR`n110 END" 'ON ERROR in proc'= "10 ON ERROR GOTO 100`n20 CALL B`n30 END`n100 PRINT ERR`n110 END`nSUB B`nPRINT 1 / 0`nEND SUB" 'ON ERROR GOTO 0' = "10 ON ERROR GOTO 100`n20 ON ERROR GOTO 0`n30 END`n100 END" + 'RESUME' = "10 ON ERROR GOTO 100`n20 D = 0`n30 X = 1 / D`n40 END`n100 D = 2`n110 RESUME" + 'RESUME NEXT' = "10 ON ERROR GOTO 100`n20 X = 1 / 0`n30 PRINT `"ok`"`n40 END`n100 RESUME NEXT" + 'RESUME line' = "10 ON ERROR GOTO 100`n20 X = 1 / 0`n30 END`n100 RESUME 30" } $failed = @() foreach ($p in $probes.GetEnumerator()) { diff --git a/LANGREF.md b/LANGREF.md index 84e4514..a304d25 100644 --- a/LANGREF.md +++ b/LANGREF.md @@ -685,6 +685,45 @@ The rules, which are GW-BASIC's: A program that never uses `ON ERROR` is compiled exactly as before, and pays nothing for the feature. +### RESUME + +`RESUME` ends a handler and goes back to the program. Three forms: + +| Form | Goes back to | +|----------------|-------------------------------------------------| +| `RESUME` | The statement that failed, to try it again | +| `RESUME NEXT` | The statement after the one that failed | +| `RESUME n` | Line `n`, or a named label | + +`RESUME 0` means the same as a bare `RESUME`. + +```basic +10 ON ERROR GOTO 200 +20 Divisor = 0 +30 Tries = Tries + 1 : R = 100 / Divisor +40 PRINT "took"; Tries; "tries, got"; R +50 END +200 Divisor = 4 +210 RESUME +``` + +The statement, not the line: on line 30 above, `RESUME` returns to +`R = 100 / Divisor` and leaves `Tries` alone, and `RESUME NEXT` would carry on +at line 40. Where the failing statement is the last in a `FOR` or `WHILE` body, +`RESUME NEXT` continues the loop rather than leaving it. + +Two rules follow from where a handler runs: + +- `RESUME` must be module-level code, like the handler it ends. +- After an error raised **inside a `SUB` or `FUNCTION`**, a bare `RESUME` or + `RESUME NEXT` stops the program with `RESUME cannot return into a SUB or + FUNCTION`: the unwind discarded that frame, so there is nothing to go back + to. `RESUME n` still works, and is the way out. + +`RESUME` outside a handler stops the program with `RESUME without error`. +Neither of these two is trappable -- a handler that caught its own failing +`RESUME` would be re-entered by it forever. + ### DIM Declare arrays: @@ -1439,7 +1478,6 @@ The reason says whether waiting will help. Each of these is practical on both Linux and Windows and simply has not been written. Programs using them are refused today. -- **Error trapping** -- `RESUME`, `RESUME NEXT` (`ON ERROR GOTO`, `ERR`, `ERL` and `ERROR` are implemented) - **Console control** -- `WIDTH`, `CSRLIN`, `VIEW PRINT`, `INKEY$`, `SLEEP` - **Operating system** -- `SHELL`, `ENVIRON$`, `KILL`, `NAME`, `FILES`, `CHDIR`, `MKDIR`, `RMDIR` - **Odds and ends** -- `INPUT$`, `SHARED`, `STATIC` diff --git a/examples/errtrap.bas b/examples/errtrap.bas new file mode 100644 index 0000000..d30f989 --- /dev/null +++ b/examples/errtrap.bas @@ -0,0 +1,27 @@ +10 REM Error trapping: the three ways out of a handler +20 DEFINT A-Z +30 REM ---- RESUME : give up on the file and carry on past it +40 ON ERROR GOTO 900 +50 Found = 0 +60 OPEN "no-such-data.txt" FOR INPUT AS #1 +70 Found = 1 +80 CLOSE #1 +90 PRINT "found ="; Found +100 REM ---- RESUME: fix the cause, then run the same statement again +110 ON ERROR GOTO 930 +120 Divisor = 0 +130 Tries = Tries + 1 : Rate = 1000 / Divisor +140 PRINT "rate"; Rate; "after"; Tries; "try" +150 REM ---- RESUME NEXT: step over the statement that failed +160 ON ERROR GOTO 960 +170 FOR I = 1 TO 5 +180 Total = Total + I * 100 / (I - 3) +190 NEXT I +200 PRINT "total"; Total; "skipping"; Skipped; "term" +210 END +900 PRINT "error"; ERR; "at line"; ERL; "- no file, carrying on" +910 RESUME 90 +930 Divisor = 8 +940 RESUME +960 Skipped = Skipped + 1 +970 RESUME NEXT diff --git a/src/codegen.rs b/src/codegen.rs index 6fec894..29ef55b 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -397,6 +397,10 @@ enum RtError { OutOfMemory, GosubOverflow, BadFileNum, + /// RESUME with nothing to return from. + ResumeNoError, + /// RESUME after an error raised inside a procedure, whose frame is gone. + ResumeInProc, } impl RtError { @@ -411,9 +415,19 @@ impl RtError { RtError::OutOfMemory => "_err_memory", RtError::GosubOverflow => "_err_gosub", RtError::BadFileNum => "_err_badfile", + RtError::ResumeNoError => "_err_resnoerr", + RtError::ResumeInProc => "_err_resproc", } } + /// Whether an `ON ERROR` handler may catch this. + /// + /// RESUME's own two failures may not: the handler would be re-entered by + /// the very RESUME that failed, and would never stop. + fn trappable(self) -> bool { + !matches!(self, RtError::ResumeNoError | RtError::ResumeInProc) + } + /// Short tag used to build a unique trampoline label. fn tag(self) -> &'static str { match self { @@ -425,6 +439,8 @@ impl RtError { RtError::OutOfMemory => "mem", RtError::GosubOverflow => "gosub", RtError::BadFileNum => "badfile", + RtError::ResumeNoError => "resnoerr", + RtError::ResumeInProc => "resproc", } } } @@ -630,6 +646,11 @@ pub struct CodeGen { /// Array descriptors currently held in registers, innermost loop last. hoisted_arrays: Vec, gosub_used: bool, // whether GOSUB is used (need return stack) + /// One entry per module-level statement, in emission order, when trapping. + /// + /// Only the count matters -- the labels are `_rs_` and `_rn_` -- but + /// keeping the vector makes the table emission read as what it is. + resume_points: Vec, /// Whether the program contains `ON ERROR`, and so pays for trapping. /// /// Everything trapping costs is behind this: the context capture, the @@ -2461,7 +2482,12 @@ impl CodeGen { let sym = kind.symbol(); emit!(self, " lea {}, [rip + {}]", Self::arg_reg(0), sym); emit!(self, " mov {}, {}", Self::arg_reg(1), line); - self.emit(" call _rt_error"); + let entry = if kind.trappable() { + "_rt_error" + } else { + "_rt_fatal" + }; + emit!(self, " call {}", entry); } } @@ -2626,6 +2652,14 @@ impl CodeGen { ); } + // An error raised in here is trapped like any other, but there is + // nothing for a bare RESUME to return to once the unwind has discarded + // this frame. The counter is what the trap reads to know that; it + // needs no unwinding of its own, because the trap resets it. + if self.traps { + self.emit(" inc QWORD PTR [rip + _err_depth]"); + } + // Generate body let exit_label = format!(".Lproc_exit_{}", mangle(name)); let saved_exit = self.proc_exit_label.replace(exit_label.clone()); @@ -2636,6 +2670,12 @@ impl CodeGen { self.loop_stack = saved_loops; self.proc_exit_label = saved_exit; self.emit_label(&exit_label); + // Every route out passes here, EXIT SUB included, so one decrement + // matches the increment above. A trapped error skips it, which is + // exactly right: the trap resets the counter instead. + if self.traps { + self.emit(" dec QWORD PTR [rip + _err_depth]"); + } // Return - load return value into appropriate register based on type if is_function { @@ -2681,7 +2721,39 @@ impl CodeGen { self.stack_offset = old_stack_offset; } + /// Compile one statement, bracketed by its resume points when trapping. + /// + /// A wrapper rather than code at the top and bottom of `gen_stmt_inner`, + /// because several of its arms `return` early and would skip the closing + /// label -- which is the one RESUME NEXT needs. + /// + /// The two labels are all RESUME needs, because the emitted layout already + /// says what runs next: after the last statement of a FOR body comes the + /// increment and the back-jump, after the last statement of an IF branch + /// comes that branch's exit jump, and after the last statement of the + /// program comes main's epilogue. Every awkward case falls out of that. fn gen_stmt(&mut self, stmt: &Stmt) { + // Module level only. A statement inside a procedure has no resume + // point: the unwind discards its frame, so there is nothing to go back + // to, and `_err_depth` is what tells the trap so. + let point = if self.traps && self.current_proc.is_none() { + let idx = self.resume_points.len(); + self.resume_points.push(idx); + self.emit_label(&format!("_rs_{}", idx)); + emit!(self, " mov QWORD PTR [rip + _err_stmt], {}", idx); + Some(idx) + } else { + None + }; + + self.gen_stmt_inner(stmt); + + if let Some(idx) = point { + self.emit_label(&format!("_rn_{}", idx)); + } + } + + fn gen_stmt_inner(&mut self, stmt: &Stmt) { if stmt.line != 0 { self.current_line = stmt.line; } @@ -3514,6 +3586,40 @@ impl CodeGen { // reads as undimensioned again and a later DIM allocates afresh. // free(NULL) is defined, so erasing an array that was never // dimensioned is harmless. + // RESUME returns from a handler. Its own two failures are + // untrappable, or the handler would be re-entered by the RESUME + // that failed and would never stop -- so the branches are emitted + // whatever `--unsafe` says, and their trampolines call the fatal + // path directly. + StmtKind::Resume(target) => { + let no_error = self.error_label(RtError::ResumeNoError); + self.emit(" mov rax, QWORD PTR [rip + _err_active]"); + self.emit(" test rax, rax"); + emit!(self, " jz {}", no_error); + + match target { + ResumeTarget::At(t) => { + let label = Self::branch_label(t); + self.emit(" mov QWORD PTR [rip + _err_active], 0"); + emit!(self, " jmp {}", label); + } + ResumeTarget::Same | ResumeTarget::Next => { + let in_proc = self.error_label(RtError::ResumeInProc); + self.emit(" mov rax, QWORD PTR [rip + _err_resume]"); + self.emit(" test rax, rax"); + emit!(self, " js {}", in_proc); + self.emit(" mov QWORD PTR [rip + _err_active], 0"); + let table = match target { + ResumeTarget::Next => "_resume_next", + _ => "_resume_at", + }; + emit!(self, " lea rcx, [rip + {}]", table); + self.emit(" mov rax, QWORD PTR [rcx + rax*8]"); + self.emit(" jmp rax"); + } + } + } + // `ON ERROR GOTO n` arms the handler; `GOTO 0` disarms it. Both // clear the in-handler flag, so re-arming is also how a program // that left its handler by GOTO starts trapping again. @@ -6706,6 +6812,24 @@ impl CodeGen { // DATA pointer self.emit("_data_ptr: .quad 0"); + // Where RESUME and RESUME NEXT go, one entry per module-level + // statement. Two tables rather than one plus arithmetic, because + // "the statement after this one" is a position in the emitted code, + // not the next index -- a nested body's last statement is followed by + // its loop's increment, not by whatever the parser called next. + if self.traps { + self.emit(""); + self.emit(".p2align 3"); + self.emit("_resume_at:"); + for i in &self.resume_points { + emit!(self, " .quad _rs_{}", i); + } + self.emit("_resume_next:"); + for i in &self.resume_points { + emit!(self, " .quad _rn_{}", i); + } + } + // GOSUB return stack pointer if self.gosub_used { self.emit("_gosub_sp: .quad 0"); diff --git a/src/lexer.rs b/src/lexer.rs index f49323d..f0d80e0 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -83,6 +83,7 @@ fn keyword(s: &str) -> Option { "LOCATE" => Some(Token::Locate), "COLOR" => Some(Token::Color), "RANDOMIZE" => Some(Token::Randomize), + "RESUME" => Some(Token::Resume), "RESTORE" => Some(Token::Restore), "CLS" => Some(Token::Cls), "OPEN" => Some(Token::Open), @@ -180,6 +181,7 @@ pub enum Token { Locate, Color, Randomize, + Resume, Restore, Cls, Open, diff --git a/src/parser.rs b/src/parser.rs index bde40ff..f5d63d2 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -242,6 +242,8 @@ pub enum StmtKind { /// `ON ERROR GOTO n` -- install an error handler; `None` is `GOTO 0`, /// which removes it and puts the fatal path back. OnError(Option), + /// `RESUME`, `RESUME NEXT`, `RESUME n` -- return from a handler. + Resume(ResumeTarget), /// `LOCATE [row][, col]` -- move the cursor. /// /// Either part may be omitted, in which case that coordinate is left where @@ -414,6 +416,17 @@ pub struct ArrayDecl { pub dimensions: Vec, } +/// Where a `RESUME` goes back to. +#[derive(Debug, Clone)] +pub enum ResumeTarget { + /// Bare `RESUME`, and `RESUME 0`: retry the statement that failed. + Same, + /// `RESUME NEXT`: carry on at the statement after it. + Next, + /// `RESUME n`: carry on somewhere else entirely. + At(GotoTarget), +} + #[derive(Debug, Clone)] pub enum GotoTarget { Line(u32), @@ -738,6 +751,7 @@ fn token_spelling(tok: &Token) -> Option<&'static str> { Token::Locate => "LOCATE", Token::Color => "COLOR", Token::Randomize => "RANDOMIZE", + Token::Resume => "RESUME", Token::Restore => "RESTORE", Token::Cls => "CLS", Token::Open => "OPEN", @@ -1362,6 +1376,7 @@ impl Parser { Token::Locate => self.parse_locate(), Token::Color => self.parse_color(), Token::Randomize => self.parse_randomize(), + Token::Resume => self.parse_resume(), Token::Restore => self.parse_restore(), Token::Cls => { self.advance(); @@ -2695,6 +2710,27 @@ impl Parser { /// Contextual rather than reserved, like CALL above it: LANGREF's own /// `ON ... GOSUB Draw, Erase` example uses the word as a label, and a /// reserved ERASE would take that name away from every program. + /// `RESUME`, `RESUME NEXT`, `RESUME 0`, `RESUME `. + fn parse_resume(&mut self) -> PResult { + self.advance(); // consume RESUME + // GW-BASIC spells "retry the failing statement" as either a bare + // RESUME or RESUME 0, so the two are folded together here. + if matches!(self.peek(), Token::Newline | Token::Colon | Token::Eof) { + return Ok(StmtKind::Resume(ResumeTarget::Same)); + } + if matches!(self.peek(), Token::Integer(0)) { + self.advance(); + return Ok(StmtKind::Resume(ResumeTarget::Same)); + } + if matches!(self.peek(), Token::Next) { + self.advance(); + return Ok(StmtKind::Resume(ResumeTarget::Next)); + } + Ok(StmtKind::Resume(ResumeTarget::At( + self.parse_goto_target()?, + ))) + } + /// `ERROR n` -- raise an error by GW-BASIC's number for it. fn parse_raise_error(&mut self) -> PResult { self.advance(); // consume ERROR diff --git a/src/runtime/sysv/error.s b/src/runtime/sysv/error.s index 1b2bea1..c028add 100644 --- a/src/runtime/sysv/error.s +++ b/src/runtime/sysv/error.s @@ -34,6 +34,10 @@ _err_alreadyopen: .asciz "File already open" _err_pastend: .asciz "Input past end of file" _err_unprintable: .asciz "Unprintable error" +# RESUME's own failures. Never trapped -- a handler that trapped them would be +# re-entered by its own RESUME, forever. +_err_resnoerr: .asciz "RESUME without error" +_err_resproc: .asciz "RESUME cannot return into a SUB or FUNCTION" # GW-BASIC's own error numbers, so that a listing's `IF ERR = 53` means what it # meant in 1983. Pairs of (message, number), terminated by a zero message. @@ -76,6 +80,9 @@ _err_active: .skip 8 # nonzero while a handler runs _err_code: .skip 8 # what ERR returns _err_line: .skip 8 # the line now running; codegen stores it per statement _err_erl: .skip 8 # what ERL returns: _err_line as it was when trapped +_err_stmt: .skip 8 # index of the module-level statement now running +_err_resume: .skip 8 # _err_stmt as it was when trapped; -1 = not resumable +_err_depth: .skip 8 # procedure nesting, so an error inside one is known # rsp, rbx, rbp, r12, r13, r14, r15 -- and rdi, rsi on Win64, where they are # callee-saved too. Sized the same in both trees so the offsets agree. _err_ctx: .skip 80 @@ -163,9 +170,9 @@ _rt_error: # what stops handler -> error -> handler from looping forever. mov rax, QWORD PTR [rip + _err_handler] test rax, rax - jz .Lerr_fatal + jz _rt_fatal cmp QWORD PTR [rip + _err_active], 0 - jne .Lerr_fatal + jne _rt_fatal # ERR is the number this message carries. Unknown text reports 0 rather # than inventing a code. @@ -190,6 +197,18 @@ _rt_error: # measured: a handler on line 100 reported ERL 100 for an error on line 30. mov rdx, QWORD PTR [rip + _err_line] mov QWORD PTR [rip + _err_erl], rdx + + # And what RESUME would go back to. The handler is ordinary module-level + # code, so its own statements overwrite _err_stmt the moment it starts; + # -1 when the error came from inside a procedure, whose frame the unwind + # below discards, leaving nothing for a bare RESUME to return to. + mov rdx, QWORD PTR [rip + _err_stmt] + cmp QWORD PTR [rip + _err_depth], 0 + je .Ltrap_resumable + mov rdx, -1 +.Ltrap_resumable: + mov QWORD PTR [rip + _err_resume], rdx + mov QWORD PTR [rip + _err_depth], 0 mov QWORD PTR [rip + _err_active], 1 # Abandon every frame between here and main. ERL is already set: codegen @@ -205,7 +224,16 @@ _rt_error: mov rsp, QWORD PTR [rip + _err_ctx + 0] jmp rax -.Lerr_fatal: +# _rt_fatal - Report and exit, without consulting the handler +# +# Today's whole behaviour, and still what happens when nothing is trapping. +# Called directly for the errors that must never be trapped: a handler +# re-entered by its own failing RESUME would never stop. +# +# Arguments: the same as _rt_error. +# Returns: never (exit code 1) +.globl _rt_fatal +_rt_fatal: push rbp mov rbp, rsp push rbx diff --git a/src/runtime/win64-native/error.s b/src/runtime/win64-native/error.s index db47289..7db7f8a 100644 --- a/src/runtime/win64-native/error.s +++ b/src/runtime/win64-native/error.s @@ -36,6 +36,10 @@ _err_alreadyopen: .asciz "File already open" _err_pastend: .asciz "Input past end of file" _err_unprintable: .asciz "Unprintable error" +# RESUME's own failures. Never trapped -- a handler that trapped them would be +# re-entered by its own RESUME, forever. +_err_resnoerr: .asciz "RESUME without error" +_err_resproc: .asciz "RESUME cannot return into a SUB or FUNCTION" # GW-BASIC's own error numbers, so that a listing's `IF ERR = 53` means what it # meant in 1983. Pairs of (message, number), terminated by a zero message. @@ -85,6 +89,9 @@ _err_active: .skip 8 # nonzero while a handler runs _err_code: .skip 8 # what ERR returns _err_line: .skip 8 # the line now running; codegen stores it per statement _err_erl: .skip 8 # what ERL returns: _err_line as it was when trapped +_err_stmt: .skip 8 # index of the module-level statement now running +_err_resume: .skip 8 # _err_stmt as it was when trapped; -1 = not resumable +_err_depth: .skip 8 # procedure nesting, so an error inside one is known # rsp, rbx, rbp, r12, r13, r14, r15, rdi, rsi. Win64 calls rdi and rsi # callee-saved where System V does not, so this tree saves two more; the # offsets are otherwise the same in both. @@ -181,9 +188,9 @@ _rt_error: # the way to a handler that never wanted it. mov rax, QWORD PTR [rip + _err_handler] test rax, rax - jz .Lerr_fatal + jz _rt_fatal cmp QWORD PTR [rip + _err_active], 0 - jne .Lerr_fatal + jne _rt_fatal # ERR is the number this message carries. Unknown text reports 0 rather # than inventing a code. @@ -208,6 +215,18 @@ _rt_error: # measured: a handler on line 100 reported ERL 100 for an error on line 30. mov r9, QWORD PTR [rip + _err_line] mov QWORD PTR [rip + _err_erl], r9 + + # And what RESUME would go back to. The handler is ordinary module-level + # code, so its own statements overwrite _err_stmt the moment it starts; + # -1 when the error came from inside a procedure, whose frame the unwind + # below discards, leaving nothing for a bare RESUME to return to. + mov r9, QWORD PTR [rip + _err_stmt] + cmp QWORD PTR [rip + _err_depth], 0 + je .Ltrap_resumable + mov r9, -1 +.Ltrap_resumable: + mov QWORD PTR [rip + _err_resume], r9 + mov QWORD PTR [rip + _err_depth], 0 mov QWORD PTR [rip + _err_active], 1 # Abandon every frame between here and main. ERL is already set: codegen @@ -225,7 +244,16 @@ _rt_error: mov rsp, QWORD PTR [rip + _err_ctx + 0] jmp rax -.Lerr_fatal: +# _rt_fatal - Report and exit, without consulting the handler +# +# Today's whole behaviour, and still what happens when nothing is trapping. +# Called directly for the errors that must never be trapped: a handler +# re-entered by its own failing RESUME would never stop. +# +# Arguments: the same as _rt_error. +# Returns: never (exit code 1) +.globl _rt_fatal +_rt_fatal: push rbp mov rbp, rsp push rbx diff --git a/src/sema.rs b/src/sema.rs index 7ac9e53..88296ce 100644 --- a/src/sema.rs +++ b/src/sema.rs @@ -115,7 +115,6 @@ const BUILTINS: &[(&str, usize, usize)] = &[ const UNSUPPORTED: &[(&str, &str)] = &[ // Planned: implementable on both platforms, not written yet. ("ERROR", "error trapping is not implemented yet"), - ("RESUME", "error trapping is not implemented yet"), ( "INKEY$", "INKEY$ needs raw console input, which is not implemented yet", @@ -395,6 +394,7 @@ pub fn analyze(program: &mut Program, checks: bool) -> (Symbols, Vec a.check_name_collisions(); a.check_return_has_a_gosub(&program.statements); a.check_gosub_scope_when_trapping(&program.statements); + a.check_resume_has_a_handler(&program.statements); a.resolve_array_accesses(&mut program.statements, &Scope::Module); a.check(&program.statements, &Scope::Module); (a.symbols, a.diagnostics) @@ -581,7 +581,7 @@ fn rewrite_names(stmts: &mut [Stmt], table: &[DataType; 26], procs: &HashSet {} // A handler is a branch target, and a label is not a variable -- // the same reason GOTO and GOSUB are left alone here. - StmtKind::OnError(_) => {} + StmtKind::OnError(_) | StmtKind::Resume(_) => {} StmtKind::Input { vars, .. } | StmtKind::Read(vars) => { vars.iter_mut().for_each(|v| lvalue(v, table, procs)) } @@ -757,7 +757,7 @@ fn for_each_expr_mut(stmt: &mut Stmt, f: &mut impl FnMut(&mut Expr)) { } StmtKind::Randomize(seed) => seed.iter_mut().for_each(&mut *f), StmtKind::RaiseError(e) => f(e), - StmtKind::OnError(_) => {} + StmtKind::OnError(_) | StmtKind::Resume(_) => {} StmtKind::Const { value, .. } => f(value), StmtKind::FieldAssign { target, value } => { lvalue(target, f); @@ -1165,6 +1165,29 @@ impl Analyzer { } } + /// RESUME needs an ON ERROR to return from. + /// + /// Modelled on `check_return_has_a_gosub`, and for the same reason: on its + /// own a RESUME compiles to a jump through a table that a non-trapping + /// program never emits, so the failure would be a link error rather than a + /// diagnosis. + fn check_resume_has_a_handler(&mut self, stmts: &[Stmt]) { + let mut has_handler = false; + let mut first_resume = None; + walk_stmts(stmts, &mut |stmt| match &stmt.kind { + StmtKind::OnError(_) => has_handler = true, + StmtKind::Resume(_) if first_resume.is_none() => first_resume = Some(stmt.line), + _ => {} + }); + if let (false, Some(line)) = (has_handler, first_resume) { + self.error_with_note( + line, + "RESUME without an error handler".to_string(), + "RESUME ends an ON ERROR handler; this program has no ON ERROR".to_string(), + ); + } + } + /// A trapping program may not GOSUB from inside a procedure. /// /// The GOSUB stack is deliberately left alone by a trap, because that is @@ -1718,6 +1741,32 @@ impl Analyzer { // erased nothing. The lookup is the one every array use gets -- // the enclosing procedure first, then the module. StmtKind::RaiseError(n) => self.check_numeric_operand(n, scope, line, "ERROR"), + StmtKind::Resume(target) => { + // A trapped error unwinds to main's frame, so the handler -- + // and the RESUME that ends it -- are module-level code. + if let Scope::Proc(name) = scope { + self.error_with_note( + line, + format!("RESUME is not allowed inside '{}'", name), + "RESUME ends an ON ERROR handler, which is module-level code".to_string(), + ); + } + if let ResumeTarget::At(t) = target { + self.check_target(t, line, "RESUME"); + let at_module_scope = match t { + GotoTarget::Line(n) => self.symbols.module_lines.contains(n), + GotoTarget::Label(name) => self.symbols.module_labels.contains(name), + }; + if !at_module_scope { + self.error_with_note( + line, + "a RESUME target must be at module level".to_string(), + "it is reached after the error has unwound out of every procedure" + .to_string(), + ); + } + } + } StmtKind::OnError(target) => { // `--unsafe` removes the checks that raise most of what a // handler exists to catch, so the pair would leave a handler diff --git a/tests/errors/mod.rs b/tests/errors/mod.rs index 7bfaf7c..f1085df 100644 --- a/tests/errors/mod.rs +++ b/tests/errors/mod.rs @@ -2008,7 +2008,16 @@ END SUB #[test] fn test_no_cost_without_on_error() { let asm = crate::common::compile_to_asm("PRINT 1\nFOR I = 1 TO 3\nPRINT I\nNEXT I\n").unwrap(); - for symbol in ["_err_handler", "_err_ctx", "_err_line", "_rt_trap_capture"] { + for symbol in [ + "_err_handler", + "_err_ctx", + "_err_line", + "_rt_trap_capture", + "_err_stmt", + "_err_depth", + "_resume_at", + "_resume_next", + ] { assert!( !asm.contains(symbol), "{symbol} should not appear in a program with no ON ERROR" @@ -2115,3 +2124,237 @@ fn test_trapping_disables_register_promotion() { "a trapping program must keep its counter in memory" ); } + +/// `RESUME` retries the statement that failed. +/// +/// The handler fixes the cause, so the retry succeeds. `N` proves it re-entered +/// at the failing statement rather than at the head of its line. +#[test] +fn test_resume_retries_the_failing_statement() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 100 +20 D = 0 +30 N = N + 1 : X = 1 / D +40 PRINT N; X +50 END +100 D = 2 +110 RESUME +"#, + ) + .unwrap(); + // "1" then "0.5": this compiler prints a leading zero where + // GW-BASIC does not, and numbers carry no padding. + assert_eq!( + out.trim(), + "10.5", + "N stayed 1 and the division then worked" + ); +} + +/// `RESUME NEXT` continues at the statement after the one that failed, even +/// when that is mid-line. +/// +/// This is the case per-statement resume points exist for: `A=1 : B=2` on one +/// line is how listings saved memory, and resuming at the head of the line +/// would re-run work and loop forever here. +#[test] +fn test_resume_next_is_exact_mid_line() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 100 +20 C = 9 +30 N = N + 1 : B = 1 / 0 : C = 3 +40 PRINT N; B; C +50 END +100 RESUME NEXT +"#, + ) + .unwrap(); + assert_eq!(out.trim(), "103", "N=1, B=0, C=3"); +} + +/// `RESUME NEXT` from the last statement of a FOR body continues the loop. +/// +/// Nothing special makes this work: `_rn` sits where the next code does, and +/// the next code after a body's last statement is the increment and the +/// back-jump. +#[test] +fn test_resume_next_continues_a_loop() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 100 +20 FOR I = 1 TO 3 +30 T = T + 1 +40 X = 1 / (I - 2) +50 NEXT I +60 PRINT T; I +70 END +100 RESUME NEXT +"#, + ) + .unwrap(); + assert_eq!( + out.trim(), + "34", + "all three iterations ran; falling out would give 2" + ); +} + +/// `RESUME ` jumps, and skips whatever lies between. +#[test] +fn test_resume_at_a_line() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 100 +20 PRINT 1 / 0 +30 PRINT "skipped" +40 PRINT "landed" +50 END +100 RESUME 40 +"#, + ) + .unwrap(); + assert_eq!(out.trim(), "landed"); +} + +/// The classic retry loop: try, fail, fix, try again. +#[test] +fn test_resume_retry_loop() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 200 +20 Tries = 0 +30 Divisor = 0 +40 Tries = Tries + 1 +50 R = 100 / Divisor +60 PRINT "took"; Tries; "tries, got"; R +70 END +200 Divisor = Divisor + 4 +210 RESUME +"#, + ) + .unwrap(); + assert_eq!(out.trim(), "took1tries, got25"); +} + +/// `RESUME` with no error active is refused at run time, and is not itself +/// trappable -- trapping it would loop through the handler forever. +#[test] +fn test_resume_without_an_error_is_fatal() { + let run = compile_and_run_raw( + "10 ON ERROR GOTO 100\n20 GOTO 100\n30 END\n100 RESUME\n", + "", + ) + .expect("should compile"); + assert_eq!(run.exit_code, Some(1)); + assert!( + run.stderr.contains("RESUME without error"), + "got: {:?}", + run.stderr + ); +} + +/// A bare `RESUME` after an error raised inside a procedure fails cleanly. +/// +/// The unwind discarded that frame, so there is nothing to go back to. The +/// diagnosis is the point: guessing would resume at the CALL, which looks +/// plausible and is wrong. +#[test] +fn test_resume_after_a_procedure_error_is_refused() { + let src = "10 ON ERROR GOTO 100\n20 CALL B\n30 PRINT \"no\"\n40 END\n\ + 100 RESUME\nSUB B\nPRINT 1 / 0\nEND SUB\n"; + let run = compile_and_run_raw(src, "").expect("should compile"); + assert_eq!(run.exit_code, Some(1)); + assert!(run.stderr.contains("RESUME"), "got: {:?}", run.stderr); + assert!(!run.stdout.contains("no"), "it must not resume anywhere"); + + // `RESUME ` needs no failing statement, so it is allowed. + let ok = "10 ON ERROR GOTO 100\n20 CALL B\n30 PRINT \"skipped\"\n40 PRINT \"landed\"\n50 END\n\ + 100 RESUME 40\nSUB B\nPRINT 1 / 0\nEND SUB\n"; + assert_eq!(compile_and_run(ok).unwrap().trim(), "landed"); +} + +/// A statement that continues after a call returns is still resumable. +/// +/// `X = F(1) / D` divides after `F` comes back, so the error belongs to the +/// module-level statement even though a procedure ran inside it. +#[test] +fn test_resume_after_a_call_that_returned() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 100 +20 D = 0 +30 X = F(1) / D +40 PRINT X +50 END +100 D = 4 +110 RESUME +FUNCTION F(N) + F = N + 7 +END FUNCTION +"#, + ) + .unwrap(); + assert_eq!(out.trim(), "2"); +} + +/// `RESUME` needs a handler to return from, and belongs at module level. +#[test] +fn test_resume_is_checked_at_compile_time() { + let err = compile_only("10 PRINT 1\n20 RESUME\n").expect_err("RESUME needs an ON ERROR"); + assert!(err.contains("RESUME"), "got: {}", err.stderr); + assert!(err.is_clean_rejection()); + + let err = + compile_only("10 ON ERROR GOTO 100\n20 CALL B\n30 END\n100 END\nSUB B\nRESUME\nEND SUB\n") + .expect_err("RESUME inside a procedure must be refused"); + assert!(err.is_clean_rejection(), "got: {}", err.stderr); + + let err = compile_only("10 ON ERROR GOTO 100\n20 PRINT 1\n30 END\n100 RESUME 999\n") + .expect_err("RESUME must name a line that exists"); + assert!(err.contains("RESUME"), "got: {}", err.stderr); +} + +/// `RESUME NEXT` is exact however deeply the failing statement is nested. +/// +/// The resume point is a position in the emitted code, so an IF inside a FOR +/// inside a WHILE needs nothing special -- which is the claim this checks. +#[test] +fn test_resume_next_from_a_deeply_nested_statement() { + let out = compile_and_run( + r#" +10 ON ERROR GOTO 200 +20 W = 0 +30 WHILE W < 2 +40 FOR I = 1 TO 3 +50 IF I = 2 THEN T = T + 1 : X = 1 / 0 : T = T + 10 +60 V = V + 1 +70 NEXT I +80 W = W + 1 +90 WEND +100 PRINT T; V; W +110 END +200 RESUME NEXT +"#, + ) + .unwrap(); + // Two passes of the loop; on I=2 the divide fails and RESUME NEXT lands on + // `T = T + 10`, then the loop and the WHILE both carry on normally. + assert_eq!(out.trim(), "2262", "T=22, V=6, W=2"); +} + +/// `RESUME NEXT` from the last statement of the program just ends it. +/// +/// The handler is put ahead of the failure on purpose, so that the statement +/// that fails really is the last one emitted and `_rn` for it is main's +/// epilogue. +#[test] +fn test_resume_next_off_the_end() { + let run = compile_and_run_raw( + "10 GOTO 30\n20 RESUME NEXT\n30 ON ERROR GOTO 20\n40 PRINT 1 / 0\n", + "", + ) + .expect("should compile"); + run.assert_ran_to_completion("RESUME NEXT past the last statement"); +} From 81e2d62eb8be4f4646cba9fbf4f90cc415903477 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Mon, 17 Aug 2026 10:38:11 +0000 Subject: [PATCH 7/8] Say why ERROR is refused as a value, now that it works as a statement Its entry in the UNSUPPORTED table still read "error trapping is not implemented yet", which stopped being true two commits ago. ERROR stays in the table for a different reason: it is a statement and never a name, so that `PRINT ERROR` and `X = ERROR + 1` say so rather than quietly becoming a variable that reads as zero. The note now says that instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/sema.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/sema.rs b/src/sema.rs index 88296ce..93a7039 100644 --- a/src/sema.rs +++ b/src/sema.rs @@ -114,7 +114,13 @@ const BUILTINS: &[(&str, usize, usize)] = &[ /// two are meant to be read together. const UNSUPPORTED: &[(&str, &str)] = &[ // Planned: implementable on both platforms, not written yet. - ("ERROR", "error trapping is not implemented yet"), + // Not "unimplemented": ERROR n works. It stays here so that every *other* + // mention -- `PRINT ERROR`, `X = ERROR + 1` -- says why rather than + // becoming a variable that reads as zero. + ( + "ERROR", + "ERROR is a statement, not a value; write ERROR n to raise error n", + ), ( "INKEY$", "INKEY$ needs raw console input, which is not implemented yet", From f579e1f080851f8a75ef763ee24f576de3a2c286 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Mon, 17 Aug 2026 15:41:41 +0000 Subject: [PATCH 8/8] Print numbers the way GW-BASIC prints them `PRINT "Guess"; Tries; "is"; Guess` wrote "Guess1is50". A listing's output ran together, and nothing in a column lined up, because a number here carried no spacing of its own. In GW-BASIC it carries two blanks: one where the minus sign would go if the number is not negative, and one after it. So `PRINT 1; 2; 3` is " 1 2 3 " and `PRINT 1; -2; 3` is " 1 -2 3 ", the minus taking the leading blank's place. Three smaller rules travel with it: a value below one drops its leading zero, an exponent is spelled D for a DOUBLE and E for a SINGLE rather than C's lowercase e, and a comma moves to the next 14-column print zone instead of emitting a literal tab whose width is whatever the terminal says. Where the blanks live matters. `_rt_fmt_double` keeps rendering bare digits, because WRITE and PRINT USING go through it and must not gain them: WRITE's output is meant to be read back by INPUT, and PRINT USING lays out its own. The sign position is added by `_rt_fmt_basic`, which STR$ also uses -- GW-BASIC defines STR$ as what PRINT writes less the trailing blank, which is why `MID$(STR$(N), 2)` is the idiom for stripping it and why leaving STR$ alone would have made that idiom eat a digit. The trailing blank belongs to PRINT alone, so PRINT gets its own pair of helpers and WRITE keeps the old ones. 182 tests moved. Most only ever cared what a program computed, so the harness now trims each line rather than the whole output once, and the spacing has explicit tests of its own instead of being implicit in every assertion. The rest were rewritten from the diffs the failures printed, one function at a time -- a global replace of "21" would have quietly corrupted tests that were passing. The megatest's own harness strips the sign position in `Check`, which is what its comment always claimed to do. Four expectations changed for a real reason rather than for spacing: .5 for 0.5, twice for a repeating double, and STR$(P) + STR$(Q) being "2 1" because joining two of them keeps the second one's sign position. Verified under both runtimes; the Win64 model prints all of it identically. guess.bas now reads "Guess 1 is 50 - too high". Co-Authored-By: Claude Opus 5 (1M context) --- LANGREF.md | 23 ++++++- src/codegen.rs | 33 +++++---- src/runtime/sysv/file.s | 90 ++++++++++++++++++++++++ src/runtime/sysv/print.s | 78 +++++++++++++++++++++ src/runtime/sysv/string.s | 4 +- src/runtime/win64-native/file.s | 93 +++++++++++++++++++++++++ src/runtime/win64-native/print.s | 79 +++++++++++++++++++++ src/runtime/win64-native/string.s | 4 +- tests/arithmetic/mod.rs | 54 +++++++-------- tests/arrays/mod.rs | 34 ++++----- tests/codegen/mod.rs | 22 +++--- tests/common/mod.rs | 18 ++++- tests/control/mod.rs | 101 +++++++++++++-------------- tests/data/mod.rs | 33 +++++---- tests/errors/mod.rs | 39 ++++++----- tests/file_io/mod.rs | 38 +++++----- tests/input/mod.rs | 10 +-- tests/math/mod.rs | 31 +++++---- tests/megatest/mega.bas | 18 +++-- tests/megatest/mod.rs | 11 ++- tests/print/mod.rs | 111 +++++++++++++++++++++++++++++- tests/procedures/mod.rs | 46 ++++++------- tests/strings/mod.rs | 42 +++++------ tests/types/mod.rs | 83 +++++++++++----------- tests/variables/mod.rs | 32 ++++----- 25 files changed, 820 insertions(+), 307 deletions(-) diff --git a/LANGREF.md b/LANGREF.md index a304d25..a4d0a9d 100644 --- a/LANGREF.md +++ b/LANGREF.md @@ -368,12 +368,25 @@ Output to console: ```basic PRINT "Hello, World!" -PRINT X; Y; Z ' Semicolon: no space between -PRINT A, B, C ' Comma: tab-separated +PRINT X; Y; Z ' Semicolon: items adjoin +PRINT A, B, C ' Comma: next 14-column print zone PRINT "Value: "; X PRINT ' Print blank line ``` +**A number carries its own spacing**, as in GW-BASIC: a blank where the minus +sign would go if it is not negative, and a blank after it. So `PRINT 1; 2; 3` +writes ` 1 2 3 `, and `PRINT 1; -2; 3` writes ` 1 -2 3 ` -- the minus takes +the leading blank's place. A string is written exactly as it is. + +A value below one drops its leading zero (`.5`, not `0.5`), and an exponent is +spelled with `D` for a DOUBLE and `E` for a SINGLE: `1D+20`, `1.5E-10`. + +A comma moves to the start of the next 14-column zone, padding with blanks; if +the line is already past the last zone it moves to the next line. `WRITE` and +`PRINT USING` are unaffected -- `WRITE` pads nothing, because its output is +meant to be read back by `INPUT`, and `PRINT USING` lays out its own. + Semicolon at end suppresses newline: ```basic PRINT "Enter value: "; @@ -1037,7 +1050,7 @@ nobody to ask, so it takes the clock. | `ASC(s$)` | ASCII code of first character | | `CHR$(n)` | Character from ASCII code | | `VAL(s$)` | Convert string to number | -| `STR$(x)` | Convert number to string | +| `STR$(x)` | The number as PRINT writes it, less the trailing blank | | `SPACE$(n)` | A string of n spaces | | `STRING$(n, c)` | n copies of a character (code or first of c$) | | `LTRIM$(s$)` | Drop leading spaces | @@ -1067,6 +1080,10 @@ MID$(A$, 1, 1) = "J" ' A$ is now "Jello" Both are only meaningful inside an `ON ERROR` handler. `ERL` reports the BASIC line number, as the error messages do. +`STR$` keeps the blank PRINT puts where a minus sign would go, so `STR$(5)` +is `" 5"` and `STR$(-5)` is `"-5"`. `MID$(STR$(N), 2)` is the usual way to drop +it. + ### Type Conversion Functions | Function | Description | diff --git a/src/codegen.rs b/src/codegen.rs index 29ef55b..9b6ec90 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -299,7 +299,6 @@ const MAX_EXPR_DEPTH: u32 = 256; const GOSUB_STACK_SIZE: i32 = 524288; /// ASCII character codes -const ASCII_TAB: i64 = 9; const ASCII_COMMA: i64 = 44; /// The console's file number. The runtime seeds handle 0 with standard /// output, so PRINT and PRINT # are the same helpers with a different handle. @@ -2917,7 +2916,7 @@ impl CodeGen { } self.gen_write_expr(expr, &sink); } else { - self.gen_print_expr(expr, &sink); + self.gen_print_expr(expr, &sink, true); } first = false; } @@ -2927,8 +2926,7 @@ impl CodeGen { // tab as well wrote "10\t,20". if !*write { self.emit_arg_file_num(0, &sink); - self.emit_arg_imm(1, ASCII_TAB); - self.emit(" call _rt_file_print_char"); + self.emit(" call _rt_print_zone"); } } PrintItem::Empty => {} @@ -5747,19 +5745,22 @@ impl CodeGen { } } - /// Emit one WRITE value: strings are quoted, numbers printed as usual. - /// `WRITE` quotes a string; a number is written the same as by `PRINT`. + /// Emit one WRITE value: strings are quoted, numbers written bare. + /// + /// Bare is the difference from PRINT: `WRITE 1, -2` is `1,-2`, with none of + /// the sign position or trailing blank a number carries under PRINT. WRITE + /// exists to be read back by INPUT, which is why it pads nothing. fn gen_write_expr(&mut self, expr: &Expr, sink: &FileNum) { if self.expr_type(expr) == DataType::String { self.emit_arg_file_num(0, sink); self.emit_arg_imm(1, ASCII_QUOTE); self.emit(" call _rt_file_print_char"); - self.gen_print_expr(expr, sink); + self.gen_print_expr(expr, sink, false); self.emit_arg_file_num(0, sink); self.emit_arg_imm(1, ASCII_QUOTE); self.emit(" call _rt_file_print_char"); } else { - self.gen_print_expr(expr, sink); + self.gen_print_expr(expr, sink, false); } } @@ -5769,7 +5770,9 @@ impl CodeGen { /// this replaced had missed both the TAB/SPC case and the SINGLE one, so /// `PRINT #1, A!` wrote digits a SINGLE does not carry and `PRINT #1, /// TAB(10)` positioned the console. - fn gen_print_expr(&mut self, expr: &Expr, sink: &FileNum) { + /// `padded` gives a number the spacing PRINT gives it -- a blank where the + /// sign would go and a blank after. WRITE passes false. + fn gen_print_expr(&mut self, expr: &Expr, sink: &FileNum, padded: bool) { // TAB() and SPC() position the cursor rather than producing a value, // so they are emitted for their effect and nothing is printed after. if let Expr::FnCall { name, args } = expr { @@ -5795,11 +5798,13 @@ impl CodeGen { let expr_type = self.expr_type(expr); self.gen_expr_to_double(expr); self.emit_arg_file_num(0, sink); - if expr_type == DataType::Single { - self.emit(" call _rt_file_print_single"); - } else { - self.emit(" call _rt_file_print_float"); - } + let helper = match (expr_type == DataType::Single, padded) { + (true, true) => "_rt_print_number_single", + (true, false) => "_rt_file_print_single", + (false, true) => "_rt_print_number", + (false, false) => "_rt_file_print_float", + }; + emit!(self, " call {}", helper); } } diff --git a/src/runtime/sysv/file.s b/src/runtime/sysv/file.s index c8f6054..eccd9a3 100644 --- a/src/runtime/sysv/file.s +++ b/src/runtime/sysv/file.s @@ -307,6 +307,96 @@ _rt_file_print_single: leave ret +# _rt_print_number - PRINT a DOUBLE, with the spacing PRINT gives a number +# +# A blank where the sign would go, and a blank after. WRITE renders numbers +# through _rt_file_print_float instead, because WRITE pads nothing. +# +# Arguments: rdi = file number, xmm0 = value +# Returns: nothing +.globl _rt_print_number +_rt_print_number: + push rbp + mov rbp, rsp + push rbx + sub rsp, 8 + + mov ebx, edi + lea rdi, [rip + _fmt_g_table] + xor esi, esi + call _rt_fmt_basic + jmp .Lprint_num_emit + +# _rt_print_number_single - The same for a SINGLE, whose shorter digit table +# is the reason PRINT keeps two entry points at all. +# +# Arguments: rdi = file number, xmm0 = value already widened to double +# Returns: nothing +.globl _rt_print_number_single +_rt_print_number_single: + push rbp + mov rbp, rsp + push rbx + sub rsp, 8 + + mov ebx, edi + lea rdi, [rip + _fmt_g_single_table] + mov esi, 1 + call _rt_fmt_basic + +.Lprint_num_emit: + lea rsi, [rip + _num_buf] + mov BYTE PTR [rsi + rax], 32 # the trailing blank + inc rax + mov rdx, rax + mov edi, ebx + call _rt_file_print_string + + add rsp, 8 + pop rbx + leave + ret + +# PRINT lays a line out in zones this wide; a comma moves to the next one. +.equ PRINT_ZONE, 14 + +# _rt_print_zone - PRINT's comma: move to the start of the next print zone +# +# GW-BASIC lays a line out in 14-column zones and a comma moves to the next +# one, padding with blanks. This used to emit a literal tab, which is whatever +# width the terminal says and lines nothing up. +# +# Built on _rt_file_print_tab, which already pads to a column and starts a new +# line when the cursor is past it -- which is also what a comma does past the +# last zone. +# +# Arguments: rdi = file number +# Returns: nothing +.globl _rt_print_zone +_rt_print_zone: + push rbp + mov rbp, rsp + push rbx + sub rsp, 8 + + mov ebx, edi + lea rax, [rip + _file_col] + mov rax, QWORD PTR [rax + rbx*8] # characters already on this line + xor edx, edx + mov rcx, PRINT_ZONE + div rcx + inc rax + imul rax, rax, PRINT_ZONE # first column of the next zone, 0-based + inc rax # _rt_file_print_tab counts from 1 + mov rsi, rax + mov edi, ebx + call _rt_file_print_tab + + add rsp, 8 + pop rbx + leave + ret + # _rt_con_string - Write a string to the console # The console is file handle 0. This exists for the runtime's own messages and # for PRINT USING, which has no file form; generated code passes a handle like diff --git a/src/runtime/sysv/print.s b/src/runtime/sysv/print.s index cc64db5..97d0195 100644 --- a/src/runtime/sysv/print.s +++ b/src/runtime/sysv/print.s @@ -106,12 +106,90 @@ _rt_fmt_double: .Lfd_done: # sprintf and strlen both leave the length in rax. + # + # Reshape C's rendering into GW-BASIC's. Only registers that are volatile + # in *both* ABIs are used here, so this code is identical in both trees -- + # note rsi and rdi are callee-saved on Win64 and so are avoided. + + # A value below one drops its leading zero: 0.5 -> .5, -0.5 -> -.5 + lea rcx, [rip + _num_buf] + xor edx, edx + cmp BYTE PTR [rcx], 45 # '-' + jne .Lfd_zero + mov edx, 1 +.Lfd_zero: + cmp BYTE PTR [rcx + rdx], 48 # '0' + jne .Lfd_exp + cmp BYTE PTR [rcx + rdx + 1], 46 # '.' + jne .Lfd_exp + # Shift the rest down over the zero, the NUL at [rax] included. +.Lfd_shift: + mov r9b, BYTE PTR [rcx + rdx + 1] + mov BYTE PTR [rcx + rdx], r9b + inc rdx + cmp rdx, rax + jl .Lfd_shift + dec rax + + # The exponent is spelled D for a double and E for a single, never C's + # lowercase e. +.Lfd_exp: + xor edx, edx +.Lfd_escan: + cmp rdx, rax + jge .Lfd_ret + cmp BYTE PTR [rcx + rdx], 101 # 'e' + je .Lfd_efound + inc rdx + jmp .Lfd_escan +.Lfd_efound: + mov r9b, 68 # 'D', a double + test r12d, r12d + jz .Lfd_eput + mov r9b, 69 # 'E', a single +.Lfd_eput: + mov BYTE PTR [rcx + rdx], r9b + +.Lfd_ret: add rsp, 16 pop r12 pop rbx pop rbp ret +# _rt_fmt_basic - Render a number the way BASIC writes it +# +# _rt_fmt_double gives the digits; this adds the blank that stands where a +# minus sign would go. GW-BASIC puts one there for every non-negative number, +# which is why `PRINT 1; 2` reads " 1 2 " and why STR$(5) is " 5" -- the +# `MID$(STR$(N), 2)` idiom exists to strip exactly this blank. +# +# Not folded into _rt_fmt_double, because WRITE and PRINT USING render numbers +# through that and must not gain the blank. +# +# Arguments: the same as _rt_fmt_double +# Returns: rax = length of the text in _num_buf +.globl _rt_fmt_basic +_rt_fmt_basic: + push rbp + mov rbp, rsp + call _rt_fmt_double + lea rcx, [rip + _num_buf] + cmp BYTE PTR [rcx], 45 # '-' already occupies the sign position + je .Lfb_done + # Shift right by one, the NUL at [rax] included, and blank the vacancy. + mov rdx, rax +.Lfb_shift: + mov r9b, BYTE PTR [rcx + rdx] + mov BYTE PTR [rcx + rdx + 1], r9b + dec rdx + jns .Lfb_shift + mov BYTE PTR [rcx], 32 # ' ' + inc rax +.Lfb_done: + leave + ret + # _rt_end - Terminate the program normally (END / STOP) # Valid from any frame, including inside a SUB or FUNCTION. Emitting a plain # `leave; ret` for END only terminates when it appears in main; inside a diff --git a/src/runtime/sysv/string.s b/src/runtime/sysv/string.s index a00a220..16f755b 100644 --- a/src/runtime/sysv/string.s +++ b/src/runtime/sysv/string.s @@ -70,7 +70,7 @@ _rt_str: mov rbp, rsp lea rdi, [rip + _fmt_g_table] xor esi, esi - call _rt_fmt_double + call _rt_fmt_basic # includes the sign position's blank lea rdi, [rip + _num_buf] mov rsi, rax # length leave @@ -90,7 +90,7 @@ _rt_str_single: mov rbp, rsp lea rdi, [rip + _fmt_g_single_table] mov esi, 1 - call _rt_fmt_double + call _rt_fmt_basic lea rdi, [rip + _num_buf] mov rsi, rax leave diff --git a/src/runtime/win64-native/file.s b/src/runtime/win64-native/file.s index 1315edb..054d092 100644 --- a/src/runtime/win64-native/file.s +++ b/src/runtime/win64-native/file.s @@ -329,6 +329,99 @@ _rt_file_print_single: leave ret +# _rt_print_number - PRINT a DOUBLE, with the spacing PRINT gives a number +# +# A blank where the sign would go, and a blank after. WRITE renders numbers +# through _rt_file_print_float instead, because WRITE pads nothing. +# +# Arguments: rcx = file number, xmm1 = value (xmm0 by position; see below) +# Returns: nothing +.globl _rt_print_number +_rt_print_number: + push rbp + mov rbp, rsp + push rbx + push r12 + sub rsp, 48 # Shadow space + alignment + + mov ebx, ecx # save file number + lea rcx, [rip + _fmt_g_table] + xor edx, edx + call _rt_fmt_basic + jmp .Lprint_num_emit + +# _rt_print_number_single - The same for a SINGLE, whose shorter digit table +# is the reason PRINT keeps two entry points at all. +# +# Arguments: rcx = file number, the value already widened to double +# Returns: nothing +.globl _rt_print_number_single +_rt_print_number_single: + push rbp + mov rbp, rsp + push rbx + push r12 + sub rsp, 48 # Shadow space + alignment + + mov ebx, ecx # save file number + lea rcx, [rip + _fmt_g_single_table] + mov edx, 1 + call _rt_fmt_basic + +.Lprint_num_emit: + lea rdx, [rip + _num_buf] + mov BYTE PTR [rdx + rax], 32 # the trailing blank + inc rax + mov r8, rax + mov ecx, ebx + call _rt_file_print_string + + add rsp, 48 + pop r12 + pop rbx + leave + ret + +# PRINT lays a line out in zones this wide; a comma moves to the next one. +.equ PRINT_ZONE, 14 + +# _rt_print_zone - PRINT's comma: move to the start of the next print zone +# +# GW-BASIC lays a line out in 14-column zones and a comma moves to the next +# one, padding with blanks. This used to emit a literal tab, which is whatever +# width the terminal says and lines nothing up. +# +# Built on _rt_file_print_tab, which already pads to a column and starts a new +# line when the cursor is past it -- which is also what a comma does past the +# last zone. +# +# Arguments: rcx = file number +# Returns: nothing +.globl _rt_print_zone +_rt_print_zone: + push rbp + mov rbp, rsp + push rbx + sub rsp, 40 # shadow space + alignment + + mov ebx, ecx + lea rax, [rip + _file_col] + mov rax, QWORD PTR [rax + rbx*8] # characters already on this line + xor edx, edx + mov r8, PRINT_ZONE + div r8 + inc rax + imul rax, rax, PRINT_ZONE # first column of the next zone, 0-based + inc rax # _rt_file_print_tab counts from 1 + mov rdx, rax + mov ecx, ebx + call _rt_file_print_tab + + add rsp, 40 + pop rbx + leave + ret + # _rt_con_string - Write a string to the console # The console is file handle 0. This exists for the runtime's own messages and # for PRINT USING, which has no file form; generated code passes a handle like diff --git a/src/runtime/win64-native/print.s b/src/runtime/win64-native/print.s index ab47d3f..b94841c 100644 --- a/src/runtime/win64-native/print.s +++ b/src/runtime/win64-native/print.s @@ -156,6 +156,51 @@ _rt_fmt_double: .Lfd_done: # sprintf and lstrlenA both leave the length in rax. + # + # Reshape C's rendering into GW-BASIC's, the same way the System V tree + # does. Only registers volatile in both ABIs are touched -- rsi and rdi are + # callee-saved here, and rsi is holding the precision flag besides. + + # A value below one drops its leading zero: 0.5 -> .5, -0.5 -> -.5 + lea rcx, [rip + _num_buf] + xor edx, edx + cmp BYTE PTR [rcx], 45 # '-' + jne .Lfd_zero + mov edx, 1 +.Lfd_zero: + cmp BYTE PTR [rcx + rdx], 48 # '0' + jne .Lfd_exp + cmp BYTE PTR [rcx + rdx + 1], 46 # '.' + jne .Lfd_exp + # Shift the rest down over the zero, the NUL at [rax] included. +.Lfd_shift: + mov r9b, BYTE PTR [rcx + rdx + 1] + mov BYTE PTR [rcx + rdx], r9b + inc rdx + cmp rdx, rax + jl .Lfd_shift + dec rax + + # The exponent is spelled D for a double and E for a single, never C's + # lowercase e. +.Lfd_exp: + xor edx, edx +.Lfd_escan: + cmp rdx, rax + jge .Lfd_ret + cmp BYTE PTR [rcx + rdx], 101 # 'e' + je .Lfd_efound + inc rdx + jmp .Lfd_escan +.Lfd_efound: + mov r9b, 68 # 'D', a double + test esi, esi + jz .Lfd_eput + mov r9b, 69 # 'E', a single +.Lfd_eput: + mov BYTE PTR [rcx + rdx], r9b + +.Lfd_ret: add rsp, 64 pop rsi pop rbx @@ -163,6 +208,40 @@ _rt_fmt_double: ret +# _rt_fmt_basic - Render a number the way BASIC writes it +# +# _rt_fmt_double gives the digits; this adds the blank that stands where a +# minus sign would go. GW-BASIC puts one there for every non-negative number, +# which is why `PRINT 1; 2` reads " 1 2 " and why STR$(5) is " 5" -- the +# `MID$(STR$(N), 2)` idiom exists to strip exactly this blank. +# +# Not folded into _rt_fmt_double, because WRITE and PRINT USING render numbers +# through that and must not gain the blank. +# +# Arguments: the same as _rt_fmt_double +# Returns: rax = length of the text in _num_buf +.globl _rt_fmt_basic +_rt_fmt_basic: + push rbp + mov rbp, rsp + sub rsp, 32 # shadow space + call _rt_fmt_double + lea rcx, [rip + _num_buf] + cmp BYTE PTR [rcx], 45 # '-' already occupies the sign position + je .Lfb_done + # Shift right by one, the NUL at [rax] included, and blank the vacancy. + mov rdx, rax +.Lfb_shift: + mov r9b, BYTE PTR [rcx + rdx] + mov BYTE PTR [rcx + rdx + 1], r9b + dec rdx + jns .Lfb_shift + mov BYTE PTR [rcx], 32 # ' ' + inc rax +.Lfb_done: + leave + ret + # _rt_end - Terminate the program normally (END / STOP) # Valid from any frame, including inside a SUB or FUNCTION. Emitting a plain # `leave; ret` for END only terminates when it appears in main; inside a diff --git a/src/runtime/win64-native/string.s b/src/runtime/win64-native/string.s index 4fe2420..190e529 100644 --- a/src/runtime/win64-native/string.s +++ b/src/runtime/win64-native/string.s @@ -74,7 +74,7 @@ _rt_str: sub rsp, 32 # shadow space lea rcx, [rip + _fmt_g_table] xor edx, edx - call _rt_fmt_double + call _rt_fmt_basic # includes the sign position's blank lea rcx, [rip + _num_buf] mov rdx, rax # length add rsp, 32 @@ -95,7 +95,7 @@ _rt_str_single: sub rsp, 32 # shadow space lea rcx, [rip + _fmt_g_single_table] mov edx, 1 - call _rt_fmt_double + call _rt_fmt_basic lea rcx, [rip + _num_buf] mov rdx, rax add rsp, 32 diff --git a/tests/arithmetic/mod.rs b/tests/arithmetic/mod.rs index 86cd9a2..fd4270e 100644 --- a/tests/arithmetic/mod.rs +++ b/tests/arithmetic/mod.rs @@ -20,7 +20,7 @@ PRINT 2 ^ 10 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "15", "add"); assert_eq!(lines[1], "7", "sub"); assert_eq!(lines[2], "42", "mul"); @@ -41,7 +41,7 @@ PRINT -5 + 10 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "14", "precedence"); assert_eq!(lines[1], "20", "parentheses"); assert_eq!(lines[2], "5", "negative"); @@ -75,7 +75,7 @@ IF 0 XOR 0 THEN PRINT "xor-d" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec![ @@ -113,7 +113,7 @@ PRINT NOT D% "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["-13", "-13", "-13", "-1", "0", "-2", "-256"], @@ -134,7 +134,7 @@ IF 5 <> 6 THEN PRINT "ok6" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines.len(), 6); } @@ -154,7 +154,7 @@ A% = 42: PRINT -A% "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "150", "int add"); assert_eq!(lines[1], "70", "int sub"); assert_eq!(lines[2], "60", "int mul"); @@ -181,7 +181,7 @@ A& = 12345: PRINT -A& "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "150000", "long add"); assert_eq!(lines[1], "70000", "long sub"); assert_eq!(lines[2], "500000", "long mul"); @@ -206,7 +206,7 @@ A! = 3.14: PRINT -A! "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "4", "single add"); assert_eq!(lines[1], "3.25", "single sub"); assert_eq!(lines[2], "10", "single mul"); @@ -229,7 +229,7 @@ A# = 2.71828: PRINT -A# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "4", "double add"); assert_eq!(lines[1], "50.5", "double sub"); assert_eq!(lines[2], "7", "double mul"); @@ -255,7 +255,7 @@ PRINT 1 - -2 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["-4", "-8", "4", "-4", "-9", "-6", "3"], @@ -278,7 +278,7 @@ PRINT &H10 + &H10 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["255", "15", "10", "15", "-1", "32"], @@ -299,7 +299,7 @@ PRINT -2147483648 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec![ @@ -327,17 +327,17 @@ PRINT 123456789.123456 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec![ - "0.3333333333333333", - "0.6666666666666666", + ".3333333333333333", + ".6666666666666666", "3.141592653589793", "1.4142135623730951", // Values that are exact at fewer digits keep their short form. "3.14159", - "0.1", + ".1", "123456789.123456", ] ); @@ -359,8 +359,8 @@ PRINT CSNG(1 / 3) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["3.14159", "0.1", "3.14", "0.33333334"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["3.14159", ".1", "3.14", ".33333334"]); } /// The bitwise operators must work on Double operands, which is what an @@ -386,7 +386,7 @@ PRINT A XOR B "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["8", "14", "6"], "AND/OR/XOR over Double"); } @@ -409,7 +409,7 @@ IF (E AND F) = 8 THEN PRINT "cond-ok" ELSE PRINT "cond-bad" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["8", "8", "8", "8", "cond-ok"], @@ -433,7 +433,7 @@ PRINT A > B "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["1", "2", "22", "1.2", "0", "-1"]); } @@ -457,7 +457,7 @@ PRINT 12 XOR 10 AND 6 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); // NOT binds tighter than AND, so the first two agree. assert_eq!(&lines[0..2], &["0", "0"], "NOT binds tighter than AND"); // OR and XOR share a level and associate left to right, so these agree too. @@ -479,7 +479,7 @@ IF NOT A < B THEN PRINT "not-less" ELSE PRINT "less" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); // NOT (A = B): A <> B, so NOT 0 = -1, true. assert_eq!( lines[0], "not-equal", @@ -506,10 +506,10 @@ PRINT 2 ^ 3 ^ 2 ^ 1 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "64", "(2^3)^2, not 2^(3^2)"); assert_eq!(lines[1], "-4", "^ binds tighter than unary minus"); - assert_eq!(lines[2], "0.25", "a negative exponent still parses"); + assert_eq!(lines[2], ".25", "a negative exponent still parses"); assert_eq!(lines[3], "85", "subtraction is left to right"); assert_eq!(lines[4], "2", "division is left to right"); assert_eq!(lines[5], "64", "((2^3)^2)^1"); @@ -535,7 +535,7 @@ PRINT 0 IMP 0 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); // EQV is bitwise equivalence: NOT (a XOR b). assert_eq!(&lines[0..4], &["-1", "0", "-1", "-7"], "EQV"); // IMP is implication: (NOT a) OR b. @@ -555,7 +555,7 @@ PRINT -1 IMP 0 EQV 0 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); // OR binds tighter, so the first two disagree and the first matches the third. assert_eq!(lines[0], lines[2], "OR binds tighter than EQV"); // -1 IMP (0 EQV 0) = -1 IMP -1 = -1 diff --git a/tests/arrays/mod.rs b/tests/arrays/mod.rs index b906473..97d4a2b 100644 --- a/tests/arrays/mod.rs +++ b/tests/arrays/mod.rs @@ -30,7 +30,7 @@ PRINT Grid(0, 0), Grid(0, 1), Grid(0, 2), Grid(1, 0), Grid(1, 1), Grid(1, 2) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "10", "1d a(1)"); assert_eq!(lines[1], "30", "1d a(3)"); assert_eq!(lines[2], "15", "2d diagonal sum"); @@ -85,7 +85,7 @@ PRINT A%(0) + A%(2) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["3", "100000", "3.5", "3.5", "3", "5"], @@ -114,9 +114,9 @@ PRINT "" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines[0], "579"); - assert_eq!(lines[1], "014916"); + let lines = crate::common::lines(&output); + assert_eq!(lines[0], "5 7 9"); + assert_eq!(lines[1], "0 1 4 9 16"); } /// A fresh array reads as 0 / "": allocation is zeroed, which plain malloc @@ -125,8 +125,8 @@ PRINT "" fn test_arrays_start_zeroed() { let output = compile_and_run("DIM A(5)\nDIM S$(2)\nPRINT A(0); A(3); A(5)\nPRINT LEN(S$(1))\n").unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["000", "0"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["0 0 0", "0"]); } /// REDIM resizes an existing array, reusing its descriptor, and clears it. @@ -136,8 +136,8 @@ fn test_redim() { "DIM A(2)\nA(0) = 7\nREDIM A(5)\nPRINT A(0)\nA(5) = 9\nPRINT A(5); UBOUND(A)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["0", "95"], "contents cleared, bound updated"); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["0", "9 5"], "contents cleared, bound updated"); } /// REDIM PRESERVE keeps the existing elements and zeroes the new tail. @@ -147,8 +147,8 @@ fn test_redim_preserve() { "DIM A(2)\nA(0) = 7\nA(1) = 8\nREDIM PRESERVE A(5)\nPRINT A(0); A(1); A(5)\nPRINT UBOUND(A)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["780", "5"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["7 8 0", "5"]); } /// Growing an array a step at a time, which is what PRESERVE is for. @@ -158,7 +158,7 @@ fn test_redim_preserve_in_loop() { "DIM A(1)\nFOR I = 1 TO 3\nREDIM PRESERVE A(I)\nA(I) = I * 10\nNEXT I\nPRINT A(1); A(2); A(3)\n", ) .unwrap(); - assert_eq!(output.trim(), "102030"); + assert_eq!(output.trim(), "10 20 30"); } /// String arrays survive PRESERVE too. @@ -168,14 +168,14 @@ fn test_redim_preserve_strings() { "DIM S$(1)\nS$(0) = \"keep\"\nREDIM PRESERVE S$(3)\nPRINT S$(0); LEN(S$(0))\n", ) .unwrap(); - assert_eq!(output.trim(), "keep4"); + assert_eq!(output.trim(), "keep 4"); } /// LBOUND and UBOUND take an array *name*, so a string array is fine. #[test] fn test_bounds_of_string_array() { let output = compile_and_run("DIM N$(5)\nPRINT LBOUND(N$); UBOUND(N$)\n").unwrap(); - assert_eq!(output.trim(), "05"); + assert_eq!(output.trim(), "0 5"); } /// The dimension may be computed, not only written as a literal. @@ -187,7 +187,7 @@ fn test_bounds_with_computed_dimension() { "DIM A(2,5)\nCONST D = 2\nK = 2\nPRINT UBOUND(A, K); UBOUND(A, D); UBOUND(A, 1)\n", ) .unwrap(); - assert_eq!(output.trim(), "552"); + assert_eq!(output.trim(), "5 5 2"); } /// An array behaves the same wherever its DIM is written. @@ -211,7 +211,7 @@ PRINT A(1) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["7", "7"], "read before and after the DIM agree"); } @@ -230,7 +230,7 @@ PRINT Q "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "5", "the local array works"); assert_eq!(lines[1], "0", "Q at module level is an untouched scalar"); } diff --git a/tests/codegen/mod.rs b/tests/codegen/mod.rs index b2bbb99..9e54c41 100644 --- a/tests/codegen/mod.rs +++ b/tests/codegen/mod.rs @@ -479,10 +479,16 @@ PRINT I%(3), L&(3), S!(3), D#(3), T$(3) PRINT I%(0), L&(4), D#(1) "; let out = crate::common::compile_and_run(src).expect("the program must run"); - let got: Vec<&str> = out.lines().map(str::trim).collect(); + // Split on the print zones rather than pinning their width: what this + // test is about is that every element type round-trips, and + // `test_comma_print_zones` owns the layout. + let got: Vec> = crate::common::lines(&out) + .iter() + .map(|l| l.split_whitespace().collect()) + .collect(); assert_eq!(got.len(), 2); - assert!(got[0].starts_with("6\t3000\t3\t1.5\tv"), "got {:?}", got[0]); - assert_eq!(got[1], "0\t4000\t0.5"); + assert_eq!(got[0], ["6", "3000", "3", "1.5", "v"]); + assert_eq!(got[1], ["0", "4000", ".5"]); } /// Bounds checking still catches a subscript past the end. @@ -652,7 +658,7 @@ PRINT I "; asserts_absent(src, &["xmm4"]); let out = crate::common::compile_and_run(src).expect("the program must run"); - let lines: Vec<&str> = out.trim().lines().collect(); + let lines = crate::common::lines(&out); assert_eq!(lines[0], "4", "the body ran four times"); assert_eq!(lines[1], "13", "the body's own step took effect"); } @@ -665,7 +671,7 @@ fn test_exit_for_writes_a_promoted_counter_back() { "N# = 0\nFOR I = 1 TO 100\nN# = N# + 1\nIF I = 4 THEN EXIT FOR\nNEXT I\nPRINT N#\nPRINT I\n", ) .expect("the program must run"); - let lines: Vec<&str> = out.trim().lines().collect(); + let lines = crate::common::lines(&out); assert_eq!(lines[0], "4", "the body ran four times"); assert_eq!(lines[1], "4", "the counter survived EXIT FOR"); } @@ -873,7 +879,7 @@ PRINT D# assert!(!body.contains(name), "{} should be in a register", name); } let out = crate::common::compile_and_run(src).expect("must run"); - let got: Vec<&str> = out.trim().lines().collect(); + let got = crate::common::lines(&out); assert_eq!(got, vec!["4", "8", "12", "16"], "all four still accumulate"); } @@ -940,7 +946,7 @@ fn test_promoted_accumulator_does_not_change_the_limit() { "N = 3\nK# = 0\nFOR I = 1 TO N\nN = 100\nK# = K# + 1\nNEXT I\nPRINT K#\nPRINT N\n", ) .expect("must run"); - let got: Vec<&str> = out.trim().lines().collect(); + let got = crate::common::lines(&out); assert_eq!(got, vec!["3", "100"]); } @@ -1041,7 +1047,7 @@ PRINT T# ", ) .expect("must run"); - let got: Vec<&str> = out.trim().lines().collect(); + let got = crate::common::lines(&out); assert_eq!(got, vec!["9", "23", "90"]); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 71beca6..2fe0b31 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -28,9 +28,15 @@ pub struct RunOutput { } impl RunOutput { - /// Lines of stdout, trimmed, for the common line-by-line assertion style. + /// Lines of stdout, each trimmed, for the common line-by-line style. + /// + /// Per line rather than once over the whole string, because a number + /// carries GW-BASIC's spacing: a blank where the sign would go and a blank + /// after, so `PRINT 42` writes " 42 ". A test asserting what a program + /// *computed* should not have to spell those out; the ones that assert on + /// the spacing itself read `stdout` directly, and say so. pub fn lines(&self) -> Vec<&str> { - self.stdout.trim().lines().collect() + lines(&self.stdout) } /// Panic unless the program ran to completion. @@ -57,6 +63,14 @@ impl RunOutput { } } +/// Lines of `text`, each trimmed, ignoring blank ones at the ends. +/// +/// The counterpart of [`RunOutput::lines`] for the helpers that return a bare +/// String. See there for why the trim is per line. +pub fn lines(text: &str) -> Vec<&str> { + text.trim().lines().map(str::trim).collect() +} + /// A failure of the compiler itself (lexer, parser, sema, codegen, assembler, linker). #[derive(Debug, Clone)] pub struct CompileError { diff --git a/tests/control/mod.rs b/tests/control/mod.rs index 1a1ff43..481fbf0 100644 --- a/tests/control/mod.rs +++ b/tests/control/mod.rs @@ -16,7 +16,7 @@ FOR I = 3 TO 1 STEP -1: PRINT I: NEXT I "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(&lines[0..3], &["1", "2", "3"], "for basic"); assert_eq!(&lines[3..7], &["0", "2", "4", "6"], "for step+"); assert_eq!(&lines[7..10], &["3", "2", "1"], "for step-"); @@ -40,7 +40,7 @@ FOR D# = 1 TO 3: PRINT D#: NEXT D# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(&lines[0..3], &["1", "2", "3"], "INTEGER counter"); assert_eq!(&lines[3..6], &["1", "2", "3"], "LONG counter"); assert_eq!(&lines[6..9], &["1", "2", "3"], "SINGLE counter"); @@ -60,7 +60,7 @@ FOR F = 5 TO 7: PRINT F: NEXT F "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(&lines[0..3], &["1", "2", "3"], "AS INTEGER counter"); assert_eq!(&lines[3..6], &["5", "6", "7"], "AS LONG counter"); } @@ -79,11 +79,11 @@ FOR I! = 0 TO 1 STEP 0.5: PRINT I!: NEXT I! "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(&lines[0..4], &["0", "2", "4", "6"], "INTEGER step +2"); assert_eq!(&lines[4..7], &["3", "2", "1"], "INTEGER step -1"); assert_eq!(&lines[7..9], &["1", "4"], "INTEGER computed bounds"); - assert_eq!(&lines[9..12], &["0", "0.5", "1"], "SINGLE fractional step"); + assert_eq!(&lines[9..12], &["0", ".5", "1"], "SINGLE fractional step"); } /// A step that is only known at run time still picks the right direction. @@ -107,10 +107,10 @@ PRINT "done" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(&lines[0..4], &["0", "2", "4", "6"], "runtime step +2"); assert_eq!(&lines[4..7], &["3", "2", "1"], "runtime step -1"); - assert_eq!(&lines[7..10], &["0", "0.5", "1"], "runtime DOUBLE step"); + assert_eq!(&lines[7..10], &["0", ".5", "1"], "runtime DOUBLE step"); assert_eq!(lines[10], "done", "a backwards step ran zero times"); } @@ -129,7 +129,7 @@ PRINT J% "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "4", "value after a completed loop"); assert_eq!(lines[1], "3", "value after EXIT FOR"); } @@ -172,7 +172,7 @@ PRINT J% "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "60", "(1+2+3) * (1+2+3+4)"); assert_eq!(lines[1], "4", "outer counter after the loop"); assert_eq!(lines[2], "5", "inner counter after the loop"); @@ -190,7 +190,7 @@ WEND "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["1", "2", "3"]); } @@ -217,7 +217,7 @@ LOOP WHILE X <= 3 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(&lines[0..3], &["1", "2", "3"], "do while"); assert_eq!(&lines[3..6], &["1", "2", "3"], "do until"); assert_eq!(&lines[6..9], &["1", "2", "3"], "do...loop while"); @@ -253,7 +253,7 @@ END IF "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "big", "if true"); assert_eq!(lines[1], "small", "if false"); assert_eq!(lines[2], "two", "elseif"); @@ -285,7 +285,7 @@ fn test_goto_gosub() { "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "A", "before goto"); assert_eq!(lines[1], "C", "after goto"); assert_eq!(lines[2], "in sub", "gosub"); @@ -321,7 +321,7 @@ END SELECT "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "two", "case match"); assert_eq!(lines[1], "other", "case else"); } @@ -382,7 +382,7 @@ RETURN "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "500", "many gosub"); assert_eq!( &lines[1..7], @@ -409,7 +409,7 @@ PRINT "unreachable" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["start", "before"], "END ends the program"); } @@ -428,7 +428,7 @@ PRINT Half(8) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["start", "computing"], "STOP ends the program"); } @@ -465,7 +465,7 @@ RETURN "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["start", "in helper", "back"]); } @@ -483,7 +483,7 @@ Greet : PRINT "after" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["called", "after"]); } @@ -496,8 +496,8 @@ fn test_swap() { "A = 1\nB = 2\nSWAP A, B\nPRINT A; B\nX$ = \"x\"\nY$ = \"yy\"\nSWAP X$, Y$\nPRINT X$; Y$\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["21", "yyx"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["2 1", "yyx"]); } /// SWAP in anger: a sort that exchanges array elements. @@ -507,7 +507,7 @@ fn test_swap_array_elements() { "DIM A(4)\nA(0)=5\nA(1)=3\nA(2)=4\nA(3)=1\nA(4)=2\nFOR I = 0 TO 3\nFOR J = 0 TO 3 - I\nIF A(J) > A(J+1) THEN SWAP A(J), A(J+1)\nNEXT J\nNEXT I\nFOR I = 0 TO 4\nPRINT A(I);\nNEXT I\nPRINT \"\"\n", ) .unwrap(); - assert_eq!(output.trim(), "12345"); + assert_eq!(output.trim(), "1 2 3 4 5"); } /// CONST is folded at compile time and substituted wherever the name is used, @@ -518,7 +518,7 @@ fn test_const() { "CONST MAX = 10\nCONST HALF = MAX / 2\nCONST NAME$ = \"hi\"\nPRINT MAX\nPRINT HALF\nPRINT NAME$\nDIM A(MAX)\nA(MAX) = 7\nPRINT A(10)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["10", "5", "hi", "7"]); } @@ -532,7 +532,7 @@ fn test_swap_record_fields() { "TYPE P\nX AS INTEGER\nY AS INTEGER\nEND TYPE\nDIM A AS P\nA.X = 7\nA.Y = 3\nSWAP A.X, A.Y\nPRINT A.X; A.Y\n", ) .unwrap(); - assert_eq!(output.trim(), "37"); + assert_eq!(output.trim(), "3 7"); } /// The same for a string field, whose type comes from the field rather than @@ -556,14 +556,14 @@ fn test_swap_array_record_fields() { "TYPE P\nX AS INTEGER\nEND TYPE\nDIM A(3) AS P\nA(0).X = 1\nA(1).X = 2\nSWAP A(0).X, A(1).X\nPRINT A(0).X; A(1).X\n", ) .unwrap(); - assert_eq!(output.trim(), "21"); + assert_eq!(output.trim(), "2 1"); } /// WRITE separates values with commas and quotes strings. #[test] fn test_write() { let output = compile_and_run("WRITE \"a\", 1, \"b\"\nWRITE 1, 2.5\n").unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["\"a\",1,\"b\"", "1,2.5"]); } @@ -574,8 +574,8 @@ fn test_lbound_ubound() { "DIM A(5)\nDIM M(3,7)\nPRINT LBOUND(A); UBOUND(A)\nPRINT UBOUND(M, 1); UBOUND(M, 2)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["05", "37"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["0 5", "3 7"]); } /// EXIT leaves the innermost matching loop, or returns from a procedure. @@ -585,7 +585,7 @@ fn test_exit_statements() { "FOR I = 1 TO 10\nIF I = 3 THEN EXIT FOR\nNEXT I\nPRINT I\nJ = 0\nDO\nJ = J + 1\nIF J = 4 THEN EXIT DO\nLOOP\nPRINT J\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["3", "4"]); } @@ -596,7 +596,7 @@ fn test_exit_sub() { "SUB T(N)\nIF N = 0 THEN EXIT SUB\nPRINT N\nEND SUB\nT(0)\nT(5)\nPRINT \"done\"\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["5", "done"], "the N=0 call printed nothing"); } @@ -607,10 +607,10 @@ fn test_exit_leaves_innermost_matching_loop() { "FOR I = 1 TO 2\nFOR J = 1 TO 10\nIF J = 2 THEN EXIT FOR\nNEXT J\nPRINT I; J\nNEXT I\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, - vec!["12", "22"], + vec!["1 2", "2 2"], "inner loop exited, outer continued" ); } @@ -633,7 +633,7 @@ fn test_case_lists() { "FOR G = 1 TO 5\nSELECT CASE G\nCASE 1, 2\nPRINT \"low\";\nCASE 3, 4\nPRINT \"mid\";\nCASE ELSE\nPRINT \"hi\";\nEND SELECT\nNEXT G\nPRINT \"\"\nG = 7\nSELECT CASE G\nCASE 1, 5 TO 9, 20\nPRINT \"in\"\nCASE ELSE\nPRINT \"out\"\nEND SELECT\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["lowlowmidmidhi", "in"]); } @@ -654,7 +654,7 @@ fn test_case_on_strings() { "S$ = \"b\"\nSELECT CASE S$\nCASE \"a\"\nPRINT \"is a\"\nCASE \"b\"\nPRINT \"is b\"\nEND SELECT\nT$ = \"cat\"\nSELECT CASE T$\nCASE \"a\" TO \"m\"\nPRINT \"first half\"\nCASE ELSE\nPRINT \"second half\"\nEND SELECT\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["is b", "first half"]); } @@ -691,7 +691,7 @@ RETURN ) .unwrap(); assert_eq!( - output.trim().lines().collect::>(), + crate::common::lines(&output), vec!["one/back", "two/back", "three/back"] ); } @@ -719,7 +719,7 @@ RETURN ) .unwrap(); assert_eq!( - output.trim().lines().collect::>(), + crate::common::lines(&output), vec!["none", "none", "none", "ran"] ); } @@ -796,7 +796,7 @@ RETURN ) .unwrap(); assert_eq!( - output.trim().lines().collect::>(), + crate::common::lines(&output), vec!["two", "one"], "4/2 selects the second; 1.9 truncates to 1" ); @@ -821,10 +821,7 @@ RETURN "#, ) .unwrap(); - assert_eq!( - output.trim().lines().collect::>(), - vec!["line", "label"] - ); + assert_eq!(crate::common::lines(&output), vec!["line", "label"]); } /// A subroutine reached by ON ... GOSUB may itself GOSUB, so the two share one @@ -893,7 +890,7 @@ IF X = 1 THEN PRINT "C" : PRINT "D" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["done", "C", "D"], "the whole tail is conditional"); } @@ -911,7 +908,7 @@ NEXT I "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["two", "x"], "only the matching iteration prints"); } @@ -950,7 +947,7 @@ fn test_if_then_else_line_numbers() { "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, &["else", "done"], @@ -973,7 +970,7 @@ PRINT "end" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["c", "d", "end"], "the ELSE branch takes the tail"); } @@ -1023,8 +1020,8 @@ PRINT "done" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, &["11", "12", "21", "22", "done"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, &["1 1", "1 2", "2 1", "2 2", "done"]); } /// One `NEXT` may close several loops, innermost name first. @@ -1046,8 +1043,8 @@ PRINT T "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, &["11", "12", "21", "22", "done", "8"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, &["1 1", "1 2", "2 1", "2 2", "done", "8"]); } /// The names in a multi-loop `NEXT` are checked in order, and a name with no @@ -1083,7 +1080,7 @@ PRINT "after" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["in", "after"]); } @@ -1124,7 +1121,7 @@ PRINT A(9) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, &["7", "0", "5"], @@ -1155,7 +1152,7 @@ PRINT A(9) "#, ) .unwrap(); - assert_eq!(output.trim().lines().collect::>(), &["7", "5"]); + assert_eq!(crate::common::lines(&output), &["7", "5"]); } /// ERASE of something that is not an array is a mistake, not a no-op. diff --git a/tests/data/mod.rs b/tests/data/mod.rs index 7c5683e..791c126 100644 --- a/tests/data/mod.rs +++ b/tests/data/mod.rs @@ -22,7 +22,7 @@ PRINT D "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "60", "data read sum"); assert_eq!(lines[1], "10", "restore reads first data"); } @@ -43,8 +43,8 @@ PRINT X$; " "; Y$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines[0], "102030"); + let lines = crate::common::lines(&output); + assert_eq!(lines[0], "10 20 30"); assert_eq!(lines[1], "Hello World"); } @@ -61,9 +61,12 @@ PRINT LEN(P$); LEN(Q$); LEN(R$); LEN(S$) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "alpha/beta/gamma/"); - assert_eq!(lines[1], "5450", "lengths of alpha, beta, gamma, empty"); + assert_eq!( + lines[1], "5 4 5 0", + "lengths of alpha, beta, gamma, empty" + ); } /// RESTORE must work with string DATA too. @@ -96,7 +99,7 @@ PRINT D$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["1", "two", "3.5", "four"]); } @@ -108,7 +111,7 @@ fn test_restore_to_line() { "100 DATA 1, 2\n110 DATA 3, 4\n120 READ A\n130 READ B\n140 RESTORE 110\n150 READ C\n160 PRINT A; B; C\n", ) .unwrap(); - assert_eq!(output.trim(), "123"); + assert_eq!(output.trim(), "1 2 3"); } /// Bare RESTORE still restarts from the first DATA item. @@ -118,7 +121,7 @@ fn test_restore_to_start() { "100 DATA 1, 2\n110 DATA 3, 4\n120 READ A\n130 READ B\n140 RESTORE\n150 READ C\n160 PRINT A; B; C\n", ) .unwrap(); - assert_eq!(output.trim(), "121"); + assert_eq!(output.trim(), "1 2 1"); } /// RESTORE also accepts a named label. @@ -128,7 +131,7 @@ fn test_restore_to_label() { "DATA 1, 2\nLater:\nDATA 3, 4\nREAD A\nRESTORE Later\nREAD B\nPRINT A; B\n", ) .unwrap(); - assert_eq!(output.trim(), "13"); + assert_eq!(output.trim(), "1 3"); } /// DATA items need quotes only when they contain a comma, a colon, or @@ -151,7 +154,7 @@ PRINT C$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["hello", "World", "MiXeD"], "case is preserved"); } @@ -170,7 +173,7 @@ PRINT D$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["[spaced]", "[ kept ]", "a,b", "c:d"], @@ -194,7 +197,7 @@ PRINT "["; D$; "]["; E$; "]" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["1", "0", "3", "[][x]"]); } @@ -210,7 +213,7 @@ PRINT A + B "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["after", "3"]); } @@ -235,7 +238,7 @@ PRINT D$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["42", "3.5", "-7", "text"], @@ -256,6 +259,6 @@ PRINT C "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["12", "3.5", "0"]); } diff --git a/tests/errors/mod.rs b/tests/errors/mod.rs index f1085df..014bdd0 100644 --- a/tests/errors/mod.rs +++ b/tests/errors/mod.rs @@ -1005,8 +1005,8 @@ fn test_unsafe_flag_still_produces_correct_programs() { let checked = compile_and_run_raw(source, "").unwrap(); let unchecked = compile_and_run_flags(source, "", &["--unsafe"]).unwrap(); - let expected: Vec<&str> = vec!["925", "3"]; - assert_eq!(checked.stdout.trim().lines().collect::>(), expected); + let expected: Vec<&str> = vec!["9 25", "3"]; + assert_eq!(checked.lines(), expected); assert_eq!( unchecked.stdout.trim(), checked.stdout.trim(), @@ -1773,7 +1773,7 @@ PRINT ASC("A") "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["abc", "", "abc", "ef", "cdef", "cd", "", "65"]); } @@ -1806,8 +1806,8 @@ PRINT 0 ^ 0 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, &["-8", "4", "2", "0.25", "1"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, &["-8", "4", "2", ".25", "1"]); } /// CHR$, SPACE$ and STRING$ refuse counts and codes they cannot represent. @@ -1849,7 +1849,7 @@ PRINT HEX$(255) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, &["0", "255", "65", "[]", "[ ]", "[]", "[xxx]", "FF"] @@ -1894,10 +1894,7 @@ fn test_on_error_traps_and_reports() { "#, ) .unwrap(); - assert_eq!( - out.trim().lines().collect::>(), - &["before", "handled1130"] - ); + assert_eq!(crate::common::lines(&out), &["before", "handled 11 30"]); } /// An error raised from deep inside a runtime helper is trapped too. @@ -1918,7 +1915,7 @@ fn test_on_error_traps_an_error_raised_inside_a_helper() { "#, ) .unwrap(); - assert_eq!(out.trim(), "trapped53"); + assert_eq!(out.trim(), "trapped 53"); } /// `ERROR n` is trapped like any other error, which is how a program tests its @@ -1981,7 +1978,11 @@ fn test_state_survives_the_unwind() { "#, ) .unwrap(); - assert_eq!(out.trim(), "T=15I=6", "the loop's own variables are intact"); + assert_eq!( + out.trim(), + "T= 15 I= 6", + "the loop's own variables are intact" + ); } /// An error inside a SUB unwinds to the module-level handler. @@ -2001,7 +2002,7 @@ END SUB "#, ) .unwrap(); - assert_eq!(out.trim(), "trapped11"); + assert_eq!(out.trim(), "trapped 11"); } /// A program that never traps carries none of the machinery. @@ -2076,7 +2077,7 @@ fn test_gosub_survives_a_trap() { ) .unwrap(); assert_eq!( - out.trim().lines().collect::>(), + crate::common::lines(&out), &["in sub", "trapped", "back"], "the handler's RETURN goes back to the statement after the GOSUB" ); @@ -2147,7 +2148,7 @@ fn test_resume_retries_the_failing_statement() { // GW-BASIC does not, and numbers carry no padding. assert_eq!( out.trim(), - "10.5", + "1 .5", "N stayed 1 and the division then worked" ); } @@ -2171,7 +2172,7 @@ fn test_resume_next_is_exact_mid_line() { "#, ) .unwrap(); - assert_eq!(out.trim(), "103", "N=1, B=0, C=3"); + assert_eq!(out.trim(), "1 0 3", "N=1, B=0, C=3"); } /// `RESUME NEXT` from the last statement of a FOR body continues the loop. @@ -2196,7 +2197,7 @@ fn test_resume_next_continues_a_loop() { .unwrap(); assert_eq!( out.trim(), - "34", + "3 4", "all three iterations ran; falling out would give 2" ); } @@ -2235,7 +2236,7 @@ fn test_resume_retry_loop() { "#, ) .unwrap(); - assert_eq!(out.trim(), "took1tries, got25"); + assert_eq!(out.trim(), "took 1 tries, got 25"); } /// `RESUME` with no error active is refused at run time, and is not itself @@ -2341,7 +2342,7 @@ fn test_resume_next_from_a_deeply_nested_statement() { .unwrap(); // Two passes of the loop; on I=2 the divide fails and RESUME NEXT lands on // `T = T + 10`, then the loop and the WHILE both carry on normally. - assert_eq!(out.trim(), "2262", "T=22, V=6, W=2"); + assert_eq!(out.trim(), "22 6 2", "T=22, V=6, W=2"); } /// `RESUME NEXT` from the last statement of the program just ends it. diff --git a/tests/file_io/mod.rs b/tests/file_io/mod.rs index 0875d18..d8bac42 100644 --- a/tests/file_io/mod.rs +++ b/tests/file_io/mod.rs @@ -23,7 +23,7 @@ PRINT "done" if file_path.exists() { let file_contents = fs::read_to_string(&file_path).unwrap(); let lines: Vec<&str> = file_contents.lines().collect(); - assert_eq!(lines, vec!["Hello, File!", "42"]); + assert_eq!(lines, vec!["Hello, File!", " 42 "]); } } @@ -144,18 +144,19 @@ PRINT "bytes:"; LOF(1) CLOSE #1 "#; let (output, _tmp) = compile_and_run_with_files(source, |_| Ok(())).unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "one"); assert_eq!(lines[1], "two"); assert_eq!(lines[2], "three"); - assert_eq!(lines[3], "lines:3"); + assert_eq!(lines[3], "lines: 3"); // Text files carry the host's line terminator, so LOF counts CRLF on // Windows and LF elsewhere: 11 characters of text plus three terminators. let terminator = if cfg!(windows) { 2 } else { 1 }; assert_eq!( lines[4], - format!("bytes:{}", 11 + 3 * terminator), + // `PRINT "bytes:"; N` -- the number brings its own leading blank. + format!("bytes: {}", 11 + 3 * terminator), "3 lines plus their {} terminators", if terminator == 2 { "CRLF" } else { "LF" } ); @@ -188,7 +189,7 @@ fn test_input_file_multiple_fields() { ) .unwrap() .0; - assert_eq!(output.trim(), "10/20/30"); + assert_eq!(output.trim(), "10 / 20 / 30"); } /// A quoted field may contain the delimiter, and blanks around a field are @@ -215,7 +216,7 @@ fn test_write_file_round_trip() { ) .unwrap() .0; - assert_eq!(output.trim(), "1020[ab]"); + assert_eq!(output.trim(), "10 20 [ab]"); } /// LINE INPUT # still takes the whole line, commas and all. @@ -246,9 +247,9 @@ fn test_crlf_file_reads_without_stray_carriage_returns() { ) .unwrap() .0; - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "[one][two][three]"); - assert_eq!(lines[1], "335", "no line may keep its carriage return"); + assert_eq!(lines[1], "3 3 5", "no line may keep its carriage return"); } /// A SINGLE written to a file reads back the way the console prints it. @@ -265,7 +266,7 @@ fn test_print_file_single_matches_console() { ) .unwrap() .0; - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines[0], lines[1], "a SINGLE must render the same to a file as to the console" @@ -322,7 +323,7 @@ fn test_crlf_numeric_fields() { ) .unwrap() .0; - assert_eq!(output.trim(), "10/20/30"); + assert_eq!(output.trim(), "10 / 20 / 30"); } // --------------------------------------------------------------------------- @@ -356,7 +357,7 @@ CLOSE #1 let (output, _tmp) = compile_and_run_with_files(source, |_| Ok(())).unwrap(); assert_eq!( output.lines().collect::>(), - vec!["Alice/30/50000.5", "Bob/45/61234.25"] + vec!["Alice/ 30 / 50000.5 ", "Bob/ 45 / 61234.25 "] ); } @@ -421,7 +422,7 @@ PRINT LEN(A$) CLOSE #1 "#; let (output, _tmp) = compile_and_run_with_files(source, |_| Ok(())).unwrap(); - assert_eq!(output.lines().collect::>(), vec!["515", "5"]); + assert_eq!(output.lines().collect::>(), vec![" 5 15 ", " 5 "]); } /// GET refreshes every field variable at once, because they all point into the @@ -463,7 +464,7 @@ PRINT RTRIM$(NM$); "/"; CVI(AG$) CLOSE #1 "#; let (output, _tmp) = compile_and_run_with_files(source, |_| Ok(())).unwrap(); - assert_eq!(output.trim(), "Alicia/30"); + assert_eq!(output.trim(), "Alicia/ 30"); } /// GET and PUT without a record number walk forward one record at a time. @@ -489,7 +490,7 @@ CLOSE #1 let (output, _tmp) = compile_and_run_with_files(source, |_| Ok(())).unwrap(); assert_eq!( output.lines().collect::>(), - vec!["aa1", "bb2", "cc3"] + vec!["aa 1 ", "bb 2 ", "cc 3 "] ); } @@ -506,7 +507,12 @@ PRINT CVS(MKS$(3.5)); CVD(MKD$(2.25)) let output = crate::common::compile_and_run(source).unwrap(); assert_eq!( output.lines().collect::>(), - vec!["2448", "32767-327680", "2147483647-2147483647", "3.52.25"] + vec![ + " 2 4 4 8 ", + " 32767 -32768 0 ", + " 2147483647 -2147483647 ", + " 3.5 2.25 " + ] ); } @@ -541,7 +547,7 @@ PRINT CVD(S$); LEN(S$) CLOSE #1 "#; let (output, _tmp) = compile_and_run_with_files(source, |_| Ok(())).unwrap(); - assert_eq!(output.trim(), "08"); + assert_eq!(output.trim(), "0 8"); } /// LOCK and UNLOCK are accepted in all their forms and leave the file usable. diff --git a/tests/input/mod.rs b/tests/input/mod.rs index 7400ec4..a21e056 100644 --- a/tests/input/mod.rs +++ b/tests/input/mod.rs @@ -61,9 +61,9 @@ PRINT A(3) "77\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); // The promptless INPUT prints `? `, which lands on the first output line. - assert_eq!(lines, vec!["? 10", "text", "77"]); + assert_eq!(lines, vec!["? 10", "text", "77"]); } /// The separator after an INPUT prompt decides whether a question mark follows. @@ -127,11 +127,11 @@ fn test_input_leading_semicolon_is_accepted() { #[test] fn test_input_into_narrow_scalars() { let out = compile_and_run_with_stdin("INPUT A%\nPRINT A%\n", "5\n").unwrap(); - assert_eq!(out.trim(), "? 5"); + assert_eq!(out.trim(), "? 5"); let single = compile_and_run_with_stdin("INPUT A!\nPRINT A!\n", "6\n").unwrap(); - assert_eq!(single.trim(), "? 6"); + assert_eq!(single.trim(), "? 6"); let long = compile_and_run_with_stdin("INPUT A&\nPRINT A&\n", "70000\n").unwrap(); - assert_eq!(long.trim(), "? 70000"); + assert_eq!(long.trim(), "? 70000"); } diff --git a/tests/math/mod.rs b/tests/math/mod.rs index e527e31..b0fea03 100644 --- a/tests/math/mod.rs +++ b/tests/math/mod.rs @@ -18,7 +18,7 @@ A# = 2.25: PRINT SQR(A#) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "4", "sqr literal"); assert_eq!(lines[1], "5", "sqr integer"); assert_eq!(lines[2], "100", "sqr long"); @@ -39,7 +39,7 @@ A# = -3.14159: PRINT ABS(A#) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "42", "abs literal"); assert_eq!(lines[1], "42", "abs integer"); assert_eq!(lines[2], "100000", "abs long"); @@ -61,7 +61,7 @@ A# = -3.7: PRINT FIX(A#) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "3", "int literal"); assert_eq!(lines[1], "3", "int single"); assert_eq!(lines[2], "3", "int double"); @@ -85,7 +85,7 @@ A# = -2.5: PRINT SGN(A#) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "-1", "sgn neg"); assert_eq!(lines[1], "0", "sgn zero"); assert_eq!(lines[2], "1", "sgn pos"); @@ -112,7 +112,7 @@ A# = 0.0: PRINT INT(COS(A#) * 100) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); let values: Vec<&str> = lines[0].split_whitespace().collect(); assert_eq!(values, vec!["0", "100"], "sin/cos literals"); assert_eq!(lines[1], "0", "sin integer"); @@ -138,7 +138,7 @@ A# = 0.0: PRINT INT(ATN(A#) * 100) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); let values: Vec<&str> = lines[0].split_whitespace().collect(); assert_eq!(values, vec!["0", "0"], "tan/atn literals"); assert_eq!(lines[1], "0", "tan single"); @@ -162,7 +162,7 @@ A# = 1.0: PRINT INT(LOG(A#)) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); let values: Vec<&str> = lines[0].split_whitespace().collect(); assert_eq!(values, vec!["1", "0"], "exp/log literals"); assert_eq!(lines[1], "1", "exp integer"); @@ -186,7 +186,7 @@ IF X <> Y THEN PRINT "advances" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "rnd-ok"); assert_eq!( lines[1], "bare-rnd-ok", @@ -212,7 +212,7 @@ A! = 3.5: B# = CDBL(A!): PRINT B# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "42", "cint integer"); assert_eq!(lines[1], "12345", "cint long"); assert_eq!(lines[2], "4", "cint single"); @@ -237,8 +237,9 @@ fn test_timer_matches_the_host_clock() { let output = compile_and_run("PRINT TIMER\nPRINT TIMER(0)\n").unwrap(); let after = seconds_since_midnight_utc(); - for (i, line) in output.trim().lines().enumerate() { - // TIMER counts fractional seconds, as GW-BASIC's does. + for (i, line) in crate::common::lines(&output).iter().enumerate() { + // TIMER counts fractional seconds, as GW-BASIC's does. The line is + // trimmed first: a number carries a blank on each side. let t: f64 = line .parse() .unwrap_or_else(|e| panic!("TIMER printed {line:?}: {e}")); @@ -265,7 +266,7 @@ fn test_timer_does_not_go_backwards() { "A = TIMER\nFOR I = 1 TO 2000000\nX = X + 1\nNEXT I\nB = TIMER\nPRINT (B >= A)\nPRINT (B - A < 60)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["-1", "-1"]); } @@ -321,7 +322,7 @@ IF A = D THEN PRINT "positive-stuck" ELSE PRINT "positive-advances" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, &["zero-repeats", "zero-stable", "positive-advances"], @@ -366,7 +367,7 @@ PRINT BAD fn test_date_and_time_strings() { let output = compile_and_run("PRINT DATE$\nPRINT TIME$\nPRINT LEN(DATE$); LEN(TIME$)\n").unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); let date = lines[0]; let time = lines[1]; assert_eq!(date.len(), 10, "DATE$ is MM-DD-YYYY: {date:?}"); @@ -375,7 +376,7 @@ fn test_date_and_time_strings() { assert_eq!(time.len(), 8, "TIME$ is HH:MM:SS: {time:?}"); assert_eq!(&time[2..3], ":", "separators at 3 and 6: {time:?}"); assert_eq!(&time[5..6], ":", "separators at 3 and 6: {time:?}"); - assert_eq!(lines[2], "108", "and LEN sees them as strings: 10 and 8"); + assert_eq!(lines[2], "10 8", "and LEN sees them as strings: 10 and 8"); } /// `FRE` reports free memory. A compiled program has no BASIC heap limit, so diff --git a/tests/megatest/mega.bas b/tests/megatest/mega.bas index 07312d0..719d2a5 100644 --- a/tests/megatest/mega.bas +++ b/tests/megatest/mega.bas @@ -50,7 +50,7 @@ Check "octal bare", STR$(&377), "255" Check "binary", STR$(&B1010), "10" Check "decimal", STR$(42), "42" Check "negative", STR$(-17), "-17" -Check "leading dot", STR$(.5), "0.5" +Check "leading dot", STR$(.5), ".5" Check "exponent", STR$(1E3), "1000" Check "double exp", STR$(2.5D3), "2500" @@ -107,9 +107,9 @@ T$ = "text" Check "integer", STR$(I%), "300" Check "long", STR$(L&), "100000" Check "single", STR$(S!), "1.5" -Check "double keeps its digits", STR$(D#), "0.3333333333333333" +Check "double keeps its digits", STR$(D#), ".3333333333333333" Check "string", T$, "text" -Check "unsuffixed is double", STR$(1 / 3), "0.3333333333333333" +Check "unsuffixed is double", STR$(1 / 3), ".3333333333333333" Check "integer truncates on store", STR$(CINT(2.6)), "3" Check "widening in an expression", STR$(I% + S!), "301.5" Check "CLNG", STR$(CLNG(2.5)), "2" @@ -503,7 +503,7 @@ Check "TIMER runs", TimerOK$, "positive" P = 1 Q = 2 SWAP P, Q -Check "SWAP numbers", STR$(P) + STR$(Q), "21" +Check "SWAP numbers", STR$(P) + STR$(Q), "2 1" DIM SwapMe(2) SwapMe(0) = 10 SwapMe(1) = 20 @@ -713,8 +713,16 @@ DATA 777 ' The harness. Comparing as text keeps every expectation independent of how ' PRINT spaces a number, and a failure names itself. +' +' The sign position is stripped from both sides. STR$ puts a blank there for a +' non-negative number, as GW-BASIC does, and that blank is the business of +' tests/print rather than of every expectation written down here. SUB Check(Name$, Got$, Want$) - IF Got$ = Want$ THEN + G$ = Got$ + W$ = Want$ + IF LEFT$(G$, 1) = " " THEN G$ = MID$(G$, 2) + IF LEFT$(W$, 1) = " " THEN W$ = MID$(W$, 2) + IF G$ = W$ THEN Passed = Passed + 1 ELSE PRINT "FAIL "; Name$; " got["; Got$; "] want["; Want$; "]" diff --git a/tests/megatest/mod.rs b/tests/megatest/mod.rs index 1d2b6d5..c15dd70 100644 --- a/tests/megatest/mod.rs +++ b/tests/megatest/mod.rs @@ -36,8 +36,15 @@ fn test_megatest_passes_every_check() { failures.join("\n") ); - assert!( - output.contains("FAILED0"), + // `PRINT "FAILED"; Failed` writes "FAILED 0 ": a number carries a blank + // where its sign would go, and another after. + let failed: usize = output + .lines() + .find_map(|l| l.strip_prefix("FAILED")) + .and_then(|n| n.trim().parse().ok()) + .expect("the megatest must report its tally"); + assert_eq!( + failed, 0, "the program's own tally disagrees; output was:\n{}", output ); diff --git a/tests/print/mod.rs b/tests/print/mod.rs index 9adeb21..8459631 100644 --- a/tests/print/mod.rs +++ b/tests/print/mod.rs @@ -18,7 +18,7 @@ PRINT "C" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "Hello, World!", "string"); assert_eq!(lines[1], "42", "number"); assert_eq!(lines[2], "A", "multi-a"); @@ -36,7 +36,7 @@ fn test_using_overflow_does_not_corrupt_globals() { "A = 111\nB = 222\nC = 333\nPRINT USING \"##.##\"; 1D300\nPRINT A\nPRINT B\nPRINT C\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines.len(), 4); // Too wide for the field, so GW-BASIC's '%' marker precedes the full value. assert!(lines[0].starts_with('%'), "got {}", lines[0]); @@ -192,7 +192,7 @@ PRINT POS(0) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "1", "a fresh line starts at column 1"); assert!( lines[1].ends_with('4'), @@ -269,3 +269,108 @@ fn test_locate_sets_the_column() { "POS should follow LOCATE: {output:?}" ); } + +// --------------------------------------------------------------------------- +// GW-BASIC number formatting +// +// A number carries its own spacing: a leading blank where the sign would go if +// it is not negative, and a trailing blank always. Values below one drop the +// leading zero. Without these a listing's output runs together -- `PRINT 1;2` +// gave "12" -- and nothing lines up. + +/// Every number is written with a sign position and a trailing space. +#[test] +fn test_numbers_carry_their_own_spacing() { + let out = crate::common::compile_and_run_raw( + "PRINT 1\nPRINT -1\nPRINT 1; 2; 3\nPRINT 1; -2; 3\n", + "", + ) + .expect("should compile"); + let lines: Vec<&str> = out.stdout.lines().collect(); + assert_eq!( + lines[0], " 1 ", + "a positive number is blank-signed and blank-followed" + ); + assert_eq!(lines[1], "-1 ", "a negative one uses the sign position"); + assert_eq!( + lines[2], " 1 2 3 ", + "two blanks between, one on each side" + ); + assert_eq!( + lines[3], " 1 -2 3 ", + "the minus takes the leading blank's place" + ); +} + +/// A value below one drops the leading zero, as GW-BASIC does. +#[test] +fn test_no_leading_zero_below_one() { + let out = crate::common::compile_and_run_raw("PRINT 0.5\nPRINT -0.5\nPRINT 0.125\n", "") + .expect("should compile"); + let lines: Vec<&str> = out.stdout.lines().collect(); + assert_eq!(lines[0], " .5 "); + assert_eq!(lines[1], "-.5 "); + assert_eq!(lines[2], " .125 "); +} + +/// Exponents use GW-BASIC's letters: D for double, E for single. +#[test] +fn test_exponent_letter() { + let out = crate::common::compile_and_run_raw("PRINT 1E20\nA! = 1.5E-10\nPRINT A!\n", "") + .expect("should compile"); + let lines: Vec<&str> = out.stdout.lines().collect(); + assert!(lines[0].contains('D'), "a double uses D: {:?}", lines[0]); + assert!( + !lines[0].contains('e'), + "never C's lowercase e: {:?}", + lines[0] + ); + assert!(lines[1].contains('E'), "a single uses E: {:?}", lines[1]); +} + +/// `STR$` is what PRINT writes, without the trailing blank -- so it keeps the +/// leading one. `MID$(STR$(N), 2)` is the idiom that depends on it. +#[test] +fn test_str_dollar_keeps_the_sign_position() { + let out = crate::common::compile_and_run_raw( + "PRINT \"[\"; STR$(5); \"]\"\nPRINT \"[\"; STR$(-5); \"]\"\nPRINT \"[\"; MID$(STR$(42), 2); \"]\"\n", + "", + ) + .expect("should compile"); + let lines: Vec<&str> = out.stdout.lines().collect(); + assert_eq!(lines[0], "[ 5]"); + assert_eq!(lines[1], "[-5]"); + assert_eq!( + lines[2], "[42]", + "MID$(...,2) strips the blank, not a digit" + ); +} + +/// A comma moves to the next 14-column print zone, padding with blanks. +#[test] +fn test_comma_print_zones() { + let out = crate::common::compile_and_run_raw("PRINT 1, 2\nPRINT \"ab\", \"cd\"\n", "") + .expect("should compile"); + let lines: Vec<&str> = out.stdout.lines().collect(); + // Zone two begins at column 15, and the second number's own sign blank + // sits there -- so its digit lands at 16, as in GW-BASIC. + assert_eq!(lines[0], " 1 2 "); + assert_eq!(lines[1], "ab cd"); + assert!(!out.stdout.contains('\t'), "zones are blanks, never a tab"); +} + +/// `WRITE` is not `PRINT`: its numbers carry no padding at all. +#[test] +fn test_write_numbers_are_unpadded() { + let out = + crate::common::compile_and_run_raw("WRITE 1, -2, \"x\"\n", "").expect("should compile"); + assert_eq!(out.stdout.lines().next().unwrap(), "1,-2,\"x\""); +} + +/// `PRINT USING` owns its own layout and gains nothing. +#[test] +fn test_print_using_is_unaffected() { + let out = crate::common::compile_and_run_raw("PRINT USING \"###.##\"; 3.5\n", "") + .expect("should compile"); + assert_eq!(out.stdout.lines().next().unwrap(), " 3.50"); +} diff --git a/tests/procedures/mod.rs b/tests/procedures/mod.rs index 920c4a2..5e24a44 100644 --- a/tests/procedures/mod.rs +++ b/tests/procedures/mod.rs @@ -23,7 +23,7 @@ PrintSum(10, 20) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "42", "function"); assert_eq!(lines[1], "30", "sub with params"); } @@ -43,7 +43,7 @@ END SUB "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["Hello from sub", "done"]); } @@ -70,7 +70,7 @@ PRINT Sum10(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "28", "7 params: 1+2+3+4+5+6+7"); assert_eq!(lines[1], "36", "8 params: 1+..+8"); assert_eq!(lines[2], "55", "10 params: 1+..+10"); @@ -98,7 +98,7 @@ PRINT AddThree(Mul(2, 3), Mul(4, 5), Mul(6, 7)) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "26", "nested: 2*3 + 4*5 = 6+20"); assert_eq!(lines[1], "68", "nested three: 6+20+42"); } @@ -122,7 +122,7 @@ PRINT G "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["99", "100", "100", "101"], "shared storage"); } @@ -143,7 +143,7 @@ PRINT A(1) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["7", "8"], "array shared with procedure"); } @@ -164,7 +164,7 @@ PRINT X "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["42", "84", "1"], "parameter is local"); } @@ -185,7 +185,7 @@ Count "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["0", "1", "0", "1", "0", "1"], "fresh per call"); } @@ -204,7 +204,7 @@ PrintGreeting "Again" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["Hello, World!", "Hello, Again!"]); } @@ -227,7 +227,7 @@ B(7, "world") "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["hi", "42", "7", "world"]); } @@ -248,8 +248,8 @@ M(1, "x", 2, "y", 3, "z") "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["abc", "1x2y3z"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["abc", "1 x 2 y 3 z"]); } /// Parameters declared with a type suffix must arrive narrowed to that type. @@ -267,7 +267,7 @@ T(3, 100000, 2.5, 1.25) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["3", "100000", "2.5", "1.25"]); } @@ -289,7 +289,7 @@ PRINT "[" + Greet$("x") + "]" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["Hello, World", "abababab", "[Hello, x]"], @@ -315,7 +315,7 @@ IF Name$ = "bob" THEN PRINT "eq" ELSE PRINT "ne" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["bob", "42", "eq"]); } @@ -334,7 +334,7 @@ PRINT T$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["changed", "original"]); } @@ -346,7 +346,7 @@ fn test_def_fn() { "DEF FNA(X) = X * 2\nDEF FNSUM(A, B) = A + B\nDEF FNPI = 3.14159\nDEF FNG$(N$) = \"hi \" + N$\nPRINT FNA(5)\nPRINT FNSUM(3, 4)\nPRINT FNPI\nPRINT FNG$(\"bob\")\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["10", "7", "3.14159", "hi bob"]); } @@ -365,15 +365,15 @@ fn test_option_base_one() { "OPTION BASE 1\nDIM A(3)\nA(1) = 10\nA(3) = 30\nPRINT A(1); A(3)\nPRINT LBOUND(A); UBOUND(A)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["1030", "13"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["10 30", "1 3"]); } /// The default base is still 0. #[test] fn test_option_base_defaults_to_zero() { let output = compile_and_run("DIM A(3)\nA(0) = 5\nPRINT A(0); LBOUND(A)\n").unwrap(); - assert_eq!(output.trim(), "50"); + assert_eq!(output.trim(), "5 0"); } /// `FUNCTION f(...) AS T` must actually give the result type T. @@ -386,7 +386,7 @@ fn test_function_declared_return_type() { "FUNCTION F(X) AS INTEGER\nF = X / 2\nEND FUNCTION\nFUNCTION G(X) AS LONG\nG = X * 1000\nEND FUNCTION\nPRINT F(7)\nPRINT G(3)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["3", "3000"]); } @@ -418,8 +418,8 @@ Greet 8 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, &["n=7", "plain", "n=8"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, &["n= 7", "plain", "n= 8"]); } /// CALL is recognised only in statement position before a name, so a program diff --git a/tests/strings/mod.rs b/tests/strings/mod.rs index 7cd9ae9..5c6a177 100644 --- a/tests/strings/mod.rs +++ b/tests/strings/mod.rs @@ -31,7 +31,7 @@ PRINT G$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "hello", "the source of a plain assignment"); assert_eq!(lines[1], "Jello"); assert_eq!(lines[2], "hello", "the source of a LEFT$ slice"); @@ -66,7 +66,7 @@ PRINT K$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "hello!", "a concatenation is not aliased by E$"); assert_eq!(lines[1], "Qello!"); assert_eq!( @@ -97,7 +97,7 @@ PRINT A$(3) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "Zbabab", "the accumulator, first byte edited"); assert_eq!(lines[1], "ab"); assert_eq!(lines[2], "abab"); @@ -121,7 +121,7 @@ PRINT INSTR("Hello World", "World") "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "5", "len"); assert_eq!(lines[1], "He", "left$"); assert_eq!(lines[2], "lo", "right$"); @@ -154,7 +154,7 @@ PRINT MID$("ABCDEF", GetStart(), GetLen()) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "HELLO", "left$ with len()"); assert_eq!(lines[1], "WORLD", "right$ with len()"); assert_eq!(lines[2], "BCD", "mid$ with functions"); @@ -195,7 +195,7 @@ PRINT ("abc" >= "abc") "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["-1", "0", "0", "-1", "-1", "0", "-1", "0", "-1", "-1"], @@ -218,7 +218,7 @@ IF "ab" + "c" = "abc" THEN PRINT "concat-eq" ELSE PRINT "concat-bad" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec![ @@ -258,7 +258,7 @@ NEXT I "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["apple", "banana", "cherry", "fig", "pear"], @@ -274,7 +274,7 @@ fn test_string_builders() { "PRINT \"[\"; SPACE$(3); \"]\"\nPRINT STRING$(5, 42)\nPRINT STRING$(3, \"x\")\nPRINT LEN(SPACE$(4))\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["[ ]", "*****", "xxx", "4"]); } @@ -285,7 +285,7 @@ fn test_string_trim_and_case() { "PRINT \"[\"; LTRIM$(\" abc\"); \"]\"\nPRINT \"[\"; RTRIM$(\"abc \"); \"]\"\nPRINT UCASE$(\"Hello, World!\")\nPRINT LCASE$(\"Hello, World!\")\nA$ = \" Mixed \"\nPRINT \"[\" + LTRIM$(RTRIM$(A$)) + \"]\"\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec![ @@ -305,7 +305,7 @@ fn test_case_conversion_does_not_mutate_source() { let output = compile_and_run("A$ = \"abc\"\nB$ = UCASE$(A$)\nPRINT A$\nPRINT B$\nPRINT \"abc\"\n") .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["abc", "ABC", "abc"]); } @@ -313,7 +313,7 @@ fn test_case_conversion_does_not_mutate_source() { #[test] fn test_hex_and_oct() { let output = compile_and_run("PRINT HEX$(255)\nPRINT OCT$(15)\nPRINT HEX$(16)\n").unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["FF", "17", "10"]); } @@ -326,7 +326,7 @@ fn test_string_assignment_copies() { "A$ = \"HELLO\"\nB$ = A$\nMID$(A$,1,1) = \"J\"\nPRINT A$\nPRINT B$\nPRINT \"HELLO\"\nC$ = \"HELLO\"\nPRINT C$\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, vec!["JELLO", "HELLO", "HELLO", "HELLO"], @@ -342,7 +342,7 @@ fn test_mid_assignment() { "A$ = \"hello\"\nMID$(A$,1,1) = \"J\"\nPRINT A$\nB$ = \"hello\"\nMID$(B$,2) = \"XY\"\nPRINT B$\nC$ = \"abc\"\nMID$(C$,2) = \"ZZZZZ\"\nPRINT C$\nPRINT LEN(C$)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["Jello", "hXYlo", "aZZ", "3"]); } @@ -377,7 +377,7 @@ PRINT STR$(A) = STR$(A) let output = compile_and_run(source).unwrap(); assert_eq!( output.lines().collect::>(), - vec!["21", "AB", "AB", "1011", "0", "-1"] + vec![" 2 1", "AB", "AB", "1011", " 0 ", "-1 "] ); } @@ -398,12 +398,12 @@ PRINT STR$(X!) assert_eq!( output.lines().collect::>(), vec![ - "123456789.125", - "0.3333333333333333", - "-1", - "0.30000000000000004", + " 123456789.125", + " .3333333333333333", + "-1 ", + " .30000000000000004", // A SINGLE carries ~7 digits, and STR$ respects that as PRINT does. - "3.14159", + " 3.14159", ] ); } @@ -430,7 +430,7 @@ PRINT INSTR(2, "", "x") "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["0", "0", "3", "1", "0", "0"]); } diff --git a/tests/types/mod.rs b/tests/types/mod.rs index 651c2f2..3ce5b02 100644 --- a/tests/types/mod.rs +++ b/tests/types/mod.rs @@ -17,7 +17,7 @@ Y# = CDBL(3): PRINT Y# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "4", "cint rounds"); assert_eq!(lines[1], "4", "clng rounds"); assert_eq!(lines[2], "3", "csng"); @@ -41,7 +41,7 @@ A# = 3.7: B% = A#: PRINT B% "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "3", "cint 3.1"); assert_eq!(lines[1], "4", "cint 3.5"); assert_eq!(lines[2], "4", "cint 3.9"); @@ -63,7 +63,7 @@ A% = 7: B% = 2: PRINT A% \ B% "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "3.5", "division produces double"); assert_eq!(lines[1], "3", "integer division"); } @@ -82,7 +82,7 @@ A! = 1.5: B# = 2.5: PRINT A! + B# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "300", "int+long"); assert_eq!(lines[1], "12.5", "int+single"); assert_eq!(lines[2], "12.5", "int+double"); @@ -105,7 +105,7 @@ A! = 5.5: B# = 2.25: PRINT A! - B# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "30", "int-long"); assert_eq!(lines[1], "7.5", "int-single"); assert_eq!(lines[2], "6.75", "int-double"); @@ -128,7 +128,7 @@ A! = 2.5: B# = 4.0: PRINT A! * B# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "200", "int*long"); assert_eq!(lines[1], "10", "int*single"); assert_eq!(lines[2], "7.5", "int*double"); @@ -158,7 +158,7 @@ A! = 100.0: B# = 30.0: PRINT A! MOD B# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "3.5", "int/long"); assert_eq!(lines[1], "2.5", "int/single"); assert_eq!(lines[2], "4.5", "long/single"); @@ -189,7 +189,7 @@ A% = 10: B& = 20: C! = 0.5: D# = 100.0: PRINT A% + B& * C! + D# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "256", "int^long"); assert_eq!(lines[1], "2", "int^single"); assert_eq!(lines[2], "8", "int^double"); @@ -206,13 +206,16 @@ fn test_type_records() { "TYPE Rec\nI AS INTEGER\nL AS LONG\nS AS SINGLE\nD AS DOUBLE\nN AS STRING * 20\nEND TYPE\nDIM R AS Rec\nPRINT R.I\nR.I = 7\nR.L = 100000\nR.S = 2.5\nR.D = 1.25\nR.N = \"hello\"\nPRINT R.I; R.L; R.S; R.D\nPRINT R.N; LEN(R.N)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "0", "a record starts zeroed"); - assert_eq!(lines[1], "71000002.51.25", "each field keeps its own type"); + assert_eq!( + lines[1], "7 100000 2.5 1.25", + "each field keeps its own type" + ); // A `STRING * 20` field holds twenty characters whatever it is given, so // "hello" is space-padded and LEN is 20. This asserted "hello5" while // assignment stored the source verbatim and the declared width did nothing. - assert_eq!(lines[2], "hello 20"); + assert_eq!(lines[2], "hello 20"); } /// A TYPE may contain another TYPE, to any depth. @@ -222,7 +225,7 @@ fn test_nested_records() { "TYPE Point\nX AS INTEGER\nY AS INTEGER\nEND TYPE\nTYPE Rect\nTL AS Point\nBR AS Point\nEND TYPE\nDIM B AS Rect\nB.TL.X = 1\nB.TL.Y = 2\nB.BR.X = 9\nB.BR.Y = 8\nPRINT B.TL.X; B.TL.Y; B.BR.X; B.BR.Y\n", ) .unwrap(); - assert_eq!(output.trim(), "1298"); + assert_eq!(output.trim(), "1 2 9 8"); } /// Assigning one record to another copies it, rather than aliasing. @@ -232,8 +235,8 @@ fn test_whole_record_assignment() { "TYPE P\nX AS INTEGER\nY AS INTEGER\nEND TYPE\nDIM A AS P\nDIM B AS P\nB.X = 3\nB.Y = 4\nA = B\nPRINT A.X; A.Y\nB.X = 99\nPRINT A.X\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["34", "3"], "A kept its own copy"); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["3 4", "3"], "A kept its own copy"); } /// Arrays of records, including a nested field and a string field. @@ -243,8 +246,8 @@ fn test_arrays_of_records() { "TYPE Person\nNM AS STRING * 20\nAGE AS INTEGER\nEND TYPE\nDIM P(2) AS Person\nP(0).NM = \"Alice\"\nP(0).AGE = 30\nP(1).NM = \"Bob\"\nP(1).AGE = 25\nPRINT P(0).NM; P(0).AGE\nPRINT P(1).NM; P(1).AGE\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["Alice30", "Bob25"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["Alice 30", "Bob 25"]); } /// A record array indexed by a loop variable. @@ -254,7 +257,7 @@ fn test_record_array_in_loop() { "TYPE P\nN AS INTEGER\nEND TYPE\nDIM A(4) AS P\nFOR I = 0 TO 4\nA(I).N = I * I\nNEXT I\nFOR I = 0 TO 4\nPRINT A(I).N;\nNEXT I\nPRINT \"\"\n", ) .unwrap(); - assert_eq!(output.trim(), "014916"); + assert_eq!(output.trim(), "0 1 4 9 16"); } /// A module-level record is shared with procedures, like any other global. @@ -274,7 +277,7 @@ fn test_local_record_is_fresh_each_call() { "TYPE P\nX AS INTEGER\nEND TYPE\nSUB T\nDIM L AS P\nPRINT L.X\nL.X = 9\nEND SUB\nT\nT\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["0", "0"]); } @@ -300,7 +303,7 @@ fn test_local_records_in_sibling_procedures() { "TYPE P\nX AS INTEGER\nY AS INTEGER\nEND TYPE\nSUB A(Z AS P)\nPRINT Z.X\nEND SUB\nSUB B\nDIM Z AS P\nZ.X = 5\nZ.Y = 6\nPRINT Z.X; Z.Y\nEND SUB\nB\n", ) .unwrap(); - assert_eq!(output.trim(), "56"); + assert_eq!(output.trim(), "5 6"); } /// A record is passed to a procedure by value: the callee gets the address of @@ -311,8 +314,8 @@ fn test_record_parameters() { "TYPE P\nX AS INTEGER\nY AS INTEGER\nEND TYPE\nSUB Show(V AS P)\nPRINT V.X; V.Y\nV.X = 99\nEND SUB\nDIM A AS P\nA.X = 7\nA.Y = 8\nShow(A)\nPRINT A.X\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["78", "7"], "the caller's record is unchanged"); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["7 8", "7"], "the caller's record is unchanged"); } /// A record parameter mixed with ordinary ones, and a nested record. @@ -322,7 +325,7 @@ fn test_record_parameter_mixed_and_nested() { "TYPE Pt\nX AS INTEGER\nEND TYPE\nTYPE Bx\nTL AS Pt\nEND TYPE\nSUB T(A, V AS Bx, B)\nPRINT A; V.TL.X; B\nEND SUB\nDIM Q AS Bx\nQ.TL.X = 5\nT(1, Q, 2)\n", ) .unwrap(); - assert_eq!(output.trim(), "152"); + assert_eq!(output.trim(), "1 5 2"); } /// `AS` also gives a plain variable or parameter a declared type. @@ -332,9 +335,9 @@ fn test_as_typed_variables() { "DIM N AS INTEGER\nDIM S AS STRING * 10\nN = 42\nS = \"hi\"\nPRINT N; S\nPRINT N * 2\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); // `S` is `STRING * 10`, so "hi" is padded to ten characters. - assert_eq!(lines, vec!["42hi ", "84"]); + assert_eq!(lines, vec!["42 hi", "84"]); } /// A typed parameter, and a FUNCTION with a declared result type. @@ -344,8 +347,8 @@ fn test_as_typed_parameters_and_result() { "SUB T(N AS INTEGER, S AS STRING * 10)\nPRINT N; S\nEND SUB\nFUNCTION F AS INTEGER\nF = 42\nEND FUNCTION\nT(7, \"hi\")\nPRINT F\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["7hi", "42"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["7 hi", "42"]); } /// A record variable may be assigned from an array element, whose address is @@ -356,8 +359,8 @@ fn test_record_assignment_from_array_element() { "TYPE P\nX AS INTEGER\nY AS INTEGER\nEND TYPE\nDIM A(2) AS P\nDIM One AS P\nA(1).X = 3\nA(1).Y = 4\nOne = A(1)\nPRINT One.X; One.Y\nA(1).X = 99\nPRINT One.X\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["34", "3"], "the copy is independent"); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["3 4", "3"], "the copy is independent"); } /// A record argument may be any record lvalue, not only a plain variable. @@ -370,7 +373,7 @@ fn test_record_argument_from_array_element() { "TYPE P\nX AS INTEGER\nEND TYPE\nSUB Show(V AS P)\nPRINT V.X\nEND SUB\nDIM A(3) AS P\nA(1).X = 9\nA(2).X = 4\nShow A(1)\nShow A(2)\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["9", "4"]); } @@ -413,7 +416,7 @@ PRINT LEN(P.N) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, &[ @@ -436,7 +439,7 @@ PRINT LEN(S) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["[xy ]", "-1", "4"]); } @@ -457,7 +460,7 @@ PRINT Y "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); // Assignment to an integer truncates here; LANGREF records that as a // deliberate divergence from GW-BASIC, which rounds. assert_eq!(lines, &["3", "3"], "7/2 and 3.7 both truncate to 3"); @@ -476,7 +479,7 @@ PRINT X# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["3", "3.7"], "X is INTEGER, X# is DOUBLE"); } @@ -504,12 +507,12 @@ PRINT LEN(T) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "3", "DEFINT"); assert_eq!(lines[1], "100000", "DEFLNG"); - assert_eq!(lines[2], "0.33333334", "DEFSNG carries ~7 digits"); + assert_eq!(lines[2], ".33333334", "DEFSNG carries ~7 digits"); assert_eq!( - lines[3], "0.3333333333333333", + lines[3], ".3333333333333333", "DEFDBL carries full precision" ); assert_eq!(lines[4], "text", "DEFSTR"); @@ -530,7 +533,7 @@ PRINT A "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["5", "9"]); } @@ -551,7 +554,7 @@ PRINT A; B; C; E; F .unwrap(); assert_eq!( output.trim(), - "11.7111.7", + "1 1.7 1 1 1.7", "A, C and E are INTEGER; B and F stay DOUBLE" ); } @@ -572,7 +575,7 @@ Show 4.6 "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!( lines, &["9", "4"], @@ -592,6 +595,6 @@ PRINT MID$("hello", 2, 3) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["4", "4", "ell"]); } diff --git a/tests/variables/mod.rs b/tests/variables/mod.rs index 8fb8e6b..998554d 100644 --- a/tests/variables/mod.rs +++ b/tests/variables/mod.rs @@ -17,7 +17,7 @@ X! = 3.14159: PRINT X! "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "123", "default vars"); assert_eq!(lines[1], "32000", "integer suffix"); assert_eq!(lines[2], "100000", "long suffix"); @@ -39,7 +39,7 @@ PRINT "after" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "6", "single add"); assert_eq!(lines[1], "8.75", "single mul"); assert_eq!(lines[2], "Hello World", "string concat"); @@ -64,7 +64,7 @@ PRINT "["; X$; "]" "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["0", "0", "0", "0", "[]"], "unassigned defaults"); } } @@ -85,7 +85,7 @@ PRINT LEN(Z$) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, vec!["1", "6", "0"]); } @@ -103,10 +103,10 @@ PRINT LEN(A$); LEN(B$); LEN(C$) "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines[0], "first/second/third"); // LEN of "first", "second", "third", printed adjacently by `;` - assert_eq!(lines[1], "565"); + assert_eq!(lines[1], "5 6 5"); } /// A variable may be named after a keyword when it carries a type suffix: @@ -126,8 +126,8 @@ fn test_keyword_named_variables() { #[test] fn test_leading_dot_literals() { let output = compile_and_run("PRINT .5\nPRINT .25 + .25\nPRINT .5E1\n").unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["0.5", "0.5", "5"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec![".5", ".5", "5"]); } /// LET accepts every assignment form the bare syntax does. @@ -140,8 +140,8 @@ fn test_let_accepts_every_assignment_form() { "TYPE P\nX AS INTEGER\nEND TYPE\nDIM Q AS P\nDIM A(3)\nDIM R(3) AS P\nLET X = 5\nLET A(1) = 7\nLET Q.X = 3\nLET R(1).X = 4\nS$ = \"xxllo\"\nLET MID$(S$,1,2) = \"HE\"\nPRINT X; A(1); Q.X; R(1).X\nPRINT S$\n", ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, vec!["5734", "HEllo"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, vec!["5 7 3 4", "HEllo"]); } /// A trailing `_` continues a statement on the next line. @@ -161,7 +161,7 @@ IF X = 6 AND _ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["6", "ab", "both"]); } @@ -195,7 +195,7 @@ PRINT D# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["7", "8", "9", "10"]); } @@ -214,7 +214,7 @@ PRINT L "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["11", "12"]); } @@ -244,8 +244,8 @@ PRINT I$; J$ "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); - assert_eq!(lines, &["21", "43", "65", "87", "yx"]); + let lines = crate::common::lines(&output); + assert_eq!(lines, &["2 1", "4 3", "6 5", "8 7", "yx"]); } /// SWAP between different numeric types converts, as an assignment would. @@ -261,6 +261,6 @@ PRINT B# "#, ) .unwrap(); - let lines: Vec<&str> = output.trim().lines().collect(); + let lines = crate::common::lines(&output); assert_eq!(lines, &["2", "1"], "A% takes CINT(2.5), B# takes 1"); }