diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index b3c3e98..0808f33 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -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 print the lexical token stream gasm parse parse and report syntax errors - gasm fmt [-w] 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 run static checks gasm asm [-o out.bin] 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] ") - 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") diff --git a/cmd/gasm/main_test.go b/cmd/gasm/main_test.go index ca07b47..6d7799b 100644 --- a/cmd/gasm/main_test.go +++ b/cmd/gasm/main_test.go @@ -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) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 04fc1ce..b4748f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -157,9 +157,15 @@ Two deeper analyses sit on top of the AST: ### `format` The formatter works on the **token stream, not the AST**, so it preserves -every line — comments and blanks included. It only normalises indentation, -operand spacing and per-function mnemonic alignment. It is idempotent and its -output always round-trips through the parser. +every line — comments and blanks included. It normalises indentation, operand +spacing, per-function mnemonic alignment and blank-line layout: a new block +(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` diff --git a/format/format.go b/format/format.go index 9deb840..8969d35 100644 --- a/format/format.go +++ b/format/format.go @@ -27,14 +27,6 @@ func Source(path, src string) string { mnemLen int funcID int } - const ( - kBlank = iota - kComment - kPreproc - kDirective - kLabel - kInstr - ) infos := make([]info, len(lines)) funcID := -1 @@ -70,8 +62,8 @@ func Source(path, src string) string { infos[i] = inf } - // Second pass: render. - var b strings.Builder + // Second pass: render each line. + outs := make([]outLine, 0, len(lines)) inBody := false for i, line := range lines { inf := infos[i] @@ -106,10 +98,87 @@ func Source(path, src string) string { inBody = false } } - b.WriteString(strings.TrimRight(out, " \t")) - b.WriteByte('\n') + outs = append(outs, outLine{kind: inf.kind, text: strings.TrimRight(out, " \t")}) } - return b.String() + 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') + } + 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 diff --git a/format/format_test.go b/format/format_test.go index ffbe801..7a8faca 100644 --- a/format/format_test.go +++ b/format/format_test.go @@ -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) { cases := map[string]string{ "4(SI)": "4(SI)", diff --git a/justfile b/justfile index 9216642..d1817a2 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). -version := "0.6.0" +version := "0.7.0" default: @just --list