From 36b0bd82516dda856c45d97452f3a00fefe1f730 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 7 Jul 2026 08:09:45 +0200 Subject: [PATCH 1/4] fix(completion): hide carapace internal command from completions Carapace injects an internal _carapace command for completion plumbing. In an interactive console that implementation detail should never be offered as a user command: hide it recursively after carapace.Gen has initialized completion state, and filter it out when converting carapace values into readline completions. Co-Authored-By: Claude Opus 4.8 (1M context) --- completer.go | 35 ++++++++++++++++++++++++++++++----- completer_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 completer_test.go diff --git a/completer.go b/completer.go index c60604d..8a623c2 100644 --- a/completer.go +++ b/completer.go @@ -7,6 +7,7 @@ import ( "github.com/carapace-sh/carapace/pkg/style" completer "github.com/carapace-sh/carapace/pkg/x" "github.com/reeflective/readline" + "github.com/spf13/cobra" "github.com/reeflective/console/internal/completion" "github.com/reeflective/console/internal/line" @@ -18,6 +19,7 @@ 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) + hideCarapaceCommands(menu.Command) // Split the line as shell words, only using // what the right buffer (up to the cursor) @@ -32,10 +34,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 +50,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. @@ -115,6 +123,23 @@ func (c *Console) justifyCommandComps(comps readline.Completions) readline.Compl return comps } +// hideCarapaceCommands recursively hides carapace's internal completion command +// so it is never offered as a normal user command in an interactive console. +func hideCarapaceCommands(root *cobra.Command) { + if root == nil { + return + } + + for _, cmd := range root.Commands() { + if cmd.Name() == "_carapace" { + cmd.Hidden = true + continue + } + + hideCarapaceCommands(cmd) + } +} + // highlightSyntax - Entrypoint to all input syntax highlighting in the Wiregost console. func (c *Console) highlightSyntax(input []rune) string { // Serve a memoized result when the input has not changed since the last diff --git a/completer_test.go b/completer_test.go new file mode 100644 index 0000000..fdfa3d3 --- /dev/null +++ b/completer_test.go @@ -0,0 +1,40 @@ +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 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 +} From b79a0ab396f606ef8473a4774666f164a1865ad8 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 7 Jul 2026 08:12:51 +0200 Subject: [PATCH 2/4] fix(flags): reset reused command flag state before completion and execution Console reuses cobra command trees. When the application supplies a tree directly (no generator registered via SetCommands), the tree is never regenerated, so flag values, Changed state and ArgsLenAtDash parsed by an earlier completion or execution leak into later ones for the same command. Introduce a single shared resetFlagsDefaults helper (restoring scalar flags from DefValue and slice flags from their parsed default) and call it from both paths: the execution path resets the target command before its pre-run hooks, and the completion path additionally clears ArgsLenAtDash along the command lineage before carapace parses the input. Unifies two originally separate fixes (completion and execution) into one correct implementation of the slice-default restore. Co-Authored-By: Claude Opus 4.8 (1M context) --- command.go | 48 +++++++++++++++++++++++++++++++ completer.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++ completer_test.go | 48 +++++++++++++++++++++++++++++++ run.go | 6 ++++ run_flags_test.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 run_flags_test.go diff --git a/command.go b/command.go index 10e0a89..538930f 100644 --- a/command.go +++ b/command.go @@ -1,7 +1,11 @@ package console import ( + "encoding/csv" + "strings" + "github.com/spf13/cobra" + "github.com/spf13/pflag" ) const ( @@ -76,3 +80,47 @@ next: c.filters = updated } + +// resetFlagsDefaults resets every flag on a command 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 via SetCommands), flag state parsed by +// an earlier run would otherwise leak into later ones. This is the single +// shared implementation used by both the completion and execution paths. +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 +} diff --git a/completer.go b/completer.go index 8a623c2..f76f299 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/spf13/cobra" + "github.com/spf13/pflag" "github.com/reeflective/console/internal/completion" "github.com/reeflective/console/internal/line" @@ -24,6 +25,7 @@ func (c *Console) complete(input []rune, pos int) readline.Completions { // Split the line as shell words, only using // what the right buffer (up to the cursor) args, prefixComp, prefixLine := completion.SplitArgs(input, pos) + resetCompletionFlagState(menu.Command, args) // Prepare arguments for the carapace completer // (we currently need those two dummies for avoiding a panic). @@ -99,6 +101,77 @@ func (c *Console) complete(input []rune, pos int) readline.Completions { return comps } +// 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 +} + // justifyCommandComps justifies the descriptions for all commands in all groups // to the same level, for prettiness. Also, removes any coloring from them, as currently, // the carapace engine does add coloring to each group, and we don't want this. diff --git a/completer_test.go b/completer_test.go index fdfa3d3..78f8f6a 100644 --- a/completer_test.go +++ b/completer_test.go @@ -28,6 +28,54 @@ func TestCompleteHidesCarapaceCommand(t *testing.T) { } } +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 diff --git a/run.go b/run.go index 8cd2653..581f794 100644 --- a/run.go +++ b/run.go @@ -166,6 +166,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. + 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_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]) + } +} From ff991ec3b2a3b8149d61cddcd226d961d6b066d6 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 7 Jul 2026 08:14:02 +0200 Subject: [PATCH 3/4] feat(run): expose Console.RunMenuCommand for prepared menus Menu.RunCommandArgs is the normal entry point for programmatic execution, but it always resets the active menu first. Add Console.RunMenuCommand as a small public wrapper over the internal execution path, for integrations that have already prepared a menu command tree and need to execute against it directly, controlling the async flag themselves. Co-Authored-By: Claude Opus 4.8 (1M context) --- run.go | 12 ++++++++++++ run_execute_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 run_execute_test.go diff --git a/run.go b/run.go index 581f794..83bac9e 100644 --- a/run.go +++ b/run.go @@ -143,6 +143,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. 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") + } +} From 0e1ae894cbb8e2c629029c34b7db4eaff88c90ca Mon Sep 17 00:00:00 2001 From: maxlandon Date: Tue, 7 Jul 2026 08:26:27 +0200 Subject: [PATCH 4/4] refactor: extract cobra command-tree helpers into internal/command The root package mixed public API and stateful orchestration (which must live on the Console/Menu types) with a cluster of pure functions that only manipulate cobra command trees and pflag flag sets. Move that cluster into a new internal/command package, following the existing internal/ convention (internal/line already holds cobra-aware helpers): - ActiveFilters / HideFiltered (was activeFiltersFor / hideFilteredCommands) - HideCarapace (was hideCarapaceCommands) - ResetFlagsDefaults (was resetFlagsDefaults + parseSliceDefault) - ResetCompletionFlagState (was resetCompletionFlagState + helpers) The Console/Menu methods become thin delegators. CommandFilterKey is now re-exported from command.FilterKey to avoid an import cycle while keeping the public annotation key unchanged. Net effect: the root files read as public API + orchestration, the cobra mechanics are independently unit-tested (internal/command has no dependency on console state), and no public API or behavior changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- command.go | 52 +------- completer.go | 95 +-------------- internal/command/command.go | 198 +++++++++++++++++++++++++++++++ internal/command/command_test.go | 166 ++++++++++++++++++++++++++ menu.go | 45 +------ run.go | 3 +- 6 files changed, 378 insertions(+), 181 deletions(-) create mode 100644 internal/command/command.go create mode 100644 internal/command/command_test.go diff --git a/command.go b/command.go index 538930f..6ab83bc 100644 --- a/command.go +++ b/command.go @@ -1,11 +1,9 @@ package console import ( - "encoding/csv" - "strings" - "github.com/spf13/cobra" - "github.com/spf13/pflag" + + "github.com/reeflective/console/internal/command" ) const ( @@ -13,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 @@ -80,47 +78,3 @@ next: c.filters = updated } - -// resetFlagsDefaults resets every flag on a command 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 via SetCommands), flag state parsed by -// an earlier run would otherwise leak into later ones. This is the single -// shared implementation used by both the completion and execution paths. -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 -} diff --git a/completer.go b/completer.go index f76f299..08d3352 100644 --- a/completer.go +++ b/completer.go @@ -7,9 +7,8 @@ import ( "github.com/carapace-sh/carapace/pkg/style" completer "github.com/carapace-sh/carapace/pkg/x" "github.com/reeflective/readline" - "github.com/spf13/cobra" - "github.com/spf13/pflag" + "github.com/reeflective/console/internal/command" "github.com/reeflective/console/internal/completion" "github.com/reeflective/console/internal/line" ) @@ -20,12 +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) - hideCarapaceCommands(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) - resetCompletionFlagState(menu.Command, args) + command.ResetCompletionFlagState(menu.Command, args) // Prepare arguments for the carapace completer // (we currently need those two dummies for avoiding a panic). @@ -101,77 +100,6 @@ func (c *Console) complete(input []rune, pos int) readline.Completions { return comps } -// 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 -} - // justifyCommandComps justifies the descriptions for all commands in all groups // to the same level, for prettiness. Also, removes any coloring from them, as currently, // the carapace engine does add coloring to each group, and we don't want this. @@ -196,23 +124,6 @@ func (c *Console) justifyCommandComps(comps readline.Completions) readline.Compl return comps } -// hideCarapaceCommands recursively hides carapace's internal completion command -// so it is never offered as a normal user command in an interactive console. -func hideCarapaceCommands(root *cobra.Command) { - if root == nil { - return - } - - for _, cmd := range root.Commands() { - if cmd.Name() == "_carapace" { - cmd.Hidden = true - continue - } - - hideCarapaceCommands(cmd) - } -} - // highlightSyntax - Entrypoint to all input syntax highlighting in the Wiregost console. func (c *Console) highlightSyntax(input []rune) string { // Serve a memoized result when the input has not changed since the last 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 83bac9e..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" ) @@ -182,7 +183,7 @@ func (c *Console) execute(ctx context.Context, menu *Menu, args []string, async // 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. - resetFlagsDefaults(target) + command.ResetFlagsDefaults(target) // Console-wide pre-run hooks, cannot. if err := c.runAllE(c.PreCmdRunHooks); err != nil {