Local variable expansion and macro expansion in ASM block now takes into account comments and ommits those.

This commit is contained in:
Mattias Hansson 2026-02-13 09:29:40 +01:00
parent 8e66e95c90
commit 4acd8f2e87
2 changed files with 400 additions and 12 deletions

View file

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

View file

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