// 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 } // 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...) 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...) interpreted, spellings, interpretationFailure := interpretFlagTokens(remaining, selectedNode.Flags, session.flagAliases) for _, flag := range selectedNode.Flags { 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 { 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.Failure = interpretationFailure return result } 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 { 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.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 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 *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)