242 lines
6.5 KiB
Go
242 lines
6.5 KiB
Go
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestManCommandsTrackHelp compares the gasm(1) COMMANDS list with the
|
|
// top-level help output, so a subcommand added to the binary cannot miss
|
|
// its man entry and a stale entry cannot outlive its command.
|
|
func TestManCommandsTrackHelp(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)
|
|
}
|
|
|
|
raw, err := os.ReadFile(filepath.Join("..", "..", "docs", "man", "gasm.1"))
|
|
if err != nil {
|
|
t.Fatalf("read man page: %v", err)
|
|
}
|
|
|
|
helpOut, err := exec.Command(bin, "--help").Output()
|
|
if err != nil {
|
|
t.Fatalf("gasm --help: %v", err)
|
|
}
|
|
|
|
binCmds := helpCommands(string(helpOut))
|
|
pageCmds := roffCommands(string(raw))
|
|
for c := range binCmds {
|
|
if !pageCmds[c] {
|
|
t.Errorf("command %q is in the binary's help but missing from gasm(1) COMMANDS", c)
|
|
}
|
|
}
|
|
for c := range pageCmds {
|
|
if !binCmds[c] {
|
|
t.Errorf("command %q is in gasm(1) COMMANDS but the binary does not list it", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// helpCommands extracts the command names from the top-level help output's
|
|
// Commands section.
|
|
func helpCommands(help string) map[string]bool {
|
|
m := map[string]bool{}
|
|
inCmds := false
|
|
for line := range strings.SplitSeq(help, "\n") {
|
|
if strings.TrimSpace(line) == "Commands:" {
|
|
inCmds = true
|
|
continue
|
|
}
|
|
if !inCmds {
|
|
continue
|
|
}
|
|
t := strings.TrimSpace(line)
|
|
if t == "" {
|
|
break
|
|
}
|
|
name, _, _ := strings.Cut(t, " ")
|
|
m[name] = true
|
|
}
|
|
return m
|
|
}
|
|
|
|
// roffCommands extracts the command names from gasm(1)'s COMMANDS section,
|
|
// where each entry is written as `.B gasm\-<name>(1)` or `.B gasm <name>`.
|
|
func roffCommands(page string) map[string]bool {
|
|
m := map[string]bool{}
|
|
inCmds := false
|
|
for line := range strings.SplitSeq(page, "\n") {
|
|
if strings.HasPrefix(line, ".SH ") {
|
|
inCmds = strings.HasPrefix(line, ".SH COMMANDS")
|
|
continue
|
|
}
|
|
if !inCmds || !strings.HasPrefix(line, ".B gasm") {
|
|
continue
|
|
}
|
|
entry := strings.ReplaceAll(strings.TrimPrefix(line, ".B "), `\-`, "-")
|
|
entry = strings.TrimSuffix(entry, "(1)")
|
|
switch {
|
|
case strings.HasPrefix(entry, "gasm-"):
|
|
m[strings.TrimPrefix(entry, "gasm-")] = true
|
|
case strings.HasPrefix(entry, "gasm "):
|
|
m[strings.TrimPrefix(entry, "gasm ")] = 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), " ")
|
|
}
|