Skip to content

Commit 5f2ec3c

Browse files
authored
Update shell completion to respect flag groups (#1659)
Signed-off-by: Marc Khouzam <[email protected]> Co-authored-by: Marc Khouzam <[email protected]>
1 parent b9ca594 commit 5f2ec3c

File tree

3 files changed

+238
-0
lines changed

3 files changed

+238
-0
lines changed

completions.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ func (c *Command) getCompletions(args []string) (*Command, []string, ShellCompDi
325325
var completions []string
326326
var directive ShellCompDirective
327327

328+
// Enforce flag groups before doing flag completions
329+
finalCmd.enforceFlagGroupsForCompletion()
330+
328331
// Note that we want to perform flagname completion even if finalCmd.DisableFlagParsing==true;
329332
// doing this allows for completion of persistent flag names even for commands that disable flag parsing.
330333
//

completions_test.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2691,3 +2691,189 @@ func TestFixedCompletions(t *testing.T) {
26912691
t.Errorf("expected: %q, got: %q", expected, output)
26922692
}
26932693
}
2694+
2695+
func TestCompletionForGroupedFlags(t *testing.T) {
2696+
getCmd := func() *Command {
2697+
rootCmd := &Command{
2698+
Use: "root",
2699+
Run: emptyRun,
2700+
}
2701+
childCmd := &Command{
2702+
Use: "child",
2703+
ValidArgsFunction: func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) {
2704+
return []string{"subArg"}, ShellCompDirectiveNoFileComp
2705+
},
2706+
Run: emptyRun,
2707+
}
2708+
rootCmd.AddCommand(childCmd)
2709+
2710+
rootCmd.PersistentFlags().Int("ingroup1", -1, "ingroup1")
2711+
rootCmd.PersistentFlags().String("ingroup2", "", "ingroup2")
2712+
2713+
childCmd.Flags().Bool("ingroup3", false, "ingroup3")
2714+
childCmd.Flags().Bool("nogroup", false, "nogroup")
2715+
2716+
// Add flags to a group
2717+
childCmd.MarkFlagsRequiredTogether("ingroup1", "ingroup2", "ingroup3")
2718+
2719+
return rootCmd
2720+
}
2721+
2722+
// Each test case uses a unique command from the function above.
2723+
testcases := []struct {
2724+
desc string
2725+
args []string
2726+
expectedOutput string
2727+
}{
2728+
{
2729+
desc: "flags in group not suggested without - prefix",
2730+
args: []string{"child", ""},
2731+
expectedOutput: strings.Join([]string{
2732+
"subArg",
2733+
":4",
2734+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2735+
},
2736+
{
2737+
desc: "flags in group suggested with - prefix",
2738+
args: []string{"child", "-"},
2739+
expectedOutput: strings.Join([]string{
2740+
"--ingroup1",
2741+
"--ingroup2",
2742+
"--ingroup3",
2743+
"--nogroup",
2744+
":4",
2745+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2746+
},
2747+
{
2748+
desc: "when flag in group present, other flags in group suggested even without - prefix",
2749+
args: []string{"child", "--ingroup2", "value", ""},
2750+
expectedOutput: strings.Join([]string{
2751+
"--ingroup1",
2752+
"--ingroup3",
2753+
"subArg",
2754+
":4",
2755+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2756+
},
2757+
{
2758+
desc: "when all flags in group present, flags not suggested without - prefix",
2759+
args: []string{"child", "--ingroup1", "8", "--ingroup2", "value2", "--ingroup3", ""},
2760+
expectedOutput: strings.Join([]string{
2761+
"subArg",
2762+
":4",
2763+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2764+
},
2765+
{
2766+
desc: "group ignored if some flags not applicable",
2767+
args: []string{"--ingroup2", "value", ""},
2768+
expectedOutput: strings.Join([]string{
2769+
"child",
2770+
"completion",
2771+
"help",
2772+
":4",
2773+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2774+
},
2775+
}
2776+
2777+
for _, tc := range testcases {
2778+
t.Run(tc.desc, func(t *testing.T) {
2779+
c := getCmd()
2780+
args := []string{ShellCompNoDescRequestCmd}
2781+
args = append(args, tc.args...)
2782+
output, err := executeCommand(c, args...)
2783+
switch {
2784+
case err == nil && output != tc.expectedOutput:
2785+
t.Errorf("expected: %q, got: %q", tc.expectedOutput, output)
2786+
case err != nil:
2787+
t.Errorf("Unexpected error %q", err)
2788+
}
2789+
})
2790+
}
2791+
}
2792+
2793+
func TestCompletionForMutuallyExclusiveFlags(t *testing.T) {
2794+
getCmd := func() *Command {
2795+
rootCmd := &Command{
2796+
Use: "root",
2797+
Run: emptyRun,
2798+
}
2799+
childCmd := &Command{
2800+
Use: "child",
2801+
ValidArgsFunction: func(cmd *Command, args []string, toComplete string) ([]string, ShellCompDirective) {
2802+
return []string{"subArg"}, ShellCompDirectiveNoFileComp
2803+
},
2804+
Run: emptyRun,
2805+
}
2806+
rootCmd.AddCommand(childCmd)
2807+
2808+
rootCmd.PersistentFlags().IntSlice("ingroup1", []int{1}, "ingroup1")
2809+
rootCmd.PersistentFlags().String("ingroup2", "", "ingroup2")
2810+
2811+
childCmd.Flags().Bool("ingroup3", false, "ingroup3")
2812+
childCmd.Flags().Bool("nogroup", false, "nogroup")
2813+
2814+
// Add flags to a group
2815+
childCmd.MarkFlagsMutuallyExclusive("ingroup1", "ingroup2", "ingroup3")
2816+
2817+
return rootCmd
2818+
}
2819+
2820+
// Each test case uses a unique command from the function above.
2821+
testcases := []struct {
2822+
desc string
2823+
args []string
2824+
expectedOutput string
2825+
}{
2826+
{
2827+
desc: "flags in mutually exclusive group not suggested without the - prefix",
2828+
args: []string{"child", ""},
2829+
expectedOutput: strings.Join([]string{
2830+
"subArg",
2831+
":4",
2832+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2833+
},
2834+
{
2835+
desc: "flags in mutually exclusive group suggested with the - prefix",
2836+
args: []string{"child", "-"},
2837+
expectedOutput: strings.Join([]string{
2838+
"--ingroup1",
2839+
"--ingroup2",
2840+
"--ingroup3",
2841+
"--nogroup",
2842+
":4",
2843+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2844+
},
2845+
{
2846+
desc: "when flag in mutually exclusive group present, other flags in group not suggested even with the - prefix",
2847+
args: []string{"child", "--ingroup1", "8", "-"},
2848+
expectedOutput: strings.Join([]string{
2849+
"--ingroup1", // Should be suggested again since it is a slice
2850+
"--nogroup",
2851+
":4",
2852+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2853+
},
2854+
{
2855+
desc: "group ignored if some flags not applicable",
2856+
args: []string{"--ingroup1", "8", "-"},
2857+
expectedOutput: strings.Join([]string{
2858+
"--ingroup1",
2859+
"--ingroup2",
2860+
":4",
2861+
"Completion ended with directive: ShellCompDirectiveNoFileComp", ""}, "\n"),
2862+
},
2863+
}
2864+
2865+
for _, tc := range testcases {
2866+
t.Run(tc.desc, func(t *testing.T) {
2867+
c := getCmd()
2868+
args := []string{ShellCompNoDescRequestCmd}
2869+
args = append(args, tc.args...)
2870+
output, err := executeCommand(c, args...)
2871+
switch {
2872+
case err == nil && output != tc.expectedOutput:
2873+
t.Errorf("expected: %q, got: %q", tc.expectedOutput, output)
2874+
case err != nil:
2875+
t.Errorf("Unexpected error %q", err)
2876+
}
2877+
})
2878+
}
2879+
}

flag_groups.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,52 @@ func sortedKeys(m map[string]map[string]bool) []string {
172172
sort.Strings(keys)
173173
return keys
174174
}
175+
176+
// enforceFlagGroupsForCompletion will do the following:
177+
// - when a flag in a group is present, other flags in the group will be marked required
178+
// - when a flag in a mutually exclusive group is present, other flags in the group will be marked as hidden
179+
// This allows the standard completion logic to behave appropriately for flag groups
180+
func (c *Command) enforceFlagGroupsForCompletion() {
181+
if c.DisableFlagParsing {
182+
return
183+
}
184+
185+
flags := c.Flags()
186+
groupStatus := map[string]map[string]bool{}
187+
mutuallyExclusiveGroupStatus := map[string]map[string]bool{}
188+
c.Flags().VisitAll(func(pflag *flag.Flag) {
189+
processFlagForGroupAnnotation(flags, pflag, requiredAsGroup, groupStatus)
190+
processFlagForGroupAnnotation(flags, pflag, mutuallyExclusive, mutuallyExclusiveGroupStatus)
191+
})
192+
193+
// If a flag that is part of a group is present, we make all the other flags
194+
// of that group required so that the shell completion suggests them automatically
195+
for flagList, flagnameAndStatus := range groupStatus {
196+
for _, isSet := range flagnameAndStatus {
197+
if isSet {
198+
// One of the flags of the group is set, mark the other ones as required
199+
for _, fName := range strings.Split(flagList, " ") {
200+
_ = c.MarkFlagRequired(fName)
201+
}
202+
}
203+
}
204+
}
205+
206+
// If a flag that is mutually exclusive to others is present, we hide the other
207+
// flags of that group so the shell completion does not suggest them
208+
for flagList, flagnameAndStatus := range mutuallyExclusiveGroupStatus {
209+
for flagName, isSet := range flagnameAndStatus {
210+
if isSet {
211+
// One of the flags of the mutually exclusive group is set, mark the other ones as hidden
212+
// Don't mark the flag that is already set as hidden because it may be an
213+
// array or slice flag and therefore must continue being suggested
214+
for _, fName := range strings.Split(flagList, " ") {
215+
if fName != flagName {
216+
flag := c.Flags().Lookup(fName)
217+
flag.Hidden = true
218+
}
219+
}
220+
}
221+
}
222+
}
223+
}

0 commit comments

Comments
 (0)