1package fang
2
3import (
4 "cmp"
5 "fmt"
6 "io"
7 "iter"
8 "os"
9 "reflect"
10 "regexp"
11 "strconv"
12 "strings"
13 "sync"
14
15 "github.com/charmbracelet/colorprofile"
16 "github.com/charmbracelet/lipgloss/v2"
17 "github.com/charmbracelet/x/ansi"
18 "github.com/charmbracelet/x/term"
19 "github.com/spf13/cobra"
20 "github.com/spf13/pflag"
21)
22
23const (
24 minSpace = 10
25 shortPad = 2
26 longPad = 4
27)
28
29var width = sync.OnceValue(func() int {
30 if s := os.Getenv("__FANG_TEST_WIDTH"); s != "" {
31 w, _ := strconv.Atoi(s)
32 return w
33 }
34 w, _, err := term.GetSize(os.Stdout.Fd())
35 if err != nil {
36 return 120
37 }
38 return min(w, 120)
39})
40
41func helpFn(c *cobra.Command, w *colorprofile.Writer, styles Styles) {
42 writeLongShort(w, styles, cmp.Or(c.Long, c.Short))
43 usage := styleUsage(c, styles.Codeblock.Program, true)
44 examples := styleExamples(c, styles)
45
46 padding := styles.Codeblock.Base.GetHorizontalPadding()
47 blockWidth := lipgloss.Width(usage)
48 for _, ex := range examples {
49 blockWidth = max(blockWidth, lipgloss.Width(ex))
50 }
51 blockWidth = min(width()-padding, blockWidth+padding)
52 blockStyle := styles.Codeblock.Base.Width(blockWidth)
53
54 // if the color profile is ascii or notty, or if the block has no
55 // background color set, remove the vertical padding.
56 if w.Profile <= colorprofile.Ascii || reflect.DeepEqual(blockStyle.GetBackground(), lipgloss.NoColor{}) {
57 blockStyle = blockStyle.PaddingTop(0).PaddingBottom(0)
58 }
59
60 _, _ = fmt.Fprintln(w, styles.Title.Render("usage"))
61 _, _ = fmt.Fprintln(w, blockStyle.Render(usage))
62 if len(examples) > 0 {
63 cw := blockStyle.GetWidth() - blockStyle.GetHorizontalPadding()
64 _, _ = fmt.Fprintln(w, styles.Title.Render("examples"))
65 for i, example := range examples {
66 if lipgloss.Width(example) > cw {
67 examples[i] = ansi.Truncate(example, cw, "…")
68 }
69 }
70 _, _ = fmt.Fprintln(w, blockStyle.Render(strings.Join(examples, "\n")))
71 }
72
73 groups, groupKeys := evalGroups(c)
74 cmds, cmdKeys := evalCmds(c, styles)
75 flags, flagKeys := evalFlags(c, styles)
76 space := calculateSpace(cmdKeys, flagKeys)
77
78 for _, groupID := range groupKeys {
79 group := cmds[groupID]
80 if len(group) == 0 {
81 continue
82 }
83 renderGroup(w, styles, space, groups[groupID], func(yield func(string, string) bool) {
84 for _, k := range cmdKeys {
85 cmds, ok := group[k]
86 if !ok {
87 continue
88 }
89 if !yield(k, cmds) {
90 return
91 }
92 }
93 })
94 }
95
96 if len(flags) > 0 {
97 renderGroup(w, styles, space, "flags", func(yield func(string, string) bool) {
98 for _, k := range flagKeys {
99 if !yield(k, flags[k]) {
100 return
101 }
102 }
103 })
104 }
105
106 _, _ = fmt.Fprintln(w)
107}
108
109// DefaultErrorHandler is the default [ErrorHandler] implementation.
110func DefaultErrorHandler(w io.Writer, styles Styles, err error) {
111 _, _ = fmt.Fprintln(w, styles.ErrorHeader.String())
112 _, _ = fmt.Fprintln(w, styles.ErrorText.Render(err.Error()+"."))
113 _, _ = fmt.Fprintln(w)
114 if isUsageError(err) {
115 _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
116 lipgloss.Left,
117 styles.ErrorText.UnsetWidth().Render("Try"),
118 styles.Program.Flag.Render(" --help "),
119 styles.ErrorText.UnsetWidth().UnsetMargins().UnsetTransform().Render("for usage."),
120 ))
121 _, _ = fmt.Fprintln(w)
122 }
123}
124
125// XXX: this is a hack to detect usage errors.
126// See: https://github.com/spf13/cobra/pull/2266
127func isUsageError(err error) bool {
128 s := err.Error()
129 for _, prefix := range []string{
130 "flag needs an argument:",
131 "unknown flag:",
132 "unknown shorthand flag:",
133 "unknown command",
134 "invalid argument",
135 } {
136 if strings.HasPrefix(s, prefix) {
137 return true
138 }
139 }
140 return false
141}
142
143func writeLongShort(w *colorprofile.Writer, styles Styles, longShort string) {
144 if longShort == "" {
145 return
146 }
147 _, _ = fmt.Fprintln(w)
148 _, _ = fmt.Fprintln(w, styles.Text.Width(width()).PaddingLeft(shortPad).Render(longShort))
149}
150
151var otherArgsRe = regexp.MustCompile(`(\[.*\])`)
152
153// styleUsage stylized styleUsage line for a given command.
154func styleUsage(c *cobra.Command, styles Program, complete bool) string {
155 u := c.Use
156 if complete {
157 u = c.UseLine()
158 }
159 hasArgs := strings.Contains(u, "[args]")
160 hasFlags := strings.Contains(u, "[flags]") || strings.Contains(u, "[--flags]") || c.HasFlags() || c.HasPersistentFlags() || c.HasAvailableFlags()
161 hasCommands := strings.Contains(u, "[command]") || c.HasAvailableSubCommands()
162 for _, k := range []string{
163 "[args]",
164 "[flags]", "[--flags]",
165 "[command]",
166 } {
167 u = strings.ReplaceAll(u, k, "")
168 }
169
170 var otherArgs []string //nolint:prealloc
171 for _, arg := range otherArgsRe.FindAllString(u, -1) {
172 u = strings.ReplaceAll(u, arg, "")
173 otherArgs = append(otherArgs, arg)
174 }
175
176 u = strings.TrimSpace(u)
177
178 useLine := []string{}
179 if complete {
180 parts := strings.Fields(u)
181 useLine = append(useLine, styles.Name.Render(parts[0]))
182 if len(parts) > 1 {
183 useLine = append(useLine, styles.Command.Render(" "+strings.Join(parts[1:], " ")))
184 }
185 } else {
186 useLine = append(useLine, styles.Command.Render(u))
187 }
188 if hasCommands {
189 useLine = append(
190 useLine,
191 styles.DimmedArgument.Render(" [command]"),
192 )
193 }
194 if hasArgs {
195 useLine = append(
196 useLine,
197 styles.DimmedArgument.Render(" [args]"),
198 )
199 }
200 for _, arg := range otherArgs {
201 useLine = append(
202 useLine,
203 styles.DimmedArgument.Render(" "+arg),
204 )
205 }
206 if hasFlags {
207 useLine = append(
208 useLine,
209 styles.DimmedArgument.Render(" [--flags]"),
210 )
211 }
212 return lipgloss.JoinHorizontal(lipgloss.Left, useLine...)
213}
214
215// styleExamples for a given command.
216// will print both the cmd.Use and cmd.Example bits.
217func styleExamples(c *cobra.Command, styles Styles) []string {
218 if c.Example == "" {
219 return nil
220 }
221 usage := []string{}
222 examples := strings.Split(c.Example, "\n")
223 var indent bool
224 for i, line := range examples {
225 line = strings.TrimSpace(line)
226 if (i == 0 || i == len(examples)-1) && line == "" {
227 continue
228 }
229 s := styleExample(c, line, indent, styles.Codeblock)
230 usage = append(usage, s)
231 indent = len(line) > 1 && (line[len(line)-1] == '\\' || line[len(line)-1] == '|')
232 }
233
234 return usage
235}
236
237func styleExample(c *cobra.Command, line string, indent bool, styles Codeblock) string {
238 if strings.HasPrefix(line, "# ") {
239 return lipgloss.JoinHorizontal(
240 lipgloss.Left,
241 styles.Comment.Render(line),
242 )
243 }
244
245 var isQuotedString bool
246 var foundProgramName bool
247 var isRedirecting bool
248 programName := c.Root().Name()
249 args := strings.Fields(line)
250 var cleanArgs []string
251 for i, arg := range args {
252 isQuoteStart := arg[0] == '"' || arg[0] == '\''
253 isQuoteEnd := arg[len(arg)-1] == '"' || arg[len(arg)-1] == '\''
254 isFlag := arg[0] == '-'
255
256 switch i {
257 case 0:
258 args[i] = ""
259 if indent {
260 args[i] = styles.Program.DimmedArgument.Render(" ")
261 indent = false
262 }
263 default:
264 args[i] = styles.Program.DimmedArgument.Render(" ")
265 }
266
267 if isRedirecting {
268 args[i] += styles.Program.DimmedArgument.Render(arg)
269 isRedirecting = false
270 continue
271 }
272
273 switch arg {
274 case "\\":
275 if i == len(args)-1 {
276 args[i] += styles.Program.DimmedArgument.Render(arg)
277 continue
278 }
279 case "|", "||", "-", "&", "&&":
280 args[i] += styles.Program.DimmedArgument.Render(arg)
281 continue
282 }
283
284 if isRedirect(arg) {
285 args[i] += styles.Program.DimmedArgument.Render(arg)
286 isRedirecting = true
287 continue
288 }
289
290 if !foundProgramName { //nolint:nestif
291 if isQuotedString {
292 args[i] += styles.Program.QuotedString.Render(arg)
293 isQuotedString = !isQuoteEnd
294 continue
295 }
296 if left, right, ok := strings.Cut(arg, "="); ok {
297 args[i] += styles.Program.Flag.Render(left + "=")
298 if right[0] == '"' {
299 isQuotedString = true
300 args[i] += styles.Program.QuotedString.Render(right)
301 continue
302 }
303 args[i] += styles.Program.Argument.Render(right)
304 continue
305 }
306
307 if arg == programName {
308 args[i] += styles.Program.Name.Render(arg)
309 foundProgramName = true
310 continue
311 }
312 }
313
314 if !isQuoteStart && !isQuotedString && !isFlag {
315 cleanArgs = append(cleanArgs, arg)
316 }
317
318 if !isQuoteStart && !isFlag && isSubCommand(c, cleanArgs, arg) {
319 args[i] += styles.Program.Command.Render(arg)
320 continue
321 }
322 isQuotedString = isQuotedString || isQuoteStart
323 if isQuotedString {
324 args[i] += styles.Program.QuotedString.Render(arg)
325 isQuotedString = !isQuoteEnd
326 continue
327 }
328 // handle a flag
329 if isFlag {
330 name, value, ok := strings.Cut(arg, "=")
331 // it is --flag=value
332 if ok {
333 args[i] += lipgloss.JoinHorizontal(
334 lipgloss.Left,
335 styles.Program.Flag.Render(name+"="),
336 styles.Program.Argument.Render(value),
337 )
338 continue
339 }
340 // it is either --bool-flag or --flag value
341 args[i] += lipgloss.JoinHorizontal(
342 lipgloss.Left,
343 styles.Program.Flag.Render(name),
344 )
345 continue
346 }
347
348 args[i] += styles.Program.Argument.Render(arg)
349 }
350
351 return lipgloss.JoinHorizontal(
352 lipgloss.Left,
353 args...,
354 )
355}
356
357func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
358 flags := map[string]string{}
359 keys := []string{}
360 c.Flags().VisitAll(func(f *pflag.Flag) {
361 if f.Hidden {
362 return
363 }
364 var parts []string
365 if f.Shorthand == "" {
366 parts = append(
367 parts,
368 styles.Program.Flag.Render("--"+f.Name),
369 )
370 } else {
371 parts = append(
372 parts,
373 styles.Program.Flag.Render("-"+f.Shorthand+" --"+f.Name),
374 )
375 }
376 key := lipgloss.JoinHorizontal(lipgloss.Left, parts...)
377 help := styles.FlagDescription.Render(f.Usage)
378 if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" && f.DefValue != "[]" {
379 help = lipgloss.JoinHorizontal(
380 lipgloss.Left,
381 help,
382 styles.FlagDefault.Render("("+f.DefValue+")"),
383 )
384 }
385 flags[key] = help
386 keys = append(keys, key)
387 })
388 return flags, keys
389}
390
391// result is map[groupID]map[styled cmd name]styled cmd help, and the keys in
392// the order they are defined.
393func evalCmds(c *cobra.Command, styles Styles) (map[string](map[string]string), []string) {
394 padStyle := lipgloss.NewStyle().PaddingLeft(0) //nolint:mnd
395 keys := []string{}
396 cmds := map[string]map[string]string{}
397 for _, sc := range c.Commands() {
398 if sc.Hidden {
399 continue
400 }
401 if _, ok := cmds[sc.GroupID]; !ok {
402 cmds[sc.GroupID] = map[string]string{}
403 }
404 key := padStyle.Render(styleUsage(sc, styles.Program, false))
405 help := styles.FlagDescription.Render(sc.Short)
406 cmds[sc.GroupID][key] = help
407 keys = append(keys, key)
408 }
409 return cmds, keys
410}
411
412func evalGroups(c *cobra.Command) (map[string]string, []string) {
413 // make sure the default group is the first
414 ids := []string{""}
415 groups := map[string]string{"": "commands"}
416 for _, g := range c.Groups() {
417 groups[g.ID] = g.Title
418 ids = append(ids, g.ID)
419 }
420 return groups, ids
421}
422
423func renderGroup(w io.Writer, styles Styles, space int, name string, items iter.Seq2[string, string]) {
424 _, _ = fmt.Fprintln(w, styles.Title.Render(name))
425 for key, help := range items {
426 _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
427 lipgloss.Left,
428 lipgloss.NewStyle().PaddingLeft(longPad).Render(key),
429 strings.Repeat(" ", space-lipgloss.Width(key)),
430 help,
431 ))
432 }
433}
434
435func calculateSpace(k1, k2 []string) int {
436 const spaceBetween = 2
437 space := minSpace
438 for _, k := range append(k1, k2...) {
439 space = max(space, lipgloss.Width(k)+spaceBetween)
440 }
441 return space
442}
443
444func isSubCommand(c *cobra.Command, args []string, word string) bool {
445 cmd, _, _ := c.Root().Traverse(args)
446 return cmd != nil && cmd.Name() == word
447}
448
449var redirectPrefixes = []string{">", "<", "&>", "2>", "1>", ">>", "2>>"}
450
451func isRedirect(s string) bool {
452 for _, p := range redirectPrefixes {
453 if strings.HasPrefix(s, p) {
454 return true
455 }
456 }
457 return false
458}