Repository: alecthomas/kong
Pull request #511 (2025-03-20 22:28:22)

feat: Allow configuring hooks via Kong's functional options
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.

Also updates the README.md to [list a table of hooks with their meanings](https://github.com/boblail/kong/tree/lail/register-hooks-via-Options?tab=readme-ov-file#hooks-beforereset-beforeresolve-beforeapply-afterapply).




Patch:
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/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 {


Test patch:
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"`