// Copyright (c) 2026 Petr BalvĂ­n (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-.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 -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), " ") }