Added meaningful error messages for Starlark code

This commit is contained in:
Mattias Hansson 2026-06-11 02:16:38 +02:00
parent 205ae92d0e
commit d4f1213c06
5 changed files with 693 additions and 29 deletions

View file

@ -42,11 +42,13 @@ func (c *Compiler) Registry() *CommandRegistry {
func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) {
var codeOutput []string var codeOutput []string
var lastKind = preproc.Source var lastKind = preproc.Source
var scriptBuffer []string var scriptBuffer []preproc.Line
var scriptIsLibrary bool var scriptIsLibrary bool
var macroBuffer []string var macroBuffer []string
var currentMacroName string var currentMacroName string
var currentMacroParams []string var currentMacroParams []string
var currentMacroSourceFile string
var currentMacroStartLine int
var currentAsmTarget *[]string // nil = no active ASM block, or points to target slice var currentAsmTarget *[]string // nil = no active ASM block, or points to target slice
// Reset deferred ASM storage for this compilation // Reset deferred ASM storage for this compilation
@ -77,12 +79,16 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) {
Name: currentMacroName, Name: currentMacroName,
Params: currentMacroParams, Params: currentMacroParams,
Body: macroBuffer, Body: macroBuffer,
SourceFile: currentMacroSourceFile,
StartLine: currentMacroStartLine,
} }
codeOutput = append(codeOutput, fmt.Sprintf("; ENDSCRIPT MACRO %s", currentMacroName)) codeOutput = append(codeOutput, fmt.Sprintf("; ENDSCRIPT MACRO %s", currentMacroName))
} }
macroBuffer = nil macroBuffer = nil
currentMacroName = "" currentMacroName = ""
currentMacroParams = nil currentMacroParams = nil
currentMacroSourceFile = ""
currentMacroStartLine = 0
} }
// Close previous Assembler block // Close previous Assembler block
@ -199,12 +205,17 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) {
*currentAsmTarget = append(*currentAsmTarget, codePart+commentPart) *currentAsmTarget = append(*currentAsmTarget, codePart+commentPart)
} else if line.Kind == preproc.Script || line.Kind == preproc.ScriptLibrary { } else if line.Kind == preproc.Script || line.Kind == preproc.ScriptLibrary {
// Collect script lines for execution // Collect script lines for execution
scriptBuffer = append(scriptBuffer, line.Text) scriptBuffer = append(scriptBuffer, line)
} else if line.Kind == preproc.ScriptMacroDef { } else if line.Kind == preproc.ScriptMacroDef {
// Skip the header line (already parsed in transition) // Skip the header line (already parsed in transition)
if strings.HasPrefix(strings.TrimSpace(line.Text), "SCRIPT MACRO ") { if strings.HasPrefix(strings.TrimSpace(line.Text), "SCRIPT MACRO ") {
continue continue
} }
// Capture source provenance from first body line
if len(macroBuffer) == 0 {
currentMacroSourceFile = line.Filename
currentMacroStartLine = line.LineNo
}
// Collect macro body lines // Collect macro body lines
macroBuffer = append(macroBuffer, line.Text) macroBuffer = append(macroBuffer, line.Text)
} }

View file

