Skip to content

Commit

Permalink
Make the help output pretty (#64)
Browse files Browse the repository at this point in the history
* Make the help output pretty

* Add another sub command to the simple example
  • Loading branch information
FollowTheProcess authored Aug 3, 2024
1 parent 09564f4 commit 3cd05c8
Show file tree
Hide file tree
Showing 10 changed files with 183 additions and 13 deletions.
5 changes: 4 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ Things I want to do to make this library as good as it can be and a better, simp
- [x] Rename `cmd.example` to `cmd.examples`
- [x] Clean up/rewrite some of the functions borrowed from Cobra e.g. `argsMinusFirstX`
- [ ] Clean up and optimise the parsing logic
- [ ] Do some benchmarking and see where we can improve
- [x] Remove the `Loop` tag from parsing functions
- [ ] More full test programs as integration tests
- [ ] Write a package example doc
- [ ] Make the help output as pretty as possible, see [clap] for inspiration as their help is so nice
- [ ] Write some fully formed examples under `./examples` for people to see as inspiration
- [x] Make the help output as pretty as possible, see [clap] for inspiration as their help is so nice
- [x] Implement something nice to do the whole `-- <bonus args>` thing, maybe `ExtraArgs`?
- [x] Thin wrapper around tabwriter to keep it consistent
- [ ] Try this on some of my CLI tools to work out the bugs in real world programs

[clap]: https://github.com/clap-rs/clap
2 changes: 1 addition & 1 deletion args.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func MaxArgs(n int) ArgValidator {
return func(cmd *Command, args []string) error {
if len(args) > n {
return fmt.Errorf(
"command %s has a limit of %d arguments, but got %d: %v",
"command %s has a limit of %d argument(s), but got %d: %v",
cmd.name,
n,
len(args),
Expand Down
2 changes: 1 addition & 1 deletion args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func TestArgValidators(t *testing.T) {
cli.Allow(cli.MaxArgs(3)),
},
wantErr: true,
errMsg: "command test has a limit of 3 arguments, but got 7: [loads of args here wow so many]",
errMsg: "command test has a limit of 3 argument(s), but got 7: [loads of args here wow so many]",
},
{
name: "exactargs pass",
Expand Down
22 changes: 14 additions & 8 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"unicode/utf8"

"github.com/FollowTheProcess/cli/internal/colour"
"github.com/FollowTheProcess/cli/internal/flag"
"github.com/FollowTheProcess/cli/internal/table"
)
Expand Down Expand Up @@ -221,6 +222,8 @@ func (c *Command) Execute() error {
}

// A command cannot have no subcommands and no run function, it must define one or the other
// TODO: We can do this at build time, if a command has no run function and no subcommands
// then return the error from [New].
if cmd.run == nil && len(cmd.subcommands) == 0 {
return fmt.Errorf(
"command %s has no subcommands and no run function, a command must either be runnable or have subcommands",
Expand Down Expand Up @@ -441,34 +444,36 @@ func defaultHelp(cmd *Command) error {

// TODO: See if we can be clever about dynamically generating the syntax for e.g. variadic args
// required args, flags etc. based on what the command has defined.
s.WriteString(colour.Title("Usage:"))
s.WriteString(" ")
s.WriteString(colour.Bold(cmd.name))
if len(cmd.subcommands) == 0 {
// We don't have any subcommands so usage will be:
// "Usage: {name} [OPTIONS] ARGS..."
s.WriteString("Usage: ")
s.WriteString(cmd.name)
s.WriteString(" [OPTIONS] ARGS...")
} else {
// We do have subcommands, so usage will instead be:
// "Usage: {name} [OPTIONS] COMMAND"
s.WriteString("Usage: ")
s.WriteString(cmd.name)
s.WriteString(" [OPTIONS] COMMAND")
}

// If the user defined some examples, show those
if len(cmd.examples) != 0 {
s.WriteString("\n\nExamples:")
s.WriteString("\n\n")
s.WriteString(colour.Title("Examples:"))
for _, example := range cmd.examples {
s.WriteString(example.String())
}
}

// Now show subcommands
if len(cmd.subcommands) != 0 {
s.WriteString("\n\nCommands:\n")
s.WriteString("\n\n")
s.WriteString(colour.Title("Commands:"))
s.WriteString("\n")
tab := table.New(s)
for _, subcommand := range cmd.subcommands {
tab.Row(" %s\t%s\n", subcommand.name, subcommand.short)
tab.Row(" %s\t%s\n", colour.Bold(subcommand.name), subcommand.short)
}
if err := tab.Flush(); err != nil {
return fmt.Errorf("could not format subcommands: %w", err)
Expand All @@ -483,7 +488,8 @@ func defaultHelp(cmd *Command) error {
// If there weren't, we need some more space
s.WriteString("\n\n")
}
s.WriteString("Options:\n")
s.WriteString(colour.Title("Options:"))
s.WriteString("\n")
s.WriteString(usage)

// Subcommand help
Expand Down
3 changes: 3 additions & 0 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ func TestHelp(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Force no colour in tests
t.Setenv("NO_COLOR", "true")

stderr := &bytes.Buffer{}
stdout := &bytes.Buffer{}

Expand Down
44 changes: 43 additions & 1 deletion examples/simple/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ func BuildCLI() (*cli.Command, error) {
return nil, err
}

do, err := buildDoCommand()
if err != nil {
return nil, err
}

demo, err := cli.New(
"demo",
cli.Short("An example CLI to demonstrate the library and play with it for real."),
cli.Example("A basic subcommand", "demo say hello world"),
cli.Example("Can do things", "demo do something --count 3"),
cli.Allow(cli.NoArgs()),
cli.SubCommands(say),
cli.SubCommands(say, do),
)
if err != nil {
return nil, err
Expand Down Expand Up @@ -52,6 +58,30 @@ func buildSayCommand() (*cli.Command, error) {
return say, nil
}

type doOptions struct {
count int
fast bool
}

func buildDoCommand() (*cli.Command, error) {
var options doOptions
do, err := cli.New(
"do",
cli.Short("Do a thing"),
cli.Example("Do something", "demo do something --fast"),
cli.Example("Do it 3 times", "demo do something --count 3"),
cli.Allow(cli.MaxArgs(1)), // Only allowed to do one thing
cli.Flag(&options.count, "count", 'c', 1, "Number of times to do the thing"),
cli.Flag(&options.fast, "fast", 'f', false, "Do the thing quickly"),
cli.Run(runDo(&options)),
)
if err != nil {
return nil, err
}

return do, nil
}

func runSay(options *sayOptions) func(cmd *cli.Command, args []string) error {
return func(cmd *cli.Command, args []string) error {
if options.shout {
Expand All @@ -68,3 +98,15 @@ func runSay(options *sayOptions) func(cmd *cli.Command, args []string) error {
return nil
}
}

func runDo(options *doOptions) func(cmd *cli.Command, args []string) error {
return func(cmd *cli.Command, args []string) error {
if options.fast {
fmt.Fprintf(cmd.Stdout(), "Doing %s %d times, but fast!\n", args[0], options.count)
} else {
fmt.Fprintf(cmd.Stdout(), "Doing %s %d times\n", args[0], options.count)
}

return nil
}
}
40 changes: 40 additions & 0 deletions internal/colour/colour.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Package colour implements basic text colouring for cli's limited needs.
//
// In particular, it's not expected to provide every ANSI code, just the ones we need. The codes have also been padded so that they are
// the same length, which means [text/tabwriter] will correctly calculate alignment as long as styles are not mixed within a table.
package colour

import "os"

// ANSI codes for coloured output, they are all the same length so as not to throw off
// alignment of [text/tabwriter].
const (
CodeReset = "\x1b[000000m" // Reset all attributes
CodeTitle = "\x1b[1;37;4m" // Bold, white & underlined
CodeBold = "\x1b[1;0037m" // Bold & white
)

// Title returns the given text in a title style, bold white and underlined.
//
// If $NO_COLOR is set, text will be returned unmodified.
func Title(text string) string {
if noColour() {
return text
}
return CodeTitle + text + CodeReset
}

// Bold returns the given text in bold white.
//
// If $NO_COLOR is set, text will be returned unmodified.
func Bold(text string) string {
if noColour() {
return text
}
return CodeBold + text + CodeReset
}

// noColour returns whether the $NO_COLOR env var was set.
func noColour() bool {
return os.Getenv("NO_COLOR") != ""
}
73 changes: 73 additions & 0 deletions internal/colour/colour_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package colour_test

import (
"testing"

"github.com/FollowTheProcess/cli/internal/colour"
"github.com/FollowTheProcess/test"
)

func TestColour(t *testing.T) {
tests := []struct {
name string // Name of the test case
text string // Text to colour
fn func(text string) string // Printer function to return the coloured version of text
want string // Expected result containing ANSI escape codes
noColor bool // Whether to set the $NO_COLOR env var
}{
{
name: "bold",
text: "hello bold",
fn: colour.Bold,
want: colour.CodeBold + "hello bold" + colour.CodeReset,
},
{
name: "bold no color",
text: "hello bold",
fn: colour.Bold,
noColor: true,
want: "hello bold",
},
{
name: "title",
text: "Section",
fn: colour.Title,
want: colour.CodeTitle + "Section" + colour.CodeReset,
},
{
name: "title no color",
text: "Section",
fn: colour.Title,
noColor: true,
want: "Section",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.noColor {
t.Setenv("NO_COLOR", "true")
}
got := tt.fn(tt.text)
test.Equal(t, got, tt.want)
})
}
}

func TestCodesAllSameLength(t *testing.T) {
test.True(t, len(colour.CodeBold) == len(colour.CodeReset))
test.True(t, len(colour.CodeBold) == len(colour.CodeTitle))
test.True(t, len(colour.CodeReset) == len(colour.CodeTitle))
}

func BenchmarkBold(b *testing.B) {
for range b.N {
colour.Bold("Some bold text here")
}
}

func BenchmarkTitle(b *testing.B) {
for range b.N {
colour.Title("Some title here")
}
}
3 changes: 2 additions & 1 deletion internal/flag/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"unicode/utf8"

"github.com/FollowTheProcess/cli/internal/colour"
"github.com/FollowTheProcess/cli/internal/table"
)

Expand Down Expand Up @@ -201,7 +202,7 @@ func (s *Set) Usage() (string, error) {
shorthand = "N/A"
}

tab.Row(" %s\t--%s\t%s\t%s\n", shorthand, entry.Name, entry.Value.Type(), entry.Usage)
tab.Row(" %s\t--%s\t%s\t%s\n", colour.Bold(shorthand), colour.Bold(entry.Name), entry.Value.Type(), entry.Usage)
}

if err := tab.Flush(); err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/flag/set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,8 @@ func TestUsage(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Force no colour in tests
t.Setenv("NO_COLOR", "true")
set := tt.newSet(t)
golden := filepath.Join(test.Data(t), "TestUsage", tt.golden)

Expand Down

0 comments on commit 3cd05c8

Please sign in to comment.