Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,12 +445,13 @@ For a complete, runnable implementation of this pattern, see the loginsvc exampl

### 🪃 Custom Type Handlers

Declare options (flags, env vars, config file keys) with custom types by implementing two methods on your options struct.
Declare options (flags, env vars, config file keys) with custom types by implementing methods on your options struct.

Just implement two methods on your options structs:
Implement these methods on your options structs:

- `Define<FieldName>`: return a `pflag.Value` that knows how to handle your custom type, along with an enhanced description.
- `Decode<FieldName>`: decode the input into your custom type.
- `Complete<FieldName>` (optional): provide shell completion candidates for the generated flag value. `structcli.Define()` auto-registers it.

```go
type Environment string
Expand Down Expand Up @@ -482,21 +483,18 @@ func (o *ServerOptions) DecodeTargetEnv(input any) (any, error) {
return EnvDevelopment, nil
}

// Attach handles flag definition and shell completion for our custom type.
func (o *ServerOptions) Attach(c *cobra.Command) error {
if err := structcli.Define(c, o); err != nil {
return err
}

// Register shell completion after the flag has been defined.
c.RegisterFlagCompletionFunc("target-env", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"dev", "staging", "prod"}, cobra.ShellCompDirectiveNoFileComp
})
// CompleteTargetEnv provides shell completion for --target-env.
func (o *ServerOptions) CompleteTargetEnv(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"dev", "staging", "prod"}, cobra.ShellCompDirectiveNoFileComp
}

return nil
func (o *ServerOptions) Attach(c *cobra.Command) error {
return structcli.Define(c, o)
}
```

`Complete<FieldName>` works for any field that becomes a flag (not only `flagcustom:"true"` fields).

In [values](/values/values.go) we provide `pflag.Value` implementations for standard types.

