Skip to main content

zsh/ported/zle/
compcore.rs

1//! Direct port of `Src/Zle/compcore.c` — completion core code.
2//!
3//! Original C copyright: Sven Wischnowsky 1995-1997.
4//!
5//! C source is 3,638 lines. This file ports:
6//!   - the file-scope globals (c:36-279)
7//!   - the pure-string helpers (`rembslash`, `remsquote`,
8//!     `comp_quoting_string`, `multiquote`, `tildequote`, `matcheq`,
9//!     `matchcmp`, `ctokenize`, `comp_str`)
10//!   - the linked-list group manipulators (`begcmgroup`,
11//!     `endcmgroup`, `addexpl`, `addmatch`)
12//!   - the param-table helpers (`get_user_var`, `get_data_arr`,
13//!     `set_list_array`)
14//!   - the hook entry points (`before_complete`, `after_complete`)
15//!     in their non-runhookdef branches
16
17#![allow(non_snake_case, non_upper_case_globals, dead_code)]
18
19use crate::ported::context::zcontext_restore_partial;
20use crate::ported::module::{gethookdef, runhookdef};
21use crate::ported::params::{getsparam, paramtab, paramtab_hashed_storage, setaparam, setsparam};
22use crate::ported::signals::{queue_signals, unqueue_signals};
23use crate::ported::zle::comp_h::{
24    Aminfo, Brinfo, Cadata, Ccmakedat, Cexpl, Cline, Cmatch, Cmgroup, Cmlist, Menuinfo, CAF_ALL,
25    CAF_ARRAYS, CAF_KEYS, CAF_MATCH, CAF_MATSORT, CAF_NOSORT, CAF_NUMSORT, CAF_QUOTE, CAF_REVSORT,
26    CAF_UNIQALL, CAF_UNIQCON, CGF_MATSORT, CGF_NOSORT, CGF_NUMSORT, CGF_REVSORT, CGF_UNIQALL,
27    CGF_UNIQCON, CMF_DELETE, CMF_DISPLINE, CMF_FMULT, CMF_MULT, CMF_NOLIST, CMF_PACKED, CMF_PARBR,
28    CMF_PARNEST, CMF_ROWS,
29};
30use crate::ported::zle::complete::{
31    COMPIPREFIX, COMPLIST, COMPPREFIX, COMPQSTACK, COMPSUFFIX, INCOMPFUNC,
32};
33use crate::ported::zle::compmatch::{bld_parts, cline_matched};
34use crate::ported::zle::compresult::{do_ambig_menu, ztat};
35use crate::ported::zle::zle_h::{invalidatelist, COMP_LIST_COMPLETE, COMP_LIST_EXPAND, CUT_RAW};
36use crate::ported::zle::zle_refresh::{CLEARLIST, SHOWINGLIST};
37use crate::ported::zle::zle_tricky::{
38    inststr, MENUCMP, ORIGCS, ORIGLINE, USEGLOB, USEMENU, VALIDLIST, WOULDINSTAB,
39};
40use crate::ported::zle::zle_utils::foredel;
41#[allow(unused_imports)]
42use crate::ported::zle::{
43    deltochar::*, textobjects::*, zle_h::*, zle_hist::*, zle_main::*, zle_misc::*, zle_move::*,
44    zle_params::*, zle_refresh::*, zle_tricky::*, zle_utils::*, zle_vi::*, zle_word::*,
45};
46use crate::ported::zsh_h::{
47    isset, Bnull, Dnull, Equals, Hat, Inbrace, Inbrack, Inpar, Outbrace, Outpar, Pound, Qstring,
48    Quest, Snull, Star, Stringg, Tilde, BASHAUTOLIST, NUMERICGLOBSORT, PM_HASHED, PM_TYPE,
49    QT_BACKSLASH, QT_DOLLARS, QT_DOUBLE, QT_NONE, QT_SINGLE, RCQUOTES, SORTIT_IGNORING_BACKSLASHES,
50    SORTIT_NUMERICALLY, ZCONTEXT_HIST, ZCONTEXT_LEX, ZCONTEXT_PARSE,
51};
52use crate::DPUTS;
53use std::sync::atomic::{AtomicI32, Ordering};
54use std::sync::{Mutex, OnceLock};
55
56// =====================================================================
57// do_completion — `Src/Zle/compcore.c:287`.
58// =====================================================================
59
60/// Direct port of `int do_completion(Hookdef dummy, Compldat dat)`
61/// from compcore.c:287. The top-level completion driver: per-round
62/// state reset → `makecomplist` → dispatch to `do_ambiguous` /
63/// `do_single` / `do_allmatches` per result count.
64pub fn do_completion(s: &str, incmd: i32, lst: i32) -> i32 {
65    // c:287
66
67    // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
68    // Native (Rust) plugins that registered completions via
69    // `register_completion` (src/extensions/plugin_host.rs) have their
70    // compsys `compdef` wiring deferred to here — the completion pipeline
71    // is a safe point to eval the glue (compsys itself evals here),
72    // whereas evaling during `zmodload -R` plugin-init hangs the VM.
73    // Idempotent + cheap: no-ops once the pending queue is drained.
74    crate::plugin_host::flush_pending_completions();
75
76    let osl = SHOWINGLIST.load(Ordering::Relaxed); // c:289
77    let mut ret: i32 = 0; // c:289
78
79    // c:296-297 — `ainfo = fainfo = NULL`.
80    if let Ok(mut g) = ainfo.get_or_init(|| Mutex::new(None)).lock() {
81        *g = None;
82    }
83    if let Ok(mut g) = fainfo.get_or_init(|| Mutex::new(None)).lock() {
84        *g = None;
85    }
86    if let Ok(mut g) = matchers.get_or_init(|| Mutex::new(Vec::new())).lock() {
87        g.clear(); // c:298
88    }
89
90    // c:300-307 — compqstack reset.
91    let instring = INSTRING.load(Ordering::Relaxed); // c:307
92                                                     // c:305 — `compqstack = instring == QT_NONE ? "\\" : <quote-char>`.
93                                                     // Inlined `char_from_qt(x)` as `(x as u8) as char`.
94    let head_q: char = if instring == QT_NONE {
95        // c:305
96        QT_BACKSLASH as u8 as char
97    } else {
98        instring as u8 as char
99    };
100    if let Ok(mut g) = COMPQSTACK.get_or_init(|| Mutex::new(String::new())).lock() {
101        *g = head_q.to_string(); // c:305-306
102    }
103    // !!! RUST-ONLY LINE — NO C COUNTERPART !!!
104    // In C, `$compstate[all_quotes]` has NO storage of its own: its
105    // `compkparams` row is `{ "all_quotes", PM_SCALAR | PM_READONLY, NULL,
106    // GSU(compqstack_gsu) }` (complete.c:1299) and `compqstack_gsu`
107    // (complete.c:1242-1243) routes every read through `get_compqstack`
108    // (complete.c:1479) against the live `compqstack` global — so the
109    // c:305-306 assignment IS the parameter update. zshrs splits the two: a
110    // single-key `${compstate[KEY]}` read comes straight out of
111    // `paramtab_hashed_storage` (`src/ported/subst.rs:7034-7044`), which
112    // special-cases only `nmatches`, so nothing ever published `all_quotes`
113    // and it read EMPTY where zsh gives `\`, `"`, `'` (`_cmdambivalent`
114    // sh:47 and the documented `compquote` idiom both read it). Run the
115    // getter and store its result at each `compqstack` write.
116    set_compstate_str(
117        "all_quotes",
118        &crate::ported::zle::complete::get_compqstack(std::ptr::null_mut()),
119    );
120
121    hasunqu.store(0, Ordering::Relaxed); // c:309
122    let wouldinstab_v = WOULDINSTAB.load(Ordering::Relaxed); // c:310
123    useline.store(
124        // c:310
125        if wouldinstab_v != 0 {
126            -1
127        } else if lst != COMP_LIST_COMPLETE {
128            1
129        } else {
130            0
131        },
132        Ordering::Relaxed,
133    );
134    useexact.store(opt_isset("RECEXACT"), Ordering::Relaxed); // c:311
135    set_compstate_str("exact_string", ""); // c:312
136    let useline_v = useline.load(Ordering::Relaxed);
137    uselist.store(
138        // c:314
139        if useline_v != 0 {
140            if opt_isset("AUTOLIST") != 0 && opt_isset("BASHAUTOLIST") == 0 {
141                if opt_isset("LISTAMBIGUOUS") != 0 {
142                    3
143                } else {
144                    2
145                }
146            } else {
147                0
148            }
149        } else {
150            1
151        },
152        Ordering::Relaxed,
153    );
154
155    let useglob_v = USEGLOB.load(Ordering::Relaxed); // c:319
156    let opm: String = if useglob_v != 0 {
157        "*".into()
158    } else {
159        "".into()
160    };
161    if let Ok(mut g) = comppatmatch.get_or_init(|| Mutex::new(None)).lock() {
162        *g = Some(opm.clone()); // c:319
163    }
164    // c:320-321 — `zsfree(comppatinsert); comppatinsert = ztrdup("menu");`
165    // `comppatinsert` is a plain module global (`complete.c:69`) that the
166    // `$compstate[pattern_insert]` entry is only a `VAL()` VIEW onto
167    // (`complete.c:1281`), so it OUTLIVES the completion widget's parameter
168    // scope. Writing only the parameter — as this port did — meant
169    // `do_ambiguous`'s GLOB_COMPLETE test (`compresult.c:764`) read it back
170    // absent after `endparamscope()` deleted `$compstate`, so that whole
171    // branch was dead. Keep the global and the parameter in step here, and
172    // re-sync the global from the parameter after the completion function
173    // returns (the c:843-925 unwind below).
174    if let Ok(mut g) = crate::ported::zle::complete::COMPPATINSERT
175        .get_or_init(|| Mutex::new(String::new()))
176        .lock()
177    {
178        *g = "menu".into(); // c:321
179    }
180    set_compstate_str("pattern_insert", "menu"); // c:320
181    forcelist.store(0, Ordering::Relaxed); // c:322
182    haspattern.store(0, Ordering::Relaxed); // c:323
183                                            // c:324 — complistmax mirrors the LISTMAX parameter for every
184                                            // completion; asklist reads it to decide when to prompt "do you wish
185                                            // to see all N possibilities?". Leaving the static at 0 made large
186                                            // command lists (`l<Tab>`, 230 matches) dump without asking.
187    crate::ported::zle::complete::COMPLISTMAX
188        .store(env_iparam("LISTMAX") as i64, Ordering::Relaxed); // c:324
189
190    // c:325 — `complastprompt = ztrdup(isset(ALWAYSLASTPROMPT) ? "yes" : "")`.
191    // The global outlives `$compstate`, which is gone once the completion
192    // function returns; complistmatches reads it at c:2033.
193    let lastprompt_v = if opt_isset("ALWAYSLASTPROMPT") != 0 { "yes" } else { "" };
194    if let Ok(mut g) = crate::ported::zle::complete::COMPLASTPROMPT
195        .get_or_init(|| Mutex::new(String::new()))
196        .lock()
197    {
198        *g = lastprompt_v.into();
199    }
200    set_compstate_str(
201        // c:326
202        "last_prompt",
203        if opt_isset("ALWAYSLASTPROMPT") != 0 {
204            "yes"
205        } else {
206            ""
207        },
208    );
209    dolastprompt.store(1, Ordering::Relaxed); // c:327
210
211    // c:329-330 — complist string.
212    let cl_str = if opt_isset("LISTROWSFIRST") != 0 {
213        if opt_isset("LISTPACKED") != 0 {
214            "packed rows"
215        } else {
216            "rows"
217        }
218    } else if opt_isset("LISTPACKED") != 0 {
219        "packed"
220    } else {
221        ""
222    };
223    if let Ok(mut g) = COMPLIST.get_or_init(|| Mutex::new(String::new())).lock() {
224        *g = cl_str.into(); // c:329
225    }
226    startauto.store(opt_isset("AUTOMENU"), Ordering::Relaxed); // c:331
227
228    let zlc = ZLEMETACS.load(Ordering::Relaxed);
229    let we_v = WE.load(Ordering::Relaxed);
230    movetoend.store(
231        // c:332
232        if zlc == we_v || opt_isset("ALWAYSTOEND") != 0 {
233            2
234        } else {
235            1
236        },
237        Ordering::Relaxed,
238    );
239    SHOWINGLIST.store(0, Ordering::Relaxed); // c:333
240    hasmatched.store(0, Ordering::Relaxed); // c:334
241    hasunmatched.store(0, Ordering::Relaxed); // c:334
242    minmlen.store(1_000_000, Ordering::Relaxed); // c:335
243    maxmlen.store(-1, Ordering::Relaxed); // c:336
244                                          // c:337 — `compignored = 0`. This line was absent, so the counter
245                                          // behind `$compstate[ignored]` (complete.c:41, exported through the
246                                          // compstate table at complete.c:1300) accumulated across every
247                                          // completion of the session instead of counting only THIS round's
248                                          // ignored-pattern rejections. `_ignored` gates its whole body on
249                                          // `[[ … || $compstate[ignored] -eq 0 ]] && return 1`, so a stale
250                                          // non-zero made it run on rounds that ignored nothing.
251    crate::ported::zle::complete::COMPIGNORED.store(0, Ordering::Relaxed); // c:337
252    nmessages.store(0, Ordering::Relaxed); // c:338
253    hasallmatch.store(0, Ordering::Relaxed); // c:339
254
255    // c:342 — main dispatch.
256    if makecomplist(s, incmd, lst) != 0 {
257        // c:342
258        // c:344 — error path.
259        ZLEMETACS.store(0, Ordering::Relaxed); // c:344
260        foredel(ZLEMETALL.load(Ordering::Relaxed), CUT_RAW); // c:345 — `foredel(zlemetall, CUT_RAW)`
261        let _ = inststr(
262            &ORIGLINE
263                .get_or_init(|| Mutex::new(String::new()))
264                .lock()
265                .map(|g| g.clone())
266                .unwrap_or_default(),
267        ); // c:346 — `inststr(origline)`
268        ZLEMETACS.store(ORIGCS.load(Ordering::Relaxed), Ordering::Relaxed); // c:347
269        CLEARLIST.store(1, Ordering::Relaxed); // c:348
270        ret = 1;
271        if let Ok(mut g) = MINFO.get_or_init(|| Mutex::new(Menuinfo::default())).lock() {
272            g.cur = None;
273        } // c:350
274        if useline.load(Ordering::Relaxed) < 0 {
275            // c:352
276            unmetafy_line(); // c:354
277                             // zshrs bridge, no C counterpart: C has ONE line buffer —
278                             // `zleline`/`zlecs`/`zlell` (zle_main.c:43,48) — which `doinsert`
279                             // (zle_misc.c:37,51) writes and `metafy_line`/`unmetafy_line`
280                             // (zle_tricky.c:978,995) convert, so C's sandwich round-trips
281                             // through the same characters. This port splits it into the editor
282                             // buffer (`zle_main::ZLELINE`, the Vec<char> `selfinsert` writes —
283                             // zle_misc.rs:205) and the completion staging buffer
284                             // (`compcore::ZLELINE`, the only one the two conversions read), so
285                             // the two copies below hand the unmetafied line to the widget and
286                             // take the edited line back. Without them the inserted character
287                             // landed in a buffer `metafy_line` never reads and vanished: TAB on
288                             // an all-blank line inserted nothing where zsh inserts a literal
289                             // TAB (the `wouldinstab` path, zle_tricky.c:183-197 → c:311 →
290                             // c:782 → `_main_complete`'s insert-tab early return → c:860).
291                             // Same idiom as docomplete's entry bridge (zle_tricky.rs:857).
292            {
293                let comp_line: Vec<char> = ZLELINE
294                    .get_or_init(|| Mutex::new(String::new()))
295                    .lock()
296                    .map(|g| g.chars().collect())
297                    .unwrap_or_default();
298                let comp_ll = comp_line.len();
299                let comp_cs = ZLECS.load(Ordering::SeqCst).clamp(0, comp_ll as i32) as usize;
300                if let Ok(mut g) = crate::ported::zle::zle_main::ZLELINE.lock() {
301                    *g = comp_line;
302                }
303                crate::ported::zle::zle_main::ZLECS.store(comp_cs, Ordering::SeqCst);
304                crate::ported::zle::zle_main::ZLELL.store(comp_ll, Ordering::SeqCst);
305            }
306            ret = selfinsert(&[]); // c:355
307            {
308                let ed_line: String = crate::ported::zle::zle_main::ZLELINE
309                    .lock()
310                    .map(|g| g.iter().collect())
311                    .unwrap_or_default();
312                let ed_ll = ed_line.chars().count() as i32;
313                let ed_cs = crate::ported::zle::zle_main::ZLECS.load(Ordering::SeqCst) as i32;
314                if let Ok(mut g) = ZLELINE.get_or_init(|| Mutex::new(String::new())).lock() {
315                    *g = ed_line;
316                }
317                ZLECS.store(ed_cs.clamp(0, ed_ll), Ordering::SeqCst);
318                ZLELL.store(ed_ll, Ordering::SeqCst);
319            }
320            metafy_line(); // c:356
321        }
322        return goto_compend(ret); // c:358 goto compend
323    }
324
325    // c:360-362 — clear lastprebr/lastpostbr. Runs AFTER `makecomplist`,
326    // so the values `get_comp_string` recorded are still live for the
327    // c:2999 brace filter in `add_match_data`.
328    lastprebr_set(None); // c:360
329    lastpostbr_set(None); // c:361
330
331    let curpm = comppatmatch
332        .get_or_init(|| Mutex::new(None))
333        .lock()
334        .ok()
335        .and_then(|g| g.clone())
336        .unwrap_or_default();
337    if !curpm.is_empty() && curpm != opm {
338        // c:363
339        haspattern.store(1, Ordering::Relaxed); // c:364
340    }
341    let nm = nmatches.load(Ordering::Relaxed); // c:366
342    let dm = diffmatches.load(Ordering::Relaxed);
343    tracing::debug!(
344        target: "compsys_args",
345        nm,
346        dm,
347        useline = useline.load(Ordering::Relaxed),
348        uselist = uselist.load(Ordering::Relaxed),
349        iforcemenu = iforcemenu.load(Ordering::Relaxed),
350        "do_completion branch point"
351    );
352    if iforcemenu.load(Ordering::Relaxed) != 0 {
353        // c:366
354        if nm != 0 {
355            {
356                let _ = do_ambig_menu();
357            };
358        } // c:367
359        ret = if nm == 0 { 1 } else { 0 }; // c:369
360    } else if useline.load(Ordering::Relaxed) < 0 {
361        // c:370
362        unmetafy_line(); // c:372
363                         // zshrs bridge, no C counterpart — same split-line-buffer copies as the
364                         // c:352-357 arm above; see the comment there.
365        {
366            let comp_line: Vec<char> = ZLELINE
367                .get_or_init(|| Mutex::new(String::new()))
368                .lock()
369                .map(|g| g.chars().collect())
370                .unwrap_or_default();
371            let comp_ll = comp_line.len();
372            let comp_cs = ZLECS.load(Ordering::SeqCst).clamp(0, comp_ll as i32) as usize;
373            if let Ok(mut g) = crate::ported::zle::zle_main::ZLELINE.lock() {
374                *g = comp_line;
375            }
376            crate::ported::zle::zle_main::ZLECS.store(comp_cs, Ordering::SeqCst);
377            crate::ported::zle::zle_main::ZLELL.store(comp_ll, Ordering::SeqCst);
378        }
379        ret = selfinsert(&[]); // c:373
380        {
381            let ed_line: String = crate::ported::zle::zle_main::ZLELINE
382                .lock()
383                .map(|g| g.iter().collect())
384                .unwrap_or_default();
385            let ed_ll = ed_line.chars().count() as i32;
386            let ed_cs = crate::ported::zle::zle_main::ZLECS.load(Ordering::SeqCst) as i32;
387            if let Ok(mut g) = ZLELINE.get_or_init(|| Mutex::new(String::new())).lock() {
388                *g = ed_line;
389            }
390            ZLECS.store(ed_cs.clamp(0, ed_ll), Ordering::SeqCst);
391            ZLELL.store(ed_ll, Ordering::SeqCst);
392        }
393        metafy_line(); // c:374
394    } else if useline.load(Ordering::Relaxed) == 0 && uselist.load(Ordering::Relaxed) != 0 {
395        // c:374
396        ZLEMETACS.store(0, Ordering::Relaxed); // c:375
397        foredel(ZLEMETALL.load(Ordering::Relaxed), CUT_RAW); // c:376 — `foredel(zlemetall, CUT_RAW)`
398        let _ = inststr(
399            &ORIGLINE
400                .get_or_init(|| Mutex::new(String::new()))
401                .lock()
402                .map(|g| g.clone())
403                .unwrap_or_default(),
404        ); // c:377 — `inststr(origline)`
405        ZLEMETACS.store(ORIGCS.load(Ordering::Relaxed), Ordering::Relaxed); // c:378
406        SHOWINGLIST.store(-2, Ordering::Relaxed);
407        // c:379
408    } else if useline.load(Ordering::Relaxed) == 2 && nm > 1 {
409        // c:380
410        // c:381 — `do_allmatches(1)`. Faithful minfo-driven insertion:
411        // iterates `amatches`, chaining do_single/accept_last against
412        // the shared ZLEMETALINE buffer (see compresult::do_allmatches).
413        crate::ported::zle::compresult::do_allmatches(1);
414        if let Ok(mut g) = MINFO.get_or_init(|| Mutex::new(Menuinfo::default())).lock() {
415            g.cur = None;
416        } // c:383
417        if forcelist.load(Ordering::Relaxed) != 0 {
418            // c:385
419            SHOWINGLIST.store(-2, Ordering::Relaxed);
420        } else {
421            invalidatelist(); // c:388
422        }
423    } else if useline.load(Ordering::Relaxed) != 0 {
424        // c:389
425        if nm > 1 && dm != 0 {
426            // c:391
427            // c:393 — `ret = do_ambiguous()`. Inlined: flatten `amatches`
428            // into &[String] and dispatch.
429            ret = {
430                let groups = amatches
431                    .get_or_init(|| Mutex::new(Vec::new()))
432                    .lock()
433                    .map(|g| g.clone())
434                    .unwrap_or_default();
435                let all: Vec<String> = groups
436                    .into_iter()
437                    .flat_map(|g| g.matches.into_iter().filter_map(|m| m.str))
438                    .collect();
439                crate::ported::zle::compresult::do_ambiguous(&all)
440            };
441            if SHOWINGLIST.load(Ordering::Relaxed) == 0
442                && uselist.load(Ordering::Relaxed) != 0
443                && LISTSHOWN.load(Ordering::Relaxed) != 0
444                && (USEMENU.load(Ordering::Relaxed) == 2 || oldlist.load(Ordering::Relaxed) != 0)
445            {
446                SHOWINGLIST.store(osl, Ordering::Relaxed);
447                // c:395
448            }
449        } else if nm == 1 || (nm > 1 && dm == 0) {
450            // c:396
451            do_single_first_match(); // c:399-411
452            if forcelist.load(Ordering::Relaxed) != 0 {
453                // c:412
454                if uselist.load(Ordering::Relaxed) != 0 {
455                    SHOWINGLIST.store(-2, Ordering::Relaxed);
456                } else {
457                    CLEARLIST.store(1, Ordering::Relaxed);
458                }
459            } else {
460                invalidatelist(); // c:418
461            }
462        } else if nmessages.load(Ordering::Relaxed) != 0 && forcelist.load(Ordering::Relaxed) != 0 {
463            // c:419
464            if uselist.load(Ordering::Relaxed) != 0 {
465                SHOWINGLIST.store(-2, Ordering::Relaxed);
466            } else {
467                CLEARLIST.store(1, Ordering::Relaxed);
468            }
469        }
470    } else {
471        // c:425
472        invalidatelist(); // c:426
473        LASTAMBIG.store(
474            // c:427
475            opt_isset("BASHAUTOLIST"),
476            Ordering::Relaxed,
477        );
478        if forcelist.load(Ordering::Relaxed) != 0 {
479            CLEARLIST.store(1, Ordering::Relaxed);
480        } // c:428
481        ZLEMETACS.store(0, Ordering::Relaxed); // c:429
482        foredel(ZLEMETALL.load(Ordering::Relaxed), CUT_RAW); // c:430 — `foredel(zlemetall, CUT_RAW)`
483        let _ = inststr(
484            &ORIGLINE
485                .get_or_init(|| Mutex::new(String::new()))
486                .lock()
487                .map(|g| g.clone())
488                .unwrap_or_default(),
489        ); // c:431 — `inststr(origline)`
490        ZLEMETACS.store(ORIGCS.load(Ordering::Relaxed), Ordering::Relaxed); // c:432
491    }
492
493    // c:436 — explanation strings.
494    if SHOWINGLIST.load(Ordering::Relaxed) == 0
495        && VALIDLIST.load(Ordering::Relaxed) != 0
496        && USEMENU.load(Ordering::Relaxed) != 2
497        && uselist.load(Ordering::Relaxed) != 0
498        && (nm != 1 || dm != 0)
499        && useline.load(Ordering::Relaxed) >= 0
500        && useline.load(Ordering::Relaxed) != 2
501        && (oldlist.load(Ordering::Relaxed) == 0 || LISTSHOWN.load(Ordering::Relaxed) == 0)
502    {
503        onlyexpl.store(3, Ordering::Relaxed); // c:441
504        SHOWINGLIST.store(-2, Ordering::Relaxed);
505        // c:442
506    }
507
508    goto_compend(ret)
509}
510
511// =====================================================================
512// before_complete / after_complete — `Src/Zle/compcore.c:461 / 503`.
513// =====================================================================
514
515/// Direct port of `int before_complete(Hookdef dummy, int *lst)`
516/// from `Src/Zle/compcore.c:461`. Pre-completion hook: snapshots
517/// `menucmp` into `oldmenucmp`, decides whether the current state
518/// shortcircuits via menu-completion, clamps the cursor when re-
519/// entering an in-word completion, and toggles automenu mode.
520/// Returns 1 to suppress the next-stage match build, 0 to continue.
521pub fn before_complete(lst: &mut i32) -> i32 {
522    // c:461
523
524    // c:463 — `oldmenucmp = menucmp;`
525    OLDMENUCMP.store(MENUCMP.load(Ordering::Relaxed), Ordering::Relaxed);
526
527    // c:465-466 — `if (showagain && validlist) showinglist = -2;`
528    if SHOWAGAIN.load(Ordering::Relaxed) != 0 && VALIDLIST.load(Ordering::Relaxed) != 0 {
529        SHOWINGLIST.store(-2, Ordering::Relaxed);
530    }
531    // c:467 — `showagain = 0;`
532    SHOWAGAIN.store(0, Ordering::Relaxed);
533
534    let has_cur = MINFO
535        .get()
536        .and_then(|m| m.lock().ok())
537        .map(|m| m.cur.is_some())
538        .unwrap_or(false);
539    let menucmp_v = MENUCMP.load(Ordering::Relaxed);
540
541    // c:471-474 — menu-completion shortcircuit (non-listing path).
542    // C: `do_menucmp(*lst); return 1;`. An active menu (minfo.cur set,
543    // menucmp on) means this Tab should step the menu cursor and insert the
544    // next match, NOT restart completion.
545    if has_cur && menucmp_v != 0 && *lst != COMP_LIST_EXPAND {
546        if *lst == COMP_LIST_COMPLETE {
547            // do_menucmp c:1258-1260 — just (re)show the list.
548            SHOWINGLIST.store(-2, Ordering::Relaxed);
549        } else {
550            // do_menucmp c:1263-1268 — `if (zlemetaline == NULL) metafy_line()`.
551            // before_complete runs before docomplete metafies the buffer, so
552            // do_single would otherwise edit a stale/unmetafied line and drop
553            // the command prefix (`cat alpha.txt` → `alpine.md`). Metafy the
554            // completion buffer (still holding the previous match's line) so
555            // do_single replaces only the word region tracked by minfo.
556            if ZLEMETALL.load(Ordering::Relaxed) == 0 {
557                metafy_line();
558            }
559            // do_menucmp c:1270-1276 —
560            //   while (zmult) { minfo.cur = valid_match(minfo.cur, 1);
561            //                   zmult -= sign; }
562            //   do_single(*minfo.cur);
563            // valid_match advances the menu cursor one valid match in the
564            // ZMULT direction, updating minfo.group_idx/cur_idx; do_single
565            // then replaces the previously-inserted match (tracked via
566            // minfo.pos/len/end) with the new one.
567            let mult = crate::ported::zle::zle_main::ZMOD
568                .lock()
569                .map(|g| g.mult)
570                .unwrap_or(1);
571            ZMULT.store(mult, Ordering::Relaxed);
572            let steps = mult.abs().max(1);
573            let mut mc = None;
574            for _ in 0..steps {
575                let cur_idx = MINFO
576                    .get()
577                    .and_then(|m| m.lock().ok())
578                    .map(|m| m.cur_idx)
579                    .unwrap_or(0);
580                mc = crate::ported::zle::compresult::valid_match(cur_idx, 1);
581            }
582            // c:1272 — minfo.cur = valid_match(...); set before do_single so
583            // the insertion state stays consistent with the advanced cursor.
584            if let Ok(mut mst) = MINFO.get_or_init(|| Mutex::new(Menuinfo::default())).lock() {
585                mst.cur = mc.clone().map(Box::new);
586            }
587            if let Some(ref m) = mc {
588                crate::ported::zle::compresult::do_single(m); // c:1276
589            }
590        }
591        return 1; // c:473
592    }
593    // c:475-479 — menu-completion shortcircuit (listing path).
594    if has_cur
595        && menucmp_v != 0
596        && VALIDLIST.load(Ordering::Relaxed) != 0
597        && *lst == COMP_LIST_COMPLETE
598    {
599        SHOWINGLIST.store(-2, Ordering::Relaxed);
600        onlyexpl.store(0, Ordering::Relaxed); // c:477
601                                              // c:477 — `listdat.valid = 0;`
602        if let Some(ld) = listdat.get() {
603            if let Ok(mut g) = ld.lock() {
604                g.valid = 0;
605            }
606        }
607        return 1; // c:478
608    }
609
610    // c:488-489 — `if ((fromcomp & FC_INWORD) && (zlecs = lastend) > zlell)
611    //              zlecs = zlell;` — re-entering an in-word completion
612    //              moves the cursor back to `lastend` (clamped to `zlell`).
613    //
614    // c:483-487: this hook "runs before metafication", so C writes the EDITOR
615    // cursor. In this port that is `zle_main::ZLECS`, which docomplete copies
616    // into the completion buffer right after the hook returns
617    // (zle_tricky.rs, the block before `metafy_line()`). C stores the
618    // metafied `lastend` into the unmetafied `zlecs` as is; so does this.
619    // The store used to land on `ZLEMETACS`, which that metafy_line()
620    // recomputes, so the move never happened: after `tst aB<TAB>` left the
621    // cursor at `a|BC`, the next TAB completed `a` + `BC` where zsh
622    // completes `aBC`.
623    if (fromcomp.load(Ordering::Relaxed) & crate::ported::zle::comp_h::FC_INWORD) != 0 {
624        let le = lastend.load(Ordering::Relaxed).max(0) as usize; // c:488 `zlecs = lastend`
625        let ll = crate::ported::zle::zle_main::ZLELL.load(Ordering::SeqCst);
626        crate::ported::zle::zle_main::ZLECS.store(le.min(ll), Ordering::SeqCst); // c:489 `zlecs = zlell`
627    }
628
629    // c:494-496 — automenu trigger.
630    if startauto.load(Ordering::Relaxed) != 0 && LASTAMBIG.load(Ordering::Relaxed) != 0 {
631        let bashauto = isset(BASHAUTOLIST);
632        let last = LASTAMBIG.load(Ordering::Relaxed);
633        if !bashauto || last == 2 {
634            USEMENU.store(2, Ordering::Relaxed);
635        }
636    }
637
638    0 // c:498
639}
640
641/// Direct port of `int after_complete(Hookdef dummy, int *dat)`
642/// from `Src/Zle/compcore.c:503`. Post-completion hook: when a
643/// completion has just transitioned into menu-completion (menucmp
644/// went 0→1 across this round), runs MENUSTARTHOOK so registered
645/// hook ported can veto or modify the about-to-display menu.
646///
647/// Hook handlers are registered via `addhookfunc("menu_start", fn)`
648/// (see `crate::ported::module::addhookfunc`), which writes to the
649/// global HOOKTAB. C's `comphooks[]` table declares `menu_start` as
650/// HOOKF_ALL, so every handler fires and the first non-zero return
651/// short-circuits the chain (see runhookdef at module.c:990).
652///
653/// Return value semantics (c:518-532):
654///   - `ret == 0` → no action (no handler vetoed).
655///   - `ret >= 1` → zero `dat[1]`, clear menucmp/menuacc, null minfo.cur.
656///   - `ret >= 2` → also rewind buffer to origline.
657///   - `ret == 2` → also schedule list clear (CLEARLIST=1, invalidatelist).
658pub fn after_complete(dat: &mut [i32]) -> i32 {
659    // c:503
660    let menucmp_v = MENUCMP.load(Ordering::Relaxed);
661    let oldmenucmp_v = OLDMENUCMP.load(Ordering::Relaxed);
662
663    // c:505 — `if (menucmp && !oldmenucmp) { ... }`.
664    //
665    // `iforcemenu == -1` marks a completion driven from INSIDE
666    // `domenuselect`'s interactive filter loop (complist.rs:2776-2779 —
667    // C sets it at c:2773). C never reaches the restore below on that
668    // path because its `oldmenucmp` is still 1 from the outer menu:
669    // c:517's `runhookdef(MENUSTARTHOOK, …)` runs the whole interactive
670    // loop BEFORE c:520 clears `menucmp`, so every nested
671    // `before_complete` (c:462) snapshots a 1.
672    //
673    // In this port `menucmp` reads 0 at the nested `before_complete`
674    // (measured: menucmp=1 oldmenucmp=0 ifm=-1 at the gate) and
675    // `do_ambig_menu` only re-raises it later, so the gate opened and
676    // the `ret >= 2` arm below ran `foredel` + `inststr(origline)` —
677    // throwing away the match the same completion had just inserted.
678    // That is why `menu select interactive` reported the typed
679    // characters instead of the completion: the status line read the
680    // line AFTER it had been reverted (`interactive: /s[]` where zsh
681    // shows `interactive: /sbin[]`).
682    //
683    // The same `iforcemenu != -1` test is what C uses everywhere else to
684    // mean "not driven by the interactive menu-select widget" (c:763,
685    // c:832, c:1381, c:1437), so state it directly here.
686    if menucmp_v == 0 || oldmenucmp_v != 0 || iforcemenu.load(Ordering::Relaxed) == -1 {
687        return 0; // c:535
688    }
689
690    // c:506-517 — build chdata. cdat.matches=amatches, cdat.num=
691    //              nmatches, cdat.nmesg=nmessages, cdat.cur=NULL. The
692    //              Rust hook dispatch path doesn't yet thread chdata
693    //              into shell-fn args (handlers in the standard zsh
694    //              distribution all read directly from compsys globals
695    //              via $compstate). The fields above are still tracked
696    //              via amatches/nmatches/nmessages globals and visible
697    //              to handlers through the normal completion-state
698    //              parameter reads.
699
700    // c:518 — `runhookdef(MENUSTARTHOOK, &cdat)`. Canonical dispatch
701    // via `gethookdef("menu_start") + runhookdef(h, &cdat)`. Returns
702    // 0 when no Hookfn is registered (matches c:993-995: empty funcs
703    // and h->def NULL → return 0).
704    let mut ret: i32 = 0;
705    let h_menu_start = gethookdef("menu_start");
706    if !h_menu_start.is_null() {
707        ret = runhookdef(h_menu_start, std::ptr::null_mut());
708    }
709
710    if ret == 0 {
711        return 0; // c:535
712    }
713
714    // c:519 — `dat[1] = 0`. The C caller passes a 2-int array; index 1
715    // carries the menu-acceptance flag for the outer compfunc loop.
716    if dat.len() > 1 {
717        dat[1] = 0;
718    }
719    // c:520 — `menucmp = menuacc = 0`.
720    MENUCMP.store(0, Ordering::Relaxed);
721    menuacc.store(0, Ordering::Relaxed);
722    // c:521 — `minfo.cur = NULL`.
723    if let Some(m) = MINFO.get() {
724        if let Ok(mut mi) = m.lock() {
725            mi.cur = None;
726        }
727    }
728
729    if ret >= 2 {
730        // c:522
731        // c:523 — `fixsuffix()`.
732        fixsuffix();
733        // c:524 — `zlemetacs = 0`.
734        ZLEMETACS.store(0, Ordering::Relaxed);
735        // c:525 — `foredel(zlemetall, CUT_RAW)` removes the entire line.
736        let metall = ZLEMETALL.load(Ordering::Relaxed);
737        foredel(metall, CUT_RAW);
738        // c:526 — `inststr(origline)` reinserts the pre-completion buffer.
739        let origline_v: String = ORIGLINE
740            .get()
741            .and_then(|m| m.lock().ok().map(|g| g.clone()))
742            .unwrap_or_default();
743        let _ = inststr(&origline_v);
744        // c:527 — `zlemetacs = origcs`.
745        let origcs_v = ORIGCS.load(Ordering::Relaxed);
746        ZLEMETACS.store(origcs_v, Ordering::Relaxed);
747
748        if ret == 2 {
749            // c:528
750            // c:529 — `clearlist = 1`.
751            CLEARLIST.store(1, Ordering::Relaxed);
752            // c:530 — `invalidatelist()`.
753            invalidatelist();
754        }
755    }
756
757    0 // c:535
758}
759
760// =====================================================================
761// callcompfunc — `Src/Zle/compcore.c:544`.
762// =====================================================================
763
764/// Port of `static void callcompfunc(char *s, char *fn)` from
765/// compcore.c:544. Selects the `$compstate[context]` value, then
766/// dispatches into the user shell function `fn`. The `comprpms`/
767/// `compkpms` slots are modelled by the `$compstate` hash storage
768/// (a key's PM_UNSET bit is spelled as removing the entry).
769pub fn callcompfunc(s: &str, fn_name: &str) {
770    tracing::debug!(target: "compsys_args", %s, %fn_name, "callcompfunc ENTER");
771    // c:544
772
773    if fn_name.is_empty() {
774        return;
775    } // c:552 getshfunc(NULL)
776      // Re-assert the `$module_path` compiled default at completion entry IF it
777      // has gone empty. `MODULE_PATH` is PM_DONTIMPORT (no env var seeds it), and
778      // its array half is not re-derived when the completion widget scope
779      // re-establishes the tied colon-arrays the way PATH/FPATH are from the
780      // environment — so `$module_path` reads empty inside completers, breaking
781      // every `_files -W module_path` (e.g. `zmodload <tab>`). Only restore when
782      // empty so a user's `module_path+=(…)` customization is preserved.
783      // module_path_init is idempotent (OnceLock-cached MODULE_DIR).
784    if crate::ported::params::getaparam("module_path")
785        .map(|a| a.is_empty())
786        .unwrap_or(true)
787    {
788        crate::ported::init::module_path_init();
789    }
790    let _lv = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:548 int lv = lastval
791    let _icf = INCOMPFUNC.load(Ordering::Relaxed); // c:555
792    let _osc = crate::ported::builtin::SFCONTEXT.load(Ordering::Relaxed); // c:555
793
794    let _useglob = USEGLOB.load(Ordering::Relaxed); // c:579
795
796    // c:561-563 — `kset = CP_ALLKEYS & ~(CP_PARAMETER | CP_REDIRECT |
797    // CP_QUOTE | CP_QUOTING | CP_EXACTSTR | CP_OLDLIST | CP_OLDINS | …)`,
798    // handed to `comp_setunset(…, kset, ~kset & CP_ALLKEYS)` at c:818.
799    // Every cleared bit raises PM_UNSET on that key's `compkpms` slot
800    // (complete.c:1557-1558), and a PM_UNSET param is skipped by every
801    // hash scan — so `${(@kv)compstate}`, and therefore `_lastcomp`
802    // (`_main_complete` sh:407), carries no entry for it at all.
803    //
804    // zshrs's assoc backing is a flat name→map with no per-key flag bits,
805    // so the equivalent of raising PM_UNSET is removing the entry. The
806    // publishes below used to write "" for these keys instead, which is a
807    // different observable state: present, with an empty value.
808    let kunset = |key: &str| {
809        // c:complete.c:1558 — `(*p)->node.flags |= PM_UNSET`.
810        if let Ok(mut tab) = paramtab_hashed_storage().lock() {
811            if let Some(hash) = tab.get_mut(crate::ported::zle::complete::COMPSTATENAME) {
812                hash.remove(key);
813            }
814        }
815        crate::ported::params::unsetparam(&format!("compstate[{}]", key));
816    };
817    // c:562 — `CP_EXACTSTR` is one of the bits cleared out of `kset`, so
818    // `$compstate[exact_string]` starts the round UNSET; only a later
819    // exact match publishes it (c:3046-3055, mirrored at the
820    // `set_compstate_str("exact_string", …)` site below). do_completion's
821    // c:312 `compexactstr = ""` resets the GLOBAL, not the param's set
822    // bit — the port's matching publish left the key present-and-empty,
823    // so `_lastcomp` carried an `exact_string` entry zsh does not have
824    // whenever the round found no exact match.
825    kunset("exact_string"); // c:562
826
827    // c:667-693 — `compquote` / `compquoting` from the quote state
828    // `get_comp_string` recorded. These ARE `$compstate[quote]` and
829    // `$compstate[quoting]` (complete.c:1276-1277). Neither was ported, so
830    // both read empty for every completion — `_main_complete`'s
831    // `[[ -n $compstate[quote] ]]` branches, `_path_files`'s quoting
832    // decisions and `addmatches`'s own c:2139 quote block all behaved as
833    // if nothing were ever quoted.
834    {
835        use crate::ported::zle::complete::{COMPQUOTE, COMPQUOTING};
836        let instring = INSTRING.load(Ordering::Relaxed);
837        let (cq, cqg): (&str, &str) = if instring > QT_BACKSLASH {
838            // c:669
839            match instring {
840                QT_SINGLE => ("'", "single"),    // c:671-674
841                QT_DOUBLE => ("\"", "double"),   // c:676-679
842                QT_DOLLARS => ("$'", "dollars"), // c:681-684
843                _ => ("", ""),
844            }
845        } else if INBACKT.load(Ordering::Relaxed) != 0 {
846            ("`", "backtick") // c:687-689
847        } else {
848            ("", "") // c:691-693
849        };
850        for (global, v) in [(&COMPQUOTE, cq), (&COMPQUOTING, cqg)] {
851            if let Ok(mut g) = global.get_or_init(|| Mutex::new(String::new())).lock() {
852                *g = v.to_string();
853            }
854        }
855        // The `$compstate` entries are gsu VIEWS onto those globals in C;
856        // this port has to publish them explicitly.
857        //
858        // c:561-563 — `CP_QUOTE | CP_QUOTING` start cleared in `kset`;
859        // only the two quoted arms (c:686, c:690) raise them. The
860        // unquoted arm at c:691-693 leaves the globals empty AND the
861        // params unset, so the keys must disappear rather than appear
862        // with an empty value.
863        if cq.is_empty() && cqg.is_empty() {
864            kunset("quote"); // c:562
865            kunset("quoting"); // c:562
866        } else {
867            set_compstate_str("quote", cq); // complete.c:1276, c:686/690
868            set_compstate_str("quoting", cqg); // complete.c:1277, c:686/690
869        }
870    }
871
872    // Publish the completion word split at the cursor into the
873    // `$PREFIX` / `$SUFFIX` params (+ empty ignored-prefix/suffix). In C
874    // these are gsu-bound to `compprefix`/`compsuffix`; the Rust ports
875    // have no gsu binding, so without this every completer reads
876    // `$PREFIX=''` — `_main_complete`'s `compset -P 1 '='` then matches
877    // the empty prefix and wrongly forces `$compstate[context]=equal`,
878    // and `_path_files` has no prefix to glob. The word is split the way
879    // c:699-718 splits it — whole-word under `unset(COMPLETEINWORD)`, else
880    // at `OFFS` (zlemetacs - wb), the cursor offset within the word.
881    {
882        // c:699-718 — the compprefix/compsuffix split. C branches on
883        // `unset(COMPLETEINWORD)` FIRST: with the option OFF (the default)
884        // the WHOLE word is the prefix and the suffix is EMPTY — completion
885        // runs from the end of the word whatever column the cursor sits in;
886        // only with completeinword SET is the word split at `offs`:
887        //     if (unset(COMPLETEINWORD)) {
888        //         tmp = (linwhat == IN_MATH ? dupstring(s) : multiquote(s, 0));
889        //         untokenize(tmp);
890        //         compprefix = ztrdup(tmp);
891        //         compsuffix = ztrdup("");
892        //     } else { … split at s + offs … }
893        // The port applied the split unconditionally, i.e. completeinword
894        // semantics for everyone, so a mid-word TAB completed against the
895        // text left of the cursor instead of the whole word. The sibling
896        // site (`set_comp_sep`, c:1892-1906) already carries this branch —
897        // compcore.rs:2861-2868.
898        //
899        // c:700/711/715 wrap the word in `multiquote(s, 0)` before publishing
900        // it, and c:701/712/716 `untokenize` the result. That pair is NOT a
901        // no-op: `do_completion` seeds `$compqstack` with one element on every
902        // completion (c:301-308, compcore.rs:100-112) — the quote the word sits
903        // inside, or `QT_BACKSLASH` when it sits inside none — so every
904        // character the user QUOTED gets its quoting put back before the
905        // completion function sees `$PREFIX`.
906        //
907        // It only comes out right on a TOKENIZED word: `quotestring` passes a
908        // parser token straight through (utils.c:6392), so a live glob `*` (the
909        // token `Star`) stays bare while a `*` the user escaped — a plain `*`
910        // in the word by then — gets its backslash back. Handing it the
911        // UNTOKENIZED word instead escapes the LIVE metacharacters and
912        // publishes `\*` for `ls *`, `\*\(` for `ls *(`, `\$\{\(` for
913        // `echo ${(`.
914        //
915        // This port untokenizes at `get_comp_string`'s return, so the tokenized
916        // twin travels separately in `crate::comp_word_tok` (published by
917        // `makecomplist`, narrowed the same way `s` is). Every accessor there
918        // is fallible: `None` means no usable twin and the untokenized word is
919        // published as before.
920        //
921        // Without this, `echo "$PATH <TAB>` published `$PATH ` where zsh
922        // publishes `$PATH\ `. The word holds a real token (the `$`), so
923        // `get_comp_string` leaves `instring` at `QT_NONE`
924        // (zle_tricky.c:1729-1731 — `has_real_token(s + 1)` fails the
925        // quote-form test), the qstack head is therefore `QT_BACKSLASH`, and
926        // the space inside the unterminated `"` is a literal that has to be
927        // re-quoted with a backslash. `_expand` rebuilt its expansion from the
928        // unescaped word and produced none, so the second TAB listed
929        // "No Matches" where zsh lists the expansions.
930        let word_tok = crate::comp_word_tok::get();
931        let in_math = linwhat.load(Ordering::Relaxed) == IN_MATH_LW;
932        // c:698 — `makebangspecial(0)`. Clears ISPECIAL on `bangchar` for the
933        // duration of the `multiquote` calls below, restored at c:719. The
934        // completion word is not history-expanded, so a `!` in it must not be
935        // re-quoted as `\!` when it is published into `$PREFIX`/`$SUFFIX` —
936        // even though the SAME `quotestring` must escape it everywhere else in
937        // an interactive shell.
938        //
939        // This is a no-op unless something actually reads the bit;
940        // `quotestring`'s `ispecial` only started doing so in a103e66b60,
941        // which is why wiring it now matters and did not before.
942        crate::ported::utils::makebangspecial(false);
943        let (pre, suf) = if !isset(crate::ported::zsh_h::COMPLETEINWORD) {
944            // c:699
945            // c:700-703 — `tmp = (linwhat == IN_MATH ? dupstring(s)
946            //                                        : multiquote(s, 0));`
947            let pre = if in_math {
948                s.to_string()
949            } else {
950                // The twin is usable only when it IS `s` tokenized. The
951                // completeinword arm below gets that check from `span`; this
952                // arm took the twin unchecked, so a twin left by an earlier
953                // completion published that completion's word as `$PREFIX`.
954                let whole = word_tok
955                    .as_deref()
956                    .filter(|t| crate::ported::lex::untokenize(t) == s);
957                crate::comp_word_tok::multiquoted(whole)
958                    .unwrap_or_else(|| s.to_string())
959            };
960            (pre, String::new())
961        } else {
962            // c:704
963            let scs: Vec<char> = s.chars().collect();
964            let off = (OFFS.load(Ordering::Relaxed).max(0) as usize).min(scs.len()); // c:707
965            let hd: String = scs[..off].iter().collect(); // c:709-713
966            let tl: String = scs[off..].iter().collect(); // c:714-717
967            if in_math {
968                // c:711 `dupstring_wlen(s, offs)` / c:715 `dupstring(ss)`.
969                (hd, tl)
970            } else {
971                // c:711/715 — the same split, each half through `multiquote`.
972                let (htok, ttok) = match word_tok.as_deref() {
973                    Some(t) => (
974                        crate::comp_word_tok::span(t, 0, hd.len()),
975                        crate::comp_word_tok::span(t, hd.len(), tl.len()),
976                    ),
977                    None => (None, None),
978                };
979                (
980                    crate::comp_word_tok::multiquoted(htok.as_deref()).unwrap_or(hd),
981                    crate::comp_word_tok::multiquoted(ttok.as_deref()).unwrap_or(tl),
982                )
983            }
984        };
985        // c:719 — `makebangspecial(1)`. Restores the bit, but only if
986        // `inittyptab` had stored `ZTF_BANGCHAR` (utils.c:4291); in a
987        // non-interactive shell it correctly stays clear.
988        crate::ported::utils::makebangspecial(true);
989        let _ = crate::ported::params::setsparam("PREFIX", &pre);
990        let _ = crate::ported::params::setsparam("SUFFIX", &suf);
991        // c:724-741 — `$IPREFIX` / `$ISUFFIX`.
992        //
993        //     zsfree(compiprefix); zsfree(compisuffix);
994        //     if (parwb < 0) { compiprefix = ztrdup(""); compisuffix = ztrdup(""); }
995        //     else {
996        //         compiprefix = zalloc((l = wb - parwb) + 1);
997        //         memcpy(compiprefix, zlemetaline + parwb, l);
998        //         compisuffix = zalloc((l = parwe - we) + 1);
999        //         memcpy(compisuffix, zlemetaline + we, l);
1000        //         wb = parwb; we = parwe; offs = paroffs;
1001        //     }
1002        //
1003        // `makecomplist` (c:952-957) parks the word's ORIGINAL boundaries in
1004        // `parwb`/`parwe`/`paroffs` before `check_param` narrows `wb`/`we`/
1005        // `offs` down to the parameter NAME, so the two spans differ by
1006        // exactly the sigil (`$`, `${`, `${(flags)`) and by the `}` plus any
1007        // modifiers on the other side. That difference IS `$IPREFIX` /
1008        // `$ISUFFIX`.
1009        //
1010        // Both were hardcoded to the empty string here, which dropped the
1011        // whole block: `echo $PATH<TAB>` published `IPREFIX=''` where zsh
1012        // publishes `IPREFIX='$'`. Anything that rebuilds the word from
1013        // `$IPREFIX$PREFIX$SUFFIX$ISUFFIX` then lost the sigil — `_expand`
1014        // (Completion/Base/Completer/_expand:22) saw the word as `PATH`, its
1015        // substitution step produced `PATH` again, and sh:128's
1016        // "expansion equals the word" test returned 1, so the completer
1017        // emitted no expansions at all.
1018        let (ipre_v, isuf_v) = if PARWB.load(Ordering::Relaxed) < 0 {
1019            (String::new(), String::new()) // c:727-728
1020        } else {
1021            let line = ZLEMETALINE
1022                .get_or_init(|| Mutex::new(String::new()))
1023                .lock()
1024                .map(|g| g.clone())
1025                .unwrap_or_default();
1026            let parwb = PARWB.load(Ordering::Relaxed).max(0) as usize;
1027            let parwe = PARWE.load(Ordering::Relaxed).max(0) as usize;
1028            let wb = WB.load(Ordering::Relaxed).max(0) as usize;
1029            let we = WE.load(Ordering::Relaxed).max(0) as usize;
1030            // `get` returns None on an out-of-range or non-char-boundary
1031            // span; C's memcpy cannot fail because it copies bytes out of a
1032            // buffer it already sized, so the fallback here is the empty
1033            // string — the same value the `parwb < 0` arm uses.
1034            let ip = line.get(parwb..wb).unwrap_or("").to_string(); // c:732-734
1035            let is = line.get(we..parwe).unwrap_or("").to_string(); // c:735-737
1036            WB.store(PARWB.load(Ordering::Relaxed), Ordering::Relaxed); // c:739
1037            WE.store(PARWE.load(Ordering::Relaxed), Ordering::Relaxed); // c:740
1038            OFFS.store(PAROFFS.load(Ordering::Relaxed), Ordering::Relaxed); // c:741
1039            (ip, is)
1040        };
1041        let _ = crate::ported::params::setsparam("IPREFIX", &ipre_v);
1042        let _ = crate::ported::params::setsparam("ISUFFIX", &isuf_v);
1043        // c:742-745 — `compqiprefix = ztrdup(qipre ? qipre : "");
1044        //              compqisuffix = ztrdup(qisuf ? qisuf : "");`
1045        // `compqiprefix`/`compqisuffix` ARE `$QIPREFIX`/`$QISUFFIX`
1046        // (complete.c:1266-1267), and `qipre`/`qisuf` are what
1047        // `get_comp_string` (zle_tricky.c:1753-1766) filled in with the
1048        // opening/closing quote of the word being completed. The port
1049        // hardcoded both to "" here, so `$QIPREFIX` was permanently empty:
1050        // completing inside `"…"` / `'…'` / `$'…'` dropped the opening
1051        // quote off the command line and every `$QIPREFIX`-testing
1052        // completer took its unquoted branch.
1053        crate::vm_helper::set_readonly_special(
1054            "QIPREFIX",
1055            &crate::ported::zle::zle_tricky::qipre_get(),
1056        ); // c:743
1057        crate::vm_helper::set_readonly_special(
1058            "QISUFFIX",
1059            &crate::ported::zle::zle_tricky::qisuf_get(),
1060        ); // c:745
1061           // c:complete.c:1235-1295 — in C these params ARE `compprefix`/
1062           // `compsuffix`/`compiprefix`/`compisuffix` (gsu-bound, one
1063           // storage), so the publish above resets the globals too. The Rust
1064           // compparams have no gsu binding, so the globals kept the PREVIOUS
1065           // call's values — and `expand-or-complete` calls this twice per
1066           // TAB. Mirror the reset onto the globals. (Same block as
1067           // addmatches below and bin_compfiles -p/-P in computil.rs.)
1068        for (param, global) in [
1069            ("PREFIX", &COMPPREFIX),
1070            ("SUFFIX", &COMPSUFFIX),
1071            ("IPREFIX", &COMPIPREFIX),
1072            ("ISUFFIX", &crate::ported::zle::complete::COMPISUFFIX),
1073        ] {
1074            if let Some(v) = crate::ported::params::getsparam(param) {
1075                if let Ok(mut g) = global.get_or_init(|| Mutex::new(String::new())).lock() {
1076                    *g = v;
1077                }
1078            }
1079        }
1080
1081        // c:720-723 — `zsfree(complastprefix); zsfree(complastsuffix);
1082        //              complastprefix = ztrdup(compprefix);
1083        //              complastsuffix = ztrdup(compsuffix);`.
1084        // Assigned from the compprefix/compsuffix GLOBALS the block above
1085        // just published — which is literally what C copies — rather than
1086        // from a second, locally recomputed split that can only agree with
1087        // them by accident. Taken here (before the completion fn runs and
1088        // before any `compset -P/-S` strips $PREFIX/$SUFFIX) so
1089        // `domenuselect`'s interactive status line renders the search
1090        // buffer as `interactive: <prefix>[]<suffix>` (setmstatus,
1091        // complist.c:2234-2235).
1092        for (src, dst) in [
1093            (&COMPPREFIX, &crate::ported::zle::complete::COMPLASTPREFIX),
1094            (&COMPSUFFIX, &crate::ported::zle::complete::COMPLASTSUFFIX),
1095        ] {
1096            let v = src
1097                .get_or_init(|| Mutex::new(String::new()))
1098                .lock()
1099                .map(|g| g.clone())
1100                .unwrap_or_default();
1101            if let Ok(mut g) = dst.get_or_init(|| Mutex::new(String::new())).lock() {
1102                *g = v; // c:722-723
1103            }
1104        }
1105
1106        // c:743-751 — `compqiprefix = qipre; compqisuffix = qisuf;
1107        //   origlpre = strlen(compqiprefix)+strlen(compiprefix)+strlen(compprefix);
1108        //   origlsuf = strlen(compqisuffix)+strlen(compisuffix)+strlen(compsuffix);
1109        //   lenchanged = 0;`
1110        // `origlpre`/`origlsuf` record how long the prefix/suffix were when the
1111        // completion widget was entered; `addmatches` (c:2252-2254) compares the
1112        // CURRENT lengths against them and sets `lenchanged` when a completer has
1113        // moved the PREFIX/SUFFIX split (`compset -P/-S`, `_approximate`,
1114        // `_prefix`). `do_ambiguous` (compresult.c:794) reads that flag: with the
1115        // split moved it must NOT put the old word back when the unambiguous
1116        // string comes out short. All three were declared but never assigned, so
1117        // the flag was permanently 0 and `ls **/<TAB><TAB>s` restored `**/s` where
1118        // zsh leaves the word deleted (`interactive: []`).
1119        //
1120        // `compqiprefix`/`compqisuffix` ARE `$QIPREFIX`/`$QISUFFIX` in C
1121        // (gsu-bound, complete.c) — read back the values the publish above set
1122        // rather than a second, independently derived copy.
1123        {
1124            use crate::ported::zle::complete::{COMPISUFFIX, COMPQIPREFIX, COMPQISUFFIX};
1125            let qip = crate::ported::params::getsparam("QIPREFIX").unwrap_or_default(); // c:744
1126            let qis = crate::ported::params::getsparam("QISUFFIX").unwrap_or_default(); // c:746
1127            for (global, v) in [(&COMPQIPREFIX, &qip), (&COMPQISUFFIX, &qis)] {
1128                if let Ok(mut g) = global.get_or_init(|| Mutex::new(String::new())).lock() {
1129                    *g = v.clone();
1130                }
1131            }
1132            let glen = |g: &std::sync::OnceLock<Mutex<String>>| -> usize {
1133                g.get_or_init(|| Mutex::new(String::new()))
1134                    .lock()
1135                    .map(|s| s.len())
1136                    .unwrap_or(0)
1137            };
1138            origlpre.store(
1139                (qip.len() + glen(&COMPIPREFIX) + glen(&COMPPREFIX)) as i32,
1140                Ordering::Relaxed,
1141            ); // c:747-748
1142            origlsuf.store(
1143                (qis.len() + glen(&COMPISUFFIX) + glen(&COMPSUFFIX)) as i32,
1144                Ordering::Relaxed,
1145            ); // c:749-750
1146            lenchanged.store(0, Ordering::Relaxed); // c:751
1147        }
1148    }
1149
1150    // c:591-617 — context selection.
1151    let context = compcontext_for(s); // c:591-617
1152    tracing::debug!(
1153        target: "compsys_args",
1154        %context,
1155        linwhat = linwhat.load(Ordering::Relaxed),
1156        ispar = ispar.load(Ordering::Relaxed),
1157        "callcompfunc context"
1158    );
1159    set_compstate_str("context", &context); // c:619
1160
1161    // c:577 — `compparameter = compredirect = ""`, then c:586 (subscript),
1162    // c:607 (IN_ENV value / array_value) overwrite it with `varname`, the
1163    // parameter name `get_comp_string` split off the line. This publish was
1164    // missing entirely, so `$compstate[parameter]` kept whatever a previous
1165    // completion left: `_value` dispatched `-value-,,-default-` instead of
1166    // `-value-,PATH,-default-` and never reached the per-parameter completer.
1167    let varname = || {
1168        crate::ported::zle::zle_tricky::VARNAME
1169            .get_or_init(|| Mutex::new(None))
1170            .lock()
1171            .ok()
1172            .and_then(|g| g.clone())
1173            .unwrap_or_default()
1174    };
1175    let compparameter = match context.as_str() {
1176        "subscript" if linwhat.load(Ordering::Relaxed) == IN_MATH_LW => varname(), // c:585-588
1177        "value" | "array_value" if linwhat.load(Ordering::Relaxed) == IN_ENV_LW => varname(), // c:607
1178        // c:628-629 — the default (no-command-word) `value` arm names
1179        // the parameter from the FIRST word on the line, not `varname`.
1180        "value" => crate::ported::zle::zle_tricky::CLWORDS
1181            .lock()
1182            .ok()
1183            .and_then(|g| g.first().cloned())
1184            .unwrap_or_default(),
1185        _ => String::new(), // c:577
1186    };
1187    // c:561-563 — `kset = CP_ALLKEYS & ~(CP_PARAMETER | …)`: the key
1188    // starts UNSET and only c:586 / c:594 / c:607 / c:626 raise
1189    // `kset |= CP_PARAMETER`, which is exactly the set of arms that give
1190    // `compparameter` a name. Publishing "" instead left the key present
1191    // in `${(@kv)compstate}` (and so in `_lastcomp`) where zsh has no
1192    // entry at all.
1193    if compparameter.is_empty() {
1194        kunset("parameter"); // c:562
1195    } else {
1196        set_compstate_str("parameter", &compparameter);
1197    }
1198
1199    // c:598-602 — `compcontext = "redirect"; if (rdstr) compredirect =
1200    // rdstr;`. `compredirect` is `$compstate[redirect]` (complete.c:1265)
1201    // and was never written by this port, so `_redirect` had nothing to
1202    // dispatch on and `_expand`'s multios branch (sh:236) saw an empty
1203    // operator. Reset to "" (c:577) in every other context.
1204    let compredirect = if context == "redirect" {
1205        crate::ported::zle::zle_tricky::RDSTR
1206            .lock()
1207            .ok()
1208            .and_then(|g| g.clone())
1209            .unwrap_or_default() // c:600-601
1210    } else {
1211        String::new() // c:577
1212    };
1213    // c:561-563 / c:601 — `CP_REDIRECT` likewise starts cleared and is
1214    // raised only by the `redirect` context arm.
1215    if compredirect.is_empty() {
1216        kunset("redirect"); // c:562
1217    } else {
1218        set_compstate_str("redirect", &compredirect); // c:601
1219    }
1220    // C binds `compredirect` to `$compstate[redirect]` through one gsu
1221    // storage; this port keeps the global and the param separate, so
1222    // mirror the write (same pattern as PREFIX/COMPPREFIX above).
1223    if let Ok(mut g) = crate::ported::zle::complete::COMPREDIRECT
1224        .get_or_init(|| Mutex::new(String::new()))
1225        .lock()
1226    {
1227        *g = compredirect;
1228    }
1229
1230    // c:648-653 — `compredirs = zlinklist2array(rdstrs, 1)`, published as
1231    // the `redirections` real-param (complete.c:1250). One entry per
1232    // COMPLETED redirection on the line, each `<op>:<target>`.
1233    setaparam(
1234        "redirections",
1235        crate::ported::zle::zle_tricky::RDSTRS
1236            .lock()
1237            .map(|g| g.clone())
1238            .unwrap_or_default(),
1239    );
1240
1241    // c:634-645 — `if (compwords) freearray(compwords); if (usea && …)
1242    // { compwords = copy of clwords } else compwords = empty`. C rebuilds
1243    // `$words` from the parsed line on EVERY call, which is what makes the
1244    // SECOND completion pass of `expand-or-complete` (zle_tricky.c:851)
1245    // see the full command line again — `get_comp_string` runs only once
1246    // per TAB. This port was missing the rebuild, so a first pass that
1247    // restricted `$words` (any `_arguments` spec with a `*::`/`*:::` rest
1248    // argument calls `comparguments -W` → restrict_range) left the second
1249    // pass with an empty word array: no command word, no completer
1250    // dispatch, and every match from the first pass discarded.
1251    //
1252    // `usea` (c:590) is 0 only in the math context; C's `aadd` sub-case
1253    // (parameter-subscript, c:626-630) needs `varname`, which this port
1254    // does not compute yet — it is treated as 0 here, exactly as before.
1255    {
1256        use crate::ported::zle::complete::{COMPCURRENT, COMPWORDS};
1257        let usea = linwhat.load(Ordering::Relaxed) != IN_MATH_LW;
1258        let ws: Vec<String> = if usea {
1259            crate::ported::zle::zle_tricky::CLWORDS
1260                .lock()
1261                .map(|g| g.clone())
1262                .unwrap_or_default() // c:639-643
1263        } else {
1264            Vec::new() // c:645
1265        };
1266        let n = ws.len() as i32;
1267        // c:751 — `compcurrent = (usea ? (clwpos + 1 - aadd) : 0)`. Like
1268        // `compwords`, this is RECOMPUTED per call, never carried over: the
1269        // first pass' `comparguments -W` shifts it down to the restricted
1270        // range, and reusing that value left the second pass pointing at
1271        // the command word. `clwpos < 0` means the cursor sits past the
1272        // last word (fresh trailing word) — same guard as the publish site
1273        // in get_comp_string.
1274        let clwpos = crate::ported::zle::zle_tricky::CLWPOS.load(Ordering::Relaxed);
1275        let cur = if !usea {
1276            0 // c:751 — math context: no words, no current
1277        } else if clwpos < 0 {
1278            n + 1
1279        } else {
1280            (clwpos + 1).max(1)
1281        };
1282        if let Ok(mut g) = COMPWORDS.get_or_init(|| Mutex::new(Vec::new())).lock() {
1283            *g = ws.clone();
1284        }
1285        COMPCURRENT.store(cur, Ordering::Relaxed);
1286        // zshrs bridge: `$words`/`$CURRENT` are plain paramtab copies here
1287        // (C binds them to the globals via gsu), so the rebuild has to
1288        // reach the params too — see get_comp_string's publish site.
1289        setaparam("words", ws);
1290        let _ = crate::ported::params::setiparam("CURRENT", cur as i64);
1291    }
1292
1293    // c:571-572 —
1294    // ```c
1295    //     if (!*complastprompt)
1296    //         kset &= ~CP_LASTPROMPT;
1297    // ```
1298    // C only READS `complastprompt` here (to drop CP_LASTPROMPT from the
1299    // "keys the completion function may set" mask); it never writes it.
1300    // The single writer is do_completion at c:325,
1301    // `complastprompt = ztrdup(isset(ALWAYSLASTPROMPT) ? "yes" : "")`,
1302    // ported at compcore.rs:200-208.
1303    //
1304    // This site used to WRITE `$compstate[last_prompt]` back from
1305    // `dolastprompt` (which do_completion has just set to 1 at c:326),
1306    // which stomped the "" that NO_ALWAYS_LAST_PROMPT had just stored.
1307    // addmatch's `if (!complastprompt || !*complastprompt) dolastprompt = 0`
1308    // (c:3014-3015) then never fired, so `dolastprompt` stayed 1,
1309    // `clearflag` came out 1 in asklist (c:1925) / compprintlist (c:2061),
1310    // and zrefresh's reset frame took the `if (clearflag)` branch at
1311    // c:1168-1172 (`\r` + `moveto(0, lpromptw)`) instead of the
1312    // `!clearflag` branch at c:1146-1167 (TCCLEAREOD + `zputs(lpromptbuf)`)
1313    // — so after a completion listing the prompt was never repainted.
1314    // `kset` is not materialised in this port (it is only used
1315    // descriptively, see c:561-563 above), so the C statement has no
1316    // representable effect beyond the read.
1317    let _complastprompt_isset = !get_compstate_str("last_prompt")
1318        .unwrap_or_default()
1319        .is_empty(); // c:571
1320
1321    // c:753-765 — `$compstate[list]` is REBUILT here from `uselist`, it is
1322    // not the value do_completion left in `complist` at c:327-330:
1323    //
1324    //     switch (uselist) { case 0: ""; 1: "list"; 2: "autolist";
1325    //                        3: "ambiguous"; }
1326    //     if (isset(LISTPACKED))   complist = dyncat(complist, " packed");
1327    //     if (isset(LISTROWSFIRST)) complist = dyncat(complist, " rows");
1328    //
1329    // The port published only the do_completion half ("packed"/"rows"), so
1330    // the leading state word was never there: `_main_complete` and friends
1331    // test `$compstate[list]` for `list`/`autolist`/`ambiguous` and always
1332    // read them as absent. Write the rebuilt value to BOTH the param and the
1333    // `complist` global, which is one storage in C (gsu-bound) and is what
1334    // `addmatch` reads at c:2048-2050 for CMF_PACKED/CMF_ROWS.
1335    let mut cl_value = match uselist.load(Ordering::Relaxed) {
1336        0 => String::new(),           // c:755
1337        1 => "list".to_string(),      // c:756
1338        2 => "autolist".to_string(),  // c:757
1339        3 => "ambiguous".to_string(), // c:758
1340        _ => String::new(),
1341    };
1342    if opt_isset("LISTPACKED") != 0 {
1343        cl_value.push_str(" packed"); // c:761
1344    }
1345    if opt_isset("LISTROWSFIRST") != 0 {
1346        cl_value.push_str(" rows"); // c:763
1347    }
1348    if let Ok(mut g) = COMPLIST.get_or_init(|| Mutex::new(String::new())).lock() {
1349        *g = cl_value.clone(); // c:765
1350    }
1351    set_compstate_str("list", &cl_value); // c:765
1352
1353    // c:767-782 — `$compstate[insert]` per (useline, usemenu).
1354    let ul = useline.load(Ordering::Relaxed);
1355    let um = USEMENU.load(Ordering::Relaxed);
1356    let ins = if ul != 0 {
1357        // c:768-776
1358        match um {
1359            // c:769-772 — `compinsert = (isset(AUTOMENU) ?
1360            //                            "automenu-unambiguous" : "unambiguous");`
1361            //
1362            // AUTO_MENU is on in every emulation by default (options.c:90
1363            // lists it as `OPT_ALL`), so this arm — the one taken by an
1364            // ordinary first TAB — normally yields "automenu-unambiguous",
1365            // NOT the bare "unambiguous" this port hardcoded.
1366            //
1367            // The distinction is not cosmetic: it is the only way the shell
1368            // function layer learns that the next TAB is allowed to start
1369            // menu completion. Completion/Base/Core/_main_complete:302 gates
1370            // the whole MENUSELECT/MENUMODE block on
1371            // `[[ "$compstate[insert]" = *menu* ]]`, which
1372            // "automenu-unambiguous" satisfies and "unambiguous" does not
1373            // (ported at _main_complete.rs:929-931), and
1374            // Base/Completer/_match:53 tests for the value verbatim.
1375            0 => {
1376                if opt_isset("AUTOMENU") != 0 {
1377                    "automenu-unambiguous"
1378                } else {
1379                    "unambiguous"
1380                }
1381            }
1382            1 => "menu",
1383            2 => "automenu",
1384            _ => "",
1385        }
1386    } else {
1387        // c:777-780 — `compinsert = ""; kset &= ~CP_INSERT;`
1388        ""
1389    };
1390    // c:781 — `compinsert = (useline < 0 ? tricat("tab ", "", compinsert)
1391    //                                    : ztrdup(compinsert));`
1392    //
1393    // `useline < 0` is set at c:310 from `wouldinstab`, i.e. TAB was
1394    // pressed with nothing but blanks to its left AND a completion
1395    // widget is installed (zle_tricky.c:192-196). The "tab " prefix is
1396    // the ONLY signal `_main_complete` has for that case: sh:70-79 of
1397    // Completion/Base/Core/_main_complete tests `compstate[insert] =
1398    // tab*` and, with the default `insert-tab yes`, returns 0 before
1399    // any completer runs, so the widget falls through to inserting a
1400    // literal TAB. Dropping the prefix made zshrs run the full
1401    // completer chain on an empty command line, which surfaced every
1402    // diagnostic those completers emit (e.g. a user `_describe` over an
1403    // unset array printing "compdescribe: invalid argument") onto a
1404    // prompt where zsh prints nothing at all.
1405    let ins = if ul < 0 {
1406        format!("tab {}", ins) // c:781
1407    } else {
1408        ins.to_string()
1409    };
1410    set_compstate_str("insert", &ins); // c:781
1411
1412    // c:785-790 — `$compstate[exact]`:
1413    //     if (useexact) compexact = ztrdup("accept");
1414    //     else { compexact = ztrdup(""); kset &= ~CP_EXACT; }
1415    // Outside `kset` the key is PM_UNSET (c:818 comp_setunset), so it is
1416    // not in `${(k)compstate}`. The store has no per-key unset bit, so the
1417    // unset key is represented by its absence; the c:912 read-back treats
1418    // an absent key as the "" published here (useexact stays 0).
1419    if useexact.load(Ordering::Relaxed) != 0 {
1420        set_compstate_str("exact", "accept"); // c:786
1421    } else if let Ok(mut tab) = paramtab_hashed_storage().lock() {
1422        if let Some(h) = tab.get_mut("compstate") {
1423            h.shift_remove("exact"); // c:788-789
1424        }
1425    }
1426
1427    // c:791-794 — `$compstate[to_end]` per movetoend.
1428    set_compstate_str(
1429        "to_end",
1430        if movetoend.load(Ordering::Relaxed) == 1 {
1431            "single"
1432        } else {
1433            "match"
1434        },
1435    );
1436
1437    // c:797-812 — `$compstate[old_list]` / `$compstate[old_insert]`:
1438    //
1439    //     if (hasoldlist && lastpermmnum) {
1440    //         compoldlist = listshown ? "shown" : "yes";
1441    //         if (minfo.cur) { sprintf(buf,"%d",(*minfo.cur)->gnum);
1442    //                          compoldins = buf; }
1443    //         else compoldins = "";
1444    //     } else compoldlist = compoldins = "";
1445    //
1446    // Both publishes were missing, so a completer could never tell that a
1447    // previous list is still around. `_menu` and `_history_complete_word`
1448    // read `$compstate[old_list]` and write back "keep" to reuse it; with
1449    // the entry value absent (and the c:923-925 readback below equally
1450    // absent) the keep round-trip could not work at all.
1451    let hasoldlist_v = hasoldlist.load(Ordering::Relaxed);
1452    let lastpermmnum_v = lastpermmnum.load(Ordering::Relaxed);
1453    if hasoldlist_v != 0 && lastpermmnum_v != 0 {
1454        // c:797
1455        set_compstate_str(
1456            "old_list",
1457            if LISTSHOWN.load(Ordering::Relaxed) != 0 {
1458                "shown" // c:799
1459            } else {
1460                "yes" // c:801
1461            },
1462        );
1463        // c:803-808 — `compoldins = minfo.cur ? (*minfo.cur)->gnum : ""`.
1464        let cur_gnum: Option<i32> = MINFO
1465            .get()
1466            .and_then(|m| m.lock().ok())
1467            .and_then(|m| m.cur.as_ref().map(|c| c.gnum));
1468        match cur_gnum {
1469            // c:806 — `kset |= CP_OLDINS` only in the minfo.cur arm.
1470            Some(g) => set_compstate_str("old_insert", &g.to_string()), // c:804-805
1471            None => kunset("old_insert"),                               // c:808
1472        }
1473    } else {
1474        // c:810 — `compoldlist = compoldins = ""` with CP_OLDLIST /
1475        // CP_OLDINS still cleared from c:562, i.e. both params stay
1476        // PM_UNSET and neither key appears in `${(@kv)compstate}`.
1477        kunset("old_list");
1478        kunset("old_insert");
1479    }
1480
1481    // c:838 — `incompfunc = 1` before invoking the user fn.
1482    INCOMPFUNC.store(1, Ordering::Relaxed); // c:838
1483
1484    // c:828-832 — `largs = newlinklist(); addlinknode(largs,
1485    //   dupstring(fn)); while (*cfargs) addlinknode(largs,
1486    //   dupstring(*p++));`. argv[0] = function name, then the
1487    // wrapper-widget args stored in `cfargs` by `completecall`.
1488    let largs: Vec<String> = {
1489        let mut v = vec![fn_name.to_string()];
1490        if let Ok(cf) = crate::ported::zle::zle_tricky::cfargs.lock() {
1491            v.extend(cf.iter().cloned());
1492        }
1493        v
1494    };
1495
1496    // c:833-834 — `int oxt = isset(XTRACE); opts[XTRACE] = 0;`. Mute
1497    // xtrace during the body so PS4 noise doesn't appear from every
1498    // compsys helper line.
1499    let oxt = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE) as i32;
1500    crate::ported::options::opt_state_set(
1501        &crate::ported::zsh_h::opt_name(crate::ported::zsh_h::XTRACE),
1502        false,
1503    );
1504    let _ = oxt; // c:833 saved for restore at c:836
1505
1506    // c:835 — `cfret = doshfunc(shfunc, largs, 1)`. The body runner
1507    // closure resolves the actual implementation:
1508    //   - If a Rust compsys port is registered for `fn_name` and
1509    //     `backend = "rust"`, run that.
1510    //   - Else autoload + run the upstream shfunc body via the
1511    //     standard dispatch path. dispatch_function_call already
1512    //     wraps the fusevm Chunk in its own doshfunc scope; we
1513    //     intentionally call only the body half here so the C-faithful
1514    //     prologue/epilogue runs exactly once around the body.
1515    let largs_for_body = largs.clone();
1516    let fn_name_owned = fn_name.to_string();
1517
1518    // c:843-925 reads `complist`/`compinsert`/`compexact`/`comptoend`/
1519    // `compoldlist`/`compoldins` AFTER `endparamscope()` (c:838). It can do
1520    // that because in C those are plain globals (complete.c:36-44) and the
1521    // `$compstate` entries are only gsu VIEWS onto them (complete.c:1280-1300
1522    // `VAL(compinsert)` …) — tearing down the parameter cannot touch the
1523    // value.
1524    //
1525    // This port has no gsu binding: the values live in the `compstate`
1526    // parameter itself, which `callcompfunc` stamps PM_SPECIAL|PM_REMOVABLE
1527    // at `locallevel + 1` (the c:816-817 block above), so `doshfunc`'s
1528    // `endparamscope()` DELETES it. Measured with a tracing probe on the
1529    // `cd /<TAB>` round: every key read back `None` after the call — `list`,
1530    // `insert`, `exact` and `to_end` alike.
1531    //
1532    // So snapshot the hash at the END OF THE BODY — still inside the
1533    // function scope, and after the completion function's last write, which
1534    // is exactly the state C's globals hold when it reads them at c:843.
1535    let compstate_end: std::sync::Arc<Mutex<Option<indexmap::IndexMap<String, String>>>> =
1536        std::sync::Arc::new(Mutex::new(None));
1537    let compstate_end_body = std::sync::Arc::clone(&compstate_end);
1538
1539    let body_runner = move || -> i32 {
1540        // c:6042 — `runshfunc(prog, wrappers, name)`. zshrs runs the
1541        // body via either the Rust compsys port (direct fn call) or
1542        // the fusevm Chunk dispatch (via exec accessors).
1543        // BODY ONLY: the enclosing `doshfunc` below is c:835's single
1544        // frame. `dispatch_function_call` wraps a second doshfunc, which
1545        // pushed the completion function onto $funcstack twice —
1546        // `compdef -K _fk …` saw `(_fk2 _fk _fk)` where zsh has
1547        // `(_fk2 _fk)`. run_function_body keeps the Rust-port and autoload
1548        // short-circuits. C convention: largs[0] = fn name, [1..] = argv.
1549        let rc = crate::ported::exec::run_function_body(&fn_name_owned, &largs_for_body[1..])
1550            .unwrap_or_else(|| crate::ported::builtin::LASTVAL.load(Ordering::Relaxed));
1551        // Capture `$compstate` before the enclosing doshfunc scope ends.
1552        if let Ok(tab) = paramtab_hashed_storage().lock() {
1553            if let Some(h) = tab.get("compstate") {
1554                if let Ok(mut g) = compstate_end_body.lock() {
1555                    *g = Some(h.clone());
1556                }
1557            }
1558        }
1559        rc
1560    };
1561
1562    // Look up the real shfunc; if missing we still want doshfunc's
1563    // scope around the Rust port (synth_shf carries just the name).
1564    //
1565    // c:552 / c:835 — C hands `doshfunc` the node `getshfunc(fn)` returned,
1566    // flags included. PM_UNDEFINED is what keeps `doshfunc` from setting
1567    // `scriptname = name` (exec.c:5835-5836) before the autoload lookup, so a
1568    // corrupt `.zwc` met while loading `_main_complete` is reported under the
1569    // CALLER's name (`zsh:2: invalid zwc file:`); hardcoded 0 printed
1570    // `_main_complete: invalid zwc file:`. PM_LOADDIR is dropped for the same
1571    // reason `vm_helper::dispatch_function_call` drops it: it describes a
1572    // `filename` field this synthesized node does not carry.
1573    let real_flags = crate::ported::utils::getshfunc(fn_name)
1574        .map(|f| f.node.flags & !(crate::ported::zsh_h::PM_LOADDIR as i32))
1575        .unwrap_or(0);
1576    let mut synth_shf = crate::ported::zsh_h::shfunc {
1577        node: crate::ported::zsh_h::hashnode {
1578            next: None,
1579            nam: fn_name.to_string(),
1580            flags: real_flags,
1581        },
1582        filename: None,
1583        lineno: 0,
1584        funcdef: None,
1585        redir: None,
1586        sticky: None,
1587        body: None,
1588        redir_text: None,
1589    };
1590    // c:817-819 — `makecompparams(); comp_setunset(rset, …, kset,
1591    // ~kset & CP_ALLKEYS);`. makecompparams creates every compkparams row
1592    // (complete.c:1261-1290) inside `$compstate`; comp_setunset then marks
1593    // the rows outside `kset` PM_UNSET. The rows below are in `kset` on
1594    // every call — the c:562-564 initial mask never excludes them and no
1595    // later c:565-812 statement clears them — so `${(k)compstate}` always
1596    // lists them. Their values come from getters or C globals
1597    // (get_compstate_str), never from a set_compstate_str publish, so
1598    // without this the keys were missing from `${(k)compstate}` and from
1599    // `_lastcomp`. The value stored is only a placeholder: assoc scans
1600    // recompute it (params.rs, the get_compstate_str refresh).
1601    for key in [
1602        "nmatches",              // c:1262 GSU(nmatches_gsu)
1603        "unambiguous",           // c:1275 GSU(unambig_gsu)
1604        "unambiguous_cursor",    // c:1276 GSU(unambig_curs_gsu)
1605        "unambiguous_positions", // c:1278 GSU(unambig_pos_gsu)
1606        "insert_positions",      // c:1280 GSU(insert_pos_gsu)
1607        "list_max",              // c:1282 VAL(complistmax)
1608        "vared",                 // c:1287 VAL(compvared); c:569 only adds the bit
1609        "list_lines",            // c:1288 GSU(listlines_gsu)
1610        "all_quotes",            // c:1289 GSU(compqstack_gsu)
1611        "ignored",               // c:1290 VAL(compignored)
1612    ] {
1613        let present = paramtab_hashed_storage()
1614            .lock()
1615            .map(|t| t.get("compstate").is_some_and(|h| h.contains_key(key)))
1616            .unwrap_or(true);
1617        if !present {
1618            set_compstate_str(key, &get_compstate_str(key).unwrap_or_default());
1619        }
1620    }
1621
1622    // c:816-817 — `startparamscope(); makecompparams();`.
1623    //
1624    // C creates `$words` / `$CURRENT` / `$PREFIX` / `$SUFFIX` /
1625    // `$IPREFIX` / `$ISUFFIX` / `$QIPREFIX` / `$QISUFFIX` /
1626    // `$compstate` here with `createparam(name, type|PM_SPECIAL|
1627    // PM_REMOVABLE|PM_LOCAL)` and then `pm->level = locallevel + 1`
1628    // (addcompparams c:1300-1307, makecompparams c:1348). Their VALUES
1629    // live in C globals reached through a gsu vtable, so creating them
1630    // empty here costs nothing.
1631    //
1632    // zshrs has no gsu binding: the values live in the params, and the
1633    // block above has already written every one of them with
1634    // `setsparam`/`setaparam`/`setiparam` — i.e. at level 0, flagged as
1635    // ordinary scalars. Re-running `createparam` would shadow those
1636    // values with empty ones, so only the scope half of c:817 is
1637    // applied: stamp the level and the special/removable bits onto the
1638    // params that are already in place.
1639    //
1640    // Without this, `${(t)PREFIX}` read `scalar` instead of
1641    // `scalar-local-special` and the names outlived the completion.
1642    // `_parameters` filters candidates with
1643    // `${(@k)parameters[(R)…~*local*]}`, so every one of these was
1644    // offered as a completion for `unset <TAB>`.
1645    //
1646    // `PM_READONLY` (on QIPREFIX/QISUFFIX in the c:1256-1259 table) is
1647    // stamped from each row's own type, the way c:1301 or's `cp->type`
1648    // in. The bit used to be dropped here because a sticky one failed
1649    // the next completion's publish; the publishes that run after this
1650    // stamp now go through `vm_helper::set_readonly_special`, the
1651    // gsu-setfn equivalent C uses (c:1308-1324), which is not subject to
1652    // the assignment gate.
1653    {
1654        use crate::ported::zsh_h::{PM_READONLY, PM_REMOVABLE, PM_SPECIAL};
1655        // c:1307 / c:1348 — `pm->level = locallevel + 1`.
1656        let level = crate::ported::params::locallevel.load(Ordering::Relaxed) + 1;
1657        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
1658            for (name, ty) in crate::ported::zle::complete::COMPRPARAMS
1659                .iter()
1660                .map(|cp| (cp.name, cp.r#type))
1661                .chain([("compstate", 0)])
1662            {
1663                if let Some(pm) = tab.get_mut(name) {
1664                    pm.level = level;
1665                    // c:1301 — the row's own type bits, which is where
1666                    // QIPREFIX/QISUFFIX's PM_READONLY comes from.
1667                    pm.node.flags |= ty & PM_READONLY as i32;
1668                    // c:complete.c:1313-1315 — every comprparams integer
1669                    // row is a `VAL(...)` row (`CURRENT`), so it gets
1670                    // `pm->gsu.i = &compvarinteger_gsu; pm->base = 10;`,
1671                    // which is why `typeset -p CURRENT` prints `-i10`.
1672                    if crate::ported::zsh_h::PM_TYPE(ty as u32)
1673                        == crate::ported::zsh_h::PM_INTEGER
1674                    {
1675                        pm.base = 10;
1676                    }
1677                    // c:1301 — `cp->type | PM_SPECIAL | PM_REMOVABLE |
1678                    // PM_LOCAL`. PM_REMOVABLE is load-bearing:
1679                    // `scanendscope` (params.c:5905) only takes the
1680                    // "restore the shadowed value" branch for
1681                    // `(flags & (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL`.
1682                    // These params have no shadow to restore — they must
1683                    // be deleted, which is the PM_REMOVABLE path.
1684                    pm.node.flags |= (PM_SPECIAL | PM_REMOVABLE) as i32;
1685                }
1686            }
1687        }
1688    }
1689
1690    // c:820 — `makezleparams(1);`.
1691    //
1692    // This line was missing entirely, so EVERY completion function ran
1693    // with no ZLE parameters at all: `$BUFFER`, `$LBUFFER`, `$RBUFFER`,
1694    // `$CURSOR`, `$HISTNO`, `$WIDGET`, `$KEYS`, `$BUFFERLINES`,
1695    // `$PENDING` all read as the empty string. `_fc` computes
1696    // `(( num = num - HISTNO ))` to turn history event numbers into the
1697    // negative offsets it completes, so `fc -<TAB>` offered nothing;
1698    // more broadly any completer that inspects the line it is completing
1699    // (`$BUFFER`/`$CURSOR`) saw an empty line.
1700    crate::ported::zle::zle_params::makezleparams(1); // c:820
1701    {
1702        // c:839 — `endparamscope()` is what tears these down again in C:
1703        // `makezleparams` creates each one PM_SPECIAL|PM_REMOVABLE|
1704        // PM_LOCAL at `locallevel + 1` (zle_params.c:200-206), so leaving
1705        // the completion scope unsets them and they never reach the
1706        // interactive shell.
1707        //
1708        // The Rust `makezleparams` publishes through
1709        // `setsparam`/`setiparam`/`setaparam` (the values live in the
1710        // params, not behind a gsu vtable), which leaves them at the
1711        // enclosing level as ordinary params. Stamp the same level and
1712        // flags the comp params get above so `doshfunc`'s `endparamscope`
1713        // removes them on the way out. Without this the names leaked into
1714        // the interactive shell after the first TAB — `$BUFFER` and
1715        // friends stayed set at the prompt, and `_parameters` offered
1716        // them (its candidate filter is
1717        // `${(@k)parameters[(R)…~*local*]}`).
1718        //
1719        // As with QIPREFIX above, `PM_READONLY` (c:201, `ro` is 1 here)
1720        // is deliberately not stamped: C re-creates the params from
1721        // scratch on every call, so the bit never blocks the next one,
1722        // whereas a bit that outlived the teardown would fail the next
1723        // widget's `makezleparams(0)` publish.
1724        use crate::ported::zsh_h::{PM_REMOVABLE, PM_SPECIAL};
1725        let level = crate::ported::params::locallevel.load(Ordering::Relaxed) + 1;
1726        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
1727            for name in crate::ported::zle::zle_params::ZLEPARAM_NAMES {
1728                if let Some(pm) = tab.get_mut(*name) {
1729                    pm.level = level; // c:206 `pm->level = locallevel + 1`
1730                    pm.node.flags |= (PM_SPECIAL | PM_REMOVABLE) as i32; // c:200
1731                }
1732            }
1733        }
1734    }
1735
1736    let cfret_val = crate::ported::exec::doshfunc(&mut synth_shf, largs, true, body_runner);
1737    crate::ported::zle::zle_tricky::cfret.store(cfret_val, Ordering::Relaxed);
1738
1739    // c:836 — `opts[XTRACE] = oxt;` restore xtrace state.
1740    crate::ported::options::opt_state_set(
1741        &crate::ported::zsh_h::opt_name(crate::ported::zsh_h::XTRACE),
1742        oxt != 0,
1743    );
1744
1745    // c:839-841 — `lastcmd = 0; incompfunc = icf; startauto = 0;`.
1746    // `startauto` is cleared BEFORE the c:908 recompute below; without the
1747    // clear the AUTO_MENU value do_completion stored at c:331 survived any
1748    // completer that emptied `$compstate[insert]`.
1749    startauto.store(0, Ordering::Relaxed); // c:841
1750
1751    // c:843-925 — unwind: read the compstate values the completion function
1752    // may have rewritten back into the compcore globals. In C these ARE the
1753    // globals (the compstate entries are gsu-bound to `complist`,
1754    // `compinsert`, `compexact`, `comptoend`, `compoldlist`, `compoldins`),
1755    // so this is a plain read of a mutated variable; here it is a read of
1756    // `$compstate[…]`.
1757    //
1758    // Only the `usemenu` third of the c:857-907 arm existed before. Every
1759    // other assignment in the block — `uselist`, `forcelist`, `onlyexpl`,
1760    // `useline`, `insmnum`, `insspace`, `startauto`, `useexact`,
1761    // `movetoend`, `oldlist`, `oldins` — was simply absent, so a completion
1762    // function could not influence any of them: `compstate[list]=...force`
1763    // never forced a list, `_menu`'s `compstate[old_list]=keep` never kept
1764    // one, `compstate[insert]=2` never selected the 2nd match, and
1765    // `compstate[insert]=''` never suppressed insertion.
1766    //
1767    // Read `$compstate[…]` via the compstate hash (the canonical home), NOT
1768    // the flat `compstate[KEY]` bracketed param: the latter reads empty here
1769    // because the completion fn's write lands in the hash storage while the
1770    // flat param is scoped to the fn.
1771
1772    // Read one `$compstate` entry as C reads its backing global at c:843+:
1773    // the end-of-body snapshot first, then whatever is still live.
1774    //
1775    // `None` means the port HAS NO VALUE for this entry — a state C cannot
1776    // be in, because `callcompfunc` itself assigned every one of these
1777    // globals before the call (c:753-812). Applying C's "NULL" arm to it
1778    // would be a mistranslation of "absent" as "empty", and that is exactly
1779    // what regressed `menu select`: the `insert`/`list` entries read back
1780    // absent, so `useline` and `uselist` were both driven to 0 and
1781    // `do_completion` took its c:425 else-branch (revert the line, show
1782    // nothing) instead of listing. On `None` the globals keep the values
1783    // this function published, which is the round-trip C would have seen.
1784    let post = |key: &str| -> Option<String> {
1785        if let Some(v) = compstate_end
1786            .lock()
1787            .ok()
1788            .and_then(|g| g.as_ref().and_then(|h| h.get(key).cloned()))
1789        {
1790            return Some(v);
1791        }
1792        get_compstate_str(key)
1793    };
1794
1795    // c:843-855 — uselist / forcelist / onlyexpl from `complist`.
1796    if let Some(post_list) = post("list") {
1797        uselist.store(
1798            if post_list.starts_with("list") {
1799                1 // c:846
1800            } else if post_list.starts_with("auto") {
1801                2 // c:848
1802            } else if post_list.starts_with("ambig") {
1803                3 // c:850
1804            } else {
1805                0 // c:844 / c:852
1806            },
1807            Ordering::Relaxed,
1808        );
1809        forcelist.store(
1810            if post_list.contains("force") { 1 } else { 0 }, // c:853
1811            Ordering::Relaxed,
1812        );
1813        onlyexpl.store(
1814            (if post_list.contains("expl") { 1 } else { 0 })      // c:854
1815                | (if post_list.contains("messages") { 2 } else { 0 }), // c:855
1816            Ordering::Relaxed,
1817        );
1818        // Keep the `complist` global in step with the param — one storage in C.
1819        if let Ok(mut g) = COMPLIST.get_or_init(|| Mutex::new(String::new())).lock() {
1820            *g = post_list.clone();
1821        }
1822    }
1823
1824    // c:857-907 — useline / usemenu / insmnum / insspace from `compinsert`.
1825    let post_insert = match post("insert") {
1826        Some(v) => v,
1827        // Absent: leave useline/usemenu/insmnum/insspace as published.
1828        None => String::new(),
1829    };
1830    let have_insert = post("insert").is_some();
1831    // c:857-858 — `if (!compinsert) useline = 0;`. C's test is on the
1832    // POINTER: it fires only when `$compstate[insert]` has no value at all.
1833    // An EMPTY STRING is a perfectly ordinary `char *` and falls all the way
1834    // through to the c:883-896 else, whose own comment spells out that the
1835    // empty case is the one it exists for:
1836    //
1837    //     } else {
1838    //         if (strpfx("menu", compinsert)) useline = 1, usemenu = 1;
1839    //         else if (strpfx("auto", compinsert)) useline = 1, usemenu = 2;
1840    //         else {
1841    //             useline = usemenu = 0;
1842    //             /* if compstate[insert] was emptied, no unambiguous prefix
1843    //              * ever gets inserted so allow the next tab to already start
1844    //              * menu completion */
1845    //             startauto = lastambig = isset(AUTOMENU);
1846    //         }
1847    //
1848    // The port used to short-circuit `post_insert.is_empty()` into the NULL
1849    // arm, so it set `useline = 0` and stopped — never clearing `usemenu`
1850    // and, far more visibly, never arming `startauto`/`lastambig`. That
1851    // arming is the ONLY thing that lets the SECOND Tab start menu
1852    // completion after a round that inserted nothing: `before_complete`
1853    // (c:493-495, `startauto && lastambig` → `usemenu = 2`) has no other
1854    // source. Measured on `bindkey -` under
1855    // scripts/parity_combos/full.zsh (`menu 'select=0' interactive`):
1856    // `$compstate[insert]` reads back EMPTY in both shells there (sampled
1857    // from `comppostfuncs`, the last hook `_main_complete` runs before it
1858    // snapshots `_lastcomp`), so c:895 is the only arming zsh gets — and
1859    // zsh's second Tab entered interactive menu-select and printed
1860    // `interactive: -[]` above the list where this port's did not. The
1861    // follow-on keystrokes then went to the line instead of the menu
1862    // filter: typing `s` narrowed zsh's list to `-s` and self-inserted here.
1863    if !have_insert {
1864        // no-op: keep the c:767-782 values
1865    } else if post_insert.contains("tab") {
1866        useline.store(-1, Ordering::Relaxed); // c:860
1867    } else if post_insert == "unambig"
1868        || post_insert == "unambiguous"
1869        || post_insert == "automenu-unambiguous"
1870    {
1871        // c:861-864 — C compares these three EXACTLY, and does so *before*
1872        // the `strpfx("menu", …)` / `strpfx("auto", …)` arms at c:885-888.
1873        // The ordering is load-bearing now that the entry value grew an
1874        // "automenu" prefix (c:770): a prefix test would see "auto" and pick
1875        // usemenu = 2, starting menu completion on the very FIRST TAB
1876        // instead of inserting the unambiguous prefix and only arming the
1877        // next TAB — the arming is carried by `startauto` (c:908 below).
1878        useline.store(1, Ordering::Relaxed); // c:864
1879        USEMENU.store(0, Ordering::Relaxed); // c:864
1880    } else if post_insert == "all" {
1881        useline.store(2, Ordering::Relaxed); // c:866
1882        USEMENU.store(0, Ordering::Relaxed); // c:866
1883    } else if post_insert
1884        .as_bytes()
1885        .first()
1886        .is_some_and(u8::is_ascii_digit)
1887    {
1888        // c:867 — `idigit(*compinsert)`: insert the Nth match directly.
1889        // `first()` rather than `[0]`: an empty `$compstate[insert]` now
1890        // reaches this arm (see the c:857-858 note above), and C's
1891        // `idigit(*compinsert)` reads the NUL terminator there — false —
1892        // where an unguarded index would panic.
1893        useline.store(1, Ordering::Relaxed); // c:872
1894        USEMENU.store(3, Ordering::Relaxed); // c:872
1895                                             // c:873 — `insmnum = atoi(compinsert)`; inlined (leading digits).
1896        insmnum.store(
1897            post_insert
1898                .chars()
1899                .take_while(|c| c.is_ascii_digit())
1900                .collect::<String>()
1901                .parse::<i32>()
1902                .unwrap_or(0),
1903            Ordering::Relaxed,
1904        ); // c:873
1905        insspace.store(
1906            if post_insert.ends_with(' ') { 1 } else { 0 }, // c:881
1907            Ordering::Relaxed,
1908        );
1909    } else {
1910        if post_insert.starts_with("menu") {
1911            // c:885
1912            useline.store(1, Ordering::Relaxed); // c:886
1913            USEMENU.store(1, Ordering::Relaxed); // c:886
1914        } else if post_insert.starts_with("auto") {
1915            // c:887
1916            useline.store(1, Ordering::Relaxed); // c:888
1917            USEMENU.store(2, Ordering::Relaxed); // c:888
1918        } else {
1919            useline.store(0, Ordering::Relaxed); // c:890
1920            USEMENU.store(0, Ordering::Relaxed); // c:890
1921                                                 // c:891-894 — "if compstate[insert] was emptied, no unambiguous
1922                                                 // prefix ever gets inserted so allow the next tab to already
1923                                                 // start menu completion".
1924            let am = opt_isset("AUTOMENU");
1925            startauto.store(am, Ordering::Relaxed); // c:894
1926            LASTAMBIG.store(am, Ordering::Relaxed); // c:894
1927        }
1928        // c:897-898 — `if (useline && (p = strchr(compinsert, ':')))
1929        //               insmnum = atoi(++p);`
1930        if useline.load(Ordering::Relaxed) != 0 {
1931            if let Some(colon) = post_insert.find(':') {
1932                // c:898 — `insmnum = atoi(++p)`; inlined (leading digits).
1933                insmnum.store(
1934                    post_insert[colon + 1..]
1935                        .chars()
1936                        .take_while(|c| c.is_ascii_digit())
1937                        .collect::<String>()
1938                        .parse::<i32>()
1939                        .unwrap_or(0),
1940                    Ordering::Relaxed,
1941                ); // c:898
1942            }
1943        }
1944    }
1945    // c:908-911 — `startauto = startauto ||
1946    //     compinsert == "automenu-unambiguous" ||
1947    //     (bashlistfirst && isset(AUTOMENU) && !*compinsert);`
1948    if have_insert
1949        && startauto.load(Ordering::Relaxed) == 0
1950        && (post_insert == "automenu-unambiguous"
1951            || (crate::ported::zle::zle_tricky::BASHLISTFIRST.load(Ordering::Relaxed) != 0
1952                && opt_isset("AUTOMENU") != 0
1953                && post_insert.is_empty()))
1954    {
1955        startauto.store(1, Ordering::Relaxed); // c:908
1956    }
1957
1958    // c:912 — `useexact = (compexact && !strcmp(compexact, "accept"));`
1959    if let Some(post_exact) = post("exact") {
1960        useexact.store(
1961            if post_exact == "accept" { 1 } else { 0 },
1962            Ordering::Relaxed,
1963        );
1964    }
1965
1966    // `comppatinsert` (complete.c:69) is `VAL()`-bound to
1967    // `$compstate[pattern_insert]` (complete.c:1281), so a completer's
1968    // `compstate[pattern_insert]=unambiguous` (`_expand:_expand:…`,
1969    // `_approximate:91`) lands straight in the C global and C needs no
1970    // read-back here. This port keeps the two storages separate, so mirror
1971    // the parameter into the global now — same treatment `complist` gets at
1972    // c:846-853 above. Absent (`None`) keeps the c:321 value, which is the
1973    // state C would be in when no completer touched it.
1974    if let Some(post_patins) = post("pattern_insert") {
1975        if let Ok(mut g) = crate::ported::zle::complete::COMPPATINSERT
1976            .get_or_init(|| Mutex::new(String::new()))
1977            .lock()
1978        {
1979            *g = post_patins;
1980        }
1981    }
1982
1983    // `complastprompt` (complete.c:57) is `VAL()`-bound to
1984    // `$compstate[last_prompt]` (complete.c:1293); mirror a completer's
1985    // write into the global the same way.
1986    if let Some(post_lastprompt) = post("last_prompt") {
1987        if let Ok(mut g) = crate::ported::zle::complete::COMPLASTPROMPT
1988            .get_or_init(|| Mutex::new(String::new()))
1989            .lock()
1990        {
1991            *g = post_lastprompt;
1992        }
1993    }
1994
1995    // c:914-921 — movetoend from `comptoend`.
1996    if let Some(post_toend) = post("to_end") {
1997        movetoend.store(
1998            if post_toend.is_empty() {
1999                0 // c:915
2000            } else if post_toend == "single" {
2001                1 // c:917
2002            } else if post_toend == "always" {
2003                3 // c:919
2004            } else {
2005                2 // c:921
2006            },
2007            Ordering::Relaxed,
2008        );
2009    }
2010
2011    // c:923-925 — `oldlist = (hasoldlist && compoldlist &&
2012    //                         !strcmp(compoldlist, "keep"));
2013    //              oldins  = (hasoldlist && minfo.cur &&
2014    //                         compoldins && !strcmp(compoldins, "keep"));`
2015    let hasoldlist_v = hasoldlist.load(Ordering::Relaxed);
2016    oldlist.store(
2017        if hasoldlist_v != 0 && post("old_list").as_deref() == Some("keep") {
2018            1
2019        } else {
2020            0
2021        },
2022        Ordering::Relaxed,
2023    ); // c:923
2024    let has_cur = MINFO
2025        .get()
2026        .and_then(|m| m.lock().ok())
2027        .map(|m| m.cur.is_some())
2028        .unwrap_or(false);
2029    oldins.store(
2030        if hasoldlist_v != 0 && has_cur && post("old_insert").as_deref() == Some("keep") {
2031            1
2032        } else {
2033            0
2034        },
2035        Ordering::Relaxed,
2036    ); // c:924-925
2037
2038    // c:932 — `lastval = lv`: the completion function's exit status must not
2039    // leak into the interactive shell's `$?`.
2040    crate::ported::builtin::LASTVAL.store(_lv, Ordering::Relaxed); // c:932
2041
2042    // c:840 — incompfunc = icf. Restore.
2043    INCOMPFUNC.store(_icf, Ordering::Relaxed);
2044}
2045
2046// =====================================================================
2047// makecomplist — `Src/Zle/compcore.c:946`.
2048// =====================================================================
2049
2050/// Guards the first-use hook attachment in `makecomplist` (see there).
2051static COMPCTL_HOOKS: std::sync::Once = std::sync::Once::new();
2052
2053/// Direct port of `int makecomplist(char *s, int incmd, int lst)` from
2054/// compcore.c:946. Top-level dispatch into the completion subsystem:
2055/// either the new compsys path (`callcompfunc`) or the legacy compctl
2056/// path (`COMPCTLMAKEHOOK`).
2057pub fn makecomplist(s: &str, incmd: i32, lst: i32) -> i32 {
2058    // c:946
2059    let owb = WB.load(Ordering::Relaxed); // c:946
2060    let owe = WE.load(Ordering::Relaxed);
2061    let ooffs = OFFS.load(Ordering::Relaxed);
2062
2063    // c:952-958 — `if (compfunc && (p = check_param(s, 0, 0)))`.
2064    let mut s_owned = s.to_string();
2065    // The TOKENIZED twin of `s`, for `callcompfunc`'s `multiquote(s, 0)`
2066    // (c:700/711/715) — see `crate::comp_word_tok`. C has no equivalent
2067    // bookkeeping: its `s` is tokenized from `get_comp_string`
2068    // (zle_tricky.c:2221) right through to `callcompfunc`, while this port
2069    // untokenizes at the return and stashes the tokenized form in
2070    // `COMP_STRING_TOK`. The twin is only usable when it still untokenizes
2071    // back to the word actually being completed; a completion that did not
2072    // come through `get_comp_string` leaves a stale value behind.
2073    let mut s_tok = crate::ported::zle::zle_tricky::COMP_STRING_TOK
2074        .get()
2075        .and_then(|m| m.lock().ok())
2076        .map(|g| g.clone())
2077        .filter(|t| crate::ported::lex::untokenize(t) == s_owned);
2078    // c:952 — C hands `check_param` the TOKENIZED `s`, and the test at
2079    // c:1141 is written in parser tokens precisely so an escaped `\$` (a
2080    // plain 0x24 by then, its `Bnull` chucked at zle_tricky.c:1920) is NOT a
2081    // parameter context. Feed the twin, with `offs` remapped into its byte
2082    // space; when there is no usable twin fall back to the untokenized word,
2083    // which cannot tell `\$PATH` from `$PATH`.
2084    let probe: Option<(String, usize)> = s_tok.as_ref().and_then(|t| {
2085        crate::comp_word_tok::tok_index(t, ooffs.max(0) as usize).map(|ti| (t.clone(), ti))
2086    });
2087    if compfunc_active() {
2088        let found = match &probe {
2089            Some((t, ti)) => {
2090                OFFS.store(*ti as i32, Ordering::Relaxed);
2091                let r = check_param(t, false, false, false);
2092                if r.is_none() {
2093                    OFFS.store(ooffs, Ordering::Relaxed);
2094                }
2095                r
2096            }
2097            None => check_param(&s_owned, false, false, true),
2098        };
2099        if let Some(p) = found {
2100            // c:951
2101            // c:952 — `s = p`, where C's `p` points at the parameter NAME and
2102            // check_param has already NUL-terminated it at the end of the
2103            // name (`b[we-wb] = '\0'`, c:1298). Taking `s[p..]` alone kept
2104            // everything that followed the name (the `}` of a `${…}`, any
2105            // `:modifiers`, the ignored suffix), so `callcompfunc` received
2106            // e.g. `PA}` instead of `PA` and published that as `$PREFIX`.
2107            // `we - wb` is exactly `e - b`, set by check_param at c:1295-1296.
2108            let namelen = (WE.load(Ordering::Relaxed) - WB.load(Ordering::Relaxed)).max(0) as usize;
2109            match &probe {
2110                // `p` and `namelen` are byte counts in the TWIN. Convert the
2111                // whole result back to the untokenized byte space the rest of
2112                // this port indexes `wb`/`we`/`offs` in — the line the user
2113                // typed, which the c:1788-1926 cleanup loop has already
2114                // stripped of its quote characters.
2115                Some((t, _)) => {
2116                    let cut = namelen.min(t.len().saturating_sub(p));
2117                    let name_tok = t.get(p..p + cut).unwrap_or("").to_string();
2118                    s_owned = crate::ported::lex::untokenize(&name_tok); // c:952 + c:1298
2119                    let pre_u = crate::comp_word_tok::untok_index(t, p).unwrap_or(0);
2120                    OFFS.store(ooffs - pre_u as i32, Ordering::Relaxed); // c:1294
2121                    let wb = ZLEMETACS.load(Ordering::Relaxed) - OFFS.load(Ordering::Relaxed);
2122                    WB.store(wb, Ordering::Relaxed); // c:1295
2123                    WE.store(wb + s_owned.len() as i32, Ordering::Relaxed); // c:1296
2124                    s_tok = Some(name_tok);
2125                }
2126                None => {
2127                    let tail = &s_owned[p..];
2128                    let cut = namelen.min(tail.len());
2129                    s_tok = s_tok.and_then(|t| crate::comp_word_tok::span(&t, p, cut));
2130                    s_owned = tail[..cut].to_string(); // c:952 + c:1298
2131                }
2132            }
2133            PARWB.store(owb, Ordering::Relaxed); // c:953
2134            PARWE.store(owe, Ordering::Relaxed); // c:955
2135            PAROFFS.store(ooffs, Ordering::Relaxed); // c:956
2136        } else {
2137            PARWB.store(-1, Ordering::Relaxed); // c:958
2138        }
2139    } else {
2140        PARWB.store(-1, Ordering::Relaxed); // c:958
2141    }
2142
2143    // Publish the narrowed twin for `callcompfunc` (c:700/711/715).
2144    crate::comp_word_tok::set(s_tok);
2145
2146    linwhat.store(INWHAT.load(Ordering::Relaxed), Ordering::Relaxed); // c:960
2147
2148    if compfunc_active() {
2149        // c:962
2150        let os = s_owned.clone(); // c:964
2151        let onm = nmatches.load(Ordering::Relaxed); // c:965
2152        let odm = diffmatches.load(Ordering::Relaxed); // c:965
2153        let osi = movefd(0); // c:965 movefd(0)
2154                             // c:965 moves the shell's stdin off fd 0 and c:1013/1035/1039 `redup(osi, 0)`
2155                             // puts it back, so the completion function runs with fd 0 FREE — and
2156                             // the very next `open()`/`opendir()` in that window is handed
2157                             // descriptor 0. zsh parks /dev/null there for exactly the same window
2158                             // (the idiom, with its rationale, is spelled out at
2159                             // Src/Zle/zle_main.c:1521-1526: "Many commands don't like having a
2160                             // closed stdin, open on /dev/null instead"); measured against zsh 5.9,
2161                             // `[[ /dev/fd/0 -ef /dev/null ]]` is TRUE inside a completion
2162                             // function, while zshrs left fd 0 closed.
2163                             //
2164                             // A closed fd 0 is not merely untidy here: `_path_files -W /dev -g
2165                             // '*(-/)'` calls `opendir("/dev")`, which lands on descriptor 0, so
2166                             // `/dev/fd/0` — the target of the `/dev/stdin` symlink — resolves to
2167                             // the very directory being scanned. The `-/` qualifier then stats a
2168                             // directory and admits `/dev/stdin`, and `mount /dev/<TAB>` listed a
2169                             // bogus `stdin@` next to `fd/` and `monotonic/`.
2170        if osi > 0 {
2171            unsafe {
2172                let devnull = std::ffi::CString::new("/dev/null").unwrap();
2173                let _ = libc::open(devnull.as_ptr(), libc::O_RDWR | libc::O_NOCTTY);
2174            }
2175        }
2176
2177        // c:967-968 — bmatchers = mstack = NULL.
2178        if let Ok(mut g) = bmatchers.get_or_init(|| Mutex::new(None)).lock() {
2179            *g = None;
2180        }
2181        if let Ok(mut g) = mstack.get_or_init(|| Mutex::new(None)).lock() {
2182            *g = None;
2183        }
2184        // c:970-971 — ainfo = fainfo = hcalloc(sizeof(struct aminfo)).
2185        if let Ok(mut g) = ainfo.get_or_init(|| Mutex::new(None)).lock() {
2186            *g = Some(Aminfo::default());
2187        }
2188        if let Ok(mut g) = fainfo.get_or_init(|| Mutex::new(None)).lock() {
2189            *g = Some(Aminfo::default());
2190        }
2191        if let Ok(mut g) = freecl.get_or_init(|| Mutex::new(None)).lock() {
2192            *g = None; // c:973
2193        }
2194        if VALIDLIST.load(Ordering::Relaxed) == 0 {
2195            LASTAMBIG.store(0, Ordering::Relaxed);
2196            // c:976
2197        }
2198        // c:976 — `amatches = NULL;`. In C that drops a POINTER: after c:1022
2199        // and c:1026 (`amatches = pmatches; ... lastmatches = pmatches;`)
2200        // the two name the SAME groups, and stay aliased until the next
2201        // completion (`invalidatelist`, c:Src/Zle/compresult.c:2331-2345,
2202        // ends both at once and clears `hasoldlist`). So every write made to
2203        // `amatches` in between — above all `calclist`'s per-group geometry
2204        // (`dcount`, `cols`, `lins`, `widths`) — is ALSO in `lastmatches`,
2205        // and that is what c:1001 `amatches = lastmatches` hands back for
2206        // `compstate[old_list]=keep`. zshrs holds two cloned Vecs, so those
2207        // writes stayed in `amatches` alone and the kept list came back with
2208        // `lcount` set but no rows or columns: `printlist` drew nothing, and
2209        // `_history-complete-older`'s second TAB dropped the listing zsh
2210        // keeps (spec-fuzz 9510/case0028, the 9401/0022 cell). While
2211        // `hasoldlist` says the alias is live, carry `amatches` into
2212        // `lastmatches` before letting go of it.
2213        let prev = amatches
2214            .get_or_init(|| Mutex::new(Vec::new()))
2215            .lock()
2216            .map(|mut g| std::mem::take(&mut *g))
2217            .unwrap_or_default(); // c:977
2218        if hasoldlist.load(Ordering::Relaxed) != 0 && !prev.is_empty() {
2219            if let Ok(mut g) = lastmatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
2220                *g = prev;
2221            }
2222        }
2223        mnum.store(0, Ordering::Relaxed); // c:978
2224        unambig_mnum.store(-1, Ordering::Relaxed); // c:979
2225        if let Ok(mut g) = isuf.get_or_init(|| Mutex::new(String::new())).lock() {
2226            g.clear(); // c:980
2227        }
2228        // c:981 — `insmnum = zmult;` where `zmult` is `zmod.mult` (c:Src/Zle/zle.h:267).
2229        // Read ZMOD, not the ZMULT copy: nothing syncs ZMULT before a fresh
2230        // completion, so `reverse-menu-complete`'s negation (zle_tricky.c:347)
2231        // never reached do_ambig_menu and the menu started at the FIRST match.
2232        insmnum.store(
2233            crate::ported::zle::zle_main::ZMOD.lock().map(|g| g.mult).unwrap_or(1),
2234            Ordering::Relaxed,
2235        );
2236        oldlist.store(0, Ordering::Relaxed); // c:986
2237        oldins.store(0, Ordering::Relaxed); // c:986
2238        begcmgroup(Some("default"), 0); // c:987
2239        MENUCMP.store(0, Ordering::Relaxed); // c:988
2240        menuacc.store(0, Ordering::Relaxed); // c:988
2241        newmatches.store(0, Ordering::Relaxed); // c:988
2242        onlyexpl.store(0, Ordering::Relaxed); // c:988
2243
2244        let dup_s = crate::ported::mem::dupstring(&os); // c:990
2245        let cf_name = compfunc
2246            .get_or_init(|| Mutex::new(None))
2247            .lock()
2248            .ok()
2249            .and_then(|g| g.clone())
2250            .unwrap_or_default();
2251        callcompfunc(&dup_s, &cf_name); // c:991
2252        endcmgroup(None); // c:992
2253
2254        // !!! RUST-ONLY — the comphooks hookdefs (complete.c:1712 `comphooks`) and
2255        // compctl.c:4049 `boot_` (which addhookfunc()s ccmakehookfn /
2256        // cccleanuphookfn on them) are never reached by zshrs's static module
2257        // registration outside a running ZLE, so attach both on first use.
2258        COMPCTL_HOOKS.call_once(|| {
2259            // Only the two compctl hookdefs (complete.c:1712-1716): registering
2260            // the whole zle_main::boot_ set would also define "complete", and a
2261            // defined-but-empty "complete" hook stops docompletion from falling
2262            // back to do_completion.
2263            for name in ["compctl_make", "compctl_cleanup"] {
2264                if gethookdef(name).is_null() {
2265                    let h = Box::into_raw(Box::new(crate::ported::zsh_h::hookdef {
2266                        next: std::ptr::null_mut(),
2267                        name: name.to_string(),
2268                        def: None,
2269                        flags: 0,
2270                        funcs: std::ptr::null_mut(),
2271                    }));
2272                    if crate::ported::module::addhookdef(h) != 0 {
2273                        unsafe { drop(Box::from_raw(h)) };
2274                    }
2275                }
2276            }
2277            crate::ported::zle::compctl::boot_();
2278        });
2279        // c:995 — runhookdef(COMPCTLCLEANUPHOOK, NULL).
2280        // c:995 — `runhookdef(COMPCTLCLEANUPHOOK, NULL)`; COMPCTLCLEANUPHOOK is
2281        // `comphooks + 3` (comp.h:450) = the "compctl_cleanup" hookdef.
2282        runhookdef(gethookdef("compctl_cleanup"), std::ptr::null_mut()); // c:995
2283
2284        if oldlist.load(Ordering::Relaxed) != 0 {
2285            // c:997
2286            nmatches.store(onm, Ordering::Relaxed); // c:998
2287            diffmatches.store(odm, Ordering::Relaxed); // c:999
2288            VALIDLIST.store(1, Ordering::Relaxed); // c:1000
2289            if let Ok(mut g) = amatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
2290                if let Ok(last) = lastmatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
2291                    *g = last.clone(); // c:1001
2292                }
2293            }
2294            if let Ok(mut g) = lmatches.get_or_init(|| Mutex::new(None)).lock() {
2295                let last_l = lastlmatches
2296                    .get_or_init(|| Mutex::new(None))
2297                    .lock()
2298                    .ok()
2299                    .and_then(|g| g.clone());
2300                *g = last_l; // c:1007
2301            }
2302            // c:1008-1011 — `if (pmatches) freematches(pmatches, 1)`.
2303            let drained = pmatches
2304                .get_or_init(|| Mutex::new(Vec::new()))
2305                .lock()
2306                .map(|mut g| std::mem::take(&mut *g))
2307                .unwrap_or_default();
2308            freematches(drained, 1); // c:1009-1010
2309            hasperm.store(0, Ordering::Relaxed); // c:1011
2310            redup(osi); // c:1012
2311            return 0; // c:1013
2312        }
2313        if !lastmatches
2314            .get_or_init(|| Mutex::new(Vec::new()))
2315            .lock()
2316            .map(|g| g.is_empty())
2317            .unwrap_or(true)
2318        {
2319            // c:1015
2320            if let Ok(mut g) = lastmatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
2321                g.clear(); // c:1016-1017
2322            }
2323        }
2324        permmatches(1); // c:1019
2325                        // c:1020-1029 — copy pmatches → amatches/lastmatches; swap holders.
2326        let p_snap = pmatches
2327            .get_or_init(|| Mutex::new(Vec::new()))
2328            .lock()
2329            .ok()
2330            .map(|g| g.clone())
2331            .unwrap_or_default();
2332        if let Ok(mut g) = amatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
2333            *g = p_snap.clone(); // c:1020
2334        }
2335        lastpermmnum.store(permmnum.load(Ordering::Relaxed), Ordering::Relaxed); // c:1021
2336        lastpermgnum.store(permgnum.load(Ordering::Relaxed), Ordering::Relaxed); // c:1022
2337        if let Ok(mut g) = lastmatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
2338            *g = p_snap; // c:1024
2339        }
2340        let lm_snap = lmatches
2341            .get_or_init(|| Mutex::new(None))
2342            .lock()
2343            .ok()
2344            .and_then(|g| g.clone());
2345        if let Ok(mut g) = lastlmatches.get_or_init(|| Mutex::new(None)).lock() {
2346            *g = lm_snap; // c:1025
2347        }
2348        if let Ok(mut g) = pmatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
2349            g.clear(); // c:1026
2350        }
2351        hasperm.store(0, Ordering::Relaxed); // c:1027
2352        hasoldlist.store(1, Ordering::Relaxed); // c:1028
2353
2354        let any_nm =
2355            nmatches.load(Ordering::Relaxed) != 0 || nmessages.load(Ordering::Relaxed) != 0;
2356        let errset = errflag_get();
2357        tracing::debug!(
2358            target: "compsys_args",
2359            nm = nmatches.load(Ordering::Relaxed),
2360            nmsg = nmessages.load(Ordering::Relaxed),
2361            errset,
2362            "makecomplist RETURN"
2363        );
2364        if any_nm && !errset {
2365            // c:1030
2366            VALIDLIST.store(1, Ordering::Relaxed); // c:1031
2367            redup(osi); // c:1032
2368            return 0; // c:1033
2369        }
2370        redup(osi); // c:1035
2371        return 1; // c:1036
2372    } else {
2373        // c:1038
2374        // c:1040-1047 — compctl dispatch via COMPCTLMAKEHOOK.
2375        let mut dat = Ccmakedat {
2376            str: Some(s_owned.clone()), // c:1042
2377            incmd,                      // c:1043
2378            lst,                        // c:1044
2379        };
2380        // !!! RUST-ONLY — the comphooks hookdefs (complete.c:1712 `comphooks`) and
2381        // compctl.c:4049 `boot_` (which addhookfunc()s ccmakehookfn /
2382        // cccleanuphookfn on them) are never reached by zshrs's static module
2383        // registration outside a running ZLE, so attach both on first use.
2384        COMPCTL_HOOKS.call_once(|| {
2385            // Only the two compctl hookdefs (complete.c:1712-1716): registering
2386            // the whole zle_main::boot_ set would also define "complete", and a
2387            // defined-but-empty "complete" hook stops docompletion from falling
2388            // back to do_completion.
2389            for name in ["compctl_make", "compctl_cleanup"] {
2390                if gethookdef(name).is_null() {
2391                    let h = Box::into_raw(Box::new(crate::ported::zsh_h::hookdef {
2392                        next: std::ptr::null_mut(),
2393                        name: name.to_string(),
2394                        def: None,
2395                        flags: 0,
2396                        funcs: std::ptr::null_mut(),
2397                    }));
2398                    if crate::ported::module::addhookdef(h) != 0 {
2399                        unsafe { drop(Box::from_raw(h)) };
2400                    }
2401                }
2402            }
2403            crate::ported::zle::compctl::boot_();
2404        });
2405        // c:1046 — `runhookdef(COMPCTLMAKEHOOK, (void *) &dat)`; COMPCTLMAKEHOOK is
2406        // `comphooks + 2` (comp.h:449) = the "compctl_make" hookdef.
2407        runhookdef(
2408            gethookdef("compctl_make"),
2409            &mut dat as *mut Ccmakedat as *mut std::ffi::c_void,
2410        ); // c:1046
2411        runhookdef(gethookdef("compctl_cleanup"), std::ptr::null_mut()); // c:1049
2412        return dat.lst; // c:1050
2413    }
2414}
2415
2416// =====================================================================
2417// multiquote — `Src/Zle/compcore.c:1065`.
2418// =====================================================================
2419
2420/// Port of `mod_export char *multiquote(char *s, int ign)` from
2421/// compcore.c:1064.
2422pub fn multiquote(s: &str, ign: i32) -> String {
2423    // c:1065
2424    // c:1067 — `char *p = compqstack;`. C takes the global POINTER and
2425    // walks it; nothing is copied. `compqstack` holds one byte per open
2426    // quoting level (c:301-308 allocates 2 bytes; c:1853-1859 pushes one
2427    // more per nesting level), so it is a handful of bytes at most.
2428    // Copying it into a stack buffer keeps the read allocation-free —
2429    // `multiquote` runs twice per completion match (compmatch.c:1160 and
2430    // c:1172 via `comp_match`), so a 46765-match `compadd -k functions`
2431    // was doing ~94k heap clones of a one-byte String.
2432    let mut qbuf = [0u8; 32];
2433    let mut qspill = String::new();
2434    let qlen = match COMPQSTACK.get_or_init(|| Mutex::new(String::new())).lock() {
2435        Ok(g) => {
2436            let b = g.as_bytes();
2437            if b.len() <= qbuf.len() {
2438                qbuf[..b.len()].copy_from_slice(b);
2439                b.len()
2440            } else {
2441                // Deeper nesting than the buffer holds: fall back to an
2442                // owned copy so no level is ever dropped.
2443                qspill = g.clone();
2444                usize::MAX
2445            }
2446        }
2447        Err(_) => 0,
2448    };
2449    let p_bytes: &[u8] = if qlen == usize::MAX {
2450        qspill.as_bytes()
2451    } else {
2452        &qbuf[..qlen]
2453    };
2454    if !p_bytes.is_empty() && (ign == 0 || p_bytes.len() > 1) {
2455        // c:1070
2456        let start = if ign != 0 { 1 } else { 0 }; // c:1071
2457        let mut cur = s.to_string();
2458        for &q in &p_bytes[start..] {
2459            // c:1073
2460            let qt = match q as i32 {
2461                // c:1074
2462                x if x == QT_BACKSLASH => QT_BACKSLASH,
2463                x if x == QT_SINGLE => QT_SINGLE,
2464                x if x == QT_DOUBLE => QT_DOUBLE,
2465                x if x == QT_DOLLARS => QT_DOLLARS,
2466                _ => QT_BACKSLASH,
2467            };
2468            cur = crate::ported::utils::quotestring(&cur, qt);
2469        }
2470        cur // c:1092
2471    } else {
2472        s.to_string() // c:1092
2473    }
2474}
2475
2476// =====================================================================
2477// tildequote — `Src/Zle/compcore.c:1092`.
2478// =====================================================================
2479
2480/// Port of `mod_export char *tildequote(char *s, int ign)` from
2481/// compcore.c:1091.
2482pub fn tildequote(s: &str, ign: i32) -> String {
2483    // c:1092
2484    let bytes = s.as_bytes(); // c:1092
2485    let tilde = !bytes.is_empty() && bytes[0] == b'~'; // c:1097
2486    let staged = if tilde {
2487        // c:1098
2488        let mut tmp = String::with_capacity(s.len());
2489        tmp.push('x');
2490        tmp.push_str(&s[1..]);
2491        tmp
2492    } else {
2493        s.to_string()
2494    };
2495    let mut quoted = multiquote(&staged, ign); // c:1099
2496    if tilde && !quoted.is_empty() {
2497        // c:1100
2498        let mut new_q = String::with_capacity(quoted.len());
2499        let mut swapped = false;
2500        for c in quoted.chars() {
2501            if !swapped && c == 'x' {
2502                new_q.push('~');
2503                swapped = true;
2504            } else {
2505                new_q.push(c);
2506            }
2507        }
2508        quoted = new_q;
2509    }
2510    quoted // c:1101
2511}
2512
2513// =====================================================================
2514// check_param — `Src/Zle/compcore.c:1113`.
2515// =====================================================================
2516
2517/// Direct port of `static char *check_param(char *s, int set, int test)`
2518/// from compcore.c:1113. Walks backwards from cursor in `s` looking
2519/// for `$<name>`. When found and the cursor sits inside the name,
2520/// returns the byte index in `s` where the name starts; updates
2521/// `ispar`/`parq`/`eparq` (when `!test`) and `ipre`/`ripre`/`isuf`/
2522/// `parpre`/`parflags`/`mflags`/`wb`/`we`/`offs` (when `set`).
2523/// Returns `None` when there's no parameter expression at the cursor.
2524///
2525/// `untok` is the one parameter C does not have. Every C caller — compcore.c
2526/// c:952 and c:1722, compctl.c:2889 and compctl.c:3161 — passes a TOKENIZED
2527/// word, and the distinction is load-bearing: an escaped `\$` reaches c:1141
2528/// as a plain 0x24 — never the `String` token, its `Bnull` having been
2529/// chucked by the line-cleanup loop at zle_tricky.c:1881/1920 — so
2530/// C reads `echo \$PATH` as no parameter context and `echo $PATH` as one.
2531/// Pass `untok = false` (faithful) whenever the tokenized word is in hand.
2532/// The legacy compctl caller has only the untokenized word and passes
2533/// `true`, which lets the `$`/`{`/`(` literals stand in for their tokens at
2534/// the cost of that one path not seeing the escape.
2535pub fn check_param(s: &str, set: bool, test: bool, untok: bool) -> Option<usize> {
2536    // c:1113
2537
2538    // c:1117-1118 — zsfree(parpre); parpre = NULL.
2539    if let Ok(mut g) = parpre.get_or_init(|| Mutex::new(String::new())).lock() {
2540        g.clear();
2541    }
2542
2543    if !test {
2544        // c:1120
2545        ispar.store(0, Ordering::Relaxed); // c:1121
2546        parq.store(0, Ordering::Relaxed); // c:1121
2547        eparq.store(0, Ordering::Relaxed); // c:1121
2548    }
2549
2550    let bytes = s.as_bytes(); // local view
2551    let offs_v = OFFS.load(Ordering::Relaxed) as usize; // c:1140 cursor in word
2552
2553    let mut found = false; // c:1115
2554    let mut qstring = false; // c:1115
2555    let mut p: usize = offs_v.min(bytes.len().saturating_sub(1)); // c:1140 p = s + offs
2556
2557    // c:1141 — `if (*p == String || *p == Qstring)`. A plain 0x24 is NOT a
2558    // hit: the lexer emits `String` for a live `$` and leaves an escaped
2559    // `\$` as `Bnull` + 0x24, whose `Bnull` the line-cleanup loop chucks
2560    // (zle_tricky.c:1881/1920) — so the bare byte reaching here means the
2561    // user quoted it. Only `untok = true` widens the test.
2562    let is_str = |c: char| c == Stringg || (untok && c == '$');
2563
2564    // c:1140-1162 — scan backward for `String` or `Qstring`.
2565    loop {
2566        if p < bytes.len() {
2567            let ch = char_at(bytes, p);
2568            if is_str(ch) || ch == Qstring {
2569                // c:1141
2570                let next = char_at(bytes, p + ch.len_utf8());
2571                let snull_next = is_str(ch) && next == Snull; // c:1151
2572                let qstr_quot = ch == Qstring && next == '\''; // c:1152
2573                if p < offs_v && !snull_next && !qstr_quot {
2574                    found = true; // c:1154
2575                    qstring = ch == Qstring; // c:1155
2576                    break;
2577                }
2578            }
2579        }
2580        if p == 0 {
2581            break;
2582        } // c:1160
2583        p = prev_char_index(bytes, p);
2584    }
2585
2586    if found {
2587        // c:1166
2588        // c:1173-1174 — fold `$$$$` chains.
2589        while p > 0 {
2590            let prev = prev_char_index(bytes, p);
2591            let pc = char_at(bytes, prev);
2592            if is_str(pc) || pc == Qstring {
2593                p = prev;
2594            } else {
2595                break;
2596            }
2597        }
2598        loop {
2599            // c:1175-1176
2600            let n1 = p + char_at(bytes, p).len_utf8();
2601            if n1 >= bytes.len() {
2602                break;
2603            }
2604            let c1 = char_at(bytes, n1);
2605            let n2 = n1 + c1.len_utf8();
2606            if n2 >= bytes.len() {
2607                break;
2608            }
2609            let c2 = char_at(bytes, n2);
2610            if (is_str(c1) || c1 == Qstring) && (is_str(c2) || c2 == Qstring) {
2611                p = n2;
2612            } else {
2613                break;
2614            }
2615        }
2616    }
2617
2618    // c:1179 — guard against `$(`, `$[`, `$'`.
2619    let next_char = if p + 1 <= bytes.len() {
2620        let dollar_len = char_at(bytes, p).len_utf8();
2621        char_at(bytes, p + dollar_len)
2622    } else {
2623        '\0'
2624    };
2625    if !(found && next_char != Inpar && next_char != Inbrack && next_char != Snull) {
2626        return None; // c:1316
2627    }
2628
2629    // c:1181 — b = p + 1 (start of body), e = b initially.
2630    let dollar_len = char_at(bytes, p).len_utf8();
2631    let mut b: usize = p + dollar_len; // c:1181
2632    let mut br: i32 = 1; // c:1182
2633    let mut nest: i32 = 0; // c:1182
2634
2635    // c:1182 — `if (*b == Inbrace)`. The `untok = true` caller also takes the
2636    // literal `{`, which is how `${…}` reaches it.
2637    let brace_ch = char_at(bytes, b);
2638    if brace_ch == Inbrace || (untok && brace_ch == '{') {
2639        let (ib, ob) = if brace_ch == '{' {
2640            ('{', '}')
2641        } else {
2642            (Inbrace, Outbrace)
2643        };
2644        // c:1184
2645        // c:1188 — `if (!skipparens(Inbrace, Outbrace, &tb) && tb - s <= offs) return NULL;`
2646        let mut tb: &str = &s[b..];
2647        let bal = crate::ported::utils::skipparens(ib, ob, &mut tb);
2648        let tb_after = s.len() - tb.len();
2649        if bal == 0 && tb_after <= offs_v {
2650            return None; // c:1189
2651        }
2652
2653        b += brace_ch.len_utf8(); // c:1192 b++
2654        br += 1;
2655        // c:1193-1203 — skip leading `(...)` flag group. C has a
2656        // ternary `qstring ? skipparens('(',')',&b) : skipparens(Inpar,Outpar,&b)`
2657        // — two source-level skipparens calls. Mirror that explicitly
2658        // so the call-coverage metric matches C.
2659        let mut b_str: &str = &s[b..];
2660        // The `untok = true` caller sees the `(`/`)` of a `${(flags)name}`
2661        // group as literals in the non-qstring case too — the same
2662        // accommodation the `Inbrace`/`{` test above makes. Without it
2663        // `skipparens` was handed `Inpar` against a literal `(`, returned -1
2664        // (its "wrong opening char" code) instead of 0, `b` never advanced
2665        // past the flags, the name scan then found `(` where a name should be
2666        // and `check_param` bailed with `ispar == 0`: `${(k)<TAB>` never
2667        // reached the `-brace-parameter-` context at all.
2668        let flag_ret: i32 = if qstring || (untok && char_at(bytes, b) == '(') {
2669            crate::ported::utils::skipparens('(', ')', &mut b_str)
2670        } else {
2671            crate::ported::utils::skipparens(Inpar, Outpar, &mut b_str)
2672        };
2673        let after_flags_pos = s.len() - b_str.len();
2674        if flag_ret > 0 || after_flags_pos > offs_v {
2675            ispar.store(2, Ordering::Relaxed); // c:1201
2676            return None; // c:1202
2677        }
2678        b = after_flags_pos;
2679
2680        // c:1205 — detect `nest` from preceding `${ ${` chain.
2681        let mut tb = p;
2682        while tb > 0 {
2683            let prev = prev_char_index(bytes, tb);
2684            let pc = char_at(bytes, prev);
2685            if pc == Outbrace || pc == Inbrace {
2686                tb = prev;
2687                break;
2688            }
2689            tb = prev;
2690        }
2691        if tb > 0 {
2692            let cc = char_at(bytes, tb);
2693            let prev = prev_char_index(bytes, tb);
2694            let pp = char_at(bytes, prev);
2695            if cc == Inbrace && (pp == Stringg || cc == Qstring) {
2696                nest = 1; // c:1207
2697            }
2698        }
2699    }
2700
2701    // c:1212-1213 — skip `^=~` prefix flags.
2702    while b < bytes.len() {
2703        let c = char_at(bytes, b);
2704        if c == '^' || c == Hat || c == '=' || c == Equals || c == '~' || c == Tilde {
2705            b += c.len_utf8();
2706        } else {
2707            break;
2708        }
2709    }
2710    // c:1215 — `#` / `+` length-prefix.
2711    if b < bytes.len() {
2712        let c = char_at(bytes, b);
2713        if c == '#' || c == Pound || c == '+' {
2714            b += c.len_utf8();
2715        }
2716    }
2717
2718    let mut e: usize = b; // c:1219
2719    if br != 0 {
2720        // c:1220
2721        let qopen = if test { Dnull } else { '"' };
2722        while e < bytes.len() && char_at(bytes, e) == qopen {
2723            // c:1221
2724            e += qopen.len_utf8();
2725            parq.fetch_add(1, Ordering::Relaxed); // c:1221
2726        }
2727        if !test {
2728            b = e;
2729        } // c:1223
2730    }
2731
2732    // c:1226-1252 — find end of name.
2733    if e < bytes.len() {
2734        let c = char_at(bytes, e);
2735        let one_char_name = matches!(c,
2736            ch if ch == Quest || ch == Star || ch == Stringg || ch == Qstring
2737                || ch == '?' || ch == '*' || ch == '$' || ch == '-' || ch == '!' || ch == '@');
2738        if one_char_name {
2739            // c:1230
2740            e += c.len_utf8();
2741        } else if c.is_ascii_digit() {
2742            // c:1232
2743            while e < bytes.len() && char_at(bytes, e).is_ascii_digit() {
2744                // c:1233
2745                e += 1;
2746            }
2747        } else {
2748            // c:1232-1241 — the `itype_end(e, INAMESPC, 0)` do/while:
2749            //
2750            //     do { e = ie;
2751            //          if (comppatmatch && *comppatmatch &&
2752            //              (*e == Star || *e == Quest))  ie = e + 1;
2753            //          else                              ie = itype_end(e, …);
2754            //     } while (ie != e);
2755            //
2756            // The loop, not just its first iteration, is what lets a glob
2757            // metacharacter sit INSIDE the parameter name when
2758            // `$compstate[pattern_match]` is on: `$fo*ba<TAB>` must treat
2759            // `fo*ba` as one name. The port ran a single `walk_namespace`
2760            // and stopped at the `*`, so pattern parameter-name completion
2761            // saw the name end early and completed the wrong span.
2762            let patmatch_on = comppatmatch
2763                .get_or_init(|| Mutex::new(None))
2764                .lock()
2765                .ok()
2766                .and_then(|g| g.clone())
2767                .map(|v| !v.is_empty())
2768                .unwrap_or(false); // c:1235
2769            let mut ie = e + walk_namespace(&bytes[e..]); // c:1232
2770            loop {
2771                e = ie; // c:1234
2772                if e >= bytes.len() {
2773                    break;
2774                }
2775                let ec = char_at(bytes, e);
2776                if patmatch_on && (ec == Star || ec == Quest) {
2777                    ie = e + ec.len_utf8(); // c:1237
2778                } else {
2779                    ie = e + walk_namespace(&bytes[e..]); // c:1239
2780                }
2781                if ie == e {
2782                    break; // c:1240
2783                }
2784            }
2785            if e == b && c == '.' {
2786                // c:1242-1250 — a lone `.` counts as an incomplete name.
2787                e += 1;
2788            }
2789        }
2790    }
2791
2792    // c:1259 — `if (offs <= e - s && offs >= b - s)`.
2793    if offs_v <= e && offs_v >= b {
2794        // c:1263 — strip trailing `"`s when br set.
2795        if br != 0 {
2796            let qopen = if test { Dnull } else { '"' };
2797            let mut pq = e;
2798            while pq < bytes.len() && char_at(bytes, pq) == qopen {
2799                pq += qopen.len_utf8();
2800                parq.fetch_sub(1, Ordering::Relaxed);
2801                eparq.fetch_add(1, Ordering::Relaxed);
2802            }
2803        }
2804        if test {
2805            // c:1269
2806            return Some(b); // c:1270
2807        }
2808        if set {
2809            // c:1273
2810            if br >= 2 {
2811                // c:1274
2812                mflags.fetch_or(CMF_PARBR, Ordering::Relaxed); // c:1275
2813                if nest != 0 {
2814                    // c:1276
2815                    mflags.fetch_or(CMF_PARNEST, Ordering::Relaxed); // c:1277
2816                }
2817            }
2818            // c:1280 — `isuf = dupstring(e); untokenize(isuf)`.
2819            // `s` is the word lifted off the METAFIED line, so its content
2820            // bytes are metafied (`Meta` = 0x83 followed by `c ^ 32`) and are
2821            // not valid UTF-8. `dupstring` copies bytes; `from_utf8_lossy`
2822            // rewrote each escape as a 3-byte U+FFFD, which both destroyed
2823            // the character and shifted every later offset. `b`/`e` are byte
2824            // offsets into these same bytes, so the split stays byte-exact.
2825            let mut tail = unsafe { String::from_utf8_unchecked(bytes[e..].to_vec()) };
2826            tail = strip_tokens(&tail); // crate::lex::untokenize substitute
2827            if let Ok(mut g) = isuf.get_or_init(|| Mutex::new(String::new())).lock() {
2828                *g = tail;
2829            }
2830            // c:1284 — `ripre = dyncat(ripre, s_through_b)` (the spec tree
2831            // e73499b372 has it at c:1279). Metafied bytes, byte-exact (see
2832            // the `isuf` split above).
2833            let head = unsafe { String::from_utf8_unchecked(bytes[..b].to_vec()) };
2834            if let Ok(mut g) = ripre.get_or_init(|| Mutex::new(String::new())).lock() {
2835                *g = format!("{}{}", *g, head);
2836            }
2837            if let Ok(mut g) = ipre.get_or_init(|| Mutex::new(String::new())).lock() {
2838                *g = strip_tokens(&format!("{}{}", *g, head));
2839            }
2840        }
2841        // c:1285-1286 — save prefix for compfunc.
2842        let cf_active = compfunc
2843            .get_or_init(|| Mutex::new(None))
2844            .lock()
2845            .ok()
2846            .and_then(|g| g.clone())
2847            .map(|s| !s.is_empty())
2848            .unwrap_or(false);
2849        if cf_active {
2850            let pf = if br >= 2 {
2851                CMF_PARBR | (if nest != 0 { CMF_PARNEST } else { 0 })
2852            } else {
2853                0
2854            };
2855            parflags.store(pf, Ordering::Relaxed); // c:1287
2856            // c:1290 `untokenize(parpre = ztrdup(s))` — same metafied
2857            // prefix as the `ripre` copy above, byte-exact.
2858            let head = unsafe { String::from_utf8_unchecked(bytes[..b].to_vec()) };
2859            if let Ok(mut g) = parpre.get_or_init(|| Mutex::new(String::new())).lock() {
2860                *g = strip_tokens(&head); // c:1290
2861            }
2862        }
2863        // c:1293 — adjust wb/we/offs.
2864        let off_delta = b as i32;
2865        OFFS.fetch_sub(off_delta, Ordering::Relaxed); // c:1294
2866        let new_offs = OFFS.load(Ordering::Relaxed);
2867        let zlc = ZLEMETACS.load(Ordering::Relaxed);
2868        WB.store(zlc - new_offs, Ordering::Relaxed); // c:1295
2869        WE.store(
2870            WB.load(Ordering::Relaxed) + (e - b) as i32,
2871            Ordering::Relaxed,
2872        ); // c:1296
2873        ispar.store(if br >= 2 { 2 } else { 1 }, Ordering::Relaxed); // c:1297
2874        return Some(b); // c:1299
2875    } else if offs_v > e && e < bytes.len() && char_at(bytes, e) == ':' {
2876        // c:1312
2877        // c:1313-1316 — colon-modifier guess.
2878        let offsptr = offs_v;
2879        let mut e2 = e;
2880        while e2 < offsptr && e2 < bytes.len() {
2881            let c = char_at(bytes, e2);
2882            if c != ':' && !c.is_alphanumeric() {
2883                break;
2884            }
2885            e2 += c.len_utf8();
2886        }
2887        ispar.store(if br >= 2 { 2 } else { 1 }, Ordering::Relaxed); // c:1316
2888        return None; // c:1317
2889    }
2890
2891    let _ = (Bnull,); // silence unused-import warning if Bnull not hit
2892    None // c:1320
2893}
2894
2895// =====================================================================
2896// rembslash — `Src/Zle/compcore.c:1323`.
2897// =====================================================================
2898
2899/// Port of `mod_export char *rembslash(char *s)` from compcore.c:1322.
2900///
2901/// "Strip backslash escapes from a token, treating `\X` as `X`."
2902pub fn rembslash(s: &str) -> String {
2903    // c:1323
2904    let mut result = String::with_capacity(s.len()); // c:1323
2905    let mut chars = s.chars().peekable(); // c:1327
2906    while let Some(c) = chars.next() {
2907        if c == '\\' {
2908            // c:1328
2909            if let Some(nxt) = chars.next() {
2910                // c:1329
2911                result.push(nxt);
2912            }
2913        } else {
2914            result.push(c); // c:1343-1333
2915        }
2916    }
2917    result // c:1343
2918}
2919
2920// =====================================================================
2921// remsquote — `Src/Zle/compcore.c:1343`.
2922// =====================================================================
2923
2924/// Port of `mod_export int remsquote(char *s)` from compcore.c:1342.
2925pub fn remsquote(s: &mut String) -> i32 {
2926    // c:1343
2927    let rcquotes = isset(RCQUOTES); // c:1343
2928    let qa: usize = if rcquotes { 1 } else { 3 };
2929
2930    let bytes = s.as_bytes(); // c:1346
2931    let mut t = Vec::<u8>::with_capacity(bytes.len());
2932    let mut ret: i32 = 0;
2933    let mut i = 0;
2934    while i < bytes.len() {
2935        // c:1348
2936        let matched = if qa == 1 {
2937            // c:1349
2938            i + 1 < bytes.len() && bytes[i] == b'\'' && bytes[i + 1] == b'\''
2939        } else {
2940            i + 3 < bytes.len()                                              // c:1351
2941                && bytes[i]     == b'\''
2942                && bytes[i + 1] == b'\\'
2943                && bytes[i + 2] == b'\''
2944                && bytes[i + 3] == b'\''
2945        };
2946        if matched {
2947            ret += qa as i32; // c:1352
2948            t.push(b'\''); // c:1353
2949            i += qa + 1; // c:1354
2950        } else {
2951            t.push(bytes[i]); // c:1356
2952            i += 1;
2953        }
2954    }
2955    *s = String::from_utf8(t).unwrap_or_default(); // c:1357
2956    ret // c:1366
2957}
2958
2959// =====================================================================
2960// ctokenize — `Src/Zle/compcore.c:1366`.
2961// =====================================================================
2962
2963/// Port of `mod_export char *ctokenize(char *p)` from compcore.c:1365.
2964///
2965/// C calls `tokenize(p)` first then walks the string replacing
2966/// unescaped `$`/`{`/`}` with the token bytes `String`/`Inbrace`/
2967/// `Outbrace`. Backslash-escaped variants become `Bnull`.
2968pub fn ctokenize(p: &str) -> String {
2969    // c:1366
2970    // c:1370 — `tokenize(p);` ran FIRST in C and was missing here entirely,
2971    // so the glob metacharacters (`*`, `?`, `[`, `]`, `~`, `^`, `#`, …) that
2972    // `tokenize` turns into their high-bit token chars stayed literal. Every
2973    // consumer of `comp_str(untok=0)` (c:1411-1414) therefore received a
2974    // string that `haswilds`/`patcompile` read as plain text, so a typed
2975    // pattern in $PREFIX/$SUFFIX was never seen as a pattern. The loop below
2976    // is only the extra `$`/`{`/`}` pass C runs after tokenize.
2977    let mut tokenized = p.to_string();
2978    crate::ported::glob::tokenize(&mut tokenized); // c:1370
2979    let p: &str = &tokenized;
2980    let bytes = p.as_bytes(); // c:1372
2981    let mut out = Vec::<u8>::with_capacity(bytes.len());
2982    let mut bslash = false; // c:1369
2983    let mut prev_idx: Option<usize> = None;
2984    let mut i = 0;
2985    while i < bytes.len() {
2986        let b = bytes[i]; // c:1373
2987        if b == b'\\' {
2988            // c:1374
2989            bslash = true;
2990            out.push(b);
2991            prev_idx = Some(out.len() - 1);
2992        } else {
2993            if b == b'$' || b == b'{' || b == b'}' {
2994                // c:1377
2995                if bslash {
2996                    // c:1378
2997                    if let Some(pi) = prev_idx {
2998                        // c:1379
2999                        out.truncate(pi);
3000                        let mut buf = [0u8; 4];
3001                        out.extend_from_slice(Bnull.encode_utf8(&mut buf).as_bytes());
3002                    }
3003                    out.push(b);
3004                } else {
3005                    let tok = if b == b'$' {
3006                        Stringg
3007                    }
3008                    // c:1381
3009                    else if b == b'{' {
3010                        Inbrace
3011                    }
3012                    // c:1382
3013                    else {
3014                        Outbrace
3015                    }; // c:1382
3016                    let mut buf = [0u8; 4];
3017                    out.extend_from_slice(tok.encode_utf8(&mut buf).as_bytes());
3018                }
3019            } else {
3020                out.push(b);
3021            }
3022            bslash = false; // c:1384
3023            prev_idx = Some(out.len().saturating_sub(1));
3024        }
3025        i += 1;
3026    }
3027    String::from_utf8(out).unwrap_or_default() // c:1403
3028}
3029
3030// =====================================================================
3031// comp_str — `Src/Zle/compcore.c:1403`.
3032// =====================================================================
3033
3034/// Port of `mod_export char *comp_str(int *ipl, int *pl, int untok)`
3035/// from compcore.c:1402.
3036pub fn comp_str(untok: bool) -> (String, i32, i32) {
3037    // c:1403
3038    let mut p = COMPPREFIX
3039        .get_or_init(|| Mutex::new(String::new())) // c:1405
3040        .lock()
3041        .unwrap()
3042        .clone();
3043    let mut s = COMPSUFFIX
3044        .get_or_init(|| Mutex::new(String::new())) // c:1406
3045        .lock()
3046        .unwrap()
3047        .clone();
3048    let ip = COMPIPREFIX
3049        .get_or_init(|| Mutex::new(String::new())) // c:1407
3050        .lock()
3051        .unwrap()
3052        .clone();
3053    if !untok {
3054        // c:1411
3055        p = ctokenize(&p); // c:1412
3056        p = p.chars().filter(|&c| c != Bnull).collect(); // c:1413 remnulargs
3057        s = ctokenize(&s); // c:1414
3058        s = s.chars().filter(|&c| c != Bnull).collect(); // c:1415
3059    }
3060    let lp = p.len() as i32; // c:1417
3061    let lip = ip.len() as i32; // c:1419
3062    let mut str = String::with_capacity(ip.len() + p.len() + s.len() + 1); // c:1420
3063    str.push_str(&ip); // c:1435
3064    str.push_str(&p); // c:1435
3065    str.push_str(&s); // c:1435
3066    (str, lip, lp) // c:1435-1430
3067}
3068
3069// =====================================================================
3070// comp_quoting_string — `Src/Zle/compcore.c:1435`.
3071// =====================================================================
3072
3073/// Port of `mod_export char *comp_quoting_string(int stype)` from
3074/// compcore.c:1434.
3075pub fn comp_quoting_string(stype: i32) -> &'static str {
3076    // c:1435
3077    match stype {
3078        // c:1435
3079        x if x == QT_SINGLE => "'",   // c:1439-1440
3080        x if x == QT_DOUBLE => "\"",  // c:1441-1442
3081        x if x == QT_DOLLARS => "$'", // c:1443-1444
3082        _ => {
3083            // c:1445
3084            let _ = QT_BACKSLASH;
3085            "\\" // c:1446
3086        }
3087    }
3088}
3089
3090// =====================================================================
3091// set_comp_sep — `Src/Zle/compcore.c:1460`.
3092// =====================================================================
3093
3094/// Direct port of `int set_comp_sep(void)` from compcore.c:1458 —
3095/// the `compset -q` driver that re-parses the current completion
3096/// word splitting it on the IFS, then resubmits the right slice
3097/// as the new completion target.
3098///
3099/// Inputs are now published/correct: `wb`/`we`/`offs` are written by
3100/// `get_comp_string` (WB/WE/OFFS shared statics) and `compqstack` by
3101/// `callcompfunc`'s c:305 reset (deduped to `complete::COMPQSTACK`).
3102///
3103/// Byte model: all of C's single-metafied-byte index arithmetic (the
3104/// `inull` walk c:1774-1804, the `chuck` removals, the `s[swb-1-sqq+dq]`
3105/// indexing c:1830, the `p[soffs]` chuck c:1739) is performed on local
3106/// single-byte-metafied `Vec<u8>` buffers built by `to_sb` (each
3107/// token-null marker `Snull`/`Dnull`/`Bnull` maps to ONE byte
3108/// 0x9d/0x9e/0x9f; `Meta`-escapes stay two bytes) and converted back
3109/// with `from_sb` (byte -> `char`, the char-per-metafied-byte form the
3110/// downstream comp* globals expect). `wb`/`we`/`zlemetacs` are consumed
3111/// as byte offsets. For ASCII completion words — command names, paths,
3112/// options, the dominant `compset -q` case — byte == char, so the
3113/// offsets are exact and the algorithm is a verifiable translation of C.
3114///
3115/// Known limitation (INHERITED from zshrs's input/lexer model, not a
3116/// defect of this port): for non-ASCII quoted words the shared cursor
3117/// model conflates byte and char units — `ingetc` steps the input by
3118/// char while `inbufct`/`zlemetall` are byte lengths (input.rs:355/540,
3119/// lex.rs:3013-3024) — so the incoming `wb`/`we`/`zlemetacs` diverge
3120/// from single-byte offsets. Tracked separately with the
3121/// `get_comp_string` quote-form tail; see that function's note.
3122///
3123/// The `QT_DOLLARS` (`$'...'`) arm (c:1613-1622) is fully wired: the
3124/// `getkeystring_with` decode applies `GETKEY_UPDATE_OFFSET`, so
3125/// both the `dolq` byte-count delta and the `css += zlemetacs - j`
3126/// cursor micro-adjustment are computed (inheriting the same non-ASCII
3127/// byte/char caveat noted above).
3128pub fn set_comp_sep() -> i32 {
3129    use crate::ported::lex::{
3130        ctxtlex, noaliases, set_noaliases, set_tok, set_tokstr, tok, tokstr, untokenize,
3131        LEX_LEXFLAGS,
3132    };
3133    use crate::ported::string::{dupstring_wlen, tricat};
3134    use crate::ported::utils::getkeystring_with;
3135    use crate::ported::zle::comp_h::{CP_QUOTE, CP_QUOTING};
3136    // COMPPREFIX/COMPSUFFIX/COMPIPREFIX/COMPQSTACK are already imported at
3137    // the module top; only the remaining comp* globals are pulled in here.
3138    use crate::ported::zle::complete::{
3139        COMPCURRENT, COMPISUFFIX, COMPQIPREFIX, COMPQISUFFIX, COMPQUOTE, COMPQUOTING, COMPWORDS,
3140    };
3141    use crate::ported::zle::zle_utils::{zle_restore_positions, zle_save_positions};
3142    use crate::ported::zsh_h::{
3143        Meta, COMPLETEINWORD, ENDINPUT, GETKEYS_DOLLARS_QUOTE, GETKEY_UPDATE_OFFSET, LEXERR,
3144        LEXFLAGS_ZLE, STRING_LEX,
3145    };
3146
3147    // ── single-byte-metafied <-> char-per-byte String conversions ──
3148    // Each metafied byte is one `char` in the port's string world, so
3149    // `c as u8` for c < 0x100 reconstructs the C single-byte buffer
3150    // (Meta-escapes = 0x83 + xor). A token char (crate::token_char) is its
3151    // one C token byte; real U+0084..=U+00A2 text is metafied the way C
3152    // holds data in that range, so it cannot come back as a token.
3153    let to_sb = |s: &str| -> Vec<u8> {
3154        let mut v = Vec::with_capacity(s.len());
3155        for c in s.chars() {
3156            let cp = c as u32;
3157            if let Some(t) = crate::token_char::token_byte(c) {
3158                v.push(t);
3159            } else if (0x84..=0xa2).contains(&cp) {
3160                v.push(Meta);
3161                v.push(cp as u8 ^ 32);
3162            } else if cp < 0x100 {
3163                v.push(cp as u8);
3164            } else {
3165                let mut b = [0u8; 4];
3166                v.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
3167            }
3168        }
3169        v
3170    };
3171    let from_sb = |b: &[u8]| -> String {
3172        let mut out = String::with_capacity(b.len());
3173        let mut i = 0;
3174        while i < b.len() {
3175            let x = b[i];
3176            if x == Meta && i + 1 < b.len() && (0x84..=0xa2).contains(&(b[i + 1] ^ 32)) {
3177                out.push((b[i + 1] ^ 32) as char); // data char to_sb metafied
3178                i += 2;
3179                continue;
3180            }
3181            out.push(crate::token_char::token_char_from_byte(x).unwrap_or(x as char));
3182            i += 1;
3183        }
3184        out
3185    };
3186    let snap = |g: &'static OnceLock<Mutex<String>>| -> String {
3187        g.get_or_init(|| Mutex::new(String::new()))
3188            .lock()
3189            .unwrap()
3190            .clone()
3191    };
3192    let put = |g: &'static OnceLock<Mutex<String>>, v: String| {
3193        *g.get_or_init(|| Mutex::new(String::new())).lock().unwrap() = v;
3194    };
3195
3196    // marker byte values (Snull/Dnull/Bnull/Stringg/Qstring are `char`).
3197    let snull_b = Snull as u32 as u8; // 0x9d
3198    let dnull_b = Dnull as u32 as u8; // 0x9e
3199    let bnull_b = Bnull as u32 as u8; // 0x9f
3200    let stringg_b = Stringg as u32 as u8; // 0x85 ($)
3201    let qstring_b = Qstring as u32 as u8; // 0x8c ("$)
3202    let meta_b: u8 = Meta; // 0x83
3203
3204    // c:1460 — s = comp_str(&lip, &lp, 1) with untok = 1.
3205    let (s_full, lip, lp) = comp_str(true);
3206    // c:1473 — int owe = we, owb = wb.
3207    let owe = WE.load(Ordering::Relaxed);
3208    let owb = WB.load(Ordering::Relaxed);
3209
3210    let mut foo: Vec<String> = Vec::new(); // c:1462 newlinklist()
3211
3212    // c:1478-1500 — locals.
3213    let mut swb: i32 = 0; // c:1490
3214    let mut swe: i32 = 0;
3215    let scs: i32; // cursor (fixed once set below)
3216    let mut soffs: i32 = 0;
3217    let ne = *crate::ported::utils::noerrs_lock().lock().unwrap(); // c:1479
3218    let mut got = false;
3219    let mut i: i32 = 0;
3220    let mut cur: i32 = -1;
3221    let mut css: i32 = 0;
3222    let mut remq = false;
3223    let mut dq: i32 = 0;
3224    let mut sq: i32 = 0;
3225    let qttype: i32;
3226    let mut sqq: i32 = 0;
3227    let mut lsq: i32 = 0;
3228    let mut qa: i32 = 0;
3229    let mut dolq: i32 = 0;
3230    let ois = INSTRING.load(Ordering::Relaxed); // c:1471
3231    let oib = INBACKT.load(Ordering::Relaxed);
3232    let noffs = lp; // active-prefix length
3233    let ona = noaliases();
3234
3235    // c:1476 — s += lip; wb += lip; untokenize(s).
3236    let s_after: String = if (lip as usize) <= s_full.len() {
3237        s_full[lip as usize..].to_string()
3238    } else {
3239        String::new()
3240    };
3241    WB.store(owb + lip, Ordering::Relaxed);
3242    let s = untokenize(&s_after);
3243    let s_b_full = to_sb(&s); // reconstructed arg, single-byte
3244
3245    // c:1483-1488 — zle_save_positions / addedx / noerrs / zcontext / lexflags.
3246    zle_save_positions();
3247    let ol = snap(&ZLEMETALINE);
3248    ADDEDX.store(1, Ordering::Relaxed);
3249    *crate::ported::utils::noerrs_lock().lock().unwrap() = 1;
3250    let lex_saved = lexsave(); // zcontext_save()
3251    LEX_LEXFLAGS.set(LEXFLAGS_ZLE);
3252
3253    // c:1494-1499 — tl = strlen(s)+2; tmp = " " + s[..noffs] + 'x' + s[noffs..].
3254    let noffs_u = (noffs.max(0) as usize).min(s_b_full.len());
3255    let tl0 = s_b_full.len() as i32 + 2; // strlen(s) + 2
3256    let mut tmp_b: Vec<u8> = Vec::with_capacity(s_b_full.len() + 3);
3257    tmp_b.push(b' ');
3258    tmp_b.extend_from_slice(&s_b_full[..noffs_u]);
3259    scs = 1 + noffs;
3260    ZLEMETACS.store(scs, Ordering::Relaxed);
3261    tmp_b.push(b'x');
3262    tmp_b.extend_from_slice(&s_b_full[noffs_u..]);
3263    let mut tmp = from_sb(&tmp_b);
3264
3265    // c:1501-1640 — quote-stack head processing.
3266    let compqstack_s = snap(&COMPQSTACK);
3267    qttype = compqstack_s
3268        .chars()
3269        .next()
3270        .map(|c| c as i32)
3271        .unwrap_or(QT_NONE);
3272    let qstack2 = compqstack_s
3273        .chars()
3274        .nth(1)
3275        .map(|c| c as u32 != 0)
3276        .unwrap_or(false); // compqstack[1]
3277    if qttype == QT_BACKSLASH {
3278        // c:1503-1506
3279        remq = true;
3280        tmp = rembslash(&tmp);
3281    } else if qttype == QT_SINGLE {
3282        // c:1508-1514
3283        qa = if isset(RCQUOTES) { 1 } else { 3 };
3284        let mut t = tmp.clone();
3285        sq = remsquote(&mut t);
3286        tmp = t;
3287    } else if qttype == QT_DOUBLE {
3288        // c:1516-1543 — strip \\ and \" pairs, tracking zlemetacs/css/dq.
3289        let mut v = to_sb(&tmp);
3290        let mut j: i32 = 0;
3291        let mut pi = 0usize;
3292        let mut zcs = ZLEMETACS.load(Ordering::Relaxed);
3293        while pi < v.len() {
3294            let c = v[pi];
3295            let nxt = v.get(pi + 1).copied();
3296            if c == b'\\' && (nxt == Some(b'\\') || nxt == Some(b'"')) {
3297                dq += 1;
3298                v.remove(pi); // chuck(p): drop the backslash
3299                match v.get(pi).copied() {
3300                    Some(b'"') => zcs -= 1,
3301                    _ => {
3302                        if j > zcs {
3303                            zcs += 1;
3304                            css += 1;
3305                        }
3306                    }
3307                }
3308                if pi >= v.len() {
3309                    break; // if (!*p) break
3310                }
3311            }
3312            pi += 1;
3313            j += 1;
3314        }
3315        ZLEMETACS.store(zcs, Ordering::Relaxed);
3316        tmp = from_sb(&v);
3317    } else if qttype == QT_DOLLARS {
3318        // c:1613-1622 — string decode + dolq, with the GETKEY_UPDATE_OFFSET
3319        // cursor micro-adjustment. `j = zlemetacs` (c:1614); the decode
3320        // updates zlemetacs in place as pre-cursor escapes collapse; then
3321        // `css += zlemetacs - j` (c:1621) folds the delta into the word
3322        // offset. GETKEYS_DOLLARS_QUOTE carries the port's decode flags;
3323        // GETKEY_UPDATE_OFFSET enables the offset bookkeeping in
3324        // getkeystring_with.
3325        let j = ZLEMETACS.load(Ordering::Relaxed); // c:1614 — j = zlemetacs
3326        let mut zcs = j;
3327        let (dec, _consumed) = getkeystring_with(
3328            &tmp,
3329            (GETKEYS_DOLLARS_QUOTE | GETKEY_UPDATE_OFFSET) as u32,
3330            Some(&mut zcs),
3331        );
3332        ZLEMETACS.store(zcs, Ordering::Relaxed);
3333        let sl_new = to_sb(&dec).len() as i32;
3334        // c:1619 — dolq = tl - sl (bytes removed by $' quoting).
3335        dolq = tl0 - sl_new;
3336        // c:1621 — css += zlemetacs - j.
3337        css += zcs - j;
3338        tmp = dec;
3339    }
3340    let odq = dq; // c:1642
3341
3342    // c:1643-1647 — push into lexer, set the working metaline.
3343    crate::ported::input::inpush(&dupstrspace(&tmp), 0, None);
3344    put(&ZLEMETALINE, tmp.clone());
3345    ZLEMETALL.store(tl0 - 1, Ordering::Relaxed); // tl - addedx
3346    crate::ported::hist::strinbeg(0);
3347    set_noaliases(true);
3348
3349    // c:1650-1755 — the ctxtlex token loop.
3350    let mut ns_b: Vec<u8> = Vec::new();
3351    loop {
3352        ctxtlex();
3353        let mut tokv = tok();
3354        let mut ts_opt = tokstr();
3355        if tokv == LEXERR {
3356            // c:1654-1668 — odd active-quote count means unterminated
3357            // string; treat as STRING and drop a trailing space.
3358            match &ts_opt {
3359                None => break,
3360                Some(ts) => {
3361                    let j = ts.chars().filter(|&c| c == Snull || c == Dnull).count();
3362                    if j & 1 == 1 {
3363                        tokv = STRING_LEX;
3364                        set_tok(STRING_LEX);
3365                        if ts.ends_with(' ') {
3366                            let mut t = ts.clone();
3367                            t.pop();
3368                            set_tokstr(Some(t.clone()));
3369                            ts_opt = Some(t);
3370                        }
3371                    }
3372                }
3373            }
3374        }
3375        if tokv == ENDINPUT {
3376            break; // c:1670
3377        }
3378        let mut last_p: Option<usize> = None;
3379        if let Some(ts) = ts_opt.as_ref() {
3380            if !ts.is_empty() {
3381                // c:1673-1680 — Bnull accounting against dq.
3382                if dq != 0 {
3383                    let cs: Vec<char> = ts.chars().collect();
3384                    let mut k = 0usize;
3385                    while dq != 0 && k < cs.len() {
3386                        if cs[k] == Bnull {
3387                            dq -= 1;
3388                            if cs.get(k + 1) == Some(&'\\') {
3389                                dq -= 1;
3390                            }
3391                        }
3392                        k += 1;
3393                    }
3394                }
3395                // c:1681-1690 — single-quote lsq accounting.
3396                if qttype == QT_SINGLE {
3397                    lsq = 0;
3398                    for c in ts.chars() {
3399                        if sq != 0 && c == Snull {
3400                            sq -= qa;
3401                        }
3402                        if c == '\'' {
3403                            sq -= qa;
3404                            lsq += qa;
3405                        }
3406                    }
3407                } else {
3408                    lsq = 0;
3409                }
3410                foo.push(ts.clone()); // addlinknode(foo, p = ztrdup(tokstr))
3411                last_p = Some(foo.len() - 1);
3412            }
3413        }
3414        // c:1694-1705 — capture the cursor word once lexflags cleared.
3415        if !got && LEX_LEXFLAGS.get() == 0 {
3416            if let Some(cur_idx) = last_p {
3417                got = true;
3418                cur = cur_idx as i32;
3419                swb = WB.load(Ordering::Relaxed) - dq - sq - dolq;
3420                swe = WE.load(Ordering::Relaxed) - dq - sq - dolq;
3421                sqq = lsq;
3422                soffs = ZLEMETACS.load(Ordering::Relaxed) - swb - css;
3423                // chuck(p + soffs): drop the injected 'x' from the node.
3424                let mut wb_bytes = to_sb(&foo[cur_idx]);
3425                if soffs >= 0 && (soffs as usize) < wb_bytes.len() {
3426                    wb_bytes.remove(soffs as usize);
3427                }
3428                foo[cur_idx] = from_sb(&wb_bytes);
3429                ns_b = wb_bytes; // ns = dupstring(p)
3430            }
3431        }
3432        i += 1;
3433        if tokv == ENDINPUT || tokv == LEXERR {
3434            break; // c:1707 do-while
3435        }
3436    }
3437
3438    // c:1709-1719 — tear down lexer state, restore positions.
3439    set_noaliases(ona);
3440    crate::ported::hist::strinend();
3441    crate::ported::input::inpop();
3442    crate::ported::utils::errflag
3443        .fetch_and(!crate::ported::utils::ERRFLAG_ERROR, Ordering::Relaxed);
3444    *crate::ported::utils::noerrs_lock().lock().unwrap() = ne;
3445    lexrestore(lex_saved); // zcontext_restore()
3446    WB.store(owb, Ordering::Relaxed);
3447    WE.store(owe, Ordering::Relaxed);
3448    put(&ZLEMETALINE, ol);
3449    zle_restore_positions();
3450    if cur < 0 || i < 1 {
3451        return 1; // c:1721
3452    }
3453
3454    // c:1723-1733 — check_param dispatch with offs temporarily = soffs.
3455    let o_offs = OFFS.load(Ordering::Relaxed);
3456    OFFS.store(soffs, Ordering::Relaxed);
3457    if check_param(&from_sb(&ns_b), false, true, false).is_some() {
3458        for b in ns_b.iter_mut() {
3459            if *b == dnull_b {
3460                *b = b'"';
3461            } else if *b == snull_b {
3462                *b = b'\'';
3463            }
3464        }
3465    }
3466    OFFS.store(o_offs, Ordering::Relaxed);
3467
3468    // c:1735 — ts = untokenize(dupstring(ns)).
3469    let ts_str = untokenize(&from_sb(&ns_b));
3470    let ts_b = to_sb(&ts_str);
3471
3472    // c:1737-1772 — quote-form detection: instring / inbackt / autoq.
3473    let ns0 = ns_b.first().copied();
3474    let ns1 = ns_b.get(1).copied();
3475    let quote_open = ns0 == Some(snull_b)
3476        || ns0 == Some(dnull_b)
3477        || ((ns0 == Some(stringg_b) || ns0 == Some(qstring_b)) && ns1 == Some(snull_b));
3478    let mut ts_off = 0usize;
3479    if quote_open {
3480        let mut nsptr = 0usize; // C's nsptr offset into ns
3481        match ns0 {
3482            x if x == Some(snull_b) => INSTRING.store(QT_SINGLE, Ordering::Relaxed),
3483            x if x == Some(dnull_b) => INSTRING.store(QT_DOUBLE, Ordering::Relaxed),
3484            _ => {
3485                INSTRING.store(QT_DOLLARS, Ordering::Relaxed);
3486                nsptr += 1;
3487                ts_off += 1;
3488                swb += 1;
3489            }
3490        }
3491        INBACKT.store(0, Ordering::Relaxed);
3492        swb += 1;
3493        // c:1747 — if (nsptr[strlen(nsptr)-1] == *nsptr && nsptr[1]) swe--
3494        let ns_slice = &ns_b[nsptr.min(ns_b.len())..];
3495        if ns_slice.len() >= 2 && *ns_slice.last().unwrap() == ns_slice[0] {
3496            swe -= 1;
3497        }
3498        // c:1749-1753 — autoq from ts prefix.
3499        ts_off += 1; // ++tsptr
3500        let ts_prefix = from_sb(&ts_b[..ts_off.min(ts_b.len())]);
3501        let autoq_v = if qstack2 {
3502            String::new()
3503        } else {
3504            multiquote(&ts_prefix, 1)
3505        };
3506        put(&AUTOQ, autoq_v);
3507    } else {
3508        INSTRING.store(QT_NONE, Ordering::Relaxed);
3509        put(&AUTOQ, String::new());
3510    }
3511
3512    // c:1774-1804 — the inull walk: drop null markers from ns, adjusting
3513    // swb/scs/soffs. `scs` is copied to a mutable walker `scs_w`.
3514    let mut scs_w = scs;
3515    {
3516        let mut pi = 0usize;
3517        let mut wi = swb;
3518        while pi < ns_b.len() {
3519            let c = ns_b[pi];
3520            if crate::ported::ztype_h::inull(c) {
3521                let next = ns_b.get(pi + 1).copied();
3522                let next_truthy = next.is_some();
3523                if wi < scs_w && c == bnull_b {
3524                    if next_truthy && remq {
3525                        swb -= 2;
3526                    }
3527                    if odq != 0 {
3528                        swb -= 1;
3529                        if next == Some(b'\\') {
3530                            swb -= 1;
3531                        }
3532                    }
3533                }
3534                if next_truthy || c != bnull_b {
3535                    if c == bnull_b {
3536                        if scs_w == wi + 1 {
3537                            scs_w += 1;
3538                            soffs += 1;
3539                        }
3540                    } else {
3541                        let cond = scs_w > wi;
3542                        wi -= 1; // C's post-decrement in `scs > i--`
3543                        if cond {
3544                            scs_w -= 1;
3545                        }
3546                    }
3547                } else if scs_w == swe {
3548                    scs_w -= 1;
3549                }
3550                ns_b.remove(pi); // chuck(p--); loop p++ revisits index pi
3551                wi += 1;
3552            } else {
3553                pi += 1;
3554                wi += 1;
3555            }
3556        }
3557    }
3558
3559    // c:1806 — ns = ts (the untokenized copy, advanced past open quote).
3560    let mut ns_final_b = ts_b[ts_off.min(ts_b.len())..].to_vec();
3561
3562    // c:1808-1813 — backslash-quoting length fixup.
3563    let instr = INSTRING.load(Ordering::Relaxed);
3564    let qstack_has_bs = compqstack_s.chars().any(|c| c as i32 == QT_BACKSLASH);
3565    if instr != QT_NONE && qstack_has_bs {
3566        let ns_now = from_sb(&ns_final_b);
3567        let rl = ns_final_b.len() as i32;
3568        let ql = to_sb(&multiquote(&ns_now, if qstack2 { 1 } else { 0 })).len() as i32;
3569        if ql > rl {
3570            swb -= ql - rl;
3571        }
3572    }
3573
3574    // c:1826-1855 — split the reconstructed s into qp (prefix) / qs (suffix)
3575    // around the word, with the empirical swb-1-sqq+dq / swe-- offsets.
3576    let idx = swb - 1 - sqq + dq;
3577    let iu = idx.clamp(0, s_b_full.len() as i32) as usize;
3578    let s_prefix = from_sb(&s_b_full[..iu]);
3579    let mut qp = if qttype == QT_SINGLE {
3580        dupstring_wlen(&s_prefix, iu)
3581    } else {
3582        rembslash(&s_prefix)
3583    };
3584    if swe < swb {
3585        swe = swb;
3586    }
3587    swe -= 1;
3588    let sl_s = s_b_full.len() as i32;
3589    if swe > sl_s {
3590        swe = sl_s;
3591        if ns_final_b.len() as i32 > swe - swb + 1 {
3592            let newlen = (swe - swb + 1).max(0) as usize;
3593            ns_final_b.truncate(newlen);
3594        }
3595    }
3596    let swe_u = swe.clamp(0, s_b_full.len() as i32) as usize;
3597    let s_suffix = from_sb(&s_b_full[swe_u..]);
3598    let mut qs = if qttype == QT_SINGLE {
3599        s_suffix.clone()
3600    } else {
3601        rembslash(&s_suffix)
3602    };
3603    let sl_ns = ns_final_b.len() as i32;
3604    if soffs > sl_ns {
3605        soffs = sl_ns;
3606    }
3607    if qttype == QT_SINGLE {
3608        let mut a = qp;
3609        remsquote(&mut a);
3610        qp = a;
3611        let mut b = qs;
3612        remsquote(&mut b);
3613        qs = b;
3614    }
3615
3616    // c:1857-1935 — publish the results.
3617    // c:1861-1868 — prepend the active quote char to compqstack.
3618    {
3619        let head = if instr == QT_NONE {
3620            QT_BACKSLASH
3621        } else {
3622            instr
3623        };
3624        let mut new_qstack = String::new();
3625        if let Some(hc) = char::from_u32(head as u32) {
3626            new_qstack.push(hc);
3627        }
3628        new_qstack.push_str(&compqstack_s);
3629        put(&COMPQSTACK, new_qstack);
3630        // !!! RUST-ONLY LINE — NO C COUNTERPART !!!
3631        // Same reason as the c:305-306 site above: in C the c:1854-1860
3632        // `compqstack = p` IS the `$compstate[all_quotes]` update, because
3633        // `compqstack_gsu` (complete.c:1299) has no storage of its own.
3634        set_compstate_str(
3635            "all_quotes",
3636            &crate::ported::zle::complete::get_compqstack(std::ptr::null_mut()),
3637        );
3638    }
3639
3640    // c:1870-1892 — compquote / compquoting + comp_setunset.
3641    let mut set = (CP_QUOTE | CP_QUOTING) as i32;
3642    let mut unset = 0i32;
3643    let (cq, cqg) = if instr == QT_DOUBLE {
3644        ("\"", "double")
3645    } else if instr == QT_SINGLE {
3646        ("'", "single")
3647    } else if instr == QT_DOLLARS {
3648        ("$'", "dollars")
3649    } else {
3650        unset = set;
3651        set = 0;
3652        ("", "")
3653    };
3654    put(&COMPQUOTE, cq.to_string());
3655    put(&COMPQUOTING, cqg.to_string());
3656    crate::ported::zle::complete::comp_setunset(0, 0, set, unset);
3657
3658    // c:1894-1907 — compprefix / compsuffix from ns around soffs.
3659    if !isset(COMPLETEINWORD) {
3660        put(&COMPPREFIX, untokenize(&from_sb(&ns_final_b)));
3661        put(&COMPSUFFIX, String::new());
3662    } else {
3663        let so = (soffs.max(0) as usize).min(ns_final_b.len());
3664        put(&COMPPREFIX, untokenize(&from_sb(&ns_final_b[..so])));
3665        put(&COMPSUFFIX, untokenize(&from_sb(&ns_final_b[so..])));
3666    }
3667    // c:1908-1910 — drop a dangling final backslash from compprefix.
3668    {
3669        let cp = snap(&COMPPREFIX);
3670        let cpb = cp.as_bytes();
3671        let n = cpb.len();
3672        if n > 1 && cpb[n - 1] == b'\\' && cpb[n - 2] != b'\\' && cpb[n - 2] != meta_b {
3673            let mut t = cp;
3674            t.pop();
3675            put(&COMPPREFIX, t);
3676        }
3677    }
3678
3679    // c:1912-1925 — fold qp/qs into the quoted ignored prefix/suffix.
3680    let cqip = tricat(
3681        &snap(&COMPQIPREFIX),
3682        &snap(&COMPIPREFIX),
3683        &multiquote(&qp, 1),
3684    );
3685    put(&COMPQIPREFIX, cqip);
3686    let cqis = tricat(
3687        &multiquote(&qs, 1),
3688        &snap(&COMPISUFFIX),
3689        &snap(&COMPQISUFFIX),
3690    );
3691    put(&COMPQISUFFIX, cqis);
3692    put(&COMPIPREFIX, String::new());
3693    put(&COMPISUFFIX, String::new());
3694
3695    // c:1926-1934 — rebuild compwords / compcurrent from foo.
3696    {
3697        let words: Vec<String> = foo.iter().map(|w| untokenize(w)).collect();
3698        let cnt = words.len() as i32;
3699        *COMPWORDS
3700            .get_or_init(|| Mutex::new(Vec::new()))
3701            .lock()
3702            .unwrap() = words;
3703        let mut compcur = cur + 1;
3704        if compcur > cnt {
3705            compcur = cnt;
3706        }
3707        COMPCURRENT.store(compcur, Ordering::Relaxed);
3708    }
3709
3710    // zshrs bridge: in C every comp* global written above IS the shell
3711    // parameter (gsu-bound at complete.c:1235-1295 — one storage), so a
3712    // completer sees the re-split word list the instant `compset -q`
3713    // returns. zshrs's `$words` / `$CURRENT` / `$PREFIX` / … are plain
3714    // paramtab copies published once per `callcompfunc`, so without this
3715    // mirror they still describe the PRE-split line. `_trap` is
3716    //     if [[ CURRENT -eq 2 ]]; then compset -q; _normal; else …
3717    // and `_normal` re-reads `$words[1]` — which stayed `trap`, so it
3718    // dispatched `_trap` again: unbounded recursion until FUNCNEST, with
3719    // the error text landing in the user's buffer. Same mirror
3720    // `restrict_range` (complete.rs:1301-1307) already does for its own
3721    // COMPWORDS/COMPCURRENT edit.
3722    {
3723        let words = COMPWORDS
3724            .get_or_init(|| Mutex::new(Vec::new()))
3725            .lock()
3726            .map(|g| g.clone())
3727            .unwrap_or_default();
3728        setaparam("words", words);
3729        let _ =
3730            crate::ported::params::setiparam("CURRENT", COMPCURRENT.load(Ordering::Relaxed) as i64);
3731        for (param, global) in [
3732            ("PREFIX", &COMPPREFIX),
3733            ("SUFFIX", &COMPSUFFIX),
3734            ("IPREFIX", &COMPIPREFIX),
3735            ("ISUFFIX", &COMPISUFFIX),
3736            ("QIPREFIX", &COMPQIPREFIX),
3737            ("QISUFFIX", &COMPQISUFFIX),
3738            // NOTE: the list ENDS here, matching `comprparams[]`
3739            // (Src/Zle/complete.c:1248-1258), whose last entry is QISUFFIX.
3740            //
3741            // `QUOTE` and `QUOTING` were previously published here as
3742            // top-level parameters. They are NOT in comprparams — `compquote`
3743            // and `compquoting` live in `compkparams`
3744            // (Src/Zle/complete.c:1266-1267) and are therefore
3745            // `$compstate[quote]` / `$compstate[quoting]` KEYS ONLY, never
3746            // shell parameters. zsh's `callcompfunc` (c:1585-1610) restores
3747            // the C globals, not any `$QUOTE`/`$QUOTING`.
3748            //
3749            // Publishing them created two real scalars that zsh does not have,
3750            // which the user's `_parameters` then offered as completion
3751            // matches — a stable +2 on any cell reaching this path.
3752            // The compstate side is published correctly and separately at
3753            // compcore.rs:886-890.
3754        ] {
3755            // Same gsu-setfn bypass as the restore in complete.rs:
3756            // QIPREFIX/QISUFFIX carry PM_READONLY (c:1256-1257).
3757            crate::vm_helper::set_readonly_special(param, &snap(global));
3758        }
3759    }
3760
3761    // c:1935-1937 — restore instring / inbackt, ret = 0.
3762    INSTRING.store(ois, Ordering::Relaxed);
3763    INBACKT.store(oib, Ordering::Relaxed);
3764    0
3765}
3766
3767// Brace counters live in zle_tricky.c:114 — re-exported there. Local
3768// re-exports here so call sites stay short:
3769#[doc(hidden)]
3770// =====================================================================
3771// set_list_array — `Src/Zle/compcore.c:1947`.
3772// =====================================================================
3773
3774/// Port of `static void set_list_array(char *name, LinkList l)` from
3775/// compcore.c:1947. Writes an array-typed parameter via the canonical
3776/// `setaparam` (params.c:3595).
3777pub fn set_list_array(name: &str, l: &[String]) {
3778    // c:1947
3779    let _ = setaparam(name, l.to_vec()); // c:1956
3780}
3781
3782// =====================================================================
3783// get_user_var — `Src/Zle/compcore.c:1956`.
3784// =====================================================================
3785
3786/// Port of `mod_export char **get_user_var(char *nam)` from
3787/// compcore.c:1956.
3788pub fn get_user_var(nam: Option<&str>) -> Option<Vec<String>> {
3789    // c:1956
3790    let nam = nam?; // c:1956
3791    if nam.starts_with('(') {
3792        // c:1960
3793        let mut arrlist: Vec<String> = Vec::new();
3794        let bytes = nam.as_bytes();
3795        let mut buf = Vec::<u8>::new();
3796        let mut notempty = false; // c:1963
3797        let mut brk = false;
3798        let mut i = 1; // c:1967
3799        while i < bytes.len() {
3800            let b = bytes[i];
3801            if b == b'\\' && i + 1 < bytes.len() {
3802                // c:1969
3803                buf.push(bytes[i + 1]); // c:1970
3804                notempty = true;
3805                i += 2;
3806                continue;
3807            }
3808            if b == b',' || b == b' ' || b == b'\t' || b == b'\n' || b == b')' {
3809                if b == b')' {
3810                    brk = true;
3811                } // c:1972
3812                if notempty {
3813                    // c:1974
3814                    let mut start = 0;
3815                    if !buf.is_empty() && buf[0] == b'\n' {
3816                        start = 1;
3817                    } // c:1977
3818                    // c:1979 `addlinknode(arrlist, s)` (c:1977-1978 is the
3819                    // leading-newline skip this `start` reproduces). `buf`
3820                    // is a byte-exact subsequence of the METAFIED `nam` (the
3821                    // `Meta` skip below keeps each escape pair intact), so
3822                    // it is not valid UTF-8 and `from_utf8_lossy` would have
3823                    // replaced every escape with U+FFFD. Splits only ever
3824                    // happen at the ASCII delimiters above, which can never
3825                    // be a UTF-8 continuation byte, so the copy is exact.
3826                    let s = unsafe { String::from_utf8_unchecked(buf[start..].to_vec()) };
3827                    arrlist.push(s); // c:1979
3828                }
3829                buf.clear(); // c:1981
3830                notempty = false;
3831            } else {
3832                notempty = true; // c:1983
3833                buf.push(b);
3834                // c:1984-1985 — `if (*ptr == Meta) ptr++;`. The byte AFTER a
3835                // Meta escape is the real character XOR 0x20 and must never
3836                // be examined as a delimiter: a metafied `)` is `Meta 0x09`,
3837                // whose second byte is TAB, so without this skip `inblank`
3838                // split the list in the middle of a quoted element.
3839                if b == crate::ported::zsh_h::Meta as u8 && i + 1 < bytes.len() {
3840                    i += 1;
3841                    buf.push(bytes[i]);
3842                }
3843            }
3844            i += 1;
3845            if brk {
3846                break;
3847            } // c:1988
3848        }
3849        if !brk || arrlist.is_empty() {
3850            return None;
3851        } // c:1991
3852        Some(arrlist) // c:1996
3853    } else {
3854        // c:1999
3855        // c:2003 — `if ((arr = getaparam(nam)) || (arr = gethparam(nam)))
3856        //          arr = (incompfunc ? arrdup(arr) : arr);
3857        //          else if ((val = getsparam(nam))) { arr = {val, NULL}; }`
3858        //
3859        // Route through the canonical accessors, exactly the three C calls in
3860        // that order. The previous port read `pm.u_arr` / `pm.u_str` straight
3861        // out of the paramtab node, which is NOT where every parameter keeps
3862        // its value:
3863        //
3864        //   * assoc arrays live in `paramtab_hashed_storage`, so `gethparam`
3865        //     was effectively unimplemented here — the whole middle arm of the
3866        //     C condition was missing;
3867        //   * a plain array whose node carries its value anywhere other than a
3868        //     populated `u_arr` (special/tied/magic params, and arrays
3869        //     materialised by a Rust compsys port rather than by a shell
3870        //     assignment) read back as None.
3871        //
3872        // A None here is reported by `cd_init` as `compdescribe: invalid
3873        // argument: <name>` (computil.rs:1052, c:516) and aborts the whole
3874        // description, so `_describe -t global-aliases 'global alias' ARR`
3875        // printed that error onto the command line and then a second
3876        // `compdescribe: no parsed state` from the following `-g` call.
3877        // Only reachable when `list-grouped` is FALSE: the grouped path in
3878        // `_describe` (sh:82) first copies each array into a local via
3879        // `eval local _a_N=( "${ARR[@]}" )`, and those locals DO land in
3880        // `u_arr`, which is why the same completion worked with the style on.
3881        queue_signals();
3882        let result = crate::ported::params::getaparam(nam)
3883            .or_else(|| crate::ported::params::gethparam(nam)) // c:2003
3884            .or_else(|| crate::ported::params::getsparam(nam).map(|v| vec![v])); // c:2005-2008
3885        unqueue_signals(); // c:2022
3886        result
3887    }
3888}
3889
3890// =====================================================================
3891// get_data_arr — `Src/Zle/compcore.c:2022`.
3892// =====================================================================
3893
3894/// Direct port of `static char **get_data_arr(char *name, int keys)`
3895/// from `Src/Zle/compcore.c:2022`:
3896/// ```c
3897/// queue_signals();
3898/// if (!(v = fetchvalue(&vbuf, &name, 1,
3899///                      (keys ? SCANPM_WANTKEYS : SCANPM_WANTVALS) |
3900///                      SCANPM_MATCHMANY)))
3901///     ret = NULL;
3902/// else
3903///     ret = getarrvalue(v);
3904/// unqueue_signals();
3905/// ```
3906/// A SUBSCRIPTED name goes straight through that call: `fetchvalue`
3907/// consumes the identifier (`Src/params.c:2196`) and hands the `[…]`
3908/// remainder to `getindex` (c:2289), which sets the SCANPM_MATCH* bits and
3909/// runs `getvaluearr` → `paramvalarr` → `scanparamvals`.
3910///
3911/// A BARE name keeps this port's accessor path (`getaparam` / `gethparam` /
3912/// `gethkparam`) rather than `fetchvalue`, because those are where the
3913/// zsh/parameter magic hashes and arrays (`commands`, `builtins`,
3914/// `reswords`, …) resolve their module scanfn/getfn — the paramtab node
3915/// holds no value for them, so a `fetchvalue` + `arrgetfn` read would come
3916/// back empty and `compadd -k commands` would add the literal word
3917/// "commands".
3918pub fn get_data_arr(name: &str, keys: bool) -> Option<Vec<String>> {
3919    // c:2022
3920
3921    queue_signals(); // c:2028
3922
3923    if name.contains('[') {
3924        // c:2030-2033 — `fetchvalue(&vbuf, &name, 1, (keys ?
3925        //   SCANPM_WANTKEYS : SCANPM_WANTVALS) | SCANPM_MATCHMANY)`.
3926        //
3927        // Both scan flags are FIXED at this call site: WANTKEYS vs WANTVALS
3928        // comes from `keys`, and SCANPM_MATCHMANY is ALWAYS set — so
3929        // `scanparamvals`' single-match early-out (`Src/params.c:648-650`)
3930        // never fires and EVERY match is returned, whatever the case of the
3931        // subscript's search letter. `Src/params.c:1719-1722` picks the
3932        // match TARGET (`i`/`I` → SCANPM_MATCHKEY, `k`/`K` →
3933        // SCANPM_KEYMATCH, else SCANPM_MATCHVAL) while
3934        // `scanparamvals` (c:665-681) picks the OUTPUT purely from
3935        // WANTKEYS/WANTVALS. That is why `compadd -k 'styles[(R)…]'`
3936        // (Zsh/Command/_zstyle:363) yields the KEYS of the entries whose
3937        // VALUE matched.
3938        let sf = (if keys {
3939            crate::ported::zsh_h::SCANPM_WANTKEYS
3940        } else {
3941            crate::ported::zsh_h::SCANPM_WANTVALS
3942        }) | crate::ported::zsh_h::SCANPM_MATCHMANY;
3943        let mut vbuf = crate::ported::zsh_h::value {
3944            pm: None,
3945            arr: Vec::new(),
3946            scanflags: 0,
3947            valflags: 0,
3948            start: 0,
3949            end: -1,
3950        };
3951        let mut cursor: &str = name;
3952        let result =
3953            match crate::ported::params::fetchvalue(Some(&mut vbuf), &mut cursor, 1, sf as i32) {
3954                None => None, // c:2034-2035
3955                Some(v) => {
3956                    // c:2037 — `ret = getarrvalue(v)`. zshrs's `getarrvalue`
3957                    // takes the resolved array plus the slice bounds rather
3958                    // than the Value, so spell out the C body's steps here.
3959                    //
3960                    // c:2554-2555 — `else if (IS_UNSET_VALUE(v)) return
3961                    // arrdup(&nular[1]);` (empty). IS_UNSET_VALUE (c:472-474)
3962                    // is `!pm || (pm->flags & PM_UNSET) || !*pm->nam`, which is
3963                    // how a missing assoc KEY reports nothing: getindex rebinds
3964                    // `v->pm` to a `PM_SCALAR|PM_UNSET` element on a miss
3965                    // (c:1588).
3966                    let unset = v.pm.as_ref().map_or(true, |p| {
3967                        (p.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) != 0
3968                            || p.node.nam.is_empty()
3969                    });
3970                    if unset {
3971                        Some(Vec::new()) // c:2555
3972                    } else {
3973                        let arr = crate::ported::params::getvaluearr(Some(&mut *v)); // c:2564
3974                        if v.start == 0 && v.end == -1 {
3975                            // c:2565-2566 — whole-value read: hand the array
3976                            // straight back. A PRESENT assoc key lands here
3977                            // with an empty `arr` (getvaluearr has no
3978                            // PM_SCALAR arm, c:719), which is C's "a scalar
3979                            // element is not a match list" answer.
3980                            Some(arr)
3981                        } else {
3982                            // !!! RUST-ONLY: `params::getarrvalue` takes a
3983                            // 1-based start; a non-negative `v.start` is
3984                            // getindex's 0-based offset (c:2145), a negative
3985                            // one already counts from the end.
3986                            let start = v.start as i64;
3987                            Some(crate::ported::params::getarrvalue(
3988                                &arr,
3989                                if start >= 0 { start + 1 } else { start },
3990                                v.end as i64,
3991                            ))
3992                        }
3993                    }
3994                }
3995            };
3996        unqueue_signals(); // c:2039
3997        return result;
3998    }
3999
4000    // Route through the same param accessors `${(k)name}` / `${(v)name}`
4001    // / `${name}` use so SPECIAL magic hashes (`commands`, `builtins`,
4002    // `functions`, `aliases`, `reswords`, …) resolve via their module
4003    // scanfns — the raw `paramtab_hashed_storage` map is empty for those,
4004    // which is why `compadd -k commands` previously added the literal
4005    // word "commands" instead of every command name.
4006    let result = if keys {
4007        // c:2028-2033 — `fetchvalue(…, SCANPM_WANTKEYS | SCANPM_MATCHMANY)`
4008        // then `getarrvalue` (c:2556) → `getvaluearr` → for a PM_HASHED param
4009        // `paramvalarr(v->pm->gsu.h->getfn(v->pm), v->scanflags)`
4010        // (`Src/params.c:735-736`). `paramvalarr` emits through
4011        // `scanhashtable` (`Src/params.c:718`, `Src/hashtable.c:426`), so C
4012        // hands back an assoc's keys in HASH-BUCKET order — the very order
4013        // `${(k)assoc}` produces, since `${(k)}` reaches that same
4014        // `paramvalarr`.
4015        //
4016        // That agreement is load-bearing for `compadd -k NAME -d DISP`: the
4017        // caller builds DISP from `${(kv)NAME}` (e.g.
4018        // `Completion/Unix/Command/_git`'s __git_diff_filters), so the two
4019        // arrays are paired element-for-element ONLY while `-k` enumerates in
4020        // the same order `${(kv)}` did.
4021        //
4022        // `gethkparam` reads `paramtab_hashed_storage` directly
4023        // (`params.rs:6801`), an INSERTION-ordered `IndexMap`: for
4024        // `filters=(A added C copied b "pairing broken")` it yields `A C b`
4025        // where zsh scans `b A C`. Every key then drew the WRONG display
4026        // string, and since `matchcmp` sorts by `disp` when present
4027        // (c:3179-3191) two otherwise-`matcheq` matches with mismatched `disp`
4028        // stopped being adjacent, so the consecutive-run dedup at c:3271 —
4029        // which only collapses ADJACENT equals — left the duplicate in the
4030        // list (`git diff --diff-filter=<TAB>` listed `b -- pairing broken`
4031        // twice).
4032        //
4033        // `subst::assoc_keys` is the keys-only read `${(k)NAME}` itself uses:
4034        // it rebuilds zsh's bucket layout to recover the scan order, and for
4035        // a magic hash runs the scanfn with SCANPM_WANTKEYS alone (c:2029).
4036        // Reading through `assoc_get` asked for values too, so
4037        // `compadd -k functions` deparsed every function body only to drop
4038        // it (parameter.c:484-486 skips the body for a keys-only scan).
4039        crate::ported::subst::assoc_keys(name)
4040            .filter(|k| !k.is_empty())
4041            // A magic hash (`commands`, `builtins`, `functions`, …) has no
4042            // `paramtab_hashed_storage` row at all, so `assoc_get` yields
4043            // nothing and the module scanfn behind `gethkparam` is the only
4044            // backing. gethkparam also answers Some(empty) for an assoc that
4045            // exists but holds nothing, which is C's "exists, no entries".
4046            .or_else(|| crate::ported::params::gethkparam(name))
4047            // But `compadd -k` on a REGULAR array (e.g. `_setopt`'s
4048            // `local -a onopts`) must add its ELEMENTS: C's
4049            // `fetchvalue(SCANPM_WANTKEYS)` ignores WANTKEYS for a non-hash and
4050            // returns the value array. gethkparam returns None for a non-hash,
4051            // so fall back to the elements — without this, `setopt`/`unsetopt
4052            // <tab>` (and any `compadd -k <plain-array>`) produced ZERO matches.
4053            .or_else(|| crate::ported::params::getaparam(name))
4054    } else {
4055        // SCANPM_WANTVALS — plain-array elements, else assoc values.
4056        crate::ported::params::getaparam(name)
4057            .filter(|v| !v.is_empty())
4058            .or_else(|| crate::ported::params::gethparam(name))
4059    };
4060
4061    unqueue_signals(); // c:2041
4062    result
4063}
4064
4065// =====================================================================
4066// addmatch — `Src/Zle/compcore.c:2041`.
4067// =====================================================================
4068
4069/// Port of `static void addmatch(char *str, int flags, char ***dispp,
4070///                                int line)` from compcore.c:2041.
4071pub fn addmatch(str: &str, flags: i32, disp: Option<&str>, line: bool) {
4072    // c:2041
4073    let mut cm = Cmatch::default(); // c:2041
4074    cm.str = Some(str.to_string()); // c:2047
4075                                    // c:2049-2051 — inline read of `complist` parameter, parse `packed`/
4076                                    // `rows` substrings into CMF_PACKED/CMF_ROWS flag bits.
4077    let complist_extra = {
4078        let s = COMPLIST
4079            .get_or_init(|| Mutex::new(String::new()))
4080            .lock()
4081            .map(|g| g.clone())
4082            .unwrap_or_default();
4083        let packed = if s.contains("packed") { CMF_PACKED } else { 0 }; // c:2050
4084        let rows = if s.contains("rows") { CMF_ROWS } else { 0 }; // c:2051
4085        if s.is_empty() {
4086            0
4087        } else {
4088            packed | rows
4089        }
4090    };
4091    cm.flags = flags | complist_extra; // c:2048
4092    if let Some(d) = disp {
4093        // c:2052
4094        cm.disp = Some(d.to_string()); // c:2056
4095    } else if line {
4096        // c:2057
4097        cm.disp = Some(String::new()); // c:2058
4098        cm.flags |= CMF_DISPLINE; // c:2059
4099    }
4100    mnum.fetch_add(1, Ordering::Relaxed); // c:2060
4101                                          // c:2061 — `ainfo->count++`. Missing here while the sibling
4102                                          // increment in `add_match_data` (c:3006) was present, so every
4103                                          // match added through THIS path (`compadd -x`/`-X` dummies, the
4104                                          // CAF_ALL `<all>` placeholder) was invisible to `ainfo.count` —
4105                                          // the counter `do_ambiguous`/`permmatches` use to decide whether
4106                                          // any match exists in the group.
4107    if let Ok(mut g) = ainfo.get_or_init(|| Mutex::new(None)).lock() {
4108        if let Some(a) = g.as_mut() {
4109            a.count += 1; // c:2061
4110        }
4111    }
4112    {
4113        let cell = curexpl.get_or_init(|| Mutex::new(None)); // c:2062
4114        if let Ok(mut g) = cell.lock() {
4115            if let Some(e) = g.as_mut() {
4116                e.count += 1;
4117            }
4118        }
4119    }
4120    let mcell = crate::comp_match_handles::matches_arc(); // c:2066
4121    if let Ok(mut g) = mcell.lock() {
4122        g.push(cm);
4123    }
4124    newmatches.store(1, Ordering::Relaxed); // c:2068
4125    {
4126        let cell = mgroup.get_or_init(|| Mutex::new(None)); // c:2069
4127        if let Ok(g) = cell.lock() {
4128            if let Some(grp) = g.as_ref() {
4129                // c:2068 `mgroup->new = 1` — `new_` is the shared flag the
4130                // `amatches` entry sees too (comp_h.rs Cmgroup::new_).
4131                grp.new_.store(1, Ordering::Relaxed);
4132            }
4133        }
4134    }
4135}
4136
4137// =====================================================================
4138// addmatches — `Src/Zle/compcore.c:2080`.
4139// =====================================================================
4140
4141/// Direct port of `int addmatches(Cadata dat, char **argv)` from
4142/// compcore.c:2080 — the workhorse called from every `compadd`
4143/// invocation. Walks `argv`, runs the matcher chain against each
4144/// candidate, builds the Cline chain via `add_match_data`, and
4145/// appends accepted matches to the current group.
4146///
4147/// Real-bodied across all major phases: prologue (group selection
4148/// c:2105-2118, brace-state snapshot c:2129-2132, instring/inbackt
4149/// save c:2148-2179, `*argv` empty short-circuit c:2127), mstack
4150/// push c:2210-2222, aign/pign suffix-ignore + Patprog filters
4151/// c:2223-2246, disp array c:2247-2250, lipre/lisuf/lpre/lsuf
4152/// assembly c:2253-2300, per-candidate match loop with comp_match
4153/// dispatch + add_match_data emit + apar/opar writeback
4154/// c:2482-2601, apar/opar setaparam c:2602-2605, exp addexpl
4155/// c:2610, hasallmatch CAF_ALL placeholder c:2612-2614, dummy
4156/// entries c:2616-2617.
4157pub fn addmatches(
4158    dat: &mut Cadata, // c:2080
4159    argv: &[String],
4160) -> i32 {
4161    let _nm = mnum.load(Ordering::Relaxed); // c:2089 nm
4162                                            // c:2089-2090 — `ohp = haspattern`, `ois = instring`, `oib = inbackt`,
4163                                            // and c:2084 `oaq = autoq`. All four are restored at c:2624-2633; the
4164                                            // port saved none of them.
4165    let ohp = haspattern.load(Ordering::Relaxed); // c:2089
4166    let ois = INSTRING.load(Ordering::Relaxed); // c:2090
4167    let oib = INBACKT.load(Ordering::Relaxed); // c:2090
4168    let oaq = AUTOQ
4169        .get_or_init(|| Mutex::new(String::new()))
4170        .lock()
4171        .map(|g| g.clone())
4172        .unwrap_or_default(); // c:2084
4173                              // c:2085 — `char *oqp = qipre, *oqs = qisuf`, restored at c:2629-2630.
4174    let oqp = crate::ported::zle::zle_tricky::qipre_get(); // c:2085
4175    let oqs = crate::ported::zle::zle_tricky::qisuf_get(); // c:2085
4176
4177    // c:2093 — `Cmlist oms = mstack;`, restored at c:2622 `mstack = oms;`.
4178    // The `-M` matcher a compadd carries is pushed onto `mstack` (c:2212) for
4179    // the duration of THAT call only. This port pushed but never restored, so
4180    // the matcher leaked into every later compadd of the same completion: once
4181    // `_describe` added an option list with `-M 'r:|[_-]=* r:|=*'`, a following
4182    // bare `compadd -k commands` matched the line prefix `-` against every
4183    // command name. `hash -<TAB>` offered 1152 candidates where zsh offers the
4184    // 6 options; the same leak inflates any completion that mixes a matcher-
4185    // carrying compadd with a later plain one.
4186    let oms_saved: Option<std::sync::Arc<Cmlist>> = mstack
4187        .get_or_init(|| Mutex::new(None))
4188        .lock()
4189        .ok()
4190        .and_then(|mut g| g.take());
4191    if let Ok(mut g) = mstack.get_or_init(|| Mutex::new(None)).lock() {
4192        *g = oms_saved.clone();
4193    }
4194    // Restores `mstack` on EVERY exit from this function (C restores at the
4195    // single c:2622 return; the port has several early returns).
4196    struct MstackRestore(Option<std::sync::Arc<Cmlist>>);
4197    impl Drop for MstackRestore {
4198        fn drop(&mut self) {
4199            if let Ok(mut g) = mstack.get_or_init(|| Mutex::new(None)).lock() {
4200                *g = self.0.take();
4201            }
4202        }
4203    }
4204    let _mstack_guard = MstackRestore(oms_saved);
4205
4206    // c:2049 — C's `complist` global is GSU-backed by `$compstate[list]`, so a
4207    // completer's `compstate[list]="... packed"` write (e.g. `_describe`
4208    // setting the grouped/packed layout) is visible to `addmatch` via `complist`.
4209    // The Rust port keeps COMPLIST as a separate global set only from the global
4210    // at completion init (c:740), so param writes never reached it — matches
4211    // added by grouped `_describe`/`_arguments` never got CMF_PACKED, so their
4212    // group lost CGF_PACKED and calclist skipped the per-column-width pass,
4213    // collapsing the name/description columns into one uniform column. Sync the
4214    // param → global here so `packed`/`rows` reach CMF_PACKED/CMF_ROWS.
4215    if let Some(cl) = get_compstate_str("list") {
4216        if let Ok(mut g) = COMPLIST.get_or_init(|| Mutex::new(String::new())).lock() {
4217            *g = cl;
4218        }
4219    }
4220
4221    if dat.dummies >= 0 {
4222        // c:2106
4223        dat.aflags = (dat.aflags | CAF_NOSORT | CAF_UNIQCON) & !CAF_UNIQALL; // c:2107-2108
4224    }
4225
4226    let gflags = (if (dat.aflags & CAF_NOSORT) != 0 {
4227        CGF_NOSORT
4228    } else {
4229        0
4230    }) | (if (dat.aflags & CAF_MATSORT) != 0 {
4231        CGF_MATSORT
4232    } else {
4233        0
4234    }) | (if (dat.aflags & CAF_NUMSORT) != 0 {
4235        CGF_NUMSORT
4236    } else {
4237        0
4238    }) | (if (dat.aflags & CAF_REVSORT) != 0 {
4239        CGF_REVSORT
4240    } else {
4241        0
4242    }) | (if (dat.aflags & CAF_UNIQALL) != 0 {
4243        CGF_UNIQALL
4244    } else {
4245        0
4246    }) | (if (dat.aflags & CAF_UNIQCON) != 0 {
4247        CGF_UNIQCON
4248    } else {
4249        0
4250    });
4251
4252    tracing::debug!(target: "compsys_args", group = dat.group.as_deref().unwrap_or("<none>"), exp = dat.exp.as_deref().unwrap_or("<none>"), nargs = argv.len(), doadd = dat.dpar.is_empty(), "addmatches group");
4253    if let Some(g) = dat.group.as_deref() {
4254        // c:2115
4255        endcmgroup(None); // c:2116
4256        begcmgroup(Some(g), gflags); // c:2117
4257    } else {
4258        endcmgroup(None); // c:2119
4259        begcmgroup(Some("default"), 0); // c:2120
4260    }
4261
4262    if dat.mesg.is_some() || dat.exp.is_some() {
4263        // c:2122
4264        let mut e = Cexpl::default(); // c:2123
4265        e.always = if dat.mesg.is_some() { 1 } else { 0 }; // c:2124
4266        e.count = 0;
4267        e.fcount = 0; // c:2125
4268        e.str = Some(
4269            dat.mesg
4270                .clone() // c:2126
4271                .or_else(|| dat.exp.clone())
4272                .unwrap_or_default(),
4273        );
4274        if let Ok(mut g) = curexpl.get_or_init(|| Mutex::new(None)).lock() {
4275            *g = Some(e);
4276        }
4277        if dat.mesg.is_some() && dat.dpar.is_empty() && dat.opar.is_none() && dat.apar.is_none() {
4278            // c:2129
4279            addexpl(true); // c:2130
4280        }
4281    } else if let Ok(mut g) = curexpl.get_or_init(|| Mutex::new(None)).lock() {
4282        *g = None; // c:2133
4283    }
4284
4285    // c:2138 — empty-argv early return.
4286    if argv.is_empty() && dat.dummies == 0 && (dat.aflags & CAF_ALL) == 0 {
4287        return 1; // c:2139
4288    }
4289
4290    // c:2132-2135 — `for (bp = brbeg; bp; bp = bp->next)
4291    //                    bp->curpos = ((dat->aflags & CAF_QUOTE) ? bp->pos
4292    //                                                            : bp->qpos);`
4293    // and the same for `brend`. Only the CAF_QUOTE test existed here (as an
4294    // unused local); the snapshot itself was never performed, so `curpos`
4295    // stayed 0 for every brace and `add_match_data`'s brpl/brsl (c:2979) had
4296    // nothing to read.
4297    {
4298        let quote_mode = (dat.aflags & CAF_QUOTE) != 0; // c:2133
4299        for chain in [&BRBEG, &BREND] {
4300            if let Ok(mut g) = chain.get_or_init(|| Mutex::new(None)).lock() {
4301                let mut cur = g.as_deref_mut();
4302                while let Some(n) = cur {
4303                    n.curpos = if quote_mode { n.pos } else { n.qpos }; // c:2133/2135
4304                    cur = n.next.as_deref_mut();
4305                }
4306            }
4307        }
4308    }
4309
4310    if (dat.flags & 0x0008/*CMF_ISPAR*/) != 0 {
4311        // c:2148
4312        dat.flags |= parflags.load(Ordering::Relaxed); // c:2149
4313    }
4314
4315    let qc = compquote_first(); // c:2139
4316    if let Some(q) = qc {
4317        // c:2139 — `if (compquote && (qc = *compquote))`
4318        match q {
4319            '`' => {
4320                instring_set(QT_NONE);
4321                inbackt_set(0);
4322                autoq_set(""); // c:2140-2146
4323            }
4324            _ => {
4325                match q {
4326                    '\'' => instring_set(QT_SINGLE), // c:2149-2151
4327                    '"' => instring_set(QT_DOUBLE),  // c:2153-2155
4328                    '$' => instring_set(QT_DOLLARS), // c:2157-2159
4329                    _ => {}
4330                }
4331                // c:2162-2163 — `inbackt = 0;
4332                //   autoq = multiquote(*compquote == '$' ? compquote+1 : compquote, 1);`
4333                // Both lines were missing: `autoq` kept the PREVIOUS
4334                // completion's quote, which is what `do_single`
4335                // (compresult.c) re-closes the inserted word with.
4336                inbackt_set(0); // c:2162
4337                let cq = crate::ported::zle::complete::COMPQUOTE
4338                    .get_or_init(|| Mutex::new(String::new()))
4339                    .lock()
4340                    .map(|g| g.clone())
4341                    .unwrap_or_default();
4342                let cq = if cq.starts_with('$') {
4343                    &cq[1..]
4344                } else {
4345                    &cq[..]
4346                };
4347                autoq_set(&multiquote(cq, 1)); // c:2163
4348            }
4349        }
4350    } else {
4351        instring_set(QT_NONE);
4352        inbackt_set(0);
4353        autoq_set(""); // c:2166-2168
4354    }
4355    // c:2170-2171 — `qipre = ztrdup(compqiprefix ? compqiprefix : "");
4356    //                qisuf = ztrdup(compqisuffix ? compqisuffix : "");`
4357    // `compqiprefix`/`compqisuffix` ARE `$QIPREFIX`/`$QISUFFIX` (complete.c:
4358    // 1266-1267), so a completer that ran `compset -q` (or `compset -P`)
4359    // hands its edited value back to the match builder here. Without this,
4360    // `add_match_data` saw whatever `get_comp_string` had left.
4361    for (global, param) in [
4362        (&crate::ported::zle::zle_tricky::QIPRE, "QIPREFIX"), // c:2170
4363        (&crate::ported::zle::zle_tricky::QISUF, "QISUFFIX"), // c:2171
4364    ] {
4365        let v = crate::ported::params::getsparam(param).unwrap_or_default();
4366        if let Ok(mut g) = global.get_or_init(|| Mutex::new(String::new())).lock() {
4367            *g = v;
4368        }
4369    }
4370
4371    // c:2173 — `useexact = (compexact && !strcmp(compexact, "accept"))`.
4372    //
4373    // C's `compexact` global IS `$compstate[exact]` (gsu-bound at
4374    // complete.c:1281). The port read `getsparam("compexact")` — a shell
4375    // parameter of that literal name, which nothing ever sets — so this line
4376    // unconditionally CLEARED `useexact` on every compadd, defeating
4377    // REC_EXACT and any completer that set `compstate[exact]=accept`.
4378    let exact_str = get_compstate_str("exact").unwrap_or_default();
4379    useexact.store(if exact_str == "accept" { 1 } else { 0 }, Ordering::Relaxed);
4380
4381    // c:2170-2175 —
4382    //     if ((doadd = (!dat->apar && !dat->opar && !dat->dpar))) {
4383    //         if (dat->aflags & CAF_MATCH)
4384    //             hasmatched = 1;
4385    //         else
4386    //             hasunmatched = 1;
4387    //     }
4388    // `doadd` says this compadd puts matches on the completion list rather
4389    // than only filling `-A`/`-O`/`-D` arrays; only those calls tell the
4390    // result stage anything about the line. CAF_MATCH is `compadd` WITHOUT
4391    // `-U`, i.e. the candidate was matched against `$PREFIX`/`$SUFFIX`, so
4392    // the cline parts carry meaningful line/word anchors — that is exactly
4393    // the condition `cut_cline` (compresult.c:57) tests before it is willing
4394    // to trim the unambiguous string, and the condition `do_ambiguous`
4395    // (compresult.c:794) tests before restoring the typed word.
4396    //
4397    // The port set `hasmatched` only from the compctl path (compctl.c:3292),
4398    // never from compadd, so every compsys completion left both flags 0:
4399    // `cut_cline` took the `!hasmatched` "keep everything" branch forever and
4400    // `do_ambiguous`'s `!hasunmatched` guard was always satisfied. Measured on
4401    // the parity corpus under the `full` zstyle fixture: `cut_cline` reads
4402    // `hasmatched=0` without these lines and `hasmatched=1` with them, on 22
4403    // of the 31 corpus buffers that reach it (`df -` also reaches it with
4404    // `hasunmatched=1`, so both arms of the `if/else` fire).
4405    //
4406    // Placement follows C: this is c:2169-2175, ahead of the mstack push
4407    // (c:2200) and well ahead of the `*argv = NULL` prefix-mismatch bail at
4408    // c:2335. The port hoists parts of c:2280-2454 above the `doadd` binding
4409    // at the candidate loop and returns early on that bail, so setting the
4410    // flags there would silently skip the calls C still counts.
4411    let doadd = dat.apar.is_none() && dat.opar.is_none() && dat.dpar.is_empty(); // c:2170
4412    if doadd {
4413        if (dat.aflags & CAF_MATCH) != 0 {
4414            hasmatched.store(1, Ordering::Relaxed); // c:2172
4415        } else {
4416            hasunmatched.store(1, Ordering::Relaxed); // c:2174
4417        }
4418    }
4419
4420    // c:2210-2222 — push dat.match onto mstack (the matcher chain
4421    // queried by match_str during candidate evaluation).
4422    //
4423    // c:2094 `Cmlist oms = mstack` / c:2623 `mstack = oms` — the push is
4424    // scoped to THIS addmatches call: C stack-allocates the link (`struct
4425    // cmlist mst`) and restores the saved head on the way out. The port
4426    // pushed but never restored, so a `compadd -M` matcher stayed live for
4427    // every later compadd of the same completion. `ssh -<TAB>` showed it:
4428    // _arguments adds its options with `-M 'r:|[_-]=* r:|=*'`, that spec
4429    // survived onto _hosts' compadd, and match_str (c:593 walks the whole
4430    // mstack) then let the typed `-` match any host CONTAINING a `-` —
4431    // `ec2-23-20-9-23.compute-1.amazonaws.com` was offered where zsh
4432    // offers options only. `MstackPop` restores on every exit path.
4433    struct MstackPop(bool);
4434    impl Drop for MstackPop {
4435        fn drop(&mut self) {
4436            if !self.0 {
4437                return;
4438            }
4439            if let Ok(mut mst) = mstack.get_or_init(|| Mutex::new(None)).lock() {
4440                // C restores the saved head; since this frame pushed exactly
4441                // one link, dropping it is the same list.
4442                *mst = mst.take().and_then(|link| link.next.clone());
4443            }
4444        }
4445    }
4446    let _mstack_pop = MstackPop(dat.match_.is_some());
4447    if let Some(ref m) = dat.match_ {
4448        // c:2210
4449        if let Ok(mut mst) = mstack.get_or_init(|| Mutex::new(None)).lock() {
4450            // C: mst.next = mstack; mst.matcher = dat->match; mstack = &mst.
4451            let new_link = std::sync::Arc::new(Cmlist {
4452                next: mst.take(),
4453                matcher: m.clone(),
4454                str: String::new(),
4455            });
4456            *mst = Some(new_link);
4457        }
4458        // c:2215 — add_bmatchers(dat->match).
4459        crate::ported::zle::compmatch::add_bmatchers(Some(m));
4460        // c:2217 — addlinknode(matchers, dat->match).
4461        if let Ok(mut g) = matchers.get_or_init(|| Mutex::new(Vec::new())).lock() {
4462            g.push(m.clone());
4463        }
4464    }
4465    // c:2220-2221 — `if (mnum && (mstack || bmatchers)) update_bmatchers();`
4466    // prunes bmatchers of any matcher no longer on mstack; without it the
4467    // restored stack and the bmatchers list drift apart.
4468    if mnum.load(Ordering::Relaxed) != 0 {
4469        let live = mstack
4470            .get_or_init(|| Mutex::new(None))
4471            .lock()
4472            .map(|g| g.is_some())
4473            .unwrap_or(false)
4474            || bmatchers
4475                .get_or_init(|| Mutex::new(None))
4476                .lock()
4477                .map(|g| g.is_some())
4478                .unwrap_or(false);
4479        if live {
4480            crate::ported::zle::compmatch::update_bmatchers();
4481        }
4482    }
4483
4484    // c:2223-2246 — get suffixes to ignore from dat.ign param.
4485    let (aign, pign) = if let Some(ign_name) = dat.ign.as_deref() {
4486        // c:2224
4487        let aign_raw = get_user_var(Some(ign_name)).unwrap_or_default();
4488        let mut literal_suffixes: Vec<String> = Vec::new();
4489        let mut pat_progs: Vec<crate::ported::pattern::Patprog> = Vec::new();
4490        for entry in aign_raw {
4491            // c:2231-2232 — `tokenize(tmp); remnulargs(tmp);` — fignore
4492            // entries are param values (untokenized text); C tokenizes
4493            // each BEFORE the token checks below and the patcompile.
4494            let mut tmp = entry.clone();
4495            crate::ported::glob::tokenize(&mut tmp); // c:2231
4496            crate::ported::glob::remnulargs(&mut tmp); // c:2232
4497                                                       // c:2233-2236 — `(tmp[0] == Quest && tmp[1] == Star) ||
4498                                                       // (tmp[1] == Quest && tmp[0] == Star)` token short-circuit:
4499                                                       // trailing literal suffix.
4500            let tch: Vec<char> = tmp.chars().collect();
4501            let suffix: String = tch.iter().skip(2).collect();
4502            let star_prefix = tch.len() >= 3
4503                && ((tch[0] == crate::ported::zsh_h::Quest
4504                    && tch[1] == crate::ported::zsh_h::Star)
4505                    || (tch[1] == crate::ported::zsh_h::Quest
4506                        && tch[0] == crate::ported::zsh_h::Star))
4507                && !crate::ported::pattern::haswilds(&suffix);
4508            if star_prefix {
4509                // c:2236 — `untokenize(*sp++ = tmp + 2);`
4510                literal_suffixes.push(crate::ported::lex::untokenize(&suffix));
4511            } else if let Some(prog) =
4512                crate::ported::pattern::patcompile(&tmp, 0, None::<&mut String>)
4513            {
4514                pat_progs.push(prog);
4515            }
4516        }
4517        (literal_suffixes, pat_progs)
4518    } else {
4519        (Vec::new(), Vec::new())
4520    };
4521
4522    // c:2247-2250 — get display strings.
4523    let disp_arr: Vec<String> = if let Some(ref d) = dat.disp {
4524        get_user_var(Some(d.as_str())).unwrap_or_default()
4525    } else {
4526        Vec::new()
4527    };
4528
4529    // zshrs bridge: in C the `compprefix`/`compsuffix`/`compiprefix`/
4530    // `compisuffix` globals ARE `$PREFIX`/`$SUFFIX`/`$IPREFIX`/`$ISUFFIX`
4531    // (the compparams are gsu-bound to them). The Rust compparams carry
4532    // no gsu binding (`complete.rs` `gsu: 0`), so the compsys completers'
4533    // writes to `$PREFIX` land in the param table while `compadd` reads
4534    // the globals — leaving `lpre` empty and every candidate matching.
4535    // During a live completion (`INCOMPFUNC`), refresh the globals from
4536    // the params so `comp_match` filters against the prefix the completer
4537    // actually set. Gated on INCOMPFUNC so direct-call unit tests that
4538    // seed the globals aren't clobbered.
4539    if INCOMPFUNC.load(Ordering::Relaxed) != 0 {
4540        for (param, global) in [
4541            ("PREFIX", &COMPPREFIX),
4542            ("SUFFIX", &COMPSUFFIX),
4543            ("IPREFIX", &COMPIPREFIX),
4544            ("ISUFFIX", &crate::ported::zle::complete::COMPISUFFIX),
4545        ] {
4546            if let Some(v) = getsparam(param) {
4547                if let Ok(mut g) = global.get_or_init(|| Mutex::new(String::new())).lock() {
4548                    *g = v;
4549                }
4550            }
4551        }
4552    }
4553
4554    // c:2253-2300 — CAF_MATCH lipre/lisuf/lpre/lsuf assembly.
4555    let compiprefix_s = COMPIPREFIX
4556        .get_or_init(|| Mutex::new(String::new()))
4557        .lock()
4558        .map(|g| g.clone())
4559        .unwrap_or_default();
4560    let compisuffix_s = crate::ported::zle::complete::COMPISUFFIX
4561        .get_or_init(|| Mutex::new(String::new()))
4562        .lock()
4563        .map(|g| g.clone())
4564        .unwrap_or_default();
4565    let compprefix_s = COMPPREFIX
4566        .get_or_init(|| Mutex::new(String::new()))
4567        .lock()
4568        .map(|g| g.clone())
4569        .unwrap_or_default();
4570    let compsuffix_s = COMPSUFFIX
4571        .get_or_init(|| Mutex::new(String::new()))
4572        .lock()
4573        .map(|g| g.clone())
4574        .unwrap_or_default();
4575    // c:2252-2253 — `if (dat->aflags & CAF_MATCH) { lipre = dupstring(compiprefix);
4576    // lisuf = dupstring(compisuffix); … }`. `lipre`/`lisuf` start out NULL
4577    // (c:2083) and are ONLY filled in under CAF_MATCH, because they exist to
4578    // feed the per-candidate match. `compadd -U` clears CAF_MATCH, so with `-U`
4579    // the C match carries no ignored prefix at all and the word on the line is
4580    // replaced whole. Reading them unconditionally handed every `-U` match the
4581    // live `$IPREFIX`, which c:2278-2282 below then prepends to `dat->ipre`:
4582    // `ls ~zzqaa<TAB>` under `completer _expand` with `hash -d zzqaa=/usr/share/zsh`
4583    // has IPREFIX=`~`, so `_expand`'s `compadd -UQ` re-inserted the tilde in
4584    // front of the expansion and the line became `ls ~/usr/share/zsh/`.
4585    // `lpre`/`lsuf` are left read unconditionally: every consumer of them below
4586    // is itself CAF_MATCH-gated or reached only through `comp_match` (c:2535).
4587    let lipre = if (dat.aflags & CAF_MATCH) != 0 {
4588        compiprefix_s.clone()
4589    } else {
4590        String::new()
4591    };
4592    let lisuf = if (dat.aflags & CAF_MATCH) != 0 {
4593        compisuffix_s.clone()
4594    } else {
4595        String::new()
4596    };
4597    let mut lpre = compprefix_s.clone();
4598    let mut lsuf = compsuffix_s.clone();
4599
4600    // c:2252-2254 — `if (llpl + strlen(compqiprefix) + strlen(lipre) != origlpre
4601    //                 || llsl + strlen(compqisuffix) + strlen(lisuf) != origlsuf)
4602    //                    lenchanged = 1;`
4603    // The completer moved the PREFIX/SUFFIX split since the widget was entered
4604    // (`compset -P/-S`, `_approximate`, `_prefix`). `do_ambiguous`
4605    // (compresult.c:794) uses this to decide NOT to put the old word back when
4606    // the unambiguous string comes out shorter than the word on the line.
4607    // Never assigned before, so the flag was stuck at 0.
4608    if (dat.aflags & CAF_MATCH) != 0 {
4609        let qip = getsparam("QIPREFIX").unwrap_or_default();
4610        let qis = getsparam("QISUFFIX").unwrap_or_default();
4611        if lpre.len() + qip.len() + lipre.len() != origlpre.load(Ordering::Relaxed) as usize
4612            || lsuf.len() + qis.len() + lisuf.len() != origlsuf.load(Ordering::Relaxed) as usize
4613        {
4614            lenchanged.store(1, Ordering::Relaxed); // c:2254
4615        }
4616    }
4617
4618    // c:2266-2276 — "Test if there is an existing -P prefix":
4619    //     if (dat->pre && *dat->pre) {
4620    //         int prefix_length = pfxlen(dat->pre, lpre);
4621    //         if (dat->pre[prefix_length] == '\0' ||
4622    //             lpre[prefix_length] == '\0') {
4623    //             /* $compadd_args[-P] is a prefix of ${PREFIX}, or
4624    //              * vice-versa. */
4625    //             llpl -= prefix_length;
4626    //             lpre += prefix_length;
4627    //         }
4628    //     }
4629    // `compadd -P PRE` puts PRE on the line AHEAD of every candidate, so —
4630    // exactly like the `-p PPRE` strip below — it has to come off `lpre`
4631    // before comp_match compares the candidates against it. `dat.pre` stays
4632    // in the add_match_data call further down; that is what re-inserts it.
4633    // This step was missing entirely, so `_zstyle`'s
4634    //     compadd -P : -qS : chpwd completion vcs_info zftp zle
4635    // matched NOTHING the moment $PREFIX was ":" — comp_match(":", "",
4636    // "chpwd") rejects every candidate — which is the whole `zstyle <TAB>`
4637    // divergence. C compares C strings byte-wise, so the shared-prefix
4638    // length is a BYTE count here (zle_tricky::pfxlen counts chars, which
4639    // would mis-slice a multibyte prefix).
4640    if (dat.aflags & CAF_MATCH) != 0 {
4641        // c:2267
4642        if let Some(pre) = dat.pre.as_deref() {
4643            if !pre.is_empty() {
4644                // c:2268 — `pfxlen(dat->pre, lpre)`, in bytes.
4645                let prefix_length: usize = pre
4646                    .chars()
4647                    .zip(lpre.chars())
4648                    .take_while(|(a, b)| a == b)
4649                    .map(|(a, _)| a.len_utf8())
4650                    .sum();
4651                // c:2269-2270 — one of the two ran out at the split point,
4652                // i.e. one is a prefix of the other.
4653                if prefix_length == pre.len() || prefix_length == lpre.len() {
4654                    lpre = lpre[prefix_length..].to_string(); // c:2273-2274
4655                }
4656            }
4657        }
4658    }
4659
4660    // c:2084 — `char *oppre = dat->ppre`: the UNQUOTED path prefix as it
4661    // arrived, captured before the c:2288 quoting rewrites `dat.ppre`. Used
4662    // as the `prpre` default at c:2436.
4663    let oppre: Option<String> = dat.ppre.clone(); // c:2084
4664
4665    // c:2314-2340 — strip the path-prefix (`compadd -p PPRE`) from `lpre`
4666    // before matching each candidate. PPRE is already on the command line
4667    // ahead of the match, so comp_match must compare the candidate against
4668    // `lpre` with PPRE removed (candidate `sub` vs `su`, not vs the full
4669    // `/tmp/ptree/su`). Try the active matcher first (`match_str`, part=1),
4670    // else strip literally. Without this every `cmd /a/b/pre<TAB>` produced
4671    // no matches — the resolved candidate never matched the full-path lpre.
4672    let mut bcp: i32 = 0;
4673    let mut bcs: i32 = 0;
4674    // c:2339 / c:2366 — either the ppre or the psuf probe can conclude that
4675    // NO candidate can match (`*argv = NULL`), which skips the whole add.
4676    let mut ppre_nomatch = false;
4677    if (dat.aflags & CAF_MATCH) != 0 {
4678        // ppre is quoted below (c:2288) in C before this block; do the same
4679        // ordering here so lengths line up with the on-line text.
4680        let ppre_q: Option<String> = dat.ppre.as_deref().map(|existing| {
4681            if (dat.flags & 0x0001/*CMF_FILE*/) != 0 {
4682                tildequote(existing, if (dat.aflags & CAF_QUOTE) != 0 { 0 } else { 1 })
4683            } else {
4684                multiquote(existing, if (dat.aflags & CAF_QUOTE) != 0 { 0 } else { 1 })
4685            }
4686        });
4687        if let Some(ppre) = ppre_q.as_deref() {
4688            if !ppre.is_empty() {
4689                // NOTE: `dat.ppre` is left UNquoted here — the canonical
4690                // quoting happens in the c:2288 block below. `ppre_q` is a
4691                // local, same-quoting copy used only for the length math.
4692                let lpl = ppre.len();
4693                // c:2314 — `ml = match_str(lpre, ppre, …, part=1)`. Reset the
4694                // match accumulators first so this probe doesn't leak into the
4695                // per-candidate comp_match (the Rust threads the match Cline
4696                // through comp_match's own output, and instmatch re-inserts
4697                // dat.ppre from the Cmatch, so the probe's `pline` is unused).
4698                crate::ported::zle::compmatch::start_match();
4699                let ml = crate::ported::zle::compmatch::match_str(
4700                    lpre.as_bytes(),
4701                    ppre.as_bytes(),
4702                    None,
4703                    0,
4704                    None,
4705                    0,
4706                    0,
4707                    1,
4708                );
4709                if ml >= 0 {
4710                    // c:2318-2325 — matcher matched the prefix.
4711                    let cut = (ml as usize).min(lpre.len());
4712                    lpre = lpre[cut..].to_string();
4713                    bcp = ml;
4714                } else if lpre.len() <= lpl && ppre.starts_with(lpre.as_str()) {
4715                    // c:2335 — lpre is a prefix of ppre → nothing left.
4716                    lpre.clear();
4717                } else if lpre.len() > lpl && lpre.starts_with(ppre) {
4718                    // c:2337 — ppre is a literal prefix of lpre → strip it.
4719                    lpre = lpre[lpl..].to_string();
4720                } else {
4721                    // c:2339 — `*argv = NULL`: no candidate can match.
4722                    ppre_nomatch = true;
4723                    bcp = lpl as i32; // c:2341
4724                }
4725                crate::ported::zle::compmatch::start_match();
4726            }
4727        }
4728
4729        // c:2344-2368 — the SUFFIX twin of the block above, and it was
4730        // missing outright. `compadd -s PSUF` puts PSUF on the command line
4731        // AFTER the match, so `lsuf` (a copy of `$SUFFIX`) has to have PSUF
4732        // taken off it before per-candidate matching, exactly as `lpre` has
4733        // PPRE taken off above. Without it comp_match was handed the FULL
4734        // `$SUFFIX` and matched it against a word tail that no longer
4735        // contained PSUF, so every candidate was rejected: `_path_files`
4736        // sh:698-702 fires `compadd -Qf -p "$linepath$testpath" -s
4737        // "/${tmp3#*/}"` for an ambiguous INTERMEDIATE path component, which
4738        // made `ls /u/l/b<TAB>`, `ls /u/s/b<TAB>` and `ls /u/l/<TAB>` add
4739        // ZERO matches where zsh adds two or three.
4740        let psuf_q: Option<String> = dat.psuf.as_deref().map(|existing| {
4741            multiquote(existing, if (dat.aflags & CAF_QUOTE) != 0 { 1 } else { 0 })
4742        });
4743        if let Some(psuf) = psuf_q.as_deref() {
4744            if !psuf.is_empty() {
4745                let lsl = psuf.len();
4746                // c:2345 — `ml = match_str(lsuf, s, &bsl, 0, NULL, 1, 0, 1)`
4747                // — sfx=1, test=1. Bracket it with start_match() for the same
4748                // reason as the ppre probe above: this is a PROBE, its match
4749                // accumulators must not leak into the per-candidate
4750                // comp_match that follows.
4751                crate::ported::zle::compmatch::start_match();
4752                let ml = crate::ported::zle::compmatch::match_str(
4753                    lsuf.as_bytes(),
4754                    psuf.as_bytes(),
4755                    None,
4756                    0,
4757                    None,
4758                    1,
4759                    0,
4760                    1,
4761                );
4762                if ml >= 0 {
4763                    // c:2357-2359 — `lsuf[llsl - ml] = '\0'; llsl -= ml;
4764                    //                bcs = ml;`. `ml` is a byte count, so cut
4765                    // the bytes rather than the &str.
4766                    let keep = lsuf.len().saturating_sub(ml.max(0) as usize);
4767                    let lb = lsuf.as_bytes();
4768                    lsuf = String::from_utf8_lossy(lb.get(..keep).unwrap_or(lb)).into_owned();
4769                    bcs = ml;
4770                } else {
4771                    if lsuf.len() <= lsl && psuf.ends_with(lsuf.as_str()) {
4772                        // c:2362 — lsuf is a suffix of psuf → nothing left.
4773                        lsuf.clear();
4774                    } else if lsuf.len() > lsl && lsuf.ends_with(psuf) {
4775                        // c:2364 — psuf is a literal suffix of lsuf → strip it.
4776                        let keep = lsuf.len() - lsl;
4777                        let lb = lsuf.as_bytes();
4778                        lsuf = String::from_utf8_lossy(lb.get(..keep).unwrap_or(lb)).into_owned();
4779                    } else {
4780                        // c:2366 — `*argv = NULL`: no candidate can match.
4781                        ppre_nomatch = true;
4782                    }
4783                    bcs = lsl as i32; // c:2368
4784                }
4785                crate::ported::zle::compmatch::start_match();
4786            }
4787        }
4788    }
4789    // c:2339 / c:2366 — `*argv = NULL` only EMPTIES the word list. Everything
4790    // after it still runs: the c:2370-2420 pattern probe, the `-A`/`-O`/`-D`
4791    // arrays (c:2596-2603), `addexpl` (c:2604-2605), the `-C` all-match
4792    // (c:2606-2609), the `-E` dummies (c:2610-2611) and the c:2617-2627
4793    // restores. Returning here instead made `compadd -p , -C` on the word
4794    // `a` add NOTHING and fail, where zsh adds `<all>` and returns 0.
4795    let argv: &[String] = if ppre_nomatch { &[] } else { argv };
4796
4797    // c:2360-2389 — when `$compstate[pattern_match]` is set, compile the
4798    // line prefix+suffix (with the completion point as a `*` placeholder)
4799    // into a Patprog so candidates are matched as GLOBS instead of literal
4800    // prefixes. This is what makes `_approximate` work: it turns on
4801    // pattern_match and injects a leading `(#a$n)` approximate-glob into
4802    // PREFIX (via the compadd prefix injector), so e.g. `(#a1)aple*` matches
4803    // `apple`. The Rust port previously always passed `cp = None` to
4804    // comp_match, so pattern_match / approximate completion did nothing.
4805    // C uses an `x` placeholder at the completion point precisely so the
4806    // point wildcard alone never enables pattern matching; mirror that by
4807    // probing haswilds() on lpre+lsuf WITHOUT the placeholder — only real
4808    // wildcards in the typed prefix/suffix (`(#a1)`, `*`, `[...]`) count.
4809    let cp: Option<crate::ported::pattern::Patprog> = {
4810        let cpm = get_compstate_str("pattern_match").unwrap_or_default();
4811        if !cpm.is_empty() {
4812            let is = cpm.starts_with('*'); // c:2361 is = (*comppatmatch == '*')
4813            let mut probe = format!("{}{}", lpre, lsuf);
4814            crate::ported::glob::tokenize(&mut probe); // c:2371
4815            if crate::ported::pattern::haswilds(&probe) {
4816                // c:2372 — has real wildcards.
4817                let mut pat = String::new();
4818                pat.push_str(&lpre); // c:2367 lpre
4819                if is {
4820                    // c:2374 — `tmp[llpl] = Star` (completion-point wildcard).
4821                    pat.push('*');
4822                }
4823                pat.push_str(&lsuf); // c:2369 lsuf
4824                crate::ported::glob::tokenize(&mut pat);
4825                crate::ported::glob::remnulargs(&mut pat); // c:2376
4826                let prog = crate::ported::pattern::patcompile(&pat, 0, None); // c:2377
4827                if prog.is_some() {
4828                    haspattern.store(1, Ordering::Relaxed); // c:2378
4829                }
4830                prog
4831            } else {
4832                None
4833            }
4834        } else {
4835            None
4836        }
4837    };
4838
4839    // c:2278-2300 — dat.ipre/isuf/ppre/psuf duplication with lipre/lisuf.
4840    if let Some(ref existing) = dat.ipre.clone() {
4841        dat.ipre = Some(if !lipre.is_empty() {
4842            format!("{}{}", lipre, existing)
4843        } else {
4844            existing.clone()
4845        });
4846    } else if !lipre.is_empty() {
4847        dat.ipre = Some(lipre.clone());
4848    }
4849    if let Some(ref existing) = dat.isuf.clone() {
4850        dat.isuf = Some(if !lisuf.is_empty() {
4851            format!("{}{}", lisuf, existing)
4852        } else {
4853            existing.clone()
4854        });
4855    } else if !lisuf.is_empty() {
4856        dat.isuf = Some(lisuf.clone());
4857    }
4858    let quote_flag = if (dat.aflags & CAF_QUOTE) != 0 { 1 } else { 0 };
4859    if let Some(ref existing) = dat.ppre.clone() {
4860        let quoted = if (dat.flags & 0x0001/*CMF_FILE*/) != 0 {
4861            tildequote(existing, quote_flag)
4862        } else {
4863            multiquote(existing, quote_flag)
4864        };
4865        dat.ppre = Some(quoted);
4866    }
4867    if let Some(ref existing) = dat.psuf.clone() {
4868        dat.psuf = Some(multiquote(existing, quote_flag));
4869    }
4870    let ppl = dat.ppre.as_deref().map(|s| s.len()).unwrap_or(0);
4871    let psl = dat.psuf.as_deref().map(|s| s.len()).unwrap_or(0);
4872
4873    // c:2431-2455 — the whole `if (*argv) { … }` block was missing.
4874    if !argv.is_empty() {
4875        // c:2436-2440 — `if (!dat->prpre && (dat->prpre = dupstring(oppre)))
4876        //                    { singsub(&(dat->prpre)); untokenize(dat->prpre); }`
4877        // `oppre` is the UNQUOTED `dat->ppre` saved at c:2084, before the
4878        // c:2288 quoting above. `prpre` is the path used for the real
4879        // filesystem probes (ztat/opendir in add_match_data c:2735+), so it
4880        // has to be the substituted, untokenized form — not the quoted one.
4881        // Without this default, `compadd -p <dir>/` with no explicit `-W`
4882        // left `prpre` empty and every file test ran against the bare match
4883        // name in the CWD.
4884        if dat.prpre.is_none() {
4885            if let Some(op) = oppre.clone() {
4886                let subbed = crate::ported::subst::singsub(&op); // c:2437
4887                dat.prpre = Some(crate::ported::lex::untokenize(&subbed).to_string());
4888                // c:2438
4889            }
4890        }
4891        // c:2443-2447 — `-r`/`-R` are mutually exclusive: a remove-func
4892        // (`remf`) wins and CLEARS `rems`. The port passed both through to
4893        // the match, so a compadd carrying both applied the char-based
4894        // suffix removal as well as the widget.
4895        if dat.remf.is_some() {
4896            dat.rems = None; // c:2445
4897        }
4898        // c:2449-2454 — quote the line prefix/suffix used for matching with
4899        // the OUTER quoting level (`ign = 1`), so comp_match compares the
4900        // candidate against text quoted the same way the line is.
4901        lpre = if (dat.aflags & CAF_QUOTE) == 0
4902            && dat.ppre.is_none()
4903            && (dat.flags & 0x0001/*CMF_FILE*/) != 0
4904        {
4905            tildequote(&lpre, 1) // c:2452
4906        } else {
4907            multiquote(&lpre, 1) // c:2452
4908        };
4909        lsuf = multiquote(&lsuf, 1); // c:2454
4910    }
4911
4912    // c:2170 — `doadd` was computed (and set hasmatched/hasunmatched) above,
4913    // at C's position for the assignment.
4914    tracing::debug!(
4915        target: "compsys_args",
4916        %lpre,
4917        %lsuf,
4918        doadd,
4919        caf_match = (dat.aflags & CAF_MATCH) != 0,
4920        dpar = ?dat.dpar,
4921        "addmatches candidate-loop setup"
4922    );
4923    let mut apar_list: Vec<String> = Vec::new();
4924    let mut opar_list: Vec<String> = Vec::new();
4925
4926    // c:2189-2207 — `-D par…` setup. Each `dpar` name holds an array
4927    // PARALLEL to the candidate words; as each word is added, the current
4928    // element of every dpar array is consumed into an output list, and at
4929    // the end the output lists are written back (so each dpar param ends
4930    // up holding just the entries for the words that matched). Names whose
4931    // array is empty/missing are dropped (C swaps them out). Bug #657 —
4932    // the previous port collected apar/opar but never handled dpar.
4933    let mut dpar_names: Vec<String> = Vec::new();
4934    let mut dparr: Vec<Vec<String>> = Vec::new();
4935    let mut dpar_idx: Vec<usize> = Vec::new();
4936    let mut dparl: Vec<Vec<String>> = Vec::new();
4937    for name in &dat.dpar {
4938        match crate::ported::params::getaparam(name) {
4939            Some(arr) if !arr.is_empty() => {
4940                dpar_names.push(name.clone()); // c:2197 getaparam non-empty
4941                dparr.push(arr);
4942                dpar_idx.push(0);
4943                dparl.push(Vec::new());
4944            }
4945            _ => {} // c:2200 — drop names with an empty/missing array
4946        }
4947    }
4948
4949    // c:2460-2476 + c:2582-2600 — CAF_ARRAYS expansion. `compadd -a
4950    // NAME…` / `-k NAME…` pass PARAMETER names, not literal matches:
4951    // the candidates are the values of the named arrays (or, with
4952    // CAF_KEYS from `-k`, the keys of the named associative arrays).
4953    // C weaves array-switching through the candidate loop via the
4954    // `next_array` label; because every non-empty array's elements are
4955    // consumed in order, the resulting candidate set is just the
4956    // concatenation of `get_data_arr` over the names — so expand up
4957    // front. Without this, `_command_names`' `compadd -k commands`
4958    // adds the literal word "commands" instead of every command name.
4959    let expanded_argv: Vec<String>;
4960    let argv: &[String] = if (dat.aflags & CAF_ARRAYS) != 0 {
4961        let keys = (dat.aflags & CAF_KEYS) != 0; // c:2468 CAF_KEYS
4962        let mut acc: Vec<String> = Vec::new();
4963        for name in argv {
4964            if let Some(vals) = get_data_arr(name, keys) {
4965                acc.extend(vals);
4966            }
4967        }
4968        expanded_argv = acc;
4969        &expanded_argv
4970    } else {
4971        argv
4972    };
4973
4974    // c:2482-2601 — main candidate loop.
4975    let mut added = 0i32;
4976    let mut disp_idx = 0usize;
4977    let mut compignored_local = 0i32;
4978    // c:2520-2522 / c:2540-2542 — `-D` parallel-array bookkeeping: the dpar
4979    // arrays advance in LOCKSTEP with the candidate words. When a word is
4980    // skipped (ignored, or comp_match fails) its dpar element must be dropped
4981    // — i.e. `dpar_idx` advances WITHOUT the corresponding value being kept —
4982    // so that surviving words stay aligned with their array elements. The
4983    // port only advanced `dpar_idx` on the KEEP path (else branch below), so
4984    // after any non-matching word every survivor grabbed the wrong element
4985    // (e.g. `compadd -D` for path completion kept `/Applications` instead of
4986    // `/tmp`), breaking `cmd /path/<TAB>` entirely.
4987    macro_rules! dpar_skip_word {
4988        () => {
4989            for i in 0..dpar_idx.len() {
4990                if dpar_idx[i] < dparr[i].len() {
4991                    dpar_idx[i] += 1;
4992                }
4993            }
4994        };
4995    }
4996    'cand: for word in argv {
4997        // c:2482
4998        // c:2486-2489 — advance disp index.
4999        let cur_disp = if !disp_arr.is_empty() && disp_idx < disp_arr.len() {
5000            let d = disp_arr[disp_idx].clone();
5001            disp_idx += 1;
5002            Some(d)
5003        } else {
5004            None
5005        };
5006
5007        // c:2491-2527 — aign/pign suffix-test + Patprog test.
5008        if !aign.is_empty() || !pign.is_empty() {
5009            let full = format!(
5010                "{}{}{}",
5011                dat.ppre.as_deref().unwrap_or(""),
5012                word,
5013                dat.psuf.as_deref().unwrap_or("")
5014            );
5015            // c:2508-2510 — literal-suffix check. C requires the ignored
5016            // suffix to be STRICTLY shorter than the whole string
5017            // (`filell < il`), so an fignore entry equal to the candidate
5018            // does NOT ignore it; the port's `>=` dropped those candidates.
5019            for suf in &aign {
5020                if full.len() > suf.len() && full.ends_with(suf.as_str()) {
5021                    // c:2519 — `compignored++` on the GLOBAL, which is
5022                    // `$compstate[ignored]` (complete.c:1300). The port
5023                    // counted into a local that was discarded at the end of
5024                    // the function, so `_ignored` (which returns 1 unless
5025                    // `$compstate[ignored]` is non-zero) could never fire and
5026                    // ignored-patterns had no fallback completer.
5027                    crate::ported::zle::complete::COMPIGNORED.fetch_add(1, Ordering::Relaxed);
5028                    compignored_local += 1;
5029                    dpar_skip_word!(); // c:2520
5030                    continue 'cand;
5031                }
5032            }
5033            // c:2512-2517 — Patprog check.
5034            for prog in &pign {
5035                if crate::ported::pattern::pattry(prog, &full) {
5036                    crate::ported::zle::complete::COMPIGNORED.fetch_add(1, Ordering::Relaxed); // c:2519
5037                    compignored_local += 1;
5038                    dpar_skip_word!(); // c:2520
5039                    continue 'cand;
5040                }
5041            }
5042        }
5043
5044        // c:2528 — CAF_MATCH dispatch: when CAF_MATCH is set, run
5045        // comp_match with the active matcher chain (else branch); else
5046        // emit the (multi)quoted word directly with a single-anchor
5047        // Cline (no-matcher path c:2530-2533).
5048        let ms: String;
5049        let _lc;
5050        let isexact;
5051        if (dat.aflags & CAF_MATCH) == 0 {
5052            // c:2528-2534 — non-match mode: just (multi)quote the word.
5053            ms = if (dat.aflags & CAF_QUOTE) != 0 {
5054                word.clone()
5055            } else {
5056                multiquote(word, 0)
5057            };
5058            let sl = ms.len() as i32;
5059            _lc = bld_parts(&ms, sl, -1, None, None);
5060            isexact = 0;
5061        } else {
5062            // c:2535
5063            // c:2535-2546 — matcher-driven mode via comp_match.
5064            let qu = if (dat.aflags & CAF_QUOTE) != 0 {
5065                0
5066            } else if dat.ppre.is_some() || (dat.flags & 0x0001/*CMF_FILE*/) == 0 {
5067                1
5068            } else {
5069                2
5070            };
5071            let mut lc_out: Option<Box<Cline>> = None;
5072            let mut isexact_out = 0i32;
5073            // c:2535 — comp_match(lpre, lsuf, s, cp, &lc, qu, &bpl, bcp,
5074            //          &bsl, bcs, &isexact).
5075            match crate::ported::zle::compmatch::comp_match(
5076                &lpre,
5077                &lsuf,
5078                word,
5079                cp.as_ref(),
5080                Some(&mut lc_out),
5081                qu,
5082                None,
5083                bcp, // c:2535 — brace-count base advanced by the ppre strip
5084                None,
5085                bcs, // c:2535 — and by the psuf strip (c:2359/c:2368)
5086                &mut isexact_out,
5087            ) {
5088                Some(matched) => {
5089                    ms = matched;
5090                    _lc = lc_out;
5091                    isexact = isexact_out;
5092                }
5093                None => {
5094                    dpar_skip_word!(); // c:2540 — drop this word's dpar element
5095                    continue 'cand; // c:2541-2545 reject
5096                }
5097            }
5098        }
5099
5100        if doadd {
5101            // c:2547
5102            // c:2556 — add_match_data.
5103            let cm = add_match_data(
5104                0,
5105                &ms,
5106                word,
5107                _lc.clone(), // line — real Cline from comp_match
5108                dat.ipre.as_deref().unwrap_or(""),
5109                "", // ripre
5110                dat.isuf.as_deref().unwrap_or(""),
5111                dat.pre.as_deref(),
5112                dat.prpre.as_deref().unwrap_or(""),
5113                dat.ppre.as_deref().unwrap_or(""),
5114                None, // pline (path-prefix Cline; unused on this path)
5115                dat.psuf.as_deref().unwrap_or(""),
5116                None, // sline (path-suffix Cline; unused on this path)
5117                dat.suf.as_deref(),
5118                dat.flags,
5119                isexact,
5120            );
5121            // c:2557-2564 — `cm->disp = dparr ? *dparr++ : NULL` (and the
5122            // CMF_DISPLINE flag when `-l`/CAF_...): C patches the STORED
5123            // match through the returned pointer; the port's return-by-value
5124            // clone can't, so patch the just-pushed copy in the live list.
5125            // Without this every `compadd -d` display string (the
5126            // description lines _describe/_arguments build) was silently
5127            // dropped and lists rendered bare match names.
5128            // c:2562-2563 — `if (disp) cm->disp = dupstring(*disp)`. C
5129            // patches the STORED match through the returned pointer; the
5130            // port's add_match_data returns a VALUE clone while the stored
5131            // copy lives in the `matches` list, so patch that copy in place
5132            // (inline — no C-counterpart fn). Without this every
5133            // `compadd -d` display string (the description lines
5134            // _describe/_arguments build) was silently dropped and lists
5135            // rendered bare match names.
5136            // c:2560-2561 — `cm->rems = dat->rems; cm->remf = dat->remf`. These
5137            // were previously SKIPPED here (only `disp` was patched), so a
5138            // `compadd -r <chars>` / `-R <widget>` custom suffix-removal spec
5139            // never reached the match: makesuffixstr got `s=None` and the
5140            // char-based auto-remove-suffix was inert. Patch them on the just-
5141            // pushed match copy (the port's add_match_data returns a value).
5142            //
5143            // c:2560 — the whole patch is inside `if ((cm = add_match_data(…)))`,
5144            // so a match REJECTED by the c:2999 brace filter must not have the
5145            // previous match's rems/remf/disp written over it.
5146            if cm.is_some() {
5147                if let Ok(mut g) = crate::comp_match_handles::matches_arc().lock() {
5148                    if let Some(last) = g.last_mut() {
5149                        last.rems = dat.rems.clone(); // c:2560
5150                        last.remf = dat.remf.clone(); // c:2561
5151                        if cur_disp.is_some() {
5152                            last.disp = cur_disp.clone(); // c:2562-2563
5153                        }
5154                    }
5155                }
5156                added += 1;
5157            }
5158        } else {
5159            // c:2566
5160            if dat.apar.is_some() {
5161                // c:2567
5162                apar_list.push(ms.clone());
5163            }
5164            if dat.opar.is_some() {
5165                // c:2569
5166                opar_list.push(word.clone());
5167            }
5168            // c:2571-2578 — consume one element from each live dpar array
5169            // in lockstep with this added word.
5170            for i in 0..dparl.len() {
5171                if dpar_idx[i] < dparr[i].len() {
5172                    dparl[i].push(dparr[i][dpar_idx[i]].clone());
5173                    dpar_idx[i] += 1;
5174                }
5175            }
5176        }
5177    }
5178
5179    // c:2602-2608 — apar/opar/dpar writeback.
5180    if let Some(ref name) = dat.apar {
5181        setaparam(name, apar_list);
5182    }
5183    if let Some(ref name) = dat.opar {
5184        setaparam(name, opar_list);
5185    }
5186    // c:2606-2607 — set_list_array(dpar[i], dparl[i]).
5187    for (i, name) in dpar_names.iter().enumerate() {
5188        setaparam(name, std::mem::take(&mut dparl[i]));
5189    }
5190
5191    // c:2610 — explanation emit.
5192    if dat.exp.is_some() {
5193        // c:2610
5194        addexpl(false);
5195    }
5196
5197    // c:2612-2614 — `<all>` placeholder when CAF_ALL set. C passes the
5198    // SHARED disp cursor (`addmatch("<all>", …, &disp, 1)`, c:2612), exactly
5199    // as the dummy loop below does, so with `-d` the placeholder CONSUMES the
5200    // next display string. Passing None left its disp empty, and
5201    // `bld_all_str` (c:2198-2230) then filled it by concatenating the match
5202    // `str`s — so `compadd -C -d arr -- a b` listed "a b" where zsh lists the
5203    // next entry of `arr`.
5204    let hasall = hasallmatch.load(Ordering::Relaxed);
5205    if hasall == 0 && (dat.aflags & CAF_ALL) != 0 {
5206        let d: Option<&str> = if disp_idx < disp_arr.len() {
5207            let d = Some(disp_arr[disp_idx].as_str());
5208            disp_idx += 1;
5209            d
5210        } else {
5211            None
5212        };
5213        addmatch(
5214            "<all>",
5215            dat.flags | crate::ported::zle::comp_h::CMF_ALL,
5216            d,
5217            true,
5218        );
5219        hasallmatch.store(1, Ordering::Relaxed);
5220    }
5221
5222    // c:2616-2617 — dummy entries. C's addmatch advances the SHARED disp
5223    // cursor (`&disp`, c:2054-2058), so each dummy carries the next
5224    // remaining display string — this is how the description-only lines
5225    // (compdescribe's CRT_EXPL `-E n` run: "opt  --  description") reach
5226    // the list. Passing None here dropped every description line.
5227    while dat.dummies > 0 {
5228        let d: Option<&str> = if disp_idx < disp_arr.len() {
5229            let d = Some(disp_arr[disp_idx].as_str());
5230            disp_idx += 1;
5231            d
5232        } else {
5233            None
5234        };
5235        addmatch(
5236            "",
5237            dat.flags | crate::ported::zle::comp_h::CMF_DUMMY,
5238            d,
5239            false,
5240        );
5241        dat.dummies -= 1;
5242    }
5243
5244    tracing::debug!(
5245        target: "compsys_args",
5246        added,
5247        mnum = mnum.load(Ordering::Relaxed),
5248        doadd,
5249        "addmatches candidate-loop done"
5250    );
5251    let _ = (ppl, psl, compignored_local, added);
5252
5253    // c:2624-2626 — `instring = ois; inbackt = oib; autoq = oaq;`. The
5254    // c:2139-2168 block above rewrites all three from `$compstate[quote]`
5255    // for the duration of THIS compadd only; the port set them and never
5256    // put them back, so a single `compadd` inside a quoted word left
5257    // `instring`/`inbackt` clobbered for the rest of the completion (and for
5258    // `get_comp_string`'s next round, which reads them).
5259    instring_set(ois); // c:2624
5260    inbackt_set(oib); // c:2625
5261    autoq_set(&oaq); // c:2626
5262                     // c:2627-2630 — `zsfree(qipre); zsfree(qisuf); qipre = oqp; qisuf = oqs;`
5263    for (global, v) in [
5264        (&crate::ported::zle::zle_tricky::QIPRE, oqp), // c:2629
5265        (&crate::ported::zle::zle_tricky::QISUF, oqs), // c:2630
5266    ] {
5267        if let Ok(mut g) = global.get_or_init(|| Mutex::new(String::new())).lock() {
5268            *g = v;
5269        }
5270    }
5271
5272    // c:2632-2633 — `if (mnum == nm) haspattern = ohp;`. A compadd that
5273    // added NOTHING must not leave `haspattern` raised by its own pattern
5274    // compile at c:2387/c:2428 — `do_ambiguous` keys menu-vs-insert
5275    // behaviour off it (compresult.c:764).
5276    if mnum.load(Ordering::Relaxed) == _nm {
5277        haspattern.store(ohp, Ordering::Relaxed); // c:2633
5278    }
5279
5280    // c:2635 — `return (mnum == nm)`: non-zero (1) when NO new matches were
5281    // added this call, 0 when at least one landed. Was hardcoded `0`, so
5282    // every `compadd` reported success even against zero matches — the
5283    // `completer` chain (_main_complete) then stopped after `_complete`
5284    // and never advanced to `_approximate` / `_ignored` / etc., and any
5285    // `compadd … && ret=0` idiom in a completer wrongly took the match arm.
5286    if mnum.load(Ordering::Relaxed) == _nm {
5287        1
5288    } else {
5289        0
5290    }
5291}
5292
5293// =====================================================================
5294// add_match_data — `Src/Zle/compcore.c:2643`.
5295// =====================================================================
5296
5297/// Direct port of `Cmatch add_match_data(int alt, char *str, char *orig,
5298///    Cline line, char *ipre, char *ripre, char *isuf, char *pre,
5299///    char *prpre, char *ppre, Cline pline, char *psuf, Cline sline,
5300///    char *suf, int flags, int exact)` from compcore.c:2643.
5301///
5302/// Builds one `Cmatch` from the supplied prefix/suffix bits plus the
5303/// surrounding Cline chain. Threads `line`/`pline`/`sline` through
5304/// `cline_matched` so the CLF_MATCHED state-machine update fires the
5305/// same way as C, then performs path-prefix/suffix splicing via
5306/// `bld_parts` to extend the Cline chain at the appropriate anchor.
5307///
5308/// Returns `None` — C's NULL at c:3000 — when the match is rejected by the
5309/// unfinished-brace filter (`lastprebr`/`lastpostbr` + `hasbrpsfx`). Nothing
5310/// is registered in that case: `mnum`, `ai->count` and the match list are all
5311/// left untouched.
5312#[allow(clippy::too_many_arguments)]
5313pub fn add_match_data(
5314    // c:2643
5315    alt: i32,
5316    str: &str,
5317    orig: &str,
5318    mut line: Option<Box<Cline>>,
5319    ipre_: &str,
5320    ripre_: &str,
5321    isuf_: &str,
5322    pre: Option<&str>,
5323    prpre: &str,
5324    ppre: &str,
5325    mut pline: Option<Box<Cline>>,
5326    psuf: &str,
5327    mut sline: Option<Box<Cline>>,
5328    suf: Option<&str>,
5329    flags: i32,
5330    exact: i32,
5331) -> Option<Cmatch> {
5332    // c:2663 — DPUTS(!line, "BUG: add_match_data() without cline")
5333    DPUTS!(line.is_none(), "BUG: add_match_data() without cline"); // c:2663
5334                                                                   // c:2656 — `Aminfo ai = (alt ? fainfo : ainfo);` — EVERY `ai->…` below
5335                                                                   // (c:3002 join_clines, c:3005 count++, c:3023 firstm, c:3036-3063 exact)
5336                                                                   // goes through this selection. The port bound it and then used the plain
5337                                                                   // `ainfo` at all five sites, so every alternate-set match (`alt != 0` —
5338                                                                   // the fignore/ignored-suffix path) accumulated into the MAIN aminfo:
5339                                                                   // `ainfo.count` counted matches that were never offered, and the
5340                                                                   // unambiguous cline built from `ainfo.line` was polluted by strings the
5341                                                                   // user asked to ignore.
5342    let ai_ref: &OnceLock<Mutex<Option<Aminfo>>> = if alt != 0 { &fainfo } else { &ainfo }; // c:2656
5343                                                                                            // c:2666-2671 — cline_matched(line); pline; sline.
5344    cline_matched(&mut line);
5345    if pline.is_some() {
5346        cline_matched(&mut pline);
5347    }
5348    if sline.is_some() {
5349        cline_matched(&mut sline);
5350    }
5351
5352    // c:2675-2697 — accumulator lengths.
5353    let psl = psuf.len();
5354    let isl = isuf_.len();
5355    let qisuf_v = qisuf_get(); // c:2680
5356    let qisl = qisuf_v.len();
5357    let _salen = (if sline.is_none() { psl } else { 0 }) + isl + qisl; // c:2675-2683
5358
5359    let ipl = ipre_.len();
5360    // c:2769-2770 — `if (pre) palen += (pl = strlen(pre));`: C leaves `pl` at 0
5361    // for a NULL `pre`, so every later use (bld_parts at c:2809, the memcpy at
5362    // c:2837) copies nothing. A zero-length &str is exactly that behaviour, so
5363    // the arithmetic below keeps using a &str view; only the STORED cm.pre /
5364    // cm.suf keep the Option, which is the distinction c:2943-2944 preserves.
5365    let pre_s: &str = pre.unwrap_or("");
5366    let suf_s: &str = suf.unwrap_or("");
5367    let _ppl = ppre.len();
5368    let _pl = pre_s.len();
5369    let qipre_v = qipre_get(); // c:2686
5370    let qipl_v = qipre_v.clone();
5371    let _qipl = qipl_v.len();
5372
5373    let _stl = str.len();
5374    let _lpl = ripre_.len();
5375    let _lsl = suf_s.len();
5376    let _ml = ipl;
5377
5378    // c:2671-2762 — path-suffix Cline splicing. salen accumulates psl
5379    // (psuf when no sline), isl (isuf), qisl (qisuf). When salen > 0
5380    // and line is non-empty, we walk to the tail and append the
5381    // bld_parts-built Cline for each contributing string.
5382    let psl_local = if sline.is_none() && !psuf.is_empty() {
5383        psuf.len() as i32
5384    } else {
5385        0
5386    };
5387    let isl_local = isuf_.len() as i32;
5388    let qisl_local = qisuf_v.len() as i32;
5389    let salen = psl_local + isl_local + qisl_local;
5390    if salen > 0 && line.is_some() {
5391        // Walk to the tail of line via .next.
5392        unsafe {
5393            let mut tail: *mut Option<Box<Cline>> = &mut line;
5394            while let Some(ref n) = *tail {
5395                if n.next.is_none() {
5396                    break;
5397                }
5398                tail = &mut (*tail).as_mut().unwrap().next;
5399            }
5400            // For each contributing string, build a Cline chain via
5401            // bld_parts and attach to the tail node's .next.
5402            if psl_local > 0 {
5403                let s = bld_parts(psuf, psl_local, psl_local, None, None);
5404                if let Some(node) = (*tail).as_mut() {
5405                    node.next = s;
5406                    while let Some(ref nn) = node.next {
5407                        if nn.next.is_none() {
5408                            break;
5409                        }
5410                        // already linked correctly; loop to advance tail
5411                        break;
5412                    }
5413                }
5414                // Walk to the new tail.
5415                while let Some(ref n) = *tail {
5416                    if n.next.is_none() {
5417                        break;
5418                    }
5419                    tail = &mut (*tail).as_mut().unwrap().next;
5420                }
5421            }
5422            if isl_local > 0 {
5423                let s = bld_parts(isuf_, isl_local, isl_local, None, None);
5424                if let Some(node) = (*tail).as_mut() {
5425                    node.next = s;
5426                }
5427                while let Some(ref n) = *tail {
5428                    if n.next.is_none() {
5429                        break;
5430                    }
5431                    tail = &mut (*tail).as_mut().unwrap().next;
5432                }
5433            }
5434            if qisl_local > 0 {
5435                let mut s = bld_parts(&qisuf_v, qisl_local, qisl_local, None, None);
5436                // c:2741 — qsl->flags |= CLF_SUF; qsl->suffix = qsl->prefix.
5437                if let Some(qsl) = s.as_mut() {
5438                    qsl.flags |= crate::ported::zle::comp_h::CLF_SUF;
5439                    qsl.suffix = qsl.prefix.take();
5440                }
5441                if let Some(node) = (*tail).as_mut() {
5442                    node.next = s;
5443                }
5444            }
5445        }
5446    }
5447
5448    // c:2766-2873 — path-prefix Cline splicing. palen accumulates qipl,
5449    // ipl, pl, ppl (when no pline). Each contributing string gets a
5450    // bld_parts Cline prepended to `line`.
5451    let qipl_local = qipre_v.len() as i32;
5452    let ipl_local = ipre_.len() as i32;
5453    let pl_local = pre_s.len() as i32;
5454    let ppl_local = if pline.is_none() && !ppre.is_empty() {
5455        ppre.len() as i32
5456    } else {
5457        0
5458    };
5459    if pl_local > 0 {
5460        if ppl_local > 0 {
5461            let p = bld_parts(ppre, ppl_local, ppl_local, None, None);
5462            // Walk p to its tail, link its tail's next to line.
5463            if p.is_some() {
5464                let mut p_chain = p;
5465                let mut tail: *mut Option<Box<Cline>> = &mut p_chain;
5466                unsafe {
5467                    while let Some(ref n) = *tail {
5468                        if n.next.is_none() {
5469                            break;
5470                        }
5471                        tail = &mut (*tail).as_mut().unwrap().next;
5472                    }
5473                    if let Some(t) = (*tail).as_mut() {
5474                        t.next = line.take();
5475                    }
5476                }
5477                line = p_chain;
5478            }
5479        }
5480        let p = bld_parts(pre_s, pl_local, pl_local, None, None);
5481        if let Some(mut head) = p {
5482            let mut t: *mut Option<Box<Cline>> = &mut head.next;
5483            unsafe {
5484                while (*t).is_some() {
5485                    if (*t).as_deref().unwrap().next.is_none() {
5486                        break;
5487                    }
5488                    t = &mut (*t).as_mut().unwrap().next;
5489                }
5490                *t = line.take();
5491            }
5492            line = Some(head);
5493        }
5494        if ipl_local > 0 {
5495            let p = bld_parts(ipre_, ipl_local, ipl_local, None, None);
5496            if let Some(mut head) = p {
5497                let mut t: *mut Option<Box<Cline>> = &mut head.next;
5498                unsafe {
5499                    // Walk to the empty slot past the tail node, then attach
5500                    // `line` there. The previous code stopped AT the last node
5501                    // and did `*t = line`, OVERWRITING it (dropped the final
5502                    // bld_parts segment — e.g. the last dir component `zdt3/`
5503                    // of `/private/tmp/zdt3/` — so ambiguous path completion
5504                    // corrupted the line). c:2812/2818/2824/2841 `lp->next =
5505                    // line` appends; it never replaces lp.
5506                    while (*t).is_some() {
5507                        t = &mut (*t).as_mut().unwrap().next;
5508                    }
5509                    *t = line.take();
5510                }
5511                line = Some(head);
5512            }
5513        }
5514        if qipl_local > 0 {
5515            let p = bld_parts(&qipre_v, qipl_local, qipl_local, None, None);
5516            if let Some(mut head) = p {
5517                let mut t: *mut Option<Box<Cline>> = &mut head.next;
5518                unsafe {
5519                    // Walk to the empty slot past the tail node, then attach
5520                    // `line` there. The previous code stopped AT the last node
5521                    // and did `*t = line`, OVERWRITING it (dropped the final
5522                    // bld_parts segment — e.g. the last dir component `zdt3/`
5523                    // of `/private/tmp/zdt3/` — so ambiguous path completion
5524                    // corrupted the line). c:2812/2818/2824/2841 `lp->next =
5525                    // line` appends; it never replaces lp.
5526                    while (*t).is_some() {
5527                        t = &mut (*t).as_mut().unwrap().next;
5528                    }
5529                    *t = line.take();
5530                }
5531                line = Some(head);
5532            }
5533        }
5534    } else if qipl_local + ipl_local + pl_local + ppl_local > 0 || pline.is_some() {
5535        // c:2827-2842 — consolidated apre buffer.
5536        let apre = format!(
5537            "{}{}{}{}",
5538            qipre_v.as_str(),
5539            ipre_,
5540            pre_s,
5541            if pline.is_none() { ppre } else { "" }
5542        );
5543        let apre_len = apre.len() as i32;
5544        if apre_len > 0 {
5545            let p = bld_parts(&apre, apre_len, apre_len, None, None);
5546            if let Some(mut head) = p {
5547                let mut t: *mut Option<Box<Cline>> = &mut head.next;
5548                unsafe {
5549                    // Walk to the empty slot past the tail node, then attach
5550                    // `line` there. The previous code stopped AT the last node
5551                    // and did `*t = line`, OVERWRITING it (dropped the final
5552                    // bld_parts segment — e.g. the last dir component `zdt3/`
5553                    // of `/private/tmp/zdt3/` — so ambiguous path completion
5554                    // corrupted the line). c:2812/2818/2824/2841 `lp->next =
5555                    // line` appends; it never replaces lp.
5556                    while (*t).is_some() {
5557                        t = &mut (*t).as_mut().unwrap().next;
5558                    }
5559                    *t = line.take();
5560                }
5561                line = Some(head);
5562            }
5563        }
5564    }
5565
5566    // c:2869 — `stl = strlen(str);`
5567    // c:2870-2920 — MULTIBYTE_SUPPORT.
5568    // c:2871 — "If "str" contains a character that won't convert into a wide
5569    // c:2872 —  character, change it into a $'\123' sequence."
5570    // `mbrtowc` is the locale gate: under UTF-8 a stray 0x9b is MB_INVALID and
5571    // becomes `$'\233'` in cm->str — which is then what the listing prints and
5572    // what do_single inserts — while under LC_ALL=C every byte converts and the
5573    // string is kept as is. Without this the raw byte reached the listing (as
5574    // mb_niceformat's `\M-^[`) and the line.
5575    //
5576    // C walks the byte-metafied string and decodes each `Meta, c^32` pair
5577    // inline (c:2876-2877, c:2897-2898). `str` here is the char-level
5578    // metafied form (`unmetafy_str`), so the pairs are decoded up front and
5579    // the loop below runs on the same raw bytes C's `curchar` sees. Copied
5580    // runs (c:2911-2912) go back out in that form: a run that is valid UTF-8
5581    // stays as its characters, any other byte >= 0x80 is re-metafied as
5582    // `Meta, b^32` (utils.c:4880-4886 `if (imeta(c)) { *p++ = Meta; *p++ = c ^ 32; }`).
5583    let mut new_str: Option<String> = None;
5584    {
5585        use crate::ported::utils::{
5586            mbrtowc, unmetafy_str, MbStateBuf, MBSTATE_ZERO, MB_INCOMPLETE, MB_INVALID,
5587        };
5588        let src = unmetafy_str(str);
5589        let fe = src.len();
5590        let copy_run = |out: &mut String, run: &[u8]| match std::str::from_utf8(run) {
5591            Ok(s) => out.push_str(s),
5592            Err(_) => {
5593                for &b in run {
5594                    if b < 0x80 {
5595                        out.push(b as char);
5596                    } else {
5597                        out.push(crate::ported::zsh_h::Meta as char);
5598                        out.push(char::from(b ^ 32));
5599                    }
5600                }
5601            }
5602        };
5603        // c:2874 — `memset(&mbs, '\0', sizeof mbs);`
5604        let mut mbs: MbStateBuf = MBSTATE_ZERO;
5605        // c:2875 — `for (t = f = fs = str, fe = f + stl; fs < fe; )`. `t` (the
5606        // output end) is `fs` until the first invalid byte allocates new_str.
5607        let (mut t, mut f, mut fs) = (0usize, 0usize, 0usize);
5608        while fs < fe {
5609            // c:2876-2877 — `curchar = *f++` (Meta pair already decoded).
5610            let curchar = src[f];
5611            f += 1;
5612            // c:2878 — `cnt = mbrtowc(&wc, &curchar, 1, &mbs);`
5613            let mut wc: libc::wchar_t = 0;
5614            let cnt = unsafe {
5615                mbrtowc(
5616                    &mut wc,
5617                    &curchar as *const u8 as *const libc::c_char,
5618                    1,
5619                    &mut mbs as *mut MbStateBuf as *mut libc::c_void,
5620                )
5621            };
5622            match cnt {
5623                // c:2880-2882 — `case MB_INCOMPLETE: if (f < fe) continue;`
5624                MB_INCOMPLETE if f < fe => continue,
5625                // c:2883-2884 — FALL THROUGH into `case MB_INVALID:`
5626                MB_INCOMPLETE | MB_INVALID => {
5627                    // c:2885-2886 — "Get mbs out of its undefined state."
5628                    mbs = MBSTATE_ZERO;
5629                    // c:2887-2892 — `if (!new_str) { new_str = zhalloc(...);
5630                    // memcpy(new_str, str, t - str); ... }`
5631                    let out = new_str.get_or_insert_with(|| {
5632                        let mut s = String::new();
5633                        copy_run(&mut s, &src[..t]);
5634                        s
5635                    });
5636                    // c:2893-2895 — "Output one byte from the start of this
5637                    // invalid multibyte sequence unless we got MB_INCOMPLETE at
5638                    // the end of the string, in which case we output all the
5639                    // incomplete bytes."
5640                    loop {
5641                        // c:2897-2898
5642                        let c = src[fs];
5643                        fs += 1;
5644                        // c:2899-2905 — `$'\NNN'`
5645                        out.push_str("$'\\");
5646                        out.push((b'0' + ((c >> 6) & 7)) as char);
5647                        out.push((b'0' + ((c >> 3) & 7)) as char);
5648                        out.push((b'0' + (c & 7)) as char);
5649                        out.push('\'');
5650                        // c:2906 — `} while (cnt == MB_INCOMPLETE && fs < fe);`
5651                        if !(cnt == MB_INCOMPLETE && fs < fe) {
5652                            break;
5653                        }
5654                    }
5655                    // c:2907-2908 — "Scanning restarts from the spot after the
5656                    // char we skipped."
5657                    f = fs;
5658                }
5659                // c:2910-2913 — `default: while (fs < f) *t++ = *fs++;`
5660                _ => {
5661                    match new_str.as_mut() {
5662                        Some(out) => copy_run(out, &src[fs..f]),
5663                        None => t = f,
5664                    }
5665                    fs = f;
5666                }
5667            }
5668        }
5669    }
5670    // c:2916-2920 — `if (new_str) { *t = '\0'; str = new_str; stl = t - str; }`
5671    let str: &str = new_str.as_deref().unwrap_or(str);
5672    let stl = str.len();
5673
5674    // c:2929-2932 — Cmatch allocation + str/orig/ppre/psuf.
5675    let mut cm = Cmatch::default(); // c:2929
5676    cm.str = Some(str.to_string()); // c:2930
5677    cm.orig = Some(orig.to_string()); // c:2931
5678    cm.ppre = if ppre.is_empty() {
5679        None
5680    } else {
5681        Some(ppre.into())
5682    }; // c:2932
5683    cm.psuf = if psuf.is_empty() {
5684        None
5685    } else {
5686        Some(psuf.into())
5687    }; // c:2933
5688
5689    // c:2934 — prpre only when CMF_FILE.
5690    cm.prpre = if (flags & CMF_FILE) != 0 && !prpre.is_empty() {
5691        Some(prpre.into())
5692    } else {
5693        None
5694    };
5695
5696    // c:2935-2938 — ipre = qipre + ipre (concat when qipre non-empty).
5697    // qipre_v already computed above.
5698    cm.ipre = if !qipre_v.is_empty() {
5699        if !ipre_.is_empty() {
5700            Some(format!("{}{}", qipre_v, ipre_))
5701        } else {
5702            Some(qipre_v.clone())
5703        }
5704    } else if !ipre_.is_empty() {
5705        Some(ipre_.into())
5706    } else {
5707        None
5708    };
5709
5710    cm.ripre = if ripre_.is_empty() {
5711        None
5712    } else {
5713        Some(ripre_.into())
5714    }; // c:2939
5715
5716    // c:2940-2943 — isuf = isuf + qisuf (concat when qisuf non-empty).
5717    cm.isuf = if !qisuf_v.is_empty() {
5718        if !isuf_.is_empty() {
5719            Some(format!("{}{}", isuf_, qisuf_v))
5720        } else {
5721            Some(qisuf_v.clone())
5722        }
5723    } else if !isuf_.is_empty() {
5724        Some(isuf_.into())
5725    } else {
5726        None
5727    };
5728
5729    // c:2943-2944 — `cm->pre = pre; cm->suf = suf;` are UNCONDITIONAL. The six
5730    // neighbours above them (ppre/psuf/prpre/ipre/ripre/isuf, c:2931-2942) each
5731    // collapse empty-to-NULL with `x && *x ? x : NULL`, and C pointedly does NOT
5732    // do that for these two: an explicit `compadd -S ''` is a REAL empty suffix
5733    // and must stay distinguishable from "no -S given" (`dat.suf` starts NULL at
5734    // complete.c:625). This port collapsed both, so `-S ''` was indistinguishable
5735    // from omitting -S.
5736    cm.pre = pre.map(|s| s.to_string()); // c:2943
5737    cm.suf = suf.map(|s| s.to_string()); // c:2944
5738
5739    // c:2946 — flags + CMF_PACKED/CMF_ROWS from complist.
5740    let complist_s = COMPLIST
5741        .get()
5742        .and_then(|m| m.lock().ok().map(|g| g.clone()))
5743        .unwrap_or_default();
5744    let extra_flags = (if complist_s.contains("packed") {
5745        CMF_PACKED
5746    } else {
5747        0
5748    }) | (if complist_s.contains("rows") {
5749        CMF_ROWS
5750    } else {
5751        0
5752    });
5753    cm.flags = flags | extra_flags;
5754
5755    // c:2950-2951 — mode/fmode init to 0.
5756    cm.mode = 0;
5757    cm.fmode = 0;
5758    cm.modec = '\0';
5759    cm.fmodec = '\0';
5760
5761    // c:2952-2970 — CMF_FILE: stat the path for mode + modec.
5762    use crate::ported::zle::comp_h::CMF_FILE;
5763    if (flags & CMF_FILE) != 0 && !orig.is_empty() && !orig.ends_with('/') {
5764        let pb = format!("{}{}", cm.prpre.as_deref().unwrap_or("./"), orig);
5765        // c:2960-2963 — `ztat(pb, &buf, 1); cm->mode = buf.st_mode;
5766        //   if ((cm->modec = file_type(buf.st_mode)) == ' ') cm->modec = 0;`
5767        // The `1` is ztat's ls flag → lstat, so modec is the type of the
5768        // link itself (`@` for a symlink). This is the marker printlist
5769        // shows, so it must come from lstat, not stat.
5770        if let Some(meta) = ztat(&pb, true) {
5771            use std::os::unix::fs::MetadataExt;
5772            cm.mode = meta.mode();
5773            let c = crate::ported::glob::file_type(cm.mode); // c:2962
5774            cm.modec = if c == ' ' { '\0' } else { c }; // c:2963
5775        }
5776        // c:2965-2968 — `ztat(pb, &buf, 0)` → stat, so fmode/fmodec is the
5777        // type of the symlink target (the marker used when following links).
5778        if let Some(meta) = ztat(&pb, false) {
5779            use std::os::unix::fs::MetadataExt;
5780            cm.fmode = meta.mode();
5781            let c = crate::ported::glob::file_type(cm.fmode); // c:2967
5782            cm.fmodec = if c == ' ' { '\0' } else { c }; // c:2968
5783        }
5784    }
5785
5786    // c:2970-2972 — `if ((*compqstack == QT_BACKSLASH && compqstack[1]) ||
5787    //                    (autoq && *compqstack && compqstack[1] == QT_BACKSLASH))
5788    //                    cm->flags |= CMF_NOSPACE;`
5789    //
5790    // Missing entirely. A match produced inside a nested quoting context
5791    // whose outer level is backslash-quoting must not get the automatic
5792    // trailing space (it would be swallowed by the enclosing quotes);
5793    // without the flag every such completion inserted a stray space.
5794    {
5795        let qstack = COMPQSTACK
5796            .get_or_init(|| Mutex::new(String::new()))
5797            .lock()
5798            .map(|g| g.clone())
5799            .unwrap_or_default();
5800        let qb = qstack.as_bytes();
5801        let autoq_set_now = AUTOQ
5802            .get()
5803            .and_then(|m| m.lock().ok().map(|g| !g.is_empty()))
5804            .unwrap_or(false);
5805        let q0_bslash = !qb.is_empty() && qb[0] as i32 == QT_BACKSLASH;
5806        let q1_bslash = qb.len() > 1 && qb[1] as i32 == QT_BACKSLASH;
5807        if (q0_bslash && qb.len() > 1) || (autoq_set_now && !qb.is_empty() && q1_bslash) {
5808            cm.flags |= crate::ported::zle::comp_h::CMF_NOSPACE; // c:2972
5809        }
5810    }
5811
5812    // c:2973-2992 — brpl/brsl brace position arrays. Walk BRBEG/BREND
5813    // (the global Brinfo chains from `Src/Zle/zle_tricky.c`), reading
5814    // `curpos` for each entry — the per-match position addmatches snapshots
5815    // at c:2132-2135 from `pos`/`qpos` according to CAF_QUOTE. The port read
5816    // the raw `qpos`, which is the UNQUOTED-case value only, so a
5817    // `compadd -Q` brace expansion got the wrong insertion offsets.
5818    cm.brpl = BRBEG
5819        .get_or_init(|| Mutex::new(None))
5820        .lock()
5821        .ok()
5822        .and_then(|g| {
5823            g.as_ref().map(|head| {
5824                let mut out: Vec<i32> = Vec::new();
5825                let mut cur = Some(head.as_ref());
5826                while let Some(n) = cur {
5827                    out.push(n.curpos);
5828                    cur = n.next.as_deref();
5829                }
5830                out
5831            })
5832        })
5833        .unwrap_or_default();
5834    cm.brsl = BREND
5835        .get_or_init(|| Mutex::new(None))
5836        .lock()
5837        .ok()
5838        .and_then(|g| {
5839            g.as_ref().map(|head| {
5840                let mut out: Vec<i32> = Vec::new();
5841                let mut cur = Some(head.as_ref());
5842                while let Some(n) = cur {
5843                    out.push(n.curpos);
5844                    cur = n.next.as_deref();
5845                }
5846                out
5847            })
5848        })
5849        .unwrap_or_default();
5850
5851    cm.qipl = qipre_v.len() as i32; // c:2994
5852    cm.qisl = qisuf_v.len() as i32; // c:2995
5853                                    // c:2996 — autoq read.
5854    let autoq_v = AUTOQ
5855        .get()
5856        .and_then(|m| m.lock().ok().map(|g| g.clone()))
5857        .unwrap_or_default();
5858    cm.autoq = if !autoq_v.is_empty() {
5859        Some(autoq_v)
5860    } else if INBACKT.load(Ordering::Relaxed) != 0 {
5861        Some("`".into())
5862    } else {
5863        None
5864    };
5865
5866    cm.rems = None;
5867    cm.remf = None;
5868    cm.disp = None; // c:2997
5869
5870    // c:2999-3000 — `if ((lastprebr || lastpostbr) &&
5871    //                    !hasbrpsfx(cm, lastprebr, lastpostbr)) return NULL;`
5872    //
5873    // The brace filter, absent from the port entirely. When the word being
5874    // completed sits in an UNFINISHED brace list with a comma before the
5875    // cursor (`echo a{b,<TAB>`), `get_comp_string` (zle_tricky.c:2203-2213)
5876    // records the text ahead of the `{` in `lastprebr` and the text after
5877    // the last `}` in `lastpostbr`. Every candidate is then trial-inserted
5878    // by `hasbrpsfx` and DROPPED unless it reproduces exactly that
5879    // surrounding text — which is what makes the brace element the only
5880    // thing being completed. Without the filter every candidate survived,
5881    // so `echo a{b,<TAB>` listed the whole fasd/file corpus where zsh
5882    // reports "No Matches": the stripped word `a` was still the match
5883    // prefix, but nothing checked that inserting a candidate leaves `a`
5884    // in front of the brace.
5885    //
5886    // Both operands are NULL-vs-set tests in C, so this must not be
5887    // written as an emptiness test: `cmd {b,<TAB>` records an EMPTY but
5888    // non-NULL `lastprebr` and still has to run the filter.
5889    {
5890        let (lpb, lsb) = (
5891            crate::ported::zle::zle_tricky::LASTPREBR
5892                .get()
5893                .and_then(|m| m.lock().ok())
5894                .and_then(|g| g.clone()),
5895            crate::ported::zle::zle_tricky::LASTPOSTBR
5896                .get()
5897                .and_then(|m| m.lock().ok())
5898                .and_then(|g| g.clone()),
5899        );
5900        if (lpb.is_some() || lsb.is_some())
5901            && !crate::ported::zle::compresult::hasbrpsfx(&cm, lpb.as_deref(), lsb.as_deref())
5902        {
5903            return None; // c:3000
5904        }
5905    }
5906
5907    // c:3002 — ai->line = join_clines(ai->line, line).
5908    if let Ok(mut g) = ai_ref.get_or_init(|| Mutex::new(None)).lock() {
5909        if let Some(a) = g.as_mut() {
5910            let old_line = a.line.take();
5911            a.line = crate::ported::zle::compmatch::join_clines(old_line, line);
5912        }
5913    }
5914
5915    // c:3004 — mnum++.
5916    mnum.fetch_add(1, Ordering::Relaxed);
5917
5918    // c:3005 — ai->count++.
5919    if let Ok(mut g) = ai_ref.get_or_init(|| Mutex::new(None)).lock() {
5920        if let Some(a) = g.as_mut() {
5921            a.count += 1;
5922        }
5923    }
5924
5925    // c:3008 — addlinknode((alt ? fmatches : matches), cm). Already
5926    // wired below via matches Vec push.
5927
5928    // c:3009-3010 — newmatches = 1; mgroup->new = 1. Only the first half was
5929    // ported; without `mgroup->new` the group holding this match was not
5930    // marked dirty, so `permmatches` could serve its cached permanent copy
5931    // and the match never appeared (the sibling `addmatch` at c:2068 sets it).
5932    newmatches.store(1, Ordering::Relaxed); // c:3009
5933    if let Ok(mg) = mgroup.get_or_init(|| Mutex::new(None)).lock() {
5934        if let Some(grp) = mg.as_ref() {
5935            grp.new_.store(1, Ordering::Relaxed); // c:3010
5936        }
5937    }
5938
5939    // c:3011-3012 — compignored++ when alt.
5940    if alt != 0 {
5941        crate::ported::zle::complete::COMPIGNORED.fetch_add(1, Ordering::Relaxed);
5942    }
5943
5944    // c:3015-3016 — `if (!*complastprompt) dolastprompt = 0;`. Read the
5945    // `complastprompt` value (the "last_prompt" compstate, set at c:326 to
5946    // "yes"/"" from ALWAYS_LAST_PROMPT) — NOT `complastprefix`, a different
5947    // variable the previous port read by mistake. With that bug dolastprompt
5948    // was cleared on every completion, so `clearflag` stayed 0 and `trashzle`
5949    // never emitted TCCLEAREOD: an on-screen completion list was left
5950    // stranded when the command was accepted.
5951    let complastprompt_v = get_compstate_str("last_prompt").unwrap_or_default();
5952    if complastprompt_v.is_empty() {
5953        dolastprompt.store(0, Ordering::Relaxed);
5954    }
5955
5956    // c:3018-3023 — curexpl.count/fcount increment.
5957    if let Ok(mut g) = curexpl.get_or_init(|| Mutex::new(None)).lock() {
5958        if let Some(e) = g.as_mut() {
5959            if alt != 0 {
5960                e.fcount += 1;
5961            } else {
5962                e.count += 1;
5963            }
5964        }
5965    }
5966
5967    // c:3023-3024 — ai->firstm = cm.
5968    if let Ok(mut g) = ai_ref.get_or_init(|| Mutex::new(None)).lock() {
5969        if let Some(a) = g.as_mut() {
5970            if a.firstm.is_none() {
5971                a.firstm = Some(Box::new(cm.clone()));
5972            }
5973        }
5974    }
5975
5976    // c:3027-3034 — minmlen/maxmlen tracking.
5977    let lpl = cm.ppre.as_deref().map(|s| s.len()).unwrap_or(0);
5978    let lsl = cm.psuf.as_deref().map(|s| s.len()).unwrap_or(0);
5979    let ml = (stl + lpl + lsl) as i32;
5980    let cur_min = minmlen.load(Ordering::Relaxed);
5981    let cur_max = maxmlen.load(Ordering::Relaxed);
5982    if ml < cur_min {
5983        minmlen.store(ml, Ordering::Relaxed);
5984    }
5985    if ml > cur_max {
5986        maxmlen.store(ml, Ordering::Relaxed);
5987    }
5988
5989    // c:3036-3064 — exact-match tracking on ai.
5990    if exact != 0 {
5991        // c:3036
5992        // c:3039-3056 — `if (incompfunc && !*compexactstr) compexactstr =
5993        // ppre + str + psuf;` — publishing `$compstate[exact_string]`, which
5994        // `_main_complete` reads to decide whether to accept the exact match
5995        // outright. The publish was missing, so the parameter stayed at the
5996        // "" that do_completion writes at c:312 and the exact match was never
5997        // recognisable to the shell-function layer. Computed BEFORE taking
5998        // the ai lock (set_compstate_str takes the paramtab lock).
5999        let publish_exact = INCOMPFUNC.load(Ordering::Relaxed) != 0
6000            && get_compstate_str("exact_string")
6001                .map(|s| s.is_empty())
6002                .unwrap_or(true);
6003        let exact_str = format!(
6004            "{}{}{}",
6005            cm.ppre.as_deref().unwrap_or(""), // c:3047-3049
6006            str,                              // c:3051
6007            cm.psuf.as_deref().unwrap_or("")  // c:3053
6008        );
6009        let mut do_publish = false;
6010        if let Ok(mut g) = ai_ref.get_or_init(|| Mutex::new(None)).lock() {
6011            if let Some(a) = g.as_mut() {
6012                if a.exact == 0 {
6013                    // c:3037
6014                    a.exact = useexact.load(Ordering::Relaxed); // c:3038
6015                    do_publish = publish_exact;
6016                    a.exactm = Some(Box::new(cm.clone())); // c:3057
6017                } else if useexact.load(Ordering::Relaxed) != 0
6018                    // c:3058 — C also requires that this exact match DIFFERS
6019                    // from the one already recorded (`!ai->exactm ||
6020                    // !matcheq(cm, ai->exactm)`). The port dropped that half,
6021                    // so a second compadd of the SAME string (routine when
6022                    // several completers offer it) escalated `exact` to 2 —
6023                    // "ambiguous exact" — and the accept-exact path was lost.
6024                    && a.exactm
6025                        .as_deref()
6026                        .map(|em| !matcheq(&cm, em))
6027                        .unwrap_or(true)
6028                {
6029                    // c:3059-3060 — ambiguous exact: set to 2, clear exactm.
6030                    a.exact = 2;
6031                    a.exactm = None;
6032                }
6033            }
6034        }
6035        if do_publish {
6036            set_compstate_str("exact_string", &exact_str); // c:3046-3055
6037        }
6038    }
6039
6040    // c:3064 — push cm into matches/fmatches LinkList.
6041    let cell = if alt != 0 {
6042        crate::comp_match_handles::fmatches_arc()
6043    } else {
6044        crate::comp_match_handles::matches_arc()
6045    };
6046    if let Ok(mut g) = cell.lock() {
6047        g.push(cm.clone());
6048    }
6049
6050    Some(cm) // c:3067 return cm
6051}
6052
6053// `lookup_complist_flags` deleted — Rust-only 8-line helper. Inlined
6054// at the single call site in callcompfunc (c:2049-2051).
6055
6056// =====================================================================
6057// begcmgroup — `Src/Zle/compcore.c:3073`.
6058// =====================================================================
6059
6060/// Port of `mod_export void begcmgroup(char *n, int flags)` from
6061/// compcore.c:3073.
6062pub fn begcmgroup(n: Option<&str>, flags: i32) {
6063    // c:3073
6064    if let Some(name) = n {
6065        // c:3073
6066        let mask = CGF_NOSORT | CGF_UNIQALL | CGF_UNIQCON                    // c:3085
6067                 | CGF_MATSORT | CGF_NUMSORT | CGF_REVSORT;
6068        // c:3078-3094 — reuse an existing group with the same name+flags.
6069        let reused = {
6070            let cell = amatches.get_or_init(|| Mutex::new(Vec::new()));
6071            cell.lock().ok().and_then(|mut g| {
6072                g.iter_mut()
6073                    .find(|grp| grp.name.as_deref() == Some(name) && (grp.flags & mask) == flags)
6074                    .map(|grp| {
6075                        // c:3088 `mgroup = p` is a pointer alias. `mgroup`
6076                        // is only ever read for name/flags/`new`/`ylist` and
6077                        // the shared `l*` lists, never for `matches` or
6078                        // `perm` — the sorted array and the permanent copy
6079                        // `permmatches` keeps on the group. Leave those two
6080                        // out of the copy: cloning them duplicated every
6081                        // match of the group twice on each `compadd` into
6082                        // it (2 x 47058 per call on `arch <TAB>`).
6083                        let kept_matches = std::mem::take(&mut grp.matches);
6084                        let kept_perm = grp.perm.take();
6085                        let active = grp.clone();
6086                        grp.matches = kept_matches;
6087                        grp.perm = kept_perm;
6088                        active
6089                    })
6090            })
6091        };
6092        if let Some(active) = reused {
6093            // c:3090-3093 — `expls = p->lexpls; matches = p->lmatches;
6094            //   fmatches = p->lfmatches; allccs = p->lallccs;`. These are
6095            //   pointer aliases into the reused group so appends keep flowing
6096            //   into it. `active` was `.cloned()` from `amatches`, and the
6097            //   `l*` fields are `Arc<Mutex<…>>`, so this clone SHARES the same
6098            //   allocations as the `amatches` original — rebinding the handles
6099            //   to them makes every subsequent append land in both, no copy.
6100            crate::comp_match_handles::rebind_current(
6101                &active.lmatches,
6102                &active.lfmatches,
6103                &active.lexpls,
6104                &active.lallccs,
6105            );
6106            let mc = mgroup.get_or_init(|| Mutex::new(None));
6107            if let Ok(mut s) = mc.lock() {
6108                *s = Some(active);
6109            }
6110            return; // c:3095
6111        }
6112    }
6113    let mut grp = Cmgroup::default(); // c:3101
6114    grp.name = n.map(String::from); // c:3105
6115    grp.flags = flags; // c:3108
6116    let cell = amatches.get_or_init(|| Mutex::new(Vec::new()));
6117    if let Ok(mut g) = cell.lock() {
6118        g.insert(0, grp.clone()); // c:3121-3124 — shares grp's Arc l* fields
6119    }
6120    // c:3110-3118 — alias the file-scope handles to the fresh group's (empty)
6121    // `l*` Arcs. Same allocation as the `amatches` clone above, so appends
6122    // land in both. No clearing needed: the new group's accumulators start
6123    // empty. Rebind BEFORE moving `grp` into `mgroup`.
6124    crate::comp_match_handles::rebind_current(
6125        &grp.lmatches,
6126        &grp.lfmatches,
6127        &grp.lexpls,
6128        &grp.lallccs,
6129    );
6130    let mc = mgroup.get_or_init(|| Mutex::new(None));
6131    if let Ok(mut s) = mc.lock() {
6132        *s = Some(grp);
6133    }
6134}
6135
6136// =====================================================================
6137// endcmgroup — `Src/Zle/compcore.c:3131`.
6138// =====================================================================
6139
6140/// Port of `mod_export void endcmgroup(char **ylist)` from
6141/// compcore.c:3131.
6142pub fn endcmgroup(ylist: Option<Vec<String>>) {
6143    // c:3131 — C is a one-liner (`mgroup->ylist = ylist`) because the
6144    // file-scope `matches`/`fmatches`/`expls`/`allccs` LinkLists ARE this
6145    // group's `l*` lists (aliased by begcmgroup). The Rust port keeps
6146    // those as separate Mutex globals, so on close we flush them into the
6147    // matching group inside `amatches` (identified by name+flags exactly
6148    // as begcmgroup dedups). Without this the matches added between
6149    // begcmgroup/endcmgroup never reach permmatches and `nmatches`
6150    // stays 0.
6151    // c:3133 — `mgroup->ylist = ylist`. The NULL is stored AS a NULL: an
6152    // absent ylist and an empty-but-present one take different branches in
6153    // `calclist` (compresult.c:1526/1673/1726 test the pointer), so
6154    // `unwrap_or_default()` here would erase the distinction before it
6155    // ever reaches the listing code.
6156    let yl = ylist;
6157
6158    // c:3131 — in C this is a one-liner (`mgroup->ylist = ylist`): `matches`
6159    // etc. already ARE `mgroup->lmatches` (aliased in begcmgroup), so nothing
6160    // to flush. The port now mirrors that — the group's `l*` are shared
6161    // `Arc`s the file-scope handles point at, so `compadd`'s appends are
6162    // already in the group. `new_` is shared the same way now
6163    // (comp_h.rs `Cmgroup::new_`), so the only per-clone SCALAR field left
6164    // to copy is `ylist`.
6165
6166    // Identify the current group and record ylist on the mgroup holder.
6167    let (name, flags) = {
6168        let mc = mgroup.get_or_init(|| Mutex::new(None));
6169        match mc.lock() {
6170            Ok(mut g) => match g.as_mut() {
6171                Some(grp) => {
6172                    grp.ylist = yl.clone(); // c:3140
6173                    (grp.name.clone(), grp.flags)
6174                }
6175                None => return,
6176            },
6177            Err(_) => return,
6178        }
6179    };
6180
6181    // Does the closing group carry any live content? Read the SHARED `l*`
6182    // Arcs (via handles that drop their guard before we lock the inner Vec,
6183    // so no lock is held into the `amatches` block below → no deadlock).
6184    // `newmatches` must be marked whenever a group holds matches OR an
6185    // explanation-only message (`_message -e`), else permmatches early-returns
6186    // on its stale cache and the group never displays.
6187    let flushed_any = !crate::comp_match_handles::matches_arc()
6188        .lock()
6189        .unwrap()
6190        .is_empty()
6191        || !crate::comp_match_handles::fmatches_arc()
6192            .lock()
6193            .unwrap()
6194            .is_empty()
6195        || !crate::comp_match_handles::expls_arc()
6196            .lock()
6197            .unwrap()
6198            .is_empty();
6199
6200    let mask = CGF_NOSORT | CGF_UNIQALL | CGF_UNIQCON | CGF_MATSORT | CGF_NUMSORT | CGF_REVSORT;
6201    // Copy ONLY the scalar field to the amatches entry; its `l*` and `new_`
6202    // are the same allocations as this group's, already carrying the
6203    // appended matches and the dirty flag.
6204    if let Ok(mut g) = amatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
6205        if let Some(grp) = g
6206            .iter_mut()
6207            .find(|grp| grp.name == name && (grp.flags & mask) == (flags & mask))
6208        {
6209            grp.ylist = yl;
6210        }
6211    }
6212    if flushed_any {
6213        newmatches.store(1, Ordering::Relaxed);
6214    }
6215}
6216
6217// =====================================================================
6218// addexpl — `Src/Zle/compcore.c:3140`.
6219// =====================================================================
6220
6221/// Port of `mod_export void addexpl(int always)` from compcore.c:3140.
6222pub fn addexpl(always: bool) {
6223    // c:3140
6224    let curexpl_snap = {
6225        let cell = curexpl.get_or_init(|| Mutex::new(None));
6226        cell.lock().ok().and_then(|g| g.clone())
6227    };
6228    let curexpl_str = match curexpl_snap.as_ref().and_then(|e| e.str.clone()) {
6229        Some(s) => s,
6230        None => return,
6231    };
6232    let curexpl_count = curexpl_snap.as_ref().map(|e| e.count).unwrap_or(0);
6233    let curexpl_fcount = curexpl_snap.as_ref().map(|e| e.fcount).unwrap_or(0);
6234
6235    let elist = crate::comp_match_handles::expls_arc();
6236    if let Ok(mut g) = elist.lock() {
6237        for e in g.iter_mut() {
6238            // c:3145
6239            if e.str.as_deref() == Some(curexpl_str.as_str()) {
6240                // c:3147
6241                e.count += curexpl_count; // c:3148
6242                e.fcount += curexpl_fcount; // c:3149
6243                if always {
6244                    // c:3150
6245                    e.always = 1;
6246                    nmessages.fetch_add(1, Ordering::Relaxed); // c:3152
6247                    newmatches.store(1, Ordering::Relaxed); // c:3153
6248                    let mc = mgroup.get_or_init(|| Mutex::new(None));
6249                    if let Ok(mg) = mc.lock() {
6250                        if let Some(grp) = mg.as_ref() {
6251                            grp.new_.store(1, Ordering::Relaxed); // c:3154
6252                        }
6253                    }
6254                }
6255                return; // c:3156
6256            }
6257        }
6258        if let Some(e) = curexpl_snap {
6259            // c:3159
6260            g.push(e);
6261        }
6262    }
6263    newmatches.store(1, Ordering::Relaxed); // c:3160
6264    if always {
6265        // c:3161
6266        let mc = mgroup.get_or_init(|| Mutex::new(None));
6267        if let Ok(mg) = mc.lock() {
6268            if let Some(grp) = mg.as_ref() {
6269                grp.new_.store(1, Ordering::Relaxed); // c:3162
6270            }
6271        }
6272        nmessages.fetch_add(1, Ordering::Relaxed); // c:3173
6273    }
6274}
6275
6276// =====================================================================
6277// matchcmp — `Src/Zle/compcore.c:3173`.
6278// =====================================================================
6279
6280/// Port of `static int matchcmp(Cmatch *a, Cmatch *b)` from
6281/// compcore.c:3173.
6282///
6283/// WARNING: signature differs from C — Rust=(a, ak, b, bk) vs C=(a, b).
6284/// `ak`/`bk` are the `zstrcmp` operands of `a`/`b` prepared once per sort
6285/// (`crate::tolerant_sort::MatchSortKey`). C reads `(*a)->str` /
6286/// `(*a)->disp` as metafied, NUL-terminated `char *` and the comparison
6287/// costs nothing but the collation; the port stores matches unmetafied, and
6288/// deriving the operands inside the comparator re-did a metafy check, a NUL
6289/// scan, a backslash scan and a NUL-terminating copy per operand on every
6290/// one of qsort's O(n log n) calls. `arch <TAB>` (47058 command names,
6291/// sorted four times by four `$compstate[nmatches]` reads) spent 61% of the
6292/// completion there. Callers build keys with `MatchSortKey::new(m)`.
6293pub fn matchcmp(
6294    a: &Cmatch,
6295    ak: &crate::tolerant_sort::MatchSortKey,
6296    b: &Cmatch,
6297    bk: &crate::tolerant_sort::MatchSortKey,
6298) -> std::cmp::Ordering {
6299    // c:3173
6300    let order = MATCHORDER.load(Ordering::Relaxed);
6301    let sortdir = if (order & CGF_REVSORT) != 0 { -1 } else { 1 }; // c:3177
6302
6303    let cmp = (b.disp.is_some() as i32) - (a.disp.is_some() as i32); // c:3176
6304    // c:3175 — `const char *as, *bs;`. C assigns POINTERS here
6305    // (c:3181-3182 / c:3191-3192); the comparator allocates nothing, and
6306    // neither does this: the operands are borrowed from the prepared keys.
6307    let (as_, bs) = if (order & CGF_MATSORT) != 0 || (cmp == 0 && a.disp.is_none()) {
6308        (&ak.str, &bk.str) // c:3181-3182
6309    } else {
6310        // c:3183-3184 / c:3186-3188 — C returns these two orderings RAW
6311        // (`return cmp;`); `sortdir` is applied only to the final `zstrcmp`
6312        // at c:3194. The port multiplied both early returns by `sortdir`, so
6313        // under CGF_REVSORT (`compadd -R`/reverse sort) the structural
6314        // groupings inverted too: matches WITHOUT display strings sorted
6315        // ahead of matches with them, and one-per-line display strings sank
6316        // to the bottom of the list instead of heading it.
6317        if cmp != 0 {
6318            return if cmp < 0 {
6319                std::cmp::Ordering::Less // c:3184
6320            } else {
6321                std::cmp::Ordering::Greater
6322            };
6323        }
6324        let displine_cmp = (b.flags & CMF_DISPLINE) - (a.flags & CMF_DISPLINE); // c:3186
6325        if displine_cmp != 0 {
6326            return if displine_cmp < 0 {
6327                std::cmp::Ordering::Less // c:3188
6328            } else {
6329                std::cmp::Ordering::Greater
6330            };
6331        }
6332        // c:3191-3192 — cmp == 0 and `a` has a disp, so both do.
6333        match (ak.disp.as_ref(), bk.disp.as_ref()) {
6334            (Some(ad), Some(bd)) => (ad, bd),
6335            _ => (&ak.str, &bk.str),
6336        }
6337    };
6338    // c:3195-3197 — `sortdir * zstrcmp(as, bs, SORTIT_IGNORING_BACKSLASHES |
6339    // ((isset(NUMERICGLOBSORT) || matchorder & CGF_NUMSORT) ?
6340    //   SORTIT_NUMERICALLY : 0))`. zstrcmp routes through strcoll, so the
6341    // ordering is the locale's collation (case-insensitive on the usual
6342    // macOS/Linux locales) — matching zsh. The previous byte comparison
6343    // sorted ASCII, putting every uppercase name (`README.md`) ahead of
6344    // lowercase ones (`alpha.txt`), which diverged from zsh's completion
6345    // order.
6346    let numeric = crate::ported::zsh_h::isset(crate::ported::zsh_h::NUMERICGLOBSORT)
6347        || (order & CGF_NUMSORT) != 0;
6348    let flags = crate::ported::zsh_h::SORTIT_IGNORING_BACKSLASHES as u32
6349        | if numeric {
6350            crate::ported::zsh_h::SORTIT_NUMERICALLY as u32
6351        } else {
6352            0
6353        };
6354    // c:3181-3182/3191-3192 — the operands are `(*a)->str` / `(*a)->disp`,
6355    // i.e. METAFIED match strings. Nothing on the path from `compadd` to the
6356    // `qsort` at c:3259 unmetafies them, and `zstrcmp` does not either — only
6357    // `strmetasort` does (c:299-315), and that is a different C function on a
6358    // different path. So C's `strcoll` here sees metafied bytes, which for
6359    // most non-ASCII text is not a valid multibyte string, and the collation
6360    // degrades to a byte-wise result. zshrs stores match strings unmetafied,
6361    // so it was handing `strcoll` well-formed UTF-8 and getting the locale's
6362    // real collation instead: under `LC_ALL=en_US.UTF-8` on macOS every pair
6363    // of CJK/Kana/Hangul strings collates EQUAL, so `compadd -- 日本語 中文字
6364    // 한국어 ascii あかさたなはまやらわ` listed them in an arbitrary
6365    // qsort-tie order while zsh listed them deterministically.
6366    //
6367    // The keys hold the metafied bytes, so the same bytes reach the same
6368    // `strcoll` C uses. Metafication is the IDENTITY on ASCII, so the ASCII
6369    // ordering — including the case-insensitive collation noted above — is
6370    // untouched.
6371    let base = crate::metafied_key::zstrcmp_operands(as_, bs, flags);
6372    if sortdir < 0 {
6373        base.reverse()
6374    } else {
6375        base
6376    }
6377}
6378
6379/// Port of `static int matcheq(Cmatch a, Cmatch b)` from
6380/// compcore.c:3206.
6381pub fn matcheq(a: &Cmatch, b: &Cmatch) -> bool {
6382    // c:3207
6383    matchstreq(a.ipre.as_ref(),  b.ipre.as_ref())  &&                        // c:3207
6384    matchstreq(a.pre.as_ref(),   b.pre.as_ref())   &&                        // c:3210
6385    matchstreq(a.ppre.as_ref(),  b.ppre.as_ref())  &&                        // c:3211
6386    matchstreq(a.psuf.as_ref(),  b.psuf.as_ref())  &&                        // c:3212
6387    matchstreq(a.suf.as_ref(),   b.suf.as_ref())   &&                        // c:3213
6388    matchstreq(a.str.as_ref(),  b.str.as_ref()) // c:3214
6389}
6390
6391// =====================================================================
6392// makearray — `Src/Zle/compcore.c:3224`.
6393// =====================================================================
6394
6395/// Port of `static Cmatch *makearray(LinkList l, int type, int flags,
6396///                                    int *np, int *nlp, int *llp)`
6397/// from compcore.c:3223. Returns `(arr, n, nl, ll)`.
6398///
6399/// `type` is fixed to `1` (match-sort path) for the in-file call sites
6400/// from `permmatches`. The `type=0` string-sort path on `lexpls` is
6401/// inlined at the `permmatches` call site (C uses a `(char **)` cast
6402/// trick that has no safe Rust equivalent).
6403///
6404/// c:3230-3236 — `rp = hcalloc(...); for (nod = firstnode(l); nod; incnode(nod))
6405/// *ap++ = (Cmatch) getdata(nod);`. The array holds the SAME `Cmatch`
6406/// POINTERS the LinkList holds, so every `->flags |= CMF_MULT` /
6407/// `|= CMF_FMULT` / `= CMF_DELETE` below writes THROUGH to the live match
6408/// still sitting on `g->lmatches` — and C never empties that list, so the
6409/// marks are still there on the NEXT `permmatches` call. `Cmatch` is a
6410/// value struct in the port (`comp_h.rs:517`), so taking the list by value
6411/// meant every one of those writes landed on a detached copy and was
6412/// discarded; a second `permmatches` in the same completion (every
6413/// `_description`/`_wanted` block reads `$compstate[nmatches]`, which is
6414/// `permmatches(0)` — `Src/Zle/complete.c:1411-1413`) restarted from
6415/// unmarked matches.
6416///
6417/// `src` is therefore `&mut` the LIVE list, and the "array" is an INDEX
6418/// PERMUTATION `ord` into it — the exact analogue of C's array of aliasing
6419/// pointers. Sorting, compacting (`*cp++ = *ap`) and truncating (`*cp =
6420/// NULL`) move only indices, exactly as C moves only pointers; every flag
6421/// write goes to `src[..]`, i.e. to the live match.
6422pub fn makearray(src: &mut Vec<Cmatch>, flags: i32) -> (Vec<Cmatch>, i32, i32, i32) {
6423    // c:3224
6424    let mut ord: Vec<usize> = (0..src.len()).collect(); // c:3230-3236
6425    let mut n: i32 = ord.len() as i32; // c:3224
6426    let mut nl: i32 = 0; // c:3231
6427    let mut ll: i32 = 0; // c:3231
6428
6429    if n > 0 {
6430        // c:3258 (type==1 branch)
6431        if (flags & CGF_NOSORT) == 0 {
6432            // c:3259
6433            // Now sort the array (it contains matches).                     // c:3260
6434            MATCHORDER.store(flags, Ordering::Relaxed); // c:3261
6435                                                        // c:3262 — C `qsort(rp, n, sizeof(Cmatch), matchcmp)`. Must use the
6436                                                        // qsort-tolerant sort: matchcmp→zstrcmp is not a strict weak order
6437                                                        // (numeric/natural sort), which makes Rust's sort_by PANIC.
6438                                                        // Sorting the permutation is C's "qsort the POINTER array": the
6439                                                        // matches themselves never move.
6440            crate::tolerant_sort::qsort_matches(&mut ord, src);
6441
6442            if (flags & CGF_UNIQCON) == 0 {
6443                // c:3269 not -2
6444                // remove dupes
6445                let mut cp = 0usize; // c:3272
6446                let mut ap = 0usize;
6447                while ap < ord.len() {
6448                    // c:3274 for ap;*ap;ap++
6449                    // Scan the run of duplicates FIRST, using the element at
6450                    // `ap` (C keeps `*ap` stable — `*cp++ = *ap` copies, it
6451                    // does not move). Doing `ord.swap(ap, cp)` before this scan
6452                    // (the old code) overwrote `rp[ap]` with a stale slot once
6453                    // any earlier removal made `cp < ap`, so `rp[ap].str ==
6454                    // rp[bp+1].str` compared the wrong element and same-string
6455                    // duplicates (e.g. `libpng-config` from several $path dirs)
6456                    // were never collapsed.
6457                    // c:3271 — collapse the run of matcheq duplicates.
6458                    let mut bp = ap;
6459                    while bp + 1 < ord.len() && matcheq(&src[ord[ap]], &src[ord[bp + 1]]) {
6460                        bp += 1;
6461                        n -= 1; // c:3271 bp[1] && matcheq
6462                    }
6463                    // c:3272 — `ap = bp`: from here C compares against the
6464                    // LAST element of the collapsed run.
6465                    let run_end = bp;
6466                    // c:3274-3278 — mark (do NOT remove) the following
6467                    // elements that are not matcheq but would DISPLAY the
6468                    // same string. C advances only `bp` here; `n` is not
6469                    // decremented and the outer loop resumes at `run_end + 1`,
6470                    // so every CMF_MULT element stays in the array and in
6471                    // `mcount` — it is excluded from the *listing* later via
6472                    // `nl` (c:3287-3288 → `lcount = nn - nl`). The port
6473                    // decremented `n` and skipped past them, deleting them
6474                    // outright: `mcount` under-counted, so a word with two
6475                    // same-string matches from different path prefixes looked
6476                    // like a single unambiguous match and was inserted
6477                    // without ever offering the choice.
6478                    let mut mark = run_end;
6479                    let mut dup = 0i32; // c:3274
6480                    while mark + 1 < ord.len()
6481                        && src[ord[run_end]].disp.is_none()
6482                        && src[ord[mark + 1]].disp.is_none()             // c:3274 !disp
6483                        && src[ord[run_end]].str == src[ord[mark + 1]].str
6484                    {
6485                        src[ord[mark + 1]].flags |= CMF_MULT; // c:3276
6486                        dup = 1; // c:3277
6487                        mark += 1;
6488                    }
6489                    if dup != 0 {
6490                        // c:3279
6491                        src[ord[run_end]].flags |= CMF_FMULT; // c:3280
6492                    }
6493                    // c:3270 `*cp++ = *ap` — keep the first of the run at `cp`.
6494                    if ap != cp {
6495                        ord.swap(ap, cp);
6496                    }
6497                    cp += 1;
6498                    ap = run_end + 1; // c:3272 ap = bp; then outer ap++
6499                }
6500                ord.truncate(cp); // c:3282 *cp = NULL
6501            }
6502            for &i in ord.iter() {
6503                // c:3293
6504                let m = &src[i];
6505                if m.disp.is_some() && (m.flags & CMF_DISPLINE) != 0 {
6506                    // c:3294
6507                    ll += 1;
6508                }
6509                if (m.flags & (CMF_NOLIST | CMF_MULT)) != 0 {
6510                    // c:3296
6511                    nl += 1;
6512                }
6513            }
6514        } else {
6515            // c:3300 used -O nosort or -V
6516            if (flags & CGF_UNIQALL) == 0 && (flags & CGF_UNIQCON) == 0 {
6517                // c:3302 didn't use -1 or -2
6518                MATCHORDER.store(flags, Ordering::Relaxed); // c:3297
6519                                                            // c:3298-3300 — `sp = zhalloc(...); memcpy(sp, rp, ...)` copies the
6520                                                            // POINTER array, so each `sp` slot ALIASES the same match as the
6521                                                            // `rp` slot it came from and the flag writes below ARE writes into
6522                                                            // `rp`'s matches. A cloned index permutation reproduces that
6523                                                            // aliasing exactly. The old `rp.clone()` produced detached copies
6524                                                            // and had to hunt the original back down with `matcheq`, which
6525                                                            // always found the FIRST equal element: a run of three identical
6526                                                            // matches marked one element CMF_DELETE twice instead of two
6527                                                            // elements once, so only one of the two dupes was ever dropped.
6528                // !!! MEASURED DIVERGENCE (see `crate::tolerant_sort`): the
6529                // pair walk below kills the LATER member of each equal pair in
6530                // THIS sorted copy, and the survivor keeps its slot in `ord`,
6531                // i.e. in the displayed order. `matchcmp` returns 0 for two
6532                // copies of one match, so the C answer is the platform
6533                // `qsort(3)`'s undefined tie order while `qsort_tolerant` is
6534                // stable and always keeps the first-added copy. Measured on
6535                // `git show <TAB><TAB>`, where `_git` compadds the same 20
6536                // commits twice into `-J -default-`: zsh/Darwin lists them in
6537                // two ascending runs, zshrs in insertion order. Same set, same
6538                // descriptions. Not portably fixable — zsh's own order here is
6539                // whatever its libc's quicksort left behind.
6540                let mut sord: Vec<usize> = ord.clone();
6541                // c:3301-3302 — qsort matchcmp; tolerant sort (non-total-order cmp).
6542                crate::tolerant_sort::qsort_matches(&mut sord, src);
6543
6544                let mut del = false; // c:3303
6545                                     // c:3303-3318 — walk the sorted permutation in pairs. C's inner
6546                                     // `for (dup = 0; ...; bp = ++sp)` run-walk is NOT replicated (it
6547                                     // advances the array BASE, not `bp` — an upstream oddity); the
6548                                     // pairwise form is equivalent for CMF_MULT/CMF_FMULT because the
6549                                     // array is sorted, so every element whose predecessor shares its
6550                                     // `str` is exactly the set the run-walk marks.
6551                for k in 1..sord.len() {
6552                    let (ai, bi) = (sord[k - 1], sord[k]); // c:3304
6553                    if matcheq(&src[ai], &src[bi]) {
6554                        // c:3305
6555                        src[bi].flags = CMF_DELETE; // c:3306
6556                        del = true; // c:3307
6557                    } else if src[ai].disp.is_none() {
6558                        // c:3308
6559                        if src[bi].disp.is_none() && src[ai].str == src[bi].str {
6560                            // c:3310-3311
6561                            src[bi].flags |= CMF_MULT; // c:3312
6562                            src[ai].flags |= CMF_FMULT; // c:3316
6563                        }
6564                    }
6565                }
6566                if del {
6567                    // c:3319
6568                    ord.retain(|&i| (src[i].flags & CMF_DELETE) == 0); // c:3320-3328
6569                    n = ord.len() as i32;
6570                }
6571            } else if (flags & CGF_UNIQCON) == 0 {
6572                // c:3344 -1 not -2
6573                let mut cp = 0usize;
6574                let mut ap = 0usize;
6575                while ap < ord.len() {
6576                    // c:3334
6577                    // Scan the runs BEFORE the compaction swap: once an
6578                    // earlier removal makes `cp < ap`, `ord.swap(ap, cp)`
6579                    // overwrites slot `ap` with a stale index and every
6580                    // comparison below reads the wrong element. C copies
6581                    // (`*cp++ = *ap`), it does not swap. Same fix as the
6582                    // sorted branch above.
6583                    let mut bp = ap;
6584                    while bp + 1 < ord.len() && matcheq(&src[ord[ap]], &src[ord[bp + 1]]) {
6585                        bp += 1;
6586                        n -= 1; // c:3336
6587                    }
6588                    let run_end = bp; // c:3337 `ap = bp`
6589                                      // c:3338-3342 — mark, do NOT remove: `n` is untouched and
6590                                      // the outer loop resumes at `run_end + 1`, so CMF_MULT
6591                                      // elements stay in the array (they are dropped from the
6592                                      // LISTING via `nl` at c:3351-3352, not from `mcount`).
6593                    let mut mark = run_end;
6594                    let mut dup = 0i32;
6595                    while mark + 1 < ord.len()
6596                        && src[ord[run_end]].disp.is_none()
6597                        && src[ord[mark + 1]].disp.is_none()
6598                        && src[ord[run_end]].str == src[ord[mark + 1]].str
6599                    {
6600                        src[ord[mark + 1]].flags |= CMF_MULT; // c:3340
6601                        dup = 1; // c:3341
6602                        mark += 1;
6603                    }
6604                    if dup != 0 {
6605                        src[ord[run_end]].flags |= CMF_FMULT; // c:3344
6606                    }
6607                    if ap != cp {
6608                        ord.swap(ap, cp); // c:3335 `*cp++ = *ap`
6609                    }
6610                    cp += 1;
6611                    ap = run_end + 1;
6612                }
6613                ord.truncate(cp); // c:3346
6614            }
6615            for &i in ord.iter() {
6616                // c:3361
6617                let m = &src[i];
6618                if m.disp.is_some() && (m.flags & CMF_DISPLINE) != 0 {
6619                    // c:3362
6620                    ll += 1;
6621                }
6622                if (m.flags & (CMF_NOLIST | CMF_MULT)) != 0 {
6623                    // c:3364
6624                    nl += 1;
6625                }
6626            }
6627        }
6628    }
6629    // c:3362 — C returns `rp` itself (the compacted pointer array).
6630    // Materialise the permutation for the caller; the flag writes above have
6631    // already landed on the live matches in `src`.
6632    let arr: Vec<Cmatch> = ord.iter().map(|&i| src[i].clone()).collect();
6633    (arr, n, nl, ll) // c:3366-3373
6634}
6635
6636// =====================================================================
6637// dupmatch — `Src/Zle/compcore.c:3370`.
6638// =====================================================================
6639
6640/// Port of `static Cmatch dupmatch(Cmatch m, int nbeg, int nend)` from
6641/// compcore.c:3370. Deep-copies one match; brpl/brsl are truncated to
6642/// nbeg/nend per the C body's nbeg/nend-sized `zalloc` + element copy.
6643pub fn dupmatch(m: &Cmatch, nbeg: i32, nend: i32) -> Cmatch {
6644    // c:3370
6645    let mut r = Cmatch::default(); // c:3370-3374
6646    r.str = m.str.clone(); // c:3376 ztrdup
6647    r.orig = m.orig.clone(); // c:3377
6648    r.ipre = m.ipre.clone(); // c:3378
6649    r.ripre = m.ripre.clone(); // c:3379
6650    r.isuf = m.isuf.clone(); // c:3380
6651    r.ppre = m.ppre.clone(); // c:3381
6652    r.psuf = m.psuf.clone(); // c:3382
6653    r.prpre = m.prpre.clone(); // c:3383
6654    r.pre = m.pre.clone(); // c:3384
6655    r.suf = m.suf.clone(); // c:3385
6656    r.flags = m.flags; // c:3386
6657    if !m.brpl.is_empty() {
6658        // c:3387
6659        let take = (nbeg as usize).min(m.brpl.len()); // c:3390 zalloc(nbeg)
6660        r.brpl = m.brpl[..take].to_vec(); // c:3392 element-wise copy
6661    } else {
6662        r.brpl = Vec::new(); // c:3395 NULL
6663    }
6664    if !m.brsl.is_empty() {
6665        // c:3396
6666        let take = (nend as usize).min(m.brsl.len()); // c:3399
6667        r.brsl = m.brsl[..take].to_vec(); // c:3401
6668    } else {
6669        r.brsl = Vec::new(); // c:3404
6670    }
6671    r.rems = m.rems.clone(); // c:3405
6672    r.remf = m.remf.clone(); // c:3406
6673    r.autoq = m.autoq.clone(); // c:3407
6674    r.qipl = m.qipl; // c:3408
6675    r.qisl = m.qisl; // c:3409
6676    r.disp = m.disp.clone(); // c:3410
6677    r.mode = m.mode; // c:3411
6678    r.modec = m.modec; // c:3412
6679    r.fmode = m.fmode; // c:3413
6680    r.fmodec = m.fmodec; // c:3414
6681    r // c:3416
6682}
6683
6684/// Port of `mod_export int permmatches(int last)` from compcore.c:3422.
6685/// Promotes the per-round `amatches` accumulator into the permanent
6686/// `pmatches` snapshot via deep-copy through `dupmatch`/`makearray`.
6687pub fn permmatches(last: i32) -> i32 {
6688    // c:3423
6689    let ofi = PERMMATCHES_FI.load(Ordering::Relaxed); // c:3423 ofi = fi
6690
6691    // c:3433 — `if (pmatches && !newmatches)`
6692    let pmatches_set = pmatches
6693        .get_or_init(|| Mutex::new(Vec::new()))
6694        .lock()
6695        .map(|g| !g.is_empty())
6696        .unwrap_or(false);
6697    if pmatches_set && newmatches.load(Ordering::Relaxed) == 0 {
6698        // c:3433
6699        if last != 0 && PERMMATCHES_FI.load(Ordering::Relaxed) != 0 {
6700            // c:3434
6701            // ainfo = fainfo                                                // c:3435
6702            let famref = fainfo
6703                .get_or_init(|| Mutex::new(None))
6704                .lock()
6705                .ok()
6706                .and_then(|g| g.clone());
6707            if let Ok(mut a) = ainfo.get_or_init(|| Mutex::new(None)).lock() {
6708                *a = famref;
6709            }
6710        }
6711        return PERMMATCHES_FI.load(Ordering::Relaxed); // c:3437
6712    }
6713    newmatches.store(0, Ordering::Relaxed); // c:3439
6714    PERMMATCHES_FI.store(0, Ordering::Relaxed); // c:3439 fi = 0
6715
6716    {
6717        // pmatches = lmatches = NULL                                        // c:3441
6718        if let Ok(mut g) = pmatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
6719            g.clear();
6720        }
6721        if let Ok(mut g) = lmatches.get_or_init(|| Mutex::new(None)).lock() {
6722            *g = None;
6723        }
6724    }
6725    nmatches.store(0, Ordering::Relaxed); // c:3442
6726    smatches.store(0, Ordering::Relaxed); // c:3442
6727    diffmatches.store(0, Ordering::Relaxed); // c:3442
6728
6729    // c:3444 — `if (!ainfo->count)`.
6730    let ainfo_count = ainfo
6731        .get_or_init(|| Mutex::new(None))
6732        .lock()
6733        .ok()
6734        .and_then(|g| g.as_ref().map(|a| a.count))
6735        .unwrap_or(0);
6736    if ainfo_count == 0 {
6737        // c:3444
6738        if last != 0 {
6739            // c:3445
6740            let famref = fainfo
6741                .get_or_init(|| Mutex::new(None))
6742                .lock()
6743                .ok()
6744                .and_then(|g| g.clone());
6745            if let Ok(mut a) = ainfo.get_or_init(|| Mutex::new(None)).lock() {
6746                *a = famref;
6747            }
6748        }
6749        PERMMATCHES_FI.store(1, Ordering::Relaxed); // c:3447
6750    }
6751
6752    let nbeg = NBRBEG.load(Ordering::Relaxed);
6753    let nend = NBREND.load(Ordering::Relaxed);
6754
6755    let mut gn: i32 = 1; // c:3429 gn = 1
6756    let mut mn: i32 = 1; // c:3429 mn = 1
6757    let fi = PERMMATCHES_FI.load(Ordering::Relaxed);
6758
6759    // c:3449 `Cmgroup g = amatches` — C walks the live chain in place. The
6760    // groups are TAKEN out of `amatches` (and every one is stored back after
6761    // the loop, c:3488/3542/3544 write-back below) rather than cloned: a
6762    // clone deep-copied each group's `matches` and its `perm` copy on every
6763    // `permmatches` call, i.e. on every `$compstate[nmatches]` read that
6764    // finds new matches — `arch <TAB>` copied 2 x 47058 matches four times
6765    // for nothing. Nothing the loop calls (`makearray`, `dupmatch`) reads
6766    // `amatches`.
6767    let groups_snapshot: Vec<Cmgroup> = {
6768        amatches
6769            .get_or_init(|| Mutex::new(Vec::new()))
6770            .lock()
6771            .ok()
6772            .map(|mut g| std::mem::take(&mut *g))
6773            .unwrap_or_default()
6774    };
6775    let mut new_pmatches: Vec<Cmgroup> = Vec::with_capacity(groups_snapshot.len());
6776    // c:3488 `g->perm = n`, c:3460-3467 `g->mcount/lcount/llcount`, c:3471
6777    // `g->ccount = 0`, c:3542 `g->num = gn++` and c:3544 `g->new = 0` are all
6778    // writes to the LIVE group in `amatches`. The port mutated a detached
6779    // clone and threw it away, so `g->new` never cleared and `g->perm` was
6780    // never recorded: the c:3452 cache test (`!g->perm || g->new`) could
6781    // never take its reuse branch and every `permmatches` call rebuilt and
6782    // re-`dupmatch`ed every group from scratch. Collect the mutated groups
6783    // and store them back after the loop.
6784    let mut updated_groups: Vec<Cmgroup> = Vec::with_capacity(new_pmatches.capacity());
6785    // c:3490-3491 / c:3528-3529 — `if (!lmatches) lmatches = n;` (resp.
6786    // `g->perm`). `lmatches` is the head C keeps for `makecomplist`'s
6787    // old-list reuse (c:1006 `lmatches = lastlmatches`); the port never
6788    // assigned it, so `compstate[old_list]=keep` had no list to restore.
6789    let mut lmatches_head: Option<Cmgroup> = None;
6790
6791    for g_orig in groups_snapshot.into_iter() {
6792        // c:3449 while (g)
6793        let mut g = g_orig; // borrow-mut snapshot
6794        let must_rebuild = fi != ofi || g.perm.is_none() || g.new_.load(Ordering::Relaxed) != 0; // c:3456
6795        if must_rebuild {
6796            // c:3456
6797            // c:3455-3457 — `mlist = g->lfmatches` / `mlist = g->lmatches`.
6798            // C hands `makearray` the LIVE LinkList: the CMF_MULT/CMF_FMULT/
6799            // CMF_DELETE marks it writes go through to the matches that stay
6800            // on the group, and persist for the next `permmatches` call.
6801            // Cloning the `Vec` here detached every one of those writes.
6802            // Clone the `Arc` instead (shared storage, comp_h.rs:433) and
6803            // hand `makearray` a `&mut` into it.
6804            let src_arc = if fi != 0 {
6805                g.lfmatches.clone() // c:3455
6806            } else {
6807                g.lmatches.clone() // c:3457
6808            };
6809
6810            // The guard is scoped to the call. `makearray` locks nothing
6811            // (`matchcmp`/`matcheq` read only MATCHORDER), and these two are
6812            // the ONLY `lmatches`/`lfmatches` lock sites in the tree, so no
6813            // re-entrant lock and no deadlock; the guard is dropped before
6814            // the next loop iteration.
6815            let (arr, nn, nl, ll) = {
6816                let mut src_list = src_arc.lock().unwrap();
6817                makearray(&mut src_list, g.flags) // c:3459
6818            };
6819            g.mcount = nn; // c:3464
6820            g.lcount = nn - nl; // c:3465
6821            if g.lcount < 0 {
6822                g.lcount = 0;
6823            } // c:3466
6824            g.llcount = ll; // c:3467
6825            // c:3468 — pointer test, so an EMPTY ylist still forces
6826            // `lcount = 0` and `smatches = 2` rather than keeping the
6827            // match-derived count.
6828            if let Some(ylen) = g.ylist.as_ref().map(|y| y.len() as i32) {
6829                g.lcount = ylen; // c:3469
6830                smatches.store(2, Ordering::Relaxed); // c:3470
6831            }
6832            // c:3472 — makearray(lexpls, 0, 0, &ecount, NULL, NULL).
6833            let mut exps = g.lexpls.lock().unwrap().clone(); // type=0 path
6834            g.ecount = exps.len() as i32;
6835            // c:3475 ccount = 0
6836            g.ccount = 0; // c:3475
6837            tracing::debug!(
6838                target: "compsys_args",
6839                mcount = g.mcount,
6840                lcount = g.lcount,
6841                nn,
6842                nl,
6843                ll,
6844                name = ?g.name,
6845                "permmatches group"
6846            );
6847            nmatches.fetch_add(g.mcount, Ordering::Relaxed); // c:3477
6848            smatches.fetch_add(g.lcount, Ordering::Relaxed); // c:3478
6849            if g.mcount > 1 {
6850                // c:3480
6851                diffmatches.store(1, Ordering::Relaxed); // c:3481
6852            }
6853
6854            // n = (Cmgroup) zshcalloc(...)                                  // c:3483
6855            let mut n_grp = Cmgroup::default();
6856            // c:3487 — `if (g->perm) freematches(g->perm, 0)`. Drop on
6857            // perm Box<Cmgroup> reclaims the C `free` path.
6858            g.perm = None; // c:3490 g->perm = n
6859                           // Then below we set g.perm = Some(Box::new(n_grp.clone())).
6860
6861            n_grp.num = gn;
6862            gn += 1; // c:3499
6863            n_grp.flags = g.flags; // c:3500
6864            n_grp.mcount = g.mcount; // c:3501
6865            n_grp.matches = arr
6866                .iter() // c:3502-3505 dupmatch loop
6867                .map(|m| dupmatch(m, nbeg, nend))
6868                .collect();
6869            n_grp.name = g.name.clone(); // c:3504
6870            n_grp.lcount = g.lcount; // c:3508
6871            n_grp.llcount = g.llcount; // c:3509
6872            // c:3509-3513 — `if (g->ylist) n->ylist = zarrdup(g->ylist);
6873            // else n->ylist = NULL;` — a plain clone of the Option carries
6874            // both the NULL and the empty-but-present case unchanged.
6875            n_grp.ylist = g.ylist.clone(); // c:3511 zarrdup / c:3513 NULL
6876            if g.ecount != 0 {
6877                // c:3515
6878                // Build n->expls from g->expls deep-copying str + (fi
6879                // ? fcount : count); always carries over; fcount = 0.
6880                n_grp.expls = exps
6881                    .drain(..)
6882                    .map(|o| Cexpl {
6883                        // c:3517-3525
6884                        count: if fi != 0 { o.fcount } else { o.count }, // c:3520
6885                        always: o.always,                                // c:3521
6886                        fcount: 0,                                       // c:3522
6887                        str: o.str.clone(),                              // c:3523 ztrdup
6888                    })
6889                    .collect();
6890                n_grp.ecount = g.ecount;
6891            } else {
6892                n_grp.expls = Vec::new(); // c:3528
6893            }
6894            n_grp.widths = Vec::new(); // c:3531
6895                                       // Stitch perm chain (prev/next handled implicitly by Vec).
6896            g.matches = arr; // mirror C: g->matches = makearray result
6897            g.perm = Some(Box::new(n_grp.clone())); // c:3488 g->perm = n
6898            if lmatches_head.is_none() {
6899                lmatches_head = Some(n_grp.clone()); // c:3490-3491
6900            }
6901            new_pmatches.push(n_grp); // c:3492-3495
6902        } else {
6903            // reuse existing g->perm                                        // c:3534
6904            nmatches.fetch_add(g.mcount, Ordering::Relaxed); // c:3540
6905            smatches.fetch_add(g.lcount, Ordering::Relaxed); // c:3541
6906            if g.mcount > 1 {
6907                diffmatches.store(1, Ordering::Relaxed); // c:3543
6908            }
6909            g.num = gn;
6910            gn += 1; // c:3542
6911            if let Some(p) = g.perm.as_deref() {
6912                if lmatches_head.is_none() {
6913                    lmatches_head = Some(p.clone()); // c:3528-3529
6914                }
6915                new_pmatches.push(p.clone()); // c:3533 pmatches = g->perm
6916            }
6917        }
6918        g.new_.store(0, Ordering::Relaxed); // c:3544
6919        updated_groups.push(g);
6920    }
6921    // c:3488/3542/3544 write-back — see the note above the loop.
6922    if let Ok(mut g) = amatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
6923        *g = updated_groups;
6924    }
6925    // c:3490 — `lmatches` is the head of the permanent chain.
6926    if let Ok(mut g) = lmatches.get_or_init(|| Mutex::new(None)).lock() {
6927        *g = lmatches_head;
6928    }
6929
6930    // c:3490-3538 — C threads each group onto `pmatches` via
6931    // `n->next = pmatches; pmatches = n` (a PREPEND), so pmatches ends up
6932    // in the REVERSE of amatches iteration order. The port accumulates into
6933    // a Vec with push() (append), so reverse once here to recover C's order.
6934    // Without this, `group-order`/`group-name` groups list in creation order
6935    // reversed (e.g. `group-order veggies fruits` showed fruits first), and
6936    // the gnum/rnum numbering below — which C runs over the reversed
6937    // pmatches — was assigned against the wrong sequence.
6938    new_pmatches.reverse();
6939
6940    // c:3551-3563 — assign rnum/gnum, recompute diffmatches/nbrbeg.
6941    let mut first_first: Option<Cmatch> = None;
6942    for g_pm in new_pmatches.iter_mut() {
6943        g_pm.nbrbeg = nbeg; // c:3552
6944        g_pm.nbrend = nend; // c:3553
6945        let mut rn = 1i32; // c:3554
6946        for m in g_pm.matches.iter_mut() {
6947            m.rnum = rn;
6948            rn += 1; // c:3555
6949            m.gnum = mn;
6950            mn += 1; // c:3556
6951        }
6952        if diffmatches.load(Ordering::Relaxed) == 0 && !g_pm.matches.is_empty() {
6953            match first_first.as_ref() {
6954                // c:3558
6955                Some(p0) => {
6956                    if !matcheq(&g_pm.matches[0], p0) {
6957                        diffmatches.store(1, Ordering::Relaxed); // c:3560
6958                    }
6959                }
6960                None => first_first = Some(g_pm.matches[0].clone()), // c:3562
6961            }
6962        }
6963    }
6964
6965    if let Ok(mut g) = pmatches.get_or_init(|| Mutex::new(Vec::new())).lock() {
6966        *g = new_pmatches;
6967    }
6968
6969    hasperm.store(1, Ordering::Relaxed); // c:3565
6970    permmnum.store(mn - 1, Ordering::Relaxed); // c:3566
6971    permgnum.store(gn - 1, Ordering::Relaxed); // c:3567
6972    if let Ok(mut ld) = listdat
6973        .get_or_init(|| Mutex::new(Default::default()))
6974        .lock()
6975    {
6976        ld.valid = 0; // c:3568
6977    }
6978
6979    fi // c:3570
6980}
6981
6982// =====================================================================
6983// freematch / freematches — `Src/Zle/compcore.c:3575 / 3605`.
6984// =====================================================================
6985
6986/// Port of `static void freematch(Cmatch m, int nbeg, int nend)` from
6987/// `Src/Zle/compcore.c:3575`. C frees each Cmatch field via `zsfree`
6988/// (str/orig/ipre/ripre/isuf/ppre/psuf/pre/suf/prpre/rems/remf/disp/
6989/// autoq) and `zfree(m->brpl, nbeg * sizeof(int))` /
6990/// `zfree(m->brsl, nend * sizeof(int))` — all collapse to Rust's
6991/// automatic Drop on the owned String / `Vec<i32>` fields. nbeg/nend
6992/// kept on the signature for C parity (consumed by C as `zfree` size
6993/// args; Rust Vec carries its own length).
6994pub fn freematch(m: Cmatch, _nbeg: i32, _nend: i32) {
6995    // c:3577 — `if (!m) return;` — Rust's owned `m` is always valid;
6996    // dropping it on return runs every field's destructor (c:3579-3596
6997    // zsfree / zfree calls collapsed).
6998    drop(m); // c:3598 zfree(m)
6999}
7000
7001/// Direct port of `mod_export void freematches(Cmgroup g, int cm)` from
7002/// `Src/Zle/compcore.c:3605`. The C path walks the cmgroup chain freeing
7003/// each Cmatch + ylist + expls + widths + name; in Rust those are
7004/// owned by `Vec`/`Box`/`String` so Drop covers the per-node free.
7005/// The `cm` arm at c:3636-3637 (`minfo.cur = NULL`) is the only
7006/// side-effect that doesn't fall out of Rust's ownership model — wire
7007/// it explicitly.
7008pub fn freematches(g: Vec<Cmgroup>, cm: i32) {
7009    // c:3605
7010    drop(g);
7011    if cm != 0 {
7012        // c:3636
7013        if let Ok(mut g) = MINFO.get_or_init(|| Mutex::new(Menuinfo::default())).lock() {
7014            g.cur = None; // c:3637
7015        }
7016    }
7017}
7018
7019// =====================================================================
7020// Extern globals — declared in other C files, mirrored here per
7021// PORT.md Rule 9 ("stub the EXTERN dependencies ... locally with
7022// file:line citations to their home file") so the local body ports
7023// below have a value source. When the canonical Rust homes land,
7024// these become `pub use crate::ported::<canonical>::*` re-exports.
7025// =====================================================================
7026
7027/// Port of `mod_export int lastend` from `Src/Zle/compcore.c:276`.
7028/// Byte position in the metafied line where the most-recent
7029/// completion insertion ended.
7030///
7031/// Re-export alias of [`lastend`] — C has ONE `lastend` (compcore.c:276).
7032/// Two atomics existed: `compresult` wrote the lowercase `lastend`, while
7033/// `complist`'s menu-list positioning read this uppercase `LASTEND`, which was
7034/// never stored (always 0) — so menu-selection column math used a stale zero.
7035pub use self::lastend as LASTEND;
7036
7037/// Port of `mod_export int wb` from `Src/lex.c:120`. Word-begin
7038/// position in the metafied line for the currently-completing word.
7039pub static WB: AtomicI32 = AtomicI32::new(0); // lex.c:120
7040/// Port of `mod_export int we` from `Src/lex.c:120`. Word-end position.
7041pub static WE: AtomicI32 = AtomicI32::new(0); // lex.c:120
7042/// Port of `mod_export int zlemetacs` from `Src/lex.c:104`. Cursor
7043/// position in the metafied line.
7044pub static ZLEMETACS: AtomicI32 = AtomicI32::new(0); // lex.c:104
7045/// Port of `mod_export int zlemetall` from `Src/lex.c:104`. Length
7046/// of the metafied line.
7047pub static ZLEMETALL: AtomicI32 = AtomicI32::new(0); // lex.c:104
7048/// Port of `mod_export int addedx` from `Src/lex.c:115`. Non-zero
7049/// while a dummy `x` cursor marker is in the line being lexed
7050/// (so completion can capture the partial word at the cursor).
7051pub static ADDEDX: AtomicI32 = AtomicI32::new(0); // lex.c:115
7052
7053/// Port of `mod_export char *zlemetaline` from `Src/lex.c:103`. The
7054/// metafied edit buffer for the current ZLE session — `foredel`,
7055/// `inststr`, `selfinsert` operate on this directly when compcore's
7056/// error-recovery path fires (compcore.c:344-355).
7057pub static ZLEMETALINE: OnceLock<Mutex<String>> = OnceLock::new(); // lex.c:103
7058/// Port of `mod_export ZLE_STRING_T zleline` from `Src/zle_main.c`.
7059pub static ZLELINE: OnceLock<Mutex<String>> = OnceLock::new(); // zle_main.c
7060/// Port of `mod_export int zlecs` from `Src/zle_main.c`.
7061pub static ZLECS: AtomicI32 = AtomicI32::new(0); // zle_main.c
7062/// Port of `mod_export int zlell` from `Src/zle_main.c`.
7063pub static ZLELL: AtomicI32 = AtomicI32::new(0); // zle_main.c
7064/// Port of `mod_export int inwhat` from `Src/lex.c:110`. Lex context
7065/// classification — IN_NOTHING / IN_CMD / IN_COND / IN_MATH / IN_PAR /
7066/// IN_ENV.
7067pub static INWHAT: AtomicI32 = AtomicI32::new(0); // lex.c:110
7068/// Port of `mod_export int zmult` from `Src/zle_main.c`. Numeric
7069/// prefix multiplier for the current ZLE command.
7070pub static ZMULT: AtomicI32 = AtomicI32::new(1); // zle_main.c
7071/// Port of `mod_export char *compfunc` from `Src/Zle/zle_tricky.c:143`.
7072/// Name of the user completion shell function — non-empty when the
7073/// new completion system (`compsys`) is active; empty for compctl.
7074pub static compfunc: OnceLock<Mutex<Option<String>>> = OnceLock::new(); // zle_tricky.c:143
7075/// Port of `mod_export char *comppatmatch` from `Src/Zle/zle_tricky.c`.
7076/// `$compstate[pattern_match]` — when non-empty + non-"\0" enables
7077/// pattern-aware matching for parameter-name completion.
7078pub static comppatmatch: OnceLock<Mutex<Option<String>>> = OnceLock::new();
7079// `compqstack` (C: complete.c `mod_export char *compqstack`) is deduped
7080// to the single canonical `complete::COMPQSTACK`, imported at the top of
7081// this module. The former compcore-local `compqstack` static was an
7082// orphan: the c:305 reset wrote it while `multiquote` (c:1065) read the
7083// imported COMPQSTACK, so the reset never reached its reader — a real
7084// bug now fixed by pointing both at COMPQSTACK.
7085// =====================================================================
7086// File-scope globals — `Src/Zle/compcore.c:36-279`.
7087// =====================================================================
7088
7089/// Port of `int useexact` from compcore.c:36.
7090pub static useexact: AtomicI32 = AtomicI32::new(0); // c:36
7091/// Port of `int useline` from compcore.c:36.
7092pub static useline: AtomicI32 = AtomicI32::new(0); // c:36
7093/// Port of `int uselist` from compcore.c:36.
7094pub static uselist: AtomicI32 = AtomicI32::new(0); // c:36
7095/// Port of `int forcelist` from compcore.c:36.
7096pub static forcelist: AtomicI32 = AtomicI32::new(0); // c:36
7097/// Port of `int startauto` from compcore.c:36.
7098pub static startauto: AtomicI32 = AtomicI32::new(0); // c:36
7099
7100/// Port of `mod_export int iforcemenu` from compcore.c:39.
7101pub static iforcemenu: AtomicI32 = AtomicI32::new(0); // c:39
7102
7103/// Port of `mod_export int dolastprompt` from compcore.c:44.
7104pub static dolastprompt: AtomicI32 = AtomicI32::new(0); // c:44
7105
7106/// Port of `mod_export int oldlist` from compcore.c:49.
7107pub static oldlist: AtomicI32 = AtomicI32::new(0); // c:49
7108/// Port of `mod_export int oldins` from compcore.c:49.
7109pub static oldins: AtomicI32 = AtomicI32::new(0); // c:49
7110
7111/// Port of `int origlpre` from compcore.c:54.
7112pub static origlpre: AtomicI32 = AtomicI32::new(0); // c:54
7113/// Port of `int origlsuf` from compcore.c:54.
7114pub static origlsuf: AtomicI32 = AtomicI32::new(0); // c:54
7115/// Port of `int lenchanged` from compcore.c:54.
7116pub static lenchanged: AtomicI32 = AtomicI32::new(0); // c:54
7117
7118/// Port of `int movetoend` from compcore.c:61.
7119pub static movetoend: AtomicI32 = AtomicI32::new(0); // c:61
7120
7121/// Port of `mod_export int insmnum` from compcore.c:66.
7122pub static insmnum: AtomicI32 = AtomicI32::new(0); // c:66
7123/// Port of `mod_export int insspace` from compcore.c:66.
7124pub static insspace: AtomicI32 = AtomicI32::new(0); // c:66
7125
7126/// Port of `mod_export int menuacc` from compcore.c:81.
7127pub static menuacc: AtomicI32 = AtomicI32::new(0); // c:81
7128
7129/// Port of `int hasunqu` from compcore.c:86.
7130pub static hasunqu: AtomicI32 = AtomicI32::new(0); // c:86
7131/// Port of `int useqbr` from compcore.c:86.
7132pub static useqbr: AtomicI32 = AtomicI32::new(0); // c:86
7133/// Port of `int brpcs` from compcore.c:86.
7134pub static brpcs: AtomicI32 = AtomicI32::new(0); // c:86
7135/// Port of `int brscs` from compcore.c:86.
7136pub static brscs: AtomicI32 = AtomicI32::new(0); // c:86
7137
7138/// Port of `mod_export int ispar` from compcore.c:91.
7139pub static ispar: AtomicI32 = AtomicI32::new(0); // c:91
7140/// Port of `mod_export int linwhat` from compcore.c:91.
7141pub static linwhat: AtomicI32 = AtomicI32::new(0); // c:91
7142
7143/// Port of `char *parpre` from compcore.c:96.
7144pub static parpre: OnceLock<Mutex<String>> = OnceLock::new(); // c:96
7145
7146/// Port of `int parflags` from compcore.c:101.
7147pub static parflags: AtomicI32 = AtomicI32::new(0); // c:101
7148
7149/// Port of `mod_export int mflags` from compcore.c:106.
7150pub static mflags: AtomicI32 = AtomicI32::new(0); // c:106
7151
7152/// Port of `int parq` from compcore.c:111.
7153pub static parq: AtomicI32 = AtomicI32::new(0); // c:111
7154/// Port of `int eparq` from compcore.c:111.
7155pub static eparq: AtomicI32 = AtomicI32::new(0); // c:111
7156
7157/// Port of `mod_export char *ipre` from compcore.c:118.
7158pub static ipre: OnceLock<Mutex<String>> = OnceLock::new(); // c:118
7159/// Port of `mod_export char *ripre` from compcore.c:118.
7160pub static ripre: OnceLock<Mutex<String>> = OnceLock::new(); // c:118
7161/// Port of `mod_export char *isuf` from compcore.c:118.
7162pub static isuf: OnceLock<Mutex<String>> = OnceLock::new(); // c:118
7163
7164/// Port of `mod_export LinkList matches` from compcore.c:124.
7165pub static matches: OnceLock<Mutex<std::sync::Arc<Mutex<Vec<Cmatch>>>>> = OnceLock::new(); // c:124 (Arc handle — see comp_match_handles)
7166/// Port of `LinkList fmatches` from compcore.c:126.
7167pub static fmatches: OnceLock<Mutex<std::sync::Arc<Mutex<Vec<Cmatch>>>>> = OnceLock::new(); // c:126 (Arc handle)
7168
7169/// Port of `mod_export Cmgroup amatches` from compcore.c:135.
7170pub static amatches: OnceLock<Mutex<Vec<Cmgroup>>> = OnceLock::new(); // c:135
7171/// Port of `mod_export Cmgroup pmatches` from compcore.c:135.
7172pub static pmatches: OnceLock<Mutex<Vec<Cmgroup>>> = OnceLock::new(); // c:135
7173/// Port of `mod_export Cmgroup lastmatches` from compcore.c:135.
7174pub static lastmatches: OnceLock<Mutex<Vec<Cmgroup>>> = OnceLock::new(); // c:135
7175/// Port of `mod_export Cmgroup lmatches` from compcore.c:135. Last
7176/// element pointer in the perm list; here a single-slot holder.
7177pub static lmatches: OnceLock<Mutex<Option<Cmgroup>>> = OnceLock::new(); // c:135
7178/// Port of `mod_export Cmgroup lastlmatches` from compcore.c:135.
7179pub static lastlmatches: OnceLock<Mutex<Option<Cmgroup>>> = OnceLock::new(); // c:135
7180
7181/// Port of `mod_export int hasoldlist` from compcore.c:140.
7182pub static hasoldlist: AtomicI32 = AtomicI32::new(0); // c:140
7183/// Port of `mod_export int hasperm` from compcore.c:140.
7184pub static hasperm: AtomicI32 = AtomicI32::new(0); // c:140
7185/// Port of `int hasallmatch` from compcore.c:145.
7186pub static hasallmatch: AtomicI32 = AtomicI32::new(0); // c:145
7187
7188/// Port of `mod_export int newmatches` from compcore.c:150.
7189pub static newmatches: AtomicI32 = AtomicI32::new(0); // c:150
7190
7191/// Port of `mod_export int permmnum` from compcore.c:155.
7192pub static permmnum: AtomicI32 = AtomicI32::new(0); // c:155
7193/// Port of `mod_export int permgnum` from compcore.c:155.
7194pub static permgnum: AtomicI32 = AtomicI32::new(0); // c:155
7195/// Port of `mod_export int lastpermmnum` from compcore.c:155.
7196pub static lastpermmnum: AtomicI32 = AtomicI32::new(0); // c:155
7197/// Port of `mod_export int lastpermgnum` from compcore.c:155.
7198pub static lastpermgnum: AtomicI32 = AtomicI32::new(0); // c:155
7199
7200/// Port of `mod_export int nmatches` from compcore.c:160.
7201pub static nmatches: AtomicI32 = AtomicI32::new(0); // c:160
7202/// Port of `mod_export int smatches` from compcore.c:162.
7203pub static smatches: AtomicI32 = AtomicI32::new(0); // c:162
7204
7205/// Port of `mod_export int diffmatches` from compcore.c:167.
7206pub static diffmatches: AtomicI32 = AtomicI32::new(0); // c:167
7207
7208/// Port of `mod_export int nmessages` from compcore.c:172.
7209pub static nmessages: AtomicI32 = AtomicI32::new(0); // c:172
7210
7211/// Port of `mod_export int onlyexpl` from compcore.c:177.
7212pub static onlyexpl: AtomicI32 = AtomicI32::new(0); // c:177
7213
7214/// Port of `mod_export struct cldata listdat` from compcore.c:182.
7215pub static listdat: OnceLock<Mutex<crate::ported::zle::comp_h::Cldata>> = OnceLock::new(); // c:182
7216
7217/// Port of `mod_export int ispattern` from compcore.c:187.
7218pub static ispattern: AtomicI32 = AtomicI32::new(0); // c:187
7219/// Port of `mod_export int haspattern` from compcore.c:187.
7220pub static haspattern: AtomicI32 = AtomicI32::new(0); // c:187
7221
7222/// Port of `mod_export int hasmatched` from compcore.c:192.
7223pub static hasmatched: AtomicI32 = AtomicI32::new(0); // c:192
7224/// Port of `mod_export int hasunmatched` from compcore.c:192.
7225pub static hasunmatched: AtomicI32 = AtomicI32::new(0); // c:192
7226
7227/// Port of `Cmgroup mgroup` from compcore.c:197.
7228pub static mgroup: OnceLock<Mutex<Option<Cmgroup>>> = OnceLock::new(); // c:197
7229
7230/// Port of `mod_export int mnum` from compcore.c:202.
7231pub static mnum: AtomicI32 = AtomicI32::new(0); // c:202
7232
7233/// Port of `mod_export int unambig_mnum` from compcore.c:207.
7234pub static unambig_mnum: AtomicI32 = AtomicI32::new(0); // c:207
7235
7236/// Port of `int maxmlen` from compcore.c:212.
7237pub static maxmlen: AtomicI32 = AtomicI32::new(0); // c:212
7238/// Port of `int minmlen` from compcore.c:212.
7239pub static minmlen: AtomicI32 = AtomicI32::new(0); // c:212
7240
7241/// Port of `LinkList expls` from compcore.c:218.
7242pub static expls: OnceLock<Mutex<std::sync::Arc<Mutex<Vec<Cexpl>>>>> = OnceLock::new(); // c:218 (Arc handle)
7243
7244/// Port of `mod_export Cexpl curexpl` from compcore.c:221.
7245pub static curexpl: OnceLock<Mutex<Option<Cexpl>>> = OnceLock::new(); // c:221
7246
7247/// Port of `LinkList matchers` from compcore.c:236. The C list holds
7248/// `Cmatcher` pointers (the parsed match-spec chains pushed by
7249/// addmatches's `add_bmatchers` block). Rust port mirrors that with
7250/// owned `Box<Cmatcher>` entries.
7251pub static matchers: OnceLock<Mutex<Vec<Box<crate::ported::zle::comp_h::Cmatcher>>>> =
7252    OnceLock::new(); // c:236
7253
7254/// Port of `mod_export Aminfo ainfo` from compcore.c:246.
7255pub static ainfo: OnceLock<Mutex<Option<Aminfo>>> = OnceLock::new(); // c:246
7256/// Port of `mod_export Aminfo fainfo` from compcore.c:246.
7257pub static fainfo: OnceLock<Mutex<Option<Aminfo>>> = OnceLock::new(); // c:246
7258
7259/// Port of `mod_export LinkList allccs` from compcore.c:259.
7260pub static allccs: OnceLock<Mutex<std::sync::Arc<Mutex<Vec<String>>>>> = OnceLock::new(); // c:259 (Arc handle)
7261
7262/// Port of `int fromcomp` from compcore.c:271.
7263pub static fromcomp: AtomicI32 = AtomicI32::new(0); // c:271
7264
7265/// Port of `mod_export int lastend` from compcore.c:276.
7266pub static lastend: AtomicI32 = AtomicI32::new(0); // c:276
7267
7268/// Port of `mod_export Brinfo brbeg` from `Src/Zle/zle_tricky.c`.
7269/// Linked list of opening-brace positions in the word being completed.
7270pub static BRBEG: OnceLock<Mutex<Option<Box<Brinfo>>>> = OnceLock::new(); // zle_tricky.c brbeg
7271
7272/// Port of `mod_export Brinfo brend` from `Src/Zle/zle_tricky.c`.
7273/// Linked list of closing-brace positions in the word being completed.
7274pub static BREND: OnceLock<Mutex<Option<Box<Brinfo>>>> = OnceLock::new(); // zle_tricky.c brend
7275
7276/// Port of `static int oldmenucmp` from compcore.c:457.
7277pub static OLDMENUCMP: AtomicI32 = AtomicI32::new(0); // c:457
7278
7279/// Port of `static int parwb` from compcore.c:540.
7280pub static PARWB: AtomicI32 = AtomicI32::new(0); // c:540
7281/// Port of `static int parwe` from compcore.c:540.
7282pub static PARWE: AtomicI32 = AtomicI32::new(0); // c:540
7283/// Port of `static int paroffs` from compcore.c:540.
7284pub static PAROFFS: AtomicI32 = AtomicI32::new(0); // c:540
7285
7286/// Port of `static int matchorder` from compcore.c:3169.
7287pub static MATCHORDER: AtomicI32 = AtomicI32::new(0); // c:3169
7288
7289/// Port of `mod_export int lastchar` from `Src/Zle/zle_main.c`. Last
7290/// keyboard char consumed by the binding loop — read by `selfinsert`.
7291pub static LASTCHAR: AtomicI32 = AtomicI32::new(0); // zle_main.c
7292                                                    // minfo_clear_cur / minfo_asked_zero deleted — Rust-only 2-line
7293                                                    // wrappers around C's inline writes `minfo.cur = NULL` and
7294                                                    // `minfo.asked = 0`. All call sites inlined.
7295
7296/// Direct port of `struct menuinfo minfo` — `Src/Zle/zle_tricky.c`
7297/// (the single file-scope instance). The struct type itself lives
7298/// in `comp_h.rs::Menuinfo` (port of comp.h:284-295).
7299pub static MINFO: OnceLock<Mutex<Menuinfo>> = OnceLock::new(); // zle_tricky.c minfo
7300
7301// =====================================================================
7302// matcheq — `Src/Zle/compcore.c:3203-3215`.
7303// =====================================================================
7304
7305#[inline]
7306fn matchstreq(a: Option<&String>, b: Option<&String>) -> bool {
7307    // c:3207
7308    match (a, b) {
7309        (None, None) => true,
7310        (Some(x), Some(y)) => x == y,
7311        _ => false,
7312    }
7313}
7314
7315/// First-match shortcut path from compcore.c:398-411. `Cmgroup m = amatches;
7316/// while (!m->mcount) m = m->next; do_single(m->matches[0])`.
7317fn do_single_first_match() {
7318    // c:398
7319    let groups = amatches
7320        .get_or_init(|| Mutex::new(Vec::new()))
7321        .lock()
7322        .ok()
7323        .map(|g| g.clone())
7324        .unwrap_or_default();
7325    let first = groups
7326        .into_iter()
7327        .find(|g| g.mcount > 0)
7328        .and_then(|g| g.matches.first().cloned());
7329    if let Some(m) = first {
7330        // c:407 — `minfo.cur = NULL`.
7331        if let Ok(mut g) = MINFO.get_or_init(|| Mutex::new(Menuinfo::default())).lock() {
7332            g.cur = None;
7333            g.asked = 0; // c:408
7334        }
7335        // c:409 — `do_single(m->matches[0])`. This is the actual
7336        // single-match insert: it deletes the word between wb/we (incl.
7337        // the addx placeholder) and inserts the completed string with
7338        // its suffix. Previously this call was omitted (the match was
7339        // merely stashed on minfo.cur), so a unique completion left the
7340        // typed prefix + 'x' on the line instead of the completed word.
7341        crate::ported::zle::compresult::do_single(&m);
7342    }
7343}
7344
7345/// compcore.c:444 `compend:` epilogue — free matchers, snap zlemetacs.
7346fn goto_compend(ret: i32) -> i32 {
7347    // c:444
7348    if let Ok(mut g) = matchers.get_or_init(|| Mutex::new(Vec::new())).lock() {
7349        g.clear(); // c:445-446 freecmatcher loop
7350    }
7351    let line_len = ZLEMETALL.load(Ordering::Relaxed); // c:448 strlen(zlemetaline)
7352    if ZLEMETACS.load(Ordering::Relaxed) > line_len {
7353        // c:449
7354        ZLEMETACS.store(line_len, Ordering::Relaxed); // c:450
7355    }
7356    ret // c:453
7357}
7358
7359// `COMP_LIST_COMPLETE` / `QT_NONE_STUB` / `QT_BACKSLASH_STUB` local
7360// aliases deleted — call sites now reach the real C-side constants
7361// directly (`crate::ported::zle::zle_h::COMP_LIST_COMPLETE`,
7362// `QT_NONE`, `QT_BACKSLASH`).
7363// The local `COMP_LIST_COMPLETE = 2` was a value-mismatch bug (the
7364// real constant is 1 per `Src/Zle/zle.h:357`).
7365
7366// `char_from_qt` deleted — Rust-only 1-line `(qt as u8) as char`
7367// helper. Inlined at the two call sites in get_compstate_str.
7368
7369// `showinglist_stub` / `showinglist_set` / `clearlist_set` /
7370// `listshown_stub` / `instring_stub` deleted — Rust-only 1-line
7371// accessors for C globals (SHOWINGLIST / CLEARLIST / LISTSHOWN /
7372// INSTRING). C reads/writes the bare globals inline; callers in
7373// compcore.rs now do `<GLOBAL>.load(Ordering::Relaxed)` /
7374// `<GLOBAL>.store(v, Ordering::Relaxed)` directly.
7375// `fn foredel` / `fn inststr` locals deleted — both duplicated
7376// canonical ports living in their proper home files:
7377//   - foredel: `Src/Zle/zle_utils.c:1105`
7378//     → `foredel`
7379//   - inststr: macro `Src/Zle/zle_tricky.c:57` (inststr(X) →
7380//     inststrlen(X,1,-1)) → `inststr`
7381// The duplicates here narrowed C signatures (foredel dropped `flags`,
7382// inststr dropped the i32 return + duplicated inststrlen's body) and
7383// violated Rule C (every decl in its mirroring C file). Callers in
7384// this module now route through the canonical ported.
7385/// `IN_NOTHING_LW` constant.
7386// These MUST match the raw IN_* enum in zsh_h (Src/zsh.h:2322-2332), which is
7387// the single encoding C uses for both `inwhat` and `linwhat`. `linwhat` is
7388// copied verbatim from `INWHAT` (makecomplist c:960), so a divergent ordering
7389// here made makecomplistglobal's `linwhat == IN_ENV_LW` never match after an
7390// assignment (`x=<Tab>`): INWHAT held IN_ENV=4 while IN_ENV_LW was 5, so
7391// value/assignment completion silently did nothing.
7392pub const IN_NOTHING_LW: i32 = 0; // = IN_NOTHING (zsh.h:2322)
7393/// `IN_CMD_LW` constant.
7394pub const IN_CMD_LW: i32 = 1; // = IN_CMD (zsh.h:2324)
7395/// `IN_MATH_LW` constant.
7396pub const IN_MATH_LW: i32 = 2; // = IN_MATH (zsh.h:2326)
7397/// `IN_COND_LW` constant.
7398pub const IN_COND_LW: i32 = 3; // = IN_COND (zsh.h:2328)
7399/// `IN_ENV_LW` constant.
7400pub const IN_ENV_LW: i32 = 4; // = IN_ENV (zsh.h:2330)
7401/// `IN_PAR_LW` constant.
7402pub const IN_PAR_LW: i32 = 5; // = IN_PAR (zsh.h:2332)
7403                              // `origline_stub` / `origcs_stub` deleted — Rust-only 1-line
7404                              // accessors for the `ORIGLINE` / `ORIGCS` globals (ports of C
7405                              // `origline` / `origcs` at zle_tricky.c:75 etc.). C reads these
7406                              // globals inline; callers in compcore.rs now do the lock/load
7407                              // directly.
7408/// Port of `void unmetafy_line(void)` from `zle_tricky.c:995`.
7409///
7410/// C body:
7411///   `zlemetaline[zlemetall] = '\0';
7412///    zleline = stringaszleline(zlemetaline, zlemetacs, &zlell,
7413///                              &linesz, &zlecs);
7414///    free(zlemetaline); zlemetaline = NULL;
7415///    CCRIGHT();`
7416///
7417/// Reads ZLEMETALINE, decodes via the canonical stringaszleline
7418/// (handles incs adjustment + unmetafy + UTF-8 decode), populates
7419/// ZLELINE / ZLELL / ZLECS, clears ZLEMETALINE/ZLEMETALL.
7420pub fn unmetafy_line() {
7421    // zle_tricky.c:995
7422    let meta = ZLEMETALINE
7423        .get_or_init(|| Mutex::new(String::new()))
7424        .lock()
7425        .map(|g| g.clone())
7426        .unwrap_or_default();
7427    let zlemetacs = ZLEMETACS.load(Ordering::Relaxed) as i32;
7428    let mut out_ll: i32 = 0;
7429    let mut out_cs: i32 = 0;
7430    // c:998 — `zleline = stringaszleline(zlemetaline, zlemetacs, &zlell, &linesz, &zlecs);`
7431    let line = crate::ported::zle::zle_utils::stringaszleline(
7432        &meta,
7433        zlemetacs,
7434        Some(&mut out_ll),
7435        None,
7436        Some(&mut out_cs),
7437    );
7438    if let Ok(mut g) = ZLELINE.get_or_init(|| Mutex::new(String::new())).lock() {
7439        *g = line.iter().collect();
7440    }
7441    ZLELL.store(out_ll, Ordering::Relaxed);
7442    ZLECS.store(out_cs, Ordering::Relaxed);
7443    // c:1001-1002 — `free(zlemetaline); zlemetaline = NULL;`. Rust:
7444    // clear the buffer + zero the length to mark meta-mode inactive.
7445    if let Some(m) = ZLEMETALINE.get() {
7446        if let Ok(mut g) = m.lock() {
7447            g.clear();
7448        }
7449    }
7450    ZLEMETALL.store(0, Ordering::Relaxed);
7451    // c:1007 — CCRIGHT(): combining-char alignment fixup. No-op in
7452    // this Rust port (handled by stringaszleline's codepoint walk).
7453}
7454
7455/// Port of `void metafy_line(void)` from `zle_tricky.c:978`.
7456///
7457/// C body:
7458///   `zlemetaline = zlelineasstring(zleline, zlell, zlecs,
7459///                                  &zlemetall, &zlemetacs, 0);
7460///    metalinesz = zlemetall;
7461///    free(zleline); zleline = NULL;`
7462///
7463/// Reads ZLELINE, encodes via the canonical zlelineasstring (handles
7464/// wcrtomb + metafy expansion), populates ZLEMETALINE / ZLEMETALL /
7465/// ZLEMETACS, clears ZLELINE/ZLELL.
7466pub fn metafy_line() {
7467    // zle_tricky.c:978
7468    let raw_vec: Vec<char> = ZLELINE
7469        .get_or_init(|| Mutex::new(String::new()))
7470        .lock()
7471        .map(|g| g.chars().collect())
7472        .unwrap_or_default();
7473    let zlell = raw_vec.len();
7474    let zlecs = ZLECS.load(Ordering::Relaxed);
7475    let mut out_ll: i32 = 0;
7476    let mut out_cs: i32 = 0;
7477    // c:982 — `zlemetaline = zlelineasstring(zleline, zlell, zlecs, &zlemetall, &zlemetacs, 0);`
7478    let meta = crate::ported::zle::zle_utils::zlelineasstring(
7479        &raw_vec,
7480        zlell,
7481        zlecs,
7482        Some(&mut out_ll),
7483        Some(&mut out_cs),
7484        0,
7485    );
7486    if let Ok(mut g) = ZLEMETALINE.get_or_init(|| Mutex::new(String::new())).lock() {
7487        *g = meta;
7488    }
7489    ZLEMETALL.store(out_ll, Ordering::Relaxed);
7490    ZLEMETACS.store(out_cs, Ordering::Relaxed);
7491    // c:985 — `metalinesz = zlemetall;`. Rust String grows on demand;
7492    // no separate sizeline tracker.
7493    // c:989-990 — `free(zleline); zleline = NULL;`. Rust: clear the
7494    // buffer + zero ZLELL.
7495    if let Ok(mut g) = ZLELINE.get().unwrap().lock() {
7496        g.clear();
7497    }
7498    ZLELL.store(0, Ordering::Relaxed);
7499}
7500
7501fn opt_isset(name: &str) -> i32 {
7502    // options.c
7503    if crate::ported::options::opt_state_get(name).unwrap_or(false) {
7504        1
7505    } else {
7506        0
7507    }
7508}
7509/// Real call into `getiparam(name)` — the canonical paramtab read.
7510/// Mirrors C's `getiparam` at params.c:3044 which reads the global
7511/// `paramtab` directly via `gethashnode2`.
7512fn env_iparam(name: &str) -> i32 {
7513    // params.c:3044
7514    crate::ported::params::getiparam(name) as i32
7515}
7516fn lastprebr_set(s: Option<&str>) {
7517    // zle_tricky.c lastprebr
7518    if let Ok(mut g) = LASTPREBR.get_or_init(|| Mutex::new(None)).lock() {
7519        *g = s.map(str::to_string);
7520    }
7521}
7522fn lastpostbr_set(s: Option<&str>) {
7523    // zle_tricky.c lastpostbr
7524    if let Ok(mut g) = LASTPOSTBR.get_or_init(|| Mutex::new(None)).lock() {
7525        *g = s.map(str::to_string);
7526    }
7527}
7528
7529/// Choose `$compstate[context]` per the lex classification in `inwhat`
7530/// (and the `ispar` modifier). Direct lift of compcore.c:578-633.
7531///
7532/// The arm ORDER is load-bearing and follows C exactly: `ispar`,
7533/// `IN_PAR`, `IN_MATH`, **`lincmd`**, **`linredir`**, then the
7534/// `switch (linwhat)` for IN_ENV / IN_COND / default. An earlier
7535/// revision tested IN_COND/IN_ENV before `lincmd`, which is the
7536/// opposite of C — harmless in practice only because `get_comp_string`
7537/// forces `lincmd = 0` inside a `[[ … ]]` (c:1198-1202, the `!incond`
7538/// terms) and `docomplete` zeroes it for IN_ENV (zle_tricky.c:701-702).
7539fn compcontext_for(_s: &str) -> String {
7540    use crate::ported::zle::zle_tricky::{CMDSTR, LINARR, LINCMD, LINREDIR};
7541    let ip = ispar.load(Ordering::Relaxed); // c:578
7542    if ip != 0 {
7543        // c:579
7544        return if ip == 2 {
7545            "brace_parameter".into()
7546        } else {
7547            "parameter".into()
7548        };
7549    }
7550    let lw = linwhat.load(Ordering::Relaxed);
7551    let insubscr = crate::ported::zle::zle_tricky::INSUBSCR.load(Ordering::Relaxed); // c:583
7552    if lw == IN_PAR_LW {
7553        return "assign_parameter".into(); // c:580-581
7554    }
7555    if lw == IN_MATH_LW {
7556        // c:582-591 — inside an unclosed `[` the math context is a
7557        // SUBSCRIPT, and `$compstate[parameter]` names the array
7558        // (published by callcompfunc from `varname`). Without this
7559        // `echo $fpath[<TAB>` never reached `_subscript`.
7560        return if insubscr != 0 {
7561            "subscript".into() // c:584
7562        } else {
7563            "math".into() // c:590
7564        };
7565    }
7566    if LINCMD.load(Ordering::Relaxed) != 0 {
7567        // c:592-597 — `[` in COMMAND position is a subscript too.
7568        return if insubscr != 0 {
7569            "subscript".into() // c:594
7570        } else {
7571            "command".into() // c:597
7572        };
7573    }
7574    if LINREDIR.load(Ordering::Relaxed) != 0 {
7575        // c:598-602 — the cursor word is a redirection TARGET
7576        // (`echo x > /tm<TAB>`). `callcompfunc` publishes `rdstr` as
7577        // `$compstate[redirect]` right after this returns, which is what
7578        // `_redirect` (Completion/Zsh/Context/_redirect) dispatches on.
7579        return "redirect".into(); // c:599
7580    }
7581    match lw {
7582        // c:604
7583        // c:605-606 — an array assignment (`x=(a b <TAB>)`) is
7584        // `array_value`, a scalar one (`x=<TAB>`) plain `value`.
7585        x if x == IN_ENV_LW => if LINARR.load(Ordering::Relaxed) != 0 {
7586            "array_value"
7587        } else {
7588            "value"
7589        }
7590        .into(),
7591        x if x == IN_COND_LW => "condition".into(), // c:619-620
7592        // c:622-630 — no command word was parsed at all, so this is the
7593        // value of something rather than an argument to a command.
7594        _ => {
7595            let have_cmdstr = CMDSTR.lock().map(|g| g.is_some()).unwrap_or(false); // c:623
7596            if have_cmdstr {
7597                "command".into() // c:624
7598            } else {
7599                "value".into() // c:626
7600            }
7601        }
7602    }
7603}
7604
7605/// File-scope `int offs` from `Src/Zle/zle_tricky.c:88`. The C source
7606/// declares this as `mod_export`; mirrored here per Rule 9 since it's
7607/// not yet at a canonical Rust home.
7608pub static OFFS: AtomicI32 = AtomicI32::new(0); // zle_tricky.c:88
7609
7610/// File-scope `Compctl freecl` from `Src/Zle/compcore.c:255`. The
7611/// freelist of available Compctl slots for the current completion call.
7612pub static freecl: OnceLock<Mutex<Option<i32>>> = OnceLock::new(); // c:255
7613
7614/// Real call into `doshfunc` — `Src/exec.c`. Looks up the function
7615/// in the global shfunctab (`getshfunc`) and dispatches via the VM's
7616/// `functions_compiled` map. Returns the function's exit status
7617/// (LASTVAL after the call), matching C's `doshfunc` return value.
7618pub fn shfunc_call(name: &str) -> i32 {
7619    // exec.c
7620    if crate::ported::utils::getshfunc(name).is_none() {
7621        // c:exec.c:5800
7622        return 1; // missing fn → status 1
7623    }
7624    // Route through the canonical exec accessors dispatcher so the
7625    // function actually executes. Hook returns Option<i32>; None
7626    // means no executor context is set up yet (fall back to
7627    // LASTVAL read).
7628    crate::ported::exec::dispatch_function_call(name, &[])
7629        .unwrap_or_else(|| crate::ported::builtin::LASTVAL.load(Ordering::Relaxed))
7630}
7631/// Real call into `setsparam(&format!("compstate[{key}]"), val)` — the
7632/// canonical paramtab write. Mirrors C's `setsparam` at params.c:3350.
7633///
7634/// Now `pub` so compsys engine ports can write `$compstate[KEY]`
7635/// directly. Also dual-writes to `paramtab_hashed_storage()` under
7636/// the "compstate" key so subscript lookups via the hash-param
7637/// machinery see the same value — `$compstate` IS a PM_HASHED param
7638/// (created by `makecompparams` at `complete.rs:1499`), and shell
7639/// scripts read it as such.
7640pub fn set_compstate_str(key: &str, val: &str) {
7641    // !!! RUST-ONLY: in C these keys are globals behind compstate_gsu and
7642    // `$compstate` exists only between makecompparams (complete.c:1340)
7643    // and endparamscope (compcore.c:839). This bridge publishes them
7644    // through the param, so the association has to exist first.
7645    //
7646    // Hash-storage write FIRST: a `compstate` row in
7647    // `paramtab_hashed_storage()` is what makes the setsparam below
7648    // route `compstate[KEY]` as an association key rather than an
7649    // arithmetic subscript. assignsparam does not special-case the
7650    // name, because outside completion zsh has no `$compstate`.
7651    if let Ok(mut tab) = paramtab_hashed_storage().lock() {
7652        tab.entry("compstate".to_string())
7653            .or_default()
7654            .insert(key.to_string(), val.to_string());
7655    }
7656
7657    // params.c:3350 — flat bracketed-param write; creates the PM_HASHED
7658    // node on first use and keeps it in step with the store.
7659    let pname = format!("compstate[{}]", key);
7660    let _ = setsparam(&pname, val);
7661
7662    // The `VAL(...)` rows in `compkparams` (`Src/Zle/complete.c:1292`,
7663    // `:1297`, `:1300`) name a real variable rather than a getter, so in
7664    // C an assignment to `$compstate[KEY]` updates that variable and a
7665    // later read sees it. [`get_compstate_str`] serves those keys from
7666    // the backing global, so the write has to land there too or the
7667    // round-trip is lost. The getter-only rows are deliberately absent:
7668    // C recomputes them on every read and a stored value would be stale.
7669    match key {
7670        // c:1292 `VAL(complistmax)`.
7671        "list_max" => {
7672            if let Ok(n) = val.parse::<i64>() {
7673                crate::ported::zle::complete::COMPLISTMAX.store(n, Ordering::Relaxed);
7674            }
7675        }
7676        // c:1297 `VAL(compvared)`.
7677        "vared" => {
7678            if let Ok(mut s) = crate::ported::zle::complete::COMPVARED
7679                .get_or_init(|| Mutex::new(String::new()))
7680                .lock()
7681            {
7682                *s = val.to_string();
7683            }
7684        }
7685        // c:1300 `VAL(compignored)`.
7686        "ignored" => {
7687            if let Ok(n) = val.parse::<i64>() {
7688                crate::ported::zle::complete::COMPIGNORED.store(n, Ordering::Relaxed);
7689            }
7690        }
7691        _ => {}
7692    }
7693}
7694
7695/// The `$compstate` keys whose values C does not store: their
7696/// `compkparams` rows (`Src/Zle/complete.c:1261-1300`) carry a `gsu`
7697/// vtable instead of a `var` pointer, so every read runs the getter
7698/// against live completion state. Listed in `compkparams` order.
7699pub const LIVE_COMPSTATE_KEYS: &[&str] = &[
7700    "nmatches",              // c:1262 nmatches_gsu
7701    "unambiguous",           // c:1285 unambig_gsu
7702    "unambiguous_cursor",    // c:1286 unambig_curs_gsu
7703    "unambiguous_positions", // c:1288 unambig_pos_gsu
7704    "insert_positions",      // c:1290 insert_pos_gsu
7705    "list_max",              // c:1292 VAL(complistmax)
7706    "vared",                 // c:1297 VAL(compvared)
7707    "list_lines",            // c:1298 listlines_gsu
7708    "all_quotes",            // c:1299 compqstack_gsu
7709    "ignored",               // c:1300 VAL(compignored)
7710];
7711
7712/// Read `$compstate[KEY]`. Returns `None` when the key was never set.
7713///
7714/// The [`LIVE_COMPSTATE_KEYS`] arm below is the Rust stand-in for C's
7715/// per-key gsu getter firing on each read; everything else comes from
7716/// the hash-storage view (the canonical home for a PM_HASHED param),
7717/// falling back to the legacy flat `compstate[KEY]` bracketed param for
7718/// entries that some code wrote via raw `setsparam` without going
7719/// through [`set_compstate_str`].
7720pub fn get_compstate_str(key: &str) -> Option<String> {
7721    // c:complete.c:1236-1252 — the gsu-backed keys are recomputed on
7722    // every read; a stored value would be stale. Before this arm covered
7723    // more than `nmatches`, none of them existed anywhere in zshrs's
7724    // compstate storage, so `_lastcomp` (`_main_complete` sh:407) came
7725    // back missing nine entries — `_lastcomp[unambiguous]` and
7726    // `[unambiguous_cursor]` (read at sh:84-86 and by `_next_tags`
7727    // sh:105) among them.
7728    let nil = std::ptr::null_mut();
7729    match key {
7730        // c:complete.c:1401-1405 — `get_nmatches`: flush pending match
7731        // groups via `permmatches(0)`, then read the counter. A stored
7732        // read served a stale 0, so every completer's
7733        // `nm != $compstate[nmatches]` idiom (_describe, _arguments,
7734        // _alternative, …) concluded "nothing was added" and option
7735        // completion died even though addmatches had added hundreds.
7736        "nmatches" => {
7737            let v = if permmatches(0) != 0 {
7738                0
7739            } else {
7740                nmatches.load(Ordering::Relaxed)
7741            };
7742            return Some(v.to_string());
7743        }
7744        // c:1439-1442 — `unambig_data(NULL, NULL, NULL)`.
7745        "unambiguous" => return Some(crate::ported::zle::complete::get_unambig(nil)),
7746        // c:1446-1450 — `unambig_data(&c, NULL, NULL); return c`.
7747        "unambiguous_cursor" => {
7748            return Some(crate::ported::zle::complete::get_unambig_curs(nil).to_string())
7749        }
7750        // c:1447-1456 — `unambig_data(NULL, &p, NULL); return p`.
7751        "unambiguous_positions" => return Some(crate::ported::zle::complete::get_unambig_pos(nil)),
7752        // c:1458-1466 — `unambig_data(NULL, NULL, &p); return p`.
7753        "insert_positions" => return Some(crate::ported::zle::complete::get_insert_pos(nil)),
7754        // c:1292 `VAL(complistmax)`; seeded from $LISTMAX at c:323.
7755        "list_max" => {
7756            return Some(
7757                crate::ported::zle::complete::COMPLISTMAX
7758                    .load(Ordering::Relaxed)
7759                    .to_string(),
7760            )
7761        }
7762        // c:1297 `VAL(compvared)` — the parameter name `vared` is
7763        // editing, `""` outside `vared` (c:compcore.c:565-570). zshrs
7764        // does not track `varedarg` yet, so the global stays at the
7765        // `""` C publishes for every non-`vared` completion.
7766        "vared" => {
7767            return Some(
7768                crate::ported::zle::complete::COMPVARED
7769                    .get_or_init(|| Mutex::new(String::new()))
7770                    .lock()
7771                    .map(|s| s.clone())
7772                    .unwrap_or_default(),
7773            )
7774        }
7775        // c:1408-1420 — `get_listlines` → `list_lines()`.
7776        "list_lines" => return Some(crate::ported::zle::complete::get_listlines(nil).to_string()),
7777        // c:1469 — `get_compqstack`: one char per quoting level.
7778        "all_quotes" => return Some(crate::ported::zle::complete::get_compqstack(nil)),
7779        // c:1300 `VAL(compignored)` — matches dropped by `compadd -F`.
7780        "ignored" => {
7781            return Some(
7782                crate::ported::zle::complete::COMPIGNORED
7783                    .load(Ordering::Relaxed)
7784                    .to_string(),
7785            )
7786        }
7787        _ => {}
7788    }
7789    if let Ok(tab) = paramtab_hashed_storage().lock() {
7790        if let Some(hash) = tab.get("compstate") {
7791            if let Some(v) = hash.get(key) {
7792                return Some(v.clone());
7793            }
7794        }
7795    }
7796    // Fallback: pre-existing callers wrote via raw `setsparam` only.
7797    let pname = format!("compstate[{}]", key);
7798    getsparam(&pname)
7799}
7800
7801/// Local helper: position before-the-current char (handles UTF-8).
7802#[inline]
7803fn prev_char_index(bytes: &[u8], pos: usize) -> usize {
7804    // local
7805    if pos == 0 {
7806        return 0;
7807    }
7808    let mut i = pos - 1;
7809    while i > 0 && (bytes[i] & 0xC0) == 0x80 {
7810        i -= 1;
7811    }
7812    i
7813}
7814
7815#[inline]
7816fn char_at(bytes: &[u8], pos: usize) -> char {
7817    // local
7818    if pos >= bytes.len() {
7819        return '\0';
7820    }
7821    let s = match std::str::from_utf8(&bytes[pos..]) {
7822        Ok(s) => s,
7823        Err(_) => return '\0',
7824    };
7825    s.chars().next().unwrap_or('\0')
7826}
7827
7828/// Depth counter so `set_comp_sep`'s sanity assert ("lexsave/restore
7829/// balanced") fires when a future port mismatches them.
7830static LEXSAVE_DEPTH: AtomicI32 = AtomicI32::new(0); // local
7831
7832/// Walk a balanced pair of in/out token bytes starting at `start`,
7833/// returning the index just after the closing token, or None if
7834/// unbalanced. C `skipparens` returns the position; this version
7835/// returns the same semantic.
7836fn skip_token_parens(bytes: &[u8], start: usize, open: char, close: char) -> Option<usize> {
7837    let mut depth: i32 = 0;
7838    let mut i = start;
7839    while i < bytes.len() {
7840        let c = char_at(bytes, i);
7841        if c == open {
7842            depth += 1;
7843        } else if c == close {
7844            depth -= 1;
7845            if depth == 0 {
7846                return Some(i + c.len_utf8());
7847            }
7848        }
7849        i += c.len_utf8();
7850    }
7851    if depth == 0 {
7852        Some(i)
7853    } else {
7854        None
7855    }
7856}
7857
7858/// Walk the INAMESPC name-character class — equivalent to C's
7859/// `itype_end(e, INAMESPC, 0)` loop. Stops at first non-name char.
7860fn walk_namespace(bytes: &[u8]) -> usize {
7861    // local
7862    let s = match std::str::from_utf8(bytes) {
7863        Ok(s) => s,
7864        Err(_) => return 0,
7865    };
7866    let mut len = 0usize;
7867    for c in s.chars() {
7868        if c.is_alphanumeric() || c == '_' {
7869            len += c.len_utf8();
7870        } else {
7871            break;
7872        }
7873    }
7874    len
7875}
7876
7877/// Strip Inbrace/Outbrace/Stringg/etc. token bytes back to literal
7878/// characters — substitute for C `untokenize()` over the slice. The
7879/// canonical Rust untokenize lives in `crate::lex::untokenize`.
7880fn strip_tokens(s: &str) -> String {
7881    // local
7882    crate::lex::untokenize(s).to_string()
7883}
7884
7885/// File-scope `int hcompcall` accessor — `compfunc` active iff non-empty.
7886fn compfunc_active() -> bool {
7887    compfunc
7888        .get_or_init(|| Mutex::new(None))
7889        .lock()
7890        .ok()
7891        .and_then(|g| g.clone())
7892        .map(|s| !s.is_empty())
7893        .unwrap_or(false)
7894}
7895
7896/// Direct port of `void lexsave(void)` from `Src/lex.c`. Delegates
7897/// to `zcontext_save` which pushes the lex/parse/hist context stack
7898/// frame. Returns a token (current stack depth) for symmetry with
7899/// the C `int` save token used by `set_comp_sep` for invariant check.
7900fn lexsave() -> usize {
7901    // lex.c via context.c:80
7902    crate::ported::context::zcontext_save();
7903    (LEXSAVE_DEPTH.fetch_add(1, Ordering::SeqCst) + 1) as usize
7904}
7905
7906/// Direct port of `void lexrestore(void)` from `Src/lex.c`. Pops the
7907/// last `zcontext_save` frame. C body restores hist/lex/parse via
7908/// `zcontext_restore_partial(ZCONTEXT_HIST|ZCONTEXT_LEX|ZCONTEXT_PARSE)`.
7909fn lexrestore(_token: usize) {
7910    // lex.c via context.c:117
7911    let parts = ZCONTEXT_HIST | ZCONTEXT_LEX | ZCONTEXT_PARSE;
7912    zcontext_restore_partial(parts);
7913    LEXSAVE_DEPTH.fetch_sub(1, Ordering::SeqCst);
7914}
7915
7916// ---- Extern stubs for addmatches's bucket-3 dependencies ----
7917
7918/// Reads the first char of `char *compquote` — `Src/Zle/complete.c:54`,
7919/// gsu-bound to `$compstate[quote]` (complete.c:1276), i.e. C's
7920/// `(qc = *compquote)` at c:2139.
7921///
7922/// !!! WARNING: RUST-ONLY HELPER !!!
7923/// C dereferences the bare global; the port keeps it in an
7924/// `OnceLock<Mutex<String>>`, so the deref needs a function. It used to
7925/// read `zle_tricky::COMPQUOTE` — a Rust-only DUPLICATE of the global
7926/// that has no counterpart in `Src/Zle/zle_tricky.c` and that nothing
7927/// ever writes, so `addmatches`'s quote block (c:2139-2168) always took
7928/// the `else` arm and cleared `instring`/`autoq` on every compadd.
7929fn compquote_first() -> Option<char> {
7930    // complete.c:54
7931    crate::ported::zle::complete::COMPQUOTE
7932        .get_or_init(|| Mutex::new(String::new()))
7933        .lock()
7934        .ok()
7935        .and_then(|g| g.chars().next())
7936}
7937fn instring_set(v: i32) {
7938    // zle_tricky.c:419
7939    INSTRING.store(v, Ordering::Relaxed);
7940}
7941fn inbackt_set(v: i32) {
7942    // zle_tricky.c:419
7943    INBACKT.store(v, Ordering::Relaxed);
7944}
7945fn autoq_set(s: &str) {
7946    // zle_tricky.c autoq
7947    if let Ok(mut g) = AUTOQ.get_or_init(|| Mutex::new(String::new())).lock() {
7948        *g = s.to_string();
7949    }
7950}
7951
7952// ---- Extern stubs for makecomplist's bucket-3 dependencies ----
7953
7954/// File-scope holder for `Cmlist bmatchers` — `Src/Zle/compcore.c:236`.
7955/// C linked-list of matchers active for brace-matching, populated by
7956/// `add_bmatchers` walking the user-installed `Cmatcher` chain.
7957pub static bmatchers: OnceLock<Mutex<Option<std::sync::Arc<Cmlist>>>> = OnceLock::new(); // c:236
7958
7959/// File-scope holder for `Cmlist mstack` — `Src/Zle/compcore.c:236`.
7960/// Matcher-stack — current active matcher list for compadd recursion.
7961pub static mstack: OnceLock<Mutex<Option<std::sync::Arc<Cmlist>>>> = OnceLock::new(); // c:236
7962
7963// ---- Extern stubs for add_match_data's Cline operations ----
7964
7965/// Bridge to `cline_matched()` — `Src/Zle/compmatch.c:253`. The
7966/// real port takes `&mut Option<Box<Cline>>` walking the chain
7967/// marking each node CLF_MATCHED. With only a string slice here we
7968/// build a one-node Cline shim and route the call through it so the
7969/// CLF_MATCHED state-machine update fires the same way as in C.
7970fn cline_matched_compcore(line: Option<&str>) {
7971    // compmatch.c:253
7972    let Some(s) = line else {
7973        return;
7974    };
7975    if s.is_empty() {
7976        return;
7977    }
7978    let mut head = Some(Box::new(Cline {
7979        line: Some(s.to_string()),
7980        llen: s.len() as i32,
7981        ..Default::default()
7982    }));
7983    cline_matched(&mut head);
7984}
7985/// Reads `char *qisuf` — `Src/Zle/zle_tricky.c:137`.
7986///
7987/// This used to be `getsparam("qisuf")`, i.e. a lookup for a SHELL
7988/// PARAMETER spelled with the C variable's name. No such parameter
7989/// exists in zsh or in this port (the shell-visible names are
7990/// `$QIPREFIX` / `$QISUFFIX`, complete.c:1266-1267, and they are gsu
7991/// views onto `compqiprefix`/`compqisuffix`, not onto `qipre`/`qisuf`),
7992/// so the read missed on every match and `add_match_data` built every
7993/// `cm->ipre`/`cm->isuf` without the word's quotes.
7994fn qisuf_get() -> String {
7995    // zle_tricky.c:137
7996    crate::ported::zle::zle_tricky::qisuf_get()
7997}
7998/// Reads `char *qipre` — `Src/Zle/zle_tricky.c:137`. See [`qisuf_get`].
7999fn qipre_get() -> String {
8000    // zle_tricky.c:137
8001    crate::ported::zle::zle_tricky::qipre_get()
8002}
8003
8004/// Adapter for `int movefd(int fd)` from `Src/utils.c:2974` —
8005/// delegates to the canonical port in `ported::utils::movefd`.
8006fn movefd(fd: i32) -> i32 {
8007    // utils.c:2974
8008    crate::ported::utils::movefd(fd)
8009}
8010
8011/// Adapter for `int redup(int x, int y)` from `Src/utils.c:2021` —
8012/// delegates to the canonical port `ported::utils::redup`. Every caller
8013/// here is one of C's three `redup(osi, 0)` sites (compcore.c:1013/1035/
8014/// 1039), i.e. "put the descriptor `movefd(0)` parked at c:964 back on
8015/// fd 0", so the target is 0.
8016///
8017/// The adapter used to pass `-1` as the target. `redup(osi, -1)` takes
8018/// the `x != y` arm, `dup2(osi, -1)` fails with EBADF, and the saved
8019/// descriptor is closed anyway — so the shell's stdin was DESTROYED by
8020/// the first Tab and fd 0 stayed closed for the rest of the session
8021/// (`cat` at the prompt after a completion read a bad descriptor).
8022fn redup(new: i32) {
8023    // utils.c:2021
8024    crate::ported::utils::redup(new, 0);
8025}
8026
8027/// File-scope registry mirroring `Src/init.c`'s `zshhooks[]` table —
8028/// each hook name maps to the ordered list of shfunc names to call.
8029pub static HOOK_FNS: OnceLock<Mutex<std::collections::HashMap<String, Vec<String>>>> =
8030    OnceLock::new(); // init.c zshhooks
8031
8032/// Adapter for the `errflag` global from `Src/init.c` — reads the
8033/// canonical atomic in `ported::utils::errflag`.
8034fn errflag_get() -> bool {
8035    crate::ported::utils::errflag.load(Ordering::Relaxed) != 0 // init.c
8036}
8037
8038// =====================================================================
8039// permmatches — `Src/Zle/compcore.c:3423`.
8040// =====================================================================
8041
8042/// Static state for `permmatches`'s `static int fi`. C scopes the
8043/// flag to the function; Rust hoists it to file scope per Rule S1.
8044static PERMMATCHES_FI: AtomicI32 = AtomicI32::new(0); // c:3423 static int fi
8045
8046/// Port of the `type==0` string-sort branch of `makearray()` from
8047/// compcore.c:3239-3257. Sorts strings via `strmetasort` + dedup.
8048pub fn makearray_strings(mut rp: Vec<String>, flags: i32) -> (Vec<String>, i32) {
8049    // c:3239
8050    let mut n: i32 = rp.len() as i32;
8051    if flags != 0 && n > 0 {
8052        // c:3240
8053        let numeric = isset(NUMERICGLOBSORT); // c:3243
8054        let mut sf = SORTIT_IGNORING_BACKSLASHES as u32;
8055        if numeric {
8056            sf |= SORTIT_NUMERICALLY as u32;
8057        }
8058        crate::ported::sort::strmetasort(&mut rp, sf, None); // c:3242-3244
8059
8060        // Dedup consecutive equals.                                         // c:3247
8061        let mut cp = 0usize;
8062        let mut ap = 0usize;
8063        while ap < rp.len() {
8064            if ap != cp {
8065                rp.swap(ap, cp);
8066            }
8067            cp += 1;
8068            let mut bp = ap;
8069            while bp + 1 < rp.len() && rp[ap] == rp[bp + 1] {
8070                // c:3250
8071                bp += 1;
8072                n -= 1;
8073            }
8074            ap = bp + 1; // c:3252
8075        }
8076        rp.truncate(cp); // c:3253
8077    }
8078    (rp, n)
8079}
8080
8081#[cfg(test)]
8082mod tests {
8083    use super::*;
8084
8085    #[test]
8086    fn rembslash_basic() {
8087        let _g = crate::test_util::global_state_lock();
8088        let _g = zle_test_setup();
8089        assert_eq!(rembslash("hello\\ world"), "hello world");
8090        assert_eq!(rembslash("no\\\\slash"), "no\\slash");
8091        assert_eq!(rembslash("plain"), "plain");
8092    }
8093
8094    #[test]
8095    fn comp_quoting_string_table() {
8096        let _g = crate::test_util::global_state_lock();
8097        let _g = zle_test_setup();
8098        assert_eq!(comp_quoting_string(QT_SINGLE), "'");
8099        assert_eq!(comp_quoting_string(QT_DOUBLE), "\"");
8100        assert_eq!(comp_quoting_string(QT_DOLLARS), "$'");
8101        assert_eq!(comp_quoting_string(0), "\\");
8102        assert_eq!(comp_quoting_string(QT_BACKSLASH), "\\");
8103    }
8104
8105    #[test]
8106    fn matcheq_equal_strings() {
8107        let _g = crate::test_util::global_state_lock();
8108        let _g = zle_test_setup();
8109        let mut a = Cmatch::default();
8110        a.str = Some("foo".into());
8111        let mut b = Cmatch::default();
8112        b.str = Some("foo".into());
8113        assert!(matcheq(&a, &b));
8114    }
8115
8116    #[test]
8117    fn matcheq_different_strings() {
8118        let _g = crate::test_util::global_state_lock();
8119        let _g = zle_test_setup();
8120        let mut a = Cmatch::default();
8121        a.str = Some("foo".into());
8122        let mut b = Cmatch::default();
8123        b.str = Some("bar".into());
8124        assert!(!matcheq(&a, &b));
8125    }
8126
8127    #[test]
8128    fn matcheq_one_side_none() {
8129        let _g = crate::test_util::global_state_lock();
8130        let _g = zle_test_setup();
8131        let mut a = Cmatch::default();
8132        a.pre = Some("p".into());
8133        let b = Cmatch::default();
8134        assert!(!matcheq(&a, &b));
8135    }
8136
8137    #[test]
8138    fn get_user_var_reads_array_from_paramtab() {
8139        let _g = crate::test_util::global_state_lock();
8140        // c:2003 — `getaparam(nam)` first. Verify array params come
8141        //          out as a Vec, not via env.
8142        let _g = zle_test_setup();
8143        setaparam("__test_arr", vec!["a".into(), "bb".into(), "ccc".into()]);
8144        let got = get_user_var(Some("__test_arr"));
8145        assert_eq!(got, Some(vec!["a".into(), "bb".into(), "ccc".into()]));
8146        // Cleanup so we don't poison other tests.
8147        setaparam("__test_arr", vec![]);
8148    }
8149
8150    #[test]
8151    fn get_user_var_reads_scalar_as_single_element_array() {
8152        let _g = crate::test_util::global_state_lock();
8153        // c:2007-2009 — getsparam fallback: wrap scalar in 1-element array.
8154        let _g = zle_test_setup();
8155        setsparam("__test_scalar", "hello");
8156        let got = get_user_var(Some("__test_scalar"));
8157        assert_eq!(got, Some(vec!["hello".to_string()]));
8158        setsparam("__test_scalar", "");
8159    }
8160
8161    #[test]
8162    fn get_user_var_paren_list_splits_on_separators() {
8163        let _g = crate::test_util::global_state_lock();
8164        // c:1960-1996 — `(a b c)` paren list, NOT a param lookup.
8165        let _g = zle_test_setup();
8166        let got = get_user_var(Some("(one two three)"));
8167        assert_eq!(got, Some(vec!["one".into(), "two".into(), "three".into()]));
8168    }
8169
8170    #[test]
8171    fn get_user_var_none_for_missing() {
8172        let _g = crate::test_util::global_state_lock();
8173        // c:1956 + c:2009 — missing param returns None.
8174        let _g = zle_test_setup();
8175        // (env vars must not leak through — we don't read $PATH etc.)
8176        let got = get_user_var(Some("__definitely_not_a_param_xyz"));
8177        assert_eq!(got, None);
8178    }
8179
8180    #[test]
8181    fn get_data_arr_reads_hashed_keys_or_values() {
8182        let _g = crate::test_util::global_state_lock();
8183        // c:2022 — fetchvalue(name, SCANPM_WANTKEYS|WANTVALS|MATCHMANY).
8184        let _g = zle_test_setup();
8185        crate::ported::params::sethparam(
8186            "__test_hash",
8187            vec!["k1".into(), "v1".into(), "k2".into(), "v2".into()],
8188        );
8189
8190        let keys = get_data_arr("__test_hash", true);
8191        assert!(keys.is_some(), "hashed param should have keys");
8192        let mut keys = keys.unwrap();
8193        keys.sort();
8194        assert_eq!(keys, vec!["k1".to_string(), "k2".to_string()]);
8195
8196        let vals = get_data_arr("__test_hash", false);
8197        assert!(vals.is_some(), "hashed param should have values");
8198        let mut vals = vals.unwrap();
8199        vals.sort();
8200        assert_eq!(vals, vec!["v1".to_string(), "v2".to_string()]);
8201    }
8202
8203    /// The `compadd -k <magic-hash>` path, pinned end-to-end from
8204    /// `get_data_arr` down.
8205    ///
8206    /// c:2022-2037 — `fetchvalue(…, SCANPM_WANTKEYS|SCANPM_MATCHMANY)`
8207    /// then `getarrvalue`, which for a PM_HASHED param is
8208    /// `paramvalarr(v->pm->gsu.h->getfn(v->pm), v->scanflags)`
8209    /// (Src/params.c:717). For a SPECIALPMDEF magic hash that `getfn`
8210    /// returns the fake HashTable whose `scantab` IS the module's
8211    /// `scanpm*` fn, so the SCAN is the backing.
8212    ///
8213    /// zshrs stores ordinary assoc contents in the name-keyed
8214    /// `paramtab_hashed_storage` map; an EMPTY row seeded there for a
8215    /// magic name used to answer first and shadow the scan. That is the
8216    /// exact route `_path_commands`' `compadd -k commands` (sh:103)
8217    /// takes, so command-name completion returned ZERO external
8218    /// commands in a fresh shell and only started working after some
8219    /// other `$commands` read had run the scan (which is what calls
8220    /// `fillcmdnamtable` under HASH_LIST_ALL,
8221    /// Src/Modules/parameter.c:253).
8222    ///
8223    /// Probed with `builtins` rather than `commands` so the assertion
8224    /// does not depend on `$PATH` or on `pathchecked` state another
8225    /// test may have consumed.
8226    #[test]
8227    fn get_data_arr_scans_a_magic_hash_past_an_empty_storage_row() {
8228        let _g = crate::test_util::global_state_lock();
8229        let _g = zle_test_setup();
8230        crate::vm_helper::seed_partab_param("builtins");
8231        // Exactly the row that used to answer instead of the scan.
8232        crate::ported::params::paramtab_hashed_storage()
8233            .lock()
8234            .unwrap()
8235            .insert("builtins".to_string(), Default::default());
8236
8237        let keys = get_data_arr("builtins", true)
8238            .expect("c:2034 — a PM_HASHED magic name must not fetch as None");
8239        assert!(
8240            keys.iter().any(|k| k == "print"),
8241            "c:2037 — `compadd -k builtins` must see the scanfn's keys, not the \
8242             empty hashed-storage row (got {} key(s))",
8243            keys.len()
8244        );
8245
8246        crate::ported::params::paramtab_hashed_storage()
8247            .lock()
8248            .unwrap()
8249            .remove("builtins");
8250    }
8251
8252    #[test]
8253    fn get_data_arr_none_for_non_hashed() {
8254        let _g = crate::test_util::global_state_lock();
8255        // c:2032 — fetchvalue NULL → return NULL for params that
8256        //          aren't associative arrays.
8257        let _g = zle_test_setup();
8258        setsparam("__test_scalar2", "value");
8259        let got = get_data_arr("__test_scalar2", false);
8260        assert_eq!(got, None, "scalar params must NOT come out of get_data_arr");
8261    }
8262
8263    #[test]
8264    fn before_complete_snapshots_oldmenucmp() {
8265        let _g = crate::test_util::global_state_lock();
8266        // c:463 — `oldmenucmp = menucmp;`
8267        let _g = zle_test_setup();
8268        MENUCMP.store(7, Ordering::Relaxed);
8269        OLDMENUCMP.store(0, Ordering::Relaxed);
8270        let mut lst = 0;
8271        let _ = before_complete(&mut lst);
8272        assert_eq!(OLDMENUCMP.load(Ordering::Relaxed), 7);
8273        // Reset for other tests.
8274        MENUCMP.store(0, Ordering::Relaxed);
8275        OLDMENUCMP.store(0, Ordering::Relaxed);
8276    }
8277
8278    #[test]
8279    fn before_complete_clears_showagain() {
8280        let _g = crate::test_util::global_state_lock();
8281        // c:467 — `showagain = 0;` always (after the validlist gate).
8282        let _g = zle_test_setup();
8283        SHOWAGAIN.store(5, Ordering::Relaxed);
8284        let mut lst = 0;
8285        let _ = before_complete(&mut lst);
8286        assert_eq!(
8287            SHOWAGAIN.load(Ordering::Relaxed),
8288            0,
8289            "SHOWAGAIN must be cleared by before_complete"
8290        );
8291    }
8292
8293    #[test]
8294    fn remsquote_default_quoting() {
8295        let _g = crate::test_util::global_state_lock();
8296        let _g = zle_test_setup();
8297        let mut s = String::from("a'\\''b");
8298        let n = remsquote(&mut s);
8299        assert_eq!(s, "a'b");
8300        assert_eq!(n, 3);
8301    }
8302
8303    #[test]
8304    fn ctokenize_dollar_substitution() {
8305        let _g = crate::test_util::global_state_lock();
8306        let _g = zle_test_setup();
8307        let out = ctokenize("$x{y}");
8308        let chars: Vec<char> = out.chars().collect();
8309        assert_eq!(chars[0], Stringg);
8310        assert_eq!(chars[1], 'x');
8311        assert_eq!(chars[2], Inbrace);
8312        assert_eq!(chars[3], 'y');
8313        assert_eq!(chars[4], Outbrace);
8314    }
8315
8316    #[test]
8317    fn get_user_var_inline_list() {
8318        let _g = crate::test_util::global_state_lock();
8319        let _g = zle_test_setup();
8320        let result = get_user_var(Some("(a b c)")).unwrap();
8321        assert_eq!(result, vec!["a", "b", "c"]);
8322    }
8323
8324    /// `matchcmp` with freshly built operand keys.
8325    fn mc(a: &Cmatch, b: &Cmatch) -> std::cmp::Ordering {
8326        use crate::tolerant_sort::MatchSortKey;
8327        matchcmp(a, &MatchSortKey::new(a), b, &MatchSortKey::new(b))
8328    }
8329
8330    #[test]
8331    fn matchcmp_str_sort_default() {
8332        let _g = crate::test_util::global_state_lock();
8333        let _g = zle_test_setup();
8334        MATCHORDER.store(CGF_MATSORT, Ordering::Relaxed);
8335        let mut a = Cmatch::default();
8336        a.str = Some("apple".into());
8337        let mut b = Cmatch::default();
8338        b.str = Some("banana".into());
8339        assert_eq!(mc(&a, &b), std::cmp::Ordering::Less);
8340        assert_eq!(mc(&b, &a), std::cmp::Ordering::Greater);
8341        assert_eq!(mc(&a, &a), std::cmp::Ordering::Equal);
8342        MATCHORDER.store(0, Ordering::Relaxed);
8343    }
8344
8345    /// `matchcmp` must collate the METAFIED form of the match strings,
8346    /// because that is what `Src/Zle/compcore.c:3194` hands `zstrcmp`:
8347    /// `(*a)->str` is a metafied match string and nothing on the path to
8348    /// the `qsort` at c:3259 unmetafies it (only `strmetasort` does, at
8349    /// c:299-315, and that is a different path serving `${(o)}`).
8350    ///
8351    /// The failure this pins was measured under `LC_ALL=en_US.UTF-8` on
8352    /// macOS, where `strcoll` returns 0 for every pair of CJK / Kana /
8353    /// Hangul strings: comparing the UNmetafied text called all four
8354    /// distinct matches EQUAL, so their list order fell out of the
8355    /// qsort's tie handling and drifted from zsh's. Metafication escapes
8356    /// bytes in `[0x83, 0xa2]` (`Src/utils.c:4195-4201`), which are dense
8357    /// inside these UTF-8 sequences, so C's `strcoll` sees a non-multibyte
8358    /// byte string and produces a total order instead.
8359    ///
8360    /// The locale is set and restored inside the test because the
8361    /// divergence is invisible under `LC_ALL=C`, where `strcoll` is
8362    /// `strcmp` and both forms agree.
8363    #[test]
8364    fn matchcmp_collates_metafied_form_so_distinct_matches_never_tie() {
8365        let _g = crate::test_util::global_state_lock();
8366        let _g = zle_test_setup();
8367        MATCHORDER.store(CGF_MATSORT, Ordering::Relaxed);
8368
8369        let saved: Option<std::ffi::CString> = unsafe {
8370            let p = libc::setlocale(libc::LC_ALL, std::ptr::null());
8371            if p.is_null() {
8372                None
8373            } else {
8374                Some(std::ffi::CStr::from_ptr(p).to_owned())
8375            }
8376        };
8377        let have_utf8 =
8378            unsafe { !libc::setlocale(libc::LC_ALL, c"en_US.UTF-8".as_ptr()).is_null() };
8379
8380        let mk = |s: &str| {
8381            let mut m = Cmatch::default();
8382            m.str = Some(s.into());
8383            m
8384        };
8385        // The corpus from the bug report, in the order `compadd` received it.
8386        let words = [
8387            "日本語",
8388            "中文字",
8389            "한국어",
8390            "ascii",
8391            "あかさたなはまやらわ",
8392        ];
8393        let cms: Vec<Cmatch> = words.iter().map(|w| mk(w)).collect();
8394
8395        let mut failures: Vec<String> = Vec::new();
8396        for i in 0..cms.len() {
8397            for j in 0..cms.len() {
8398                if i == j {
8399                    continue;
8400                }
8401                // Distinct matches must never compare equal: an Equal here
8402                // leaves the listing order to the sort's tie handling, which
8403                // is exactly the nondeterminism zsh does not have.
8404                if mc(&cms[i], &cms[j]) == std::cmp::Ordering::Equal {
8405                    failures.push(format!("{} vs {}", words[i], words[j]));
8406                }
8407                // Antisymmetry, which a byte/metafied total order guarantees.
8408                assert_eq!(
8409                    mc(&cms[i], &cms[j]),
8410                    mc(&cms[j], &cms[i]).reverse(),
8411                    "matchcmp not antisymmetric for {} vs {}",
8412                    words[i],
8413                    words[j]
8414                );
8415            }
8416        }
8417
8418        // ASCII is the overwhelmingly common case and metafication is the
8419        // identity on it (`imeta` covers only NUL and [0x83, 0xa2]), so the
8420        // locale-collated ordering that put `alpha.txt` before `README.md`
8421        // must survive untouched.
8422        assert_eq!(
8423            mc(&mk("alpha.txt"), &mk("README.md")),
8424            crate::ported::sort::zstrcmp(
8425                "alpha.txt",
8426                "README.md",
8427                crate::ported::zsh_h::SORTIT_IGNORING_BACKSLASHES as u32,
8428            ),
8429            "metafication must not change ASCII match ordering",
8430        );
8431
8432        unsafe {
8433            match saved {
8434                Some(ref s) => libc::setlocale(libc::LC_ALL, s.as_ptr()),
8435                None => libc::setlocale(libc::LC_ALL, c"C".as_ptr()),
8436            };
8437        }
8438        MATCHORDER.store(0, Ordering::Relaxed);
8439
8440        assert!(
8441            failures.is_empty(),
8442            "matchcmp reported distinct non-ASCII matches as EQUAL \
8443             (en_US.UTF-8 available: {have_utf8}): {failures:?}",
8444        );
8445    }
8446
8447    #[test]
8448    fn dupmatch_clones_strings_and_truncates_braces() {
8449        let _g = crate::test_util::global_state_lock();
8450        let _g = zle_test_setup();
8451        // C body c:3370: deep-copy strings, truncate brpl/brsl to nbeg/nend.
8452        let mut src = Cmatch::default();
8453        src.str = Some("foo".into());
8454        src.ipre = Some("ipre".into());
8455        src.flags = 7;
8456        src.brpl = vec![10, 20, 30, 40];
8457        src.brsl = vec![5, 6, 7];
8458        src.qipl = 1;
8459        src.qisl = 2;
8460        src.mode = 0o755;
8461        src.modec = 'd';
8462
8463        let r = dupmatch(&src, 2, 1);
8464        assert_eq!(r.str.as_deref(), Some("foo"));
8465        assert_eq!(r.ipre.as_deref(), Some("ipre"));
8466        assert_eq!(r.flags, 7);
8467        assert_eq!(r.brpl, vec![10, 20]); // truncated to nbeg=2
8468        assert_eq!(r.brsl, vec![5]); // truncated to nend=1
8469        assert_eq!(r.qipl, 1);
8470        assert_eq!(r.qisl, 2);
8471        assert_eq!(r.mode, 0o755);
8472        assert_eq!(r.modec, 'd');
8473    }
8474
8475    #[test]
8476    fn dupmatch_empty_braces_stay_empty() {
8477        let _g = crate::test_util::global_state_lock();
8478        let _g = zle_test_setup();
8479        // C body c:3395/3404: NULL brpl/brsl stay NULL regardless of nbeg/nend.
8480        let src = Cmatch::default();
8481        let r = dupmatch(&src, 5, 5);
8482        assert!(r.brpl.is_empty());
8483        assert!(r.brsl.is_empty());
8484    }
8485
8486    #[test]
8487    fn makearray_sorted_and_deduped() {
8488        let _g = crate::test_util::global_state_lock();
8489        let _g = zle_test_setup();
8490        // c:3262-3291: sort + dedup with matcheq. Same str + nil disp =>
8491        // collapses into one entry with CMF_FMULT set on the survivor.
8492        let mut a = Cmatch::default();
8493        a.str = Some("z".into());
8494        let mut b = Cmatch::default();
8495        b.str = Some("a".into());
8496        let mut c = Cmatch::default();
8497        c.str = Some("a".into());
8498        let (arr, n, _nl, _ll) = makearray(&mut vec![a, b, c], CGF_MATSORT);
8499        // Two distinct visible strings after dedup ("a", "z").
8500        assert_eq!(arr.len(), 2);
8501        assert_eq!(n, 2);
8502        assert_eq!(arr[0].str.as_deref(), Some("a"));
8503        assert_eq!(arr[1].str.as_deref(), Some("z"));
8504    }
8505
8506    #[test]
8507    fn makearray_nosort_unchanged_order() {
8508        let _g = crate::test_util::global_state_lock();
8509        let _g = zle_test_setup();
8510        // c:3300: CGF_NOSORT branch; with no UNIQ flags, order preserved.
8511        let mut a = Cmatch::default();
8512        a.str = Some("z".into());
8513        let mut b = Cmatch::default();
8514        b.str = Some("a".into());
8515        let (arr, n, _, _) = makearray(&mut vec![a, b], CGF_NOSORT | CGF_UNIQALL);
8516        // UNIQALL active so no dedup pass runs.
8517        assert_eq!(n, 2);
8518        assert_eq!(arr[0].str.as_deref(), Some("z"));
8519        assert_eq!(arr[1].str.as_deref(), Some("a"));
8520    }
8521
8522    #[test]
8523    fn makearray_flag_writes_reach_the_live_list() {
8524        let _g = crate::test_util::global_state_lock();
8525        let _g = zle_test_setup();
8526        // c:3230-3236 + c:3274-3280 — `rp` aliases the LinkList's matches, so
8527        // `(bp[1])->flags |= CMF_MULT` marks the LIVE match; C never empties
8528        // `g->lmatches`, so the mark is still there on the next
8529        // `permmatches`. Two matches with the same `str` but different `ppre`
8530        // are NOT matcheq (c:3210 compares ppre) yet DISPLAY the same string
8531        // (c:3274-3275 compares only `str`), so the first takes CMF_FMULT and
8532        // the second CMF_MULT — and neither is removed.
8533        let mut a = Cmatch::default();
8534        a.str = Some("cfg".into());
8535        a.ppre = Some("bin/".into());
8536        let mut b = Cmatch::default();
8537        b.str = Some("cfg".into());
8538        b.ppre = Some("sbin/".into());
8539        let mut live = vec![a, b];
8540
8541        let (arr, n, nl, _ll) = makearray(&mut live, CGF_MATSORT);
8542        assert_eq!(n, 2, "not matcheq (ppre differs), so both stay in mcount");
8543        assert_eq!(nl, 1, "the CMF_MULT one drops out of the LISTING only");
8544        assert_eq!(arr.len(), 2);
8545        // The writes landed on the live list, not on a detached copy.
8546        assert_ne!(live[0].flags & CMF_FMULT, 0, "c:3280 on the live match");
8547        assert_ne!(live[1].flags & CMF_MULT, 0, "c:3276 on the live match");
8548
8549        // A second permmatches pass over the SAME live list (every
8550        // `_description` block reads `$compstate[nmatches]`, which is
8551        // `permmatches(0)` — `Src/Zle/complete.c:1413`) sees the marks
8552        // already set and agrees with the first.
8553        let (arr2, n2, nl2, _) = makearray(&mut live, CGF_MATSORT);
8554        assert_eq!((arr2.len(), n2, nl2), (arr.len(), n, nl));
8555    }
8556
8557    #[test]
8558    fn makearray_nosort_deletes_every_dupe_of_a_run() {
8559        let _g = crate::test_util::global_state_lock();
8560        let _g = zle_test_setup();
8561        // c:3305-3307 — in the CGF_NOSORT dedup-all branch C marks
8562        // `bp[0]->flags = CMF_DELETE` on the SORTED-array element, which
8563        // aliases a distinct live match on each pair. The old port searched
8564        // the detached copy back to the first `matcheq` element, so a run of
8565        // three identical matches marked the same element twice and dropped
8566        // only one of the two dupes.
8567        let mk = |s: &str| {
8568            let mut m = Cmatch::default();
8569            m.str = Some(s.into());
8570            m
8571        };
8572        let mut live = vec![mk("dup"), mk("dup"), mk("dup"), mk("other")];
8573        let (arr, n, _nl, _ll) = makearray(&mut live, CGF_NOSORT);
8574        assert_eq!(n, 2, "both dupes removed, `dup` + `other` remain");
8575        assert_eq!(arr.len(), 2);
8576        assert_eq!(live[1].flags & CMF_DELETE, CMF_DELETE, "c:3306");
8577        assert_eq!(live[2].flags & CMF_DELETE, CMF_DELETE, "c:3306");
8578    }
8579
8580    #[test]
8581    fn makearray_strings_dedup_consecutive() {
8582        let _g = crate::test_util::global_state_lock();
8583        let _g = zle_test_setup();
8584        // c:3239 path: sort + drop adjacent duplicates.
8585        let (arr, n) = makearray_strings(vec!["b".into(), "a".into(), "a".into(), "c".into()], 1);
8586        assert_eq!(n, 3);
8587        assert_eq!(arr, vec!["a", "b", "c"]);
8588    }
8589
8590    #[test]
8591    fn check_param_no_dollar_returns_none() {
8592        let _g = crate::test_util::global_state_lock();
8593        let _g = zle_test_setup();
8594        // c:1316: no `$` in string → return None.
8595        OFFS.store(2, Ordering::Relaxed);
8596        assert_eq!(check_param("abc", false, false, false), None);
8597    }
8598
8599    #[test]
8600    fn check_param_simple_dollar_var_at_cursor() {
8601        let _g = crate::test_util::global_state_lock();
8602        let _g = zle_test_setup();
8603        // c:1259-1311: `$FOO` with cursor inside the name → return b.
8604        // OFFS is a byte offset into the UTF-8 word: one past the token.
8605        OFFS.store(Stringg.len_utf8() as i32 + 1, Ordering::Relaxed);
8606        let s = format!("{}FOO", Stringg);
8607        let r = check_param(&s, false, true, false);
8608        assert!(r.is_some(), "expected Some(b) inside $FOO");
8609    }
8610
8611    /// c:1141 — a live `$` is the `String` token; an escaped `\$` reaches
8612    /// `check_param` as a plain 0x24, its `Bnull` chucked by the line-cleanup
8613    /// loop (zle_tricky.c:1881/1920). So `echo \$PATH` has NO parameter
8614    /// context while `echo $PATH` has one. Accepting the literal made the two
8615    /// indistinguishable: `\$PATH` published `PREFIX=PATH` / `IPREFIX=\$`
8616    /// where zsh publishes the whole `\$PATH` as the prefix.
8617    #[test]
8618    fn check_param_escaped_dollar_is_not_a_parameter() {
8619        let _g = crate::test_util::global_state_lock();
8620        let _g = zle_test_setup();
8621
8622        // `echo \$PATH` — the word as check_param sees it: literal `$`.
8623        let escaped = "$PATH";
8624        OFFS.store(escaped.len() as i32, Ordering::Relaxed);
8625        assert_eq!(
8626            check_param(escaped, false, true, false),
8627            None,
8628            "a bare 0x24 means the user escaped it"
8629        );
8630
8631        // `echo $PATH` — the same five visible chars, but a `String` token.
8632        let live = format!("{Stringg}PATH");
8633        OFFS.store(live.len() as i32, Ordering::Relaxed);
8634        assert_eq!(
8635            check_param(&live, false, true, false),
8636            Some(Stringg.len_utf8()),
8637            "the String token opens a parameter name at b = p + 1"
8638        );
8639    }
8640
8641    /// c:1226-1229 — the end-of-name test lists the `Quest`/`Star` TOKENS but
8642    /// only the literal `-`/`!`. `$-` lexes to `String` + `Dash`, which
8643    /// matches neither, so `e` never leaves `b`, the c:1254 cursor-inside-name
8644    /// test fails and C reports no parameter context at all. Reading the
8645    /// untokenized `-` as a one-char name instead split the word and dropped
8646    /// the `$` from what `_expand` echoed back.
8647    #[test]
8648    fn check_param_dollar_dash_is_not_a_one_char_name() {
8649        let _g = crate::test_util::global_state_lock();
8650        let _g = zle_test_setup();
8651        let dash = crate::ported::zsh_h::Dash;
8652        let s = format!("{Stringg}{dash}");
8653        OFFS.store(s.len() as i32, Ordering::Relaxed);
8654        assert_eq!(check_param(&s, false, true, false), None);
8655    }
8656
8657    /// c:1189-1202 + c:1297 — `${(k)<cursor>` is a brace parameter: the
8658    /// `(k)` flag group is skipped and `ispar` ends at 2, which is what
8659    /// `compcontext_for` turns into `brace_parameter`.
8660    ///
8661    /// Exercises the `untok = true` arm — a caller holding only the
8662    /// untokenized word, where the flag group arrives as literal `(`/`)`.
8663    /// Handing those to `skipparens(Inpar, Outpar, …)` returns -1 ("wrong
8664    /// opening char"), `b` never moved past the flags, the name scan hit `(`
8665    /// and bailed — `ispar` stayed 0, the context stayed `command`, and
8666    /// `echo ${(k)<TAB>` produced nothing at all.
8667    #[test]
8668    fn check_param_brace_with_flag_group_sets_ispar_two() {
8669        let _g = crate::test_util::global_state_lock();
8670        let _g = zle_test_setup();
8671        ispar.store(0, Ordering::Relaxed);
8672        // `${(k)` with the cursor just past the `)`.
8673        let s = "${(k)";
8674        OFFS.store(s.len() as i32, Ordering::Relaxed);
8675        let r = check_param(s, false, false, true);
8676        assert!(r.is_some(), "cursor sits at the (empty) parameter name");
8677        assert_eq!(
8678            ispar.load(Ordering::Relaxed),
8679            2,
8680            "a flag group must still leave a brace-parameter context"
8681        );
8682    }
8683
8684    #[test]
8685    fn callcompfunc_empty_fn_no_panic() {
8686        let _g = crate::test_util::global_state_lock();
8687        let _g = zle_test_setup();
8688        // c:552: getshfunc(NULL) early-return.
8689        callcompfunc("anything", "");
8690    }
8691
8692    #[test]
8693    fn callcompfunc_sets_compstate_context() {
8694        let _g = crate::test_util::global_state_lock();
8695        let _g = zle_test_setup();
8696        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
8697        // c:619: context selection — verified via the pure
8698        // compcontext_for helper (callcompfunc calls it and writes
8699        // to paramtab via setsparam, but paramtab read-back in a
8700        // unit-test context without a live VM is unreliable).
8701        ispar.store(0, Ordering::Relaxed);
8702        linwhat.store(IN_PAR_LW, Ordering::Relaxed);
8703        assert_eq!(compcontext_for("foo"), "assign_parameter");
8704        // Body executes without panicking against the real paramtab.
8705        callcompfunc("foo", "_test_fn");
8706    }
8707
8708    /// c:complete.c:1235-1295 — `$PREFIX`/`$SUFFIX`/`$IPREFIX`/`$ISUFFIX`
8709    /// and the `compprefix`/`compsuffix`/`compiprefix`/`compisuffix`
8710    /// globals are ONE storage in C, so `callcompfunc`'s per-call publish
8711    /// of the cursor word split resets BOTH.
8712    ///
8713    /// Regression for `PATH=/usr/bin:<TAB>`: `expand-or-complete` calls
8714    /// the completion function twice per TAB and `_path_files` leaves its
8715    /// last path component in `$PREFIX`. Because only the param was
8716    /// republished, the second pass's `compset -P '*:'` (which reads the
8717    /// GLOBAL) matched the stale `bin:` instead of the live `/usr/bin:` —
8718    /// `$IPREFIX` came out `bin:` and the rebuilt line lost `/usr/`.
8719    #[test]
8720    fn callcompfunc_republishes_word_split_onto_comp_globals() {
8721        let _g = crate::test_util::global_state_lock();
8722        let _g = zle_test_setup();
8723        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
8724        // Leftovers from the previous completion pass.
8725        *COMPPREFIX
8726            .get_or_init(|| Mutex::new(String::new()))
8727            .lock()
8728            .unwrap() = "bin:".to_string();
8729        *COMPIPREFIX
8730            .get_or_init(|| Mutex::new(String::new()))
8731            .lock()
8732            .unwrap() = "PATH=/usr/".to_string();
8733
8734        // Cursor at the end of the word, as get_comp_string leaves it.
8735        OFFS.store("/usr/bin:".chars().count() as i32, Ordering::Relaxed);
8736        callcompfunc("/usr/bin:", "_test_fn");
8737
8738        assert_eq!(
8739            COMPPREFIX
8740                .get_or_init(|| Mutex::new(String::new()))
8741                .lock()
8742                .unwrap()
8743                .clone(),
8744            "/usr/bin:",
8745            "compprefix must track the freshly published $PREFIX"
8746        );
8747        assert_eq!(
8748            COMPIPREFIX
8749                .get_or_init(|| Mutex::new(String::new()))
8750                .lock()
8751                .unwrap()
8752                .clone(),
8753            "",
8754            "compiprefix must be reset with $IPREFIX, not carried over"
8755        );
8756    }
8757
8758    /// c:compcore.c:820 — `makezleparams(1)` publishes the ZLE special
8759    /// params for the completion function, and c:839's `endparamscope`
8760    /// takes them away again on the way out.
8761    ///
8762    /// The publish was missing entirely, so every completer saw
8763    /// `$BUFFER`/`$CURSOR`/`$HISTNO`/`$WIDGET`/`$KEYS`/`$LBUFFER`/
8764    /// `$RBUFFER`/`$BUFFERLINES`/`$PENDING` as the empty string.
8765    ///
8766    /// Both halves are pinned with ONE observable: seed `BUFFER` with a
8767    /// sentinel at the enclosing scope first.
8768    ///   * publish missing  → the sentinel survives (`Some(sentinel)`),
8769    ///   * publish present but teardown missing → the live line survives,
8770    ///   * both present      → the name is gone entirely.
8771    /// Only the last is C behaviour.
8772    #[test]
8773    fn callcompfunc_publishes_and_tears_down_zle_params() {
8774        let _g = crate::test_util::global_state_lock();
8775        let _g = zle_test_setup();
8776        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
8777
8778        // `$FUNCNEST` is unset in a bare unit-test paramtab, and
8779        // `getiparam` reports 0 for unset — which trips doshfunc's
8780        // c:6000 guard (`funcstacksz >= zsh_funcnest`) on the very first
8781        // frame and returns BEFORE c:839's endparamscope. Give it the
8782        // shell's real default so the scope actually unwinds.
8783        let funcnest_save = crate::ported::params::getiparam("FUNCNEST");
8784        let _ = crate::ported::params::setiparam("FUNCNEST", 500);
8785
8786        const SENTINEL: &str = "@@not-published@@";
8787        for name in ["BUFFER", "WIDGET", "LBUFFER"] {
8788            let _ = crate::ported::params::setsparam(name, SENTINEL);
8789        }
8790        // A non-empty editor line so the publish is distinguishable from
8791        // "published an empty string".
8792        *crate::ported::zle::zle_main::ZLELINE.lock().unwrap() = "fc -".chars().collect();
8793        crate::ported::zle::zle_main::ZLELL.store(4, Ordering::SeqCst);
8794        crate::ported::zle::zle_main::ZLECS.store(4, Ordering::SeqCst);
8795
8796        OFFS.store(1, Ordering::Relaxed);
8797        callcompfunc("-", "_test_fn");
8798        let _ = crate::ported::params::setiparam("FUNCNEST", funcnest_save);
8799
8800        for name in ["BUFFER", "WIDGET", "LBUFFER"] {
8801            let after = crate::ported::params::getsparam(name);
8802            assert_ne!(
8803                after.as_deref(),
8804                Some(SENTINEL),
8805                "${name} still holds the pre-call sentinel — c:820 makezleparams(1) \
8806                 never ran, so the completion function saw no ZLE parameters"
8807            );
8808            assert_eq!(
8809                after, None,
8810                "${name} outlived the completion scope — c:839 endparamscope must \
8811                 unset every PM_LOCAL zleparam, or the name leaks into the \
8812                 interactive shell after the first TAB"
8813            );
8814        }
8815    }
8816
8817    /// Test-only serializer for tests that mutate file-scope globals.
8818    ///
8819    /// Every acquisition is poison-tolerant
8820    /// (`.unwrap_or_else(|e| e.into_inner())`, the same recovery
8821    /// `zle_test_setup()` and `test_util::global_state_lock()` use). A
8822    /// plain `.unwrap()` turned ONE failing test into sixteen: the first
8823    /// panic while the guard was held poisoned the mutex, and every later
8824    /// compcore test died on `PoisonError` before running a single
8825    /// assertion, hiding whether it would have passed. Recovery here does
8826    /// not weaken any test — each one still resets the globals it touches
8827    /// via `zle_test_setup()`.
8828    static GLOBAL_MUT_LOCK: Mutex<()> = Mutex::new(());
8829
8830    #[test]
8831    fn compcontext_for_routes_ispar_first() {
8832        let _g = crate::test_util::global_state_lock();
8833        let _g = zle_test_setup();
8834        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
8835        // c:583/593 — the subscript arms key off `insubscr`; pin it off so
8836        // the non-subscript routing below is deterministic (`zle_reset`
8837        // does not clear it).
8838        crate::ported::zle::zle_tricky::INSUBSCR.store(0, Ordering::Relaxed);
8839        crate::ported::zle::zle_tricky::LINCMD.store(0, Ordering::Relaxed);
8840        // c:598/606/623 — the redirect / array_value / default-command
8841        // arms read three more file-scope globals; pin them so the
8842        // routing below is deterministic regardless of test order.
8843        crate::ported::zle::zle_tricky::LINREDIR.store(0, Ordering::Relaxed);
8844        crate::ported::zle::zle_tricky::LINARR.store(0, Ordering::Relaxed);
8845        *crate::ported::zle::zle_tricky::CMDSTR.lock().unwrap() = Some("ls".into());
8846        ispar.store(2, Ordering::Relaxed);
8847        linwhat.store(IN_NOTHING_LW, Ordering::Relaxed);
8848        assert_eq!(compcontext_for("x"), "brace_parameter");
8849        ispar.store(1, Ordering::Relaxed);
8850        assert_eq!(compcontext_for("x"), "parameter");
8851        ispar.store(0, Ordering::Relaxed);
8852        linwhat.store(IN_MATH_LW, Ordering::Relaxed);
8853        assert_eq!(compcontext_for("x"), "math");
8854        linwhat.store(IN_COND_LW, Ordering::Relaxed);
8855        assert_eq!(compcontext_for("x"), "condition");
8856        linwhat.store(IN_ENV_LW, Ordering::Relaxed);
8857        assert_eq!(compcontext_for("x"), "value");
8858        linwhat.store(IN_NOTHING_LW, Ordering::Relaxed);
8859        assert_eq!(compcontext_for("x"), "command");
8860        *crate::ported::zle::zle_tricky::CMDSTR.lock().unwrap() = None;
8861    }
8862
8863    /// c:598-602 / c:605-606 / c:622-630 — the three arms that were
8864    /// missing from this port entirely. `linredir` outranks the
8865    /// `switch (linwhat)` but is outranked by `lincmd`; `linarr`
8866    /// upgrades IN_ENV from `value` to `array_value`; and the default
8867    /// arm degrades to `value` when no command word was parsed.
8868    #[test]
8869    fn compcontext_for_redirect_array_value_and_cmdstr_arms() {
8870        let _g = crate::test_util::global_state_lock();
8871        let _g = zle_test_setup();
8872        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
8873        use crate::ported::zle::zle_tricky::{CMDSTR, INSUBSCR, LINARR, LINCMD, LINREDIR};
8874        ispar.store(0, Ordering::Relaxed);
8875        INSUBSCR.store(0, Ordering::Relaxed);
8876        LINCMD.store(0, Ordering::Relaxed);
8877        LINARR.store(0, Ordering::Relaxed);
8878        *CMDSTR.lock().unwrap() = Some("echo".into());
8879
8880        // c:598-599 — `echo x > /tm<TAB>`.
8881        linwhat.store(IN_NOTHING_LW, Ordering::Relaxed);
8882        LINREDIR.store(1, Ordering::Relaxed);
8883        assert_eq!(compcontext_for("x"), "redirect");
8884        // c:592 — but a command-position word still wins over it.
8885        LINCMD.store(1, Ordering::Relaxed);
8886        assert_eq!(compcontext_for("x"), "command");
8887        LINCMD.store(0, Ordering::Relaxed);
8888        LINREDIR.store(0, Ordering::Relaxed);
8889
8890        // c:605-606 — IN_ENV splits on `linarr`.
8891        linwhat.store(IN_ENV_LW, Ordering::Relaxed);
8892        assert_eq!(compcontext_for("x"), "value");
8893        LINARR.store(1, Ordering::Relaxed);
8894        assert_eq!(compcontext_for("x"), "array_value");
8895        LINARR.store(0, Ordering::Relaxed);
8896
8897        // c:623-626 — the default arm needs a command word to say
8898        // "command"; without one it is a `value`.
8899        linwhat.store(IN_NOTHING_LW, Ordering::Relaxed);
8900        assert_eq!(compcontext_for("x"), "command");
8901        *CMDSTR.lock().unwrap() = None;
8902        assert_eq!(compcontext_for("x"), "value");
8903    }
8904
8905    /// c:582-597 — with `insubscr` set, BOTH the math arm and the command
8906    /// arm select the `subscript` context (so `_complete` dispatches
8907    /// `-subscript-`). Regression for `echo $fpath[<TAB>`, which routed to
8908    /// `math` and never reached `_subscript`.
8909    #[test]
8910    fn compcontext_for_routes_subscript_when_insubscr_set() {
8911        let _g = crate::test_util::global_state_lock();
8912        let _g = zle_test_setup();
8913        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
8914        use crate::ported::zle::zle_tricky::{CMDSTR, INSUBSCR, LINCMD, LINREDIR};
8915        ispar.store(0, Ordering::Relaxed);
8916        LINCMD.store(0, Ordering::Relaxed);
8917        LINREDIR.store(0, Ordering::Relaxed);
8918        *CMDSTR.lock().unwrap() = Some("ls".into());
8919
8920        // c:584 — math context inside an unclosed subscript.
8921        linwhat.store(IN_MATH_LW, Ordering::Relaxed);
8922        INSUBSCR.store(1, Ordering::Relaxed);
8923        assert_eq!(compcontext_for("x"), "subscript");
8924        // c:590 — a real math expression stays `math`.
8925        INSUBSCR.store(0, Ordering::Relaxed);
8926        assert_eq!(compcontext_for("x"), "math");
8927
8928        // c:593-597 — `[` in command position is a subscript, not a command.
8929        linwhat.store(IN_NOTHING_LW, Ordering::Relaxed);
8930        LINCMD.store(1, Ordering::Relaxed);
8931        INSUBSCR.store(1, Ordering::Relaxed);
8932        assert_eq!(compcontext_for("x"), "subscript");
8933        INSUBSCR.store(0, Ordering::Relaxed);
8934        assert_eq!(compcontext_for("x"), "command");
8935        LINCMD.store(0, Ordering::Relaxed);
8936        *CMDSTR.lock().unwrap() = None;
8937    }
8938
8939    #[test]
8940    fn addmatches_empty_argv_early_return() {
8941        let _g = crate::test_util::global_state_lock();
8942        let _g = zle_test_setup();
8943        // c:2138-2139: empty argv + dummies==0 + no CAF_ALL → return 1.
8944        let mut dat = Cadata::default();
8945        dat.dummies = 0;
8946        dat.aflags = 0;
8947        assert_eq!(addmatches(&mut dat, &[]), 1);
8948    }
8949
8950    /// c:2252-2253 — `lipre`/`lisuf` are read from `compiprefix`/`compisuffix`
8951    /// ONLY inside `if (dat->aflags & CAF_MATCH)`. `compadd -U` clears
8952    /// CAF_MATCH, so a `-U` match carries NO ignored prefix and replaces the
8953    /// word on the line whole; c:2278-2282 then has nothing to prepend.
8954    ///
8955    /// Reading them unconditionally gave every `-U` match the live `$IPREFIX`.
8956    /// Measured on a PTY (`scripts/comptab_screen.py`) with
8957    /// `fpath=(/opt/homebrew/Cellar/zsh/5.9.2/share/zsh/functions)`,
8958    /// `zstyle ':completion:*' completer _expand` and
8959    /// `hash -d zzqaa=/usr/share/zsh` — both shells report
8960    /// `IPREFIX=<~> PREFIX=<zzqaa>`, and the match set is identical:
8961    ///
8962    /// ```text
8963    ///   buffer `ls ~zzqaa` + TAB
8964    ///     zsh     ls /usr/share/zsh/
8965    ///     zshrs   ls ~/usr/share/zsh/    <- before
8966    ///     zshrs   ls /usr/share/zsh/     <- after
8967    /// ```
8968    #[test]
8969    fn addmatches_u_flag_match_carries_no_ignored_prefix() {
8970        let _g = crate::test_util::global_state_lock();
8971        let _g = zle_test_setup();
8972        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
8973
8974        // The `~` `_expand` leaves in `$IPREFIX` when the word is `~zzqaa`.
8975        // Seeded on the GLOBAL, not the param: the param->global refresh above
8976        // is gated on INCOMPFUNC, which is 0 here.
8977        *COMPIPREFIX
8978            .get_or_init(|| Mutex::new(String::new()))
8979            .lock()
8980            .unwrap() = "~".to_string();
8981
8982        let ipre_of_last = |aflags: i32| -> Option<String> {
8983            crate::comp_match_handles::matches_arc().lock().unwrap().clear();
8984            let mut dat = Cadata::default();
8985            dat.dummies = -1;
8986            dat.aflags = aflags;
8987            let _ = addmatches(&mut dat, &["/usr/share/zsh".to_string()]);
8988            let arc = crate::comp_match_handles::matches_arc();
8989            let m = arc.lock().unwrap();
8990            m.last().and_then(|cm| cm.ipre.clone())
8991        };
8992
8993        // `compadd -U`: no CAF_MATCH, so c:2253 never runs and the match has
8994        // no ipre to re-insert in front of the expansion.
8995        assert_eq!(
8996            ipre_of_last(0),
8997            None,
8998            "c:2252 — a non-CAF_MATCH (`-U`) match must carry no ignored prefix"
8999        );
9000
9001        // A matching compadd still picks `$IPREFIX` up, which is what keeps
9002        // `compset -P`'d completers re-inserting the part they consumed.
9003        assert_eq!(
9004            ipre_of_last(CAF_MATCH),
9005            Some("~".to_string()),
9006            "c:2253 — under CAF_MATCH the ignored prefix still reaches the match"
9007        );
9008
9009        *COMPIPREFIX
9010            .get_or_init(|| Mutex::new(String::new()))
9011            .lock()
9012            .unwrap() = String::new();
9013    }
9014
9015    #[test]
9016    fn addmatches_appends_argv_to_default_group() {
9017        let _g = crate::test_util::global_state_lock();
9018        let _g = zle_test_setup();
9019        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9020        // c:2200 simplified body: each argv entry → addmatch into "default" group.
9021        amatches
9022            .get_or_init(|| Mutex::new(Vec::new()))
9023            .lock()
9024            .unwrap()
9025            .clear();
9026        crate::comp_match_handles::matches_arc()
9027            .lock()
9028            .unwrap()
9029            .clear();
9030        let mut dat = Cadata::default();
9031        dat.dummies = -1;
9032        let _ = addmatches(&mut dat, &["a".into(), "b".into()]);
9033        let n = crate::comp_match_handles::matches_arc()
9034            .lock()
9035            .unwrap()
9036            .len();
9037        assert!(n >= 2);
9038    }
9039
9040    /// c:2094 `Cmlist oms = mstack` / c:2623 `mstack = oms` — a `compadd -M`
9041    /// matcher lives only for the duration of that one call. When the port
9042    /// leaked it, `_arguments`' option matcher `r:|[_-]=* r:|=*` stayed live
9043    /// for the `_hosts` compadd that followed and a typed `-` matched every
9044    /// host CONTAINING a `-`, so `ssh -<TAB>` listed
9045    /// `ec2-…compute-1.amazonaws.com` among the options.
9046    #[test]
9047    fn addmatches_restores_mstack_after_the_call() {
9048        let _g = crate::test_util::global_state_lock();
9049        let _g = zle_test_setup();
9050        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9051        fn mstack_depth() -> usize {
9052            let g = mstack.get_or_init(|| Mutex::new(None)).lock().unwrap();
9053            let mut n = 0;
9054            let mut cur = g.as_deref();
9055            while let Some(link) = cur {
9056                n += 1;
9057                cur = link.next.as_deref();
9058            }
9059            n
9060        }
9061        let before = mstack_depth();
9062        let mut dat = Cadata::default();
9063        dat.dummies = -1;
9064        dat.match_ = crate::ported::zle::complete::parse_cmatcher("compadd", "r:|[_-]=* r:|=*");
9065        assert!(dat.match_.is_some(), "matcher spec must parse");
9066        let _ = addmatches(&mut dat, &["a-b".into()]);
9067        assert_eq!(mstack_depth(), before, "matcher leaked past its compadd");
9068    }
9069
9070    #[test]
9071    fn add_match_data_returns_populated_cmatch() {
9072        let _g = crate::test_util::global_state_lock();
9073        let _g = zle_test_setup();
9074        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9075        // c:3052-3067: cm.str/orig/pre/suf populated; mnum bumps by 1.
9076        crate::comp_match_handles::matches_arc()
9077            .lock()
9078            .unwrap()
9079            .clear();
9080        let before = mnum.load(Ordering::Relaxed);
9081        let cm = add_match_data(
9082            0,
9083            "match",
9084            "match-orig",
9085            None,
9086            "ipre",
9087            "ripre",
9088            "isuf",
9089            Some("pre"),
9090            "prpre",
9091            "ppre",
9092            None,
9093            "psuf",
9094            None,
9095            Some("suf"),
9096            0,
9097            0,
9098        );
9099        let cm = cm.expect("no unfinished brace is recorded, so c:2999 must not reject");
9100        assert_eq!(cm.str.as_deref(), Some("match"));
9101        assert_eq!(cm.orig.as_deref(), Some("match-orig"));
9102        assert_eq!(cm.pre.as_deref(), Some("pre"));
9103        assert_eq!(cm.suf.as_deref(), Some("suf"));
9104        assert_eq!(mnum.load(Ordering::Relaxed), before + 1);
9105    }
9106
9107    #[test]
9108    fn add_match_data_exact_records_into_ainfo() {
9109        let _g = crate::test_util::global_state_lock();
9110        let _g = zle_test_setup();
9111        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9112        // c:3037-3058: exact != 0 writes `ai->exact = useexact` and
9113        // `ai->exactm = cm`. The test sets useexact=1 to exercise the
9114        // accept-exact path.
9115        let saved_useexact = useexact.load(Ordering::Relaxed);
9116        useexact.store(1, Ordering::Relaxed);
9117        if let Ok(mut g) = ainfo.get_or_init(|| Mutex::new(None)).lock() {
9118            *g = Some(Aminfo::default());
9119        }
9120        let _ = add_match_data(
9121            0,
9122            "x",
9123            "x",
9124            None,
9125            "",
9126            "",
9127            "",
9128            Some(""),
9129            "",
9130            "",
9131            None,
9132            "",
9133            None,
9134            Some(""),
9135            0,
9136            1,
9137        );
9138        let a = ainfo.get().unwrap().lock().unwrap().clone().unwrap();
9139        useexact.store(saved_useexact, Ordering::Relaxed);
9140        assert_eq!(a.exact, 1);
9141        assert!(a.exactm.is_some());
9142    }
9143
9144    /// Faithful end-to-end exercise of `set_comp_sep` (`compset -q`): an
9145    /// ASCII argument `a b c` with the cursor at the end of the middle
9146    /// word must be re-lexed into three words, narrowed to the cursor
9147    /// word `b`, and split into qp/qs ignored prefix/suffix around it.
9148    /// Drives the real lexer (via `global_state_lock`'s `inittyptab`) and
9149    /// asserts the word split, cursor index, compprefix, and the qp/qs
9150    /// reconstruction — covering the marker/offset arithmetic
9151    /// (`swb-1-sqq+dq`, `p[soffs]` chuck) for the byte-consistent case.
9152    #[test]
9153    fn set_comp_sep_splits_ascii_word() {
9154        use crate::ported::zle::complete::{
9155            COMPCURRENT, COMPISUFFIX, COMPQIPREFIX, COMPQISUFFIX, COMPQUOTE, COMPWORDS,
9156        };
9157        let _g = crate::test_util::global_state_lock();
9158        let _g = zle_test_setup();
9159
9160        let setg = |g: &'static OnceLock<Mutex<String>>, v: &str| {
9161            *g.get_or_init(|| Mutex::new(String::new())).lock().unwrap() = v.to_string();
9162        };
9163        let getg = |g: &'static OnceLock<Mutex<String>>| -> String {
9164            g.get_or_init(|| Mutex::new(String::new()))
9165                .lock()
9166                .unwrap()
9167                .clone()
9168        };
9169
9170        // Reconstructed arg "a b c"; cursor between 'b' and the following
9171        // space: compprefix="a b" (active-prefix len 3), compsuffix=" c".
9172        setg(&COMPPREFIX, "a b");
9173        setg(&COMPSUFFIX, " c");
9174        setg(&COMPIPREFIX, "");
9175        setg(&COMPISUFFIX, "");
9176        setg(&COMPQIPREFIX, "");
9177        setg(&COMPQISUFFIX, "");
9178        setg(&COMPQSTACK, ""); // empty => qttype = QT_NONE (unquoted)
9179        setg(&COMPQUOTE, "");
9180        OFFS.store(0, Ordering::Relaxed);
9181        WB.store(0, Ordering::Relaxed);
9182        WE.store(0, Ordering::Relaxed);
9183        ZLEMETACS.store(0, Ordering::Relaxed);
9184        INSTRING.store(0, Ordering::Relaxed);
9185        INBACKT.store(0, Ordering::Relaxed);
9186
9187        // c:1721 — a real split happened (cur >= 0), so ret == 0.
9188        assert_eq!(set_comp_sep(), 0, "compset -q must split the ASCII word");
9189
9190        // c:1926-1934 — three words, cursor word 'b' with its injected
9191        // 'x' chucked back out.
9192        let words = COMPWORDS
9193            .get_or_init(|| Mutex::new(Vec::new()))
9194            .lock()
9195            .unwrap()
9196            .clone();
9197        assert_eq!(
9198            words,
9199            vec!["a".to_string(), "b".to_string(), "c".to_string()],
9200            "arg must re-lex into three words"
9201        );
9202        // c:1930 — 0-offset cur=1 -> 1-based compcurrent=2.
9203        assert_eq!(COMPCURRENT.load(Ordering::Relaxed), 2);
9204
9205        // c:1894-1906 — prefix/suffix of the cursor word (COMPLETEINWORD off).
9206        assert_eq!(getg(&COMPPREFIX), "b");
9207        assert_eq!(getg(&COMPSUFFIX), "");
9208
9209        // c:1912-1919 — qp/qs fold into compqiprefix/compqisuffix. The
9210        // just-prepended 1-char compqstack makes multiquote(...,1) a
9211        // no-op, so the ignored prefix/suffix are verbatim slices of the
9212        // arg; qip + word + qis must reconstruct "a b c".
9213        let recon = format!(
9214            "{}{}{}",
9215            getg(&COMPQIPREFIX),
9216            getg(&COMPPREFIX),
9217            getg(&COMPQISUFFIX)
9218        );
9219        assert_eq!(recon, "a b c", "qip + word + qis must reconstruct the arg");
9220
9221        // c:1926-1934 + c:complete.c:1235-1295 — in C `$words` / `$CURRENT`
9222        // ARE `compwords` / `compcurrent` (gsu-bound, one storage), so the
9223        // re-split is visible to the calling completer the moment `compset -q`
9224        // returns. zshrs's params are separate paramtab copies, so the port
9225        // has to publish them explicitly. Without that publish `_trap`
9226        //     if [[ CURRENT -eq 2 ]]; then compset -q; _normal; else …
9227        // left `$words` as `(trap -)` with `$CURRENT` still 2, `_normal`
9228        // re-dispatched the command word `trap`, and `_trap` recursed until
9229        // FUNCNEST — printing `maximum nested function level reached` into
9230        // the user's edit buffer on `trap -<TAB>`.
9231        assert_eq!(
9232            crate::ported::params::getaparam("words"),
9233            Some(vec!["a".to_string(), "b".to_string(), "c".to_string()]),
9234            "compset -q must republish $words for the calling completer"
9235        );
9236        assert_eq!(
9237            crate::ported::params::getsparam("CURRENT").as_deref(),
9238            Some("2"),
9239            "compset -q must republish $CURRENT for the calling completer"
9240        );
9241        assert_eq!(
9242            crate::ported::params::getsparam("PREFIX").as_deref(),
9243            Some("b"),
9244            "compset -q must republish $PREFIX for the calling completer"
9245        );
9246    }
9247
9248    #[test]
9249    fn foredel_deletes_forward_from_zlemetacs() {
9250        let _g = crate::test_util::global_state_lock();
9251        let _g = zle_test_setup();
9252        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9253        // zle_utils.c:1105 — delete `ct` chars forward from ZLEMETACS.
9254        if let Ok(mut g) = ZLEMETALINE.get_or_init(|| Mutex::new(String::new())).lock() {
9255            *g = "abcdef".to_string();
9256        }
9257        ZLEMETACS.store(2, Ordering::Relaxed);
9258        ZLEMETALL.store(6, Ordering::Relaxed);
9259        foredel(3, CUT_RAW);
9260        let line = ZLEMETALINE.get().unwrap().lock().unwrap().clone();
9261        assert_eq!(line, "abf");
9262        assert_eq!(ZLEMETALL.load(Ordering::Relaxed), 3);
9263    }
9264
9265    #[test]
9266    fn inststr_inserts_at_zlemetacs() {
9267        let _g = crate::test_util::global_state_lock();
9268        let _g = zle_test_setup();
9269        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9270        // zle_tricky.c:278 — insert at cursor.
9271        if let Ok(mut g) = ZLEMETALINE.get_or_init(|| Mutex::new(String::new())).lock() {
9272            *g = "hello".to_string();
9273        }
9274        ZLEMETACS.store(5, Ordering::Relaxed);
9275        ZLEMETALL.store(5, Ordering::Relaxed);
9276        let _ = inststr(" world");
9277        let line = ZLEMETALINE.get().unwrap().lock().unwrap().clone();
9278        assert_eq!(line, "hello world");
9279        assert_eq!(ZLEMETACS.load(Ordering::Relaxed), 11);
9280    }
9281
9282    #[test]
9283    fn metafy_and_unmetafy_roundtrip_globals() {
9284        let _g = crate::test_util::global_state_lock();
9285        let _g = zle_test_setup();
9286        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9287        // zle_tricky.c:978,995 — meta/unmeta operate on the global pair.
9288        if let Ok(mut g) = ZLELINE.get_or_init(|| Mutex::new(String::new())).lock() {
9289            *g = "plain ascii".to_string();
9290        }
9291        ZLECS.store(3, Ordering::Relaxed);
9292        ZLELL.store(11, Ordering::Relaxed);
9293        metafy_line();
9294        // For ASCII input the meta form equals the raw form.
9295        assert_eq!(
9296            ZLEMETALINE.get().unwrap().lock().unwrap().clone(),
9297            "plain ascii"
9298        );
9299        assert_eq!(ZLEMETACS.load(Ordering::Relaxed), 3);
9300        unmetafy_line();
9301        assert_eq!(
9302            ZLELINE.get().unwrap().lock().unwrap().clone(),
9303            "plain ascii"
9304        );
9305    }
9306
9307    #[test]
9308    fn selfinsert_appends_lastchar_at_zlecs() {
9309        let _g = crate::test_util::global_state_lock();
9310        let _g = zle_test_setup();
9311        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9312        // zle_misc.c:112-141 — insert one char at cursor, bump zlecs.
9313        //
9314        // `selfinsert()` (zle_misc.rs:180) writes through
9315        // `self_insert(c)` which mutates the `zle_main::ZLELINE`
9316        // (`Mutex<Vec<char>>`) plus `zle_main::ZLECS`/`ZLELL` —
9317        // NOT the `compcore::ZLELINE` (`OnceLock<Mutex<String>>`)
9318        // used by the meta/unmeta tests above. The original test
9319        // seeded the wrong buffer, so the assert kept seeing "ab"
9320        // (the compcore buffer never received the insert) while
9321        // self_insert silently appended 'c' to the zle_main buffer.
9322        //
9323        // Set up the zle_main buffer for the insert, then read back
9324        // from there. `zle_test_setup` already clears the zle_main
9325        // statics, so we start from a known zero state.
9326        {
9327            let mut g = crate::ported::zle::zle_main::ZLELINE.lock().unwrap();
9328            *g = "ab".chars().collect();
9329        }
9330        crate::ported::zle::zle_main::ZLECS.store(2, Ordering::Relaxed);
9331        crate::ported::zle::zle_main::ZLELL.store(2, Ordering::Relaxed);
9332        LASTCHAR.store(b'c' as i32, Ordering::Relaxed);
9333        // Force the wide-char re-derive path (lastchar_wide_valid=0 →
9334        // selfinsert refills it from LASTCHAR per zle_misc.c:119-122).
9335        LASTCHAR_WIDE_VALID.store(0, Ordering::Relaxed);
9336        let rv = selfinsert(&[]);
9337        assert_eq!(rv, 0);
9338        let buf: String = crate::ported::zle::zle_main::ZLELINE
9339            .lock()
9340            .unwrap()
9341            .iter()
9342            .collect();
9343        assert_eq!(buf, "abc");
9344        assert_eq!(
9345            crate::ported::zle::zle_main::ZLECS.load(Ordering::Relaxed),
9346            3,
9347        );
9348    }
9349
9350    #[test]
9351    fn minfo_clear_and_asked_zero_mutate_state() {
9352        let _g = crate::test_util::global_state_lock();
9353        let _g = zle_test_setup();
9354        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9355        if let Ok(mut g) = MINFO.get_or_init(|| Mutex::new(Menuinfo::default())).lock() {
9356            let mut cm = Cmatch::default();
9357            cm.str = Some("x".into());
9358            g.cur = Some(Box::new(cm));
9359            g.asked = 1;
9360        }
9361        if let Ok(mut g) = MINFO.get_or_init(|| Mutex::new(Menuinfo::default())).lock() {
9362            g.cur = None;
9363        }
9364        if let Ok(mut g) = MINFO.get_or_init(|| Mutex::new(Menuinfo::default())).lock() {
9365            g.asked = 0;
9366        }
9367        let m = MINFO.get().unwrap().lock().unwrap().clone();
9368        assert!(m.cur.is_none());
9369        assert_eq!(m.asked, 0);
9370    }
9371
9372    #[test]
9373    fn cline_matched_stub_marks_node() {
9374        let _g = crate::test_util::global_state_lock();
9375        let _g = zle_test_setup();
9376        // compmatch.c:253 — sets CLF_MATCHED on the node chain. We
9377        // verify by running through the stub on a non-empty string
9378        // without panicking and trusting compmatch's body for the
9379        // actual flag set.
9380        cline_matched_compcore(Some("foo"));
9381        cline_matched_compcore(None);
9382        cline_matched_compcore(Some(""));
9383    }
9384
9385    #[test]
9386    fn permmatches_returns_fi_zero_when_count_present() {
9387        let _g = crate::test_util::global_state_lock();
9388        let _g = zle_test_setup();
9389        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9390        // c:3444-3447: if ainfo->count is non-zero, fi stays 0.
9391        amatches
9392            .get_or_init(|| Mutex::new(Vec::new()))
9393            .lock()
9394            .unwrap()
9395            .clear();
9396        pmatches
9397            .get_or_init(|| Mutex::new(Vec::new()))
9398            .lock()
9399            .unwrap()
9400            .clear();
9401        if let Ok(mut a) = ainfo.get_or_init(|| Mutex::new(None)).lock() {
9402            *a = Some(Aminfo {
9403                count: 5,
9404                ..Default::default()
9405            });
9406        }
9407        newmatches.store(1, Ordering::Relaxed);
9408        let fi = permmatches(0);
9409        assert_eq!(fi, 0);
9410        assert_eq!(hasperm.load(Ordering::Relaxed), 1);
9411    }
9412
9413    /// c:2068 / c:3010 / c:3154 / c:3162 — `mgroup->new = 1` MUST be visible
9414    /// through the `amatches` chain, because in C `mgroup` and the `amatches`
9415    /// entry are the same `struct cmgroup` (`begcmgroup` c:3087 on reuse,
9416    /// c:3100+c:3123 on create). `permmatches` reads the flag while walking
9417    /// `amatches` (c:3452 `if (fi != ofi || !g->perm || g->new)`) and clears it
9418    /// there (c:3544).
9419    ///
9420    /// With `new_` a per-clone `i32`, the write landed only on the `mgroup`
9421    /// copy, so an OPEN group's freshly added matches were invisible: the c:3452
9422    /// test took its reuse branch and c:3536 added the group's STALE `mcount` to
9423    /// `nmatches`. `$compstate[nmatches]` is live through `get_nmatches`
9424    /// (`Src/Zle/complete.c:1411-1413`), so every completer that returns
9425    /// `[[ nm -ne compstate[nmatches] ]]` — `Completion/Unix/Type/_path_files`
9426    /// sh:895 — reported "added nothing" and the whole completer chain ran past
9427    /// the completer that should have ended it.
9428    #[test]
9429    fn mgroup_new_flag_is_shared_with_the_amatches_entry() {
9430        let _g = crate::test_util::global_state_lock();
9431        let _g = zle_test_setup();
9432        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9433        amatches
9434            .get_or_init(|| Mutex::new(Vec::new()))
9435            .lock()
9436            .unwrap()
9437            .clear();
9438
9439        begcmgroup(Some("corrections"), 0); // c:3072
9440                                            // c:2068 — exactly the write `addmatch` performs, through `mgroup`.
9441        mgroup
9442            .get_or_init(|| Mutex::new(None))
9443            .lock()
9444            .unwrap()
9445            .as_ref()
9446            .expect("begcmgroup must leave a current group")
9447            .new_
9448            .store(1, Ordering::Relaxed);
9449
9450        let seen = amatches
9451            .get_or_init(|| Mutex::new(Vec::new()))
9452            .lock()
9453            .unwrap()
9454            .iter()
9455            .find(|g| g.name.as_deref() == Some("corrections"))
9456            .map(|g| g.new_.load(Ordering::Relaxed));
9457        assert_eq!(
9458            seen,
9459            Some(1),
9460            "c:3452 permmatches reads `g->new` off the amatches walk, so the \
9461             mgroup write must alias it"
9462        );
9463
9464        // c:3544 — and the clear on the amatches walk must reach `mgroup` too.
9465        amatches
9466            .get_or_init(|| Mutex::new(Vec::new()))
9467            .lock()
9468            .unwrap()
9469            .iter()
9470            .for_each(|g| g.new_.store(0, Ordering::Relaxed));
9471        assert_eq!(
9472            mgroup
9473                .get_or_init(|| Mutex::new(None))
9474                .lock()
9475                .unwrap()
9476                .as_ref()
9477                .unwrap()
9478                .new_
9479                .load(Ordering::Relaxed),
9480            0
9481        );
9482    }
9483
9484    /// c:3449-3544 / c:3087 — `permmatches` walks `amatches` in place and
9485    /// `begcmgroup` re-opening an existing group aliases it. The port takes
9486    /// the groups out of `amatches` for the walk and leaves the group's sorted
9487    /// `matches` and `perm` out of `mgroup`'s copy; both must leave the
9488    /// `amatches` entry whole: a re-opened group keeps its permanent copy (so
9489    /// an unchanged group is served from it, c:3534) and new matches added
9490    /// after the re-open are sorted in with the old ones on the next read.
9491    #[test]
9492    fn permmatches_and_group_reopen_keep_the_amatches_group_whole() {
9493        let _g = crate::test_util::global_state_lock();
9494        let _g = zle_test_setup();
9495        let _g = GLOBAL_MUT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
9496        amatches.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap().clear();
9497        pmatches.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap().clear();
9498        if let Ok(mut a) = ainfo.get_or_init(|| Mutex::new(None)).lock() {
9499            *a = Some(Aminfo { count: 1, ..Default::default() });
9500        }
9501        MATCHORDER.store(0, Ordering::Relaxed);
9502        let add = |words: &[&str]| {
9503            let cell = crate::comp_match_handles::matches_arc();
9504            let mut l = cell.lock().unwrap();
9505            for w in words {
9506                let mut m = Cmatch::default();
9507                m.str = Some(w.to_string());
9508                l.push(m);
9509            }
9510            drop(l);
9511            mgroup.get_or_init(|| Mutex::new(None)).lock().unwrap()
9512                .as_ref().unwrap().new_.store(1, Ordering::Relaxed);
9513            newmatches.store(1, Ordering::Relaxed);
9514        };
9515        let pm_strs = || -> Vec<String> {
9516            pmatches.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap()
9517                .iter().flat_map(|g| g.matches.iter().map(|m| m.str.clone().unwrap()))
9518                .collect()
9519        };
9520
9521        begcmgroup(Some("big"), 0);
9522        add(&["delta", "alpha", "charlie"]);
9523        permmatches(0);
9524        assert_eq!(pm_strs(), ["alpha", "charlie", "delta"]);
9525
9526        // Re-open the same group: `mgroup` aliases it, `amatches` keeps both
9527        // the sorted array and the permanent copy.
9528        begcmgroup(Some("big"), 0);
9529        {
9530            let am = amatches.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap();
9531            let g = am.iter().find(|g| g.name.as_deref() == Some("big")).unwrap();
9532            assert_eq!(g.matches.len(), 3, "re-open must not strip the sorted array");
9533            assert_eq!(g.perm.as_ref().map(|p| p.matches.len()), Some(3));
9534        }
9535        // c:3433 — nothing new: the cached permanent copy is served.
9536        newmatches.store(1, Ordering::Relaxed);
9537        permmatches(0);
9538        assert_eq!(pm_strs(), ["alpha", "charlie", "delta"]);
9539        assert_eq!(nmatches.load(Ordering::Relaxed), 3);
9540
9541        add(&["bravo"]);
9542        permmatches(0);
9543        assert_eq!(pm_strs(), ["alpha", "bravo", "charlie", "delta"]);
9544        assert_eq!(nmatches.load(Ordering::Relaxed), 4);
9545        assert_eq!(
9546            amatches.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap().len(),
9547            1,
9548            "the walk must put every group back"
9549        );
9550    }
9551
9552    /// c:1323 — `rembslash` removes backslash escapes by walking the
9553    /// string and dropping every `\` while keeping its successor.
9554    /// `\\\\` (literal `\\`) → `\` (single backslash); `\a` → `a`.
9555    /// A regression that drops the successor too would silently strip
9556    /// real chars from `path/to/\$file`.
9557    #[test]
9558    fn rembslash_unescapes_canonical_pairs() {
9559        let _g = crate::test_util::global_state_lock();
9560        assert_eq!(rembslash(r"\a"), "a");
9561        assert_eq!(rembslash(r"\\"), r"\");
9562        assert_eq!(rembslash(r"\$foo"), "$foo");
9563        assert_eq!(rembslash("plain"), "plain");
9564    }
9565
9566    /// c:1323 — empty input → empty output (the loop never runs).
9567    /// Catches a regression that returns " " or "\0" for empty input.
9568    #[test]
9569    fn rembslash_empty_input_returns_empty() {
9570        let _g = crate::test_util::global_state_lock();
9571        assert_eq!(rembslash(""), "");
9572    }
9573
9574    /// c:1323 — trailing lone `\` MUST silently drop (no following
9575    /// char to keep). C's pattern `if (let Some) { push }` is
9576    /// equivalent. Regression that pushes the literal `\` would break
9577    /// shell paths with trailing backslashes (rare but legal).
9578    #[test]
9579    fn rembslash_trailing_lone_backslash_drops_silently() {
9580        let _g = crate::test_util::global_state_lock();
9581        assert_eq!(rembslash(r"foo\"), "foo");
9582    }
9583
9584    /// c:1366 — `ctokenize` is the inverse of `untokenize`: escapes
9585    /// shell metacharacters into their tokenised forms used by the
9586    /// completion machinery. Plain alphanumerics pass through.
9587    #[test]
9588    fn ctokenize_passes_alphanumerics_through() {
9589        let _g = crate::test_util::global_state_lock();
9590        assert_eq!(ctokenize("foo123"), "foo123");
9591        assert_eq!(ctokenize(""), "");
9592        assert_eq!(ctokenize("path/to"), "path/to");
9593    }
9594
9595    /// c:1435 — `comp_quoting_string` returns one of the canonical
9596    /// quote strings: `'`, `"`, `$'`, or "" depending on `stype`.
9597    /// Catches a regression where the dispatch returns the wrong
9598    /// quote — completion would generate `cmd 'arg"` (mismatched).
9599    #[test]
9600    fn comp_quoting_string_dispatches_known_styles() {
9601        let _g = crate::test_util::global_state_lock();
9602        // The exact stype values are private to the completion impl,
9603        // but the function MUST return a non-panicking string for
9604        // every reasonable input. Probe a few values.
9605        for stype in 0..=8 {
9606            let _ = comp_quoting_string(stype);
9607        }
9608    }
9609
9610    /// c:1065 — `multiquote` with empty COMPQSTACK is a no-op (returns
9611    /// input unchanged). The stack is the per-completion quoting
9612    /// context; outside completion it's empty. Regression that quotes
9613    /// regardless would corrupt every non-completion caller.
9614    #[test]
9615    fn multiquote_empty_stack_returns_input_unchanged() {
9616        let _g = crate::test_util::global_state_lock();
9617        // Reset COMPQSTACK to empty.
9618        if let Some(c) = COMPQSTACK.get() {
9619            if let Ok(mut g) = c.lock() {
9620                g.clear();
9621            }
9622        }
9623        assert_eq!(multiquote("hello", 0), "hello");
9624        assert_eq!(multiquote("", 0), "");
9625    }
9626
9627    /// c:1073 — `multiquote` ESCAPES for the active quoting level; it must
9628    /// never WRAP the candidate in quote characters. `comp_match`
9629    /// (compmatch.c:1172) runs every compadd candidate through it and then
9630    /// matches the result against `$PREFIX` (compmatch.c:1178), so a wrapped
9631    /// candidate can never share a prefix with the typed word.
9632    ///
9633    /// Regression pinned: `quotestring(QT_DOUBLE)` used to return `"abcdef"`
9634    /// (with the quote pair) instead of the body `abcdef`, against C's
9635    /// contract at utils.c:6131-6134. Inside a quoted word — `cmd "ab<TAB>`,
9636    /// where compqstack holds QT_DOUBLE — `comp_match` compared `ab` to
9637    /// `"abcdef"`, rejected it, and `compadd` produced ZERO matches, so
9638    /// completion silently did nothing for EVERY quoted word.
9639    #[test]
9640    fn multiquote_escapes_but_never_wraps_the_candidate() {
9641        let _g = crate::test_util::global_state_lock();
9642        for (qt, label) in [
9643            (QT_DOUBLE, "QT_DOUBLE"),
9644            (QT_SINGLE, "QT_SINGLE"),
9645            (QT_DOLLARS, "QT_DOLLARS"),
9646            (QT_BACKSLASH, "QT_BACKSLASH"),
9647        ] {
9648            if let Ok(mut g) = COMPQSTACK.get_or_init(|| Mutex::new(String::new())).lock() {
9649                *g = (qt as u8 as char).to_string(); // c:305-306
9650            }
9651            assert_eq!(
9652                multiquote("abcdef", 0),
9653                "abcdef",
9654                "{label}: a candidate with nothing to escape must pass through \
9655                 unchanged so comp_match can prefix-match it"
9656            );
9657        }
9658        if let Some(c) = COMPQSTACK.get() {
9659            if let Ok(mut g) = c.lock() {
9660                g.clear();
9661            }
9662        }
9663    }
9664
9665    /// c:1092 — `tildequote("foo")` (no leading ~) MUST behave like
9666    /// multiquote — the tilde-special path is a no-op when there's no
9667    /// `~` to protect. Regression that always strips/restores would
9668    /// silently mangle non-tilde inputs.
9669    #[test]
9670    fn tildequote_non_tilde_input_unchanged() {
9671        let _g = crate::test_util::global_state_lock();
9672        // Empty COMPQSTACK + no ~ → input unchanged.
9673        if let Some(c) = COMPQSTACK.get() {
9674            if let Ok(mut g) = c.lock() {
9675                g.clear();
9676            }
9677        }
9678        assert_eq!(tildequote("foo/bar", 0), "foo/bar");
9679    }
9680
9681    /// c:1092 — empty input through tildequote is empty out.
9682    #[test]
9683    fn tildequote_empty_input_empty_output() {
9684        let _g = crate::test_util::global_state_lock();
9685        if let Some(c) = COMPQSTACK.get() {
9686            if let Ok(mut g) = c.lock() {
9687                g.clear();
9688            }
9689        }
9690        assert_eq!(tildequote("", 0), "");
9691    }
9692
9693    // ─── zsh-corpus pins for rembslash ─────────────────────────────
9694
9695    /// `rembslash("\\a\\b\\c")` strips backslashes → "abc".
9696    #[test]
9697    fn compcore_corpus_rembslash_strips_escapes() {
9698        let _g = crate::test_util::global_state_lock();
9699        assert_eq!(rembslash(r"\a\b\c"), "abc");
9700    }
9701
9702    /// `rembslash` with no backslashes is identity.
9703    #[test]
9704    fn compcore_corpus_rembslash_no_escapes_identity() {
9705        let _g = crate::test_util::global_state_lock();
9706        assert_eq!(rembslash("hello"), "hello");
9707    }
9708
9709    /// `rembslash("")` returns empty string.
9710    #[test]
9711    fn compcore_corpus_rembslash_empty_is_empty() {
9712        let _g = crate::test_util::global_state_lock();
9713        assert_eq!(rembslash(""), "");
9714    }
9715
9716    /// `rembslash("\\\\")` (escaped backslash) returns "\\".
9717    #[test]
9718    fn compcore_corpus_rembslash_escaped_backslash() {
9719        let _g = crate::test_util::global_state_lock();
9720        // r"\\" in Rust is the 2-char string "\\\\"  → one literal backslash + one literal backslash
9721        assert_eq!(rembslash(r"\\"), r"\");
9722    }
9723
9724    /// `rembslash` mid-string escape preserves surroundings.
9725    #[test]
9726    fn compcore_corpus_rembslash_preserves_context() {
9727        let _g = crate::test_util::global_state_lock();
9728        assert_eq!(rembslash(r"hello\ world"), "hello world");
9729    }
9730
9731    /// Trailing single backslash is consumed (no char after).
9732    #[test]
9733    fn compcore_corpus_rembslash_trailing_backslash_consumed() {
9734        let _g = crate::test_util::global_state_lock();
9735        assert_eq!(rembslash(r"abc\"), "abc");
9736    }
9737
9738    // ═══════════════════════════════════════════════════════════════════
9739    // Additional C-parity tests for Src/Zle/compcore.c rembslash + remsquote
9740    // + ctokenize + multiquote / tildequote string transforms.
9741    // ═══════════════════════════════════════════════════════════════════
9742
9743    /// c:1323 — `rembslash("abc")` (no backslash) is identity.
9744    #[test]
9745    fn rembslash_no_backslash_is_identity() {
9746        let _g = crate::test_util::global_state_lock();
9747        assert_eq!(rembslash("abc"), "abc");
9748        assert_eq!(rembslash("hello world"), "hello world");
9749    }
9750
9751    /// c:1323 — `rembslash` of single backslash + char drops the backslash.
9752    #[test]
9753    fn rembslash_drops_backslash_keeps_next() {
9754        let _g = crate::test_util::global_state_lock();
9755        assert_eq!(rembslash(r"\a"), "a");
9756        assert_eq!(rembslash(r"\."), ".");
9757        assert_eq!(rembslash(r"\$"), "$");
9758    }
9759
9760    /// c:1323 — repeated escapes: every `\X` collapses to `X`.
9761    #[test]
9762    fn rembslash_multiple_escapes_chain() {
9763        let _g = crate::test_util::global_state_lock();
9764        assert_eq!(rembslash(r"\a\b\c"), "abc");
9765    }
9766
9767    /// c:1343 — `remsquote("")` returns 0 (no chars consumed).
9768    #[test]
9769    fn remsquote_empty_returns_zero() {
9770        let _g = crate::test_util::global_state_lock();
9771        let mut s = String::new();
9772        let r = remsquote(&mut s);
9773        assert_eq!(r, 0);
9774        assert_eq!(s, "");
9775    }
9776
9777    /// c:1343 — `remsquote("abc")` (no quote sequences) is identity,
9778    /// returns 0.
9779    #[test]
9780    fn remsquote_no_quotes_is_identity() {
9781        let _g = crate::test_util::global_state_lock();
9782        let mut s = String::from("hello world");
9783        let r = remsquote(&mut s);
9784        assert_eq!(r, 0, "no quote sequences → 0");
9785        assert_eq!(s, "hello world", "string unchanged");
9786    }
9787
9788    /// c:1366 — `ctokenize("")` returns empty string.
9789    #[test]
9790    fn ctokenize_empty_returns_empty() {
9791        let _g = crate::test_util::global_state_lock();
9792        assert_eq!(ctokenize(""), "");
9793    }
9794
9795    /// c:1366 — `ctokenize("abc")` (no special chars) preserves content.
9796    #[test]
9797    fn ctokenize_plain_ascii_preserved() {
9798        let _g = crate::test_util::global_state_lock();
9799        // No $, {, }, or backslash → byte-for-byte preservation.
9800        let r = ctokenize("abc");
9801        assert_eq!(r.as_bytes()[0], b'a');
9802        assert_eq!(r.as_bytes()[1], b'b');
9803        assert_eq!(r.as_bytes()[2], b'c');
9804    }
9805
9806    /// c:1505 — `comp_quoting_string(0)` returns a static str (no panic).
9807    #[test]
9808    fn comp_quoting_string_returns_static_str_for_all_stypes() {
9809        let _g = crate::test_util::global_state_lock();
9810        for stype in 0..10 {
9811            let _s = comp_quoting_string(stype);
9812            // No panic = pass; returned &'static str is well-defined.
9813        }
9814    }
9815
9816    /// c:954 — `multiquote("")` returns empty.
9817    #[test]
9818    fn multiquote_empty_returns_empty() {
9819        let _g = crate::test_util::global_state_lock();
9820        let _g2 = zle_test_setup();
9821        let r = multiquote("", 0);
9822        assert_eq!(r, "");
9823    }
9824
9825    /// c:980 — `tildequote("")` returns empty.
9826    #[test]
9827    fn tildequote_empty_returns_empty() {
9828        let _g = crate::test_util::global_state_lock();
9829        let _g2 = zle_test_setup();
9830        let r = tildequote("", 0);
9831        assert_eq!(r, "");
9832    }
9833
9834    /// c:980 — `tildequote("plain")` (no tilde) is identity.
9835    #[test]
9836    fn tildequote_no_tilde_is_identity() {
9837        let _g = crate::test_util::global_state_lock();
9838        let _g2 = zle_test_setup();
9839        let r = tildequote("plain", 0);
9840        assert_eq!(r, "plain", "no tilde → input unchanged");
9841    }
9842
9843    // ═══════════════════════════════════════════════════════════════════
9844    // Additional C-parity tests for Src/Zle/compcore.c
9845    // c:936 multiquote / c:972 tildequote / c:1014 check_param /
9846    // c:1315 rembslash / c:1338 remsquote / c:1381 ctokenize /
9847    // c:1478 comp_quoting_string / c:2809 matchcmp / c:2872 matcheq
9848    // ═══════════════════════════════════════════════════════════════════
9849
9850    /// c:1315 — `rembslash("")` empty returns empty.
9851    #[test]
9852    fn rembslash_empty_returns_empty() {
9853        assert_eq!(rembslash(""), "");
9854    }
9855
9856    /// c:1315 — `rembslash` is pure.
9857    #[test]
9858    fn rembslash_is_pure() {
9859        for s in ["", "abc", r"\a", r"\\\\"] {
9860            let first = rembslash(s);
9861            for _ in 0..3 {
9862                assert_eq!(rembslash(s), first, "rembslash({:?}) must be pure", s);
9863            }
9864        }
9865    }
9866
9867    /// c:1381 — `ctokenize` is deterministic.
9868    #[test]
9869    fn ctokenize_is_deterministic() {
9870        for s in ["", "abc", "a*b", "a?b"] {
9871            let first = ctokenize(s);
9872            for _ in 0..3 {
9873                assert_eq!(
9874                    ctokenize(s),
9875                    first,
9876                    "ctokenize({:?}) must be deterministic",
9877                    s
9878                );
9879            }
9880        }
9881    }
9882
9883    /// c:1478 — `comp_quoting_string(0)` returns non-empty static.
9884    #[test]
9885    fn comp_quoting_string_zero_returns_static_str() {
9886        let _: &'static str = comp_quoting_string(0);
9887    }
9888
9889    /// c:936 — `multiquote` is pure for arbitrary input.
9890    #[test]
9891    fn multiquote_is_pure() {
9892        let _g = crate::test_util::global_state_lock();
9893        let _g2 = zle_test_setup();
9894        for s in ["", "abc", "x y"] {
9895            let first = multiquote(s, 0);
9896            for _ in 0..3 {
9897                assert_eq!(
9898                    multiquote(s, 0),
9899                    first,
9900                    "multiquote({:?}, 0) must be pure",
9901                    s
9902                );
9903            }
9904        }
9905    }
9906
9907    /// c:972 — `tildequote` is pure for non-tilde input.
9908    #[test]
9909    fn tildequote_is_pure() {
9910        let _g = crate::test_util::global_state_lock();
9911        let _g2 = zle_test_setup();
9912        for s in ["", "abc", "no_tilde", "/path/to/file"] {
9913            let first = tildequote(s, 0);
9914            for _ in 0..3 {
9915                assert_eq!(
9916                    tildequote(s, 0),
9917                    first,
9918                    "tildequote({:?}, 0) must be pure",
9919                    s
9920                );
9921            }
9922        }
9923    }
9924
9925    /// c:1338 — `remsquote(&mut empty)` returns 0.
9926    #[test]
9927    fn remsquote_empty_returns_zero_pin() {
9928        let mut s = String::new();
9929        let r = remsquote(&mut s);
9930        assert_eq!(r, 0, "empty input → 0");
9931    }
9932
9933    /// c:1014 — `check_param("")` empty returns Option<usize>.
9934    #[test]
9935    fn check_param_empty_returns_option_type() {
9936        let _g = crate::test_util::global_state_lock();
9937        let _g2 = zle_test_setup();
9938        let _: Option<usize> = check_param("", false, false, false);
9939    }
9940
9941    /// c:1014 — `check_param` is deterministic for empty input.
9942    #[test]
9943    fn check_param_empty_is_deterministic() {
9944        let _g = crate::test_util::global_state_lock();
9945        let _g2 = zle_test_setup();
9946        let first = check_param("", false, false, false);
9947        for _ in 0..3 {
9948            assert_eq!(check_param("", false, false, false), first);
9949        }
9950    }
9951
9952    /// c:1439 — `comp_str(false)` returns (String, i32, i32) tuple.
9953    #[test]
9954    fn comp_str_returns_string_i32_i32_tuple() {
9955        let _g = crate::test_util::global_state_lock();
9956        let _g2 = zle_test_setup();
9957        let _: (String, i32, i32) = comp_str(false);
9958    }
9959
9960    // ═══════════════════════════════════════════════════════════════════
9961    // Additional C-parity tests for Src/Zle/compcore.c
9962    // c:1505 set_comp_sep / c:1544 set_list_array / c:1555 get_user_var /
9963    // c:1645 get_data_arr / c:2681 begcmgroup / c:2735 endcmgroup /
9964    // c:2749 addexpl / c:1478 comp_quoting_string
9965    // ═══════════════════════════════════════════════════════════════════
9966
9967    /// c:1505 — `set_comp_sep` returns i32 (compile-time type pin).
9968    #[test]
9969    fn set_comp_sep_returns_i32_type() {
9970        let _g = crate::test_util::global_state_lock();
9971        let _g2 = zle_test_setup();
9972        let _: i32 = set_comp_sep();
9973    }
9974
9975    /// c:1555 — `get_user_var(None)` returns Option<Vec<String>>.
9976    #[test]
9977    fn get_user_var_returns_option_vec_string_type() {
9978        let _g = crate::test_util::global_state_lock();
9979        let _g2 = zle_test_setup();
9980        let _: Option<Vec<String>> = get_user_var(None);
9981    }
9982
9983    /// c:1555 — `get_user_var(None)` is deterministic.
9984    #[test]
9985    fn get_user_var_none_is_deterministic() {
9986        let _g = crate::test_util::global_state_lock();
9987        let _g2 = zle_test_setup();
9988        let first = get_user_var(None);
9989        for _ in 0..3 {
9990            assert_eq!(
9991                get_user_var(None),
9992                first,
9993                "get_user_var(None) must be deterministic"
9994            );
9995        }
9996    }
9997
9998    /// c:1645 — `get_data_arr("", false)` returns Option<Vec<String>>.
9999    #[test]
10000    fn get_data_arr_returns_option_vec_string_type() {
10001        let _g = crate::test_util::global_state_lock();
10002        let _g2 = zle_test_setup();
10003        let _: Option<Vec<String>> = get_data_arr("", false);
10004    }
10005
10006    /// c:1645 — `get_data_arr("", _)` empty name returns None.
10007    #[test]
10008    fn get_data_arr_empty_name_returns_none() {
10009        let _g = crate::test_util::global_state_lock();
10010        let _g2 = zle_test_setup();
10011        assert!(get_data_arr("", false).is_none(), "empty name → None");
10012        assert!(
10013            get_data_arr("", true).is_none(),
10014            "empty name (keys=true) → None"
10015        );
10016    }
10017
10018    /// c:2681 — `begcmgroup(None, 0)` safe.
10019    #[test]
10020    fn begcmgroup_none_no_panic() {
10021        let _g = crate::test_util::global_state_lock();
10022        let _g2 = zle_test_setup();
10023        begcmgroup(None, 0);
10024    }
10025
10026    /// c:2735 — `endcmgroup(None)` safe.
10027    #[test]
10028    fn endcmgroup_none_no_panic() {
10029        let _g = crate::test_util::global_state_lock();
10030        let _g2 = zle_test_setup();
10031        endcmgroup(None);
10032    }
10033
10034    /// c:2681 + c:2735 — beg/end cmgroup round-trip safe.
10035    #[test]
10036    fn begcmgroup_endcmgroup_round_trip_safe() {
10037        let _g = crate::test_util::global_state_lock();
10038        let _g2 = zle_test_setup();
10039        for _ in 0..3 {
10040            begcmgroup(None, 0);
10041            endcmgroup(None);
10042        }
10043    }
10044
10045    /// c:2749 — `addexpl(false)` safe.
10046    #[test]
10047    fn addexpl_false_no_panic() {
10048        let _g = crate::test_util::global_state_lock();
10049        let _g2 = zle_test_setup();
10050        addexpl(false);
10051    }
10052
10053    /// c:1544 — `set_list_array("", &[])` empty inputs is safe.
10054    #[test]
10055    fn set_list_array_empty_no_panic() {
10056        let _g = crate::test_util::global_state_lock();
10057        let _g2 = zle_test_setup();
10058        set_list_array("", &[]);
10059    }
10060
10061    /// c:1478 — `comp_quoting_string(N)` returns &'static str (type pin).
10062    #[test]
10063    fn comp_quoting_string_returns_static_str_type() {
10064        let _: &'static str = comp_quoting_string(0);
10065    }
10066
10067    /// c:1478 — `comp_quoting_string` is pure across stypes.
10068    #[test]
10069    fn comp_quoting_string_pure_across_stypes() {
10070        for stype in 0..10 {
10071            let first = comp_quoting_string(stype);
10072            for _ in 0..3 {
10073                assert_eq!(
10074                    comp_quoting_string(stype),
10075                    first,
10076                    "comp_quoting_string({}) must be pure",
10077                    stype
10078                );
10079            }
10080        }
10081    }
10082
10083    /// c:Src/Zle/compcore.c:483-489 — `before_complete` runs before
10084    /// metafication and moves the EDITOR cursor to `lastend` when the last
10085    /// completion left it inside the word (`FC_INWORD`), clamped to the line
10086    /// length. docomplete copies the editor cursor into the completion buffer
10087    /// after the hook, so the store has to land on `zle_main::ZLECS`. It used to
10088    /// land on `ZLEMETACS`, which that copy overwrites: the second TAB of
10089    /// `tst aB` (cursor left at `a|BC`) completed PREFIX=a SUFFIX=BC where zsh
10090    /// completes PREFIX=aBC (Y02compmatch #27, #40).
10091    #[test]
10092    fn before_complete_in_word_moves_the_editor_cursor_to_lastend() {
10093        let _g = crate::test_util::global_state_lock();
10094        let _g = zle_test_setup();
10095        use crate::ported::zle::zle_main::{ZLECS as EDCS, ZLELL as EDLL};
10096        let s = Ordering::SeqCst;
10097        if let Some(m) = MINFO.get() {
10098            m.lock().unwrap().cur = None;
10099        }
10100        MENUCMP.store(0, s);
10101        startauto.store(0, s);
10102
10103        // `tst aBC`, cursor at `a|BC`, last completion ended at 7.
10104        EDLL.store(7, s);
10105        EDCS.store(5, s);
10106        lastend.store(7, s);
10107        fromcomp.store(crate::ported::zle::comp_h::FC_INWORD, s);
10108        let mut lst = COMP_COMPLETE;
10109        assert_eq!(before_complete(&mut lst), 0);
10110        assert_eq!(EDCS.load(s), 7, "c:488 — zlecs = lastend");
10111
10112        // c:489 — clamped to zlell.
10113        EDCS.store(5, s);
10114        lastend.store(9, s);
10115        before_complete(&mut lst);
10116        assert_eq!(EDCS.load(s), 7, "c:489 — zlecs = zlell when lastend > zlell");
10117
10118        // Without FC_INWORD the cursor stays where the user left it.
10119        EDCS.store(5, s);
10120        fromcomp.store(0, s);
10121        before_complete(&mut lst);
10122        assert_eq!(EDCS.load(s), 5, "no FC_INWORD, no move");
10123
10124        lastend.store(0, s);
10125        EDCS.store(0, s);
10126        EDLL.store(0, s);
10127    }
10128}