@ -165,7 +165,7 @@ func TestExecuteScript_BasicPrint(t *testing.T) {
" print(' nop')", " print(' nop')",
} }
output, err := executeScript(scriptLines, ctx, false) output, err := testExecuteScript(scriptLines, ctx, false)
if err != nil { if err != nil {
t.Fatalf("executeScript failed: %v", err) t.Fatalf("executeScript failed: %v", err)
} }
@ -189,7 +189,7 @@ func TestExecuteScript_EmptyOutput(t *testing.T) {
"x = 1 + 1", "x = 1 + 1",
} }
output, err := executeScript(scriptLines, ctx, false) output, err := testExecuteScript(scriptLines, ctx, false)
if err != nil { if err != nil {
t.Fatalf("executeScript failed: %v", err) t.Fatalf("executeScript failed: %v", err)
} }
@ -210,7 +210,7 @@ func TestExecuteScript_Library_DefinesFunction(t *testing.T) {
" print(' nop')", " print(' nop')",
} }
_, err := executeScript(libraryLines, ctx, true) _, err := testExecuteScript(libraryLines, ctx, true)
if err != nil { if err != nil {
t.Fatalf("library executeScript failed: %v", err) t.Fatalf("library executeScript failed: %v", err)
} }
@ -232,7 +232,7 @@ func TestExecuteScript_Library_FunctionCallableFromScript(t *testing.T) {
" print(' nop')", " print(' nop')",
} }
_, err := executeScript(libraryLines, ctx, true) _, err := testExecuteScript(libraryLines, ctx, true)
if err != nil { if err != nil {
t.Fatalf("library executeScript failed: %v", err) t.Fatalf("library executeScript failed: %v", err)
} }
@ -242,7 +242,7 @@ func TestExecuteScript_Library_FunctionCallableFromScript(t *testing.T) {
"emit_nops(2)", "emit_nops(2)",
} }
output, err := executeScript(scriptLines, ctx, false) output, err := testExecuteScript(scriptLines, ctx, false)
if err != nil { if err != nil {
t.Fatalf("script executeScript failed: %v", err) t.Fatalf("script executeScript failed: %v", err)
} }
@ -267,7 +267,7 @@ func TestExecuteScript_MultipleLibraries_Accumulate(t *testing.T) {
"def func_a():", "def func_a():",
" print(' ; from a')", " print(' ; from a')",
} }
_, err := executeScript(lib1, ctx, true) _, err := testExecuteScript(lib1, ctx, true)
if err != nil { if err != nil {
t.Fatalf("lib1 failed: %v", err) t.Fatalf("lib1 failed: %v", err)
} }
@ -277,7 +277,7 @@ func TestExecuteScript_MultipleLibraries_Accumulate(t *testing.T) {
"def func_b():", "def func_b():",
" print(' ; from b')", " print(' ; from b')",
} }
_, err = executeScript(lib2, ctx, true) _, err = testExecuteScript(lib2, ctx, true)
if err != nil { if err != nil {
t.Fatalf("lib2 failed: %v", err) t.Fatalf("lib2 failed: %v", err)
} }
@ -295,7 +295,7 @@ func TestExecuteScript_MultipleLibraries_Accumulate(t *testing.T) {
"func_a()", "func_a()",
"func_b()", "func_b()",
} }
output, err := executeScript(scriptLines, ctx, false) output, err := testExecuteScript(scriptLines, ctx, false)
if err != nil { if err != nil {
t.Fatalf("script failed: %v", err) t.Fatalf("script failed: %v", err)
} }
@ -322,7 +322,7 @@ func TestExecuteScript_RegularScript_DoesNotPersist(t *testing.T) {
"local_func()", "local_func()",
} }
output, err := executeScript(scriptLines, ctx, false) output, err := testExecuteScript(scriptLines, ctx, false)
if err != nil { if err != nil {
t.Fatalf("script failed: %v", err) t.Fatalf("script failed: %v", err)
} }
@ -377,7 +377,7 @@ func TestExecuteMacro_WithLibraryFunction(t *testing.T) {
"def emit_nop():", "def emit_nop():",
" print(' nop')", " print(' nop')",
} }
_, err := executeScript(lib, ctx, true) _, err := testExecuteScript(lib, ctx, true)
if err != nil { if err != nil {
t.Fatalf("library failed: %v", err) t.Fatalf("library failed: %v", err)
} }
@ -540,7 +540,7 @@ func TestExecuteScript_LocalVariableExpansion(t *testing.T) {
"print(' inc |counter|')", "print(' inc |counter|')",
} }
output, err := executeScript(scriptLines, ctx, false) output, err := testExecuteScript(scriptLines, ctx, false)
if err != nil { if err != nil {
t.Fatalf("executeScript failed: %v", err) t.Fatalf("executeScript failed: %v", err)
} }
@ -569,7 +569,7 @@ func TestExecuteScript_Library_GlobalVariableExpansion(t *testing.T) {
" print(' inc |global_counter|')", " print(' inc |global_counter|')",
} }
_, err := executeScript(libraryLines, ctx, true) _, err := testExecuteScript(libraryLines, ctx, true)
if err != nil { if err != nil {
t.Fatalf("library script failed: %v", err) t.Fatalf("library script failed: %v", err)
} }
@ -579,7 +579,7 @@ func TestExecuteScript_Library_GlobalVariableExpansion(t *testing.T) {
"inc_global()", "inc_global()",
} }
output, err := executeScript(scriptLines, ctx, false) output, err := testExecuteScript(scriptLines, ctx, false)
if err != nil { if err != nil {
t.Fatalf("executeScript failed: %v", err) 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 // 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 { if err != nil {
t.Fatalf("library script failed: %v", err) t.Fatalf("library script failed: %v", err)
} }
@ -625,7 +625,7 @@ func TestExecuteScript_Library_VariableExpansionAtDefinitionTime(t *testing.T) {
"use_local()", "use_local()",
} }
output, err := executeScript(scriptLines, ctx, false) output, err := testExecuteScript(scriptLines, ctx, false)
if err != nil { if err != nil {
t.Fatalf("executeScript failed: %v", err) t.Fatalf("executeScript failed: %v", err)
} }
@ -1219,3 +1219,16 @@ func TestAsmAfterVarsWithVariables(t *testing.T) {
t.Errorf("expected ASM block in deferred section") 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)
}

