@@ -3,7 +3,10 @@ package fang
33import (
44 "cmp"
55 "fmt"
6+ "io"
7+ "iter"
68 "os"
9+ "reflect"
710 "regexp"
811 "strconv"
912 "strings"
@@ -20,6 +23,7 @@ import (
2023const (
2124 minSpace = 10
2225 shortPad = 2
26+ longPad = 4
2327)
2428
2529var width = sync.OnceValue(func() int {
@@ -45,65 +49,95 @@ func helpFn(c *cobra.Command, w *colorprofile.Writer, styles Styles) {
4549 blockWidth = max(blockWidth, lipgloss.Width(ex))
4650 }
4751 blockWidth = min(width()-padding, blockWidth+padding)
52+ blockStyle := styles.Codeblock.Base.Width(blockWidth)
4853
49− styles.Codeblock.Base = styles.Codeblock.Base.Width(blockWidth)
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+ }
5059
5160 _, _ = fmt.Fprintln(w, styles.Title.Render("usage"))
52− _, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(usage))
61+ _, _ = fmt.Fprintln(w, blockStyle.Render(usage))
5362 if len(examples) > 0 {
54− cw := styles.Codeblock.Base.GetWidth() - styles.Codeblock.Base.GetHorizontalPadding()
63+ cw := blockStyle.GetWidth() - blockStyle.GetHorizontalPadding()
5564 _, _ = fmt.Fprintln(w, styles.Title.Render("examples"))
5665 for i, example := range examples {
5766 if lipgloss.Width(example) > cw {
5867 examples[i] = ansi.Truncate(example, cw, "…")
5968 }
6069 }
61− _, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(strings.Join(examples, "\n")))
70+ _, _ = fmt.Fprintln(w, blockStyle.Render(strings.Join(examples, "\n")))
6271 }
6372
73+ groups, groupKeys := evalGroups(c)
6474 cmds, cmdKeys := evalCmds(c, styles)
6575 flags, flagKeys := evalFlags(c, styles)
6676 space := calculateSpace(cmdKeys, flagKeys)
6777
68− leftPadding := 4
69− if len(cmds) > 0 {
70− _, _ = fmt.Fprintln(w, styles.Title.Render("commands"))
71− for _, k := range cmdKeys {
72− _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
73− lipgloss.Left,
74− lipgloss.NewStyle().PaddingLeft(leftPadding).Render(k),
75− strings.Repeat(" ", space-lipgloss.Width(k)),
76− cmds[k],
77− ))
78+ for _, groupID := range groupKeys {
79+ group := cmds[groupID]
80+ if len(group) == 0 {
81+ continue
7882 }
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+ })
7994 }
8095
8196 if len(flags) > 0 {
82− _, _ = fmt.Fprintln(w, styles.Title.Render("flags"))
83− for _, k := range flagKeys {
84− _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
85− lipgloss.Left,
86− lipgloss.NewStyle().PaddingLeft(leftPadding).Render(k),
87− strings.Repeat(" ", space-lipgloss.Width(k)),
88− flags[k],
89− ))
90− }
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+ })
91104 }
92105
93106 _, _ = fmt.Fprintln(w)
94107}
95108
96−func writeError(w *colorprofile.Writer, styles Styles, err error) {
109+// DefaultErrorHandler is the default [ErrorHandler] implementation.
110+func DefaultErrorHandler(w io.Writer, styles Styles, err error) {
97111 _, _ = fmt.Fprintln(w, styles.ErrorHeader.String())
98112 _, _ = fmt.Fprintln(w, styles.ErrorText.Render(err.Error()+"."))
99113 _, _ = fmt.Fprintln(w)
100− _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal(
101− lipgloss.Left,
102− styles.ErrorText.UnsetWidth().Render("Try"),
103− styles.Program.Flag.Render("--help"),
104− styles.ErrorText.UnsetWidth().UnsetMargins().UnsetTransform().PaddingLeft(1).Render("for usage."),
105− ))
106− _, _ = 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
127+func 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
107141}
108142
109143func writeLongShort(w *colorprofile.Writer, styles Styles, longShort string) {
@@ -118,8 +152,10 @@ var otherArgsRe = regexp.MustCompile(`(\[.*\])`)
118152
119153// styleUsage stylized styleUsage line for a given command.
120154func styleUsage(c *cobra.Command, styles Program, complete bool) string {
121− // XXX: maybe use c.UseLine() here?
122155 u := c.Use
156+ if complete {
157+ u = c.UseLine()
158+ }
123159 hasArgs := strings.Contains(u, "[args]")
124160 hasFlags := strings.Contains(u, "[flags]") || strings.Contains(u, "[--flags]") || c.HasFlags() || c.HasPersistentFlags() || c.HasAvailableFlags()
125161 hasCommands := strings.Contains(u, "[command]") || c.HasAvailableSubCommands()
@@ -139,34 +175,38 @@ func styleUsage(c *cobra.Command, styles Program, complete bool) string {
139175
140176 u = strings.TrimSpace(u)
141177
142− useLine := []string{
143− styles.Name.Render(u),
144− }
145− if !complete {
146− useLine[0] = styles.Command.Render(u)
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))
147187 }
148188 if hasCommands {
149189 useLine = append(
150190 useLine,
151− styles.DimmedArgument.Render("[command]"),
191+ styles.DimmedArgument.Render(" [command]"),
152192 )
153193 }
154194 if hasArgs {
155195 useLine = append(
156196 useLine,
157− styles.DimmedArgument.Render("[args]"),
197+ styles.DimmedArgument.Render(" [args]"),
158198 )
159199 }
160200 for _, arg := range otherArgs {
161201 useLine = append(
162202 useLine,
163− styles.DimmedArgument.Render(arg),
203+ styles.DimmedArgument.Render(" "+arg),
164204 )
165205 }
166206 if hasFlags {
167207 useLine = append(
168208 useLine,
169− styles.DimmedArgument.Render("[--flags]"),
209+ styles.DimmedArgument.Render(" [--flags]"),
170210 )
171211 }
172212 return lipgloss.JoinHorizontal(lipgloss.Left, useLine...)
@@ -180,19 +220,21 @@ func styleExamples(c *cobra.Command, styles Styles) []string {
180220 }
181221 usage := []string{}
182222 examples := strings.Split(c.Example, "\n")
223+ var indent bool
183224 for i, line := range examples {
184225 line = strings.TrimSpace(line)
185226 if (i == 0 || i == len(examples)-1) && line == "" {
186227 continue
187228 }
188− s := styleExample(c, line, styles.Codeblock)
229+ s := styleExample(c, line, indent, styles.Codeblock)
189230 usage = append(usage, s)
231+ indent = len(line) > 1 && (line[len(line)-1] == '\\' || line[len(line)-1] == '|')
190232 }
191233
192234 return usage
193235}
194236
195−func styleExample(c *cobra.Command, line string, styles Codeblock) string {
237+func styleExample(c *cobra.Command, line string, indent bool, styles Codeblock) string {
196238 if strings.HasPrefix(line, "# ") {
197239 return lipgloss.JoinHorizontal(
198240 lipgloss.Left,
@@ -200,66 +242,110 @@ func styleExample(c *cobra.Command, line string, styles Codeblock) string {
200242 )
201243 }
202244
203− args := strings.Fields(line)
204− var nextIsFlag bool
205245 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
206251 for i, arg := range args {
207− if i == 0 {
208− args[i] = styles.Program.Name.Render(arg)
209− continue
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(" ")
210265 }
211266
212− quoteStart := arg[0] == '"'
213− quoteEnd := arg[len(arg)-1] == '"'
214− flagStart := arg[0] == '-'
215− if i == 1 && !quoteStart && !flagStart {
216− args[i] = styles.Program.Command.Render(arg)
267+ if isRedirecting {
268+ args[i] += styles.Program.DimmedArgument.Render(arg)
269+ isRedirecting = false
217270 continue
218271 }
219− if quoteStart {
220− isQuotedString = true
221− }
222− if isQuotedString {
223− args[i] = styles.Program.QuotedString.Render(arg)
224− if quoteEnd {
225− isQuotedString = false
272+
273+ switch arg {
274+ case "\\":
275+ if i == len(args)-1 {
276+ args[i] += styles.Program.DimmedArgument.Render(arg)
277+ continue
226278 }
279+ case "|", "||", "-", "&", "&&":
280+ args[i] += styles.Program.DimmedArgument.Render(arg)
227281 continue
228282 }
229− if nextIsFlag {
230− args[i] = styles.Program.Flag.Render(arg)
283+
284+ if isRedirect(arg) {
285+ args[i] += styles.Program.DimmedArgument.Render(arg)
286+ isRedirecting = true
231287 continue
232288 }
233− var dashes string
234− if strings.HasPrefix(arg, "-") {
235− dashes = "-"
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+ }
236312 }
237− if strings.HasPrefix(arg, "--") {
238− dashes = "--"
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
239327 }
240328 // handle a flag
241− if dashes != "" {
329+ if isFlag {
242330 name, value, ok := strings.Cut(arg, "=")
243− name = strings.TrimPrefix(name, dashes)
244331 // it is --flag=value
245332 if ok {
246− args[i] = lipgloss.JoinHorizontal(
333+ args[i] += lipgloss.JoinHorizontal(
247334 lipgloss.Left,
248− styles.Program.Flag.Render(dashes+name+"="),
249− styles.Program.Argument.UnsetPadding().Render(value),
335+ styles.Program.Flag.Render(name+"="),
336+ styles.Program.Argument.Render(value),
250337 )
251338 continue
252339 }
253340 // it is either --bool-flag or --flag value
254− args[i] = lipgloss.JoinHorizontal(
341+ args[i] += lipgloss.JoinHorizontal(
255342 lipgloss.Left,
256− styles.Program.Flag.Render(dashes+name),
343+ styles.Program.Flag.Render(name),
257344 )
258− // if the flag is not a bool flag, next arg continues current flag
259− nextIsFlag = !isFlagBool(c, name)
260345 continue
261346 }
262− args[i] = styles.Program.Argument.Render(arg)
347+
348+ args[i] += styles.Program.Argument.Render(arg)
263349 }
264350
265351 return lipgloss.JoinHorizontal(
@@ -284,8 +370,7 @@ func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
284370 } else {
285371 parts = append(
286372 parts,
287− styles.Program.Flag.Render("-"+f.Shorthand),
288− styles.Program.Flag.Render("--"+f.Name),
373+ styles.Program.Flag.Render("-"+f.Shorthand+" --"+f.Name),
289374 )
290375 }
291376 key := lipgloss.JoinHorizontal(lipgloss.Left, parts...)
@@ -303,22 +388,50 @@ func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
303388 return flags, keys
304389}
305390
306−func evalCmds(c *cobra.Command, styles Styles) (map[string]string, []string) {
391+// result is map[groupID]map[styled cmd name]styled cmd help, and the keys in
392+// the order they are defined.
393+func evalCmds(c *cobra.Command, styles Styles) (map[string](map[string]string), []string) {
307394 padStyle := lipgloss.NewStyle().PaddingLeft(0) //nolint:mnd
308395 keys := []string{}
309− cmds := map[string]string{}
396+ cmds := map[string]map[string]string{}
310397 for _, sc := range c.Commands() {
311398 if sc.Hidden {
312399 continue
313400 }
401+ if _, ok := cmds[sc.GroupID]; !ok {
402+ cmds[sc.GroupID] = map[string]string{}
403+ }
314404 key := padStyle.Render(styleUsage(sc, styles.Program, false))
315405 help := styles.FlagDescription.Render(sc.Short)
316− cmds[key] = help
406+ cmds[sc.GroupID][key] = help
317407 keys = append(keys, key)
318408 }
319409 return cmds, keys
320410}
321411
412+func 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+
423+func 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+
322435func calculateSpace(k1, k2 []string) int {
323436 const spaceBetween = 2
324437 space := minSpace
@@ -328,13 +441,18 @@ func calculateSpace(k1, k2 []string) int {
328441 return space
329442}
330443
331−func isFlagBool(c *cobra.Command, name string) bool {
332− flag := c.Flags().Lookup(name)
333− if flag == nil && len(name) == 1 {
334− flag = c.Flags().ShorthandLookup(name)
335− }
336− if flag == nil {
337− return false
444+func isSubCommand(c *cobra.Command, args []string, word string) bool {
445+ cmd, _, _ := c.Root().Traverse(args)
446+ return cmd != nil && cmd.Name() == word
447+}
448+
449+var redirectPrefixes = []string{">", "<", "&>", "2>", "1>", ">>", "2>>"}
450+
451+func isRedirect(s string) bool {
452+ for _, p := range redirectPrefixes {
453+ if strings.HasPrefix(s, p) {
454+ return true
455+ }
338456 }
339− return flag.Value.Type() == "bool"
457+ return false
340458}