help.go

  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}