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 builtinzshrs Rust plugin
Languageshell script (interpreted)CRust (any native lang via the C ABI)
Artifact.zsh text file.so built in the shell's tree.dylib / .so cdylib
Build againstnothing — it's sourcedthe 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 stabilityn/anone — bound to the exact shell build; no version magic in eitherstable, versioned ABI_VERSION; mismatches refused at load
Distributiona file to sourcemust track and rebuild against each shell releaseone crates.io SDK crate, independent of the shell's source
Load / unloadsource (re-parse every startup)enable -f / -d (bash); zmodload (zsh) — dlsym internal symbolszmodload -R / -uRdlsym one symbol, znative_init
Executioninterpreted each callnative machine codenative machine code
Registersfunctions, aliases, options, ZLE widgets, fpath completionsbuiltins/params/hooks via the shell's internal APIbuiltins + a curated host API (print/eval/getvar/setvar)
Third-party viableyes (oh-my-zsh, zinit)rare in practice — needs the shell source tree and tracks its internals, so ~only the shell's own bundled modules existyes — depend on one crates.io crate, no zshrs source needed
Type safetynoneC (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.rsdlopen via libloading, an in-process command registry, and the host-callback table plugins call back through.
  • Shared ABI: the znative crate. Both the host and every plugin depend on it, so both agree on the exact #[repr(C)] struct layout. ABI_VERSION gates 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 consults plugin_host::dispatch before spawning a process — the analog of zsh's resolvebuiltin slot for zmodload -ab autoloaded 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:

MethodPurpose
host.print(s)write to the shell's stdout
host.eval(code) -> i32run shell code, return its exit status
host.getvar(name) -> Optionread a shell scalar parameter
host.setvar(name, value)set a shell scalar parameter
host.getfunction(name) -> Optionread a shell function's deparsed body (${functions[name]}) — ABI v3
host.addfunction(name, body) -> booldefine/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) -> i32invoke 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::Library is kept alive for the process (or until unload); its Drop is a dlclose.
  • 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.