diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index e4d6037..553c6a5 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -42,11 +42,13 @@ func (c *Compiler) Registry() *CommandRegistry { func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { var codeOutput []string var lastKind = preproc.Source - var scriptBuffer []string + var scriptBuffer []preproc.Line var scriptIsLibrary bool var macroBuffer []string var currentMacroName string var currentMacroParams []string + var currentMacroSourceFile string + var currentMacroStartLine int var currentAsmTarget *[]string // nil = no active ASM block, or points to target slice // Reset deferred ASM storage for this compilation @@ -74,15 +76,19 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { if lastKind == preproc.ScriptMacroDef { if currentMacroName != "" { c.ctx.ScriptMacros[currentMacroName] = &ScriptMacro{ - Name: currentMacroName, - Params: currentMacroParams, - Body: macroBuffer, + Name: currentMacroName, + Params: currentMacroParams, + Body: macroBuffer, + SourceFile: currentMacroSourceFile, + StartLine: currentMacroStartLine, } codeOutput = append(codeOutput, fmt.Sprintf("; ENDSCRIPT MACRO %s", currentMacroName)) } macroBuffer = nil currentMacroName = "" currentMacroParams = nil + currentMacroSourceFile = "" + currentMacroStartLine = 0 } // Close previous Assembler block @@ -199,12 +205,17 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { *currentAsmTarget = append(*currentAsmTarget, codePart+commentPart) } else if line.Kind == preproc.Script || line.Kind == preproc.ScriptLibrary { // Collect script lines for execution - scriptBuffer = append(scriptBuffer, line.Text) + scriptBuffer = append(scriptBuffer, line) } else if line.Kind == preproc.ScriptMacroDef { // Skip the header line (already parsed in transition) if strings.HasPrefix(strings.TrimSpace(line.Text), "SCRIPT MACRO ") { continue } + // Capture source provenance from first body line + if len(macroBuffer) == 0 { + currentMacroSourceFile = line.Filename + currentMacroStartLine = line.LineNo + } // Collect macro body lines macroBuffer = append(macroBuffer, line.Text) } diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go index f5e4276..336929e 100644 --- a/internal/compiler/compiler_test.go +++ b/internal/compiler/compiler_test.go @@ -165,7 +165,7 @@ func TestExecuteScript_BasicPrint(t *testing.T) { " print(' nop')", } - output, err := executeScript(scriptLines, ctx, false) + output, err := testExecuteScript(scriptLines, ctx, false) if err != nil { t.Fatalf("executeScript failed: %v", err) } @@ -189,7 +189,7 @@ func TestExecuteScript_EmptyOutput(t *testing.T) { "x = 1 + 1", } - output, err := executeScript(scriptLines, ctx, false) + output, err := testExecuteScript(scriptLines, ctx, false) if err != nil { t.Fatalf("executeScript failed: %v", err) } @@ -210,7 +210,7 @@ func TestExecuteScript_Library_DefinesFunction(t *testing.T) { " print(' nop')", } - _, err := executeScript(libraryLines, ctx, true) + _, err := testExecuteScript(libraryLines, ctx, true) if err != nil { t.Fatalf("library executeScript failed: %v", err) } @@ -232,7 +232,7 @@ func TestExecuteScript_Library_FunctionCallableFromScript(t *testing.T) { " print(' nop')", } - _, err := executeScript(libraryLines, ctx, true) + _, err := testExecuteScript(libraryLines, ctx, true) if err != nil { t.Fatalf("library executeScript failed: %v", err) } @@ -242,7 +242,7 @@ func TestExecuteScript_Library_FunctionCallableFromScript(t *testing.T) { "emit_nops(2)", } - output, err := executeScript(scriptLines, ctx, false) + output, err := testExecuteScript(scriptLines, ctx, false) if err != nil { t.Fatalf("script executeScript failed: %v", err) } @@ -267,7 +267,7 @@ func TestExecuteScript_MultipleLibraries_Accumulate(t *testing.T) { "def func_a():", " print(' ; from a')", } - _, err := executeScript(lib1, ctx, true) + _, err := testExecuteScript(lib1, ctx, true) if err != nil { t.Fatalf("lib1 failed: %v", err) } @@ -277,7 +277,7 @@ func TestExecuteScript_MultipleLibraries_Accumulate(t *testing.T) { "def func_b():", " print(' ; from b')", } - _, err = executeScript(lib2, ctx, true) + _, err = testExecuteScript(lib2, ctx, true) if err != nil { t.Fatalf("lib2 failed: %v", err) } @@ -295,7 +295,7 @@ func TestExecuteScript_MultipleLibraries_Accumulate(t *testing.T) { "func_a()", "func_b()", } - output, err := executeScript(scriptLines, ctx, false) + output, err := testExecuteScript(scriptLines, ctx, false) if err != nil { t.Fatalf("script failed: %v", err) } @@ -322,7 +322,7 @@ func TestExecuteScript_RegularScript_DoesNotPersist(t *testing.T) { "local_func()", } - output, err := executeScript(scriptLines, ctx, false) + output, err := testExecuteScript(scriptLines, ctx, false) if err != nil { t.Fatalf("script failed: %v", err) } @@ -377,7 +377,7 @@ func TestExecuteMacro_WithLibraryFunction(t *testing.T) { "def emit_nop():", " print(' nop')", } - _, err := executeScript(lib, ctx, true) + _, err := testExecuteScript(lib, ctx, true) if err != nil { t.Fatalf("library failed: %v", err) } @@ -540,7 +540,7 @@ func TestExecuteScript_LocalVariableExpansion(t *testing.T) { "print(' inc |counter|')", } - output, err := executeScript(scriptLines, ctx, false) + output, err := testExecuteScript(scriptLines, ctx, false) if err != nil { t.Fatalf("executeScript failed: %v", err) } @@ -569,7 +569,7 @@ func TestExecuteScript_Library_GlobalVariableExpansion(t *testing.T) { " print(' inc |global_counter|')", } - _, err := executeScript(libraryLines, ctx, true) + _, err := testExecuteScript(libraryLines, ctx, true) if err != nil { t.Fatalf("library script failed: %v", err) } @@ -579,7 +579,7 @@ func TestExecuteScript_Library_GlobalVariableExpansion(t *testing.T) { "inc_global()", } - output, err := executeScript(scriptLines, ctx, false) + output, err := testExecuteScript(scriptLines, ctx, false) if err != nil { t.Fatalf("executeScript failed: %v", err) } @@ -610,7 +610,7 @@ func TestExecuteScript_Library_VariableExpansionAtDefinitionTime(t *testing.T) { } // Library defined at global scope - |local_var| won't find caller's local - _, err := executeScript(libraryLines, ctx, true) + _, err := testExecuteScript(libraryLines, ctx, true) if err != nil { t.Fatalf("library script failed: %v", err) } @@ -625,7 +625,7 @@ func TestExecuteScript_Library_VariableExpansionAtDefinitionTime(t *testing.T) { "use_local()", } - output, err := executeScript(scriptLines, ctx, false) + output, err := testExecuteScript(scriptLines, ctx, false) if err != nil { t.Fatalf("executeScript failed: %v", err) } @@ -1219,3 +1219,16 @@ func TestAsmAfterVarsWithVariables(t *testing.T) { t.Errorf("expected ASM block in deferred section") } } + +// testExecuteScript is a test helper that wraps executeScript with convenient types +func testExecuteScript(scriptLines []string, ctx *CompilerContext, isLibrary bool) ([]string, error) { + lines := make([]preproc.Line, len(scriptLines)) + for i, text := range scriptLines { + lines[i] = preproc.Line{ + Text: text, + Filename: "test.c65", + LineNo: i + 1, + } + } + return executeScript(lines, ctx, isLibrary) +} diff --git a/internal/compiler/context.go b/internal/compiler/context.go index f9edee4..8bcf83a 100644 --- a/internal/compiler/context.go +++ b/internal/compiler/context.go @@ -8,9 +8,11 @@ import ( // ScriptMacro represents a named, parameterized script macro type ScriptMacro struct { - Name string // macro name - Params []string // parameter names - Body []string // Starlark code lines (the macro body) + Name string // macro name + Params []string // parameter names + Body []string // Starlark code lines (the macro body) + SourceFile string // source file where macro is defined + StartLine int // 1-based line number in source file of first body line } // CompilerContext holds all shared resources needed by commands during compilation diff --git a/internal/compiler/scriptexec.go b/internal/compiler/scriptexec.go index d0d848c..c9a01c4 100644 --- a/internal/compiler/scriptexec.go +++ b/internal/compiler/scriptexec.go @@ -2,29 +2,195 @@ package compiler import ( "bytes" + "errors" "fmt" + "os" "strings" + "c65gm/internal/preproc" "c65gm/internal/utils" "go.starlark.net/lib/math" "go.starlark.net/starlark" ) +// mapStarlarkLine maps a Starlark 1-based line number to an index into scriptLines +// (a slice of preproc.Line). Returns -1 if the line cannot be mapped. +// For non-library scripts, the Starlark source is: +// +// Line 1: def _main(): +// Line 2..N+1: indented script lines +// Line N+2: _main() +// +// For library scripts, lines are used as-is. +func mapStarlarkLine(starlarkLine int, numScriptLines int, isLibrary bool) int { + var idx int + if isLibrary { + idx = starlarkLine - 1 + } else { + idx = starlarkLine - 2 + } + if idx < 0 || idx >= numScriptLines { + return -1 + } + return idx +} + +// starlarkPosition extracts the source position from a Starlark error. +// Returns the 1-based line number (0 if unknown). +func starlarkPosition(err error) int { + var evalErr *starlark.EvalError + if errors.As(err, &evalErr) { + if len(evalErr.CallStack) > 0 { + top := evalErr.CallStack.At(0) + return int(top.Pos.Line) + } + } + return 0 +} + +// starlarkErrorMsg extracts just the message from a Starlark error. +func starlarkErrorMsg(err error) string { + var evalErr *starlark.EvalError + if errors.As(err, &evalErr) { + return evalErr.Msg + } + return err.Error() +} + +// printScriptErrorContext prints a Starlark error with script-block-bounded source context, +// synthesizing SCRIPT/ENDSCRIPT boundary lines that the preprocessor discards. +// When the original Starlark error has a call stack with frames beyond the script block +// (e.g. a library function), those frames are shown as a backtrace below the source context. +func printScriptErrorContext(err error, errMsg string, scriptLines []preproc.Line, errorIdx int, blockType string) { + if len(scriptLines) == 0 || errorIdx < 0 || errorIdx >= len(scriptLines) { + fmt.Fprintf(os.Stderr, "\nError: Starlark error: %s\n\n", errMsg) + return + } + + line := scriptLines[errorIdx] + filename := line.Filename + + const contextLines = 3 + + fmt.Fprintf(os.Stderr, "\nError: Starlark error: %s\n", errMsg) + fmt.Fprintf(os.Stderr, " --> %s:%d\n\n", filename, line.LineNo) + + startIdx := errorIdx - contextLines + if startIdx < 0 { + startIdx = 0 + } + endIdx := errorIdx + contextLines + if endIdx >= len(scriptLines) { + endIdx = len(scriptLines) - 1 + } + + scriptMarkerLineNo := scriptLines[0].LineNo - 1 + endScriptLineNo := scriptLines[len(scriptLines)-1].LineNo + 1 + + maxLineNo := endScriptLineNo + if scriptLines[endIdx].LineNo > maxLineNo { + maxLineNo = scriptLines[endIdx].LineNo + } + if scriptMarkerLineNo > maxLineNo { + maxLineNo = scriptMarkerLineNo + } + lineNumWidth := len(fmt.Sprintf("%d", maxLineNo)) + + if startIdx == 0 { + fmt.Fprintf(os.Stderr, " %*d | %s\n", lineNumWidth, scriptMarkerLineNo, blockType) + } + + for i := startIdx; i <= endIdx; i++ { + l := scriptLines[i] + marker := " " + if i == errorIdx { + marker = ">> " + } + fmt.Fprintf(os.Stderr, "%s%*d | %s\n", marker, lineNumWidth, l.LineNo, l.Text) + } + + if endIdx == len(scriptLines)-1 { + fmt.Fprintf(os.Stderr, " %*d | END%s\n", lineNumWidth, endScriptLineNo, blockType) + } + + // Show call stack if the error has additional frames beyond the script block + printStarlarkBacktrace(err, line.LineNo, filename) + + fmt.Fprintf(os.Stderr, "\n") +} + +// printStarlarkBacktrace prints a concise call stack from a Starlark error, +// filtering out frames from the given scriptBlockLine and /_main wrappers. +func printStarlarkBacktrace(err error, scriptBlockLine int, scriptFile string) { + var evalErr *starlark.EvalError + if !errors.As(err, &evalErr) || len(evalErr.CallStack) < 2 { + return + } + + var frames []string + for _, cf := range evalErr.CallStack { + name := cf.Name + pos := cf.Pos + // Skip frames that are already shown in the bounded source context + if name == "" || name == "_main" { + continue + } + if int(pos.Line) == scriptBlockLine && pos.Filename() == scriptFile { + continue + } + if pos.Filename() != "" && int(pos.Line) > 0 { + frames = append(frames, fmt.Sprintf(" %s: in %s", pos.String(), name)) + } + } + if len(frames) > 0 { + fmt.Fprintf(os.Stderr, "Call stack:\n%s\n", strings.Join(frames, "\n")) + } +} + +// printScriptErrorFallback prints what we can from a Starlark error when +// we couldn't map it to a specific source line within a script block. +func printScriptErrorFallback(err error) { + fmt.Fprintf(os.Stderr, "\nError: Starlark error: %s\n", err) + var evalErr *starlark.EvalError + if errors.As(err, &evalErr) && len(evalErr.CallStack) > 0 { + fmt.Fprintf(os.Stderr, "Call stack:\n") + for _, cf := range evalErr.CallStack { + name := cf.Name + pos := cf.Pos + if pos.Filename() != "" && int(pos.Line) > 0 { + fmt.Fprintf(os.Stderr, " %s: in %s\n", pos.String(), name) + } + } + } + fmt.Fprintf(os.Stderr, "\n") +} + // executeScript runs a Starlark script and returns the output lines. // If isLibrary is true, the script is executed at top level (no _main wrapper) // and resulting globals are persisted to ctx.ScriptLibraryGlobals. -func executeScript(scriptLines []string, ctx *CompilerContext, isLibrary bool) ([]string, error) { +func executeScript(scriptLines []preproc.Line, ctx *CompilerContext, isLibrary bool) ([]string, error) { + // Extract text from preproc.Lines + texts := make([]string, len(scriptLines)) + for i, l := range scriptLines { + texts[i] = l.Text + } + // Join script lines - scriptText := strings.Join(scriptLines, "\n") + scriptText := strings.Join(texts, "\n") // Expand |varname| -> actual variable names scriptText = expandVariables(scriptText, ctx) + // Determine the source filename for Starlark + sourceFile := scriptLines[0].Filename + var finalScript string + var starlarkFilename string if isLibrary { // LIBRARY: execute at top level so defs become globals finalScript = scriptText + starlarkFilename = sourceFile } else { // Regular SCRIPT: wrap in function (Starlark requires control flow inside functions) finalScript = "def _main():\n" @@ -32,6 +198,7 @@ func executeScript(scriptLines []string, ctx *CompilerContext, isLibrary bool) ( finalScript += " " + line + "\n" } finalScript += "_main()\n" + starlarkFilename = sourceFile } // Capture print output @@ -55,9 +222,25 @@ func executeScript(scriptLines []string, ctx *CompilerContext, isLibrary bool) ( } // Execute - globals, err := starlark.ExecFile(thread, "script.star", finalScript, predeclared) + globals, err := starlark.ExecFile(thread, starlarkFilename, finalScript, predeclared) if err != nil { - return nil, err + // Map Starlark error position back to source + starLine := starlarkPosition(err) + if starLine > 0 { + idx := mapStarlarkLine(starLine, len(scriptLines), isLibrary) + if idx >= 0 { + msg := starlarkErrorMsg(err) + blockType := "SCRIPT" + if isLibrary { + blockType = "SCRIPT LIBRARY" + } + printScriptErrorContext(err, msg, scriptLines, idx, blockType) + return nil, fmt.Errorf("Starlark error: %s", msg) + } + } + // Fallback: print whatever info we can extract + printScriptErrorFallback(err) + return nil, fmt.Errorf("Starlark error: %w", err) } // For LIBRARY: persist new globals (functions, variables defined at top level) @@ -130,6 +313,12 @@ func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]stri } finalScript += "_macro()\n" + // Use the source file where the macro was defined + starlarkFilename := macro.SourceFile + if starlarkFilename == "" { + starlarkFilename = "macro.star" + } + // Capture print output var output bytes.Buffer thread := &starlark.Thread{ @@ -154,9 +343,19 @@ func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]stri } // Execute - _, err := starlark.ExecFile(thread, "macro.star", finalScript, predeclared) + _, err := starlark.ExecFile(thread, starlarkFilename, finalScript, predeclared) if err != nil { - return nil, err + // Map error position back to macro definition site + starLine := starlarkPosition(err) + if starLine > 0 && macro.SourceFile != "" { + idx := mapStarlarkLine(starLine, len(macro.Body), false) // macro is always wrapped + if idx >= 0 { + sourceLine := macro.StartLine + idx + msg := starlarkErrorMsg(err) + return nil, fmt.Errorf("Starlark error: at %s:%d: %s", macro.SourceFile, sourceLine, msg) + } + } + return nil, fmt.Errorf("Starlark error: %w", err) } // Split output into lines diff --git a/internal/compiler/scriptexec_test.go b/internal/compiler/scriptexec_test.go new file mode 100644 index 0000000..1bb2104 --- /dev/null +++ b/internal/compiler/scriptexec_test.go @@ -0,0 +1,439 @@ +package compiler + +import ( + "bytes" + "fmt" + "os" + "strings" + "testing" + + "c65gm/internal/preproc" + + "go.starlark.net/lib/math" + "go.starlark.net/starlark" +) + +// --- Unit tests for mapStarlarkLine --- + +func TestMapStarlarkLine_Regular(t *testing.T) { + tests := []struct { + starlarkLine int + numLines int + isLibrary bool + want int + }{ + {2, 5, false, 0}, // first script line + {3, 5, false, 1}, // second script line + {6, 5, false, 4}, // last script line + {1, 5, false, -1}, // def _main(): line — out of range + {7, 5, false, -1}, // _main() call — out of range + {0, 5, false, -1}, // before start + {2, 1, false, 0}, // single line script + {3, 1, false, -1}, // single line, past it + } + + for _, tt := range tests { + got := mapStarlarkLine(tt.starlarkLine, tt.numLines, tt.isLibrary) + if got != tt.want { + t.Errorf("mapStarlarkLine(%d, %d, %v) = %d, want %d", + tt.starlarkLine, tt.numLines, tt.isLibrary, got, tt.want) + } + } +} + +func TestMapStarlarkLine_Library(t *testing.T) { + tests := []struct { + starlarkLine int + numLines int + want int + }{ + {1, 5, 0}, // first script line + {2, 5, 1}, // second script line + {5, 5, 4}, // last script line + {0, 5, -1}, // before start + {6, 5, -1}, // past end + {1, 1, 0}, // single line + {2, 1, -1}, // single line, past it + } + + for _, tt := range tests { + got := mapStarlarkLine(tt.starlarkLine, tt.numLines, true) + if got != tt.want { + t.Errorf("mapStarlarkLine(%d, %d, true) = %d, want %d", + tt.starlarkLine, tt.numLines, got, tt.want) + } + } +} + +// --- Tests for starlarkPosition and starlarkErrorMsg --- + +func TestStarlarkPosition_NoEvalError(t *testing.T) { + got := starlarkPosition(fmt.Errorf("plain error")) + if got != 0 { + t.Errorf("starlarkPosition(plain error) = %d, want 0", got) + } +} + +func TestStarlarkPosition_WithCallStack(t *testing.T) { + // Create an EvalError by executing Starlark code with a runtime error + thread := &starlark.Thread{} + predeclared := starlark.StringDict{"math": math.Module} + _, err := starlark.ExecFile(thread, "test.star", + `def f(): + x = 1 // 0 +f()`, + predeclared) + if err == nil { + t.Fatal("expected Starlark error") + } + + pos := starlarkPosition(err) + if pos <= 0 { + t.Errorf("starlarkPosition() = %d, want > 0 (error: %v)", pos, err) + } +} + +func TestStarlarkErrorMsg_StripsCallStack(t *testing.T) { + thread := &starlark.Thread{} + predeclared := starlark.StringDict{"math": math.Module} + _, err := starlark.ExecFile(thread, "test.star", `x = 1 // 0`, predeclared) + if err == nil { + t.Fatal("expected Starlark error") + } + + msg := starlarkErrorMsg(err) + if !strings.Contains(msg, "division by zero") { + t.Errorf("starlarkErrorMsg() = %q, want division by zero", msg) + } + // Should NOT contain traceback prefixes + if strings.Contains(msg, "Traceback") { + t.Errorf("starlarkErrorMsg() should not contain Traceback, got %q", msg) + } +} + +func TestStarlarkErrorMsg_PlainError(t *testing.T) { + msg := starlarkErrorMsg(fmt.Errorf("hello")) + if msg != "hello" { + t.Errorf("starlarkErrorMsg(plain) = %q, want 'hello'", msg) + } +} + +// --- Tests for printScriptErrorContext --- + +func TestPrintScriptErrorContext_Basic(t *testing.T) { + scriptLines := []preproc.Line{ + {Text: " for i in range(3):", Filename: "test.c65", LineNo: 7, Kind: preproc.Script}, + {Text: " print(i)", Filename: "test.c65", LineNo: 8, Kind: preproc.Script}, + {Text: " end", Filename: "test.c65", LineNo: 9, Kind: preproc.Script}, + } + + // Capture stderr + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + err := fmt.Errorf("test error") + printScriptErrorContext(err, "test error", scriptLines, 1, "SCRIPT") + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + // Should contain SCRIPT marker + if !strings.Contains(output, "SCRIPT") { + t.Errorf("output should contain SCRIPT marker, got:\n%s", output) + } + // Should contain ENDSCRIPT + if !strings.Contains(output, "ENDSCRIPT") { + t.Errorf("output should contain ENDSCRIPT, got:\n%s", output) + } + // Should contain the error line + if !strings.Contains(output, "print(i)") { + t.Errorf("output should contain the error line, got:\n%s", output) + } + // Should contain Starlark error header + if !strings.Contains(output, "Starlark error:") { + t.Errorf("output should contain 'Starlark error:', got:\n%s", output) + } + // Should contain source marker + if !strings.Contains(output, "-->") { + t.Errorf("output should contain '-->', got:\n%s", output) + } +} + +func TestPrintScriptErrorContext_EdgeCases(t *testing.T) { + tests := []struct { + name string + lines []preproc.Line + errorIdx int + blockType string + wantMarker bool + }{ + { + name: "empty lines", + lines: []preproc.Line{}, + errorIdx: 0, + blockType: "SCRIPT", + wantMarker: false, + }, + { + name: "invalid index", + lines: []preproc.Line{{Text: "x", Filename: "t", LineNo: 1}}, + errorIdx: 5, + blockType: "SCRIPT", + wantMarker: false, + }, + { + name: "first line", + lines: []preproc.Line{{Text: "x", Filename: "t", LineNo: 1}}, + errorIdx: 0, + blockType: "SCRIPT LIBRARY", + wantMarker: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printScriptErrorContext(fmt.Errorf("err"), "err", tt.lines, tt.errorIdx, tt.blockType) + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if tt.wantMarker && !strings.Contains(output, tt.blockType) { + t.Errorf("output should contain %q, got:\n%s", tt.blockType, output) + } + if !tt.wantMarker && !strings.Contains(output, "Starlark error") { + // Should at minimum print the error + t.Errorf("output should contain error even in edge case, got:\n%s", output) + } + }) + } +} + +// --- Tests for executeScript error prefix --- + +func TestExecuteScript_ErrorPrefix(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + scriptLines := []preproc.Line{ + {Text: "def f():\n return 1 // 0\nf()", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + } + + _, err := executeScript(scriptLines, ctx, false) + + w.Close() + os.Stderr = old + var buf bytes.Buffer + buf.ReadFrom(r) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "Starlark error:") { + t.Errorf("error should contain 'Starlark error:', got: %v", err) + } +} + +func TestExecuteScript_LibraryRuntimeError(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Define a library function that errors + libLines := []preproc.Line{ + {Text: "def bad():\n return 1 // 0", Filename: "test.c65", LineNo: 5, Kind: preproc.ScriptLibrary}, + } + _, err := executeScript(libLines, ctx, true) + if err != nil { + t.Fatalf("library definition should succeed: %v", err) + } + + // Capture stderr + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + // Call the library function — should error + callLines := []preproc.Line{ + {Text: "bad()", Filename: "test.c65", LineNo: 10, Kind: preproc.Script}, + } + _, err = executeScript(callLines, ctx, false) + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + stderr := buf.String() + + if err == nil { + t.Fatal("expected error from library call") + } + + if !strings.Contains(err.Error(), "Starlark error:") { + t.Errorf("error should contain 'Starlark error:', got: %v", err) + } + + // Should show bounded context for the calling SCRIPT block + if !strings.Contains(stderr, "SCRIPT") { + t.Errorf("stderr should contain SCRIPT marker, got:\n%s", stderr) + } + if !strings.Contains(stderr, "ENDSCRIPT") { + t.Errorf("stderr should contain ENDSCRIPT, got:\n%s", stderr) + } +} + +func TestExecuteScript_DirectRuntimeError(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Capture stderr + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + scriptLines := []preproc.Line{ + {Text: " print(1 // 0)", Filename: "test.c65", LineNo: 3, Kind: preproc.Script}, + } + _, err := executeScript(scriptLines, ctx, false) + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + stderr := buf.String() + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "Starlark error:") { + t.Errorf("error should contain 'Starlark error:', got: %v", err) + } + + // Should show the error source line in stderr context + if !strings.Contains(stderr, "print(1 // 0)") { + t.Errorf("stderr should contain the error line, got:\n%s", stderr) + } +} + +// --- Tests for ExecuteMacro error mapping --- + +func TestExecuteMacro_ErrorDefinitionSite(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Register a macro with source provenance + ctx.ScriptMacros["div_macro"] = &ScriptMacro{ + Name: "div_macro", + Params: []string{"a", "b"}, + Body: []string{"result = a // b"}, + SourceFile: "defs.c65", + StartLine: 12, + } + + // Capture stderr + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + _, err := ExecuteMacro("div_macro", []string{"10", "0"}, ctx) + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + + if err == nil { + t.Fatal("expected error") + } + + // Error should mention the definition site + if !strings.Contains(err.Error(), "defs.c65:12") { + t.Errorf("error should reference definition site 'defs.c65:12', got: %v", err) + } + if !strings.Contains(err.Error(), "Starlark error:") { + t.Errorf("error should contain 'Starlark error:', got: %v", err) + } +} + +func TestExecuteMacro_ErrorFallbackNoSource(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Register a macro WITHOUT source provenance (backward compatibility) + ctx.ScriptMacros["m"] = &ScriptMacro{ + Name: "m", + Params: []string{}, + Body: []string{"1 // 0"}, + } + + _, err := ExecuteMacro("m", []string{}, ctx) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "Starlark error:") { + t.Errorf("error should contain 'Starlark error:', got: %v", err) + } +} + +// --- Tests for printStarlarkBacktrace --- + +func TestPrintStarlarkBacktrace_NoFrames(t *testing.T) { + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printStarlarkBacktrace(fmt.Errorf("plain error"), 10, "test.c65") + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if output != "" { + t.Errorf("expected empty output for plain error, got: %q", output) + } +} + +func TestPrintScriptErrorFallback(t *testing.T) { + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printScriptErrorFallback(fmt.Errorf("test message")) + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "Starlark error:") { + t.Errorf("output should contain 'Starlark error:', got: %q", output) + } + if !strings.Contains(output, "test message") { + t.Errorf("output should contain the error message, got: %q", output) + } +}