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}