From 9f24408a0b40e9eb2f6cc4e75ab78cf47ec27780 Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Fri, 12 Jun 2026 15:12:34 +0200 Subject: [PATCH] Added pragma to control number of steps in scripts and macros. Now that we support file loading some scripts might need more steps to process images or whatnot. --- internal/commands/macro.go | 8 +- internal/compiler/compiler.go | 2 +- internal/compiler/compiler_test.go | 10 +- internal/compiler/scriptexec.go | 28 +++- internal/compiler/scriptexec_test.go | 213 ++++++++++++++++++++++++++- syntax.md | 4 +- 6 files changed, 248 insertions(+), 17 deletions(-) diff --git a/internal/commands/macro.go b/internal/commands/macro.go index 19e68fd..b26f45c 100644 --- a/internal/commands/macro.go +++ b/internal/commands/macro.go @@ -11,8 +11,9 @@ import ( // MacroCommand handles macro invocations // Syntax: @macroname(arg1, arg2, ...) type MacroCommand struct { - macroName string - args []string + macroName string + args []string + pragmaSetIndex int } func (c *MacroCommand) WillHandle(line preproc.Line) bool { @@ -30,11 +31,12 @@ func (c *MacroCommand) Interpret(line preproc.Line, _ *compiler.CompilerContext) c.macroName = name c.args = args + c.pragmaSetIndex = line.PragmaSetIndex return nil } func (c *MacroCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) { - macroOutput, err := compiler.ExecuteMacro(c.macroName, c.args, ctx) + macroOutput, err := compiler.ExecuteMacro(c.macroName, c.args, ctx, c.pragmaSetIndex) if err != nil { return nil, fmt.Errorf("macro %s: %w", c.macroName, err) } diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 553c6a5..cf2e0d9 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -169,7 +169,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { return nil, fmt.Errorf("compilation failed") } - macroOutput, err := ExecuteMacro(macroName, args, c.ctx) + macroOutput, err := ExecuteMacro(macroName, args, c.ctx, line.PragmaSetIndex) if err != nil { c.printErrorWithContext(lines, i, fmt.Errorf("macro %s: %w", macroName, err)) return nil, fmt.Errorf("compilation failed") diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go index 336929e..d9496e4 100644 --- a/internal/compiler/compiler_test.go +++ b/internal/compiler/compiler_test.go @@ -352,7 +352,7 @@ func TestExecuteMacro_Basic(t *testing.T) { } // Execute macro - output, err := ExecuteMacro("test_macro", []string{"3"}, ctx) + output, err := ExecuteMacro("test_macro", []string{"3"}, ctx, 0) if err != nil { t.Fatalf("ExecuteMacro failed: %v", err) } @@ -392,7 +392,7 @@ func TestExecuteMacro_WithLibraryFunction(t *testing.T) { } // Execute macro - output, err := ExecuteMacro("nop_macro", []string{}, ctx) + output, err := ExecuteMacro("nop_macro", []string{}, ctx, 0) if err != nil { t.Fatalf("ExecuteMacro failed: %v", err) } @@ -416,7 +416,7 @@ func TestExecuteMacro_StringParameter(t *testing.T) { } // Execute with identifier (should be passed as string) - output, err := ExecuteMacro("jump_to", []string{"my_label"}, ctx) + output, err := ExecuteMacro("jump_to", []string{"my_label"}, ctx, 0) if err != nil { t.Fatalf("ExecuteMacro failed: %v", err) } @@ -451,7 +451,7 @@ func TestExecuteMacro_LocalVariableExpansion(t *testing.T) { } // Execute macro with "myvar" as argument - should expand |myvar| to testfunc_myvar - output, err := ExecuteMacro("load_var", []string{"myvar"}, ctx) + output, err := ExecuteMacro("load_var", []string{"myvar"}, ctx, 0) if err != nil { t.Fatalf("ExecuteMacro failed: %v", err) } @@ -500,7 +500,7 @@ func TestExecuteMacro_LocalVariableExpansion_MultipleVars(t *testing.T) { } // Execute macro with actual variable names as arguments - output, err := ExecuteMacro("table_lookup", []string{"scroll_color_table", "color_index", "row_color"}, ctx) + output, err := ExecuteMacro("table_lookup", []string{"scroll_color_table", "color_index", "row_color"}, ctx, 0) if err != nil { t.Fatalf("ExecuteMacro failed: %v", err) } diff --git a/internal/compiler/scriptexec.go b/internal/compiler/scriptexec.go index b943923..37db8fe 100644 --- a/internal/compiler/scriptexec.go +++ b/internal/compiler/scriptexec.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "c65gm/internal/preproc" @@ -211,8 +212,8 @@ func executeScript(scriptLines []preproc.Line, ctx *CompilerContext, isLibrary b }, } - // Set execution limit (prevent infinite loops) - thread.SetMaxExecutionSteps(1000000) // 1M steps + // Set execution limit from pragma or default (prevent infinite loops) + thread.SetMaxExecutionSteps(readScriptMaxSteps(ctx, scriptLines[0].PragmaSetIndex)) // Build predeclared: math module + library globals + file I/O builtins predeclared := starlark.StringDict{ @@ -398,8 +399,25 @@ func makeLoadText(projectRoot string) *starlark.Builtin { }) } +// readScriptMaxSteps reads the _P_SCRIPT_MAX_STEPS pragma from the given pragma set. +// Returns the configured value (must be > 0), or 1000000 as default. +func readScriptMaxSteps(ctx *CompilerContext, pragmaSetIndex int) uint64 { + const defaultSteps uint64 = 1000000 + ps := ctx.Pragma.GetPragmaSetByIndex(pragmaSetIndex) + v := ps.GetPragma("_P_SCRIPT_MAX_STEPS") + if v == "" { + return defaultSteps + } + n, err := strconv.ParseUint(v, 10, 64) + if err != nil || n == 0 { + return defaultSteps + } + return n +} + // ExecuteMacro executes a named macro with the given arguments and returns output lines -func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]string, error) { +// pragmaSetIndex is the index of the pragma set at the macro invocation call site. +func ExecuteMacro(macroName string, args []string, ctx *CompilerContext, pragmaSetIndex int) ([]string, error) { // Look up the macro macro, ok := ctx.ScriptMacros[macroName] if !ok { @@ -446,8 +464,8 @@ func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]stri }, } - // Set execution limit - thread.SetMaxExecutionSteps(1000000) + // Set execution limit from pragma at call site or default + thread.SetMaxExecutionSteps(readScriptMaxSteps(ctx, pragmaSetIndex)) // Build predeclared: math + library globals + file I/O builtins + parameter bindings predeclared := starlark.StringDict{ diff --git a/internal/compiler/scriptexec_test.go b/internal/compiler/scriptexec_test.go index 7cc9c20..8a2211b 100644 --- a/internal/compiler/scriptexec_test.go +++ b/internal/compiler/scriptexec_test.go @@ -355,7 +355,7 @@ func TestExecuteMacro_ErrorDefinitionSite(t *testing.T) { r, w, _ := os.Pipe() os.Stderr = w - _, err := ExecuteMacro("div_macro", []string{"10", "0"}, ctx) + _, err := ExecuteMacro("div_macro", []string{"10", "0"}, ctx, 0) w.Close() os.Stderr = old @@ -387,7 +387,7 @@ func TestExecuteMacro_ErrorFallbackNoSource(t *testing.T) { Body: []string{"1 // 0"}, } - _, err := ExecuteMacro("m", []string{}, ctx) + _, err := ExecuteMacro("m", []string{}, ctx, 0) if err == nil { t.Fatal("expected error") } @@ -842,3 +842,212 @@ func TestExecuteScript_LoadText_LibraryFunction(t *testing.T) { t.Errorf("output[1] = %q, want \"line1=bar\"", output[1]) } } + +// --- Tests for _P_SCRIPT_MAX_STEPS pragma --- + +func TestReadScriptMaxSteps_Default(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + // No pragma set — index 0 has empty set + got := readScriptMaxSteps(ctx, 0) + if got != 1000000 { + t.Errorf("readScriptMaxSteps() with no pragma = %d, want 1000000", got) + } +} + +func TestReadScriptMaxSteps_CustomValue(t *testing.T) { + pragma := preproc.NewPragma() + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5000000") + ctx := NewCompilerContext(pragma) + + got := readScriptMaxSteps(ctx, pragma.GetCurrentPragmaSetIndex()) + if got != 5000000 { + t.Errorf("readScriptMaxSteps() = %d, want 5000000", got) + } +} + +func TestReadScriptMaxSteps_Zero(t *testing.T) { + pragma := preproc.NewPragma() + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "0") + ctx := NewCompilerContext(pragma) + + got := readScriptMaxSteps(ctx, pragma.GetCurrentPragmaSetIndex()) + if got != 1000000 { + t.Errorf("readScriptMaxSteps(0) = %d, want 1000000 (default)", got) + } +} + +func TestReadScriptMaxSteps_Invalid(t *testing.T) { + pragma := preproc.NewPragma() + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "not-a-number") + ctx := NewCompilerContext(pragma) + + got := readScriptMaxSteps(ctx, pragma.GetCurrentPragmaSetIndex()) + if got != 1000000 { + t.Errorf("readScriptMaxSteps(invalid) = %d, want 1000000 (default)", got) + } +} + +func TestExecuteScript_MaxStepsPragma_LowLimit(t *testing.T) { + pragma := preproc.NewPragma() + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5") // very low limit + ctx := NewCompilerContext(pragma) + + // A simple loop will exceed 5 steps + scriptLines := []preproc.Line{ + {Text: " for i in range(10):", Filename: "test.c65", LineNo: 1, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()}, + {Text: " x = i", Filename: "test.c65", LineNo: 2, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()}, + } + + _, err := executeScript(scriptLines, ctx, false) + if err == nil { + t.Fatal("expected step-limit error, got nil") + } + if !strings.Contains(err.Error(), "Starlark error") { + t.Errorf("error should contain 'Starlark error', got: %v", err) + } +} + +func TestExecuteScript_MaxStepsPragma_HighLimit(t *testing.T) { + pragma := preproc.NewPragma() + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "500000") // generous limit + ctx := NewCompilerContext(pragma) + + scriptLines := []preproc.Line{ + {Text: " result = 0", Filename: "test.c65", LineNo: 1, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()}, + {Text: " for i in range(100):", Filename: "test.c65", LineNo: 2, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()}, + {Text: " result = result + i", Filename: "test.c65", LineNo: 3, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()}, + {Text: " print(str(result))", Filename: "test.c65", LineNo: 4, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed with generous limit: %v", err) + } + if len(output) < 1 || output[0] != "4950" { + t.Errorf("expected output '4950', got %v", output) + } +} + +func TestExecuteMacro_MaxStepsPragma_LowLimit(t *testing.T) { + pragma := preproc.NewPragma() + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5") // very low limit + ctx := NewCompilerContext(pragma) + + // Register a simple macro + ctx.ScriptMacros["loop_macro"] = &ScriptMacro{ + Name: "loop_macro", + Params: []string{}, + Body: []string{"for i in range(10):", " x = i"}, + SourceFile: "test.c65", + StartLine: 1, + } + + _, err := ExecuteMacro("loop_macro", []string{}, ctx, pragma.GetCurrentPragmaSetIndex()) + if err == nil { + t.Fatal("expected step-limit error, got nil") + } + if !strings.Contains(err.Error(), "Starlark error") { + t.Errorf("error should contain 'Starlark error', got: %v", err) + } +} + +// Test that two SCRIPT blocks in the same compilation get different step limits +// when a #PRAGMA _P_SCRIPT_MAX_STEPS change appears between them. +func TestExecuteScript_MaxStepsPragma_ChangesBetweenBlocks(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // First block: default limit (index 0, no pragmas) — should succeed + firstLines := []preproc.Line{ + {Text: " result = 0", Filename: "test.c65", LineNo: 1, Kind: preproc.Script, PragmaSetIndex: 0}, + {Text: " for i in range(10):", Filename: "test.c65", LineNo: 2, Kind: preproc.Script, PragmaSetIndex: 0}, + {Text: " result = result + i", Filename: "test.c65", LineNo: 3, Kind: preproc.Script, PragmaSetIndex: 0}, + {Text: " print(str(result))", Filename: "test.c65", LineNo: 4, Kind: preproc.Script, PragmaSetIndex: 0}, + } + + output, err := executeScript(firstLines, ctx, false) + if err != nil { + t.Fatalf("first script (default limit) should succeed: %v", err) + } + if len(output) < 1 || output[0] != "45" { + t.Errorf("first script output = %v, want ['45']", output) + } + + // Simulate #PRAGMA _P_SCRIPT_MAX_STEPS 5 between the blocks + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5") + lowLimitIdx := pragma.GetCurrentPragmaSetIndex() + + // Second block: low limit (index from pragma) — should fail + secondLines := []preproc.Line{ + {Text: " for i in range(10):", Filename: "test.c65", LineNo: 10, Kind: preproc.Script, PragmaSetIndex: lowLimitIdx}, + {Text: " x = i", Filename: "test.c65", LineNo: 11, Kind: preproc.Script, PragmaSetIndex: lowLimitIdx}, + } + + _, err = executeScript(secondLines, ctx, false) + if err == nil { + t.Fatal("second script (limit=5) should fail with step-limit error, got nil") + } + if !strings.Contains(err.Error(), "Starlark error") { + t.Errorf("second script error should contain 'Starlark error', got: %v", err) + } +} + +// Test that two macro invocations in the same compilation get different step limits +// when a #PRAGMA _P_SCRIPT_MAX_STEPS change appears between them. +func TestExecuteMacro_MaxStepsPragma_ChangesBetweenBlocks(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + ctx.ScriptMacros["loop_macro"] = &ScriptMacro{ + Name: "loop_macro", + Params: []string{}, + Body: []string{"result = 0", "for i in range(10):", " result = result + i", "print(str(result))"}, + SourceFile: "test.c65", + StartLine: 1, + } + + // First invocation: default limit (first pragma set) — should succeed + output, err := ExecuteMacro("loop_macro", []string{}, ctx, pragma.GetCurrentPragmaSetIndex()) + if err != nil { + t.Fatalf("first macro (default limit) should succeed: %v", err) + } + if len(output) < 1 || output[0] != "45" { + t.Errorf("first macro output = %v, want ['45']", output) + } + + // Simulate #PRAGMA _P_SCRIPT_MAX_STEPS 5 between the invocations + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5") + lowLimitIdx := pragma.GetCurrentPragmaSetIndex() + + // Second invocation: low limit — should fail + _, err = ExecuteMacro("loop_macro", []string{}, ctx, lowLimitIdx) + if err == nil { + t.Fatal("second macro (limit=5) should fail with step-limit error, got nil") + } + if !strings.Contains(err.Error(), "Starlark error") { + t.Errorf("second macro error should contain 'Starlark error', got: %v", err) + } +} + +func TestExecuteMacro_MaxStepsPragma_HighLimit(t *testing.T) { + pragma := preproc.NewPragma() + pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "500000") // generous limit + ctx := NewCompilerContext(pragma) + + ctx.ScriptMacros["sum_macro"] = &ScriptMacro{ + Name: "sum_macro", + Params: []string{}, + Body: []string{"result = 0", "for i in range(100):", " result = result + i", "print(str(result))"}, + SourceFile: "test.c65", + StartLine: 1, + } + + output, err := ExecuteMacro("sum_macro", []string{}, ctx, pragma.GetCurrentPragmaSetIndex()) + if err != nil { + t.Fatalf("ExecuteMacro failed with generous limit: %v", err) + } + if len(output) < 1 || output[0] != "4950" { + t.Errorf("expected output '4950', got %v", output) + } +} diff --git a/syntax.md b/syntax.md index 8cc9f66..9e13bd2 100644 --- a/syntax.md +++ b/syntax.md @@ -394,7 +394,9 @@ ENDSCRIPT - Output from `print()` goes directly to assembler - Can reference compiler variables using `|varname|` syntax - Math module available: `import math` -- Maximum 1 million execution steps (prevents infinite loops) +- Maximum 1 million execution steps per block (prevents infinite loops). Each `SCRIPT`, `SCRIPT LIBRARY`, and `@macro()` invocation gets its own step counter. The limit is not cumulative across the compilation. +- If a block exceeds the limit, compilation fails with: `Starlark computation cancelled: too many steps` +- Use `#PRAGMA _P_SCRIPT_MAX_STEPS ` to change the limit (e.g., `#PRAGMA _P_SCRIPT_MAX_STEPS 5000000` for more steps, or `#PRAGMA _P_SCRIPT_MAX_STEPS 100` for a tight limit during debugging). The pragma is sticky — it applies to all subsequent blocks until changed. - Executed at compile time, not runtime **File I/O Built-in Functions:**