help.go

  1package fang
  2
  3import (
  4	"cmp"
  5	"fmt"
  6	"os"
  7	"regexp"
  8	"strconv"
  9	"strings"
 10	"sync"
 11
 12	"github.com/charmbracelet/colorprofile"
 13	"github.com/charmbracelet/lipgloss/v2"
 14	"github.com/charmbracelet/x/ansi"
 15	"github.com/charmbracelet/x/term"
 16	"github.com/spf13/cobra"
 17	"github.com/spf13/pflag"
 18)
 19
 20const (
 21	minSpace = 10
 22	shortPad = 2
 23)
 24
 25var width = sync.OnceValue(func() int {
 26	if s := os.Getenv("__FANG_TEST_WIDTH"); s != "" {
 27		w, _ := strconv.Atoi(s)
 28		return w
 29	}
 30	w, _, err := term.GetSize(os.Stdout.Fd())
 31	if err != nil {
 32		return 120
 33	}
 34	return min(w, 120)
 35})
 36
 37func helpFn(c *cobra.Command, w *colorprofile.Writer, styles Styles) {
 38	writeLongShort(w, styles, cmp.Or(c.Long, c.Short))
 39	usage := styleUsage(c, styles.Codeblock.Program, true)
 40	examples := styleExamples(c, styles)
 41
 42	padding := styles.Codeblock.Base.GetHorizontalPadding()
 43	blockWidth := lipgloss.Width(usage)
 44	for _, ex := range examples {
 45		blockWidth = max(blockWidth, lipgloss.Width(ex))
 46	}
 47	blockWidth = min(width()-padding, blockWidth+padding)
 48
 49	styles.Codeblock.Base = styles.Codeblock.Base.Width(blockWidth)
 50
 51	_, _ = fmt.Fprintln(w, styles.Title.Render("usage"))
 52	_, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(usage))
 53	if len(examples) > 0 {
 54		cw := styles.Codeblock.Base.GetWidth() - styles.Codeblock.Base.GetHorizontalPadding()
 55		_, _ = fmt.Fprintln(w, styles.Title.Render("examples"))
 56		for i, example := range examples {
 57			if lipgloss.Width(example) > cw {
 58				examples[i] = ansi.Truncate(example, cw, "…")
 59			}
 60		}
 61		_, _ = fmt.Fprintln(w, styles.Codeblock.Base.Render(strings.Join(examples, "\n")))
 62	}
 63
 64	cmds, cmdKeys := evalCmds(c, styles)
 65	flags, flagKeys := evalFlags(c, styles)
 66	space := calculateSpace(cmdKeys, flagKeys)
 67
 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		}
 79	}
 80
 81	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		}
 91	}
 92
 93	_, _ = fmt.Fprintln(w)
 94}
 95
 96func writeError(w *colorprofile.Writer, styles Styles, err error) {
 97	_, _ = fmt.Fprintln(w, styles.ErrorHeader.String())
 98	_, _ = fmt.Fprintln(w, styles.ErrorText.Render(err.Error()+"."))
 99	_, _ = 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)
