diff --git a/console/application.go b/console/application.go index 3209d2f0b..e2ae91bb2 100644 --- a/console/application.go +++ b/console/application.go @@ -21,6 +21,8 @@ func NewApplication(name, usage, usageText, version string, artisan ...bool) con instance.Usage = usage instance.UsageText = usageText instance.Version = version + instance.CommandNotFound = commandNotFound + instance.OnUsageError = onUsageError isArtisan := len(artisan) > 0 && artisan[0] return &Application{ @@ -38,8 +40,9 @@ func (r *Application) Register(commands []console.Command) { Action: func(ctx *cli.Context) error { return item.Handle(NewCliContext(ctx)) }, - Category: item.Extend().Category, - Flags: flagsToCliFlags(item.Extend().Flags), + Category: item.Extend().Category, + Flags: flagsToCliFlags(item.Extend().Flags), + OnUsageError: onUsageError, } r.instance.Commands = append(r.instance.Commands, &cliCommand) } @@ -105,7 +108,7 @@ func (r *Application) Run(args []string, exitIfArtisan bool) error { } if exitIfArtisan { - os.Exit(1) + os.Exit(0) } } diff --git a/console/cli_helper.go b/console/cli_helper.go new file mode 100644 index 000000000..f3bb43413 --- /dev/null +++ b/console/cli_helper.go @@ -0,0 +1,345 @@ +package console + +import ( + "fmt" + "io" + "os" + "sort" + "strings" + "text/tabwriter" + "text/template" + + "github.com/charmbracelet/huh" + "github.com/urfave/cli/v2" + "github.com/xrash/smetrics" + + "github.com/goravel/framework/support/color" +) + +func init() { + cli.HelpPrinterCustom = printHelpCustom + cli.AppHelpTemplate = appHelpTemplate + cli.CommandHelpTemplate = commandHelpTemplate + cli.VersionPrinter = printVersion + huh.ErrUserAborted = cli.Exit(color.Red().Sprint("Cancelled."), 0) +} + +const maxLineLength = 10000 + +var usageTemplate = `{{if .UsageText}}{{wrap (colorize .UsageText) 3}}{{else}}{{.HelpName}}{{if .VisibleFlags}} [options]{{end}}{{if .ArgsUsage}}{{.ArgsUsage}}{{else}}{{if .Args}} [arguments...]{{end}}{{end}}{{end}}` +var commandTemplate = `{{ $cv := offsetCommands .VisibleCommands 5}}{{range .VisibleCategories}}{{if .Name}} + {{yellow .Name}}:{{end}}{{range .VisibleCommands}} + {{$s := join .Names ", "}}{{green $s}}{{ $sp := subtract $cv (offset $s 3) }}{{ indent $sp ""}}{{wrap (colorize .Usage) $cv}}{{end}}{{end}}` + +var flagTemplate = `{{ $cv := offsetFlags .VisibleFlags 5}}{{range .VisibleFlags}} + {{$s := getFlagName .}}{{green $s}}{{ $sp := subtract $cv (offset $s 1) }}{{ indent $sp ""}}{{$us := (capitalize .Usage)}}{{wrap (colorize $us) $cv}}{{$df := getFlagDefaultText . }}{{if $df}} {{yellow $df}}{{end}}{{end}}` + +var appHelpTemplate = `{{$v := offset .Usage 6}}{{wrap (colorize .Usage) 3}}{{if .Version}} {{green (wrap .Version $v)}}{{end}} + +{{ yellow "Usage:" }} + {{if .UsageText}}{{wrap (colorize .UsageText) 3}}{{end}}{{if .VisibleFlags}} + +{{ yellow "Options:" }}{{template "flagTemplate" .}}{{end}}{{if .VisibleCommands}} + +{{ yellow "Available commands:" }}{{template "commandTemplate" .}}{{end}} +` + +var commandHelpTemplate = `{{ yellow "Description:" }} + {{ (colorize .Usage) }} + +{{ yellow "Usage:" }} + {{template "usageTemplate" .}}{{if .VisibleFlags}} + +{{ yellow "Options:" }}{{template "flagTemplate" .}}{{end}} +` + +var colorsFuncMap = template.FuncMap{ + "green": color.Green().Sprint, + "red": color.Red().Sprint, + "blue": color.Blue().Sprint, + "yellow": color.Yellow().Sprint, + "cyan": color.Cyan().Sprint, + "white": color.White().Sprint, + "gray": color.Gray().Sprint, + "default": color.Default().Sprint, + "black": color.Black().Sprint, + "magenta": color.Magenta().Sprint, +} + +var colorizeTemp = template.New("colorize").Funcs(colorsFuncMap) + +func subtract(a, b int) int { + return a - b +} + +func indent(spaces int, v string) string { + pad := strings.Repeat(" ", spaces) + return pad + strings.Replace(v, "\n", "\n"+pad, -1) +} + +func wrap(input string, offset int) string { + var ss []string + + lines := strings.Split(input, "\n") + + padding := strings.Repeat(" ", offset) + + for i, line := range lines { + if line == "" { + ss = append(ss, line) + } else { + wrapped := wrapLine(line, offset, padding) + if i == 0 { + ss = append(ss, wrapped) + } else { + ss = append(ss, padding+wrapped) + + } + + } + } + + return strings.Join(ss, "\n") +} + +func wrapLine(input string, offset int, padding string) string { + if maxLineLength <= offset || len(input) <= maxLineLength-offset { + return input + } + + lineWidth := maxLineLength - offset + words := strings.Fields(input) + if len(words) == 0 { + return input + } + + wrapped := words[0] + spaceLeft := lineWidth - len(wrapped) + for _, word := range words[1:] { + if len(word)+1 > spaceLeft { + wrapped += "\n" + padding + word + spaceLeft = lineWidth - len(word) + } else { + wrapped += " " + word + spaceLeft -= 1 + len(word) + } + } + + return wrapped +} + +func offset(input string, fixed int) int { + return len(input) + fixed +} + +func offsetCommands(cmd []*cli.Command, fixed int) int { + var maxLen = 0 + for i := range cmd { + if s := strings.Join(cmd[i].Names(), ", "); len(s) > maxLen { + maxLen = len(s) + } + } + return maxLen + fixed +} + +func offsetFlags(flags []cli.Flag, fixed int) int { + var maxLen = 0 + for i := range flags { + if s := cli.FlagNamePrefixer(flags[i].Names(), ""); len(s) > maxLen { + maxLen = len(s) + } + } + return maxLen + fixed +} + +func getFlagName(flag cli.DocGenerationFlag) string { + names := flag.Names() + sort.Slice(names, func(i, j int) bool { + return len(names[i]) < len(names[j]) + }) + + return cli.FlagNamePrefixer(names, "") +} + +func getFlagDefaultText(flag cli.DocGenerationFlag) string { + defaultValueString := "" + if bf, ok := flag.(*cli.BoolFlag); !ok || !bf.DisableDefaultText { + if s := flag.GetDefaultText(); s != "" { + defaultValueString = fmt.Sprintf(`[default: %s]`, s) + } + } + return defaultValueString +} + +func capitalize(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func colorize(tpml string) string { + if strings.Contains(tpml, "{{") && strings.Contains(tpml, "}}") { + if tp, err := colorizeTemp.Parse(tpml); err == nil { + var out strings.Builder + if err = tp.Execute(&out, tpml); err == nil { + return out.String() + } + } + } + + return tpml +} + +func printVersion(ctx *cli.Context) { + _, _ = fmt.Fprintf(ctx.App.Writer, "%v %v\n", ctx.App.Usage, color.Green().Sprint(ctx.App.Version)) +} + +func printHelpCustom(out io.Writer, templ string, data interface{}, _ map[string]interface{}) { + + funcMap := template.FuncMap{ + "join": strings.Join, + "subtract": subtract, + "indent": indent, + "trim": strings.TrimSpace, + "capitalize": capitalize, + "wrap": wrap, + "offset": offset, + "offsetCommands": offsetCommands, + "offsetFlags": offsetFlags, + "getFlagName": getFlagName, + "getFlagDefaultText": getFlagDefaultText, + "colorize": colorize, + } + + w := tabwriter.NewWriter(out, 1, 8, 2, ' ', 0) + t := template.Must(template.New("help").Funcs(funcMap).Funcs(colorsFuncMap).Parse(templ)) + templates := map[string]string{ + "usageTemplate": usageTemplate, + "commandTemplate": commandTemplate, + "flagTemplate": flagTemplate, + } + for name, value := range templates { + if _, err := t.New(name).Parse(value); err != nil { + if os.Getenv("CLI_TEMPLATE_ERROR_DEBUG") != "" { + _, _ = fmt.Fprintf(cli.ErrWriter, "CLI TEMPLATE ERROR: %#v\n", err) + } + } + } + + err := t.Execute(w, data) + if err != nil { + // If the writer is closed, t.Execute will fail, and there's nothing + // we can do to recover. + if os.Getenv("CLI_TEMPLATE_ERROR_DEBUG") != "" { + _, _ = fmt.Fprintf(cli.ErrWriter, "CLI TEMPLATE ERROR: %#v\n", err) + } + return + } + _ = w.Flush() +} + +func commandNotFound(ctx *cli.Context, command string) { + var ( + msgTxt = fmt.Sprintf("Command '%s' is not defined.", command) + suggestion string + ) + if alternatives := findAlternatives(command, func() (collection []string) { + for i := range ctx.App.Commands { + collection = append(collection, ctx.App.Commands[i].Names()...) + } + return + }()); len(alternatives) > 0 { + if len(alternatives) == 1 { + msgTxt = msgTxt + " Did you mean this?" + } else { + msgTxt = msgTxt + " Did you mean one of these?" + } + suggestion = "\n " + strings.Join(alternatives, "\n ") + } + color.Errorln(msgTxt) + color.Gray().Println(suggestion) +} + +func onUsageError(_ *cli.Context, err error, _ bool) error { + if flag, ok := strings.CutPrefix(err.Error(), "flag provided but not defined: -"); ok { + color.Red().Printfln("The '%s' option does not exist.", flag) + return nil + } + if flag, ok := strings.CutPrefix(err.Error(), "flag needs an argument: -"); ok { + color.Red().Printfln("The '%s' option requires a value.", flag) + return nil + } + if errMsg := err.Error(); strings.HasPrefix(errMsg, "invalid value") && strings.Contains(errMsg, "for flag -") { + var value, flag string + if _, parseErr := fmt.Sscanf(errMsg, "invalid value %q for flag -%s", &value, &flag); parseErr == nil { + color.Red().Printfln("Invalid value '%s' for option '%s'.", value, strings.TrimSuffix(flag, ":")) + return nil + } + } + + return err +} + +func findAlternatives(name string, collection []string) (result []string) { + var ( + threshold = 1e3 + alternatives = make(map[string]float64) + collectionParts = make(map[string][]string) + ) + for i := range collection { + collectionParts[collection[i]] = strings.Split(collection[i], ":") + } + for i, sub := range strings.Split(name, ":") { + for collectionName, parts := range collectionParts { + exists := alternatives[collectionName] != 0 + if len(parts) <= i { + if exists { + alternatives[collectionName] += threshold + } + continue + } + lev := smetrics.WagnerFischer(sub, parts[i], 1, 1, 1) + if float64(lev) <= float64(len(sub))/3 || strings.Contains(parts[i], sub) { + if exists { + alternatives[collectionName] += float64(lev) + } else { + alternatives[collectionName] = float64(lev) + } + } else if exists { + alternatives[collectionName] += threshold + } + } + } + for _, item := range collection { + lev := smetrics.WagnerFischer(name, item, 1, 1, 1) + if float64(lev) <= float64(len(name))/3 || strings.Contains(item, name) { + if alternatives[item] != 0 { + alternatives[item] -= float64(lev) + } else { + alternatives[item] = float64(lev) + } + } + } + type scoredItem struct { + name string + score float64 + } + var sortedAlternatives []scoredItem + for item, score := range alternatives { + if score < 2*threshold { + sortedAlternatives = append(sortedAlternatives, scoredItem{item, score}) + } + } + sort.Slice(sortedAlternatives, func(i, j int) bool { + if sortedAlternatives[i].score == sortedAlternatives[j].score { + return sortedAlternatives[i].name < sortedAlternatives[j].name + } + return sortedAlternatives[i].score < sortedAlternatives[j].score + }) + for _, item := range sortedAlternatives { + result = append(result, item.name) + } + return result +} diff --git a/console/cli_helper_test.go b/console/cli_helper_test.go new file mode 100644 index 000000000..8c079e711 --- /dev/null +++ b/console/cli_helper_test.go @@ -0,0 +1,159 @@ +package console + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/support/color" +) + +func TestShowCommandHelp_HelpPrinterCustom(t *testing.T) { + cliApp := NewApplication("test", "test", "test", "test", true) + cliApp.Register([]console.Command{ + &TestFooCommand{}, + &TestBarCommand{}, + }) + tests := []struct { + name string + call string + containsOutput []string + }{ + { + name: "print app help", + containsOutput: []string{ + color.Yellow().Sprint("Usage:"), + color.Yellow().Sprint("Options:"), + color.Yellow().Sprint("Available commands:"), + color.Yellow().Sprint("test"), + color.Green().Sprint("test:foo"), + color.Green().Sprint("test:bar"), + }, + }, + { + name: "print command help", + call: "help test:foo", + containsOutput: []string{ + color.Yellow().Sprint("Description:"), + color.Yellow().Sprint("Usage:"), + color.Yellow().Sprint("Options:"), + color.Green().Sprint("-b, --bool"), + color.Green().Sprint("-i, --int"), + color.Blue().Sprint("int"), + }, + }, + { + name: "print version", + call: "--version", + containsOutput: []string{ + "test " + color.Green().Sprint("test"), + }, + }, + { + name: "command not found", + call: "not-found", + containsOutput: []string{ + color.New(color.FgLightRed).Sprint("Command 'not-found' is not defined."), + }, + }, + { + name: "command not found(suggest)", + call: "test", + containsOutput: []string{ + color.New(color.FgLightRed).Sprint("Command 'test' is not defined. Did you mean one of these?"), + color.Gray().Sprint(" test:bar"), + color.Gray().Sprint(" test:foo"), + }, + }, + { + name: "command not found(suggest)", + call: "fo", + containsOutput: []string{ + color.New(color.FgLightRed).Sprint("Command 'fo' is not defined. Did you mean this?"), + color.Gray().Sprint(" test:foo"), + }, + }, + { + name: "option not found", + call: "test:foo --not-found", + containsOutput: []string{ + color.Red().Sprint("The 'not-found' option does not exist."), + }, + }, + { + name: "option needs a value", + call: "test:foo --int", + containsOutput: []string{ + color.Red().Sprint("The 'int' option requires a value."), + }, + }, + { + name: "option value is not valid", + call: "test:foo --int not-a-number", + containsOutput: []string{ + color.Red().Sprint("Invalid value 'not-a-number' for option 'int'."), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &bytes.Buffer{} + cliApp.(*Application).instance.Writer = output + got := color.CaptureOutput(func(io.Writer) { + assert.NoError(t, cliApp.Call(tt.call)) + }) + if len(got) == 0 { + got = output.String() + } + for _, contain := range tt.containsOutput { + assert.Contains(t, got, contain) + } + }) + } +} + +type TestFooCommand struct { +} + +type TestBarCommand struct { + TestFooCommand +} + +func (receiver *TestFooCommand) Signature() string { + return "test:foo" +} + +func (receiver *TestFooCommand) Description() string { + return "Test command" +} + +func (receiver *TestFooCommand) Extend() command.Extend { + return command.Extend{ + Category: "test", + Flags: []command.Flag{ + &command.BoolFlag{ + Name: "bool", + Aliases: []string{"b"}, + Usage: "bool flag", + }, + &command.IntFlag{ + Name: "int", + Aliases: []string{"i"}, + Usage: "{{ blue \"int\" }} flag", + }, + }, + } +} + +func (receiver *TestFooCommand) Handle(_ console.Context) error { + + return nil +} + +func (receiver *TestBarCommand) Signature() string { + return "test:bar" +} diff --git a/console/service_provider.go b/console/service_provider.go index fa55f0804..67674254b 100644 --- a/console/service_provider.go +++ b/console/service_provider.go @@ -14,8 +14,8 @@ type ServiceProvider struct { func (receiver *ServiceProvider) Register(app foundation.Application) { app.Singleton(Binding, func(app foundation.Application) (any, error) { - name := "Goravel Framework" - usage := app.Version() + name := "artisan" + usage := "Goravel Framework" usageText := "artisan [global options] command [options] [arguments...]" return NewApplication(name, usage, usageText, app.Version(), true), nil }) diff --git a/support/color/color.go b/support/color/color.go index 5b3e296db..fa08c1ed4 100644 --- a/support/color/color.go +++ b/support/color/color.go @@ -175,6 +175,11 @@ func Warningln(a ...any) { warning.Println(a...) } // CaptureOutput simulates capturing of os.stdout with a buffer and returns what was written to the screen func CaptureOutput(f func(w io.Writer)) string { var outBuf bytes.Buffer + info.Writer = &outBuf + warning.Writer = &outBuf + err.Writer = &outBuf + debug.Writer = &outBuf + success.Writer = &outBuf pterm.SetDefaultOutput(&outBuf) f(&outBuf)