feat(fmt): go-fmt-style recursive formatting and canonical blank-line layout

Assisted-by: Qwen 3.8 Max Preview
This commit is contained in:
2026-07-12 21:24:41 +02:00
parent 1a01870695
commit e98680597d
6 changed files with 266 additions and 30 deletions
+64 -10
View File
@@ -11,7 +11,9 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"path/filepath"
"strings" "strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/arch"
@@ -26,7 +28,7 @@ import (
// version is the release version, stamped at build time via // version is the release version, stamped at build time via
// -ldflags "-X main.version=…" (defaulting to the current release). // -ldflags "-X main.version=…" (defaulting to the current release).
var version = "0.6.0" var version = "0.7.0"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
@@ -63,7 +65,8 @@ func usage(w io.Writer) {
Usage: Usage:
gasm tokens <file> print the lexical token stream gasm tokens <file> print the lexical token stream
gasm parse <file> parse and report syntax errors gasm parse <file> parse and report syntax errors
gasm fmt [-w] <file...> canonicalise formatting (-w writes in place) gasm fmt [-w] [path...] canonicalise formatting (no path or a directory:
reformat every .s below it in place, like go fmt)
gasm lint <file...> run static checks gasm lint <file...> run static checks
gasm asm [-o out.bin] <file> assemble to machine code (amd64, Phase 2) gasm asm [-o out.bin] <file> assemble to machine code (amd64, Phase 2)
gasm lsp run the language server over stdio gasm lsp run the language server over stdio
@@ -130,15 +133,39 @@ func cmdParse(args []string) int {
} }
func cmdFmt(args []string) int { func cmdFmt(args []string) int {
fs := flag.NewFlagSet("fmt", flag.ExitOnError) fs_ := flag.NewFlagSet("fmt", flag.ExitOnError)
write := fs.Bool("w", false, "write result to the source file") write := fs_.Bool("w", false, "write result to the source file")
fs.Parse(args) fs_.Parse(args)
if fs.NArg() == 0 { // Like go fmt: with no arguments, or with a directory argument, every .s
fmt.Fprintln(os.Stderr, "usage: gasm fmt [-w] <file...>") // file below the directory is formatted in place and the names of the
return 2 // changed files are listed; explicit file arguments keep the -w / stdout
// behaviour.
paths := fs_.Args()
dirMode := len(paths) == 0
if dirMode {
paths = []string{"."}
}
var files []string
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
fmt.Fprintln(os.Stderr, "gasm:", err)
return 1
}
if info.IsDir() {
dirMode = true
found, err := asmFiles(p)
if err != nil {
fmt.Fprintln(os.Stderr, "gasm:", err)
return 1
}
files = append(files, found...)
continue
}
files = append(files, p)
} }
rc := 0 rc := 0
for _, path := range fs.Args() { for _, path := range files {
src, err := readSource(path) src, err := readSource(path)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "gasm:", err) fmt.Fprintln(os.Stderr, "gasm:", err)
@@ -146,11 +173,15 @@ func cmdFmt(args []string) int {
continue continue
} }
out := format.Source(path, src) out := format.Source(path, src)
if *write { if dirMode || *write {
if out != src { if out != src {
if err := os.WriteFile(path, []byte(out), 0o644); err != nil { if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gasm:", err) fmt.Fprintln(os.Stderr, "gasm:", err)
rc = 1 rc = 1
continue
}
if dirMode {
fmt.Println(path)
} }
} }
continue continue
@@ -160,6 +191,29 @@ func cmdFmt(args []string) int {
return rc return rc
} }
// asmFiles collects the .s files below dir, skipping directories whose name
// starts with "." or "_" — as the go tooling does, which keeps .git and
// scratch or reference trees (e.g. _refs) untouched.
func asmFiles(dir string) ([]string, error) {
var out []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if path != dir && (strings.HasPrefix(d.Name(), ".") || strings.HasPrefix(d.Name(), "_")) {
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(d.Name(), ".s") {
out = append(out, path)
}
return nil
})
return out, err
}
func cmdLint(args []string) int { func cmdLint(args []string) int {
fs := flag.NewFlagSet("lint", flag.ExitOnError) fs := flag.NewFlagSet("lint", flag.ExitOnError)
disable := fs.String("disable", "", "comma-separated rule codes to disable") disable := fs.String("disable", "", "comma-separated rule codes to disable")
+52 -3
View File
@@ -52,6 +52,54 @@ func capture(fn func() int) (stdout, stderr string, code int) {
return string(ob), string(eb), code return string(ob), string(eb), code
} }
// TestCmdFmtRecursive checks the go-fmt-style directory mode: with no
// arguments every .s file below the working directory is formatted in place
// ("." and "_" directories skipped), changed files are listed, and a second
// run is a no-op.
func TestCmdFmtRecursive(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
unformatted := []byte("TEXT ·f(SB),NOSPLIT,$0\nRET\n")
write := func(path string) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, unformatted, 0o644); err != nil {
t.Fatal(err)
}
}
write("a_amd64.s")
write(filepath.Join("sub", "b_amd64.s"))
write(filepath.Join("_refs", "c_amd64.s"))
write(filepath.Join(".git", "d_amd64.s"))
out, errOut, code := capture(func() int { return cmdFmt(nil) })
if code != 0 {
t.Fatalf("code = %d (%s)", code, errOut)
}
if out != "a_amd64.s\n"+filepath.Join("sub", "b_amd64.s")+"\n" {
t.Errorf("listed files unexpected:\n%s", out)
}
for _, p := range []string{"a_amd64.s", filepath.Join("sub", "b_amd64.s")} {
b, _ := os.ReadFile(p)
if !strings.Contains(string(b), "\tRET") {
t.Errorf("%s not formatted in place:\n%s", p, b)
}
}
for _, p := range []string{filepath.Join("_refs", "c_amd64.s"), filepath.Join(".git", "d_amd64.s")} {
b, _ := os.ReadFile(p)
if string(b) != string(unformatted) {
t.Errorf("%s must not be touched:\n%s", p, b)
}
}
// Second pass: everything is canonical, nothing is listed.
out, _, code = capture(func() int { return cmdFmt(nil) })
if code != 0 || out != "" {
t.Errorf("second pass: code=%d out=%q, want a no-op", code, out)
}
}
func TestCmdTokens(t *testing.T) { func TestCmdTokens(t *testing.T) {
path := writeTemp(t, "f_amd64.s", clean) path := writeTemp(t, "f_amd64.s", clean)
out, _, code := capture(func() int { return cmdTokens([]string{path}) }) out, _, code := capture(func() int { return cmdTokens([]string{path}) })
@@ -158,9 +206,10 @@ func TestUsage(t *testing.T) {
} }
func TestCmdArgErrors(t *testing.T) { func TestCmdArgErrors(t *testing.T) {
// Missing file arguments produce a usage error (code 2). // A missing path is an error (code 1); cmdFmt with no arguments is the
if _, _, code := capture(func() int { return cmdFmt(nil) }); code != 2 { // recursive mode now, covered by TestCmdFmtRecursive.
t.Errorf("cmdFmt() code = %d, want 2", code) if _, _, code := capture(func() int { return cmdFmt([]string{"no/such/path"}) }); code != 1 {
t.Errorf("cmdFmt(missing path) code = %d, want 1", code)
} }
if _, _, code := capture(func() int { return cmdLint(nil) }); code != 2 { if _, _, code := capture(func() int { return cmdLint(nil) }); code != 2 {
t.Errorf("cmdLint() code = %d, want 2", code) t.Errorf("cmdLint() code = %d, want 2", code)
+9 -3
View File
@@ -157,9 +157,15 @@ Two deeper analyses sit on top of the AST:
### `format` ### `format`
The formatter works on the **token stream, not the AST**, so it preserves The formatter works on the **token stream, not the AST**, so it preserves
every line — comments and blanks included. It only normalises indentation, every line — comments and blanks included. It normalises indentation, operand
operand spacing and per-function mnemonic alignment. It is idempotent and its spacing, per-function mnemonic alignment and blank-line layout: a new block
output always round-trips through the parser. (a label, `TEXT` or `GLOBL`) is preceded by exactly one blank line (comments
leading a block stay with it), runs of blanks collapse to one, and a `RET`
terminates the body so the next function's doc comment stays at column 0. It
is idempotent and its output always round-trips through the parser. With a
directory argument — or none — it reformats every `.s` file below it in
place and lists the files changed, the way `go fmt` does (`.` and `_`
directories are skipped).
### `lsp` ### `lsp`
+81 -12
View File
@@ -27,14 +27,6 @@ func Source(path, src string) string {
mnemLen int mnemLen int
funcID int funcID int
} }
const (
kBlank = iota
kComment
kPreproc
kDirective
kLabel
kInstr
)
infos := make([]info, len(lines)) infos := make([]info, len(lines))
funcID := -1 funcID := -1
@@ -70,8 +62,8 @@ func Source(path, src string) string {
infos[i] = inf infos[i] = inf
} }
// Second pass: render. // Second pass: render each line.
var b strings.Builder outs := make([]outLine, 0, len(lines))
inBody := false inBody := false
for i, line := range lines { for i, line := range lines {
inf := infos[i] inf := infos[i]
@@ -106,10 +98,87 @@ func Source(path, src string) string {
inBody = false inBody = false
} }
} }
b.WriteString(strings.TrimRight(out, " \t")) outs = append(outs, outLine{kind: inf.kind, text: strings.TrimRight(out, " \t")})
}
return normalizeSpacing(outs)
}
// Line classification, shared by the formatting passes.
const (
kBlank = iota
kComment
kPreproc
kDirective
kLabel
kInstr
)
// outLine is one rendered line together with its classification.
type outLine struct {
kind int
text string
}
// normalizeSpacing enforces the canonical blank-line layout: runs of blank
// lines collapse to one, and a new block — a label, or a TEXT or GLOBL
// directive — is preceded by exactly one blank line. Comments immediately
// above a block belong to it, so the blank line is inserted before them. No
// blank line is forced at the top of the file, right after a TEXT (the
// function's first label), or between stacked labels that share an address.
func normalizeSpacing(outs []outLine) string {
blockStart := func(ol outLine) bool {
switch ol.kind {
case kLabel:
return true
case kDirective:
// TEXT and GLOBL open a block; DATA continues a GLOBL block.
return strings.HasPrefix(ol.text, "TEXT") || strings.HasPrefix(ol.text, "GLOBL")
}
return false
}
insert := make([]bool, len(outs))
for i, ol := range outs {
if !blockStart(ol) {
continue
}
j := i
for j > 0 && outs[j-1].kind == kComment {
j--
}
if j == 0 {
continue // top of file
}
switch prev := outs[j-1]; {
case prev.kind == kBlank, prev.kind == kLabel:
continue // already separated, or stacked labels
case prev.kind == kDirective && strings.HasPrefix(prev.text, "TEXT"):
continue // the function's first label
}
insert[j] = true
}
var b strings.Builder
prevBlank := true // also suppresses leading blanks
for i, ol := range outs {
if insert[i] && !prevBlank {
b.WriteByte('\n') b.WriteByte('\n')
} }
return b.String() if ol.kind == kBlank {
if !prevBlank {
b.WriteByte('\n')
}
prevBlank = true
continue
}
b.WriteString(ol.text)
b.WriteByte('\n')
prevBlank = false
}
out := strings.TrimRight(b.String(), "\n")
if out == "" {
return ""
}
return out + "\n"
} }
// renderInstr renders an instruction line: a tab, the mnemonic padded to the // renderInstr renders an instruction line: a tab, the mnemonic padded to the
+58
View File
@@ -77,6 +77,64 @@ func TestDocCommentIndent(t *testing.T) {
} }
} }
// TestBlankLines checks the blank-line canonicalisation: exactly one blank
// line before a new block (a label, or TEXT/GLOBL), runs of blanks collapsed
// to one, and no blank forced after TEXT, between stacked labels, or at the
// top of the file. Leading comments belong to the block they precede.
func TestBlankLines(t *testing.T) {
in := "#include \"textflag.h\"\n" +
"TEXT ·f(SB), NOSPLIT, $0\n" +
"first:\n" + // first label: no blank after TEXT
"XORQ AX, AX\n" +
"JMP next\n" + // unlabeled glue: fmt inserts a blank before next:
"next:\n" +
"stacked:\n" + // stacked labels share an address: no blank between
"INCQ AX\n" +
"\n" +
"\n" + // two blanks collapse to one
"// separated block\n" + // comment belongs to the label below
"later:\n" +
"RET\n" +
"// func g()\n" + // doc comment: blank goes before it
"TEXT ·g(SB), NOSPLIT, $0\n" +
"RET\n" +
"GLOBL ·mask(SB), RODATA, $8\n" + // blank before GLOBL…
"DATA ·mask+0(SB)/4, $1\n" + // …but not before DATA
"\n" +
"\n" +
"\n" // trailing blanks dropped
want := "#include \"textflag.h\"\n" +
"\n" +
"TEXT ·f(SB), NOSPLIT, $0\n" +
"first:\n" +
"\tXORQ AX, AX\n" +
"\tJMP next\n" +
"\n" +
"next:\n" +
"stacked:\n" +
"\tINCQ AX\n" +
"\n" +
"\t// separated block\n" + // body comment before a label stays indented
"later:\n" +
"\tRET\n" +
"\n" +
"// func g()\n" +
"TEXT ·g(SB), NOSPLIT, $0\n" +
"\tRET\n" +
"\n" +
"GLOBL ·mask(SB), RODATA, $8\n" +
"DATA ·mask+0(SB)/4, $1\n"
got := Source("b_amd64.s", in)
if got != want {
t.Fatalf("formatting mismatch:\n--- got ---\n%q\n--- want ---\n%q", got, want)
}
if again := Source("b_amd64.s", got); again != got {
t.Fatalf("not idempotent:\n%q", again)
}
}
func TestOperandSpacing(t *testing.T) { func TestOperandSpacing(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"4(SI)": "4(SI)", "4(SI)": "4(SI)",
+1 -1
View File
@@ -3,7 +3,7 @@
# gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm).
version := "0.6.0" version := "0.7.0"
default: default:
@just --list @just --list