Add Cobra-backed command parsing

This commit is contained in:
2026-10-07 13:27:00 +00:00
parent 82128463cd
commit f206c531f1
7 changed files with 500 additions and 4 deletions

260
internal/cobra/parse.go Normal file
View File

@@ -0,0 +1,260 @@
// Package cobra adapts CommandKit's neutral command declarations to Cobra and
// pflag. Backend types and failures remain inside this package.
package cobra
import (
"context"
"fmt"
"io"
"strconv"
"strings"
spfcobra "github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Command describes one node in a per-parse command tree.
type Command struct {
Name string
Summary string
Description string
Runnable bool
Flags []Flag
Children []*Command
}
// Flag describes one local option without exposing a pflag value.
type Flag struct {
Name string
Short string
Usage string
Default string
Boolean bool
Repeated bool
Required bool
}
// Failure is a backend-owned parse failure translated to neutral message data.
type Failure struct {
Message string
}
// Result contains the selected command and raw input collected by pflag.
type Result struct {
Path []string
HelpRequested bool
FlagValues [][]string
Operands []string
Failure *Failure
}
// ParseOptions makes the parser's context and streams explicit. Nil writers
// are replaced with io.Discard.
type ParseOptions struct {
Context context.Context
In io.Reader
Out io.Writer
Err io.Writer
}
// Parse constructs a fresh Cobra tree and parses one argv. It routes only the
// leading command words, stopping permanently at the first option or --.
func Parse(root *Command, argv []string, options ParseOptions) Result {
result := Result{}
if root == nil {
return failure(result, "command tree is nil")
}
options = normalizeOptions(options)
backendRoot, nodes := buildTree(root, options)
leading, tail := splitRoutingPrefix(argv)
selected, unconsumed, err := backendRoot.Find(leading)
if err != nil {
return failure(result, err.Error())
}
if selected == nil {
return failure(result, "no command was selected")
}
selectedNode := declarationFor(nodes, selected)
if selectedNode == nil {
return failure(result, "selected command is not part of the declared tree")
}
result.Path = pathFor(root, selectedNode)
remaining := append(append([]string(nil), unconsumed...), tail...)
if err := selected.ParseFlags(remaining); err != nil {
return failure(result, err.Error())
}
operands := selected.Flags().Args()
result.Operands = append([]string(nil), operands...)
result.FlagValues = make([][]string, len(selectedNode.Flags))
for index, flag := range selectedNode.Flags {
if value := selected.Flags().Lookup(flag.Name); value != nil {
result.FlagValues[index] = value.Value.(*rawValue).valuesCopy()
}
}
if helpFlag := selected.Flags().Lookup("help"); helpFlag != nil {
helpRequested, err := helpFlag.Value.(*rawValue).lastBool()
if err != nil {
return failure(result, err.Error())
}
result.HelpRequested = helpRequested
}
if !selectedNode.Runnable && len(result.Operands) > 0 {
return failure(result, "unknown command or unexpected group operands: "+strings.Join(result.Operands, " "))
}
return result
}
func normalizeOptions(options ParseOptions) ParseOptions {
if options.Context == nil {
options.Context = context.Background()
}
if options.In == nil {
options.In = strings.NewReader("")
}
if options.Out == nil {
options.Out = io.Discard
}
if options.Err == nil {
options.Err = io.Discard
}
return options
}
func buildTree(root *Command, options ParseOptions) (*spfcobra.Command, map[*Command]*spfcobra.Command) {
nodes := make(map[*Command]*spfcobra.Command)
var build func(*Command) *spfcobra.Command
build = func(declaration *Command) *spfcobra.Command {
backend := &spfcobra.Command{
Use: declaration.Name,
Short: declaration.Summary,
Long: declaration.Description,
Args: func(*spfcobra.Command, []string) error { return nil },
SilenceErrors: true,
SilenceUsage: true,
DisableSuggestions: true,
}
backend.SetContext(options.Context)
backend.SetIn(options.In)
backend.SetOut(options.Out)
backend.SetErr(options.Err)
backend.SetHelpFunc(func(*spfcobra.Command, []string) {})
backend.SetUsageFunc(func(*spfcobra.Command) error { return nil })
backend.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err })
backend.Flags().SetInterspersed(true)
backend.Flags().SortFlags = false
backend.DisableAutoGenTag = true
helpValue := &rawValue{boolean: true}
backend.Flags().VarP(helpValue, "help", "h", "help for this command")
backend.Flags().Lookup("help").NoOptDefVal = "true"
for _, declarationFlag := range declaration.Flags {
value := &rawValue{boolean: declarationFlag.Boolean}
backend.Flags().VarP(value, declarationFlag.Name, declarationFlag.Short, declarationFlag.Usage)
backendFlag := backend.Flags().Lookup(declarationFlag.Name)
backendFlag.DefValue = declarationFlag.Default
if declarationFlag.Boolean {
backendFlag.NoOptDefVal = "true"
}
}
// Cobra's automatic help flag is suppressed because CommandKit declares
// and interprets the local help flag itself.
backend.SetHelpCommand(nil)
nodes[declaration] = backend
for _, child := range declaration.Children {
backend.AddCommand(build(child))
}
return backend
}
return build(root), nodes
}
func declarationFor(nodes map[*Command]*spfcobra.Command, selected *spfcobra.Command) *Command {
for declaration, backend := range nodes {
if backend == selected {
return declaration
}
}
return nil
}
func splitRoutingPrefix(argv []string) ([]string, []string) {
for index, argument := range argv {
if argument == "--" || (strings.HasPrefix(argument, "-") && argument != "-") {
return append([]string(nil), argv[:index]...), append([]string(nil), argv[index:]...)
}
}
return append([]string(nil), argv...), nil
}
func pathFor(root, selected *Command) []string {
var reverse []string
for current := selected; current != nil && current != root; {
reverse = append(reverse, current.Name)
current = parentOf(root, current)
}
path := make([]string, len(reverse))
for index := range reverse {
path[len(reverse)-index-1] = reverse[index]
}
return path
}
func parentOf(root, target *Command) *Command {
for _, child := range root.Children {
if child == target {
return root
}
if parent := parentOf(child, target); parent != nil {
return parent
}
}
return nil
}
func failure(result Result, message string) Result {
result.Failure = &Failure{Message: message}
return result
}
type rawValue struct {
values []string
boolean bool
}
func (v *rawValue) Set(value string) error {
v.values = append(v.values, value)
return nil
}
func (v *rawValue) String() string {
if len(v.values) > 0 {
return v.values[len(v.values)-1]
}
return ""
}
func (v *rawValue) Type() string {
if v.boolean {
return "bool"
}
return "string"
}
func (v *rawValue) IsBoolFlag() bool { return v.boolean }
func (v *rawValue) valuesCopy() []string { return append([]string(nil), v.values...) }
func (v *rawValue) lastBool() (bool, error) {
var value bool
for _, raw := range v.values {
parsed, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("invalid boolean value %q for --help", raw)
}
value = parsed
}
return value, nil
}
var _ pflag.Value = (*rawValue)(nil)