See [full example](examples/full/cli/cli.go) for more details.
Expand Down
12 changes: 11 additions & 1 deletion define.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func define(c *cobra.Command, o any, startingGroup string, structPath string, ex
if !val.IsValid() {
val = internalreflect.GetValue(internalreflect.GetValuePtr(o).Interface())
}
structPtr := internalreflect.GetValuePtr(o)

for i := range val.NumField() {
field := val.Field(i)
Expand Down Expand Up @@ -236,6 +237,15 @@ func define(c *cobra.Command, o any, startingGroup string, structPath string, ex
if err := applyPresetAliases(); err != nil {
return err
}
completeHookName := fmt.Sprintf("Complete%s", f.Name)
completeHookFunc := structPtr.MethodByName(completeHookName)
if completeHookFunc.IsValid() {
if _, exists := c.GetFlagCompletionFunc(name); !exists {
if err := internalhooks.StoreCompletionHookFunc(c, name, completeHookFunc); err != nil {
return fmt.Errorf("couldn't register completion hook %s: %w", completeHookName, err)
}
}
}

return nil
}
Expand All @@ -246,7 +256,7 @@ func define(c *cobra.Command, o any, startingGroup string, structPath string, ex
defineHookName := fmt.Sprintf("Define%s", f.Name)
decodeHookName := fmt.Sprintf("Decode%s", f.Name)

if structPtr := internalreflect.GetValuePtr(o); structPtr.IsValid() {
if structPtr.IsValid() {
defineHookFunc := structPtr.MethodByName(defineHookName)
decodeHookFunc := structPtr.MethodByName(decodeHookName)

Expand Down
239 changes: 222 additions & 17 deletions define_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,10 @@ func (o *comprehensiveCustomOptions) DecodeServerMode(input any) (any, error) {
return s, nil
}

func (o *comprehensiveCustomOptions) CompleteServerMode(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{string(development), string(staging), string(production)}, cobra.ShellCompDirectiveDefault
}

func (o *comprehensiveCustomOptions) DefineSomeConfig(name, short, descr string, structField reflect.StructField, fieldValue reflect.Value) (pflag.Value, string) {
enhancedDesc := descr + " (must be .yaml, .yml, or .json)"
fieldPtr := fieldValue.Addr().Interface().(*string)
Expand All @@ -468,30 +472,19 @@ func (o *comprehensiveCustomOptions) DecodeSomeConfig(input any) (any, error) {
return input, nil
}

func (o *comprehensiveCustomOptions) Attach(c *cobra.Command) error {
// 1. Define all the flags first.
if err := Define(c, o); err != nil {
return err
}

// 2. Now, perform any command-specific setup, like completion.
// This is the new, recommended pattern.
c.RegisterFlagCompletionFunc("server-mode", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{string(development), string(staging), string(production)}, cobra.ShellCompDirectiveDefault
})

c.RegisterFlagCompletionFunc("some-config", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"yaml", "yml", "json"}, cobra.ShellCompDirectiveFilterFileExt
})
func (o *comprehensiveCustomOptions) CompleteSomeConfig(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"yaml", "yml", "json"}, cobra.ShellCompDirectiveFilterFileExt
}

return nil
func (o *comprehensiveCustomOptions) Attach(c *cobra.Command) error {
return Define(c, o)
}

func (suite *structcliSuite) TestFlagcustom_ComprehensiveScenarios() {
opts := &comprehensiveCustomOptions{}

c := &cobra.Command{Use: "test"}
// Call Attach, which now handles both Define and completion setup.
// Call Attach, which now delegates completion registration to Define().
err := opts.Attach(c)
require.NoError(suite.T(), err, "define should work for custom flags too")

Expand All @@ -516,11 +509,223 @@ func (suite *structcliSuite) TestFlagcustom_ComprehensiveScenarios() {
normalFlag := f.Lookup("normal-flag")
assert.NotNil(suite.T(), normalFlag, "normal flags should still work")

modeCompletion, modeCompletionExists := c.GetFlagCompletionFunc("server-mode")
require.True(suite.T(), modeCompletionExists, "server-mode completion should be registered")
modeSuggestions, modeDirective := modeCompletion(c, nil, "")
assert.Equal(suite.T(), []string{string(development), string(staging), string(production)}, modeSuggestions)
assert.Equal(suite.T(), cobra.ShellCompDirectiveDefault, modeDirective)

configCompletion, configCompletionExists := c.GetFlagCompletionFunc("some-config")
require.True(suite.T(), configCompletionExists, "some-config completion should be registered")
configSuggestions, configDirective := configCompletion(c, nil, "")
assert.Equal(suite.T(), []string{"yaml", "yml", "json"}, configSuggestions)
assert.Equal(suite.T(), cobra.ShellCompDirectiveFilterFileExt, configDirective)

// Assert a field without a real flag tag is skipped
missingFlag := f.Lookup("no-method")
assert.Nil(suite.T(), missingFlag, "flags without methods should be skipped")
}

type autoCompleteOptions struct {
Region string `flag:"region" flagdescr:"target region"`
}

func (o *autoCompleteOptions) Attach(c *cobra.Command) error { return nil }

func (o *autoCompleteOptions) CompleteRegion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"us-east-1", "us-west-2"}, cobra.ShellCompDirectiveNoFileComp
}

func (suite *structcliSuite) TestDefine_AutoRegistersCompletionForStandardFlags() {
opts := &autoCompleteOptions{}
c := &cobra.Command{Use: "test"}

require.NoError(suite.T(), Define(c, opts))

completion, exists := c.GetFlagCompletionFunc("region")
require.True(suite.T(), exists, "region completion should be registered")

suggestions, directive := completion(c, nil, "us")
assert.Equal(suite.T(), []string{"us-east-1", "us-west-2"}, suggestions)
assert.Equal(suite.T(), cobra.ShellCompDirectiveNoFileComp, directive)
}

type builtInCompletionOptions struct {
LogLevel zapcore.Level `flag:"log-level" flagdescr:"log level"`
}

func (o *builtInCompletionOptions) Attach(c *cobra.Command) error { return nil }

func (o *builtInCompletionOptions) CompleteLogLevel(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"debug", "info", "warn", "error"}, cobra.ShellCompDirectiveNoFileComp
}

func (suite *structcliSuite) TestDefine_AutoRegistersCompletionForBuiltInHookFlags() {
opts := &builtInCompletionOptions{}
c := &cobra.Command{Use: "test"}

require.NoError(suite.T(), Define(c, opts))

completion, exists := c.GetFlagCompletionFunc("log-level")
require.True(suite.T(), exists, "log-level completion should be registered")

suggestions, directive := completion(c, nil, "de")
assert.Equal(suite.T(), []string{"debug", "info", "warn", "error"}, suggestions)
assert.Equal(suite.T(), cobra.ShellCompDirectiveNoFileComp, directive)
}

type nestedCompletionChild struct {
Profile string `flag:"profile"`
}

func (o *nestedCompletionChild) CompleteProfile(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"default", "strict"}, cobra.ShellCompDirectiveNoFileComp
}

type nestedCompletionOptions struct {
Child nestedCompletionChild
}

func (o *nestedCompletionOptions) Attach(c *cobra.Command) error { return nil }

func (suite *structcliSuite) TestDefine_AutoRegistersCompletionForNestedStructFlags() {
opts := &nestedCompletionOptions{}
c := &cobra.Command{Use: "test"}

require.NoError(suite.T(), Define(c, opts))

completion, exists := c.GetFlagCompletionFunc("profile")
require.True(suite.T(), exists, "nested profile completion should be registered")

suggestions, directive := completion(c, nil, "")
assert.Equal(suite.T(), []string{"default", "strict"}, suggestions)
assert.Equal(suite.T(), cobra.ShellCompDirectiveNoFileComp, directive)
}

type manualCompletionPrecedenceOptions struct {
Mode string `flag:"mode"`
}

func (o *manualCompletionPrecedenceOptions) Attach(c *cobra.Command) error { return nil }

func (o *manualCompletionPrecedenceOptions) CompleteMode(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"hook"}, cobra.ShellCompDirectiveNoFileComp
}

