diff --git a/command.go b/command.go index 10e0a89..6ab83bc 100644 --- a/command.go +++ b/command.go @@ -2,6 +2,8 @@ package console import ( "github.com/spf13/cobra" + + "github.com/reeflective/console/internal/command" ) const ( @@ -9,7 +11,7 @@ const ( // The value will be used as a filter to disable commands when the console // calls the Filter("name") method on the console. // The string value will be comma-splitted, with each split being a filter. - CommandFilterKey = "console-hidden" + CommandFilterKey = command.FilterKey ) // Commands is a simple function a root cobra command containing an arbitrary tree diff --git a/completer.go b/completer.go index c60604d..08d3352 100644 --- a/completer.go +++ b/completer.go @@ -8,6 +8,7 @@ import ( completer "github.com/carapace-sh/carapace/pkg/x" "github.com/reeflective/readline" + "github.com/reeflective/console/internal/command" "github.com/reeflective/console/internal/completion" "github.com/reeflective/console/internal/line" ) @@ -18,10 +19,12 @@ func (c *Console) complete(input []rune, pos int) readline.Completions { // Ensure the carapace library is called so that the function // completer.Complete() variable is correctly initialized before use. carapace.Gen(menu.Command) + command.HideCarapace(menu.Command) // Split the line as shell words, only using // what the right buffer (up to the cursor) args, prefixComp, prefixLine := completion.SplitArgs(input, pos) + command.ResetCompletionFlagState(menu.Command, args) // Prepare arguments for the carapace completer // (we currently need those two dummies for avoiding a panic). @@ -32,10 +35,14 @@ func (c *Console) complete(input []rune, pos int) readline.Completions { // The completions are never nil: fill out our own object // with everything it contains, regardless of errors. - raw := make([]readline.Completion, len(completions.Values)) + raw := make([]readline.Completion, 0, len(completions.Values)) - for idx, val := range completions.Values { - raw[idx] = readline.Completion{ + for _, val := range completions.Values { + if strings.TrimSpace(val.Value) == "_carapace" { + continue + } + + comp := readline.Completion{ Value: line.UnescapeValue(prefixComp, prefixLine, val.Value), Display: val.Display, Description: val.Description, @@ -44,15 +51,17 @@ func (c *Console) complete(input []rune, pos int) readline.Completions { } if !completions.Nospace.Matches(val.Value) { - raw[idx].Value = val.Value + " " + comp.Value = val.Value + " " } // Remove short/long flags grouping // join to single tag group for classic zsh side-by-side view switch val.Tag { case "shorthand flags", "longhand flags": - raw[idx].Tag = "flags" + comp.Tag = "flags" } + + raw = append(raw, comp) } // Assign both completions and command/flags/args usage strings. diff --git a/completer_test.go b/completer_test.go new file mode 100644 index 0000000..78f8f6a --- /dev/null +++ b/completer_test.go @@ -0,0 +1,88 @@ +package console + +import ( + "strings" + "testing" + + "github.com/reeflective/readline" + "github.com/spf13/cobra" +) + +func TestCompleteHidesCarapaceCommand(t *testing.T) { + c := New("test") + root := &cobra.Command{Use: "root"} + internal := &cobra.Command{Use: "_carapace"} + root.AddCommand(internal, &cobra.Command{Use: "visible"}) + c.activeMenu().Command = root + + comps := c.complete(nil, 0) + + if !internal.Hidden { + t.Fatal("_carapace command was not hidden") + } + + for _, value := range completionValues(comps) { + if strings.TrimSpace(value) == "_carapace" { + t.Fatalf("completion values include internal command: %v", completionValues(comps)) + } + } +} + +func TestCompleteResetsFlagDefaults(t *testing.T) { + c := New("test") + root := &cobra.Command{Use: "root"} + cmd := &cobra.Command{Use: "serve"} + cmd.Flags().Bool("verbose", false, "") + root.AddCommand(cmd) + c.activeMenu().Command = root + + if err := cmd.Flags().Set("verbose", "true"); err != nil { + t.Fatal(err) + } + + _ = c.complete([]rune("serve "), len("serve ")) + + flag := cmd.Flags().Lookup("verbose") + if flag == nil { + t.Fatal("missing verbose flag") + } + if flag.Changed { + t.Fatal("completion did not clear flag Changed state") + } + if flag.Value.String() != "false" { + t.Fatalf("flag value = %q, want false", flag.Value.String()) + } +} + +func TestCompleteResetsArgsLenAtDash(t *testing.T) { + c := New("test") + root := &cobra.Command{Use: "root"} + cmd := &cobra.Command{Use: "serve"} + cmd.Flags().Bool("verbose", false, "") + root.AddCommand(cmd) + c.activeMenu().Command = root + + if err := cmd.Flags().Parse([]string{"--", "positional"}); err != nil { + t.Fatal(err) + } + if got := cmd.Flags().ArgsLenAtDash(); got < 0 { + t.Fatalf("test setup did not set ArgsLenAtDash: %d", got) + } + + _ = c.complete([]rune("serve "), len("serve ")) + + if got := cmd.Flags().ArgsLenAtDash(); got != -1 { + t.Fatalf("ArgsLenAtDash = %d, want -1", got) + } +} + +func completionValues(comps readline.Completions) []string { + var values []string + + comps.EachValue(func(comp readline.Completion) readline.Completion { + values = append(values, comp.Value) + return comp + }) + + return values +} diff --git a/internal/command/command.go b/internal/command/command.go new file mode 100644 index 0000000..febc2e3 --- /dev/null +++ b/internal/command/command.go @@ -0,0 +1,198 @@ +// Package command provides pure utilities for manipulating cobra command +// trees: matching commands against console filters, hiding filtered or internal +// commands, resetting reused flag state, and locating the command targeted by a +// line of input. None of these functions depend on console state, so they can be +// tested in isolation; the root console package wraps them in its own methods. +package command + +import ( + "encoding/csv" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// FilterKey is the cobra annotation key whose comma-separated value marks a +// command with the filters that hide it. The console re-exports this as +// CommandFilterKey for application use. +const FilterKey = "console-hidden" + +// ActiveFilters returns the console filters that cmd (or its nearest annotated +// ancestor) declares itself incompatible with. A non-empty result means the +// command is currently hidden/unavailable under the given console filters. +func ActiveFilters(cmd *cobra.Command, consoleFilters []string) []string { + if cmd.Annotations == nil { + if cmd.HasParent() { + return ActiveFilters(cmd.Parent(), consoleFilters) + } + + return nil + } + + // Get the filters declared on the command. + filterStr := cmd.Annotations[FilterKey] + var filters []string + + for _, cmdFilter := range strings.Split(filterStr, ",") { + for _, filter := range consoleFilters { + if cmdFilter != "" && cmdFilter == filter { + filters = append(filters, cmdFilter) + } + } + } + + if len(filters) > 0 || !cmd.HasParent() { + return filters + } + + // Any parent that is hidden makes its whole subtree hidden also. + return ActiveFilters(cmd.Parent(), consoleFilters) +} + +// HideFiltered hides every subcommand of root that matches an active console +// filter, so it is not shown in help strings or offered as a completion. +// Commands already hidden are left untouched. +func HideFiltered(root *cobra.Command, consoleFilters []string) { + for _, cmd := range root.Commands() { + // Don't override commands if they are already hidden. + if cmd.Hidden { + continue + } + + if filters := ActiveFilters(cmd, consoleFilters); len(filters) > 0 { + cmd.Hidden = true + } + } +} + +// HideCarapace recursively hides carapace's internal _carapace completion +// command so it is never offered as a normal user command. +func HideCarapace(root *cobra.Command) { + if root == nil { + return + } + + for _, cmd := range root.Commands() { + if cmd.Name() == "_carapace" { + cmd.Hidden = true + continue + } + + HideCarapace(cmd) + } +} + +// ResetFlagsDefaults resets every flag on target back to its registered default +// value and clears its Changed state. Console reuses cobra command trees across +// completions and executions; when the application supplies a command tree +// directly (no generator), flag state parsed by an earlier run would otherwise +// leak into later ones. +func ResetFlagsDefaults(target *cobra.Command) { + if target == nil { + return + } + + target.Flags().VisitAll(func(flag *pflag.Flag) { + flag.Changed = false + + switch value := flag.Value.(type) { + case pflag.SliceValue: + _ = value.Replace(parseSliceDefault(flag.DefValue)) + default: + _ = flag.Value.Set(flag.DefValue) + } + }) +} + +// parseSliceDefault turns a pflag slice flag's DefValue string representation +// (e.g. "[a,b]") back into the individual default elements. +func parseSliceDefault(defValue string) []string { + if defValue == "" || defValue == "[]" { + return nil + } + if strings.HasPrefix(defValue, "[") && strings.HasSuffix(defValue, "]") { + defValue = defValue[1 : len(defValue)-1] + } + if defValue == "" { + return nil + } + + values, err := csv.NewReader(strings.NewReader(defValue)).Read() + if err != nil { + return []string{defValue} + } + + return values +} + +// ResetCompletionFlagState clears flag state left over from a previous +// completion or execution on a reused command tree, before carapace parses the +// current input. It restores the target command's flag defaults (shared with +// the execution path) and resets ArgsLenAtDash along the command's lineage. +func ResetCompletionFlagState(root *cobra.Command, args []string) { + if root == nil { + return + } + + target := findCompletionTarget(root, args) + + // Force cobra to merge persistent/inherited flags into the full flag set + // so ResetFlagsDefaults sees them all. + _ = target.LocalFlags() + + ResetFlagsDefaults(target) + resetArgsLenAtDash(target) +} + +// resetArgsLenAtDash clears the "-- seen at index" bookkeeping on the target +// command and every parent, which a previous parse may have left set. +func resetArgsLenAtDash(target *cobra.Command) { + for cmd := target; cmd != nil; cmd = cmd.Parent() { + resetFlagSetArgsLenAtDash(cmd.Flags(), cmd.DisplayName()) + resetFlagSetArgsLenAtDash(cmd.PersistentFlags(), cmd.DisplayName()) + } +} + +func resetFlagSetArgsLenAtDash(fs *pflag.FlagSet, name string) { + if fs == nil { + return + } + + // FlagSet.Init resets argsLenAtDash to -1 without discarding registered + // flags; it is the only exported way to clear that internal state. + fs.Init(name, pflag.ContinueOnError) +} + +// findCompletionTarget walks the command tree following the positional words in +// args, stopping at the first flag or "--", to locate the command being completed. +func findCompletionTarget(root *cobra.Command, args []string) *cobra.Command { + cmd := root + for _, arg := range args { + if arg == "--" || strings.HasPrefix(arg, "-") { + break + } + + next := findSubcommand(cmd, arg) + if next == nil { + break + } + cmd = next + } + + return cmd +} + +func findSubcommand(cmd *cobra.Command, name string) *cobra.Command { + if cmd == nil { + return nil + } + + for _, sub := range cmd.Commands() { + if sub.Name() == name || sub.HasAlias(name) { + return sub + } + } + + return nil +} diff --git a/internal/command/command_test.go b/internal/command/command_test.go new file mode 100644 index 0000000..fe5ad41 --- /dev/null +++ b/internal/command/command_test.go @@ -0,0 +1,166 @@ +package command + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func filtered(use string, filters string) *cobra.Command { + return &cobra.Command{ + Use: use, + Annotations: map[string]string{FilterKey: filters}, + } +} + +func TestActiveFilters(t *testing.T) { + root := &cobra.Command{Use: "root"} + win := filtered("win", "windows") + multi := filtered("multi", "windows,admin") + plain := &cobra.Command{Use: "plain"} + root.AddCommand(win, multi, plain) + + if got := ActiveFilters(win, []string{"windows"}); !reflect.DeepEqual(got, []string{"windows"}) { + t.Fatalf("win with windows active = %v, want [windows]", got) + } + if got := ActiveFilters(win, []string{"linux"}); len(got) != 0 { + t.Fatalf("win with linux active = %v, want none", got) + } + if got := ActiveFilters(multi, []string{"admin"}); !reflect.DeepEqual(got, []string{"admin"}) { + t.Fatalf("multi with admin active = %v, want [admin]", got) + } + if got := ActiveFilters(plain, []string{"windows"}); len(got) != 0 { + t.Fatalf("plain command = %v, want none", got) + } +} + +// A command with no matching filter inherits its parent's filtered state, so a +// hidden subtree stays hidden regardless of the child's own annotations. +func TestActiveFiltersInheritsFromParent(t *testing.T) { + parent := filtered("parent", "windows") + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + + if got := ActiveFilters(child, []string{"windows"}); !reflect.DeepEqual(got, []string{"windows"}) { + t.Fatalf("child under filtered parent = %v, want [windows]", got) + } +} + +func TestHideFiltered(t *testing.T) { + root := &cobra.Command{Use: "root"} + win := filtered("win", "windows") + lin := filtered("lin", "linux") + root.AddCommand(win, lin) + + HideFiltered(root, []string{"windows"}) + + if !win.Hidden { + t.Fatal("windows-filtered command should be hidden") + } + if lin.Hidden { + t.Fatal("linux-filtered command should stay visible with only windows active") + } +} + +func TestHideCarapace(t *testing.T) { + root := &cobra.Command{Use: "root"} + internal := &cobra.Command{Use: "_carapace"} + sub := &cobra.Command{Use: "sub"} + nestedInternal := &cobra.Command{Use: "_carapace"} + sub.AddCommand(nestedInternal) + root.AddCommand(internal, sub) + + HideCarapace(root) + + if !internal.Hidden { + t.Fatal("top-level _carapace not hidden") + } + if !nestedInternal.Hidden { + t.Fatal("nested _carapace not hidden") + } + if sub.Hidden { + t.Fatal("regular command must not be hidden") + } +} + +func TestResetFlagsDefaults(t *testing.T) { + cmd := &cobra.Command{Use: "serve"} + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().StringSlice("item", []string{"base"}, "") + + if err := cmd.Flags().Set("verbose", "true"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("item", "one"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("item", "two"); err != nil { + t.Fatal(err) + } + + ResetFlagsDefaults(cmd) + + if cmd.Flags().Changed("verbose") || cmd.Flags().Changed("item") { + t.Fatal("Changed state not cleared") + } + if v, _ := cmd.Flags().GetBool("verbose"); v { + t.Fatal("verbose not reset to false") + } + if items, _ := cmd.Flags().GetStringSlice("item"); !reflect.DeepEqual(items, []string{"base"}) { + t.Fatalf("item slice = %v, want [base]", items) + } +} + +func TestResetFlagsDefaultsNilSafe(t *testing.T) { + ResetFlagsDefaults(nil) // must not panic +} + +func TestParseSliceDefault(t *testing.T) { + cases := map[string][]string{ + "": nil, + "[]": nil, + "[base]": {"base"}, + "[a,b,c]": {"a", "b", "c"}, + } + + for in, want := range cases { + if got := parseSliceDefault(in); !reflect.DeepEqual(got, want) { + t.Fatalf("parseSliceDefault(%q) = %v, want %v", in, got, want) + } + } +} + +func TestResetCompletionFlagState(t *testing.T) { + root := &cobra.Command{Use: "root"} + serve := &cobra.Command{Use: "serve"} + serve.Flags().Bool("verbose", false, "") + root.AddCommand(serve) + + if err := serve.Flags().Set("verbose", "true"); err != nil { + t.Fatal(err) + } + if err := serve.Flags().Parse([]string{"--", "positional"}); err != nil { + t.Fatal(err) + } + if serve.Flags().ArgsLenAtDash() < 0 { + t.Fatal("setup did not set ArgsLenAtDash") + } + + // Target the "serve" subcommand from the completion words. + ResetCompletionFlagState(root, []string{"serve"}) + + if serve.Flags().Changed("verbose") { + t.Fatal("Changed state not cleared on completion reset") + } + if v, _ := serve.Flags().GetBool("verbose"); v { + t.Fatal("verbose not reset") + } + if got := serve.Flags().ArgsLenAtDash(); got != -1 { + t.Fatalf("ArgsLenAtDash = %d, want -1", got) + } +} + +func TestResetCompletionFlagStateNilSafe(t *testing.T) { + ResetCompletionFlagState(nil, nil) // must not panic +} diff --git a/menu.go b/menu.go index 5ee29f8..5214068 100644 --- a/menu.go +++ b/menu.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/reeflective/console/internal/command" "github.com/reeflective/console/internal/strutil" "github.com/reeflective/console/internal/ui" "github.com/reeflective/readline" @@ -331,36 +332,7 @@ func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string { consoleFilters := append([]string(nil), m.console.filters...) m.console.mutex.RUnlock() - return activeFiltersFor(cmd, consoleFilters) -} - -func activeFiltersFor(cmd *cobra.Command, consoleFilters []string) []string { - if cmd.Annotations == nil { - if cmd.HasParent() { - return activeFiltersFor(cmd.Parent(), consoleFilters) - } - - return nil - } - - // Get the filters on the command - filterStr := cmd.Annotations[CommandFilterKey] - var filters []string - - for _, cmdFilter := range strings.Split(filterStr, ",") { - for _, filter := range consoleFilters { - if cmdFilter != "" && cmdFilter == filter { - filters = append(filters, cmdFilter) - } - } - } - - if len(filters) > 0 || !cmd.HasParent() { - return filters - } - - // Any parent that is hidden make its whole subtree hidden also. - return activeFiltersFor(cmd.Parent(), consoleFilters) + return command.ActiveFilters(cmd, consoleFilters) } // SetErrFilteredCommandTemplate sets the error template to be used @@ -421,16 +393,11 @@ func (m *Menu) regenerate() { // hide commands that are filtered so that they are not // shown in the help strings or proposed as completions. func (m *Menu) hideFilteredCommands(root *cobra.Command) { - for _, cmd := range root.Commands() { - // Don't override commands if they are already hidden - if cmd.Hidden { - continue - } + m.console.mutex.RLock() + consoleFilters := append([]string(nil), m.console.filters...) + m.console.mutex.RUnlock() - if filters := m.ActiveFiltersFor(cmd); len(filters) > 0 { - cmd.Hidden = true - } - } + command.HideFiltered(root, consoleFilters) } func (m *Menu) resetCmdOutput() { diff --git a/run.go b/run.go index 8cd2653..dc46510 100644 --- a/run.go +++ b/run.go @@ -11,6 +11,7 @@ import ( "github.com/kballard/go-shellquote" "github.com/spf13/cobra" + "github.com/reeflective/console/internal/command" "github.com/reeflective/console/internal/line" ) @@ -143,6 +144,18 @@ func (m *Menu) RunCommandLine(ctx context.Context, line string) (err error) { return m.RunCommandArgs(ctx, args) } +// RunMenuCommand runs a processed argument vector against a caller-prepared +// menu command tree, giving direct access to the lower-level execution path +// used internally by StartContext and Menu.RunCommandArgs. +// +// Most callers should prefer Menu.RunCommandArgs, which resets the active menu +// (regenerating its command tree and rebinding the prompt) before execution. +// RunMenuCommand is for integrations that have already prepared a menu and want +// to execute against it directly, controlling the async flag themselves. +func (c *Console) RunMenuCommand(ctx context.Context, menu *Menu, args []string, async bool) error { + return c.execute(ctx, menu, args, async) +} + // execute - The user has entered a command input line, the arguments have been processed: // we synchronize a few elements of the console, then pass these arguments to the command // parser for execution and error handling. @@ -166,6 +179,12 @@ func (c *Console) execute(ctx context.Context, menu *Menu, args []string, async return err } + // Restore the target command's flags to their defaults before running it. + // When the same command instance is reused (a caller-supplied tree with no + // generator), flag values and Changed state from an earlier run would + // otherwise leak into this execution. + command.ResetFlagsDefaults(target) + // Console-wide pre-run hooks, cannot. if err := c.runAllE(c.PreCmdRunHooks); err != nil { return fmt.Errorf("pre-run error: %s", err.Error()) diff --git a/run_execute_test.go b/run_execute_test.go new file mode 100644 index 0000000..66ac266 --- /dev/null +++ b/run_execute_test.go @@ -0,0 +1,33 @@ +package console + +import ( + "context" + "testing" + + "github.com/spf13/cobra" +) + +func TestConsoleRunMenuCommandRunsPreparedMenu(t *testing.T) { + c := New("test") + menu := c.ActiveMenu() + + var ran bool + root := &cobra.Command{Use: "root"} + root.AddCommand(&cobra.Command{ + Use: "run", + Run: func(*cobra.Command, []string) { + ran = true + }, + }) + menu.Command = root + + if err := c.RunMenuCommand(context.Background(), menu, []string{"run"}, false); err != nil { + t.Fatal(err) + } + if !ran { + t.Fatal("RunMenuCommand did not run the target command") + } + if c.isExecuting.Load() { + t.Fatal("RunMenuCommand left the console marked as executing") + } +} diff --git a/run_flags_test.go b/run_flags_test.go new file mode 100644 index 0000000..82679c7 --- /dev/null +++ b/run_flags_test.go @@ -0,0 +1,73 @@ +package console + +import ( + "context" + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func TestRunCommandArgsResetsFlagDefaults(t *testing.T) { + c := New("test") + menu := c.ActiveMenu() + + type runState struct { + verbose bool + verboseChanged bool + items []string + itemsChanged bool + } + var states []runState + + root := &cobra.Command{Use: "root"} + cmd := &cobra.Command{ + Use: "run", + RunE: func(cmd *cobra.Command, _ []string) error { + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return err + } + items, err := cmd.Flags().GetStringSlice("item") + if err != nil { + return err + } + + states = append(states, runState{ + verbose: verbose, + verboseChanged: cmd.Flags().Changed("verbose"), + items: append([]string(nil), items...), + itemsChanged: cmd.Flags().Changed("item"), + }) + + return nil + }, + } + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().StringSlice("item", []string{"base"}, "") + root.AddCommand(cmd) + menu.SetCommands(func() *cobra.Command { return root }) + + if err := menu.RunCommandArgs(context.Background(), []string{"run", "--verbose", "--item", "one", "--item", "two"}); err != nil { + t.Fatal(err) + } + if err := menu.RunCommandArgs(context.Background(), []string{"run"}); err != nil { + t.Fatal(err) + } + + if len(states) != 2 { + t.Fatalf("executed %d times, want 2", len(states)) + } + if !states[0].verbose || !states[0].verboseChanged { + t.Fatalf("first run verbose state = %+v, want true/changed", states[0]) + } + if !reflect.DeepEqual(states[0].items, []string{"one", "two"}) || !states[0].itemsChanged { + t.Fatalf("first run slice state = %+v, want [one two]/changed", states[0]) + } + if states[1].verbose || states[1].verboseChanged { + t.Fatalf("second run verbose state = %+v, want false/not changed", states[1]) + } + if !reflect.DeepEqual(states[1].items, []string{"base"}) || states[1].itemsChanged { + t.Fatalf("second run slice state = %+v, want [base]/not changed", states[1]) + } +}