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
This commit is contained in:
1 parent
acd30088af
commit
e600fc00e7
3 files changed
+202
-9
No files matched your search
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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")
|
||||
}
|
||||
}
|
||||
+59
-7
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user