Skip to main content

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}