Skip to content
File

Blob: util/help_printer.go

go393 lines
1// Code Generation by ChatGPT o4-mini-high with this specification:
2// 1. Core Layout
3// - Sections in order:
4// NAME
5// USAGE
6// VERSION (app level only)
7// DESCRIPTION
8// COMMANDS (only user-defined, hide built-in "help")
9// OPTIONS
10// COPYRIGHT (app level only)
11//
12// 2. Section Headers
13// - "NAME:", "USAGE:", "VERSION:", "DESCRIPTION:", "COMMANDS:", "OPTIONS:" and "COPYRIGHT:" in bold green.
14// - Category headings (e.g. "Chord Options", "Gateway Options", "Global Options") in bold cyan, indented 2 spaces.
15//
16// 3. Wrapping & Indentation
17// - Wrap all free-form text (DESCRIPTION and each flag’s usage) at 160 columns.
18// - DESCRIPTION lines indented 3 spaces.
19// - Flags indented 2 spaces.
20// - Two-space gap between flag label and usage.
21// - Wrapped continuation lines of flag usage get an extra 2-space indent.
22//
23// 4. Flag Grouping & Ordering
24// - Group flags by their category.
25// - Alphabetically sort categories (empty category labeled "Global Options").
26// - Within each category, preserve the order from the `Flags` slice.
27//
28// 5. Label Alignment
29// - Compute the maximum width of any flag label (e.g. "--foo BAR") across all categories.
30// - Right-pad every other label to that width so all usage columns start in the same column.
31//
32// 6. Help-flag & Help-command Suppression
33// - Skip any flag whose rendered label starts with `--help`.
34// - Skip the built-in `help` subcommand unless explicitly defined.
35//
36// 7. Color & Readability
37// - Use `fatih/color` for ANSI colors:
38// - Bright green for top-level headers.
39// - Cyan for category headings.
40// - Insert blank lines before each category and between major sections for breathing room.
41package util
42 
43import (
44 "io"
45 "os"
46 "sort"
47 "strconv"
48 "strings"
49 "text/template"
50 
51 "github.com/fatih/color"
52 "github.com/urfave/cli/v3"
53 "golang.org/x/term"
54)
55 
56const (
57 // indentation and wrapping parameters
58 descIndent = " "
59 flagIndent = " "
60 gapBetween = 2
61 cmdNameWidth = 20
62 minWrap = 40
63 maxWrap = 160
64)
65 
66// template for the entire help output
67const helpTpl = `{{ section "NAME:" }}
68 {{ .Name }} - {{ .Usage }}
69
70{{ section "USAGE:" }}
71 {{ .UsageLine }}
72{{ if .Version }}
73{{ section "VERSION:" }}
74 {{ .Version }}
75{{ end }}
76{{- if .DescriptionLines }}
77{{ section "DESCRIPTION:" }}
78{{- range .DescriptionLines }}
79 {{ . }}
80{{- end }}
81{{ end }}
82{{- if .Commands }}
83{{ section "COMMANDS:" }}
84{{- range .Commands }}
85 {{ .Name | padCmd }} {{ .Usage }}
86{{- end }}
87{{ end }}
88{{ section .OptionsHeader }}
89{{- range .FlagGroups }}
90 {{- if .Category }}
91{{ header .Category }}
92 {{- end }}
93 {{- range .Flags }}
94 {{- if $.Stacked }}
95{{ .Label }}
96 {{- range .UsageLines }}
97{{ $.UsageIndent }}{{ . }}
98 {{- end }}
99 {{ else }}
100{{ pad .Label $.MaxLabel }} {{ index .UsageLines 0 }}
101 {{- range $i, $line := .UsageLines }}
102 {{- if gt $i 0 }}
103{{ $.UsageIndent }}{{ $line }}
104 {{- end }}
105 {{- end }}
106 {{- end }}
107 {{- end }}
108{{ end }}
109{{- if .CopyrightLines }}
110{{ section "COPYRIGHT:" }}
111{{- range .CopyrightLines }}
112 {{ . }}
113{{- end }}
114{{ end }}`
115 
116var (
117 // colors
118 sectionColor = color.New(color.FgGreen, color.Bold).SprintFunc()
119 headerColor = color.New(color.FgCyan, color.Bold).SprintFunc()
120 
121 // template.FuncMap for coloring, padding, and headers
122 funcMap = template.FuncMap{
123 "section": func(s string) string { return sectionColor(s) },
124 "header": func(s string) string { return flagIndent + headerColor(s) },
125 "pad": func(label string, width int) string {
126 if n := width - len(label); n > 0 {
127 return label + strings.Repeat(" ", n)
128 }
129 return label
130 },
131 "padCmd": func(name string) string {
132 if n := cmdNameWidth - len(name); n > 0 {
133 return name + strings.Repeat(" ", n)
134 }
135 return name
136 },
137 }
138 
139 // parsed template
140 helpT = template.Must(template.New("help").Funcs(funcMap).Parse(helpTpl))
141)
142 
143// helpData is the model passed to the template.
144type helpData struct {
145 Name string
146 Usage string
147 UsageLine string
148 Version string
149 DescriptionLines []string
150 Commands []cmdItem
151 OptionsHeader string
152 FlagGroups []flagGroup
153 MaxLabel int
154 UsageIndent string
155 CopyrightLines []string
156 
157 Stacked bool
158}
159 
160type cmdItem struct {
161 Name string
162 Usage string
163}
164 
165type flagGroup struct {
166 Category string
167 Flags []flagItem
168}
169 
170type flagItem struct {
171 Label string
172 UsageLines []string
173}
174 
175// getTermWidth returns the terminal width or defaultWidth if unavailable.
176func getTermWidth(defaultWidth int) int {
177 // 1) Direct system call
178 if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
179 return w
180 }
181 
182 // 2) Environment override (e.g. in CI)
183 if cols := os.Getenv("COLUMNS"); cols != "" {
184 if c, err := strconv.Atoi(cols); err == nil && c > 0 {
185 return c
186 }
187 }
188 
189 // 3) fallback
190 return defaultWidth
191}
192 
193// PrettierHelpPrinter installs the templated HelpPrinter into urfave/cli.
194func PrettierHelpPrinter() {
195 original := cli.HelpPrinter
196 cli.HelpPrinter = func(w io.Writer, templ string, data any) {
197 d := buildHelpData(data)
198 if err := helpT.ExecuteTemplate(w, "help", d); err != nil {
199 // fallback to default on error
200 original(w, templ, data)
201 }
202 }
203}
204 
205// wrap splits text into lines of at most width, preserving paragraph breaks.
206func wrap(text string, width int) []string {
207 var lines []string
208 for para := range strings.SplitSeq(text, "\n\n") {
209 words := strings.Fields(para)
210 if len(words) == 0 {
211 lines = append(lines, "")
212 continue
213 }
214 line := words[0]
215 for _, w := range words[1:] {
216 if len(line)+1+len(w) > width {
217 lines = append(lines, line)
218 line = w
219 } else {
220 line += " " + w
221 }
222 }
223 lines = append(lines, line, "")
224 }
225 if len(lines) > 0 && lines[len(lines)-1] == "" {
226 lines = lines[:len(lines)-1]
227 }
228 return lines
229}
230 
231// flagCategory returns the category exposed by a cli.Flag.
232func flagCategory(f cli.Flag) string {
233 if categorized, ok := f.(cli.CategorizableFlag); ok {
234 return categorized.GetCategory()
235 }
236 return ""
237}
238 
239// flagHidden reports whether a flag should be omitted from help.
240func flagHidden(f cli.Flag) bool {
241 if visible, ok := f.(cli.VisibleFlag); ok {
242 return !visible.IsVisible()
243 }
244 return false
245}
246 
247// buildHelpData extracts flags, commands, and metadata into helpData.
248func buildHelpData(data any) *helpData {
249 var (
250 flags []cli.Flag
251 cmds []*cli.Command
252 helpName string
253 usage string
254 desc string
255 argsUsage string
256 isApp bool
257 app *cli.Command
258 )
259 
260 switch v := data.(type) {
261 case *cli.Command:
262 flags, cmds, helpName, usage, desc = v.Flags, v.Commands, v.FullName(), v.Usage, v.Description
263 isApp, app = v.Root() == v, v.Root()
264 argsUsage = v.ArgsUsage
265 default:
266 return &helpData{Name: helpName, Usage: usage}
267 }
268 
269 termWidth := getTermWidth(maxWrap)
270 // compute wrap width
271 wrapWidth := min(maxWrap, termWidth) - 2
272 
273 // usage line
274 var usageLine string
275 if isApp {
276 usageLine = helpName + " [global options] command [command options]"
277 } else {
278 usageLine = helpName
279 if len(flags) > 0 {
280 usageLine += " [command options]"
281 }
282 if argsUsage != "" {
283 usageLine += " " + argsUsage
284 }
285 }
286 
287 d := &helpData{
288 Name: helpName,
289 Usage: usage,
290 UsageLine: usageLine,
291 }
292 
293 // version
294 if isApp {
295 d.Version = app.Version
296 }
297 
298 // description
299 if desc != "" {
300 d.DescriptionLines = wrap(desc, wrapWidth-len(descIndent))
301 }
302 
303 // commands
304 for _, c := range cmds {
305 if c.Hidden || c.Name == "help" {
306 continue
307 }
308 d.Commands = append(d.Commands, cmdItem{
309 Name: c.Name,
310 Usage: c.Usage,
311 })
312 }
313 
314 // options header
315 if isApp {
316 d.OptionsHeader = "GLOBAL OPTIONS:"
317 } else {
318 d.OptionsHeader = "OPTIONS:"
319 }
320 
321 // group flags by category
322 byCat := make(map[string][]cli.Flag)
323 var cats []string
324 for _, f := range flags {
325 if flagHidden(f) {
326 continue
327 }
328 raw := strings.TrimRight(f.String(), "\n")
329 label := strings.SplitN(raw, "\t", 2)[0]
330 if strings.HasPrefix(label, "--help") {
331 continue
332 }
333 cat := flagCategory(f)
334 if _, ok := byCat[cat]; !ok {
335 cats = append(cats, cat)
336 }
337 byCat[cat] = append(byCat[cat], f)
338 }
339 sort.Strings(cats)
340 
341 // compute max label width
342 for _, cat := range cats {
343 for _, f := range byCat[cat] {
344 lbl := strings.SplitN(strings.TrimRight(f.String(), "\n"), "\t", 2)[0]
345 if l := len(lbl); l > d.MaxLabel {
346 d.MaxLabel = l + 2
347 }
348 }
349 }
350 
351 // usage indent for continuations
352 d.UsageIndent = flagIndent + strings.Repeat(" ", d.MaxLabel+gapBetween)
353 wlen := wrapWidth - len(flagIndent) - d.MaxLabel - gapBetween
354 if wlen < minWrap {
355 d.Stacked = true
356 d.UsageIndent = flagIndent + " "
357 }
358 
359 // assemble flag groups
360 for _, cat := range cats {
361 group := flagGroup{Category: cat}
362 for _, f := range byCat[cat] {
363 raw := strings.TrimRight(f.String(), "\n")
364 parts := strings.SplitN(raw, "\t", 2)
365 label := flagIndent + parts[0]
366 usageText := ""
367 if len(parts) > 1 {
368 usageText = parts[1]
369 }
370 var lines []string
371 if d.Stacked {
372 lines = wrap(usageText, termWidth-4)
373 } else {
374 lines = wrap(usageText, wlen)
375 }
376 group.Flags = append(group.Flags, flagItem{
377 Label: label,
378 UsageLines: lines,
379 })
380 }
381 d.FlagGroups = append(d.FlagGroups, group)
382 }
383 
384 // copyright
385 if isApp && strings.TrimSpace(app.Copyright) != "" {
386 for line := range strings.SplitSeq(app.Copyright, "\n") {
387 d.CopyrightLines = append(d.CopyrightLines, line)
388 }
389 }
390 
391 return d
392}