feat: parse all Go CLIs with flaggy; dump bash complete.
stdlib flag dropped --hop after the query. One wrapper in internal/cli, source <(./bin/cli/complete.go bash). Gitea #34.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// Package cli is the shared flaggy wrapper (D23).
|
||||
//
|
||||
// flaggy: zero deps, flags at any position, shell completion scripts.
|
||||
// Individual tools keep ShowCompletion off so a query like "completion" is
|
||||
// not stolen; dump scripts with bin/cli/complete.go.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/integrii/flaggy"
|
||||
)
|
||||
|
||||
// ErrHelp means -h/--help was requested (exit 0).
|
||||
var ErrHelp = errors.New("help")
|
||||
|
||||
var parseMu sync.Mutex
|
||||
|
||||
// New returns a per-call parser. Never reuse: flaggy parses once.
|
||||
func New(name string) *flaggy.Parser {
|
||||
p := flaggy.NewParser(name)
|
||||
p.ShowVersionWithVersionFlag = false
|
||||
p.ShowCompletion = false
|
||||
// Extra positionals become TrailingArguments (search "two words --json").
|
||||
// Unknown dash tokens are rejected in Parse after flaggy returns.
|
||||
p.ShowHelpOnUnexpected = false
|
||||
p.ShowHelpWithHFlag = true
|
||||
return p
|
||||
}
|
||||
|
||||
// Parse runs p.ParseArgs and turns flaggy's os.Exit into an error.
|
||||
// Not safe to call in parallel (flaggy.PanicInsteadOfExit is process-global).
|
||||
func Parse(p *flaggy.Parser, args []string) error {
|
||||
parseMu.Lock()
|
||||
defer parseMu.Unlock()
|
||||
prev := flaggy.PanicInsteadOfExit
|
||||
flaggy.PanicInsteadOfExit = true
|
||||
defer func() { flaggy.PanicInsteadOfExit = prev }()
|
||||
|
||||
var exitMsg string
|
||||
err := func() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
exitMsg = fmt.Sprint(r)
|
||||
}
|
||||
}()
|
||||
return p.ParseArgs(args)
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exitMsg != "" {
|
||||
if strings.Contains(exitMsg, "code: 0") {
|
||||
return ErrHelp
|
||||
}
|
||||
return errors.New(exitMsg)
|
||||
}
|
||||
if u := unknownFlags(p, args); len(u) > 0 {
|
||||
return fmt.Errorf("unknown flag %q", u[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unknownFlags(p *flaggy.Parser, args []string) []string {
|
||||
flags := collectFlags(&p.Subcommand)
|
||||
var out []string
|
||||
skipNext := false
|
||||
for _, a := range args {
|
||||
if skipNext {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if a == "--" {
|
||||
break
|
||||
}
|
||||
name, inline := flagName(a)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if name == "h" || name == "help" {
|
||||
continue
|
||||
}
|
||||
f := findFlag(flags, name)
|
||||
if f == nil {
|
||||
out = append(out, a)
|
||||
continue
|
||||
}
|
||||
if !inline && !isBoolFlag(f) {
|
||||
skipNext = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flagName(a string) (name string, inline bool) {
|
||||
if a == "-" || !strings.HasPrefix(a, "-") {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimLeft(a, "-")
|
||||
name, _, inline = strings.Cut(rest, "=")
|
||||
return name, inline
|
||||
}
|
||||
|
||||
func collectFlags(sc *flaggy.Subcommand) []*flaggy.Flag {
|
||||
out := append([]*flaggy.Flag{}, sc.Flags...)
|
||||
for _, sub := range sc.Subcommands {
|
||||
out = append(out, collectFlags(sub)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findFlag(flags []*flaggy.Flag, name string) *flaggy.Flag {
|
||||
for _, f := range flags {
|
||||
if f.HasName(name) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBoolFlag(f *flaggy.Flag) bool {
|
||||
_, ok := f.AssignmentVar.(*bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Query joins the first positional with leftover trailing words.
|
||||
func Query(first string, trailing []string) string {
|
||||
parts := make([]string, 0, 1+len(trailing))
|
||||
if s := strings.TrimSpace(first); s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
for _, t := range trailing {
|
||||
if s := strings.TrimSpace(t); s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// Code maps parse errors to process exit codes (0 help, 2 usage).
|
||||
func Code(err error) int {
|
||||
if err == nil || errors.Is(err, ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// Fail prints err unless it is help or a flaggy exit that already wrote stderr.
|
||||
func Fail(err error) int {
|
||||
if err == nil || errors.Is(err, ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "Panic instead of exit") {
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
|
||||
// Tool is one shebang CLI for completion dump.
|
||||
type Tool struct {
|
||||
Path string
|
||||
Name string
|
||||
New func() *flaggy.Parser
|
||||
}
|
||||
|
||||
// BashScript concatenates flaggy bash complete scripts and binds each
|
||||
// function to the shebang path (./bin/subject/method.go).
|
||||
func BashScript(tools []Tool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# 2dph flaggy completions (D23). source <(./bin/cli/complete.go bash)\n")
|
||||
for _, t := range tools {
|
||||
p := t.New()
|
||||
p.Name = t.Name
|
||||
script := flaggy.GenerateBashCompletion(p)
|
||||
b.WriteString(script)
|
||||
fn := "_" + strings.ReplaceAll(t.Name, "-", "_") + "_complete"
|
||||
if t.Path != "" && t.Path != t.Name {
|
||||
fmt.Fprintf(&b, "complete -F %s %s\n", fn, t.Path)
|
||||
if !strings.HasPrefix(t.Path, "./") {
|
||||
fmt.Fprintf(&b, "complete -F %s ./%s\n", fn, t.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/integrii/flaggy"
|
||||
)
|
||||
|
||||
func TestParseBoolAndIntAnyPosition(t *testing.T) {
|
||||
p := New("t")
|
||||
jsonOut := false
|
||||
n := 20
|
||||
q := ""
|
||||
p.Bool(&jsonOut, "", "json", "JSON")
|
||||
p.Int(&n, "n", "n", "limit")
|
||||
p.AddPositionalValue(&q, "query", 1, false, "q")
|
||||
if err := Parse(p, []string{"two", "words", "--json", "-n", "5"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := Query(q, p.TrailingArguments)
|
||||
if got != "two words" || !jsonOut || n != 5 {
|
||||
t.Fatalf("q=%q json=%v n=%d", got, jsonOut, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnknownFlagIsError(t *testing.T) {
|
||||
p := New("t")
|
||||
jsonOut := false
|
||||
p.Bool(&jsonOut, "", "json", "JSON")
|
||||
if err := Parse(p, []string{"--nope"}); err == nil {
|
||||
t.Fatal("unknown flag accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHelpIsErrHelp(t *testing.T) {
|
||||
p := New("t")
|
||||
jsonOut := false
|
||||
p.Bool(&jsonOut, "", "json", "JSON")
|
||||
err := Parse(p, []string{"--help"})
|
||||
if !errors.Is(err, ErrHelp) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingFlagValueIsError(t *testing.T) {
|
||||
p := New("t")
|
||||
n := 0
|
||||
p.Int(&n, "", "hop", "hop")
|
||||
if err := Parse(p, []string{"--hop"}); err == nil {
|
||||
t.Fatal("expected missing value error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashScriptNamesShebangPath(t *testing.T) {
|
||||
out := BashScript([]Tool{{
|
||||
Path: "bin/brain/search.go",
|
||||
Name: "brain-search",
|
||||
New: newSearchLike,
|
||||
}})
|
||||
if !strings.Contains(out, "--json") || !strings.Contains(out, "--hop") {
|
||||
t.Fatalf("flags missing:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "complete -F") || !strings.Contains(out, "bin/brain/search.go") {
|
||||
t.Fatalf("shebang complete missing:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func newSearchLike() *flaggy.Parser {
|
||||
p := New("brain-search")
|
||||
jsonOut := false
|
||||
hop := 0
|
||||
p.Bool(&jsonOut, "", "json", "JSON")
|
||||
p.Int(&hop, "", "hop", "graph hop")
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cli
|
||||
|
||||
import "github.com/integrii/flaggy"
|
||||
|
||||
type QAStats struct {
|
||||
JSONL string
|
||||
}
|
||||
|
||||
func QAParser() *flaggy.Parser {
|
||||
c := QAStats{}
|
||||
return BindQA(&c)
|
||||
}
|
||||
|
||||
func BindQA(c *QAStats) *flaggy.Parser {
|
||||
p := New("qa-stats")
|
||||
p.Description = "DuckDB quantiles / JSONL count"
|
||||
p.String(&c.JSONL, "", "jsonl", "JSONL file (else stdin JSON [float,…])")
|
||||
return p
|
||||
}
|
||||
|
||||
func ParseQAStats(args []string) (QAStats, error) {
|
||||
var c QAStats
|
||||
return c, Parse(BindQA(&c), args)
|
||||
}
|
||||
Reference in New Issue
Block a user