From 08d078f9b7827ce47ca2bfd0c3f01a3d27fc520b Mon Sep 17 00:00:00 2001 From: Scott Densmore Date: Tue, 18 Aug 2026 22:24:15 -0700 Subject: [PATCH] feat(interpreter): add SPC and POS print functions SPC( and POS( are standard Microsoft 8K BASIC functions that were not reserved words, so they lexed as ordinary identifiers and became implicit array references. PRINT "A";SPC(5);"B" auto-dimensioned an array and printed A0B instead of emitting five spaces. Reserve both names, parse them as call expressions, and evaluate them in the PRINT item path alongside TAB. SPC yields a print-list directive that renders as a run of spaces; POS returns the current output column as a number, so it also works outside PRINT. A negative SPC count is a runtime error, mirroring TAB. No program in the pinned corpus uses either name as a function or a bare identifier, so reserving them changes no existing transcript. Closes #121 Co-Authored-By: Claude Opus 5 (1M context) --- docs/language-reference.md | 9 +++++++-- pkg/interpreter/evaluator.go | 30 ++++++++++++++++++++++++++---- pkg/interpreter/evaluator_test.go | 28 ++++++++++++++++++++++++++++ pkg/interpreter/lexer_test.go | 15 +++++++++++++++ pkg/interpreter/parser.go | 2 ++ pkg/interpreter/token.go | 6 ++++++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/docs/language-reference.md b/docs/language-reference.md index 86f0b35..1f28c64 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -58,7 +58,7 @@ and invalid logical operands are runtime errors. | Statement | Supported forms | | --- | --- | | Assignment | `LET A=1`, `A=1`, `A$(I)="X"` | -| Output | `PRINT`, comma print zones, semicolon suppression, adjacent items, `TAB` | +| Output | `PRINT`, comma print zones, semicolon suppression, adjacent items, `TAB`, `SPC` | | Input | `INPUT A`, `INPUT "PROMPT";A$`, multiple scalar or array targets | | Branching | `IF expression THEN line`, `IF expression THEN statement`, `GOTO` | | Subroutines | `GOSUB`, `RETURN`, computed `ON expression GOSUB` | @@ -88,6 +88,7 @@ falls through without jumping; fractional or negative selectors are errors. | `LOG(x)` | Natural logarithm | | `EXP(x)` | Natural exponential | | `RND(x)` | Random value in `[0,1)` with classic repeat/reseed behavior | +| `POS(x)` | Current output column, counting from zero; `x` is ignored | Supplying the CLI `-seed` option replaces the initial random source so a run can be reproduced. @@ -113,7 +114,11 @@ without one, the prompt is simply `? `. `PRINT` starts a new line unless its final item is followed by `;` or `,`. Commas advance to 14-column print zones. `TAB(n)` advances when `n` is to the -right of the current output column and otherwise emits no spacing. +right of the current output column and otherwise emits no spacing. `SPC(n)` +emits `n` spaces regardless of the current column, and `POS(x)` reports that +column as a number. Both truncate their argument toward zero; a negative `SPC` +count is a runtime error. `TAB` and `SPC` are print-list directives rather than +values, so using either outside `PRINT` is a type error. ## Annotated structured-source extension diff --git a/pkg/interpreter/evaluator.go b/pkg/interpreter/evaluator.go index 4cf43d4..4c6522a 100644 --- a/pkg/interpreter/evaluator.go +++ b/pkg/interpreter/evaluator.go @@ -696,11 +696,14 @@ func (e *Evaluator) evalPrintStatement(statement *PrintStmt) error { return err } text := formatValue(value) - if tab, ok := value.(TabValue); ok { + switch directive := value.(type) { + case TabValue: text = "" - if tab.Pos > e.OutputColumn { - text = strings.Repeat(" ", tab.Pos-e.OutputColumn) + if directive.Pos > e.OutputColumn { + text = strings.Repeat(" ", directive.Pos-e.OutputColumn) } + case SpcValue: + text = strings.Repeat(" ", directive.Count) } if _, err := io.WriteString(e.Out, text); err != nil { return fmt.Errorf("write output: %w", err) @@ -806,6 +809,11 @@ type TabValue struct { Pos int } +// SpcValue represents a run of spaces from SPC. +type SpcValue struct { + Count int +} + func (e *Evaluator) evalExpression(expression Expression) (any, error) { switch value := expression.(type) { case *IntegerLiteral: @@ -1086,6 +1094,20 @@ func (e *Evaluator) evalCallExpression(expression *CallExpression) (any, error) return nil, errors.New("TAB position cannot be negative") } return TabValue{Pos: int(argument)}, nil + case "SPC": + argument, err := e.singleNumberArgument(expression) + if err != nil { + return nil, err + } + if argument < 0 { + return nil, errors.New("SPC count cannot be negative") + } + return SpcValue{Count: int(argument)}, nil + case "POS": + if _, err := e.singleNumberArgument(expression); err != nil { + return nil, err + } + return float64(e.OutputColumn), nil case "SIN": argument, err := e.singleNumberArgument(expression) if err != nil { @@ -1464,7 +1486,7 @@ func formatValue(value any) string { return exact case string: return typed - case TabValue: + case TabValue, SpcValue: return "" default: return fmt.Sprint(typed) diff --git a/pkg/interpreter/evaluator_test.go b/pkg/interpreter/evaluator_test.go index aedc598..f7503c6 100644 --- a/pkg/interpreter/evaluator_test.go +++ b/pkg/interpreter/evaluator_test.go @@ -333,6 +333,31 @@ func TestEvaluatorUsesMicrosoftPrintZones(t *testing.T) { } } +func TestEvaluatorEmitsSpcSpacingAndReportsPosColumn(t *testing.T) { + t.Parallel() + + program := mustParse(t, `10 PRINT "A";SPC(5);"B" +20 PRINT "C";SPC(0);"D" +30 PRINT POS(0) +40 PRINT "EF";: PRINT POS(0) +50 PRINT "G",: PRINT POS(0) +60 PRINT TAB(4);SPC(2);POS(0) +`) + var output bytes.Buffer + if err := NewEvaluator(program, &output).Run(); err != nil { + t.Fatalf("run: %v", err) + } + want := "A" + strings.Repeat(" ", 5) + "B\n" + + "CD\n" + + "0\n" + + "EF2\n" + + "G" + strings.Repeat(" ", 13) + "14\n" + + strings.Repeat(" ", 6) + "6\n" + if got := output.String(); got != want { + t.Fatalf("output: got %q, want %q", got, want) + } +} + func TestEvaluatorNamedNextUnwindsAbandonedInnerLoops(t *testing.T) { t.Parallel() @@ -606,6 +631,9 @@ func TestEvaluatorReportsRuntimeErrors(t *testing.T) { {name: "division by zero", program: mustParse(t, "10 PRINT 1/0\n"), want: "division by zero"}, {name: "negative sleep", program: mustParse(t, "10 SLEEP -1\n"), want: "SLEEP duration cannot be negative"}, {name: "negative tab", program: mustParse(t, "10 PRINT TAB(-1)\n"), want: "TAB position cannot be negative"}, + {name: "negative spc", program: mustParse(t, "10 PRINT SPC(-1)\n"), want: "SPC count cannot be negative"}, + {name: "spc argument count", program: mustParse(t, "10 PRINT SPC(1,2)\n"), want: "SPC expects 1 argument, got 2"}, + {name: "pos argument count", program: mustParse(t, "10 PRINT POS(0,1)\n"), want: "POS expects 1 argument, got 2"}, {name: "string arithmetic", program: mustParse(t, "10 PRINT \"x\"+1\n"), want: "expected number"}, {name: "mixed comparison", program: mustParse(t, "10 PRINT \"x\"=1\n"), want: "type mismatch in comparison"}, {name: "string assignment type mismatch", program: mustParse(t, "10 A$=1\n"), want: "string variable A$ requires a string value"}, diff --git a/pkg/interpreter/lexer_test.go b/pkg/interpreter/lexer_test.go index 3177f44..f0958fe 100644 --- a/pkg/interpreter/lexer_test.go +++ b/pkg/interpreter/lexer_test.go @@ -132,6 +132,21 @@ func TestLexerRecognizesArctangent(t *testing.T) { } } +func TestLexerRecognizesSpcAndPos(t *testing.T) { + t.Parallel() + + lexer := NewLexer("10 PRINT SPC(3);POS(0)\n") + want := []TokenType{ + NUMBER, PRINT, SPC, LPAREN, NUMBER, RPAREN, SEMICOLON, POS, LPAREN, NUMBER, RPAREN, EOL, + EOF, + } + for index, wantType := range want { + if token := lexer.NextToken(); token.Type != wantType { + t.Fatalf("token %d: got %s (%q), want %s", index, token.Type, token.Literal, wantType) + } + } +} + func TestLexerRecognizesNotAndOnGosub(t *testing.T) { t.Parallel() diff --git a/pkg/interpreter/parser.go b/pkg/interpreter/parser.go index 964e2fb..cfdc418 100644 --- a/pkg/interpreter/parser.go +++ b/pkg/interpreter/parser.go @@ -68,6 +68,8 @@ func NewParser(lexer *Lexer) *Parser { parser.prefixParseFuncs[NOT] = parser.parsePrefixExpression parser.prefixParseFuncs[LPAREN] = parser.parseGroupedExpression parser.prefixParseFuncs[TAB] = parser.parseCallExpression + parser.prefixParseFuncs[SPC] = parser.parseCallExpression + parser.prefixParseFuncs[POS] = parser.parseCallExpression parser.prefixParseFuncs[SIN] = parser.parseCallExpression parser.prefixParseFuncs[COS] = parser.parseCallExpression parser.prefixParseFuncs[TAN] = parser.parseCallExpression diff --git a/pkg/interpreter/token.go b/pkg/interpreter/token.go index 0a45825..178d2ce 100644 --- a/pkg/interpreter/token.go +++ b/pkg/interpreter/token.go @@ -67,6 +67,10 @@ const ( SLEEP TokenType = "SLEEP" // TAB moves PRINT output to a target column. TAB TokenType = "TAB" + // SPC emits a run of spaces in a PRINT list. + SPC TokenType = "SPC" + // POS reports the current PRINT output column. + POS TokenType = "POS" // SIN computes a sine value. SIN TokenType = "SIN" // COS computes a cosine value. @@ -160,6 +164,8 @@ var keywords = map[string]TokenType{ "print": PRINT, "sleep": SLEEP, "tab": TAB, + "spc": SPC, + "pos": POS, "sin": SIN, "cos": COS, "tan": TAN,