Files
commandkit/internal/cobra/parse.go

440 lines
12 KiB
Go

// Package cobra adapts CommandKit's neutral command declarations to Cobra and
// pflag. Backend types and failures remain inside this package.
package cobra
import (
"bytes"
"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
Arguments []Argument
Children []*Command
}
// Argument describes one positional input for usage rendering.
type Argument struct {
Name string
Usage string
Min int
Max int
}
// 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
}
// Session owns one mutable backend tree for a single command invocation.
type Session struct {
metadata *Command
root *spfcobra.Command
declarations map[*spfcobra.Command]*Command
}
// Prepare constructs a fresh backend tree for one invocation.
func Prepare(root *Command, options ParseOptions) *Session {
session := &Session{}
if root == nil {
return session
}
session.metadata = root
options = normalizeOptions(options)
session.root, session.declarations = buildTree(root, options)
return session
}
// Metadata returns the neutral tree used to prepare this session.
func (session *Session) Metadata() *Command {
if session == nil {
return nil
}
return session.metadata
}
// Parse routes leading command words and parses flags on this session's tree.
func (session *Session) Parse(argv []string) Result {
result := Result{}
if session == nil || session.root == nil {
return failure(result, "command tree is nil")
}
leading, tail := splitRoutingPrefix(argv)
selected, unconsumed, err := session.root.Find(leading)
if err != nil {
return failure(result, err.Error())
}
if selected == nil {
return failure(result, "no command was selected")
}
selectedNode := session.declarations[selected]
if selectedNode == nil {
return failure(result, "selected command is not part of the declared tree")
}
var pathOK bool
result.Path, pathOK = pathFor(selected, session.declarations)
if !pathOK {
return failure(result, "selected command is not part of the declared tree")
}
consumed := len(leading) - len(unconsumed)
if consumed != len(result.Path) || !equalPath(leading[:consumed], result.Path) {
return failure(result, "command path must match a registered command exactly")
}
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
}
// RenderHelp renders registered metadata for an exact path on this session.
func (session *Session) RenderHelp(path []string) (string, *Failure) {
if session == nil || session.root == nil {
return "", &Failure{Message: "command tree is nil"}
}
selected, remaining, err := session.root.Find(path)
if err != nil {
return "", &Failure{Message: err.Error()}
}
declaration := session.declarations[selected]
selectedPath, pathOK := pathFor(selected, session.declarations)
if declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) {
return "", &Failure{Message: "help path is not registered"}
}
buffer := new(bytes.Buffer)
previousOut, previousErr := selected.OutOrStdout(), selected.ErrOrStderr()
selected.SetOut(buffer)
selected.SetErr(buffer)
defer func() {
selected.SetOut(previousOut)
selected.SetErr(previousErr)
}()
if err := selected.Usage(); err != nil {
return "", &Failure{Message: err.Error()}
}
return buffer.String(), nil
}
// UsageLine returns Cobra's usage line for an exact path on this session.
func (session *Session) UsageLine(path []string) (string, *Failure) {
if session == nil || session.root == nil {
return "", &Failure{Message: "command tree is nil"}
}
selected, remaining, err := session.root.Find(path)
declaration := session.declarations[selected]
selectedPath, pathOK := pathFor(selected, session.declarations)
if err != nil || declaration == nil || !pathOK || len(remaining) != 0 || !equalPath(path, selectedPath) {
return "", &Failure{Message: "usage path is not registered"}
}
line := selected.UseLine()
if selected.HasSubCommands() {
line += " [command]"
}
return line, nil
}
// RenderHelp renders a fresh command tree for one help request.
func RenderHelp(root *Command, path []string) (string, *Failure) {
return Prepare(root, ParseOptions{}).RenderHelp(path)
}
// UsageLine resolves a path on a fresh command tree.
func UsageLine(root *Command, path []string) (string, *Failure) {
return Prepare(root, ParseOptions{}).UsageLine(path)
}
// Parse constructs a fresh command tree and parses one argv.
func Parse(root *Command, argv []string, options ParseOptions) Result {
return Prepare(root, options).Parse(argv)
}
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[*spfcobra.Command]*Command) {
nodes := make(map[*spfcobra.Command]*Command)
var build func(*Command) *spfcobra.Command
build = func(declaration *Command) *spfcobra.Command {
backend := &spfcobra.Command{
Use: commandUse(declaration),
Short: declaration.Summary,
Long: helpDescription(declaration),
Args: func(*spfcobra.Command, []string) error { return nil },
SilenceErrors: true,
SilenceUsage: true,
DisableSuggestions: true,
}
if declaration.Runnable {
backend.Run = func(*spfcobra.Command, []string) {}
}
backend.SetContext(options.Context)
backend.SetIn(options.In)
backend.SetOut(options.Out)
backend.SetErr(options.Err)
backend.SetHelpFunc(func(*spfcobra.Command, []string) {})
backend.SetFlagErrorFunc(func(_ *spfcobra.Command, err error) error { return err })
backend.SetUsageTemplate(usageTemplate)
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")
helpFlag := backend.Flags().Lookup("help")
helpFlag.NoOptDefVal = "true"
helpFlag.Usage = "help for this command (default: false)"
for _, declarationFlag := range declaration.Flags {
value := &rawValue{boolean: declarationFlag.Boolean}
backend.Flags().VarP(value, declarationFlag.Name, declarationFlag.Short, flagUsage(declarationFlag))
backendFlag := backend.Flags().Lookup(declarationFlag.Name)
backendFlag.DefValue = ""
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[backend] = declaration
for _, child := range declaration.Children {
backend.AddCommand(build(child))
}
return backend
}
return build(root), nodes
}
const usageTemplate = `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}}
{{end}}Usage:
{{.UseLine}}{{if .HasSubCommands}}
{{.CommandPath}} [command]{{end}}{{if .HasSubCommands}}
Available Commands:{{range .Commands}}
{{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
`
func commandUse(command *Command) string {
var parts []string
for _, argument := range command.Arguments {
name := argument.Name
if argument.Min == 1 && argument.Max == 1 {
parts = append(parts, name)
} else if argument.Max == -1 {
if argument.Min > 0 {
parts = append(parts, name+"...")
} else {
parts = append(parts, "["+name+"...]")
}
} else {
parts = append(parts, fmt.Sprintf("%s{%d..%d}", name, argument.Min, argument.Max))
}
}
if len(parts) == 0 {
return command.Name
}
return command.Name + " " + strings.Join(parts, " ")
}
func helpDescription(command *Command) string {
var sections []string
if description := strings.TrimSpace(command.Description); description != "" {
sections = append(sections, description)
}
if len(command.Arguments) > 0 {
lines := []string{"Arguments:"}
for _, argument := range command.Arguments {
maximum := fmt.Sprint(argument.Max)
if argument.Max == -1 {
maximum = "unbounded"
}
line := fmt.Sprintf(" %s (min %d, max %s)", argument.Name, argument.Min, maximum)
if argument.Usage != "" {
line += " — " + argument.Usage
}
lines = append(lines, line)
}
sections = append(sections, strings.Join(lines, "\n"))
}
return strings.Join(sections, "\n\n")
}
func flagUsage(flag Flag) string {
var details []string
if flag.Required {
details = append(details, "required")
}
if flag.Repeated {
details = append(details, "repeatable")
}
details = append(details, "default: "+flag.Default)
usage := flag.Usage
if usage != "" {
usage += " (" + strings.Join(details, "; ") + ")"
} else {
usage = strings.Join(details, "; ")
}
return usage
}
func equalPath(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
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(selected *spfcobra.Command, declarations map[*spfcobra.Command]*Command) ([]string, bool) {
var reverse []string
for current := selected; current != nil; current = current.Parent() {
declaration := declarations[current]
if declaration == nil {
return nil, false
}
if current.Parent() != nil {
reverse = append(reverse, declaration.Name)
}
}
for left, right := 0, len(reverse)-1; left < right; left, right = left+1, right-1 {
reverse[left], reverse[right] = reverse[right], reverse[left]
}
return reverse, true
}
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)