View File

@@ -0,0 +1,118 @@
package cobra
import (
"context"
"io"
"reflect"
"strings"
"testing"
)
func TestParseRoutesBeforeOptionsAndKeepsFlagsLocal(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{
{Name: "config", Children: []*Command{
{Name: "show", Runnable: true, Flags: []Flag{{Name: "scope"}}},
}},
}}
result := Parse(tree, []string{"config", "show", "--scope", "app"}, ParseOptions{})
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
if !reflect.DeepEqual(result.Path, []string{"config", "show"}) || !reflect.DeepEqual(result.FlagValues, [][]string{{"app"}}) {
t.Fatalf("selected result = %#v", result)
}
result = Parse(tree, []string{"config", "--scope", "app", "show"}, ParseOptions{})
if result.Failure == nil {
t.Fatal("accepted a child flag before the complete command path")
}
}
func TestParseInterspersedOperandsBooleanFlagsAndTerminator(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{
{Name: "restore", Runnable: true, Flags: []Flag{
{Name: "enabled", Boolean: true},
{Name: "tag", Repeated: true},
}},
}}
result := Parse(tree, []string{"restore", "before", "--enabled", "--tag", "a,b", "--tag=c", "--", "--literal"}, ParseOptions{})
if result.Failure != nil {
t.Fatalf("parse failed: %s", result.Failure.Message)
}
if !reflect.DeepEqual(result.FlagValues, [][]string{{"true"}, {"a,b", "c"}}) {
t.Fatalf("flag values = %#v", result.FlagValues)
}
if !reflect.DeepEqual(result.Operands, []string{"before", "--literal"}) {
t.Fatalf("operands = %#v", result.Operands)
}
result = Parse(tree, []string{"restore", "--enabled=false"}, ParseOptions{})
if result.Failure != nil || !reflect.DeepEqual(result.FlagValues[0], []string{"false"}) {
t.Fatalf("explicit false = %#v", result)
}
}
func TestParseHelpIsLocalAndFalseDoesNotRequestHelp(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{
{Name: "run", Runnable: true, Flags: []Flag{{Name: "required", Required: true}}},
}}
result := Parse(tree, []string{"run", "--help"}, ParseOptions{})
if result.Failure != nil || !result.HelpRequested {
t.Fatalf("help parse = %#v", result)
}
result = Parse(tree, []string{"run", "--help=false"}, ParseOptions{})
if result.Failure != nil || result.HelpRequested {
t.Fatalf("false help parse = %#v", result)
}
result = Parse(tree, []string{"run", "--help=1"}, ParseOptions{})
if result.Failure != nil || !result.HelpRequested {
t.Fatalf("strconv boolean help parse = %#v", result)
}
result = Parse(tree, []string{"run", "--help=invalid", "--help"}, ParseOptions{})
if result.Failure == nil {
t.Fatal("accepted an invalid earlier help occurrence")
}
}
func TestParseRunnableParentAndGroupUnknownCommand(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{
{Name: "deploy", Runnable: true, Flags: []Flag{{Name: "scope"}}, Children: []*Command{{Name: "now", Runnable: true}}},
{Name: "config", Children: []*Command{{Name: "show", Runnable: true}}},
}}
result := Parse(tree, []string{"deploy", "target"}, ParseOptions{})
if result.Failure != nil || !reflect.DeepEqual(result.Path, []string{"deploy"}) || !reflect.DeepEqual(result.Operands, []string{"target"}) {
t.Fatalf("runnable parent result = %#v", result)
}
result = Parse(tree, []string{"deploy", "--scope", "local"}, ParseOptions{})
if result.Failure != nil || !reflect.DeepEqual(result.FlagValues, [][]string{{"local"}}) {
t.Fatalf("runnable parent local flag = %#v", result)
}
result = Parse(tree, []string{"deploy", "now"}, ParseOptions{})
if result.Failure != nil || !reflect.DeepEqual(result.Path, []string{"deploy", "now"}) {
t.Fatalf("nested runnable child = %#v", result)
}
result = Parse(tree, []string{"config", "mystery"}, ParseOptions{})
if result.Failure == nil {
t.Fatal("accepted an unknown trailing command beneath a group")
}
}
func TestParseReturnsNeutralFailuresAndFreshState(t *testing.T) {
tree := &Command{Name: "app", Children: []*Command{
{Name: "run", Runnable: true, Flags: []Flag{{Name: "value"}}},
}}
options := ParseOptions{Context: context.Background(), In: strings.NewReader("unused"), Out: io.Discard, Err: io.Discard}
first := Parse(tree, []string{"run", "--value=old"}, options)
second := Parse(tree, []string{"run"}, options)
if first.Failure != nil || second.Failure != nil {
t.Fatalf("parse results = %#v, %#v", first, second)
}
if !reflect.DeepEqual(first.FlagValues, [][]string{{"old"}}) || !reflect.DeepEqual(second.FlagValues, [][]string{nil}) {
t.Fatalf("state leaked between parses: %#v then %#v", first.FlagValues, second.FlagValues)
}
failed := Parse(tree, []string{"run", "--unknown"}, options)
if failed.Failure == nil || failed.Failure.Message == "" {
t.Fatalf("unknown flag failure = %#v", failed.Failure)
}
}