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,