From e600fc00e70a4951905a89d0205a395e06c87bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 7 Oct 2026 12:35:51 +0200 Subject: [PATCH] fix(format): preserve macro adjacency and continuation bodies The canonical spelling of a #define line glued a '(' to the macro name whatever the input's spacing, turning an object macro whose body opens with a parenthesis into a parameterised one, and it flattened the backslash continuations of a multi-line body into one physical line, fusing the statements the expansion splits at those boundaries. Both change what a valid file assembles to, so renderPreproc now keeps the name's adjacency (the same column check the preprocessor applies) and restores the continuation boundaries from the token positions. Assisted-by: GLM 5.3 --- format/expansion_test.go | 139 +++++++++++++++++++++++++++++++++++++++ format/format.go | 66 +++++++++++++++++-- format/format_test.go | 6 +- 3 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 format/expansion_test.go diff --git a/format/expansion_test.go b/format/expansion_test.go new file mode 100644 index 0000000..32e2600 --- /dev/null +++ b/format/expansion_test.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package format + +import ( + "slices" + "strings" + "testing" + + "sourcedock.dev/petrbalvin/gasm-sdk/parser" +) + +// TestFormatPreservesMacroAdjacency pins the distinction the macro language +// draws from the source's layout: a '(' glued to a #define name makes the +// macro parameterised, while one separated by a space leaves it an object +// macro whose body happens to open with a parenthesis. Both spellings are +// valid input and each must format to itself, because swapping one for the +// other changes what every use of the macro expands to. +func TestFormatPreservesMacroAdjacency(t *testing.T) { + for _, in := range []string{ + "#define A(x) x+1\n", + "#define A (x) x+1\n", + "#define A(x) y x y\n", + "#define A ()\n", + } { + if got := Source(in); got != in { + t.Errorf("formatting %q:\n got %q", in, got) + } + } +} + +// TestFormatPreservesExpansionSemantics proves the formatter's contract on +// the assembly path: a file the expander reads cleanly must expand to exactly +// the same statements after formatting. Each case is a macro shape whose +// meaning lives in the source's layout, where a formatter that only counts +// tokens cannot see the difference it destroys. +func TestFormatPreservesExpansionSemantics(t *testing.T) { + cases := []struct { + name string + src string + }{ + { + // An object macro whose body opens with a parenthesis: gluing the + // '(' to the name would turn it into a parameterised one, and the + // bare use would stop expanding at all. + name: "object macro with parenthesised body", + src: "#define M (BX)\n" + + "TEXT ·f(SB), $0\n" + + "\tMOVL M, AX\n" + + "\tRET\n", + }, + { + // A zero-argument macro invoked on a line whose mnemonic padding + // opens a gap before the '(': the invocation must still expand, + // because the parentheses are tokens however far apart. + name: "zero-argument macro with alignment padding", + src: "#define A() ADD $1, R0\n" + + "TEXT ·f(SB), $0\n" + + "\tA()\n" + + "\tRET\n", + }, + { + // The arm64 style: a macro body written across continuations + // without ';' separators. The expansion splits the body at the + // line boundaries, so flattening the continuations would fuse two + // instructions into one operand list. + name: "continuation body without semicolons", + src: "#define M() \\\n" + + "\tADD $1, R0 \\\n" + + "\tSUB $2, R1\n" + + "TEXT ·f(SB), $0\n" + + "\tM()\n" + + "\tRET\n", + }, + { + // The mixed style of the runtime sources: continuations that also + // carry ';' separators inside some of their lines. + name: "continuation body with semicolons", + src: "#define PAIR(v) \\\n" + + "\tADD $v, R0; \\\n" + + "\tSUB $v, R1\n" + + "TEXT ·f(SB), $0\n" + + "\tPAIR(3)\n" + + "\tRET\n", + }, + { + // A parameterised macro whose formal list itself spans a + // continuation: the body opens on the next line, and the formal + // list must stay glued to the name. + name: "continuation after the formal list", + src: "#define M(a, b) \\\n" + + "\tADD $a, b\n" + + "TEXT ·f(SB), $0\n" + + "\tM(1, R0)\n" + + "\tRET\n", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := Source(tc.src) + if again := Source(out); again != out { + t.Fatalf("not idempotent:\n%s", again) + } + assertSameExpansion(t, tc.src, out) + }) + } +} + +// assertSameExpansion expands src and formatted with the macro machinery and +// compares the statement sequences they produce. +func assertSameExpansion(t *testing.T, src, formatted string) { + t.Helper() + before, errs := parser.ParseWithOptions("in.s", src, parser.Options{Expand: true}) + if len(errs) > 0 { + t.Fatalf("source does not expand: %v", errs) + } + after, errs := parser.ParseWithOptions("out.s", formatted, parser.Options{Expand: true}) + if len(errs) > 0 { + t.Fatalf("formatted source does not expand: %v\n%s", errs, formatted) + } + want, got := stmtSignature(before), stmtSignature(after) + if !slices.Equal(got, want) { + t.Fatalf("expansion changed:\n--- before ---\n%q\n--- after ---\n%q\n%s", want, got, formatted) + } +} + +// TestContinuationBodyNotFlattened pins the rendered spelling of a +// continuation body directly: the backslash boundaries the input carried are +// the canonical form, not a flattened single line. +func TestContinuationBodyNotFlattened(t *testing.T) { + in := "#define M() \\\n\tADD $1, R0 \\\n\tSUB $2, R1\n" + if got := Source(in); got != in { + t.Fatalf("continuation body must render to itself:\n got %q\nwant %q", got, in) + } + if strings.Contains(strings.ReplaceAll(Source(in), "\\\n", ""), "ADD $1, R0 SUB") { + t.Fatal("continuation body was flattened into one physical line") + } +} diff --git a/format/format.go b/format/format.go index e59de62..4cca4c1 100644 --- a/format/format.go +++ b/format/format.go @@ -10,6 +10,7 @@ package format import ( "strings" + "unicode/utf8" "sourcedock.dev/petrbalvin/gasm-sdk/lexer" "sourcedock.dev/petrbalvin/gasm-sdk/token" @@ -288,24 +289,75 @@ func renderInstr(line []token.Token, width int) string { // renderPreproc renders a preprocessor line such as #include "textflag.h". func renderPreproc(line []token.Token) string { - // "#" directive [args] + // "#" include "header" if len(line) >= 3 && line[1].Kind == token.Ident && line[1].Text == "include" && line[2].Kind == token.String { // Whatever follows the header name is stray, but it is the file's // stray text: it renders after the name rather than vanishing, so // re-lexing the output sees exactly the tokens the input carried. out := "#include " + line[2].Text - if rest := renderOps(line[3:]); rest != "" { + if rest := renderDirective(line[3:]); rest != "" { out += " " + rest } return out } // The body of a directive, a macro definition included, is an ordinary - // token run: rendering it through renderOps applies the same punctuation - // rules as everywhere else, so a macro body keeps its canonical spelling - // ($v, (a, b), the ';' separators between statements) instead of being - // spread with a space between every token. - return "#" + renderOps(line[1:]) + // token run: rendering it through renderDirective applies the same + // punctuation rules as everywhere else, so a macro body keeps its + // canonical spelling ($v, (a, b), the ';' separators between statements) + // instead of being spread with a space between every token. + return "#" + renderDirective(line[1:]) +} + +// renderDirective renders the token run of a preprocessor directive. Two +// distinctions of the macro language live in the source's layout and must +// survive the canonical spelling, because the expansion reads them back: +// +// - A '(' glued to a #define name makes the macro parameterised, while one +// separated by a space leaves it an object macro whose body happens to +// open with a parenthesis. The separator is decided by the input's +// adjacency, exactly the column check the preprocessor applies. +// - A backslash continuation the lexer spliced into the logical line is a +// statement boundary inside a macro body: the expansion splits a +// multi-line body at those boundaries, so flattening them would fuse two +// instructions into one operand list and change what the file assembles +// to. The boundary is restored from the token positions as " \\\n\t". +func renderDirective(toks []token.Token) string { + name := -1 // index of the macro name in a #define, else -1 + if len(toks) >= 2 && toks[0].Kind == token.Ident && toks[0].Text == "define" && + toks[1].Kind == token.Ident { + name = 1 + } + var b strings.Builder + for i, t := range toks { + if i > 0 { + prev := toks[i-1] + spliced := t.Pos.Line > prev.Pos.Line + switch { + case spliced: + b.WriteString(" \\\n\t") + case i-1 == name && t.Kind == token.LParen && + t.Pos.Column != prev.Pos.Column+utf8.RuneCountInString(prev.Text): + b.WriteByte(' ') + default: + // The operand punctuation rules, plus the guards that keep a + // tight spelling from re-lexing as something else. + sp := spaceBetween(prev, t) + if !sp && (t.Kind == token.Slash || t.Kind == token.Star) && + strings.HasSuffix(b.String(), "/") { + sp = true + } + if !sp && wouldMerge(prev, t) { + sp = true + } + if sp { + b.WriteByte(' ') + } + } + } + b.WriteString(t.Text) + } + return b.String() } // renderOps re-spaces a run of operand tokens into canonical form. It never diff --git a/format/format_test.go b/format/format_test.go index c4df860..a48759c 100644 --- a/format/format_test.go +++ b/format/format_test.go @@ -329,10 +329,12 @@ func TestSemicolonSeparators(t *testing.T) { }, { // The continuation-spliced macro shape of the runtime sources: - // the lexer makes one logical line of the backslash continuations. + // the lexer makes one logical line of the backslash continuations, + // and the canonical form restores them, because the expansion + // splits a macro body at those line boundaries. name: "inside a macro body, continued", in: "#define MOVLTOREG(v, off) \\\n\tMOVL $v, AX; \\\n\tMOVL AX, ret+off(FP)\n", - want: "#define MOVLTOREG(v, off) MOVL $v, AX; MOVL AX, ret+off(FP)\n", + want: "#define MOVLTOREG(v, off) \\\n\tMOVL $v, AX; \\\n\tMOVL AX, ret+off(FP)\n", }, { name: "inside a macro body, one line",