func (suite *structcliSuite) TestDefine_DoesNotOverrideExistingFlagCompletion() {
opts := &manualCompletionPrecedenceOptions{}
c := &cobra.Command{Use: "test"}
c.Flags().String("mode", "", "pre-defined mode")

require.NoError(suite.T(), c.RegisterFlagCompletionFunc("mode", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"manual"}, cobra.ShellCompDirectiveDefault
}))

require.NoError(suite.T(), Define(c, opts))

completion, exists := c.GetFlagCompletionFunc("mode")
require.True(suite.T(), exists, "mode completion should exist")

suggestions, directive := completion(c, nil, "")
assert.Equal(suite.T(), []string{"manual"}, suggestions, "existing completion must take precedence")
assert.Equal(suite.T(), cobra.ShellCompDirectiveDefault, directive)
}

type ignoreCompletionOptions struct {
Hidden string `flag:"hidden" flagignore:"true"`
Visible string `flag:"visible"`
}

func (o *ignoreCompletionOptions) Attach(c *cobra.Command) error { return nil }

func (o *ignoreCompletionOptions) CompleteHidden(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"should-not-register"}, cobra.ShellCompDirectiveNoFileComp
}

func (o *ignoreCompletionOptions) CompleteVisible(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"register-me"}, cobra.ShellCompDirectiveNoFileComp
}

func (suite *structcliSuite) TestDefine_DoesNotRegisterCompletionForIgnoredFlags() {
opts := &ignoreCompletionOptions{}
c := &cobra.Command{Use: "test"}

require.NoError(suite.T(), Define(c, opts))
assert.Nil(suite.T(), c.Flags().Lookup("hidden"), "ignored flag should not be defined")
require.NotNil(suite.T(), c.Flags().Lookup("visible"), "visible flag should be defined")

_, hiddenCompletionExists := c.GetFlagCompletionFunc("hidden")
assert.False(suite.T(), hiddenCompletionExists, "ignored flag completion should not be registered")

visibleCompletion, visibleCompletionExists := c.GetFlagCompletionFunc("visible")
require.True(suite.T(), visibleCompletionExists, "visible completion should be registered")
suggestions, directive := visibleCompletion(c, nil, "")
assert.Equal(suite.T(), []string{"register-me"}, suggestions)
assert.Equal(suite.T(), cobra.ShellCompDirectiveNoFileComp, directive)
}

type presetCompletionOptions struct {
Level int `flag:"level" flagpreset:"max=10"`
}

func (o *presetCompletionOptions) Attach(c *cobra.Command) error { return nil }

func (o *presetCompletionOptions) CompleteLevel(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"1", "5", "10"}, cobra.ShellCompDirectiveNoFileComp
}

func (suite *structcliSuite) TestDefine_RegistersCompletionOnlyForCanonicalFlagWithPresets() {
opts := &presetCompletionOptions{}
c := &cobra.Command{Use: "test"}

require.NoError(suite.T(), Define(c, opts))
require.NotNil(suite.T(), c.Flags().Lookup("level"), "canonical flag should be defined")
require.NotNil(suite.T(), c.Flags().Lookup("max"), "preset alias flag should be defined")

levelCompletion, levelCompletionExists := c.GetFlagCompletionFunc("level")
require.True(suite.T(), levelCompletionExists, "canonical flag completion should be registered")

suggestions, directive := levelCompletion(c, nil, "")
assert.Equal(suite.T(), []string{"1", "5", "10"}, suggestions)
assert.Equal(suite.T(), cobra.ShellCompDirectiveNoFileComp, directive)

_, aliasCompletionExists := c.GetFlagCompletionFunc("max")
assert.False(suite.T(), aliasCompletionExists, "preset alias should not get value completion")
}

type completionForwardingOptions struct {
Target string `flag:"target"`
seenCommandUse string
seenArgs []string
seenToComplete string
}

func (o *completionForwardingOptions) Attach(c *cobra.Command) error { return nil }

func (o *completionForwardingOptions) CompleteTarget(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
o.seenCommandUse = cmd.Use
o.seenArgs = append([]string{}, args...)
o.seenToComplete = toComplete

return []string{"ok"}, cobra.ShellCompDirectiveNoFileComp
}

func (suite *structcliSuite) TestDefine_ForwardsCompletionInvocationArguments() {
opts := &completionForwardingOptions{}
c := &cobra.Command{Use: "test"}

require.NoError(suite.T(), Define(c, opts))
completion, exists := c.GetFlagCompletionFunc("target")
require.True(suite.T(), exists, "target completion should be registered")

suggestions, directive := completion(c, []string{"sub", "cmd"}, "to-comp")
assert.Equal(suite.T(), []string{"ok"}, suggestions)
assert.Equal(suite.T(), cobra.ShellCompDirectiveNoFileComp, directive)
assert.Equal(suite.T(), "test", opts.seenCommandUse)
assert.Equal(suite.T(), []string{"sub", "cmd"}, opts.seenArgs)
assert.Equal(suite.T(), "to-comp", opts.seenToComplete)
}

type nestedStruct struct {
Value string `flagdescr:"nested value"`
}
Expand Down
Loading