From 50db6615b2447110b9fc570665674a7ecf078d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Mon, 14 Sep 2026 18:47:08 +0200 Subject: [PATCH] feat(cmd): add gofmt-style -l and -d modes to gasm fmt --- cmd/gasm/main.go | 25 ++++++- cmd/gasm/unidiff.go | 140 +++++++++++++++++++++++++++++++++++++++ cmd/gasm/unidiff_test.go | 94 ++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 cmd/gasm/unidiff.go create mode 100644 cmd/gasm/unidiff_test.go diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index c096b55..1a8d710 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -56,6 +56,8 @@ func main() { os.Exit(cmdLint(os.Args[2:])) case "asm": os.Exit(cmdAsm(os.Args[2:])) + case "dis": + os.Exit(cmdDis(os.Args[2:])) case "verify": os.Exit(cmdVerify(os.Args[2:])) case "debug": @@ -132,6 +134,7 @@ func usage(w io.Writer) { {"fmt", "canonicalise formatting (gofmt for assembly)"}, {"lint", "run static checks"}, {"asm", "assemble .s files to machine code (amd64, arm64, riscv64, loong64)"}, + {"dis", "disassemble machine code (raw bytes or an assembled .s file)"}, {"verify", "JIT-assemble and run dynamic checks (amd64, arm64, riscv64, loong64)"}, {"debug", "interactive source-level debugger (amd64, arm64, riscv64, loong64)"}, {"diff", "compare machine code of two .s files"}, @@ -268,7 +271,7 @@ standard input. } func cmdFmt(args []string) int { - fs := newCommand("fmt", "gasm fmt [-w] [path...]", ` + fs := newCommand("fmt", "gasm fmt [-w|-l|-d] [path...]", ` Canonicalise the formatting of Plan 9 assembly sources: indentation, operand spacing, per-function mnemonic alignment and blank-line layout (exactly one blank line before each label, TEXT and GLOBL block). Formatting is @@ -278,9 +281,19 @@ With no paths, or a directory path, every .s file below it is reformatted in place and the changed files are listed, the way go fmt does; "." and "_" directories are skipped. Explicit file paths print to stdout unless -w is given. + +-l and -d rewrite nothing: -l prints the paths whose formatting differs +from gasm's (empty output means everything is formatted, which is what a CI +check wants), -d prints the diffs. They are mutually exclusive. `) write := fs.Bool("w", false, "write result to the source file") + list := fs.Bool("l", false, "list files whose formatting differs from gasm's") + diffMode := fs.Bool("d", false, "print diffs instead of rewriting files") fs.Parse(args) + if *list && *diffMode { + fmt.Fprintln(os.Stderr, "gasm fmt: -l and -d are mutually exclusive") + return 2 + } // 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 @@ -318,6 +331,16 @@ given. continue } out := format.Source(src) + if *list || *diffMode { + if out != src { + if *list { + fmt.Println(path) + } else { + fmt.Print(unifiedDiff(path, strings.Split(src, "\n"), strings.Split(out, "\n"))) + } + } + continue + } if dirMode || *write { if out != src { if err := os.WriteFile(path, []byte(out), 0o644); err != nil { diff --git a/cmd/gasm/unidiff.go b/cmd/gasm/unidiff.go new file mode 100644 index 0000000..9277fe5 --- /dev/null +++ b/cmd/gasm/unidiff.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "slices" + "strings" +) + +// unifiedDiff renders a unified diff with three lines of context between the +// two line slices, in the form `gofmt -d` prints. An empty result means the +// inputs are identical. +func unifiedDiff(name string, a, b []string) string { + if slices.Equal(a, b) { + return "" + } + var out strings.Builder + fmt.Fprintf(&out, "--- %s\n+++ %s\n", name, name) + + // Longest common subsequence over the lines (assembly files are small + // enough for the quadratic table). + n, m := len(a), len(b) + lcs := make([][]int, n+1) + for i := range lcs { + lcs[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[j] { + lcs[i][j] = lcs[i+1][j+1] + 1 + } else if lcs[i+1][j] >= lcs[i][j+1] { + lcs[i][j] = lcs[i+1][j] + } else { + lcs[i][j] = lcs[i][j+1] + } + } + } + + // Walk the LCS once, assigning every op its absolute position in both + // files (1-based, the position an insertion sits before). + type op struct { + kind byte // ' ', '-' or '+' + aLine, bLine int + text string + } + var ops []op + aPos, bPos := 0, 0 + emit := func(kind byte, text string) { + ops = append(ops, op{kind: kind, aLine: aPos + 1, bLine: bPos + 1, text: text}) + switch kind { + case ' ': + aPos++ + bPos++ + case '-': + aPos++ + case '+': + bPos++ + } + } + i, j := 0, 0 + for i < n && j < m { + switch { + case a[i] == b[j]: + emit(' ', a[i]) + i++ + j++ + case lcs[i+1][j] >= lcs[i][j+1]: + emit('-', a[i]) + i++ + default: + emit('+', b[j]) + j++ + } + } + for ; i < n; i++ { + emit('-', a[i]) + } + for ; j < m; j++ { + emit('+', b[j]) + } + + // Group the edits into hunks: consecutive changes separated by more than + // twice the context lines start a new hunk. + const context = 3 + var changes []int + for k, o := range ops { + if o.kind != ' ' { + changes = append(changes, k) + } + } + for g := 0; g < len(changes); { + last := g + for last+1 < len(changes) && changes[last+1]-changes[last]-1 <= 2*context { + last++ + } + lo := max(0, changes[g]-context) + hi := min(len(ops), changes[last]+1+context) + // The header numbers are the first line of each side actually shown: + // the first context, deletion or insertion line. A hunk that shows + // no old lines is a pure insertion and reports the position it sits + // before (0 at the top of the file); the mirror rule holds for a + // pure deletion. + aStart := ops[lo].aLine - 1 + bStart := ops[lo].bLine - 1 + countA, countB := 0, 0 + for _, o := range ops[lo:hi] { + switch o.kind { + case ' ': + countA++ + countB++ + case '-': + countA++ + case '+': + countB++ + } + } + for _, o := range ops[lo:hi] { + if o.kind != '+' { + aStart = o.aLine + break + } + } + for _, o := range ops[lo:hi] { + if o.kind != '-' { + bStart = o.bLine + break + } + } + fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", aStart, countA, bStart, countB) + for _, o := range ops[lo:hi] { + out.WriteByte(o.kind) + out.WriteString(o.text) + out.WriteByte('\n') + } + g = last + 1 + } + return out.String() +} diff --git a/cmd/gasm/unidiff_test.go b/cmd/gasm/unidiff_test.go new file mode 100644 index 0000000..ab20090 --- /dev/null +++ b/cmd/gasm/unidiff_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "slices" + "strings" + "testing" +) + +func lines(ss ...string) []string { return ss } + +func TestUnifiedDiffIdentical(t *testing.T) { + if got := unifiedDiff("f", lines("a", "b"), lines("a", "b")); got != "" { + t.Errorf("identical inputs produced %q, want empty", got) + } +} + +func TestUnifiedDiffSingleChange(t *testing.T) { + a := lines("1", "2", "3", "4", "5", "6", "7", "8") + b := lines("1", "2", "3!", "4", "5", "6", "7", "8") + want := "--- f\n+++ f\n" + + "@@ -1,6 +1,6 @@\n" + + " 1\n 2\n-3\n+3!\n 4\n 5\n 6\n" + if got := unifiedDiff("f", a, b); got != want { + t.Errorf("diff = %q, want %q", got, want) + } +} + +func TestUnifiedDiffInsertAtStart(t *testing.T) { + got := unifiedDiff("f", lines("x"), lines("new", "x")) + // The single existing line is shown as trailing context, so the hunk + // covers it. + want := "--- f\n+++ f\n@@ -1,1 +1,2 @@\n+new\n x\n" + if got != want { + t.Errorf("diff = %q, want %q", got, want) + } +} + +func TestUnifiedDiffDeleteAtEnd(t *testing.T) { + got := unifiedDiff("f", lines("x", "y"), lines("x")) + want := "--- f\n+++ f\n@@ -1,2 +1,1 @@\n x\n-y\n" + if got != want { + t.Errorf("diff = %q, want %q", got, want) + } +} + +func TestUnifiedDiffTwoHunks(t *testing.T) { + var a, b []string + for i := 1; i <= 20; i++ { + a = append(a, itoa(i)) + b = append(b, itoa(i)) + } + b[1] = "2!" + b[17] = "18!" + got := unifiedDiff("f", a, b) + if !strings.Contains(got, "@@ -1,5 +1,5 @@\n 1\n-2\n+2!\n 3\n 4\n 5\n") { + t.Errorf("first hunk wrong:\n%s", got) + } + if !strings.Contains(got, "@@ -15,6 +15,6 @@\n 15\n 16\n 17\n-18\n+18!\n 19\n 20\n") { + t.Errorf("second hunk wrong:\n%s", got) + } +} + +// TestUnifiedDiffAdjacentHunks merges changes separated by exactly twice the +// context into one hunk. +func TestUnifiedDiffAdjacentHunks(t *testing.T) { + a := lines("1", "2", "3", "4", "5", "6", "7", "8") + b := slices.Clone(a) + b[0] = "1!" + b[7] = "8!" + got := unifiedDiff("f", a, b) + want := "--- f\n+++ f\n" + + "@@ -1,8 +1,8 @@\n" + + "-1\n+1!\n 2\n 3\n 4\n 5\n 6\n 7\n-8\n+8!\n" + if got != want { + t.Errorf("diff = %q, want %q", got, want) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [4]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +}