527 lines
18 KiB
Go
527 lines
18 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"
|
|
|
|
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
|
"gitea.maximumdirect.net/eric/CommandKit/internal/commandpath"
|
|
|
|
spfcobra "github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
// Session owns one mutable backend tree for a single command invocation.
|
|
type Session struct {
|
|
metadata *commandmeta.Command
|
|
root *spfcobra.Command
|
|
declarations map[*spfcobra.Command]*commandmeta.Command
|
|
flagAliases []commandmeta.FlagAlias
|
|
flagAliasesForPath func([]string) []commandmeta.FlagAlias
|
|
}
|
|
|
|
// Prepare constructs a fresh backend tree for one invocation.
|
|
func Prepare(root *commandmeta.Command, options commandmeta.ParseOptions) *Session {
|
|
session := &Session{}
|
|
if root == nil {
|
|
return session
|
|
}
|
|
session.metadata = root
|
|
session.flagAliases = append([]commandmeta.FlagAlias(nil), options.FlagAliases...)
|
|
session.flagAliasesForPath = options.FlagAliasesForPath
|
|
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() *commandmeta.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) commandmeta.Result {
|
|
result := commandmeta.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")
|
|
}
|
|
result.Key = selectedNode.Key
|
|
var pathOK bool
|
|
selectedPath, pathOK := pathFor(selected, session.declarations)
|
|
if !pathOK {
|
|
return failure(result, "selected command is not part of the declared tree")
|
|
}
|
|
result.Path = []string(selectedPath)
|
|
consumed := len(leading) - len(unconsumed)
|
|
if consumed != len(selectedPath) || !commandpath.FromTokens(leading[:consumed]).Equal(selectedPath) {
|
|
return failure(result, "command path must match a registered command exactly")
|
|
}
|
|
|
|
remaining := append(commandpath.CloneTokens(unconsumed), tail...)
|
|
localFlags := make([]commandmeta.Flag, 0, len(selectedNode.Flags)+len(selectedNode.CoreFlags))
|
|
localFlags = append(localFlags, selectedNode.Flags...)
|
|
localFlags = append(localFlags, selectedNode.CoreFlags...)
|
|
aliases := session.flagAliases
|
|
if session.flagAliasesForPath != nil {
|
|
aliases = session.flagAliasesForPath(result.Path)
|
|
}
|
|
interpreted, spellings, occurrences, interpretationFailure := interpretFlagTokens(remaining, localFlags, aliases)
|
|
for _, flag := range localFlags {
|
|
if value := selected.Flags().Lookup(flag.Name); value != nil {
|
|
value.Value.(*rawValue).queueSpellings(spellings[flag.Name])
|
|
}
|
|
}
|
|
if helpFlag := selected.Flags().Lookup("help"); helpFlag != nil {
|
|
helpFlag.Value.(*rawValue).queueSpellings(spellings["help"])
|
|
}
|
|
if err := selected.ParseFlags(interpreted); err != nil {
|
|
result.FlagOccurrences = occurrences
|
|
return failure(result, err.Error())
|
|
}
|
|
// Parse the completed prefix first so an earlier backend failure keeps its
|
|
// precedence over a missing value discovered while interpreting aliases.
|
|
if interpretationFailure != nil {
|
|
result.FlagOccurrences = occurrences
|
|
result.Failure = interpretationFailure
|
|
return result
|
|
}
|
|
result.FlagOccurrences = occurrences
|
|
operands := selected.Flags().Args()
|
|
|
|
result.Operands = append([]string(nil), operands...)
|
|
result.FlagValues = make([][]string, len(selectedNode.Flags))
|
|
result.FlagSpellings = make([][]string, len(selectedNode.Flags))
|
|
for index, flag := range selectedNode.Flags {
|
|
if value := selected.Flags().Lookup(flag.Name); value != nil {
|
|
raw := value.Value.(*rawValue)
|
|
result.FlagValues[index] = raw.valuesCopy()
|
|
result.FlagSpellings[index] = raw.spellingsCopy()
|
|
}
|
|
}
|
|
if helpFlag := selected.Flags().Lookup("help"); helpFlag != nil {
|
|
helpRequested, err := helpFlag.Value.(*rawValue).lastBool()
|
|
if err != nil {
|
|
for index, occurrence := range result.FlagOccurrences {
|
|
if occurrence.Name != "help" {
|
|
continue
|
|
}
|
|
if _, parseErr := strconv.ParseBool(occurrence.RawValue); parseErr != nil {
|
|
result.FlagOccurrences = result.FlagOccurrences[:index+1]
|
|
break
|
|
}
|
|
}
|
|
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, *commandmeta.Failure) {
|
|
if session == nil || session.root == nil {
|
|
return "", &commandmeta.Failure{Message: "command tree is nil"}
|
|
}
|
|
selected, remaining, err := session.root.Find(path)
|
|
if err != nil {
|
|
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", commandpath.FromTokens(path).String()), Usage: true}
|
|
}
|
|
declaration := session.declarations[selected]
|
|
selectedPath, pathOK := pathFor(selected, session.declarations)
|
|
if declaration == nil || !pathOK || len(remaining) != 0 || !commandpath.FromTokens(path).Equal(selectedPath) {
|
|
return "", &commandmeta.Failure{Message: fmt.Sprintf("unknown help path %q", commandpath.FromTokens(path).String()), Usage: true}
|
|
}
|
|
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 "", &commandmeta.Failure{Message: err.Error()}
|
|
}
|
|
return buffer.String(), nil
|
|
}
|
|
|
|
// RenderHelpView renders canonical command metadata with the selected entered
|
|
// path and its application-owned aliases.
|
|
func (session *Session) RenderHelpView(view commandmeta.HelpView) (string, *commandmeta.Failure) {
|
|
if session == nil || session.root == nil || session.metadata == nil {
|
|
return "", &commandmeta.Failure{Message: "command tree is nil"}
|
|
}
|
|
if view.Discovery {
|
|
return renderDiscoveryHelp(session.metadata.Name, view), nil
|
|
}
|
|
content, failure := session.RenderHelp(view.CanonicalPath)
|
|
if failure != nil {
|
|
return "", failure
|
|
}
|
|
if !commandpath.FromTokens(view.EnteredPath).Equal(commandpath.FromTokens(view.CanonicalPath)) {
|
|
if len(view.CanonicalPath) > 0 {
|
|
entered := displayCommandPath(session.metadata.Name, view.EnteredPath)
|
|
canonical := displayCommandPath(session.metadata.Name, view.CanonicalPath)
|
|
lines := strings.Split(content, "\n")
|
|
adjusted := make([]string, 0, len(lines)+1)
|
|
annotated := false
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, " "+canonical) {
|
|
line = strings.Replace(line, canonical, entered, 1)
|
|
if !annotated {
|
|
adjusted = append(adjusted, line, "Alias for: "+displayCommandPath(session.metadata.Name, view.CanonicalPath))
|
|
annotated = true
|
|
continue
|
|
}
|
|
}
|
|
adjusted = append(adjusted, line)
|
|
}
|
|
content = strings.Join(adjusted, "\n")
|
|
}
|
|
}
|
|
return appendHelpAliases(content, view.CommandAliases, view.FlagAliases), nil
|
|
}
|
|
|
|
func appendHelpAliases(content string, commandAliases []commandmeta.CommandAlias, flagAliases []commandmeta.FlagAlias) string {
|
|
var sections []string
|
|
if len(commandAliases) > 0 {
|
|
lines := []string{"Command Aliases:"}
|
|
for _, alias := range commandAliases {
|
|
lines = append(lines, " "+strings.Join(alias.Source, " ")+" → "+strings.Join(alias.Target, " "))
|
|
}
|
|
sections = append(sections, strings.Join(lines, "\n"))
|
|
}
|
|
if len(flagAliases) > 0 {
|
|
lines := []string{"Flag Aliases:"}
|
|
for _, alias := range flagAliases {
|
|
lines = append(lines, " "+alias.Source+" → "+alias.Target)
|
|
}
|
|
sections = append(sections, strings.Join(lines, "\n"))
|
|
}
|
|
if len(sections) == 0 {
|
|
return content
|
|
}
|
|
return strings.TrimRight(content, "\n") + "\n\n" + strings.Join(sections, "\n\n") + "\n"
|
|
}
|
|
|
|
func renderDiscoveryHelp(rootName string, view commandmeta.HelpView) string {
|
|
lines := []string{"Usage:", " " + displayCommandPath(rootName, view.EnteredPath) + " [command]"}
|
|
if len(view.CoreFlags) > 0 {
|
|
lines = append(lines, "", "Reporting Flags:")
|
|
for _, flag := range view.CoreFlags {
|
|
lines = append(lines, " --"+flag.Name+" — "+flag.Usage+" (default: "+flag.Default+")")
|
|
}
|
|
}
|
|
if len(view.CommandAliases) > 0 {
|
|
lines = append(lines, "", "Available Aliases:")
|
|
for _, alias := range view.CommandAliases {
|
|
lines = append(lines, " "+strings.Join(alias.Source, " ")+" → "+strings.Join(alias.Target, " "))
|
|
}
|
|
}
|
|
if len(view.FlagAliases) > 0 {
|
|
lines = append(lines, "", "Flag Aliases:")
|
|
for _, alias := range view.FlagAliases {
|
|
lines = append(lines, " "+alias.Source+" → "+alias.Target)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n") + "\n"
|
|
}
|
|
|
|
func displayCommandPath(rootName string, path []string) string {
|
|
parts := append([]string{rootName}, path...)
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// UsageLine returns Cobra's usage line for an exact path on this session.
|
|
func (session *Session) UsageLine(path []string) (string, *commandmeta.Failure) {
|
|
if session == nil || session.root == nil {
|
|
return "", &commandmeta.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 || !commandpath.FromTokens(path).Equal(selectedPath) {
|
|
return "", &commandmeta.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 *commandmeta.Command, path []string) (string, *commandmeta.Failure) {
|
|
return Prepare(root, commandmeta.ParseOptions{}).RenderHelp(path)
|
|
}
|
|
|
|
// UsageLine resolves a path on a fresh command tree.
|
|
func UsageLine(root *commandmeta.Command, path []string) (string, *commandmeta.Failure) {
|
|
return Prepare(root, commandmeta.ParseOptions{}).UsageLine(path)
|
|
}
|
|
|
|
// Parse constructs a fresh command tree and parses one argv.
|
|
func Parse(root *commandmeta.Command, argv []string, options commandmeta.ParseOptions) commandmeta.Result {
|
|
return Prepare(root, options).Parse(argv)
|
|
}
|
|
|
|
func normalizeOptions(options commandmeta.ParseOptions) commandmeta.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 *commandmeta.Command, options commandmeta.ParseOptions) (*spfcobra.Command, map[*spfcobra.Command]*commandmeta.Command) {
|
|
nodes := make(map[*spfcobra.Command]*commandmeta.Command)
|
|
var build func(*commandmeta.Command) *spfcobra.Command
|
|
build = func(declaration *commandmeta.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 append(append([]commandmeta.Flag(nil), declaration.Flags...), declaration.CoreFlags...) {
|
|
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 *commandmeta.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 *commandmeta.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 commandmeta.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 splitRoutingPrefix(argv []string) ([]string, []string) {
|
|
for index, argument := range argv {
|
|
// Empty strings and "-" can be operands but never command names.
|
|
// Keep them out of Find, which could skip them and select a child
|
|
// from a later operand instead of retaining the runnable parent.
|
|
if argument == "" || strings.HasPrefix(argument, "-") {
|
|
return commandpath.CloneTokens(argv[:index]), commandpath.CloneTokens(argv[index:])
|
|
}
|
|
}
|
|
return commandpath.CloneTokens(argv), nil
|
|
}
|
|
|
|
func pathFor(selected *spfcobra.Command, declarations map[*spfcobra.Command]*commandmeta.Command) (commandpath.Path, 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 commandpath.FromTokens(reverse), true
|
|
}
|
|
|
|
func failure(result commandmeta.Result, message string) commandmeta.Result {
|
|
result.Failure = &commandmeta.Failure{Message: message}
|
|
return result
|
|
}
|
|
|
|
type rawValue struct {
|
|
values []string
|
|
spellings []string
|
|
pendingSpellings []string
|
|
boolean bool
|
|
}
|
|
|
|
func (v *rawValue) Set(value string) error {
|
|
v.values = append(v.values, value)
|
|
if len(v.pendingSpellings) > 0 {
|
|
v.spellings = append(v.spellings, v.pendingSpellings[0])
|
|
v.pendingSpellings = v.pendingSpellings[1:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (v *rawValue) queueSpellings(spellings []string) {
|
|
v.pendingSpellings = append(v.pendingSpellings, spellings...)
|
|
}
|
|
|
|
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) spellingsCopy() []string { return append([]string(nil), v.spellings...) }
|
|
|
|
func (v *rawValue) lastBool() (bool, error) {
|
|
var value bool
|
|
for index, raw := range v.values {
|
|
parsed, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
spelling := "--help"
|
|
if index < len(v.spellings) {
|
|
spelling = v.spellings[index]
|
|
}
|
|
return false, fmt.Errorf("invalid boolean value %q for %s", raw, spelling)
|
|
}
|
|
value = parsed
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
var _ pflag.Value = (*rawValue)(nil)
|