Native (Rust) Plugins
August 29, 2026 · View on GitHub
zshrs hosts plugins written in a native compiled language (Rust), loaded
at runtime with no recompile of the shell. A plugin is an ordinary
cdylib the shell dlopens through a stable, versioned C ABI. This is
unique to zshrs — see the comparison below.
Rust plugins vs zsh plugins
Shells already have two extension models, and zshrs adds a third. All still work in zshrs (it runs zsh script plugins and its ported modules as before); the Rust plugin path is additive.
Native runtime plugin loading itself is not new — bash has
enable -f file.so name (loadable builtins, since ~1996) and zsh has
zmodload for C modules. What is new is how zshrs exposes it:
script plugin (.zsh) | bash enable -f / zsh zmodload native builtin | zshrs Rust plugin | |
|---|---|---|---|
| Language | shell script (interpreted) | C | Rust (any native lang via the C ABI) |
| Artifact | .zsh text file | .so built in the shell's tree | .dylib / .so cdylib |
| Build against | nothing — it's sourced | the shell's private internal headers (builtins.h/shell.h for bash; the .mdd + Src/Modules/ build for zsh) | the published znative crate — cargo add znative |
| ABI stability | n/a | none — bound to the exact shell build; no version magic in either | stable, versioned ABI_VERSION; mismatches refused at load |
| Distribution | a file to source | must track and rebuild against each shell release | one crates.io SDK crate, independent of the shell's source |
| Load / unload | source (re-parse every startup) | enable -f / -d (bash); zmodload (zsh) — dlsym internal symbols | zmodload -R / -uR — dlsym one symbol, znative_init |
| Execution | interpreted each call | native machine code | native machine code |
| Registers | functions, aliases, options, ZLE widgets, fpath completions | builtins/params/hooks via the shell's internal API | builtins + a curated host API (print/eval/getvar/setvar) |
| Third-party viable | yes (oh-my-zsh, zinit) | rare in practice — needs the shell source tree and tracks its internals, so ~only the shell's own bundled modules exist | yes — depend on one crates.io crate, no zshrs source needed |
| Type safety | none | C (unchecked against shell internals) | Rust type system, checked against the ABI crate |
The distinction that matters is not "loads native code" — bash and zsh both do. It is that bash's and zsh's native interfaces are their internal C APIs: you compile against the shell's private headers, in its build tree, with no stable ABI and no version gate, so a plugin is welded to one shell build and can crash a mismatched one. That is why neither ecosystem has meaningful third-party native plugins — the native modules that exist are almost all the ones the shell ships itself.
zshrs instead exposes a stable, published, versioned C ABI — the
znative crate on crates.io, gated by ABI_VERSION: a third party
runs cargo add znative, writes a handler, ships a cdylib, and it
loads into any compatible zshrs — native speed, no shell source tree, no
recompile, and version-mismatched plugins refused rather than crashing.
Entry 17 in the filtered register, INVENTIONS.md.
First shell to make its native-plugin interface an independently-published, versioned ABI package instead of its own build-tree internals.
Architecture
┌────────────────────────┐ ┌──────────────────────────┐
│ zshrs (host) │ │ libfoo.dylib (plugin) │
│ │ │ │
│ zmodload -R libfoo ────┼─dlopen─▶ znative_init(host) │
│ │ │ host.register_builtin │
│ plugin_host registry ◀────────┤ ("foo", handler) │
│ │ │ │
│ execute_external_bg │ │ │
│ └ plugin_host:: │ │ │
│ dispatch("foo") ──┼─call───▶ handler(host,argc,argv) │
└────────────────────────┘ └──────────────────────────┘
stable C ABI: znative crate (#[repr(C)])
- Host loader:
src/extensions/plugin_host.rs—dlopenvialibloading, an in-process command registry, and the host-callback table plugins call back through. - Shared ABI: the
znativecrate. Both the host and every plugin depend on it, so both agree on the exact#[repr(C)]struct layout.ABI_VERSIONgates loading: a plugin whose version does not match the host is refused (a wrong layout would be undefined behaviour). - Command resolution: fusevm compiles names it does not recognise as
builtins into external execution. A plugin command is unknown at
compile time, so it arrives at
execute_external_bg(src/vm_helper.rs), which consultsplugin_host::dispatchbefore spawning a process — the analog of zsh'sresolvebuiltinslot forzmodload -abautoloaded builtins. Plugin commands therefore resolve after real builtins and shell functions, before PATH lookup.
Writing a plugin
Cargo.toml:
[lib]
crate-type = ["cdylib"]
[dependencies]
znative = "0.12"
src/lib.rs:
use znative::{declare_plugin, Args, Host};
use std::os::raw::c_int;
fn rhello(host: &Host, args: &Args) -> c_int {
let who = if args.rest().is_empty() {
"world".into()
} else {
args.rest().join(", ")
};
host.print(&format!("hello, {who}\n"));
0
}
declare_plugin! {
name: "hello",
version: "0.1.0",
builtins: {
"rhello" => rhello,
},
}
cargo build produces libhello.dylib (macOS) or libhello.so (Linux).
Host API
Inside a handler, Host is the shell's callback table:
| Method | Purpose |
|---|---|
host.print(s) | write to the shell's stdout |
host.eval(code) -> i32 | run shell code, return its exit status |
host.getvar(name) -> Option | read a shell scalar parameter |
host.setvar(name, value) | set a shell scalar parameter |
host.getfunction(name) -> Option | read a shell function's deparsed body (${functions[name]}) — ABI v3 |
host.addfunction(name, body) -> bool | define/replace a shell function (functions[name]=body) — ABI v3 |
host.register_builtin(n, f) | register a command handler dynamically |
host.add_match(word) | emit one completion candidate (see below) |
host.install_completion(cmd, gen) | wire a native completion into compsys |
host.register_compfn(name, f) | override a compsys _fn with a native handler — ABI v4 |
host.comp_dispatch(name, args) -> i32 | invoke a compsys _fn (_alternative, …) — ABI v4 |
host.empty_command_hash() | empty the command-name hash (cmdnamtab) — ABI v4 |
Args decodes argv: .name() is argv[0], .rest() the arguments,
.to_vec() the whole vector.
getfunction/addfunction are the only structured access to shell
functions (getvar is scalars only, eval returns just a status).
Because addfunction then getfunction round-trips a body through the
shell's own parser and pretty-printer, the pair doubles as
deparse-as-a-service — define a temp function from arbitrary source, read
back its normalized form (one statement per line, tab-indented). That is
exactly how history-formatting plugins (e.g. zsh-hist) auto-format a
command line.
A handler returns the command's exit status (0 = success), exactly like
a shell builtin.
Native completions
A plugin can provide a completion written in Rust for a command. Add a
completions: section to declare_plugin!, mapping the command to a
generator function. The generator receives $CURRENT (1-based index of
the word being completed) followed by every word on the line, and emits
candidates with host.add_match:
const NAMES: &[&str] = &["alice", "bob", "carol", "dave", "erin"];
fn greet_complete(host: &Host, args: &Args) -> c_int {
let a = args.rest(); // [CURRENT, word0, word1, ...]
let current: usize = a.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let words = &a[1..]; // words[0] == "greet"
let prefix = current.checked_sub(1).and_then(|i| words.get(i))
.map(String::as_str).unwrap_or("");
for &c in NAMES {
if c.starts_with(prefix) { host.add_match(c); }
}
0
}
declare_plugin! {
name: "greet",
version: "0.1.0",
builtins: { "greet" => greet },
completions: { "greet" => greet_complete },
}
Then greet <TAB> offers alice bob carol dave erin, filtered by the
current prefix — all decided in Rust.
How it wires up: the macro registers the generator as a hidden builtin and
records a completion request. The host defers the compdef wiring until
the first time completion fires (a safe point in the completion pipeline;
doing it during plugin-init would run compsys glue too early). So load
the plugin after compinit. No leading-underscore is used for the
generator name — zsh treats _* command names as autoloadable completers,
which would shadow the builtin.
Compsys function overrides (ABI v4)
A plugin can also replace a compsys internal _fn — e.g. _command_names
— with a native Rust handler, not just add a per-command completion. Add a
compfns: section to declare_plugin!, mapping the _NAME to a handler
(fn(&Host, &Args) -> c_int):
declare_plugin! {
name: "command_names",
version: "0.1.0",
compfns: { "_command_names" => command_names },
}
The completion router resolves an override before the built-in Rust port
and before the autoloaded shell function, so the plugin's version wins.
This is how a user reproduces a customized completer (one whose behavior
differs from stock zsh — such as a framework's patched _command_names) at
native speed, instead of the built-in port silently running stock behavior.
The handler builds its tag/spec list in Rust, then delegates the heavy
lifting with host.comp_dispatch("_alternative", &specs); host.getvar
reads $PREFIX/$PATH/etc., and host.eval runs any snippet needing shell
scope (e.g. a local -A +h commands shadow that must stay live across the
nested _alternative/_path_commands calls). See
examples/plugin-command-names/.
Overrides are opt-in per zmodload -R: without the plugin loaded, the
built-in port runs unchanged. Unload purges the override before
dlclose, like command registrations.
Managing plugins
zmodload -R <path>... # load each cdylib
zmodload -R # list loaded plugins: name version path
zmodload -uR <name>... # unload each plugin by name
-R without -A is a zshrs extension. In C zsh -R removes a module
alias and is only meaningful alongside -A; zmodload -A -R <name> keeps
that behaviour, so no zsh parity is lost.
Loading a plugin whose name is already loaded is refused — unload first.
Unload purges the plugin's command registrations before dlclose, so
no live function pointer survives the library it lives in.
Installing with znative
zmodload -R is the low-level primitive. For distribution, znative (zshrs's
package manager) installs a plugin straight from a GitHub repo — it clones,
cargo build --releases the cdylib, and zmodload -Rs it. The full command,
source-spec, store-layout, and znative.toml reference is in
ZNATIVE.md; this section covers the plugin-authoring side.
The one line a .zshrc needs per plugin is znative load owner/repo — it
installs on the first start and loads from the store (zero-network) after:
znative load MenkeTechnologies/zshrs-forgit # native (Rust) plugin, self-installing
znative load MenkeTechnologies/zshrs-git-fuzzy
znative add (install without a .zshrc line), znative list, znative remove round
out the store — see ZNATIVE.md for all commands.
znative auto-detects a native plugin from a Cargo.toml with a cdylib
crate-type (ordinary *.plugin.zsh script repos install too, no metadata
needed). An optional znative.toml at the repo root supplies metadata and the
lib stem:
[plugin]
name = "forgit"
version = "0.1.0"
description = "forgit ported to a native zshrs plugin"
[native]
lib = "forgit" # produces libforgit.{dylib,so}
A plugin published this way depends on the SDK as a git dependency so it
builds standalone: znative = { git = "https://github.com/MenkeTechnologies/zshrs" }.
Script (.zsh) plugins
znative installs ordinary zsh script plugins too — the ones that register ZLE
widgets, functions, and completions. These stay script (the native ABI
registers builtins and completions, not ZLE widgets), and znative loads them
by adding the repo to $fpath and sourcing its *.plugin.zsh:
znative add zdharma-continuum/history-search-multi-word # Ctrl-R multi-word history search
history-search-multi-word is a good stress test: a ZLE widget with its
own forked syntax-highlighter, paged POSTDISPLAY output, live
region_highlight, and in-widget key reads. It installs, znative loads, binds
^R, and runs under zshrs's ZLE unchanged.
Beyond owner/repo, znative add also takes github:owner/repo, a git+URL
(optionally with an @ref tag/branch), and path:DIR for a local checkout —
see ZNATIVE.md for all forms. A runnable .zshrc using znative is
at examples/zshrc; the one line a startup file needs is
znative load, which is zero-network (store + index only) and safe even before
anything is installed.
ABI versioning
ABI_VERSION in the znative crate is bumped on any change to the
HostApi / PluginInfo / BuiltinFn layout or semantics. The host
refuses to load a mismatched plugin. Keep the crate's major/minor aligned
with your target zshrs release. The single exported symbol every plugin
must provide is znative_init (generated by declare_plugin!).
Examples
Two runnable examples ship in examples/.
plugin-hello/ — builtins, host API:
cargo build --manifest-path examples/plugin-hello/Cargo.toml
zshrs -c '
zmodload -R examples/plugin-hello/target/debug/libhello.dylib
rhello alice bob
renv HOME
renv MYVAR hi; echo $MYVAR
zmodload -uR hello
'
plugin-complete/ — a native Rust
completion for a greet command:
cargo build --manifest-path examples/plugin-complete/Cargo.toml
zshrs # interactive
# % autoload -Uz compinit; compinit
# % zmodload -R examples/plugin-complete/target/debug/libgreet.dylib
# % greet <TAB> → alice bob carol dave erin
# % greet --lang <TAB> → rust ruby python perl go
plugin-kubectl/ — a real-world completion:
kubectl completion that delegates to cobra's kubectl __complete, so it
tracks the installed kubectl version (subcommands, flags, live resources)
with no static tree to maintain. Published as:
znative load MenkeTechnologies/zshrs-kubectl-completion.
plugin-forgit/ — the forgit git+fzf
plugin ported command-for-command from zsh (ga glo gd gcf …). See
PORTING_ZSH_PLUGIN.md for the full zsh→Rust
walkthrough. Published as a standalone repo:
znative load MenkeTechnologies/zshrs-forgit.
plugin-git-fuzzy/ — git-fuzzy's
status command: a self-reentrant fzf UI (every preview/keybind
re-invokes a helper, plus a --listen live-reload watcher). Shows the shim
technique that lets fzf binds reach native builtins — see the "self-reentrant
fzf tools" section of the porting guide. Published as:
znative load MenkeTechnologies/zshrs-git-fuzzy.
plugin-zsh-z/ — zsh-z, the frecency
directory jumper (z <partial>), reimplemented in Rust: the ~/.z datafile,
the frecency formula, aging, matching, and all z options. cd is delegated
to the shell (host.eval) so $PWD/hooks stay correct; a chpwd hook does
the recording. Published as: znative load MenkeTechnologies/zshrs-zsh-z.
plugin-git-repos/ — zsh-git-repo-cache:
scan the filesystem for every git repo, cache it, and fzf-pick one to cd
into, with --clean/--dirty filters. The shell version does a sequential
git status per repo; the native version classifies in parallel across
threads — a real speedup. Published as: znative load MenkeTechnologies/zshrs-git-repos.
plugin-revolver/ — revolver, the
progress spinner (revolver start/update/stop/demo, 55 styles), ported from
zsh. Upstream forks a background process and coordinates through a
$PPID-keyed statefile; the native port keeps the animator as an in-process
thread and the running spinner in a static slot — no fork, no
statefile, since start/update/stop now share memory. Shows how a plugin
does concurrent/background work under a non-forking shell. Published as a
standalone repo: znative load MenkeTechnologies/zshrs-revolver.
plugin-fasd/ — fasd, frecency for files
and directories (a s d f j v + fasd), ported from the fasd shell script.
A preexec hook feeds every command's path arguments to the datafile; queries
match terms in order (last term in the basename) case-sensitive → insensitive
→ fuzzy, and score Σrank * frecent(dx). Where zsh-z tracks only cd
targets, fasd tracks files too — the regex + fuzzy matcher and awk scoring are
reimplemented in Rust. Published as: znative load MenkeTechnologies/zshrs-fasd.
plugin-reveal/ — reveal (from gh_reveal):
open the current repo's GitHub page(s) in the browser. Picks the platform
opener, reads git remote -v, normalizes each SSH/HTTPS URL to
host/user/repo (dropping scheme, userinfo, port, .git), and opens
https://<that> — Heroku remotes open their dashboard + app URLs. Shows a
plugin that shells out (git, the OS opener) rather than reimplementing.
Published as: znative load MenkeTechnologies/zshrs-reveal.
Porting an existing zsh plugin
If you have a zsh plugin (shell functions + aliases + completions) and want it as a native plugin, PORTING_ZSH_PLUGIN.md is a step-by-step guide: the construct-by-construct mapping, the list→fzf→act pattern, subprocess/tty handling, and what stays shell — worked end-to-end on forgit.
Safety notes
- The loaded
libloading::Libraryis kept alive for the process (or until unload); itsDropis adlclose. - A plugin panicking across the C-ABI boundary is undefined behaviour. Handlers should catch their own panics or avoid panicking; the host does not unwind through the FFI boundary for you.
- Plugin builtins run synchronously in-process, even when backgrounded
(
foo &) — zshrs is non-forking and an in-process builtin has nothing to background.