Tested with Nyxt 2 Pre-release 3

Typed, customizable hooks

Typed, customizable hooks

By Pierre Neidhardt

Below, we share what we've learned about hook design, extensibility, and how we've improved upon legacy hook systems in Nyxt.

Note: For an introduction to hooks in Nyxt, see our other article on hooks.

Definitions:

During the development of Nyxt we quickly felt dissatisfied with our initial hook implementation (based on Emacs, built with cl-hooks (https://github.com/scymtym/architecture.hooks)). Hooks are an important extension feature. They need to be powerful, reliable, and easy to use.

No existing implementations satisfied our needs, so we decided to write our own with the following enhancements/concepts:

Our work has now been merged in Serapeum and can be accessed from the serapeum/contrib/hooks package.

Let's have a look at the implementation details!

Declaring new hook types

We provide a define-hook-type macro. For instance

will generate

Say we've got a #'my-downcase function of type (function (string) string). Now we can create a hook and add #'my-downcase to it.

The library comes with the following predefined hook types:

Lambdas as handlers

You don't always want to declare top-level functions before adding a handler to a hook. So the above example could be replaced with the following:

Disabling handlers

See the disable-hook and enable-hook methods which accept multiple handler names as argument.

Not passing any handler name is equivalent to selecting all handlers.

Disabling a handler and re-enabling it moves it to the front of the handler list, which may change the handler order. Keep this in mind if execution order matters!

Handler combinations

A hook can be configured in how it runs its handlers. Example:

In the above the result of the first handler is passed as the input to the second and so on. The final result is the output of the last handler.

The library provides a few default combination functions:

Typing

A common pitfall that keeps tripping Emacs users is when a handler is added to a hook that takes an argument of an unexpected type. This kind of error is usually only caught at runtime.

This is why we've introduced typing in our library. In the [[Declaring new hook types]] section we saw that defining a hook type generates a new add-hook method that's specialized over the specified types. Since there is only one such method, it's only possible to call add-hook over the right handler object, which is created by the associated typed handler constructor (e.g. handler-string->string).

Common Lisp compilers like SBCL perform function type-checking at compile time, which allows us to catch errors early when the user tries to create a handler over a function of the wrong type.

Global hooks and object-bound hooks

The define-hook function allows for registering hooks globally without binding them to global variables.

With just a type and a name, it defines a global hook which can then be accessed with the find-hook:

You can also bind a hook over an object. This hook is unique to the object.

Conclusion

We've been using our novel hook system in Nyxt for a while now and it's proven both robust and flexible. It has removed a whole class of errors from user configurations!

We hope these design decisions will be met with success. It'd be great to see this kind of sophistication in Emacs and other extensible programs!

Thanks for reading :-)