diff --git a/.github/workflows/TestingCI.yml b/.github/workflows/TestingCI.yml index 2dc4130..0c99e74 100644 --- a/.github/workflows/TestingCI.yml +++ b/.github/workflows/TestingCI.yml @@ -122,6 +122,16 @@ 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" + '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 4f26433..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: "; @@ -639,6 +652,91 @@ 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. + +### 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: @@ -952,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 | @@ -972,6 +1070,20 @@ 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. + +`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 | @@ -1312,6 +1424,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 @@ -1321,21 +1438,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: @@ -1361,8 +1495,7 @@ 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` +- **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 50a6edd..9b6ec90 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")), @@ -297,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. @@ -395,6 +396,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 { @@ -409,9 +414,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 { @@ -423,6 +438,8 @@ impl RtError { RtError::OutOfMemory => "mem", RtError::GosubOverflow => "gosub", RtError::BadFileNum => "badfile", + RtError::ResumeNoError => "resnoerr", + RtError::ResumeInProc => "resproc", } } } @@ -584,8 +601,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>, @@ -621,7 +645,18 @@ 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 + /// 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 + /// 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 { @@ -1197,7 +1232,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, @@ -1421,6 +1456,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"); @@ -1445,6 +1483,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 { @@ -1484,6 +1532,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) => { @@ -1584,10 +1633,35 @@ 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 + /// 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)) @@ -1732,6 +1806,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)) @@ -2397,7 +2481,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); } } @@ -2429,6 +2518,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(); @@ -2561,6 +2651,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()); @@ -2571,6 +2669,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 { @@ -2616,12 +2720,58 @@ 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; } + // 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; self.emit_label(&format!("_line_{}", n)); } @@ -2766,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; } @@ -2776,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 => {} @@ -3190,18 +3339,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]"); @@ -3236,10 +3379,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); } @@ -3283,10 +3423,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); } @@ -3447,6 +3584,71 @@ 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. + 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". + 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 { @@ -3640,7 +3842,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 +3873,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 { @@ -5543,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); } } @@ -5565,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 { @@ -5591,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); } } @@ -6030,7 +6239,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", @@ -6304,6 +6513,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"); @@ -6599,6 +6817,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/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 adf31ec..f5d63d2 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -237,6 +237,13 @@ 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), + /// `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 @@ -409,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), @@ -733,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", @@ -983,6 +1002,27 @@ impl Parser { matches!(self.peek_at(1), Token::Ident(_)) } + /// True if something follows the current token for it to take as an operand. + /// + /// `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::Newline | Token::Colon | Token::Eof | Token::Eq + ) + } + /// 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) @@ -1336,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(); @@ -1371,6 +1412,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_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(), @@ -2329,6 +2371,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, @@ -2650,6 +2710,33 @@ 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 + 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..c028add 100644 --- a/src/runtime/sysv/error.s +++ b/src/runtime/sysv/error.s @@ -33,16 +33,207 @@ _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" +# 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. +# +# 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 + + +# 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 +_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 + .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: +# 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) # 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 _rt_fatal + cmp QWORD PTR [rip + _err_active], 0 + jne _rt_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 + + # 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 + # 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 + +# _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/sysv/file.s b/src/runtime/sysv/file.s index 5380224..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 @@ -896,6 +986,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/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/error.s b/src/runtime/win64-native/error.s index c47ce5b..7db7f8a 100644 --- a/src/runtime/win64-native/error.s +++ b/src/runtime/win64-native/error.s @@ -35,6 +35,41 @@ _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" +# 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. +# +# 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 @@ -42,16 +77,183 @@ _err_pastend: .asciz "Input past end of file" _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 +_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. +_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): +# 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) # 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 _rt_fatal + cmp QWORD PTR [rip + _err_active], 0 + jne _rt_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 + + # 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 + # 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 + +# _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/file.s b/src/runtime/win64-native/file.s index cfd4cf6..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 @@ -944,6 +1037,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/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/src/sema.rs b/src/sema.rs index 300dd84..93a7039 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,10 +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. - ("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"), + // 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", @@ -290,6 +298,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 +384,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 +399,8 @@ 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.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) @@ -447,7 +470,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()?; @@ -552,6 +582,12 @@ 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::Resume(_) => {} StmtKind::Input { vars, .. } | StmtKind::Read(vars) => { vars.iter_mut().for_each(|v| lvalue(v, table, procs)) } @@ -726,6 +762,8 @@ 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::OnError(_) | StmtKind::Resume(_) => {} StmtKind::Const { value, .. } => f(value), StmtKind::FieldAssign { target, value } => { lvalue(target, f); @@ -818,6 +856,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, } @@ -851,11 +891,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(); @@ -1125,6 +1171,77 @@ 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 + /// 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. @@ -1629,6 +1746,87 @@ 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::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 + // 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"); + } + } + 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() { @@ -2159,6 +2357,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/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 d3ca9a2..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 { @@ -108,6 +122,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/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 671a05f..014bdd0 100644 --- a/tests/errors/mod.rs +++ b/tests/errors/mod.rs @@ -424,6 +424,217 @@ 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 + ); + } +} + +/// 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() { + // 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. +/// +/// 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() { @@ -794,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(), @@ -857,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"), @@ -887,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()); @@ -945,6 +1157,38 @@ 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\nPRINT ERROR\n", "ERROR"), + ("DEFINT A-Z\nPRINT CSRLIN\n", "CSRLIN"), + ("DEFSTR A-Z\nPRINT INKEY$\n", "INKEY$"), + ("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 @@ -1529,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"]); } @@ -1562,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. @@ -1605,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"] @@ -1626,3 +1870,492 @@ 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!(crate::common::lines(&out), &["before", "handled 11 30"]); +} + +/// 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(), "trapped 53"); +} + +/// `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= 15 I= 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(), "trapped 11"); +} + +/// 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", + "_err_stmt", + "_err_depth", + "_resume_at", + "_resume_next", + ] { + 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!( + crate::common::lines(&out), + &["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" + ); +} + +/// `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(), + "1 .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(), "1 0 3", "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(), + "3 4", + "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(), "took 1 tries, got 25"); +} + +/// `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(), "22 6 2", "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"); +} 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/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(); 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"); }