feat(docs): man pages for gasm and every command, guarded against CLI drift
Test / test (push) Successful in 2m4s
Test / test (push) Successful in 2m4s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestManPagesTrackTheCLI builds the binary once, then compares every
|
||||
// command's live `-h` output with its docs/man/gasm-<command>.1 page: the
|
||||
// flag sets must agree both ways, and the page's SYNOPSIS line must carry
|
||||
// the command's usage line. A flag or a usage change that skips the man
|
||||
// page fails here, so the pages cannot drift from the binary.
|
||||
func TestManPagesTrackTheCLI(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("builds the gasm binary")
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "gasm")
|
||||
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
|
||||
t.Fatalf("build gasm: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
for _, cmd := range []string{
|
||||
"tokens", "parse", "fmt", "lint", "asm", "dis", "verify",
|
||||
"debug", "diff", "profile", "audit-instructions", "scaffold", "lsp",
|
||||
} {
|
||||
t.Run(cmd, func(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "docs", "man", "gasm-"+cmd+".1"))
|
||||
if err != nil {
|
||||
t.Fatalf("read man page: %v", err)
|
||||
}
|
||||
page := string(raw)
|
||||
|
||||
out, _ := exec.Command(bin, cmd, "-h").CombinedOutput()
|
||||
help := string(out)
|
||||
|
||||
binFlags := helpFlags(help)
|
||||
pageFlags := roffFlags(page)
|
||||
for f := range binFlags {
|
||||
if !pageFlags[f] {
|
||||
t.Errorf("flag -%s is in the binary's help but missing from the man page", f)
|
||||
}
|
||||
}
|
||||
for f := range pageFlags {
|
||||
if !binFlags[f] {
|
||||
t.Errorf("flag -%s is in the man page but the binary does not accept it", f)
|
||||
}
|
||||
}
|
||||
|
||||
want := helpUsage(help)
|
||||
got := roffSynopsis(page)
|
||||
if want != "" && got != want {
|
||||
t.Errorf("SYNOPSIS drift:\n page: %s\nbinary: %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// helpFlags extracts the flag names from a `gasm <cmd> -h` output.
|
||||
func helpFlags(help string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
inFlags := false
|
||||
for line := range strings.SplitSeq(help, "\n") {
|
||||
if strings.TrimRight(line, " \t") == "Flags:" {
|
||||
inFlags = true
|
||||
continue
|
||||
}
|
||||
if !inFlags {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, " -") {
|
||||
continue
|
||||
}
|
||||
token := strings.FieldsFunc(strings.TrimLeft(line, " "), func(r rune) bool {
|
||||
return r == ' ' || r == '\t'
|
||||
})
|
||||
if len(token) == 0 {
|
||||
continue
|
||||
}
|
||||
m[strings.TrimLeft(token[0], "-")] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
var roffEscape = regexp.MustCompile(`\\f[BIRP]`)
|
||||
|
||||
// roffFlags extracts the flag names from a man page's OPTIONS section.
|
||||
func roffFlags(page string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
inOptions := false
|
||||
for line := range strings.SplitSeq(page, "\n") {
|
||||
if strings.HasPrefix(line, ".SH ") {
|
||||
inOptions = strings.HasPrefix(line, ".SH OPTIONS")
|
||||
continue
|
||||
}
|
||||
if !inOptions {
|
||||
continue
|
||||
}
|
||||
// Flag entries are written as either `.B \-flag` or `\fB\-flag`.
|
||||
var body string
|
||||
switch {
|
||||
case strings.HasPrefix(line, `.B \-`):
|
||||
body = line[3:]
|
||||
case strings.HasPrefix(line, `\fB\-`):
|
||||
body = line[1:]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
name := roffEscape.ReplaceAllString(body, "")
|
||||
name = strings.ReplaceAll(name, `\-`, "-")
|
||||
name = strings.TrimSpace(name)
|
||||
if i := strings.IndexAny(name, " \t"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
m[strings.TrimLeft(name, "-")] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// helpUsage returns the command's usage line without the "Usage: " prefix.
|
||||
func helpUsage(help string) string {
|
||||
for line := range strings.SplitSeq(help, "\n") {
|
||||
if strings.HasPrefix(line, "Usage: ") {
|
||||
return normaliseUsage(line[len("Usage: "):])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// roffSynopsis returns the page's SYNOPSIS usage line, unescaped.
|
||||
func roffSynopsis(page string) string {
|
||||
inSyn := false
|
||||
for line := range strings.SplitSeq(page, "\n") {
|
||||
if strings.HasPrefix(line, ".SH ") {
|
||||
inSyn = strings.HasPrefix(line, ".SH SYNOPSIS")
|
||||
continue
|
||||
}
|
||||
if !inSyn || !strings.HasPrefix(line, ".B ") {
|
||||
continue
|
||||
}
|
||||
return normaliseUsage(strings.ReplaceAll(line[3:], `\-`, "-"))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normaliseUsage flattens whitespace and drops the roff font escapes so that
|
||||
// the binary's usage line and the page's SYNOPSIS line compare equal.
|
||||
func normaliseUsage(s string) string {
|
||||
s = roffEscape.ReplaceAllString(s, "")
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
Reference in New Issue
Block a user