From dae8be92e3f343951b3b621b8ccf6c871b1cdb3f Mon Sep 17 00:00:00 2001 From: Celestino Amoroso Date: Thu, 19 Mar 2026 18:28:11 +0100 Subject: [PATCH] new /v2 subdirectory --- v2/README.md | 86 +++++++++ v2/arg-base.go | 40 +++++ v2/arg-string-array.go | 49 +++++ v2/arg-string.go | 36 ++++ v2/cli-usage.go | 92 ++++++++++ v2/cli-version.go | 59 ++++++ v2/cli.go | 284 +++++++++++++++++++++++++++++ v2/cli_test.go | 385 ++++++++++++++++++++++++++++++++++++++++ v2/common.go | 39 ++++ v2/demo.cast | 32 ++++ v2/expected.txt | 10 ++ v2/go.mod | 3 + v2/got.txt | 10 ++ v2/opt-base.go | 165 +++++++++++++++++ v2/opt-bool.go | 90 ++++++++++ v2/opt-help.go | 30 ++++ v2/opt-int-array.go | 120 +++++++++++++ v2/opt-int.go | 81 +++++++++ v2/opt-manager.go | 10 ++ v2/opt-multi.go | 77 ++++++++ v2/opt-string-array.go | 91 ++++++++++ v2/opt-string-map.go | 112 ++++++++++++ v2/opt-string.go | 139 +++++++++++++++ v2/opt-version.go | 36 ++++ v2/opt_test.go | 143 +++++++++++++++ v2/parser.go | 122 +++++++++++++ v2/simple-opt-tracer.go | 18 ++ 27 files changed, 2359 insertions(+) create mode 100644 v2/README.md create mode 100644 v2/arg-base.go create mode 100644 v2/arg-string-array.go create mode 100644 v2/arg-string.go create mode 100644 v2/cli-usage.go create mode 100644 v2/cli-version.go create mode 100644 v2/cli.go create mode 100644 v2/cli_test.go create mode 100644 v2/common.go create mode 100644 v2/demo.cast create mode 100644 v2/expected.txt create mode 100644 v2/go.mod create mode 100644 v2/got.txt create mode 100644 v2/opt-base.go create mode 100644 v2/opt-bool.go create mode 100644 v2/opt-help.go create mode 100644 v2/opt-int-array.go create mode 100644 v2/opt-int.go create mode 100644 v2/opt-manager.go create mode 100644 v2/opt-multi.go create mode 100644 v2/opt-string-array.go create mode 100644 v2/opt-string-map.go create mode 100644 v2/opt-string.go create mode 100644 v2/opt-version.go create mode 100644 v2/opt_test.go create mode 100644 v2/parser.go create mode 100644 v2/simple-opt-tracer.go diff --git a/v2/README.md b/v2/README.md new file mode 100644 index 0000000..989bc20 --- /dev/null +++ b/v2/README.md @@ -0,0 +1,86 @@ +# go-pkg/cli + +Lightweight, dependency-free command-line argument and option parsing library for Go. + +## Overview + +This package provides a small, composable CLI parser with support for: +- boolean, string, int, and array/map options +- positional arguments (single and repeating) +- aliases, short flags, defaults and hidden options +- validation for incompatible options and custom "special" values +- automatic usage and version printing + +See the core parser implementation in [`parser.go`](parser.go) and the high-level API in [`cli.go`](cli.go). + +## Features + +- Option types: [`AddBoolOpt`](opt-bool.go), [`AddStringOpt`](opt-string.go), [`AddIntOpt`](opt-int.go), [`AddStringArrayOpt`](opt-string-array.go), [`AddIntArrayOpt`](opt-int-array.go), [`AddStringMapOpt`](opt-string-map.go) +- Positional args: [`AddStringArg`](arg-string.go), [`AddStringArrayArg`](arg-string-array.go) +- Usage & version output: [`CliParser.Usage`](cli-usage.go), [`CliParser.PrintVersion`](cli-version.go) +- Option tracing via [`CliParser.TraceOptions`](cli.go) and [`SimpleOptionTracer`](simple-opt-tracer.go) +- Programmatic option setting: [`SetOptionValue`](opt-manager.go) + +## Quick start + +Example: define options and parse argv + +```go +package main + +import ( + "fmt" + "git.portale-stac.it/go-pkg/cli" +) + +func main() { + var parser cli.CliParser + parser.Init([]string{"prog", "--debug", "--config", "app.yaml", "input.txt"}, "$VER:prog,0.1.0,2025,email:$") + + // define target variables and options + var debug bool + var config string + var inputs []string + + parser.AddBoolOpt("debug", "d", &debug, "Enable debug") + parser.AddStringOpt("config", "c", &config, "app.yaml", "Config file") + parser.AddStringArrayArg("files", true, &inputs, "Input files") + + // print usage: parser.Usage() - see [`CliParser.Usage`](cli-usage.go) + if err := parser.Parse(); err != nil { + fmt.Println("parse error:", err) + return + } + + fmt.Println("debug:", debug) + fmt.Println("config:", config) + fmt.Println("inputs:", inputs) +} +``` + +Refer to the unit test for a realistic example: [`cli_test.go`](cli_test.go). + +## Usage + +## API Reference + +Key types and methods: +- [`CliParser`](cli.go) — main parser type +- [`CliParser.Init`](cli.go) — initialize with argv and version +- [`CliParser.Parse`](cli.go) — run parsing +- Option constructors: [`AddBoolOpt`](opt-bool.go), [`AddStringOpt`](opt-string.go), [`AddIntOpt`](opt-int.go), [`AddStringArrayOpt`](opt-string-array.go), [`AddIntArrayOpt`](opt-int-array.go), [`AddStringMapOpt`](opt-string-map.go) +- Arg constructors: [`AddStringArg`](arg-string.go), [`AddStringArrayArg`](arg-string-array.go) +- Utilities: [`TraceOptions`](cli.go), [`SetOptionValue`](opt-manager.go), [`SimpleOptionTracer`](simple-opt-tracer.go) + +For implementation details, consult: +- parser internals: [`parser.go`](parser.go) +- option base helpers: [`opt-base.go`](opt-base.go) +- usage/version printing: [`cli-usage.go`](cli-usage.go), [`cli-version.go`](cli-version.go) + +## License + +This project is licensed under the terms in [LICENSE](LICENSE). + +## Contributing + +Open issues or pull requests are welcome. Run and extend the tests in [`cli_test.go`](cli_test.go) when making changes. diff --git a/v2/arg-base.go b/v2/arg-base.go new file mode 100644 index 0000000..1b344f5 --- /dev/null +++ b/v2/arg-base.go @@ -0,0 +1,40 @@ +package cli + +import ( + "strings" +) + +type argSpec interface { + getBase() *argBase + parse(cli *CliParser, specIndex int, args []string, argIndex int) (consumedArgs int, err error) +} + +type argBase struct { + name string + description string + required bool + repeat bool +} + +// -------------- Argument Template functions ---------------- +func (cli *CliParser) getArgTemplate(spec argSpec) (templ string) { + arg := spec.getBase() + templ = "<" + arg.name + ">" + if arg.repeat { + templ += " ..." + } + if !arg.required { + templ = "[" + templ + "]" + } + return +} + +func (cli *CliParser) getArgsTemplate() (argsTemplate string) { + templates := make([]string, len(cli.argSpecs)) + for i, argSpec := range cli.argSpecs { + templates[i] = cli.getArgTemplate(argSpec) + } + + argsTemplate = strings.Join(templates, " ") + return +} diff --git a/v2/arg-string-array.go b/v2/arg-string-array.go new file mode 100644 index 0000000..f3469d5 --- /dev/null +++ b/v2/arg-string-array.go @@ -0,0 +1,49 @@ +package cli + +// -------------- argStringArray ---------------- +type argStringArray struct { + base argBase + targetVar *[]string +} + +func (arg *argStringArray) getBase() *argBase { + return &arg.base +} + +func (spec *argStringArray) parse(cli *CliParser, specIndex int, args []string, argIndex int) (consumedArgs int, err error) { + consumedArgs = 1 + if spec.targetVar != nil { + if argIndex < len(args) { + remainingSpecs := len(cli.argSpecs) - specIndex - 1 + availableArgs := len(args) - remainingSpecs + if availableArgs > 0 { + *spec.targetVar = args[argIndex : argIndex+availableArgs] + consumedArgs = availableArgs + } else { + err = errTooFewArguments(len(cli.argSpecs)) + } + } else if spec.base.required { + err = errMissingRequiredArg(spec.base.name) + } + } + return +} + +func (cli *CliParser) AddStringArrayArg(name string, required bool, targetVar *[]string, description string) { + // todo: check if arg already exists + if len(cli.argSpecs) > 0 { + lastArg := cli.argSpecs[len(cli.argSpecs)-1].getBase() + if lastArg.repeat { + panic(errRepeatArgAlreadyDefined(lastArg.name)) + } + } + cli.argSpecs = append(cli.argSpecs, &argStringArray{ + base: argBase{ + name: name, + description: description, + required: required, + repeat: true, + }, + targetVar: targetVar, + }) +} diff --git a/v2/arg-string.go b/v2/arg-string.go new file mode 100644 index 0000000..079b583 --- /dev/null +++ b/v2/arg-string.go @@ -0,0 +1,36 @@ +package cli + +// -------------- argString ---------------- +type argString struct { + base argBase + targetVar *string +} + +func (arg *argString) getBase() *argBase { + return &arg.base +} + +func (spec *argString) parse(cli *CliParser, specIndex int, args []string, argIndex int) (consumedArgs int, err error) { + consumedArgs = 1 + if spec.targetVar != nil { + if argIndex < len(args) { + *spec.targetVar = args[argIndex] + } else if spec.base.required { + err = errMissingRequiredArg(spec.base.name) + } + } + return +} + +func (cli *CliParser) AddStringArg(name string, required bool, targetVar *string, description string) { + // todo: check if arg already exists + cli.argSpecs = append(cli.argSpecs, &argString{ + base: argBase{ + name: name, + description: description, + required: required, + repeat: false, + }, + targetVar: targetVar, + }) +} diff --git a/v2/cli-usage.go b/v2/cli-usage.go new file mode 100644 index 0000000..e31b8a0 --- /dev/null +++ b/v2/cli-usage.go @@ -0,0 +1,92 @@ +package cli + +import ( + "fmt" + "os" + "strings" +) + +// Usage() +func (cli *CliParser) Usage() string { + var sb strings.Builder + + program, _ := cli.GetVersionSection("program") + fmt.Fprint(&sb, "NAME ", program) + if cli.description != "" { + fmt.Fprint(&sb, " - ", cli.description) + } + sb.WriteByte('\n') + + publicCount := cli.publicOptionCount() + if publicCount > 0 { + fmt.Fprintf(&sb, "USAGE: %s [] %s\n", program, cli.getArgsTemplate()) + } else { + fmt.Fprintf(&sb, "USAGE: %s %s\n", program, cli.getArgsTemplate()) + } + + if len(cli.argSpecs) > 0 || len(cli.options) > 0 { + fmt.Fprintf(&sb, "where:\n") + if len(cli.argSpecs) > 0 { + for _, argSpec := range cli.argSpecs { + arg := argSpec.getBase() + fmt.Fprintf(&sb, " <%s> %s\n", arg.name, arg.description) + } + } + if publicCount > 0 { + fmt.Fprintf(&sb, " \n") + templates, maxWidth := cli.makeOptionTemplateList() + for i, opti := range cli.options { + if opti.isHidden() { + continue + } + opt := opti.getBase() + aliases := opt.aliases + if len(aliases) > 0 && opt.isArray { + // Skip the last alias because it is the implicit plural alias + aliases = aliases[0 : len(aliases)-1] + } + if len(aliases) > 0 { + fmt.Fprintf(&sb, " %-*s %s (alias: %s)", maxWidth, templates[i], opt.description, strings.Join(aliases, ", ")) + } else { + fmt.Fprintf(&sb, " %-*s %s", maxWidth, templates[i], opt.description) + } + if value := opti.getDefaultValue(); value != "" { + fmt.Fprintf(&sb, " (default: %q)", value) + } + sb.WriteByte('\n') + } + } + } + return sb.String() +} + +func (cli *CliParser) publicOptionCount() (count int) { + for _, opti := range cli.options { + opt := opti.getBase() + if !opt.hidden { + count++ + } + } + return +} + +func (cli *CliParser) makeOptionTemplateList() (templates []string, maxWidth int) { + maxWidth = 0 + templates = make([]string, len(cli.options)) + for i, opti := range cli.options { + if opti.isHidden() { + templates[i] = "" + } else { + templates[i] = opti.getTemplate() + if len(templates[i]) > maxWidth { + maxWidth = len(templates[i]) + } + } + } + return templates, maxWidth +} + +// PrintUsage() +func (cli *CliParser) PrintUsage() { + os.Stdout.WriteString(cli.Usage()) +} diff --git a/v2/cli-version.go b/v2/cli-version.go new file mode 100644 index 0000000..fadbee5 --- /dev/null +++ b/v2/cli-version.go @@ -0,0 +1,59 @@ +package cli + +import ( + "fmt" + "os" + "strings" +) + +// const ( +// VER_PROGRAM = iota +// VER_VERSION +// VER_DATE +// VER_EMAIL +// ) + +// GetVersionSection() +func (cli *CliParser) GetVersionSection(sectionName string) (secValue string, err error) { + var sectionId int + if sectionName == "" || sectionName == "all" || sectionName == "full" { + secValue = cli.version[5 : len(cli.version)-2] + } else { + sections := strings.Split(cli.version[5:len(cli.version)-2], ",") + switch sectionName { + case "program": + sectionId = 0 + case "version", "number": + sectionId = 1 + case "date": + sectionId = 2 + case "email": + sectionId = 3 + case "full": + sectionId = -1 + default: + sectionId = 1 + } + if sectionId >= 0 && sectionId < len(sections) { + secValue = sections[sectionId] + } else { + err = fmt.Errorf("unknown version section %q", sectionName) + } + } + return +} + +// PrintVersion() +func (cli *CliParser) PrintVersion(specs []string) { + if len(specs) == 0 { + if specValue, err := cli.GetVersionSection("number"); err == nil { + os.Stdout.WriteString(specValue + "\n") + } + } else { + for _, spec := range specs { + if specValue, err := cli.GetVersionSection(spec); err == nil { + os.Stdout.WriteString(specValue + "\n") + } + } + } +} diff --git a/v2/cli.go b/v2/cli.go new file mode 100644 index 0000000..11aab86 --- /dev/null +++ b/v2/cli.go @@ -0,0 +1,284 @@ +package cli + +import ( + "fmt" + "strings" +) + +type CliOptionTracer interface { + TraceCliOption(name string, valueType string, value any) +} + +type cliParser interface { + getOptionValue(argIndex int) (string, bool) + getCliArgs(startIndex, endIndex int) (args []string) + PrintVersion(specs []string) + PrintUsage() + FlagIsSet(flag int16) bool +} + +type cliOptionParser interface { + parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, optValue string, err error) + getTemplate() string + getDefaultValue() string + getBase() *cliOptionBase + init() + isSet() bool + isHidden() bool + requiresValue() bool + finalCheck() (err error) +} + +type OptManager interface { + SetOptionValue(name string, value string) (err error) +} + +type SpecialValueFunc func(manager OptManager, cliValue string, currentValue any) (value any, err error) +type FinalCheckFunc func(currentValue any) (err error) + +type OptReference interface { + AddSpecialValue(cliValue string, specialFunc SpecialValueFunc) + OnFinalCheck(checkFunc FinalCheckFunc) + SetHidden(hidden bool) +} + +const ( + ResetOnEqualSign = int16(1 << iota) // for some option types, like arrays, the equal signs reset to empty the accumulator of values +) + +type CliParser struct { + description string + version string + options []cliOptionParser + argSpecs []argSpec + cliArgs []string + flags int16 +} + +func (cli *CliParser) Init(version string, description string, flags ...int16) { + cli.version = version + cli.description = description + for _, flag := range flags { + cli.flags |= flag + } +} + +func (cli *CliParser) FlagIsSet(flag int16) bool { + return (cli.flags & flag) == flag +} +func (cli *CliParser) GetOption(name string) (ref OptReference) { + var opt cliOptionParser + if strings.HasPrefix(name, "-") { + opt = cli.findOptionByArg(name) + } else { + opt = cli.findOptionByName(name) + } + if opt != nil { + ref = opt.(OptReference) + } + return ref + +} + +func (cli *CliParser) SetIncompatibleOption(optName string, incompatibleOptNames ...string) error { + var opti cliOptionParser + if opti = cli.findOptionByName(optName); opti == nil { + return errOptionNotFound(optName) + } + for _, incompatibleName := range incompatibleOptNames { + var incompatibleOpti cliOptionParser + if incompatibleOpti = cli.findOptionByName(incompatibleName); incompatibleOpti == nil { + return errOptionNotFound(incompatibleName) + } + incompatibleOpti.getBase().addIncompatibleOption(optName) + } + opti.getBase().addIncompatibleOption(incompatibleOptNames...) + return nil +} + +func (cli *CliParser) TraceOptions(tracer CliOptionTracer) { + for _, opti := range cli.options { + opt := opti.getBase() + var valueType string + var value any + if optiWithTargetVar, ok := opti.(interface { + getTargetVar() (any, string) + }); ok { + value, valueType = optiWithTargetVar.getTargetVar() + } else { + value = nil + valueType = "n/a" + } + tracer.TraceCliOption(opt.name, valueType, value) + } +} + +func (cli *CliParser) AddVersion() { + name := "version" + short := "v" + aliases := []string(nil) + description := `Print program info; allowed sections: "all", "program", "number" or "version", "date", "email"` + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + cli.options = append(cli.options, &cliOptionVersion{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + }, + }) +} + +func (cli *CliParser) AddHelp() { + name := "help" + short := "h" + aliases := []string(nil) + description := "Print this help text" + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + cli.options = append(cli.options, &cliOptionHelp{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + }, + }) +} + +func (cli *CliParser) addHelpAndVersion() { + var hasHelp, hasVersion bool + for _, opti := range cli.options { + opti.init() + opt := opti.getBase() + if opt.Is("help") { + hasHelp = true + } else if opt.Is("version") { + hasVersion = true + } + } + if !hasHelp { + cli.AddHelp() + } + if !hasVersion { + cli.AddVersion() + } + +} + +func (cli *CliParser) parseOptions() (commandArgs []string, err error) { + // var commandArgs []string + var optionsAllowed bool = true + + skipNext := false + for i, arg := range cli.cliArgs[1:] { + if skipNext { + skipNext = false + } else { + if optionsAllowed && arg[0] == '-' { + if arg == "--" { + optionsAllowed = false + } else if skipNext, err = cli.parseArg(arg, i); err == nil { + err = cli.checkCompatibility(arg) + } + if err != nil { + break + } + } else { + commandArgs = append(commandArgs, arg) + } + } + } + return +} + +func (cli *CliParser) checkOptionValues() (err error) { + for _, opt := range cli.options { + if err = opt.finalCheck(); err != nil { + break + } + } + return +} + +func (cli *CliParser) parseCommandArgs(commandArgs []string) (err error) { + var n int + + i := 0 + // acquire arguments as required by the argSpecs + specIndex := -1 + for index, argSpec := range cli.argSpecs { + specIndex = index + if n, err = argSpec.parse(cli, specIndex, commandArgs, i); err != nil { + break + } + i += n + if i >= len(commandArgs) { + break + } + } + + // check if there are remaining arg-specs that require a value + if err == nil { + if i < len(commandArgs) { + err = fmt.Errorf("too many arguments: %d allowed", i) + } else { + specIndex++ + if specIndex < len(cli.argSpecs) { + // skip all non required args + for _, spec := range cli.argSpecs[specIndex:] { + if !spec.getBase().required { + specIndex++ + } + } + } + // return error if there are remaining arg-specs that require a value + if specIndex < len(cli.argSpecs) { + err = errTooFewArguments(len(cli.argSpecs)) + } + } + } + return +} + +func (cli *CliParser) Parse(argv []string) (err error) { + var commandArgs []string + + cli.cliArgs = argv + + cli.addHelpAndVersion() + + // first parse options and collect command arguments in the args array + if commandArgs, err = cli.parseOptions(); err != nil { + return + } + + // do final checks + if err = cli.checkOptionValues(); err != nil { + return + } + + // then parse collected arguments + err = cli.parseCommandArgs(commandArgs) + return +} + +func (cli *CliParser) checkCompatibility(arg string) (err error) { + var opti cliOptionParser + if opti = cli.findOptionByArg(arg); opti != nil { + opt := opti.getBase() + for _, incompatibleName := range opt.incompatibleWith { + var incompatibleOpti cliOptionParser + if incompatibleOpti = cli.findOptionByName(incompatibleName); incompatibleOpti != nil { + if incompatibleOpti.isSet() { + err = errIncompatibleOptions(opt.name, incompatibleOpti.getBase().name) + return + } + } + } + } + return +} diff --git a/v2/cli_test.go b/v2/cli_test.go new file mode 100644 index 0000000..6380644 --- /dev/null +++ b/v2/cli_test.go @@ -0,0 +1,385 @@ +package cli + +import ( + "fmt" + "strings" + "testing" +) + +const version = `$VER:ddt-ocr,2.0.0,2026-03-19,celestino.amoroso@gmail.com:$` + +type GlobalData struct { + config string + log []string + printOcr bool + saveClips bool + trace bool + page []int + cliVars map[string]string + inputName string + workDir string + attempts int + sources []string + dest string + report string + verbose int +} + +func TestVerbose(t *testing.T) { + var cli CliParser + var gd GlobalData + + if err := initCli(&cli, &gd); err != nil { + t.Error(err) + return + } + if err := cli.Parse(commonArgs()); err != nil { + t.Error(err) + } else if gd.verbose != 3 { + t.Errorf("Expected verbose level 3, got %d", gd.verbose) + } +} + +func TestVersion(t *testing.T) { + var cli CliParser + var gd GlobalData + + if err := initCli(&cli, &gd); err != nil { + t.Error(err) + return + } + if v, err := cli.GetVersionSection("all"); err == nil { + if v != version[5:len(version)-2] { + t.Errorf("Version string does not match expected.\nGot:\n%q\nExpected:\n%q", v, version) + } + } else { + t.Error(err) + } +} + +func TestVersionNumber(t *testing.T) { + var cli CliParser + var gd GlobalData + + if err := initCli(&cli, &gd); err != nil { + t.Error(err) + return + } + secValues := []string{"ddt-ocr", "1.0.1", "2025-11-23", "celestino.amoroso@gmail.com"} + for i, sec := range []string{"program", "version", "date", "email"} { + if v, err := cli.GetVersionSection(sec); err == nil { + if v != secValues[i] { + t.Errorf("Version section %q does not match expected value.\nGot:\n%q\nExpected:\n%q", sec, v, version) + } + } else { + t.Error(err) + } + } +} + +func TestUsage(t *testing.T) { + const expectedUsage = `NAME ddt-ocr - cli-test +USAGE: ddt-ocr [] ... [] +where: + Source image files + Output destination file + Optional report file + + -V, --verbose Print verbose output (default: "0") + -o, --print-ocr Print the OCR output to stderr + -s, --save-clip Save the image clips as PNG files (alias: save-clips) + -t, --trace Enable trace mode for detailed logging + -p, --page(s) ["," ...] Process only the specified pages (comma-separated list) + -c, --config Alternate configuration file + -l, --log(s) ["," ...] Logging options (comma-separated list) + --var(s) ["," ...] Define one or more comma separated variables for the actions context (multiple allowed) + -n, --input-name Input file name when source comes from stdin + -d, --work-dir Work directory + --attempts Attempts for retrying failed operations (default: "1") +` + var cli CliParser + var gd GlobalData + + if err := initCli(&cli, &gd); err != nil { + t.Error(err) + return + } + usage := cli.Usage() + if usage != expectedUsage { + t.Errorf("Usage output does not match expected.\nGot:\n%s\nExpected:\n%s", usage, expectedUsage) + } +} +func TestParser(t *testing.T) { + const expectedOutput = `Option: verbose, Type: num, Value: 3 +Option: print-ocr, Type: bool, Value: true +Option: save-clip, Type: bool, Value: false +Option: trace, Type: bool, Value: true +Option: page, Type: num-array, Value: [17 18] +Option: config, Type: string, Value: devel-config.yaml +Option: log, Type: string-array, Value: [all] +Option: var, Type: map-string, Value: map[deploy_env:devel] +Option: input-name, Type: string, Value: my-scan.pdf +Option: work-dir, Type: string, Value: +Option: attempts, Type: num, Value: 1 +Option: help, Type: n/a, Value: +Option: version, Type: n/a, Value: +` + + var cli CliParser + var gd GlobalData + var sb strings.Builder + + if err := initCli(&cli, &gd); err != nil { + t.Error(err) + return + } + tracer := NewSimpleOptionTracer(&sb) + if err := cli.Parse(commonArgs()); err == nil { + cli.TraceOptions(tracer) + if sb.String() != expectedOutput { + t.Errorf("Parsed options do not match expected list.\nGot:\n%q\nExpected:\n%q", sb.String(), expectedOutput) + } + } else { + t.Error(err) + } +} + +func commonArgs() []string { + return []string{ + "ddt-ocr", + "--log", "all", + "-t", + "-VVV", + "--config", "devel-config.yaml", + "--var", "deploy_env=devel", + "--print-ocr", + "--pages", "17,18", + "--input-name=my-scan.pdf", + "scan1.pdf", "scan2.pdf", "result.txt", "report.txt", + } +} + +func initCli(cli *CliParser, gd *GlobalData) (err error) { + cli.Init(version, "cli-test") + err = gd.addOptions(cli) + return +} + +func TestOptErrorUnknownOption(t *testing.T) { + const unknownOption = "--logging" + var expectedErr = errUnknownOption(unknownOption) + var cli CliParser + var gd GlobalData + + if err := initCliUnknownOption(&cli, &gd); err == nil { + if err = cli.Parse(commonBadArgs(unknownOption)); err != nil { + if err.Error() != expectedErr.Error() { + t.Errorf("Invalid error message.\nGot:\n%v\nExpected:\n%v", err, expectedErr) + } + } else { + t.Errorf("Expected error for unknown option %q, but got none", unknownOption) + } + } else { + t.Error(err) + return + } +} + +func commonBadArgs(option string) []string { + return []string{ + "ddt-ocr", + option, + "scan1.pdf", "scan2.pdf", "result.txt", "report.txt", + } +} +func initCliUnknownOption(cli *CliParser, gd *GlobalData) (err error) { + cli.Init(version, "cli-test") + err = gd.addOptions(cli) + return +} + +func TestOptErrorMissingOptionValue(t *testing.T) { + const missingValueOption = "--page" + var expectedErr = fmt.Errorf(`invalid value scan1.pdf (string) for option "page" (num-array)`) + var cli CliParser + var gd GlobalData + + if err := initCliMissingOptionValue(&cli, &gd); err == nil { + if err = cli.Parse(commonBadArgs(missingValueOption)); err != nil { + if err.Error() != expectedErr.Error() { + t.Errorf("Invalid error message.\nGot:\n%v\nExpected:\n%v", err, expectedErr) + } + } else { + t.Errorf("Expected error for unknown option %q, but got none", missingValueOption) + } + } else { + t.Error(err) + return + } +} + +func initCliMissingOptionValue(cli *CliParser, gd *GlobalData) (err error) { + cli.Init(version, "cli-test") + err = gd.addOptions(cli) + return +} + +func TestOptErrorInvalidOptionValue(t *testing.T) { + const missingInvalidValueOption = "--page" + var expectedErr = errInvalidOptionValue("page", "scan1.pdf", "num-array") + var cli CliParser + var gd GlobalData + + if err := initCliInvalidOptionValue(&cli, &gd); err == nil { + if err = cli.Parse(commonBadArgs(missingInvalidValueOption)); err != nil { + if err.Error() != expectedErr.Error() { + t.Errorf("Invalid error message.\nGot:\n%v\nExpected:\n%v", err, expectedErr) + } + } else { + t.Errorf("Expected error for unknown option %q, but got none", missingInvalidValueOption) + } + } else { + t.Error(err) + return + } +} + +func initCliInvalidOptionValue(cli *CliParser, gd *GlobalData) (err error) { + cli.Init(version, "cli-test") + err = gd.addOptions(cli) + return +} + +func TestArgErrorMissingRequired(t *testing.T) { + const missingRequiredArg = "--page" + var expectedErr = fmt.Errorf(`missing required arg `) + var cli CliParser + var gd GlobalData + + if err := initCliMissingRequiredArg(&cli, &gd); err == nil { + if err = cli.Parse([]string{"ddt-ocr"}); err != nil { + if err.Error() != expectedErr.Error() { + t.Errorf("Invalid error message.\nGot:\n%v\nExpected:\n%v", err, expectedErr) + } + } else { + t.Errorf("Expected error for unknown option %q, but got none", missingRequiredArg) + } + } else { + t.Error(err) + return + } +} + +func initCliMissingRequiredArg(cli *CliParser, gd *GlobalData) (err error) { + cli.Init(version, "cli-test") + err = gd.addOptions(cli) + return +} + +func TestArgErrorRepeatProperty(t *testing.T) { + var expectedErr = fmt.Errorf(`repeat property already set for arg `) + var cli CliParser + var gd GlobalData + + if err := initCliRepeatArg(&cli, &gd); err != nil { + if err.Error() != expectedErr.Error() { + t.Errorf("Invalid error message.\nGot:\n%v\nExpected:\n%v", err, expectedErr) + } + } else { + t.Errorf("Expected error, but got none") + } +} + +func initCliRepeatArg(cli *CliParser, gd *GlobalData) (err error) { + cli.Init(version, "cli-test") + if err = gd.addOptions(cli); err == nil { + defer func() { + if r := recover(); r != nil { + err = r.(error) + } + }() + // This will raise error because we can't declare two args array + cli.AddStringArrayArg("other", true, nil, "other args") + cli.AddStringArrayArg("other2", true, nil, "other args 2") + } + return +} + +func TestOptHidden(t *testing.T) { + const expectedUsage = `NAME ddt-ocr - cli-test +USAGE: ddt-ocr [] ... [] +where: + Source image files + Output destination file + Optional report file + + -V, --verbose Print verbose output (default: "0") + -o, --print-ocr Print the OCR output to stderr + -t, --trace Enable trace mode for detailed logging + -p, --page(s) ["," ...] Process only the specified pages (comma-separated list) + -c, --config Alternate configuration file + -l, --log(s) ["," ...] Logging options (comma-separated list) + --var(s) ["," ...] Define one or more comma separated variables for the actions context (multiple allowed) + -n, --input-name Input file name when source comes from stdin + -d, --work-dir Work directory + --attempts Attempts for retrying failed operations (default: "1") +` + var cli CliParser + var gd GlobalData + + if err := initCliHiddenOpt(&cli, &gd); err != nil { + t.Error(err) + return + } + usage := cli.Usage() + if usage != expectedUsage { + t.Errorf("Usage output does not match expected text.\nGot:\n%s\nExpected:\n%s", usage, expectedUsage) + } +} + +func initCliHiddenOpt(cli *CliParser, gd *GlobalData) (err error) { + if err = initCli(cli, gd); err == nil { + if ref := cli.GetOption("save-clip"); ref != nil { + ref.SetHidden(true) + } else { + err = fmt.Errorf("option save-clip not found") + } + } + return +} + +func (gd *GlobalData) addOptions(cli *CliParser) (err error) { + // Always recover from panic to return error before adding options + defer func() { + if r := recover(); r != nil { + err = r.(error) + } + }() + + // Define options + cli.AddMultiOpt("verbose", "V", &gd.verbose, 0, "Print verbose output") + cli.AddBoolOpt("print-ocr", "o", &gd.printOcr, "Print the OCR output to stderr") + cli.AddBoolOpt("save-clip", "s", &gd.saveClips, "Save the image clips as PNG files", "save-clips") + cli.AddBoolOpt("trace", "t", &gd.trace, "Enable trace mode for detailed logging") + cli.AddIntArrayOpt("page", "p", &gd.page, gd.page, "Process only the specified pages (comma-separated list)") + cli.AddFileOpt("config", "c", &gd.config, gd.config, "Alternate configuration file") + cli.AddStringArrayOpt("log", "l", &gd.log, gd.log, "Logging options (comma-separated list)") + cli.AddStringMapOpt("var", "", &gd.cliVars, nil, "Define one or more comma separated variables for the actions context (multiple allowed)") + cli.AddStringOpt("input-name", "n", &gd.inputName, "", "Input file name when source comes from stdin") + cli.AddDirOpt("work-dir", "d", &gd.workDir, "", "Work directory") + if ref := cli.AddIntOpt("attempts", "", &gd.attempts, 1, "Attempts for retrying failed operations"); ref != nil { + ref.AddSpecialValue("many", func(manager OptManager, cliValue string, currentValue any) (any, error) { + return 1000, nil + }) + ref.AddSpecialValue("few", func(manager OptManager, cliValue string, currentValue any) (any, error) { + manager.SetOptionValue("page", "1") + return nil, nil + }) + } + + // Define arguments + cli.AddStringArrayArg("image-sources", true, &gd.sources, "Source image files") + cli.AddStringArg("dest", true, &gd.dest, "Output destination file") + cli.AddStringArg("report", false, &gd.report, "Optional report file") + return +} diff --git a/v2/common.go b/v2/common.go new file mode 100644 index 0000000..75a39f6 --- /dev/null +++ b/v2/common.go @@ -0,0 +1,39 @@ +package cli + +import "fmt" + +func errMissingOptionValue(opt string) error { + return fmt.Errorf("option %q requires a value", opt) +} + +func errOptionAlreadyDefined(opt string) error { + return fmt.Errorf("option name %q already used", opt) +} + +func errRepeatArgAlreadyDefined(argName string) error { + return fmt.Errorf("repeat property already set for arg <%s>", argName) +} + +func errMissingRequiredArg(argName string) error { + return fmt.Errorf("missing required arg <%s>", argName) +} + +func errTooFewArguments(numRequired int) error { + return fmt.Errorf("too few arguments: ad least %d required", numRequired) +} + +func errOptionNotFound(name string) error { + return fmt.Errorf("option %q not found", name) +} + +func errInvalidOptionValue(name string, value any, optType string) error { + return fmt.Errorf("invalid value %v (%T) for option %q (%s)", value, value, name, optType) +} + +func errIncompatibleOptions(currentOptName string, incompatibleOptName string) error { + return fmt.Errorf("option %q cannot specified together with option %q", currentOptName, incompatibleOptName) +} + +func errUnknownOption(name string) error { + return fmt.Errorf("unknown option %q", name) +} diff --git a/v2/demo.cast b/v2/demo.cast new file mode 100644 index 0000000..4f67694 --- /dev/null +++ b/v2/demo.cast @@ -0,0 +1,32 @@ +{"version":3,"term":{"cols":124,"rows":29,"type":"xterm-256color","theme":{"fg":"#000000","bg":"#ffffff","palette":"#000000:#990000:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#666666:#e60000:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6"}},"timestamp":1772788104,"env":{"SHELL":"/bin/bash"}} +[0.377778, "o", "\u001b]0;camoroso@minis:~/go/src/git.portale-stac.it/go-pkg/cli\u0007"] +[0.002778, "o", "\u001b]3008;start=d3bcbbeb-42c6-4c51-80ba-f39e06f42083;machineid=526a9f09b4ab47fe82b923c17d37ab65;user=camoroso;hostname=minis.portale-stac.it;bootid=eb177a24-5411-4a8d-8c1f-794a53af4509;pid=00000000000000275634;type=shell;cwd=/home/camoroso/go/src/git.portale-stac.it/go-pkg/cli\u001b\\\u001b[?2004h"] +[0.000004, "o", "\u001b[0m\u001b[32mcamoroso@minis\u001b[0m:\u001b[32m~/go/src/git.portale-stac.it/go-pkg/cli\u001b[0m$ "] +[8.403131, "o", "l"] +[0.169049, "o", "l"] +[0.325703, "o", "\r\n\u001b[?2004l\r"] +[0.009626, "o", "\u001b]3008;start=953815a1-a645-440b-8eff-0f9c11592d0a;machineid=526a9f09b4ab47fe82b923c17d37ab65;user=camoroso;hostname=minis.portale-stac.it;bootid=eb177a24-5411-4a8d-8c1f-794a53af4509;pid=00000000000000275634;type=command;cwd=/home/camoroso/go/src/git.portale-stac.it/go-pkg/cli\u001b\\"] +[0.005808, "o", "totale 124\r\n-rw-r--r--. 1 camoroso camoroso 823 11 dic 07.49 arg-base.go\r\n-rw-r--r--. 1 camoroso camoroso 1309 11 dic 07.49 arg-string-array.go\r\n-rw-r--r--. 1 camoroso camoroso 859 11 dic 07.49 arg-string.go\r\n-rw-r--r--. 1 camoroso camoroso 6397 6 mar 09.30 cli.go\r\n-rw-r--r--. 1 camoroso camoroso 12102 5 mar 22.30 cli_test.go\r\n-rw-r--r--. 1 camoroso camoroso 2241 23 gen 11.20 cli-usage.go\r\n-rw-r--r--. 1 camoroso camoroso 1249 11 dic 07.49 cli-version.go\r\n-rw-r--r--. 1 camoroso camoroso 1163 11 dic 07.49 common.go\r\n-rw-r--r--. 1 camoroso camoroso 1221 6 mar 10.08 demo.cast\r\n-rw-r--r--. 1 camoroso camoroso 534 14 dic 13.06 expected.txt\r\n-rw-r--r--. 1 camoroso camoroso 49 11 dic 07.53 go.mod\r\n-rw-r--r--. 1 camoroso camoroso 535 14 dic 13.06 got.txt\r\n-rw-r--r--. 1 camoroso camoroso 1520 11 dic 07.52 LICENSE\r\n-rw-r--r--. 1 camoroso camoroso 4009 6 mar 09.32 opt-base.go\r\n-rw-r--r--. 1 camoroso camoroso 1893 6 mar 09.32 opt-bool.go\r\n-rw-r--r--. 1 camoroso camoroso 525 5 mar 22.30 opt-help.go\r\n-rw-r--r--. 1 camoroso camoroso 3036 6 mar 09.32 opt-int-array.go\r\n-rw-r--r--. 1 camoroso camoroso 1977 6 mar 09.32 opt-int.go\r\n-rw-r--r--. 1 camoroso camoroso 238 5 mar 22.30 opt-manager.go\r\n-rw-r--r--. 1 camoroso camoroso 1650 6 mar 09.32 opt-multi.go\r\n-rw-r--r--. 1 camoroso camoroso 2597 6 mar 09.33 opt-string-array.go\r\n-rw-r--r--. 1 camoroso camoroso 3509 6 mar 09.33 opt-string.go\r\n-rw-r--r--. 1 camoroso camoroso 2761 6 mar 09.33 opt-string-map.go\r\n-rw-r--r--. 1 camoroso camoroso 2938 6 mar 09.31 opt_test.go\r\n-rw-r--r--. 1 camoroso camoroso 681 5 mar 22.30 opt-version.go\r\n-rw-r--r--. 1 camoroso camoroso 2748 6 mar 06.40 parser.go\r\n-rw-r--r--. 1 camoroso camoroso 3179 14 dic 05.49 README.md\r\n-rw-r--r--. 1 camoroso camoroso 357 11 dic 07.49 simple-opt-tracer.go\r\n"] +[0.000679, "o", "\u001b]0;camoroso@minis:~/go/src/git.portale-stac.it/go-pkg/cli\u0007\u001b]3008;end=953815a1-a645-440b-8eff-0f9c11592d0a;exit=success\u001b\\"] +[0.005912, "o", "\u001b]3008;start=d3bcbbeb-42c6-4c51-80ba-f39e06f42083;machineid=526a9f09b4ab47fe82b923c17d37ab65;user=camoroso;hostname=minis.portale-stac.it;bootid=eb177a24-5411-4a8d-8c1f-794a53af4509;pid=00000000000000275634;type=shell;cwd=/home/camoroso/go/src/git.portale-stac.it/go-pkg/cli\u001b\\"] +[0.000155, "o", "\u001b[?2004h\u001b[0m\u001b[32mcamoroso@minis\u001b[0m:\u001b[32m~/go/src/git.portale-stac.it/go-pkg/cli\u001b[0m$ "] +[5.384265, "o", "vi "] +[3.604471, "o", "R"] +[0.476469, "o", "E"] +[0.52018, "o", "ADME.md "] +[1.009636, "o", "\r\n\u001b[?2004l\r"] +[0.021129, "o", "\u001b]3008;start=0ab19f1b-fb87-4b0b-9e3d-56eaf214f0c7;machineid=526a9f09b4ab47fe82b923c17d37ab65;user=camoroso;hostname=minis.portale-stac.it;bootid=eb177a24-5411-4a8d-8c1f-794a53af4509;pid=00000000000000275634;type=command;cwd=/home/camoroso/go/src/git.portale-stac.it/go-pkg/cli\u001b\\"] +[0.066055, "o", "\u001b[?1049h\u001b[22;0;0t\u001b[>4;2m\u001b[?1h\u001b=\u001b[?2004h\u001b[?1004h\u001b[1;29r\u001b[?12h\u001b[?12l\u001b[22;2t\u001b[22;1t"] +[0.000017, "o", "\u001b[27m\u001b[23m\u001b[29m\u001b[m\u001b[H\u001b[2J\u001b[?25l\u001b[29;1H\"README.md\""] +[0.000161, "o", " 84R, 3179B"] +[0.019593, "o", "\u001b[2;1H▽\u001b[6n\u001b[2;1H \u001b[3;1H\u001bPzz\u001b\\\u001b[0%m\u001b[6n\u001b[3;1H \u001b[1;1H\u001b[>c\u001b]10;?\u0007\u001b]11;?\u0007"] +[0.004625, "o", "\u001b[1;1H\u001b[35m# \u001b[m\u001b[35mgo-pkg/cli\u001b[m\u001b[2;1H\u001b[K\u001b[3;1HLightweight, dependency-free command-line argument and option parsing library for Go.\u001b[3;86H\u001b[K\u001b[5;1H\u001b[35m## \u001b[m\u001b[35mOverview\u001b[m\r\n\r\nThis package provides a small, composable CLI parser with support for:\r\n\u001b[38;5;130m-\u001b[m boolean, string, int, and array/map options\r\n\u001b[38;5;130m-\u001b[m positional arguments (single and repeating)\r\n\u001b[38;5;130m-\u001b[m aliases, short flags, defaults and hidden options\r\n\u001b[38;5;130m-\u001b[m validation for incompatible options and custom \"special\" values\r\n\u001b[38;5;130m-\u001b[m automatic usage and version \u001b[103mprint\u001b[ming\r\n\r\nSee the core parser implementation in [\u001b[35m`\u001b[mparser.go\u001b[35m`\u001b[m](\u001b[31mparser.go\u001b[m) and the high-level API in [\u001b[35m`\u001b[mcli.go\u001b[35m`\u001b[m](\u001b[31mcli.go\u001b[m).\r\n\r\n\u001b[35m## \u001b[m\u001b[35mFeatures\u001b[m\r\n\r\n\u001b[38;5;130m-\u001b[m Option types: [\u001b[35m`\u001b[mAddBoolOpt\u001b[35m`\u001b[m](\u001b[31mopt-bool.go\u001b[m), [\u001b[35m`\u001b[mAddStringOpt\u001b[35m`\u001b[m](\u001b[31mopt-string.go\u001b[m), [\u001b[35m`\u001b[mAddIntOpt\u001b[35m`\u001b[m](\u001b[31mopt-int.go\u001b[m), [\u001b[35m`\u001b[mAddStringArrayOptt\u001b[19;1H\u001b[35m`\u001b[m](\u001b[31mopt-string-array.go\u001b[m), [\u001b[35m`\u001b[mAddIntArrayOpt\u001b[35m`\u001b[m](\u001b[31mopt-int-array.go\u001b[m), [\u001b[35m`\u001b[mAddStringMapOpt\u001b[35m`\u001b[m](\u001b[31mopt-string-map.go\u001b[m)\r\n\u001b[38;5;130m-\u001b[m Positional args: [\u001b[35m`\u001b[mAddStringArg\u001b[35m`\u001b[m](\u001b[31marg-string.go\u001b[m), [\u001b[35m`\u001b[mAddStringArrayArg\u001b[35m`\u001b[m](\u001b[31marg-string-array.go\u001b[m)\r\n\u001b[38;5;130m-\u001b[m Usage & version output: [\u001b[35m`\u001b[mCliParser.Usage\u001b[35m`\u001b[m](\u001b[31mcli-usage.go\u001b[m), [\u001b[35m`\u001b[mCliParser.PrintVersion\u001b[35m`\u001b[m](\u001b[31mcli-version.go\u001b[m)\r\n\u001b[38;5;130m-\u001b[m Option tracing via [\u001b[35m`\u001b[mCliParser.TraceOptions\u001b[35m`\u001b[m](\u001b[31mcli.go\u001b[m) and [\u001b[35m`\u001b[mSimpleOptionTracer\u001b[35m`\u001b[m](\u001b[31msimple-opt-tracer.go\u001b[m)\r\n\u001b[38;5;130m-\u001b[m Programmatic option setting: [\u001b[35m`\u001b[mSetOptionValue\u001b[35m`\u001b[m](\u001b[31mopt-manager.go\u001b[m)\r\n\r\n\u001b[35m## \u001b[m\u001b[35mQuick start\u001b[m\r\n\r\nExample: define options and parse argv\u001b[29;107H1,1\u001b[11CCim\u001b[1;1H\u001b[?25h\u001b[?4m"] +[2.98534, "o", "\u001b[?25l\u001b[29;97H:\u001b[1;1H\u001b[29;1H\u001b[K\u001b[29;1H:\u001b[?25h"] +[0.478525, "o", "q"] +[0.506599, "o", "\r"] +[0.00491, "o", "\u001b[?25l\u001b[?2004l\u001b[>4;m\u001b[23;2t\u001b[23;1t\u001b[29;1H\u001b[K\u001b[29;1H\u001b[?1004l\u001b[?2004l\u001b[?1l\u001b>\u001b[?1049l\u001b[23;0;0t\u001b[?25h\u001b[>4;m"] +[0.004173, "o", "\u001b]0;camoroso@minis:~/go/src/git.portale-stac.it/go-pkg/cli\u0007\u001b]3008;end=0ab19f1b-fb87-4b0b-9e3d-56eaf214f0c7;exit=success\u001b\\"] +[0.01003, "o", "\u001b]3008;start=d3bcbbeb-42c6-4c51-80ba-f39e06f42083;machineid=526a9f09b4ab47fe82b923c17d37ab65;user=camoroso;hostname=minis.portale-stac.it;bootid=eb177a24-5411-4a8d-8c1f-794a53af4509;pid=00000000000000275634;type=shell;cwd=/home/camoroso/go/src/git.portale-stac.it/go-pkg/cli\u001b\\"] +[0.000293, "o", "\u001b[?2004h\u001b[0m\u001b[32mcamoroso@minis\u001b[0m:\u001b[32m~/go/src/git.portale-stac.it/go-pkg/cli\u001b[0m$ "] +[4.057766, "o", "\u001b[?2004l\r\r\nexit\r\n"] +[0.003248, "x", "0"] diff --git a/v2/expected.txt b/v2/expected.txt new file mode 100644 index 0000000..09e46ff --- /dev/null +++ b/v2/expected.txt @@ -0,0 +1,10 @@ + Option: print-ocr, Type: bool, Value: true + Option: save-clip, Type: bool, Value: false + Option: trace, Type: bool, Value: false + Option: page, Type: num-array, Value: [17 18] + Option: config, Type: string, Value: devel-config.yaml + Option: log, Type: string-array, Value: [all] + Option: var, Type: map-string, Value: map[deploy_env:devel] + Option: input-name, Type: string, Value: + Option: help, Type: n/a, Value: + Option: version, Type: n/a, Value: diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..7a41638 --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,3 @@ +module git.portale-stac.it/go-pkg/cli/v2 + +go 1.25.4 diff --git a/v2/got.txt b/v2/got.txt new file mode 100644 index 0000000..4ca7410 --- /dev/null +++ b/v2/got.txt @@ -0,0 +1,10 @@ + Option: print-ocr, Type: bool, Value: true + Option: save-clip, Type: bool, Value: false + Option: trace, Type: bool, Value: false + Option: page, Type: num-array, Value: [17 18] + Option: config, Type: string, Value: devel-config.yaml + Option: log, Type: string-array, Value: [all] + Option: var, Type: map-string, Value: map[deploy_env:devel] + Option: input-name, Type: string, Value: + Option: help, Type: n/a, Value: + Option: version, Type: n/a, Value: diff --git a/v2/opt-base.go b/v2/opt-base.go new file mode 100644 index 0000000..ec86555 --- /dev/null +++ b/v2/opt-base.go @@ -0,0 +1,165 @@ +package cli + +import ( + "fmt" + "slices" + "strings" +) + +type cliOptionBase struct { + name string + shortAlias string + aliases []string + description string + isArray bool + hidden bool + alreadySeen bool + specialValues map[string]SpecialValueFunc + finalCheckFunc FinalCheckFunc + incompatibleWith []string +} + +func (opt *cliOptionBase) getBase() *cliOptionBase { + return opt +} + +func (opt *cliOptionBase) getTargetVar() (any, string) { + return nil, "n/a" +} + +func (opt *cliOptionBase) Is(name string) (result bool) { + if result = opt.name == name; !result && len(opt.aliases) > 0 { + result = slices.Contains(opt.aliases, name) + } + return +} + +func (opt *cliOptionBase) isHidden() bool { + return opt.hidden +} + +func (opt *cliOptionBase) SetHidden(hidden bool) { + opt.hidden = hidden +} + +func (opt *cliOptionBase) OnFinalCheck(checkFunc FinalCheckFunc) { + opt.finalCheckFunc = checkFunc +} + +func (opt *cliOptionBase) AddSpecialValue(cliValue string, specialFunc SpecialValueFunc) { + if opt.specialValues == nil { + opt.specialValues = make(map[string]SpecialValueFunc) + } + opt.specialValues[cliValue] = specialFunc +} + +func (opt *cliOptionBase) getSpecialFunc(cliValue string) (specialFunc SpecialValueFunc, exists bool) { + if len(opt.specialValues) > 0 { + specialFunc, exists = opt.specialValues[cliValue] + } + return +} + +func (opt *cliOptionBase) isSet() bool { + return false +} + +func (opt *cliOptionBase) requiresValue() bool { + return true +} + +func (opt *cliOptionBase) addIncompatibleOption(names ...string) { + opt.incompatibleWith = append(opt.incompatibleWith, names...) +} + +func (opt *cliOptionBase) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, value string, err error) { + err = fmt.Errorf("unhandled option %q", opt.name) + return +} + +func (opt *cliOptionBase) getDefaultValue() string { + return "" +} + +func pluralSuffix(word string) (suffix string) { + if len(word) > 0 { + if strings.HasSuffix(word, "x") || strings.HasSuffix(word, "s") || strings.HasSuffix(word, "ch") { + suffix = "es" + } else { + suffix = "s" + } + } + return +} + +func makePlural(word string) (plural string) { + return word + pluralSuffix(word) +} + +func (opt *cliOptionBase) makeOptTemplate(isArray bool, valueType string) (templ string) { + var suffix, multiValue string + if isArray { + suffix = "(" + pluralSuffix(opt.name) + ")" + multiValue = `["," ...]` + } + if opt.shortAlias != "" { + templ = fmt.Sprintf(`-%s, --%s%s <%s>%s`, opt.shortAlias, opt.name, suffix, valueType, multiValue) + } else { + templ = fmt.Sprintf(`--%s%s <%s>%s`, opt.name, suffix, valueType, multiValue) + } + return +} + +func (opt *cliOptionBase) makeOptSimpleTemplate(valueRequired, multiValue bool, valueType string) (templ string) { + var valueSpec, dots string + + if multiValue { + dots = " ..." + } + if len(valueType) > 0 { + valueSpec = " <" + valueType + ">" + dots + if !valueRequired { + valueSpec = " [" + valueSpec[1:] + "]" + } + } + + if opt.shortAlias != "" { + templ = fmt.Sprintf(`-%s, --%s%s`, opt.shortAlias, opt.name, valueSpec) + } else { + templ = fmt.Sprintf(`--%s%s`, opt.name, valueSpec) + } + return +} + +func (opt *cliOptionBase) getSpecialValue(parser cliParser, value string, targetVar any) (boxedValue any, err error) { + if specialFunc, exists := opt.getSpecialFunc(value); exists { + manager := parser.(OptManager) + boxedValue, err = specialFunc(manager, value, targetVar) + } + return +} + +func (opt *cliOptionBase) fetchOptionValue(parser cliParser, argIndex int, valuePtr *string) (value string, skipNextArg bool, err error) { + if valuePtr != nil { + value = *valuePtr + } else { + if source, optionPresent := parser.getOptionValue(argIndex); optionPresent { + skipNextArg = true + value = source + } else { + err = errMissingOptionValue(opt.name) + } + } + return +} + +func (opt *cliOptionBase) finalCheck() (err error) { + return +} + +func (opt *cliOptionBase) checkValue(value any) (err error) { + if opt.finalCheckFunc != nil { + err = opt.finalCheckFunc(value) + } + return +} diff --git a/v2/opt-bool.go b/v2/opt-bool.go new file mode 100644 index 0000000..cb565ec --- /dev/null +++ b/v2/opt-bool.go @@ -0,0 +1,90 @@ +package cli + +import ( + "fmt" +) + +const ( + boolTypeName = "bool" +) + +type cliOptionBool struct { + cliOptionBase + targetVar *bool +} + +func (opt *cliOptionBool) init() { + if opt.targetVar != nil { + *opt.targetVar = false + } +} + +func (opt *cliOptionBool) getTargetVar() (any, string) { + var value bool + if opt.targetVar != nil { + value = *opt.targetVar + } + return value, boolTypeName +} + +func (opt *cliOptionBool) isSet() bool { + if opt.targetVar != nil { + return *(opt.targetVar) + } + return false +} + +func (opt *cliOptionBool) requiresValue() bool { + return false +} + +func (opt *cliOptionBool) getTemplate() (templ string) { + if opt.shortAlias != "" { + templ = fmt.Sprintf(`-%s, --%s`, opt.shortAlias, opt.name) + } else { + templ = fmt.Sprintf(`--%s`, opt.name) + } + return +} + +func (opt *cliOptionBool) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, optValue string, err error) { + var boxedValue any + optValue = "true" + + if boxedValue, err = opt.getSpecialValue(parser, optValue, opt.targetVar); err == nil { + if opt.targetVar != nil { + if boxedValue != nil { + if val, ok := boxedValue.(bool); ok { + *(opt.targetVar) = val + } else { + err = errInvalidOptionValue(opt.name, boxedValue, "bool") + } + } else { + *(opt.targetVar) = true + } + } + } + return +} + +func (opt *cliOptionBool) finalCheck() (err error) { + currentValue, _ := opt.getTargetVar() + return opt.getBase().checkValue(currentValue) +} + +func (cli *CliParser) AddBoolOpt(name, short string, targetVar *bool, description string, aliases ...string) OptReference { + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + opt := &cliOptionBool{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + }, + targetVar: targetVar, + } + cli.options = append(cli.options, opt) + return opt +} diff --git a/v2/opt-help.go b/v2/opt-help.go new file mode 100644 index 0000000..ab4521a --- /dev/null +++ b/v2/opt-help.go @@ -0,0 +1,30 @@ +package cli + +import ( + "io" +) + +type cliOptionHelp struct { + cliOptionBase +} + +func (opt *cliOptionHelp) init() { +} + +func (opt *cliOptionHelp) requiresValue() bool { + return false +} + +func (opt *cliOptionHelp) getDefaultValue() string { + return "" +} + +func (opt *cliOptionHelp) getTemplate() string { + return opt.makeOptSimpleTemplate(false, false, "") +} + +func (opt *cliOptionHelp) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, value string, err error) { + parser.PrintUsage() + err = io.EOF + return +} diff --git a/v2/opt-int-array.go b/v2/opt-int-array.go new file mode 100644 index 0000000..2507611 --- /dev/null +++ b/v2/opt-int-array.go @@ -0,0 +1,120 @@ +package cli + +import ( + "strconv" + "strings" +) + +const ( + intArrayTypeName = "num-array" +) + +type cliOptionIntArray struct { + cliOptionBase + defaultValue []int + targetVar *[]int +} + +func (opt *cliOptionIntArray) init() { + if opt.targetVar != nil { + *opt.targetVar = opt.defaultValue + } +} + +func (opt *cliOptionIntArray) getTargetVar() (any, string) { + var value []int + if opt.targetVar != nil { + value = *opt.targetVar + } + return value, intArrayTypeName +} + +func (opt *cliOptionIntArray) requiresValue() bool { + return opt.targetVar != nil +} + +func (opt *cliOptionIntArray) getDefaultValue() string { + def := make([]string, len(opt.defaultValue)) + for i, v := range opt.defaultValue { + def[i] = strconv.Itoa(v) + } + return strings.Join(def, ",") +} + +func (opt *cliOptionIntArray) getTemplate() string { + return opt.makeOptTemplate(true, "num") +} + +func parseIntRange(value string) (min int, max int, err error) { + var dashPresent bool + var minStr, maxStr string + minStr, maxStr, dashPresent = strings.Cut(value, "-") + if dashPresent { + if min, err = strconv.Atoi(minStr); err == nil { + if max, err = strconv.Atoi(maxStr); err == nil { + if min > max { + err = errInvalidOptionValue("", value, "invalid range") + } + } + } + } else if min, err = strconv.Atoi(value); err == nil { + max = min + } + return +} + +func (opt *cliOptionIntArray) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, optValue string, err error) { + if optValue, skipNextArg, err = opt.fetchOptionValue(parser, argIndex, valuePtr); err == nil { + var boxedValue any + if boxedValue, err = opt.getSpecialValue(parser, optValue, opt.targetVar); err == nil { + if opt.targetVar != nil { + if boxedValue != nil { + if val, ok := boxedValue.([]int); ok { + *opt.targetVar = val + } else { + err = errInvalidOptionValue(opt.name, boxedValue, "num-array") + } + } else { + if !opt.alreadySeen || (valuePtr != nil && parser.FlagIsSet(ResetOnEqualSign)) { + *opt.targetVar = []int{} + opt.alreadySeen = true + } + for value := range strings.SplitSeq(optValue, ",") { + var minRange, maxRange int + if minRange, maxRange, err = parseIntRange(value); err == nil { + for i := minRange; i <= maxRange; i++ { + *opt.targetVar = append(*opt.targetVar, i) + } + } else { + err = errInvalidOptionValue(opt.name, value, "num-array") + break + } + } + } + } + } + } + return +} + +func (opt *cliOptionIntArray) finalCheck() (err error) { + currentValue, _ := opt.getTargetVar() + return opt.getBase().checkValue(currentValue) +} + +func (cli *CliParser) AddIntArrayOpt(name, short string, targetVar *[]int, defaultValue []int, description string, aliases ...string) OptReference { + aliases = cli.checkAlreadyUsedNames(name, short, aliases) + opt := &cliOptionIntArray{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + isArray: true, + }, + targetVar: targetVar, + defaultValue: defaultValue, + } + cli.options = append(cli.options, opt) + return opt +} diff --git a/v2/opt-int.go b/v2/opt-int.go new file mode 100644 index 0000000..20a6e72 --- /dev/null +++ b/v2/opt-int.go @@ -0,0 +1,81 @@ +package cli + +import "strconv" + +const ( + intTypeName = "num" +) + +type cliOptionInt struct { + cliOptionBase + defaultValue int + targetVar *int +} + +func (opt *cliOptionInt) init() { + if opt.targetVar != nil { + *opt.targetVar = opt.defaultValue + } +} + +func (opt *cliOptionInt) getTargetVar() (any, string) { + var value int + if opt.targetVar != nil { + value = *opt.targetVar + } + return value, intTypeName +} + +func (opt *cliOptionInt) requiresValue() bool { + return opt.targetVar != nil +} + +func (opt *cliOptionInt) getDefaultValue() string { + return strconv.Itoa(opt.defaultValue) +} + +func (opt *cliOptionInt) getTemplate() string { + return opt.makeOptTemplate(false, intTypeName) +} + +func (opt *cliOptionInt) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, optValue string, err error) { + if optValue, skipNextArg, err = opt.fetchOptionValue(parser, argIndex, valuePtr); err == nil { + var boxedValue any + if boxedValue, err = opt.getSpecialValue(parser, optValue, opt.targetVar); err == nil { + if opt.targetVar != nil { + if boxedValue != nil { + if val, ok := boxedValue.(string); ok { + *opt.targetVar, err = strconv.Atoi(val) + } else { + err = errInvalidOptionValue(opt.name, boxedValue, "int") + } + } else { + *opt.targetVar, err = strconv.Atoi(optValue) + } + } + } + } + return +} +func (opt *cliOptionInt) finalCheck() (err error) { + currentValue, _ := opt.getTargetVar() + return opt.getBase().checkValue(currentValue) +} + +func (cli *CliParser) AddIntOpt(name, short string, targetVar *int, defaultValue int, description string, aliases ...string) OptReference { + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + opt := &cliOptionInt{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + }, + targetVar: targetVar, + defaultValue: defaultValue, + } + cli.options = append(cli.options, opt) + return opt +} diff --git a/v2/opt-manager.go b/v2/opt-manager.go new file mode 100644 index 0000000..215ea52 --- /dev/null +++ b/v2/opt-manager.go @@ -0,0 +1,10 @@ +package cli + +func (cli *CliParser) SetOptionValue(name string, value string) (err error) { + if opt := cli.findOptionByArg(name); opt != nil { + _, _, err = opt.parse(cli, -1, &value) + } else { + err = errOptionNotFound(name) + } + return +} diff --git a/v2/opt-multi.go b/v2/opt-multi.go new file mode 100644 index 0000000..fbc2fd2 --- /dev/null +++ b/v2/opt-multi.go @@ -0,0 +1,77 @@ +package cli + +import ( + "fmt" + "strconv" +) + +const ( + multiTypeName = "num" +) + +type cliOptionMulti struct { + cliOptionBase + defaultValue int + targetVar *int +} + +func (opt *cliOptionMulti) init() { + if opt.targetVar != nil { + *opt.targetVar = opt.defaultValue + } +} + +func (opt *cliOptionMulti) getTargetVar() (any, string) { + var value int + if opt.targetVar != nil { + value = *opt.targetVar + } + return value, multiTypeName +} + +func (opt *cliOptionMulti) requiresValue() bool { + return false +} + +func (opt *cliOptionMulti) getDefaultValue() string { + return strconv.Itoa(opt.defaultValue) +} + +func (opt *cliOptionMulti) getTemplate() (templ string) { + if opt.shortAlias != "" { + templ = fmt.Sprintf(`-%s, --%s`, opt.shortAlias, opt.name) + } else { + templ = fmt.Sprintf(`--%s`, opt.name) + } + return +} + +func (opt *cliOptionMulti) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, value string, err error) { + if opt.targetVar != nil { + *opt.targetVar++ + } + value = "true" + return +} +func (opt *cliOptionMulti) finalCheck() (err error) { + currentValue, _ := opt.getTargetVar() + return opt.getBase().checkValue(currentValue) +} + +func (cli *CliParser) AddMultiOpt(name, short string, targetVar *int, defaultValue int, description string, aliases ...string) OptReference { + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + opt := &cliOptionMulti{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + }, + targetVar: targetVar, + defaultValue: defaultValue, + } + cli.options = append(cli.options, opt) + return opt +} diff --git a/v2/opt-string-array.go b/v2/opt-string-array.go new file mode 100644 index 0000000..ed52c56 --- /dev/null +++ b/v2/opt-string-array.go @@ -0,0 +1,91 @@ +package cli + +import ( + "strings" +) + +const ( + stringArrayTypeName = "string-array" +) + +type cliOptionStringArray struct { + cliOptionBase + defaultValue []string + targetVar *[]string +} + +func (opt *cliOptionStringArray) init() { + opt.isArray = true + if opt.targetVar != nil { + *opt.targetVar = opt.defaultValue + } +} + +func (opt *cliOptionStringArray) getTargetVar() (any, string) { + var value []string + if opt.targetVar != nil { + value = *opt.targetVar + } + return value, stringArrayTypeName +} + +func (opt *cliOptionStringArray) requiresValue() bool { + return opt.targetVar != nil +} + +func (opt *cliOptionStringArray) getDefaultValue() string { + return strings.Join(opt.defaultValue, ",") +} + +func (opt *cliOptionStringArray) getTemplate() string { + return opt.makeOptTemplate(true, "string") +} + +// parse retrieves the option value from the parser and updates the target variable. +// It handles comma-separated values and special values if configured. +func (opt *cliOptionStringArray) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, value string, err error) { + if value, skipNextArg, err = opt.fetchOptionValue(parser, argIndex, valuePtr); err == nil { + var boxedValue any + if boxedValue, err = opt.getSpecialValue(parser, value, opt.targetVar); err == nil { + if opt.targetVar != nil { + if boxedValue != nil { + if val, ok := boxedValue.([]string); ok { + *opt.targetVar = val + } else { + err = errInvalidOptionValue(opt.name, boxedValue, "array of string") + } + } else if opt.alreadySeen { + *opt.targetVar = append(*opt.targetVar, strings.Split(value, ",")...) + } else { + *opt.targetVar = strings.Split(value, ",") + } + } + } + } + return +} + +// AddStringArrayOpt adds a new string array option to the CLI parser. +// It takes the name, short alias, target variable, default value, description, and optional aliases. +// It returns a reference to the created option. +func (cli *CliParser) AddStringArrayOpt(name, short string, targetVar *[]string, defaultValue []string, description string, aliases ...string) OptReference { + aliases = cli.checkAlreadyUsedNames(name, short, aliases) + opt := &cliOptionStringArray{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + isArray: true, + }, + targetVar: targetVar, + defaultValue: defaultValue, + } + cli.options = append(cli.options, opt) + return opt +} + +func (opt *cliOptionStringArray) finalCheck() (err error) { + currentValue, _ := opt.getTargetVar() + return opt.getBase().checkValue(currentValue) +} diff --git a/v2/opt-string-map.go b/v2/opt-string-map.go new file mode 100644 index 0000000..b35590e --- /dev/null +++ b/v2/opt-string-map.go @@ -0,0 +1,112 @@ +package cli + +import ( + "fmt" + "strings" +) + +const ( + stringMapTypeName = "map-string" +) + +type cliOptionStringMap struct { + cliOptionBase + defaultValue map[string]string + targetVar *map[string]string +} + +func (opt *cliOptionStringMap) init() { + opt.isArray = true + if opt.targetVar != nil { + *opt.targetVar = opt.defaultValue + } +} + +func (opt *cliOptionStringMap) getTargetVar() (any, string) { + var value map[string]string + if opt.targetVar != nil { + value = *opt.targetVar + } + return value, stringMapTypeName +} + +func (opt *cliOptionStringMap) getBase() *cliOptionBase { + return &opt.cliOptionBase +} + +func MapJoin[T any](m map[string]T, kvSep, itemSep string) string { + var sb strings.Builder + for key, value := range m { + if sb.Len() > 0 { + sb.WriteString(itemSep) + } + fmt.Fprintf(&sb, "%q%s%v", key, kvSep, value) + } + return sb.String() +} + +func (opt *cliOptionStringMap) requiresValue() bool { + return opt.targetVar != nil +} + +func (opt *cliOptionStringMap) getDefaultValue() string { + return MapJoin(opt.defaultValue, ",", "=") +} + +func (opt *cliOptionStringMap) getTemplate() string { + return opt.makeOptTemplate(true, "key=value") +} + +func (opt *cliOptionStringMap) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, value string, err error) { + if value, skipNextArg, err = opt.fetchOptionValue(parser, argIndex, valuePtr); err == nil { + var boxedValue any + if boxedValue, err = opt.getSpecialValue(parser, value, opt.targetVar); err == nil { + if opt.targetVar != nil { + dict := *opt.targetVar + if boxedValue != nil { + if val, ok := boxedValue.(map[string]string); ok { + dict = val + } else { + err = errInvalidOptionValue(opt.name, boxedValue, "map of string") + } + } else { + if dict == nil || !opt.alreadySeen { + dict = make(map[string]string) + } + for value := range strings.SplitSeq(value, ",") { + if k, v, sepExists := strings.Cut(value, "="); sepExists { + dict[k] = v + } else { + dict[k] = "" + } + } + } + *opt.targetVar = dict + } + } + return + } + return +} + +func (opt *cliOptionStringMap) finalCheck() (err error) { + currentValue, _ := opt.getTargetVar() + return opt.getBase().checkValue(currentValue) +} + +func (cli *CliParser) AddStringMapOpt(name, short string, targetVar *map[string]string, defaultValue map[string]string, description string, aliases ...string) OptReference { + aliases = cli.checkAlreadyUsedNames(name, short, aliases) + opt := &cliOptionStringMap{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + isArray: true, + }, + targetVar: targetVar, + defaultValue: defaultValue, + } + cli.options = append(cli.options, opt) + return opt +} diff --git a/v2/opt-string.go b/v2/opt-string.go new file mode 100644 index 0000000..769f416 --- /dev/null +++ b/v2/opt-string.go @@ -0,0 +1,139 @@ +package cli + +const ( + stringTypeName = "string" +) + +type cliOptionString struct { + cliOptionBase + defaultValue string + targetVar *string +} + +func (opt *cliOptionString) init() { + if opt.targetVar != nil { + *opt.targetVar = opt.defaultValue + } +} + +func (opt *cliOptionString) getTargetVar() (any, string) { + var value string + if opt.targetVar != nil { + value = *opt.targetVar + } + return value, stringTypeName +} + +func (opt *cliOptionString) requiresValue() bool { + return opt.targetVar != nil +} + +func (opt *cliOptionString) getDefaultValue() string { + return opt.defaultValue +} + +func (opt *cliOptionString) getTemplate() string { + return opt.makeOptTemplate(false, stringTypeName) +} + +func (opt *cliOptionString) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, value string, err error) { + if value, skipNextArg, err = opt.fetchOptionValue(parser, argIndex, valuePtr); err == nil { + var boxedValue any + if boxedValue, err = opt.getSpecialValue(parser, value, opt.targetVar); err == nil { + if opt.targetVar != nil { + if boxedValue != nil { + if val, ok := boxedValue.(string); ok { + *(opt.targetVar) = val + } else { + err = errInvalidOptionValue(opt.name, boxedValue, "string") + } + } else { + *(opt.targetVar) = value + } + } + } + } + return +} +func (opt *cliOptionString) finalCheck() (err error) { + currentValue, _ := opt.getTargetVar() + return opt.getBase().checkValue(currentValue) +} + +func (cli *CliParser) AddStringOpt(name, short string, targetVar *string, defaultValue string, description string, aliases ...string) OptReference { + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + opt := &cliOptionString{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + }, + targetVar: targetVar, + defaultValue: defaultValue, + } + cli.options = append(cli.options, opt) + return opt +} + +//---------------------- cliOptionDir ---------------------- + +type cliOptionDir struct { + cliOptionString +} + +func (opt *cliOptionDir) getTemplate() string { + return opt.cliOptionBase.makeOptTemplate(false, "dir") +} + +func (cli *CliParser) AddDirOpt(name, short string, targetVar *string, defaultValue string, description string, aliases ...string) OptReference { + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + opt := &cliOptionDir{ + cliOptionString: cliOptionString{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + }, + targetVar: targetVar, + defaultValue: defaultValue, + }, + } + cli.options = append(cli.options, opt) + return opt +} + +//---------------------- cliOptionFile ---------------------- + +type cliOptionFile struct { + cliOptionString +} + +func (opt *cliOptionFile) getTemplate() string { + return opt.cliOptionBase.makeOptTemplate(false, "file") +} + +func (cli *CliParser) AddFileOpt(name, short string, targetVar *string, defaultValue string, description string, aliases ...string) OptReference { + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + opt := &cliOptionFile{ + cliOptionString: cliOptionString{ + cliOptionBase: cliOptionBase{ + name: name, + shortAlias: short, + aliases: aliases, + description: description, + }, + targetVar: targetVar, + defaultValue: defaultValue, + }, + } + cli.options = append(cli.options, opt) + return opt +} diff --git a/v2/opt-version.go b/v2/opt-version.go new file mode 100644 index 0000000..b97c8f3 --- /dev/null +++ b/v2/opt-version.go @@ -0,0 +1,36 @@ +package cli + +import ( + "io" +) + +type cliOptionVersion struct { + cliOptionBase +} + +func (opt *cliOptionVersion) init() { +} + +func (opt *cliOptionVersion) requiresValue() bool { + return false +} + +func (opt *cliOptionVersion) getDefaultValue() string { + return "" +} + +func (opt *cliOptionVersion) getTemplate() string { + return opt.makeOptSimpleTemplate(false, true, "section") +} + +func (opt *cliOptionVersion) parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, value string, err error) { + var args []string + if valuePtr != nil { + args = []string{*valuePtr} + } else { + args = parser.getCliArgs(argIndex+2, -1) + } + parser.PrintVersion(args) + err = io.EOF + return +} diff --git a/v2/opt_test.go b/v2/opt_test.go new file mode 100644 index 0000000..70bfd89 --- /dev/null +++ b/v2/opt_test.go @@ -0,0 +1,143 @@ +package cli + +import ( + "fmt" + "testing" +) + +func TestOneOptWithEqual(t *testing.T) { + var cli CliParser + var color string + // Always recover from panic to return error before adding options + defer func() { + if r := recover(); r != nil { + if err := r.(error); err != nil { + t.Error(err) + } + + } + }() + + // Define options + cli.AddStringOpt("color", "c", &color, "white", "Set color") + + args := []string{ + "TestOneOptWithEqual", + "--color=blue", + } + + cli.Init("1.0.0", "TestOneOptWithEqual description") + if err := cli.Parse(args); err != nil { + t.Error(err) + } else if color != "blue" { + t.Errorf("Expected color blue, got %q", color) + } +} + +func addBoxOption(cli *CliParser, boxPtr *[]int) { + // Define options + optRef := cli.AddIntArrayOpt("box", "b", boxPtr, []int{1, 2, 3, 4}, "Box spec: Left,Width,Top,Height; provide 4 int values") + optRef.OnFinalCheck(func(currentValue any) (err error) { + if array, ok := currentValue.([]int); ok { + if len(array) != 4 { + err = fmt.Errorf("--box option requires exactly 4 items, %d provided", len(array)) + } + } else { + err = fmt.Errorf("wrong datatype for --box option value") + } + return + }) +} + +func testIntArrayOptOk(t *testing.T, n int, args []string, flags ...int16) { + var box []int + var cli CliParser + t.Logf("Arg set n. %d", n) + addBoxOption(&cli, &box) + cli.Init("1.0.0", "TestIntArrayOpt description", flags...) + if err := cli.Parse(args); err != nil { + t.Error(err) + } else if len(box) != 4 { + t.Errorf(`Expected 4 items, got %d`, len(box)) + } +} + +func testIntArrayOptKo(t *testing.T, n int, args []string, msg string, flags ...int16) { + var box []int + var cli CliParser + t.Logf("Arg set n. %d", n) + addBoxOption(&cli, &box) + cli.Init("1.0.0", "TestIntArrayOpt description", flags...) + if err := cli.Parse(args); err == nil { + t.Errorf("Expected error, got nil") + } else if err.Error() != msg { + t.Errorf(`Invalid error: %q; expected: %q`, err.Error(), msg) + } +} + +func TestIntArrayOpt(t *testing.T) { + var args []string + // Always recover from panic to return error before adding options + defer func() { + if r := recover(); r != nil { + if err := r.(error); err != nil { + t.Error(err) + } + + } + }() + + n := 0 + + n++ + args = []string{ + "TestIntArrayOpt", + "--box=100,200,150,450", + } + testIntArrayOptOk(t, n, args) + + n++ + args = []string{ + "TestIntArrayOpt", + "--box=100,200,150", + } + testIntArrayOptKo(t, n, args, "--box option requires exactly 4 items, 3 provided") + + n++ + args = []string{ + "TestIntArrayOpt", + "--box", "100,200,150,450", + } + testIntArrayOptOk(t, n, args) + + n++ + args = []string{ + "TestIntArrayOpt", + "--box", + } + testIntArrayOptKo(t, n, args, `option "box" requires a value`) + + n++ + args = []string{ + "TestIntArrayOpt", + "--box", "100,200,150", + "--box", "450", + } + testIntArrayOptOk(t, n, args) + + n++ + args = []string{ + "TestIntArrayOpt", + "--box", "100,200,150", + "--box", "450,12", + } + testIntArrayOptKo(t, n, args, "--box option requires exactly 4 items, 5 provided") + + n++ + args = []string{ + "TestIntArrayOpt", + "--box", "100,200,150", + "--box=100,200,150,450", + } + testIntArrayOptOk(t, n, args, ResetOnEqualSign) +} diff --git a/v2/parser.go b/v2/parser.go new file mode 100644 index 0000000..a518010 --- /dev/null +++ b/v2/parser.go @@ -0,0 +1,122 @@ +package cli + +import ( + "slices" + "strings" +) + +// Private functions implementation + +func (cli *CliParser) optionExists(name, short string, aliases []string) (exists bool) { + for _, opti := range cli.options { + opt := opti.getBase() + if len(opt.shortAlias) > 0 && opt.shortAlias == short { + exists = true + break + } else if opt.Is(name) { + exists = true + break + } else { + if slices.ContainsFunc(aliases, opt.Is) { + exists = true + break + } + } + } + return +} + +func (cli *CliParser) checkAlreadyUsedNames(name, short string, aliases []string) []string { + if plural := makePlural(name); !slices.Contains(aliases, plural) { + aliases = append(aliases, plural) + } + if cli.optionExists(name, short, aliases) { + panic(errOptionAlreadyDefined(name)) + } + return aliases +} + +func (cli *CliParser) getOptionValue(argIndex int) (value string, present bool) { + if argIndex < len(cli.cliArgs)-2 { + value = cli.cliArgs[1+argIndex+1] + present = true + } + return +} + +func (cli *CliParser) getCliArgs(startIndex, endIndex int) (args []string) { + if endIndex < 0 || endIndex > len(cli.cliArgs) { + endIndex = len(cli.cliArgs) + } + if startIndex < 0 { + startIndex = 0 + } + if startIndex > endIndex { + startIndex = endIndex + } + args = cli.cliArgs[startIndex:endIndex] + return +} + +func (cli *CliParser) findOptionByArg(arg string) (matchingOpt cliOptionParser) { + if strings.HasPrefix(arg, "--") { + for _, opti := range cli.options { + opt := opti.getBase() + if opt.Is(arg[2:]) { + matchingOpt = opti + break + } + } + } else if strings.HasPrefix(arg, "-") { + for _, opti := range cli.options { + opt := opti.getBase() + if opt.shortAlias == arg[1:] { + matchingOpt = opti + break + } + } + } + return +} + +func (cli *CliParser) findOptionByName(optName string) (matchingOpt cliOptionParser) { + for _, opti := range cli.options { + opt := opti.getBase() + if opt.Is(optName) { + matchingOpt = opti + break + } + } + return +} + +func (cli *CliParser) parseArg(arg string, index int) (skipNextArg bool, err error) { + var opts []string + var name, value, dashes string + var equalPresent bool + name, value, equalPresent = strings.Cut(arg, "=") + if strings.HasPrefix(name, "--") { + opts = []string{name[2:]} + dashes = "--" + } else { + opts = strings.Split(name[1:], "") + dashes = "-" + } + for i, optName := range opts { + if opt := cli.findOptionByArg(dashes + optName); opt != nil { + if equalPresent && i == len(opts)-1 { + _, _, err = opt.parse(cli, index, &value) + } else if i < len(opts)-1 && opt.requiresValue() { + err = errMissingOptionValue(dashes + optName) + } else { + skipNextArg, _, err = opt.parse(cli, index, nil) + } + } else { + err = errUnknownOption(dashes + optName) + } + if err != nil { + break + } + } + return +} diff --git a/v2/simple-opt-tracer.go b/v2/simple-opt-tracer.go new file mode 100644 index 0000000..e380c64 --- /dev/null +++ b/v2/simple-opt-tracer.go @@ -0,0 +1,18 @@ +package cli + +import ( + "fmt" + "io" +) + +type SimpleOptionTracer struct { + w io.Writer +} + +func NewSimpleOptionTracer(w io.Writer) *SimpleOptionTracer { + return &SimpleOptionTracer{w: w} +} + +func (tr *SimpleOptionTracer) TraceCliOption(name string, valueType string, value any) { + fmt.Fprintf(tr.w, "Option: %s, Type: %s, Value: %v\n", name, valueType, value) +}