main.go

  1// gen-restic-cmds parses restic's man pages and generates a Go source file
  2// containing every command and its accepted flags. Run via go generate:
  3//
  4//	//go:generate go run git.secluded.site/keld/cmd/gen-restic-cmds
  5package main
  6
  7import (
  8	"bufio"
  9	"fmt"
 10	"os"
 11	"os/exec"
 12	"path/filepath"
 13	"regexp"
 14	"sort"
 15	"strings"
 16	"text/template"
 17)
 18
 19// command mirrors the runtime type but lives here so the generator has no
 20// import dependency on the package it writes into.
 21type command struct {
 22	Name        string
 23	Description string
 24	Options     []option
 25}
 26
 27type option struct {
 28	Name        string
 29	Alias       string
 30	Default     string
 31	Description string
 32	Repeatable  bool
 33	IsBool      bool
 34}
 35
 36// Regexes for the small subset of troff macros cobra emits.
 37var (
 38	sectionStart   = regexp.MustCompile(`^\.SH (.+)$`)
 39	paragraphBreak = regexp.MustCompile(`^\.PP$`)
 40	escapeSequence = regexp.MustCompile(`\\f[A-Z]|\\&|\\`)
 41	strayBackticks = regexp.MustCompile("[`]{2,}")
 42	filePattern    = regexp.MustCompile(`^restic(-.*)?\.1$`)
 43)
 44
 45func main() {
 46	manDir, err := os.MkdirTemp("", "keld-gen-*")
 47	if err != nil {
 48		fatalf("creating temp dir: %v", err)
 49	}
 50	defer func() { _ = os.RemoveAll(manDir) }()
 51
 52	resticBin := os.Getenv("KELD_EXECUTABLE")
 53	if resticBin == "" {
 54		resticBin = "restic"
 55	}
 56
 57	// Capture the version string for the header comment.
 58	versionOut, err := exec.Command(resticBin, "version").Output()
 59	if err != nil {
 60		fatalf("running %s version: %v", resticBin, err)
 61	}
 62	version := strings.TrimSpace(string(versionOut))
 63
 64	genCmd := exec.Command(resticBin, "generate", "--man", manDir)
 65	genCmd.Stderr = os.Stderr
 66	if err := genCmd.Run(); err != nil {
 67		fatalf("running %s generate --man: %v", resticBin, err)
 68	}
 69
 70	entries, err := os.ReadDir(manDir)
 71	if err != nil {
 72		fatalf("reading man dir: %v", err)
 73	}
 74
 75	var commands []command
 76	for _, entry := range entries {
 77		if entry.IsDir() || !filePattern.MatchString(entry.Name()) {
 78			continue
 79		}
 80
 81		cmd, err := parseManPage(filepath.Join(manDir, entry.Name()))
 82		if err != nil {
 83			fatalf("parsing %s: %v", entry.Name(), err)
 84		}
 85		if cmd != nil {
 86			commands = append(commands, *cmd)
 87		}
 88	}
 89
 90	sort.Slice(commands, func(i, j int) bool {
 91		return commands[i].Name < commands[j].Name
 92	})
 93
 94	outPath := "commands_gen.go"
 95	if err := writeGoFile(outPath, version, commands); err != nil {
 96		fatalf("writing %s: %v", outPath, err)
 97	}
 98
 99	fmt.Fprintf(os.Stderr, "generated %s (%d commands from %s)\n", outPath, len(commands), version)
100}
101
102// parseManPage reads a single restic-*.1 man page and extracts the command
103// name, description, and per-command options. Inherited (global) options are
104// skipped for subcommand pages; they appear once via restic.1.
105func parseManPage(path string) (*command, error) {
106	f, err := os.Open(path)
107	if err != nil {
108		return nil, err
109	}
110	defer func() { _ = f.Close() }()
111
112	base := filepath.Base(path)
113	m := filePattern.FindStringSubmatch(base)
114	if m == nil {
115		return nil, fmt.Errorf("unexpected filename %s", base)
116	}
117
118	// restic.1 → "" → "global", restic-backup.1 → "-backup" → "backup"
119	cmdName := strings.TrimPrefix(m[1], "-")
120	if cmdName == "" {
121		cmdName = "global"
122	}
123
124	cmd := &command{Name: cmdName}
125	var (
126		section string
127		opt     *option
128	)
129
130	flush := func() {
131		if opt != nil {
132			opt.Description = strings.TrimSpace(opt.Description)
133			cmd.Options = append(cmd.Options, *opt)
134			opt = nil
135		}
136	}
137
138	scanner := bufio.NewScanner(f)
139	for scanner.Scan() {
140		line := scanner.Text()
141
142		// Handle troff macro lines.
143		if strings.HasPrefix(line, ".") {
144			if sm := sectionStart.FindStringSubmatch(line); sm != nil {
145				flush()
146				section = sm[1]
147				continue
148			}
149			if paragraphBreak.MatchString(line) {
150				flush()
151			}
152			continue
153		}
154
155		// Strip all font/escape sequences and backtick artifacts from content lines.
156		line = escapeSequence.ReplaceAllString(line, "")
157		line = strayBackticks.ReplaceAllString(line, "")
158
159		switch section {
160		case "NAME":
161			// "restic-backup - Create a new backup" → description after " - "
162			if idx := strings.Index(line, " - "); idx >= 0 {
163				cmd.Description = line[idx+3:]
164			}
165
166		case "OPTIONS":
167			parseOptionLine(line, &opt, cmd)
168
169			// Skip inherited options on subcommand pages; they duplicate
170			// what restic.1 provides as "global".
171		}
172	}
173
174	flush()
175	return cmd, scanner.Err()
176}
177
178// parseOptionLine handles a single content line within an OPTIONS section.
179// It either starts a new option (when the line contains "--") or appends to
180// the current option's description.
181func parseOptionLine(line string, opt **option, cmd *command) {
182	if *opt == nil {
183		// Looking for a new option — must contain a long flag.
184		if !strings.Contains(line, "--") {
185			return
186		}
187		*opt = &option{}
188
189		// Split on "=" to separate the flag spec from the default value.
190		//   "-n, --dry-run[=false]"  →  ["-n, --dry-run[", "false]"]
191		//   "--exclude=[]"           →  ["--exclude", "[]"]
192		parts := strings.SplitN(line, "=", 2)
193		flagSpec := parts[0]
194
195		if len(parts) > 1 {
196			def := strings.Trim(parts[1], "[]\"")
197			(*opt).Default = def
198			(*opt).Repeatable = strings.Contains(parts[1], "[]") && def == ""
199			(*opt).IsBool = def == "false" || def == "true"
200		}
201
202		// Split the flag spec on "," to handle "-n, --dry-run" form.
203		for _, param := range strings.Split(flagSpec, ",") {
204			param = strings.TrimSpace(param)
205			param = strings.Trim(param, "[]")
206			if strings.HasPrefix(param, "--") {
207				(*opt).Name = strings.TrimPrefix(param, "--")
208			} else if strings.HasPrefix(param, "-") {
209				(*opt).Alias = strings.TrimPrefix(param, "-")
210			}
211		}
212	} else {
213		// Continuation line — append to description.
214		desc := strings.TrimSpace(line)
215		if desc == "" {
216			return
217		}
218		if (*opt).Description != "" {
219			(*opt).Description += " "
220		}
221		(*opt).Description += desc
222	}
223}
224
225func writeGoFile(path, version string, commands []command) error {
226	f, err := os.Create(path)
227	if err != nil {
228		return err
229	}
230	defer func() { _ = f.Close() }()
231
232	return outputTmpl.Execute(f, struct {
233		Version  string
234		Commands []command
235	}{
236		Version:  version,
237		Commands: commands,
238	})
239}
240
241var outputTmpl = template.Must(template.New("").Funcs(template.FuncMap{
242	"quote": func(s string) string { return fmt.Sprintf("%q", s) },
243}).Parse(`// Code generated by gen-restic-cmds; DO NOT EDIT.
244// Source: {{.Version}}
245
246package restic
247
248// Commands maps restic command names to their parsed man-page definitions.
249// The "global" entry contains flags common to all commands.
250var Commands = map[string]Command{
251{{- range .Commands}}
252	{{quote .Name}}: {
253		Name:        {{quote .Name}},
254		Description: {{quote .Description}},
255		Options: []Option{
256{{- range .Options}}
257			{Name: {{quote .Name}}, Alias: {{quote .Alias}}, Default: {{quote .Default}}, Description: {{quote .Description}}, Repeatable: {{printf "%v" .Repeatable}}, IsBool: {{printf "%v" .IsBool}}},
258{{- end}}
259		},
260	},
261{{- end}}
262}
263`))
264
265func fatalf(format string, args ...any) {
266	fmt.Fprintf(os.Stderr, "gen-restic-cmds: "+format+"\n", args...)
267	os.Exit(1)
268}