diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 83bd53c..6a61b1c 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -109,12 +109,21 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { if line.Kind == preproc.Assembler { text := line.Text - // Check for |@macro()| pattern first - if macroStart := strings.Index(text, "|@"); macroStart != -1 { - macroEnd := strings.Index(text[macroStart+1:], "|") + // Find comment boundary - only process |...| patterns in the code portion + commentPos := findAsmCommentStart(text) + codePart := text + commentPart := "" + if commentPos != -1 { + codePart = text[:commentPos] + commentPart = text[commentPos:] + } + + // Check for |@macro()| pattern first (only in code portion, outside strings) + macroStart := findPipeOutsideStrings(codePart, 0) + if macroStart != -1 && macroStart+1 < len(codePart) && codePart[macroStart+1] == '@' { + macroEnd := findPipeOutsideStrings(codePart, macroStart+1) if macroEnd != -1 { - macroEnd += macroStart + 1 - invocation := text[macroStart+1 : macroEnd] // @name(args) + invocation := codePart[macroStart+1 : macroEnd] // @name(args) macroName, args, err := ParseMacroInvocation(invocation) if err != nil { @@ -136,24 +145,26 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } } - // Expand |varname| -> scoped_varname for local variables in ASM blocks + // Expand |varname| -> scoped_varname for local variables in ASM blocks (only in code portion, outside strings) + searchFrom := 0 for { - start := strings.IndexByte(text, '|') + start := findPipeOutsideStrings(codePart, searchFrom) if start == -1 { break } - end := strings.IndexByte(text[start+1:], '|') + end := findPipeOutsideStrings(codePart, start+1) if end == -1 { c.printErrorWithContext(lines, i, fmt.Errorf("unclosed | in assembler line")) return nil, fmt.Errorf("compilation failed") } - end += start + 1 - varName := text[start+1 : end] + varName := codePart[start+1 : end] expandedName := c.ctx.SymbolTable.ExpandName(varName, c.ctx.CurrentScope()) - text = text[:start] + expandedName + text[end+1:] + codePart = codePart[:start] + expandedName + codePart[end+1:] + // Continue searching after the replacement + searchFrom = start + len(expandedName) } - codeOutput = append(codeOutput, text) + codeOutput = append(codeOutput, codePart+commentPart) } else if line.Kind == preproc.Script || line.Kind == preproc.ScriptLibrary { // Collect script lines for execution scriptBuffer = append(scriptBuffer, line.Text) @@ -329,6 +340,103 @@ func parseMacroHeader(header string) (string, []string, error) { return name, params, nil } +// findAsmCommentStart finds the position of ';' that starts a comment in an ASM line, +// taking into account that ';' inside strings (single or double quoted) should be ignored. +// Supports ACME-style escape sequences (backslash escapes) inside strings. +// Returns -1 if no comment is found. +func findAsmCommentStart(line string) int { + inString := false + escaped := false + var quoteChar rune + + for i, ch := range line { + if escaped { + escaped = false + continue + } + if inString { + if ch == '\\' { + escaped = true + continue + } + if ch == quoteChar { + inString = false + } + continue + } + // Not in string + if ch == '"' || ch == '\'' { + inString = true + quoteChar = ch + continue + } + if ch == ';' { + return i + } + } + return -1 +} + +// findPipeOutsideStrings finds the position of '|' that is not inside a string. +// Supports ACME-style escape sequences (backslash escapes) inside strings. +// Starts searching from 'startFrom' position. +// Returns -1 if no such '|' is found. +func findPipeOutsideStrings(line string, startFrom int) int { + inString := false + escaped := false + var quoteChar rune + + for i, ch := range line { + if i < startFrom { + // Track string state even before startFrom + if escaped { + escaped = false + continue + } + if inString { + if ch == '\\' { + escaped = true + continue + } + if ch == quoteChar { + inString = false + } + continue + } + if ch == '"' || ch == '\'' { + inString = true + quoteChar = ch + } + continue + } + + if escaped { + escaped = false + continue + } + if inString { + if ch == '\\' { + escaped = true + continue + } + if ch == quoteChar { + inString = false + } + continue + } + // Not in string + if ch == '"' || ch == '\'' { + inString = true + quoteChar = ch + continue + } + if ch == '|' { + return i + } + } + return -1 +} + // 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 3b4f2ef..8ba41d8 100644 --- a/internal/compiler/compiler_test.go +++ b/internal/compiler/compiler_test.go @@ -459,3 +459,283 @@ func TestParseMacroInvocation(t *testing.T) { } } } + +func TestFindAsmCommentStart(t *testing.T) { + tests := []struct { + input string + want int + }{ + // Basic cases + {"lda #$00", -1}, // no comment + {"; comment", 0}, // comment at start + {"lda #$00 ; comment", 9}, // comment after code + {" lda #$00 ; comment", 11}, // with leading whitespace + + // Semicolon in double-quoted string + {`!text "hello; world"`, -1}, // no comment, ; inside string + {`!text "hello; world" ; comment`, 21}, // comment after string with ; + {`!text "a;b;c"`, -1}, // multiple ; in string + + // Semicolon in single-quoted string + {`!byte ';'`, -1}, // ; as character literal + {`!byte ';' ; comment`, 10}, // comment after ; char literal + + // Escape sequences in strings + {`!text "hello\"world"`, -1}, // escaped quote, no comment + {`!text "hello\"world" ; comment`, 21}, // comment after string with escaped quote + {`!text "path\\file"`, -1}, // escaped backslash + {`!text "a\\;b"`, -1}, // escaped backslash before ; + {`!text "a\;b"`, -1}, // escaped ; in string (stays in string) + + // Mixed quotes + {`!text "it's"`, -1}, // single quote inside double + {`!byte '"'`, -1}, // double quote as char literal + {`!text "say \"hi\""`, -1}, // escaped quotes in string + + // Edge cases + {"", -1}, // empty line + {`""`, -1}, // empty string + {`"" ; comment`, 3}, // empty string then comment + {`!text "unterminated`, -1}, // unterminated string (no ; found) + } + + for _, tt := range tests { + got := findAsmCommentStart(tt.input) + if got != tt.want { + t.Errorf("findAsmCommentStart(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} + +func TestAsmBlock_VariableExpansion_IgnoresComments(t *testing.T) { + pragma := preproc.NewPragma() + comp := NewCompiler(pragma) + + // Add a variable to the symbol table so expansion can work + comp.Context().SymbolTable.AddVar("myvar", "", KindByte, 0) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "variable in code expands", + input: " lda |myvar|", + expected: " lda myvar", + }, + { + name: "variable in comment stays unexpanded", + input: "; use |myvar| here", + expected: "; use |myvar| here", + }, + { + name: "variable in code, different in comment", + input: " lda |myvar| ; load |myvar|", + expected: " lda myvar ; load |myvar|", + }, + { + name: "only comment with variable", + input: " nop ; |myvar|", + expected: " nop ; |myvar|", + }, + { + name: "variable in string not affected", + input: ` !text "|myvar|"`, + expected: ` !text "|myvar|"`, + }, + { + name: "variable after string with semicolon", + input: ` !text "a;b" ; |myvar|`, + expected: ` !text "a;b" ; |myvar|`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create fresh compiler for each test + comp := NewCompiler(pragma) + comp.Context().SymbolTable.AddVar("myvar", "", KindByte, 0) + + lines := []preproc.Line{ + { + Text: tt.input, + Filename: "test.asm", + LineNo: 1, + Kind: preproc.Assembler, + }, + { + // Empty source line to close the ASM block + Text: "", + Filename: "test.asm", + LineNo: 2, + Kind: preproc.Source, + }, + } + + output, err := comp.Compile(lines) + if err != nil { + t.Fatalf("Compile failed: %v", err) + } + + // Find the ASM output line (between ; ASM and ; ENDASM markers) + var resultLine string + inAsmBlock := false + for _, line := range output { + if line == "; ASM" { + inAsmBlock = true + continue + } + if line == "; ENDASM" { + break + } + if inAsmBlock { + resultLine = line + break + } + } + + if resultLine != tt.expected { + t.Errorf("got %q, want %q\nfull output: %v", resultLine, tt.expected, output) + } + }) + } +} + +func TestFindPipeOutsideStrings(t *testing.T) { + tests := []struct { + input string + startFrom int + want int + }{ + // Basic cases + {"lda |var|", 0, 4}, + {"lda |var|", 5, 8}, + {"no pipes", 0, -1}, + + // Pipe in string should be skipped + {`"a|b"`, 0, -1}, + {`"a|b" |var|`, 0, 6}, + {`'|' |x|`, 0, 4}, + + // Escape sequences + {`"a\"|b"`, 0, -1}, // escaped quote, pipe still in string + {`"a\\"|b|`, 0, 5}, // escaped backslash, pipe outside + {`"a\|b"`, 0, -1}, // escaped pipe stays in string + + // Start from different positions + {"|a| |b|", 0, 0}, + {"|a| |b|", 1, 2}, + {"|a| |b|", 3, 4}, + } + + for _, tt := range tests { + got := findPipeOutsideStrings(tt.input, tt.startFrom) + if got != tt.want { + t.Errorf("findPipeOutsideStrings(%q, %d) = %d, want %d", tt.input, tt.startFrom, got, tt.want) + } + } +} + +func TestAsmBlock_MacroExpansion_IgnoresComments(t *testing.T) { + pragma := preproc.NewPragma() + comp := NewCompiler(pragma) + + // Register a test macro + comp.Context().ScriptMacros["delay"] = &ScriptMacro{ + Name: "delay", + Params: []string{"cycles"}, + Body: []string{ + "for i in range(cycles):", + " print(' nop')", + }, + } + + tests := []struct { + name string + input string + expectExpanded bool + expectedLines int // number of output lines (excluding comment wrappers) + }{ + { + name: "macro in code expands", + input: " |@delay(3)|", + expectExpanded: true, + expectedLines: 3, // 3 nops + }, + { + name: "macro in comment does not expand", + input: "; |@delay(3)|", + expectExpanded: false, + expectedLines: 1, // just the original line + }, + { + name: "macro after semicolon comment does not expand", + input: " nop ; |@delay(3)|", + expectExpanded: false, + expectedLines: 1, // just the original line with nop + }, + { + name: "macro in string does not expand", + input: ` !text "|@delay(3)|"`, + expectExpanded: false, + expectedLines: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create fresh compiler for each test to avoid state issues + comp := NewCompiler(pragma) + comp.Context().ScriptMacros["delay"] = &ScriptMacro{ + Name: "delay", + Params: []string{"cycles"}, + Body: []string{ + "for i in range(cycles):", + " print(' nop')", + }, + } + + lines := []preproc.Line{ + { + Text: tt.input, + Filename: "test.asm", + LineNo: 1, + Kind: preproc.Assembler, + }, + { + // Empty source line to close the ASM block + Text: "", + Filename: "test.asm", + LineNo: 2, + Kind: preproc.Source, + }, + } + + output, err := comp.Compile(lines) + if err != nil { + t.Fatalf("Compile failed: %v", err) + } + + // Count nop lines to determine if macro expanded + nopCount := 0 + for _, line := range output { + if strings.TrimSpace(line) == "nop" { + nopCount++ + } + } + + if tt.expectExpanded { + if nopCount != tt.expectedLines { + t.Errorf("expected %d nop lines (macro expanded), got %d\nfull output: %v", + tt.expectedLines, nopCount, output) + } + } else { + if nopCount > 0 { + t.Errorf("expected no nop lines (macro should not expand in comment), got %d\nfull output: %v", + nopCount, output) + } + } + }) + } +}