Plugins
cdin has a loader, a site directory, and an extension contract. It does not have a package manager, a plugin registry, a dependency resolver, or network access at runtime.
That is not a missing feature so much as a boundary. The loader is about twenty lines of intent; the interesting part is what it deliberately refuses to do, and why each refusal matters. Everything above that line — installing, versioning, dependency order, a UI for it — is cdin-x, which is free to be as large as it needs to be.
And a build ships that half, marked essential next to vim, so the panel is
reachable in every editor rather than only in one that was set up by hand. Which
of the two you are talking about matters: data/core/ owns what loads, cdin-x
owns what is installed. Extensions is the user’s view of it.
Two roots, and the difference decides everything
| root | what it is | who puts it there | when it loads |
|---|---|---|---|
EXEDIR/data/plugins |
bundled — the mandatory set | a build, from cdin-x | always, whatever config.plugins says |
config.site_path()/plugins |
site — what you installed | you, or cdin-x’s manager | only if config.plugins selects it |
Every bundled entry loads, always. Not “the ones that are enabled” — all of
them. That is what makes a build a runnable editor rather than a runtime with
plugins available, and it is why config.plugins = false and --no-plugins
cannot turn off vim mode: a cdin without it is not an editor, and a debugging
flag is not a policy.
A failing bundled plugin is reported at error level and the rest still load. A failing site plugin is logged and skipped. The editor always starts, because an editor that refuses to open is a worse bug than a missing feature.
A bundled plugin always wins over a site plugin of the same name — on disk and at runtime. The scan is bundled-first and first-seen-wins, so a name is never loaded twice from two places. The loser is skipped without a log line, which makes this the quietest of the failure modes here: if you shadow a bundled name while developing, your version simply never runs.
What counts as a plugin
Either of these, in either root:
plugins/my-plugin/init.lua a directory
plugins/my-plugin.lua a single file
The name is the directory name or the filename without .lua. Entry points are
loaded in sorted order by name, not in filesystem order — so load order is
reproducible, and a plugin cannot rely on being scanned first because its name
sorted earlier by accident.
Load order within a root is alphabetical, and that is not dependency order. If plugin A needs something plugin B registered, A has to be robust to running first. Where that is not acceptable, the answer is cdin-x’s manager, which sorts on declared dependencies.
What a plugin is handed
-- <site>/plugins/my-plugin/init.lua
local M = {}
function M.init(core, config)
-- called with two positional arguments
end
function M.unload() -- optional
end
return M -- must return a table
Two things are easy to get wrong here, and both fail quietly.
init receives two arguments, positionally. function M.init(config) compiles
perfectly and then reads the core table as your config. Worth checking by eye
rather than by testing.
The entry point must return a table. A nil return is reported as
entry point must return a table, not as a missing-plugin error.
init may be called more than once. The loader dofiles the entry point — it
does not require it — so a reload re-runs the file’s body, while sibling modules
you pulled in with require stay cached from the first load. Guard it:
local loaded = false
function M.init(core, config)
if loaded then return end
loaded = true
-- …
end
Without the guard, an enable/disable cycle doubles every registration the body
performs. The loader’s command.add duplicate-name assert is what turns that
into a visible failure rather than a silent one — but only for commands.
unload is the counterpart, and using it is what makes the cycle work. A
plugin that registers and never unregisters accumulates one copy per load. The
loader calls it on core:unload-plugin and nothing else; it is not called on
exit.
Requiring your own files
The site directory itself is added to package.path, as site/?.lua and
site/?/init.lua — not site/plugins, so a plugin’s own files are addressed
through the plugins. prefix:
require("plugins.my-plugin.commands") -- → <site>/plugins/my-plugin/commands.lua
require("plugins.my-plugin.util") -- → <site>/plugins/my-plugin/util/init.lua
The roots are appended, never prepended. A site plugin may extend the editor;
it may not shadow the core it extends, nor the bundled modules the runtime loads
first. That is why a plugin that wants to replace a core.* behaviour has to do
it through one of the seams below rather than by putting a file in the way.
Your init.lua runs before that happens, so you cannot require a site
plugin’s module from it. The loader extends package.path as the first thing
load_all() does, and load_all() is step 11 of the boot order — your file is
step 9. Anything you want from a site plugin has to happen in that plugin’s own
init(), or in a core.add_thread you start from a command. What you can do
in init.lua is set configuration and bind keys, which is what almost everything
there is.
The seams
There is no hook or event system. There are a set of provider registries and document hooks, and beyond that you wrap a function and call the original. Every seam below is documented in the extension contract, which is the normative version.
Provider registries
The clean ones. Register, and the runtime uses your answer; the runtime never learns what a plugin is for.
| call | what it claims |
|---|---|
core.register_status_pill(key, fn) |
one coloured badge at the left of the status bar; fn returns nil or (text, bg_key, fg_key) |
core.register_vcs_provider(provider) |
{ is_ignored, refresh_ignored_now, status } — file ignoring and the git branch in the status bar |
core.register_recent_provider(provider) |
the recent-item list on the empty view |
core.register_help_shortcuts(list) |
entries for the quick-reference list on the empty view; returns a handle |
core.unregister_help_shortcuts(handle) |
removes that group |
style.set_fallback(key, hex) |
a default colour for a key your plugin reads |
A pill returns style key names, not colours. The second and third values are
resolved as style[bg_key] and style[fg_key], so they must be names of keys a
theme defines — "vim_normal_bg", "accent" — and that is what lets the badge
follow a theme change. A literal colour there will not work.
A pill whose function returns nil draws nothing, so the badge disappears rather
than sitting there showing a zero. register_status_pill is keyed, so
core._status_pills[key] = nil removes one — documented here because the
registry has no remove and a plugin that registered it is the only thing that
should clear it.
core.register_vcs_provider cannot be called from init(). Unlike the other
three, it is installed from inside the project scanner’s thread body, and that
thread does not start until the frame loop runs — after every plugin’s init()
has returned. Calling it there gives attempt to call a nil value. This is a real
rough edge in the runtime, not a mistake on your part: defer the call with
core.add_thread, or tolerate a nil function and register on a later pass.
core.unregister_help_shortcuts returns false for a handle that was never
registered rather than raising, because unload also runs on the error path and a
second call should be harmless.
Document hooks
Three lists on the Doc class, called with the document:
table.insert(Doc._before_save, fn) -- fn(doc) before writing to disk
table.insert(Doc._after_save, fn) -- fn(doc) after writing
table.insert(Doc._after_load, fn) -- fn(doc) after reading
These are preferred over wrapping Doc.save, because wrapping it puts your code
in the path of every save including ones you did not want to touch, and because
unloading is table.remove rather than a restore-everything dance. cdin-x’s
trimwhitespace uses _before_save and its autoreload uses _after_load and
_after_save; neither wraps anything.
The prompt
core.command_view is the one prompt primitive:
core.command_view:enter(label, submit, suggest, cancel)
core.command_view:set_text(text)
submit(text, suggestion) and suggest(text) -> items may both be nil. An item
is anything with a text field; cdin’s own doc:go-to-line adds a __tostring
metatable so an item can be both a display string and carry a payload.
A plugin that builds a second prompt rather than using this one ends up with two implementations of the same thing, and the one in the plugin is the one nobody tests.
Wrapping a function
For everything else, save the original and put it back on unload:
local original = { update = RootView.update }
RootView.update = function(...) original.update(...) end
function M.unload()
RootView.update = original.update
end
core.quit, keymap.on_key_pressed, StatusView.get_items and
RootView.on_text_input / update / draw are wrapped this way by the
extensions that need them. Wrap the outermost one, and restore it in
unload — a wrapper that leaks is a wrapper that runs again next load.
What cdin hands you outright
core.add_thread(fn, weak_ref) |
a coroutine; coroutine.yield(seconds) to sleep. Never block the frame loop with I/O |
core.try(fn, ...) |
run it, log the error, return whether it worked |
core.log(fmt, …) / core.log_quiet(…) / core.error(fmt, …) |
printf-style; the error level goes to the log view |
core.open_doc(filename) |
open or create; returns the existing one if already open |
core.active_view, core.last_active_view |
the focused view, and the one before it |
core.active_docview() |
the active view if it is a document view, else nil |
core.set_active_view(view) |
focus a view |
core.set_project_dir(path) |
change the working directory; the project scan follows |
core.reload_module(name) |
drop from package.loaded, require again, fold the new fields back into the old table |
core.push_clip_rect / core.pop_clip_rect |
scoped clipping, for a custom view |
core.redraw = true |
after any state change a view caches |
core.reload_module folding new fields into the old table is the detail worth
knowing: a module somebody is still holding stays valid instead of becoming a
second, stale copy of itself. What it does not undo is what the module
registered while loading. A module that registers commands at its top level and
gets reloaded will collide with itself — so write modules so their top level is
declarations and their init() does the registering.
core.set_project_dir is a runtime operation, not a workflow. The working
directory is the project; the file list and revision the views read are runtime
state. The prompt that asks which directory is a workflow, and lives in a plugin
that calls this.
Managing the set at runtime
Three commands, and they are the only way to change the loaded set after startup:
| command | does |
|---|---|
core:list-plugins |
what is on disk in both roots, and which of it is loaded |
core:load-plugin <name>… |
load by name, whatever config.plugins said |
core:unload-plugin <name>… |
run its unload() and forget it |
Comma-separated or whitespace-separated lists work. With no argument they report what to do rather than failing silently.
These are the :packadd case: config.plugins decides what loads on its own,
and naming a plugin explicitly is how a bare editor stays useful rather than
merely empty. Loading is deliberately not gated on the site set being enabled, so
config.plugins = false still leaves you a way to bring one thing in.
Nothing here persists. There is no saved state — what you load this way is
gone at exit, and the only thing that survives a restart is what config.plugins
asks for. Persistence is the package manager’s job, and the manager is in every
build: see below.
The manager
Not a runtime command, and not a plugin you have to install. It is marked
essential in cdin-x, so make bundles it the
way it bundles vim, and the panel is a keystroke away
(Ctrl+Shift+M) in any build.
The keys, what the panel downloads, and where installed extensions end up are in extensions. Four facts matter here because they are about the loader:
- What the manager installs is not in either of the loader’s roots. It keeps
its own store, makes it
requireable itself, and loads it itself — without readingconfig.plugins. Soconfig.plugins = falseand--no-pluginsdo not switch off what you installed from the panel; they switch off the site directory.core:list-pluginsreports the two roots; the panel reports everything. - The extensions the build ships show as in editor: present, listed, and not the manager’s to remove.
- Enable and disable persist — in the manager’s own state file, keyed by name — so the panel’s idea of what is on survives a restart without the runtime growing a registry of its own.
- Nothing it installed is a site plugin, so nothing here is reachable through
config.plugins = { … }either. The whitelist selects the site directory and nothing else.
Installing your own
Drop a directory or a .lua file into config.site_path()/plugins/ and restart,
or run core:load-plugin <name>. To find out where that is:
require("core.config").site_path()
config.site_path() rather than a field on the table, deliberately — see
configuration. Your plugin can be loaded by
name while it is still being written, which is the whole development loop.
For installing from a catalog, an in-app UI, dependency resolution or anything that survives a restart, that is the manager, and you want it rather than a second implementation here.
The two things that are not yours
The bundled set. The loader knows nothing about where it came from. Whatever
produced it is not this repository’s business; only the build knows, and it knows
it as a path — CDINX_DIR. A build never fetches anything; the manager, at
run time, downloads only what you ask it to install.
The site directory’s name. config.site_dirname is the editor’s to choose,
and cdin-x reads config.site_path() rather than computing a path of its own.
That is the whole of what it knows about where anything lives, and it is why
renaming the site directory renames it for both halves at once.
Files
| file | holds |
|---|---|
data/core/plugins.lua |
the loader: both roots, entry points, the error policy |
data/core/config.lua |
config.plugins and the path resolution |
data/core/commands/plugin.lua |
the three management commands |
data/core/help.lua |
the help-shortcut registry |
data/core/project.lua |
the VCS provider hook, and the file scan |
data/core/docs.lua |
core.open_doc, core.reload_module, clipping |
data/core/init.lua |
core.add_thread, core.set_active_view, core.quit |
data/core/loop.lua |
core.step, core.run, the thread scheduler, and where core.redraw is consumed |
data/core/preboot.lua |
the session state read before anything else loads |
data/core/lifecycle.lua |
the project module, .lite_project.lua |