From 78d4066dabe5432d604eb28ab5376a3ad1708e29 Mon Sep 17 00:00:00 2001 From: Bob Lail Date: Fri, 21 Mar 2025 20:04:20 -0700 Subject: [PATCH] feat: Allow configuring global hooks via Kong's functional options (#511) Lets you pass `kong.WithBeforeApply` along with a function that supports dynamic bindings to register a `BeforeApply` hook without tying it directly to a node in the schema. Co-authored-by: Sutina Wipawiwat --- README.md | 18 ++++++++++++---- hooks.go | 6 ++++++ kong.go | 15 ++++++++++++- kong_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ options.go | 34 ++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9e1d6cc..9f21cfe 100644 --- a/README.md +++ b/README.md @@ -308,10 +308,16 @@ func main() { ## Hooks: BeforeReset(), BeforeResolve(), BeforeApply(), AfterApply() -If a node in the CLI, or any of its embedded fields, has a `BeforeReset(...) error`, `BeforeResolve -(...) error`, `BeforeApply(...) error` and/or `AfterApply(...) error` method, those -methods will be called before values are reset, before validation/assignment, -and after validation/assignment, respectively. +If a node in the CLI, or any of its embedded fields, implements a `BeforeReset(...) error`, `BeforeResolve +(...) error`, `BeforeApply(...) error` and/or `AfterApply(...) error` method, those will be called as Kong +resets, resolves, validates, and assigns values to the node. + +| Hook | Description | +| --------------- | ----------------------------------------------------------------------------------------------------------- | +| `BeforeReset` | Invoked before values are reset to their defaults (as defined by the grammar) or to zero values | +| `BeforeResolve` | Invoked before resolvers are applied to a node | +| `BeforeApply` | Invoked before the traced command line arguments are applied to the grammar | +| `AfterApply` | Invoked after command line arguments are applied to the grammar **and validated**` | The `--help` flag is implemented with a `BeforeReset` hook. @@ -340,6 +346,10 @@ func main() { } ``` +It's also possible to register these hooks with the functional options +`kong.WithBeforeReset`, `kong.WithBeforeResolve`, `kong.WithBeforeApply`, and +`kong.WithAfterApply`. + ## The Bind() option Arguments to hooks are provided via the `Run(...)` method or `Bind(...)` option. `*Kong`, `*Context`, `*Path` and parent commands are also bound and finally, hooks can also contribute bindings via `kong.Context.Bind()` and `kong.Context.BindTo()`. diff --git a/hooks.go b/hooks.go index 9fdf24c..e95d21b 100644 --- a/hooks.go +++ b/hooks.go @@ -1,5 +1,11 @@ package kong +// BeforeReset is a documentation-only interface describing hooks that run before defaults values are applied. +type BeforeReset interface { + // This is not the correct signature - see README for details. + BeforeReset(args ...any) error +} + // BeforeResolve is a documentation-only interface describing hooks that run before resolvers are applied. type BeforeResolve interface { // This is not the correct signature - see README for details. diff --git a/kong.go b/kong.go index a09b711..4f3901f 100644 --- a/kong.go +++ b/kong.go @@ -71,6 +71,8 @@ type Kong struct { postBuildOptions []Option embedded []embedded dynamicCommands []*dynamicCommand + + hooks map[string][]reflect.Value } // New creates a new Kong parser on grammar. @@ -84,6 +86,7 @@ func New(grammar any, options ...Option) (*Kong, error) { registry: NewRegistry().RegisterDefaults(), vars: Vars{}, bindings: bindings{}, + hooks: make(map[string][]reflect.Value), helpFormatter: DefaultHelpValueFormatter, ignoreFields: make([]*regexp.Regexp, 0), flagNamer: func(s string) string { @@ -366,7 +369,7 @@ func (k *Kong) applyHook(ctx *Context, name string) error { default: panic("unsupported Path") } - for _, method := range getMethods(value, name) { + for _, method := range k.getMethods(value, name) { binds := k.bindings.clone() binds.add(ctx, trace) binds.add(trace.Node().Vars().CloneWith(k.vars)) @@ -380,6 +383,16 @@ func (k *Kong) applyHook(ctx *Context, name string) error { return k.applyHookToDefaultFlags(ctx, ctx.Path[0].Node(), name) } +func (k *Kong) getMethods(value reflect.Value, name string) []reflect.Value { + return append( + // Identify callbacks by reflecting on value + getMethods(value, name), + + // Identify callbacks that were registered with a kong.Option + k.hooks[name]..., + ) +} + // Call hook on any unset flags with default values. func (k *Kong) applyHookToDefaultFlags(ctx *Context, node *Node, name string) error { if node == nil { diff --git a/kong_test.go b/kong_test.go index 3ea6b5f..f2d315a 100644 --- a/kong_test.go +++ b/kong_test.go @@ -588,6 +588,65 @@ func TestHooks(t *testing.T) { } } +func TestGlobalHooks(t *testing.T) { + var cli struct { + One struct { + Two string `kong:"arg,optional"` + Three string + } `cmd:""` + } + + called := []string{} + log := func(name string) any { + return func(value *kong.Path) error { + switch { + case value.App != nil: + called = append(called, fmt.Sprintf("%s (app)", name)) + + case value.Positional != nil: + called = append(called, fmt.Sprintf("%s (arg) %s", name, value.Positional.Name)) + + case value.Flag != nil: + called = append(called, fmt.Sprintf("%s (flag) %s", name, value.Flag.Name)) + + case value.Argument != nil: + called = append(called, fmt.Sprintf("%s (arg) %s", name, value.Argument.Name)) + + case value.Command != nil: + called = append(called, fmt.Sprintf("%s (cmd) %s", name, value.Command.Name)) + } + return nil + } + } + p := mustNew(t, &cli, + kong.WithBeforeReset(log("BeforeReset")), + kong.WithBeforeResolve(log("BeforeResolve")), + kong.WithBeforeApply(log("BeforeApply")), + kong.WithAfterApply(log("AfterApply")), + ) + + _, err := p.Parse([]string{"one", "two", "--three=THREE"}) + assert.NoError(t, err) + assert.Equal(t, []string{ + "BeforeReset (app)", + "BeforeReset (cmd) one", + "BeforeReset (arg) two", + "BeforeReset (flag) three", + "BeforeResolve (app)", + "BeforeResolve (cmd) one", + "BeforeResolve (arg) two", + "BeforeResolve (flag) three", + "BeforeApply (app)", + "BeforeApply (cmd) one", + "BeforeApply (arg) two", + "BeforeApply (flag) three", + "AfterApply (app)", + "AfterApply (cmd) one", + "AfterApply (arg) two", + "AfterApply (flag) three", + }, called) +} + func TestShort(t *testing.T) { var cli struct { Bool bool `short:"b"` diff --git a/options.go b/options.go index d20b2fb..5fe3532 100644 --- a/options.go +++ b/options.go @@ -123,6 +123,40 @@ func PostBuild(fn func(*Kong) error) Option { }) } +// WithBeforeReset registers a hook to run before fields values are reset to their defaults +// (as specified in the grammar) or to zero values. +func WithBeforeReset(fn any) Option { + return withHook("BeforeReset", fn) +} + +// WithBeforeResolve registers a hook to run before resolvers are applied. +func WithBeforeResolve(fn any) Option { + return withHook("BeforeResolve", fn) +} + +// WithBeforeApply registers a hook to run before command line arguments are applied to the grammar. +func WithBeforeApply(fn any) Option { + return withHook("BeforeApply", fn) +} + +// WithAfterApply registers a hook to run after values are applied to the grammar and validated. +func WithAfterApply(fn any) Option { + return withHook("AfterApply", fn) +} + +// withHook registers a named hook. +func withHook(name string, fn any) Option { + value := reflect.ValueOf(fn) + if value.Kind() != reflect.Func { + panic(fmt.Errorf("expected function, got %s", value.Type())) + } + + return OptionFunc(func(k *Kong) error { + k.hooks[name] = append(k.hooks[name], value) + return nil + }) +} + // Name overrides the application name. func Name(name string) Option { return PostBuild(func(k *Kong) error {