zsh/compsys/ported/shared.rs
1//! Tiny helpers shared between per-fn ports. Kept here (not in
2//! library.rs) so the `ported/` tree stands alone.
3
4use std::path::Path;
5
6// =====================================================================
7// Function-local parameter declarations for the Rust ports.
8// =====================================================================
9//
10// An upstream completion function opens with a `local`/`typeset` line
11// (`Completion/Base/Core/_main_complete:11,27-54`). Every scratch name
12// it touches is therefore created at `locallevel`, reads back as
13// `…-local` through `${(t)name}`, and is unwound by `endparamscope`
14// when the function returns.
15//
16// A Rust port assigns the same names with `setsparam`/`setaparam`,
17// which routes through `createparam(name, PM_SCALAR)` — no PM_LOCAL,
18// so the parameter is born at level 0 and both properties are lost:
19// `${(t)_comp_tags}` reads `scalar` and the name survives the call.
20// That is observable: the user's `_parameters`
21// (`~/.zpwr/autoload/comp_utils/_parameters:34`) filters candidates
22// with `${(@k)parameters[(R)${pattern[2]}~*local*]}`, i.e. it drops
23// every parameter whose type string contains `local`. Leaked port
24// scratch names slipped through that filter and `unset <TAB>` offered
25// them alongside the user's real parameters.
26//
27// `declare_locals` is the one place that gap is closed: it is the
28// Rust-port spelling of the upstream `local NAME …` line and mirrors
29// the PM_LOCAL branch of `bin_typeset` (`src/ported/builtin.rs:5570`,
30// port of `Src/builtin.c:2469-2575`).
31
32pub use crate::ported::zsh_h::{PM_ARRAY, PM_HASHED, PM_INTEGER, PM_READONLY, PM_UNIQUE};
33
34/// Declare `names` local to the CURRENT function scope — the Rust-port
35/// equivalent of an upstream completion function's `local NAME …` line.
36///
37/// `kind` carries the type/attribute bits the shell source spells out
38/// (`PM_ARRAY` for `local -a`, `PM_HASHED` for `local -A`, `PM_UNIQUE`
39/// for `typeset -U`); pass `0` for a plain scalar `local`.
40///
41/// Mirrors `Src/builtin.c:2469-2575` (`typeset_single`'s PM_LOCAL arm):
42/// only allocate a shadow when the visible parameter lives at a LOWER
43/// scope than the current `locallevel`, then stamp `pm->level =
44/// locallevel` so `endparamscope` unwinds it. At top level
45/// (`locallevel == 0`) C's `pm->level < locallevel` can never hold, so
46/// nothing is declared — the port then behaves exactly as it does today
47/// when run outside a function (unit tests, `--doctor`).
48pub fn declare_locals(names: &[&str], kind: u32) {
49 use crate::ported::params::{createparam, locallevel, paramtab};
50 use crate::ported::zsh_h::{PM_HIDE, PM_LOCAL, PM_SPECIAL};
51 use std::sync::atomic::Ordering;
52
53 let cur = locallevel.load(Ordering::Relaxed); // c:2469 locallevel
54 if cur == 0 {
55 return;
56 }
57 for name in names {
58 // c:2469 — `(!pm || pm->level < locallevel)`.
59 //
60 // The same table read also settles `newspecial`. c:2083-2085:
61 // if ((pm->node.flags & PM_SPECIAL)
62 // && !(on & PM_HIDE) && !(pm->node.flags & PM_HIDE & ~off))
63 // newspecial = NS_NORMAL;
64 // i.e. localizing a PM_SPECIAL parameter keeps it special unless
65 // `-h` hides it, on either the special itself or this statement.
66 let (needs_shadow, newspecial) = paramtab()
67 .read()
68 .ok()
69 .and_then(|t| {
70 t.get(*name).map(|pm| {
71 let special = (pm.node.flags as u32 & PM_SPECIAL) != 0
72 && (kind & PM_HIDE) == 0
73 && (pm.node.flags as u32 & PM_HIDE) == 0;
74 (pm.level < cur, special)
75 })
76 })
77 .unwrap_or((true, false));
78 if !needs_shadow {
79 continue;
80 }
81 // c:2470 — `createparam(pname, on | PM_LOCAL)`.
82 let _ = createparam(name, (kind | PM_LOCAL) as i32);
83 // c:2575 — `else if (on & PM_LOCAL) pm->level = locallevel;`
84 // plus the attribute stamp so `typeset -U` keeps PM_UNIQUE.
85 if let Ok(mut tab) = paramtab().write() {
86 if let Some(pm) = tab.get_mut(*name) {
87 pm.level = cur;
88 pm.node.flags |= (kind & PM_UNIQUE) as i32;
89 // c:2425 — `pm->node.flags = (PM_TYPE(pm->node.flags) | on
90 // | PM_SPECIAL) & ~off;`. `createparam` deliberately drops
91 // the bit (`Src/params.c:1174` stores `flags & ~PM_LOCAL`
92 // on the fresh struct), so the special-ness of the shadowed
93 // parameter has to be re-stamped here the way the
94 // `newspecial` arm of `typeset_single` does. Without it
95 // `integer SECONDS=0` (_main_complete sh:162) read
96 // `integer-local` where zsh reads `integer-local-special`,
97 // and `_parameters` — which drops every candidate whose
98 // type matches `*local*` — mis-classified the shadow.
99 if newspecial {
100 pm.node.flags |= PM_SPECIAL as i32;
101 }
102 }
103 }
104 }
105}
106
107/// A parameter scope for a Rust port that is invoked as a DIRECT Rust
108/// call rather than through `dispatch_function_call`.
109///
110/// `declare_locals` only stamps `pm->level = locallevel`; the unwind is
111/// `endparamscope`'s job, and that runs from `doshfunc`. A port reached
112/// by a plain Rust call (`_alternative` -> `_tags(&…)` /
113/// `_next_label(&…)` -> `_description(&…)`) therefore never gets one, so
114/// every name in its `declare_locals` list stayed shadowed for the rest
115/// of the CALLER's body.
116///
117/// Concretely: `_tags` declares `tmp` and `_description` declares
118/// `opts`, and both names are `_files`' own locals holding the results
119/// of its `zparseopts -a opts '/=tmp' 'g+:-=tmp' … W: …` line. After
120/// `_alternative` ran either port, `_files` saw `opts=()` / `tmp=()`, so
121/// `-W /dev` and `-g '*(-%b,-/)'` were both dropped — `mount /dev/<TAB>`
122/// listed every file in `$PWD` and `PATH=…:<TAB>` listed files instead
123/// of directories.
124///
125/// Holding one of these for the port's body reproduces the visible half
126/// of what a real shell function gets from `endparamscope`
127/// (`Src/params.c:5867-5933`, the `pm->level > locallevel` arm): each
128/// declared name is put back exactly as the caller left it.
129///
130/// It restores by NAME rather than by bumping `locallevel` and calling
131/// `endparamscope`, because a port's body also writes caller-visible
132/// state (`_comp_tags`, `curtag`, the `expl` array named by
133/// `_description`'s `$2`). A whole-scope unwind takes those with it —
134/// `_tags` then reported "comptags: no tags registered" for every
135/// context.
136pub struct LocalScope {
137 saved: Vec<(String, Option<Box<crate::ported::zsh_h::param>>)>,
138 /// The parallel assoc backing for each saved name.
139 ///
140 /// A PM_HASHED parameter keeps its key/value pairs in
141 /// `paramtab_hashed_storage` — a map keyed by NAME with no scope
142 /// dimension (`params.rs`, the `copyparam` PM_HASHED note in
143 /// `typeset_single`) — not in the `param` struct. Putting the
144 /// `paramtab` node back is therefore only half an unwind: a name
145 /// declared through this scope and then filled by `sethparam` kept
146 /// its row for the rest of the process, and `${(t)name}` /
147 /// `${#name}` / `${(k)name}` all still answered from that row with
148 /// the node long gone.
149 ///
150 /// `endparamscope` does the same two-sided restore for the scopes it
151 /// unwinds, so this is the parallel-storage half of the same unwind,
152 /// saved and replayed by name.
153 saved_hash: Vec<(String, Option<indexmap::IndexMap<String, String>>)>,
154}
155
156impl LocalScope {
157 /// Declare `names` local (see [`declare_locals`]) and remember what
158 /// each one looked like beforehand.
159 pub fn declare(names: &[&str], kind: u32) -> Self {
160 let mut scope = LocalScope {
161 saved: Vec::new(),
162 saved_hash: Vec::new(),
163 };
164 scope.also(names, kind);
165 scope
166 }
167
168 /// Remember the parallel assoc backing (see [`LocalScope::saved_hash`])
169 /// for each name about to be declared.
170 fn remember_hash(&mut self, names: &[&str]) {
171 if let Ok(store) = crate::ported::params::paramtab_hashed_storage().lock() {
172 for name in names {
173 self.saved_hash
174 .push(((*name).to_string(), store.get(*name).cloned()));
175 }
176 }
177 }
178
179 /// Add more names to an existing scope — the port equivalent of a
180 /// second `local -a …` line.
181 pub fn also(&mut self, names: &[&str], kind: u32) {
182 if let Ok(tab) = crate::ported::params::paramtab().read() {
183 for name in names {
184 self.saved
185 .push(((*name).to_string(), tab.get(*name).cloned()));
186 }
187 }
188 self.remember_hash(names);
189 declare_locals(names, kind);
190 }
191
192 /// `local NAME="$NAME"` — see [`declare_locals_keeping_value`].
193 pub fn also_keeping_value(&mut self, names: &[&str]) {
194 if let Ok(tab) = crate::ported::params::paramtab().read() {
195 for name in names {
196 self.saved
197 .push(((*name).to_string(), tab.get(*name).cloned()));
198 }
199 }
200 self.remember_hash(names);
201 declare_locals_keeping_value(names);
202 }
203}
204
205impl Drop for LocalScope {
206 fn drop(&mut self) {
207 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
208 for (name, prev) in self.saved.iter().rev() {
209 match prev {
210 Some(pm) => {
211 tab.insert(name.clone(), pm.clone());
212 }
213 None => {
214 tab.remove(name);
215 }
216 }
217 }
218 }
219 // The assoc half of the same unwind — see [`LocalScope::saved_hash`].
220 // Taken after the `paramtab` lock is released: nothing here needs both,
221 // and holding one across the other is how a port deadlocks itself.
222 if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
223 for (name, prev) in self.saved_hash.iter().rev() {
224 match prev {
225 Some(map) => {
226 store.insert(name.clone(), map.clone());
227 }
228 None => {
229 store.remove(name);
230 }
231 }
232 }
233 }
234 }
235}
236
237/// `typeset -r NAME` applied AFTER the value is in place — the second
238/// half of an upstream `local -ar NAME=(…)` / `local -r NAME=…` line.
239///
240/// [`declare_locals`] cannot carry `PM_READONLY` itself: `createparam`
241/// stamps the bit immediately, and the port assigns the value on the
242/// NEXT statement, so the assignment would be rejected as a write to a
243/// read-only parameter. Upstream has no such split — `local -ar x=(…)`
244/// is one operation whose value lands before the bit does — so the port
245/// declares, assigns, then calls this.
246///
247/// Mirrors the `PM_READONLY` arm of `typeset_single`
248/// (`Src/builtin.c:2469-2575`): the bit is OR'd onto the existing
249/// `pm->node.flags`, and because the param already lives at
250/// `locallevel`, `endparamscope` unwinds it with the rest of the scope.
251///
252/// Skipped at `locallevel == 0` for the same reason [`declare_locals`]
253/// returns early there: with no function scope there is no shadow to
254/// stamp and no `endparamscope` to unstamp it, so the bit would pin the
255/// caller's GLOBAL parameter read-only forever — the next completion's
256/// own assignment would then fail with "read-only variable". Upstream
257/// cannot reach that state at all: `local -ar` is a syntax error outside
258/// a function.
259pub fn mark_readonly(names: &[&str]) {
260 use crate::ported::params::{locallevel, paramtab};
261 use crate::ported::zsh_h::PM_READONLY;
262 use std::sync::atomic::Ordering;
263 if locallevel.load(Ordering::Relaxed) == 0 {
264 return;
265 }
266 if let Ok(mut tab) = paramtab().write() {
267 for name in names {
268 if let Some(pm) = tab.get_mut(*name) {
269 pm.node.flags |= PM_READONLY as i32;
270 }
271 }
272 }
273}
274
275// =====================================================================
276// Boolean style tests — `zstyle -t` / `zstyle -T`.
277// =====================================================================
278//
279// !!! WARNING: RUST-ONLY HELPERS !!!
280//
281// These two have no C counterpart, because upstream has no function to
282// port here: every compsys boolean style test is literally the COMMAND
283// `zstyle -t "$ctx" <style>` (or `-T`) in the shell source, with the
284// arms below it reading `$?`. The helpers run that same builtin —
285// [`bin_zstyle`](crate::ported::modules::zutil::bin_zstyle), the port of
286// `Src/Modules/zutil.c:487` — so the tri-state exit is the builtin's
287// own rather than a re-derivation of it.
288//
289// They exist because the obvious-looking
290// [`testforstyle`](crate::ported::modules::zutil::testforstyle)
291// (`Src/Modules/zutil.c:465`) is NOT `zstyle -t`. It is the primitive
292// behind `zstyle -q` (c:749-756) and answers "is this style DEFINED for
293// this context", ignoring the value entirely — so
294// `zstyle ':completion:*' <style> 0` made every port that called it take
295// the TRUE branch, the exact opposite of what the style asks for. The
296// same misport was measured and fixed at `_setup`'s `last-prompt`
297// (e3f05f5c05); these helpers are that fix generalised so the shape is
298// written once instead of once per completer.
299//
300// The exits are the `case 't': case 'T':` arm at c:701-724:
301//
302// * 0 — style set for the context AND its first value is one of
303// `true` / `yes` / `on` / `1` (c:719-722).
304// * 1 — style set for the context but its first value is not one of
305// those (c:719-722), or set with NO values (c:724 `vals ? 1 : 2`).
306// * 2 — no style pattern matched this context (c:724 `: 2`), for `-t`
307// only; `-T` returns 0 there instead (c:724 `: 0`), which is the
308// whole difference between the two letters.
309
310/// Build the empty `options` struct `bin_zstyle` wants. It parses
311/// `args[0]` itself (the BUILTIN spec carries a NULL optstr at c:2139),
312/// so nothing needs to be pre-set here.
313fn empty_ops() -> crate::ported::zsh_h::options {
314 crate::ported::zsh_h::options {
315 ind: [0u8; crate::ported::zsh_h::MAX_OPS],
316 args: Vec::new(),
317 argscount: 0,
318 argsalloc: 0,
319 }
320}
321
322/// `zstyle -t <ctx> <style>` — 0 boolean-true, 1 set-but-not-true,
323/// 2 unset for this context. See the block comment above.
324pub fn zstyle_t(ctx: &str, style: &str) -> i32 {
325 crate::ported::modules::zutil::bin_zstyle(
326 "zstyle",
327 &["-t".to_string(), ctx.to_string(), style.to_string()],
328 &empty_ops(),
329 0,
330 )
331}
332
333/// `zstyle -s <ctx> <style> <name>` — the SCALAR style lookup, as the value
334/// it assigns plus the truth of its return status.
335///
336/// Port of `bin_zstyle`'s `case 's'` (`Src/Modules/zutil.c:643-658`):
337///
338/// ```text
339/// c:648 if ((vals = lookupstyle(args[1], args[2])) && vals[0]) {
340/// c:649 ret = sepjoin(vals, (args[4] ? args[4] : " "), 0);
341/// c:650 val = 0;
342/// c:651 } else {
343/// c:652 ret = ztrdup("");
344/// c:653 val = 1;
345/// c:654 }
346/// ```
347///
348/// Two things every `.first()` spelling of this gets wrong, and both are
349/// load-bearing for the `command` style (`_call_program` sh:26):
350///
351/// * c:649 joins the WHOLE value array with the separator (`" "` unless a
352/// fourth argument overrides it). `zstyle :ctx command ps -e` is a
353/// two-element style whose scalar value is `ps -e`; reading element 1
354/// alone silently drops `-e`. The manual states the contract in as many
355/// words — "The _string_s ... are concatenated with spaces between them
356/// and the resulting string is evaluated."
357///
358/// * c:648 branches on whether the style HAS a first element, not on
359/// whether that element is non-empty. `zstyle :ctx command ''` is SET,
360/// c:650 returns true, and the caller runs the empty command rather than
361/// falling through to its default. Treating `""` as unset inverts that.
362///
363/// `None` is c:651-653 (`val = 1`, the style does not apply here); `Some` is
364/// c:648-650 and carries the joined value, which may legitimately be `""`.
365pub fn zstyle_s(ctx: &str, style: &str) -> Option<String> {
366 // c:648 `lookupstyle(args[1], args[2]) && vals[0]` — an empty Vec is both
367 // C's NULL return (c:447) and its zero-element array; C answers `val = 1`
368 // for either, so one test covers both.
369 let vals = crate::ported::modules::zutil::lookupstyle(ctx, style);
370 if vals.is_empty() {
371 return None; // c:652-653
372 }
373 // c:649 `sepjoin(vals, " ", 0)`
374 Some(crate::ported::utils::zjoin(&vals, ' '))
375}
376
377/// `zstyle -T <ctx> <style>` — as [`zstyle_t`], except that an UNSET
378/// style is true (0) rather than 2 (c:724). This is the "default yes"
379/// spelling upstream uses for styles like `add-space` and `verbose`.
380#[allow(non_snake_case)]
381pub fn zstyle_T(ctx: &str, style: &str) -> i32 {
382 crate::ported::modules::zutil::bin_zstyle(
383 "zstyle",
384 &["-T".to_string(), ctx.to_string(), style.to_string()],
385 &empty_ops(),
386 0,
387 )
388}
389
390/// Hand `args` to `bin_zparseopts` through its `-v <name>` source array,
391/// declared LOCAL to the enclosing function scope first.
392///
393/// zsh's `zparseopts` has no `-v`: upstream reads the positional list, so
394/// there is no `sh:NN local __compsys_argv` line to port. The bridge array
395/// exists only because `bin_zparseopts` takes its argv from `paramtab` by
396/// name, and it cannot be dropped without rewriting that builtin's entry
397/// point — the DESTINATION arrays (`-a __gopt`, …) go through `paramtab`
398/// too. What it must not be is GLOBAL.
399///
400/// A completer is allowed to turn `WARN_CREATE_GLOBAL` on for its own body
401/// (`~/.zinit/completions/_mc:36` — `setopt localoptions warncreateglobal
402/// typesetsilent` — the house style of the zsh-completions collection;
403/// `compinit`'s `_comp_options` only turns it OFF for the utility functions
404/// that do not opt back in). Every port reached from such a completer then
405/// printed one `_requested: array parameter __compsys_argv created globally
406/// in function _requested` per call, on the terminal, in place of the match
407/// list.
408///
409/// The destination arrays escaped the same diagnostic only by accident: they
410/// are left behind after the call, so `createparam` reports `created == 0`
411/// from the second completion onwards and `check_warn_pm`
412/// (`src/ported/params.rs:6669`) returns early. `__compsys_argv` is unset
413/// after every call — the tidier lifetime — so it was re-created, and warned
414/// about, every single time.
415pub fn set_bridge_argv(name: &str, args: &[String]) {
416 declare_locals(&[name], PM_ARRAY);
417 let _ = crate::ported::params::setaparam(name, args.to_vec());
418}
419
420/// `local NAME="$NAME"` — declare `names` local while carrying the
421/// enclosing scope's scalar value into the shadow.
422///
423/// Upstream spells this out where the completer chain must keep
424/// reading an inherited value it is also allowed to overwrite:
425/// `_main_complete:31` (`curcontext="$curcontext"`), `_tags:19`,
426/// `_dispatch:4`. A bare [`declare_locals`] would hand the port an
427/// empty parameter instead.
428pub fn declare_locals_keeping_value(names: &[&str]) {
429 for name in names {
430 let inherited = crate::ported::params::getsparam(name);
431 declare_locals(&[name], 0);
432 if let Some(v) = inherited {
433 let _ = crate::ported::params::setsparam(name, &v);
434 }
435 }
436}
437/// The directory list `compinit` must scan: `$fpath` as it stands at
438/// call time (`Completion/compinit:523` `for _i_dir in $fpath`, and
439/// `compaudit` at sh:455), falling back to `env_fpath` when the array is
440/// unset or empty.
441///
442/// `ShellExecutor::fpath` (vm_helper.rs:532) is seeded once at startup
443/// from `$FPATH` (vm_helper.rs:1174/1287) and never resynced, so the
444/// `fpath=( … )` line that precedes `compinit` in every .zshrc was
445/// invisible to the scan. With `$FPATH` exported the two agreed by
446/// accident; without it — `zsh -f`, a login shell that builds `fpath` in
447/// .zshrc, the parity harness's child env — the scan got ZERO
448/// directories, and the worker's empty result was then written over the
449/// completion cache, leaving `$_comps` empty and every command falling
450/// through to `-default-`.
451pub fn compinit_scan_dirs(env_fpath: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
452 match crate::ported::params::getaparam("fpath") {
453 Some(live) if !live.is_empty() => live.iter().map(std::path::PathBuf::from).collect(),
454 _ => env_fpath.to_vec(),
455 }
456}
457
458/// `is_executable` — see implementation.
459pub fn is_executable(path: &Path) -> bool {
460 #[cfg(unix)]
461 {
462 use std::os::unix::fs::PermissionsExt;
463 if let Ok(meta) = path.metadata() {
464 let mode = meta.permissions().mode();
465 return mode & 0o111 != 0;
466 }
467 }
468 #[cfg(not(unix))]
469 {
470 if let Some(ext) = path.extension() {
471 let ext = ext.to_string_lossy().to_lowercase();
472 return matches!(ext.as_str(), "exe" | "bat" | "cmd" | "com");
473 }
474 }
475 false
476}
477
478/// Shell-glob matcher — supports `*`, `?`, and `(a|b|c)`
479/// alternation (zsh extended-glob's `(…|…)` form). Sufficient for
480/// the patterns end-user completion files use (e.g.
481/// `*.(md|rs|toml)` from `_suffix_alias_files`).
482pub fn glob_matches(pattern: &str, text: &str) -> bool {
483 // Handle leading `(alt1|alt2|…)` at the top level — split at the
484 // matching close paren, try each alternative concatenated with
485 // the remainder.
486 if let Some(rest) = pattern.strip_prefix('(') {
487 if let Some(close) = find_top_close_paren(rest) {
488 let group = &rest[..close];
489 let after = &rest[close + 1..];
490 return group.split('|').any(|alt| {
491 let combined = format!("{}{}", alt, after);
492 glob_matches(&combined, text)
493 });
494 }
495 }
496 let pat: Vec<char> = pattern.chars().collect();
497 let txt: Vec<char> = text.chars().collect();
498 glob_helper(&pat, &txt)
499}
500
501fn find_top_close_paren(s: &str) -> Option<usize> {
502 let mut depth: i32 = 1;
503 for (i, c) in s.char_indices() {
504 match c {
505 '(' => depth += 1,
506 ')' => {
507 depth -= 1;
508 if depth == 0 {
509 return Some(i);
510 }
511 }
512 _ => {}
513 }
514 }
515 None
516}
517
518fn glob_helper(pat: &[char], txt: &[char]) -> bool {
519 if pat.is_empty() {
520 return txt.is_empty();
521 }
522 // Inline alternation at any position: when we encounter `(...)`,
523 // re-route through `glob_matches` on the remainder.
524 if pat[0] == '(' {
525 let rest: String = pat[1..].iter().collect();
526 let txt_str: String = txt.iter().collect();
527 if let Some(close) = find_top_close_paren(&rest) {
528 let group = &rest[..close];
529 let after = &rest[close + 1..];
530 return group.split('|').any(|alt| {
531 let combined = format!("{}{}", alt, after);
532 glob_matches(&combined, &txt_str)
533 });
534 }
535 }
536 match pat[0] {
537 '*' => {
538 for i in 0..=txt.len() {
539 if glob_helper(&pat[1..], &txt[i..]) {
540 return true;
541 }
542 }
543 false
544 }
545 '?' => !txt.is_empty() && glob_helper(&pat[1..], &txt[1..]),
546 c => !txt.is_empty() && txt[0] == c && glob_helper(&pat[1..], &txt[1..]),
547 }
548}
549
550/// Shell-glob matcher mirror of the helper that used to live in
551/// `compsys/functions.rs` — kept as a separate symbol because callers
552/// were spelled `functions::glob_match(...)`, distinct from
553/// `glob_matches` above (which the `library.rs`/`ported/_path_files`
554/// code used). Both share semantics; the duplicate is intentional for
555/// API-shape compat with both call-site ()/* styles */.
556pub fn glob_match(pattern: &str, text: &str) -> bool {
557 glob_matches(pattern, text)
558}
559
560/// Levenshtein edit distance, used by `_approximate`, `_correct`,
561/// `_correct_filename`, and `_correct_word`. Moved out of
562/// `compsys/functions.rs` so it can be shared across the per-fn ports
563/// without introducing a circular dependency between them.
564pub fn edit_distance(a: &str, b: &str) -> usize {
565 let a_chars: Vec<char> = a.chars().collect();
566 let b_chars: Vec<char> = b.chars().collect();
567 let m = a_chars.len();
568 let n = b_chars.len();
569
570 let mut dp = vec![vec![0; n + 1]; m + 1];
571
572 // Levenshtein DP base row/col init — needless_range_loop trips here
573 // but the index IS the value being written, not a positional access.
574 #[allow(clippy::needless_range_loop)]
575 for i in 0..=m {
576 dp[i][0] = i;
577 }
578 #[allow(clippy::needless_range_loop)]
579 for j in 0..=n {
580 dp[0][j] = j;
581 }
582
583 for i in 1..=m {
584 for j in 1..=n {
585 let cost = if a_chars[i - 1] == b_chars[j - 1] {
586 0
587 } else {
588 1
589 };
590 dp[i][j] = (dp[i - 1][j] + 1)
591 .min(dp[i][j - 1] + 1)
592 .min(dp[i - 1][j - 1] + cost);
593 }
594 }
595
596 dp[m][n]
597}
598
599/// Check if a string matches any ignored pattern. Extracted from
600/// `compsys/base.rs::is_ignored`. Uses the same `glob_match` helper
601/// as the rest of the per-fn ports.
602pub fn is_ignored(s: &str, patterns: &[String]) -> bool {
603 for pattern in patterns {
604 if glob_match(pattern, s) {
605 return true;
606 }
607 }
608 false
609}
610
611/// `get_ignored_patterns(context)` — collect `ignored-patterns`
612/// zstyle values for `context` via the real `lookupstyle` in
613/// `src/ported/modules/zutil.rs`.
614pub fn get_ignored_patterns(context: &str) -> Vec<String> {
615 crate::ported::modules::zutil::lookupstyle(context, "ignored-patterns")
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 /// `_dispatch:51` is handed `$words[1]` verbatim, so the `$_comps` key it
623 /// needs is only reachable through `${(Q)}`. These are the exact word
624 /// shapes the completion line produces.
625 #[test]
626 fn dequote_q_recovers_the_command_name_from_a_quoted_word() {
627 let _g = crate::test_util::global_state_lock();
628 assert_eq!(dequote_q("\"env\""), "env");
629 assert_eq!(dequote_q("'rm'"), "rm");
630 assert_eq!(dequote_q("\\rm"), "rm");
631 assert_eq!(dequote_q("/usr/bin/ls"), "/usr/bin/ls");
632 assert_eq!(dequote_q("-default-"), "-default-");
633 }
634
635 /// c:Src/subst.c:4139-4140 runs the `(Q)` parse under `noerrs = 1`, so an
636 /// UNBALANCED quote is kept as a literal rather than deleted. A
637 /// hand-rolled "drop every quote character" walker returns `xy` here and
638 /// would turn the command word `a"b` into a key that matches nothing.
639 #[test]
640 fn dequote_q_keeps_an_unbalanced_quote_as_a_literal() {
641 let _g = crate::test_util::global_state_lock();
642 assert_eq!(dequote_q("x\"y"), "x\"y");
643 assert_eq!(dequote_q("x'y"), "x'y");
644 }
645
646 /// The zparseopts bridge array must be a FUNCTION LOCAL, not a global.
647 ///
648 /// `__compsys_argv` is written by 18 `run_*` helpers under
649 /// `src/compsys/ported/`, once per call, and unset again straight after —
650 /// so every call RE-CREATES it. `createparam` at `locallevel == 0` makes
651 /// that a global creation, and `check_warn_pm`
652 /// (`src/ported/params.rs:6669`, port of `Src/params.c:3158`) prints
653 /// `<caller>: array parameter __compsys_argv created globally in function
654 /// <caller>` for each one whenever `WARN_CREATE_GLOBAL` is on.
655 ///
656 /// It is on more often than the option's rarity suggests: `compinit`'s
657 /// `_comp_options` clears it (sh:171 `NO_warncreateglobal`), but a
658 /// completer is free to set it back for its own body, and the
659 /// zsh-completions house style does exactly that
660 /// (`_mc:36` — `setopt localoptions warncreateglobal typesetsilent`).
661 /// `mc <TAB>` then drew 29 rows of diagnostics instead of its match list,
662 /// and `mc -<TAB>` 40.
663 ///
664 /// Asserting the TYPE STRING rather than `pm.level` is deliberate: it is
665 /// the same `${(t)name}` text `_parameters` filters on, so this test also
666 /// pins the property that made the earlier leaks visible as bogus
667 /// completion matches.
668 #[test]
669 fn bridge_argv_is_declared_local_not_created_global() {
670 let _g = crate::test_util::global_state_lock();
671 crate::ported::utils::inc_locallevel();
672 let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
673 set_bridge_argv("__compsys_argv", &["-J".to_string(), "grp".to_string()]);
674 let ty = crate::ported::params::paramtab()
675 .read()
676 .ok()
677 .and_then(|t| {
678 t.get("__compsys_argv")
679 .map(|pm| crate::ported::modules::parameter::paramtypestr(pm))
680 })
681 .unwrap_or_default();
682 assert_eq!(
683 ty, "array-local",
684 "bridge argv must be `local -a`; `array` means every call \
685 re-creates a global and WARN_CREATE_GLOBAL prints a line"
686 );
687 assert_eq!(
688 crate::ported::params::getaparam("__compsys_argv").unwrap_or_default(),
689 vec!["-J".to_string(), "grp".to_string()],
690 "declaring it local must not cost the value zparseopts reads"
691 );
692 }));
693 crate::ported::params::endparamscope();
694 let _ = crate::ported::params::unsetparam("__compsys_argv");
695 if let Err(p) = out {
696 std::panic::resume_unwind(p);
697 }
698 }
699
700 /// The type/attribute bits an upstream `local` line spells have to reach
701 /// `createparam`, and the names have to be GONE after `endparamscope`.
702 ///
703 /// This is the substrate the whole compsys scratch-parameter fix rests on:
704 /// 95 ports call `declare_locals` with the kind their shell source spells
705 /// (`0` for a bare `local`, PM_ARRAY for `local -a`, PM_HASHED for
706 /// `local -A`, PM_HIDE for `local -H`, PM_ARRAY|PM_UNIQUE for
707 /// `local -aU`). Two things can go wrong independently and this pins both:
708 ///
709 /// * the bits are dropped, so `${(t)name}` reads `scalar-local` where zsh
710 /// reads `array-local` — the port then works by luck, because the first
711 /// `setaparam` retypes it, and `_parameters`' type filter still sees a
712 /// different string than zsh does;
713 /// * `pm->level` is not stamped, so `endparamscope` leaves the name
714 /// behind and one TAB puts a completer's working variable in the user's
715 /// interactive shell.
716 ///
717 /// Names are prefixed so this test cannot collide with a real parameter or
718 /// with another test in the same process.
719 #[test]
720 fn declare_locals_carries_the_shell_kind_and_unwinds() {
721 let _g = crate::test_util::global_state_lock();
722 crate::ported::utils::inc_locallevel();
723 let cases: [(&str, u32, &str); 4] = [
724 ("zzlk_scalar", 0, "scalar"),
725 ("zzlk_array", PM_ARRAY, "array"),
726 ("zzlk_assoc", PM_HASHED, "association"),
727 ("zzlk_uniq", PM_ARRAY | PM_UNIQUE, "unique"),
728 ];
729 let inner = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
730 for (name, kind, want) in cases {
731 declare_locals(&[name], kind);
732 let ty = crate::ported::params::paramtab()
733 .read()
734 .ok()
735 .and_then(|t| {
736 t.get(name)
737 .map(|pm| crate::ported::modules::parameter::paramtypestr(pm))
738 })
739 .unwrap_or_default();
740 assert!(
741 ty.contains(want) && ty.contains("local"),
742 "declare_locals({name}, {kind:#x}) produced `{ty}`, \
743 expected it to contain `{want}` and `local`"
744 );
745 }
746 }));
747 crate::ported::params::endparamscope();
748 let survivors: Vec<&str> = cases
749 .iter()
750 .map(|(n, _, _)| *n)
751 .filter(|n| {
752 crate::ported::params::paramtab()
753 .read()
754 .map(|t| t.get(*n).is_some())
755 .unwrap_or(false)
756 })
757 .collect();
758 for (n, _, _) in cases {
759 let _ = crate::ported::params::unsetparam(n);
760 }
761 if let Err(p) = inner {
762 std::panic::resume_unwind(p);
763 }
764 assert!(
765 survivors.is_empty(),
766 "{survivors:?} outlived endparamscope, i.e. pm->level was never \
767 stamped and every port that declares a name this way still leaks it"
768 );
769 }
770
771 /// compinit sh:523 — the scan reads `$fpath`, not the `$FPATH` the
772 /// process happened to inherit.
773 ///
774 /// Regression: `builtin_compinit` scanned `ShellExecutor::fpath`,
775 /// which is env-seeded at startup and never resynced, so
776 /// `fpath=( … ); compinit` scanned the STARTUP list. With no `FPATH`
777 /// exported that list is empty, the worker returned zero completers,
778 /// and the empty result was written over the completion cache —
779 /// `$_comps` empty, every command resolved to `-default-`.
780 #[test]
781 fn compinit_scans_the_live_fpath_array_not_the_startup_env() {
782 use std::path::PathBuf;
783 let _g = crate::test_util::global_state_lock();
784 let env_seeded = vec![PathBuf::from("/from/FPATH/env")];
785
786 crate::ported::params::setaparam(
787 "fpath",
788 vec!["/live/one".to_string(), "/live/two".to_string()],
789 );
790 assert_eq!(
791 compinit_scan_dirs(&env_seeded),
792 vec![PathBuf::from("/live/one"), PathBuf::from("/live/two")],
793 "sh:523 scans $fpath"
794 );
795
796 // Unset / empty array — keep the env-derived list rather than
797 // scanning nothing.
798 crate::ported::params::setaparam("fpath", Vec::new());
799 assert_eq!(compinit_scan_dirs(&env_seeded), env_seeded);
800 crate::ported::params::unsetparam("fpath");
801 assert_eq!(compinit_scan_dirs(&env_seeded), env_seeded);
802 }
803
804 /// `mark_readonly` is the `-r` of `local -ar` (sh:52) and must not
805 /// escape the function scope: stamped at `locallevel == 0` the bit
806 /// would pin the caller's global read-only forever, and the next
807 /// completion's own assignment would fail with "read-only variable".
808 #[test]
809 fn mark_readonly_is_scoped_to_a_function() {
810 use crate::ported::modules::parameter::paramtypestr;
811 let _g = crate::test_util::global_state_lock();
812 // `mark_readonly` keys off `locallevel` (shared.rs:216), the
813 // process-wide `AtomicI32` port of `Src/params.c:54`. Nothing
814 // unwinds it when a test panics out of a `doshfunc`-shaped port
815 // (`_wanted_impl`'s inc/dec pair, `FnScope`, `LocalScope`), so the
816 // "no scope — no readonly bit" leg below only holds from a pinned
817 // 0, which is also the value a real shell starts at. Without this
818 // the assertion passed alone and failed inside a full run.
819 crate::ported::params::locallevel.store(0, std::sync::atomic::Ordering::Relaxed);
820 let type_of = |n: &str| {
821 crate::ported::params::paramtab()
822 .read()
823 .ok()
824 .and_then(|t| t.get(n).map(|pm| paramtypestr(pm)))
825 .unwrap_or_default()
826 };
827
828 crate::ported::params::setaparam("_ro_probe", vec!["a".to_string()]);
829 mark_readonly(&["_ro_probe"]);
830 assert_eq!(type_of("_ro_probe"), "array", "no scope — no readonly bit");
831
832 crate::ported::utils::inc_locallevel();
833 declare_locals(&["_ro_probe"], PM_ARRAY);
834 crate::ported::params::setaparam("_ro_probe", vec!["b".to_string()]);
835 mark_readonly(&["_ro_probe"]);
836 assert_eq!(type_of("_ro_probe"), "array-local-readonly");
837 crate::ported::params::endparamscope();
838 assert_eq!(
839 type_of("_ro_probe"),
840 "array",
841 "endparamscope must unwind the readonly shadow"
842 );
843 crate::ported::params::unsetparam("_ro_probe");
844 }
845
846 // glob_match coverage migrated from `compsys/base.rs` when the
847 // local glob_match helper there was removed in favor of this
848 // single shared implementation.
849
850 #[test]
851 fn test_glob_match_simple() {
852 assert!(glob_match("*.txt", "file.txt"));
853 assert!(glob_match("*.txt", ".txt"));
854 assert!(!glob_match("*.txt", "file.rs"));
855 }
856
857 #[test]
858 fn test_glob_match_question() {
859 assert!(glob_match("file?.txt", "file1.txt"));
860 assert!(glob_match("file?.txt", "fileX.txt"));
861 assert!(!glob_match("file?.txt", "file.txt"));
862 assert!(!glob_match("file?.txt", "file12.txt"));
863 }
864
865 #[test]
866 fn test_glob_match_star_middle() {
867 assert!(glob_match("foo*bar", "foobar"));
868 assert!(glob_match("foo*bar", "foo123bar"));
869 assert!(glob_match("foo*bar", "fooXYZbar"));
870 assert!(!glob_match("foo*bar", "foobaz"));
871 }
872
873 #[test]
874 fn test_glob_match_multiple_stars() {
875 assert!(glob_match("*foo*", "foo"));
876 assert!(glob_match("*foo*", "afoo"));
877 assert!(glob_match("*foo*", "foob"));
878 assert!(glob_match("*foo*", "afoob"));
879 assert!(!glob_match("*foo*", "bar"));
880 }
881
882 #[test]
883 fn test_glob_match_exact() {
884 assert!(glob_match("exact", "exact"));
885 assert!(!glob_match("exact", "exacty"));
886 assert!(!glob_match("exact", "xact"));
887 }
888}
889
890/// Call another compsys completer BY NAME, so `$fpath` arbitration still runs.
891///
892/// zshrs-original — C has no port tree to arbitrate against. Every upstream
893/// completer reaches its helpers as a bare command word (`_files "$@"`), which
894/// goes through the normal function lookup, so a user's own `_files` earlier in
895/// `$fpath` wins. A Rust port that calls its sibling port as a plain Rust fn
896/// skips that lookup entirely: `crate::ported::exec::dispatch_function_call` is
897/// the only path that consults `compsys::router::try_rust_dispatch` and its
898/// `has_fpath_override` gate, so the user's file is silently dead.
899///
900/// This is not hypothetical. `_command_names` had the same defect (fixed in
901/// b8e714f7be) and `_parameters` had it in the `-brace-parameter-` /
902/// `-subscript-` contexts, which is why `echo ${<TAB>` offered zshrs's own
903/// parameter list instead of the user's. On this host `_files` is overridden at
904/// `~/.zpwr/autoload/comp_utils/_files` (fpath position 18, ahead of the stock
905/// tree at 24) and ten ports call it directly.
906///
907/// `fallback` runs only when no shell function and no registered port claims
908/// the name — i.e. in unit tests with no executor installed. It is a DEGRADED
909/// stand-in for the `doshfunc` frame the dispatch path opens: no `FUNCSTACK`
910/// entry, no param scope, no `locallevel` bump. A caller whose sh semantics
911/// depend on the callee's scope depth — anything driving `comptags`, which is
912/// indexed by `locallevel` (`Src/Zle/computil.c:3782` "Array of tag-set
913/// infos. Index is the locallevel", `:3873` `level = locallevel -
914/// (args[0][2] ? 1 : 0)`) — must supply the missing piece inside its own
915/// `fallback` closure rather than assume this helper does it.
916///
917/// # Naming convention for ports
918///
919/// A port with a dispatching entry point splits in two, and the names are
920/// chosen so that the OBVIOUS call is the CORRECT one:
921///
922/// * `_NAME` — the dispatching wrapper, one line: `call_compfn("_NAME",
923/// args, || _NAME_impl(args))`. This is what every sibling port calls, and
924/// it matches the zsh function name character for character.
925/// * `_NAME_impl` — the raw body. Two callers, both of which must not
926/// re-enter dispatch: the wrapper's own `fallback` above, and the
927/// `compsys::router` arm for `"_NAME"`. **The router arm MUST name
928/// `_NAME_impl`.** Pointing it at `_NAME` makes dispatch call the wrapper,
929/// which calls dispatch, forever.
930///
931/// Anything else that names `_NAME_impl` is asserting it genuinely needs no
932/// `doshfunc` frame — sh `continue` expressed as recursion
933/// (`_next_label.rs`), or a callee whose `comptags` level the caller manages
934/// by hand (`_message.rs`, `_wanted.rs`). Those sites carry a comment saying
935/// why.
936pub fn call_compfn(name: &str, args: &[String], fallback: impl FnOnce() -> i32) -> i32 {
937 crate::ported::exec::dispatch_function_call(name, args).unwrap_or_else(fallback)
938}
939
940// =====================================================================
941// `scriptname` for the duration of a port call.
942// =====================================================================
943//
944// `doshfunc` sets `scriptname` to the function's own name on entry
945// (`Src/exec.c:5963` — `scriptname = dupstring(name);`) and restores the
946// caller's on exit (`Src/exec.c:6124` — `scriptname = funcsave->scriptname;`).
947// That is what every diagnostic reads: `zwarning` prints it ahead of the
948// builtin name (`Src/utils.c:147-155`), so an error raised by a builtin
949// inside `_tags` reads `_tags:comptags:36: ...`.
950//
951// The Rust ports reach that same builtin without a `doshfunc` frame. Ports
952// call each other as plain Rust calls — `_describe` invokes `_tags` directly
953// (`Base/Utility/_describe.rs`) — and only
954// `crate::ported::exec::dispatch_function_call` goes through `doshfunc`. So
955// `scriptname` kept whatever shell function was last entered and every
956// diagnostic named the wrong function:
957//
958// zsh _tags:comptags:36: can only be called from completion function
959// zshrs _describe:comptags: can only be called from completion function
960//
961// `FnScope::enter` is the `scriptname` half of that prologue/epilogue, applied
962// at the entry of each port so a port called either way reports identically.
963//
964// `FnScope` also carries the `lineno` half. In C the second field of the
965// diagnostic prefix is the GLOBAL `lineno`, printed by `zerrmsg`
966// (`Src/utils.c:301-305` — `if ((unset(SHINSTDIN) || locallevel) && lineno)
967// fprintf(file, "%lld: ", lineno);`), and it is maintained by the wordcode
968// line markers as each statement of the function body executes
969// (`Src/exec.c:1356` — `lineno = code - 1;`, `Src/exec.c:2057` —
970// `lineno = WC_PIPE_LINENO(pcode) - 1;`). A Rust port has no wordcode, so
971// nothing advances that counter and the field came out empty:
972//
973// zsh _describe:compdescribe:129: no parsed state
974// zshrs _describe:compdescribe: no parsed state
975//
976// `execlist` saves `lineno` on entry to a body and restores it on exit
977// (`Src/exec.c:1429` — `oldlineno = lineno;`, `Src/exec.c:1696` —
978// `lineno = oldlineno;`), which is what makes a nested call leave the
979// caller's line intact. `FnScope` reproduces that save/restore, and
980// [`set_sh_lineno`] is what a port calls to stand in for the line marker.
981//
982// Entry deliberately publishes 0 ("unknown"), not the caller's line: 0 is the
983// value `zerrmsg` treats as "no line to print", so a statement that has not
984// been annotated yet keeps today's behaviour (field absent) instead of
985// inheriting a number belonging to a different file. A wrong line number is
986// worse than a missing one — it points the reader at the wrong function.
987
988/// RAII guard publishing `scriptname` and `lineno` for the body of a Rust
989/// compsys port, mirroring `doshfunc`'s `scriptname` save/set/restore and
990/// `execlist`'s `lineno` save/restore.
991pub struct FnScope {
992 saved: Option<String>,
993 saved_lineno: u64,
994}
995
996impl FnScope {
997 /// `scriptname = dupstring(name)` (`Src/exec.c:5963`) plus
998 /// `oldlineno = lineno` (`Src/exec.c:1429`), remembering the caller's
999 /// values for [`Drop`].
1000 pub fn enter(name: &str) -> Self {
1001 let saved = crate::ported::utils::scriptname_get();
1002 crate::ported::utils::set_scriptname(Some(name.to_string()));
1003 let saved_lineno = crate::ported::lex::lineno();
1004 // No wordcode line marker has run for this body yet, and the caller's
1005 // line belongs to a different file — publish "unknown" so `zerrmsg`
1006 // omits the field (`Src/utils.c:301` — `&& lineno`).
1007 crate::ported::lex::set_lineno(0);
1008 FnScope {
1009 saved,
1010 saved_lineno,
1011 }
1012 }
1013}
1014
1015/// !!! WARNING: Rust-only helper, no C counterpart !!!
1016///
1017/// This is NOT a port of anything. No C function corresponds to it; it exists
1018/// only because a Rust port can reach a sibling port's body without the frame
1019/// a shell-function call would have opened.
1020///
1021/// It supplies ONLY the `FUNCSTACK` half of a `doshfunc` frame — the push at
1022/// `c:Src/exec.c:6005-6016` and the pop at `c:6218-6219` — and nothing else of
1023/// that prologue.
1024///
1025/// It is deliberately NOT `doshfunc` itself. `doshfunc` also bumps
1026/// `locallevel`, and `comptags` is indexed by that level
1027/// (`Src/Zle/computil.c:3782`). The ports that call `_impl` bodies pair their
1028/// own `inc_locallevel`/`dec_locallevel` around a `_tags` registration and the
1029/// `comptags -A` lookup that consumes it; a second bump landing between them
1030/// splits that pair. `_message.rs` records that dispatching there also flips
1031/// fourteen tests (`dash_e_registers_its_own_tag_level`,
1032/// `default_mode_registers_the_messages_tag`, and eleven `_x_*`
1033/// `routes_to_message_*`), which is why the level is managed by hand and only
1034/// the frame is restored here.
1035///
1036/// Why the frame has to exist at all: `_next_label` sh:9 (and `_all_labels`
1037/// sh:27) gate their `_comp_tags` strip on `(( $#funcstack > _tags_level ))`.
1038/// A raw `_next_label_impl` call leaves `$#funcstack` one short, so that guard
1039/// is unobservable and a tag zsh drops survives.
1040///
1041/// The `FUNCSTACK` half of a `doshfunc` frame, for a port that deliberately
1042/// calls a sibling port's raw `_impl` body instead of dispatching.
1043///
1044/// `doshfunc` pushes one `FS_FUNC` frame per shell-function call
1045/// (`Src/exec.c:6005-6016`) and pops it on the way out (`c:6218-6219`).
1046/// [`call_compfn`] gets that for free; a raw `_impl` call does not, and
1047/// `call_compfn`'s own contract says the caller "must supply the missing piece
1048/// inside its own `fallback` closure rather than assume this helper does it".
1049///
1050/// The missing frame is not cosmetic. `_next_label` sh:9 and `_all_labels`
1051/// sh:27 gate their `_comp_tags` strip on `(( $#funcstack > _tags_level ))`,
1052/// so a callee running one frame shallower than zsh's silently keeps a tag
1053/// zsh drops. Measured on `_arguments : '1::optional delimiter:(\:)' '*:spec'`
1054/// (an empty rest action, which sh:413-417 routes to `_message -e`):
1055///
1056/// ```text
1057/// zsh _tags_level=9 _comp_tags=' argument-rest '
1058/// zshrs _tags_level=8 _comp_tags=' argument-1 argument-rest '
1059/// ```
1060///
1061/// This guard supplies ONLY the funcstack frame. It deliberately does not go
1062/// through `doshfunc`, which would also bump `locallevel` — `comptags` is
1063/// indexed by that level (`Src/Zle/computil.c:3782`), and the ports that call
1064/// `_impl` bodies pair their own `inc_locallevel`/`dec_locallevel` around the
1065/// registration and its lookup. Adding a second bump between them would split
1066/// that pair.
1067pub struct PortFuncstackFrame;
1068
1069impl PortFuncstackFrame {
1070 /// `funcstack = &funcsave->fstack` with `tp = FS_FUNC` (c:6005-6016).
1071 pub fn push(name: &str) -> Self {
1072 if let Ok(mut stack) = crate::ported::modules::parameter::FUNCSTACK.lock() {
1073 stack.push(crate::ported::zsh_h::funcstack {
1074 prev: None, // c:6014 (Vec-stack: index encodes link)
1075 name: name.to_string(), // c:6005
1076 filename: None, // c:6019
1077 caller: None, // c:6011
1078 flineno: 0, // c:6018
1079 lineno: crate::ported::lex::lineno() as i64, // c:6013
1080 tp: crate::ported::zsh_h::FS_FUNC, // c:6015
1081 });
1082 }
1083 PortFuncstackFrame
1084 }
1085}
1086
1087impl Drop for PortFuncstackFrame {
1088 /// c:6218-6219 — `if (fpushed) funcstack = funcstack->prev;`
1089 fn drop(&mut self) {
1090 if let Ok(mut stack) = crate::ported::modules::parameter::FUNCSTACK.lock() {
1091 stack.pop();
1092 }
1093 }
1094}
1095
1096impl Drop for FnScope {
1097 /// `scriptname = funcsave->scriptname` (`Src/exec.c:6124`) and
1098 /// `lineno = oldlineno` (`Src/exec.c:1696`).
1099 fn drop(&mut self) {
1100 crate::ported::utils::set_scriptname(self.saved.take());
1101 crate::ported::lex::set_lineno(self.saved_lineno);
1102 }
1103}
1104
1105/// Publish the upstream shell-source line of the statement a port is about to
1106/// run, standing in for the wordcode line marker C executes ahead of every
1107/// statement (`Src/exec.c:2057` — `lineno = WC_PIPE_LINENO(pcode) - 1;`).
1108///
1109/// Diagnostics only originate at builtin call sites, so a port only needs this
1110/// immediately before invoking a builtin that can call `zwarnnam`; the value is
1111/// then read by `zwarning`/`zerrmsg` (`src/ported/utils.rs:191`).
1112/// [`FnScope`] restores the caller's line when the port returns.
1113///
1114/// `line` MUST be read off the upstream `Completion/**` file the port was
1115/// translated from. Never estimate it — the `// sh:NN` comments in the ports
1116/// predate later upstream edits and have drifted (`_describe`'s
1117/// `compdescribe -I` was annotated `sh:118-121` but lives at line 122 of both
1118/// zsh 5.9.2 and master).
1119///
1120/// `scripts/check_sh_lineno.py` diffs every `sh:NN` annotation against the
1121/// upstream file and reports the ones whose cited line does not carry the
1122/// quoted code; run it before trusting an annotation as a `line` argument.
1123/// An annotation it reports as `unverified`, `suspect` or `out-of-range` has
1124/// NOT been proven and must not be passed here.
1125pub fn set_sh_lineno(line: u64) {
1126 crate::ported::lex::set_lineno(line);
1127}
1128
1129/// `while getopts OPTSTRING var; do case $var in … esac; done` — the whole
1130/// loop, for a port whose upstream function parses its options that way.
1131///
1132/// Port of `bin_getopts` (c:Src/builtin.c:5656-5776), called repeatedly until
1133/// it returns 1, starting from the `zoptind = 1` / `optcind = 0` that
1134/// `doshfunc` gives every function entry. `on_opt(var, optarg)` receives what
1135/// each call stores in `var` — `"o"` for `-o`, `"+o"` for `+o`, `"?"` for an
1136/// invalid option or a missing argument (`":"` for the latter when the
1137/// optstring opens with `:`) — so a caller matches the same strings its
1138/// upstream `case` arms do and ignores the rest.
1139///
1140/// Three getopts behaviours a per-word match cannot express:
1141/// * CLUSTERING — the characters of one word are options one at a time, so
1142/// `-default-` is `-d -e -f -a -u -l -t -` (c:5704-5706);
1143/// * RECOVERY — an invalid option is reported with `zwarn` (not
1144/// `zwarnnam`, so the prefix is `fn:LINE:`) and the loop CARRIES ON
1145/// (c:5715-5731);
1146/// * `+` words are options too unless POSIX_BUILTINS is set (c:5696).
1147///
1148/// `$OPTARG` receives every value C stores in `zoptarg`. Returns OPTIND-1,
1149/// the count `shift OPTIND-1` removes.
1150pub fn getopts_loop(
1151 args: &[String],
1152 optstring: &str,
1153 sh_line: u64,
1154 mut on_opt: impl FnMut(&str, Option<&str>),
1155) -> usize {
1156 use crate::ported::params::setsparam;
1157 let quiet = optstring.starts_with(':'); // c:5681
1158 let optstr = &optstring[quiet as usize..]; // c:5682
1159 let posix = crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS); // c:5667
1160 let warn = |msg: String| {
1161 set_sh_lineno(sh_line);
1162 crate::ported::utils::zwarn(&msg);
1163 };
1164 let mut zoptind = 1usize; // c:5673
1165 let mut optcind = 0usize; // c:5674
1166 loop {
1167 if args.len() < zoptind {
1168 return zoptind - 1; // c:5676-5678
1169 }
1170 let mut str: Vec<char> = args[zoptind - 1].chars().collect();
1171 if str.is_empty() {
1172 return zoptind - 1; // c:5687-5688
1173 }
1174 if optcind >= str.len() {
1175 // c:5689-5694
1176 optcind = 0;
1177 zoptind += 1;
1178 if args.len() < zoptind {
1179 return zoptind - 1;
1180 }
1181 str = args[zoptind - 1].chars().collect();
1182 }
1183 if optcind == 0 {
1184 // c:5695-5703
1185 if str.len() < 2 || (str[0] != '-' && (posix || str[0] != '+')) {
1186 return zoptind - 1;
1187 }
1188 if str.len() == 2 && str[0] == '-' && str[1] == '-' {
1189 zoptind += 1;
1190 return zoptind - 1;
1191 }
1192 optcind = 1;
1193 }
1194 let opch = str[optcind]; // c:5704
1195 optcind += 1; // c:5706
1196 let sign = if str[0] == '+' { "+" } else { "-" }; // c:5707-5711
1197 let optbuf = if str[0] == '+' { format!("+{}", opch) } else { opch.to_string() };
1198 // c:5715 — check for legality
1199 let pos = if opch == ':' { None } else { optstr.find(opch) };
1200 let Some(pos) = pos else {
1201 if posix {
1202 optcind = 0;
1203 zoptind += 1;
1204 }
1205 if quiet {
1206 let _ = setsparam("OPTARG", &format!("{}{}", sign, opch)); // c:5725
1207 } else {
1208 warn(format!("bad option: {}{}", sign, opch)); // c:5727
1209 let _ = setsparam("OPTARG", ""); // c:5729
1210 }
1211 on_opt("?", None); // c:5723
1212 continue;
1213 };
1214 // c:5735 — check for required argument
1215 if optstr.as_bytes().get(pos + opch.len_utf8()) == Some(&b':') {
1216 let optarg: String = if optcind == str.len() {
1217 if args.len() <= zoptind {
1218 // c:5737-5753
1219 if posix {
1220 optcind = 0;
1221 zoptind += 1;
1222 }
1223 if quiet {
1224 let _ = setsparam("OPTARG", &format!("{}{}", sign, opch));
1225 on_opt(":", None);
1226 } else {
1227 let _ = setsparam("OPTARG", "");
1228 warn(format!("argument expected after {}{} option", sign, opch));
1229 on_opt("?", None);
1230 }
1231 continue;
1232 }
1233 zoptind += 1; // c:5755
1234 args[zoptind - 1].clone()
1235 } else {
1236 str[optcind..].iter().collect() // c:5757
1237 };
1238 optcind = 0; // c:5765
1239 zoptind += 1; // c:5766
1240 let _ = setsparam("OPTARG", &optarg); // c:5768
1241 on_opt(&optbuf, Some(&optarg)); // c:5774
1242 } else {
1243 let _ = setsparam("OPTARG", ""); // c:5771
1244 on_opt(&optbuf, None); // c:5774
1245 }
1246 }
1247}
1248
1249/// `$(CMD ARGS...)` in an unquoted word, for an external CMD — the words
1250/// the substitution splices into the command line.
1251///
1252/// What zsh does, and so what this does:
1253/// * the child's stdout is used whatever its exit status — `$( )` has no
1254/// status gate (c:Src/exec.c `getoutput`), so a failing tool that still
1255/// printed names contributes them;
1256/// * the child's stderr is NOT captured; it reaches the terminal;
1257/// * an unquoted substitution is split on `$IFS` whitespace, and every
1258/// name these callers list is a plain word;
1259/// * a CMD found nowhere is the child's c:Src/exec.c:903
1260/// `zerr("command not found: %s")`, printed as `FN:LINE: command not
1261/// found: CMD` — `sh_line` is the upstream line the substitution sits
1262/// on. The child has run `entersubsh`, so the diagnostic must not
1263/// repaint the editor (`SubshStateGuard`, c:1247-1248).
1264pub fn cmdsubst_external_words(cmd: &str, args: &[&str], sh_line: u64) -> Vec<String> {
1265 let out = std::process::Command::new(cmd)
1266 .args(args)
1267 .stdin(std::process::Stdio::null())
1268 .stderr(std::process::Stdio::inherit())
1269 .output();
1270 match out {
1271 Ok(o) => String::from_utf8_lossy(&o.stdout)
1272 .split_whitespace()
1273 .map(String::from)
1274 .collect(),
1275 Err(e) => {
1276 if e.kind() == std::io::ErrorKind::NotFound {
1277 set_sh_lineno(sh_line);
1278 let _subsh = crate::ported::exec::SubshStateGuard::enter(); // c:1247-1248
1279 crate::ported::utils::zwarn(&format!("command not found: {}", cmd)); // c:903
1280 }
1281 Vec::new()
1282 }
1283 }
1284}
1285
1286/// `eval "$comp"` — the way every compsys dispatcher invokes the completer
1287/// named by `$_comps` / `$_patcomps` (`_dispatch` sh:31/63/76/87,
1288/// `_normal` sh:32).
1289///
1290/// Upstream never CALLS the completer by name; it `eval`s the registered
1291/// value as shell text. Two things follow, and a port needs both:
1292///
1293/// * the value can carry arguments (`compdef '_files -/' mycmd` stores
1294/// `_files -/`), which a by-name dispatch cannot express; and
1295/// * `eval` pushes an `FS_EVAL` funcstack frame named `(eval)`
1296/// (`Src/builtin.c:6164-6199`), so every completer invoked this way runs
1297/// one frame deeper than its caller.
1298///
1299/// The frame is not cosmetic. Completion code reads `$#funcstack` to decide
1300/// nesting depth — `_all_labels`/`_alternative` compare it against
1301/// `_tags_level` — so a missing frame silently changes completion behaviour.
1302/// A port calling `dispatch_function_call(&comp, &[])` pushes only the
1303/// completer's own `FS_FUNC` frame; `$funcstack` then reads
1304/// `_mytest _dispatch _normal …` where zsh reports
1305/// `_mytest (eval) _dispatch _normal …`.
1306///
1307/// `line` is the upstream line the `eval` sits on; publishing it via
1308/// [`set_sh_lineno`] is what makes `$functrace` read `_dispatch:63` instead
1309/// of `_dispatch:0` (the caller's line is recorded at push time by `doshfunc`
1310/// c:6013 / `EvalFuncstackFrame::push` c:6169).
1311///
1312/// The body mirrors `static int eval(char **argv)` (`Src/builtin.c:6151`)
1313/// with `argv == { comp, NULL }`; the funcstack half is the shared canonical
1314/// port [`crate::ported::exec::EvalFuncstackFrame`] (c:6164-6199), the same
1315/// one the live `eval` builtin uses, so both entry points build an identical
1316/// frame.
1317pub fn eval_comp(comp: &str, line: u64) -> i32 {
1318 set_sh_lineno(line);
1319 let oscriptname = crate::ported::utils::scriptname_get(); // c:6154
1320 let fstack = crate::ported::exec::EvalFuncstackFrame::push(); // c:6164-6199
1321 if fstack.pushed() {
1322 // c:6165 — `scriptname = "(eval)";` (inside the `!ineval` arm).
1323 crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
1324 }
1325 // c:6209 — `execode(prog, 1, 0, "eval");` APPENDS its context argument to
1326 // `zsh_eval_context` for the duration of the body (Src/exec.c:1245-1266).
1327 //
1328 // That push is DELIBERATELY NOT made here. `docs/COMPLETION_DISPATCH.md`
1329 // "Divergence C" records the decision that compsys Rust ports do not
1330 // synthesize `$zsh_eval_context` frames, and
1331 // tests/zsh_eval_context_frames.rs::compsys_ports_synthesize_no_eval_context_frames
1332 // pins it by scanning this tree for that constructor call. (The scan is a
1333 // plain substring match, so naming the call verbatim here — even in prose —
1334 // trips it; hence the circumlocution.)
1335 //
1336 // A push was added here in 9e55378587 and broke that test. It is left out
1337 // rather than re-added, and the test is left alone, because the decision is
1338 // documented and the test is its enforcement — not because the case is
1339 // clear-cut. It is not: Divergence C reasons that the Rust chain "never
1340 // evals", whereas this function genuinely does parse and execute a string
1341 // below, so a frame here would arguably be truthful rather than fabricated.
1342 // Resolving that tension is a design call for the maintainer; silently
1343 // overriding a pinned decision from inside a bug fix is not.
1344 //
1345 // The funcstack half above is separate and IS pushed: it is a real frame
1346 // for a call that really happens, and no invariant forbids it.
1347 //
1348 // c:6203-6216 — `prog = parse_string(...); … execode(prog, …)`; a NULL
1349 // prog (parse failure) is `lastval = 1` at c:6215.
1350 let mut lastval = crate::ported::exec::execute_script(comp).unwrap_or(1);
1351 // c:6211-6212 — `if (errflag && !lastval) lastval = errflag;`
1352 {
1353 let ef = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
1354 if ef != 0 && lastval == 0 {
1355 lastval = ef;
1356 }
1357 }
1358 drop(fstack); // c:6218-6219 `if (fpushed) funcstack = funcstack->prev;`
1359 // c:6221 — `errflag &= ~ERRFLAG_ERROR;`
1360 //
1361 // `eval` swallows the error bit on the way out, UNCONDITIONALLY, and that
1362 // is load-bearing for completion: it is what lets an error deep inside a
1363 // completer TRUNCATE the work while KEEPING the matches already banked.
1364 // `_CC` ends in `_files -g "*(-.):t:source files" -g "*(-/):t:directories"`,
1365 // which brace-expands to four sdefs; the third is a genuine bad pattern
1366 // (`/` ends a segment at EVERY paren depth — c:Src/pattern.c:949-952 — so
1367 // the group is unterminated and c:Src/pattern.c:913-914 rejects it).
1368 // `zerr` raises ERRFLAG_ERROR (c:Src/utils.c:184); that aborts
1369 // `_path_files`, `_files` (so the FOURTH sdef, `*(-/)` = every directory,
1370 // never runs), `_arguments` and `_CC` — and then THIS clear runs at the
1371 // `eval "$comp"` in sh:Completion/Base/Core/_dispatch:63, so
1372 // `makecomplist`'s `(nmatches || nmessages) && !errflag`
1373 // (c:Src/Zle/compcore.c:1031) still sees errflag == 0 and keeps the one
1374 // match added before the error. Without it the completion is discarded
1375 // and `CC <TAB>` produces nothing at all.
1376 //
1377 // Measured on an instrumented zsh 5.9.999.3 driving `CC <TAB>`:
1378 // ZDBG SET utils.c:184 zerr fmt=<bad pattern: %s> errflag=1
1379 // ZDBG FUNC< _path_files errflag=1 ret=1
1380 // ZDBG FUNC< _files errflag=1 ret=1
1381 // ZDBG FUNC< _arguments errflag=1 ret=1
1382 // ZDBG FUNC< _CC errflag=1 ret=1
1383 // ZDBG CLR builtin.c:6213 before=1 stmt=<errflag &= ~ERRFLAG_ERROR;>
1384 // ZDBG FUNC< _dispatch errflag=0 ret=1
1385 // ZDBG CHECK compcore.c:1031 nmatches=1 nmessages=0 errflag=0
1386 // Of the 87 errflag clear sites in the C source, that is the ONLY one in
1387 // the whole session that observes a set ERRFLAG_ERROR.
1388 crate::ported::utils::errflag.fetch_and(
1389 !crate::ported::zsh_h::ERRFLAG_ERROR,
1390 std::sync::atomic::Ordering::Relaxed,
1391 );
1392 crate::ported::utils::set_scriptname(oscriptname); // c:6222
1393 lastval // c:6225
1394}
1395
1396/// Run an already-expanded action word list as a COMMAND, the way the shell
1397/// does at `_arguments` sh:455 / sh:465 and `_all_labels` sh:35 / sh:39.
1398///
1399/// `cmd` is the command word (`$action[1]`), `argv` the rest of the words the
1400/// shell would have passed, and `line` the upstream line the command sits on
1401/// — it is published through [`set_sh_lineno`] so any diagnostic this raises
1402/// carries the same `_arguments:465:` prefix zsh prints.
1403///
1404/// **Why a helper rather than a bare
1405/// [`crate::ported::exec::dispatch_function_call`]:** that entry resolves
1406/// SHELL FUNCTIONS and the native ports standing in for them, and nothing
1407/// else — `vm_helper.rs:4706` ends in `functions_compiled.get(name).cloned()?`,
1408/// so a builtin, an executable on `$PATH`, and a name that exists nowhere all
1409/// come back `None`. Call sites turned that `None` into a plain non-zero
1410/// status, which means a command word the shell would have DIAGNOSED
1411/// disappeared without a byte of output. Measured on
1412/// `~/.zinit/plugins/MenkeTechnologies---zsh-more-completions/more_src6/_tor-resolve`,
1413/// whose rest spec is
1414///
1415/// ```text
1416/// '*:hostname or IP and SOCKS host[:port]:'
1417/// ```
1418///
1419/// — a MESSAGE carrying an unescaped `:`, so `parse_caarg` (c:1137-1138,
1420/// `mult == 0`) takes everything after the first colon as the ACTION and
1421/// `_arguments` runs `port]:` as a command. zsh reports
1422/// `_arguments:465: command not found: port]:` and zshrs printed nothing at
1423/// all, which is the "one-sided silence" shape that reads as a hang.
1424///
1425/// The three arms mirror what `execcmd` does with a command word: a shell
1426/// function (or port) runs; a name that IS a builtin or IS on `$PATH` is real
1427/// but has no execution route from here, so it keeps the pre-existing
1428/// "action did not succeed" answer rather than having one invented for it;
1429/// and only a name that resolves to nothing reaches
1430/// `Src/exec.c:903`'s diagnostic.
1431///
1432/// `zwarn`, NOT `zerr`: c:903 runs in the FORKED child of `execcmd`, which
1433/// `_exit(127)`s at c:908, so the parent shell's `errflag` is never raised.
1434/// `zerr` here runs in the live shell and would set `ERRFLAG_ERROR`
1435/// (`src/ported/utils.rs:236`), abandoning the rest of the completion.
1436/// The same fork has a second consequence — the child already cleared
1437/// `zleactive`, so the diagnostic must not repaint the editor — which
1438/// the `SubshStateGuard` at the emission site carries; see the comment
1439/// there.
1440///
1441/// This is the extraction of `_all_labels`' private `dispatch_action` tail,
1442/// which had the same three arms; that port now calls this one, so the
1443/// behaviour lives in a single place instead of being re-derived per caller.
1444pub fn dispatch_action_command(cmd: &str, argv: &[String], line: u64) -> i32 {
1445 // The line has to be published BEFORE anything can diagnose: `lineno` is
1446 // what `zerrmsg` prints after the function name (`Src/utils.c:301-305`),
1447 // and `FnScope` zeroed it on entry to the port's body.
1448 set_sh_lineno(line);
1449
1450 // `compadd` is a BUILTIN, so `dispatch_function_call` finds no shell
1451 // function for it and the action would add nothing. Route it to the real
1452 // builtin in `src/ported/zle/complete`.
1453 if cmd == "compadd" {
1454 let ops = crate::ported::zsh_h::options {
1455 ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1456 args: Vec::new(),
1457 argscount: 0,
1458 argsalloc: 0,
1459 };
1460 return crate::ported::zle::complete::bin_compadd("compadd", argv, &ops, 0);
1461 }
1462
1463 // c:Src/exec.c:3105-3109 and the identical gate at c:3484-3488 —
1464 // `execcmd` resolves a command word as
1465 // if (!(cflags & (BINF_BUILTIN | BINF_COMMAND)) &&
1466 // (hn = shfunctab->getnode(shfunctab, cmdarg))) { is_shfunc = 1; break; }
1467 // and only THEN as a builtin and a `$PATH` executable. `getnode` finds a
1468 // node that is ALREADY in `shfunctab`: a defined function, or the autoload
1469 // STUB `autoload` / `compinit` put there. A FILE sitting in `$fpath` is not
1470 // a command in zsh — nothing autoloads it on demand.
1471 //
1472 // zshrs's `dispatch_function_call` DOES autoload it on demand:
1473 // `src/vm_helper.rs:4816-4845` takes any `_`-prefixed name with no
1474 // shfunctab entry, probes `$fpath` with `getfpfunc`, and on a hit runs
1475 // `autoload -rUz -- NAME` and executes the file. That gate keys on the
1476 // FILENAME, where `compinit` keys on the file's TAG LINE (`compinit`
1477 // sh:533-548 — `read -rA _i_line < $_i_file`, then `case $_i_tag in
1478 // (\#compdef) compdef -na … ;; (\#autoload) autoload -rUz … ;; esac`), so
1479 // an UNTAGGED `_`-file is registered by neither shell yet run by zshrs.
1480 //
1481 // The user-visible cost is a SWALLOWED DIAGNOSTIC. On this host
1482 // `~/.zpwr/autoload/comp_utils` holds an untagged `__fasd_files_comp`, and
1483 // the user's `_files` sh:167-169 offers it as an `_alternative` action.
1484 // After `compinit` both shells agree it is not a function
1485 // (`${+functions[__fasd_files_comp]}` == 0, while `${+functions[_cat]}`
1486 // == 1 because `_cat` carries `#compdef`), yet on `cat zzzzqqq<TAB>`:
1487 // zsh _alternative:71: command not found: __fasd_files_comp
1488 // _alternative:71: command not found: __fasd_dirs_comp
1489 // zshrs (nothing at all)
1490 // because zshrs ran the file instead of diagnosing the name. A completion
1491 // that should report an error showed silence.
1492 //
1493 // So resolve the command word the way `execcmd` does, instead of letting
1494 // dispatch invent a function out of `$fpath`. A Rust port or a plugin
1495 // override counts as resolvable: each stands in for the STOCK file of that
1496 // name, which `compinit` would have stubbed.
1497 // c:3105-3109 `is_shfunc = 1` → c:4157 `if (is_shfunc) { … doshfunc … }`.
1498 // `dispatch_function_call` IS that `shfunctab->getnode` test now: it
1499 // resolves a real definition, an autoload stub, a Rust port, a plugin
1500 // override or a `_regex_arguments` registration, and returns `None` for
1501 // everything else — since fba82d9a40 it no longer invents a function out
1502 // of `$fpath` (src/vm_helper.rs). Ask it directly and fall through to
1503 // builtin → `$PATH` → not-found on `None`.
1504 //
1505 // The pre-gate this replaces was not merely redundant, it was a false
1506 // NEGATIVE: `_regex_arguments`-registered names are real `eval`-defined
1507 // shell functions in zsh, but in zshrs they live in a registry
1508 // (`vm_helper.rs`) that `function_exists` / `getshfunc` cannot see, so the
1509 // gate reported `command not found` for a name zsh runs.
1510 if let Some(rc) = crate::ported::exec::dispatch_function_call(cmd, argv) {
1511 return rc;
1512 }
1513
1514 // c:Src/exec.c:756-766 — in `execute()` a command word containing `/` is
1515 // exec'd directly, and when it is absolute, `./`/`../`-relative or
1516 // PATHDIRS is unset the FAILED exec is reported with its errno, never as
1517 // `command not found`:
1518 // if (arg0 == s || unset(PATHDIRS) || (arg0[0] == '.' && …)) {
1519 // zerr("%e: %s", lerrno, arg0);
1520 // _exit((lerrno == EACCES || lerrno == ENOEXEC) ? 126 : 127); }
1521 // `_nfs-cat`'s spec `'1:nfs:// URL:'` makes `// URL:` the action, and zsh
1522 // prints `_arguments:465: permission denied: //` where this printed
1523 // `command not found: //`. No builtin name contains a `/`, so this sits
1524 // ahead of the builtin / `$PATH` arm exactly as c:756 sits ahead of the
1525 // path search.
1526 if let Some(slash) = cmd.find('/') {
1527 use crate::ported::zsh_h::{isset, PATHDIRS};
1528 let dot = cmd.starts_with('.') && (slash == 1 || (cmd.starts_with("..") && slash == 2));
1529 if slash == 0 || !isset(PATHDIRS) || dot {
1530 let lerrno = direct_exec_errno(cmd);
1531 if lerrno == 0 {
1532 // An executable file: real, but with no execution route from
1533 // here — the same answer the `$PATH` arm below gives.
1534 return 1;
1535 }
1536 let _subsh = crate::ported::exec::SubshStateGuard::enter(); // c:1247-1248
1537 crate::ported::utils::zwarn(&format!(
1538 "{}: {}",
1539 crate::ported::utils::zsh_errno_msg(lerrno),
1540 cmd
1541 )); // c:762
1542 return if lerrno == libc::EACCES || lerrno == libc::ENOEXEC { 126 } else { 127 }; // c:763
1543 }
1544 }
1545
1546 // Neither a shell function nor a registered port. zsh looks for a builtin
1547 // and then for an executable on `$PATH`; only when both miss does it
1548 // report the command as not found.
1549 if crate::ported::builtin::createbuiltintable().contains_key(cmd)
1550 || crate::ported::exec::findcmd(cmd, 0, 0).is_some()
1551 {
1552 return 1;
1553 }
1554
1555 // c:Src/exec.c:820-833 — the `$path` walk `execute()` runs before it can
1556 // say `command not found`: every `dir/arg0` is exec'd and a failure
1557 // `isgooderr` accepts (c:613-621) is kept as `eno`; c:836-837 then
1558 // reports THAT errno instead. The empty command word (`_arguments`
1559 // sh:465 `"$action[1]"` for an empty action) makes each candidate the
1560 // directory itself, EACCES, so zsh prints `permission denied: `; so
1561 // does a non-executable file of that name on `$path`.
1562 let mut eno = 0;
1563 for pp in crate::ported::params::getsparam("PATH").unwrap_or_default().split(':') {
1564 let candidate = if pp.is_empty() || pp == "." {
1565 cmd.to_string() // c:821-822
1566 } else {
1567 format!("{}/{}", pp, cmd) // c:826-830
1568 };
1569 let ee = direct_exec_errno(&candidate);
1570 if ee == 0 {
1571 // A program the exec would start (see the slash arm above).
1572 return 1;
1573 }
1574 if crate::ported::exec::isgooderr(ee, pp) {
1575 eno = ee; // c:823-824 / c:831-832
1576 }
1577 }
1578 if eno != 0 {
1579 let _subsh = crate::ported::exec::SubshStateGuard::enter(); // c:1247-1248
1580 crate::ported::utils::zwarn(&format!(
1581 "{}: {}",
1582 crate::ported::utils::zsh_errno_msg(eno),
1583 cmd
1584 )); // c:837
1585 return if eno == libc::EACCES || eno == libc::ENOEXEC { 126 } else { 127 }; // c:842
1586 }
1587
1588 // c:Src/exec.c:903 — `zerr("command not found: %s", arg0);`
1589 //
1590 // The FORKED CHILD is not just about `errflag`. Before `execute()`
1591 // ever reaches c:903 the child has run `entersubsh()`, whose last
1592 // two scalar deltas are `opts[USEZLE] = 0; zleactive = 0;`
1593 // (Src/exec.c:1247-1248). `zwarning` opens with
1594 // `if (isatty(2)) zleentry(ZLE_CMD_TRASH);` (Src/utils.c:144-145)
1595 // and `trashzle` is gated on `if (zleactive && !trashedzle)`
1596 // (Src/Zle/zle_main.c:2071), so in C that hook does NOTHING here:
1597 // no `zrefresh`, no `moveto(nlnct, 0)`, no `resetneeded = 1`. The
1598 // diagnostic lands wherever ZLE left the cursor — appended to the
1599 // command line's own row — and the editor display is not repainted.
1600 //
1601 // zshrs runs this in the live shell with no fork, so without the
1602 // guard `zleactive` is still 1, `trashzle` fires, and its
1603 // `moveto(nlnct, 0)` writes `\r` + `\n` before the text while
1604 // `resetneeded` makes the following `zrefresh` repaint the prompt
1605 // after it. Measured against zsh on `_alternative 'x:x: nosuchcmd'`:
1606 // zsh : row 0 `$ true _alternative:63: command not found: …`
1607 // zshrs: row 0 `$ true`, row 1 the diagnostic, row 2 the prompt again
1608 // — the same text, three rows instead of one.
1609 //
1610 // `SubshStateGuard` (exec.rs) is the ported stand-in for exactly
1611 // those `entersubsh` deltas that are correct without a fork, and it
1612 // restores them on drop.
1613 let _subsh = crate::ported::exec::SubshStateGuard::enter(); // c:1247-1248
1614 crate::ported::utils::zwarn(&format!("command not found: {}", cmd)); // c:903
1615 127 // c:908 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127)`
1616}
1617
1618/// !!! WARNING: RUST-ONLY HELPER !!!
1619///
1620/// The errno c:Src/exec.c:758's `zexecve(arg0, …)` would fail with, worked
1621/// out WITHOUT exec'ing: `dispatch_action_command` runs in the live shell and
1622/// has no fork to exec in. 0 means the exec would start a program. execve(2)
1623/// fails ENOENT/ENOTDIR/EACCES on the path walk and X_OK test that
1624/// `access(2)` performs, and EACCES on a directory, which `access` accepts.
1625fn direct_exec_errno(path: &str) -> i32 {
1626 let Ok(c) = std::ffi::CString::new(path) else {
1627 return libc::ENOENT;
1628 };
1629 if unsafe { libc::access(c.as_ptr(), libc::X_OK) } != 0 {
1630 return std::io::Error::last_os_error().raw_os_error().unwrap_or(libc::ENOENT);
1631 }
1632 match std::fs::metadata(path) {
1633 Ok(m) if m.is_file() => 0,
1634 Ok(_) => libc::EACCES,
1635 Err(e) => e.raw_os_error().unwrap_or(libc::ENOENT),
1636 }
1637}
1638
1639// =====================================================================
1640// `$( <builtin> )` — capturing a builtin's stdout without a subshell.
1641// =====================================================================
1642
1643/// !!! WARNING: RUST-ONLY HELPER !!!
1644///
1645/// The `$( … )` around a BUILTIN, for the ports whose upstream source
1646/// reads a builtin's stdout: `_limits` sh:5 `$(limit)`, `_parameters`
1647/// sh:34 `$( typeset -m … )`, `_correct_filename` sh:60
1648/// `$(whence -wm …)`, `_user_math_func` sh:6 `$(functions -M)`.
1649///
1650/// C has no counterpart because C never needs one — a shell has no way
1651/// to reach a builtin's stdout EXCEPT a command substitution, so
1652/// upstream pays for a fork and a full parse to read a table the same
1653/// process already holds. `exec::run_command_substitution` reproduces
1654/// that faithfully, including the deep clone of all shell state that
1655/// every real `$( … )` performs; on a completer that runs per keystroke
1656/// the clone is the entire cost. This runs the builtin in-process with
1657/// fd 1 pointed at a scratch file instead, which reaches the identical
1658/// bytes.
1659///
1660/// It exists so there is ONE of these. Two hand-rolled copies had
1661/// already appeared (`_parameters`, then `_correct_filename` explicitly
1662/// copying it), and each got to decide independently whether to restore
1663/// fd 1 on the failure path, whether to flush first, and whether to
1664/// delete the scratch file.
1665///
1666/// A temp FILE, not a pipe: these listings are unbounded (`typeset -m`
1667/// over `$PATH`/`$LS_COLORS`, `whence -m` over a large `$PATH`) and a
1668/// pipe would deadlock the moment the output outgrew its 64K buffer
1669/// with nobody draining the read end.
1670///
1671/// `discard_stderr` is the `2>/dev/null` some of those call sites write.
1672///
1673/// Returns what the builtin printed, or the empty string if the scratch
1674/// file cannot be made or fd 1 cannot be redirected — in which case the
1675/// builtin is NOT run, so a failure here adds nothing wrong, it only
1676/// leaves the caller with no candidates.
1677pub fn capture_builtin_stdout(discard_stderr: bool, run: impl FnOnce()) -> String {
1678 let (fd, path) = match crate::ported::utils::gettempfile(None) {
1679 Some(t) => t,
1680 None => return String::new(),
1681 };
1682 // Flush FIRST: `println!` writes through a `LineWriter` over fd 1, and
1683 // anything still buffered from before the redirect would otherwise land
1684 // in the capture instead of on the terminal.
1685 let _ = std::io::Write::flush(&mut std::io::stdout());
1686 let saved_out = unsafe { libc::dup(1) };
1687 let saved_err = if discard_stderr {
1688 unsafe { libc::dup(2) }
1689 } else {
1690 -1
1691 };
1692 let devnull = if discard_stderr {
1693 unsafe { libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY) }
1694 } else {
1695 -1
1696 };
1697 let redirected = saved_out >= 0 && unsafe { libc::dup2(fd, 1) } >= 0;
1698 if redirected {
1699 let err_redirected =
1700 devnull >= 0 && saved_err >= 0 && unsafe { libc::dup2(devnull, 2) } >= 0;
1701 run();
1702 let _ = std::io::Write::flush(&mut std::io::stdout());
1703 unsafe {
1704 libc::dup2(saved_out, 1);
1705 }
1706 if err_redirected {
1707 unsafe {
1708 libc::dup2(saved_err, 2);
1709 }
1710 }
1711 }
1712 for f in [saved_out, saved_err, devnull, fd] {
1713 if f >= 0 {
1714 unsafe {
1715 libc::close(f);
1716 }
1717 }
1718 }
1719 let text = if redirected {
1720 std::fs::read_to_string(&path).unwrap_or_default()
1721 } else {
1722 String::new()
1723 };
1724 let _ = std::fs::remove_file(&path);
1725 text
1726}
1727
1728#[cfg(test)]
1729mod capture_builtin_stdout_tests {
1730 use super::*;
1731
1732 /// RUST-ONLY test scaffold: write straight to the fd. A ported builtin
1733 /// reaches fd 1 through `println!`, but libtest replaces the `print!`
1734 /// macros' sink (`std::io::set_output_capture`), so a `println!` here
1735 /// would never reach the redirect and the test would measure libtest
1736 /// rather than this function.
1737 fn write_fd(fd: i32, s: &str) {
1738 unsafe {
1739 libc::write(fd, s.as_ptr() as *const libc::c_void, s.len());
1740 }
1741 }
1742
1743 /// The captured text is what the builtin printed, and fd 1 is the
1744 /// caller's again afterwards.
1745 #[test]
1746 fn captures_stdout_and_restores_fd1() {
1747 let _g = crate::test_util::global_state_lock();
1748
1749 let text = capture_builtin_stdout(false, || {
1750 write_fd(1, "one\ntwo\n");
1751 });
1752
1753 assert_eq!(text, "one\ntwo\n");
1754 // fd 1 still writes somewhere valid — a leaked redirect shows up
1755 // as an EBADF here, and as vanished output for the rest of the
1756 // process.
1757 assert!(
1758 unsafe { libc::fcntl(1, libc::F_GETFD) } >= 0,
1759 "fd 1 was not restored"
1760 );
1761 }
1762
1763 /// `2>/dev/null` on the call site must keep stderr OUT of the capture
1764 /// and must not leave fd 2 pointing at `/dev/null` afterwards.
1765 #[test]
1766 fn discard_stderr_leaves_fd2_usable() {
1767 let _g = crate::test_util::global_state_lock();
1768 let text = capture_builtin_stdout(true, || {
1769 write_fd(1, "out\n");
1770 write_fd(2, "this must not be captured\n");
1771 });
1772 assert_eq!(text, "out\n");
1773 assert!(
1774 unsafe { libc::fcntl(2, libc::F_GETFD) } >= 0,
1775 "fd 2 was not restored"
1776 );
1777 }
1778}
1779
1780#[cfg(test)]
1781mod lineno_scope_tests {
1782 use super::*;
1783 use crate::ported::lex::{lineno, set_lineno};
1784
1785 /// `zerrmsg` prints the line only when it is non-zero
1786 /// (`Src/utils.c:301` — `&& lineno`), so a port body must start at 0:
1787 /// an un-annotated statement has to report NO line rather than inherit
1788 /// the caller's, which belongs to a different file.
1789 #[test]
1790 fn getopts_loop_clusters_and_recovers_like_bin_getopts() {
1791 let _g = crate::test_util::global_state_lock();
1792 let run = |args: &[&str], optstring: &str| {
1793 let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
1794 let mut seen: Vec<String> = Vec::new();
1795 let n = getopts_loop(&args, optstring, 7, |opt, optarg| {
1796 seen.push(format!("{}={}", opt, optarg.unwrap_or("")));
1797 });
1798 (n, seen)
1799 };
1800 // A nested `_alternative` action gets `$expl[@]`: `-J` and every
1801 // letter of `-default-` are invalid options, reported and skipped,
1802 // and the first spec word ends the options (OPTIND-1 == 2).
1803 let (n, seen) = run(&["-J", "-default-", "t:d:(a b)"], "O:C:");
1804 assert_eq!(n, 2);
1805 assert_eq!(seen.len(), 9, "{:?}", seen);
1806 assert!(seen.iter().all(|s| s == "?="));
1807 // Attached and separated arguments; `--` is consumed.
1808 let (n, seen) = run(&["-Oargs", "-C", "ctx", "--", "-"], "O:C:");
1809 assert_eq!(n, 4);
1810 assert_eq!(seen, ["O=args", "C=ctx"]);
1811 // `+x` is an option outside POSIX_BUILTINS and stores `+x`; a
1812 // missing argument is `?` and ends the walk.
1813 let (n, seen) = run(&["+x", "-t"], "t:x");
1814 assert_eq!(n, 2);
1815 assert_eq!(seen, ["+x=", "?="]);
1816 // `-ot-` clusters: `o`, then `t` swallows the rest of the word.
1817 let (n, seen) = run(&["-ot-", "rest"], "oOt:12JVx");
1818 assert_eq!(n, 1);
1819 assert_eq!(seen, ["o=", "t=-"]);
1820 }
1821
1822 #[test]
1823 fn fn_scope_zeroes_lineno_for_the_port_body() {
1824 let _g = crate::test_util::global_state_lock();
1825 set_lineno(218); // caller mid-body, e.g. _main_complete sh:218
1826 {
1827 let _s = FnScope::enter("_describe");
1828 assert_eq!(lineno(), 0, "port body must start with no known line");
1829 }
1830 }
1831
1832 /// `execlist` restores the caller's line when a body finishes
1833 /// (`Src/exec.c:1429` / `Src/exec.c:1696`), which is what keeps a
1834 /// nested call from renumbering its caller's diagnostics.
1835 #[test]
1836 fn fn_scope_restores_the_callers_lineno_on_exit() {
1837 let _g = crate::test_util::global_state_lock();
1838 set_lineno(218);
1839 {
1840 let _s = FnScope::enter("_describe");
1841 set_sh_lineno(129);
1842 assert_eq!(lineno(), 129);
1843 }
1844 assert_eq!(lineno(), 218, "caller's line must survive the port call");
1845 }
1846
1847 /// Nested ports each restore their own caller, so `_describe` calling
1848 /// `_tags` leaves `_describe`'s line intact for the statement after it.
1849 #[test]
1850 fn nested_fn_scopes_unwind_to_the_right_line() {
1851 let _g = crate::test_util::global_state_lock();
1852 set_lineno(0);
1853 let outer = FnScope::enter("_describe");
1854 set_sh_lineno(122);
1855 {
1856 let _inner = FnScope::enter("_tags");
1857 assert_eq!(lineno(), 0);
1858 set_sh_lineno(36); // _tags sh:36 — comptags "-i$prev" …
1859 assert_eq!(lineno(), 36);
1860 }
1861 assert_eq!(lineno(), 122, "_describe's line must survive _tags");
1862 drop(outer);
1863 assert_eq!(lineno(), 0);
1864 }
1865}
1866
1867#[cfg(test)]
1868mod diagnostic_framing_tests {
1869 use std::sync::atomic::Ordering;
1870
1871 /// The `command not found` diagnostic must NOT trash the line editor.
1872 ///
1873 /// `Src/exec.c:903` is reached only inside the fork `execcmd` makes for
1874 /// an external command, and that child has already run `entersubsh`,
1875 /// whose last two scalar deltas are `opts[USEZLE] = 0; zleactive = 0;`
1876 /// (`Src/exec.c:1247-1248`). `zwarning` opens with
1877 /// `if (isatty(2)) zleentry(ZLE_CMD_TRASH);` (`Src/utils.c:144-145`)
1878 /// and `trashzle` runs its body only `if (zleactive && !trashedzle)`
1879 /// (`Src/Zle/zle_main.c:2071`) — so with `zleactive` cleared the hook
1880 /// is a no-op and the diagnostic lands on the command line's own row
1881 /// with no repaint after it.
1882 ///
1883 /// The two flags asserted here are the ones `trashzle` sets on its way
1884 /// through: `trashedzle = 1` (c:2079) and `resetneeded = 1` (c:2091),
1885 /// the second of which is what makes the NEXT `zrefresh` repaint the
1886 /// prompt below the diagnostic. Either being raised is the three-row
1887 /// display zshrs used to produce where zsh produces one.
1888 ///
1889 /// `fd 2` is pointed at a pty for the duration: `isatty(2)` is false
1890 /// under `cargo test`, and with it false `zwarning` never reaches the
1891 /// hook at all — the case would pass without measuring anything.
1892 #[test]
1893 fn the_not_found_diagnostic_does_not_trash_the_line_editor() {
1894 use crate::ported::builtins::sched::zleactive;
1895 use crate::ported::init::zle_load_state;
1896 use crate::ported::zle::zle_refresh::{RESETNEEDED, TRASHEDZLE};
1897
1898 let _g = crate::test_util::global_state_lock();
1899
1900 let mut master: libc::c_int = 0;
1901 let mut slave: libc::c_int = 0;
1902 let rc = unsafe {
1903 libc::openpty(
1904 &mut master,
1905 &mut slave,
1906 std::ptr::null_mut(),
1907 std::ptr::null_mut::<libc::termios>(),
1908 std::ptr::null_mut::<libc::winsize>(),
1909 )
1910 };
1911 assert_eq!(rc, 0, "openpty failed; the probe needs a terminal on fd 2");
1912 let saved_stderr = unsafe { libc::dup(2) };
1913 assert!(saved_stderr >= 0, "dup(2) failed");
1914 assert!(
1915 unsafe { libc::dup2(slave, 2) } >= 0,
1916 "dup2 onto fd 2 failed"
1917 );
1918 assert_eq!(
1919 unsafe { libc::isatty(2) },
1920 1,
1921 "fd 2 must be a terminal or Src/utils.c:144 skips the hook"
1922 );
1923
1924 let saved = (
1925 zleactive.load(Ordering::Relaxed),
1926 zle_load_state.load(Ordering::SeqCst),
1927 TRASHEDZLE.load(Ordering::Relaxed),
1928 RESETNEEDED.load(Ordering::Relaxed),
1929 );
1930 // `zle_load_state == 1` is "the module is loaded", the state in
1931 // which `zleentry` forwards to `zle_main_entry` (Src/init.c:1777)
1932 // rather than to the no-ZLE fallback. An interactive shell running
1933 // a completer is always in it.
1934 zleactive.store(1, Ordering::Relaxed);
1935 zle_load_state.store(1, Ordering::SeqCst);
1936 TRASHEDZLE.store(0, Ordering::Relaxed);
1937 RESETNEEDED.store(0, Ordering::Relaxed);
1938 crate::ported::utils::errflag.store(0, Ordering::Relaxed);
1939
1940 let status = super::dispatch_action_command("nosuchcmd_zz_framing_probe", &[], 63);
1941
1942 let trashed = TRASHEDZLE.load(Ordering::Relaxed);
1943 let reset = RESETNEEDED.load(Ordering::Relaxed);
1944 let still_active = zleactive.load(Ordering::Relaxed);
1945
1946 zleactive.store(saved.0, Ordering::Relaxed);
1947 zle_load_state.store(saved.1, Ordering::SeqCst);
1948 TRASHEDZLE.store(saved.2, Ordering::Relaxed);
1949 RESETNEEDED.store(saved.3, Ordering::Relaxed);
1950 crate::ported::utils::errflag.store(0, Ordering::Relaxed);
1951 unsafe {
1952 libc::dup2(saved_stderr, 2);
1953 libc::close(saved_stderr);
1954 libc::close(slave);
1955 libc::close(master);
1956 }
1957
1958 assert_eq!(status, 127, "c:908 — a name that resolves nowhere is 127");
1959 assert_eq!(
1960 trashed, 0,
1961 "trashzle ran: the diagnostic moved the cursor off the command \
1962 line's row (Src/Zle/zle_main.c:2071 is false in C's forked child)"
1963 );
1964 assert_eq!(
1965 reset, 0,
1966 "resetneeded was raised: the next zrefresh repaints the prompt \
1967 BELOW the diagnostic, which zsh never does here"
1968 );
1969 assert_eq!(
1970 still_active, 1,
1971 "the entersubsh stand-in must restore zleactive when it drops"
1972 );
1973 }
1974}
1975
1976#[cfg(test)]
1977mod zstyle_bool_tests {
1978 use super::{empty_ops, zstyle_T, zstyle_t};
1979
1980 const CTX: &str = ":completion:zstyle-bool-probe:zstyle-bool-probe:";
1981
1982 fn set_style(value: &str) {
1983 crate::ported::modules::zutil::bin_zstyle(
1984 "zstyle",
1985 &[CTX.to_string(), "boolprobe".to_string(), value.to_string()],
1986 &empty_ops(),
1987 0,
1988 );
1989 }
1990
1991 fn del_style() {
1992 crate::ported::modules::zutil::bin_zstyle(
1993 "zstyle",
1994 &["-d".to_string(), CTX.to_string(), "boolprobe".to_string()],
1995 &empty_ops(),
1996 0,
1997 );
1998 }
1999
2000 /// `Src/Modules/zutil.c:701-724` — the `-t`/`-T` arm reads the style's
2001 /// FIRST VALUE. `testforstyle` (c:465), which backs `zstyle -q` (c:749-756),
2002 /// answers only "is this style defined" and returns the same thing for
2003 /// `boolprobe yes` and `boolprobe 0`. Every compsys port that tested a
2004 /// boolean style through it therefore took the TRUE branch for a style the
2005 /// user had explicitly turned OFF.
2006 #[test]
2007 fn value_decides_the_exit_not_mere_definition() {
2008 let _g = crate::test_util::global_state_lock();
2009 del_style();
2010
2011 // c:724 — `return (args[0][1] == 't' ? (vals ? 1 : 2) : 0);`
2012 assert_eq!(zstyle_t(CTX, "boolprobe"), 2, "-t, no pattern matched → 2");
2013 assert_eq!(zstyle_T(CTX, "boolprobe"), 0, "-T, no pattern matched → 0");
2014
2015 // c:719-722 — the four boolean-true spellings.
2016 for v in ["true", "yes", "on", "1"] {
2017 del_style();
2018 set_style(v);
2019 assert_eq!(zstyle_t(CTX, "boolprobe"), 0, "-t on `{v}` must be 0");
2020 assert_eq!(zstyle_T(CTX, "boolprobe"), 0, "-T on `{v}` must be 0");
2021 }
2022
2023 // c:719-722 — anything else is FALSE for both letters. This is the
2024 // whole defect class: `testforstyle` answered 0 ("defined") for each
2025 // of these, so the ports ran the true branch.
2026 // An ARBITRARY non-boolean value is FALSE for both letters too —
2027 // `-T` is "true unless it is set to something that is not true", NOT
2028 // "true unless it is set to one of the four false-y words". Five
2029 // private `zstyle_t_default_true` copies had spelled it
2030 // `!matches!(first, "no"|"false"|"off"|"0")`, which answers TRUE here;
2031 // `_describe`'s copy of the same-named helper answered FALSE, and the
2032 // disagreement between two bodies under one name is how it surfaced.
2033 for v in ["maybe", "2", "yes-ish", "-1"] {
2034 del_style();
2035 set_style(v);
2036 assert_eq!(
2037 zstyle_t(CTX, "boolprobe"),
2038 1,
2039 "-t on `{v}` must be 1 — only true/yes/on/1 are true"
2040 );
2041 assert_eq!(
2042 zstyle_T(CTX, "boolprobe"),
2043 1,
2044 "-T on `{v}` must be 1 — a set-but-not-true value is FALSE"
2045 );
2046 }
2047
2048 // Set with NO values is a distinct case from unset, and both are
2049 // `-T` true (c:724 `vals ? 1 : 2` for -t; the -T arm returns 0).
2050 del_style();
2051 crate::ported::modules::zutil::bin_zstyle(
2052 "zstyle",
2053 &[CTX.to_string(), "boolprobe".to_string()],
2054 &empty_ops(),
2055 0,
2056 );
2057 assert_eq!(zstyle_T(CTX, "boolprobe"), 0, "-T on a valueless style → 0");
2058
2059 for v in ["false", "no", "off", "0"] {
2060 del_style();
2061 set_style(v);
2062 assert_eq!(
2063 zstyle_t(CTX, "boolprobe"),
2064 1,
2065 "-t on `{v}` must be 1, not 0 — the style is OFF"
2066 );
2067 assert_eq!(
2068 zstyle_T(CTX, "boolprobe"),
2069 1,
2070 "-T on `{v}` must be 1, not 0 — the style is OFF"
2071 );
2072 // The defect itself, pinned: the primitive the ports used
2073 // answers the SAME thing here as it does for `yes`. Every
2074 // converted site spelled its test `testforstyle(…) == 0`, so
2075 // every one of them ran the true branch for a style the user
2076 // had switched off.
2077 assert_eq!(
2078 crate::ported::modules::zutil::testforstyle(CTX, "boolprobe"),
2079 0,
2080 "`testforstyle` reports `{v}` as TRUE — it is zstyle -q's \
2081 primitive (zutil.c:465/749-756) and never reads the value"
2082 );
2083 }
2084
2085 del_style();
2086 }
2087}
2088
2089/// Keys of `$functions` whose name starts with `prefix` — the scan behind
2090/// every upstream `${(k)functions[(I)<prefix>*]}` / `${(k)functions:#^<prefix>*}`.
2091///
2092/// The upstream spelling matters: those subscripts go through the
2093/// `zsh/parameter` module's own scan, and that scan is the NOT-DISABLED half
2094/// of `shfunctab` (c:Src/Modules/parameter.c:482):
2095///
2096/// ```c
2097/// if (dis ? (hn->flags & DISABLED) : !(hn->flags & DISABLED)) {
2098/// ```
2099///
2100/// `$functions` is the `dis == 0` caller (`scanpmfunctions`, c:530-534);
2101/// `$dis_functions` is the other one (c:537-541). A port that walks
2102/// `shfunctab` itself without that test offers names `disable -f` has taken
2103/// out of `$functions`, which is the whole reason this lives in one place:
2104/// three call sites want this scan (`_zcalc_line`, `_email_addresses`,
2105/// `_vcs_info_hooks`) and each one that hand-rolled it was one more chance
2106/// to drop the filter.
2107///
2108/// Returns the FULL names in `shfunctab` order — the prefix strip differs
2109/// per call site (`##zsh_math_func_` is literal, `#*-` cuts at the first
2110/// dash) so it stays with the caller, and no sort is applied because the
2111/// upstream subscripts do not sort either.
2112pub fn shfunc_names_with_prefix(prefix: &str) -> Vec<String> {
2113 let Ok(tab) = crate::ported::hashtable::shfunctab_lock().read() else {
2114 return Vec::new();
2115 };
2116 tab.iter()
2117 // c:Src/Modules/parameter.c:482 — `$functions` is the NOT-DISABLED half.
2118 .filter(|(_, f)| (f.node.flags & crate::ported::zsh_h::DISABLED) == 0)
2119 .filter(|(k, _)| k.starts_with(prefix))
2120 .map(|(k, _)| k.clone())
2121 .collect()
2122}
2123
2124/// `$name[key]` for an ASSOCIATIVE parameter, including the `zsh/parameter`
2125/// magic hashes (`aliases`, `galiases`, `dis_aliases`, `dis_galiases`,
2126/// `userdirs`, `nameddirs`, `commands`, …).
2127///
2128/// `getaparam` is the wrong accessor for these and returns `None`, never an
2129/// empty result that would be visible as such: C's `getaparam` bails unless
2130/// `PM_TYPE(v->pm->node.flags) == PM_ARRAY` (c:Src/params.c:3108), and every
2131/// magic hash is `PM_HASHED`. The port mirrors that at
2132/// `src/ported/params.rs:6334`, so a hand-rolled
2133/// `getaparam(name)?.chunks(2)` helper silently reads EVERY such hash as
2134/// absent — the completer then takes its "no such key" branch always.
2135/// `gethkparam`/`gethparam` (c:Src/params.c:3117/3131) are the hash
2136/// accessors and dispatch through the module's own scan.
2137///
2138/// The flat key/value fallback stays for assocs a port staged with
2139/// `setaparam`, which do arrive as PM_ARRAY.
2140pub fn assoc_get(name: &str, key: &str) -> Option<String> {
2141 let keys = crate::ported::params::gethkparam(name).unwrap_or_default();
2142 if !keys.is_empty() {
2143 let vals = crate::ported::params::gethparam(name).unwrap_or_default();
2144 return keys
2145 .iter()
2146 .position(|k| k == key)
2147 .and_then(|i| vals.get(i).cloned());
2148 }
2149 crate::ported::params::getaparam(name)
2150 .unwrap_or_default()
2151 .chunks(2)
2152 .find(|kv| kv.first().map(|k| k == key).unwrap_or(false))
2153 .and_then(|kv| kv.get(1).cloned())
2154}
2155
2156/// `${(Q)s}` — remove one level of shell quoting from `s`.
2157///
2158/// Faithful port of the scalar `(Q)` arm of `paramsubst`'s quote block,
2159/// c:Src/subst.c:4136-4153: `parse_subst_string` to re-lex the word,
2160/// `remnulargs` to drop the null markers that leaves behind, `untokenize`
2161/// to turn the tokens back into text.
2162///
2163/// Every port that needs `(Q)` calls THIS. Hand-rolled "drop every quote
2164/// character" walkers get the common case right and the rest wrong: they
2165/// delete an unbalanced `'`/`"` that the C path keeps as a literal, and
2166/// they do not decode `$'...'`.
2167pub fn dequote_q(s: &str) -> String {
2168 use std::sync::atomic::Ordering;
2169 // c:4137 `int one = noerrs, oef = errflag, haserr;`
2170 let one = *crate::ported::utils::noerrs_lock().lock().unwrap();
2171 let oef = crate::ported::utils::errflag.load(Ordering::Relaxed);
2172 // c:4139-4140 `if (!quoteerr) noerrs = 1;`. Without this the re-lex
2173 // REPORTS a malformed word — `zshrs: 1: unmatched "` on the terminal
2174 // mid-completion — where zsh swallows it. `(X)` is the flag that sets
2175 // `quoteerr` and asks for the diagnostic, and no caller here passes it.
2176 *crate::ported::utils::noerrs_lock().lock().unwrap() = 1;
2177 // c:4141 `haserr = parse_subst_string(val);`
2178 let parsed = crate::ported::lex::parse_subst_string(s);
2179 // c:4142 `noerrs = one;`
2180 *crate::ported::utils::noerrs_lock().lock().unwrap() = one;
2181 // c:4143-4146 — "Retain any user interrupt error status", and drop
2182 // everything the parse raised. Leaving a parse error set here would
2183 // abort the completer that called us, several frames up. The
2184 // `else if (haserr || errflag)` arm at c:4147 belongs to `quoteerr`
2185 // and is unreachable with `noerrs` on.
2186 let int_bit =
2187 crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_INT;
2188 crate::ported::utils::errflag.store(oef | int_bit, Ordering::Relaxed);
2189 match parsed {
2190 Ok(mut r) => {
2191 crate::ported::glob::remnulargs(&mut r); // c:4151
2192 crate::ported::lex::untokenize(&r) // c:4152
2193 }
2194 // C parses IN PLACE and keeps whatever the failed parse left in
2195 // `val`; the port's `parse_subst_string` hands back an Err instead,
2196 // so the untouched input is what survives.
2197 Err(_) => s.to_string(),
2198 }
2199}
2200
2201/// `${~spec}` for a word that starts with `~`: tilde expansion of `~`, `~/…`,
2202/// `~user`, `~N`, `~-`, `~+` through the ported `filesubstr`
2203/// (c:Src/subst.c `filesubstr`), which wants the leading `~` as its Tilde
2204/// token. `None` when the expansion fails (an unknown user), the case
2205/// `eval "x=~user/" 2>/dev/null` leaves empty.
2206pub fn tilde_expand(spec: &str) -> Option<String> {
2207 let rest = spec.strip_prefix('~')?;
2208 crate::ported::subst::filesubstr(&format!("\u{e198}{}", rest), false)
2209}
2210
2211/// `setopt localoptions` — the whole option table is snapshotted on entry and
2212/// put back when the guard drops, so an option the function flips (`setopt
2213/// nonomatch`, `emulate -R zsh`) cannot outlive it on any exit path.
2214pub struct LocalOptions {
2215 opts: std::collections::HashMap<String, bool>,
2216 emulation: i32,
2217 emulation_bits: i32,
2218 fully: bool,
2219 dash: bool,
2220}
2221
2222impl LocalOptions {
2223 pub fn enter() -> Self {
2224 use std::sync::atomic::Ordering::Relaxed;
2225 Self {
2226 opts: crate::ported::options::opt_state_snapshot(),
2227 emulation: crate::ported::options::emulation.load(Relaxed),
2228 emulation_bits: crate::ported::options::EMULATION.load(Relaxed),
2229 fully: crate::ported::options::FULLY_EMULATING.load(Relaxed),
2230 dash: crate::extensions::dash_mode::dash_strict(),
2231 }
2232 }
2233}
2234
2235impl Drop for LocalOptions {
2236 fn drop(&mut self) {
2237 use std::sync::atomic::Ordering::Relaxed;
2238 crate::ported::options::opt_state_restore(std::mem::take(&mut self.opts));
2239 crate::ported::options::emulation.store(self.emulation, Relaxed);
2240 crate::ported::options::EMULATION.store(self.emulation_bits, Relaxed);
2241 crate::ported::options::FULLY_EMULATING.store(self.fully, Relaxed);
2242 crate::extensions::dash_mode::set_dash_strict(self.dash);
2243 }
2244}
2245
2246/// `${(z)s}` — split `s` into shell words with the real lexer, keeping each
2247/// word's quoting (c:Src/subst.c:2439-2440 `shsplit = LEXFLAGS_ACTIVE`, then
2248/// `bufferwords`, c:Src/subst.c:4185-4199). `${(z):-x $pats}[2,-1]` callers
2249/// pass the whole string and drop the sentinel word themselves.
2250pub fn split_z(s: &str) -> Vec<String> {
2251 crate::ported::hist::bufferwords(s, None, crate::ported::zsh_h::LEXFLAGS_ACTIVE).0
2252}
2253
2254/// `${#<assoc>[(I)<prefix>*]}` — how many keys of the associative parameter
2255/// `name` begin with `prefix`. Same accessor story as [`assoc_get`].
2256pub fn assoc_key_count_with_prefix(name: &str, prefix: &str) -> usize {
2257 let keys = crate::ported::params::gethkparam(name).unwrap_or_default();
2258 if !keys.is_empty() {
2259 return keys.iter().filter(|k| k.starts_with(prefix)).count();
2260 }
2261 crate::ported::params::getaparam(name)
2262 .unwrap_or_default()
2263 .chunks(2)
2264 .filter_map(|kv| kv.first())
2265 .filter(|k| k.starts_with(prefix))
2266 .count()
2267}
2268
2269#[cfg(test)]
2270mod assoc_accessor_tests {
2271 use super::{assoc_get, assoc_key_count_with_prefix};
2272 use crate::ported::params::{getaparam, sethparam, unsetparam};
2273
2274 /// A PM_HASHED parameter is invisible to `getaparam` — c:Src/params.c:3108
2275 /// only yields a value for `PM_TYPE(...) == PM_ARRAY`, and the port
2276 /// mirrors that (`src/ported/params.rs:6334`). Every completer that read
2277 /// an associative parameter with `getaparam(name)?.chunks(2)` therefore
2278 /// saw EVERY key as absent and took its "no such key" branch always
2279 /// (`_expand_alias` expanded nothing, `_tilde_files` reported
2280 /// "unknown user" for every `~name/`). The accessors must be
2281 /// `gethkparam`/`gethparam` (c:3117/3131).
2282 #[test]
2283 fn reads_a_pm_hashed_parameter_that_getaparam_cannot_see() {
2284 let _g = crate::test_util::global_state_lock();
2285 const NAME: &str = "zzq_shared_assoc_probe";
2286 sethparam(
2287 NAME,
2288 vec![
2289 "alpha".to_string(),
2290 "one".to_string(),
2291 "alpine".to_string(),
2292 "two".to_string(),
2293 "beta".to_string(),
2294 "three".to_string(),
2295 ],
2296 );
2297
2298 assert!(
2299 getaparam(NAME).is_none(),
2300 "premise broken: getaparam now sees a PM_HASHED param, so this \
2301 test no longer guards anything"
2302 );
2303 assert_eq!(assoc_get(NAME, "alpha"), Some("one".to_string()));
2304 assert_eq!(assoc_get(NAME, "beta"), Some("three".to_string()));
2305 assert_eq!(assoc_get(NAME, "gamma"), None);
2306 // `${#assoc[(I)al*]}` — two keys start with `al`.
2307 assert_eq!(assoc_key_count_with_prefix(NAME, "al"), 2);
2308 assert_eq!(assoc_key_count_with_prefix(NAME, "b"), 1);
2309 assert_eq!(assoc_key_count_with_prefix(NAME, "zz"), 0);
2310
2311 unsetparam(NAME);
2312 }
2313}
2314
2315/// `(( $+builtins[<name>] ))` — is there an ENABLED builtin by this name?
2316///
2317/// Not the same question as "is the name in `builtintab`". C's
2318/// `getpmbuiltin` is `getbuiltin(ht, name, 0)`
2319/// (c:Src/Modules/parameter.c:799), which flags the Param `PM_UNSET` unless
2320/// the node exists AND passes `!(bn->node.flags & DISABLED)`
2321/// (c:Src/Modules/parameter.c:784-792) — that is what makes
2322/// `$+builtins[nosuchthing]` and `$+builtins[<disabled>]` evaluate to 0.
2323///
2324/// The port's `createbuiltintable()` is a static superset built once from
2325/// the flat `BUILTINS` slice plus every module's bintab and the zshrs-only
2326/// builtins (`src/ported/builtin.rs:147-180`), so `contains_key` answers
2327/// yes for names `$builtins` reports as unset: the module-loaded gate that
2328/// `getbuiltin` applies is the reason `zsh -f` and `zshrs -f` both print 0
2329/// for `$+builtins[chmod]`, `[stat]`, `[zpty]`, `[zselect]` while the raw
2330/// table holds all four.
2331pub fn plus_builtins(name: &str) -> bool {
2332 if name.is_empty() {
2333 return false;
2334 }
2335 match crate::ported::modules::parameter::getpmbuiltin(std::ptr::null_mut(), name) {
2336 Some(pm) => (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) == 0,
2337 None => false,
2338 }
2339}
2340
2341/// `(( $+commands[name] ))` — is `name` a hashed command?
2342///
2343/// c:Src/Modules/parameter.c:213 `getpmcommand` is the getfn behind the
2344/// `commands` special hash, which is NOT paramtab-hashed storage, so
2345/// `getaparam("commands")` cannot answer this. `getpmcommand` always returns a
2346/// param — on a miss one carrying `PM_UNSET` (c:239) — so existence is the
2347/// flag, which is what `$+` reads. The subscript fetch also loads the
2348/// `zsh/parameter` autoload stub, hence `mark_module_param_used`.
2349pub fn plus_commands(name: &str) -> bool {
2350 crate::vm_helper::mark_module_param_used("commands");
2351 crate::ported::modules::parameter::getpmcommand(std::ptr::null_mut(), name)
2352 .map(|pm| (pm.node.flags & crate::ported::zsh_h::PM_UNSET as i32) == 0)
2353 .unwrap_or(false)
2354}
2355
2356#[cfg(test)]
2357mod plus_builtins_tests {
2358 use super::plus_builtins;
2359 use crate::ported::builtin::{createbuiltintable, BUILTINS_DISABLED};
2360
2361 /// `$+builtins[X]` is 0 for a DISABLED builtin (c:Src/Modules/parameter.c:784-792
2362 /// gates on `!(bn->node.flags & DISABLED)`), while `createbuiltintable()` is
2363 /// an immutable superset that still holds the node — the disabled bit lives
2364 /// in the parallel `BUILTINS_DISABLED` set (`src/ported/builtin.rs:18595-18602`).
2365 /// Any port that answers sh's `(( $+builtins[...] ))` from the raw table
2366 /// therefore says yes where the shell says no.
2367 #[test]
2368 fn disabled_builtin_is_unset_even_though_the_raw_table_holds_it() {
2369 let _g = crate::test_util::global_state_lock();
2370 const NAME: &str = "print";
2371 assert!(plus_builtins(NAME), "premise: `print` starts enabled");
2372
2373 let re_enable = {
2374 let mut set = BUILTINS_DISABLED.lock().unwrap();
2375 set.insert(NAME.to_string())
2376 };
2377 let while_disabled = plus_builtins(NAME);
2378 let raw_table_still_has_it = createbuiltintable().contains_key(NAME);
2379 if re_enable {
2380 BUILTINS_DISABLED.lock().unwrap().remove(NAME);
2381 }
2382
2383 assert!(
2384 raw_table_still_has_it,
2385 "premise: the static table keeps the node when a builtin is disabled"
2386 );
2387 assert!(
2388 !while_disabled,
2389 "`disable print` must make `$+builtins[print]` 0 (c:parameter.c:784-792)"
2390 );
2391 assert!(plus_builtins(NAME), "`print` restored after the probe");
2392 }
2393
2394 /// The empty name is not a builtin — sh:12 reaches this with `$words[1]`
2395 /// already checked non-empty (sh:10), but `_shadow` sh:56 does not.
2396 #[test]
2397 fn empty_name_is_not_a_builtin() {
2398 let _g = crate::test_util::global_state_lock();
2399 assert!(!plus_builtins(""));
2400 }
2401}
2402
2403/// `(( $+parameters[<name>] ))` — does a SET parameter by this name exist?
2404///
2405/// `paramtab` keeps the node of an unset parameter, so `get(name).is_some()`
2406/// is not this question: `getpmparameter` flags its Param PM_UNSET unless the
2407/// real node exists AND passes `!(rpm->node.flags & PM_UNSET)`
2408/// (c:Src/Modules/parameter.c:114-115), and the scan behind
2409/// `${(k)parameters[(I)…]}` skips the same nodes outright
2410/// (`scanpmparameters`, c:138-139: `if (((Param)hn)->node.flags & PM_UNSET)
2411/// continue;`). `unset RANDOM` does not remove `RANDOM` from the table, it
2412/// flags it — `$+parameters[RANDOM]` then reads 0 on both shells (measured),
2413/// while the raw node is still there.
2414pub fn plus_parameters(name: &str) -> bool {
2415 if name.is_empty() {
2416 return false;
2417 }
2418 crate::ported::params::paramtab()
2419 .read()
2420 .map(|t| match t.get(name) {
2421 // c:114-115
2422 Some(pm) => (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) == 0,
2423 None => false,
2424 })
2425 .unwrap_or(false)
2426}
2427
2428/// `${#parameters[(I)<prefix>*]}` — how many SET parameters begin with
2429/// `prefix`. Same PM_UNSET skip as [`plus_parameters`], from the scan side
2430/// (c:Src/Modules/parameter.c:138-139).
2431pub fn parameter_count_with_prefix(prefix: &str) -> usize {
2432 crate::ported::params::paramtab()
2433 .read()
2434 .map(|t| {
2435 t.iter()
2436 .filter(|(k, pm)| {
2437 k.starts_with(prefix)
2438 // c:138-139
2439 && (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) == 0
2440 })
2441 .count()
2442 })
2443 .unwrap_or(0)
2444}
2445
2446#[cfg(test)]
2447mod plus_parameters_tests {
2448 use super::{parameter_count_with_prefix, plus_parameters};
2449 use crate::ported::params::{paramtab, setsparam, unsetparam};
2450
2451 /// An unset parameter KEEPS its `paramtab` node — `unset RANDOM` flags it
2452 /// rather than removing it — so `paramtab().get(name).is_some()` answers
2453 /// yes where `$+parameters[name]` answers 0
2454 /// (c:Src/Modules/parameter.c:114-115), and the `(I)` scan skips the same
2455 /// node (c:138-139). Measured on both shells after `unset RANDOM`:
2456 /// `$+parameters[RANDOM]` = 0 and `${#parameters[(I)RANDOM*]}` = 0.
2457 #[test]
2458 fn unset_parameter_is_absent_from_both_the_test_and_the_count() {
2459 let _g = crate::test_util::global_state_lock();
2460 const NAME: &str = "ZZQ_SHARED_PARAM_PROBE";
2461 setsparam(NAME, "value");
2462 assert!(plus_parameters(NAME), "premise: the name starts set");
2463 assert_eq!(parameter_count_with_prefix(NAME), 1);
2464
2465 unsetparam(NAME);
2466 let node_still_there = paramtab()
2467 .read()
2468 .map(|t| t.get(NAME).is_some())
2469 .unwrap_or(false);
2470 assert!(
2471 !plus_parameters(NAME),
2472 "`$+parameters[{}]` must be 0 once unset (c:parameter.c:114-115); \
2473 node still in paramtab: {}",
2474 NAME,
2475 node_still_there
2476 );
2477 assert_eq!(
2478 parameter_count_with_prefix(NAME),
2479 0,
2480 "the `(I)` scan skips PM_UNSET nodes (c:parameter.c:138-139)"
2481 );
2482 }
2483
2484 #[test]
2485 fn empty_name_is_not_a_parameter() {
2486 let _g = crate::test_util::global_state_lock();
2487 assert!(!plus_parameters(""));
2488 }
2489}
2490
2491/// `(( $+functions[<name>] ))` — is there a FUNCTION by this name?
2492///
2493/// The plain-shell half is `getpmfunction` (c:Src/Modules/parameter.c:444)
2494/// → `getfunction(ht, name, 0)` (c:388): the node has to be in `shfunctab`
2495/// AND not `DISABLED`, or the Param comes back flagged `PM_UNSET`
2496/// (c:399-438). An `autoload -Uz` stub counts — `PM_UNDEFINED` still yields
2497/// the `builtin autoload -X…` string, not `PM_UNSET` (c:401-407) — which is
2498/// what makes `$+functions[_files]` read 1 after `compinit`.
2499///
2500/// The second half is zshrs-only, and it exists because C has nothing to
2501/// arbitrate: a name the compsys router serves natively
2502/// (`compsys::router::try_rust_dispatch`, or a `zmodload -R` plugin
2503/// override) RUNS when it is called, yet leaves no `shfunctab` node behind.
2504/// Answering this question from `shfunctab` alone therefore contradicts the
2505/// shell's own dispatch. Measured with `fpath=(…5.9.2/share/zsh/functions)`
2506/// and no `compinit`, where `_shadow` has a port but no definition file:
2507///
2508/// ```text
2509/// _shadow x → rc 0 (the port ran)
2510/// _call_function rc _shadow foo → rc 1 ("no such function")
2511/// ```
2512///
2513/// Both arms are needed, in this order: `shfunctab` first so a user's own
2514/// body or an `autoload` stub answers for itself, then the router — which
2515/// already steps aside for an `$fpath` file or a defined shfunc
2516/// (`has_fpath_override` / `has_shfunc_override`, `src/compsys/router.rs:53`
2517/// and `:62`), and returns `None` outright when the backend is not `rust`.
2518/// So this never claims a name the shell would not actually run.
2519pub fn plus_functions(name: &str) -> bool {
2520 if name.is_empty() {
2521 return false;
2522 }
2523 // c:Src/Modules/parameter.c:444 → c:399-438
2524 let in_shfunctab =
2525 match crate::ported::modules::parameter::getpmfunction(std::ptr::null_mut(), name) {
2526 Some(pm) => (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) == 0,
2527 None => false,
2528 };
2529 if in_shfunctab {
2530 return true;
2531 }
2532 // zshrs-only: a natively-routed completer has no shfunctab node.
2533 crate::compsys::router::try_rust_dispatch(name).is_some()
2534 || crate::extensions::plugin_host::compfn_override(name).is_some()
2535}
2536
2537#[cfg(test)]
2538mod plus_functions_tests {
2539 use super::plus_functions;
2540
2541 /// A name the compsys router serves has no `shfunctab` node, so the
2542 /// plain `getshfunc` test reports it absent while calling it runs the
2543 /// port. `_shadow` is the cleanest witness: it landed in
2544 /// `Completion/Base/Utility/_shadow` AFTER zsh 5.9, so the reference
2545 /// tree the parity harness pins
2546 /// (`/opt/homebrew/Cellar/zsh/5.9.2/share/zsh/functions`) has no file
2547 /// for it and nothing can autoload a stub, while the port is live.
2548 #[test]
2549 fn a_routed_port_with_no_definition_file_counts_as_a_function() {
2550 let _g = crate::test_util::global_state_lock();
2551 let routed = crate::compsys::router::try_rust_dispatch("_shadow").is_some();
2552 let in_table = crate::ported::utils::getshfunc("_shadow").is_some();
2553 if !routed {
2554 // backend != rust in this build's config — nothing to assert.
2555 return;
2556 }
2557 assert!(
2558 plus_functions("_shadow"),
2559 "router serves `_shadow` (shfunctab node: {in_table}) so \
2560 `$+functions[_shadow]` must not read 0"
2561 );
2562 }
2563
2564 /// Neither arm claims a name that does not exist at all.
2565 #[test]
2566 fn unknown_name_is_not_a_function() {
2567 let _g = crate::test_util::global_state_lock();
2568 assert!(!plus_functions("_zzq_no_such_completer_xyz"));
2569 assert!(!plus_functions(""));
2570 }
2571}
2572
2573/// `[[ "$funcstack[2]" = _prefix ]]` — was this completer invoked BY
2574/// `_prefix`?
2575///
2576/// `_prefix` moves the whole `$SUFFIX` out of the way before it re-runs the
2577/// completer list — `ISUFFIX="$SUFFIX"` then `SUFFIX=''`
2578/// (`Completion/Base/Completer/_prefix` sh:18-23) — so a completer that
2579/// rebuilds the word as `$IPREFIX$PREFIX$SUFFIX$ISUFFIX` under `_prefix`
2580/// gets the suffix back that `_prefix` just removed. Every completer that
2581/// reads the word therefore special-cases this one caller
2582/// (`_expand` sh:22, `_expand_alias` sh:9, `_user_expand` sh:18).
2583///
2584/// `funcstack[1]` is the CALLEE — the completer asking the question — so the
2585/// caller is index 1 of the innermost-first list `funcstackgetfn` builds
2586/// (`c:Src/Modules/parameter.c:627`).
2587pub fn caller_is_prefix() -> bool {
2588 crate::ported::modules::parameter::funcstackgetfn(std::ptr::null_mut())
2589 .get(1)
2590 .map(|n| n == "_prefix")
2591 .unwrap_or(false)
2592}
2593
2594// =====================================================================
2595// `_expand` / `_user_expand` emit helpers
2596// =====================================================================
2597//
2598// `_user_expand` sh:87-145 is `_expand` sh:184-243 with the `${opre}`/`${pre}`
2599// keep-prefix rewrite and the `-fW $pref` taken out — upstream duplicated the
2600// block rather than factoring it. The two ports share these three pieces so
2601// they cannot drift the way two copies of `caller_is_prefix` would have.
2602
2603/// `_expand` sh:185-189 / sh:198-202 / sh:225-229, `_user_expand`
2604/// sh:88-92 / sh:101-105 / sh:127-131 — the same `_description` call, with
2605/// `-V` (keep insertion order) unless the `sort` style asked for a menu.
2606pub fn expansion_description_args(sort: &str, tag: &str, descr: &str, word: &str) -> Vec<String> {
2607 let mut args: Vec<String> = Vec::new();
2608 if sort != "menu" {
2609 args.push("-V".to_string());
2610 }
2611 args.push(tag.to_string());
2612 args.push("expl".to_string());
2613 args.push(descr.to_string());
2614 args.push(format!("o:{}", word));
2615 args
2616}
2617
2618/// `_expand` sh:218-220 — `compadd "$expl[@]" -fW "$pref" -UQ -qS <suf> -a <array>`.
2619///
2620/// `_user_expand` sh:120-122 is the same call WITHOUT `-fW "$pref"`: it never
2621/// computes a `$pref`, because a user expansion is an arbitrary string and not
2622/// necessarily a path that `-W` could stat against. `pref: None` is that call.
2623pub fn expansion_partition_argv(
2624 expl: &[String],
2625 pref: Option<&str>,
2626 suf: &str,
2627 array: &str,
2628) -> Vec<String> {
2629 let mut argv: Vec<String> = expl.to_vec();
2630 if let Some(pref) = pref {
2631 argv.push("-fW".to_string());
2632 argv.push(pref.to_string());
2633 }
2634 argv.extend([
2635 "-UQ".to_string(),
2636 "-qS".to_string(),
2637 suf.to_string(),
2638 "-a".to_string(),
2639 array.to_string(),
2640 ]);
2641 argv
2642}
2643
2644/// `${(r:n:)s}` — pad on the right with spaces to `n` characters, or cut
2645/// to the first `n` when it is already longer. `_expand` sh:232,
2646/// `_user_expand` sh:134.
2647pub fn right_pad_or_truncate(s: &str, n: usize) -> String {
2648 let len = s.chars().count();
2649 if len >= n {
2650 s.chars().take(n).collect()
2651 } else {
2652 format!("{}{}", s, " ".repeat(n - len))
2653 }
2654}
2655
2656/// `${s% * }` — remove the SHORTEST suffix matching `<space>*<space>`.
2657///
2658/// `_all_labels` sh:27 and `_next_label` sh:9 both run
2659/// `_comp_tags="${_comp_tags% * }"` before appending the new spec with
2660/// sh:29 / sh:11 `_comp_tags="$_comp_tags $__spec "`, so the exact spacing
2661/// this leaves is load-bearing: `_comp_tags` is pattern-matched elsewhere as
2662/// `*\ ${tag}\ *` and is published to the user as `$_lastcomp[tags]`
2663/// (`_main_complete` sh:416).
2664///
2665/// Two properties of `%` (shortest match, anchored at the end) that a
2666/// "drop the last word" paraphrase gets wrong, both measured against
2667/// `zsh -f`:
2668///
2669/// * nothing is stripped when the value does NOT end in a space — the
2670/// pattern's own trailing space has nothing to match (`" a b"` → `" a b"`);
2671/// * what remains keeps NO trailing space, because the pattern's LEADING
2672/// space is consumed too (`" a b c "` → `" a b"`, `"a b "` → `"a"`,
2673/// `" abc "` → `""`).
2674///
2675/// The previous inline copies in both ports trimmed the trailing spaces,
2676/// dropped the last word and re-appended a space, so the sh:29 / sh:11
2677/// append then produced `" a b spec "` with a doubled space where zsh
2678/// produces `" a b spec "`.
2679pub fn strip_shortest_space_delimited_suffix(s: &str) -> String {
2680 // The pattern ends in a space, so a value that does not cannot match.
2681 if !s.ends_with(' ') {
2682 return s.to_string();
2683 }
2684 let bytes = s.as_bytes();
2685 // `<space>*<space>` needs two spaces; a lone trailing one cannot match
2686 // (`" "` is unchanged in zsh).
2687 if bytes.len() < 2 {
2688 return s.to_string();
2689 }
2690 // The shortest match starts at the LAST space that is not the trailing
2691 // one; everything from it is removed. Space is ASCII, so this byte index
2692 // is always a char boundary.
2693 match bytes[..bytes.len() - 1].iter().rposition(|&b| b == b' ') {
2694 Some(i) => s[..i].to_string(),
2695 None => s.to_string(),
2696 }
2697}
2698
2699#[cfg(test)]
2700mod strip_shortest_space_delimited_suffix_tests {
2701 use super::strip_shortest_space_delimited_suffix as strip;
2702
2703 /// Every shape below was measured with `zsh -f` running
2704 /// `x=<input>; print -r -- "${x% * }"`.
2705 #[test]
2706 fn matches_zsh_parameter_expansion_on_every_measured_shape() {
2707 assert_eq!(strip(" a b c "), " a b");
2708 assert_eq!(strip(" a b "), " a");
2709 assert_eq!(strip(" a b "), " a b");
2710 assert_eq!(strip("a b "), "a");
2711 assert_eq!(strip(" abc "), "");
2712 }
2713
2714 /// `%` is anchored at the end: with no trailing space the pattern's own
2715 /// trailing space has nothing to match, so the value is untouched.
2716 #[test]
2717 fn a_value_without_a_trailing_space_is_unchanged() {
2718 assert_eq!(strip(" a b"), " a b");
2719 assert_eq!(strip("abc"), "abc");
2720 assert_eq!(strip(""), "");
2721 }
2722
2723 /// A single trailing space is not enough for `<space>*<space>`.
2724 #[test]
2725 fn a_lone_space_is_unchanged() {
2726 assert_eq!(strip(" "), " ");
2727 }
2728
2729 /// The regression this helper exists for: `_all_labels` sh:27-29 and
2730 /// `_next_label` sh:9-11 strip and then append `" $__spec "`. The result
2731 /// must carry ONE space between specs, not two.
2732 #[test]
2733 fn strip_then_append_leaves_a_single_space_between_specs() {
2734 let after_strip = strip(" tag1 tag2 ");
2735 assert_eq!(format!("{} {} ", after_strip, "tag3"), " tag1 tag3 ");
2736 }
2737}
2738
2739#[cfg(test)]
2740mod slash_action_tests {
2741 use super::*;
2742
2743 /// c:Src/exec.c:756-766: an action word with a `/` is exec'd directly
2744 /// and a failed exec reports its errno with 126/127, never
2745 /// `command not found`. `_nfs-cat`'s `'1:nfs:// URL:'` runs `//`, which
2746 /// zsh answers `permission denied: //` (126); a missing absolute path is
2747 /// `no such file or directory` (127). Both used to fall through to the
2748 /// `$PATH` miss and return 127 for `command not found`.
2749 #[test]
2750 fn a_slash_action_reports_the_exec_errno() {
2751 let _g = crate::test_util::global_state_lock();
2752 assert_eq!(direct_exec_errno("//"), libc::EACCES);
2753 assert_eq!(direct_exec_errno("/"), libc::EACCES);
2754 assert_eq!(direct_exec_errno("/nonexistent-zshrs-dir/x"), libc::ENOENT);
2755 assert_eq!(direct_exec_errno("/bin/sh"), 0);
2756 assert_eq!(dispatch_action_command("//", &["URL:".to_string()], 0), 126);
2757 assert_eq!(dispatch_action_command("/nonexistent-zshrs-dir/x", &[], 0), 127);
2758 }
2759}