├── .github └── workflows │ └── test.yaml ├── .golangci.yml ├── LICENSE ├── README.md ├── application.go ├── application_test.go ├── args.go ├── args_enhancement.go ├── binary.go ├── category.go ├── command.go ├── command_test.go ├── completion.go ├── completion_installer.go ├── completion_others.go ├── completion_unix.go ├── context.go ├── context_test.go ├── errors.go ├── errors_stacktrace.go ├── errors_test.go ├── flag.go ├── flag_test.go ├── flags.go ├── flags_enhancement.go ├── flags_enhancement_test.go ├── funcs.go ├── go.go ├── go.mod ├── go.sum ├── help.go ├── help_test.go ├── logging_flags.go ├── logging_flags_test.go ├── output_flags.go └── resources ├── completion.bash ├── completion.fish └── completion.zsh /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | lint: 9 | name: Lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: golangci-lint 14 | uses: golangci/golangci-lint-action@v8 15 | 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | go: 21 | - '1.17' 22 | - '1.18' 23 | - '1.19' 24 | - '1.20' 25 | - '1.21' 26 | - '1.22' 27 | - '1.23' 28 | - '1.24' 29 | name: Go ${{ matrix.go }} test 30 | steps: 31 | - 32 | name: Checkout 33 | uses: actions/checkout@v3 34 | - 35 | name: Setup Go 36 | uses: actions/setup-go@v5 37 | with: 38 | go-version: ${{ matrix.go }} 39 | - 40 | name: Run tests 41 | run: go test ./... 42 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | issues-exit-code: 1 5 | 6 | formatters: 7 | enable: 8 | - gofmt 9 | - gci 10 | 11 | linters: 12 | enable: 13 | - wrapcheck 14 | settings: 15 | wrapcheck: 16 | ignore-package-globs: 17 | # We already make sure your own packages wrap errors properly 18 | - github.com/symfony-cli/* 19 | errcheck: 20 | exclude-functions: 21 | - github.com/symfony-cli/terminal.Printf 22 | - github.com/symfony-cli/terminal.Println 23 | - github.com/symfony-cli/terminal.Printfln 24 | - github.com/symfony-cli/terminal.Eprintf 25 | - github.com/symfony-cli/terminal.Eprintln 26 | - github.com/symfony-cli/terminal.Eprintfln 27 | - github.com/symfony-cli/terminal.Eprint 28 | - fmt.Fprintln 29 | - fmt.Fprintf 30 | - fmt.Fprint 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Console 2 | ======= 3 | 4 | Console tries to mimick the Symfony PHP Console component as much as possible, 5 | but in Go. 6 | -------------------------------------------------------------------------------- /application.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "fmt" 24 | "io" 25 | "sort" 26 | "strings" 27 | "sync" 28 | "time" 29 | 30 | "github.com/symfony-cli/terminal" 31 | ) 32 | 33 | // Application is the main structure of a cli application. 34 | type Application struct { 35 | // The name of the program. Defaults to filepath.Base(os.Executable()) 36 | Name string 37 | // Full name of command for help, defaults to Name 38 | HelpName string 39 | // Description of the program. 40 | Usage string 41 | // Version of the program 42 | Version string 43 | // Channel of the program (dev, beta, stable, ...) 44 | Channel string 45 | // Description of the program 46 | Description string 47 | // List of commands to execute 48 | Commands []*Command 49 | // List of flags to parse 50 | Flags []Flag 51 | // Prefix used to automatically find flag in environment 52 | FlagEnvPrefix []string 53 | // Categories contains the categorized commands and is populated on app startup 54 | Categories CommandCategories 55 | // An action to execute before any subcommands are run, but after the context is ready 56 | // If a non-nil error is returned, no subcommands are run 57 | Before BeforeFunc 58 | // An action to execute after any subcommands are run, but after the subcommand has finished 59 | // It is run even if Action() panics 60 | After AfterFunc 61 | // The action to execute when no subcommands are specified 62 | Action ActionFunc 63 | // Build date 64 | BuildDate string 65 | // Copyright of the binary if any 66 | Copyright string 67 | // Writer writer to write output to 68 | Writer io.Writer 69 | // ErrWriter writes error output 70 | ErrWriter io.Writer 71 | 72 | setupOnce sync.Once 73 | } 74 | 75 | // Run is the entry point to the cli app. Parses the arguments slice and routes 76 | // to the proper flag/args combination 77 | func (a *Application) Run(arguments []string) (err error) { 78 | defer func() { 79 | if e := recover(); e != nil { 80 | HandleExitCoder(WrapPanic(e)) 81 | } 82 | }() 83 | 84 | a.setupOnce.Do(func() { 85 | a.setup() 86 | }) 87 | 88 | context := NewContext(a, nil, nil) 89 | context.flagSet, err = a.parseArgs(arguments[1:]) 90 | 91 | a.configureIO(context) 92 | 93 | if err := checkFlagsValidity(a.Flags, context.flagSet, context); err != nil { 94 | return err 95 | } 96 | 97 | if err != nil { 98 | err = IncorrectUsageError{err} 99 | _ = ShowAppHelp(context) 100 | fmt.Fprintln(a.Writer) 101 | HandleExitCoder(err) 102 | return err 103 | } 104 | 105 | defer func() { 106 | if a.After != nil { 107 | if afterErr := a.After(context); afterErr != nil { 108 | if err != nil { 109 | err = newMultiError(err, afterErr) 110 | } else { 111 | err = afterErr 112 | } 113 | } 114 | } 115 | }() 116 | 117 | args := context.Args() 118 | if args.Present() { 119 | name := args.first() 120 | context.Command = a.BestCommand(name) 121 | } 122 | 123 | if a.Before != nil { 124 | beforeErr := a.Before(context) 125 | if beforeErr != nil { 126 | fmt.Fprintf(a.Writer, "%v\n\n", beforeErr) 127 | _ = ShowAppHelp(context) 128 | HandleExitCoder(beforeErr) 129 | err = beforeErr 130 | return err 131 | } 132 | } 133 | 134 | if checkHelp(context) { 135 | err := ShowAppHelpAction(context) 136 | HandleExitCoder(err) 137 | return err 138 | } 139 | 140 | if checkVersion(context) { 141 | ShowVersion(context) 142 | return nil 143 | } 144 | 145 | if c := context.Command; c != nil { 146 | err = c.Run(context) 147 | } else { 148 | err = a.Action(context) 149 | } 150 | HandleExitCoder(err) 151 | return err 152 | } 153 | 154 | // MustRun is the entry point to the CLI app. Parses the arguments slice and routes 155 | // to the proper flag/args combination. Under the hood it calls `Run` but will panic 156 | // if any error happen 157 | func (a *Application) MustRun(arguments []string) { 158 | if err := a.Run(arguments); err != nil { 159 | panic(err) 160 | } 161 | } 162 | 163 | // Command returns the named command on App. Returns nil if the command does not 164 | // exist 165 | func (a *Application) Command(name string) *Command { 166 | for _, c := range a.Commands { 167 | if c.HasName(name, true) { 168 | c.UserName = name 169 | return c 170 | } 171 | } 172 | return nil 173 | } 174 | 175 | // BestCommand returns the named command on App or a command fuzzy matching if 176 | // there is only one. Returns nil if the command does not exist of if the fuzzy 177 | // matching find more than one. 178 | func (a *Application) BestCommand(name string) *Command { 179 | name = strings.ToLower(name) 180 | if c := a.Command(name); c != nil { 181 | return c 182 | } 183 | 184 | // fuzzy match? 185 | var matches []*Command 186 | for _, c := range a.Commands { 187 | if c.HasName(name, false) { 188 | matches = append(matches, c) 189 | } 190 | } 191 | if len(matches) == 1 { 192 | matches[0].UserName = name 193 | return matches[0] 194 | } 195 | return nil 196 | } 197 | 198 | // Category returns the named CommandCategory on App. Returns nil if the category does not exist 199 | func (a *Application) Category(name string) *CommandCategory { 200 | name = strings.ToLower(name) 201 | if a.Categories == nil { 202 | return nil 203 | } 204 | 205 | for _, c := range a.Categories.Categories() { 206 | if c.Name() == name { 207 | return &c 208 | } 209 | } 210 | 211 | return nil 212 | } 213 | 214 | // VisibleCategories returns a slice of categories and commands that are 215 | // Hidden=false 216 | func (a *Application) VisibleCategories() []CommandCategory { 217 | ret := []CommandCategory{} 218 | for _, category := range a.Categories.Categories() { 219 | if len(category.VisibleCommands()) > 0 { 220 | ret = append(ret, category) 221 | } 222 | } 223 | return ret 224 | } 225 | 226 | // VisibleCommands returns a slice of the Commands with Hidden=false 227 | func (a *Application) VisibleCommands() []*Command { 228 | ret := []*Command{} 229 | for _, command := range a.Commands { 230 | if command.Hidden == nil || !command.Hidden() { 231 | ret = append(ret, command) 232 | } 233 | } 234 | 235 | sort.Slice(ret, func(i, j int) bool { 236 | return ret[i].Name < ret[j].Name 237 | }) 238 | 239 | return ret 240 | } 241 | 242 | // VisibleFlags returns a slice of the Flags with Hidden=false 243 | func (a *Application) VisibleFlags() []Flag { 244 | return visibleFlags(a.Flags) 245 | } 246 | 247 | // setup runs initialization code to ensure all data structures are ready for 248 | // `Run` or inspection prior to `Run`. 249 | func (a *Application) setup() { 250 | if a.BuildDate == "" { 251 | a.BuildDate = time.Now().Format(time.RFC3339) 252 | } 253 | 254 | if a.Name == "" { 255 | a.Name = CurrentBinaryName() 256 | } 257 | 258 | if a.HelpName == "" { 259 | a.HelpName = CurrentBinaryName() 260 | } 261 | 262 | if a.Usage == "" { 263 | a.Usage = "A new cli application" 264 | } 265 | 266 | if a.Version == "" { 267 | a.Version = "0.0.0" 268 | } 269 | 270 | if a.Channel == "" { 271 | a.Channel = "dev" 272 | } 273 | 274 | if a.Action == nil { 275 | a.Action = helpCommand.Action 276 | } 277 | 278 | if a.Writer == nil { 279 | a.Writer = terminal.Stdout 280 | } 281 | if a.ErrWriter == nil { 282 | a.ErrWriter = terminal.Stderr 283 | } 284 | 285 | a.prependFlag(VersionFlag) 286 | 287 | if LogLevelFlag != nil && LogLevelFlag.Name != "" { 288 | a.prependFlag(LogLevelFlag) 289 | } 290 | 291 | if QuietFlag != nil && QuietFlag.Name != "" { 292 | a.prependFlag(QuietFlag.ForApp(a)) 293 | } 294 | 295 | if NoInteractionFlag != nil && NoInteractionFlag.Name != "" { 296 | a.prependFlag(NoInteractionFlag) 297 | } 298 | 299 | if AnsiFlag != nil { 300 | a.prependFlag(AnsiFlag) 301 | } 302 | 303 | if NoAnsiFlag != nil && NoAnsiFlag.Name != "" { 304 | a.prependFlag(NoAnsiFlag) 305 | } 306 | 307 | if a.Command(helpCommand.Name) == nil && (helpCommand.Hidden == nil || !helpCommand.Hidden()) { 308 | a.Commands = append([]*Command{helpCommand}, a.Commands...) 309 | // This command is global and as such is mutated by tests so we reset 310 | // the flags to ensure a consistent behaviour 311 | helpCommand.Flags = nil 312 | } 313 | 314 | if a.Command(versionCommand.Name) == nil && (versionCommand.Hidden == nil || !versionCommand.Hidden()) { 315 | a.Commands = append([]*Command{versionCommand}, a.Commands...) 316 | // This command is global and as such is mutated by tests so we reset 317 | // the flags to ensure a consistent behaviour 318 | helpCommand.Flags = nil 319 | } 320 | 321 | if HelpFlag != nil { 322 | a.prependFlag(HelpFlag) 323 | } 324 | 325 | registerAutocompleteCommands(a) 326 | 327 | for _, c := range a.Commands { 328 | c.normalizeCommandNames() 329 | if c.HelpName == "" { 330 | c.HelpName = fmt.Sprintf("%s %s", a.HelpName, c.FullName()) 331 | } 332 | checkFlagsUnicity(a.Flags, c.Flags, c.FullName()) 333 | checkArgsModes(c.Args) 334 | } 335 | 336 | a.Categories = newCommandCategories() 337 | for _, command := range a.Commands { 338 | a.Categories.AddCommand(command.Category, command) 339 | } 340 | sort.Sort(a.Categories.(*commandCategories)) 341 | } 342 | 343 | func (a *Application) prependFlag(fl Flag) { 344 | if !hasFlag(a.Flags, fl) { 345 | a.Flags = append([]Flag{fl}, a.Flags...) 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /args.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | type Args interface { 23 | // Get returns the named argument, or else a blank string 24 | Get(name string) string 25 | // first returns the first argument, or else a blank string 26 | first() string 27 | // Tail returns the rest of the arguments (the last "array" one) 28 | // or else an empty string slice 29 | Tail() []string 30 | // Len returns the length of the wrapped slice 31 | Len() int 32 | // Present checks if there are any arguments present 33 | Present() bool 34 | // Slice returns a copy of the internal slice 35 | Slice() []string 36 | } 37 | 38 | type args struct { 39 | values []string 40 | command *Command 41 | } 42 | 43 | func (a *args) Get(name string) string { 44 | if a.command == nil { 45 | return "" 46 | } 47 | 48 | for i, arg := range a.command.Args { 49 | if arg.Name != name || arg.Slice { 50 | continue 51 | } 52 | 53 | if len(a.values) >= i+1 { 54 | return a.values[i] 55 | } 56 | 57 | return arg.Default 58 | } 59 | 60 | return "" 61 | } 62 | 63 | func (a *args) first() string { 64 | if len(a.values) > 0 { 65 | return (a.values)[0] 66 | } 67 | return "" 68 | } 69 | 70 | func (a *args) Tail() []string { 71 | if a.command != nil { 72 | for i, arg := range a.command.Args { 73 | if !arg.Slice { 74 | continue 75 | } 76 | 77 | if len(a.values) >= i+1 { 78 | ret := make([]string, len(a.values)-i) 79 | copy(ret, a.values[i:]) 80 | return ret 81 | } 82 | 83 | break 84 | } 85 | } else if a.Len() > 1 { 86 | ret := make([]string, a.Len()-1) 87 | copy(ret, a.values[1:]) 88 | return ret 89 | } 90 | 91 | return []string{} 92 | } 93 | 94 | func (a *args) Len() int { 95 | return len(a.values) 96 | } 97 | 98 | func (a *args) Present() bool { 99 | return a.Len() != 0 100 | } 101 | 102 | func (a *args) Slice() []string { 103 | ret := make([]string, len(a.values)) 104 | copy(ret, a.values) 105 | return ret 106 | } 107 | -------------------------------------------------------------------------------- /args_enhancement.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "bytes" 24 | "fmt" 25 | "strings" 26 | 27 | "github.com/pkg/errors" 28 | ) 29 | 30 | type ArgDefinition []*Arg 31 | 32 | func (def ArgDefinition) Usage() string { 33 | if len(def) < 1 { 34 | return "" 35 | } 36 | buf := bytes.Buffer{} 37 | 38 | buf.WriteString(" [--]") 39 | 40 | for _, arg := range def { 41 | element := "<" + arg.Name + ">" 42 | if arg.Optional { 43 | element = "[" + element + "]" 44 | } else if arg.Slice { 45 | element = "(" + element + ")" 46 | } 47 | 48 | if arg.Slice { 49 | element += "..." 50 | } 51 | 52 | buf.WriteString(" ") 53 | buf.WriteString(element) 54 | } 55 | 56 | return strings.TrimRight(buf.String(), " ") 57 | } 58 | 59 | type Arg struct { 60 | Name, Default string 61 | Description string 62 | Optional bool 63 | Slice bool 64 | } 65 | 66 | func (a *Arg) String() string { 67 | requiredString := "" 68 | if !a.Optional { 69 | requiredString = " (required)" 70 | } 71 | 72 | defaultValueString := "" 73 | if a.Default != "" { 74 | defaultValueString = fmt.Sprintf(` [default: "%s"]`, a.Default) 75 | } 76 | 77 | usageWithDefault := strings.TrimSpace(fmt.Sprintf("%s%s%s", a.Description, defaultValueString, requiredString)) 78 | return fmt.Sprintf("%s\t%s", a.Name, usageWithDefault) 79 | } 80 | 81 | func checkArgsModes(args []*Arg) { 82 | arguments := make(map[string]bool) 83 | hasSliceArgument := false 84 | hasOptional := false 85 | 86 | for _, arg := range args { 87 | if arguments[arg.Name] { 88 | panic(fmt.Sprintf(`An argument with name "%s" already exists.`, arg.Name)) 89 | } 90 | 91 | if hasSliceArgument { 92 | panic("Cannot add an argument after an array argument.") 93 | } 94 | if !arg.Optional && hasOptional { 95 | panic("Cannot add a required argument after an optional one.") 96 | } 97 | 98 | if arg.Slice { 99 | hasSliceArgument = true 100 | } 101 | if arg.Optional { 102 | hasOptional = true 103 | } 104 | 105 | arguments[arg.Name] = true 106 | } 107 | } 108 | 109 | func checkRequiredArgs(command *Command, context *Context) error { 110 | args := context.Args() 111 | hasSliceArgument := false 112 | maximumArgsLen := 0 113 | 114 | for _, arg := range command.Args { 115 | if arg.Slice { 116 | hasSliceArgument = true 117 | } else { 118 | maximumArgsLen++ 119 | } 120 | 121 | if arg.Optional { 122 | continue 123 | } 124 | 125 | if arg.Slice { 126 | if len(args.Tail()) < 1 { 127 | return errors.Errorf(`Required argument "%s" is not set`, arg.Name) 128 | } 129 | break 130 | } 131 | 132 | if args.Get(arg.Name) == "" { 133 | return errors.Errorf(`Required argument "%s" is not set`, arg.Name) 134 | } 135 | } 136 | 137 | if !hasSliceArgument && args.Len() > maximumArgsLen { 138 | return errors.New("Too many arguments") 139 | } 140 | 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /binary.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "os" 24 | "path/filepath" 25 | 26 | "github.com/pkg/errors" 27 | ) 28 | 29 | func CurrentBinaryName() string { 30 | argv0, err := os.Executable() 31 | if nil != err { 32 | return "" 33 | } 34 | 35 | return filepath.Base(argv0) 36 | } 37 | 38 | func CurrentBinaryPath() (string, error) { 39 | argv0, err := os.Executable() 40 | if nil != err { 41 | return argv0, errors.WithStack(err) 42 | } 43 | return argv0, nil 44 | } 45 | 46 | func CurrentBinaryInvocation() (string, error) { 47 | if len(os.Args) == 0 || os.Args[0] == "" { 48 | return "", errors.New("no binary invokation found") 49 | } 50 | 51 | return os.Args[0], nil 52 | } 53 | 54 | func (c *Context) CurrentBinaryPath() string { 55 | path, err := CurrentBinaryPath() 56 | if err != nil { 57 | panic(err) 58 | } 59 | 60 | return path 61 | } 62 | 63 | func (c *Context) CurrentBinaryInvocation() string { 64 | invocation, err := CurrentBinaryInvocation() 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | return invocation 70 | } 71 | -------------------------------------------------------------------------------- /category.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import "sort" 23 | 24 | type CommandCategories interface { 25 | // AddCommand adds a command to a category, creating a new category if necessary. 26 | AddCommand(category string, command *Command) 27 | // Categories returns a copy of the category slice 28 | Categories() []CommandCategory 29 | } 30 | 31 | type commandCategories []*commandCategory 32 | 33 | func newCommandCategories() CommandCategories { 34 | ret := commandCategories([]*commandCategory{}) 35 | return &ret 36 | } 37 | 38 | func (c *commandCategories) Less(i, j int) bool { 39 | return (*c)[i].Name() < (*c)[j].Name() 40 | } 41 | 42 | func (c *commandCategories) Len() int { 43 | return len(*c) 44 | } 45 | 46 | func (c *commandCategories) Swap(i, j int) { 47 | (*c)[i], (*c)[j] = (*c)[j], (*c)[i] 48 | } 49 | 50 | func (c *commandCategories) AddCommand(category string, command *Command) { 51 | for _, commandCategory := range []*commandCategory(*c) { 52 | if commandCategory.name == category { 53 | commandCategory.commands = append(commandCategory.commands, command) 54 | return 55 | } 56 | } 57 | newVal := commandCategories(append(*c, 58 | &commandCategory{name: category, commands: []*Command{command}})) 59 | (*c) = newVal 60 | } 61 | 62 | func (c *commandCategories) Categories() []CommandCategory { 63 | ret := make([]CommandCategory, len(*c)) 64 | for i, cat := range *c { 65 | ret[i] = cat 66 | } 67 | return ret 68 | } 69 | 70 | // CommandCategory is a category containing commands. 71 | type CommandCategory interface { 72 | // Name returns the category name string 73 | Name() string 74 | // VisibleCommands returns a slice of the Commands with Hidden=false 75 | VisibleCommands() []*Command 76 | } 77 | 78 | type commandCategory struct { 79 | name string 80 | commands []*Command 81 | } 82 | 83 | func (c *commandCategory) Name() string { 84 | return c.name 85 | } 86 | 87 | func (c *commandCategory) VisibleCommands() []*Command { 88 | if c.commands == nil { 89 | return []*Command{} 90 | } 91 | 92 | ret := []*Command{} 93 | for _, command := range c.commands { 94 | if command.Hidden == nil || !command.Hidden() { 95 | ret = append(ret, command) 96 | } 97 | } 98 | 99 | sort.Slice(ret, func(i, j int) bool { 100 | return ret[i].Name < ret[j].Name 101 | }) 102 | 103 | return ret 104 | } 105 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "fmt" 24 | "regexp" 25 | "strings" 26 | ) 27 | 28 | type Alias struct { 29 | Name string 30 | Hidden bool 31 | } 32 | 33 | func (a *Alias) String() string { 34 | return a.Name 35 | } 36 | 37 | // Command is a subcommand for a console.App. 38 | type Command struct { 39 | // The name of the command 40 | Name string 41 | // A list of aliases for the command 42 | Aliases []*Alias 43 | // A short description of the usage of this command 44 | Usage string 45 | // A longer explanation of how the command works 46 | Description string 47 | // or a function responsible to render the description 48 | DescriptionFunc DescriptionFunc 49 | // The category the command is part of 50 | Category string 51 | // The function to call when checking for shell command completions 52 | ShellComplete ShellCompleteFunc 53 | // An action to execute before any sub-subcommands are run, but after the context is ready 54 | // If a non-nil error is returned, no sub-subcommands are run 55 | Before BeforeFunc 56 | // An action to execute after any subcommands are run, but after the subcommand has finished 57 | // It is run even if Action() panics 58 | After AfterFunc 59 | // The function to call when this command is invoked 60 | Action ActionFunc 61 | // List of flags to parse 62 | Flags []Flag 63 | // List of args to parse 64 | Args ArgDefinition 65 | // Treat all flags as normal arguments if true 66 | FlagParsing FlagParsingMode 67 | // Boolean to hide this command from help 68 | Hidden func() bool 69 | // Full name of command for help, defaults to full command name, including parent commands. 70 | HelpName string 71 | // The name used on the CLI by the user 72 | UserName string 73 | } 74 | 75 | func Hide() bool { 76 | return true 77 | } 78 | 79 | func (c *Command) normalizeCommandNames() { 80 | c.Category = strings.ToLower(c.Category) 81 | c.Name = strings.ToLower(c.Name) 82 | c.HelpName = strings.ToLower(c.HelpName) 83 | for _, alias := range c.Aliases { 84 | alias.Name = strings.ToLower(alias.Name) 85 | } 86 | } 87 | 88 | // FullName returns the full name of the command. 89 | // For subcommands this ensures that parent commands are part of the command path 90 | func (c *Command) FullName() string { 91 | if c.Category != "" { 92 | return strings.Join([]string{c.Category, c.Name}, ":") 93 | } 94 | return c.Name 95 | } 96 | 97 | func (c *Command) PreferredName() string { 98 | name := c.FullName() 99 | if name == "" && len(c.Aliases) > 0 { 100 | names := []string{} 101 | for _, a := range c.Aliases { 102 | if name := a.String(); name != "" { 103 | names = append(names, a.String()) 104 | } 105 | } 106 | return strings.Join(names, ", ") 107 | } 108 | return name 109 | } 110 | 111 | // Run invokes the command given the context, parses ctx.Args() to generate command-specific flags 112 | func (c *Command) Run(ctx *Context) (err error) { 113 | if HelpFlag != nil { 114 | // append help to flags 115 | if !hasFlag(c.Flags, HelpFlag) { 116 | c.Flags = append(c.Flags, HelpFlag) 117 | } 118 | } 119 | 120 | set, err := c.parseArgs(ctx.rawArgs().Tail(), ctx.App.FlagEnvPrefix) 121 | context := NewContext(ctx.App, set, ctx) 122 | context.Command = c 123 | if err == nil { 124 | err = checkFlagsValidity(c.Flags, set, context) 125 | } 126 | if err == nil { 127 | err = checkRequiredArgs(c, context) 128 | } 129 | if err != nil { 130 | _ = ShowCommandHelp(ctx, c.FullName()) 131 | fmt.Fprintln(ctx.App.Writer) 132 | return IncorrectUsageError{err} 133 | } 134 | 135 | if checkCommandHelp(context, c.FullName()) { 136 | return nil 137 | } 138 | 139 | if c.After != nil { 140 | defer func() { 141 | afterErr := c.After(context) 142 | if afterErr != nil { 143 | HandleExitCoder(err) 144 | if err != nil { 145 | err = newMultiError(err, afterErr) 146 | } else { 147 | err = afterErr 148 | } 149 | } 150 | }() 151 | } 152 | 153 | if c.Before != nil { 154 | err = c.Before(context) 155 | if err != nil { 156 | _ = ShowCommandHelp(ctx, c.FullName()) 157 | HandleExitCoder(err) 158 | return err 159 | } 160 | } 161 | 162 | err = c.Action(context) 163 | if err != nil { 164 | HandleExitCoder(err) 165 | } 166 | return err 167 | } 168 | 169 | // Names returns the names including short names and aliases. 170 | func (c *Command) Names() []string { 171 | names := []string{} 172 | if name := c.FullName(); name != "" { 173 | names = append(names, name) 174 | } 175 | for _, a := range c.Aliases { 176 | if a.Hidden { 177 | continue 178 | } 179 | if name := a.String(); name != "" { 180 | names = append(names, a.String()) 181 | } 182 | } 183 | 184 | return names 185 | } 186 | 187 | // HasName returns true if Command.Name matches given name 188 | func (c *Command) HasName(name string, exact bool) bool { 189 | possibilities := []string{} 190 | if c.Category != "" { 191 | possibilities = append(possibilities, strings.Join([]string{c.Category, c.Name}, ":")) 192 | } else { 193 | possibilities = append(possibilities, c.Name) 194 | } 195 | for _, alias := range c.Aliases { 196 | possibilities = append(possibilities, alias.String()) 197 | } 198 | for _, p := range possibilities { 199 | if p == name { 200 | return true 201 | } 202 | } 203 | if exact { 204 | return false 205 | } 206 | 207 | parts := strings.Split(name, ":") 208 | for i, part := range parts { 209 | parts[i] = regexp.QuoteMeta(part) 210 | } 211 | re := regexp.MustCompile("^" + strings.Join(parts, "[^:]*:") + "[^:]*$") 212 | for _, p := range possibilities { 213 | if re.MatchString(p) { 214 | return true 215 | } 216 | } 217 | return false 218 | } 219 | 220 | // Arguments returns a slice of the Arguments 221 | func (c *Command) Arguments() ArgDefinition { 222 | return ArgDefinition(c.Args) 223 | } 224 | 225 | // VisibleFlags returns a slice of the Flags with Hidden=false 226 | func (c *Command) VisibleFlags() []Flag { 227 | return visibleFlags(c.Flags) 228 | } 229 | -------------------------------------------------------------------------------- /command_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "errors" 24 | "flag" 25 | "io" 26 | "reflect" 27 | "strings" 28 | "testing" 29 | 30 | . "gopkg.in/check.v1" 31 | ) 32 | 33 | type CommandSuite struct{} 34 | 35 | var _ = Suite(&CommandSuite{}) 36 | 37 | func (cs *CommandSuite) TestCommandFlagParsing(c *C) { 38 | cases := []struct { 39 | testArgs []string 40 | skipFlagParsing bool 41 | expectedErr string 42 | }{ 43 | // Test normal "not ignoring flags" flow 44 | {[]string{"test-cmd", "-break", "blah", "blah"}, false, "Incorrect usage: flag provided but not defined: -break"}, 45 | 46 | {[]string{"test-cmd", "blah", "blah"}, true, ""}, // Test SkipFlagParsing without any args that look like flags 47 | {[]string{"test-cmd", "blah", "-break"}, true, ""}, // Test SkipFlagParsing with random flag arg 48 | {[]string{"test-cmd", "blah", "-help"}, true, ""}, // Test SkipFlagParsing with "special" help flag arg 49 | } 50 | 51 | for _, ca := range cases { 52 | app := &Application{} 53 | app.setup() 54 | set := flag.NewFlagSet("test", 0) 55 | c.Assert(set.Parse(ca.testArgs), IsNil) 56 | 57 | context := NewContext(app, set, nil) 58 | 59 | flagParsingMode := FlagParsingNormal 60 | if ca.skipFlagParsing { 61 | flagParsingMode = FlagParsingSkipped 62 | } 63 | 64 | command := Command{ 65 | Name: "test-cmd", 66 | Aliases: []*Alias{{Name: "tc"}}, 67 | Usage: "this is for testing", 68 | Description: "testing", 69 | Action: func(_ *Context) error { return nil }, 70 | FlagParsing: flagParsingMode, 71 | Args: []*Arg{ 72 | {Name: "my-arg", Slice: true}, 73 | }, 74 | } 75 | 76 | err := command.Run(context) 77 | 78 | if ca.expectedErr == "" { 79 | c.Assert(err, IsNil) 80 | } else { 81 | c.Assert(err, ErrorMatches, ca.expectedErr) 82 | } 83 | c.Assert(context.Args().Slice(), DeepEquals, ca.testArgs) 84 | } 85 | } 86 | 87 | func TestCommand_Run_DoesNotOverwriteErrorFromBefore(t *testing.T) { 88 | app := &Application{ 89 | Commands: []*Command{ 90 | { 91 | Name: "bar", 92 | Before: func(c *Context) error { 93 | return errors.New("before error") 94 | }, 95 | After: func(c *Context) error { 96 | return errors.New("after error") 97 | }, 98 | }, 99 | }, 100 | } 101 | 102 | err := app.Run([]string{"foo", "bar"}) 103 | if err == nil { 104 | t.Fatalf("expected to receive error from Run, got none") 105 | } 106 | 107 | if !strings.Contains(err.Error(), "before error") { 108 | t.Errorf("expected text of error from Before method, but got none in \"%v\"", err) 109 | } 110 | if !strings.Contains(err.Error(), "after error") { 111 | t.Errorf("expected text of error from After method, but got none in \"%v\"", err) 112 | } 113 | } 114 | 115 | func TestCaseInsensitiveCommandNames(t *testing.T) { 116 | app := Application{} 117 | app.ErrWriter = io.Discard 118 | projectList := &Command{Name: "project:LIST", Aliases: []*Alias{{Name: "FOO"}}} 119 | projectLink := &Command{Name: "PROJECT:link"} 120 | app.Commands = []*Command{ 121 | projectList, 122 | projectLink, 123 | } 124 | 125 | app.setup() 126 | 127 | if c := app.BestCommand("project:list"); c != projectList { 128 | t.Fatalf("expected project:list, got %v", c) 129 | } 130 | if c := app.BestCommand("Project:lISt"); c != projectList { 131 | t.Fatalf("expected project:list, got %v", c) 132 | } 133 | if c := app.BestCommand("project:link"); c != projectLink { 134 | t.Fatalf("expected project:link, got %v", c) 135 | } 136 | if c := app.BestCommand("project:Link"); c != projectLink { 137 | t.Fatalf("expected project:link, got %v", c) 138 | } 139 | if c := app.BestCommand("foo"); c != projectList { 140 | t.Fatalf("expected project:link, got %v", c) 141 | } 142 | if c := app.BestCommand("FoO"); c != projectList { 143 | t.Fatalf("expected project:link, got %v", c) 144 | } 145 | } 146 | 147 | func TestFuzzyCommandNames(t *testing.T) { 148 | app := Application{} 149 | app.ErrWriter = io.Discard 150 | projectList := &Command{Name: "project:list"} 151 | projectLink := &Command{Name: "project:link"} 152 | app.Commands = []*Command{ 153 | projectList, 154 | projectLink, 155 | } 156 | 157 | c := app.BestCommand("project:list") 158 | if c != projectList { 159 | t.Fatalf("expected project:list, got %v", c) 160 | } 161 | c = app.BestCommand("project:link") 162 | if c != projectLink { 163 | t.Fatalf("expected project:link, got %v", c) 164 | } 165 | c = app.BestCommand("pro:list") 166 | if c != projectList { 167 | t.Fatalf("expected project:list, got %v", c) 168 | } 169 | c = app.BestCommand("pro:lis") 170 | if c != projectList { 171 | t.Fatalf("expected project:list, got %v", c) 172 | } 173 | c = app.BestCommand("p:lis") 174 | if c != projectList { 175 | t.Fatalf("expected project:list, got %v", c) 176 | } 177 | c = app.BestCommand("p:li") 178 | if c != nil { 179 | t.Fatalf("expected no matches, got %v", c) 180 | } 181 | } 182 | 183 | func TestCommandWithNoNames(t *testing.T) { 184 | c := Command{ 185 | Aliases: []*Alias{ 186 | {}, 187 | {Name: "foo"}, 188 | {Name: "bar"}, 189 | }, 190 | } 191 | 192 | if got, expected := c.Names(), []string{"foo", "bar"}; len(got) != 2 { 193 | t.Fatalf("expected two names, got %v", len(got)) 194 | } else if !reflect.DeepEqual(got, expected) { 195 | t.Fatalf("expected %v, got %v", expected, got) 196 | } 197 | 198 | if name := c.PreferredName(); name != "foo, bar" { 199 | t.Fatalf(`expected "foo, bar", got "%v"`, name) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /completion.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || linux || freebsd || openbsd 2 | 3 | package console 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "runtime/debug" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/posener/complete" 12 | "github.com/rs/zerolog" 13 | "github.com/symfony-cli/terminal" 14 | ) 15 | 16 | func init() { 17 | for _, key := range []string{"COMP_LINE", "COMP_POINT", "COMP_DEBUG"} { 18 | if _, hasEnv := os.LookupEnv(key); hasEnv { 19 | // Disable Garbage collection for faster autocompletion 20 | debug.SetGCPercent(-1) 21 | return 22 | } 23 | } 24 | } 25 | 26 | var autoCompleteCommand = &Command{ 27 | Category: "self", 28 | Name: "autocomplete", 29 | Description: "Internal command to provide shell completion suggestions", 30 | Hidden: Hide, 31 | FlagParsing: FlagParsingSkippedAfterFirstArg, 32 | Args: ArgDefinition{ 33 | &Arg{ 34 | Slice: true, 35 | Optional: true, 36 | }, 37 | }, 38 | Action: AutocompleteAppAction, 39 | } 40 | 41 | func registerAutocompleteCommands(a *Application) { 42 | if IsGoRun() { 43 | return 44 | } 45 | 46 | a.Commands = append( 47 | []*Command{shellAutoCompleteInstallCommand, autoCompleteCommand}, 48 | a.Commands..., 49 | ) 50 | } 51 | 52 | func AutocompleteAppAction(c *Context) error { 53 | // connect posener/complete logger to our logging facilities 54 | logger := terminal.Logger.WithLevel(zerolog.DebugLevel) 55 | complete.Log = func(format string, args ...interface{}) { 56 | logger.Msgf("completion | "+format, args...) 57 | } 58 | 59 | cmd := complete.Command{ 60 | GlobalFlags: make(complete.Flags), 61 | Sub: make(complete.Commands), 62 | } 63 | 64 | // transpose registered commands and flags to posener/complete equivalence 65 | for _, command := range c.App.Commands { 66 | subCmd := command.convertToPosenerCompleteCommand(c) 67 | 68 | if command.Hidden == nil || !command.Hidden() { 69 | cmd.Sub[command.FullName()] = subCmd 70 | } 71 | for _, alias := range command.Aliases { 72 | if !alias.Hidden { 73 | cmd.Sub[alias.String()] = subCmd 74 | } 75 | } 76 | } 77 | 78 | for _, f := range c.App.VisibleFlags() { 79 | if vf, ok := f.(*verbosityFlag); ok { 80 | vf.addToPosenerFlags(c, cmd.GlobalFlags) 81 | continue 82 | } 83 | 84 | predictor := ContextPredictor{f, c} 85 | 86 | for _, name := range f.Names() { 87 | name = fmt.Sprintf("%s%s", prefixFor(name), name) 88 | cmd.GlobalFlags[name] = predictor 89 | } 90 | } 91 | 92 | if !complete.New(c.App.HelpName, cmd).Complete() { 93 | return errors.New("Could not run auto-completion") 94 | } 95 | 96 | return nil 97 | } 98 | 99 | func (c *Command) convertToPosenerCompleteCommand(ctx *Context) complete.Command { 100 | command := complete.Command{ 101 | Flags: make(complete.Flags, 0), 102 | } 103 | 104 | for _, f := range c.VisibleFlags() { 105 | for _, name := range f.Names() { 106 | name = fmt.Sprintf("%s%s", prefixFor(name), name) 107 | command.Flags[name] = ContextPredictor{f, ctx} 108 | } 109 | } 110 | 111 | if len(c.Args) > 0 || c.ShellComplete != nil { 112 | command.Args = ContextPredictor{c, ctx} 113 | } 114 | 115 | return command 116 | } 117 | 118 | func (c *Command) PredictArgs(ctx *Context, a complete.Args) []string { 119 | if c.ShellComplete != nil { 120 | return c.ShellComplete(ctx, a) 121 | } 122 | 123 | return nil 124 | } 125 | 126 | type Predictor interface { 127 | PredictArgs(*Context, complete.Args) []string 128 | } 129 | 130 | // ContextPredictor determines what terms can follow a command or a flag 131 | // It is used for autocompletion, given the last word in the already completed 132 | // command line, what words can complete it. 133 | type ContextPredictor struct { 134 | predictor Predictor 135 | ctx *Context 136 | } 137 | 138 | // Predict invokes the predict function and implements the Predictor interface 139 | func (p ContextPredictor) Predict(a complete.Args) []string { 140 | return p.predictor.PredictArgs(p.ctx, a) 141 | } 142 | -------------------------------------------------------------------------------- /completion_installer.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || linux || freebsd || openbsd 2 | 3 | package console 4 | 5 | import ( 6 | "bytes" 7 | "embed" 8 | "fmt" 9 | "os" 10 | "path" 11 | "strings" 12 | "text/template" 13 | 14 | "github.com/pkg/errors" 15 | "github.com/posener/complete" 16 | "github.com/symfony-cli/terminal" 17 | ) 18 | 19 | // CompletionTemplates holds our shell completions templates. 20 | // 21 | //go:embed resources/completion.* 22 | var CompletionTemplates embed.FS 23 | 24 | var shellAutoCompleteInstallCommand = &Command{ 25 | Category: "self", 26 | Name: "completion", 27 | Aliases: []*Alias{ 28 | {Name: "completion"}, 29 | }, 30 | Usage: "Dumps the completion script for the current shell", 31 | ShellComplete: func(context *Context, c complete.Args) []string { 32 | return []string{"bash", "zsh", "fish"} 33 | }, 34 | Description: `The {{.HelpName}} command dumps the shell completion script required 35 | to use shell autocompletion (currently, bash, zsh and fish completion are supported). 36 | 37 | Static installation 38 | ------------------- 39 | 40 | Dump the script to a global completion file and restart your shell: 41 | 42 | {{.HelpName}} {{ call .Shell }} | sudo tee {{ call .CompletionFile }} 43 | 44 | Or dump the script to a local file and source it: 45 | 46 | {{.HelpName}} {{ call .Shell }} > completion.sh 47 | 48 | # source the file whenever you use the project 49 | source completion.sh 50 | 51 | # or add this line at the end of your "{{ call .RcFile }}" file: 52 | source /path/to/completion.sh 53 | 54 | Dynamic installation 55 | -------------------- 56 | 57 | Add this to the end of your shell configuration file (e.g. "{{ call .RcFile }}"): 58 | 59 | eval "$({{.HelpName}} {{ call .Shell }})"`, 60 | DescriptionFunc: func(command *Command, application *Application) string { 61 | var buf bytes.Buffer 62 | 63 | tpl := template.Must(template.New("description").Parse(command.Description)) 64 | 65 | if err := tpl.Execute(&buf, struct { 66 | // allows to directly access any field from the command inside the template 67 | *Command 68 | Shell func() string 69 | RcFile func() string 70 | CompletionFile func() string 71 | }{ 72 | Command: command, 73 | Shell: GuessShell, 74 | RcFile: func() string { 75 | switch GuessShell() { 76 | case "fish": 77 | return "~/.config/fish/config.fish" 78 | case "zsh": 79 | return "~/.zshrc" 80 | default: 81 | return "~/.bashrc" 82 | } 83 | }, 84 | CompletionFile: func() string { 85 | switch GuessShell() { 86 | case "fish": 87 | return fmt.Sprintf("/etc/fish/completions/%s.fish", application.HelpName) 88 | case "zsh": 89 | return fmt.Sprintf("$fpath[1]/_%s", application.HelpName) 90 | default: 91 | return fmt.Sprintf("/etc/bash_completion.d/%s", application.HelpName) 92 | } 93 | }, 94 | }); err != nil { 95 | panic(err) 96 | } 97 | 98 | return buf.String() 99 | }, 100 | Args: []*Arg{ 101 | { 102 | Name: "shell", 103 | Description: `The shell type (e.g. "bash"), the value of the "$SHELL" env var will be used if this is not given`, 104 | Optional: true, 105 | }, 106 | }, 107 | Action: func(c *Context) error { 108 | shell := c.Args().Get("shell") 109 | if shell == "" { 110 | shell = GuessShell() 111 | } 112 | 113 | templates, err := template.ParseFS(CompletionTemplates, "resources/*") 114 | if err != nil { 115 | return errors.WithStack(err) 116 | } 117 | 118 | if tpl := templates.Lookup(fmt.Sprintf("completion.%s", shell)); tpl != nil { 119 | return errors.WithStack(tpl.Execute(terminal.Stdout, c)) 120 | } 121 | 122 | var supportedShell []string 123 | 124 | for _, tmpl := range templates.Templates() { 125 | if tmpl.Tree == nil || tmpl.Root == nil { 126 | continue 127 | } 128 | supportedShell = append(supportedShell, strings.TrimLeft(path.Ext(tmpl.Name()), ".")) 129 | } 130 | 131 | if shell == "" { 132 | return errors.Errorf(`shell not detected, supported shells: "%s"`, strings.Join(supportedShell, ", ")) 133 | } 134 | 135 | return errors.Errorf(`shell "%s" is not supported, supported shells: "%s"`, shell, strings.Join(supportedShell, ", ")) 136 | }, 137 | } 138 | 139 | func GuessShell() string { 140 | if shell := os.Getenv("SHELL"); shell != "" { 141 | return path.Base(shell) 142 | } 143 | 144 | return "" 145 | } 146 | -------------------------------------------------------------------------------- /completion_others.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin && !linux && !freebsd && !openbsd 2 | // +build !darwin,!linux,!freebsd,!openbsd 3 | 4 | package console 5 | 6 | const HasAutocompleteSupport = false 7 | 8 | func IsAutocomplete(c *Command) bool { 9 | return false 10 | } 11 | 12 | func registerAutocompleteCommands(a *Application) { 13 | } 14 | -------------------------------------------------------------------------------- /completion_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || linux || freebsd || openbsd 2 | // +build darwin linux freebsd openbsd 3 | 4 | package console 5 | 6 | const SupportsAutocomplete = true 7 | 8 | func IsAutocomplete(c *Command) bool { 9 | return c == autoCompleteCommand 10 | } 11 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "flag" 24 | "fmt" 25 | 26 | "github.com/pkg/errors" 27 | ) 28 | 29 | // Context is a type that is passed through to 30 | // each Handler action in a cli application. Context 31 | // can be used to retrieve context-specific args and 32 | // parsed command-line options. 33 | type Context struct { 34 | App *Application 35 | Command *Command 36 | 37 | flagSet *flag.FlagSet 38 | args *args 39 | parentContext *Context 40 | } 41 | 42 | // NewContext creates a new context. For use in when invoking an App or Command action. 43 | func NewContext(app *Application, set *flag.FlagSet, parentCtx *Context) *Context { 44 | return &Context{App: app, flagSet: set, parentContext: parentCtx} 45 | } 46 | 47 | // Set assigns a value to a context flag. 48 | func (c *Context) Set(name, value string) error { 49 | if fs := lookupFlagSet(name, c); fs != nil { 50 | return errors.WithStack(fs.Set(name, value)) 51 | } 52 | 53 | return fmt.Errorf("no such flag -%v", name) 54 | } 55 | 56 | // IsSet determines if the flag was actually set 57 | func (c *Context) IsSet(name string) bool { 58 | if fs := lookupFlagSet(name, c); fs != nil { 59 | isSet := false 60 | fs.Visit(func(f *flag.Flag) { 61 | if f.Name == name { 62 | isSet = true 63 | } 64 | }) 65 | if isSet { 66 | return true 67 | } 68 | } 69 | 70 | return false 71 | } 72 | 73 | // HasFlag determines if a flag is defined in this context and all of its parent 74 | // contexts. 75 | func (c *Context) HasFlag(name string) bool { 76 | return lookupFlag(name, c) != nil 77 | } 78 | 79 | // Lineage returns *this* context and all of its ancestor contexts in order from 80 | // child to parent 81 | func (c *Context) Lineage() []*Context { 82 | lineage := []*Context{} 83 | 84 | for cur := c; cur != nil; cur = cur.parentContext { 85 | lineage = append(lineage, cur) 86 | } 87 | 88 | return lineage 89 | } 90 | 91 | // Args returns the command line arguments associated with the context. 92 | func (c *Context) rawArgs() Args { 93 | v := args{ 94 | values: c.flagSet.Args(), 95 | } 96 | return &v 97 | } 98 | 99 | func (c *Context) Args() Args { 100 | // cache args fetch 101 | if c.args != nil { 102 | return c.args 103 | } 104 | 105 | argsValue := make([]string, 0, c.flagSet.NArg()) 106 | for _, arg := range c.flagSet.Args() { 107 | if arg == "--" { 108 | continue 109 | } 110 | 111 | argsValue = append(argsValue, arg) 112 | } 113 | 114 | c.args = &args{ 115 | values: argsValue, 116 | command: c.Command, 117 | } 118 | return c.args 119 | } 120 | 121 | // NArg returns the number of the command line arguments. 122 | func (c *Context) NArg() int { 123 | return c.Args().Len() 124 | } 125 | 126 | func lookupFlag(name string, ctx *Context) Flag { 127 | for _, c := range ctx.Lineage() { 128 | if c.Command == nil { 129 | continue 130 | } 131 | 132 | for _, f := range c.Command.Flags { 133 | for _, n := range f.Names() { 134 | if n == name { 135 | return f 136 | } 137 | } 138 | } 139 | } 140 | 141 | if ctx.App != nil { 142 | for _, f := range ctx.App.Flags { 143 | for _, n := range f.Names() { 144 | if n == name { 145 | return f 146 | } 147 | } 148 | } 149 | } 150 | 151 | return nil 152 | } 153 | 154 | func lookupFlagSet(name string, ctx *Context) *flag.FlagSet { 155 | for _, c := range ctx.Lineage() { 156 | if c.Command != nil { 157 | name = expandShortcut(c.Command.Flags, name) 158 | } 159 | if c.App != nil { 160 | name = expandShortcut(c.App.Flags, name) 161 | } 162 | if f := c.flagSet.Lookup(name); f != nil { 163 | return c.flagSet 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func lookupRawFlag(name string, ctx *Context) *flag.Flag { 171 | for _, c := range ctx.Lineage() { 172 | if c.Command != nil { 173 | name = expandShortcut(c.Command.Flags, name) 174 | } 175 | if c.App != nil { 176 | name = expandShortcut(c.App.Flags, name) 177 | } 178 | if f := c.flagSet.Lookup(name); f != nil { 179 | return f 180 | } 181 | } 182 | 183 | return nil 184 | } 185 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "flag" 24 | "time" 25 | 26 | "github.com/symfony-cli/terminal" 27 | . "gopkg.in/check.v1" 28 | ) 29 | 30 | type ContextSuite struct{} 31 | 32 | var _ = Suite(&ContextSuite{}) 33 | 34 | func (cs *ContextSuite) TestNewContext(c *C) { 35 | set := flag.NewFlagSet("test", 0) 36 | set.Int("myflag", 12, "doc") 37 | set.Int64("myflagInt64", int64(12), "doc") 38 | set.Uint("myflagUint", uint(93), "doc") 39 | set.Uint64("myflagUint64", uint64(93), "doc") 40 | set.Float64("myflag64", float64(17), "doc") 41 | globalSet := flag.NewFlagSet("test", 0) 42 | globalSet.Int("myflag", 42, "doc") 43 | globalSet.Int64("myflagInt64", int64(42), "doc") 44 | globalSet.Uint("myflagUint", uint(33), "doc") 45 | globalSet.Uint64("myflagUint64", uint64(33), "doc") 46 | globalSet.Float64("myflag64", float64(47), "doc") 47 | globalCtx := NewContext(nil, globalSet, nil) 48 | command := &Command{Name: "mycommand"} 49 | ctx := NewContext(nil, set, globalCtx) 50 | ctx.Command = command 51 | c.Assert(ctx.Int("myflag"), Equals, 12) 52 | c.Assert(ctx.Int64("myflagInt64"), Equals, int64(12)) 53 | c.Assert(ctx.Uint("myflagUint"), Equals, uint(93)) 54 | c.Assert(ctx.Uint64("myflagUint64"), Equals, uint64(93)) 55 | c.Assert(ctx.Float64("myflag64"), Equals, float64(17)) 56 | c.Assert(ctx.Command.Name, Equals, "mycommand") 57 | } 58 | 59 | func (cs *ContextSuite) TestContext_Int(c *C) { 60 | set := flag.NewFlagSet("test", 0) 61 | set.Int("myflag", 12, "doc") 62 | parentSet := flag.NewFlagSet("test", 0) 63 | parentSet.Int("top-flag", 13, "doc") 64 | parentCtx := NewContext(nil, parentSet, nil) 65 | ctx := NewContext(nil, set, parentCtx) 66 | c.Assert(ctx.Int("myflag"), Equals, 12) 67 | c.Assert(ctx.Int("top-flag"), Equals, 13) 68 | } 69 | 70 | func (cs *ContextSuite) TestContext_Int64(c *C) { 71 | set := flag.NewFlagSet("test", 0) 72 | set.Int64("myflagInt64", 12, "doc") 73 | parentSet := flag.NewFlagSet("test", 0) 74 | parentSet.Int64("top-flag", 13, "doc") 75 | parentCtx := NewContext(nil, parentSet, nil) 76 | ctx := NewContext(nil, set, parentCtx) 77 | c.Assert(ctx.Int64("myflagInt64"), Equals, int64(12)) 78 | c.Assert(ctx.Int64("top-flag"), Equals, int64(13)) 79 | } 80 | 81 | func (cs *ContextSuite) TestContext_Uint(c *C) { 82 | set := flag.NewFlagSet("test", 0) 83 | set.Uint("myflagUint", uint(13), "doc") 84 | parentSet := flag.NewFlagSet("test", 0) 85 | parentSet.Uint("top-flag", uint(14), "doc") 86 | parentCtx := NewContext(nil, parentSet, nil) 87 | ctx := NewContext(nil, set, parentCtx) 88 | c.Assert(ctx.Uint("myflagUint"), Equals, uint(13)) 89 | c.Assert(ctx.Uint("top-flag"), Equals, uint(14)) 90 | } 91 | 92 | func (cs *ContextSuite) TestContext_Uint64(c *C) { 93 | set := flag.NewFlagSet("test", 0) 94 | set.Uint64("myflagUint64", uint64(9), "doc") 95 | parentSet := flag.NewFlagSet("test", 0) 96 | parentSet.Uint64("top-flag", uint64(10), "doc") 97 | parentCtx := NewContext(nil, parentSet, nil) 98 | ctx := NewContext(nil, set, parentCtx) 99 | c.Assert(ctx.Uint64("myflagUint64"), Equals, uint64(9)) 100 | c.Assert(ctx.Uint64("top-flag"), Equals, uint64(10)) 101 | } 102 | 103 | func (cs *ContextSuite) TestContext_Float64(c *C) { 104 | set := flag.NewFlagSet("test", 0) 105 | set.Float64("myflag", float64(17), "doc") 106 | parentSet := flag.NewFlagSet("test", 0) 107 | parentSet.Float64("top-flag", float64(18), "doc") 108 | parentCtx := NewContext(nil, parentSet, nil) 109 | ctx := NewContext(nil, set, parentCtx) 110 | c.Assert(ctx.Float64("myflag"), Equals, float64(17)) 111 | c.Assert(ctx.Float64("top-flag"), Equals, float64(18)) 112 | } 113 | 114 | func (cs *ContextSuite) TestContext_Duration(c *C) { 115 | set := flag.NewFlagSet("test", 0) 116 | set.Duration("myflag", 12*time.Second, "doc") 117 | parentSet := flag.NewFlagSet("test", 0) 118 | parentSet.Duration("top-flag", 13*time.Second, "doc") 119 | parentCtx := NewContext(nil, parentSet, nil) 120 | ctx := NewContext(nil, set, parentCtx) 121 | c.Assert(ctx.Duration("myflag"), Equals, 12*time.Second) 122 | c.Assert(ctx.Duration("top-flag"), Equals, 13*time.Second) 123 | } 124 | 125 | func (cs *ContextSuite) TestContext_String(c *C) { 126 | set := flag.NewFlagSet("test", 0) 127 | set.String("myflag", "hello world", "doc") 128 | parentSet := flag.NewFlagSet("test", 0) 129 | parentSet.String("top-flag", "hai veld", "doc") 130 | parentCtx := NewContext(nil, parentSet, nil) 131 | ctx := NewContext(nil, set, parentCtx) 132 | c.Assert(ctx.String("myflag"), Equals, "hello world") 133 | c.Assert(ctx.String("top-flag"), Equals, "hai veld") 134 | } 135 | 136 | func (cs *ContextSuite) TestContext_Bool(c *C) { 137 | set := flag.NewFlagSet("test", 0) 138 | set.Bool("myflag", false, "doc") 139 | parentSet := flag.NewFlagSet("test", 0) 140 | parentSet.Bool("top-flag", true, "doc") 141 | parentCtx := NewContext(nil, parentSet, nil) 142 | ctx := NewContext(nil, set, parentCtx) 143 | c.Assert(ctx.Bool("myflag"), Equals, false) 144 | c.Assert(ctx.Bool("top-flag"), Equals, true) 145 | } 146 | 147 | func (cs *ContextSuite) TestContext_Args(c *C) { 148 | set := flag.NewFlagSet("test", 0) 149 | set.Bool("myflag", false, "doc") 150 | ctx := NewContext(nil, set, nil) 151 | c.Assert(set.Parse([]string{"--myflag", "bat", "baz"}), IsNil) 152 | c.Assert(ctx.Args().Len(), Equals, 2) 153 | c.Assert(ctx.Bool("myflag"), Equals, true) 154 | } 155 | 156 | func (cs *ContextSuite) TestContext_NArg(c *C) { 157 | set := flag.NewFlagSet("test", 0) 158 | set.Bool("myflag", false, "doc") 159 | ctx := NewContext(nil, set, nil) 160 | c.Assert(set.Parse([]string{"--myflag", "bat", "baz"}), IsNil) 161 | c.Assert(ctx.NArg(), Equals, 2) 162 | } 163 | 164 | func (cs *ContextSuite) TestContext_HasFlag(c *C) { 165 | app := &Application{ 166 | Flags: []Flag{ 167 | &StringFlag{Name: "top-flag"}, 168 | }, 169 | Commands: []*Command{ 170 | { 171 | Name: "hello", 172 | Aliases: []*Alias{{Name: "hi"}}, 173 | Flags: []Flag{ 174 | &StringFlag{Name: "one-flag"}, 175 | }, 176 | }, 177 | }, 178 | } 179 | set := flag.NewFlagSet("test", 0) 180 | set.Bool("one-flag", false, "doc") 181 | parentSet := flag.NewFlagSet("test", 0) 182 | parentSet.Bool("top-flag", true, "doc") 183 | parentCtx := NewContext(app, parentSet, nil) 184 | ctx := NewContext(app, set, parentCtx) 185 | 186 | c.Assert(parentCtx.HasFlag("top-flag"), Equals, true) 187 | c.Assert(parentCtx.HasFlag("one-flag"), Equals, false) 188 | c.Assert(parentCtx.HasFlag("bogus"), Equals, false) 189 | 190 | parentCtx.Command = app.Commands[0] 191 | 192 | c.Assert(ctx.HasFlag("top-flag"), Equals, true) 193 | c.Assert(ctx.HasFlag("one-flag"), Equals, true) 194 | c.Assert(ctx.HasFlag("bogus"), Equals, false) 195 | } 196 | 197 | func (cs *ContextSuite) TestContext_IsSet(c *C) { 198 | set := flag.NewFlagSet("test", 0) 199 | set.Bool("one-flag", false, "doc") 200 | set.Bool("two-flag", false, "doc") 201 | set.String("three-flag", "hello world", "doc") 202 | parentSet := flag.NewFlagSet("test", 0) 203 | parentSet.Bool("top-flag", true, "doc") 204 | parentCtx := NewContext(nil, parentSet, nil) 205 | ctx := NewContext(nil, set, parentCtx) 206 | 207 | c.Assert(set.Parse([]string{"--one-flag", "--two-flag", "frob"}), IsNil) 208 | c.Assert(parentSet.Parse([]string{"--top-flag"}), IsNil) 209 | 210 | c.Assert(ctx.IsSet("one-flag"), Equals, true) 211 | c.Assert(ctx.IsSet("two-flag"), Equals, true) 212 | c.Assert(ctx.IsSet("three-flag"), Equals, false) 213 | c.Assert(ctx.IsSet("top-flag"), Equals, true) 214 | c.Assert(ctx.IsSet("bogus"), Equals, false) 215 | } 216 | 217 | func (cs *ContextSuite) TestContext_Set(c *C) { 218 | set := flag.NewFlagSet("test", 0) 219 | set.Int("int", 5, "an int") 220 | ctx := NewContext(nil, set, nil) 221 | 222 | c.Assert(ctx.Set("int", "1"), IsNil) 223 | c.Assert(ctx.Int("int"), Equals, 1) 224 | } 225 | 226 | func (cs *ContextSuite) TestContext_Set_AppFlags(c *C) { 227 | defer func() { 228 | c.Assert(terminal.SetLogLevel(1), IsNil) 229 | }() 230 | 231 | app := &Application{ 232 | Commands: []*Command{ 233 | { 234 | Name: "foo", 235 | Action: func(ctx *Context) error { 236 | err := ctx.Set("log-level", "4") 237 | c.Assert(err, IsNil) 238 | 239 | return nil 240 | }, 241 | }, 242 | }, 243 | } 244 | app.MustRun([]string{"cmd", "foo"}) 245 | } 246 | 247 | func (cs *ContextSuite) TestContext_Lineage(c *C) { 248 | set := flag.NewFlagSet("test", 0) 249 | set.Bool("local-flag", false, "doc") 250 | parentSet := flag.NewFlagSet("test", 0) 251 | parentSet.Bool("top-flag", true, "doc") 252 | parentCtx := NewContext(nil, parentSet, nil) 253 | ctx := NewContext(nil, set, parentCtx) 254 | c.Assert(set.Parse([]string{"--local-flag"}), IsNil) 255 | c.Assert(parentSet.Parse([]string{"--top-flag"}), IsNil) 256 | 257 | lineage := ctx.Lineage() 258 | c.Assert(len(lineage), Equals, 2) 259 | c.Assert(lineage[0], Equals, ctx) 260 | c.Assert(lineage[1], Equals, parentCtx) 261 | } 262 | 263 | func (cs *ContextSuite) TestContext_lookupFlagSet(c *C) { 264 | set := flag.NewFlagSet("test", 0) 265 | set.Bool("local-flag", false, "doc") 266 | parentSet := flag.NewFlagSet("test", 0) 267 | parentSet.Bool("top-flag", true, "doc") 268 | parentCtx := NewContext(nil, parentSet, nil) 269 | ctx := NewContext(nil, set, parentCtx) 270 | c.Assert(set.Parse([]string{"--local-flag"}), IsNil) 271 | c.Assert(parentSet.Parse([]string{"--top-flag"}), IsNil) 272 | 273 | fs := lookupFlagSet("top-flag", ctx) 274 | c.Assert(fs, Equals, parentCtx.flagSet) 275 | 276 | fs = lookupFlagSet("local-flag", ctx) 277 | c.Assert(fs, Equals, ctx.flagSet) 278 | 279 | if fs := lookupFlagSet("frob", ctx); fs != nil { 280 | c.Fail() 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "bytes" 24 | "fmt" 25 | "os" 26 | "path/filepath" 27 | "reflect" 28 | "strings" 29 | 30 | "github.com/symfony-cli/terminal" 31 | ) 32 | 33 | // OsExiter is the function used when the app exits. If not set defaults to os.Exit. 34 | var OsExiter = os.Exit 35 | 36 | // MultiError is an error that wraps multiple errors. 37 | type MultiError interface { 38 | error 39 | // Errors returns a copy of the errors slice 40 | Errors() []error 41 | } 42 | 43 | // newMultiError creates a new MultiError. Pass in one or more errors. 44 | func newMultiError(err ...error) MultiError { 45 | ret := multiError(err) 46 | return &ret 47 | } 48 | 49 | type multiError []error 50 | 51 | // Error implements the error interface. 52 | func (m *multiError) Error() string { 53 | errs := make([]string, len(*m)) 54 | for i, err := range *m { 55 | errs[i] = err.Error() 56 | } 57 | 58 | return strings.Join(errs, "\n") 59 | } 60 | 61 | // Errors returns a copy of the errors slice 62 | func (m *multiError) Errors() []error { 63 | errs := make([]error, len(*m)) 64 | for _, err := range *m { 65 | errs = append(errs, err) 66 | } 67 | return errs 68 | } 69 | 70 | // ExitCoder is the interface checked by `App` and `Command` for a custom exit 71 | // code 72 | type ExitCoder interface { 73 | error 74 | ExitCode() int 75 | } 76 | 77 | type exitError struct { 78 | exitCode int 79 | message string 80 | } 81 | 82 | // Exit wraps a message and exit code into an ExitCoder suitable for handling by 83 | // HandleExitCoder 84 | func Exit(message string, exitCode int) ExitCoder { 85 | return &exitError{ 86 | exitCode: exitCode, 87 | message: message, 88 | } 89 | } 90 | 91 | func (ee *exitError) Error() string { 92 | return ee.message 93 | } 94 | 95 | func (ee *exitError) ExitCode() int { 96 | return ee.exitCode 97 | } 98 | 99 | // HandleExitCoder checks if the error fulfills the ExitCoder interface, and if 100 | // so prints the error to stderr (if it is non-empty) and calls OsExiter with the 101 | // given exit code. If the given error is a MultiError, then this func is 102 | // called on all members of the Errors slice. 103 | func HandleExitCoder(err error) { 104 | if err == nil { 105 | return 106 | } 107 | 108 | HandleError(err) 109 | OsExiter(handleExitCode(err)) 110 | } 111 | 112 | func HandleError(err error) { 113 | if err == nil { 114 | return 115 | } 116 | 117 | if multiErr, ok := err.(MultiError); ok { 118 | for _, merr := range multiErr.Errors() { 119 | HandleError(merr) 120 | } 121 | return 122 | } 123 | 124 | if msg := err.Error(); msg != "" { 125 | var buf bytes.Buffer 126 | 127 | if terminal.IsVerbose() && isGoRun() { 128 | msg = fmt.Sprintf("[%s]\n%s", reflect.TypeOf(err), err) 129 | } 130 | 131 | buf.WriteString(terminal.FormatBlockMessage("error", msg)) 132 | 133 | if terminal.IsVerbose() { 134 | var traceBuf bytes.Buffer 135 | if FormatErrorChain(&traceBuf, err, !isGoRun()) { 136 | buf.WriteString("\nError trace:\n") 137 | buf.Write(traceBuf.Bytes()) 138 | } 139 | } 140 | 141 | terminal.Eprint(buf.String()) 142 | } 143 | } 144 | 145 | func handleExitCode(err error) int { 146 | if exitErr, ok := err.(ExitCoder); ok { 147 | return exitErr.ExitCode() 148 | } 149 | 150 | if multiErr, ok := err.(MultiError); ok { 151 | for _, merr := range multiErr.Errors() { 152 | if exitErr, ok := merr.(ExitCoder); ok { 153 | return exitErr.ExitCode() 154 | } 155 | } 156 | } 157 | 158 | return 1 159 | } 160 | 161 | type IncorrectUsageError struct { 162 | ParentError error 163 | } 164 | 165 | func (e IncorrectUsageError) Cause() error { 166 | return e.ParentError 167 | } 168 | 169 | func (e IncorrectUsageError) Error() string { 170 | return fmt.Sprintf("Incorrect usage: %s", e.ParentError.Error()) 171 | } 172 | 173 | func isGoRun() bool { 174 | // Unfortunately, Golang does not expose that we are currently using go run 175 | // So we detect the main binary is (or used to be ;)) "go" and then the 176 | // current binary is within a temp "go-build" directory. 177 | _, exe := filepath.Split(os.Getenv("_")) 178 | argv0, _ := os.Executable() 179 | 180 | return exe == "go" && strings.Contains(argv0, "go-build") 181 | } 182 | -------------------------------------------------------------------------------- /errors_stacktrace.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "bytes" 24 | "fmt" 25 | "runtime" 26 | "strings" 27 | 28 | "github.com/pkg/errors" 29 | "github.com/symfony-cli/terminal" 30 | ) 31 | 32 | type WrappedPanic struct { 33 | msg interface{} 34 | stack []uintptr 35 | } 36 | 37 | func WrapPanic(msg interface{}) error { 38 | if err, ok := msg.(error); ok { 39 | if _, hasStackTrace := err.(stackTracer); hasStackTrace { 40 | return err 41 | } 42 | } 43 | 44 | const depth = 100 45 | var pcs [depth]uintptr 46 | // 5 is the number of call functions made after a panic to reach the recover with the call this function :) 47 | n := runtime.Callers(0, pcs[:]) 48 | 49 | return WrappedPanic{ 50 | msg: msg, 51 | stack: skipPanicsFromStacktrace(pcs[0:n]), 52 | } 53 | } 54 | 55 | func (p WrappedPanic) Error() string { 56 | return fmt.Sprintf("panic: %v", p.msg) 57 | } 58 | 59 | func (p WrappedPanic) Cause() error { 60 | if err, ok := p.msg.(error); ok { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (p WrappedPanic) StackTrace() errors.StackTrace { 68 | f := make([]errors.Frame, len(p.stack)) 69 | for i, n := 0, len(f); i < n; i++ { 70 | f[i] = errors.Frame(p.stack[i]) 71 | } 72 | return f 73 | } 74 | 75 | func skipPanicsFromStacktrace(stack []uintptr) []uintptr { 76 | newStack := make([]uintptr, len(stack)) 77 | pos := 0 78 | for i, n := 0, len(newStack); i < n; i++ { 79 | f := stack[i] 80 | 81 | pc := uintptr(f) - 1 82 | fn := runtime.FuncForPC(pc) 83 | // we found a panic call, let's strip the previous frames 84 | if fn.Name() == "runtime.gopanic" { 85 | pos = 0 86 | continue 87 | } 88 | 89 | newStack[pos] = f 90 | pos++ 91 | } 92 | 93 | return newStack[:pos] 94 | } 95 | 96 | type causer interface { 97 | Cause() error 98 | } 99 | 100 | type stackTracer interface { 101 | StackTrace() errors.StackTrace 102 | } 103 | 104 | func pc(f errors.Frame) uintptr { return uintptr(f) - 1 } 105 | 106 | func FormatErrorChain(buf *bytes.Buffer, err error, trimPaths bool) bool { 107 | var parent error 108 | 109 | // Go up in the error tree following causes. 110 | // Each new cause is kept until we don't have new ones 111 | // or we find one with a stacktrace, in this case this 112 | // one must be treated on its own. 113 | for cause := err; cause != nil; { 114 | errWithCause, ok := cause.(causer) 115 | if !ok { 116 | break 117 | } 118 | cause = errWithCause.Cause() 119 | if _, newClauseHasStackTrace := cause.(stackTracer); newClauseHasStackTrace { 120 | parent = cause 121 | break 122 | } 123 | } 124 | 125 | var st errors.StackTrace 126 | if errWithStackTrace, hasStackTrace := err.(stackTracer); hasStackTrace { 127 | st = errWithStackTrace.StackTrace() 128 | } else if parent != nil { 129 | if errWithStackTrace, hasStackTrace := parent.(stackTracer); hasStackTrace { 130 | st = errWithStackTrace.StackTrace() 131 | 132 | if errWithCause, ok := parent.(causer); ok { 133 | parent = errWithCause.Cause() 134 | } else { 135 | parent = nil 136 | } 137 | } 138 | } else { 139 | return false 140 | } 141 | 142 | msg := err.Error() 143 | if parent != nil { 144 | msg = strings.TrimSuffix(msg, fmt.Sprintf(": %s", parent.Error())) 145 | } 146 | 147 | buf.WriteString(terminal.FormatBlockMessage("error", msg)) 148 | 149 | for _, f := range st { 150 | buf.WriteString("\n") 151 | pc := pc(f) 152 | fn := runtime.FuncForPC(pc) 153 | if fn == nil { 154 | buf.WriteString("unknown") 155 | } else { 156 | file, line := fn.FileLine(pc) 157 | if trimPaths { 158 | file = trimGOPATH(fn.Name(), file) 159 | } 160 | fmt.Fprintf(buf, "%s\n\t%s:%d", fn.Name(), file, line) 161 | } 162 | } 163 | 164 | buf.WriteByte('\n') 165 | if parent != nil { 166 | buf.WriteByte('\n') 167 | buf.WriteString("Previous error:\n") 168 | FormatErrorChain(buf, parent, trimPaths) 169 | } 170 | 171 | return true 172 | } 173 | 174 | func trimGOPATH(name, file string) string { 175 | // Here we want to get the source file path relative to the compile time 176 | // GOPATH. As of Go 1.6.x there is no direct way to know the compiled 177 | // GOPATH at runtime, but we can infer the number of path segments in the 178 | // GOPATH. We note that fn.Name() returns the function name qualified by 179 | // the import path, which does not include the GOPATH. Thus we can trim 180 | // segments from the beginning of the file path until the number of path 181 | // separators remaining is one more than the number of path separators in 182 | // the function name. For example, given: 183 | // 184 | // GOPATH /home/user 185 | // file /home/user/src/pkg/sub/file.go 186 | // fn.Name() pkg/sub.Type.Method 187 | // 188 | // We want to produce: 189 | // 190 | // pkg/sub/file.go 191 | // 192 | // From this we can easily see that fn.Name() has one less path separator 193 | // than our desired output. We count separators from the end of the file 194 | // path until it finds two more than in the function name and then move 195 | // one character forward to preserve the initial path segment without a 196 | // leading separator. 197 | const sep = "/" 198 | goal := strings.Count(name, sep) + 2 199 | i := len(file) 200 | for n := 0; n < goal; n++ { 201 | i = strings.LastIndex(file[:i], sep) 202 | if i == -1 { 203 | // not enough separators found, set i so that the slice expression 204 | // below leaves file unmodified 205 | i = -len(sep) 206 | break 207 | } 208 | } 209 | // get back to 0 or trim the leading separator 210 | file = file[i+len(sep):] 211 | return file 212 | } 213 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "bytes" 24 | "strings" 25 | "sync" 26 | 27 | "github.com/pkg/errors" 28 | "github.com/symfony-cli/terminal" 29 | . "gopkg.in/check.v1" 30 | ) 31 | 32 | type ErrorsSuite struct{} 33 | 34 | var _ = Suite(&ErrorsSuite{}) 35 | 36 | func mockOsExiter(fn func(int)) func(int) { 37 | once := &sync.Once{} 38 | return func(rc int) { 39 | once.Do(func() { 40 | fn(rc) 41 | }) 42 | } 43 | } 44 | 45 | func (es *ErrorsSuite) TestHandleExitCoder_nil(c *C) { 46 | exitCode := 0 47 | called := false 48 | 49 | OsExiter = mockOsExiter(func(rc int) { 50 | exitCode = rc 51 | called = true 52 | }) 53 | 54 | defer func() { OsExiter = fakeOsExiter }() 55 | 56 | HandleExitCoder(nil) 57 | 58 | c.Assert(exitCode, Equals, 0) 59 | c.Assert(called, Equals, false) 60 | } 61 | 62 | func (es *ErrorsSuite) TestHandleExitCoder_ExitCoder(c *C) { 63 | exitCode := 0 64 | called := false 65 | 66 | OsExiter = mockOsExiter(func(rc int) { 67 | exitCode = rc 68 | called = true 69 | }) 70 | 71 | defer func() { OsExiter = fakeOsExiter }() 72 | 73 | HandleExitCoder(Exit("galactic perimeter breach", 9)) 74 | 75 | c.Assert(exitCode, Equals, 9) 76 | c.Assert(called, Equals, true) 77 | } 78 | 79 | func (es *ErrorsSuite) TestHandleExitCoder_MultiErrorWithExitCoder(c *C) { 80 | exitCode := 0 81 | called := false 82 | 83 | OsExiter = mockOsExiter(func(rc int) { 84 | exitCode = rc 85 | called = true 86 | }) 87 | 88 | defer func() { OsExiter = fakeOsExiter }() 89 | 90 | exitErr := Exit("galactic perimeter breach", 9) 91 | err := newMultiError(errors.New("wowsa"), exitErr, errors.New("egad")) 92 | HandleExitCoder(err) 93 | 94 | c.Assert(exitCode, Equals, 9) 95 | c.Assert(called, Equals, true) 96 | } 97 | 98 | func (es *ErrorsSuite) TestHandleExitCoder_MultiErrorWithoutExitCoder(c *C) { 99 | exitCode := 0 100 | called := false 101 | 102 | OsExiter = func(rc int) { 103 | if !called { 104 | exitCode = rc 105 | called = true 106 | } 107 | } 108 | 109 | defer func() { OsExiter = fakeOsExiter }() 110 | 111 | err := newMultiError(errors.New("wowsa"), errors.New("egad")) 112 | HandleExitCoder(err) 113 | 114 | c.Assert(exitCode, Equals, 1) 115 | c.Assert(called, Equals, true) 116 | } 117 | 118 | func (es *ErrorsSuite) TestHandleExitCoder_ErrorWithMessage(c *C) { 119 | exitCode := 0 120 | called := false 121 | 122 | OsExiter = mockOsExiter(func(rc int) { 123 | exitCode = rc 124 | called = true 125 | }) 126 | previousStderr := terminal.Stderr 127 | defer func() { 128 | OsExiter = fakeOsExiter 129 | terminal.Stderr = previousStderr 130 | }() 131 | 132 | bufferStderr := new(bytes.Buffer) 133 | formatter := terminal.NewFormatter() 134 | terminal.Stderr = terminal.NewOutput(bufferStderr, formatter) 135 | 136 | err := errors.New("gourd havens") 137 | HandleExitCoder(err) 138 | 139 | c.Assert(exitCode, Equals, 1) 140 | c.Assert(called, Equals, true) 141 | c.Assert(strings.Contains(bufferStderr.String(), "gourd havens"), Equals, true) 142 | } 143 | 144 | func (es *ErrorsSuite) TestHandleExitCoder_ErrorWithoutMessage(c *C) { 145 | exitCode := 0 146 | called := false 147 | 148 | OsExiter = mockOsExiter(func(rc int) { 149 | exitCode = rc 150 | called = true 151 | }) 152 | previousStderr := terminal.Stderr 153 | 154 | defer func() { 155 | OsExiter = fakeOsExiter 156 | terminal.Stderr = previousStderr 157 | }() 158 | 159 | bufferStderr := new(bytes.Buffer) 160 | formatter := terminal.NewFormatter() 161 | terminal.Stderr = terminal.NewOutput(bufferStderr, formatter) 162 | 163 | err := errors.New("") 164 | HandleExitCoder(err) 165 | 166 | c.Assert(exitCode, Equals, 1) 167 | c.Assert(called, Equals, true) 168 | c.Assert(bufferStderr.String(), Equals, "") 169 | } 170 | -------------------------------------------------------------------------------- /flag.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "bytes" 24 | "encoding/json" 25 | "flag" 26 | "fmt" 27 | "reflect" 28 | "regexp" 29 | "runtime" 30 | "sort" 31 | "strconv" 32 | "strings" 33 | "time" 34 | 35 | "github.com/pkg/errors" 36 | "github.com/posener/complete" 37 | "github.com/symfony-cli/terminal" 38 | ) 39 | 40 | const defaultPlaceholder = "value" 41 | 42 | var ( 43 | slPfx = fmt.Sprintf("sl:::%d:::", time.Now().UTC().UnixNano()) 44 | 45 | commaWhitespace = regexp.MustCompile("[, ]+.*") 46 | ) 47 | 48 | // VersionFlag prints the version for the application 49 | var VersionFlag = &BoolFlag{ 50 | Name: "V", 51 | Usage: "Print the version", 52 | } 53 | 54 | // HelpFlag prints the help for all commands and subcommands. 55 | // Set to nil to disable the flag. 56 | var HelpFlag = &BoolFlag{ 57 | Name: "help", 58 | Aliases: []string{"h"}, 59 | Usage: "Show help", 60 | } 61 | 62 | // FlagStringer converts a flag definition to a string. This is used by help 63 | // to display a flag. 64 | var FlagStringer FlagStringFunc = stringifyFlag 65 | 66 | // Serializeder is used to circumvent the limitations of flag.FlagSet.Set 67 | type Serializeder interface { 68 | Serialized() string 69 | } 70 | 71 | // FlagsByName is a slice of Flag. 72 | type FlagsByName []Flag 73 | 74 | func (f FlagsByName) Len() int { 75 | return len(f) 76 | } 77 | 78 | func (f FlagsByName) Less(i, j int) bool { 79 | if len(f[j].Names()) == 0 { 80 | return false 81 | } else if len(f[i].Names()) == 0 { 82 | return true 83 | } 84 | return f[i].Names()[0] < f[j].Names()[0] 85 | } 86 | 87 | func (f FlagsByName) Swap(i, j int) { 88 | f[i], f[j] = f[j], f[i] 89 | } 90 | 91 | // Flag is a common interface related to parsing flags in cli. 92 | // For more advanced flag parsing techniques, it is recommended that 93 | // this interface be implemented. 94 | type Flag interface { 95 | fmt.Stringer 96 | 97 | PredictArgs(*Context, complete.Args) []string 98 | Validate(*Context) error 99 | // Apply Flag settings to the given flag set 100 | Apply(*flag.FlagSet) 101 | Names() []string 102 | } 103 | 104 | func flagSet(fsName string, flags []Flag) *flag.FlagSet { 105 | allFlagsNames := make(map[string]interface{}) 106 | set := flag.NewFlagSet(fsName, flag.ContinueOnError) 107 | 108 | for _, f := range flags { 109 | currentFlagNames := flagNames(f) 110 | for _, name := range currentFlagNames { 111 | if _, alreadyThere := allFlagsNames[name]; alreadyThere { 112 | var msg string 113 | if fsName == "" { 114 | msg = fmt.Sprintf("flag redefined: %s", name) 115 | } else { 116 | msg = fmt.Sprintf("%s flag redefined: %s", fsName, name) 117 | } 118 | fmt.Fprintln(terminal.Stderr, msg) 119 | panic(msg) // Happens only if flags are declared with identical names 120 | } 121 | allFlagsNames[name] = nil 122 | } 123 | f.Apply(set) 124 | } 125 | return set 126 | } 127 | 128 | // Generic is a generic parseable type identified by a specific flag 129 | type Generic interface { 130 | Set(value string) error 131 | String() string 132 | } 133 | 134 | // Apply takes the flagset and calls Set on the generic flag with the value 135 | // provided by the user for parsing by the flag 136 | func (f *GenericFlag) Apply(set *flag.FlagSet) { 137 | set.Var(f.Destination, f.Name, f.Usage) 138 | } 139 | 140 | // StringMap wraps a map[string]string to satisfy flag.Value 141 | type StringMap struct { 142 | m map[string]string 143 | hasBeenSet bool 144 | } 145 | 146 | // NewStringMap creates a *StringMap with default values 147 | func NewStringMap(m map[string]string) *StringMap { 148 | return &StringMap{m: m} 149 | } 150 | 151 | // Set appends the string value to the list of values 152 | func (m *StringMap) Set(value string) error { 153 | if !m.hasBeenSet { 154 | m.m = make(map[string]string) 155 | m.hasBeenSet = true 156 | } 157 | 158 | if strings.HasPrefix(value, slPfx) { 159 | // Deserializing assumes overwrite 160 | _ = json.Unmarshal([]byte(strings.Replace(value, slPfx, "", 1)), &m.m) 161 | m.hasBeenSet = true 162 | return nil 163 | } 164 | 165 | parts := strings.SplitN(value, "=", 2) 166 | if len(parts) != 2 { 167 | return errors.New("please use key=value format") 168 | } 169 | m.m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) 170 | return nil 171 | } 172 | 173 | // String returns a readable representation of this value (for usage defaults) 174 | func (m *StringMap) String() string { 175 | if m == nil { 176 | return "" 177 | } 178 | if len(m.m) == 0 { 179 | return "" 180 | } 181 | var buffer bytes.Buffer 182 | keys := make([]string, 0, len(m.m)) 183 | for key := range m.m { 184 | keys = append(keys, key) 185 | } 186 | sort.Strings(keys) 187 | for _, key := range keys { 188 | buffer.WriteString(fmt.Sprintf(`"%s=%s", `, key, m.m[key])) 189 | } 190 | return strings.Trim(buffer.String(), ", ") 191 | } 192 | 193 | // Serialized allows StringSlice to fulfill Serializeder 194 | func (m *StringMap) Serialized() string { 195 | jsonBytes, _ := json.Marshal(m.m) 196 | return fmt.Sprintf("%s%s", slPfx, string(jsonBytes)) 197 | } 198 | 199 | // Value returns the map set by this flag 200 | func (m *StringMap) Value() map[string]string { 201 | return m.m 202 | } 203 | 204 | // Apply populates the flag given the flag set and environment 205 | func (f *StringMapFlag) Apply(set *flag.FlagSet) { 206 | if f.Destination == nil { 207 | f.Destination = NewStringMap(make(map[string]string)) 208 | } 209 | set.Var(f.Destination, f.Name, f.Usage) 210 | } 211 | 212 | // StringSlice wraps a []string to satisfy flag.Value 213 | type StringSlice struct { 214 | slice []string 215 | hasBeenSet bool 216 | } 217 | 218 | // NewStringSlice creates a *StringSlice with default values 219 | func NewStringSlice(defaults ...string) *StringSlice { 220 | return &StringSlice{slice: append([]string{}, defaults...)} 221 | } 222 | 223 | // Set appends the string value to the list of values 224 | func (f *StringSlice) Set(value string) error { 225 | if !f.hasBeenSet { 226 | f.slice = []string{} 227 | f.hasBeenSet = true 228 | } 229 | 230 | if strings.HasPrefix(value, slPfx) { 231 | // Deserializing assumes overwrite 232 | _ = json.Unmarshal([]byte(strings.Replace(value, slPfx, "", 1)), &f.slice) 233 | f.hasBeenSet = true 234 | return nil 235 | } 236 | 237 | f.slice = append(f.slice, value) 238 | return nil 239 | } 240 | 241 | // String returns a readable representation of this value (for usage defaults) 242 | func (f *StringSlice) String() string { 243 | return fmt.Sprintf("%s", f.slice) 244 | } 245 | 246 | // Serialized allows StringSlice to fulfill Serializeder 247 | func (f *StringSlice) Serialized() string { 248 | jsonBytes, _ := json.Marshal(f.slice) 249 | return fmt.Sprintf("%s%s", slPfx, string(jsonBytes)) 250 | } 251 | 252 | // Value returns the slice of strings set by this flag 253 | func (f *StringSlice) Value() []string { 254 | return f.slice 255 | } 256 | 257 | // Apply populates the flag given the flag set and environment 258 | func (f *StringSliceFlag) Apply(set *flag.FlagSet) { 259 | if f.Destination == nil { 260 | f.Destination = NewStringSlice() 261 | } 262 | 263 | set.Var(f.Destination, f.Name, f.Usage) 264 | } 265 | 266 | // IntSlice wraps an []int to satisfy flag.Value 267 | type IntSlice struct { 268 | slice []int 269 | hasBeenSet bool 270 | } 271 | 272 | // NewIntSlice makes an *IntSlice with default values 273 | func NewIntSlice(defaults ...int) *IntSlice { 274 | return &IntSlice{slice: append([]int{}, defaults...)} 275 | } 276 | 277 | // NewInt64Slice makes an *Int64Slice with default values 278 | func NewInt64Slice(defaults ...int64) *Int64Slice { 279 | return &Int64Slice{slice: append([]int64{}, defaults...)} 280 | } 281 | 282 | // SetInt directly adds an integer to the list of values 283 | func (i *IntSlice) SetInt(value int) { 284 | if !i.hasBeenSet { 285 | i.slice = []int{} 286 | i.hasBeenSet = true 287 | } 288 | 289 | i.slice = append(i.slice, value) 290 | } 291 | 292 | // Set parses the value into an integer and appends it to the list of values 293 | func (i *IntSlice) Set(value string) error { 294 | if !i.hasBeenSet { 295 | i.slice = []int{} 296 | i.hasBeenSet = true 297 | } 298 | 299 | if strings.HasPrefix(value, slPfx) { 300 | // Deserializing assumes overwrite 301 | _ = json.Unmarshal([]byte(strings.Replace(value, slPfx, "", 1)), &i.slice) 302 | i.hasBeenSet = true 303 | return nil 304 | } 305 | 306 | tmp, err := strconv.ParseInt(value, 0, 64) 307 | if err != nil { 308 | return errors.WithStack(err) 309 | } 310 | 311 | i.slice = append(i.slice, int(tmp)) 312 | return nil 313 | } 314 | 315 | // String returns a readable representation of this value (for usage defaults) 316 | func (i *IntSlice) String() string { 317 | return fmt.Sprintf("%#v", i.slice) 318 | } 319 | 320 | // Serialized allows IntSlice to fulfill Serializeder 321 | func (i *IntSlice) Serialized() string { 322 | jsonBytes, _ := json.Marshal(i.slice) 323 | return fmt.Sprintf("%s%s", slPfx, string(jsonBytes)) 324 | } 325 | 326 | // Value returns the slice of ints set by this flag 327 | func (i *IntSlice) Value() []int { 328 | return i.slice 329 | } 330 | 331 | // Apply populates the flag given the flag set and environment 332 | func (f *IntSliceFlag) Apply(set *flag.FlagSet) { 333 | if f.Destination == nil { 334 | f.Destination = NewIntSlice() 335 | } 336 | 337 | set.Var(f.Destination, f.Name, f.Usage) 338 | } 339 | 340 | // Int64Slice is an opaque type for []int to satisfy flag.Value 341 | type Int64Slice struct { 342 | slice []int64 343 | hasBeenSet bool 344 | } 345 | 346 | // Set parses the value into an integer and appends it to the list of values 347 | func (f *Int64Slice) Set(value string) error { 348 | if !f.hasBeenSet { 349 | f.slice = []int64{} 350 | f.hasBeenSet = true 351 | } 352 | 353 | if strings.HasPrefix(value, slPfx) { 354 | // Deserializing assumes overwrite 355 | _ = json.Unmarshal([]byte(strings.Replace(value, slPfx, "", 1)), &f.slice) 356 | f.hasBeenSet = true 357 | return nil 358 | } 359 | 360 | tmp, err := strconv.ParseInt(value, 0, 64) 361 | if err != nil { 362 | return errors.WithStack(err) 363 | } 364 | 365 | f.slice = append(f.slice, tmp) 366 | return nil 367 | } 368 | 369 | // String returns a readable representation of this value (for usage defaults) 370 | func (f *Int64Slice) String() string { 371 | return fmt.Sprintf("%#v", f.slice) 372 | } 373 | 374 | // Serialized allows Int64Slice to fulfill Serializeder 375 | func (f *Int64Slice) Serialized() string { 376 | jsonBytes, _ := json.Marshal(f.slice) 377 | return fmt.Sprintf("%s%s", slPfx, string(jsonBytes)) 378 | } 379 | 380 | // Value returns the slice of ints set by this flag 381 | func (f *Int64Slice) Value() []int64 { 382 | return f.slice 383 | } 384 | 385 | // Apply populates the flag given the flag set and environment 386 | func (f *Int64SliceFlag) Apply(set *flag.FlagSet) { 387 | if f.Destination == nil { 388 | f.Destination = NewInt64Slice() 389 | } 390 | 391 | set.Var(f.Destination, f.Name, f.Usage) 392 | } 393 | 394 | // Apply populates the flag given the flag set and environment 395 | func (f *BoolFlag) Apply(set *flag.FlagSet) { 396 | if f.Destination != nil { 397 | set.BoolVar(f.Destination, f.Name, f.DefaultValue, f.Usage) 398 | } else { 399 | set.Bool(f.Name, f.DefaultValue, f.Usage) 400 | } 401 | } 402 | 403 | // Apply populates the flag given the flag set and environment 404 | func (f *StringFlag) Apply(set *flag.FlagSet) { 405 | if f.Destination != nil { 406 | set.StringVar(f.Destination, f.Name, f.DefaultValue, f.Usage) 407 | } else { 408 | set.String(f.Name, f.DefaultValue, f.Usage) 409 | } 410 | } 411 | 412 | // Apply populates the flag given the flag set and environment 413 | func (f *IntFlag) Apply(set *flag.FlagSet) { 414 | if f.Destination != nil { 415 | set.IntVar(f.Destination, f.Name, f.DefaultValue, f.Usage) 416 | } else { 417 | set.Int(f.Name, f.DefaultValue, f.Usage) 418 | } 419 | } 420 | 421 | // Apply populates the flag given the flag set and environment 422 | func (f *Int64Flag) Apply(set *flag.FlagSet) { 423 | if f.Destination != nil { 424 | set.Int64Var(f.Destination, f.Name, f.DefaultValue, f.Usage) 425 | } else { 426 | set.Int64(f.Name, f.DefaultValue, f.Usage) 427 | } 428 | } 429 | 430 | // Apply populates the flag given the flag set and environment 431 | func (f *UintFlag) Apply(set *flag.FlagSet) { 432 | if f.Destination != nil { 433 | set.UintVar(f.Destination, f.Name, f.DefaultValue, f.Usage) 434 | } else { 435 | set.Uint(f.Name, f.DefaultValue, f.Usage) 436 | } 437 | } 438 | 439 | // Apply populates the flag given the flag set and environment 440 | func (f *Uint64Flag) Apply(set *flag.FlagSet) { 441 | if f.Destination != nil { 442 | set.Uint64Var(f.Destination, f.Name, f.DefaultValue, f.Usage) 443 | } else { 444 | set.Uint64(f.Name, f.DefaultValue, f.Usage) 445 | } 446 | } 447 | 448 | // Apply populates the flag given the flag set and environment 449 | func (f *DurationFlag) Apply(set *flag.FlagSet) { 450 | if f.Destination != nil { 451 | set.DurationVar(f.Destination, f.Name, f.DefaultValue, f.Usage) 452 | } else { 453 | set.Duration(f.Name, f.DefaultValue, f.Usage) 454 | } 455 | } 456 | 457 | // Apply populates the flag given the flag set and environment 458 | func (f *Float64Flag) Apply(set *flag.FlagSet) { 459 | if f.Destination != nil { 460 | set.Float64Var(f.Destination, f.Name, f.DefaultValue, f.Usage) 461 | } else { 462 | set.Float64(f.Name, f.DefaultValue, f.Usage) 463 | } 464 | } 465 | 466 | // NewFloat64Slice makes a *Float64Slice with default values 467 | func NewFloat64Slice(defaults ...float64) *Float64Slice { 468 | return &Float64Slice{slice: append([]float64{}, defaults...)} 469 | } 470 | 471 | // Float64Slice is an opaque type for []float64 to satisfy flag.Value 472 | type Float64Slice struct { 473 | slice []float64 474 | hasBeenSet bool 475 | } 476 | 477 | // Set parses the value into a float64 and appends it to the list of values 478 | func (f *Float64Slice) Set(value string) error { 479 | if !f.hasBeenSet { 480 | f.slice = []float64{} 481 | f.hasBeenSet = true 482 | } 483 | 484 | if strings.HasPrefix(value, slPfx) { 485 | // Deserializing assumes overwrite 486 | _ = json.Unmarshal([]byte(strings.Replace(value, slPfx, "", 1)), &f.slice) 487 | f.hasBeenSet = true 488 | return nil 489 | } 490 | 491 | tmp, err := strconv.ParseFloat(value, 64) 492 | if err != nil { 493 | return errors.WithStack(err) 494 | } 495 | 496 | f.slice = append(f.slice, tmp) 497 | return nil 498 | } 499 | 500 | // String returns a readable representation of this value (for usage defaults) 501 | func (f *Float64Slice) String() string { 502 | return fmt.Sprintf("%#v", f.slice) 503 | } 504 | 505 | // Serialized allows Float64Slice to fulfill Serializeder 506 | func (f *Float64Slice) Serialized() string { 507 | jsonBytes, _ := json.Marshal(f.slice) 508 | return fmt.Sprintf("%s%s", slPfx, string(jsonBytes)) 509 | } 510 | 511 | // Value returns the slice of float64s set by this flag 512 | func (f *Float64Slice) Value() []float64 { 513 | return f.slice 514 | } 515 | 516 | // Apply populates the flag given the flag set and environment 517 | func (f *Float64SliceFlag) Apply(set *flag.FlagSet) { 518 | if f.Destination == nil { 519 | f.Destination = NewFloat64Slice() 520 | } 521 | 522 | set.Var(f.Destination, f.Name, f.Usage) 523 | } 524 | 525 | func visibleFlags(fl []Flag) []Flag { 526 | visible := []Flag{} 527 | for _, flag := range fl { 528 | if !flagValue(flag).FieldByName("Hidden").Bool() { 529 | visible = append(visible, flag) 530 | } 531 | } 532 | return visible 533 | } 534 | 535 | func prefixFor(name string) (prefix string) { 536 | if len(name) == 1 { 537 | prefix = "-" 538 | } else { 539 | prefix = "--" 540 | } 541 | 542 | return 543 | } 544 | 545 | // Returns the placeholder, if any, and the unquoted usage string. 546 | func unquoteUsage(usage string) (string, string) { 547 | for i := 0; i < len(usage); i++ { 548 | if usage[i] == '`' { 549 | for j := i + 1; j < len(usage); j++ { 550 | if usage[j] == '`' { 551 | name := usage[i+1 : j] 552 | usage = usage[:i] + name + usage[j+1:] 553 | return name, usage 554 | } 555 | } 556 | break 557 | } 558 | } 559 | return "", usage 560 | } 561 | 562 | func prefixedNames(names []string, placeholder string) string { 563 | var prefixed string 564 | for i, name := range names { 565 | if name == "" { 566 | continue 567 | } 568 | 569 | prefixed += prefixFor(name) + name 570 | if placeholder != "" { 571 | prefixed += "=" + placeholder 572 | } 573 | if i < len(names)-1 { 574 | prefixed += ", " 575 | } 576 | } 577 | return prefixed 578 | } 579 | 580 | func withEnvHint(envVars []string, str string) string { 581 | envText := "" 582 | if len(envVars) > 0 { 583 | prefix := "$" 584 | suffix := "" 585 | sep := ", $" 586 | if runtime.GOOS == "windows" { 587 | prefix = "%" 588 | suffix = "%" 589 | sep = "%, %" 590 | } 591 | envText = fmt.Sprintf(" [%s%s%s]", prefix, strings.Join(envVars, sep), suffix) 592 | } 593 | return str + envText 594 | } 595 | 596 | func flagName(f Flag) string { 597 | return flagStringField(f, "Name") 598 | } 599 | 600 | func flagNames(f Flag) []string { 601 | aliases := append([]string{flagStringField(f, "Name")}, flagStringSliceField(f, "Aliases")...) 602 | 603 | ret := make([]string, 0, len(aliases)) 604 | 605 | for _, part := range aliases { 606 | // v1 -> v2 migration warning zone: 607 | // Strip off anything after the first found comma or space, which 608 | // *hopefully* makes it a tiny bit more obvious that unexpected behavior is 609 | // caused by using the v1 form of stringly typed "Name". 610 | ret = append(ret, commaWhitespace.ReplaceAllString(part, "")) 611 | } 612 | 613 | return ret 614 | } 615 | 616 | func flagStringSliceField(f Flag, name string) []string { 617 | fv := flagValue(f) 618 | field := fv.FieldByName(name) 619 | 620 | if field.IsValid() { 621 | return field.Interface().([]string) 622 | } 623 | 624 | return []string{} 625 | } 626 | 627 | func flagStringField(f Flag, name string) string { 628 | fv := flagValue(f) 629 | field := fv.FieldByName(name) 630 | 631 | if field.IsValid() { 632 | return field.String() 633 | } 634 | 635 | return "" 636 | } 637 | 638 | func flagValue(f Flag) reflect.Value { 639 | fv := reflect.ValueOf(f) 640 | for fv.Kind() == reflect.Ptr { 641 | fv = reflect.Indirect(fv) 642 | } 643 | return fv 644 | } 645 | 646 | func flagIsRequired(f Flag) bool { 647 | field := flagValue(f).FieldByName("Required") 648 | if field.IsValid() && field.Kind() == reflect.Bool { 649 | return field.Bool() 650 | } 651 | 652 | return false 653 | } 654 | 655 | func stringifyFlag(f Flag) string { 656 | fv := flagValue(f) 657 | 658 | switch f := f.(type) { 659 | case *IntSliceFlag: 660 | return withEnvHint(flagStringSliceField(f, "EnvVars"), 661 | stringifyIntSliceFlag(f)) 662 | case *Int64SliceFlag: 663 | return withEnvHint(flagStringSliceField(f, "EnvVars"), 664 | stringifyInt64SliceFlag(f)) 665 | case *Float64SliceFlag: 666 | return withEnvHint(flagStringSliceField(f, "EnvVars"), 667 | stringifyFloat64SliceFlag(f)) 668 | case *StringSliceFlag: 669 | return withEnvHint(flagStringSliceField(f, "EnvVars"), 670 | stringifyStringSliceFlag(f)) 671 | case *StringMapFlag: 672 | return withEnvHint(flagStringSliceField(f, "EnvVars"), 673 | stringifyStringMapFlag(f)) 674 | } 675 | 676 | placeholder, usage := unquoteUsage(fv.FieldByName("Usage").String()) 677 | 678 | needsPlaceholder := false 679 | defaultValueString := "" 680 | val := fv.FieldByName("DefaultValue") 681 | if !val.IsValid() { 682 | val = fv.FieldByName("Destination") 683 | } 684 | if val.IsValid() { 685 | needsPlaceholder = val.Kind() != reflect.Bool 686 | 687 | if val.Kind() == reflect.String && val.String() != "" { 688 | defaultValueString = fmt.Sprintf("%q", val.String()) 689 | } else if val.Kind() != reflect.Bool || val.Bool() { 690 | defaultValueString = fmt.Sprintf("%v", val.Interface()) 691 | } 692 | } 693 | 694 | helpText := fv.FieldByName("DefaultText") 695 | if helpText.IsValid() && helpText.String() != "" { 696 | needsPlaceholder = val.Kind() != reflect.Bool 697 | defaultValueString = helpText.String() 698 | } 699 | 700 | if defaultValueString != "" { 701 | defaultValueString = fmt.Sprintf(" [default: %s]", defaultValueString) 702 | } 703 | requiredString := "" 704 | if flagIsRequired(f) { 705 | requiredString = " (required)" 706 | } 707 | 708 | if needsPlaceholder && placeholder == "" { 709 | placeholder = defaultPlaceholder 710 | } 711 | 712 | usageWithDefault := strings.TrimSpace(fmt.Sprintf("%s%s%s", usage, defaultValueString, requiredString)) 713 | 714 | return withEnvHint(flagStringSliceField(f, "EnvVars"), 715 | fmt.Sprintf("%s\t%s", prefixedNames(f.Names(), placeholder), usageWithDefault)) 716 | } 717 | 718 | func stringifyIntSliceFlag(f *IntSliceFlag) string { 719 | defaultVals := []string{} 720 | if f.Destination != nil && len(f.Destination.Value()) > 0 { 721 | for _, i := range f.Destination.Value() { 722 | defaultVals = append(defaultVals, fmt.Sprintf("%d", i)) 723 | } 724 | } 725 | 726 | return stringifySliceFlag(f.Usage, f.Names(), defaultVals) 727 | } 728 | 729 | func stringifyInt64SliceFlag(f *Int64SliceFlag) string { 730 | defaultVals := []string{} 731 | if f.Destination != nil && len(f.Destination.Value()) > 0 { 732 | for _, i := range f.Destination.Value() { 733 | defaultVals = append(defaultVals, fmt.Sprintf("%d", i)) 734 | } 735 | } 736 | 737 | return stringifySliceFlag(f.Usage, f.Names(), defaultVals) 738 | } 739 | 740 | func stringifyFloat64SliceFlag(f *Float64SliceFlag) string { 741 | defaultVals := []string{} 742 | if f.Destination != nil && len(f.Destination.Value()) > 0 { 743 | for _, i := range f.Destination.Value() { 744 | defaultVals = append(defaultVals, strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", i), "0"), ".")) 745 | } 746 | } 747 | 748 | return stringifySliceFlag(f.Usage, f.Names(), defaultVals) 749 | } 750 | 751 | func stringifyStringSliceFlag(f *StringSliceFlag) string { 752 | defaultVals := []string{} 753 | if f.Destination != nil && len(f.Destination.Value()) > 0 { 754 | for _, s := range f.Destination.Value() { 755 | if len(s) > 0 { 756 | defaultVals = append(defaultVals, fmt.Sprintf("%q", s)) 757 | } 758 | } 759 | } 760 | 761 | return stringifySliceFlag(f.Usage, f.Names(), defaultVals) 762 | } 763 | 764 | func stringifySliceFlag(usage string, names, defaultVals []string) string { 765 | placeholder, usage := unquoteUsage(usage) 766 | if placeholder == "" { 767 | placeholder = defaultPlaceholder 768 | } 769 | 770 | defaultVal := "" 771 | if len(defaultVals) > 0 { 772 | defaultVal = fmt.Sprintf(" [default: %s]", strings.Join(defaultVals, ", ")) 773 | } 774 | 775 | usageWithDefault := strings.TrimSpace(fmt.Sprintf("%s%s", usage, defaultVal)) 776 | return fmt.Sprintf("%s\t%s", prefixedNames(names, placeholder), usageWithDefault) 777 | } 778 | 779 | func stringifyStringMapFlag(f *StringMapFlag) string { 780 | return stringifyMapFlag(f.Usage, f.Names(), f.Destination) 781 | } 782 | 783 | func stringifyMapFlag(usage string, names []string, defaultVals fmt.Stringer) string { 784 | placeholder, usage := unquoteUsage(usage) 785 | if placeholder == "" { 786 | placeholder = "key=value" 787 | } 788 | 789 | defaultVal := "" 790 | if v := defaultVals.String(); len(v) > 0 { 791 | defaultVal = fmt.Sprintf(" [default: %s]", v) 792 | } 793 | 794 | usageWithDefault := strings.TrimSpace(fmt.Sprintf("%s%s", usage, defaultVal)) 795 | return fmt.Sprintf("%s\t%s", prefixedNames(names, placeholder), usageWithDefault) 796 | } 797 | 798 | func hasFlag(flags []Flag, fl Flag) bool { 799 | for _, existing := range flags { 800 | if fl == existing { 801 | return true 802 | } 803 | } 804 | 805 | return false 806 | } 807 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "flag" 24 | "strconv" 25 | "time" 26 | 27 | "github.com/posener/complete" 28 | ) 29 | 30 | // BoolFlag is a flag with type bool 31 | type BoolFlag struct { 32 | Name string 33 | Aliases []string 34 | Usage string 35 | EnvVars []string 36 | Hidden bool 37 | DefaultValue bool 38 | DefaultText string 39 | Required bool 40 | ArgsPredictor func(*Context, complete.Args) []string 41 | Validator func(*Context, bool) error 42 | Destination *bool 43 | } 44 | 45 | // String returns a readable representation of this value 46 | // (for usage defaults) 47 | func (f *BoolFlag) String() string { 48 | return FlagStringer(f) 49 | } 50 | 51 | func (f *BoolFlag) PredictArgs(c *Context, a complete.Args) []string { 52 | if f.ArgsPredictor != nil { 53 | return f.ArgsPredictor(c, a) 54 | } 55 | return []string{"true", "false"} 56 | } 57 | 58 | func (f *BoolFlag) Validate(c *Context) error { 59 | if f.Validator != nil { 60 | return f.Validator(c, c.Bool(f.Name)) 61 | } 62 | return nil 63 | } 64 | 65 | // Names returns the names of the flag 66 | func (f *BoolFlag) Names() []string { 67 | return flagNames(f) 68 | } 69 | 70 | // Bool looks up the value of a local BoolFlag, returns 71 | // false if not found 72 | func (c *Context) Bool(name string) bool { 73 | if f := lookupRawFlag(name, c); f != nil { 74 | return lookupBool(name, f) 75 | } 76 | return false 77 | } 78 | 79 | func lookupBool(name string, f *flag.Flag) bool { 80 | if f == nil { 81 | return false 82 | } 83 | 84 | if parsed, err := strconv.ParseBool(f.Value.String()); err == nil { 85 | return parsed 86 | } 87 | 88 | return false 89 | } 90 | 91 | // DurationFlag is a flag with type time.Duration (see https://golang.org/pkg/time/#ParseDuration) 92 | type DurationFlag struct { 93 | Name string 94 | Aliases []string 95 | Usage string 96 | EnvVars []string 97 | Hidden bool 98 | DefaultValue time.Duration 99 | DefaultText string 100 | Required bool 101 | ArgsPredictor func(*Context, complete.Args) []string 102 | Validator func(*Context, time.Duration) error 103 | Destination *time.Duration 104 | } 105 | 106 | // String returns a readable representation of this value 107 | // (for usage defaults) 108 | func (f *DurationFlag) String() string { 109 | return FlagStringer(f) 110 | } 111 | 112 | func (f *DurationFlag) PredictArgs(c *Context, a complete.Args) []string { 113 | if f.ArgsPredictor != nil { 114 | return f.ArgsPredictor(c, a) 115 | } 116 | return []string{} 117 | } 118 | 119 | func (f *DurationFlag) Validate(c *Context) error { 120 | if f.Validator != nil { 121 | return f.Validator(c, c.Duration(f.Name)) 122 | } 123 | return nil 124 | } 125 | 126 | // Names returns the names of the flag 127 | func (f *DurationFlag) Names() []string { 128 | return flagNames(f) 129 | } 130 | 131 | // Duration looks up the value of a local DurationFlag, returns 132 | // 0 if not found 133 | func (c *Context) Duration(name string) time.Duration { 134 | if f := lookupRawFlag(name, c); f != nil { 135 | return lookupDuration(name, f) 136 | } 137 | return 0 138 | } 139 | 140 | func lookupDuration(name string, f *flag.Flag) time.Duration { 141 | if f == nil { 142 | return 0 143 | } 144 | 145 | if parsed, err := time.ParseDuration(f.Value.String()); err == nil { 146 | return parsed 147 | } 148 | 149 | return 0 150 | } 151 | 152 | // Float64Flag is a flag with type float64 153 | type Float64Flag struct { 154 | Name string 155 | Aliases []string 156 | Usage string 157 | EnvVars []string 158 | Hidden bool 159 | DefaultValue float64 160 | DefaultText string 161 | Required bool 162 | ArgsPredictor func(*Context, complete.Args) []string 163 | Validator func(*Context, float64) error 164 | Destination *float64 165 | } 166 | 167 | // String returns a readable representation of this value 168 | // (for usage defaults) 169 | func (f *Float64Flag) String() string { 170 | return FlagStringer(f) 171 | } 172 | 173 | func (f *Float64Flag) PredictArgs(c *Context, a complete.Args) []string { 174 | if f.ArgsPredictor != nil { 175 | return f.ArgsPredictor(c, a) 176 | } 177 | return []string{} 178 | } 179 | 180 | func (f *Float64Flag) Validate(c *Context) error { 181 | if f.Validator != nil { 182 | return f.Validator(c, c.Float64(f.Name)) 183 | } 184 | return nil 185 | } 186 | 187 | // Names returns the names of the flag 188 | func (f *Float64Flag) Names() []string { 189 | return flagNames(f) 190 | } 191 | 192 | // Float64 looks up the value of a local Float64Flag, returns 193 | // 0 if not found 194 | func (c *Context) Float64(name string) float64 { 195 | if f := lookupRawFlag(name, c); f != nil { 196 | return lookupFloat64(name, f) 197 | } 198 | return 0 199 | } 200 | 201 | func lookupFloat64(name string, f *flag.Flag) float64 { 202 | if f == nil { 203 | return 0 204 | } 205 | 206 | if parsed, err := strconv.ParseFloat(f.Value.String(), 64); err == nil { 207 | return parsed 208 | } 209 | 210 | return 0 211 | } 212 | 213 | // GenericFlag is a flag with type Generic 214 | type GenericFlag struct { 215 | Name string 216 | Aliases []string 217 | Usage string 218 | EnvVars []string 219 | Hidden bool 220 | DefaultText string 221 | Required bool 222 | ArgsPredictor func(*Context, complete.Args) []string 223 | Validator func(*Context, interface{}) error 224 | Destination Generic 225 | } 226 | 227 | // String returns a readable representation of this value 228 | // (for usage defaults) 229 | func (f *GenericFlag) String() string { 230 | return FlagStringer(f) 231 | } 232 | 233 | func (f *GenericFlag) PredictArgs(c *Context, a complete.Args) []string { 234 | if f.ArgsPredictor != nil { 235 | return f.ArgsPredictor(c, a) 236 | } 237 | return []string{} 238 | } 239 | 240 | func (f *GenericFlag) Validate(c *Context) error { 241 | if f.Validator != nil { 242 | return f.Validator(c, c.Generic(f.Name)) 243 | } 244 | return nil 245 | } 246 | 247 | // Names returns the names of the flag 248 | func (f *GenericFlag) Names() []string { 249 | return flagNames(f) 250 | } 251 | 252 | // Generic looks up the value of a local GenericFlag, returns 253 | // nil if not found 254 | func (c *Context) Generic(name string) interface{} { 255 | if f := lookupRawFlag(name, c); f != nil { 256 | return lookupGeneric(name, f) 257 | } 258 | return nil 259 | } 260 | 261 | func lookupGeneric(name string, f *flag.Flag) interface{} { 262 | if f == nil { 263 | return nil 264 | } 265 | 266 | if parsed, err := f.Value, error(nil); err == nil { 267 | return parsed 268 | } 269 | 270 | return nil 271 | } 272 | 273 | // Int64Flag is a flag with type int64 274 | type Int64Flag struct { 275 | Name string 276 | Aliases []string 277 | Usage string 278 | EnvVars []string 279 | Hidden bool 280 | DefaultValue int64 281 | DefaultText string 282 | Required bool 283 | ArgsPredictor func(*Context, complete.Args) []string 284 | Validator func(*Context, int64) error 285 | Destination *int64 286 | } 287 | 288 | // String returns a readable representation of this value 289 | // (for usage defaults) 290 | func (f *Int64Flag) String() string { 291 | return FlagStringer(f) 292 | } 293 | 294 | func (f *Int64Flag) PredictArgs(c *Context, a complete.Args) []string { 295 | if f.ArgsPredictor != nil { 296 | return f.ArgsPredictor(c, a) 297 | } 298 | return []string{} 299 | } 300 | 301 | func (f *Int64Flag) Validate(c *Context) error { 302 | if f.Validator != nil { 303 | return f.Validator(c, c.Int64(f.Name)) 304 | } 305 | return nil 306 | } 307 | 308 | // Names returns the names of the flag 309 | func (f *Int64Flag) Names() []string { 310 | return flagNames(f) 311 | } 312 | 313 | // Int64 looks up the value of a local Int64Flag, returns 314 | // 0 if not found 315 | func (c *Context) Int64(name string) int64 { 316 | if f := lookupRawFlag(name, c); f != nil { 317 | return lookupInt64(name, f) 318 | } 319 | return 0 320 | } 321 | 322 | func lookupInt64(name string, f *flag.Flag) int64 { 323 | if f == nil { 324 | return 0 325 | } 326 | 327 | if parsed, err := strconv.ParseInt(f.Value.String(), 0, 64); err == nil { 328 | return parsed 329 | } 330 | 331 | return 0 332 | } 333 | 334 | // IntFlag is a flag with type int 335 | type IntFlag struct { 336 | Name string 337 | Aliases []string 338 | Usage string 339 | EnvVars []string 340 | Hidden bool 341 | DefaultValue int 342 | DefaultText string 343 | Required bool 344 | ArgsPredictor func(*Context, complete.Args) []string 345 | Validator func(*Context, int) error 346 | Destination *int 347 | } 348 | 349 | // String returns a readable representation of this value 350 | // (for usage defaults) 351 | func (f *IntFlag) String() string { 352 | return FlagStringer(f) 353 | } 354 | 355 | func (f *IntFlag) PredictArgs(c *Context, a complete.Args) []string { 356 | if f.ArgsPredictor != nil { 357 | return f.ArgsPredictor(c, a) 358 | } 359 | return []string{} 360 | } 361 | 362 | func (f *IntFlag) Validate(c *Context) error { 363 | if f.Validator != nil { 364 | return f.Validator(c, c.Int(f.Name)) 365 | } 366 | return nil 367 | } 368 | 369 | // Names returns the names of the flag 370 | func (f *IntFlag) Names() []string { 371 | return flagNames(f) 372 | } 373 | 374 | // Int looks up the value of a local IntFlag, returns 375 | // 0 if not found 376 | func (c *Context) Int(name string) int { 377 | if f := lookupRawFlag(name, c); f != nil { 378 | return lookupInt(name, f) 379 | } 380 | return 0 381 | } 382 | 383 | func lookupInt(name string, f *flag.Flag) int { 384 | if f == nil { 385 | return 0 386 | } 387 | 388 | if parsed, err := strconv.ParseInt(f.Value.String(), 0, 64); err == nil { 389 | return int(parsed) 390 | } 391 | 392 | return 0 393 | } 394 | 395 | // IntSliceFlag is a flag with type *IntSlice 396 | type IntSliceFlag struct { 397 | Name string 398 | Aliases []string 399 | Usage string 400 | EnvVars []string 401 | Hidden bool 402 | DefaultText string 403 | Required bool 404 | ArgsPredictor func(*Context, complete.Args) []string 405 | Validator func(*Context, []int) error 406 | Destination *IntSlice 407 | } 408 | 409 | // String returns a readable representation of this value 410 | // (for usage defaults) 411 | func (f *IntSliceFlag) String() string { 412 | return FlagStringer(f) 413 | } 414 | 415 | func (f *IntSliceFlag) PredictArgs(c *Context, a complete.Args) []string { 416 | if f.ArgsPredictor != nil { 417 | return f.ArgsPredictor(c, a) 418 | } 419 | return []string{} 420 | } 421 | 422 | func (f *IntSliceFlag) Validate(c *Context) error { 423 | if f.Validator != nil { 424 | return f.Validator(c, c.IntSlice(f.Name)) 425 | } 426 | return nil 427 | } 428 | 429 | // Names returns the names of the flag 430 | func (f *IntSliceFlag) Names() []string { 431 | return flagNames(f) 432 | } 433 | 434 | // IntSlice looks up the value of a local IntSliceFlag, returns 435 | // nil if not found 436 | func (c *Context) IntSlice(name string) []int { 437 | if f := lookupRawFlag(name, c); f != nil { 438 | return lookupIntSlice(name, f) 439 | } 440 | return nil 441 | } 442 | 443 | func lookupIntSlice(name string, f *flag.Flag) []int { 444 | if f == nil { 445 | return nil 446 | } 447 | 448 | if asserted, ok := f.Value.(*IntSlice); !ok { 449 | return nil 450 | } else if parsed, err := asserted.Value(), error(nil); err == nil { 451 | return parsed 452 | } 453 | 454 | return nil 455 | } 456 | 457 | // Int64SliceFlag is a flag with type *Int64Slice 458 | type Int64SliceFlag struct { 459 | Name string 460 | Aliases []string 461 | Usage string 462 | EnvVars []string 463 | Hidden bool 464 | DefaultText string 465 | Required bool 466 | ArgsPredictor func(*Context, complete.Args) []string 467 | Validator func(*Context, []int64) error 468 | Destination *Int64Slice 469 | } 470 | 471 | // String returns a readable representation of this value 472 | // (for usage defaults) 473 | func (f *Int64SliceFlag) String() string { 474 | return FlagStringer(f) 475 | } 476 | 477 | func (f *Int64SliceFlag) PredictArgs(c *Context, a complete.Args) []string { 478 | if f.ArgsPredictor != nil { 479 | return f.ArgsPredictor(c, a) 480 | } 481 | return []string{} 482 | } 483 | 484 | func (f *Int64SliceFlag) Validate(c *Context) error { 485 | if f.Validator != nil { 486 | return f.Validator(c, c.Int64Slice(f.Name)) 487 | } 488 | return nil 489 | } 490 | 491 | // Names returns the names of the flag 492 | func (f *Int64SliceFlag) Names() []string { 493 | return flagNames(f) 494 | } 495 | 496 | // Int64Slice looks up the value of a local Int64SliceFlag, returns 497 | // nil if not found 498 | func (c *Context) Int64Slice(name string) []int64 { 499 | if f := lookupRawFlag(name, c); f != nil { 500 | return lookupInt64Slice(name, f) 501 | } 502 | return nil 503 | } 504 | 505 | func lookupInt64Slice(name string, f *flag.Flag) []int64 { 506 | if f == nil { 507 | return nil 508 | } 509 | 510 | if asserted, ok := f.Value.(*Int64Slice); !ok { 511 | return nil 512 | } else if parsed, err := asserted.Value(), error(nil); err == nil { 513 | return parsed 514 | } 515 | 516 | return nil 517 | } 518 | 519 | // Float64SliceFlag is a flag with type *Float64Slice 520 | type Float64SliceFlag struct { 521 | Name string 522 | Aliases []string 523 | Usage string 524 | EnvVars []string 525 | Hidden bool 526 | DefaultText string 527 | Required bool 528 | ArgsPredictor func(*Context, complete.Args) []string 529 | Validator func(*Context, []float64) error 530 | Destination *Float64Slice 531 | } 532 | 533 | // String returns a readable representation of this value 534 | // (for usage defaults) 535 | func (f *Float64SliceFlag) String() string { 536 | return FlagStringer(f) 537 | } 538 | 539 | func (f *Float64SliceFlag) PredictArgs(c *Context, a complete.Args) []string { 540 | if f.ArgsPredictor != nil { 541 | return f.ArgsPredictor(c, a) 542 | } 543 | return []string{} 544 | } 545 | 546 | func (f *Float64SliceFlag) Validate(c *Context) error { 547 | if f.Validator != nil { 548 | return f.Validator(c, c.Float64Slice(f.Name)) 549 | } 550 | return nil 551 | } 552 | 553 | // Names returns the names of the flag 554 | func (f *Float64SliceFlag) Names() []string { 555 | return flagNames(f) 556 | } 557 | 558 | // Float64Slice looks up the value of a local Float64SliceFlag, returns 559 | // nil if not found 560 | func (c *Context) Float64Slice(name string) []float64 { 561 | if f := lookupRawFlag(name, c); f != nil { 562 | return lookupFloat64Slice(name, f) 563 | } 564 | return nil 565 | } 566 | 567 | func lookupFloat64Slice(name string, f *flag.Flag) []float64 { 568 | if f == nil { 569 | return nil 570 | } 571 | 572 | if asserted, ok := f.Value.(*Float64Slice); !ok { 573 | return nil 574 | } else if parsed, err := asserted.Value(), error(nil); err == nil { 575 | return parsed 576 | } 577 | 578 | return nil 579 | } 580 | 581 | // StringFlag is a flag with type string 582 | type StringFlag struct { 583 | Name string 584 | Aliases []string 585 | Usage string 586 | EnvVars []string 587 | Hidden bool 588 | DefaultValue string 589 | DefaultText string 590 | Required bool 591 | ArgsPredictor func(*Context, complete.Args) []string 592 | Validator func(*Context, string) error 593 | Destination *string 594 | } 595 | 596 | // String returns a readable representation of this value 597 | // (for usage defaults) 598 | func (f *StringFlag) String() string { 599 | return FlagStringer(f) 600 | } 601 | 602 | func (f *StringFlag) PredictArgs(c *Context, a complete.Args) []string { 603 | if f.ArgsPredictor != nil { 604 | return f.ArgsPredictor(c, a) 605 | } 606 | return []string{} 607 | } 608 | 609 | func (f *StringFlag) Validate(c *Context) error { 610 | if f.Validator != nil { 611 | return f.Validator(c, c.String(f.Name)) 612 | } 613 | return nil 614 | } 615 | 616 | // Names returns the names of the flag 617 | func (f *StringFlag) Names() []string { 618 | return flagNames(f) 619 | } 620 | 621 | // String looks up the value of a local StringFlag, returns 622 | // "" if not found 623 | func (c *Context) String(name string) string { 624 | if f := lookupRawFlag(name, c); f != nil { 625 | return lookupString(name, f) 626 | } 627 | return "" 628 | } 629 | 630 | func lookupString(name string, f *flag.Flag) string { 631 | if f == nil { 632 | return "" 633 | } 634 | 635 | if parsed, err := f.Value.String(), error(nil); err == nil { 636 | return parsed 637 | } 638 | 639 | return "" 640 | } 641 | 642 | // StringSliceFlag is a flag with type *StringSlice 643 | type StringSliceFlag struct { 644 | Name string 645 | Aliases []string 646 | Usage string 647 | EnvVars []string 648 | Hidden bool 649 | DefaultText string 650 | Required bool 651 | ArgsPredictor func(*Context, complete.Args) []string 652 | Validator func(*Context, []string) error 653 | Destination *StringSlice 654 | } 655 | 656 | // String returns a readable representation of this value 657 | // (for usage defaults) 658 | func (f *StringSliceFlag) String() string { 659 | return FlagStringer(f) 660 | } 661 | 662 | func (f *StringSliceFlag) PredictArgs(c *Context, a complete.Args) []string { 663 | if f.ArgsPredictor != nil { 664 | return f.ArgsPredictor(c, a) 665 | } 666 | return []string{} 667 | } 668 | 669 | func (f *StringSliceFlag) Validate(c *Context) error { 670 | if f.Validator != nil { 671 | return f.Validator(c, c.StringSlice(f.Name)) 672 | } 673 | return nil 674 | } 675 | 676 | // Names returns the names of the flag 677 | func (f *StringSliceFlag) Names() []string { 678 | return flagNames(f) 679 | } 680 | 681 | // StringSlice looks up the value of a local StringSliceFlag, returns 682 | // nil if not found 683 | func (c *Context) StringSlice(name string) []string { 684 | if f := lookupRawFlag(name, c); f != nil { 685 | return lookupStringSlice(name, f) 686 | } 687 | return nil 688 | } 689 | 690 | func lookupStringSlice(name string, f *flag.Flag) []string { 691 | if f == nil { 692 | return nil 693 | } 694 | 695 | if asserted, ok := f.Value.(*StringSlice); !ok { 696 | return nil 697 | } else if parsed, err := asserted.Value(), error(nil); err == nil { 698 | return parsed 699 | } 700 | 701 | return nil 702 | } 703 | 704 | // StringMapFlag is a flag with type *StringMap 705 | type StringMapFlag struct { 706 | Name string 707 | Aliases []string 708 | Usage string 709 | EnvVars []string 710 | Hidden bool 711 | DefaultText string 712 | Required bool 713 | ArgsPredictor func(*Context, complete.Args) []string 714 | Validator func(*Context, map[string]string) error 715 | Destination *StringMap 716 | } 717 | 718 | // String returns a readable representation of this value 719 | // (for usage defaults) 720 | func (f *StringMapFlag) String() string { 721 | return FlagStringer(f) 722 | } 723 | 724 | func (f *StringMapFlag) PredictArgs(c *Context, a complete.Args) []string { 725 | if f.ArgsPredictor != nil { 726 | return f.ArgsPredictor(c, a) 727 | } 728 | return []string{} 729 | } 730 | 731 | func (f *StringMapFlag) Validate(c *Context) error { 732 | if f.Validator != nil { 733 | return f.Validator(c, c.StringMap(f.Name)) 734 | } 735 | return nil 736 | } 737 | 738 | // Names returns the names of the flag 739 | func (f *StringMapFlag) Names() []string { 740 | return flagNames(f) 741 | } 742 | 743 | // StringMap looks up the value of a local StringMapFlag, returns 744 | // nil if not found 745 | func (c *Context) StringMap(name string) map[string]string { 746 | if f := lookupRawFlag(name, c); f != nil { 747 | return lookupStringMap(name, f) 748 | } 749 | return nil 750 | } 751 | 752 | func lookupStringMap(name string, f *flag.Flag) map[string]string { 753 | if f == nil { 754 | return nil 755 | } 756 | 757 | if asserted, ok := f.Value.(*StringMap); !ok { 758 | return nil 759 | } else if parsed, err := asserted.Value(), error(nil); err == nil { 760 | return parsed 761 | } 762 | 763 | return nil 764 | } 765 | 766 | // Uint64Flag is a flag with type uint64 767 | type Uint64Flag struct { 768 | Name string 769 | Aliases []string 770 | Usage string 771 | EnvVars []string 772 | Hidden bool 773 | DefaultValue uint64 774 | DefaultText string 775 | Required bool 776 | ArgsPredictor func(*Context, complete.Args) []string 777 | Validator func(*Context, uint64) error 778 | Destination *uint64 779 | } 780 | 781 | // String returns a readable representation of this value 782 | // (for usage defaults) 783 | func (f *Uint64Flag) String() string { 784 | return FlagStringer(f) 785 | } 786 | 787 | func (f *Uint64Flag) PredictArgs(c *Context, a complete.Args) []string { 788 | if f.ArgsPredictor != nil { 789 | return f.ArgsPredictor(c, a) 790 | } 791 | return []string{} 792 | } 793 | 794 | func (f *Uint64Flag) Validate(c *Context) error { 795 | if f.Validator != nil { 796 | return f.Validator(c, c.Uint64(f.Name)) 797 | } 798 | return nil 799 | } 800 | 801 | // Names returns the names of the flag 802 | func (f *Uint64Flag) Names() []string { 803 | return flagNames(f) 804 | } 805 | 806 | // Uint64 looks up the value of a local Uint64Flag, returns 807 | // 0 if not found 808 | func (c *Context) Uint64(name string) uint64 { 809 | if f := lookupRawFlag(name, c); f != nil { 810 | return lookupUint64(name, f) 811 | } 812 | return 0 813 | } 814 | 815 | func lookupUint64(name string, f *flag.Flag) uint64 { 816 | if f == nil { 817 | return 0 818 | } 819 | 820 | if parsed, err := strconv.ParseUint(f.Value.String(), 0, 64); err == nil { 821 | return parsed 822 | } 823 | 824 | return 0 825 | } 826 | 827 | // UintFlag is a flag with type uint 828 | type UintFlag struct { 829 | Name string 830 | Aliases []string 831 | Usage string 832 | EnvVars []string 833 | Hidden bool 834 | DefaultValue uint 835 | DefaultText string 836 | Required bool 837 | ArgsPredictor func(*Context, complete.Args) []string 838 | Validator func(*Context, uint) error 839 | Destination *uint 840 | } 841 | 842 | // String returns a readable representation of this value 843 | // (for usage defaults) 844 | func (f *UintFlag) String() string { 845 | return FlagStringer(f) 846 | } 847 | 848 | func (f *UintFlag) PredictArgs(c *Context, a complete.Args) []string { 849 | if f.ArgsPredictor != nil { 850 | return f.ArgsPredictor(c, a) 851 | } 852 | return []string{} 853 | } 854 | 855 | func (f *UintFlag) Validate(c *Context) error { 856 | if f.Validator != nil { 857 | return f.Validator(c, c.Uint(f.Name)) 858 | } 859 | return nil 860 | } 861 | 862 | // Names returns the names of the flag 863 | func (f *UintFlag) Names() []string { 864 | return flagNames(f) 865 | } 866 | 867 | // Uint looks up the value of a local UintFlag, returns 868 | // 0 if not found 869 | func (c *Context) Uint(name string) uint { 870 | if f := lookupRawFlag(name, c); f != nil { 871 | return lookupUint(name, f) 872 | } 873 | return 0 874 | } 875 | 876 | func lookupUint(name string, f *flag.Flag) uint { 877 | if f == nil { 878 | return 0 879 | } 880 | 881 | if parsed, err := strconv.ParseUint(f.Value.String(), 0, 64); err == nil { 882 | return uint(parsed) 883 | } 884 | 885 | return 0 886 | } 887 | -------------------------------------------------------------------------------- /flags_enhancement.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "flag" 24 | "fmt" 25 | "io" 26 | "os" 27 | "reflect" 28 | "strings" 29 | 30 | "github.com/mitchellh/go-homedir" 31 | "github.com/pkg/errors" 32 | "github.com/symfony-cli/terminal" 33 | ) 34 | 35 | // FlagParsingMode defines how arguments and flags parsing is done. 36 | // Three different values: FlagParsingNormal, FlagParsingSkipped and 37 | // FlagParsingSkippedAfterFirstArg. 38 | type FlagParsingMode int 39 | 40 | const ( 41 | // FlagParsingNormal sets parsing to a normal mode, complete parsing of all 42 | // flags found after command name 43 | FlagParsingNormal FlagParsingMode = iota 44 | // FlagParsingSkipped sets parsing to a mode where parsing stops just after 45 | // the command name. 46 | FlagParsingSkipped 47 | // FlagParsingSkippedAfterFirstArg sets parsing to a hybrid mode where parsing continues 48 | // after the command name until an argument is found. 49 | // This for example allows usage like `blackfire -v=4 run --samples=2 php foo.php` 50 | FlagParsingSkippedAfterFirstArg 51 | ) 52 | 53 | func (mode FlagParsingMode) IsPostfix() bool { 54 | return mode == FlagParsingNormal 55 | } 56 | 57 | func (mode FlagParsingMode) IsPrefix() bool { 58 | return mode != FlagParsingNormal 59 | } 60 | 61 | func (app *Application) parseArgs(arguments []string) (*flag.FlagSet, error) { 62 | fs, err := parseArgs(app.fixArgs(arguments), flagSet(app.Name, app.Flags)) 63 | if err != nil { 64 | return fs, errors.WithStack(err) 65 | } 66 | 67 | parseFlagsFromEnv(app.FlagEnvPrefix, app.Flags, fs) 68 | 69 | // We expand "~" for each provided string flag 70 | fs.Visit(expandHomeInFlagsValues) 71 | 72 | err = errors.WithStack(checkRequiredFlags(app.Flags, fs)) 73 | 74 | return fs, err 75 | } 76 | 77 | func (app *Application) fixArgs(args []string) []string { 78 | return fixArgs(args, app.Flags, app.Commands, FlagParsingNormal, "") 79 | } 80 | 81 | func (c *Command) parseArgs(arguments []string, prefixes []string) (*flag.FlagSet, error) { 82 | fs, err := parseArgs(c.fixArgs(arguments), flagSet(c.Name, c.Flags)) 83 | if err != nil { 84 | return fs, errors.WithStack(err) 85 | } 86 | 87 | parseFlagsFromEnv(prefixes, c.Flags, fs) 88 | 89 | // We expand "~" for each provided string flag 90 | fs.Visit(expandHomeInFlagsValues) 91 | 92 | err = errors.WithStack(checkRequiredFlags(c.Flags, fs)) 93 | 94 | return fs, err 95 | } 96 | 97 | func (c *Command) fixArgs(args []string) []string { 98 | return fixArgs(args, c.Flags, nil, c.FlagParsing, "--") 99 | } 100 | 101 | func parseArgs(arguments []string, fs *flag.FlagSet) (*flag.FlagSet, error) { 102 | fs.SetOutput(io.Discard) 103 | err := errors.WithStack(fs.Parse(arguments)) 104 | if err != nil { 105 | return fs, err 106 | } 107 | 108 | defer func() { 109 | if e := recover(); e != nil { 110 | err = errors.WithStack(e.(error)) 111 | } 112 | }() 113 | 114 | fs.Visit(func(f *flag.Flag) { 115 | terminal.Logger.Trace().Msgf("Using CLI flags for '%s' configuration entry.\n", f.Name) 116 | }) 117 | 118 | return fs, err 119 | } 120 | 121 | func parseFlagsFromEnv(prefixes []string, flags []Flag, fs *flag.FlagSet) { 122 | definedFlags := make(map[string]bool) 123 | fs.Visit(func(f *flag.Flag) { 124 | definedFlags[f.Name] = true 125 | }) 126 | 127 | for _, f := range flags { 128 | fName := flagName(f) 129 | 130 | // flags given on the CLI overrides environment values 131 | if _, alreadyThere := definedFlags[fName]; alreadyThere { 132 | continue 133 | } 134 | 135 | envVariableNames := flagStringSliceField(f, "EnvVars") 136 | 137 | for _, prefix := range prefixes { 138 | envVariableNames = append(envVariableNames, strings.ToUpper(strings.ReplaceAll(fmt.Sprintf("%s_%s", prefix, fName), "-", "_"))) 139 | } 140 | 141 | // reverse slice order 142 | for i := len(envVariableNames)/2 - 1; i >= 0; i-- { 143 | opp := len(envVariableNames) - 1 - i 144 | envVariableNames[i], envVariableNames[opp] = envVariableNames[opp], envVariableNames[i] 145 | } 146 | 147 | for _, name := range envVariableNames { 148 | val := os.Getenv(name) 149 | if val == "" { 150 | continue 151 | } 152 | 153 | terminal.Logger.Trace().Msgf("Using %s from ENV for '%s' configuration entry.\n", name, fName) 154 | if err := fs.Set(fName, val); err != nil { 155 | panic(errors.Errorf("Failed to set flag %s with value %s", fName, val)) 156 | } 157 | } 158 | } 159 | } 160 | 161 | // fixArgs fixes command lines arguments for them to be parsed. 162 | // Examples: 163 | // upload -slot=4 --v="4" file1 file2 will return: 164 | // -slot=4 --v="4" upload file1 file2 165 | // 166 | // --config=$HOME/.blackfire.ini run --reference=1 php -n exception.php --config=foo will return: 167 | // --config=$HOME/.blackfire.ini --reference=1 run php -n exception.php --config=foo 168 | // 169 | // Note in the latter example than this function needs to pay attention to be eager 170 | // and not try to "fix" arguments belonging to a possible embedded command as run. 171 | // For this purpose, you have three different FlagParsing modes available. 172 | // See FlagParsingMode for more information. 173 | func fixArgs(args []string, flagDefs []Flag, cmdDefs []*Command, defaultMode FlagParsingMode, defaultCommand string) []string { 174 | var ( 175 | flags = make([]string, 0) 176 | nonFlags = make([]string, 0) 177 | 178 | command = defaultCommand 179 | parsingMode = defaultMode 180 | previousFlagNeedsValue = false 181 | ) 182 | 183 | var isFlag = func(name string) bool { 184 | return len(name) > 1 && name[0] == '-' 185 | } 186 | var cleanFlag = func(name string) string { 187 | if index := strings.Index(name, "="); index != -1 { 188 | name = name[:index] 189 | } 190 | name = strings.TrimLeft(name, "-") 191 | 192 | return expandShortcut(flagDefs, name) 193 | } 194 | var translateShortcutFlags = func(name string) string { 195 | twoDashes := (name[1] == '-') 196 | name = strings.Trim(name, "-") 197 | if index := strings.Index(name, "="); index != -1 { 198 | name = expandShortcut(flagDefs, name[:index]) + name[index:] 199 | } else { 200 | name = expandShortcut(flagDefs, name) 201 | } 202 | 203 | if twoDashes { 204 | name = "--" + name 205 | } else { 206 | name = "-" + name 207 | } 208 | 209 | return name 210 | } 211 | var findCommand = func(name string) *Command { 212 | var matches []*Command 213 | for _, c := range cmdDefs { 214 | if c.HasName(name, true) { 215 | c.UserName = name 216 | return c 217 | } 218 | if c.HasName(name, false) { 219 | matches = append(matches, c) 220 | } 221 | } 222 | if len(matches) == 1 { 223 | matches[0].UserName = name 224 | return matches[0] 225 | } 226 | return nil 227 | } 228 | 229 | for _, arg := range args { 230 | if parsingMode == FlagParsingSkipped { 231 | nonFlags = append(nonFlags, arg) 232 | continue 233 | } 234 | 235 | if arg == "--" { 236 | parsingMode = FlagParsingSkipped 237 | if arg != command { 238 | nonFlags = append(nonFlags, arg) 239 | } 240 | continue 241 | } 242 | 243 | // argument is a flag 244 | if isFlag(arg) { 245 | cleanedFlag := cleanFlag(arg) 246 | 247 | previousFlagNeedsValue = false 248 | // and is present in our flags/shortcuts 249 | // or special case when we are in command mode because we need to 250 | // parse them to get validation errors 251 | if flag := findFlag(flagDefs, cleanedFlag); flag != nil { 252 | arg = translateShortcutFlags(arg) 253 | equalPos := strings.Index(arg, "=") 254 | // an equal sign at the end means we want to use the next arg 255 | // as the value, but is not supported by flag parsing 256 | if equalPos == len(arg)-1 { 257 | // so we strip the sign and reset the position as equals is 258 | // not here anymore 259 | arg, equalPos = arg[:equalPos], -1 260 | } 261 | // no equals sign ... 262 | if equalPos == -1 { 263 | // ... and not a boolean flag nor a verbosity one 264 | _, isBoolFlag := flag.(*BoolFlag) 265 | _, isVerbosityFlag := flag.(*verbosityFlag) 266 | 267 | if !isBoolFlag && !isVerbosityFlag { 268 | // we keep information about the previousFlag. 269 | previousFlagNeedsValue = true 270 | } 271 | } 272 | // finally, we add to the flags 273 | flags = append(flags, arg) 274 | } else if defaultCommand == "--" && parsingMode == FlagParsingNormal { 275 | trimmedFlag := strings.TrimSpace(arg) 276 | flags = append(flags, arg) 277 | previousFlagNeedsValue = trimmedFlag[len(trimmedFlag)-1] == '=' 278 | } else { 279 | // we add to the non-flags and let the command deal with it later 280 | nonFlags = append(nonFlags, arg) 281 | } 282 | 283 | continue 284 | } 285 | 286 | // If previous flag needs the arg as its value 287 | if previousFlagNeedsValue { 288 | // we just add it to the flags list as all processing as been 289 | // done earlier 290 | flags = append(flags, arg) 291 | previousFlagNeedsValue = false 292 | continue 293 | } 294 | 295 | // let's find the command if none is set yet 296 | if command == "" { 297 | if cmd := findCommand(arg); cmd != nil { 298 | command = arg 299 | previousFlagNeedsValue = false 300 | parsingMode = cmd.FlagParsing 301 | continue 302 | } 303 | } 304 | 305 | // not a flag and command found or default was this, let's stop 306 | // because the command wants everything else as args 307 | if parsingMode == FlagParsingSkippedAfterFirstArg { 308 | parsingMode = FlagParsingSkipped 309 | } 310 | 311 | nonFlags = append(nonFlags, arg) 312 | } 313 | 314 | if command != "" { 315 | flags = append(flags, command) 316 | } 317 | 318 | return append(flags, nonFlags...) 319 | } 320 | 321 | func findFlag(flagDefs []Flag, name string) Flag { 322 | for _, f := range flagDefs { 323 | for _, n := range f.Names() { 324 | if n == name { 325 | return f 326 | } 327 | } 328 | } 329 | return nil 330 | } 331 | 332 | func expandShortcut(flagDefs []Flag, name string) string { 333 | if f := findFlag(flagDefs, name); f != nil { 334 | if _, isVerbosity := f.(*verbosityFlag); isVerbosity { 335 | return name 336 | } 337 | 338 | return flagName(f) 339 | } 340 | return name 341 | } 342 | 343 | func expandHomeInFlagsValues(f *flag.Flag) { 344 | // This is the safest right now 345 | if reflect.ValueOf(f.Value).Elem().Kind() != reflect.String { 346 | return 347 | } 348 | val := ExpandHome(f.Value.String()) 349 | if e := f.Value.Set(val); e != nil { 350 | panic(errors.Errorf("Failed to set flag %s with value %s", f.Name, val)) 351 | } 352 | } 353 | 354 | func ExpandHome(path string) string { 355 | if expandedPath, err := homedir.Expand(path); err == nil { 356 | return expandedPath 357 | } 358 | 359 | return path 360 | } 361 | 362 | func checkFlagsUnicity(appFlags []Flag, cmdFlags []Flag, commandName string) { 363 | appDefinedFlags := make(map[string]bool) 364 | for _, f := range appFlags { 365 | for _, name := range f.Names() { 366 | appDefinedFlags[name] = true 367 | } 368 | } 369 | 370 | for _, f := range cmdFlags { 371 | canonicalName := flagName(f) 372 | for _, name := range f.Names() { 373 | if appDefinedFlags[name] { 374 | msg := "" 375 | if name == canonicalName { 376 | msg = fmt.Sprintf("flag redefined by command %s: %s", commandName, name) 377 | } else { 378 | msg = fmt.Sprintf("flag redefined by command %s: %s (alias for %s)", commandName, name, canonicalName) 379 | } 380 | panic(msg) // Happens only if flags are declared with identical names 381 | } 382 | } 383 | } 384 | } 385 | 386 | func checkRequiredFlags(flags []Flag, set *flag.FlagSet) error { 387 | visited := make(map[string]bool) 388 | set.Visit(func(f *flag.Flag) { 389 | visited[f.Name] = true 390 | }) 391 | 392 | for _, f := range flags { 393 | if flagIsRequired(f) { 394 | if !visited[flagName(f)] { 395 | return errors.Errorf(`Required flag "%s" is not set`, flagName(f)) 396 | } 397 | } 398 | } 399 | return nil 400 | } 401 | 402 | func checkFlagsValidity(flags []Flag, set *flag.FlagSet, c *Context) error { 403 | visited := make(map[string]bool) 404 | set.Visit(func(f *flag.Flag) { 405 | visited[f.Name] = true 406 | }) 407 | 408 | for _, f := range flags { 409 | if !visited[flagName(f)] { 410 | continue 411 | } 412 | if err := f.Validate(c); err != nil { 413 | return errors.Wrapf(err, `invalid value for flag "%s"`, flagName(f)) 414 | } 415 | } 416 | return nil 417 | } 418 | -------------------------------------------------------------------------------- /flags_enhancement_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "errors" 24 | "flag" 25 | "fmt" 26 | 27 | "github.com/symfony-cli/terminal" 28 | . "gopkg.in/check.v1" 29 | ) 30 | 31 | type CliEnhancementSuite struct{} 32 | 33 | var _ = Suite(&CliEnhancementSuite{}) 34 | 35 | var ( 36 | testAppUploadFlags = []Flag{ 37 | &IntFlag{Name: "reference", Aliases: []string{"r"}}, 38 | &IntFlag{Name: "samples", Aliases: []string{"s"}}, 39 | &BoolFlag{Name: "test", Aliases: []string{"t"}}, 40 | } 41 | 42 | curlCmd = &Command{Name: "curl", Flags: testAppUploadFlags} 43 | uploadCmd = &Command{Name: "upload", Flags: testAppUploadFlags} 44 | fooCmd = &Command{Name: "foo", Flags: testAppUploadFlags, FlagParsing: FlagParsingSkipped} 45 | runCmd = &Command{Name: "run", Flags: testAppUploadFlags, FlagParsing: FlagParsingSkippedAfterFirstArg} 46 | 47 | testApp = Application{ 48 | Flags: []Flag{ 49 | &IntFlag{Name: "v", DefaultValue: 1}, 50 | &StringFlag{Name: "server-id"}, 51 | &StringFlag{Name: "server-token"}, 52 | &StringFlag{Name: "config"}, 53 | &BoolFlag{Name: "quiet", Aliases: []string{"q"}}, 54 | }, 55 | Commands: []*Command{ 56 | {Name: "agent"}, 57 | curlCmd, 58 | uploadCmd, 59 | fooCmd, 60 | runCmd, 61 | }, 62 | } 63 | ) 64 | 65 | func (ts *CliEnhancementSuite) TestFixAndParseArgsApplication(c *C) { 66 | var ( 67 | args []string 68 | expected []string 69 | sorted []string 70 | 71 | ctx *Context 72 | fs *flag.FlagSet 73 | argsExpected []string 74 | ) 75 | 76 | args = []string{"-reference=4", "--v=3", "-q", "upload", "file1", "file2"} 77 | expected = []string{"--v=3", "-quiet", "upload", "-reference=4", "file1", "file2"} 78 | argsExpected = []string{"upload", "-reference=4", "file1", "file2"} 79 | sorted = testApp.fixArgs(args) 80 | c.Assert(sorted, DeepEquals, expected) 81 | fs, err := testApp.parseArgs(args) 82 | c.Assert(err, IsNil) 83 | ctx = NewContext(&testApp, fs, nil) 84 | c.Check(ctx.Int("v"), Equals, 3) 85 | c.Check(ctx.Bool("quiet"), Equals, true) 86 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 87 | 88 | args = []string{"-reference", "4", "--v=3", "-q", "upload", "file1", "file2"} 89 | expected = []string{"--v=3", "-quiet", "upload", "-reference", "4", "file1", "file2"} 90 | argsExpected = []string{"upload", "-reference", "4", "file1", "file2"} 91 | sorted = testApp.fixArgs(args) 92 | c.Assert(sorted, DeepEquals, expected) 93 | fs, _ = testApp.parseArgs(args) 94 | ctx = NewContext(&testApp, fs, nil) 95 | c.Check(ctx.Int("v"), Equals, 3) 96 | c.Check(ctx.Bool("quiet"), Equals, true) 97 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 98 | 99 | args = []string{"upload", "-reference=4", "-v=3", "-q", "file1", "file2"} 100 | expected = []string{"-v=3", "-quiet", "upload", "-reference=4", "file1", "file2"} 101 | argsExpected = []string{"upload", "-reference=4", "file1", "file2"} 102 | sorted = testApp.fixArgs(args) 103 | c.Assert(sorted, DeepEquals, expected) 104 | fs, _ = testApp.parseArgs(args) 105 | ctx = NewContext(&testApp, fs, nil) 106 | c.Check(ctx.Int("v"), Equals, 3) 107 | c.Check(ctx.Bool("quiet"), Equals, true) 108 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 109 | 110 | args = []string{"upload", "-reference=4", "-v=3", "-q", "upload", "file1", "file2"} 111 | expected = []string{"-v=3", "-quiet", "upload", "-reference=4", "upload", "file1", "file2"} 112 | argsExpected = []string{"upload", "-reference=4", "upload", "file1", "file2"} 113 | sorted = testApp.fixArgs(args) 114 | c.Assert(sorted, DeepEquals, expected) 115 | fs, _ = testApp.parseArgs(args) 116 | ctx = NewContext(&testApp, fs, nil) 117 | c.Check(ctx.Int("v"), Equals, 3) 118 | c.Check(ctx.Bool("quiet"), Equals, true) 119 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 120 | 121 | args = []string{"curl", "-reference=4", "-v=3", "-q", "-X", "POST", "http://blackfire.io"} 122 | expected = []string{"-v=3", "-quiet", "curl", "-reference=4", "-X", "POST", "http://blackfire.io"} 123 | argsExpected = []string{"curl", "-reference=4", "-X", "POST", "http://blackfire.io"} 124 | sorted = testApp.fixArgs(args) 125 | c.Assert(sorted, DeepEquals, expected) 126 | fs, _ = testApp.parseArgs(args) 127 | ctx = NewContext(&testApp, fs, nil) 128 | c.Check(ctx.Int("v"), Equals, 3) 129 | c.Check(ctx.Bool("quiet"), Equals, true) 130 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 131 | 132 | args = []string{"curl"} 133 | expected = []string{"curl"} 134 | argsExpected = []string{"curl"} 135 | sorted = testApp.fixArgs(args) 136 | c.Assert(sorted, DeepEquals, expected) 137 | fs, _ = testApp.parseArgs(args) 138 | ctx = NewContext(&testApp, fs, nil) 139 | c.Check(ctx.Int("v"), Equals, 1) 140 | c.Check(ctx.Bool("quiet"), Equals, false) 141 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 142 | 143 | args = []string{"-server-id=75299154-8b63-4632-9b04-1e10bb19c144", "-server-token=f30f10d62f6f577e90e1be4e218a638ec3d16a0e0454bd69b2459bb046588c6f", "agent"} 144 | expected = []string{"-server-id=75299154-8b63-4632-9b04-1e10bb19c144", "-server-token=f30f10d62f6f577e90e1be4e218a638ec3d16a0e0454bd69b2459bb046588c6f", "agent"} 145 | argsExpected = []string{"agent"} 146 | sorted = testApp.fixArgs(args) 147 | c.Assert(sorted, DeepEquals, expected) 148 | fs, _ = testApp.parseArgs(args) 149 | ctx = NewContext(&testApp, fs, nil) 150 | c.Check(ctx.Int("v"), Equals, 1) 151 | c.Check(ctx.Bool("quiet"), Equals, false) 152 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 153 | 154 | args = []string{"-server-id=75299154-8b63-4632-9b04-1e10bb19c144", "-server-token=f30f10d62f6f577e90e1be4e218a638ec3d16a0e0454bd69b2459bb046588c6f", "agent", "-v=4"} 155 | expected = []string{"-server-id=75299154-8b63-4632-9b04-1e10bb19c144", "-server-token=f30f10d62f6f577e90e1be4e218a638ec3d16a0e0454bd69b2459bb046588c6f", "-v=4", "agent"} 156 | argsExpected = []string{"agent"} 157 | sorted = testApp.fixArgs(args) 158 | c.Assert(sorted, DeepEquals, expected) 159 | fs, _ = testApp.parseArgs(args) 160 | ctx = NewContext(&testApp, fs, nil) 161 | c.Check(ctx.Int("v"), Equals, 4) 162 | c.Check(ctx.Bool("quiet"), Equals, false) 163 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 164 | 165 | args = []string{"run", "-v=4", "--reference", "8", "php", "vd.php"} 166 | expected = []string{"-v=4", "run", "--reference", "8", "php", "vd.php"} 167 | argsExpected = []string{"run", "--reference", "8", "php", "vd.php"} 168 | sorted = testApp.fixArgs(args) 169 | c.Check(sorted, DeepEquals, expected) 170 | fs, _ = testApp.parseArgs(args) 171 | ctx = NewContext(&testApp, fs, nil) 172 | c.Check(ctx.Int("v"), Equals, 4) 173 | c.Check(ctx.Bool("quiet"), Equals, false) 174 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 175 | 176 | args = []string{"run", "--", "-v=4", "--reference", "8", "php", "vd.php"} 177 | expected = []string{"run", "--", "-v=4", "--reference", "8", "php", "vd.php"} 178 | argsExpected = []string{"run", "-v=4", "--reference", "8", "php", "vd.php"} 179 | sorted = testApp.fixArgs(args) 180 | c.Assert(sorted, DeepEquals, expected) 181 | fs, _ = testApp.parseArgs(args) 182 | ctx = NewContext(&testApp, fs, nil) 183 | c.Check(ctx.Int("v"), Equals, 1) 184 | c.Check(ctx.Bool("quiet"), Equals, false) 185 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 186 | 187 | args = []string{"-v=4", "foo", "--reference", "8", "php", "vd.php"} 188 | expected = []string{"-v=4", "foo", "--reference", "8", "php", "vd.php"} 189 | argsExpected = []string{"foo", "--reference", "8", "php", "vd.php"} 190 | sorted = testApp.fixArgs(args) 191 | c.Assert(sorted, DeepEquals, expected) 192 | fs, _ = testApp.parseArgs(args) 193 | ctx = NewContext(&testApp, fs, nil) 194 | c.Check(ctx.Int("v"), Equals, 4) 195 | c.Check(ctx.Bool("quiet"), Equals, false) 196 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 197 | 198 | args = []string{"-config", "/Users/marc/.blackfire-d1.ini", "-reference=19", "upload", "profiler/README.md"} 199 | expected = []string{"-config", "/Users/marc/.blackfire-d1.ini", "upload", "-reference=19", "profiler/README.md"} 200 | argsExpected = []string{"upload", "-reference=19", "profiler/README.md"} 201 | sorted = testApp.fixArgs(args) 202 | c.Assert(sorted, DeepEquals, expected) 203 | fs, _ = testApp.parseArgs(args) 204 | ctx = NewContext(&testApp, fs, nil) 205 | c.Check(ctx.Int("v"), Equals, 1) 206 | c.Check(ctx.Bool("quiet"), Equals, false) 207 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 208 | 209 | args = []string{"curl", "-v=4", "-reference=4", "-samples=4", "http://labomedia.org"} 210 | expected = []string{"-v=4", "curl", "-reference=4", "-samples=4", "http://labomedia.org"} 211 | argsExpected = []string{"curl", "-reference=4", "-samples=4", "http://labomedia.org"} 212 | sorted = testApp.fixArgs(args) 213 | c.Assert(sorted, DeepEquals, expected) 214 | fs, _ = testApp.parseArgs(args) 215 | ctx = NewContext(&testApp, fs, nil) 216 | c.Check(ctx.Int("v"), Equals, 4) 217 | c.Check(ctx.Bool("quiet"), Equals, false) 218 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 219 | 220 | args = []string{"run", "-v=4", "--reference", "8", "php", "vd.php", "--config=foo", "--foo", "bar"} 221 | expected = []string{"-v=4", "run", "--reference", "8", "php", "vd.php", "--config=foo", "--foo", "bar"} 222 | argsExpected = []string{"run", "--reference", "8", "php", "vd.php", "--config=foo", "--foo", "bar"} 223 | sorted = testApp.fixArgs(args) 224 | c.Assert(sorted, DeepEquals, expected) 225 | fs, _ = testApp.parseArgs(args) 226 | ctx = NewContext(&testApp, fs, nil) 227 | c.Check(ctx.Int("v"), Equals, 4) 228 | c.Check(ctx.Bool("quiet"), Equals, false) 229 | c.Check(ctx.Args().Slice(), DeepEquals, argsExpected) 230 | } 231 | 232 | func (ts *CliEnhancementSuite) TestFixAndParseArgsApplicationVerbosityFlag(c *C) { 233 | defaultLogLevel := terminal.GetLogLevel() 234 | defer func() { 235 | c.Assert(terminal.SetLogLevel(defaultLogLevel), IsNil) 236 | }() 237 | 238 | testApp := Application{ 239 | Flags: []Flag{ 240 | VerbosityFlag("log-level", "verbose", "v"), 241 | }, 242 | Commands: []*Command{ 243 | { 244 | Name: "envs", 245 | Flags: []Flag{ 246 | &StringFlag{ 247 | Name: "project", 248 | Aliases: []string{"p"}, 249 | }, 250 | }, 251 | }, 252 | }, 253 | } 254 | 255 | cases := []struct { 256 | arg string 257 | expectedLevel int 258 | }{ 259 | {"--log-level=5", 5}, 260 | {"--verbose", 3}, 261 | {"-vvv", 4}, 262 | {"-vv", 3}, 263 | {"-v", 2}, 264 | {"-v=3", 3}, 265 | } 266 | 267 | for _, tt := range cases { 268 | args := []string{tt.arg, "-p", "agb6vnth4arfo", "envs"} 269 | expected := []string{tt.arg, "envs", "-p", "agb6vnth4arfo"} 270 | sorted := testApp.fixArgs(args) 271 | c.Assert(sorted, DeepEquals, expected) 272 | fs, _ := testApp.parseArgs(args) 273 | ctx := NewContext(&testApp, fs, nil) 274 | 275 | c.Check(terminal.GetLogLevel(), Equals, tt.expectedLevel) 276 | c.Check(ctx.IsSet("log-level"), Equals, true) 277 | 278 | cmd := testApp.Command(ctx.Args().first()) 279 | fs, _ = cmd.parseArgs(ctx.Args().Tail(), []string{}) 280 | ctx = NewContext(&testApp, fs, nil) 281 | 282 | c.Check(ctx.String("project"), Equals, "agb6vnth4arfo") 283 | } 284 | } 285 | 286 | func (ts *CliEnhancementSuite) TestFixAndParseArgsCommand(c *C) { 287 | var ( 288 | args = []string{"-reference=4", "--samples=10", "-t", "file1", "-s=", "5", "-H='Host: foo'", "foo"} 289 | expected []string 290 | 291 | ctx *Context 292 | err error 293 | sorted []string 294 | fs *flag.FlagSet 295 | ) 296 | 297 | expected = []string{"-reference=4", "--samples=10", "-test", "-samples", "5", "-H='Host: foo'", "--", "file1", "foo"} 298 | sorted = curlCmd.fixArgs(args) 299 | c.Assert(sorted, DeepEquals, expected) 300 | fs, _ = curlCmd.parseArgs(args, []string{}) 301 | ctx = NewContext(&testApp, fs, nil) 302 | c.Check(ctx.Int("reference"), Equals, 4) 303 | c.Check(ctx.Int("samples"), Equals, 5) 304 | c.Check(ctx.Bool("test"), Equals, true) 305 | c.Check(ctx.Args().Slice(), DeepEquals, []string{"file1", "foo"}) 306 | 307 | expected = []string{"-reference=4", "--samples=10", "-test", "-samples", "5", "-H='Host: foo'", "--", "file1", "foo"} 308 | sorted = uploadCmd.fixArgs(args) 309 | c.Assert(sorted, DeepEquals, expected) 310 | fs, _ = uploadCmd.parseArgs(args, []string{}) 311 | ctx = NewContext(&testApp, fs, nil) 312 | c.Check(ctx.Int("reference"), Equals, 4) 313 | c.Check(ctx.Int("samples"), Equals, 5) 314 | c.Check(ctx.Bool("test"), Equals, true) 315 | c.Check(ctx.Args().Slice(), DeepEquals, []string{"file1", "foo"}) 316 | 317 | expected = append([]string{"--"}, args...) 318 | sorted = fooCmd.fixArgs(args) 319 | c.Assert(sorted, DeepEquals, expected) 320 | fs, _ = fooCmd.parseArgs(args, []string{}) 321 | ctx = NewContext(&testApp, fs, nil) 322 | c.Check(ctx.Int("reference"), Equals, 0) 323 | c.Check(ctx.Int("samples"), Equals, 0) 324 | c.Check(ctx.Bool("test"), Equals, false) 325 | c.Check(ctx.Args().Slice(), DeepEquals, args) 326 | 327 | expected = []string{"-reference=4", "--samples=10", "-test", "--", "file1", "-s=", "5", "-H='Host: foo'", "foo"} 328 | sorted = runCmd.fixArgs(args) 329 | c.Assert(sorted, DeepEquals, expected) 330 | fs, _ = runCmd.parseArgs(args, []string{}) 331 | ctx = NewContext(&testApp, fs, nil) 332 | c.Check(ctx.Int("reference"), Equals, 4) 333 | c.Check(ctx.Int("samples"), Equals, 10) 334 | c.Check(ctx.Bool("test"), Equals, true) 335 | c.Check(ctx.Args().Slice(), DeepEquals, []string{"file1", "-s=", "5", "-H='Host: foo'", "foo"}) 336 | 337 | dashDashArgs := []string{"-reference=4", "-s=", "5", "--", "--samples=10", "file1", "-f=", "3", "foo"} 338 | expected = []string{"-reference=4", "-samples", "5", "--", "--samples=10", "file1", "-f=", "3", "foo"} 339 | sorted = curlCmd.fixArgs(dashDashArgs) 340 | c.Assert(sorted, DeepEquals, expected) 341 | fs, _ = curlCmd.parseArgs(dashDashArgs, []string{}) 342 | ctx = NewContext(&testApp, fs, nil) 343 | c.Check(ctx.Int("reference"), Equals, 4) 344 | c.Check(ctx.Int("samples"), Equals, 5) 345 | c.Check(ctx.Args().Slice(), DeepEquals, []string{"--samples=10", "file1", "-f=", "3", "foo"}) 346 | 347 | weirdArgs := []string{"-reference=4", "--unknown", "-r=", "-s=", "5", "--samples=10", "file1", "-f=", "3", "foo"} 348 | expected = []string{"-reference=4", "--unknown", "-reference", "-samples", "5", "--samples=10", "-f=", "3", "--", "file1", "foo"} 349 | sorted = curlCmd.fixArgs(weirdArgs) 350 | c.Assert(sorted, DeepEquals, expected) 351 | fs, err = curlCmd.parseArgs(weirdArgs, []string{}) 352 | c.Check(err, Not(IsNil)) 353 | ctx = NewContext(&testApp, fs, nil) 354 | c.Check(ctx.Int("reference"), Equals, 4) 355 | c.Check(ctx.Int("samples"), Equals, 0) 356 | c.Check(ctx.Args().Slice(), DeepEquals, []string{"-reference", "-samples", "5", "--samples=10", "-f=", "3", "file1", "foo"}) 357 | } 358 | 359 | func (ts *CliEnhancementSuite) TestCheckRequiredFlagsSuccess(c *C) { 360 | flags := []Flag{ 361 | &StringFlag{ 362 | Name: "required", 363 | Required: true, 364 | }, 365 | &StringFlag{ 366 | Name: "optional", 367 | }, 368 | } 369 | 370 | set := flag.NewFlagSet("test", 0) 371 | for _, f := range flags { 372 | f.Apply(set) 373 | } 374 | 375 | e := set.Parse([]string{"--required", "foo"}) 376 | c.Assert(e, IsNil) 377 | 378 | err := checkRequiredFlags(flags, set) 379 | c.Assert(err, IsNil) 380 | } 381 | 382 | func (ts *CliEnhancementSuite) TestCheckRequiredFlagsFailure(c *C) { 383 | flags := []Flag{ 384 | &StringFlag{ 385 | Name: "required", 386 | Required: true, 387 | }, 388 | &StringFlag{ 389 | Name: "optional", 390 | }, 391 | } 392 | 393 | set := flag.NewFlagSet("test", 0) 394 | for _, f := range flags { 395 | f.Apply(set) 396 | } 397 | 398 | e := set.Parse([]string{"--optional", "foo"}) 399 | c.Assert(e, IsNil) 400 | 401 | err := checkRequiredFlags(flags, set) 402 | c.Assert(err, Not(IsNil)) 403 | } 404 | 405 | func (ts *CliEnhancementSuite) TestFlagsValidation(c *C) { 406 | validatorHasBeenCalled, subValidatorHasBeenCalled := false, false 407 | 408 | app := Application{ 409 | Flags: []Flag{ 410 | &StringFlag{ 411 | Name: "foo", 412 | Validator: func(context *Context, s string) error { 413 | validatorHasBeenCalled = true 414 | 415 | return nil 416 | }, 417 | }, 418 | &StringFlag{ 419 | Name: "bar", 420 | Validator: func(context *Context, s string) error { 421 | if s != "bar" { 422 | return errors.New("invalid") 423 | } 424 | return nil 425 | }, 426 | }, 427 | }, 428 | Commands: []*Command{ 429 | { 430 | Name: "test", 431 | Flags: []Flag{ 432 | &StringFlag{ 433 | Name: "sub-foo", 434 | Validator: func(context *Context, s string) error { 435 | subValidatorHasBeenCalled = true 436 | 437 | return nil 438 | }, 439 | }, 440 | &StringFlag{ 441 | Name: "sub-bar", 442 | Validator: func(context *Context, s string) error { 443 | if s != "bar" { 444 | return errors.New("invalid") 445 | } 446 | return nil 447 | }, 448 | }, 449 | }, 450 | Action: func(c *Context) error { 451 | fmt.Println("sub-foo:", c.String("sub-foo")) 452 | return nil 453 | }, 454 | }, 455 | }, 456 | } 457 | 458 | c.Assert(app.Run([]string{"app", "--foo=bar"}), IsNil) 459 | c.Assert(validatorHasBeenCalled, Equals, true) 460 | c.Assert(app.Run([]string{"app", "--bar=bar"}), IsNil) 461 | c.Assert(app.Run([]string{"app", "--bar=toto"}), ErrorMatches, "invalid value for flag \"bar\".*") 462 | 463 | c.Assert(app.Run([]string{"app", "test", "--sub-foo=bar"}), IsNil) 464 | c.Assert(subValidatorHasBeenCalled, Equals, true) 465 | c.Assert(app.Run([]string{"app", "test", "--sub-bar=bar"}), IsNil) 466 | c.Assert(app.Run([]string{"app", "test", "--sub-bar=toto"}), ErrorMatches, ".*invalid value for flag \"sub-bar\".*") 467 | } 468 | -------------------------------------------------------------------------------- /funcs.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import "github.com/posener/complete" 23 | 24 | // ShellCompleteFunc is an action to execute when the shell completion flag is set 25 | type ShellCompleteFunc func(*Context, complete.Args) []string 26 | 27 | // BeforeFunc is an action to execute before any subcommands are run, but after 28 | // the context is ready if a non-nil error is returned, no subcommands are run 29 | type BeforeFunc func(*Context) error 30 | 31 | // AfterFunc is an action to execute after any subcommands are run, but after the 32 | // subcommand has finished it is run even if Action() panics 33 | type AfterFunc func(*Context) error 34 | 35 | // ActionFunc is the action to execute when no subcommands are specified 36 | type ActionFunc func(*Context) error 37 | 38 | // CommandNotFoundFunc is executed if the proper command cannot be found 39 | type CommandNotFoundFunc func(*Context, string) error 40 | 41 | // DescriptionFunc is used by the help generation to display a description when 42 | // its computation is intensive or needs runtime information 43 | type DescriptionFunc func(*Command, *Application) string 44 | 45 | // FlagStringFunc is used by the help generation to display a flag, which is 46 | // expected to be a single line. 47 | type FlagStringFunc func(Flag) string 48 | -------------------------------------------------------------------------------- /go.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "os" 24 | "path/filepath" 25 | "strings" 26 | ) 27 | 28 | func IsGoRun() bool { 29 | // Unfortunately, Golang does not expose that we are currently using go run 30 | // So we detect the main binary is (or used to be ;)) "go" and then the 31 | // current binary is within a temp "go-build" directory. 32 | _, exe := filepath.Split(os.Getenv("_")) 33 | argv0, _ := os.Executable() 34 | 35 | return exe == "go" && strings.Contains(argv0, "go-build") 36 | } 37 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/symfony-cli/console 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/agext/levenshtein v1.2.3 7 | github.com/mitchellh/go-homedir v1.1.0 8 | github.com/pkg/errors v0.9.1 9 | github.com/posener/complete v1.2.3 10 | github.com/rs/zerolog v1.33.0 11 | github.com/symfony-cli/terminal v1.0.7 12 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c 13 | ) 14 | 15 | require ( 16 | github.com/hashicorp/errwrap v1.0.0 // indirect 17 | github.com/hashicorp/go-multierror v1.0.0 // indirect 18 | github.com/kr/pretty v0.3.1 // indirect 19 | github.com/kr/text v0.2.0 // indirect 20 | github.com/mattn/go-colorable v0.1.13 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/rogpeppe/go-internal v1.12.0 // indirect 23 | github.com/stretchr/testify v1.8.4 // indirect 24 | golang.org/x/sys v0.21.0 // indirect 25 | golang.org/x/term v0.21.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= 2 | github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 3 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 4 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 9 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 10 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 11 | github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= 12 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 13 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 14 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 15 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 16 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 17 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 18 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 19 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 20 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 21 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 22 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 23 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 24 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 25 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 26 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 27 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 28 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 29 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 30 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= 34 | github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= 35 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 36 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 37 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 38 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 39 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 40 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 41 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 42 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 43 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 44 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 45 | github.com/symfony-cli/terminal v1.0.7 h1:57L9PUTE2cHfQtP8Ti8dyiiPEYlQ1NBIDpMJ3RPEGPc= 46 | github.com/symfony-cli/terminal v1.0.7/go.mod h1:Etv22IyeGiMoIQPPj51hX31j7xuYl1njyuAFkrvybqU= 47 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 51 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 52 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 53 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 54 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 55 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 56 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 57 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 58 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 59 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 60 | -------------------------------------------------------------------------------- /help.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "fmt" 24 | "io" 25 | "sort" 26 | "strings" 27 | "text/tabwriter" 28 | "text/template" 29 | 30 | "github.com/agext/levenshtein" 31 | "github.com/rs/zerolog" 32 | ) 33 | 34 | // AppHelpTemplate is the text template for the Default help topic. 35 | // cli.go uses text/template to render templates. You can 36 | // render custom help text by setting this variable. 37 | var AppHelpTemplate = `{{.Name}}{{if .Version}} version {{.Version}}{{end}}{{if .Copyright}} {{.Copyright}}{{end}} 38 | {{.Usage}} 39 | 40 | Usage: 41 | {{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} [command options]{{end}} [arguments...]{{if .Description}} 42 | 43 | {{.Description}}{{end}}{{if .VisibleFlags}} 44 | 45 | Global options: 46 | {{range $index, $option := .VisibleFlags}}{{if $index}} 47 | {{end}}{{$option}}{{end}}{{end}}{{if .VisibleCommands}} 48 | 49 | Available commands:{{range .VisibleCategories}}{{if .Name}} 50 | {{.Name}}{{"\t"}}{{end}}{{range .VisibleCommands}} 51 | {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{end}}{{end}} 52 | ` 53 | 54 | // CategoryHelpTemplate is the text template for the category help topic. 55 | // cli.go uses text/template to render templates. You can 56 | // render custom help text by setting this variable. 57 | var CategoryHelpTemplate = `{{with .App }}{{.Name}}{{if .Version}} version {{.Version}}{{end}}{{if .Copyright}} {{.Copyright}}{{end}} 58 | {{.Usage}} 59 | 60 | Usage: 61 | {{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} [command options]{{end}} [arguments...]{{if .Description}} 62 | 63 | {{.Description}}{{end}}{{if .VisibleFlags}} 64 | 65 | Global options: 66 | {{range $index, $option := .VisibleFlags}}{{if $index}} 67 | {{end}}{{$option}}{{end}}{{end}}{{end}}{{ range .Categories }} 68 | 69 | Available commands for the "{{.Name}}" namespace:{{range .VisibleCommands}} 70 | {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{end}} 71 | ` 72 | 73 | // CommandHelpTemplate is the text template for the command help topic. 74 | // cli.go uses text/template to render templates. You can 75 | // render custom help text by setting this variable. 76 | var CommandHelpTemplate = `{{if .Usage}}Description: 77 | {{.Usage}} 78 | 79 | {{end}}Usage: 80 | {{.HelpName}}{{if .VisibleFlags}} [options]{{end}}{{.Arguments.Usage}}{{if .Arguments}} 81 | 82 | Arguments: 83 | {{range .Arguments}}{{.}} 84 | {{end}}{{end}}{{if .VisibleFlags}} 85 | 86 | Options: 87 | {{range .VisibleFlags}}{{.}} 88 | {{end}}{{end}}{{if .Description}} 89 | 90 | Help: 91 | 92 | {{.Description}} 93 | {{end}} 94 | ` 95 | 96 | var helpCommand = &Command{ 97 | Category: "self", 98 | Name: "help", 99 | Aliases: []*Alias{{Name: "help"}, {Name: "list"}}, 100 | Usage: "Display help for a command or a category of commands", 101 | Args: []*Arg{ 102 | {Name: "command", Optional: true}, 103 | }, 104 | Action: ShowAppHelpAction, 105 | } 106 | 107 | var versionCommand = &Command{ 108 | Category: "self", 109 | Name: "version", 110 | Aliases: []*Alias{{Name: "version"}}, 111 | Usage: "Display the application version", 112 | Action: func(c *Context) error { 113 | ShowVersion(c) 114 | return nil 115 | }, 116 | } 117 | 118 | // Prints help for the App or Command 119 | type helpPrinter func(w io.Writer, templ string, data interface{}) 120 | 121 | // HelpPrinter is a function that writes the help output. If not set a default 122 | // is used. The function signature is: 123 | // func(w io.Writer, templ string, data interface{}) 124 | var HelpPrinter helpPrinter = printHelp 125 | 126 | // VersionPrinter prints the version for the App 127 | var VersionPrinter = printVersion 128 | 129 | // ShowAppHelpAction is an action that displays the global help or for the 130 | // specified command. 131 | func ShowAppHelpAction(c *Context) error { 132 | args := c.Args() 133 | if args.Present() { 134 | // We use `first` here because if we are in a situation of an unknown 135 | // command, args parsing is not done. 136 | return ShowCommandHelp(c, args.first()) 137 | } 138 | 139 | return ShowAppHelp(c) 140 | } 141 | 142 | // ShowAppHelp is an action that displays the help. 143 | func ShowAppHelp(c *Context) error { 144 | HelpPrinter(c.App.Writer, AppHelpTemplate, c.App) 145 | return nil 146 | } 147 | 148 | // ShowCommandHelp prints help for the given command 149 | func ShowCommandHelp(ctx *Context, command string) error { 150 | if c := ctx.App.BestCommand(command); c != nil { 151 | if c.DescriptionFunc != nil { 152 | c.Description = c.DescriptionFunc(c, ctx.App) 153 | } 154 | 155 | HelpPrinter(ctx.App.Writer, CommandHelpTemplate, c) 156 | return nil 157 | } 158 | 159 | categories := []CommandCategory{} 160 | for _, c := range ctx.App.VisibleCategories() { 161 | if strings.HasPrefix(c.Name(), command) { 162 | categories = append(categories, c) 163 | } 164 | } 165 | if len(categories) > 0 { 166 | HelpPrinter(ctx.App.Writer, CategoryHelpTemplate, struct { 167 | App *Application 168 | Categories []CommandCategory 169 | }{ 170 | App: ctx.App, 171 | Categories: categories, 172 | }) 173 | return nil 174 | } 175 | 176 | return &CommandNotFoundError{command, ctx.App} 177 | } 178 | 179 | type CommandNotFoundError struct { 180 | command string 181 | app *Application 182 | } 183 | 184 | func (e *CommandNotFoundError) Error() string { 185 | message := fmt.Sprintf("Command %q does not exist.", e.command) 186 | if alternatives := findAlternatives(e.command, e.app.VisibleCommands()); len(alternatives) == 1 { 187 | message += "\n\nDid you mean this?\n " + alternatives[0] 188 | } else if len(alternatives) > 1 { 189 | message += "\n\nDid you mean one of these?\n " 190 | message += strings.Join(alternatives, "\n ") 191 | } 192 | 193 | return message 194 | } 195 | 196 | func (e *CommandNotFoundError) ExitCode() int { 197 | return 3 198 | } 199 | 200 | func (e *CommandNotFoundError) GetSeverity() zerolog.Level { 201 | return zerolog.InfoLevel 202 | } 203 | 204 | func findAlternatives(name string, commands []*Command) []string { 205 | alternatives := []string{} 206 | 207 | for _, command := range commands { 208 | if command.Category != "" { 209 | if command.Category == name { 210 | alternatives = append(alternatives, command.FullName()) 211 | continue 212 | } 213 | 214 | lev := levenshtein.Distance(name, command.Category, nil) 215 | if lev <= len(name)/3 { 216 | alternatives = append(alternatives, command.FullName()) 217 | continue 218 | } 219 | } 220 | 221 | for _, cmdName := range command.Names() { 222 | if strings.HasPrefix(cmdName, name) { 223 | alternatives = append(alternatives, cmdName) 224 | continue 225 | } 226 | if strings.HasSuffix(cmdName, name) { 227 | alternatives = append(alternatives, cmdName) 228 | continue 229 | } 230 | 231 | lev := levenshtein.Distance(name, cmdName, nil) 232 | if lev <= len(name)/3 { 233 | alternatives = append(alternatives, cmdName) 234 | continue 235 | } 236 | } 237 | } 238 | 239 | sort.Strings(alternatives) 240 | 241 | return alternatives 242 | } 243 | 244 | // ShowVersion prints the version number of the App 245 | func ShowVersion(c *Context) { 246 | VersionPrinter(c) 247 | } 248 | 249 | func printVersion(c *Context) { 250 | HelpPrinter(c.App.Writer, "{{.Name}}{{if .Version}} version {{.Version}}{{end}}{{if .Copyright}} {{.Copyright}}{{end}} ({{.BuildDate}} - {{.Channel}})\n", c.App) 251 | } 252 | 253 | func printHelp(out io.Writer, templ string, data interface{}) { 254 | funcMap := template.FuncMap{ 255 | "join": strings.Join, 256 | } 257 | 258 | w := tabwriter.NewWriter(out, 1, 8, 2, ' ', 0) 259 | t := template.Must(template.New("help").Funcs(funcMap).Parse(templ)) 260 | 261 | if err := t.Execute(w, data); err != nil { 262 | panic(fmt.Errorf("CLI TEMPLATE ERROR: %#v", err.Error())) 263 | } 264 | if err := w.Flush(); err != nil { 265 | panic(fmt.Errorf("CLI TEMPLATE ERROR: %#v", err.Error())) 266 | } 267 | } 268 | 269 | func checkVersion(c *Context) bool { 270 | found := false 271 | if VersionFlag.Name != "" { 272 | for _, name := range VersionFlag.Names() { 273 | if c.Bool(name) { 274 | found = true 275 | } 276 | } 277 | } 278 | return found 279 | } 280 | 281 | func IsHelp(c *Context) bool { 282 | return checkHelp(c) || c.Command == helpCommand 283 | } 284 | 285 | func checkHelp(c *Context) bool { 286 | for _, name := range HelpFlag.Names() { 287 | if c.Bool(name) { 288 | return true 289 | } 290 | } 291 | 292 | return false 293 | } 294 | 295 | func checkCommandHelp(c *Context, name string) bool { 296 | if c.Bool("h") || c.Bool("help") { 297 | _ = ShowCommandHelp(c, name) 298 | return true 299 | } 300 | 301 | return false 302 | } 303 | -------------------------------------------------------------------------------- /help_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "bytes" 24 | "flag" 25 | "io" 26 | "strings" 27 | "testing" 28 | ) 29 | 30 | func Test_ShowAppHelp_NoAuthor(t *testing.T) { 31 | output := new(bytes.Buffer) 32 | app := &Application{Writer: output} 33 | 34 | c := NewContext(app, nil, nil) 35 | 36 | if err := ShowAppHelp(c); err != nil { 37 | t.Error(err) 38 | } 39 | 40 | if bytes.Contains(output.Bytes(), []byte("AUTHOR(S):")) { 41 | t.Errorf("expected\n%snot to include %s", output.String(), "AUTHOR(S):") 42 | } 43 | } 44 | 45 | func Test_ShowAppHelp_NoVersion(t *testing.T) { 46 | output := new(bytes.Buffer) 47 | app := &Application{Writer: output} 48 | 49 | app.Version = "" 50 | 51 | c := NewContext(app, nil, nil) 52 | 53 | if err := ShowAppHelp(c); err != nil { 54 | t.Error(err) 55 | } 56 | 57 | if bytes.Contains(output.Bytes(), []byte("VERSION:")) { 58 | t.Errorf("expected\n%snot to include %s", output.String(), "VERSION:") 59 | } 60 | } 61 | 62 | func Test_Help_Custom_Flags(t *testing.T) { 63 | oldFlag := HelpFlag 64 | defer func() { 65 | HelpFlag = oldFlag 66 | }() 67 | 68 | HelpFlag = &BoolFlag{ 69 | Name: "xxx", 70 | Aliases: []string{"x"}, 71 | Usage: "show help", 72 | } 73 | 74 | app := Application{ 75 | Flags: []Flag{ 76 | &BoolFlag{Name: "help", Aliases: []string{"h"}}, 77 | }, 78 | Action: func(ctx *Context) error { 79 | if ctx.Bool("help") != true { 80 | t.Errorf("custom help flag not set") 81 | } 82 | return nil 83 | }, 84 | } 85 | output := new(bytes.Buffer) 86 | app.Writer = output 87 | app.MustRun([]string{"test", "-h"}) 88 | if output.Len() > 0 { 89 | t.Errorf("unexpected output: %s", output.String()) 90 | } 91 | } 92 | 93 | func Test_Version_Custom_Flags(t *testing.T) { 94 | oldFlag := VersionFlag 95 | defer func() { 96 | VersionFlag = oldFlag 97 | }() 98 | 99 | VersionFlag = &BoolFlag{ 100 | Name: "version", 101 | Aliases: []string{"a"}, 102 | Usage: "show version", 103 | } 104 | 105 | app := Application{ 106 | Flags: []Flag{ 107 | &BoolFlag{Name: "foo", Aliases: []string{"V"}}, 108 | }, 109 | Action: func(ctx *Context) error { 110 | if ctx.Bool("V") != true { 111 | t.Errorf("custom version flag not set") 112 | } 113 | return nil 114 | }, 115 | } 116 | output := new(bytes.Buffer) 117 | app.Writer = output 118 | app.MustRun([]string{"test", "-V"}) 119 | if output.Len() > 0 { 120 | t.Errorf("unexpected output: %s", output.String()) 121 | } 122 | } 123 | 124 | func Test_helpCommand_Action_ErrorIfNoTopic(t *testing.T) { 125 | app := &Application{} 126 | app.Writer, app.ErrWriter = io.Discard, io.Discard 127 | 128 | set := flag.NewFlagSet("test", 0) 129 | if err := set.Parse([]string{"foo"}); err != nil { 130 | t.Error(err) 131 | } 132 | 133 | c := NewContext(app, set, nil) 134 | app.setup() 135 | 136 | err := helpCommand.Action(c) 137 | 138 | if err == nil { 139 | t.Fatalf("expected error from helpCommand.Action(), but got nil") 140 | } 141 | 142 | exitErr, ok := err.(ExitCoder) 143 | if !ok { 144 | t.Fatalf("expected *exitError from helpCommand.Action(), but instead got: %v", err.Error()) 145 | } 146 | 147 | if exitErr.Error() != "Command \"foo\" does not exist." { 148 | t.Fatalf("expected an command not found error, but got: %q", exitErr.Error()) 149 | } 150 | 151 | if exitErr.ExitCode() != 3 { 152 | t.Fatalf("expected exit value = 3, got %d instead", exitErr.ExitCode()) 153 | } 154 | } 155 | 156 | func Test_helpCommand_InHelpOutput(t *testing.T) { 157 | app := &Application{} 158 | output := &bytes.Buffer{} 159 | app.Writer = output 160 | app.MustRun([]string{"test", "--help"}) 161 | 162 | s := output.String() 163 | 164 | if strings.Contains(s, "\nCOMMANDS:\nGLOBAL OPTIONS:\n") { 165 | t.Fatalf("empty COMMANDS section detected: %q", s) 166 | } 167 | 168 | if !strings.Contains(s, "--help, -h") { 169 | t.Fatalf("missing \"help, h\": %q", s) 170 | } 171 | } 172 | 173 | func Test_helpCategories(t *testing.T) { 174 | app := &Application{} 175 | output := &bytes.Buffer{} 176 | app.Writer = output 177 | app.MustRun([]string{"help"}) 178 | 179 | s := output.String() 180 | 181 | if !strings.Contains(s, "Available commands") { 182 | t.Fatalf("commands are not listed: %q", s) 183 | } 184 | 185 | output.Reset() 186 | app.MustRun([]string{"help", "self"}) 187 | s = output.String() 188 | 189 | if !strings.Contains(s, "Available commands for the \"self\" namespace:") { 190 | t.Fatalf("commands from a category are not listed: %q", s) 191 | } 192 | } 193 | 194 | func TestShowAppHelp_CommandAliases(t *testing.T) { 195 | app := &Application{ 196 | Commands: []*Command{ 197 | { 198 | Name: "frobbly", 199 | Aliases: []*Alias{{Name: "fr"}, {Name: "frob"}, {Name: "not-here", Hidden: true}}, 200 | Action: func(ctx *Context) error { 201 | return nil 202 | }, 203 | }, 204 | }, 205 | } 206 | 207 | output := &bytes.Buffer{} 208 | app.Writer = output 209 | app.MustRun([]string{"foo", "--help"}) 210 | 211 | if !strings.Contains(output.String(), "frobbly, fr, frob") { 212 | t.Errorf("expected output to include all command aliases; got: %q", output.String()) 213 | } 214 | 215 | if strings.Contains(output.String(), "not-here") { 216 | t.Errorf("expected output to exclude hidden aliases; got: %q", output.String()) 217 | } 218 | } 219 | 220 | func TestShowCommandHelp_CommandAliases(t *testing.T) { 221 | app := &Application{ 222 | Commands: []*Command{ 223 | { 224 | Name: "frobbly", 225 | Aliases: []*Alias{{Name: "fr"}, {Name: "frob"}, {Name: "bork"}}, 226 | Action: func(ctx *Context) error { 227 | return nil 228 | }, 229 | }, 230 | }, 231 | } 232 | 233 | output := &bytes.Buffer{} 234 | app.Writer = output 235 | app.MustRun([]string{"foo", "help", "fr"}) 236 | 237 | if !strings.Contains(output.String(), "frobbly") { 238 | t.Errorf("expected output to include command name; got: %q", output.String()) 239 | } 240 | 241 | if strings.Contains(output.String(), "bork") { 242 | t.Errorf("expected output to exclude command aliases; got: %q", output.String()) 243 | } 244 | } 245 | 246 | func TestShowCommandHelp_CommandShortcut(t *testing.T) { 247 | app := &Application{ 248 | Commands: []*Command{ 249 | { 250 | Name: "bar", 251 | Category: "foo", 252 | Aliases: []*Alias{{Name: "fb"}}, 253 | Action: func(ctx *Context) error { 254 | return nil 255 | }, 256 | }, 257 | }, 258 | } 259 | 260 | output := &bytes.Buffer{} 261 | app.Writer = output 262 | app.MustRun([]string{"foo", "help", "f:b"}) 263 | 264 | if !strings.Contains(output.String(), "foo:bar") { 265 | t.Errorf("expected output to include command name; got: %q", output.String()) 266 | } 267 | } 268 | 269 | func TestShowCommandHelp_DescriptionFunc(t *testing.T) { 270 | app := &Application{ 271 | Commands: []*Command{ 272 | { 273 | Name: "frobbly", 274 | Description: "this is not my custom description", 275 | DescriptionFunc: func(*Command, *Application) string { 276 | return "this is my custom description" 277 | }, 278 | Action: func(ctx *Context) error { 279 | return nil 280 | }, 281 | }, 282 | }, 283 | } 284 | 285 | output := &bytes.Buffer{} 286 | app.Writer = output 287 | app.MustRun([]string{"foo", "help", "frobbly"}) 288 | 289 | if !strings.Contains(output.String(), "this is my custom description") { 290 | t.Errorf("expected output to include result of DescriptionFunc; got: %q", output.String()) 291 | } 292 | } 293 | 294 | func TestShowAppHelp_HiddenCommand(t *testing.T) { 295 | app := &Application{ 296 | Commands: []*Command{ 297 | { 298 | Name: "frobbly", 299 | Action: func(ctx *Context) error { 300 | return nil 301 | }, 302 | }, 303 | { 304 | Name: "secretfrob", 305 | Hidden: Hide, 306 | Action: func(ctx *Context) error { 307 | return nil 308 | }, 309 | }, 310 | }, 311 | } 312 | 313 | output := &bytes.Buffer{} 314 | app.Writer = output 315 | app.MustRun([]string{"app", "--help"}) 316 | 317 | if strings.Contains(output.String(), "secretfrob") { 318 | t.Errorf("expected output to exclude \"secretfrob\"; got: %q", output.String()) 319 | } 320 | 321 | if !strings.Contains(output.String(), "frobbly") { 322 | t.Errorf("expected output to include \"frobbly\"; got: %q", output.String()) 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /logging_flags.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "flag" 24 | "fmt" 25 | "strconv" 26 | "strings" 27 | 28 | "github.com/pkg/errors" 29 | "github.com/posener/complete" 30 | "github.com/symfony-cli/terminal" 31 | ) 32 | 33 | var LogLevelFlag = VerbosityFlag("log-level", "verbose", "v") 34 | 35 | type logLevelValue struct{} 36 | 37 | func (r *logLevelValue) Set(s string) error { 38 | v, err := strconv.Atoi(s) 39 | if err != nil { 40 | return errors.WithStack(err) 41 | } 42 | return terminal.SetLogLevel(v) 43 | } 44 | 45 | func (r *logLevelValue) Get() interface{} { 46 | return terminal.GetLogLevel() 47 | } 48 | 49 | func (r *logLevelValue) String() string { 50 | return terminal.Logger.GetLevel().String() 51 | } 52 | 53 | type logLevelShortcutValue struct { 54 | set *flag.FlagSet 55 | target string 56 | logLevel string 57 | } 58 | 59 | func newLogLevelShortcutValue(set *flag.FlagSet, target string, val int) *logLevelShortcutValue { 60 | return &logLevelShortcutValue{ 61 | set: set, 62 | target: target, 63 | logLevel: strconv.Itoa(val), 64 | } 65 | } 66 | 67 | func (r *logLevelShortcutValue) IsBoolFlag() bool { return true } 68 | 69 | func (r *logLevelShortcutValue) Set(s string) error { 70 | if s != "" && s != "true" { 71 | return errors.WithStack(r.set.Set(r.target, s)) 72 | } 73 | 74 | return errors.WithStack(r.set.Set(r.target, r.logLevel)) 75 | } 76 | 77 | func (r *logLevelShortcutValue) String() string { 78 | return "" 79 | } 80 | 81 | type verbosityFlag struct { 82 | Name string 83 | Aliases []string 84 | ShortAlias string 85 | Usage string 86 | DefaultValue int 87 | DefaultText string 88 | Hidden bool 89 | EnvVars []string 90 | Destination *logLevelValue 91 | } 92 | 93 | func VerbosityFlag(name, alias, shortAlias string) *verbosityFlag { 94 | return &verbosityFlag{ 95 | Name: name, 96 | Aliases: []string{alias}, 97 | ShortAlias: shortAlias, 98 | DefaultText: "", 99 | Usage: "Increase the verbosity of messages: 1 for normal output, 2 and 3 for more verbose outputs and 4 for debug", 100 | } 101 | } 102 | 103 | func (f *verbosityFlag) PredictArgs(c *Context, a complete.Args) []string { 104 | return []string{"1", "2", "3", "4"} 105 | } 106 | 107 | func (f *verbosityFlag) Validate(c *Context) error { 108 | return nil 109 | } 110 | 111 | func (f *verbosityFlag) Apply(set *flag.FlagSet) { 112 | f.DefaultValue = terminal.GetLogLevel() 113 | f.Destination = &logLevelValue{} 114 | 115 | if f.Name != "" { 116 | set.Var(f.Destination, f.Name, f.Usage) 117 | } 118 | 119 | for _, alias := range f.Aliases { 120 | set.Var(newLogLevelShortcutValue(set, f.Name, 3), alias, "") 121 | } 122 | for i := 1; i <= len(terminal.LogLevels)-2; i++ { 123 | set.Var(newLogLevelShortcutValue(set, f.Name, i+1), strings.Repeat(f.ShortAlias, i), "") 124 | } 125 | } 126 | 127 | // Names returns the names of the flag 128 | func (f *verbosityFlag) Names() []string { 129 | names := make([]string, 0, len(f.Aliases)+len(terminal.LogLevels)-2) 130 | 131 | if f.Name != "" { 132 | names = append(names, f.Name) 133 | } 134 | 135 | names = append(names, f.Aliases...) 136 | for i := 1; i <= len(terminal.LogLevels)-2; i++ { 137 | names = append(names, strings.Repeat(f.ShortAlias, i)) 138 | } 139 | 140 | return names 141 | } 142 | 143 | // String returns a readable representation of this value (for usage defaults) 144 | func (f *verbosityFlag) String() string { 145 | _, usage := unquoteUsage(f.Usage) 146 | names := "" 147 | 148 | for i, n := 1, len(terminal.LogLevels)-2; i <= n; i++ { 149 | if i == 1 { 150 | names += prefixFor(f.ShortAlias) 151 | } else { 152 | names += "|" 153 | } 154 | names += strings.Repeat(f.ShortAlias, i) 155 | } 156 | 157 | for _, alias := range f.Aliases { 158 | if alias != "" { 159 | names += ", " + prefixFor(alias) + alias 160 | } 161 | } 162 | 163 | if f.Name != "" { 164 | names += fmt.Sprintf(", %s%s", prefixFor(f.Name), f.Name) 165 | } 166 | 167 | return fmt.Sprintf("%s\t%s", names, strings.TrimSpace(usage)) 168 | } 169 | 170 | func (f *verbosityFlag) addToPosenerFlags(c *Context, flags complete.Flags) { 171 | for i, n := 1, len(terminal.LogLevels)-2; i <= n; i++ { 172 | name := prefixFor(f.ShortAlias) 173 | name += strings.Repeat(f.ShortAlias, i) 174 | flags[name] = complete.PredictFunc(func(a complete.Args) []string { 175 | return f.PredictArgs(c, a) 176 | }) 177 | } 178 | 179 | for _, alias := range f.Aliases { 180 | if alias != "" { 181 | flags[prefixFor(alias)+alias] = complete.PredictFunc(func(a complete.Args) []string { 182 | return f.PredictArgs(c, a) 183 | }) 184 | } 185 | } 186 | 187 | if f.Name != "" { 188 | flags[prefixFor(f.Name)+f.Name] = complete.PredictFunc(func(a complete.Args) []string { 189 | return f.PredictArgs(c, a) 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /logging_flags_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "flag" 24 | 25 | "github.com/rs/zerolog" 26 | "github.com/symfony-cli/terminal" 27 | . "gopkg.in/check.v1" 28 | ) 29 | 30 | type LoggingFlagsSuite struct{} 31 | 32 | var _ = Suite(&LoggingFlagsSuite{}) 33 | 34 | func (ts *LoggingFlagsSuite) TestLogLevel(c *C) { 35 | defer func() { 36 | c.Assert(terminal.SetLogLevel(1), IsNil) 37 | }() 38 | value := &logLevelValue{} 39 | var err error 40 | 41 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.ErrorLevel) 42 | 43 | err = value.Set("foo") 44 | c.Assert(err, Not(IsNil)) 45 | c.Assert(err, ErrorMatches, ".* parsing \"foo\": invalid syntax") 46 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.ErrorLevel) 47 | 48 | err = value.Set("4") 49 | c.Assert(err, IsNil) 50 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.DebugLevel) 51 | 52 | err = value.Set("2") 53 | c.Assert(err, IsNil) 54 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.WarnLevel) 55 | 56 | err = value.Set("9") 57 | c.Assert(err, Not(IsNil)) 58 | c.Assert(err.Error(), Equals, "The provided verbosity level '9' is not in the range [1,4]") 59 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.WarnLevel) 60 | } 61 | 62 | func (ts *LoggingFlagsSuite) TestLogLevelShortcuts(c *C) { 63 | defer func() { 64 | c.Assert(terminal.SetLogLevel(1), IsNil) 65 | }() 66 | fs := flag.NewFlagSet("foo", flag.ExitOnError) 67 | fs.Var(&logLevelValue{}, "log-level", "FooBar") 68 | 69 | value := newLogLevelShortcutValue(fs, "log-level", 3) 70 | var err error 71 | 72 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.ErrorLevel) 73 | 74 | err = value.Set("true") 75 | c.Assert(err, IsNil) 76 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.InfoLevel) 77 | 78 | err = value.Set("false") 79 | c.Assert(err, NotNil) 80 | c.Assert(err, ErrorMatches, ".* invalid syntax") 81 | 82 | err = value.Set("2") 83 | c.Assert(err, IsNil) 84 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.WarnLevel) 85 | 86 | err = value.Set("9") 87 | c.Assert(err, NotNil) 88 | c.Assert(err.Error(), Equals, "The provided verbosity level '9' is not in the range [1,4]") 89 | c.Assert(terminal.Logger.GetLevel(), Equals, zerolog.WarnLevel) 90 | } 91 | -------------------------------------------------------------------------------- /output_flags.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-present Fabien Potencier 3 | * 4 | * This file is part of Symfony CLI project 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU Affero General Public License as 8 | * published by the Free Software Foundation, either version 3 of the 9 | * License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU Affero General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU Affero General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | package console 21 | 22 | import ( 23 | "flag" 24 | "io" 25 | "os" 26 | "strconv" 27 | 28 | "github.com/pkg/errors" 29 | "github.com/posener/complete" 30 | "github.com/symfony-cli/terminal" 31 | ) 32 | 33 | type quietValue struct { 34 | app *Application 35 | } 36 | 37 | func (r *quietValue) Set(s string) error { 38 | quiet, err := strconv.ParseBool(s) 39 | if err != nil { 40 | return errors.WithStack(err) 41 | } 42 | if quiet { 43 | terminal.Stdout = terminal.NewBufferedConsoleOutput(io.Discard, io.Discard) 44 | } else { 45 | terminal.Stdout = terminal.DefaultStdout 46 | } 47 | terminal.Stdin.SetInteractive(!quiet) 48 | terminal.Stderr = terminal.Stdout.Stderr 49 | 50 | if r.app != nil { 51 | r.app.Writer = terminal.Stdout 52 | r.app.ErrWriter = terminal.Stderr 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func (r *quietValue) Get() interface{} { 59 | return terminal.Stdout.Stdout.IsQuiet() 60 | } 61 | 62 | func (r *quietValue) String() string { 63 | return strconv.FormatBool(r.Get().(bool)) 64 | } 65 | 66 | func (r *quietValue) IsBoolFlag() bool { 67 | return true 68 | } 69 | 70 | var QuietFlag = newQuietFlag("quiet", "q") 71 | 72 | type quietFlag struct { 73 | Name string 74 | Aliases []string 75 | Usage string 76 | Hidden bool 77 | 78 | app *Application 79 | } 80 | 81 | func newQuietFlag(name string, aliases ...string) *quietFlag { 82 | return &quietFlag{ 83 | Name: name, 84 | Aliases: aliases, 85 | Usage: "Do not output any message", 86 | } 87 | } 88 | 89 | func (f *quietFlag) ForApp(app *Application) *quietFlag { 90 | return &quietFlag{ 91 | Name: f.Name, 92 | Aliases: f.Aliases, 93 | Usage: f.Usage, 94 | Hidden: f.Hidden, 95 | app: app, 96 | } 97 | } 98 | 99 | func (f *quietFlag) PredictArgs(c *Context, a complete.Args) []string { 100 | return []string{"true", "false", ""} 101 | } 102 | 103 | func (f *quietFlag) Validate(c *Context) error { 104 | return nil 105 | } 106 | 107 | func (f *quietFlag) Apply(set *flag.FlagSet) { 108 | set.Var(&quietValue{f.app}, f.Name, f.Usage) 109 | } 110 | 111 | // Names returns the names of the flag 112 | func (f *quietFlag) Names() []string { 113 | return flagNames(f) 114 | } 115 | 116 | // String returns a readable representation of this value (for usage defaults) 117 | func (f *quietFlag) String() string { 118 | return stringifyFlag(f) 119 | } 120 | 121 | var ( 122 | NoInteractionFlag = &BoolFlag{ 123 | Name: "no-interaction", 124 | Usage: "Disable all interactions", 125 | } 126 | NoAnsiFlag = &BoolFlag{ 127 | Name: "no-ansi", 128 | Usage: "Disable ANSI output", 129 | } 130 | AnsiFlag = &BoolFlag{ 131 | Name: "ansi", 132 | Usage: "Force ANSI output", 133 | } 134 | ) 135 | 136 | func (app *Application) configureIO(c *Context) { 137 | if IsAutocomplete(c.Command) { 138 | terminal.DefaultStdout.SetDecorated(false) 139 | terminal.Stdin.SetInteractive(false) 140 | return 141 | } 142 | 143 | if c.IsSet(AnsiFlag.Name) { 144 | terminal.DefaultStdout.SetDecorated(c.Bool(AnsiFlag.Name)) 145 | } else if c.IsSet(NoAnsiFlag.Name) { 146 | terminal.DefaultStdout.SetDecorated(!c.Bool(NoAnsiFlag.Name)) 147 | } else if _, isPresent := os.LookupEnv("NO_COLOR"); isPresent { 148 | terminal.DefaultStdout.SetDecorated(false) 149 | } 150 | 151 | if c.IsSet(NoInteractionFlag.Name) { 152 | terminal.Stdin.SetInteractive(!c.Bool(NoInteractionFlag.Name)) 153 | } else if !terminal.IsInteractive(terminal.Stdin) { 154 | terminal.Stdin.SetInteractive(false) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /resources/completion.bash: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021-present Fabien Potencier 2 | # 3 | # This file is part of Symfony CLI project 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | # Bash completions for the CLI binary 19 | # 20 | # References: 21 | # - https://github.com/posener/complete/blob/master/install/bash.go 22 | # 23 | 24 | complete -C "{{ .CurrentBinaryPath }} self:autocomplete" {{ .App.HelpName }} 25 | -------------------------------------------------------------------------------- /resources/completion.fish: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021-present Fabien Potencier 2 | # 3 | # This file is part of Symfony CLI project 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | # Fish completions for the CLI binary 19 | # 20 | # References: 21 | # - https://github.com/posener/complete/blob/master/install/fish.go 22 | # 23 | 24 | function __complete_{{ .App.HelpName }} 25 | set -lx COMP_LINE (commandline -cp) 26 | test -z (commandline -ct) 27 | and set COMP_LINE "$COMP_LINE " 28 | {{ .CurrentBinaryInvocation }} self:autocomplete 29 | end 30 | 31 | complete -f -c '{{ .App.HelpName }}' -a '(__complete_{{ .App.HelpName }})' 32 | -------------------------------------------------------------------------------- /resources/completion.zsh: -------------------------------------------------------------------------------- 1 | #compdef {{ .App.HelpName }} 2 | 3 | # Copyright (c) 2021-present Fabien Potencier 4 | # 5 | # This file is part of Symfony CLI project 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as 9 | # published by the Free Software Foundation, either version 3 of the 10 | # License, or (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | 20 | # 21 | # zsh completions for {{ .App.HelpName }} 22 | # 23 | # References: 24 | # - https://github.com/posener/complete/blob/master/install/zsh.go 25 | # 26 | 27 | autoload -U +X bashcompinit && bashcompinit 28 | complete -o nospace -C "{{ .CurrentBinaryInvocation }} self:autocomplete" {{ .App.HelpName }} 29 | --------------------------------------------------------------------------------