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 JIT-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) }
142        .to_string_lossy()
143        .into_owned();
144    staging().lock().unwrap().push((name, handler));
145    0
146}
147
148extern "C" fn host_print(_host: *const HostApi, text: *const c_char) {
149    if text.is_null() {
150        return;
151    }
152    let s = unsafe { CStr::from_ptr(text) }
153        .to_string_lossy()
154        .into_owned();
155    use std::io::Write as _;
156    let mut out = std::io::stdout();
157    let _ = out.write_all(s.as_bytes());
158    let _ = out.flush();
159}
160
161extern "C" fn host_eval(_host: *const HostApi, code: *const c_char) -> c_int {
162    if code.is_null() {
163        return 1;
164    }
165    let code = unsafe { CStr::from_ptr(code) }
166        .to_string_lossy()
167        .into_owned();
168    // A plugin builtin runs inside VM context (dispatch happens in
169    // execute_external_bg), so an executor is in scope. Re-entrant
170    // with_executor is safe: the borrow is released before `f` runs.
171    crate::fusevm_bridge::try_with_executor(|exec| exec.execute_script(&code))
172        .map(|r| r.unwrap_or(1))
173        .unwrap_or(1)
174}
175
176extern "C" fn host_getvar(_host: *const HostApi, name: *const c_char) -> *mut c_char {
177    if name.is_null() {
178        return std::ptr::null_mut();
179    }
180    let name = unsafe { CStr::from_ptr(name) }
181        .to_string_lossy()
182        .into_owned();
183    match crate::ported::params::getsparam(&name) {
184        Some(v) => match CString::new(v) {
185            Ok(c) => c.into_raw(),
186            Err(_) => std::ptr::null_mut(),
187        },
188        None => std::ptr::null_mut(),
189    }
190}
191
192extern "C" fn host_setvar(
193    _host: *const HostApi,
194    name: *const c_char,
195    value: *const c_char,
196) -> c_int {
197    if name.is_null() || value.is_null() {
198        return 1;
199    }
200    let name = unsafe { CStr::from_ptr(name) }
201        .to_string_lossy()
202        .into_owned();
203    let value = unsafe { CStr::from_ptr(value) }
204        .to_string_lossy()
205        .into_owned();
206    crate::ported::params::setsparam(&name, &value);
207    0
208}
209
210extern "C" fn host_free_cstring(_host: *const HostApi, s: *mut c_char) {
211    if !s.is_null() {
212        // Reclaim ownership of a string we handed out via `into_raw`.
213        unsafe { drop(CString::from_raw(s)) };
214    }
215}
216
217extern "C" fn host_register_completion(
218    _host: *const HostApi,
219    cmd: *const c_char,
220    generator: *const c_char,
221) -> c_int {
222    if cmd.is_null() || generator.is_null() {
223        return 1;
224    }
225    let cmd = unsafe { CStr::from_ptr(cmd) }
226        .to_string_lossy()
227        .into_owned();
228    let generator = unsafe { CStr::from_ptr(generator) }
229        .to_string_lossy()
230        .into_owned();
231    // Owner is tagged after init returns (name unknown yet); stage empty.
232    pending_completions()
233        .lock()
234        .unwrap()
235        .push((cmd, generator, String::new()));
236    0
237}
238
239extern "C" fn host_getfunction(_host: *const HostApi, name: *const c_char) -> *mut c_char {
240    if name.is_null() {
241        return std::ptr::null_mut();
242    }
243    let name = unsafe { CStr::from_ptr(name) }
244        .to_string_lossy()
245        .into_owned();
246    // Route through the exact `${functions[name]}` read: getpmfunction
247    // deparses the body into `u_str` and flags PM_UNSET when undefined.
248    match crate::ported::modules::parameter::getpmfunction(std::ptr::null_mut(), &name) {
249        Some(pm) if (pm.node.flags & crate::ported::zsh_h::PM_UNSET as i32) == 0 => {
250            match pm.u_str.and_then(|s| CString::new(s).ok()) {
251                Some(c) => c.into_raw(),
252                None => std::ptr::null_mut(),
253            }
254        }
255        _ => std::ptr::null_mut(),
256    }
257}
258
259extern "C" fn host_addfunction(
260    _host: *const HostApi,
261    name: *const c_char,
262    body: *const c_char,
263) -> c_int {
264    if name.is_null() || body.is_null() {
265        return 1;
266    }
267    let name = unsafe { CStr::from_ptr(name) }
268        .to_string_lossy()
269        .into_owned();
270    let body = unsafe { CStr::from_ptr(body) }
271        .to_string_lossy()
272        .into_owned();
273    if name.is_empty() {
274        return 1;
275    }
276    // Same install as `functions[name]=body`: parse `body` and store the
277    // shfunc in shfunctab (dis = 0 = enabled).
278    crate::ported::modules::parameter::setfunction(&name, body, 0);
279    // setfunction installs unconditionally; report success.
280    0
281}
282
283extern "C" fn host_register_compfn(
284    _host: *const HostApi,
285    name: *const c_char,
286    handler: CompFn,
287) -> c_int {
288    if name.is_null() {
289        return 1;
290    }
291    let name = unsafe { CStr::from_ptr(name) }
292        .to_string_lossy()
293        .into_owned();
294    if name.is_empty() {
295        return 1;
296    }
297    compfn_staging().lock().unwrap().push((name, handler));
298    0
299}
300
301extern "C" fn host_comp_dispatch(
302    _host: *const HostApi,
303    name: *const c_char,
304    argc: usize,
305    argv: *const *const c_char,
306) -> c_int {
307    if name.is_null() {
308        return 1;
309    }
310    let name = unsafe { CStr::from_ptr(name) }
311        .to_string_lossy()
312        .into_owned();
313    // Decode argv[0..argc] into owned Strings (argv[0] is the _fn name;
314    // dispatch_function_call takes the arguments after it).
315    let mut args: Vec<String> = Vec::with_capacity(argc);
316    if !argv.is_null() {
317        for i in 0..argc {
318            let p = unsafe { *argv.add(i) };
319            if p.is_null() {
320                break;
321            }
322            args.push(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned());
323        }
324    }
325    // args[0] is `name` itself (the compfn's argv[0]); pass args[1..] as the
326    // dispatched function's argument list, matching how the completer chain
327    // invokes `_alternative`/`_path_commands` etc.
328    let call_args: &[String] = if args.is_empty() { &[] } else { &args[1..] };
329    crate::ported::exec::dispatch_function_call(&name, call_args).unwrap_or(1)
330}
331
332extern "C" fn host_empty_command_hash(_host: *const HostApi) {
333    crate::ported::hashtable::emptycmdnamtable();
334}
335
336/// The single process-wide host table. Leaked so its address is
337/// `'static` — plugins may retain the `*const HostApi` and call through
338/// it from any builtin at any time.
339fn host_api() -> *const HostApi {
340    static API: OnceLock<usize> = OnceLock::new();
341    let addr = API.get_or_init(|| {
342        let boxed = Box::new(HostApi {
343            abi_version: ABI_VERSION,
344            ctx: std::ptr::null_mut(),
345            register_builtin: host_register_builtin,
346            print: host_print,
347            eval: host_eval,
348            getvar: host_getvar,
349            setvar: host_setvar,
350            free_cstring: host_free_cstring,
351            register_completion: host_register_completion,
352            getfunction: host_getfunction,
353            addfunction: host_addfunction,
354            register_compfn: host_register_compfn,
355            comp_dispatch: host_comp_dispatch,
356            empty_command_hash: host_empty_command_hash,
357        });
358        Box::into_raw(boxed) as usize
359    });
360    *addr as *const HostApi
361}
362
363// ============================================================
364// Public API — driven by `zmodload -R`.
365// ============================================================
366
367/// Load a plugin `cdylib` from `path`. Returns the plugin's name on
368/// success. Idempotent-ish: loading a plugin whose name is already
369/// present is refused (unload first).
370pub fn load(path: &str) -> Result<String, String> {
371    let _guard = load_lock().lock().unwrap();
372
373    // `dlopen`. libloading resolves relative paths against the loader's
374    // search rules; expand `~` for convenience since shells hand raw
375    // tokens here.
376    let expanded = expand_tilde(path);
377    let lib = unsafe { libloading::Library::new(&expanded) }
378        .map_err(|e| format!("cannot load `{}`: {}", path, e))?;
379
380    // Resolve the mandatory init symbol.
381    let init: libloading::Symbol<InitFn> = unsafe {
382        lib.get(INIT_SYMBOL).map_err(|_| {
383            format!(
384                "`{}`: not a zshrs plugin (no {})",
385                path,
386                String::from_utf8_lossy(&INIT_SYMBOL[..INIT_SYMBOL.len() - 1])
387            )
388        })?
389    };
390
391    // Clear staging, call init, collect what it registered. Snapshot the
392    // pending-completion length so we can tag the entries THIS init adds
393    // with the owning plugin (the name isn't known until init returns).
394    staging().lock().unwrap().clear();
395    compfn_staging().lock().unwrap().clear();
396    let pc_start = pending_completions().lock().unwrap().len();
397    let info_ptr: *const PluginInfo = init(host_api());
398    if info_ptr.is_null() {
399        staging().lock().unwrap().clear();
400        compfn_staging().lock().unwrap().clear();
401        pending_completions().lock().unwrap().truncate(pc_start);
402        return Err(format!(
403            "`{}`: plugin init failed (ABI mismatch or error)",
404            path
405        ));
406    }
407    let info = unsafe { &*info_ptr };
408    if info.abi_version != ABI_VERSION {
409        staging().lock().unwrap().clear();
410        compfn_staging().lock().unwrap().clear();
411        pending_completions().lock().unwrap().truncate(pc_start);
412        return Err(format!(
413            "`{}`: ABI version {} != host {}",
414            path, info.abi_version, ABI_VERSION
415        ));
416    }
417    let name = cstr_or(info.name, "unknown");
418    let version = cstr_or(info.version, "?");
419
420    // Refuse a duplicate name — the second load's builtins would shadow
421    // the first with no clean unload story.
422    if plugins().lock().unwrap().iter().any(|p| p.name == name) {
423        staging().lock().unwrap().clear();
424        compfn_staging().lock().unwrap().clear();
425        pending_completions().lock().unwrap().truncate(pc_start);
426        return Err(format!("plugin `{}` already loaded", name));
427    }
428
429    // Commit staged builtins into the live registry, tagged with owner.
430    let staged: Vec<(String, BuiltinFn)> = std::mem::take(&mut *staging().lock().unwrap());
431    {
432        let mut reg = registry().lock().unwrap();
433        let mut own = ownership().lock().unwrap();
434        for (cmd, func) in staged {
435            reg.insert(cmd.clone(), BuiltinEntry { func, _pad: () });
436            own.insert(cmd, name.clone());
437        }
438    }
439
440    // Commit staged compfn overrides, tagged with owner. (ABI v4.)
441    let staged_cf: Vec<(String, CompFn)> = std::mem::take(&mut *compfn_staging().lock().unwrap());
442    {
443        let mut cr = compfn_registry().lock().unwrap();
444        for (fname, func) in staged_cf {
445            cr.insert(fname, (func, name.clone()));
446        }
447    }
448
449    // Tag the completion wirings this init staged with the owning plugin.
450    {
451        let mut pc = pending_completions().lock().unwrap();
452        for entry in pc.iter_mut().skip(pc_start) {
453            entry.2 = name.clone();
454        }
455    }
456
457    plugins().lock().unwrap().push(LoadedPlugin {
458        name: name.clone(),
459        version: version.clone(),
460        path: expanded,
461        _lib: lib,
462    });
463
464    tracing::info!(plugin = %name, version = %version, path, "loaded native plugin");
465    Ok(name)
466}
467
468/// Install any completion wirings that plugins requested but that have not
469/// yet been bound into compsys. Called at the top of the completion
470/// pipeline (`do_completion`) — a safe point where compsys itself evals,
471/// unlike plugin-init (deep in the `zmodload` call stack, where evaling
472/// hangs the VM). Idempotent: each pending entry is installed once, then
473/// moved to `installed_completions`.
474///
475/// For each `(cmd, generator)` it defines a compsys completion function
476/// `_zshrs_plug_<cmd>` that runs the generator with `$CURRENT $words` and
477/// `compadd`s its newline-separated output, then binds it with `compdef`.
478pub fn flush_pending_completions() {
479    let pending: Vec<(String, String, String)> = {
480        let mut pc = pending_completions().lock().unwrap();
481        if pc.is_empty() {
482            return;
483        }
484        std::mem::take(&mut *pc)
485    };
486    for (cmd, generator, owner) in pending {
487        // `${(@f)...}` splits the generator's stdout on newlines into the
488        // match array; guard on compdef so a pre-compinit flush no-ops.
489        let glue = format!(
490            "_zshrs_plug_{cmd}() {{ \
491                local -a _zp_m; \
492                _zp_m=(\"${{(@f)$({gen} $CURRENT $words)}}\"); \
493                compadd -- $_zp_m; \
494             }}; \
495             (( ${{+functions[compdef]}} )) && compdef _zshrs_plug_{cmd} {cmd} 2>/dev/null; :",
496            cmd = cmd,
497            gen = generator,
498        );
499        // Use the exec.rs free fn (NOT try_with_executor): during
500        // `do_completion` the thread-local CURRENT_EXECUTOR is unset —
501        // completion runs on SESSION_EXECUTOR. try_with_executor would
502        // no-op here and the compdef glue would never eval. This free fn
503        // falls back to SESSION_EXECUTOR, so the glue lands on the same
504        // executor that then runs the completion.
505        let _ = crate::ported::exec::execute_script(&glue);
506        installed_completions().lock().unwrap().insert(cmd, owner);
507    }
508}
509
510/// Unload a plugin by name: purge its command registrations FIRST (so no
511/// live function pointer survives), then drop the `Library` (`dlclose`).
512pub fn unload(name: &str) -> Result<(), String> {
513    let _guard = load_lock().lock().unwrap();
514
515    let present = plugins().lock().unwrap().iter().any(|p| p.name == name);
516    if !present {
517        return Err(format!("plugin `{}` not loaded", name));
518    }
519
520    // Purge registry entries owned by this plugin.
521    {
522        let mut own = ownership().lock().unwrap();
523        let mut reg = registry().lock().unwrap();
524        let owned: Vec<String> = own
525            .iter()
526            .filter(|(_, o)| o.as_str() == name)
527            .map(|(c, _)| c.clone())
528            .collect();
529        for cmd in owned {
530            reg.remove(&cmd);
531            own.remove(&cmd);
532        }
533    }
534
535    // Purge compfn overrides owned by this plugin BEFORE dlclose, so no
536    // dangling CompFn pointer survives the `dlclose`. (ABI v4.)
537    compfn_registry()
538        .lock()
539        .unwrap()
540        .retain(|_, (_, o)| o.as_str() != name);
541
542    // Drop this plugin's completion bookkeeping. We do NOT eval here to
543    // tear down the compsys glue function (evaling deep in the `zmodload`
544    // call stack hangs the VM); the orphaned `_zshrs_plug_<cmd>` function
545    // simply calls a now-removed generator builtin → empty command-subst
546    // → no matches. Removing the `installed_completions` record lets a
547    // later reload re-install cleanly.
548    pending_completions()
549        .lock()
550        .unwrap()
551        .retain(|(_, _, o)| o != name);
552    installed_completions()
553        .lock()
554        .unwrap()
555        .retain(|_, o| o != name);
556
557    // Now it is safe to dlclose.
558    let mut ps = plugins().lock().unwrap();
559    if let Some(pos) = ps.iter().position(|p| p.name == name) {
560        let p = ps.remove(pos);
561        tracing::info!(plugin = %name, "unloaded native plugin");
562        drop(p); // explicit: dlclose here, after registry purge.
563    }
564    Ok(())
565}
566
567/// Completion-router hook. Returns a plugin-registered override handler
568/// for the compsys function `name` (a `_NAME`), or `None`. Consulted by
569/// `compsys::router::try_rust_dispatch` BEFORE the built-in Rust port, so a
570/// plugin's `_command_names` (etc.) supersedes both the port and the shell
571/// autoload. (ABI v4.)
572pub fn compfn_override(name: &str) -> Option<CompFn> {
573    compfn_registry().lock().unwrap().get(name).map(|(f, _)| *f)
574}
575
576/// Invoke a plugin's override for compsys `_fn` `name`, if one is
577/// registered. `args` are the completion-function arguments (argv[1..]);
578/// argv[0] is set to `name`, matching the completer-chain convention.
579/// Returns `Some(rc)` if a plugin handled it, else `None` (fall through to
580/// the built-in port / shell autoload). (ABI v4.)
581pub fn dispatch_compfn(name: &str, args: &[String]) -> Option<i32> {
582    let func = compfn_override(name)?;
583    // Build argv = [name, args...] as NUL-terminated C strings, valid for
584    // the duration of the call (mirrors `dispatch`).
585    let mut owned: Vec<CString> = Vec::with_capacity(args.len() + 1);
586    owned.push(CString::new(name).ok()?);
587    for a in args {
588        owned.push(
589            CString::new(a.as_str())
590                .unwrap_or_else(|_| CString::new(a.replace('\0', "")).unwrap_or_default()),
591        );
592    }
593    let ptrs: Vec<*const c_char> = owned.iter().map(|c| c.as_ptr()).collect();
594    let rc = func(host_api(), ptrs.len(), ptrs.as_ptr());
595    Some(rc as i32)
596}
597
598/// Command-resolution hook. Called from `execute_external_bg` for bare
599/// command names. Returns `Some(exit_status)` if a plugin owns `cmd`,
600/// else `None` (let PATH lookup proceed).
601pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
602    let entry = { registry().lock().unwrap().get(cmd).copied() }?;
603
604    // Build argv = [cmd, args...] as NUL-terminated C strings. Interior
605    // NULs can't occur in a shell word, but be defensive.
606    let mut owned: Vec<CString> = Vec::with_capacity(args.len() + 1);
607    owned.push(CString::new(cmd).ok()?);
608    for a in args {
609        owned.push(
610            CString::new(a.as_str())
611                .unwrap_or_else(|_| CString::new(a.replace('\0', "")).unwrap_or_default()),
612        );
613    }
614    let ptrs: Vec<*const c_char> = owned.iter().map(|c| c.as_ptr()).collect();
615
616    let rc = (entry.func)(host_api(), ptrs.len(), ptrs.as_ptr());
617    // `owned`/`ptrs` outlive the call. Done.
618    Some(rc as i32)
619}
620
621/// `(name, version, path)` for each loaded plugin, for `zmodload -R`
622/// listing. Sorted by name for stable output.
623pub fn list() -> Vec<(String, String, String)> {
624    let mut v: Vec<(String, String, String)> = plugins()
625        .lock()
626        .unwrap()
627        .iter()
628        .map(|p| (p.name.clone(), p.version.clone(), p.path.clone()))
629        .collect();
630    v.sort_by(|a, b| a.0.cmp(&b.0));
631    v
632}
633
634/// True if `name` is a live plugin command. Used where callers want to
635/// know whether a name resolves to a plugin without invoking it.
636pub fn is_plugin_command(name: &str) -> bool {
637    registry().lock().unwrap().contains_key(name)
638}
639
640/// `zmodload -R` native-plugin management, dispatched from
641/// `crate::ported::module::bin_zmodload` (kept out of `src/ported/`
642/// because it is a zshrs extension, not a C port).
643///
644///   `zmodload -R  <path>...`  load each cdylib
645///   `zmodload -R`             list loaded plugins (`name  version  path`)
646///   `zmodload -uR <name>...`  unload each plugin by name
647pub fn zmodload_rust_cmd(nam: &str, args: &[String], ops: &crate::ported::zsh_h::options) -> i32 {
648    use crate::ported::utils::zwarnnam;
649    use crate::ported::zsh_h::OPT_ISSET;
650
651    // `-u` → unload.
652    if OPT_ISSET(ops, b'u') {
653        if args.is_empty() {
654            zwarnnam(nam, "what do you want to unload?");
655            return 1;
656        }
657        let mut ret = 0;
658        for name in args {
659            if let Err(e) = unload(name) {
660                zwarnnam(nam, &e);
661                ret = 1;
662            }
663        }
664        return ret;
665    }
666    // No args → list.
667    if args.is_empty() {
668        for (name, version, path) in list() {
669            println!("{}  {}  {}", name, version, path);
670        }
671        return 0;
672    }
673    // Load each path.
674    let mut ret = 0;
675    for path in args {
676        if let Err(e) = load(path) {
677            zwarnnam(nam, &e);
678            ret = 1;
679        }
680    }
681    ret
682}
683
684fn cstr_or(p: *const c_char, dflt: &str) -> String {
685    if p.is_null() {
686        dflt.to_string()
687    } else {
688        unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
689    }
690}
691
692fn expand_tilde(path: &str) -> String {
693    if let Some(rest) = path.strip_prefix("~/") {
694        if let Some(home) = dirs::home_dir() {
695            return home.join(rest).to_string_lossy().into_owned();
696        }
697    }
698    path.to_string()
699}