zsh/extensions/native_cmds.rs
1//! Host-registered native commands — builtins contributed by the *binary*
2//! rather than by this library.
3//!
4//! `EXT_BUILTIN_NAMES` (`extensions/ext_builtins.rs`) and the daemon's
5//! `ZSHRS_BUILTIN_NAMES` are both compile-time lists owned by this crate. A
6//! fat binary that links sibling runtimes into the shell's address space —
7//! `zshrs-native` links zvcs (`git`), arblang (`arb`) and strykelang
8//! (`stryke`) — has no way to extend either: they are `const` arrays, and the
9//! runtimes cannot be dependencies of this crate (zvcs depends on its own
10//! vendored gitoxide by path, which makes any dependent unpublishable).
11//!
12//! So the binary registers them here, once, before the shell starts. A
13//! registered name dispatches in-process on a direct function call: no fork,
14//! no execve, no `PATH` walk, no dynamic loader — the same treatment `cat` and
15//! `sort` already get from `reg_overridable!`.
16//!
17//! # Dispatch order
18//!
19//! Registration does not jump the queue. zsh resolves a command word as
20//! alias → function → builtin → external (c:Src/exec.c:3038-3068), and a
21//! native command sits in the *builtin* slot, after the ported builtin table:
22//!
23//! * a user `git() { … }` still wins, exactly as it shadows `cat` today;
24//! * `command git` still reaches whatever `git` is on `PATH`, because the
25//! forced-external path never consults this registry;
26//! * `builtin git` reaches the native one.
27//!
28//! # Registration is one-shot and start-up only
29//!
30//! The table is written once by the binary's `main` before the shell runs and
31//! is read from every command dispatch after that, including from the worker
32//! threads. It is therefore an `RwLock` whose write side is expected to be
33//! uncontended: registering after startup is allowed but pointless, and no
34//! path ever removes an entry — a name that answered `whence -w` one moment
35//! must not vanish the next.
36
37use std::collections::BTreeMap;
38use std::sync::{OnceLock, RwLock};
39
40/// A native command body: the full argv (argv[0] is the command name, as
41/// invoked) in, a wait-status-style exit code out.
42///
43/// Taking argv[0] rather than only the operands is what lets a runtime keep
44/// its own `argv[0]`-dependent behaviour — zvcs dispatches `git-<verb>` off
45/// its own name (`dashed_subcommand`), and its diagnostics are prefixed with
46/// it.
47pub type NativeCmd = Box<dyn Fn(&[String]) -> i32 + Send + Sync>;
48
49fn table() -> &'static RwLock<BTreeMap<String, NativeCmd>> {
50 static TABLE: OnceLock<RwLock<BTreeMap<String, NativeCmd>>> = OnceLock::new();
51 TABLE.get_or_init(|| RwLock::new(BTreeMap::new()))
52}
53
54/// Register `name` as a native command backed by `f`.
55///
56/// Idempotent per name in the sense that the last registration wins; the
57/// binary calls this once per runtime from `main`, before the shell reads a
58/// line. A poisoned lock is ignored rather than panicking — losing a builtin
59/// registration must not take the shell down at startup.
60pub fn register<F>(name: &str, f: F)
61where
62 F: Fn(&[String]) -> i32 + Send + Sync + 'static,
63{
64 if let Ok(mut t) = table().write() {
65 t.insert(name.to_string(), Box::new(f));
66 }
67}
68
69/// Is `name` a host-registered native command?
70///
71/// The hot path: consulted on every command word that is neither a function
72/// nor a ported builtin, so it must not allocate. A read lock on a `BTreeMap`
73/// of a handful of short keys is a few compares.
74pub fn is_registered(name: &str) -> bool {
75 table()
76 .read()
77 .map(|t| t.contains_key(name))
78 .unwrap_or(false)
79}
80
81/// Registered *and* not masked by `disable NAME`.
82///
83/// The gate the two dispatch sites use. `disable` is zsh's way of taking a
84/// builtin out of the way without unsetting anything (c:Src/builtin.c:541-547
85/// toggles `DISABLED` on the node; this port tracks the same set in
86/// `BUILTINS_DISABLED`), and it has to work on a native command for the same
87/// reason it works on the `cat` shadow: it is the one escape hatch that is
88/// per-shell, reversible with `enable`, and needs no change at the call site.
89/// A disabled name falls through to the `PATH` binary.
90pub fn is_enabled(name: &str) -> bool {
91 is_registered(name)
92 && !crate::ported::builtin::BUILTINS_DISABLED
93 .lock()
94 .map(|s| s.contains(name))
95 .unwrap_or(false)
96}
97
98/// Every registered name, sorted. Feeds the `builtins` magic assoc
99/// (`${(k)builtins}`), `whence -m`, and compsys's command-position
100/// completion, so the shell reports the same set it will actually dispatch.
101pub fn names() -> Vec<String> {
102 table()
103 .read()
104 .map(|t| t.keys().cloned().collect())
105 .unwrap_or_default()
106}
107
108/// Run `name` with `argv` (argv[0] included) if it is registered.
109///
110/// Returns `None` when the name is not ours, so callers fall through to their
111/// existing next step — `PATH` lookup, or "command not found".
112///
113/// The read lock is held for the duration of the call. That is deliberate:
114/// nothing ever removes an entry, and the alternative (clone the boxed closure
115/// out) is not possible for a `dyn Fn`. Re-entrant dispatch — a native command
116/// that runs shell code that runs another native command — takes the read lock
117/// twice, which an `RwLock` grants.
118pub fn dispatch(name: &str, argv: &[String]) -> Option<i32> {
119 let t = table().read().ok()?;
120 let f = t.get(name)?;
121 Some(f(argv))
122}
123
124/// Test-only removal.
125///
126/// Production never removes an entry — a name that answered `whence -w` one
127/// moment must not vanish the next — but the table is process-global and the
128/// `${(k)builtins}` scan reads it, so a test that registers a probe name has
129/// to take it back out or the next test in the same process sees a builtin
130/// nobody registered.
131#[cfg(test)]
132pub(crate) fn unregister(name: &str) {
133 if let Ok(mut t) = table().write() {
134 t.remove(name);
135 }
136}
137
138thread_local! {
139 /// Set while a `command NAME …` precommand is dispatching NAME.
140 ///
141 /// `command` means "not the function, not the builtin — the thing on
142 /// `PATH`" (c:Src/exec.c:3275-3278, the `BINF_COMMAND && !POSIXBUILTINS`
143 /// arm that clears the builtin node). zshrs already honours that for its
144 /// own in-process shadows: `PATH= command cat` reports `command not
145 /// found: cat` rather than running the built-in `cat`.
146 ///
147 /// A native command has to answer the same way, and the site that catches
148 /// it — `execute_external_bg` — is the very site `command` dispatches
149 /// through, so the two are indistinguishable without this flag. The
150 /// `command` handler raises it for the duration of that one call.
151 ///
152 /// Thread-local because it describes one invocation in flight, and
153 /// commands run on worker threads.
154 static FORCED_EXTERNAL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
155}
156
157/// Restores the previous `command`-prefix state on drop, so a nested
158/// dispatch (a `command` inside a function a native command ran) unwinds
159/// correctly instead of leaving the flag stuck on.
160pub struct ForcedExternalGuard(bool);
161
162impl Drop for ForcedExternalGuard {
163 fn drop(&mut self) {
164 FORCED_EXTERNAL.with(|f| f.set(self.0));
165 }
166}
167
168/// Mark the current invocation as `command`-forced for as long as the
169/// returned guard lives.
170#[must_use]
171pub fn force_external() -> ForcedExternalGuard {
172 ForcedExternalGuard(FORCED_EXTERNAL.with(|f| f.replace(true)))
173}
174
175/// True while a `command NAME` precommand is dispatching NAME, i.e. while the
176/// user has explicitly asked for the `PATH` binary rather than this table.
177pub fn is_forced_external() -> bool {
178 FORCED_EXTERNAL.with(std::cell::Cell::get)
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use std::sync::atomic::{AtomicUsize, Ordering};
185
186 /// The registry answers for a name it was given, and only for that name.
187 /// Names are test-unique because the table is process-global and the test
188 /// binary runs many tests in one process.
189 #[test]
190 fn registered_name_dispatches_with_full_argv() {
191 static SEEN: AtomicUsize = AtomicUsize::new(0);
192 register("zshrs_test_native_dispatch", |argv| {
193 SEEN.store(argv.len(), Ordering::SeqCst);
194 assert_eq!(argv[0], "zshrs_test_native_dispatch");
195 7
196 });
197
198 assert!(is_registered("zshrs_test_native_dispatch"));
199 assert!(!is_registered("zshrs_test_native_never_registered"));
200
201 let argv = [
202 "zshrs_test_native_dispatch".to_string(),
203 "--flag".to_string(),
204 ];
205 assert_eq!(dispatch("zshrs_test_native_dispatch", &argv), Some(7));
206 assert_eq!(SEEN.load(Ordering::SeqCst), 2);
207 // An unregistered name must fall through rather than answer.
208 assert_eq!(dispatch("zshrs_test_native_never_registered", &argv), None);
209
210 unregister("zshrs_test_native_dispatch");
211 assert!(!is_registered("zshrs_test_native_dispatch"));
212 }
213
214 /// `disable NAME` masks a native command without unregistering it, and
215 /// `enable NAME` takes it back — the shell falls through to `PATH` in
216 /// between. Exercised through the same `BUILTINS_DISABLED` set that
217 /// `bin_enable` writes.
218 #[test]
219 fn disable_masks_dispatch_and_enable_restores() {
220 register("zshrs_test_native_disable", |_| 0);
221 assert!(is_enabled("zshrs_test_native_disable"));
222
223 crate::ported::builtin::BUILTINS_DISABLED
224 .lock()
225 .unwrap()
226 .insert("zshrs_test_native_disable".to_string());
227 assert!(is_registered("zshrs_test_native_disable"));
228 assert!(!is_enabled("zshrs_test_native_disable"));
229
230 crate::ported::builtin::BUILTINS_DISABLED
231 .lock()
232 .unwrap()
233 .remove("zshrs_test_native_disable");
234 assert!(is_enabled("zshrs_test_native_disable"));
235
236 unregister("zshrs_test_native_disable");
237 }
238
239 /// The `command NAME` guard is scoped and nests: an inner scope restores
240 /// the outer state on drop rather than clearing the flag outright.
241 #[test]
242 fn forced_external_guard_restores_previous_state() {
243 assert!(!is_forced_external());
244 {
245 let _outer = force_external();
246 assert!(is_forced_external());
247 {
248 let _inner = force_external();
249 assert!(is_forced_external());
250 }
251 // Still inside the outer `command` dispatch.
252 assert!(is_forced_external());
253 }
254 assert!(!is_forced_external());
255 }
256}