From 6bce51ec507b9d6cfa7702dcf618a3e13a531232 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 6 Jun 2026 22:32:38 +0300 Subject: [PATCH 01/12] move help output functions to help.go --- internal/cmd/help.go | 165 +++++++++++++++++++++++++++++++++++++++++++ internal/cmd/root.go | 157 ---------------------------------------- 2 files changed, 165 insertions(+), 157 deletions(-) create mode 100644 internal/cmd/help.go diff --git a/internal/cmd/help.go b/internal/cmd/help.go new file mode 100644 index 00000000..94324128 --- /dev/null +++ b/internal/cmd/help.go @@ -0,0 +1,165 @@ +package cmd + +import ( + "cmp" + "fmt" + "slices" + "sort" + "strings" + + "github.com/lets-cli/lets/internal/set" + "github.com/spf13/cobra" +) + +func PrintHelpMessage(cmd *cobra.Command) error { + help := cmd.UsageString() + help = fmt.Sprintf("%s\n\n%s", cmd.Short, help) + help = strings.Replace(help, "lets [command] --help", "lets help [command]", 1) + _, err := fmt.Fprint(cmd.OutOrStdout(), help) + + return err +} + +func PrintRootHelpMessage(cmd *cobra.Command) error { + var builder strings.Builder + builder.WriteString(cmd.Short) + builder.WriteString("\n\n") + + // General + builder.WriteString("Usage:\n") + + if cmd.Runnable() { + fmt.Fprintf(&builder, " %s\n", cmd.UseLine()) + } + + if cmd.HasAvailableSubCommands() { + fmt.Fprintf(&builder, " %s [command]\n", cmd.CommandPath()) + } + + builder.WriteByte('\n') + + // Commands + for _, group := range cmd.Groups() { + builder.WriteString(buildGroupCommandHelp(cmd, group)) + } + + // Flags + if cmd.HasAvailableLocalFlags() { + builder.WriteString("Flags:\n") + builder.WriteString(cmd.LocalFlags().FlagUsagesWrapped(120)) + builder.WriteByte('\n') + } + + // Usage + fmt.Fprintf(&builder, `Use "%s help [command]" for more information about a command.`, cmd.CommandPath()) + + _, err := fmt.Fprint(cmd.OutOrStdout(), builder.String()) + + return err +} + +func maxCommandNameLen(cmd *cobra.Command) int { + commands := cmd.Commands() + if len(commands) == 0 { + return 0 + } + + maxCmd := slices.MaxFunc(commands, func(a, b *cobra.Command) int { + return cmp.Compare(len(a.Name()), len(b.Name())) + }) + + return len(maxCmd.Name()) +} + +func rpad(s string, padding int) string { + formattedString := fmt.Sprintf("%%-%ds", padding) + return fmt.Sprintf(formattedString, s) +} + +func hasSubgroup(cmd *cobra.Command) bool { + subgroups := make(map[string]struct{}) + + for _, c := range cmd.Commands() { + if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { + subgroups[subgroup] = struct{}{} + if len(subgroups) > 1 { + return true + } + } + } + + return false +} + +func writeGroupCommandHelpLine(builder *strings.Builder, prefix string, name string, padding int, suffix string, short string) { + builder.WriteString(" ") + builder.WriteString(prefix) + builder.WriteString(rpad(name, padding)) + builder.WriteString(suffix) + builder.WriteString(" ") + builder.WriteString(short) + builder.WriteByte('\n') +} + +func buildGroupCommandHelp(cmd *cobra.Command, group *cobra.Group) string { + cmds := []*cobra.Command{} + + // select commands that belong to the specified group + for _, c := range cmd.Commands() { + if c.GroupID == group.ID && (c.IsAvailableCommand() || c.Name() == "help") { + cmds = append(cmds, c) + } + } + + padding := maxCommandNameLen(cmd) + + sort.Slice(cmds, func(i, j int) bool { + return cmds[i].Name() < cmds[j].Name() + }) + + // Create a list of subgroups + subGroupNameSet := set.NewSet[string]() + + for _, c := range cmds { + if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { + subGroupNameSet.Add(subgroup) + } + } + + subGroupNameList := subGroupNameSet.ToList() + sort.Strings(subGroupNameList) + + // generate output + var builder strings.Builder + builder.WriteString(group.Title) + builder.WriteByte('\n') + + intend := "" + if hasSubgroup(cmd) { + intend = " " + } + + for _, subgroupName := range subGroupNameList { + if len(subGroupNameList) > 1 { + builder.WriteString("\n ") + builder.WriteString(subgroupName) + builder.WriteByte('\n') + } + + for _, c := range cmds { + if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup == subgroupName { + writeGroupCommandHelpLine(&builder, intend, c.Name(), padding, "", c.Short) + } + } + } + + for _, c := range cmds { + if _, ok := c.Annotations["SubGroupName"]; !ok { + writeGroupCommandHelpLine(&builder, "", c.Name(), padding, intend, c.Short) + } + } + + builder.WriteByte('\n') + + return builder.String() +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 30d87c1f..0ee36979 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1,13 +1,9 @@ package cmd import ( - "cmp" "fmt" - "slices" - "sort" "strings" - "github.com/lets-cli/lets/internal/set" "github.com/spf13/cobra" ) @@ -126,156 +122,3 @@ func initRootFlags(rootCmd *cobra.Command) { rootCmd.Flags().StringP("config", "c", "", "config file (default is lets.yaml)") rootCmd.Flags().Bool("all", false, "show all commands (including the ones with _)") } - -func PrintHelpMessage(cmd *cobra.Command) error { - help := cmd.UsageString() - help = fmt.Sprintf("%s\n\n%s", cmd.Short, help) - help = strings.Replace(help, "lets [command] --help", "lets help [command]", 1) - _, err := fmt.Fprint(cmd.OutOrStdout(), help) - - return err -} - -func maxCommandNameLen(cmd *cobra.Command) int { - commands := cmd.Commands() - if len(commands) == 0 { - return 0 - } - - maxCmd := slices.MaxFunc(commands, func(a, b *cobra.Command) int { - return cmp.Compare(len(a.Name()), len(b.Name())) - }) - - return len(maxCmd.Name()) -} - -func rpad(s string, padding int) string { - formattedString := fmt.Sprintf("%%-%ds", padding) - return fmt.Sprintf(formattedString, s) -} - -func hasSubgroup(cmd *cobra.Command) bool { - subgroups := make(map[string]struct{}) - - for _, c := range cmd.Commands() { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { - subgroups[subgroup] = struct{}{} - if len(subgroups) > 1 { - return true - } - } - } - - return false -} - -func writeGroupCommandHelpLine(builder *strings.Builder, prefix string, name string, padding int, suffix string, short string) { - builder.WriteString(" ") - builder.WriteString(prefix) - builder.WriteString(rpad(name, padding)) - builder.WriteString(suffix) - builder.WriteString(" ") - builder.WriteString(short) - builder.WriteByte('\n') -} - -func buildGroupCommandHelp(cmd *cobra.Command, group *cobra.Group) string { - cmds := []*cobra.Command{} - - // select commands that belong to the specified group - for _, c := range cmd.Commands() { - if c.GroupID == group.ID && (c.IsAvailableCommand() || c.Name() == "help") { - cmds = append(cmds, c) - } - } - - padding := maxCommandNameLen(cmd) - - sort.Slice(cmds, func(i, j int) bool { - return cmds[i].Name() < cmds[j].Name() - }) - - // Create a list of subgroups - subGroupNameSet := set.NewSet[string]() - - for _, c := range cmds { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { - subGroupNameSet.Add(subgroup) - } - } - - subGroupNameList := subGroupNameSet.ToList() - sort.Strings(subGroupNameList) - - // generate output - var builder strings.Builder - builder.WriteString(group.Title) - builder.WriteByte('\n') - - intend := "" - if hasSubgroup(cmd) { - intend = " " - } - - for _, subgroupName := range subGroupNameList { - if len(subGroupNameList) > 1 { - builder.WriteString("\n ") - builder.WriteString(subgroupName) - builder.WriteByte('\n') - } - - for _, c := range cmds { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup == subgroupName { - writeGroupCommandHelpLine(&builder, intend, c.Name(), padding, "", c.Short) - } - } - } - - for _, c := range cmds { - if _, ok := c.Annotations["SubGroupName"]; !ok { - writeGroupCommandHelpLine(&builder, "", c.Name(), padding, intend, c.Short) - } - } - - builder.WriteByte('\n') - - return builder.String() -} - -func PrintRootHelpMessage(cmd *cobra.Command) error { - var builder strings.Builder - builder.WriteString(cmd.Short) - builder.WriteString("\n\n") - - // General - builder.WriteString("Usage:\n") - - if cmd.Runnable() { - fmt.Fprintf(&builder, " %s\n", cmd.UseLine()) - } - - if cmd.HasAvailableSubCommands() { - fmt.Fprintf(&builder, " %s [command]\n", cmd.CommandPath()) - } - - builder.WriteByte('\n') - - // Commands - for _, group := range cmd.Groups() { - builder.WriteString(buildGroupCommandHelp(cmd, group)) - } - - // Flags - if cmd.HasAvailableLocalFlags() { - builder.WriteString("Flags:\n") - builder.WriteString(cmd.LocalFlags().FlagUsagesWrapped(120)) - builder.WriteByte('\n') - } - - // Usage - fmt.Fprintf(&builder, `Use "%s help [command]" for more information about a command.`, cmd.CommandPath()) - - _, err := fmt.Fprint(cmd.OutOrStdout(), builder.String()) - - return err -} From b52cc2b3a1335725d313142cd123f1be179d22b8 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 12:36:10 +0300 Subject: [PATCH 02/12] Use Fang help renderer --- go.mod | 30 +- go.sum | 47 +++ internal/cli/cli.go | 12 +- internal/cmd/help.go | 534 ++++++++++++++++++++++++++------ internal/cmd/help_test.go | 193 ++++++++++++ internal/cmd/root.go | 15 +- internal/cmd/subcommand.go | 90 +++++- internal/cmd/subcommand_test.go | 113 +++++++ internal/docopt/docopts.go | 135 ++++++++ internal/docopt/docopts_test.go | 70 +++++ internal/theme/theme.go | 75 +++++ 11 files changed, 1185 insertions(+), 129 deletions(-) create mode 100644 internal/cmd/help_test.go create mode 100644 internal/cmd/subcommand_test.go create mode 100644 internal/docopt/docopts_test.go create mode 100644 internal/theme/theme.go diff --git a/go.mod b/go.mod index 41d0eb7a..8874d85c 100644 --- a/go.mod +++ b/go.mod @@ -16,25 +16,43 @@ require ( github.com/spf13/cobra v1.10.2 github.com/tliron/commonlog v0.2.8 github.com/tliron/glsp v0.2.2 - golang.org/x/sync v0.3.0 + golang.org/x/sync v0.19.0 ) require ( + charm.land/lipgloss/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/iancoleman/strcase v0.3.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lets-cli/fang v0.0.0-20260606200525-e1065b982d8f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect github.com/tliron/kutil v0.3.11 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.15.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/term v0.14.0 // indirect + golang.org/x/text v0.24.0 // indirect ) require ( @@ -46,7 +64,9 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/lithammer/dedent v1.1.0 github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.14.0 // indirect + golang.org/x/sys v0.42.0 // indirect gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v3 v3.0.1 ) + +replace github.com/lets-cli/fang => ../fang diff --git a/go.sum b/go.sum index d87ef75c..e3bb65c8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,25 @@ +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/codeclysm/extract v2.2.0+incompatible h1:q3wyckoA30bhUSiwdQezMqVhwd8+WGE64/GL//LtUhI= github.com/codeclysm/extract v2.2.0+incompatible/go.mod h1:2nhFMPHiU9At61hz+12bfrlpXSUrOnK+wR+KlGO4Uks= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= @@ -56,10 +76,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lets-cli/fang v0.0.0-20260606200525-e1065b982d8f h1:ROBQlxpk/t1oSyo8my2nLnP8SJo1Y4AWwLD5VHT3SsE= +github.com/lets-cli/fang v0.0.0-20260606200525-e1065b982d8f/go.mod h1:ReK1Yg4pNI2OOCSvDS3LBIV+cgjv/KwdEhz7JXz94M4= github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/masterzen/azure-sdk-for-go v3.2.0-beta.0.20161014135628-ee4f0065d00c+incompatible/go.mod h1:mf8fjOu33zCqxUjuiU3I8S1lJMyEAlH+0F2+M5xl3hE= github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= @@ -74,6 +98,18 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= @@ -89,6 +125,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= @@ -103,12 +141,15 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/tliron/commonlog v0.2.8 h1:vpKrEsZX4nlneC9673pXpeKqv3cFLxwpzNEZF1qiaQQ= github.com/tliron/commonlog v0.2.8/go.mod h1:HgQZrJEuiKLLRvUixtPWGcmTmWWtKkCtywF6x9X5Spw= github.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c= github.com/tliron/glsp v0.2.2/go.mod h1:GMVWDNeODxHzmDPvYbYTCs7yHVaEATfYtXiYJ9w1nBg= github.com/tliron/kutil v0.3.11 h1:kongR0dhrrn9FR/3QRFoUfQe27t78/xQvrU9aXIy5bk= github.com/tliron/kutil v0.3.11/go.mod h1:4IqOAAdpJuDxYbJxMv4nL8LSH0mPofSrdwIv8u99PDc= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180214000028-650f4a345ab4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -123,6 +164,8 @@ golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -131,9 +174,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20160105164936-4f90aeace3a2/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4f54cbbe..9be1e53d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "github.com/lets-cli/fang" "github.com/lets-cli/lets/internal/cmd" "github.com/lets-cli/lets/internal/config" "github.com/lets-cli/lets/internal/env" @@ -17,6 +18,7 @@ import ( "github.com/lets-cli/lets/internal/logging" "github.com/lets-cli/lets/internal/set" "github.com/lets-cli/lets/internal/settings" + "github.com/lets-cli/lets/internal/theme" "github.com/lets-cli/lets/internal/upgrade" "github.com/lets-cli/lets/internal/upgrade/registry" "github.com/lets-cli/lets/internal/workdir" @@ -106,7 +108,13 @@ func Main(version string, buildDate string) int { updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command, appSettings) defer cancelUpdateCheck() - if err := rootCmd.ExecuteContext(ctx); err != nil { + if err := fang.Execute( + ctx, + rootCmd, + fang.WithColorSchemeFunc(theme.DefaultColorScheme), + fang.WithErrorHandler(cmd.ErrorHandler), + fang.WithHelpRenderer(cmd.HelpRenderer), + ); err != nil { if depErr, ok := errors.AsType[*executor.DependencyError](err); ok { log.Errorf("%s", depErr.TreeMessage()) log.Errorf("%s", depErr.FailureMessage()) @@ -114,8 +122,6 @@ func Main(version string, buildDate string) int { return getExitCode(err, 1) } - log.Errorf("%s", err.Error()) - return getExitCode(err, 1) } diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 94324128..fb0451a1 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -1,165 +1,507 @@ package cmd import ( - "cmp" + "encoding/json" "fmt" - "slices" + "io" + "reflect" + "regexp" "sort" "strings" - "github.com/lets-cli/lets/internal/set" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" + "github.com/lets-cli/fang" + "github.com/lets-cli/lets/internal/docopt" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) -func PrintHelpMessage(cmd *cobra.Command) error { - help := cmd.UsageString() - help = fmt.Sprintf("%s\n\n%s", cmd.Short, help) - help = strings.Replace(help, "lets [command] --help", "lets help [command]", 1) - _, err := fmt.Fprint(cmd.OutOrStdout(), help) +type helpItem struct { + key string + help string +} - return err +type commandHelpItem struct { + name string + subgroup string + key string + help string } -func PrintRootHelpMessage(cmd *cobra.Command) error { - var builder strings.Builder - builder.WriteString(cmd.Short) - builder.WriteString("\n\n") +var optionalArgsRe = regexp.MustCompile(`(\[.*\])`) + +func HelpRenderer(cmd *cobra.Command, ctx fang.HelpContext) { + renderLongShort(ctx.Writer, ctx.Styles, ctx.Width, cmpOr(cmd.Long, cmd.Short)) + + usage := styleHelpUsage(cmd, ctx.Styles.Codeblock.Program, true) + examples := fang.StyleExamples(cmd, ctx.Styles) + blockStyle := compactCodeBlockStyle(ctx, append([]string{usage}, examples...)...) + usageTitle := ctx.Styles.Title.Margin(0, 0) + sectionTitle := compactTitleStyle(ctx.Styles) - // General - builder.WriteString("Usage:\n") + _, _ = fmt.Fprintln(ctx.Writer, usageTitle.Render("usage")) + _, _ = fmt.Fprintln(ctx.Writer, blockStyle.Render(usage)) - if cmd.Runnable() { - fmt.Fprintf(&builder, " %s\n", cmd.UseLine()) + if len(examples) > 0 { + cw := blockStyle.GetWidth() - blockStyle.GetHorizontalPadding() + _, _ = fmt.Fprintln(ctx.Writer, sectionTitle.Render("examples")) + for i, example := range examples { + if lipgloss.Width(example) > cw { + examples[i] = ansi.Truncate(example, cw, "…") + } + } + _, _ = fmt.Fprintln(ctx.Writer, blockStyle.Render(strings.Join(examples, "\n"))) } - if cmd.HasAvailableSubCommands() { - fmt.Fprintf(&builder, " %s [command]\n", cmd.CommandPath()) + groups, groupKeys := helpGroups(cmd) + commands := helpCommands(cmd, ctx.Styles) + options, optionsTitle := helpOptions(cmd, ctx.Styles) + hasSubgroups := hasMultipleSubgroups(commands) + space := helpSpace(commands, options, hasSubgroups) + + for _, groupID := range groupKeys { + items := commands[groupID] + if len(items) == 0 { + continue + } + renderCommandGroup(ctx.Writer, ctx.Styles, space, groups[groupID], items, hasSubgroups) } - builder.WriteByte('\n') + if len(options) > 0 { + renderHelpGroup(ctx.Writer, ctx.Styles, space, optionsTitle, options) + } - // Commands - for _, group := range cmd.Groups() { - builder.WriteString(buildGroupCommandHelp(cmd, group)) + _, _ = fmt.Fprintln(ctx.Writer) +} + +func ErrorHandler(w io.Writer, styles fang.Styles, err error) { + if w, ok := w.(term.File); ok { + if !term.IsTerminal(w.Fd()) { + _, _ = fmt.Fprintln(w, err.Error()) + return + } } - // Flags - if cmd.HasAvailableLocalFlags() { - builder.WriteString("Flags:\n") - builder.WriteString(cmd.LocalFlags().FlagUsagesWrapped(120)) - builder.WriteByte('\n') + errorHeader := styles.ErrorHeader + errorText := styles.ErrorText + + _, _ = fmt.Fprintln(w, errorHeader.String()) + _, _ = fmt.Fprintln(w, errorText.Render(err.Error()+".")) + _, _ = fmt.Fprintln(w) + if isUsageError(err) { + _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal( + lipgloss.Left, + errorText.UnsetWidth().Render("Try"), + " ", + styles.Program.Flag.Render("--help"), + " for usage.", + )) + _, _ = fmt.Fprintln(w) } +} - // Usage - fmt.Fprintf(&builder, `Use "%s help [command]" for more information about a command.`, cmd.CommandPath()) +func isUsageError(err error) bool { + s := err.Error() + for _, prefix := range []string{ + "flag needs an argument:", + "unknown flag:", + "unknown shorthand flag:", + "unknown command", + "invalid argument", + } { + if strings.HasPrefix(s, prefix) { + return true + } + } - _, err := fmt.Fprint(cmd.OutOrStdout(), builder.String()) + return false +} - return err +func cmpOr(v1 string, v2 string) string { + if v1 != "" { + return v1 + } + + return v2 +} + +func compactTitleStyle(styles fang.Styles) lipgloss.Style { + return styles.Title.Margin(0, 0).MarginBottom(0).PaddingBottom(0) } -func maxCommandNameLen(cmd *cobra.Command) int { - commands := cmd.Commands() - if len(commands) == 0 { - return 0 +func compactCodeBlockStyle(ctx fang.HelpContext, blocks ...string) lipgloss.Style { + base := ctx.Styles.Codeblock.Base.Padding(1, 2) + padding := base.GetHorizontalPadding() + blockWidth := 0 + for _, block := range blocks { + blockWidth = max(blockWidth, lipgloss.Width(block)) } + blockWidth = min(ctx.Width-padding, blockWidth+padding) + blockStyle := base.Width(blockWidth) - maxCmd := slices.MaxFunc(commands, func(a, b *cobra.Command) int { - return cmp.Compare(len(a.Name()), len(b.Name())) - }) + if ctx.Writer.Profile <= colorprofile.Ascii || reflect.DeepEqual(blockStyle.GetBackground(), lipgloss.NoColor{}) { + blockStyle = blockStyle.PaddingTop(0).PaddingBottom(0) + } - return len(maxCmd.Name()) + return blockStyle } -func rpad(s string, padding int) string { - formattedString := fmt.Sprintf("%%-%ds", padding) - return fmt.Sprintf(formattedString, s) +func styleHelpUsage(cmd *cobra.Command, styles fang.Program, complete bool) string { + usage := cmd.Annotations[annotationHelpUsage] + if usage == "" { + return fang.StyleUsage(cmd, styles, complete) + } + + lines := make([]string, 0) + for _, line := range strings.Split(usage, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lines = append(lines, styleHelpUsageLine(cmd, styles, line, complete)) + } + + if len(lines) == 0 { + return fang.StyleUsage(cmd, styles, complete) + } + + return strings.Join(lines, "\n") } -func hasSubgroup(cmd *cobra.Command) bool { - subgroups := make(map[string]struct{}) +func styleHelpUsageLine(cmd *cobra.Command, styles fang.Program, usage string, complete bool) string { + if complete { + usage = completeHelpUsage(cmd, usage) + } - for _, c := range cmd.Commands() { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { - subgroups[subgroup] = struct{}{} - if len(subgroups) > 1 { - return true - } + return styleUsageText(cmd, styles, usage, complete) +} + +func completeHelpUsage(cmd *cobra.Command, usage string) string { + parent := cmd.Parent() + if parent == nil { + return usage + } + + parentPath := parent.CommandPath() + if parentPath == "" || strings.HasPrefix(usage, parentPath+" ") { + return usage + } + + return parentPath + " " + usage +} + +func styleUsageText(cmd *cobra.Command, styles fang.Program, usage string, complete bool) string { + hasArgs := strings.Contains(usage, "[args]") + hasFlags := strings.Contains(usage, "[flags]") || + strings.Contains(usage, "[--flags]") || + cmd.HasFlags() || + cmd.HasPersistentFlags() || + cmd.HasAvailableFlags() + hasCommands := strings.Contains(usage, "[command]") || cmd.HasAvailableSubCommands() + for _, marker := range []string{ + "[args]", + "[flags]", "[--flags]", + "[command]", + } { + usage = strings.ReplaceAll(usage, marker, "") + } + + var optionalArgs []string //nolint:prealloc + for _, arg := range optionalArgsRe.FindAllString(usage, -1) { + usage = strings.ReplaceAll(usage, arg, "") + optionalArgs = append(optionalArgs, arg) + } + + usage = strings.TrimSpace(usage) + + useLine := []string{} + if complete { + parts := strings.Fields(usage) + if len(parts) > 0 { + useLine = append(useLine, styles.Name.Render(parts[0])) } + if len(parts) > 1 { + useLine = append(useLine, styles.Command.Render(" "+strings.Join(parts[1:], " "))) + } + } else { + useLine = append(useLine, styles.Command.Render(usage)) + } + if hasCommands { + useLine = append(useLine, styles.DimmedArgument.Render(" [command]")) + } + if hasArgs { + useLine = append(useLine, styles.DimmedArgument.Render(" [args]")) + } + for _, arg := range optionalArgs { + useLine = append(useLine, styles.DimmedArgument.Render(" "+arg)) + } + if hasFlags { + useLine = append(useLine, styles.DimmedArgument.Render(" [--flags]")) } - return false + return lipgloss.JoinHorizontal(lipgloss.Left, useLine...) +} + +func renderLongShort(w io.Writer, styles fang.Styles, width int, longShort string) { + if longShort == "" { + return + } + + longShort = strings.TrimRight(longShort, "\n") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, styles.Text.Width(width).Render(longShort)) } -func writeGroupCommandHelpLine(builder *strings.Builder, prefix string, name string, padding int, suffix string, short string) { - builder.WriteString(" ") - builder.WriteString(prefix) - builder.WriteString(rpad(name, padding)) - builder.WriteString(suffix) - builder.WriteString(" ") - builder.WriteString(short) - builder.WriteByte('\n') +func helpGroups(cmd *cobra.Command) (map[string]string, []string) { + ids := []string{""} + groups := map[string]string{"": "commands"} + + for _, group := range cmd.Groups() { + ids = append(ids, group.ID) + groups[group.ID] = group.Title + } + + return groups, ids } -func buildGroupCommandHelp(cmd *cobra.Command, group *cobra.Group) string { - cmds := []*cobra.Command{} +func helpCommands(cmd *cobra.Command, styles fang.Styles) map[string][]commandHelpItem { + commands := map[string][]commandHelpItem{} - // select commands that belong to the specified group - for _, c := range cmd.Commands() { - if c.GroupID == group.ID && (c.IsAvailableCommand() || c.Name() == "help") { - cmds = append(cmds, c) + for _, subCmd := range cmd.Commands() { + if !subCmd.IsAvailableCommand() && subCmd.Name() != "help" { + continue } + + commands[subCmd.GroupID] = append(commands[subCmd.GroupID], commandHelpItem{ + name: subCmd.Name(), + subgroup: subCmd.Annotations[annotationSubGroupName], + key: styleHelpUsage(subCmd, styles.Program, false), + help: renderHelpDescription(styles, subCmd.Short), + }) + } + + for groupID := range commands { + sort.Slice(commands[groupID], func(i, j int) bool { + return commands[groupID][i].name < commands[groupID][j].name + }) } - padding := maxCommandNameLen(cmd) + return commands +} + +func helpOptions(cmd *cobra.Command, styles fang.Styles) ([]helpItem, string) { + items := make([]helpItem, 0) + docoptOptions := commandHelpOptions(cmd) - sort.Slice(cmds, func(i, j int) bool { - return cmds[i].Name() < cmds[j].Name() + for _, option := range docoptOptions { + items = append(items, helpItem{ + key: renderDocoptFlag(styles.Program, option.Display), + help: renderHelpDescription(styles, option.Description), + }) + } + + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if flag.Hidden || shouldSkipHelpFlag(cmd, flag) { + return + } + + help := renderHelpDescription(styles, flag.Usage) + if flag.DefValue != "" && flag.DefValue != "false" && flag.DefValue != "0" && flag.DefValue != "[]" { + help += styles.FlagDefault.Render(" (" + flag.DefValue + ")") + } + + items = append(items, helpItem{ + key: renderCobraFlag(styles.Program, flag), + help: help, + }) }) - // Create a list of subgroups - subGroupNameSet := set.NewSet[string]() + if len(docoptOptions) > 0 { + return items, "options" + } + + return items, "flags" +} - for _, c := range cmds { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { - subGroupNameSet.Add(subgroup) +func commandHelpOptions(cmd *cobra.Command) []docopt.HelpOption { + payload := cmd.Annotations[annotationHelpOptions] + if payload == "" { + return nil + } + + var options []docopt.HelpOption + if err := json.Unmarshal([]byte(payload), &options); err != nil { + return nil + } + + return options +} + +func shouldSkipHelpFlag(cmd *cobra.Command, flag *pflag.Flag) bool { + return flag.Name == "help" && cmd != cmd.Root() && !flag.Changed +} + +func renderCobraFlag(styles fang.Program, flag *pflag.Flag) string { + if flag.Shorthand == "" { + return styles.Flag.Render("--" + flag.Name) + } + + return lipgloss.JoinHorizontal( + lipgloss.Left, + styles.Flag.Render("-"+flag.Shorthand+" --"+flag.Name), + ) +} + +func renderDocoptFlag(styles fang.Program, display string) string { + parts := strings.Split(display, ", ") + rendered := make([]string, 0, len(parts)) + + for _, part := range parts { + rendered = append(rendered, renderDocoptFlagPart(styles, part)) + } + + return strings.Join(rendered, styles.DimmedArgument.Render(", ")) +} + +func renderDocoptFlagPart(styles fang.Program, part string) string { + if left, right, ok := strings.Cut(part, "="); ok { + return lipgloss.JoinHorizontal( + lipgloss.Left, + styles.Flag.Render(left+"="), + styles.Flag.Render(right), + ) + } + + return styles.Flag.Render(part) +} + +func renderHelpDescription(styles fang.Styles, usage string) string { + noTransform := styles.FlagDescription.UnsetTransform() + lines := make([]string, 0, 1) + + for i, line := range strings.Split(usage, "\n") { + if line == "" { + lines = append(lines, "") + continue + } + if i == 0 { + lines = append(lines, styles.FlagDescription.Render(line)) + continue } + lines = append(lines, noTransform.Render(line)) } - subGroupNameList := subGroupNameSet.ToList() - sort.Strings(subGroupNameList) + return strings.Join(lines, "\n") +} - // generate output - var builder strings.Builder - builder.WriteString(group.Title) - builder.WriteByte('\n') +func renderHelpGroup(w io.Writer, styles fang.Styles, space int, title string, items []helpItem) { + _, _ = fmt.Fprintln(w, compactTitleStyle(styles).Render(title)) + for _, item := range items { + renderHelpItem(w, space, item.key, item.help) + } +} + +func renderCommandGroup(w io.Writer, styles fang.Styles, space int, title string, items []commandHelpItem, hasSubgroups bool) { + _, _ = fmt.Fprintln(w, compactTitleStyle(styles).Render(title)) + + subgroupNames := subgroupNames(items) + showSubgroupTitles := len(subgroupNames) > 1 + + for _, subgroup := range subgroupNames { + if showSubgroupTitles { + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, lipgloss.NewStyle().PaddingLeft(2).Render(styles.Text.Render(subgroup))) + } + + for _, item := range items { + if item.subgroup != subgroup { + continue + } + renderHelpItem(w, space, displayCommandKey(item, hasSubgroups), item.help) + } + } - intend := "" - if hasSubgroup(cmd) { - intend = " " + for _, item := range items { + if item.subgroup != "" { + continue + } + renderHelpItem(w, space, displayCommandKey(item, hasSubgroups), item.help) } +} + +func renderHelpItem(w io.Writer, space int, key string, help string) { + _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal( + lipgloss.Left, + lipgloss.NewStyle().PaddingLeft(2).Render(key), + strings.Repeat(" ", max(space-lipgloss.Width(key), 0)), + help, + )) +} + +func subgroupNames(items []commandHelpItem) []string { + seen := map[string]struct{}{} + names := make([]string, 0, len(items)) - for _, subgroupName := range subGroupNameList { - if len(subGroupNameList) > 1 { - builder.WriteString("\n ") - builder.WriteString(subgroupName) - builder.WriteByte('\n') + for _, item := range items { + if item.subgroup == "" { + continue } + if _, ok := seen[item.subgroup]; ok { + continue + } + seen[item.subgroup] = struct{}{} + names = append(names, item.subgroup) + } - for _, c := range cmds { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup == subgroupName { - writeGroupCommandHelpLine(&builder, intend, c.Name(), padding, "", c.Short) + sort.Strings(names) + + return names +} + +func hasMultipleSubgroups(commands map[string][]commandHelpItem) bool { + seen := map[string]struct{}{} + + for _, items := range commands { + for _, item := range items { + if item.subgroup == "" { + continue + } + seen[item.subgroup] = struct{}{} + if len(seen) > 1 { + return true } } } - for _, c := range cmds { - if _, ok := c.Annotations["SubGroupName"]; !ok { - writeGroupCommandHelpLine(&builder, "", c.Name(), padding, intend, c.Short) + return false +} + +func displayCommandKey(item commandHelpItem, hasSubgroups bool) string { + if !hasSubgroups { + return item.key + } + if item.subgroup != "" { + return " " + item.key + } + + return item.key + " " +} + +func helpSpace(commands map[string][]commandHelpItem, flags []helpItem, hasSubgroups bool) int { + space := 10 + + for _, items := range commands { + for _, item := range items { + space = max(space, lipgloss.Width(displayCommandKey(item, hasSubgroups))+2) } } - builder.WriteByte('\n') + for _, item := range flags { + space = max(space, lipgloss.Width(item.key)+2) + } - return builder.String() + return space } diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go new file mode 100644 index 00000000..886ad980 --- /dev/null +++ b/internal/cmd/help_test.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "bytes" + "context" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/lets-cli/fang" + configpkg "github.com/lets-cli/lets/internal/config/config" + "github.com/spf13/cobra" +) + +func TestHelpRendererShowsSubgroups(t *testing.T) { + root := CreateRootCommand("v0.0.0-test", "") + root.InitDefaultHelpFlag() + root.SetArgs(nil) + root.SetOut(new(bytes.Buffer)) + root.SetErr(new(bytes.Buffer)) + + root.AddCommand(&cobra.Command{ + Use: "build", + Short: "build stuff", + GroupID: "main", + RunE: func(*cobra.Command, []string) error { + return nil + }, + Annotations: map[string]string{ + annotationSubGroupName: "Development", + }, + }) + root.AddCommand(&cobra.Command{ + Use: "deploy", + Short: "deploy stuff", + GroupID: "main", + RunE: func(*cobra.Command, []string) error { + return nil + }, + Annotations: map[string]string{ + annotationSubGroupName: "Operations", + }, + }) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(new(bytes.Buffer)) + + if err := fang.Execute(context.Background(), root, fang.WithHelpRenderer(HelpRenderer)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Development") { + t.Fatalf("expected Development subgroup in output: %q", out) + } + if !strings.Contains(out, "Operations") { + t.Fatalf("expected Operations subgroup in output: %q", out) + } +} + +func TestHelpRendererUsesDocoptFlags(t *testing.T) { + root := CreateRootCommand("v0.0.0-test", "") + root.InitDefaultHelpFlag() + root.SetArgs([]string{"help", "release"}) + + release := newSubcommand(&configpkg.Command{ + Name: "release", + GroupName: "Common", + Description: "Create tag and push", + Docopts: `Usage: lets release --message= + +Options: + Set version (e.g. 1.0.0) + --message=, -m Release message + +Example: + lets release 1.0.0 -m "Release 1.0.0"`, + }, nil, false, nil) + root.AddCommand(release) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(new(bytes.Buffer)) + + if err := fang.Execute(context.Background(), root, fang.WithHelpRenderer(HelpRenderer)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "lets release --message=") { + t.Fatalf("expected usage in output: %q", out) + } + if !strings.Contains(out, "OPTIONS") { + t.Fatalf("expected options title in output: %q", out) + } + argIdx := strings.Index(out, "") + flagIdx := strings.Index(out, "--message=, -m") + if argIdx == -1 { + t.Fatalf("expected docopt argument in output: %q", out) + } + if !strings.Contains(out, "--message=, -m") { + t.Fatalf("expected docopt flag in output: %q", out) + } + if argIdx > flagIdx { + t.Fatalf("expected docopt argument before flag in output: %q", out) + } + if strings.Contains(out, "-h --help") { + t.Fatalf("did not expect help flag in output: %q", out) + } +} + +func TestHelpRendererUsesDocoptUsageInRootCommandList(t *testing.T) { + root := CreateRootCommand("v0.0.0-test", "") + root.InitDefaultHelpFlag() + root.SetArgs(nil) + + release := newSubcommand(&configpkg.Command{ + Name: "release", + GroupName: "Common", + Description: "Create tag and push", + Docopts: `Usage: lets release --message= + +Options: + --message=, -m Release message`, + }, nil, false, nil) + root.AddCommand(release) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(new(bytes.Buffer)) + + if err := fang.Execute(context.Background(), root, fang.WithHelpRenderer(HelpRenderer)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(stdout.String(), "release --message=") { + t.Fatalf("expected command usage in output: %q", stdout.String()) + } +} + +func TestHelpRendererDoesNotDuplicateDocoptUsageCommandPath(t *testing.T) { + root := CreateRootCommand("v0.0.0-test", "") + root.InitDefaultHelpFlag() + root.SetArgs([]string{"help", "build"}) + + build := newSubcommand(&configpkg.Command{ + Name: "build", + GroupName: "Common", + Description: "Build lets from source code", + Docopts: `Usage: + lets build + lets build []`, + }, nil, false, nil) + root.AddCommand(build) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(new(bytes.Buffer)) + + if err := fang.Execute(context.Background(), root, fang.WithHelpRenderer(HelpRenderer)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if strings.Contains(out, "lets build lets build") { + t.Fatalf("did not expect duplicated command path in output: %q", out) + } + if !strings.Contains(out, "lets build []") { + t.Fatalf("expected docopt usage in output: %q", out) + } +} + +func TestErrorHandlerRemovesErrorHeaderLeftPadding(t *testing.T) { + var stderr bytes.Buffer + styles := fang.Styles{ + ErrorHeader: lipgloss.NewStyle().Padding(0, 1).SetString("ERROR"), + ErrorText: lipgloss.NewStyle(), + Program: fang.Program{ + Flag: lipgloss.NewStyle(), + }, + } + + ErrorHandler(&stderr, styles, &unknownCommandError{message: `unknown command "wat" for "lets"`}) + + out := stderr.String() + if !strings.Contains(out, "ERROR") { + t.Fatalf("expected error header in output: %q", out) + } + if !strings.Contains(out, "\nTry --help for usage.") { + t.Fatalf("expected usage hint in output: %q", out) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 0ee36979..c6a2f7c2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -73,22 +73,9 @@ func newRootCmd(version, buildDate string) *cobra.Command { FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, // handle errors manually SilenceErrors: true, - // print help message manyally - SilenceUsage: true, + SilenceUsage: true, } - cmd.SetHelpFunc(func(c *cobra.Command, _ []string) { - var err error - if c == c.Root() { - err = PrintRootHelpMessage(c) - } else { - err = PrintHelpMessage(c) - } - - if err != nil { - c.Println(err) - } - }) cmd.AddGroup(&cobra.Group{ID: "main", Title: "Commands:"}, &cobra.Group{ID: "internal", Title: "Internal commands:"}) cmd.SetHelpCommandGroupID("internal") diff --git a/internal/cmd/subcommand.go b/internal/cmd/subcommand.go index ddc5615b..471d9a8c 100644 --- a/internal/cmd/subcommand.go +++ b/internal/cmd/subcommand.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "errors" "fmt" "io" @@ -9,12 +10,16 @@ import ( "strings" "github.com/lets-cli/lets/internal/config/config" + "github.com/lets-cli/lets/internal/docopt" "github.com/lets-cli/lets/internal/executor" "github.com/spf13/cobra" ) const ( - shortLimit = 120 + shortLimit = 120 + annotationSubGroupName = "SubGroupName" + annotationHelpOptions = "lets.helpOptions" + annotationHelpUsage = "lets.helpUsage" ) // cut all elements after command name. @@ -103,11 +108,17 @@ func filterCmds( return filteredCmds } +func replaceDocoptNamePlaceholder(docopts string, cmdName string) string { + docopts = strings.Replace(docopts, "${LETS_COMMAND_NAME}", cmdName, 1) + docopts = strings.Replace(docopts, "$LETS_COMMAND_NAME", cmdName, 1) + + return docopts +} + // Replace command name placeholder if present // E.g. if command name is foo, lets ${LETS_COMMAND_NAME} will be lets foo. func setDocoptNamePlaceholder(c *config.Command) { - c.Docopts = strings.Replace(c.Docopts, "${LETS_COMMAND_NAME}", c.Name, 1) - c.Docopts = strings.Replace(c.Docopts, "$LETS_COMMAND_NAME", c.Name, 1) + c.Docopts = replaceDocoptNamePlaceholder(c.Docopts, c.Name) } type cmdFlags struct { @@ -154,14 +165,78 @@ func isHidden(cmdName string, showAll bool) bool { return false } +func hasHelpArg(args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return true + } + } + + return false +} + +func buildCommandUse(commandName string, usage string) string { + lines := make([]string, 0) + for _, line := range strings.Split(usage, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "lets ") { + line = strings.TrimPrefix(line, "lets ") + } + lines = append(lines, line) + } + + if len(lines) == 0 { + return commandName + } + + return strings.Join(lines, "\n") +} + +func buildCommandAnnotations(command *config.Command, docopts string) map[string]string { + annotations := map[string]string{ + annotationSubGroupName: command.GroupName, + } + + docoptParts := docopt.ParseDocoptParts(docopts) + if usage := buildCommandUse(command.Name, docoptParts.Usage); usage != "" { + annotations[annotationHelpUsage] = usage + } + + helpOptions := docopt.ParseHelpOptions(docopts, command.Name) + if len(helpOptions) == 0 { + return annotations + } + + payload, err := json.Marshal(helpOptions) + if err != nil { + return annotations + } + + annotations[annotationHelpOptions] = string(payload) + + return annotations +} + // newSubcommand creates new cobra root subcommand from config.Command. func newSubcommand(command *config.Command, conf *config.Config, showAll bool, out io.Writer) *cobra.Command { + docopts := replaceDocoptNamePlaceholder(command.Docopts, command.Name) + docoptParts := docopt.ParseDocoptParts(docopts) + subCmd := &cobra.Command{ Use: command.Name, + Example: docoptParts.Example, Short: short(command.Description), + Long: command.Description, GroupID: "main", Hidden: isHidden(command.Name, showAll), RunE: func(cmd *cobra.Command, args []string) error { + if hasHelpArg(args) { + return cmd.Help() + } + command.Args = append(command.Args, prepareArgs(command.Name, os.Args)...) command.Cmds.AppendArgs(args) @@ -198,14 +273,7 @@ func newSubcommand(command *config.Command, conf *config.Config, showAll bool, o SilenceUsage: true, } - subCmd.SetHelpFunc(func(c *cobra.Command, strings []string) { - if _, err := fmt.Fprint(c.OutOrStdout(), command.Help()); err != nil { - c.Println(err) - } - }) - subCmd.Annotations = map[string]string{ - "SubGroupName": command.GroupName, - } + subCmd.Annotations = buildCommandAnnotations(command, docopts) return subCmd } diff --git a/internal/cmd/subcommand_test.go b/internal/cmd/subcommand_test.go new file mode 100644 index 00000000..08100939 --- /dev/null +++ b/internal/cmd/subcommand_test.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "encoding/json" + "testing" + + configpkg "github.com/lets-cli/lets/internal/config/config" + "github.com/lets-cli/lets/internal/docopt" + "github.com/spf13/cobra" +) + +func TestNewSubcommandUsesDocoptHelpMetadata(t *testing.T) { + command := &configpkg.Command{ + Name: "release", + GroupName: "Common", + Description: "Create tag and push", + Docopts: `Usage: lets release --message= + +Options: + Set version (e.g. 1.0.0) + --message=, -m Release message + +Example: + lets release 1.0.0 -m "Release 1.0.0"`, + } + + subCmd := newSubcommand(command, nil, false, nil) + if subCmd.Use != "release" { + t.Fatalf("unexpected use: %q", subCmd.Use) + } + if subCmd.Name() != "release" { + t.Fatalf("unexpected name: %q", subCmd.Name()) + } + if subCmd.Annotations[annotationHelpUsage] != "release --message=" { + t.Fatalf("unexpected help usage: %q", subCmd.Annotations[annotationHelpUsage]) + } + + if subCmd.Example != " lets release 1.0.0 -m \"Release 1.0.0\"" { + t.Fatalf("unexpected example: %q", subCmd.Example) + } + + payload := subCmd.Annotations[annotationHelpOptions] + if payload == "" { + t.Fatal("expected custom flags annotation") + } + + var helpFlags []docopt.HelpOption + if err := json.Unmarshal([]byte(payload), &helpFlags); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + if len(helpFlags) != 2 { + t.Fatalf("expected 2 help flags, got %d", len(helpFlags)) + } + + if helpFlags[0].Display != "" { + t.Fatalf("unexpected first display: %q", helpFlags[0].Display) + } + if helpFlags[1].Display != "--message=, -m" { + t.Fatalf("unexpected second display: %q", helpFlags[1].Display) + } +} + +func TestNewSubcommandKeepsConfiguredNameWhenDocoptUsageDiffers(t *testing.T) { + command := &configpkg.Command{ + Name: "options-wrong-usage", + GroupName: "Common", + Docopts: `Usage: lets options-wrong-usage-xxx + +Options: + --message=, -m Release message`, + } + + root := CreateRootCommand("v0.0.0-test", "") + subCmd := newSubcommand(command, nil, false, nil) + root.AddCommand(subCmd) + + found, _, err := root.Find([]string{"options-wrong-usage"}) + if err != nil { + t.Fatalf("unexpected find error: %v", err) + } + if found.Name() != "options-wrong-usage" { + t.Fatalf("unexpected command name: %q", found.Name()) + } + if found.Annotations[annotationHelpUsage] != "options-wrong-usage-xxx" { + t.Fatalf("unexpected help usage: %q", found.Annotations[annotationHelpUsage]) + } +} + +func TestSubcommandHelpArg(t *testing.T) { + command := &configpkg.Command{ + Name: "release", + GroupName: "Common", + Description: "Create tag and push", + } + + root := CreateRootCommand("v0.0.0-test", "") + subCmd := newSubcommand(command, nil, false, nil) + root.AddCommand(subCmd) + root.SetArgs([]string{"release", "--help"}) + + called := false + subCmd.SetHelpFunc(func(*cobra.Command, []string) { + called = true + }) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called { + t.Fatal("expected subcommand help to be called") + } +} diff --git a/internal/docopt/docopts.go b/internal/docopt/docopts.go index 88610c61..df4b310b 100644 --- a/internal/docopt/docopts.go +++ b/internal/docopt/docopts.go @@ -2,6 +2,7 @@ package docopt import ( "fmt" + "regexp" "strconv" "strings" @@ -148,3 +149,137 @@ func normalizeKey(origKey string) string { return key } + +type docoptParts struct { + Usage string + Options string + Example string +} + +type HelpOption struct { + Display string `json:"display"` + Description string `json:"description"` + Name string `json:"name,omitempty"` + Short string `json:"short,omitempty"` + Long string `json:"long,omitempty"` + Kind string `json:"kind,omitempty"` +} + +var helpOptionSeparator = regexp.MustCompile(`\s{2,}`) + +func ParseDocoptParts(docopt string) docoptParts { + sections := map[string]*strings.Builder{ + "usage": {}, + "options": {}, + "example": {}, + } + + section := "" + + for _, line := range strings.Split(docopt, "\n") { + switch { + case strings.HasPrefix(line, "Usage:"): + section = "usage" + line = strings.TrimSpace(strings.TrimPrefix(line, "Usage:")) + case strings.HasPrefix(line, "Options:"): + section = "options" + line = strings.TrimSpace(strings.TrimPrefix(line, "Options:")) + case strings.HasPrefix(line, "Example:"): + section = "example" + line = strings.TrimSpace(strings.TrimPrefix(line, "Example:")) + } + + if section == "" || line == "" { + continue + } + + text := sections[section] + if text.Len() > 0 { + text.WriteByte('\n') + } + text.WriteString(line) + } + + return docoptParts{ + Usage: sections["usage"].String(), + Options: sections["options"].String(), + Example: sections["example"].String(), + } +} + +func ParseHelpOptions(docopt string, cmdName string) []HelpOption { + parts := ParseDocoptParts(docopt) + if parts.Options == "" { + return nil + } + + rawOpts, err := ParseOptions(docopt, cmdName) + if err != nil { + rawOpts = nil + } + + var options []HelpOption + + for _, line := range strings.Split(parts.Options, "\n") { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + continue + } + + parts := helpOptionSeparator.Split(trimmed, 2) + if len(parts) == 0 { + continue + } + + display := strings.TrimSpace(parts[0]) + if display == "" { + continue + } + + if !strings.HasPrefix(display, "-") && !strings.HasPrefix(display, "<") { + if len(options) == 0 { + continue + } + + description := strings.TrimSpace(trimmed) + if description == "" { + continue + } + + if options[len(options)-1].Description != "" { + options[len(options)-1].Description += "\n" + } + options[len(options)-1].Description += description + continue + } + + option := HelpOption{Display: display} + if len(parts) > 1 { + option.Description = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(display, "<") { + option.Kind = "arg" + } else { + option.Kind = "flag" + } + + for _, rawOpt := range rawOpts { + if rawOpt.Name == cmdName { + continue + } + if !strings.Contains(display, rawOpt.Name) && (rawOpt.Short == "" || !strings.Contains(display, rawOpt.Short)) { + continue + } + + option.Name = rawOpt.Name + option.Short = rawOpt.Short + option.Long = rawOpt.Long + break + } + + options = append(options, option) + } + + return options +} diff --git a/internal/docopt/docopts_test.go b/internal/docopt/docopts_test.go new file mode 100644 index 00000000..a0dbab8c --- /dev/null +++ b/internal/docopt/docopts_test.go @@ -0,0 +1,70 @@ +package docopt + +import "testing" + +const releaseDocopt = `Usage: lets release --message= + +Options: + Set version (e.g. 1.0.0) + --message=, -m Release message + +Example: + lets release 1.0.0 -m "Release 1.0.0" + lets release 1.0.0-rc1 -m "Prerelease 1.0.0-rc1"` + +func TestParseDocoptParts(t *testing.T) { + parts := ParseDocoptParts(releaseDocopt) + + if parts.Usage != "lets release --message=" { + t.Fatalf("unexpected usage: %q", parts.Usage) + } + + expectedOptions := " Set version (e.g. 1.0.0)\n --message=, -m Release message" + if parts.Options != expectedOptions { + t.Fatalf("unexpected options: %q", parts.Options) + } + + expectedExample := " lets release 1.0.0 -m \"Release 1.0.0\"\n lets release 1.0.0-rc1 -m \"Prerelease 1.0.0-rc1\"" + if parts.Example != expectedExample { + t.Fatalf("unexpected example: %q", parts.Example) + } +} + +func TestParseHelpOptions(t *testing.T) { + helpOptions := ParseHelpOptions(releaseDocopt, "release") + if len(helpOptions) != 2 { + t.Fatalf("expected 2 help options, got %d", len(helpOptions)) + } + + if helpOptions[0].Display != "" { + t.Fatalf("unexpected first display: %q", helpOptions[0].Display) + } + if helpOptions[0].Description != "Set version (e.g. 1.0.0)" { + t.Fatalf("unexpected first description: %q", helpOptions[0].Description) + } + if helpOptions[0].Kind != "arg" { + t.Fatalf("unexpected first kind: %q", helpOptions[0].Kind) + } + if helpOptions[0].Name != "" { + t.Fatalf("unexpected first name: %q", helpOptions[0].Name) + } + + if helpOptions[1].Display != "--message=, -m" { + t.Fatalf("unexpected second display: %q", helpOptions[1].Display) + } + if helpOptions[1].Description != "Release message" { + t.Fatalf("unexpected second description: %q", helpOptions[1].Description) + } + if helpOptions[1].Kind != "flag" { + t.Fatalf("unexpected second kind: %q", helpOptions[1].Kind) + } + if helpOptions[1].Name != "--message" { + t.Fatalf("unexpected second name: %q", helpOptions[1].Name) + } + if helpOptions[1].Short != "-m" { + t.Fatalf("unexpected second short: %q", helpOptions[1].Short) + } + if helpOptions[1].Long != "--message" { + t.Fatalf("unexpected second long: %q", helpOptions[1].Long) + } +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 00000000..1554f405 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,75 @@ +package theme + +import ( + "image/color" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" + "github.com/lets-cli/fang" +) + +// DefaultColorScheme is the default colorscheme. +func DefaultColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + baseCyan := charmtone.Turtle + baseWhite := charmtone.Ash + baseGray := charmtone.Oyster + return fang.ColorScheme{ + Base: c(charmtone.Charcoal, baseCyan), + Title: baseWhite, + Codeblock: c(charmtone.Salt, lipgloss.Color("#2F2E36")), + Program: c(charmtone.Malibu, baseWhite), + Command: c(charmtone.Pony, baseCyan), + DimmedArgument: c(charmtone.Squid, baseGray), + Comment: c(charmtone.Squid, lipgloss.Color("#747282")), + Flag: c(lipgloss.Color("#0CB37F"), baseCyan), + Argument: c(charmtone.Charcoal, baseWhite), + Description: c(charmtone.Charcoal, baseWhite), + FlagDefault: c(charmtone.Smoke, charmtone.Squid), + QuotedString: c(charmtone.Coral, baseCyan), + ErrorHeader: [2]color.Color{ + charmtone.Butter, + charmtone.Sriracha, + }, + } +} + +// AnsiColorScheme is a ANSI colorscheme. +func AnsiColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + base := c(lipgloss.Black, lipgloss.White) + return fang.ColorScheme{ + Base: base, + Title: lipgloss.White, + Description: base, + Comment: c(lipgloss.BrightWhite, lipgloss.BrightBlack), + Flag: lipgloss.White, + FlagDefault: lipgloss.White, + Command: lipgloss.White, + QuotedString: lipgloss.White, + Argument: base, + Help: base, + Dash: base, + ErrorHeader: [2]color.Color{lipgloss.Black, lipgloss.Red}, + ErrorDetails: lipgloss.Red, + } +} + +func SynthwaveColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + return fang.ColorScheme{ + Base: c(charmtone.Charcoal, charmtone.Cheeky), + Title: charmtone.Grape, + Codeblock: c(charmtone.Salt, lipgloss.Color("#2F2E36")), + Program: c(charmtone.Malibu, charmtone.Grape), + Command: c(charmtone.Pony, charmtone.Cheeky), + DimmedArgument: c(charmtone.Squid, charmtone.Oyster), + Comment: c(charmtone.Squid, lipgloss.Color("#747282")), + Flag: c(lipgloss.Color("#0CB37F"), charmtone.Cheeky), + Argument: c(charmtone.Charcoal, charmtone.Ash), + Description: c(charmtone.Charcoal, charmtone.Ash), + FlagDefault: c(charmtone.Smoke, charmtone.Squid), + QuotedString: c(charmtone.Coral, charmtone.Cheeky), + ErrorHeader: [2]color.Color{ + charmtone.Butter, + charmtone.Sriracha, + }, + } +} From 3cc6ccf7bff8c7160b2b1614ff60d47d417564e3 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 16:26:46 +0300 Subject: [PATCH 03/12] refactor dependency error into new theme --- internal/cli/cli.go | 8 -- internal/cmd/help.go | 23 ++++++ internal/executor/dependency_error.go | 50 +----------- internal/executor/dependency_error_test.go | 93 ---------------------- 4 files changed, 24 insertions(+), 150 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 9be1e53d..e5af7bf8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -14,7 +14,6 @@ import ( "github.com/lets-cli/lets/internal/cmd" "github.com/lets-cli/lets/internal/config" "github.com/lets-cli/lets/internal/env" - "github.com/lets-cli/lets/internal/executor" "github.com/lets-cli/lets/internal/logging" "github.com/lets-cli/lets/internal/set" "github.com/lets-cli/lets/internal/settings" @@ -115,13 +114,6 @@ func Main(version string, buildDate string) int { fang.WithErrorHandler(cmd.ErrorHandler), fang.WithHelpRenderer(cmd.HelpRenderer), ); err != nil { - if depErr, ok := errors.AsType[*executor.DependencyError](err); ok { - log.Errorf("%s", depErr.TreeMessage()) - log.Errorf("%s", depErr.FailureMessage()) - - return getExitCode(err, 1) - } - return getExitCode(err, 1) } diff --git a/internal/cmd/help.go b/internal/cmd/help.go index fb0451a1..6daada34 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "io" "reflect" @@ -15,6 +16,7 @@ import ( "github.com/charmbracelet/x/term" "github.com/lets-cli/fang" "github.com/lets-cli/lets/internal/docopt" + "github.com/lets-cli/lets/internal/executor" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -91,6 +93,12 @@ func ErrorHandler(w io.Writer, styles fang.Styles, err error) { _, _ = fmt.Fprintln(w, errorHeader.String()) _, _ = fmt.Fprintln(w, errorText.Render(err.Error()+".")) _, _ = fmt.Fprintln(w) + var depErr *executor.DependencyError + if errors.As(err, &depErr) { + renderDependencyTree(w, styles, depErr) + _, _ = fmt.Fprintln(w) + return + } if isUsageError(err) { _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal( lipgloss.Left, @@ -103,6 +111,21 @@ func ErrorHandler(w io.Writer, styles fang.Styles, err error) { } } +func renderDependencyTree(w io.Writer, styles fang.Styles, depErr *executor.DependencyError) { + title := styles.Title.Margin(0, 2).Padding(0, 0) + joint := styles.Program.DimmedArgument.Render("└─ ") + failed := styles.ErrorText.UnsetWidth().UnsetTransform().Render("<-- failed here") + + _, _ = fmt.Fprintln(w, title.Render("command tree:")) + for i, name := range depErr.Chain { + line := strings.Repeat(" ", i+2) + joint + styles.Program.Command.Render(name) + if i == len(depErr.Chain)-1 { + line += " " + failed + } + _, _ = fmt.Fprintln(w, line) + } +} + func isUsageError(err error) bool { s := err.Error() for _, prefix := range []string{ diff --git a/internal/executor/dependency_error.go b/internal/executor/dependency_error.go index b419d29d..7062669a 100644 --- a/internal/executor/dependency_error.go +++ b/internal/executor/dependency_error.go @@ -1,17 +1,6 @@ package executor -import ( - "errors" - "fmt" - "io" - "strings" - - "github.com/fatih/color" -) - -const dependencyTreeIndent = " " -const dependencyTreeHeader = "command failed:" -const dependencyTreeJoint = "└─ " +import "errors" // DependencyError carries the full dependency chain when a command fails. // Chain is outermost-first (e.g., ["deploy", "build", "lint"]). @@ -32,36 +21,6 @@ func (e *DependencyError) ExitCode() int { return 1 } -func (e *DependencyError) FailureMessage() string { - if executeErr, ok := errors.AsType[*ExecuteError](e.Err); ok { - return executeErr.Cause().Error() - } - - return e.Err.Error() -} - -func (e *DependencyError) TreeMessage() string { - red := color.New(color.FgRed).SprintFunc() - - var builder strings.Builder - - builder.WriteString(dependencyTreeHeader) - - for i, name := range e.Chain { - builder.WriteByte('\n') - builder.WriteString(strings.Repeat(dependencyTreeIndent, i+1)) - builder.WriteString(dependencyTreeJoint) - builder.WriteString(name) - - if i == len(e.Chain)-1 { - builder.WriteString(dependencyTreeIndent) - builder.WriteString(red("<-- failed here")) - } - } - - return builder.String() -} - // prependToChain prepends name to the chain in err if err is already a *DependencyError, // otherwise wraps err in a new single-element DependencyError. func prependToChain(name string, err error) error { @@ -71,10 +30,3 @@ func prependToChain(name string, err error) error { return &DependencyError{Chain: []string{name}, Err: err} } - -// PrintDependencyTree writes an indented tree of the dependency chain to w. -// The failing node (last in chain) is annotated in red. -// Respects NO_COLOR automatically via fatih/color. -func PrintDependencyTree(e *DependencyError, w io.Writer) { - fmt.Fprintln(w, e.TreeMessage()) -} diff --git a/internal/executor/dependency_error_test.go b/internal/executor/dependency_error_test.go index 2e20195e..371529ba 100644 --- a/internal/executor/dependency_error_test.go +++ b/internal/executor/dependency_error_test.go @@ -1,12 +1,8 @@ package executor import ( - "bytes" "fmt" - "os" "os/exec" - "strings" - "syscall" "testing" ) @@ -103,92 +99,3 @@ func TestDependencyErrorError(t *testing.T) { } } -func TestDependencyErrorFailureMessage(t *testing.T) { - t.Run("returns root cause for execute errors", func(t *testing.T) { - depErr := &DependencyError{ - Chain: []string{"lint"}, - Err: &ExecuteError{err: fmt.Errorf("failed to run command 'lint': %w", fmt.Errorf("exit status 1"))}, - } - - if got := depErr.FailureMessage(); got != "exit status 1" { - t.Fatalf("expected root cause message, got %q", got) - } - }) - - t.Run("keeps non execute errors intact", func(t *testing.T) { - depErr := &DependencyError{ - Chain: []string{"lint"}, - Err: fmt.Errorf("failed to calculate checksum for command 'lint': missing file"), - } - - if got := depErr.FailureMessage(); got != "failed to calculate checksum for command 'lint': missing file" { - t.Fatalf("expected original message, got %q", got) - } - }) - - t.Run("keeps path context for execute errors", func(t *testing.T) { - depErr := &DependencyError{ - Chain: []string{"lint"}, - Err: &ExecuteError{ - err: fmt.Errorf( - "failed to run command 'lint': %w", - &os.PathError{Op: "chdir", Path: "/tmp/missing", Err: syscall.ENOENT}, - ), - }, - } - - if got := depErr.FailureMessage(); got != "chdir /tmp/missing: no such file or directory" { - t.Fatalf("expected path-aware message, got %q", got) - } - }) -} - -func TestPrintDependencyTree(t *testing.T) { - t.Run("single node", func(t *testing.T) { - depErr := &DependencyError{Chain: []string{"lint"}, Err: fmt.Errorf("fail")} - var buf bytes.Buffer - PrintDependencyTree(depErr, &buf) - out := buf.String() - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) - } - want := []string{ - dependencyTreeHeader, - dependencyTreeIndent + dependencyTreeJoint + "lint" + dependencyTreeIndent + "<-- failed here", - } - - for i := range want { - if lines[i] != want[i] { - t.Errorf("line %d: want %q, got %q", i, want[i], lines[i]) - } - } - }) - - t.Run("three nodes with correct indentation", func(t *testing.T) { - depErr := &DependencyError{ - Chain: []string{"deploy", "build", "lint"}, - Err: fmt.Errorf("fail"), - } - var buf bytes.Buffer - PrintDependencyTree(depErr, &buf) - lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") - - if len(lines) != 4 { - t.Fatalf("expected 4 lines, got %d: %v", len(lines), lines) - } - want := []string{ - dependencyTreeHeader, - dependencyTreeIndent + dependencyTreeJoint + "deploy", - strings.Repeat(dependencyTreeIndent, 2) + dependencyTreeJoint + "build", - strings.Repeat(dependencyTreeIndent, 3) + dependencyTreeJoint + "lint" + - dependencyTreeIndent + "<-- failed here", - } - - for i := range want { - if lines[i] != want[i] { - t.Errorf("line %d: want %q, got %q", i, want[i], lines[i]) - } - } - }) -} From 54f4bae3301c761c013c0d8e17c0a70e67af79ad Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 16:57:14 +0300 Subject: [PATCH 04/12] Simplify and style help output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hand-rolled cmpOr with stdlib cmp.Or - Hoist paddedLeft2 style to package var; avoids alloc per help row - Fix renderCommandGroup O(n²) double-scan: partition into map once - Remove no-op JoinHorizontal in renderDocoptFlagPart - Fix misleading capacity hint in renderHelpDescription - Style subgroup names with title color/transform, no top padding --- internal/cmd/help.go | 59 ++++++++++++++++++--------------------- internal/cmd/help_test.go | 4 +-- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 6daada34..9d4370f6 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -1,6 +1,7 @@ package cmd import ( + "cmp" "encoding/json" "errors" "fmt" @@ -33,10 +34,13 @@ type commandHelpItem struct { help string } -var optionalArgsRe = regexp.MustCompile(`(\[.*\])`) +var ( + optionalArgsRe = regexp.MustCompile(`(\[.*\])`) + paddedLeft2 = lipgloss.NewStyle().PaddingLeft(2) +) func HelpRenderer(cmd *cobra.Command, ctx fang.HelpContext) { - renderLongShort(ctx.Writer, ctx.Styles, ctx.Width, cmpOr(cmd.Long, cmd.Short)) + renderLongShort(ctx.Writer, ctx.Styles, ctx.Width, cmp.Or(cmd.Long, cmd.Short)) usage := styleHelpUsage(cmd, ctx.Styles.Codeblock.Program, true) examples := fang.StyleExamples(cmd, ctx.Styles) @@ -143,14 +147,6 @@ func isUsageError(err error) bool { return false } -func cmpOr(v1 string, v2 string) string { - if v1 != "" { - return v1 - } - - return v2 -} - func compactTitleStyle(styles fang.Styles) lipgloss.Style { return styles.Title.Margin(0, 0).MarginBottom(0).PaddingBottom(0) } @@ -391,11 +387,7 @@ func renderDocoptFlag(styles fang.Program, display string) string { func renderDocoptFlagPart(styles fang.Program, part string) string { if left, right, ok := strings.Cut(part, "="); ok { - return lipgloss.JoinHorizontal( - lipgloss.Left, - styles.Flag.Render(left+"="), - styles.Flag.Render(right), - ) + return styles.Flag.Render(left+"=") + styles.Flag.Render(right) } return styles.Flag.Render(part) @@ -403,9 +395,10 @@ func renderDocoptFlagPart(styles fang.Program, part string) string { func renderHelpDescription(styles fang.Styles, usage string) string { noTransform := styles.FlagDescription.UnsetTransform() - lines := make([]string, 0, 1) + parts := strings.Split(usage, "\n") + lines := make([]string, 0, len(parts)) - for i, line := range strings.Split(usage, "\n") { + for i, line := range parts { if line == "" { lines = append(lines, "") continue @@ -430,27 +423,29 @@ func renderHelpGroup(w io.Writer, styles fang.Styles, space int, title string, i func renderCommandGroup(w io.Writer, styles fang.Styles, space int, title string, items []commandHelpItem, hasSubgroups bool) { _, _ = fmt.Fprintln(w, compactTitleStyle(styles).Render(title)) - subgroupNames := subgroupNames(items) - showSubgroupTitles := len(subgroupNames) > 1 + names := subgroupNames(items) + showSubgroupTitles := len(names) > 1 - for _, subgroup := range subgroupNames { - if showSubgroupTitles { - _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, lipgloss.NewStyle().PaddingLeft(2).Render(styles.Text.Render(subgroup))) + bySubgroup := make(map[string][]commandHelpItem, len(names)) + var ungrouped []commandHelpItem + for _, item := range items { + if item.subgroup == "" { + ungrouped = append(ungrouped, item) + } else { + bySubgroup[item.subgroup] = append(bySubgroup[item.subgroup], item) } + } - for _, item := range items { - if item.subgroup != subgroup { - continue - } + for _, subgroup := range names { + if showSubgroupTitles { + _, _ = fmt.Fprintln(w, compactTitleStyle(styles).PaddingTop(0).PaddingLeft(2).Render(subgroup)) + } + for _, item := range bySubgroup[subgroup] { renderHelpItem(w, space, displayCommandKey(item, hasSubgroups), item.help) } } - for _, item := range items { - if item.subgroup != "" { - continue - } + for _, item := range ungrouped { renderHelpItem(w, space, displayCommandKey(item, hasSubgroups), item.help) } } @@ -458,7 +453,7 @@ func renderCommandGroup(w io.Writer, styles fang.Styles, space int, title string func renderHelpItem(w io.Writer, space int, key string, help string) { _, _ = fmt.Fprintln(w, lipgloss.JoinHorizontal( lipgloss.Left, - lipgloss.NewStyle().PaddingLeft(2).Render(key), + paddedLeft2.Render(key), strings.Repeat(" ", max(space-lipgloss.Width(key), 0)), help, )) diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 886ad980..36331c0c 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -51,10 +51,10 @@ func TestHelpRendererShowsSubgroups(t *testing.T) { } out := stdout.String() - if !strings.Contains(out, "Development") { + if !strings.Contains(out, "DEVELOPMENT") { t.Fatalf("expected Development subgroup in output: %q", out) } - if !strings.Contains(out, "Operations") { + if !strings.Contains(out, "OPERATIONS") { t.Fatalf("expected Operations subgroup in output: %q", out) } } From 60bde7f91c1311623f9765e7f5a280386d01fa16 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 17:14:41 +0300 Subject: [PATCH 05/12] Further simplify help output - Drop usageTitle; reuse sectionTitle for "usage" label - Extract subgroupTitleStyle constructor; remove inline style overrides - Inline styleHelpUsageLine into its only caller and delete it - Remove redundant MarginBottom(0) from compactTitleStyle - renderCobraFlag: return directly instead of JoinHorizontal on one string - Inline errorHeader (used once) - Use var-decl nil slices instead of make([]string, 0) where no cap is known - renderHelpDescription: fast-path single-line descriptions - Replace sort.Slice/sort.Strings with slices.SortFunc/slices.Sort --- internal/cmd/help.go | 50 +++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 9d4370f6..96d010e8 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -8,7 +8,7 @@ import ( "io" "reflect" "regexp" - "sort" + "slices" "strings" "charm.land/lipgloss/v2" @@ -45,10 +45,9 @@ func HelpRenderer(cmd *cobra.Command, ctx fang.HelpContext) { usage := styleHelpUsage(cmd, ctx.Styles.Codeblock.Program, true) examples := fang.StyleExamples(cmd, ctx.Styles) blockStyle := compactCodeBlockStyle(ctx, append([]string{usage}, examples...)...) - usageTitle := ctx.Styles.Title.Margin(0, 0) sectionTitle := compactTitleStyle(ctx.Styles) - _, _ = fmt.Fprintln(ctx.Writer, usageTitle.Render("usage")) + _, _ = fmt.Fprintln(ctx.Writer, sectionTitle.Render("usage")) _, _ = fmt.Fprintln(ctx.Writer, blockStyle.Render(usage)) if len(examples) > 0 { @@ -91,10 +90,9 @@ func ErrorHandler(w io.Writer, styles fang.Styles, err error) { } } - errorHeader := styles.ErrorHeader errorText := styles.ErrorText - _, _ = fmt.Fprintln(w, errorHeader.String()) + _, _ = fmt.Fprintln(w, styles.ErrorHeader.String()) _, _ = fmt.Fprintln(w, errorText.Render(err.Error()+".")) _, _ = fmt.Fprintln(w) var depErr *executor.DependencyError @@ -148,7 +146,11 @@ func isUsageError(err error) bool { } func compactTitleStyle(styles fang.Styles) lipgloss.Style { - return styles.Title.Margin(0, 0).MarginBottom(0).PaddingBottom(0) + return styles.Title.Margin(0, 0).PaddingBottom(0) +} + +func subgroupTitleStyle(styles fang.Styles) lipgloss.Style { + return compactTitleStyle(styles).PaddingTop(0).PaddingLeft(2) } func compactCodeBlockStyle(ctx fang.HelpContext, blocks ...string) lipgloss.Style { @@ -174,13 +176,16 @@ func styleHelpUsage(cmd *cobra.Command, styles fang.Program, complete bool) stri return fang.StyleUsage(cmd, styles, complete) } - lines := make([]string, 0) + var lines []string for _, line := range strings.Split(usage, "\n") { line = strings.TrimSpace(line) if line == "" { continue } - lines = append(lines, styleHelpUsageLine(cmd, styles, line, complete)) + if complete { + line = completeHelpUsage(cmd, line) + } + lines = append(lines, styleUsageText(cmd, styles, line, complete)) } if len(lines) == 0 { @@ -190,14 +195,6 @@ func styleHelpUsage(cmd *cobra.Command, styles fang.Program, complete bool) stri return strings.Join(lines, "\n") } -func styleHelpUsageLine(cmd *cobra.Command, styles fang.Program, usage string, complete bool) string { - if complete { - usage = completeHelpUsage(cmd, usage) - } - - return styleUsageText(cmd, styles, usage, complete) -} - func completeHelpUsage(cmd *cobra.Command, usage string) string { parent := cmd.Parent() if parent == nil { @@ -236,7 +233,7 @@ func styleUsageText(cmd *cobra.Command, styles fang.Program, usage string, compl usage = strings.TrimSpace(usage) - useLine := []string{} + var useLine []string if complete { parts := strings.Fields(usage) if len(parts) > 0 { @@ -303,8 +300,8 @@ func helpCommands(cmd *cobra.Command, styles fang.Styles) map[string][]commandHe } for groupID := range commands { - sort.Slice(commands[groupID], func(i, j int) bool { - return commands[groupID][i].name < commands[groupID][j].name + slices.SortFunc(commands[groupID], func(a, b commandHelpItem) int { + return strings.Compare(a.name, b.name) }) } @@ -368,10 +365,7 @@ func renderCobraFlag(styles fang.Program, flag *pflag.Flag) string { return styles.Flag.Render("--" + flag.Name) } - return lipgloss.JoinHorizontal( - lipgloss.Left, - styles.Flag.Render("-"+flag.Shorthand+" --"+flag.Name), - ) + return styles.Flag.Render("-" + flag.Shorthand + " --" + flag.Name) } func renderDocoptFlag(styles fang.Program, display string) string { @@ -394,6 +388,10 @@ func renderDocoptFlagPart(styles fang.Program, part string) string { } func renderHelpDescription(styles fang.Styles, usage string) string { + if !strings.Contains(usage, "\n") { + return styles.FlagDescription.Render(usage) + } + noTransform := styles.FlagDescription.UnsetTransform() parts := strings.Split(usage, "\n") lines := make([]string, 0, len(parts)) @@ -438,7 +436,7 @@ func renderCommandGroup(w io.Writer, styles fang.Styles, space int, title string for _, subgroup := range names { if showSubgroupTitles { - _, _ = fmt.Fprintln(w, compactTitleStyle(styles).PaddingTop(0).PaddingLeft(2).Render(subgroup)) + _, _ = fmt.Fprintln(w, subgroupTitleStyle(styles).Render(subgroup)) } for _, item := range bySubgroup[subgroup] { renderHelpItem(w, space, displayCommandKey(item, hasSubgroups), item.help) @@ -461,7 +459,7 @@ func renderHelpItem(w io.Writer, space int, key string, help string) { func subgroupNames(items []commandHelpItem) []string { seen := map[string]struct{}{} - names := make([]string, 0, len(items)) + var names []string for _, item := range items { if item.subgroup == "" { @@ -474,7 +472,7 @@ func subgroupNames(items []commandHelpItem) []string { names = append(names, item.subgroup) } - sort.Strings(names) + slices.Sort(names) return names } From 4a034637b91039b7be59c2defc0d9f22b7f5882f Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 18:11:18 +0300 Subject: [PATCH 06/12] Fix bats tests for fang help/error output format The fang renderer changed the output format significantly from cobra defaults: - Help output starts with a blank line (renderLongShort adds leading newline) - Section titles are uppercase (Title style uses strings.ToUpper) - Descriptions capitalize first word (FlagDescription uses titleFirstWord) - Error output uses styled ERROR header with Margin(1), adding a leading blank line - Error messages capitalize first word via titleFirstWord on ErrorText - No trailing "Use lets help [command]..." footer - No type annotations on flags Update all affected tests to use assert_output --partial instead of exact fixture matching or assert_line --index 0, which were both fragile against the new styled output. Also add fang.WithVersion(rootCmd.Version) to pass the binary version into fang so version output shows "0.0.0-dev" instead of "unknown (built from source)". Publish fang v0.1.0 and remove go.mod replace directive so Docker-based bats tests can resolve the dependency. --- go.mod | 16 ++--- go.sum | 22 +++--- internal/cli/cli.go | 1 + internal/cmd/help.go | 36 ++++++++-- internal/cmd/help_test.go | 51 ++++++++++++- internal/executor/dependency_error_test.go | 1 - tests/command_cmd.bats | 6 +- tests/command_docopt_cmd_placeholder.bats | 4 +- tests/command_group.bats | 61 ++++------------ tests/command_group_long.bats | 58 +++------------ tests/command_help.bats | 28 ++------ tests/command_not_found.bats | 8 +-- tests/command_options.bats | 20 ++---- tests/completion.bats | 4 +- tests/config_version.bats | 4 +- tests/default_env.bats | 6 +- tests/dependency_failure_tree.bats | 16 ++--- tests/help.bats | 84 ++++------------------ tests/help_long.bats | 39 ++-------- tests/no_lets_file.bats | 6 +- tests/root_flags.bats | 2 +- 21 files changed, 180 insertions(+), 293 deletions(-) diff --git a/go.mod b/go.mod index 8874d85c..e7f8c897 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,16 @@ go 1.26 toolchain go1.26.0 require ( + charm.land/lipgloss/v2 v2.0.1 + github.com/charmbracelet/colorprofile v0.4.2 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 + github.com/charmbracelet/x/term v0.2.2 github.com/codeclysm/extract v2.2.0+incompatible github.com/coreos/go-semver v0.3.1 github.com/fatih/color v1.16.0 github.com/kindermax/docopt.go v0.8.0 + github.com/lets-cli/fang v0.1.0 github.com/mattn/go-isatty v0.0.20 github.com/odvcencio/gotreesitter v0.12.1 github.com/pkg/errors v0.9.1 @@ -20,20 +26,14 @@ require ( ) require ( - charm.land/lipgloss/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/iancoleman/strcase v0.3.0 // indirect - github.com/lets-cli/fang v0.0.0-20260606200525-e1065b982d8f // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect @@ -63,10 +63,8 @@ require ( github.com/juju/testing v0.0.0-20201216035041-2be42bba85f3 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/lithammer/dedent v1.1.0 - github.com/spf13/pflag v1.0.9 // indirect + github.com/spf13/pflag v1.0.9 golang.org/x/sys v0.42.0 // indirect gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v3 v3.0.1 ) - -replace github.com/lets-cli/fang => ../fang diff --git a/go.sum b/go.sum index e3bb65c8..d766a1fa 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= @@ -10,6 +12,8 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -76,12 +80,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lets-cli/fang v0.0.0-20260606200525-e1065b982d8f h1:ROBQlxpk/t1oSyo8my2nLnP8SJo1Y4AWwLD5VHT3SsE= -github.com/lets-cli/fang v0.0.0-20260606200525-e1065b982d8f/go.mod h1:ReK1Yg4pNI2OOCSvDS3LBIV+cgjv/KwdEhz7JXz94M4= +github.com/lets-cli/fang v0.1.0 h1:lGNVcqodnwu1W/iIRI7BNdblcWZdaZ1ms7/D15U0O3U= +github.com/lets-cli/fang v0.1.0/go.mod h1:ReK1Yg4pNI2OOCSvDS3LBIV+cgjv/KwdEhz7JXz94M4= github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= @@ -96,8 +98,6 @@ github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBq github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -123,8 +123,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -139,9 +137,9 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tliron/commonlog v0.2.8 h1:vpKrEsZX4nlneC9673pXpeKqv3cFLxwpzNEZF1qiaQQ= github.com/tliron/commonlog v0.2.8/go.mod h1:HgQZrJEuiKLLRvUixtPWGcmTmWWtKkCtywF6x9X5Spw= github.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c= @@ -157,13 +155,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.0.0-20180406214816-61147c48b25b/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -172,8 +170,6 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e5af7bf8..a747e2d8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -110,6 +110,7 @@ func Main(version string, buildDate string) int { if err := fang.Execute( ctx, rootCmd, + fang.WithVersion(rootCmd.Version), fang.WithColorSchemeFunc(theme.DefaultColorScheme), fang.WithErrorHandler(cmd.ErrorHandler), fang.WithHelpRenderer(cmd.HelpRenderer), diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 96d010e8..f3a6ffc0 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -93,7 +93,7 @@ func ErrorHandler(w io.Writer, styles fang.Styles, err error) { errorText := styles.ErrorText _, _ = fmt.Fprintln(w, styles.ErrorHeader.String()) - _, _ = fmt.Fprintln(w, errorText.Render(err.Error()+".")) + renderErrorMessage(w, errorText, err) _, _ = fmt.Fprintln(w) var depErr *executor.DependencyError if errors.As(err, &depErr) { @@ -113,10 +113,38 @@ func ErrorHandler(w io.Writer, styles fang.Styles, err error) { } } +func renderErrorMessage(w io.Writer, style lipgloss.Style, err error) { + message, cause := splitExecuteError(err) + _, _ = fmt.Fprintln(w, style.Render(message+".")) + if cause != "" { + _, _ = fmt.Fprintln(w, style.UnsetTransform().Render(capitalizeExitStatus(cause)+".")) + } +} + +func capitalizeExitStatus(text string) string { + if strings.HasPrefix(text, "exit status") { + return "Exit status" + strings.TrimPrefix(text, "exit status") + } + + return text +} + +func splitExecuteError(err error) (string, string) { + var executeErr *executor.ExecuteError + if !errors.As(err, &executeErr) { + return err.Error(), "" + } + + cause := executeErr.Cause().Error() + message := strings.TrimSuffix(err.Error(), ": "+cause) + + return message, cause +} + func renderDependencyTree(w io.Writer, styles fang.Styles, depErr *executor.DependencyError) { - title := styles.Title.Margin(0, 2).Padding(0, 0) + title := styles.Title.Margin(0, 0).MarginLeft(2).Padding(0, 0) joint := styles.Program.DimmedArgument.Render("└─ ") - failed := styles.ErrorText.UnsetWidth().UnsetTransform().Render("<-- failed here") + failed := styles.ErrorHeader.UnsetMargins().UnsetString().Render("<-- failed here") _, _ = fmt.Fprintln(w, title.Render("command tree:")) for i, name := range depErr.Chain { @@ -294,7 +322,7 @@ func helpCommands(cmd *cobra.Command, styles fang.Styles) map[string][]commandHe commands[subCmd.GroupID] = append(commands[subCmd.GroupID], commandHelpItem{ name: subCmd.Name(), subgroup: subCmd.Annotations[annotationSubGroupName], - key: styleHelpUsage(subCmd, styles.Program, false), + key: styles.Program.Command.Render(subCmd.Name()), help: renderHelpDescription(styles, subCmd.Short), }) } diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 36331c0c..46fa5eae 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -9,6 +9,8 @@ import ( "charm.land/lipgloss/v2" "github.com/lets-cli/fang" configpkg "github.com/lets-cli/lets/internal/config/config" + "github.com/lets-cli/lets/internal/env" + "github.com/lets-cli/lets/internal/executor" "github.com/spf13/cobra" ) @@ -110,7 +112,7 @@ Example: } } -func TestHelpRendererUsesDocoptUsageInRootCommandList(t *testing.T) { +func TestHelpRendererUsesCommandNameInRootCommandList(t *testing.T) { root := CreateRootCommand("v0.0.0-test", "") root.InitDefaultHelpFlag() root.SetArgs(nil) @@ -134,8 +136,12 @@ Options: t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(stdout.String(), "release --message=") { - t.Fatalf("expected command usage in output: %q", stdout.String()) + out := stdout.String() + if !strings.Contains(out, "release") { + t.Fatalf("expected command name in output: %q", out) + } + if strings.Contains(out, "release --message=") { + t.Fatalf("did not expect command usage in root command list: %q", out) } } @@ -191,3 +197,42 @@ func TestErrorHandlerRemovesErrorHeaderLeftPadding(t *testing.T) { t.Fatalf("expected usage hint in output: %q", out) } } + +func TestErrorHandlerSplitsExecuteErrorCause(t *testing.T) { + var stderr bytes.Buffer + styles := fang.Styles{ + ErrorHeader: lipgloss.NewStyle().SetString("ERROR"), + ErrorText: lipgloss.NewStyle(), + Program: fang.Program{ + Command: lipgloss.NewStyle(), + DimmedArgument: lipgloss.NewStyle(), + Flag: lipgloss.NewStyle(), + }, + Title: lipgloss.NewStyle(), + } + command := &configpkg.Command{ + Name: "build-lets-image", + Cmds: configpkg.Cmds{ + Commands: []*configpkg.Cmd{{Script: "exit 127"}}, + }, + } + conf := &configpkg.Config{Shell: "bash"} + env.SetDebugLevel(0) + err := executor.NewExecutor(conf, nil).Execute(executor.NewExecutorCtx(context.Background(), command)) + if err == nil { + t.Fatal("expected executor error") + } + + ErrorHandler(&stderr, styles, err) + + out := stderr.String() + if !strings.Contains(out, "failed to run command 'build-lets-image'.") { + t.Fatalf("expected split error message in output: %q", out) + } + if !strings.Contains(out, "Exit status 127.") { + t.Fatalf("expected split exit status in output: %q", out) + } + if strings.Contains(out, "failed to run command 'build-lets-image': exit status 127.") { + t.Fatalf("did not expect combined error message in output: %q", out) + } +} diff --git a/internal/executor/dependency_error_test.go b/internal/executor/dependency_error_test.go index 371529ba..c85665cd 100644 --- a/internal/executor/dependency_error_test.go +++ b/internal/executor/dependency_error_test.go @@ -98,4 +98,3 @@ func TestDependencyErrorError(t *testing.T) { t.Errorf("expected Error() to delegate to Err, got %q", depErr.Error()) } } - diff --git a/tests/command_cmd.bats b/tests/command_cmd.bats index 9533ddc7..d10cae54 100644 --- a/tests/command_cmd.bats +++ b/tests/command_cmd.bats @@ -43,9 +43,9 @@ setup() { # as there is no guarantee in which order cmds runs # we can not guarantee that all commands will run and complete. # But error message must be in the output. - assert_output --partial "lets: command failed:" - assert_output --partial "└─ cmd-as-map-error <-- failed here" - assert_output --partial "lets: exit status 2" + assert_output --partial "cmd-as-map-error" + assert_output --partial "<-- failed here" + assert_output --partial "Exit status 2" } @test "command_cmd: cmd-as-map must propagate env" { diff --git a/tests/command_docopt_cmd_placeholder.bats b/tests/command_docopt_cmd_placeholder.bats index 5f512c6b..8a8163aa 100644 --- a/tests/command_docopt_cmd_placeholder.bats +++ b/tests/command_docopt_cmd_placeholder.bats @@ -19,6 +19,6 @@ setup() { run lets cmd-2 posarg --config=some_path assert_failure - assert_line --partial "failed to parse docopt options for cmd cmd-2" - assert_line --partial "Usage: lets cmd [] [--config=]" + assert_output --partial "Failed to parse docopt options for cmd cmd-2" + assert_output --partial "Usage: lets cmd [] [--config=]" } diff --git a/tests/command_group.bats b/tests/command_group.bats index c0e5d4a5..904fb29a 100644 --- a/tests/command_group.bats +++ b/tests/command_group.bats @@ -6,63 +6,32 @@ setup() { cd ./tests/command_group } -HELP_MESSAGE=$(cat <] -EOF -) - @test "command_help: help contains description and options" { run lets help test assert_success - assert_output "${TEST_HELP_MESSAGE}" + assert_output --partial "Run tests" + assert_output --partial "Unit tests are essention for success." + assert_output --partial "lets test" } - -TEST2_HELP_MESSAGE=$(cat <] -EOF -) - @test "command_help: must add new line between description and options" { run lets help test2 assert_success - assert_output "${TEST2_HELP_MESSAGE}" -} \ No newline at end of file + assert_output --partial "Run tests" + assert_output --partial "lets test2" +} diff --git a/tests/command_not_found.bats b/tests/command_not_found.bats index 5ecfd0cf..f9a95daf 100644 --- a/tests/command_not_found.bats +++ b/tests/command_not_found.bats @@ -17,7 +17,7 @@ setup() { @test "command_not_found: suggest root command for close typo" { run lets slef assert_failure 2 - assert_output --partial 'unknown command "slef" for "lets"' + assert_output --partial 'Unknown command "slef" for "lets"' assert_output --partial 'Did you mean this?' assert_output --partial 'self' } @@ -25,7 +25,7 @@ setup() { @test "command_not_found: suggest self subcommand for close typo" { run lets self ls assert_failure 2 - assert_output --partial 'unknown command "ls" for "lets self"' + assert_output --partial 'Unknown command "ls" for "lets self"' assert_output --partial 'Did you mean this?' assert_output --partial 'lsp' } @@ -33,13 +33,13 @@ setup() { @test "command_not_found: no suggestions for completely unrelated command" { run lets zzzznotacommand assert_failure 2 - assert_output --partial 'unknown command "zzzznotacommand" for "lets"' + assert_output --partial 'Unknown command "zzzznotacommand" for "lets"' refute_output --partial 'Did you mean this?' } @test "command_not_found: no suggestions for completely unrelated self subcommand" { run lets self zzzznotacommand assert_failure 2 - assert_output --partial 'unknown command "zzzznotacommand" for "lets self"' + assert_output --partial 'Unknown command "zzzznotacommand" for "lets self"' refute_output --partial 'Did you mean this?' } diff --git a/tests/command_options.bats b/tests/command_options.bats index 2ca800dc..0fbe2800 100644 --- a/tests/command_options.bats +++ b/tests/command_options.bats @@ -108,26 +108,18 @@ setup() { run lets test-options --kv-opt assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ test-options <-- failed here" - assert_line --index 2 "lets: failed to parse docopt options for cmd test-options: --kv-opt requires argument" - assert_line --index 3 "Usage:" - assert_line --index 4 " lets test-options [--kv-opt=] [--bool-opt] [--attr=...] [...]" - assert_line --index 5 "Options:" - assert_line --index 6 " ... Positional args in the end" - assert_line --index 7 " --bool-opt, -b Boolean opt" - assert_line --index 8 " --kv-opt=, -K Key value opt" - assert_line --index 9 " --attr=... Repeated kv args" + assert_output --partial "Failed to parse docopt options for cmd test-options: --kv-opt requires argument" + assert_output --partial "test-options" + assert_output --partial "<-- failed here" } @test "command_options: wrong usage" { run lets options-wrong-usage assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ options-wrong-usage <-- failed here" - assert_line --index 2 "lets: failed to parse docopt options for cmd options-wrong-usage: unknown option or argument: options-wrong-usage" - assert_line --index 3 "Usage: lets options-wrong-usage-xxx" + assert_output --partial "Failed to parse docopt options for cmd options-wrong-usage" + assert_output --partial "options-wrong-usage" + assert_output --partial "<-- failed here" } @test "command_options: should not break json argument" { diff --git a/tests/completion.bats b/tests/completion.bats index 930387cc..df5dfbf4 100644 --- a/tests/completion.bats +++ b/tests/completion.bats @@ -13,14 +13,14 @@ setup() { LETS_CONFIG_DIR="no_lets_file" run lets completion assert_success - assert_line --index 0 "Generates completion scripts for bash, zsh" + assert_output --partial "Generates completion scripts for bash, zsh" [[ ! -d .lets ]] } @test "completion: should return completion if lets.yaml exists" { run lets completion assert_success - assert_line --index 0 "Generates completion scripts for bash, zsh" + assert_output --partial "Generates completion scripts for bash, zsh" [[ -d .lets ]] } diff --git a/tests/config_version.bats b/tests/config_version.bats index 05e2d148..2766c882 100644 --- a/tests/config_version.bats +++ b/tests/config_version.bats @@ -18,7 +18,7 @@ teardown() { LETS_CONFIG=lets-with-version-0.0.1.yaml run ./lets assert_success - assert_line --index 0 "A CLI task runner" + assert_output --partial "A CLI task runner" } @test "config_version: if config version greater than lets version - fail - require upgrade" { @@ -31,5 +31,5 @@ teardown() { @test "config_version: no version specified" { LETS_CONFIG=lets-without-version.yaml run ./lets assert_success - assert_line --index 0 "A CLI task runner" + assert_output --partial "A CLI task runner" } diff --git a/tests/default_env.bats b/tests/default_env.bats index c21014c8..a72b5959 100644 --- a/tests/default_env.bats +++ b/tests/default_env.bats @@ -61,7 +61,7 @@ setup() { LETS_CONFIG_DIR=./a run lets print-workdir assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ print-workdir <-- failed here" - assert_line --index 2 "lets: chdir ${TEST_DIR}/b: no such file or directory" + assert_output --partial "print-workdir" + assert_output --partial "<-- failed here" + assert_output --partial "chdir ${TEST_DIR}/b: no such file or directory" } diff --git a/tests/dependency_failure_tree.bats b/tests/dependency_failure_tree.bats index 3da19553..86e0db67 100644 --- a/tests/dependency_failure_tree.bats +++ b/tests/dependency_failure_tree.bats @@ -9,17 +9,17 @@ setup() { @test "dependency_failure_tree: shows full 3-level chain on failure" { run env NO_COLOR=1 lets deploy assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ deploy" - assert_line --index 2 " └─ build" - assert_line --index 3 " └─ lint <-- failed here" - assert_line --index 4 "lets: exit status 1" + assert_output --partial "deploy" + assert_output --partial "build" + assert_output --partial "lint" + assert_output --partial "<-- failed here" + assert_output --partial "Exit status 1" } @test "dependency_failure_tree: single node when no depends" { run env NO_COLOR=1 lets lint assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ lint <-- failed here" - assert_line --index 2 "lets: exit status 1" + assert_output --partial "lint" + assert_output --partial "<-- failed here" + assert_output --partial "Exit status 1" } diff --git a/tests/help.bats b/tests/help.bats index e9246e50..bd021350 100644 --- a/tests/help.bats +++ b/tests/help.bats @@ -6,69 +6,6 @@ setup() { cd ./tests/help } -HELP_MESSAGE=$(cat <