107}
108
109func writeLongShort(w *colorprofile.Writer, styles Styles, longShort string) {
110	if longShort == "" {
111		return
112	}
113	_, _ = fmt.Fprintln(w)
114	_, _ = fmt.Fprintln(w, styles.Text.Width(width()).PaddingLeft(shortPad).Render(longShort))
115}
116
117var otherArgsRe = regexp.MustCompile(`(\[.*\])`)
118
119// styleUsage stylized styleUsage line for a given command.
120func styleUsage(c *cobra.Command, styles Program, complete bool) string {
121	// XXX: maybe use c.UseLine() here?
122	u := c.Use
123	hasArgs := strings.Contains(u, "[args]")
124	hasFlags := strings.Contains(u, "[flags]") || strings.Contains(u, "[--flags]") || c.HasFlags() || c.HasPersistentFlags() || c.HasAvailableFlags()
125	hasCommands := strings.Contains(u, "[command]") || c.HasAvailableSubCommands()
126	for _, k := range []string{
127		"[args]",
128		"[flags]", "[--flags]",
129		"[command]",
130	} {
131		u = strings.ReplaceAll(u, k, "")
132	}
133
134	var otherArgs []string //nolint:prealloc
135	for _, arg := range otherArgsRe.FindAllString(u, -1) {
136		u = strings.ReplaceAll(u, arg, "")
137		otherArgs = append(otherArgs, arg)
138	}
139
140	u = strings.TrimSpace(u)
141
142	useLine := []string{
143		styles.Name.Render(u),
144	}
145	if !complete {
146		useLine[0] = styles.Command.Render(u)
147	}
148	if hasCommands {
149		useLine = append(
150			useLine,
151			styles.DimmedArgument.Render("[command]"),
152		)
153	}
154	if hasArgs {
155		useLine = append(
156			useLine,
157			styles.DimmedArgument.Render("[args]"),
158		)
159	}
160	for _, arg := range otherArgs {
161		useLine = append(
162			useLine,
163			styles.DimmedArgument.Render(arg),
164		)
165	}
166	if hasFlags {
167		useLine = append(
168			useLine,
169			styles.DimmedArgument.Render("[--flags]"),
170		)
171	}
172	return lipgloss.JoinHorizontal(lipgloss.Left, useLine...)
173}
174
175// styleExamples for a given command.
176// will print both the cmd.Use and cmd.Example bits.
177func styleExamples(c *cobra.Command, styles Styles) []string {
178	if c.Example == "" {
179		return nil
180	}
181	usage := []string{}
182	examples := strings.Split(c.Example, "\n")
183	for i, line := range examples {
184		line = strings.TrimSpace(line)
185		if (i == 0 || i == len(examples)-1) && line == "" {
186			continue
187		}
188		s := styleExample(c, line, styles.Codeblock)
189		usage = append(usage, s)
190	}
191
192	return usage
193}
194
195func styleExample(c *cobra.Command, line string, styles Codeblock) string {
196	if strings.HasPrefix(line, "# ") {
197		return lipgloss.JoinHorizontal(
198			lipgloss.Left,
199			styles.Comment.Render(line),
200		)
201	}
202
203	args := strings.Fields(line)
204	var nextIsFlag bool
205	var isQuotedString bool
206	for i, arg := range args {
207		if i == 0 {
208			args[i] = styles.Program.Name.Render(arg)
209			continue
210		}
211
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)
217			continue
218		}
219		if quoteStart {
220			isQuotedString = true
221		}
222		if isQuotedString {
223			args[i] = styles.Program.QuotedString.Render(arg)
224			if quoteEnd {
225				isQuotedString = false
226			}
227			continue
228		}
229		if nextIsFlag {
230			args[i] = styles.Program.Flag.Render(arg)
231			continue
232		}
233		var dashes string
234		if strings.HasPrefix(arg, "-") {
235			dashes = "-"
236		}
237		if strings.HasPrefix(arg, "--") {
238			dashes = "--"
239		}
240		// handle a flag
241		if dashes != "" {
242			name, value, ok := strings.Cut(arg, "=")
243			name = strings.TrimPrefix(name, dashes)
244			// it is --flag=value
245			if ok {
246				args[i] = lipgloss.JoinHorizontal(
247					lipgloss.Left,
248					styles.Program.Flag.Render(dashes+name+"="),
249					styles.Program.Argument.UnsetPadding().Render(value),
250				)
251				continue
252			}
253			// it is either --bool-flag or --flag value
254			args[i] = lipgloss.JoinHorizontal(
255				lipgloss.Left,
256				styles.Program.Flag.Render(dashes+name),
257			)
258			// if the flag is not a bool flag, next arg continues current flag
259			nextIsFlag = !isFlagBool(c, name)
260			continue
261		}
262		args[i] = styles.Program.Argument.Render(arg)
263	}
264
265	return lipgloss.JoinHorizontal(
266		lipgloss.Left,
267		args...,
268	)
269}
270
271func evalFlags(c *cobra.Command, styles Styles) (map[string]string, []string) {
272	flags := map[string]string{}
273	keys := []string{}
274	c.Flags().VisitAll(func(f *pflag.Flag) {
275		if f.Hidden {
276			return
277		}
278		var parts []string
279		if f.Shorthand == "" {
280			parts = append(
281				parts,
282				styles.Program.Flag.Render("--"+f.Name),
283			)
284		} else {
285			parts = append(
286				parts,
287				styles.Program.Flag.Render("-"+f.Shorthand),
288				styles.Program.Flag.Render("--"+f.Name),
289			)
290		}
291		key := lipgloss.JoinHorizontal(lipgloss.Left, parts...)
292		help := styles.FlagDescription.Render(f.Usage)
293		if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" && f.DefValue != "[]" {
294			help = lipgloss.JoinHorizontal(
295				lipgloss.Left,
296				help,
297				styles.FlagDefault.Render("("+f.DefValue+")"),
298			)
299		}
300		flags[key] = help
301		keys = append(keys, key)
302	})
303	return flags, keys
304}
305
306func evalCmds(c *cobra.Command, styles Styles) (map[string]string, []string) {
307	padStyle := lipgloss.NewStyle().PaddingLeft(0) //nolint:mnd
308	keys := []string{}
309	cmds := map[string]string{}
310	for _, sc := range c.Commands() {
311		if sc.Hidden {
312			continue
313		}
314		key := padStyle.Render(styleUsage(sc, styles.Program, false))
315		help := styles.FlagDescription.Render(sc.Short)
316		cmds[key] = help
317		keys = append(keys, key)
318	}
319	return cmds, keys
320}
321
322func calculateSpace(k1, k2 []string) int {
323	const spaceBetween = 2
324	space := minSpace
325	for _, k := range append(k1, k2...) {
326		space = max(space, lipgloss.Width(k)+spaceBetween)
327	}
328	return space
329}
330
331func 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
338	}
339	return flag.Value.Type() == "bool"
340}