diff --git a/examples/script_library_demo/script_library_demo.c65 b/examples/script_library_demo/script_library_demo.c65 index aace2ba..3983237 100644 --- a/examples/script_library_demo/script_library_demo.c65 +++ b/examples/script_library_demo/script_library_demo.c65 @@ -1,7 +1,6 @@ //----------------------------------------------------------- -// SCRIPT LIBRARY Demo -// Demonstrates reusable Starlark functions defined in -// SCRIPT LIBRARY blocks and called from SCRIPT blocks +// SCRIPT LIBRARY and SCRIPT MACRO Demo +// Demonstrates reusable Starlark functions and macros //----------------------------------------------------------- #INCLUDE @@ -28,58 +27,75 @@ def emit_delay(cycles): # 2 cycles per nop for i in range(remainder // 2): print(" nop") -ENDSCRIPT -//----------------------------------------------------------- -// Second SCRIPT LIBRARY: Functions accumulate -//----------------------------------------------------------- -SCRIPT LIBRARY def emit_border_flash(color1, color2): print(" lda #%d" % color1) print(" sta $d020") print(" lda #%d" % color2) print(" sta $d020") - -def emit_load_store(value, addr): - print(" lda #%d" % value) - print(" sta %d" % addr) ENDSCRIPT //----------------------------------------------------------- -// Main code using library functions +// SCRIPT MACRO: Named macros with parameters +//----------------------------------------------------------- +SCRIPT MACRO delay(cycles) + emit_delay(cycles) +ENDSCRIPT + +SCRIPT MACRO nops(count) + emit_nops(count) +ENDSCRIPT + +SCRIPT MACRO border_flash(c1, c2) + emit_border_flash(c1, c2) +ENDSCRIPT + +// Macro with label parameter (passed as string) +SCRIPT MACRO set_irq(handler) + print(" lda #<%s" % handler) + print(" sta $fffe") + print(" lda #>%s" % handler) + print(" sta $ffff") +ENDSCRIPT + +//----------------------------------------------------------- +// Constants for testing expression evaluation +//----------------------------------------------------------- +BYTE CONST CYCLES_PER_LINE = 63 +BYTE CONST RED = 2 +BYTE CONST BLUE = 6 + +//----------------------------------------------------------- +// Main code demonstrating macros //----------------------------------------------------------- LABEL start - // Use emit_nops from first library - SCRIPT - print("; 5 nops from library") - emit_nops(5) - ENDSCRIPT + // Macro invocation outside ASM block + @delay(10) + @nops(3) + @border_flash(RED, BLUE) - // Use emit_delay from first library - SCRIPT - print("; 10 cycle delay") - emit_delay(10) - ENDSCRIPT + // Macro with expression argument + @delay(CYCLES_PER_LINE-20) - // Use emit_border_flash from second library - SCRIPT - print("; border flash red/blue") - emit_border_flash(2, 6) - ENDSCRIPT + // Macro with label argument + @set_irq(my_handler) - // Combine multiple library functions - SCRIPT - print("; combined: delay + flash + nops") - emit_delay(8) - emit_border_flash(0, 1) - emit_nops(3) - ENDSCRIPT - - // Use emit_load_store - SCRIPT - print("; load/store to border") - emit_load_store(5, 0xd020) - ENDSCRIPT + // Macro invocation inside ASM block + ASM + lda #$00 + |@delay(8)| + sta $d020 + |@nops(2)| + lda #$01 + ENDASM SUBEND + +LABEL my_handler + ASM + pha + inc $d020 + pla + rti + ENDASM diff --git a/internal/commands/macro.go b/internal/commands/macro.go new file mode 100644 index 0000000..5c44895 --- /dev/null +++ b/internal/commands/macro.go @@ -0,0 +1,46 @@ +package commands + +import ( + "fmt" + "strings" + + "c65gm/internal/compiler" + "c65gm/internal/preproc" +) + +// MacroCommand handles macro invocations +// Syntax: @macroname(arg1, arg2, ...) +type MacroCommand struct { + macroName string + args []string +} + +func (c *MacroCommand) WillHandle(line preproc.Line) bool { + trimmed := strings.TrimSpace(line.Text) + return strings.HasPrefix(trimmed, "@") +} + +func (c *MacroCommand) Interpret(line preproc.Line, _ *compiler.CompilerContext) error { + trimmed := strings.TrimSpace(line.Text) + + name, args, err := compiler.ParseMacroInvocation(trimmed) + if err != nil { + return err + } + + c.macroName = name + c.args = args + return nil +} + +func (c *MacroCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) { + macroOutput, err := compiler.ExecuteMacro(c.macroName, c.args, ctx) + if err != nil { + return nil, fmt.Errorf("macro %s: %w", c.macroName, err) + } + + // Return output with end comment + result := macroOutput + result = append(result, fmt.Sprintf("; end @%s", c.macroName)) + return result, nil +} diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 8015494..83bd53c 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -38,6 +38,9 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { var lastKind = preproc.Source var scriptBuffer []string var scriptIsLibrary bool + var macroBuffer []string + var currentMacroName string + var currentMacroParams []string for i, line := range lines { // Detect kind transitions and emit markers @@ -57,6 +60,21 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } } + // Store previous ScriptMacroDef block + if lastKind == preproc.ScriptMacroDef { + if currentMacroName != "" { + c.ctx.ScriptMacros[currentMacroName] = &ScriptMacro{ + Name: currentMacroName, + Params: currentMacroParams, + Body: macroBuffer, + } + codeOutput = append(codeOutput, fmt.Sprintf("; ENDSCRIPT MACRO %s", currentMacroName)) + } + macroBuffer = nil + currentMacroName = "" + currentMacroParams = nil + } + // Close previous Assembler block if lastKind == preproc.Assembler { codeOutput = append(codeOutput, "; ENDASM") @@ -71,6 +89,16 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } else if line.Kind == preproc.ScriptLibrary { codeOutput = append(codeOutput, "; SCRIPT LIBRARY") scriptIsLibrary = true + } else if line.Kind == preproc.ScriptMacroDef { + // First line is the header - parse it + name, params, err := parseMacroHeader(line.Text) + if err != nil { + c.printErrorWithContext(lines, i, err) + return nil, fmt.Errorf("compilation failed") + } + currentMacroName = name + currentMacroParams = params + codeOutput = append(codeOutput, fmt.Sprintf("; %s", line.Text)) } lastKind = line.Kind @@ -79,8 +107,36 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { // Handle non-source lines if line.Kind != preproc.Source { if line.Kind == preproc.Assembler { - // Expand |varname| -> scoped_varname for local variables in ASM blocks text := line.Text + + // Check for |@macro()| pattern first + if macroStart := strings.Index(text, "|@"); macroStart != -1 { + macroEnd := strings.Index(text[macroStart+1:], "|") + if macroEnd != -1 { + macroEnd += macroStart + 1 + invocation := text[macroStart+1 : macroEnd] // @name(args) + + macroName, args, err := ParseMacroInvocation(invocation) + if err != nil { + c.printErrorWithContext(lines, i, err) + return nil, fmt.Errorf("compilation failed") + } + + macroOutput, err := ExecuteMacro(macroName, args, c.ctx) + if err != nil { + c.printErrorWithContext(lines, i, fmt.Errorf("macro %s: %w", macroName, err)) + return nil, fmt.Errorf("compilation failed") + } + + // Emit with comments showing invocation + codeOutput = append(codeOutput, fmt.Sprintf("; %s", text)) + codeOutput = append(codeOutput, macroOutput...) + codeOutput = append(codeOutput, fmt.Sprintf("; end @%s", macroName)) + continue + } + } + + // Expand |varname| -> scoped_varname for local variables in ASM blocks for { start := strings.IndexByte(text, '|') if start == -1 { @@ -101,6 +157,13 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } else if line.Kind == preproc.Script || line.Kind == preproc.ScriptLibrary { // Collect script lines for execution scriptBuffer = append(scriptBuffer, line.Text) + } else if line.Kind == preproc.ScriptMacroDef { + // Skip the header line (already parsed in transition) + if strings.HasPrefix(strings.TrimSpace(line.Text), "SCRIPT MACRO ") { + continue + } + // Collect macro body lines + macroBuffer = append(macroBuffer, line.Text) } continue } @@ -141,6 +204,8 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { return nil, fmt.Errorf("Unclosed SCRIPT block.") } else if lastKind == preproc.ScriptLibrary { return nil, fmt.Errorf("Unclosed SCRIPT LIBRARY block.") + } else if lastKind == preproc.ScriptMacroDef { + return nil, fmt.Errorf("Unclosed SCRIPT MACRO block.") } // Analyze for overlapping absolute addresses in function call chains @@ -219,6 +284,51 @@ func (c *Compiler) checkAbsoluteOverlaps() { c.ctx.FunctionHandler.ReportAbsoluteOverlaps() } +// parseMacroHeader parses "SCRIPT MACRO name(param1, param2, ...)" and returns name and params +func parseMacroHeader(header string) (string, []string, error) { + // Expected format: "SCRIPT MACRO name(param1, param2, ...)" + header = strings.TrimSpace(header) + + // Remove "SCRIPT MACRO " prefix + if !strings.HasPrefix(header, "SCRIPT MACRO ") { + return "", nil, fmt.Errorf("invalid macro header: %s", header) + } + rest := strings.TrimSpace(header[len("SCRIPT MACRO "):]) + + // Find opening paren + parenStart := strings.IndexByte(rest, '(') + if parenStart == -1 { + return "", nil, fmt.Errorf("macro header missing '(': %s", header) + } + + // Find closing paren + parenEnd := strings.LastIndexByte(rest, ')') + if parenEnd == -1 || parenEnd < parenStart { + return "", nil, fmt.Errorf("macro header missing ')': %s", header) + } + + name := strings.TrimSpace(rest[:parenStart]) + if name == "" { + return "", nil, fmt.Errorf("macro name is empty") + } + + // Parse parameters + paramStr := strings.TrimSpace(rest[parenStart+1 : parenEnd]) + var params []string + if paramStr != "" { + parts := strings.Split(paramStr, ",") + for _, p := range parts { + param := strings.TrimSpace(p) + if param == "" { + return "", nil, fmt.Errorf("empty parameter in macro definition") + } + params = append(params, param) + } + } + + return name, params, nil +} + // assembleOutput combines all generated sections into final assembly func (c *Compiler) assembleOutput(codeLines []string) []string { var output []string diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go index 4b93e0a..3b4f2ef 100644 --- a/internal/compiler/compiler_test.go +++ b/internal/compiler/compiler_test.go @@ -142,6 +142,9 @@ func TestCompilerContext(t *testing.T) { if ctx.ScriptLibraryGlobals == nil { t.Error("ScriptLibraryGlobals not initialized") } + if ctx.ScriptMacros == nil { + t.Error("ScriptMacros not initialized") + } // Test CurrentScope scope := ctx.CurrentScope() @@ -330,3 +333,129 @@ func TestExecuteScript_RegularScript_DoesNotPersist(t *testing.T) { t.Error("local_func should not persist from regular script") } } + +func TestExecuteMacro_Basic(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Register a macro + ctx.ScriptMacros["test_macro"] = &ScriptMacro{ + Name: "test_macro", + Params: []string{"count"}, + Body: []string{ + "for i in range(count):", + " print(' nop')", + }, + } + + // Execute macro + output, err := ExecuteMacro("test_macro", []string{"3"}, ctx) + if err != nil { + t.Fatalf("ExecuteMacro failed: %v", err) + } + + if len(output) != 3 { + t.Fatalf("expected 3 output lines, got %d: %v", len(output), output) + } + + for i, line := range output { + if line != " nop" { + t.Errorf("line %d: expected ' nop', got %q", i, line) + } + } +} + +func TestExecuteMacro_WithLibraryFunction(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Define library function + lib := []string{ + "def emit_nop():", + " print(' nop')", + } + _, err := executeScript(lib, ctx, true) + if err != nil { + t.Fatalf("library failed: %v", err) + } + + // Register a macro that uses the library function + ctx.ScriptMacros["nop_macro"] = &ScriptMacro{ + Name: "nop_macro", + Params: []string{}, + Body: []string{ + "emit_nop()", + }, + } + + // Execute macro + output, err := ExecuteMacro("nop_macro", []string{}, ctx) + if err != nil { + t.Fatalf("ExecuteMacro failed: %v", err) + } + + if len(output) != 1 || output[0] != " nop" { + t.Errorf("unexpected output: %v", output) + } +} + +func TestExecuteMacro_StringParameter(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Register a macro with string parameter (label) + ctx.ScriptMacros["jump_to"] = &ScriptMacro{ + Name: "jump_to", + Params: []string{"label"}, + Body: []string{ + "print(' jmp %s' % label)", + }, + } + + // Execute with identifier (should be passed as string) + output, err := ExecuteMacro("jump_to", []string{"my_label"}, ctx) + if err != nil { + t.Fatalf("ExecuteMacro failed: %v", err) + } + + if len(output) != 1 || output[0] != " jmp my_label" { + t.Errorf("unexpected output: %v", output) + } +} + +func TestParseMacroInvocation(t *testing.T) { + tests := []struct { + input string + wantName string + wantArgs []string + wantErr bool + }{ + {"@delay(10)", "delay", []string{"10"}, false}, + {"@nops(5)", "nops", []string{"5"}, false}, + {"@setup(80, handler)", "setup", []string{"80", "handler"}, false}, + {"@empty()", "empty", []string{}, false}, + {"@expr(10+5)", "expr", []string{"10+5"}, false}, + {"missing_at()", "", nil, true}, + {"@no_parens", "", nil, true}, + } + + for _, tt := range tests { + name, args, err := ParseMacroInvocation(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("ParseMacroInvocation(%q): expected error, got none", tt.input) + } + continue + } + if err != nil { + t.Errorf("ParseMacroInvocation(%q): unexpected error: %v", tt.input, err) + continue + } + if name != tt.wantName { + t.Errorf("ParseMacroInvocation(%q): name = %q, want %q", tt.input, name, tt.wantName) + } + if len(args) != len(tt.wantArgs) { + t.Errorf("ParseMacroInvocation(%q): args = %v, want %v", tt.input, args, tt.wantArgs) + } + } +} diff --git a/internal/compiler/context.go b/internal/compiler/context.go index 5115cf6..8f28b1c 100644 --- a/internal/compiler/context.go +++ b/internal/compiler/context.go @@ -6,6 +6,13 @@ import ( "go.starlark.net/starlark" ) +// 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) +} + // CompilerContext holds all shared resources needed by commands during compilation type CompilerContext struct { // Symbol table for variables and constants @@ -31,6 +38,9 @@ type CompilerContext struct { // ScriptLibraryGlobals holds persisted Starlark globals from SCRIPT LIBRARY blocks ScriptLibraryGlobals starlark.StringDict + + // ScriptMacros holds named macro definitions from SCRIPT MACRO blocks + ScriptMacros map[string]*ScriptMacro } // NewCompilerContext creates a new compiler context with initialized resources @@ -51,6 +61,7 @@ func NewCompilerContext(pragma *preproc.Pragma) *CompilerContext { CaseSkipStack: NewLabelStack("_SKIPCASE"), Pragma: pragma, ScriptLibraryGlobals: make(starlark.StringDict), + ScriptMacros: make(map[string]*ScriptMacro), } // FunctionHandler needs references to other components diff --git a/internal/compiler/scriptexec.go b/internal/compiler/scriptexec.go index 894f858..923c0d1 100644 --- a/internal/compiler/scriptexec.go +++ b/internal/compiler/scriptexec.go @@ -2,8 +2,11 @@ package compiler import ( "bytes" + "fmt" "strings" + "c65gm/internal/utils" + "go.starlark.net/lib/math" "go.starlark.net/starlark" ) @@ -93,3 +96,179 @@ func expandVariables(text string, ctx *CompilerContext) string { } return result } + +// ExecuteMacro executes a named macro with the given arguments and returns output lines +func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]string, error) { + // Look up the macro + macro, ok := ctx.ScriptMacros[macroName] + if !ok { + return nil, fmt.Errorf("undefined macro: %s", macroName) + } + + // Check argument count + if len(args) != len(macro.Params) { + return nil, fmt.Errorf("macro %s expects %d arguments, got %d", macroName, len(macro.Params), len(args)) + } + + // Evaluate arguments and build parameter bindings + paramBindings := make(starlark.StringDict) + for i, arg := range args { + val, err := evaluateMacroArg(arg, ctx) + if err != nil { + return nil, fmt.Errorf("error evaluating argument %d for macro %s: %w", i+1, macroName, err) + } + paramBindings[macro.Params[i]] = val + } + + // Build the script: wrap macro body in a function with parameters bound + scriptText := strings.Join(macro.Body, "\n") + + // Wrap in function for control flow support + finalScript := "def _macro():\n" + for _, line := range strings.Split(scriptText, "\n") { + finalScript += " " + line + "\n" + } + finalScript += "_macro()\n" + + // Capture print output + var output bytes.Buffer + thread := &starlark.Thread{ + Print: func(_ *starlark.Thread, msg string) { + output.WriteString(msg) + output.WriteString("\n") + }, + } + + // Set execution limit + thread.SetMaxExecutionSteps(1000000) + + // Build predeclared: math + library globals + parameter bindings + predeclared := starlark.StringDict{ + "math": math.Module, + } + for k, v := range ctx.ScriptLibraryGlobals { + predeclared[k] = v + } + for k, v := range paramBindings { + predeclared[k] = v + } + + // Execute + _, err := starlark.ExecFile(thread, "macro.star", finalScript, predeclared) + if err != nil { + return nil, err + } + + // Split output into lines + outputStr := output.String() + if outputStr == "" { + return []string{}, nil + } + return strings.Split(strings.TrimRight(outputStr, "\n"), "\n"), nil +} + +// evaluateMacroArg evaluates a macro argument, returning either an int or string Starlark value +func evaluateMacroArg(arg string, ctx *CompilerContext) (starlark.Value, error) { + arg = strings.TrimSpace(arg) + + // Create lookup function for constants + lookup := func(name string) (int64, bool) { + sym := ctx.SymbolTable.Lookup(name, nil) + if sym != nil && sym.IsConst() { + return int64(sym.Value), true + } + return 0, false + } + + // Try to evaluate as expression (number, constant, arithmetic) + val, err := utils.EvaluateExpression(arg, lookup) + if err == nil { + // Successfully evaluated as integer + return starlark.MakeInt64(val), nil + } + + // If it's a valid identifier, treat as label (string) + if utils.ValidateIdentifier(arg) { + return starlark.String(arg), nil + } + + // Otherwise, error + return nil, fmt.Errorf("invalid macro argument: %s (not a valid expression or identifier)", arg) +} + +// ParseMacroInvocation parses "@name(arg1, arg2, ...)" and returns name and args +func ParseMacroInvocation(invocation string) (string, []string, error) { + invocation = strings.TrimSpace(invocation) + + // Must start with @ + if !strings.HasPrefix(invocation, "@") { + return "", nil, fmt.Errorf("macro invocation must start with @") + } + rest := invocation[1:] + + // Find opening paren + parenStart := strings.IndexByte(rest, '(') + if parenStart == -1 { + return "", nil, fmt.Errorf("macro invocation missing '(': %s", invocation) + } + + // Find closing paren + parenEnd := strings.LastIndexByte(rest, ')') + if parenEnd == -1 || parenEnd < parenStart { + return "", nil, fmt.Errorf("macro invocation missing ')': %s", invocation) + } + + name := strings.TrimSpace(rest[:parenStart]) + if name == "" { + return "", nil, fmt.Errorf("macro name is empty") + } + + // Parse arguments (handle nested parens for expressions) + argStr := strings.TrimSpace(rest[parenStart+1 : parenEnd]) + args, err := splitMacroArgs(argStr) + if err != nil { + return "", nil, err + } + + return name, args, nil +} + +// splitMacroArgs splits comma-separated arguments, respecting parentheses +func splitMacroArgs(argStr string) ([]string, error) { + if argStr == "" { + return []string{}, nil + } + + var args []string + var current strings.Builder + parenDepth := 0 + + for _, ch := range argStr { + if ch == '(' { + parenDepth++ + current.WriteRune(ch) + } else if ch == ')' { + parenDepth-- + if parenDepth < 0 { + return nil, fmt.Errorf("unbalanced parentheses in macro arguments") + } + current.WriteRune(ch) + } else if ch == ',' && parenDepth == 0 { + args = append(args, strings.TrimSpace(current.String())) + current.Reset() + } else { + current.WriteRune(ch) + } + } + + if parenDepth != 0 { + return nil, fmt.Errorf("unbalanced parentheses in macro arguments") + } + + // Add final argument + if current.Len() > 0 { + args = append(args, strings.TrimSpace(current.String())) + } + + return args, nil +} diff --git a/internal/preproc/preproc.go b/internal/preproc/preproc.go index 34bc094..0108cbd 100644 --- a/internal/preproc/preproc.go +++ b/internal/preproc/preproc.go @@ -13,6 +13,7 @@ const ( Assembler Script ScriptLibrary + ScriptMacroDef ) // Line represents one post-processed source line and its provenance. @@ -53,6 +54,7 @@ type preproc struct { inAsm bool // true when inside ASM/ENDASM block inScript bool // true when inside SCRIPT/ENDSCRIPT block inScriptLibrary bool // true when inside SCRIPT LIBRARY/ENDSCRIPT block + inScriptMacro bool // true when inside SCRIPT MACRO/ENDSCRIPT block reader FileReader // file reader abstraction } @@ -118,20 +120,31 @@ func (p *preproc) run(root string) ([]Line, error) { tokens := strings.Fields(raw) // ASM mode handling - if !p.inAsm && !p.inScript && !p.inScriptLibrary { + if !p.inAsm && !p.inScript && !p.inScriptLibrary && !p.inScriptMacro { // Check for ASM entry if includeSource && len(tokens) > 0 && tokens[0] == "ASM" { p.inAsm = true continue // don't emit ASM marker } - // Check for SCRIPT entry (SCRIPT LIBRARY or plain SCRIPT) + // Check for SCRIPT entry (SCRIPT LIBRARY, SCRIPT MACRO, or plain SCRIPT) if includeSource && len(tokens) > 0 && tokens[0] == "SCRIPT" { if len(tokens) > 1 && tokens[1] == "LIBRARY" { p.inScriptLibrary = true + } else if len(tokens) > 1 && tokens[1] == "MACRO" { + p.inScriptMacro = true + // Emit the header line (SCRIPT MACRO name(params)) for compiler to parse + out = append(out, Line{ + RawText: raw, + Text: raw, + Filename: currFrame.path, + LineNo: currFrame.line, + Kind: ScriptMacroDef, + PragmaSetIndex: p.pragma.GetCurrentPragmaSetIndex(), + }) } else { p.inScript = true } - continue // don't emit SCRIPT marker + continue // don't emit SCRIPT marker (except for MACRO which emits header) } } else if p.inAsm { // We're in ASM mode @@ -150,18 +163,21 @@ func (p *preproc) run(root string) ([]Line, error) { PragmaSetIndex: p.pragma.GetCurrentPragmaSetIndex(), }) continue - } else if p.inScript || p.inScriptLibrary { - // We're in SCRIPT or SCRIPT LIBRARY mode + } else if p.inScript || p.inScriptLibrary || p.inScriptMacro { + // We're in SCRIPT, SCRIPT LIBRARY, or SCRIPT MACRO mode // Check for ENDSCRIPT if len(tokens) > 0 && tokens[0] == "ENDSCRIPT" { p.inScript = false p.inScriptLibrary = false + p.inScriptMacro = false continue // don't emit ENDSCRIPT marker } // Determine the kind based on which mode we're in kind := Script if p.inScriptLibrary { kind = ScriptLibrary + } else if p.inScriptMacro { + kind = ScriptMacroDef } // Emit line verbatim with appropriate kind out = append(out, Line{ diff --git a/internal/preproc/preproc_test.go b/internal/preproc/preproc_test.go index aeb02f7..f21d475 100644 --- a/internal/preproc/preproc_test.go +++ b/internal/preproc/preproc_test.go @@ -1021,6 +1021,48 @@ func TestPreProcess_ScriptVsScriptLibrary(t *testing.T) { } } +func TestPreProcess_ScriptMacroBlock(t *testing.T) { + files := map[string][]string{ + "test.c65": { + "SCRIPT MACRO delay(cycles)", + " emit_delay(cycles)", + "ENDSCRIPT", + "NOP", + }, + } + reader := NewMockFileReader(files) + lines, _, err := PreProcess("test.c65", reader) + if err != nil { + t.Fatalf("PreProcess failed: %v", err) + } + + // Should have header + body line + source line = 3 lines + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d", len(lines)) + } + + // First line is the header (also ScriptMacroDef kind) + if lines[0].Kind != ScriptMacroDef { + t.Errorf("line 0: expected ScriptMacroDef, got %v", lines[0].Kind) + } + if lines[0].Text != "SCRIPT MACRO delay(cycles)" { + t.Errorf("line 0: expected header, got %q", lines[0].Text) + } + + // Second line is macro body + if lines[1].Kind != ScriptMacroDef { + t.Errorf("line 1: expected ScriptMacroDef, got %v", lines[1].Kind) + } + + // Third line is source + if lines[2].Kind != Source { + t.Errorf("line 2: expected Source, got %v", lines[2].Kind) + } + if lines[2].Text != "NOP" { + t.Errorf("line 2: expected 'NOP', got %q", lines[2].Text) + } +} + func TestPreProcess_DollarEscapeExpansion(t *testing.T) { files := map[string][]string{ "test.c65": { diff --git a/main.go b/main.go index df67337..bbde085 100644 --- a/main.go +++ b/main.go @@ -111,6 +111,7 @@ func registerCommands(comp *compiler.Compiler) { comp.Registry().Register(&commands.CaseCommand{}) comp.Registry().Register(&commands.DefaultCommand{}) comp.Registry().Register(&commands.EndSwitchCommand{}) + comp.Registry().Register(&commands.MacroCommand{}) } func writeOutput(filename string, lines []string) error {