Skip to main content

zsh/extensions/
plugin_host.rs

1//! Native (Rust) plugin host — extension; no zsh C counterpart.
2//!
3//! zsh's `Src/module.c` `dlopen`s C `.so` modules that call `addbuiltin`
4//! against the shell's own symbols. zshrs generalises that into a
5//! **stable, versioned C ABI** (`znative` crate) so third parties
6//! ship a compiled `cdylib` and load it at runtime with
7//! `zmodload -R <path>` — no zshrs recompile, no zsh script glue. This
8//! is the first compiled Unix shell hosting native compiled-language
9//! plugins.
10//!
11//! ## Where plugin commands resolve
12//!
13//! fusevm compiles command names it does not recognise as builtins into
14//! *external* execution. A freshly-loaded plugin command is therefore
15//! unknown at compile time and arrives at
16//! [`crate::vm_helper`]'s `execute_external_bg`, which consults
17//! [`dispatch`] BEFORE spawning a process — the same slot zsh uses for
18//! `zmodload -ab` autoloaded builtins (`resolvebuiltin`). Plugins thus
19//! resolve after real builtins and functions, before PATH lookup.
20//!
21//! ## ABI safety
22//!
23//! Everything crossing the boundary is `#[repr(C)]`. The host verifies
24//! the plugin's `abi_version` matches [`znative::ABI_VERSION`]
25//! before trusting any pointer it returns; a mismatch is refused (a
26//! wrong struct layout would be undefined behaviour). The loaded
27//! [`libloading::Library`] is kept alive for the process lifetime — its
28//! `Drop` is a `dlclose`, which would invalidate the still-registered
29//! function pointers, so unload explicitly purges the registry first.
30
31#![allow(unused_imports)]
32
33use std::collections::HashMap;
34use std::ffi::{CStr, CString};
35use std::os::raw::{c_char, c_int};
36use std::sync::{Mutex, OnceLock};
37
38use znative::{BuiltinFn, CompFn, HostApi, InitFn, PluginInfo, ABI_VERSION, INIT_SYMBOL};
39
40/// One loaded plugin. Dropping `_lib` runs `dlclose`, so this is only
41/// ever removed by [`unload`] AFTER its builtins are purged from
42/// [`registry`].
43struct LoadedPlugin {
44    name: String,
45    version: String,
46    path: String,
47    /// Kept alive for the process lifetime; drop = `dlclose`.
48    _lib: libloading::Library,
49}
50
51/// A registered command → its handler and owning plugin.
52#[derive(Clone, Copy)]
53struct BuiltinEntry {
54    func: BuiltinFn,
55    /// Index into the intern table is overkill; store nothing but the
56    /// function — ownership is tracked by name-prefix scan at unload.
57    _pad: (),
58}
59
60fn plugins() -> &'static Mutex<Vec<LoadedPlugin>> {
61    static P: OnceLock<Mutex<Vec<LoadedPlugin>>> = OnceLock::new();
62    P.get_or_init(|| Mutex::new(Vec::new()))
63}
64
65/// command-name → handler. Consulted by `execute_external_bg`.
66fn registry() -> &'static Mutex<HashMap<String, BuiltinEntry>> {
67    static R: OnceLock<Mutex<HashMap<String, BuiltinEntry>>> = OnceLock::new();
68    R.get_or_init(|| Mutex::new(HashMap::new()))
69}
70
71/// Staging area for builtins registered during a single `init` call.
72/// `init` runs before it returns the plugin name, so registrations are
73/// buffered here and tagged with the owning plugin afterwards. Access is
74/// serialised by [`load_lock`].
75fn staging() -> &'static Mutex<Vec<(String, BuiltinFn)>> {
76    static S: OnceLock<Mutex<Vec<(String, BuiltinFn)>>> = OnceLock::new();
77    S.get_or_init(|| Mutex::new(Vec::new()))
78}
79
80/// Serialises `load`/`unload` so the [`staging`] buffer is single-writer.
81fn load_lock() -> &'static Mutex<()> {
82    static L: OnceLock<Mutex<()>> = OnceLock::new();
83    L.get_or_init(|| Mutex::new(()))
84}
85
86/// Which plugin currently owns each registered command name — parallel
87/// to [`registry`], used only for `unload` bookkeeping.
88fn ownership() -> &'static Mutex<HashMap<String, String>> {
89    static O: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
90    O.get_or_init(|| Mutex::new(HashMap::new()))
91}
92
93/// compsys `_NAME` → (override handler, owning plugin). Consulted by the
94/// completion router (`compsys::router::try_rust_dispatch`) BEFORE the
95/// built-in Rust port, so a plugin-provided `_command_names` (etc.) wins.
96/// (ABI v4.)
97fn compfn_registry() -> &'static Mutex<HashMap<String, (CompFn, String)>> {
98    static CR: OnceLock<Mutex<HashMap<String, (CompFn, String)>>> = OnceLock::new();
99    CR.get_or_init(|| Mutex::new(HashMap::new()))
100}
101
102/// Staging for compfn overrides registered during a single `init`, tagged
103/// with the owner after init returns. Serialised by [`load_lock`]. (ABI v4.)
104fn compfn_staging() -> &'static Mutex<Vec<(String, CompFn)>> {
105    static CS: OnceLock<Mutex<Vec<(String, CompFn)>>> = OnceLock::new();
106    CS.get_or_init(|| Mutex::new(Vec::new()))
107}
108
109/// Completion wirings a plugin requested via `register_completion` that
110/// have not yet been installed into compsys. Each entry is
111/// `(cmd, generator_builtin, owning_plugin)`. Flushed by
112/// [`flush_pending_completions`] at a safe point in the completion
113/// pipeline (NOT during plugin init — evaling compsys glue deep inside
114/// the `zmodload` call stack hangs the VM; `do_completion` is a designed
115/// re-entry point where compsys itself evals).
116fn pending_completions() -> &'static Mutex<Vec<(String, String, String)>> {
117    static PC: OnceLock<Mutex<Vec<(String, String, String)>>> = OnceLock::new();
118    PC.get_or_init(|| Mutex::new(Vec::new()))
119}
120
121/// Completions already wired into compsys, so `unload` can drop their
122/// glue functions and `flush` never double-installs. Maps cmd → owner.
123fn installed_completions() -> &'static Mutex<HashMap<String, String>> {
124    static IC: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
125    IC.get_or_init(|| Mutex::new(HashMap::new()))
126}
127
128// ============================================================
129// Host API callbacks — the `extern "C"` functions plugins call back
130// through. One shared, leaked `HostApi` table for the whole process.
131// ============================================================
132
133extern "C" fn host_register_builtin(
134    _host: *const HostApi,
135    name: *const c_char,
136    handler: BuiltinFn,
137) -> c_int {
138    if name.is_null() {
139        return 1;
140    }
141    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
142    staging().lock().unwrap().push((name, handler));
143    0
144}
145
146extern "C" fn host_print(_host: *const HostApi, text: *const c_char) {
147    if text.is_null() {
148        return;
149    }
150    let s = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned();
151    use std::io::Write as _;
152    let mut out = std::io::stdout();
153    let _ = out.write_all(s.as_bytes());
154    let _ = out.flush();
155}
156
157extern "C" fn host_eval(_host: *const HostApi, code: *const c_char) -> c_int {
158    if code.is_null() {
159        return 1;
160    }
161    let code = unsafe { CStr::from_ptr(code) }.to_string_lossy().into_owned();
162    // A plugin builtin runs inside VM context (dispatch happens in
163    // execute_external_bg), so an executor is in scope. Re-entrant
164    // with_executor is safe: the borrow is released before `f` runs.
165    crate::fusevm_bridge::try_with_executor(|exec| exec.execute_script(&code))
166        .map(|r| r.unwrap_or(1))
167        .unwrap_or(1)
168}
169
170extern "C" fn host_getvar(_host: *const HostApi, name: *const c_char) -> *mut c_char {
171    if name.is_null() {
172        return std::ptr::null_mut();
173    }
174    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
175    match crate::ported::params::getsparam(&name) {
176        Some(v) => match CString::new(v) {
177            Ok(c) => c.into_raw(),
178            Err(_) => std::ptr::null_mut(),
179        },
180        None => std::ptr::null_mut(),
181    }
182}
183
184extern "C" fn host_setvar(
185    _host: *const HostApi,
186    name: *const c_char,
187    value: *const c_char,
188) -> c_int {
189    if name.is_null() || value.is_null() {
190        return 1;
191    }
192    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
193    let value = unsafe { CStr::from_ptr(value) }.to_string_lossy().into_owned();
194    crate::ported::params::setsparam(&name, &value);
195    0
196}
197
198extern "C" fn host_free_cstring(_host: *const HostApi, s: *mut c_char) {
199    if !s.is_null() {
200        // Reclaim ownership of a string we handed out via `into_raw`.
201        unsafe { drop(CString::from_raw(s)) };
202    }
203}
204
205extern "C" fn host_register_completion(
206    _host: *const HostApi,
207    cmd: *const c_char,
208    generator: *const c_char,
209) -> c_int {
210    if cmd.is_null() || generator.is_null() {
211        return 1;
212    }
213    let cmd = unsafe { CStr::from_ptr(cmd) }.to_string_lossy().into_owned();
214    let generator = unsafe { CStr::from_ptr(generator) }
215        .to_string_lossy()
216        .into_owned();
217    // Owner is tagged after init returns (name unknown yet); stage empty.
218    pending_completions()
219        .lock()
220        .unwrap()
221        .push((cmd, generator, String::new()));
222    0
223}
224
225extern "C" fn host_getfunction(_host: *const HostApi, name: *const c_char) -> *mut c_char {
226    if name.is_null() {
227        return std::ptr::null_mut();
228    }
229    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
230    // Route through the exact `${functions[name]}` read: getpmfunction
231    // deparses the body into `u_str` and flags PM_UNSET when undefined.
232    match crate::ported::modules::parameter::getpmfunction(std::ptr::null_mut(), &name) {
233        Some(pm) if (pm.node.flags & crate::ported::zsh_h::PM_UNSET as i32) == 0 => {
234            match pm.u_str.and_then(|s| CString::new(s).ok()) {
235                Some(c) => c.into_raw(),
236                None => std::ptr::null_mut(),
237            }
238        }
239        _ => std::ptr::null_mut(),
240    }
241}
242
243extern "C" fn host_addfunction(
244    _host: *const HostApi,
245    name: *const c_char,
246    body: *const c_char,
247) -> c_int {
248    if name.is_null() || body.is_null() {
249        return 1;
250    }
251    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
252    let body = unsafe { CStr::from_ptr(body) }.to_string_lossy().into_owned();
253    if name.is_empty() {
254        return 1;
255    }
256    // Same install as `functions[name]=body`: parse `body` and store the
257    // shfunc in shfunctab (dis = 0 = enabled).
258    crate::ported::modules::parameter::setfunction(&name, body, 0);
259    // setfunction installs unconditionally; report success.
260    0
261}
262
263extern "C" fn host_register_compfn(
264    _host: *const HostApi,
265    name: *const c_char,
266    handler: CompFn,
267) -> c_int {
268    if name.is_null() {
269        return 1;
270    }
271    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
272    if name.is_empty() {
273        return 1;
274    }
275    compfn_staging().lock().unwrap().push((name, handler));
276    0
277}
278
279extern "C" fn host_comp_dispatch(
280    _host: *const HostApi,
281    name: *const c_char,
282    argc: usize,
283    argv: *const *const c_char,
284) -> c_int {
285    if name.is_null() {
286        return 1;
287    }
288    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
289    // Decode argv[0..argc] into owned Strings (argv[0] is the _fn name;
290    // dispatch_function_call takes the arguments after it).
291    let mut args: Vec<String> = Vec::with_capacity(argc);
292    if !argv.is_null() {
293        for i in 0..argc {
294            let p = unsafe { *argv.add(i) };
295            if p.is_null() {
296                break;
297            }
298            args.push(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned());
299        }
300    }
301    // args[0] is `name` itself (the compfn's argv[0]); pass args[1..] as the
302    // dispatched function's argument list, matching how the completer chain
303    // invokes `_alternative`/`_path_commands` etc.
304    let call_args: &[String] = if args.is_empty() { &[] } else { &args[1..] };
305    crate::ported::exec::dispatch_function_call(&name, call_args).unwrap_or(1)
306}
307
308extern "C" fn host_empty_command_hash(_host: *const HostApi) {
309    crate::ported::hashtable::emptycmdnamtable();
310}
311
312/// The single process-wide host table. Leaked so its address is
313/// `'static` — plugins may retain the `*const HostApi` and call through
314/// it from any builtin at any time.
315fn host_api() -> *const HostApi {
316    static API: OnceLock<usize> = OnceLock::new();
317    let addr = API.get_or_init(|| {
318        let boxed = Box::new(HostApi {
319            abi_version: ABI_VERSION,
320            ctx: std::ptr::null_mut(),
321            register_builtin: host_register_builtin,
322            print: host_print,
323            eval: host_eval,
324            getvar: host_getvar,
325            setvar: host_setvar,
326            free_cstring: host_free_cstring,
327            register_completion: host_register_completion,
328            getfunction: host_getfunction,
329            addfunction: host_addfunction,
330            register_compfn: host_register_compfn,
331            comp_dispatch: host_comp_dispatch,
332            empty_command_hash: host_empty_command_hash,
333        });
334        Box::into_raw(boxed) as usize
335    });
336    *addr as *const HostApi
337}
338
339// ============================================================
340// Public API — driven by `zmodload -R`.
341// ============================================================
342
343/// Load a plugin `cdylib` from `path`. Returns the plugin's name on
344/// success. Idempotent-ish: loading a plugin whose name is already
345/// present is refused (unload first).
346pub fn load(path: &str) -> Result<String, String> {
347    let _guard = load_lock().lock().unwrap();
348
349    // `dlopen`. libloading resolves relative paths against the loader's
350    // search rules; expand `~` for convenience since shells hand raw
351    // tokens here.
352    let expanded = expand_tilde(path);
353    let lib = unsafe { libloading::Library::new(&expanded) }
354        .map_err(|e| format!("cannot load `{}`: {}", path, e))?;
355
356    // Resolve the mandatory init symbol.
357    let init: libloading::Symbol<InitFn> = unsafe {
358        lib.get(INIT_SYMBOL)
359            .map_err(|_| format!("`{}`: not a zshrs plugin (no {})", path,
360                String::from_utf8_lossy(&INIT_SYMBOL[..INIT_SYMBOL.len() - 1])))?
361    };
362
363    // Clear staging, call init, collect what it registered. Snapshot the
364    // pending-completion length so we can tag the entries THIS init adds
365    // with the owning plugin (the name isn't known until init returns).
366    staging().lock().unwrap().clear();
367    compfn_staging().lock().unwrap().clear();
368    let pc_start = pending_completions().lock().unwrap().len();
369    let info_ptr: *const PluginInfo = init(host_api());
370    if info_ptr.is_null() {
371        staging().lock().unwrap().clear();
372        compfn_staging().lock().unwrap().clear();
373        pending_completions().lock().unwrap().truncate(pc_start);
374        return Err(format!("`{}`: plugin init failed (ABI mismatch or error)", path));
375    }
376    let info = unsafe { &*info_ptr };
377    if info.abi_version != ABI_VERSION {
378        staging().lock().unwrap().clear();
379        compfn_staging().lock().unwrap().clear();
380        pending_completions().lock().unwrap().truncate(pc_start);
381        return Err(format!(
382            "`{}`: ABI version {} != host {}",
383            path, info.abi_version, ABI_VERSION
384        ));
385    }
386    let name = cstr_or(info.name, "unknown");
387    let version = cstr_or(info.version, "?");
388
389    // Refuse a duplicate name — the second load's builtins would shadow
390    // the first with no clean unload story.
391    if plugins().lock().unwrap().iter().any(|p| p.name == name) {
392        staging().lock().unwrap().clear();
393        compfn_staging().lock().unwrap().clear();
394        pending_completions().lock().unwrap().truncate(pc_start);
395        return Err(format!("plugin `{}` already loaded", name));
396    }
397
398    // Commit staged builtins into the live registry, tagged with owner.
399    let staged: Vec<(String, BuiltinFn)> = std::mem::take(&mut *staging().lock().unwrap());
400    {
401        let mut reg = registry().lock().unwrap();
402        let mut own = ownership().lock().unwrap();
403        for (cmd, func) in staged {
404            reg.insert(cmd.clone(), BuiltinEntry { func, _pad: () });
405            own.insert(cmd, name.clone());
406        }
407    }
408
409    // Commit staged compfn overrides, tagged with owner. (ABI v4.)
410    let staged_cf: Vec<(String, CompFn)> =
411        std::mem::take(&mut *compfn_staging().lock().unwrap());
412    {
413        let mut cr = compfn_registry().lock().unwrap();
414        for (fname, func) in staged_cf {
415            cr.insert(fname, (func, name.clone()));
416        }
417    }
418
419    // Tag the completion wirings this init staged with the owning plugin.
420    {
421        let mut pc = pending_completions().lock().unwrap();
422        for entry in pc.iter_mut().skip(pc_start) {
423            entry.2 = name.clone();
424        }
425    }
426
427    plugins().lock().unwrap().push(LoadedPlugin {
428        name: name.clone(),
429        version: version.clone(),
430        path: expanded,
431        _lib: lib,
432    });
433
434    tracing::info!(plugin = %name, version = %version, path, "loaded native plugin");
435    Ok(name)
436}
437
438/// Install any completion wirings that plugins requested but that have not
439/// yet been bound into compsys. Called at the top of the completion
440/// pipeline (`do_completion`) — a safe point where compsys itself evals,
441/// unlike plugin-init (deep in the `zmodload` call stack, where evaling
442/// hangs the VM). Idempotent: each pending entry is installed once, then
443/// moved to `installed_completions`.
444///
445/// For each `(cmd, generator)` it defines a compsys completion function
446/// `_zshrs_plug_<cmd>` that runs the generator with `$CURRENT $words` and
447/// `compadd`s its newline-separated output, then binds it with `compdef`.
448pub fn flush_pending_completions() {
449    let pending: Vec<(String, String, String)> = {
450        let mut pc = pending_completions().lock().unwrap();
451        if pc.is_empty() {
452            return;
453        }
454        std::mem::take(&mut *pc)
455    };
456    for (cmd, generator, owner) in pending {
457        // `${(@f)...}` splits the generator's stdout on newlines into the
458        // match array; guard on compdef so a pre-compinit flush no-ops.
459        let glue = format!(
460            "_zshrs_plug_{cmd}() {{ \
461                local -a _zp_m; \
462                _zp_m=(\"${{(@f)$({gen} $CURRENT $words)}}\"); \
463                compadd -- $_zp_m; \
464             }}; \
465             (( ${{+functions[compdef]}} )) && compdef _zshrs_plug_{cmd} {cmd} 2>/dev/null; :",
466            cmd = cmd,
467            gen = generator,
468        );
469        // Use the exec.rs free fn (NOT try_with_executor): during
470        // `do_completion` the thread-local CURRENT_EXECUTOR is unset —
471        // completion runs on SESSION_EXECUTOR. try_with_executor would
472        // no-op here and the compdef glue would never eval. This free fn
473        // falls back to SESSION_EXECUTOR, so the glue lands on the same
474        // executor that then runs the completion.
475        let _ = crate::ported::exec::execute_script(&glue);
476        installed_completions()
477            .lock()
478            .unwrap()
479            .insert(cmd, owner);
480    }
481}
482
483/// Unload a plugin by name: purge its command registrations FIRST (so no
484/// live function pointer survives), then drop the `Library` (`dlclose`).
485pub fn unload(name: &str) -> Result<(), String> {
486    let _guard = load_lock().lock().unwrap();
487
488    let present = plugins().lock().unwrap().iter().any(|p| p.name == name);
489    if !present {
490        return Err(format!("plugin `{}` not loaded", name));
491    }
492
493    // Purge registry entries owned by this plugin.
494    {
495        let mut own = ownership().lock().unwrap();
496        let mut reg = registry().lock().unwrap();
497        let owned: Vec<String> = own
498            .iter()
499            .filter(|(_, o)| o.as_str() == name)
500            .map(|(c, _)| c.clone())
501            .collect();
502        for cmd in owned {
503            reg.remove(&cmd);
504            own.remove(&cmd);
505        }
506    }
507
508    // Purge compfn overrides owned by this plugin BEFORE dlclose, so no
509    // dangling CompFn pointer survives the `dlclose`. (ABI v4.)
510    compfn_registry()
511        .lock()
512        .unwrap()
513        .retain(|_, (_, o)| o.as_str() != name);
514
515    // Drop this plugin's completion bookkeeping. We do NOT eval here to
516    // tear down the compsys glue function (evaling deep in the `zmodload`
517    // call stack hangs the VM); the orphaned `_zshrs_plug_<cmd>` function
518    // simply calls a now-removed generator builtin → empty command-subst
519    // → no matches. Removing the `installed_completions` record lets a
520    // later reload re-install cleanly.
521    pending_completions()
522        .lock()
523        .unwrap()
524        .retain(|(_, _, o)| o != name);
525    installed_completions()
526        .lock()
527        .unwrap()
528        .retain(|_, o| o != name);
529
530    // Now it is safe to dlclose.
531    let mut ps = plugins().lock().unwrap();
532    if let Some(pos) = ps.iter().position(|p| p.name == name) {
533        let p = ps.remove(pos);
534        tracing::info!(plugin = %name, "unloaded native plugin");
535        drop(p); // explicit: dlclose here, after registry purge.
536    }
537    Ok(())
538}
539
540/// Completion-router hook. Returns a plugin-registered override handler
541/// for the compsys function `name` (a `_NAME`), or `None`. Consulted by
542/// `compsys::router::try_rust_dispatch` BEFORE the built-in Rust port, so a
543/// plugin's `_command_names` (etc.) supersedes both the port and the shell
544/// autoload. (ABI v4.)
545pub fn compfn_override(name: &str) -> Option<CompFn> {
546    compfn_registry().lock().unwrap().get(name).map(|(f, _)| *f)
547}
548
549/// Invoke a plugin's override for compsys `_fn` `name`, if one is
550/// registered. `args` are the completion-function arguments (argv[1..]);
551/// argv[0] is set to `name`, matching the completer-chain convention.
552/// Returns `Some(rc)` if a plugin handled it, else `None` (fall through to
553/// the built-in port / shell autoload). (ABI v4.)
554pub fn dispatch_compfn(name: &str, args: &[String]) -> Option<i32> {
555    let func = compfn_override(name)?;
556    // Build argv = [name, args...] as NUL-terminated C strings, valid for
557    // the duration of the call (mirrors `dispatch`).
558    let mut owned: Vec<CString> = Vec::with_capacity(args.len() + 1);
559    owned.push(CString::new(name).ok()?);
560    for a in args {
561        owned.push(
562            CString::new(a.as_str())
563                .unwrap_or_else(|_| CString::new(a.replace('\0', "")).unwrap_or_default()),
564        );
565    }
566    let ptrs: Vec<*const c_char> = owned.iter().map(|c| c.as_ptr()).collect();
567    let rc = func(host_api(), ptrs.len(), ptrs.as_ptr());
568    Some(rc as i32)
569}
570
571/// Command-resolution hook. Called from `execute_external_bg` for bare
572/// command names. Returns `Some(exit_status)` if a plugin owns `cmd`,
573/// else `None` (let PATH lookup proceed).
574pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
575    let entry = { registry().lock().unwrap().get(cmd).copied() }?;
576
577    // Build argv = [cmd, args...] as NUL-terminated C strings. Interior
578    // NULs can't occur in a shell word, but be defensive.
579    let mut owned: Vec<CString> = Vec::with_capacity(args.len() + 1);
580    owned.push(CString::new(cmd).ok()?);
581    for a in args {
582        owned.push(CString::new(a.as_str()).unwrap_or_else(|_| {
583            CString::new(a.replace('\0', "")).unwrap_or_default()
584        }));
585    }
586    let ptrs: Vec<*const c_char> = owned.iter().map(|c| c.as_ptr()).collect();
587
588    let rc = (entry.func)(host_api(), ptrs.len(), ptrs.as_ptr());
589    // `owned`/`ptrs` outlive the call. Done.
590    Some(rc as i32)
591}
592
593/// `(name, version, path)` for each loaded plugin, for `zmodload -R`
594/// listing. Sorted by name for stable output.
595pub fn list() -> Vec<(String, String, String)> {
596    let mut v: Vec<(String, String, String)> = plugins()
597        .lock()
598        .unwrap()
599        .iter()
600        .map(|p| (p.name.clone(), p.version.clone(), p.path.clone()))
601        .collect();
602    v.sort_by(|a, b| a.0.cmp(&b.0));
603    v
604}
605
606/// True if `name` is a live plugin command. Used where callers want to
607/// know whether a name resolves to a plugin without invoking it.
608pub fn is_plugin_command(name: &str) -> bool {
609    registry().lock().unwrap().contains_key(name)
610}
611
612/// `zmodload -R` native-plugin management, dispatched from
613/// `crate::ported::module::bin_zmodload` (kept out of `src/ported/`
614/// because it is a zshrs extension, not a C port).
615///
616///   `zmodload -R  <path>...`  load each cdylib
617///   `zmodload -R`             list loaded plugins (`name  version  path`)
618///   `zmodload -uR <name>...`  unload each plugin by name
619pub fn zmodload_rust_cmd(
620    nam: &str,
621    args: &[String],
622    ops: &crate::ported::zsh_h::options,
623) -> i32 {
624    use crate::ported::utils::zwarnnam;
625    use crate::ported::zsh_h::OPT_ISSET;
626
627    // `-u` → unload.
628    if OPT_ISSET(ops, b'u') {
629        if args.is_empty() {
630            zwarnnam(nam, "what do you want to unload?");
631            return 1;
632        }
633        let mut ret = 0;
634        for name in args {
635            if let Err(e) = unload(name) {
636                zwarnnam(nam, &e);
637                ret = 1;
638            }
639        }
640        return ret;
641    }
642    // No args → list.
643    if args.is_empty() {
644        for (name, version, path) in list() {
645            println!("{}  {}  {}", name, version, path);
646        }
647        return 0;
648    }
649    // Load each path.
650    let mut ret = 0;
651    for path in args {
652        if let Err(e) = load(path) {
653            zwarnnam(nam, &e);
654            ret = 1;
655        }
656    }
657    ret
658}
659
660fn cstr_or(p: *const c_char, dflt: &str) -> String {
661    if p.is_null() {
662        dflt.to_string()
663    } else {
664        unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
665    }
666}
667
668fn expand_tilde(path: &str) -> String {
669    if let Some(rest) = path.strip_prefix("~/") {
670        if let Some(home) = dirs::home_dir() {
671            return home.join(rest).to_string_lossy().into_owned();
672        }
673    }
674    path.to_string()
675}