github.com/richardwilkes/toolbox@v1.121.0/cmdline/cmdline.go (about) 1 // Copyright (c) 2016-2024 by Richard A. Wilkes. All rights reserved. 2 // 3 // This Source Code Form is subject to the terms of the Mozilla Public 4 // License, version 2.0. If a copy of the MPL was not distributed with 5 // this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 // 7 // This Source Code Form is "Incompatible With Secondary Licenses", as 8 // defined by the Mozilla Public License, version 2.0. 9 10 // Package cmdline provides command line option handling. 11 package cmdline 12 13 import ( 14 "bufio" 15 "fmt" 16 "io" 17 "os" 18 "strings" 19 20 "github.com/richardwilkes/toolbox/atexit" 21 "github.com/richardwilkes/toolbox/collection" 22 "github.com/richardwilkes/toolbox/errs" 23 "github.com/richardwilkes/toolbox/i18n" 24 "github.com/richardwilkes/toolbox/xio/term" 25 ) 26 27 // CmdLine holds information about the command line. 28 type CmdLine struct { 29 // UsageSuffix, if set, will be appended to the 'Usage: ' line of the output. 30 UsageSuffix string 31 // Description, if set, will be inserted after the program identity section, before the usage. 32 Description string 33 // UsageTrailer, if set, will be appended to the end of the usage output. 34 UsageTrailer string 35 cmds map[string]Cmd 36 parent *CmdLine 37 cmd Cmd 38 out *term.ANSI 39 options Options 40 showHelp bool 41 showVersion bool 42 showLongVersion bool 43 } 44 45 // New creates a new CmdLine. If 'includeDefaultOptions' is true, help (-h, --help) and version (-v, --version, along 46 // with hidden -V, --Version for long variants) options will be added, otherwise, only the help options will be added, 47 // although they will be hidden. 48 func New(includeDefaultOptions bool) *CmdLine { 49 cl := &CmdLine{ 50 cmds: make(map[string]Cmd), 51 out: term.NewANSI(os.Stderr), 52 } 53 help := cl.NewGeneralOption(&cl.showHelp).SetSingle('h').SetName("help") 54 if includeDefaultOptions { 55 help.SetUsage(i18n.Text("Display this help information and exit.")) 56 cl.NewGeneralOption(&cl.showVersion).SetSingle('v').SetName("version").SetUsage(i18n.Text("Display short version information and exit")) 57 cl.NewGeneralOption(&cl.showLongVersion).SetSingle('V').SetName("Version").SetUsage(i18n.Text("Display the full version information and exit")) 58 } 59 return cl 60 } 61 62 // NewOption creates a new Option and attaches it to this CmdLine. 63 func (cl *CmdLine) NewOption(value Value) *Option { 64 option := new(Option) 65 option.value = value 66 option.def = value.String() 67 option.arg = i18n.Text("value") 68 cl.options = append(cl.options, option) 69 return option 70 } 71 72 // NewGeneralOption creates a new Option and attaches it to this CmdLine. Valid value types are: *bool, *int, *int8, 73 // *int16, *int32, *int64, *uint, *uint8, *uint16, *uint32, *uint64, *float32, *float64, *string, *time.Duration, 74 // *[]bool, *[]uint8, *[]uint16, *[]uint32, *[]uint64, *[]int8, *[]int16, *[]int32, *[]int64, *[]string, 75 // *[]time.Duration 76 func (cl *CmdLine) NewGeneralOption(value any) *Option { 77 option := new(Option) 78 option.value = &GeneralValue{Value: value} 79 option.def = option.value.String() 80 option.arg = i18n.Text("value") 81 cl.options = append(cl.options, option) 82 return option 83 } 84 85 // Parse the 'args', filling in any options. Returns the remaining arguments that weren't used for option content. 86 func (cl *CmdLine) Parse(args []string) []string { 87 const ( 88 lookForOptionState = iota 89 setOptionValueState 90 collectRemainingState 91 ) 92 var current *Option 93 var currentArg string 94 state := lookForOptionState 95 var remainingArgs []string 96 options := cl.availableOptions() 97 maximum := len(args) 98 seen := collection.NewSet[string]() 99 for i := 0; i < maximum; i++ { 100 arg := args[i] 101 switch state { 102 case lookForOptionState: 103 if strings.HasPrefix(arg, "@") { 104 path := arg[1:] 105 if seen.Contains(path) { 106 cl.FatalMsg(fmt.Sprintf(i18n.Text("Recursive loading of arguments from a file is not permitted: %s"), path)) 107 } 108 seen.Add(path) 109 insert, err := cl.loadArgsFromFile(path) 110 cl.FatalIfError(err) 111 args = append(args[:i], append(insert, args[i+1:]...)...) 112 maximum = len(args) 113 i-- 114 continue 115 } 116 switch { 117 case arg == "--": 118 state = collectRemainingState 119 case strings.HasPrefix(arg, "--"): 120 var value string 121 arg = arg[2:] 122 sep := strings.Index(arg, "=") 123 if sep != -1 { 124 value = arg[sep+1:] 125 arg = arg[:sep] 126 } 127 option := options[arg] 128 switch { 129 case option == nil: 130 cl.FatalMsg(fmt.Sprintf(i18n.Text("Invalid option: --%s"), arg)) 131 case option.isBool(): 132 if sep != -1 { 133 cl.FatalMsg(fmt.Sprintf(i18n.Text("Option --%[1]s does not allow an argument: %[2]s"), arg, value)) 134 } else { 135 cl.setOrFail(option, "--"+arg, "true") 136 } 137 case sep != -1: 138 cl.setOrFail(option, "--"+arg, value) 139 default: 140 state = setOptionValueState 141 current = option 142 currentArg = "--" + arg 143 } 144 case strings.HasPrefix(arg, "-"): 145 arg = arg[1:] 146 outer: 147 for j, ch := range arg { 148 if option := options[string(ch)]; option != nil { 149 switch { 150 case option.isBool(): 151 cl.setOrFail(option, "-"+arg, "true") 152 case j == len(arg)-1: 153 state = setOptionValueState 154 current = option 155 currentArg = "-" + arg[j:j+1] 156 case arg[j+1:j+2] == "=": 157 cl.setOrFail(option, "-"+arg, arg[j+2:]) 158 break outer 159 default: 160 cl.setOrFail(option, "-"+arg, arg[j+1:]) 161 break outer 162 } 163 } else { 164 cl.FatalMsg(fmt.Sprintf(i18n.Text("Invalid option: -%s"), arg[j:])) 165 break 166 } 167 } 168 default: 169 remainingArgs = append(remainingArgs, arg) 170 state = collectRemainingState 171 } 172 case setOptionValueState: 173 cl.setOrFail(current, currentArg, arg) 174 state = lookForOptionState 175 case collectRemainingState: 176 remainingArgs = append(remainingArgs, arg) 177 } 178 } 179 if state == setOptionValueState { 180 cl.FatalMsg(fmt.Sprintf(i18n.Text("Option %s requires an argument"), currentArg)) 181 } 182 if cl.showHelp { 183 cl.DisplayUsage() 184 atexit.Exit(1) 185 } 186 if cl.showLongVersion { 187 fmt.Println(LongVersion()) 188 atexit.Exit(0) 189 } 190 if cl.showVersion { 191 fmt.Println(ShortVersion()) 192 atexit.Exit(0) 193 } 194 return remainingArgs 195 } 196 197 func (cl *CmdLine) setOrFail(op *Option, arg, value string) { 198 if err := op.value.Set(value); err != nil { 199 cl.FatalMsg(fmt.Sprintf(i18n.Text("Unable to set option %s to %s\n%v"), arg, value, err)) 200 } 201 } 202 203 // FatalMsg emits an error message and causes the program to exit. 204 func (cl *CmdLine) FatalMsg(msg string) { 205 cl.out.Bell() 206 cl.out.Foreground(term.Red, term.Normal) 207 fmt.Fprint(cl, msg) 208 cl.out.Reset() 209 fmt.Fprintln(cl) 210 atexit.Exit(1) 211 } 212 213 // FatalError emits an error message and causes the program to exit. 214 func (cl *CmdLine) FatalError(err error) { 215 cl.FatalMsg(err.Error()) 216 } 217 218 // FatalIfError emits an error message and causes the program to exit if err != nil. 219 func (cl *CmdLine) FatalIfError(err error) { 220 if err != nil { 221 cl.FatalError(err) 222 } 223 } 224 225 func (cl *CmdLine) availableOptions() (available map[string]*Option) { 226 available = make(map[string]*Option, len(cl.options)) 227 for _, option := range cl.options { 228 if ok, err := option.isValid(); !ok { 229 cl.FatalMsg(fmt.Sprintf(i18n.Text("Invalid option specification: %v"), err)) 230 } else { 231 if option.single != 0 { 232 name := string(option.single) 233 if available[name] != nil { 234 cl.FatalMsg(fmt.Sprintf(i18n.Text("Option specification -%s already exists"), name)) 235 } else { 236 available[name] = option 237 } 238 } 239 if option.name != "" { 240 if available[option.name] != nil { 241 cl.FatalMsg(fmt.Sprintf(i18n.Text("Option specification --%s already exists"), option.name)) 242 } else { 243 available[option.name] = option 244 } 245 } 246 } 247 } 248 return available 249 } 250 251 func (cl *CmdLine) loadArgsFromFile(path string) (args []string, err error) { 252 var file *os.File 253 if file, err = os.Open(path); err != nil { 254 return nil, errs.NewWithCause(fmt.Sprintf(i18n.Text("Unable to open: %s"), path), err) 255 } 256 defer func() { 257 if closeErr := file.Close(); closeErr != nil && err == nil { 258 err = closeErr 259 } 260 }() 261 args = make([]string, 0) 262 scanner := bufio.NewScanner(file) 263 for scanner.Scan() { 264 args = append(args, scanner.Text()) 265 } 266 if err = scanner.Err(); err != nil { 267 return nil, errs.Wrap(err) 268 } 269 return args, nil 270 } 271 272 // SetWriter sets the io.Writer to use for output. By default, a new CmdLine uses os.Stderr. 273 func (cl *CmdLine) SetWriter(w io.Writer) { 274 cl.out = term.NewANSI(w) 275 } 276 277 // Write implements the io.Writer interface. 278 func (cl *CmdLine) Write(p []byte) (n int, err error) { 279 return cl.out.Write(p) 280 }