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  }