ADR 0142: Four things a plugin can react to¶
Status¶
Accepted
Context¶
ADR 0141 gave a plugin the editor rather than just its text, but only when asked: a command runs because somebody typed its name or pressed its key. Nothing a plugin writes can run because something happened.
That rules out the plugins people most want: format on save, lint on change, a status widget that follows the caret, project tooling that wakes when a file opens.
Decision¶
Four events, and the number is the decision as much as the list is:
| event | when |
|---|---|
buffer_changed |
the text may have changed |
cursor_moved |
the caret is somewhere else |
file_saved |
the file is on disk |
file_opened |
a buffer is ready to be worked on |
Each one is something plugins actually want. Every additional event is a promise about exactly when it fires, and that promise has to hold forever, so the bar for a fifth is high.
A hook has a command's signature. A hook is a command nobody typed,
and it gets the same AseEditorContext.
Coalesced, because the alternative is typing latency¶
buffer_changed would naturally fire from refreshCache(), which is
the per-keystroke choke point, on the thread that draws. A hook taking
5ms would then cost 5ms per keystroke.
Instead the two hot events are coalesced onto a 50ms single-shot timer.
A burst of typing is one buffer_changed; holding j is one
cursor_moved. They describe what changed since the last one rather
than every step in between.
The timer only exists while something is listening. schedule()
returns immediately when no hook is registered for either event, so an
editor with no plugins never starts it and pays nothing.
The caret is compared, not flagged. There are 56 places that move it, and instrumenting all of them is 56 chances to miss one. The flush compares the current offset against the last one reported — which also means a caret that ends where it started correctly did not move.
What a hook does is not itself an event¶
The recommendation asked for this to be decided rather than discovered as a crash. Two layers:
In the host, one re-entrancy flag for all events, not one per
event. A per-event flag stops buffer_changed calling itself but not a
buffer_changed hook that moves the caret whose hook edits the buffer —
two hooks bouncing forever. One flag makes any chain terminate. Both
cases are tests, and the single-event one takes the stack out without
the guard.
Across the timer, the same rule, and here it is load-bearing rather
than defensive: emitPluginEvent() calls refreshCache() afterwards,
which sets the buffer-changed flag. Without suppression a hook that
edits would raise the event that called it one tick later, forever.
Only the hook's own contribution is taken back. Clearing the flags
outright was the first attempt and it was wrong: the deferred
file_opened hook ran during the first event-loop spin and swallowed
the typing that had happened before it. The pending state is saved
across the emit and restored.
A related case, found the same way: runPluginCommand() calls
refreshCache() whether or not the command changed anything, so running
any plugin command looked like typing. It already knows whether the
text changed, and now says so.
A hook that edits is one undo step¶
A hook edits the AseBuffer directly, underneath the undo stack, which
is the problem ADR 0128 solved for
commands and which hooks reintroduced — they were not recorded at all.
The put-back-and-redo is now one function both use.
file_saved fires after the write, and may cause a second one¶
Firing before the write would make the name a lie. Firing after it left
a formatter's output in the buffer and the unformatted text on disk —
verified by looking, which is how this was caught: the editor showed
stripped whitespace and cat -A showed the file still had it. Two saves
to save once is not format-on-save.
So the file is written again when a hook changed the buffer, and the
buffer ends clean. Once, not until it settles: a hook's edit does not
raise file_saved, so the second write cannot call the hook again.
Consequences¶
Nineteen tests. Five in core for the registry, ordering, the two
re-entrancy shapes and the arguments nobody should pass; five in the GUI
driving a real editor with a real Lua plugin — a burst of twelve
keystrokes is asserted to be exactly one event, j k back to the
start exactly none.
Verified in the running editor with a strip-trailing-whitespace plugin:
one :w left the file clean on disk, the status said it ran once, the
tab showed no dirty dot, and u took the strip back.
What this does not do: hooks are synchronous and on the UI thread, so a slow hook is still a slow editor. The guide says "must be fast" because nothing enforces it. Making hooks async would need a thread the plugin API does not have and an answer for what a hook sees when the buffer moves underneath it — a bigger design than this one.
buffer_changed means the text may have changed: refreshCache() is
the signal and a few of its callers rebuild without an edit. A cursor
move alone never raises it, which is the distinction that matters.