Skip to content

ADR 0141: What a plugin is handed

Status

Accepted

Context

ADR 0009 shipped the smallest plugin ABI that worked, deliberately:

void (*)(AseBuffer *buffer, void *user_data)

A plugin could change text. That is all. It could not read the caret, see what was selected, read a config value, or say anything to the user. Almost every plugin people actually write — surround, comment toggle, align, sort the selection, convert case — needs "where am I / what is selected" and nothing more exotic than that, so the ABI excluded most of its own use cases.

EXTENSIBILITY.md's recommendation 2 named the answer and the reason: an opaque context handle with accessor functions, not more fields on a struct. A struct that grows a pointer per feature breaks every plugin on every release.

Decision

A command is handed an AseEditorContext *. The handle is opaque and every capability is a function, so gaining one is an added function rather than a changed layout: a plugin keeps working across a version that adds something it does not call.

AseBuffer  *ase_ctx_buffer(AseEditorContext *ctx);
size_t      ase_ctx_cursor(const AseEditorContext *ctx);
void        ase_ctx_set_cursor(AseEditorContext *ctx, size_t offset);
bool        ase_ctx_selection(const AseEditorContext *ctx, size_t *start, size_t *end);
void        ase_ctx_set_selection(AseEditorContext *ctx, size_t start, size_t end);
const char *ase_ctx_config(const AseEditorContext *ctx, const char *key);
void        ase_ctx_status(AseEditorContext *ctx, const char *message);

The editor answers, core only carries

The context holds a vtable the editor fills in and a void * back to it. Core defines the shape and forwards; it has no idea what a caret is.

That vtable is the one struct here that may grow fields freely, because no plugin ever sees it — it is filled by the GUI, which is compiled against the same header. The opacity that protects plugins costs nothing on the inside.

Every entry may be NULL, and every accessor returns the documented empty answer when it is: a host that cannot do something says so by leaving it out. A test's stand-in editor can implement three functions and be a valid host, and a NULL context behaves the same again, so nothing needs a null check at the call site.

Cursor and selection are requests, not writes

runPluginCommand() takes the whole buffer apart and puts it back to record the plugin's edit as one undo step (ADR 0128). A caret written straight into m_cursors during the command would be overwritten by that rebuild.

So set_cursor and set_selection record what was asked for, and the request is applied after the rebuild, clamped to the buffer the plugin actually left behind. A plugin that deletes half the file and then asks for a caret at the old end gets the new end instead of a crash.

The selection a plugin sees is the one on screen

Vim's visual selection includes the character under the caret; this editor's anchor/cursor pair does not (ADR 0078). Reading selectionMaxAt() directly would have handed plugins a range one character shorter than the highlight the user is looking at — v then four l over hello would surround hell.

It goes through vimVisualEnd(), which is what the painter and every visual operator already use. The test caught this on its first run.

ABI 2, and a stale plugin is refused

The command signature changed, so the ABI version did. A native plugin built for ABI 1 that got called through ABI 2 would take a context where it expects a buffer — silent memory corruption, and nothing in ABI 1 would have caught it, because the version lived in the struct the host hands the plugin, which a stale plugin is free to ignore.

Native plugins now export ASE_PLUGIN_ABI;, a one-line macro declaring what they were built against. The loader reads it before calling anything, and refuses a plugin that disagrees or omits it:

ase: plugin 'x.so' is built for ABI 0, this editor speaks 2

Lua plugins did not change at all

A Lua command's argument is the context now rather than the buffer. But ase.buffer_length, ase.buffer_get_text, ase.buffer_insert and ase.buffer_delete take the context too and reach the buffer through it, so a script written against ABI 1 passes back the value it was handed and keeps working with no edit.

That was worth arranging rather than accepting a break: the alternative made every existing script silently treat a context as a buffer.

Consequences

Twenty tests. The ABI 1 Lua fixture is deliberately left untouched, so the line that runs it is the proof that old scripts still work. A second native fixture is built without the version symbol, lands in the same directory as the good one, and the test asserts the command it would have registered is absent — proving the refusal happens before ase_plugin_register is called, not after.

Through the real editor: a Lua surround plugin over a visual selection in the running app, which read the selection, edited both ends, moved the caret and reported surrounded 6 bytes, font=monospace in the status bar — selection, mutation, caret, config and status in one command, none of which ABI 1 could express.

Clean under ASan and UBSan.

Not addressed: a : command run from Visual mode leaves the editor in Visual with the selection collapsed. That predates this — a built-in does the same — but plugins make it easy to hit, since acting on a selection is now the common case.

Events are still the missing half. A plugin can be asked to act; it cannot yet react.