From 38e36839f801b9e91ec74425310105ece7cfcb32 Mon Sep 17 00:00:00 2001 From: Celestino Amoroso Date: Thu, 11 Dec 2025 07:57:48 +0100 Subject: [PATCH] first commit status: option and argument parsing, short aliases grouping, special values, hidden options Option types: bool int, int-array, string, string-array, string-map, file, dir --- LICENSE | 29 ++++++ arg-base.go | 40 ++++++++ arg-string-array.go | 49 ++++++++++ arg-string.go | 36 ++++++++ cli-usage.go | 86 +++++++++++++++++ cli-version.go | 59 ++++++++++++ cli.go | 216 +++++++++++++++++++++++++++++++++++++++++++ cli_test.go | 81 ++++++++++++++++ common.go | 39 ++++++++ go.mod | 3 + opt-base.go | 147 +++++++++++++++++++++++++++++ opt-bool.go | 85 +++++++++++++++++ opt-help.go | 30 ++++++ opt-int-array.go | 111 ++++++++++++++++++++++ opt-int.go | 78 ++++++++++++++++ opt-manager.go | 10 ++ opt-string-array.go | 85 +++++++++++++++++ opt-string-map.go | 108 ++++++++++++++++++++++ opt-string.go | 136 +++++++++++++++++++++++++++ opt-version.go | 36 ++++++++ parser.go | 122 ++++++++++++++++++++++++ simple-opt-tracer.go | 18 ++++ 22 files changed, 1604 insertions(+) create mode 100644 LICENSE create mode 100644 arg-base.go create mode 100644 arg-string-array.go create mode 100644 arg-string.go create mode 100644 cli-usage.go create mode 100644 cli-version.go create mode 100644 cli.go create mode 100644 cli_test.go create mode 100644 common.go create mode 100644 go.mod create mode 100644 opt-base.go create mode 100644 opt-bool.go create mode 100644 opt-help.go create mode 100644 opt-int-array.go create mode 100644 opt-int.go create mode 100644 opt-manager.go create mode 100644 opt-string-array.go create mode 100644 opt-string-map.go create mode 100644 opt-string.go create mode 100644 opt-version.go create mode 100644 parser.go create mode 100644 simple-opt-tracer.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4cc617e --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2024 Celestino Amoroso (celestino.amoroso@gmail.com). +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Celestino Amoroso, nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/arg-base.go b/arg-base.go new file mode 100644 index 0000000..1b344f5 --- /dev/null +++ b/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/arg-string-array.go b/arg-string-array.go new file mode 100644 index 0000000..f3469d5 --- /dev/null +++ b/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/arg-string.go b/arg-string.go new file mode 100644 index 0000000..079b583 --- /dev/null +++ b/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/cli-usage.go b/cli-usage.go new file mode 100644 index 0000000..606da6c --- /dev/null +++ b/cli-usage.go @@ -0,0 +1,86 @@ +package cli + +import ( + "fmt" + "os" + "strings" +) + +// Usage() +func (cli *CliParser) Usage() string { + var sb strings.Builder + + program, _ := cli.GetVersionSection("program") + 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/cli-version.go b/cli-version.go new file mode 100644 index 0000000..fadbee5 --- /dev/null +++ b/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/cli.go b/cli.go new file mode 100644 index 0000000..8cad3ba --- /dev/null +++ b/cli.go @@ -0,0 +1,216 @@ +package cli + +import ( + "fmt" +) + +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() +} + +type cliOptionParser interface { + parse(parser cliParser, argIndex int, valuePtr *string) (skipNextArg bool, err error) + getTemplate() string + getDefaultValue() string + getBase() *cliOptionBase + init() + isSet() bool + isHidden() bool + requiresValue() bool +} + +type OptManager interface { + SetOptionValue(name string, value string) (err error) +} + +type SpecialValueFunc func(manager OptManager, cliValue string, currentValue any) (value any, err error) + +type OptReference interface { + AddSpecialValue(cliValue string, specialFunc SpecialValueFunc) + SetHidden(hidden bool) +} + +type CliParser struct { + version string + options []cliOptionParser + argSpecs []argSpec + cliArgs []string +} + +func (cli *CliParser) Init(argv []string, version string) { + cli.version = version + cli.cliArgs = argv +} + +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) Parse() (err error) { + var arg string + var i int + var args []string + var optionsAllowed bool = true + + cli.addHelpAndVersion() + + 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 { + args = append(args, arg) + } + } + } + if err == nil { + var argSpec argSpec + var n, specIndex int + i = 0 + + for specIndex, argSpec = range cli.argSpecs { + if n, err = argSpec.parse(cli, specIndex, args, i); err != nil { + break + } + i += n + if i >= len(args) { + break + } + } + if err == nil { + if i < len(args) { + err = fmt.Errorf("too many arguments: %d allowed", i) + } else { + specIndex++ + for _, spec := range cli.argSpecs[specIndex:] { + if !spec.getBase().required { + specIndex++ + } + } + if specIndex < len(cli.argSpecs) { + err = errTooFewArguments(len(cli.argSpecs)) + } + } + } + } + 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/cli_test.go b/cli_test.go new file mode 100644 index 0000000..cbb5a32 --- /dev/null +++ b/cli_test.go @@ -0,0 +1,81 @@ +package cli + +import ( + "fmt" + "strings" + "testing" +) + +type GlobaData struct { + config string + log []string + printOcr bool + saveClips bool + trace bool + page []int + sources []string + dest string + facoltativo string +} + +func (gd *GlobaData) String() string { + var sb strings.Builder + fmt.Fprintf(&sb, "Options:\n") + fmt.Fprintf(&sb, " Config.....: %q\n", gd.config) + fmt.Fprintf(&sb, " Log........: %v\n", gd.log) + fmt.Fprintf(&sb, " Print-Ocr..: %v\n", gd.printOcr) + fmt.Fprintf(&sb, " Save-Clips.: %v\n", gd.saveClips) + fmt.Fprintf(&sb, " Page.......: %v\n", gd.page) + fmt.Fprintf(&sb, " Trace......: %v\n", gd.trace) + fmt.Fprintf(&sb, "Argumentes:\n") + fmt.Fprintf(&sb, " Sources....: %s\n", strings.Join(gd.sources, ", ")) + fmt.Fprintf(&sb, " Destination: %s\n", gd.dest) + fmt.Fprintf(&sb, " Facoltativo: %s\n", gd.facoltativo) + return sb.String() +} + +func TestUsage(t *testing.T) { + var cli CliParser + var version = `$VER:mini-ddt-ocr,1.0.1,2025-11-23,celestino.amoroso@gmail.com:$` + var gd GlobaData + args := []string{ + "mini-ddt-ocr", + "--log", "all", + "--config", "devel-config.yaml", + // "--var", "deploy_env=devel", + "--print-ocr", + "--pages", "17,18", + "../../dev-stuff/ocis-upload.log", "ciccio", "bello", "pippo", + } + cli.Init(args, version) + if err := gd.addOptions(&cli); err != nil { + t.Error(err) + return + } + usage := cli.Usage() + fmt.Println(usage) + if err := cli.Parse(); err == nil { + fmt.Println(&gd) + } else { + t.Error(err) + } +} + +func (gd *GlobaData) addOptions(cli *CliParser) (err error) { + defer func() { + if r := recover(); r != nil { + err = r.(error) + } + }() + cli.AddBoolOpt("print-ocr", "o", &gd.printOcr, "Stampa l'output del programma OCR") + cli.AddBoolOpt("save-clip", "s", &gd.saveClips, "Registra le immagini delle aree ritagliata", "save-clips") + cli.AddBoolOpt("trace", "t", &gd.trace, "Attiva la modalità di tracciamento delle operazioni") + cli.AddIntArrayOpt("page", "p", &gd.page, gd.page, "Elabora la pagina specificata") + cli.AddStringOpt("config", "c", &gd.config, gd.config, "Specifica un percorso alternativo per il file di configurazione") + cli.AddStringArrayOpt("log", "l", &gd.log, gd.log, "Maschera di livelli di log") + + cli.AddStringArrayArg("sorgenti", true, &gd.sources, "file da elaborare") + cli.AddStringArg("dest", true, &gd.dest, "file di outout") + cli.AddStringArg("facoltativo", false, &gd.facoltativo, "file facoltativo") + return +} diff --git a/common.go b/common.go new file mode 100644 index 0000000..75a39f6 --- /dev/null +++ b/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/go.mod b/go.mod new file mode 100644 index 0000000..a3b056c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.portale-stac.it/go-pkg/cli + +go 1.25.4 diff --git a/opt-base.go b/opt-base.go new file mode 100644 index 0000000..1e41a6a --- /dev/null +++ b/opt-base.go @@ -0,0 +1,147 @@ +package cli + +import ( + "fmt" + "slices" + "strings" +) + +type cliOptionBase struct { + name string + shortAlias string + aliases []string + description string + isArray bool + hidden bool + specialValues map[string]SpecialValueFunc + 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) 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, valuePtr *string) (err error) { + return fmt.Errorf("unhandled option %q", opt.name) +} + +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 +} diff --git a/opt-bool.go b/opt-bool.go new file mode 100644 index 0000000..bdb4f18 --- /dev/null +++ b/opt-bool.go @@ -0,0 +1,85 @@ +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, err error) { + var boxedValue any + value := "true" + + if boxedValue, err = opt.getSpecialValue(parser, value, 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 (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/opt-help.go b/opt-help.go new file mode 100644 index 0000000..0873b35 --- /dev/null +++ b/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, err error) { + parser.PrintUsage() + err = io.EOF + return +} diff --git a/opt-int-array.go b/opt-int-array.go new file mode 100644 index 0000000..1cdc9df --- /dev/null +++ b/opt-int-array.go @@ -0,0 +1,111 @@ +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, err error) { + var optValue string + 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, "array of int") + } + } else { + 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 { + break + } + } + } + } + } + } + return +} + +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/opt-int.go b/opt-int.go new file mode 100644 index 0000000..2cca9da --- /dev/null +++ b/opt-int.go @@ -0,0 +1,78 @@ +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, err error) { + var value string + 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, err = strconv.Atoi(val) + } else { + err = errInvalidOptionValue(opt.name, boxedValue, "int") + } + } else { + *opt.targetVar, err = strconv.Atoi(value) + } + } + } + } + return +} + +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/opt-manager.go b/opt-manager.go new file mode 100644 index 0000000..960d773 --- /dev/null +++ b/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/opt-string-array.go b/opt-string-array.go new file mode 100644 index 0000000..520a55c --- /dev/null +++ b/opt-string-array.go @@ -0,0 +1,85 @@ +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, err error) { + var value string + 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 { + *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 +} diff --git a/opt-string-map.go b/opt-string-map.go new file mode 100644 index 0000000..6756d73 --- /dev/null +++ b/opt-string-map.go @@ -0,0 +1,108 @@ +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, err error) { + var value string + 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 { + 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 (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/opt-string.go b/opt-string.go new file mode 100644 index 0000000..90bf7f0 --- /dev/null +++ b/opt-string.go @@ -0,0 +1,136 @@ +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, err error) { + var value string + 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 (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/opt-version.go b/opt-version.go new file mode 100644 index 0000000..4e671ab --- /dev/null +++ b/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, 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/parser.go b/parser.go new file mode 100644 index 0000000..f69548f --- /dev/null +++ b/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/simple-opt-tracer.go b/simple-opt-tracer.go new file mode 100644 index 0000000..e380c64 --- /dev/null +++ b/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) +}