Plugins
Plugins are written in Scheme, the same language as your config, so the line between configuring HUME and extending it is thin.
The first half of this page covers using plugins other people wrote; Writing a plugin covers making your own.
Plugins are installed and updated by PLUM, a bundled plugin. See Core Plugins to enable it.
Installing a plugin
Add a load-plugin! call to your init.scm.
(load-plugin! "core:stdlib")
(load-plugin! "cvlmtg/grep.hume")INFO
cvlmtg/grep.hume adds one g / keybind to open a picker with a live rg (or grep) search behind it, re-running the search as you type.
core:stdlib is grep.hume's own dependency, not something every plugin needs; check each plugin's docs for what it requires. Then run :plum-install-plugins to clone it from GitHub. PLUM never installs anything on its own, so nothing is fetched behind your back at startup; once the plugin is on disk, its commands and key bindings are available from the next launch.
See How plugins are loaded for when a plugin's code runs.
If a plugin supports configuration, pass it with #:config:
(load-plugin! "core:stdlib")
(load-plugin! "core:vim-keybind" #:config (hash "change-to-eol" 'off))See Configuring a plugin for what a plugin does with this value, and the plugin's own docs for which keys it understands.
Plugin status
:plugin-statusShows one row per plugin entry: whether it is declared (waiting), loaded or failed, and for a declared entry the commands, events and languages still waiting to trigger it. A plugin that loads in pieces has a row for each file, and a local file shows under its ./ path. A plugin named in init.scm that is not installed shows as absent, so you can tell what :plum-install-plugins still has to fetch.
Reloading configuration
:reload-configReloads init.scm from scratch. Useful after editing your config without restarting the editor.
Recovering from a failed plugin
A plugin that fails to load doesn't stop the rest of init.scm: every plugin after it still loads, and every plugin named before it is unaffected. A lazy plugin loads when its first trigger fires, so its failure shows up then instead of at startup. Check :messages for the error; it names the plugin and points at the file and line the problem is in. :plugin-status shows it as failed alongside everything else, including a plugin whose manifest.scm failed before it declared anything.
If the failing plugin came from a git repository (rather than one you're editing yourself), it may already have a fix upstream: run :plum-update-plugins to pull the latest version of every installed plugin, then :reload-config to try again. This works even for a plugin that failed on the very first line of your config, since loading it never depends on anything named after it.
How plugins are loaded
(load-plugin! "name") is the call to put in init.scm. When the plugin loads depends on what the plugin ships:
| Plugin ships | Timing |
|---|---|
a manifest.scm | Lazy: code deferred until first use |
only a plugin.scm | Eager: code runs during startup |
Lazy plugins ship a manifest that lists what the plugin offers (commands, typed commands, events, languages). load-plugin! registers those entries and does not run the plugin's code until the first one is exercised. This keeps startup fast: a language-server or formatting plugin whose commands you might never call costs nothing until you do. load-plugin! never forces such a plugin to load at startup.
Eager plugins have no manifest, so plugin.scm runs during startup. This suits a plugin whose only way of being triggered is something its own code sets up (a key binding it adds or overrides, an option, a hook), since nothing else could wake it.
A directory with neither manifest.scm nor plugin.scm is an error. A missing core: plugin logs an error; a missing user/repo plugin logs an info message, and :plum-install-plugins installs it.
Choosing your own triggers
declare-plugin! lists the entries that wake a lazy plugin. Declare an installed plugin before its load-plugin! line to use your triggers instead of its manifest's:
#:commands: editor-command names the plugin provides (defined withdefine-command!). HUME creates placeholder stubs so the names are key-bindable and reachable fromcall!immediately; the first dispatch triggers real definition. A key you bind to one of these names in your owninit.scmworks this way: pressing it activates the plugin, then runs the command, so a lazy plugin's commands are key-bindable from the start even though the plugin's own bindings aren't in place yet:scheme(declare-plugin! "cvlmtg/grep.hume" #:commands '("grep/picker")) (load-plugin! "cvlmtg/grep.hume") (bind-key! 'normal "space g" "grep/picker") ; pressing <space>g the first time loads cvlmtg/grep.hume, then runs grep/picker#:typed-commands: typed-command names the plugin provides (defined withdefine-typed-command!). Same stub-then-activate behavior as#:commands, but the stub appears in:Tab completion and is reachable only from:, never from a key orcall!:scheme(declare-plugin! "cvlmtg/grep.hume" #:typed-commands '("grep")) (load-plugin! "cvlmtg/grep.hume") ; typing :grep the first time loads cvlmtg/grep.hume, then runs grep#:events: lifecycle hooks that trigger loading, as a list of symbols (e.g.,'(on-buffer-open)).#:languages: buffer language names that trigger loading. Triggers on the name being set on a buffer (by detection,:set buffer language=, or another plugin'sdefine-language!), not on the name being a known language yet — so a plugin can't use#:languages '("foo")to lazily define"foo"itself: HUME sets the buffer's language and reports it as unregistered before your plugin's activation runs, then activates you anyway. The message is informational; calldefine-language!in your activation body and everything downstream (highlighting, LSP) still works from there.
A plugin that already has a declared entry keeps those triggers when load-plugin! runs: the manifest is not read and the plugin stays lazy. Put load-plugin! right after the declare-plugin!, because a plugin that activates during startup before its load-plugin! line sees an empty config. A declare-plugin! placed after the plugin's load-plugin! is ignored, and :messages reports it.
Lazy local files
A script beside your init.scm can load lazily too. Name it with a path that starts with ./, relative to the directory of init.scm:
(declare-plugin! "./my-lazy.scm" #:commands '("my-cmd"))The name must end in .scm, must not contain .., and the file must exist, or init.scm fails with an error. #:entry is not accepted. Inside the file, (plugin-config) is an empty hash and (plugin-dir) is the file's directory. A local file appears in (loaded-plugins) once it has loaded, but not in (declared-plugins).
Writing a plugin
A plugin is a directory containing a plugin.scm; that file is the entry point HUME loads. A plugin without a plugin.scm is malformed, even if it ships a manifest.scm. HUME doesn't check for this: such a plugin may be reported as not installed, fail to load, or load only in part. An optional manifest.scm makes the plugin lazy (see Shipping a manifest). For a plugin installed by PLUM, the directory is named after its GitHub owner and repo. The simplest plugin.scm:
(define-typed-command! "hello"
"Print a greeting from my plugin."
(lambda ()
(log! 'info "Hello from my plugin!")))This registers :hello, typed at the command mode prompt.
Defining commands
Two verbs, matching the two kinds of command described in Command mode:
(define-command! "command-name"
"One-line description of what the command does."
(lambda ()
...))
(define-typed-command! "typed-command-name"
"One-line description of what the command does."
(lambda ()
...))define-command! registers an editor command: bind it to a key, or dispatch it with call!; it's never reachable from the command mode prompt. define-typed-command! registers a typed command, reachable only as :typed-command-name; it's never bindable to a key and never reachable through call!. Both take the same first two arguments: a name and a one-line description of what the command does.
Give every define-command! name of a plugin one shared prefix followed by /, such as git-diff/toggle-inline-diff, so two plugins never collide on a name. The prefix is usually the plugin's name but only has to be unique. Typed command names are not prefixed.
An editor command's lambda receives the leading arguments its declared arity asks for: (), (pane), (pane count), or (pane count extend). pane is the pane the command was invoked through, count is what a key press's count prefix injects (0 means "no count typed"), extend whether Extend mode is active. A typed command's lambda instead receives (), (pane), (pane arg), or (pane arg force). arg is the text typed after the command name (a string, or #f if none was typed), force whether ! was appended. A typed command that declares no arg rejects an argument typed after its name, and one that declares no force rejects !; a lambda with a rest parameter receives both. pane is always the leading parameter, before whichever of the others the command also declares: a command that only touches selections and doesn't care about count/extend still declares just (pane), not (pane count extend).
For commands that stream subprocess output to the terminal (installers, git operations), add the #:inline-output #t keyword, accepted by both verbs. The alt-screen opens on the command's first real output (not eagerly at the start), so a run that produces no output (an already-up-to-date check, a validation error) never flashes an empty screen or waits on an unneeded keypress. Once something is printed, HUME waits for a keypress before returning to the editor, so the output stays on screen until you've read it. This applies no matter how the command is reached (a key binding, :, or call! from another command's body, a hook, or a timer), and a command call!'d from inside another #:inline-output command shares its already-open screen and single keypress prompt rather than opening a second one.
Plugins run with the same privileges as HUME itself, so any Scheme process/filesystem function is available; there's no separate "shell builtin" layer. The one exception: inside an #:inline-output command, spawn subprocesses whose output should reach the terminal via run-inline-output! rather than a raw spawn-process/command call. It isolates the child into its own process group so a Ctrl-c meant to interrupt the subprocess doesn't kill HUME too, and it's the trigger that opens the alt-screen:
(define-command! "fetch-config"
"Clone the team config repo into the data directory."
(lambda ()
(run-inline-output! "git" (list "clone" "--"
"https://github.com/team/hume-config.git"
(path-join (data-dir) "config"))))
#:inline-output #t)For editor commands that should support dot-repeat (.), add #:repeatable #t. define-typed-command! has no #:repeatable keyword, since dot-repeat has no meaning for a : command. #:repeatable and #:inline-output are mutually exclusive:
(define-command! "delete-and-repeat"
"Delete the current selection; dot-repeatable."
(lambda (pane)
(call! "delete-selection" pane))
#:repeatable #t)Dot-repeat
. repeats a #:repeatable command. When it asks the user for something (on-next-char!, picker!, live-picker!, prompt!, show-menu!), . hands it the same answer instead of asking, and the command decides again what to do with it. This includes questions its callbacks ask, and a #:repeatable command bound to an Insert-mode key.
- Anything else that asks the user while a change is recorded (a non-repeatable command bound in Insert mode, a hook, a timer) ends the change there:
.repeats only what came before. - A
picker!pick orshow-menu!choice is matched by its text in the list the command builds this time, so the second of two "foo" items stays the second. Alive-picker!pick, or one from apicker!that got items after it opened (#:pending,picker-push!,picker-source-spawn!), reuses its original payload, which a configuration reload forgets. - A dismissal is repeated as a dismissal: the callback gets
#f. - A
show-drawer-list!pick is never recorded, and itson-selectis not part of the change. .raises an error when the re-run asks for something different or more than the first run did, can't find the picked item or a forgotten payload, or doesn't bind the#:actionskey the first run used.
Calling other commands
Use (call! ...) to dispatch other commands from within a plugin:
(define-command! "delete-and-deselect"
"Delete the selection, then collapse the cursor."
(lambda (pane)
(call! "delete-selection" pane)
(call! "collapse-selection" pane)))call! dispatches any editor command, built-in and Scheme-defined alike, activating the target plugin on demand.
A built-in command has no lambda parameter list of its own to receive one, so call! requires a pane as its first argument instead: always the pane the command should act through: (call! "delete-selection" pane), (call! "move-right" pane 5). Most built-ins act through whatever pane the value names, not necessarily the one you're focused on, as long as that pane still exists and still shows the buffer the value names; a few (entering insert mode, an open prompt, extend mode, pane focus/split/close, tabs, …) still require it to be the pane you're actually focused on (see Builtin Commands for the full breakdown). A Scheme-defined command receives pane as its own first parameter already (see above), so call! passes whatever arguments that command declares, including pane itself, if the caller wants to forward the one it's acting through.
For a built-in command, call! returns #f when the command refused (a too-small split, the last pane, …) or the name didn't resolve, and #t otherwise. That's not a guarantee anything changed, just that nothing was refused; it errors if pane doesn't resolve for that command (see above). A Scheme-defined command returns whatever its own body returns.
call! can't run typed commands
Typed commands like write, quit, or edit are not reachable through call!: only editor commands work here. Calling one logs an error and does nothing, so (call! "write") will not save.
A few built-ins take one more argument, after the optional count and extend flag: what the command acts with.
find-forward,find-backward,till-forward,till-backward,replace, andsurround-addact with a character, given as a Scheme character:(call! "find-forward" pane #\x),(call! "find-forward" pane 2 #\x). The character is required: to use one the user types, read it withon-next-char!and pass it on. To pass a one-character string, convert it with(string-ref s 0).search-forward,search-backward, andsift-withintake a pattern. With one, they work as if you had typed it at their prompt and pressed Enter, without opening the prompt. A search with no match still becomes the current search, as it does after Enter, andcall!returns#f. So does an invalid or empty pattern, which is also reported in:messagesand leaves the current search as it was, and asift-withinthat finds no match. The pattern is required: to use one the user types, read it withprompt!and pass it on.
prompt! calls its callback with the confirmed text, or with #f when the user cancels. Unlike the / and s prompts, it doesn't preview matches as you type or recall earlier patterns:
(define-command! "search-prompt" "Search forward for a pattern you type."
(lambda (pane)
(prompt! pane "search:"
(lambda (pat) (when pat (call! "search-forward" pane pat))))))Passing a string to one of the character commands raises an error, and so does passing an argument to a command that takes none, or a character to a command that takes a pattern.
Together, these make a command that removes trailing spaces and tabs from the selected lines, or from every line when no selection covers whole lines:
(define-typed-command! "trim-trailing"
"Remove trailing spaces and tabs."
(lambda (pane)
(when (selections-charwise? pane)
(select-all pane))
(when (sift-within pane "(?m)[ \t]+$")
(delete-selection pane)
(keep-primary-selection pane))))When nothing matches, sift-within leaves the selections as they were and returns #f, so the when is what keeps the command from deleting them.
When forwarding a count argument to another command, a count of 0 means "as if no count was typed". This is how move-down/move-up decide between visual-line and buffer-line movement, and it lets a key-bound command that forwards its own count behave the same way a native keybinding would.
Reading selections
(buffer-selections pane) returns the selections in pane's own pane, as a list of opaque selections: char offsets, not grapheme ordinals, anchor and head each the start of a character (a letter with its combining marks is one), start and end bounding what the selection covers with end exclusive, and a primary flag. A command body reading its own buffer's selections declares a leading pane parameter and passes that. core:stdlib's helpers answer the common questions about the whole list:
(define-command! "example" "" (lambda (pane)
(call! "stdlib/single-selection?" (buffer-selections pane))
(call! "stdlib/all-single-char?" (buffer-selections pane))
(call! "stdlib/cursor-char-index" (buffer-selections pane))))Those three read the whole list. To work with a single selection (the primary one, say), use these accessors, which also pass a #f selection through as #f:
(call! "stdlib/primary-selection" (buffer-selections pane))
(call! "stdlib/selection-anchor" primary)
(call! "stdlib/selection-head" primary)
(call! "stdlib/selection-start" primary)
(call! "stdlib/selection-end" primary)
(call! "stdlib/selection-primary?" primary)(offset->line pane idx) converts a char offset to a line number when you need one. It's a separate call rather than a field on every selection, since a selection on its own has no line information.
See Standard Library for the full list of selection helpers.
Depending on another plugin
WARNING
call! with an unknown command name logs an error and no-ops instead of aborting the command body: a missing plugin dependency shows up as an error in :messages, not as a crash, so check dependencies up front rather than relying on the error to be noticed.
If your plugin calls another plugin's commands via call!, check that the other plugin is available before you rely on it. Whether that call sits at your plugin's own top level or inside a command a key press later fires makes no difference: call! activates a lazily-declared dependency on demand either way, so the usual check is (declared-plugins), which lists every plugin named by load-plugin! or declare-plugin!:
(unless (member "core:stdlib" (declared-plugins))
(error "my-plugin: requires core:stdlib — add (load-plugin! \"core:stdlib\") before it"))The list includes a plugin that is named but not installed; that case is reported in :messages and shows as absent in :plugin-status. This is enough as long as the command you're calling is one of the dependency's own activation entries: the entries of its manifest.scm, or a custom list a user declared that includes it. If a user declared the dependency with a narrower list that leaves your command out, there's no activation stub for it: call! logs an error and returns #f instead of raising, and the check above can't catch it, since the plugin is declared, just not for the command you need.
(loaded-plugins) lists the plugins whose code has finished loading. It suits a dependency without a manifest, which loads at startup. A dependency with a manifest is lazy, so it is not in that list until something triggers it, and checking it there would fail for a plugin that works.
The check fails loudly at load time (startup or :reload-config), naming what's missing, instead of leaving the bug to surface later at whatever moment the dependent command actually runs.
Reading characters
A command that needs characters from the user (a surround operation, say) reads them with (on-next-char! callback). The next key typed calls callback with its character; Enter and Tab count as #\newline and #\tab. Any other key, such as Esc, a mouse click, or a paste calls it with #f instead and does nothing else. Scrolling the mouse wheel scrolls as usual and keeps waiting. To read another character, call on-next-char! again from the callback:
(define-command! "my-surround"
"Select the pair for the next typed char, then replace it with the one after."
(lambda (pane)
(on-next-char!
(lambda (ch)
(when (eqv? ch #\()
(call! "surround-paren" pane)
(on-next-char!
(lambda (new)
(when new (call! "replace" pane new)))))))))Several reads started before a key arrives take the following keys in the order they were started. HUME shows nothing while it waits, so make it obvious from context that a character is expected.
. repeats a #:repeatable command with the characters it read, without waiting for keys; see Dot-repeat.
Register prefix
To make subsequent (call! …) invocations in a command body target a specific register, call set-register-prefix! with a single-character register name (0–9, k, c, b; see Register prefix for what each one holds):
(define-command! "paste-kill-ring-after"
"Paste the kill-ring head after the selection (same as \"kp)."
(lambda (pane)
(set-register-prefix! "k")
(call! "paste-after" pane)))The prefix persists for the rest of the command body.
Target the black hole register ("b") to discard a selection without touching the kill ring or clipboard, useful when a command needs to throw text away as a side effect of its own logic:
(define-command! "delete-without-clobbering"
"Delete the selection without overwriting the kill ring (same as \"bd)."
(lambda (pane)
(set-register-prefix! "b")
(call! "delete" pane)))Hooks
Plugins react to editor lifecycle events by registering a hook handler with register-hook!. It must be called at the top level or inside a plugin body, not from a command body:
(register-hook! 'on-buffer-save
(lambda (pane)
(log! 'info (string-append "saved " (buffer-name pane)))))Available hooks and their lambda signatures. Every pane argument below is the same opaque value a command's own leading parameter is (see Defining commands); pass it straight into any builtin that takes one. For a hook about a buffer with no pane of its own (most of them), that value carries no particular pane; for on-buffer-enter, on-viewport-change, on-trigger-char, and on-completion-accept, it names the exact pane the event happened in:
| Hook | Fires when | Lambda args |
|---|---|---|
on-buffer-open | A buffer is opened | (pane) |
on-buffer-close | A buffer is about to close | (pane) |
on-before-buffer-save | A buffer is about to be written to its file | (pane) or (pane done) |
on-buffer-save | A buffer has been written to its file | (pane) |
on-buffer-enter | The focused buffer changes | (pane) |
on-focus-gained | The terminal regains focus | () |
on-mode-change | The editor mode changes | (old new): mode symbols ('normal, 'insert, 'extend, 'command, 'search, 'sift); compare with equal? |
on-language-set | A buffer's language is detected or changed | (pane lang): lang is the language name, or "" when the buffer has none |
on-diagnostics-changed | A buffer's LSP diagnostics change | (pane): pull details with diagnostics-for-buffer |
on-lsp-attach | A language server is ready for a buffer, once per server on that buffer | (pane server): server is the server value, the same one each time for one running server |
on-lsp-detach | A language server stops serving a buffer that stays open: it was stopped or restarted, or a server list or the buffer's language no longer includes it | (pane server) |
on-lsp-notification | A language server sends a notification HUME doesn't handle itself (it handles window/logMessage, window/showMessage, $/progress, and publishDiagnostics) | (server method params): server is the sending server's value, method a string, params a JSON handle. A handler registered here receives every method; to handle only specific ones, use register-lsp-notification-hook! instead |
on-viewport-change | The visible region of a pane changes | (pane first-line end-line): 0-based, end-exclusive; with wrapping on, end-line may run a few lines past the bottom edge |
on-trigger-char | A registered trigger character is typed | (pane char source) |
on-completion-accept | A completion entry is accepted | (pane item) |
on-option-change | A global setting is changed (:set global, set-option!, :theme) | (key value): key is a string, value the option's new value as get-option returns it |
on-text-changed | A buffer's text changes | (pane) |
on-undo-history-changed | A buffer's undo history changes: an edit is recorded, an undo, redo or jump moves to another revision, or a write changes which revision is saved | (pane): pull the tree with buffer-undo-tree |
on-before-buffer-save fires for :w, :wq and :wa, and the file is written once every handler has finished. A handler taking (pane) has finished when it returns. A handler taking (pane done) has finished when it calls (done), so it can wait for a reply that arrives later, such as a language server's. Call done on every path, failures included. A handler that raises an error is reported and counts as finished. If a handler hasn't finished after before-save-timeout-ms, the file is written anyway and a warning says so. Closing the buffer while its save waits drops the save, and :reload-config lets it go ahead. :wa reports what it saved and skipped once all its saves are done, and a save that fails stops the ones still waiting. pane is the pane the save was asked from if it shows the buffer when the handlers run, otherwise a value with no pane (:wa on a hidden buffer, or a pane that has switched buffers). A write that doesn't reach the buffer's own file (a read-only buffer, a buffer with no path) doesn't fire it. Format on save is an example.
on-buffer-open and on-buffer-close always fire as a pair for a given buffer: a buffer opened and closed within the same command never announces either one.
on-text-changed covers edits, undo, redo, :e! reload, and refreshes of read-only view buffers (:messages, :plugin-status) alike. Those buffers have no file, so a handler that looks up a path must handle it being absent. It coalesces multiple mutations made by a single command (a multi-cursor edit, a macro, a paste) into one fire, but each keystroke while typing is its own command and so fires on its own. Pair it with debounce if you want to react only after typing settles rather than on every character.
on-undo-history-changed fires once per command in the same way, but only when the undo history moves or a write changes the saved revision, so typing in an insert session announces once, when the session ends. It also fires for an undo whose net change to the text is nothing (undoing an insert together with its later delete), which on-text-changed does not.
For lazy plugins, declare the events that should trigger activation via #:events in your manifest.scm instead (see How plugins are loaded). LSP-related hooks like on-lsp-attach work fine with register-hook!, but can't be used as an #:events activation entry: a plugin gated only on on-lsp-attach never activates, since nothing attaches to a server until the plugin has already loaded and registered it. The same caveat applies to on-text-changed: gating a lazy plugin on it activates on the first edit in any buffer, not a buffer the plugin specifically cares about.
set-option! works from a hook or command handler too, not just at the top level of your plugin. It changes the global default, so use it there when that's really what you want.
For a per-buffer override, (set-buffer-option! pane "option" value) sets an option just on pane's buffer, which also works from hook and command bodies (see Buffer options for the list of settable options). Pass the value the hook itself hands you rather than assuming the buffer you're editing: a hook can fire for a buffer other than the one you're currently focused on. Setting language changes the buffer's language; "" clears it. To read a specific buffer's options back the same way, see Reading options from Scheme below.
A few more examples:
; react to diagnostics
(register-hook! 'on-diagnostics-changed
(lambda (pane)
(let ((errs (diagnostics-for-buffer pane #:severity 'error)))
(log! 'info (string-append (to-string (length errs)) " errors")))))Reading options from Scheme
(get-option "option-name")
(get-buffer-option pane "option-name")(get-option "option-name") returns the option's global value, ignoring any buffer override even if one exists. (get-buffer-option pane "option-name") returns pane's buffer's effective value (a symbol for an option that takes a fixed set of names, like tab-style): its own override if one is set, else the global default. Pass the value explicitly (e.g. inside an on-language-set hook, whose handler receives it as an argument) rather than assuming "the buffer I care about" is whichever one is focused. Errors on an unknown option name. (get-buffer-option pane "language") returns the buffer's language name, or "" if it has none. (get-buffer-option pane "line-ending") returns lf or crlf; it has no global default, so (get-option "line-ending") is an error. For wrap-mode, get-buffer-option reads the buffer/global level only: a pane pinned with :set pane wrap-mode=… can show a different style than what it reports.
(get-option "tab-width") ; the global default tab-width
(get-buffer-option pane "tab-width") ; pane's effective tab-widthShipping a manifest
A plugin is lazy because it ships a manifest.scm next to its main file. The manifest declares the entry points that wake the plugin, and a user's (load-plugin! "username/repo-name") registers them.
; manifest.scm
(declare-plugin! "username/repo-name"
#:commands '("my-cmd" "my-other-cmd"))A plugin with no manifest.scm loads eagerly. A user who wants different triggers declares them before their load-plugin! line (see Choosing your own triggers); their list replaces the manifest's rather than merging with it.
If your plugin reacts to a language but can't predict which ones a given user cares about, #:languages '("*") matches any buffer with a detected language:
; manifest.scm
(declare-plugin! "username/repo-name"
#:languages '("*")
#:commands '("my-cmd"))Keep manifest.scm to just the declare-plugin! calls. A manifest takes no #:config; the user's config comes from load-plugin! and you read it with (plugin-config).
Splitting a plugin into entries
A plugin can load in pieces. Give declare-plugin! an #:entry naming another .scm file in the plugin's directory, with its own activation entries, and that file loads only when one of them fires. The declaration without #:entry is the plugin's main file, plugin.scm. #:entry "plugin.scm" names the main file, the same as leaving #:entry out. Write every #:entry exactly as the file is named on disk, case included, and the file must exist or the declaration fails with an error. "Commands.scm" for a file named commands.scm is malformed even where the filesystem ignores case, because it may work on one system and fail on another.
; manifest.scm
(declare-plugin! "username/repo-name"
#:languages '("*"))
(declare-plugin! "username/repo-name"
#:entry "commands.scm"
#:typed-commands '("my-install"))Here plugin.scm runs when a buffer gets a language, which keeps startup cheap, and the heavier commands.scm runs the first time :my-install is used, or its argument is completed with Tab. Each file is loaded, rolled back on failure and reported on its own; a commands.scm that fails leaves what plugin.scm registered in place. Files that both require the same module share it.
The user's #:config belongs to the plugin as a whole, and (plugin-config) returns that same value in every file.
Configuring a plugin
A plugin can read the #:config value its user passed to load-plugin! with (plugin-config). It returns whatever was passed (typically a hash) or an empty hash if nothing was passed. Rather than picking it apart with raw hash-contains?/hash-ref and hand-rolling a type check, go through core:stdlib's config helpers, which default a missing key and raise an error naming your plugin and the offending key if the resolved value is the wrong type:
(unless (call! "stdlib/config-boolean" "my-plugin" (plugin-config) "disable-binding" #f)
(bind-key! 'normal "C" "my-command"))stdlib/config-string, stdlib/config-enum, stdlib/config-integer, and stdlib/config-list cover the other common config shapes; see Standard Library for their signatures. Since this call happens in your plugin's own body, at load time, check (declared-plugins) for "core:stdlib" first (see "Depending on another plugin" above).
Document the keys your plugin understands so users know what to pass.
load-plugin! is the only way to pass configuration; declare-plugin! takes no #:config.
Filesystem and processes
Plugins are trusted code: they can read and write any file, and spawn any process, just like any other Scheme program. There's no separate sandboxed subset of the filesystem. Use Scheme's own functions directly (open-input-file, create-directory!, delete-file!, read-dir, path-exists?, and so on) for file access, and command/spawn-process/wait for running external tools.
Before hand-rolling one of these, check Standard Library: its filesystem, subprocess, and git commands cover the shapes plugins need most often (idempotent delete, sorted subdirectory listing, a blocking subprocess run with a uniform success/failure return shape, git work-tree detection and repo-root resolution).
A few extra functions cover things Scheme has no way to know on its own:
| Function | Description |
|---|---|
(data-dir) | HUME's data directory, or #f if unavailable |
(runtime-dir) | HUME's runtime directory, or #f if unavailable |
(path-join seg…) | Join path segments with the OS-native separator |
(json-parse str) | Decode a JSON string: an object/array becomes a JSON handle (read with json-ref/json-contains?/json-list), a scalar crosses natively; errors on malformed input |
run-inline-output! also takes a #:cwd keyword to set the working directory and an #:env keyword, a list of ("KEY" . "VALUE") pairs added to the child's environment, and raises an error if the command exits non-zero; wrap it in a handler if a failure is expected.
command/spawn-process/wait all block the whole editor until the command finishes. That's fine for something instant (git rev-parse), but not for anything that might take a moment while the user keeps typing. For that, run it in the background instead:
(spawn-async! "git" (list "show" (string-append ref ":" path))
(lambda (stdout stderr exit-code)
(if (= exit-code 0)
(use-the-output stdout)
(report-the-failure stderr)))
#:cwd repo-root)spawn-async! starts cmd with args (in #:cwd, or HUME's working directory when omitted) and returns immediately; nothing blocks. callback is called exactly once, later, once the command has finished: stdout and stderr are its complete output as strings, exit-code is its exit code (-1 if it was killed by a signal, or if the command couldn't even be started, like a missing binary). A command that fails to start or exits non-zero still calls callback rather than raising an error, so there's only one place to handle the outcome. spawn-async! returns an id; call (cancel-async! id) to kill the command and discard its callback before it fires, useful when a debounced action (a hook firing on every keystroke, say) ends up superseded by a newer one before the older command has finished.
Only install or overwrite files under (data-dir) unless you have a specific reason to go elsewhere: that's where HUME expects a plugin's own data (installed grammars, downloaded servers, plugin state) to live.
Reading buffer text
(buffer-text pane)Returns a buffer's full live content as a string, including any unsaved edits, not what's on disk. The string always ends with a trailing newline. Line endings in the returned string are always \n, even for a file saved with \r\n.
(buffer-lines pane)
(buffer-lines pane #:start start #:end end)Returns the buffer's content as a list of lines, each with its line ending stripped. With no range, every line is returned; #:start/#:end select a 0-based, end-exclusive slice ((buffer-lines pane #:start 10 #:end 40) returns lines 10 through 39). An out-of-range #:end, or a #:start past #:end, raises an error rather than silently clamping. Compose with (viewport-range pane) to read only what's currently on screen: it returns the same 0-based, end-exclusive range shape, so its 'start and 'end pass straight through as #:start/#:end:
(let ((vr (viewport-range pane)))
(buffer-lines pane #:start (hash-ref vr 'start) #:end (hash-ref vr 'end)))If you're about to diff a buffer's content against another text, reach for (diff-buffer-lines pane ref-text) instead of (buffer-text pane), especially from a hook that fires on every keystroke.
(buffer-line-count pane)Returns the buffer's content line count as an integer, same count buffer-lines returns lines for. If all you need is the count, reach for this instead of (length (buffer-lines pane)). That idiom builds and throws away a full list of line strings just to measure it.
(line->offset pane line)Returns the 0-based char offset where content line line (0-based) starts: the conversion decoration builtins that take char offsets (like set-extra-highlights!) need when all you have is a line number, e.g. from a diff hunk. Raises if line is at or past the buffer's content line count.
Comparing text
Two functions compute a line-level diff, useful for anything that shows what changed between two versions of a file, like a git-status indicator:
(diff-lines old-text new-text)Splits both old-text and new-text into lines the same way HUME treats file content (every line ending becomes LF, and a missing trailing newline doesn't count as a change), then returns the list of hunks where they differ. Unchanged lines are left out entirely. Each hunk is:
(hash 'old-start s 'old-count n 'new-start s 'new-count n 'old-lines lines 'new-lines lines 'words spans)'old-start/'new-start are 0-based line numbers, 'old-count/'new-count are how many lines the hunk covers on each side, and 'old-lines/'new-lines are the line contents themselves (no trailing newline). Each hunk also has a 'words entry, (hash 'old spans 'new spans), naming the words that changed: a span is (hash 'line 'start 'end), with 'line counted from the hunk's first line on that side and 'start/'end character columns in it, end exclusive. A span covering a whole line is left out. A pure insertion has 'old-count 0; a pure deletion has 'new-count 0. Either way, the zero-count side's line number is exactly where the change happens, so it feeds straight into set-signs! or set-virtual-lines! with no adjustment.
(diff-buffer-lines pane ref-text)Same result, but compares ref-text against the current, unsaved content of the buffer named by pane; the buffer never has to be pulled through a builtin as one big string first. This is the one to use in a hook that fires on every keystroke.
For a finer-grained comparison inside a single changed line, highlighting exactly which words differ rather than the whole line:
(diff-words old-text new-text)Returns (hash 'hunks hunks 'deadline-hit too-long?). 'hunks is a list of (hash 'old-start 'old-end 'new-start 'new-end 'old-text 'new-text): 0-based character positions into old-text/new-text, with 'old-text/'new-text on each hunk holding the actual changed words. A pure insertion has an empty 'old-text and 'old-start equal to 'old-end; a pure deletion mirrors that on the new side. 'deadline-hit is #t when the two texts were too large to compare word-by-word in time. Treat that as a signal to fall back to highlighting the whole line instead of individual words.
Completion sources
A plugin can feed the completion menu by registering a source. A source is a procedure the editor calls with a request id. It answers by calling completion-emit! with that id exactly once, either right away or later from a callback. The smallest useful source offers a fixed list of words in Insert mode:
(register-completion-source! "todo-markers"
(lambda (id pane prefix)
(completion-emit! id '("TODO" "FIXME" "HACK")))
#:target 'buffer)Press Ctrl-Space after typing FI and FIXME is offered alongside every other source's answers. The source can ignore prefix, the word being typed: the editor filters and ranks the items against it, and replaces it with the item you accept. An empty list means the source has nothing to offer.
A bare string is shorthand for an item with only a label. For more control, pass a hashmap using the keys of an LSP CompletionItem: label is required; detail, such as a type or signature, is shown in the documentation box described below; insertText is what accepting it inserts (default: the label); filterText and sortText change what the typed text is matched and tie-broken against; kind is an LSP CompletionItemKind number; documentation, a string or a (hash 'kind "markdown" 'value text), is shown below detail in a box beside the menu while the item is highlighted ('kind "plaintext" shows it as plain text).
(register-completion-source! "todo-markers"
(lambda (id pane prefix)
(completion-emit! id
(list (hash 'label "TODO" 'detail "work left to do" 'insertText "TODO: ")
(hash 'label "FIXME" 'detail "known bug" 'insertText "FIXME: "))))
#:target 'buffer)The documentation box only appears in Insert mode, for an item that has a detail or documentation. It is drawn to the right of the menu, or to the left when there is no room on the right, and not at all when neither side fits, in which case each item's detail shows next to its label in the menu. Turn it off with :set global completion-docs=false: each item's detail then shows next to its label in the menu instead. Give each item its documentation along with the rest of the item: only a language server's items can have theirs fetched later, when highlighted.
(register-completion-source! "todo-markers"
(lambda (id pane prefix)
(completion-emit! id
(list (hash 'label "TODO"
'detail "work left to do"
'insertText "TODO: "
'documentation (hash 'kind "markdown"
'value "Marks *unfinished* work.\n\nSearch for it before a release."))
(hash 'label "NOTE"
'detail "remark for readers"
'insertText "NOTE: "
'documentation "Explains something the code alone does not."))))
#:target 'buffer)Highlighting TODO shows work left to do above the rendered markdown. Highlighting NOTE shows its detail above the plain text.
To open the menu without Ctrl-Space, give the source trigger characters for a language. This one fires whenever @ is typed in a Markdown buffer:
(set-completion-triggers! "todo-markers" "markdown" '("@"))A source can also complete the argument of a : command. A 'minibuf source is called with the whole command line and the cursor position. Here it answers asynchronously, once git has listed the branches. #:complete connects it to the command:
(register-completion-source! "git-branches"
(lambda (id input cursor)
(spawn-async! "git" '("branch" "--format=%(refname:short)")
(lambda (stdout stderr exit-code)
(completion-emit! id
(if (= exit-code 0)
(filter (lambda (s) (not (equal? s ""))) (split-many stdout "\n"))
'())))))
#:target 'minibuf #:match 'string)
(define-typed-command! "checkout" "Switch to a git branch: :checkout <branch>."
(lambda (pane arg)
(when (string? arg)
(spawn-async! "git" (list "checkout" arg)
(lambda (stdout stderr exit-code)
(unless (= exit-code 0) (log! 'error stderr))))))
#:complete "git-branches")If the user keeps typing or closes the menu before git answers, the request is out of date. completion-emit! then drops the answer and returns #f.
Source options
#:target(required):'bufferserves Insert mode and calls(proc id pane prefix). Its token is the run of word characters before the cursor.'minibufserves the:line and calls(proc id input cursor), withinputthe whole command line. Its token is the whitespace-delimited argument the cursor is in. Either way, the token is what the items are matched against and what accepting one replaces.#:match: how items are scored against the token.'fuzzy(default) keeps items containing the typed characters in order, not necessarily adjacent.'stringkeeps only items that start with the token, all scored equally, so once something is typed, fuzzy matches from other sources rank above them.'delegatedtreats the answer as already filtered and keeps the source's own order.#:priority(default0): breaks score ties between sources, higher first.#:token-chars('buffersources only, default""): adds characters to the token on top of the buffer'sword-chars. With#:token-chars "-$",foo-bais one token: it is whatprefixholds and what accepting an item replaces, and typing-keeps the menu open. It cannot contain whitespace.#:resolve #t('buffersources only, default#f): this source's items come from the buffer's language server, so highlighting one (for its documentation) or accepting one may sendcompletionItem/resolvefor it. Set it only for a source that forwards that server's items, never for one that builds its own items in a buffer with a server attached.- Names:
'bufferand'minibufsources have separate names. Registeringnameagain under the same target replaces the earlier source; the samenameunder the other target is a second, independent source. #:incomplete #toncompletion-emit!asks for the source to be called again as the user keeps typing, instead of filtering the first answer. A server response marked incomplete does the same.completion-emit!also takes a server's wholetextDocument/completionresponse, aslsp-request!orlsp-request-all!delivered it, in itsitemslist; its items join the others. Any entry that is not a string, a hashmap with alabel, or such a response is an error.
Custom pickers
The modal fuzzy-finder panel behind Fuzzy Finder is a generic widget any plugin can drive: core:pickers' own file and buffer finders are built from nothing but this API.
(picker! pane items on-select #:prompt "buffers: ")pane must still be the one you're looking at when the picker opens. Pass through whatever pane value the enclosing command or hook was itself given, or (focused-pane) if there's none in scope.
items is a list of (display . payload) pairs: display is the string shown and matched against, payload is anything you like (a path, a number, a hashmap) except #f, which is reserved for the dismiss signal; HUME never looks inside it. on-select fires exactly once: with the chosen item's payload if the user presses Enter, or #f if they press Esc, call picker-close!, or open a second picker while this one is still open (which replaces it).
picker! returns a token naming this picker; picker-close! takes it. The close is a no-op if the picker has since been closed or replaced, so a callback arriving after the user has moved on (a spawn-async! result, say) never tears down a different picker the user has open by then.
For a handful of items (buffers, a plugin's own static list, the output of a quick synchronous command), build the whole list up front and pass it to picker! directly. For anything enumeration-scale (file lists, grep-style output), open the picker empty and stream an external command's output straight into it instead:
(define token (picker! pane '() (lambda (path) (when path (open-buffer! path))) #:prompt "files: "))
(picker-source-spawn! token "git" '("ls-files" "-z" "--cached" "--others" "--exclude-standard") #:nul #t)picker-source-spawn! runs cmd with args directly (no shell), splitting its stdout into lines (or NUL-delimited fields with #:nul #t) and appending each one to the picker as its own (line . line) item: display and payload are the same raw line, except that a NUL inside a line (a command using NUL as an in-line field separator rather than a record delimiter, such as rg --vimgrep --null) shows as : in the display; the payload keeps it, so parsing inside on-select still sees the real separator. Nothing about the command's output passes through Scheme itself, so this stays fast even for tens of thousands of results; do any parsing of the selected line inside on-select, not up front. The child process is killed automatically if the picker is closed or replaced before the command finishes. (picker-push! token items) appends a batch of ordinary (display . payload) items instead, for a source that produces its own results asynchronously (an LSP request, a timer) rather than through a spawned command.
picker-source-spawn! already shows the user something is still loading. A picker populated by picker-push! from a spawn-async! callback has no such signal of its own, so pass #:pending #t to picker! when opening empty this way. It marks the panel as "results still arriving" until the first picker-push!/picker-replace! call lands.
A row too wide for the panel is clipped to fit, marked with … at the dropped end. #:truncate picks which end: 'head (default) drops the front and keeps the tail, right for a file path, whose distinguishing part (the basename) sits at the end. 'tail drops the back and keeps the head instead, right for a row whose distinguishing part sits at the front, like a grep match's path:line:col: prefix ahead of the line preview, where a head-cut would swallow the path and show only preview text.
Enter accepts the selected row in the picker's own pane; #:actions adds more ways to accept it:
(picker! pane items on-select #:prompt "files: "
#:actions (call! "stdlib/buffer-actions" on-select))stdlib/buffer-actions (from core:stdlib) is a ready-made #:actions list that binds Ctrl-o to on-select itself (a synonym for Enter), and Ctrl-t/Ctrl-v/Ctrl-s to opening the selection in a new tab, a side-by-side split, or a stacked split respectively. See core:pickers' own file, buffer, and modified-files pickers for the pattern in use, and core:stdlib's reference for with-tab/with-vsplit/with-split, the three combinators it composes.
Writing #:actions by hand instead: it's a list of (key-spec . proc) pairs, each proc taking the same one payload argument on-select does. key-spec is a single key written the same way bind-key! writes one ("ctrl-v", "alt-o", "f5"), never a multi-key sequence like "z f". An #:actions entry can never override a key the picker already uses for itself (typing to filter, Backspace, the movement keys, Enter, Esc), so pick a key outside that set (a Ctrl/Alt combination other than the movement ones, or a function key).
A nonzero exit from a spawned source is normally reported as an error, but for a command where some exit codes are a normal outcome rather than a failure (rg exits 1 for "no matches"), pass #:ok-exit-codes. The list is complete, not additive: it replaces the usual "zero is success" rule outright, so include 0 yourself unless you really want a successful run reported as a failure. rg also needs an explicit path argument here: with no path and no terminal attached to its input, it searches its (empty) stdin instead of the working directory, and finds nothing:
(picker-source-spawn! token "rg" (list "--vimgrep" "--" pattern ".") #:ok-exit-codes '(0 1)). repeats a #:repeatable command's pick without opening the picker again, through the same on-select or #:actions proc; see Dot-repeat.
A picker whose query should drive the source itself, instead of just filtering an already-fetched list, uses a different constructor; see Live requery below.
Live requery (live grep)
A picker whose query should re-run an external command with the new pattern on every keystroke (a live grep, say) uses live-picker! instead of picker!:
(define (grep/open! pane seed)
(live-picker! pane (lambda (row) (when row (goto-location! pane (grep/parse row))))
#:prompt "grep: " #:query seed
#:command (lambda (query)
(and (not (equal? query ""))
(list "rg" "--vimgrep" "--" query ".")))
#:ok-exit-codes '(0 1)
#:truncate 'tail))grep/parse above is left to the reader. #:truncate 'tail here (see #:truncate under Custom pickers above, which live-picker! accepts the same way) keeps the path and clips the line preview instead: rg --vimgrep's rows are path:line:col:preview, so the default head-cut would clip the path itself. #:actions (also under Custom pickers above) is accepted the same way too, for opening a match in a new tab or split instead of just the current pane.
grep.hume is this same idea, finished, with grep/parse included.
live-picker! opens empty: there's no items argument, and no #:pending either: a live picker is always populated by its own requery, never by a caller pushing items directly. #:command is a function from the current query to either a full argv list (the same cmd args... shape picker-source-spawn! takes) or #f. HUME calls it on every keystroke and, when it returns a real argv, spawns it exactly the way picker-source-spawn! would (#:cwd/#:nul/#:ok-exit-codes all apply the same way). Returning #f for an empty query, as above, is how you tell the picker "show nothing" rather than search for an empty pattern. That clear itself waits out the debounce window too, same as a real search, since #:command only runs once the respawn fires.
Every keystroke stops whatever search is still running, immediately, but its rows stay on screen, marked as refreshing, until the new search's own results arrive; there's no blank flash in between. #:debounce-ms (default 150) delays only the new search itself, not the stop: type fast and the respawn waits until you pause for the window, still showing the previous pattern's rows the whole time. A query that turns out to match nothing clears the list once the search finishes, rather than leaving stale rows up. rg needs an explicit path argument ("." above): with no path and no terminal attached to its input, it searches its (empty) stdin instead of the working directory and finds nothing. #:ok-exit-codes works exactly as it does for picker-source-spawn!: a complete allowlist, not additive, so include 0 yourself; rg needs '(0 1) since it exits 1 for "no matches".
A non-empty #:query (the seed argument above, say, resuming a search from wherever the last one left off) spawns immediately when the picker opens, before live-picker! even returns, not on the next keystroke. An empty (or omitted) #:query spawns nothing until the user types.
A live picker's rows always show in whatever order the source produced them; its own local fuzzy filter is off, since the query already selects what the source returns.
live-picker! returns a token exactly like picker! does. Reach for picker-push!/picker-replace!/picker-source-spawn!/picker-source-stop!/picker-close! directly, against that token, only if you need to drive a live picker's population or timing yourself instead of #:command/#:debounce-ms.
Bundled core plugins
HUME ships several built-in plugins; see Core Plugins for the full list and what each does.