2020-10-14, updated: 2024-03-12
Tested with Nyxt 2 Pre-release 3.
Typed, customizable hooks
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:
Hook: a place in the code where we can add code dynamically.
Hook handler: a piece of code that can be evaluated by a hook.
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:
Hooks are first-class objects – not just any list – so that we can tell if something evaluates to a hook.
We can
disable
hook handlers without deleting them, so that they can be toggled on and off.In Emacs, handlers can be any function, including lambdas which make hooks hard to manipulate (e.g. you can't remove a lambda by its name since it has no name).
Lambdas are effectively blackboxes once added to the hook.
Lambdas don't compare, so adding the same lambda twice will unintentionally stack it.
To overcome this limitation while still allowing the use of lambdas, we created a
handler
type with aname
slot. This allows us to fix both issues: The handler can be associated to a lambda and handlers can be compared.In Emacs, hook handlers are always run sequentially and do not return anything. There is no way to customize how they are run and what return value we expect. In particular, they do now allow for composing handlers – which would in effect turn the hook into a pipeline!
Our hook class has a
combination
slot which accepts a function that schedules the handler execution and collects the return value(s).In Emacs, hooks cannot be typed which leads to "hard to catch" errors. E.g., when the user tries to add a function of the wrong type to a hook. Therefore, we've added macros to help define typed hooks.
Emacs hooks are simple lists, they can't be attached to a given object. In our hook system we've added support for globally-accessible hooks, as well as object-bound hooks.
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
a
handler-string->string
class,a
hook-string->string
class,a
make-handler-string->string
function,a
make-hook-string->string
function,a
add-hook
method specialized overhook-string->string
andhandler-string->string
.
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.
(defvar test-hook (hooks:make-hook-string->string))
(hooks:add-hook test-hook
(make-handler-string->string #'my-downcase))
The library comes with the following predefined hook types:
(define-hook-type void (function ()))
(define-hook-type string->string (function (string) string))
(define-hook-type number->number (function (number) number))
(define-hook-type any (function (&rest t)))
- The
void
hook type is for handlers that don't return anything. This is useful when we want to use handlers for their side effects. - The
any
hook type accepts any handler type.
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:
(defvar test-hook (hooks:make-hook-string->string))
(hooks:add-hook test-hook
(make-handler-string->string (lambda (s) (string-downcase s))
:name 'my-downcase))
Disabling handlers
See the disable-hook
and enable-hook
methods which accept multiple handler names as argument.
(disable-hook test-hook 'my-downcase)
(run-hook test-hook "FOO")
; => "FOO"
(enable-hook test-hook 'my-downcase)
(run-hook test-hook "FOO")
; => "foo"
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:
(defvar test-hook (hooks:make-hook-number->number
:handlers (list #'1+ #'square)
:combination #'hooks:combine-composed-hook))
(run-hook test-hook 2)
;; => 9
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:
default-combine-hook
: Return the list of the results of the HOOK handlers applied to the arguments.combine-hook-until-failure
: As above but stop the list at the first handler that returns nil.combine-hook-until-success
: As above but return the first non-nil result.combine-composed-hook
: This is the handler from the above example.
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
:
(hooks:define-hook 'hooks:hook-number->number 'foo)
(hooks:find-hook 'foo)
;; #<HOOKS:HOOK-NUMBER->NUMBER {1007537C83}>
;; T
You can also bind a hook over an object. This hook is unique to the object.
(hooks:define-hook 'hooks:hook-number->number 'foo
:object #'mul2)
(hooks:find-hook 'foo #'mul2)
;; #<HOOKS:HOOK-NUMBER->NUMBER {100757FC43}>
;; T
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 :-)
Did you enjoy this article? Register for our newsletter to receive the latest hacker news from the world of Lisp and browsers!
- Maximum one email per month
- Unsubscribe at any time