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"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
@@ -26,7 +28,7 @@ import (
// version is the release version, stamped at build time via
// -ldflags "-X main.version=…" (defaulting to the current release).
var version = "0.6.0"
var version = "0.7.0"
func main() {
if len(os.Args) < 2 {
@@ -63,7 +65,8 @@ func usage(w io.Writer) {
Usage:
gasm tokens <file> print the lexical token stream
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 asm [-o out.bin] <file> assemble to machine code (amd64, Phase 2)
gasm lsp run the language server over stdio
@@ -130,15 +133,39 @@ func cmdParse(args []string) int {
}
func cmdFmt(args []string) int {
fs := flag.NewFlagSet("fmt", flag.ExitOnError)
write := fs.Bool("w", false, "write result to the source file")
fs.Parse(args)
if fs.NArg() == 0 {
fmt.Fprintln(os.Stderr, "usage: gasm fmt [-w] <file...>")
return 2
fs_ := flag.NewFlagSet("fmt", flag.ExitOnError)
write := fs_.Bool("w", false, "write result to the source file")
fs_.Parse(args)
// Like go fmt: with no arguments, or with a directory argument, every .s
// file below the directory is formatted in place and the names of the
// 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
for _, path := range fs.Args() {
for _, path := range files {
src, err := readSource(path)
if err != nil {
fmt.Fprintln(os.Stderr, "gasm:", err)
@@ -146,11 +173,15 @@ func cmdFmt(args []string) int {
continue
}
out := format.Source(path, src)
if *write {
if dirMode || *write {
if out != src {
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gasm:", err)
rc = 1
continue
}
if dirMode {
fmt.Println(path)
}
}
continue
@@ -160,6 +191,29 @@ func cmdFmt(args []string) int {
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 {
fs := flag.NewFlagSet("lint", flag.ExitOnError)
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
}
// 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) {
path := writeTemp(t, "f_amd64.s", clean)
out, _, code := capture(func() int { return cmdTokens([]string{path}) })
@@ -158,9 +206,10 @@ func TestUsage(t *testing.T) {
}
func TestCmdArgErrors(t *testing.T) {
// Missing file arguments produce a usage error (code 2).
if _, _, code := capture(func() int { return cmdFmt(nil) }); code != 2 {
t.Errorf("cmdFmt() code = %d, want 2", code)
// A missing path is an error (code 1); cmdFmt with no arguments is the
// recursive mode now, covered by TestCmdFmtRecursive.
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 {
t.Errorf("cmdLint() code = %d, want 2", code)