View file

@ -11,6 +11,8 @@ type ScriptMacro struct {
Name string // macro name Name string // macro name
Params []string // parameter names Params []string // parameter names
Body []string // Starlark code lines (the macro body) 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 // CompilerContext holds all shared resources needed by commands during compilation

View file

@ -2,29 +2,195 @@ package compiler
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"os"
"strings" "strings"
"c65gm/internal/preproc"
"c65gm/internal/utils" "c65gm/internal/utils"
"go.starlark.net/lib/math" "go.starlark.net/lib/math"
"go.starlark.net/starlark" "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 <toplevel>/_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 == "<toplevel>" || 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. // executeScript runs a Starlark script and returns the output lines.
// If isLibrary is true, the script is executed at top level (no _main wrapper) // If isLibrary is true, the script is executed at top level (no _main wrapper)
// and resulting globals are persisted to ctx.ScriptLibraryGlobals. // 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 // Join script lines
scriptText := strings.Join(scriptLines, "\n") scriptText := strings.Join(texts, "\n")
// Expand |varname| -> actual variable names // Expand |varname| -> actual variable names
scriptText = expandVariables(scriptText, ctx) scriptText = expandVariables(scriptText, ctx)
// Determine the source filename for Starlark
sourceFile := scriptLines[0].Filename
var finalScript string var finalScript string
var starlarkFilename string
if isLibrary { if isLibrary {
// LIBRARY: execute at top level so defs become globals // LIBRARY: execute at top level so defs become globals
finalScript = scriptText finalScript = scriptText
starlarkFilename = sourceFile
} else { } else {
// Regular SCRIPT: wrap in function (Starlark requires control flow inside functions) // Regular SCRIPT: wrap in function (Starlark requires control flow inside functions)
finalScript = "def _main():\n" finalScript = "def _main():\n"
@ -32,6 +198,7 @@ func executeScript(scriptLines []string, ctx *CompilerContext, isLibrary bool) (
finalScript += " " + line + "\n" finalScript += " " + line + "\n"
} }
finalScript += "_main()\n" finalScript += "_main()\n"
starlarkFilename = sourceFile
} }
// Capture print output // Capture print output
@ -55,9 +222,25 @@ func executeScript(scriptLines []string, ctx *CompilerContext, isLibrary bool) (
} }
// Execute // Execute
globals, err := starlark.ExecFile(thread, "script.star", finalScript, predeclared) globals, err := starlark.ExecFile(thread, starlarkFilename, finalScript, predeclared)
if err != nil { 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) // 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" finalScript += "_macro()\n"
// Use the source file where the macro was defined
starlarkFilename := macro.SourceFile
if starlarkFilename == "" {
starlarkFilename = "macro.star"
}
// Capture print output // Capture print output
var output bytes.Buffer var output bytes.Buffer
thread := &starlark.Thread{ thread := &starlark.Thread{
@ -154,9 +343,19 @@ func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]stri
} }
// Execute // Execute
_, err := starlark.ExecFile(thread, "macro.star", finalScript, predeclared) _, err := starlark.ExecFile(thread, starlarkFilename, finalScript, predeclared)
if err != nil { 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 // Split output into lines

View file

@ -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)
}
}