Compare commits

..

No commits in common. "f830851e588c682ea83292cfd32dccd00f175452" and "38e36839f801b9e91ec74425310105ece7cfcb32" have entirely different histories.

5 changed files with 52 additions and 454 deletions

View File

@ -1,84 +0,0 @@
# 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).
## 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.

View File

@ -11,12 +11,6 @@ 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 [<options>] %s\n", program, cli.getArgsTemplate())

27
cli.go
View File

@ -2,7 +2,6 @@ package cli
import (
"fmt"
"strings"
)
type CliOptionTracer interface {
@ -39,33 +38,17 @@ type OptReference interface {
}
type CliParser struct {
description string
version string
options []cliOptionParser
argSpecs []argSpec
cliArgs []string
version string
options []cliOptionParser
argSpecs []argSpec
cliArgs []string
}
func (cli *CliParser) Init(argv []string, version string, description string) {
func (cli *CliParser) Init(argv []string, version string) {
cli.version = version
cli.description = description
cli.cliArgs = argv
}
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 {

View File

@ -6,370 +6,76 @@ import (
"testing"
)
const version = `$VER:ddt-ocr,1.0.1,2025-11-23,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
type GlobaData struct {
config string
log []string
printOcr bool
saveClips bool
trace bool
page []int
sources []string
dest string
facoltativo string
}
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 (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) {
const expectedUsage = `NAME ddt-ocr - cli-test
USAGE: ddt-ocr [<options>] <image-sources> ... <dest> [<report>]
where:
<image-sources> Source image files
<dest> Output destination file
<report> Optional report file
<options>
-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) <num>["," ...] Process only the specified pages (comma-separated list)
-c, --config <file> Alternate configuration file
-l, --log(s) <string>["," ...] Logging options (comma-separated list)
-V, --var(s) <key=value>["," ...] Define one or more comma separated variables for the actions context (multiple allowed)
-n, --input-name <string> Input file name when source comes from stdin
-d, --work-dir <dir> Work directory
--attempts <num> 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: 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: <nil>
Option: version, Type: n/a, Value: <nil>
`
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(); err == nil {
cli.TraceOptions(tracer)
if sb.String() != expectedOutput {
t.Errorf("Parsed options do not match expected.\nGot:\n%q\nExpected:\n%q", sb.String(), expectedOutput)
}
} else {
t.Error(err)
}
}
func initCli(cli *CliParser, gd *GlobalData) (err error) {
var version = `$VER:mini-ddt-ocr,1.0.1,2025-11-23,celestino.amoroso@gmail.com:$`
var gd GlobaData
args := []string{
"ddt-ocr",
"mini-ddt-ocr",
"--log", "all",
"-t",
"--config", "devel-config.yaml",
"--var", "deploy_env=devel",
// "--var", "deploy_env=devel",
"--print-ocr",
"--pages", "17,18",
"--input-name=my-scan.pdf",
"scan1.pdf", "scan2.pdf", "result.txt", "report.txt",
"../../dev-stuff/ocis-upload.log", "ciccio", "bello", "pippo",
}
cli.Init(args, 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, unknownOption); err == nil {
if err = cli.Parse(); 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 initCliUnknownOption(cli *CliParser, gd *GlobalData, option string) (err error) {
args := []string{
"ddt-ocr",
option,
"scan1.pdf", "scan2.pdf", "result.txt", "report.txt",
}
cli.Init(args, 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, missingValueOption); err == nil {
if err = cli.Parse(); 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, option string) (err error) {
args := []string{
"ddt-ocr",
option,
"scan1.pdf", "scan2.pdf", "result.txt", "report.txt",
}
cli.Init(args, version, "cli-test")
err = gd.addOptions(cli)
return
}
func TestOptErrorInvalidOptionValue(t *testing.T) {
const missingInvalidValueOption = "--page"
var expectedErr = errInvalidOptionValue("page", "some", "num-array")
var cli CliParser
var gd GlobalData
if err := initCliInvalidOptionValue(&cli, &gd, missingInvalidValueOption); err == nil {
if err = cli.Parse(); 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, option string) (err error) {
args := []string{
"ddt-ocr",
option, "some",
"scan1.pdf", "scan2.pdf", "result.txt", "report.txt",
}
cli.Init(args, version, "cli-test")
err = gd.addOptions(cli)
return
}
func TestArgErrorMissingRequired(t *testing.T) {
const missingRequiredArg = "--page"
var expectedErr = fmt.Errorf(`missing required arg <image-sources>`)
var cli CliParser
var gd GlobalData
if err := initCliMissingRequiredArg(&cli, &gd); err == nil {
if err = cli.Parse(); 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) {
args := []string{
"ddt-ocr",
}
cli.Init(args, version, "cli-test")
err = gd.addOptions(cli)
return
}
func TestArgErrorReapet(t *testing.T) {
var expectedErr = fmt.Errorf(`repeat property already set for arg <other>`)
var cli CliParser
var gd GlobalData
if err := initCliReapetArg(&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 initCliReapetArg(cli *CliParser, gd *GlobalData) (err error) {
args := []string{
"ddt-ocr",
"scan1.pdf", "scan2.pdf", "result.txt", "report.txt",
}
cli.Init(args, version, "cli-test")
if err = gd.addOptions(cli); err == nil {
defer func() {
if r := recover(); r != nil {
err = r.(error)
}
}()
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 [<options>] <image-sources> ... <dest> [<report>]
where:
<image-sources> Source image files
<dest> Output destination file
<report> Optional report file
<options>
-o, --print-ocr Print the OCR output to stderr
-t, --trace Enable trace mode for detailed logging
-p, --page(s) <num>["," ...] Process only the specified pages (comma-separated list)
-c, --config <file> Alternate configuration file
-l, --log(s) <string>["," ...] Logging options (comma-separated list)
-V, --var(s) <key=value>["," ...] Define one or more comma separated variables for the actions context (multiple allowed)
-n, --input-name <string> Input file name when source comes from stdin
-d, --work-dir <dir> Work directory
--attempts <num> Attempts for retrying failed operations (default: "1")
`
var cli CliParser
var gd GlobalData
if err := initCliHiddenOpt(&cli, &gd); err != nil {
cli.Init(args, version)
if err := gd.addOptions(&cli); 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)
fmt.Println(usage)
if err := cli.Parse(); err == nil {
fmt.Println(&gd)
} else {
t.Error(err)
}
}
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
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")
// Define options
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", "V", &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")
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
}

View File

@ -73,7 +73,7 @@ func (opt *cliOptionIntArray) parse(parser cliParser, argIndex int, valuePtr *st
if val, ok := boxedValue.([]int); ok {
*opt.targetVar = val
} else {
err = errInvalidOptionValue(opt.name, boxedValue, "num-array")
err = errInvalidOptionValue(opt.name, boxedValue, "array of int")
}
} else {
for value := range strings.SplitSeq(optValue, ",") {
@ -83,7 +83,6 @@ func (opt *cliOptionIntArray) parse(parser cliParser, argIndex int, valuePtr *st
*opt.targetVar = append(*opt.targetVar, i)
}
} else {
err = errInvalidOptionValue(opt.name, value, "num-array")
break
}
}