Skip to main content

zsh/ported/
parse.rs

1//! Zsh parser — direct port from zsh/Src/parse.c.
2//!
3//! Pulls tokens via the lex.rs free ported (zshlex/tok/tokstr) and
4//! builds an AST tree (relocated to src/extensions/zsh_ast.rs as a
5//! Rust-only IR) plus emits wordcode into ECBUF via the P9b/P9c
6//! pipeline. Follows the zsh grammar closely; productions match
7//! `par_*` in Src/parse.c.
8
9use super::lex::{
10    lextok, set_tok, AMPER, AMPERBANG, AMPOUTANG, BANG_TOK, BARAMP, BAR_TOK, CASE, COPROC, DAMPER,
11    DBAR, DINANG, DINANGDASH, DINBRACK, DINPAR, DOLOOP, DONE, DOUTANG, DOUTANGAMP, DOUTANGAMPBANG,
12    DOUTANGBANG, DOUTBRACK, DOUTPAR, DSEMI, ELIF, ELSE, ENDINPUT, ENVARRAY, ENVSTRING, ESAC, FI,
13    FOR, FOREACH, FUNC, IF, INANGAMP, INANG_TOK, INBRACE_TOK, INOUTANG, INOUTPAR, INPAR_TOK,
14    IS_REDIROP, LEXERR, LEX_HEREDOCS, NEWLIN, NOCORRECT, OUTANGAMP, OUTANGAMPBANG, OUTANGBANG,
15    OUTANG_TOK, OUTBRACE_TOK, OUTPAR_TOK, REPEAT, SELECT, SEMI, SEMIAMP, SEMIBAR, SEPER,
16    STRING_LEX, THEN, TIME, TRINANG, TYPESET, UNTIL, WHILE, ZEND,
17};
18use super::zsh_h::{
19    eprog, estate, funcdump, isset, redir, unset, wc_code, wordcode, Bang, Dash, Equals, Inang,
20    Outang, Tilde, ALIASFUNCDEF, COND_AND, COND_MOD, COND_MODI, COND_NOT, COND_NT, COND_OR,
21    COND_REGEX, COND_STRDEQ, COND_STREQ, COND_STRGTR, COND_STRLT, COND_STRNEQ, CSHJUNKIELOOPS,
22    EC_DUP, EC_NODUP, EF_HEAP, EF_REAL, EXECOPT, IGNOREBRACES, IS_DASH, MULTIFUNCDEF, OPT_ISSET,
23    PM_UNDEFINED, POSIXBUILTINS, REDIRF_FROM_HEREDOC, REDIR_APP, REDIR_APPNOW, REDIR_ERRAPP,
24    REDIR_ERRAPPNOW, REDIR_ERRWRITE, REDIR_ERRWRITENOW, REDIR_FROM_HEREDOC_MASK, REDIR_HEREDOC,
25    REDIR_HEREDOCDASH, REDIR_HERESTR, REDIR_INPIPE, REDIR_MERGEIN, REDIR_MERGEOUT, REDIR_OUTPIPE,
26    REDIR_READ, REDIR_READWRITE, REDIR_VARID_MASK, REDIR_WRITE, REDIR_WRITENOW, SHORTLOOPS,
27    SHORTREPEAT, WCB_COND, WCB_SIMPLE, WC_REDIR, WC_REDIR_FROM_HEREDOC, WC_REDIR_TYPE,
28    WC_REDIR_VARID, WC_SUBLIST_COPROC, WC_SUBLIST_NOT,
29};
30pub use crate::heredoc_ast::HereDoc;
31use crate::ported::lex::{
32    incasepat, incmdpos, incond, infor, input_slice, inredir, inrepeat, intypeset, isnewlin,
33    lex_init, lineno, noaliases, nocorrect, pos, set_incasepat, set_incmdpos, set_incond,
34    set_infor, set_inredir, set_inrepeat, set_intypeset, set_isnewlin, set_lineno, set_noaliases,
35    set_nocorrect, tok, tokfd, toklineno, tokstr, zshlex,
36};
37use crate::ported::signals::unqueue_signals;
38use crate::ported::utils::{errflag, zerr, zwarnnam, ERRFLAG_ERROR};
39use crate::prompt::{cmdpop, cmdpush};
40pub use crate::zsh_ast::{
41    CaseArm, CaseTerm, CaseTerminator, CompoundCommand, ForList, HereDocInfo, ListFlags, ListOp,
42    Redirect, RedirectOp, ShellCommand, ShellWord, SimpleCommand, SublistFlags, SublistOp,
43    VarModifier, ZshAssign, ZshAssignValue, ZshCase, ZshCommand, ZshCond, ZshFor, ZshFuncDef,
44    ZshIf, ZshList, ZshParamFlag, ZshPipe, ZshProgram, ZshRedir, ZshRepeat, ZshSimple, ZshSublist,
45    ZshTry, ZshWhile,
46};
47use crate::zsh_h::{
48    wc_bdata, CS_ALWAYS, CS_ARRAY, CS_CASE, CS_CMDAND, CS_CMDOR, CS_COND, CS_CURSH, CS_ELIF,
49    CS_ELSE, CS_ERRPIPE, CS_FOR, CS_FOREACH, CS_FUNCDEF, CS_IF, CS_IFTHEN, CS_PIPE, CS_REPEAT,
50    CS_SELECT, CS_SUBSH, CS_UNTIL, CS_WHILE, EF_RUN, WCB_ARITH, WCB_ASSIGN, WCB_CASE, WCB_CURSH,
51    WCB_END, WCB_FOR, WCB_FUNCDEF, WCB_IF, WCB_LIST, WCB_PIPE, WCB_REDIR, WCB_REPEAT, WCB_SELECT,
52    WCB_SUBLIST, WCB_SUBSH, WCB_TIMED, WCB_TRY, WCB_TYPESET, WCB_WHILE, WC_ASSIGN_ARRAY,
53    WC_ASSIGN_INC, WC_ASSIGN_NEW, WC_ASSIGN_SCALAR, WC_CASE_AND, WC_CASE_HEAD, WC_CASE_OR,
54    WC_CASE_TESTAND, WC_FOR_COND, WC_FOR_LIST, WC_FOR_PPARAM, WC_IF_ELIF, WC_IF_ELSE, WC_IF_HEAD,
55    WC_IF_IF, WC_PIPE_END, WC_PIPE_LINENO, WC_PIPE_MID, WC_REDIR_WORDS, WC_SELECT_LIST,
56    WC_SELECT_PPARAM, WC_SUBLIST_AND, WC_SUBLIST_END, WC_SUBLIST_FLAGS, WC_SUBLIST_OR,
57    WC_SUBLIST_SIMPLE, WC_SUBLIST_TYPE, WC_TIMED_EMPTY, WC_TIMED_PIPE, WC_WHILE_UNTIL,
58    WC_WHILE_WHILE, Z_ASYNC, Z_DISOWN, Z_END, Z_SIMPLE, Z_SYNC,
59};
60use serde::{Deserialize, Serialize};
61use std::fs::{self, File};
62use std::io::{Read, Seek, SeekFrom, Write};
63use std::os::unix::fs::MetadataExt;
64use std::path::Path;
65use std::sync::atomic::{AtomicUsize, Ordering};
66use std::sync::mpsc;
67use std::thread;
68use std::time::Duration;
69
70// Names lifted out of inside-fn `use` statements (PORT.md
71// 'no imports inside FNs ever').
72
73// Direct port of `Src/parse.c:287-289` grow-policy constants.
74const EC_INIT_SIZE: i32 = 256;
75
76// Pending-here-document list — direct port of `Src/parse.c:84
77// struct heredocs *hdocs;`. Per-parser file-static (bucket-1 in
78// PORT_PLAN.md): each worker thread parsing a separate program needs
79// its own pending-heredoc list. Saved/restored across nested parses
80// by `parse_context_save`/`parse_context_restore` (parse.c:299/337).
81thread_local! {
82    /// Port of file-static `struct heredocs *hdocs;` from `Src/parse.c:84`.
83    pub static HDOCS: std::cell::RefCell<Option<Box<crate::ported::zsh_h::heredocs>>>
84        = const { std::cell::RefCell::new(None) };
85}
86
87// Wordcode-buffer thread-locals — direct port of `Src/parse.c:269-285`
88// file-statics. Per-evaluator (bucket-1 in PORT_PLAN.md): each worker
89// thread parsing a separate program needs its own wordcode buffer.
90//
91// ECBUF: the wordcode array being built. C `Wordcode ecbuf`
92// (parse.c:275).
93// ECLEN: allocated entries in ECBUF (parse.c:269).
94// ECUSED: entries actually used so far (parse.c:271).
95// ECNPATS: count of patterns referenced by ECBUF (parse.c:273).
96// ECSOFFS / ECSSUB: byte offsets into the string region
97// (parse.c:279). ECSSUB subtracts substring overlap.
98// ECNFUNC: count of functions defined so far (parse.c:285).
99// ECSTRS_INDEX: dedup index for long strings — C uses a binary tree
100// of `struct eccstr` (zsh.h:836); the canonical Eccstr port exists
101// at zsh_h::eccstr but stays unused at runtime here. The HashMap
102// preserves the API contract (lookup by (nfunc, str) → offs) with
103// simpler ownership semantics.
104thread_local! {
105    /// `ECBUF` static.
106    pub static ECBUF: std::cell::RefCell<Vec<u32>> = std::cell::RefCell::new(Vec::new());
107    static ECLEN: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
108    static ECUSED: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
109    static ECNPATS: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
110    static ECSOFFS: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
111    static ECSSUB: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
112    static ECNFUNC: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
113    static ECSTRS_INDEX: std::cell::RefCell<std::collections::HashMap<(i32, String), u32>>
114        = std::cell::RefCell::new(std::collections::HashMap::new());
115    /// C zsh's `eccstr` BST (parse.c:447). Port of `Eccstr ecstrs` —
116    /// a hashval-ordered binary search tree of long-strings for
117    /// dedup. Same cmp logic as C: nfunc, then hashval, then strcmp.
118    /// HashMap above is a fast-path lookup; this tree is the
119    /// C-fidelity walker that mirrors C's exact dedup-hit pattern
120    /// (including its quirks for hash-colliding content).
121    static ECSTRS_TREE: std::cell::RefCell<Option<Box<EccstrNode>>>
122        = const { std::cell::RefCell::new(None) };
123    /// Reverse index for `ecgetstr`: offs → owned string. Populated
124    /// at ecstrcode time so the consumer can recover the string from
125    /// the wordcode offs without walking the encode-time HashMap.
126    /// Stores the METAFIED BYTE form of each long-string, exactly
127    /// matching what C's strs region holds. `String` would not work
128    /// here because Rust strings carry UTF-8-encoded chars (e.g.
129    /// the Dash marker `\u{9b}` UTF-8-encodes to two bytes
130    /// `\xc2 \x9b`) while C stores zsh markers as single bytes
131    /// (raw `\x9b`). Storing Vec<u8> lets us write byte-for-byte
132    /// what C writes after metafy.
133    pub static ECSTRS_REVERSE: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>>
134        = std::cell::RefCell::new(std::collections::HashMap::new());
135}
136const EC_DOUBLE_THRESHOLD: i32 = 32768;
137const EC_INCREMENT: i32 = 1024;
138
139/// Direct port of `parse_context_save(struct parse_stack *ps, int toplevel)` at `Src/parse.c:295`.
140/// Snapshots the lexer-side file-statics (which currently live on
141/// `lexer` until Phase 7 dissolution makes them file-scope
142/// thread_local!s) plus the pending heredoc list, plus the
143/// wordcode-buffer state (STUB until Phase 9b). Saves Rust-only
144/// recursion counters too so nested parses get fresh limits.
145/// WARNING: param names don't match C — Rust=(ps) vs C=(ps, toplevel)
146pub fn parse_context_save(ps: &mut parse_stack) {
147    // parse.c:299 — `ps->hdocs = hdocs; hdocs = NULL;` — save the
148    // canonical C linked-list and clear it for the nested parse.
149    ps.hdocs = HDOCS.with_borrow_mut(|h| h.take());
150    // zshrs-only: save the parallel AST-glue Vec the same way.
151    // LEX_HEREDOCS carries terminator/strip_tabs/quoted metadata
152    // that has no C analog (C stores it implicitly via tokstr).
153    ps.lex_heredocs = LEX_HEREDOCS.with_borrow_mut(|v| std::mem::take(v));
154    // parse.c:302-310 — save lexer-side state.
155    ps.incmdpos = incmdpos();
156    // parse.c:303 — `ps->aliasspaceflag = aliasspaceflag;`. Mirrors
157    // lex.c LEX_ALIAS_SPACE_FLAG so nested parses preserve the
158    // HISTIGNORESPACE-via-alias state across parser re-entry.
159    ps.aliasspaceflag = crate::ported::lex::LEX_ALIAS_SPACE_FLAG.with(|c| c.get());
160    ps.incond = incond();
161    ps.inredir = inredir();
162    ps.incasepat = incasepat();
163    ps.isnewlin = isnewlin();
164    ps.infor = infor();
165    ps.inrepeat_ = inrepeat();
166    ps.intypeset = intypeset();
167    // parse.c:312-317 — wordcode buffer state. STUB until Phase 9b
168    // (zshrs has no ecbuf yet).
169    ps.eclen = 0;
170    ps.ecused = 0;
171    ps.ecnpats = 0;
172    ps.ecbuf = None;
173    ps.ecstrs = None;
174    ps.ecsoffs = 0;
175    ps.ecssub = 0;
176    ps.ecnfunc = 0;
177    set_incmdpos(true);
178    set_incond(0);
179    set_inredir(false);
180    set_incasepat(0);
181    set_infor(0);
182    set_inrepeat(0);
183    set_intypeset(false);
184}
185
186/// Direct port of `parse_context_restore(const struct parse_stack *ps, int toplevel)` at `Src/parse.c:326`.
187/// Inverse of `parse_context_save`. Restores lexer-side state +
188/// pending heredocs + Rust-only counters from `ps`, then clears
189/// `errflag & ERRFLAG_ERROR` per parse.c:354.
190/// WARNING: param names don't match C — Rust=(ps) vs C=(ps, toplevel)
191pub fn parse_context_restore(ps: &parse_stack) {
192    // parse.c:330-331 — free any in-progress wordcode buffer.
193    // zshrs has no wordcode yet (STUB until Phase 9b); the AST
194    // nodes are owned by their parent so dropping the parser
195    // frees them.
196
197    // parse.c:333-352 — restore saved state.
198    // parse.c:337 — `hdocs = ps->hdocs;`
199    HDOCS.with_borrow_mut(|h| *h = ps.hdocs.clone());
200    // zshrs-only: restore the parallel AST-glue Vec.
201    LEX_HEREDOCS.with_borrow_mut(|v| *v = ps.lex_heredocs.clone());
202    set_incmdpos(ps.incmdpos);
203    // parse.c:334 — `aliasspaceflag = ps->aliasspaceflag;`.
204    crate::ported::lex::LEX_ALIAS_SPACE_FLAG.with(|c| c.set(ps.aliasspaceflag));
205    set_incond(ps.incond);
206    set_inredir(ps.inredir);
207    set_incasepat(ps.incasepat);
208    set_isnewlin(ps.isnewlin);
209    set_infor(ps.infor);
210    set_inrepeat(ps.inrepeat_);
211    set_intypeset(ps.intypeset);
212    // ecbuf/eclen/ecused/ecnpats/ecstrs/ecsoffs/ecssub/ecnfunc
213    // STUB until Phase 9b.
214
215    // parse.c:354 — `errflag &= ~ERRFLAG_ERROR;` — clear the
216    // error flag so the outer parse sees a clean state.
217    errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
218}
219
220/// Direct port of `ecadjusthere(int p, int d)` at `Src/parse.c:360`. Walk
221/// the pending-heredocs list and bump each `pc` by `d` if it's
222/// at or after position `p`. Called by `ecispace` / `ecdel` when
223/// wordcodes shift.
224#[allow(unused_variables)]
225pub fn ecadjusthere(p: usize, d: i32) {
226    // parse.c:362-366 — `for (p2 = hdocs; p2; p2 = p2->next) if
227    // (p2->pc >= p) p2->pc += d;`. zshrs's hdocs are still
228    // Vec<HereDoc> on the lexer (pre-P9c migration); since none
229    // of them carry a wordcode pc today (the AST tree has no pc
230    // slots), this is a no-op until Phase 9c wires
231    // `hdocs.pc` into wordcode emission.
232}
233
234// === AST tree relocated to src/extensions/zsh_ast.rs ===
235//
236// zsh C does NOT have an AST tree — it emits wordcode directly via
237// par_event/par_list/par_sublist/par_pipe/par_cmd/par_simple/etc.
238// (Src/parse.c:485-3000) into a flat `Wordcode ecbuf[]`. The Zsh*/
239// Shell* AST node types lived in this file as a Rust-only IR that
240// stands in for that wordcode.
241//
242// P9e (PORT_PLAN.md): the types moved to src/extensions/zsh_ast.rs
243// to make their Rust-only-extension nature explicit. The full P9c +
244// P9d rewrite (par_* emitting wordcode + vm_helper reading wordcode)
245// retires them entirely — until then, callers reach them via this
246// re-export.
247
248/// Direct port of `ecispace(int p, int n)` at `Src/parse.c:372`. Insert `n`
249/// empty wordcode slots at position `p`, shifting later entries
250/// right, growing the buffer as needed, adjusting heredoc pointers.
251pub fn ecispace(p: usize, n: usize) {
252    // parse.c:376-381 — grow if needed.
253    let need = n as i32;
254    if (ECLEN.get() - ECUSED.get()) < need {
255        let cur = ECLEN.get();
256        let mut a = if cur < EC_DOUBLE_THRESHOLD {
257            cur
258        } else {
259            EC_INCREMENT
260        };
261        if need > a {
262            a = need;
263        }
264        ECBUF.with_borrow_mut(|buf| {
265            buf.resize((cur + a) as usize, 0);
266        });
267        ECLEN.set(cur + a);
268    }
269    // parse.c:382-385 — memmove p → p+n, gap of n.
270    let m = ECUSED.get() as usize - p;
271    if m > 0 {
272        ECBUF.with_borrow_mut(|buf| {
273            let needed = (ECUSED.get() as usize) + n;
274            if buf.len() < needed {
275                buf.resize(needed, 0);
276            }
277            for i in (0..m).rev() {
278                buf[p + n + i] = buf[p + i];
279            }
280            for i in 0..n {
281                buf[p + i] = 0;
282            }
283        });
284    }
285    // parse.c:386 — bump ecused by n.
286    ECUSED.set(ECUSED.get() + need);
287    // parse.c:387 — `ecadjusthere(p, n)`.
288    ecadjusthere(p, need);
289}
290
291/// Direct port of `ecadd(wordcode c)` at `Src/parse.c:397`. Append `c` to
292/// the wordcode buffer with grow-on-demand, return the new index.
293pub fn ecadd(c: u32) -> usize {
294    // parse.c:399-405 — `if ((eclen - ecused) < 1) grow`.
295    if (ECLEN.get() - ECUSED.get()) < 1 {
296        let cur = ECLEN.get();
297        let a = if cur < EC_DOUBLE_THRESHOLD {
298            cur
299        } else {
300            EC_INCREMENT
301        };
302        ECBUF.with_borrow_mut(|buf| {
303            buf.resize((cur + a) as usize, 0);
304        });
305        ECLEN.set(cur + a);
306    }
307    let idx = ECUSED.get();
308    ECBUF.with_borrow_mut(|buf| {
309        if (idx as usize) >= buf.len() {
310            buf.resize((idx + 1) as usize, 0);
311        }
312        buf[idx as usize] = c;
313    });
314    ECUSED.set(idx + 1);
315    idx as usize
316}
317
318/// Direct port of `ecdel(int p)` at `Src/parse.c:413`. Remove the
319/// wordcode at position `p`, shift later entries left by one,
320/// decrement ecused, adjust pending heredoc pointers.
321pub fn ecdel(p: usize) {
322    // parse.c:415-418 — memmove + decrement ecused.
323    let n = ECUSED.get() as usize - p - 1;
324    if n > 0 {
325        ECBUF.with_borrow_mut(|buf| {
326            for i in 0..n {
327                buf[p + i] = buf[p + i + 1];
328            }
329        });
330    }
331    ECUSED.set(ECUSED.get() - 1);
332    // parse.c:420 — `ecadjusthere(p, -1)`.
333    ecadjusthere(p, -1);
334}
335
336/// Direct port of `ecstrcode(char *s)` at `Src/parse.c:426`. Encode a
337/// string into a single wordcode (short strings ≤4 bytes packed
338/// inline; longer strings get an offset into the deduped registry).
339///
340/// The long-string path stores the METAFIED bytes (matches what C's
341/// strs region contains): collapse Rust UTF-8 chars in 0x80..=0xff
342/// to single bytes, then apply zsh metafy (high bytes ≥ 0x83 →
343/// `Meta=0x83 + byte^0x20`). Length tracking (ECSOFFS) uses the
344/// metafied byte count — same as C `strlen(s) + 1` where C's `s`
345/// is already metafied at this point.
346pub fn ecstrcode(s: &str) -> u32 {
347    // Convert Rust char-form → C-byte form. zsh's metafy() at
348    // Src/utils.c only converts bytes flagged IMETA: 0x00, 0x83
349    // (Meta itself), and 0x84..=0xa2 (Pound..Marker, the lex
350    // markers). Other bytes 0x01..=0x82 and 0xa3..=0xff pass
351    // through unchanged. See utils.c:4195-4204 typtab init.
352    //
353    // Rust receives chars. Classify each:
354    //   - codepoint in [0x83..=0xa2] → marker char (emitted by lex
355    //     post-metafy in C); 1 byte unchanged
356    //   - codepoint < 0x80 → ASCII, 1 byte unchanged
357    //   - codepoint in [0x80..=0x82] or [0xa3..=0xff] → single
358    //     non-imeta byte (user-input range); 1 byte unchanged
359    //   - codepoint > 0xff → multi-byte UTF-8 source char (e.g.
360    //     '━' = U+2501 = 0xe2 0x94 0x81). Metafy ONLY the bytes
361    //     that fall in 0x83..=0xa2; pass others through. For '━':
362    //     0xe2 stays, 0x94 → 0x83 0xb4, 0x81 stays.
363    let mut c_bytes: Vec<u8> = Vec::with_capacity(s.len());
364    let imeta = |b: u8| -> bool { b == 0 || (0x83..=0xa2).contains(&b) };
365    for ch in s.chars() {
366        let cu = ch as u32;
367        if cu < 0x80 {
368            // ASCII — single byte unchanged.
369            c_bytes.push(cu as u8);
370        } else if (0x83..=0xa2).contains(&cu) {
371            // Lex marker char (emitted by lex.add(Marker) post-metafy
372            // in C). Stored as single byte.
373            c_bytes.push(cu as u8);
374        } else {
375            // User-input char: encode UTF-8 then metafy imeta bytes.
376            // For chars 0x80..=0xff (like 'º' U+00BA), UTF-8 gives
377            // 2 bytes (e.g. `0xc2 0xba`) — zsh's lex reads these as
378            // raw bytes from input and metafy passes 0xc2 / 0xba
379            // through (both NOT imeta).
380            let mut tmp = [0u8; 4];
381            for &b in ch.encode_utf8(&mut tmp).as_bytes() {
382                if imeta(b) {
383                    c_bytes.push(0x83);
384                    c_bytes.push(b ^ 0x20);
385                } else {
386                    c_bytes.push(b);
387                }
388            }
389        }
390    }
391    // c:`has_token` (Src/utils.c:2282) → `itok(*s)` → `typtab[c] & ITOK`.
392    // ITOK is set for bytes `Pound..=Nularg` (0x84..=0xa1) per
393    // Src/utils.c:4198 (`for (t0=Pound; t0<=LAST_NORMAL_TOK; t0++)
394    // typtab[t0]|=ITOK`) plus :4200 (`for (t0=Snull; t0<=Nularg; t0++)
395    // typtab[t0]|=ITOK|IMETA|INULL`). Pound=0x84 Bang=0x9c (last normal),
396    // Snull=0x9d..Nularg=0xa1. Meta=0x83 has IMETA but NOT ITOK.
397    let t = c_bytes.iter().any(|&b| (0x84..=0xa1).contains(&b));
398    let l = c_bytes.len() + 1; // include NUL terminator
399    if l <= 4 {
400        // parse.c:436-445 — short-string inline pack. Uses raw C-bytes
401        // (NOT metafied — the inline packing stores 1 byte per slot).
402        let mut c: u32 = if t { 3 } else { 2 };
403        match l {
404            4 => {
405                c |= (c_bytes[2] as u32) << 19;
406                c |= (c_bytes[1] as u32) << 11;
407                c |= (c_bytes[0] as u32) << 3;
408            }
409            3 => {
410                c |= (c_bytes[1] as u32) << 11;
411                c |= (c_bytes[0] as u32) << 3;
412            }
413            2 => {
414                c |= (c_bytes[0] as u32) << 3;
415            }
416            1 => {
417                // parse.c:443 — empty string special case.
418                c = if t { 7 } else { 6 };
419            }
420            _ => {}
421        }
422        c
423    } else {
424        // parse.c:447-466 — long string. Port of C's eccstr BST walk
425        // exactly: walk the tree comparing nfunc, then hashval, then
426        // strcmp on bytes. Return offs on full match; insert new
427        // leaf otherwise. Matches C's exact dedup-hit pattern
428        // (which is content-dependent — hash collisions and the
429        // lazy short-circuit cmp chain make the tree shape determine
430        // whether matching nodes are reachable).
431        // hasher is byte-by-byte polynomial (hashtable.c:86); pass
432        // c_bytes via from_utf8_unchecked so non-UTF-8 zsh marker
433        // bytes feed straight in. SAFETY: hasher only iterates
434        // `.bytes()` — no UTF-8 validity assumed.
435        let val =
436            crate::ported::hashtable::hasher(unsafe { std::str::from_utf8_unchecked(&c_bytes) });
437        let nfunc = ECNFUNC.get();
438        let found_offs = ECSTRS_TREE.with_borrow_mut(|root| {
439            // Walk the tree. At each node, if all 3 cmps == 0,
440            // return the node's offs. Otherwise descend left/right
441            // by the first non-zero cmp's sign.
442            let mut cur: &mut Option<Box<EccstrNode>> = root;
443            loop {
444                let p = match cur.as_mut() {
445                    Some(p) => p,
446                    None => break None,
447                };
448                // c:448 — `cmp = p->nfunc - ecnfunc`
449                let mut cmp = (p.nfunc as i64) - (nfunc as i64);
450                if cmp == 0 {
451                    // c:448 — `&& !(cmp = (long)p->hashval - (long)val)`
452                    // C does `(int)(p->hashval - val)` — unsigned 32-bit
453                    // subtraction wraps, then cast to int. Use
454                    // wrapping_sub + as i32 to match the bit pattern.
455                    cmp = (p.hashval.wrapping_sub(val) as i32) as i64;
456                    if cmp == 0 {
457                        // c:448 — `&& !(cmp = strcmp(p->str, s))`
458                        cmp = match p.str.as_slice().cmp(c_bytes.as_slice()) {
459                            std::cmp::Ordering::Less => -1,
460                            std::cmp::Ordering::Equal => 0,
461                            std::cmp::Ordering::Greater => 1,
462                        };
463                        if cmp == 0 {
464                            // c:450 — `return p->offs;`
465                            break Some(p.offs);
466                        }
467                    }
468                }
469                // c:452 — `pp = (cmp < 0 ? &p->left : &p->right);`
470                cur = if cmp < 0 { &mut p.left } else { &mut p.right };
471            }
472        });
473        if let Some(offs) = found_offs {
474            return offs;
475        }
476        // c:462 — `p->offs = ((ecsoffs - ecssub) << 2) | (t ? 1 : 0);`
477        let offs = (((ECSOFFS.get() - ECSSUB.get()) as u32) << 2) | if t { 1 } else { 0 };
478        // c:463 — `p->aoffs = ecsoffs;` (absolute write position).
479        let aoffs = ECSOFFS.get() as u32;
480        // c:457-465 — insert new node at the NULL slot the walk
481        // terminated at. Encode the walk path as a Vec<bool> of
482        // left/right turns (true = right), then re-descend to
483        // insert. Borrow-checker friendly: a single mutable walk
484        // that either finds an existing node (descend) or fills
485        // the empty slot (return).
486        let stored = c_bytes.clone();
487        let stored_len = stored.len();
488        let new_node = Box::new(EccstrNode {
489            left: None,
490            right: None,
491            str: stored.clone(),
492            offs,
493            aoffs,
494            nfunc,
495            hashval: val,
496        });
497        ECSTRS_TREE.with_borrow_mut(|root| {
498            // Build the path first (immutable-walk; safe because we
499            // only ever go further down).
500            let mut path: Vec<bool> = Vec::new();
501            {
502                let mut cur: &Option<Box<EccstrNode>> = root;
503                while let Some(p) = cur.as_ref() {
504                    let mut cmp = (p.nfunc as i64) - (nfunc as i64);
505                    if cmp == 0 {
506                        // C does `(int)(p->hashval - val)` — unsigned 32-bit
507                        // subtraction wraps, then cast to int. Use
508                        // wrapping_sub + as i32 to match the bit pattern.
509                        cmp = (p.hashval.wrapping_sub(val) as i32) as i64;
510                        if cmp == 0 {
511                            cmp = match p.str.as_slice().cmp(c_bytes.as_slice()) {
512                                std::cmp::Ordering::Less => -1,
513                                std::cmp::Ordering::Equal => 0,
514                                std::cmp::Ordering::Greater => 1,
515                            };
516                        }
517                    }
518                    let go_right = cmp >= 0;
519                    path.push(go_right);
520                    cur = if go_right { &p.right } else { &p.left };
521                }
522            }
523            // Descend mutably along the recorded path and assign at
524            // the NULL leaf.
525            let mut cur: &mut Option<Box<EccstrNode>> = root;
526            for turn in path {
527                let p = cur.as_mut().expect("path matches walk");
528                cur = if turn { &mut p.right } else { &mut p.left };
529            }
530            *cur = Some(new_node);
531        });
532        // Also keep the existing reverse index (offs → bytes) for
533        // ecgetstr_wordcode and copy_ecstr — they read flat by offs.
534        ECSTRS_REVERSE.with_borrow_mut(|m| {
535            m.insert(offs, stored);
536        });
537        let _ = l;
538        ECSOFFS.set(ECSOFFS.get() + (stored_len + 1) as i32);
539        offs
540    }
541}
542
543/// Initialize parser status. Direct port of zsh/Src/parse.c:491
544/// `init_parse_status`. Clears the per-parse-call lexer flags
545/// so a fresh parse starts from cmd-position with no nesting
546/// state inherited from a prior parse.
547///
548/// Previously the Rust port omitted `inrepeat_ = 0` at c:501.
549/// `inrepeat_` is the `repeat N <body>` parse-state counter that
550/// the lexer toggles in 3 phases (1 → 2 → 3 → 0). Without the
551/// reset, a fresh parse called after an in-flight `repeat`
552/// command would inherit the stale counter and silently misread
553/// the next token as a body of an already-completed repeat.
554pub fn init_parse_status() {
555    // c:491
556    // parse.c:500-502 — `incasepat = incond = inredir = infor =
557    // intypeset = 0; inrepeat_ = 0; incmdpos = 1;`
558    set_incasepat(0); // c:500
559    set_incond(0); // c:500
560    set_inredir(false); // c:500
561    set_infor(0); // c:500
562    set_intypeset(false); // c:500
563    set_inrepeat(0); // c:501 inrepeat_ = 0
564    set_incmdpos(true); // c:502
565}
566
567/// Initialize parser for a fresh parse. Direct port of
568/// zsh/Src/parse.c:509 `init_parse`. C source allocates a
569/// fresh wordcode buffer (ecbuf) sized EC_INIT_SIZE, resets the
570/// per-parse-call counters, and calls init_parse_status. zshrs
571/// has no flat wordcode buffer (AST is built inline) so this
572/// function reduces to init_parse_status + recursion_depth/
573/// global_iterations clear.
574pub fn init_parse() {
575    // parse.c:513-520 — `ecbuf = (Wordcode) zalloc(EC_INIT_SIZE *
576    // sizeof(wordcode)); eclen = EC_INIT_SIZE; ecused = 0;
577    // ecnpats = 0; ecstrs = NULL; ecsoffs = ecnfunc = 0;
578    // ecssub = 0;`. P9b — initialize the per-evaluator wordcode
579    // buffer for this parse call. zshrs uses thread-local
580    // statics declared at file scope (parse.rs:25-50).
581    ECBUF.with_borrow_mut(|buf| {
582        buf.clear();
583        buf.resize(EC_INIT_SIZE as usize, 0);
584    });
585    ECLEN.set(EC_INIT_SIZE);
586    ECUSED.set(0);
587    ECNPATS.set(0);
588    ECSOFFS.set(0);
589    ECSSUB.set(0);
590    ECNFUNC.set(0);
591    ECSTRS_INDEX.with_borrow_mut(|m| m.clear());
592    ECSTRS_REVERSE.with_borrow_mut(|m| m.clear());
593    ECSTRS_TREE.with_borrow_mut(|t| *t = None);
594
595    // parse.c:522 — `init_parse_status();`
596    init_parse_status();
597}
598
599/// Port of `copy_ecstr(Eccstr s, char *p)` from `Src/parse.c:537`.
600/// Walks the BST and writes each entry to `p[s->aoffs..]` matching
601/// C's recursive in-order traversal exactly. The old impl used the
602/// `ECSTRS_REVERSE` HashMap keyed by `offs` (= ecssub-relative
603/// wordcode-encoded offset), which collides across funcdef scopes:
604/// a string at relative offs=0 inside funcdef A and another at
605/// relative offs=0 inside funcdef B share the same key, so one
606/// overwrites the other.
607pub fn copy_ecstr(_table: &std::collections::HashMap<u32, Vec<u8>>, p: &mut [u8]) {
608    // c:537-544 — walk eccstr BST recursively, writing each node's
609    // str at p[node->aoffs..node->aoffs + strlen + 1] (NUL-terminated).
610    ECSTRS_TREE.with_borrow(|root| {
611        copy_ecstr_walk(root, p);
612    });
613}
614
615/// Port of `bld_eprog(int heap)` from `Src/parse.c:547`. Finalizes
616/// the in-build `ECBUF`/`ECSTRS`/`ECNPATS` state into an `Eprog`.
617/// Resets the build state so a new parse can start.
618pub fn bld_eprog(heap: bool) -> eprog {
619    // c:547
620
621    // c:555 — emit WC_END opcode. `WCB_END` is `WC_END_DEFAULT` (0).
622    ecadd(0);
623
624    let ecused = ECUSED.with(|c| c.get()) as usize;
625    let ecnpats = ECNPATS.with(|c| c.get()) as usize;
626    let ecsoffs = ECSOFFS.with(|c| c.get()) as usize;
627
628    // c:557-559 — `ret->len = ((ecnpats * sizeof(Patprog)) +
629    //                            (ecused * sizeof(wordcode)) +
630    //                            ecsoffs);`
631    // sizeof(Patprog) = sizeof(struct patprog *) = pointer size.
632    // On 64-bit targets that's 8, on 32-bit that's 4. C's eprog
633    // ->len is the canonical value for parity tests, so we use
634    // the same arithmetic.
635    let prog_bytes = ecused * 4; // sizeof(wordcode) = 4
636    let len = (ecnpats * size_of::<*const u8>()) + prog_bytes + ecsoffs;
637
638    // Snapshot the wordcode buffer + string table.
639    let prog_words: Vec<u32> = ECBUF.with(|c| c.borrow()[..ecused].to_vec());
640    let mut strs_bytes = vec![0u8; ecsoffs];
641    ECSTRS_REVERSE.with(|c| copy_ecstr(&c.borrow(), &mut strs_bytes));
642
643    // c:566 — store strs as raw bytes via from_utf8_unchecked so
644    // single-byte zsh markers (e.g. Dash 0x9b) survive intact.
645    // `String::from_utf8_lossy` would replace them with U+FFFD
646    // (`\xef\xbf\xbd`), breaking byte-for-byte parity with C's
647    // strs region. SAFETY: downstream consumers of `eprog.strs`
648    // index by byte offset (per the wordcode `(offs >> 2)` offset
649    // encoding) and call `.as_bytes()` — they never iterate as
650    // chars or rely on UTF-8 validity, so storing non-UTF-8 bytes
651    // in a String is safe in practice. C zsh's strs is `char *`
652    // with the same byte-not-char semantics.
653    let strs_string = unsafe { String::from_utf8_unchecked(strs_bytes) };
654    let ret = eprog {
655        flags: if heap { EF_HEAP } else { EF_REAL }, // c:570
656        len: len as i32,                             // c:559
657        npats: ecnpats as i32,                       // c:561
658        nref: if heap { -1 } else { 1 },             // c:562
659        pats: Vec::new(),                            // c:563 dummy_patprog
660        prog: prog_words,                            // c:565
661        strs: Some(strs_string),
662        shf: None,
663        dump: None,
664    };
665
666    // c:577 — free ecbuf so next parse starts fresh.
667    ECBUF.with(|c| c.borrow_mut().clear());
668    ECLEN.with(|c| c.set(0));
669    ECUSED.with(|c| c.set(0));
670    ECNPATS.with(|c| c.set(0));
671    ECSOFFS.with(|c| c.set(0));
672    ECSTRS_INDEX.with(|c| c.borrow_mut().clear());
673    ECSTRS_REVERSE.with(|c| c.borrow_mut().clear());
674    ECSTRS_TREE.with(|t| *t.borrow_mut() = None);
675
676    ret
677}
678
679/// Port of `int empty_eprog(Eprog p)` from `Src/parse.c:584`. C
680/// body: `return (!p || !p->prog || *p->prog == WCB_END());` —
681/// the eprog is empty when its prog buffer is missing or the
682/// first wordcode is the WC_END marker. Used by signal handlers
683/// (`Src/signals.c:712`) to short-circuit a trap that resolves to
684/// an empty program.
685pub fn empty_eprog(p: &eprog) -> bool {
686    p.prog.is_empty() || p.prog[0] == WCB_END()
687}
688
689/// Clear pending here-document list. Direct port of
690/// `clear_hdocs(void)` from `Src/parse.c:591`. The C version walks
691/// `hdocs` and frees each node; Rust drops the `Box<heredocs>`
692/// chain automatically when the head is replaced with None.
693pub fn clear_hdocs() {
694    // c:591
695    // c:593-598 — for (p = hdocs; p; p = n) { n = p->next; zfree(p); }
696    // c:599 — hdocs = NULL;
697    HDOCS.with_borrow_mut(|h| *h = None);
698    // zshrs-only: also drop the parallel AST-glue Vec. No C
699    // analog — LEX_HEREDOCS is Rust-only working-set state.
700    LEX_HEREDOCS.with_borrow_mut(|v| v.clear());
701}
702
703/// Top-level parse-event entry. Direct port of zsh/Src/parse.c:
704/// 612-631 `parse_event`. Reads one event from the lexer (a
705/// sublist optionally followed by SEPER/AMPER/AMPERBANG) and
706/// returns the resulting ZshProgram.
707///
708/// `endtok` is the token that terminates the event — usually
709/// ENDINPUT, but for command-style substitutions the closing
710/// `)` (zsh's CMD_SUBST_CLOSE).
711///
712/// zshrs port note: zsh's parse_event returns an `Eprog` (heap-
713/// allocated wordcode program). zshrs returns a `ZshProgram`
714/// (AST root). Same role at the parse-output boundary.
715pub fn parse_event(endtok: lextok) -> Option<ZshProgram> {
716    // parse.c:616-619 — reset state and prime the lexer.
717    set_tok(ENDINPUT);
718    set_incmdpos(true);
719    // parse.c:618 — `aliasspaceflag = 0;`. Fresh event: discard any
720    // alias-space carry-over from a prior parse so HISTIGNORESPACE
721    // doesn't suppress the next entered command line.
722    crate::ported::lex::LEX_ALIAS_SPACE_FLAG.with(|c| c.set(0));
723
724    // parse.c:626-628 — a sub-parse for a substitution (endtok !=
725    // ENDINPUT, e.g. `par_subsh` with OUTPAR) doesn't need its own
726    // eprog; par_event drives it and the caller discards the program.
727    if endtok != ENDINPUT {
728        zshlex();
729        init_parse();
730        if !par_event(endtok) {
731            clear_hdocs();
732            return None;
733        }
734        return Some(ZshProgram { lists: Vec::new() });
735    }
736
737    // parse.c:616-630 — top-level event parse. The lexer pulls
738    // characters straight from SHIN via hgetc → ingetc → shingetchar
739    // (input.c) and accumulates each into its token buffer with add();
740    // no LEX_INPUT seeding. `zshlex()` (already primed by the shared
741    // entry below) reads the first token; parse_program_until drives the
742    // grammar over the event, refilling input one line at a time as
743    // ingetc's inputline() arm fires.
744    zshlex();
745    init_parse();
746    if tok() == ENDINPUT {
747        return None; // EOF — loop() terminates.
748    }
749    // single_event = TRUE: one logical command line per parse_event, the
750    // faithful port of C par_event's endtok==ENDINPUT top-level mode
751    // (parse.c:635). loop() reads+executes ONE event at a time so `-v`/`-x`
752    // echo and runtime `setopt verbose`/`xtrace` interleave with execution
753    // exactly as zsh does — whole-program parsing can't (it finishes
754    // parsing before any execution).
755    //
756    // Enabling this required isolating the runtime nested-parse machinery
757    // from the now-mid-stream outer reader (the parse/execute interleave
758    // exposed three split-global hazards that whole-program mode hid):
759    //   * cmd-subst / proc-sub / eval / source bodies re-parse via
760    //     vm_helper::parse_isolated (the AST-path bridge for C's
761    //     parse_string, exec.c:283) — it sets `strin` so a drained nested
762    //     buffer EOFs instead of STEALING the outer reader's next SHIN line.
763    //   * strinbeg/strinend now bump BOTH `strin` copies (hist.rs + the
764    //     input.rs one ingetc checks); lexinit zeroes BOTH `lexstop` copies
765    //     — C has a single global for each, zshrs split them.
766    //   * loop() saves/restores LEX_LINENO across execode (init.rs), the
767    //     execlist `oldlineno` discipline (exec.c:28/292), so per-statement
768    //     SET_LINENO doesn't freeze `$LINENO` for the next event.
769    let mut program = parse_program_until(None, true);
770    if program.lists.is_empty() {
771        clear_hdocs();
772        return None;
773    }
774    // parse.c:630 bld_eprog post-pass — wire heredoc bodies collected
775    // by zshlex into the ZshRedir nodes (mirror of `parse()`).
776    let bodies: Vec<HereDocInfo> = LEX_HEREDOCS
777        .with_borrow(|v| v.clone())
778        .into_iter()
779        .map(|h| HereDocInfo {
780            content: h.content,
781            terminator: h.terminator,
782            quoted: h.quoted,
783        })
784        .collect();
785    if !bodies.is_empty() {
786        fill_heredoc_bodies(&mut program, &bodies);
787    }
788    Some(program)
789}
790
791/// Parse one event (sublist with optional separator). Direct
792/// port of zsh/Src/parse.c:635 `par_event`. Returns true if
793/// an event was successfully parsed, false on EOF / endtok.
794///
795/// zshrs port note: the C version emits wordcodes via ecadd/
796/// set_list_code; zshrs's parser builds AST nodes via
797/// par_sublist + par_list. Same flow, different output.
798pub fn par_event(endtok: lextok) -> bool {
799    // parse.c:639-643 — skip leading SEPERs.
800    while tok() == SEPER {
801        // parse.c:640-641 — at top-level (endtok == ENDINPUT),
802        // a SEPER on a fresh line ends the event.
803        if isnewlin() > 0 && endtok == ENDINPUT {
804            return false;
805        }
806        zshlex();
807    }
808    // parse.c:644-647 — terminate on EOF or matching close-token.
809    if tok() == ENDINPUT {
810        return false;
811    }
812    if tok() == endtok {
813        return true;
814    }
815    // parse.c:649-... — drive par_sublist + handle terminator.
816    // zshrs's par_sublist already builds the AST node directly.
817    match par_sublist() {
818        Some(_) => {
819            // parse.c:651-693 — terminator handling. zshrs's
820            // par_list wraps this; for parse_event we just
821            // confirm the sublist parsed.
822            true
823        }
824        None => false,
825    }
826}
827
828/// Port of `parse_list(void)` from `Src/parse.c:697`. C-shape entry
829/// point: drives `par_list` and finalizes via `bld_eprog`. Returns
830/// `None` on syntax error.
831pub fn parse_list() -> Option<eprog> {
832    // c:697
833    set_tok(ENDINPUT);
834    init_parse();
835    zshlex();
836    // c:Src/parse.c:705 — `par_list(&c);` emits wordcode for the
837    // full multi-statement list (its goto-rec loop walks all
838    // SEPER-separated sublists). The Rust AST par_list() emits
839    // NOTHING to the wordcode buffer (only builds the AST), so
840    // bld_eprog returned an empty program AND tok stayed at
841    // SEPER, tripping the syntax-error check below for any
842    // \`cmd; cmd\` body.
843    //
844    // Route through par_event_wordcode (the wordcode emitter,
845    // lines 4395+) which mirrors C's par_list loop semantics
846    // and populates the wordcode buffer that bld_eprog reads.
847    let _start = par_event_wordcode();
848    if tok() != ENDINPUT {
849        clear_hdocs();
850        set_tok(LEXERR);
851        // c:Src/parse.c:708 — `yyerror(0);`. C-faithful invocation;
852        // the message format ("parse error near `X'") is built
853        // inside yyerror from zshlextext/tokstr().
854        yyerror(0);
855        return None;
856    }
857    Some(bld_eprog(false))
858}
859
860/// Port of `parse_cond(void)` from `Src/parse.c:722`. Only used by
861/// `bin_test`/`bin_bracket` for `/bin/test`/`[` compat — the
862/// `condlex` global must already point at `testlex` before entry.
863pub fn parse_cond() -> Option<eprog> {
864    // c:722
865    init_parse();
866    if par_cond().is_none() {
867        clear_hdocs();
868        return None;
869    }
870    Some(bld_eprog(true))
871}
872
873// ============================================================
874// Wordcode emission helpers (parse.c private helpers)
875//
876// Direct ports of zsh's wordcode-emission helpers in parse.c.
877// These write u32 opcodes into a flat `ecbuf` array thread-local
878// via ecadd / ecdel / ecispace / ecstrcode and friends. The
879// par_*_wordcode family at parse.rs:1700-3500 walks the lex
880// stream and emits a real wordcode buffer here.
881//
882// (The AST tree built by par_program / par_simple / etc. is a
883// separate path used by fusevm; see compile_zsh.rs for the AST
884// → fusevm-bytecode compiler.)
885// ============================================================
886
887/// Patch a list-placeholder wordcode with its actual opcode +
888/// jump distance. Direct port of zsh/Src/parse.c:738
889/// `set_list_code`. zsh emits an `ecadd(0)` placeholder before
890/// par_sublist runs, then comes back through set_list_code to
891/// rewrite the slot with WCB_LIST(type, distance) once the
892/// sublist's final length is known.
893///
894/// Port of `set_list_code(int p, int type, int cmplx)` from
895/// `Src/parse.c:738`. Patches the WCB_LIST header at `p` based on
896/// whether the sublist body is simple (single command, no
897/// pipeline) and Z_SYNC/Z_END — emits the Z_SIMPLE-optimized
898/// header when possible, otherwise the plain WCB_LIST(type, 0).
899pub fn set_list_code(p: usize, type_code: i32, cmplx: bool) {
900    let _ = wc_bdata;
901    // c:740 — `if (!cmplx && (type == Z_SYNC || type == (Z_SYNC | Z_END))
902    // && WC_SUBLIST_TYPE(ecbuf[p+1]) == WC_SUBLIST_END)`
903    let sublist_code = ECBUF.with_borrow(|b| b.get(p + 1).copied().unwrap_or(0));
904    let z = type_code;
905    let qualifies = !cmplx
906        && (z == Z_SYNC || z == (Z_SYNC | Z_END))
907        && WC_SUBLIST_TYPE(sublist_code) == WC_SUBLIST_END;
908    if qualifies {
909        // c:742 — `int ispipe = !(WC_SUBLIST_FLAGS(ecbuf[p+1])
910        // & WC_SUBLIST_SIMPLE);`
911        let ispipe = (WC_SUBLIST_FLAGS(sublist_code) & WC_SUBLIST_SIMPLE) == 0;
912        // c:743 — `ecbuf[p] = WCB_LIST((type|Z_SIMPLE), ecused-2-p);`
913        let used = ECUSED.get() as usize;
914        let off = used.saturating_sub(2 + p);
915        ECBUF.with_borrow_mut(|b| {
916            if p < b.len() {
917                b[p] = WCB_LIST((z | Z_SIMPLE) as wordcode, off as wordcode);
918            }
919        });
920        // c:744 — `ecdel(p+1);`
921        ecdel(p + 1);
922        // c:745-746 — `if (ispipe) ecbuf[p+1] = WC_PIPE_LINENO(ecbuf[p+1]);`
923        if ispipe {
924            ECBUF.with_borrow_mut(|b| {
925                if p + 1 < b.len() {
926                    b[p + 1] = WC_PIPE_LINENO(b[p + 1]);
927                }
928            });
929        }
930    } else {
931        // c:748 — `ecbuf[p] = WCB_LIST(type, 0);`
932        ECBUF.with_borrow_mut(|b| {
933            if p < b.len() {
934                b[p] = WCB_LIST(z as wordcode, 0);
935            }
936        });
937    }
938}
939
940/// Port of `set_sublist_code(int p, int type, int flags, int skip, int cmplx)`
941/// from `Src/parse.c:755`. Patches the WCB_SUBLIST header at `p`.
942/// When the sublist is non-complex (single command, no pipeline),
943/// sets WC_SUBLIST_SIMPLE and rewrites the following slot to
944/// `WC_PIPE_LINENO`.
945pub fn set_sublist_code(p: usize, type_code: i32, flags: i32, skip: i32, cmplx: bool) {
946    if cmplx {
947        // c:758 — `ecbuf[p] = WCB_SUBLIST(type, flags, skip);`
948        ECBUF.with_borrow_mut(|b| {
949            if p < b.len() {
950                b[p] = WCB_SUBLIST(type_code as wordcode, flags as wordcode, skip as wordcode);
951            }
952        });
953    } else {
954        // c:760 — `ecbuf[p] = WCB_SUBLIST(type, flags|WC_SUBLIST_SIMPLE, skip);`
955        ECBUF.with_borrow_mut(|b| {
956            if p < b.len() {
957                b[p] = WCB_SUBLIST(
958                    type_code as wordcode,
959                    (flags as wordcode) | WC_SUBLIST_SIMPLE,
960                    skip as wordcode,
961                );
962            }
963        });
964        // c:761 — `ecbuf[p+1] = WC_PIPE_LINENO(ecbuf[p+1]);`
965        ECBUF.with_borrow_mut(|b| {
966            if p + 1 < b.len() {
967                b[p + 1] = WC_PIPE_LINENO(b[p + 1]);
968            }
969        });
970    }
971}
972
973/// Parse a list (sublist with optional & or ;).
974///
975/// Direct port of zsh/Src/parse.c:771-804 `par_list` (and the
976/// par_list1 wrapper at parse.c:807-817).
977///
978/// **Structural divergence**: zsh's parse.c emits flat wordcode
979/// into the `ecbuf` u32 array via `ecadd(0)` (placeholder),
980/// `set_list_code(p, code, complexity)`, `wc_bdata(Z_END)`. zshrs
981/// builds an AST node `ZshList { sublist, flags }` instead. The
982/// async/sync/disown discrimination at parse.c:785-790 maps to
983/// zshrs's `ListFlags { async_, disown }` field — Z_SYNC is the
984/// default (no flags), Z_ASYNC = `&` = `async_=true`, Z_DISOWN +
985/// Z_ASYNC = `&!`/`&|` = both true. Same semantics, different
986/// representation. This divergence is repository-wide: every
987/// `par_*` function emits wordcode in C, every `parse_*` builds
988/// AST in Rust. The compile_zsh module then traverses the AST to
989/// emit fusevm bytecode, which serves the same role as zsh's
990/// wordcode but with a different opcode set and execution model.
991fn par_list(single_event: bool) -> Option<(ZshList, bool)> {
992    let sublist = par_sublist()?;
993
994    // c:769-803 — `list : { SEPER } [ sublist [ { SEPER | AMPER |
995    // AMPERBANG } list ] ]`. The second tuple element reports
996    // whether the list ended with an EXPLICIT terminator. C's list
997    // grammar only chains sublists across SEPER/AMPER/AMPERBANG; a
998    // sublist followed directly by another command (`{a} {b}`) ends
999    // the list and the dangling token is the CALLER's problem
1000    // (par_while takes a dangling INBRACE as the loop body, par_event
1001    // yyerrors at c:671-680). parse_program_until needs this bit to
1002    // reproduce that split.
1003    let (flags, terminated) = match tok() {
1004        AMPER => {
1005            zshlex();
1006            (
1007                ListFlags {
1008                    async_: true,
1009                    disown: false,
1010                },
1011                true,
1012            )
1013        }
1014        AMPERBANG => {
1015            zshlex();
1016            (
1017                ListFlags {
1018                    async_: true,
1019                    disown: true,
1020                },
1021                true,
1022            )
1023        }
1024        SEPER => {
1025            // c:Src/parse.c par_event SEPER branch — `if (isnewlin <= 0
1026            // || endtok != ENDINPUT) zshlex();`. The lexer folds both
1027            // `;` and `\n` into SEPER (lex.rs:265), distinguishing them
1028            // via LEX_ISNEWLIN (C `isnewlin`). At a TOP-LEVEL event
1029            // boundary (single_event ≈ endtok == ENDINPUT) a SEPER that
1030            // was a NEWLINE terminates the event and is NOT consumed —
1031            // it's left for the next parse_event, whose leading skip
1032            // reads the following line only THEN. That's what lets
1033            // loop() read+execute one event at a time so `-v`/`-x` echo
1034            // and runtime `setopt verbose` interleave with execution
1035            // like zsh. A `;` SEPER (isnewlin <= 0), or any SEPER inside
1036            // a body / whole-program parse (single_event = false), is
1037            // consumed to chain the next sublist.
1038            let is_newline = crate::ported::lex::LEX_ISNEWLIN.with(|c| c.get()) > 0;
1039            if !(single_event && is_newline) {
1040                zshlex();
1041            }
1042            (ListFlags::default(), true)
1043        }
1044        SEMI | NEWLIN => {
1045            // Unfolded `;` / preserved newline (LEXFLAGS_NEWLINE) — not a
1046            // top-level event terminator; consume and continue.
1047            zshlex();
1048            (ListFlags::default(), true)
1049        }
1050        _ => (ListFlags::default(), false),
1051    };
1052
1053    Some((ZshList { sublist, flags }, terminated))
1054}
1055
1056/// Parse one list — non-recursing variant. Direct port of
1057/// zsh/Src/parse.c:808 `par_list1`. Like par_list but
1058/// doesn't recurse on the trailing-separator path; used by
1059/// callers that only want one statement (e.g. each arm of a
1060/// case body).
1061pub fn par_list1() -> Option<ZshSublist> {
1062    // parse.c:810-816 — body is a single par_sublist call wrapped
1063    // in the eu/ecused tracking that zshrs doesn't need (no
1064    // wordcode buffer).
1065    par_sublist()
1066}
1067
1068/// Parse a sublist (pipelines connected by && or ||).
1069///
1070/// Direct port of zsh/Src/parse.c:825 `par_sublist` and
1071/// par_sublist2 at parse.c:869-892. par_sublist handles the
1072/// && / || conjunction and emits WC_SUBLIST opcodes; par_sublist2
1073/// handles the leading `!` negation and `coproc` keyword.
1074///
1075/// AST mapping: ZshSublist { pipe, conj_chain }, where `conj_chain`
1076/// is a Vec<(ConjOp, ZshSublist)> for chained && / ||. C uses
1077/// flat wordcode with WC_SUBLIST_AND / WC_SUBLIST_OR markers.
1078fn par_sublist() -> Option<ZshSublist> {
1079    let mut flags = SublistFlags::default();
1080
1081    // Handle coproc and !
1082    if tok() == COPROC {
1083        flags.coproc = true;
1084        zshlex();
1085    } else if tok() == BANG_TOK {
1086        flags.not = true;
1087        zshlex();
1088    }
1089
1090    let pipe = par_pline()?;
1091
1092    // Check for && or ||
1093    let next = match tok() {
1094        DAMPER => {
1095            zshlex();
1096            skip_separators();
1097            // c:Src/parse.c:par_sublist — and-or operators (`&&`,
1098            // `||`) require a sublist on each side. After consuming
1099            // `&&`/`||`, another and-or operator OR a pipe-operator
1100            // immediately after is a parse error in C zsh. zshrs's
1101            // recursion silently returned None and dropped the
1102            // operator. Bug #171 in docs/BUGS.md.
1103            if matches!(tok(), DAMPER | DBAR | BAR_TOK | BARAMP) {
1104                let name = match tok() {
1105                    DAMPER => "&&",
1106                    DBAR => "||",
1107                    BAR_TOK => "|",
1108                    BARAMP => "|&",
1109                    _ => "operator",
1110                };
1111                zerr(&format!("parse error near `{}'", name));
1112                return None;
1113            }
1114            par_sublist().map(|s| (SublistOp::And, Box::new(s)))
1115        }
1116        DBAR => {
1117            zshlex();
1118            skip_separators();
1119            if matches!(tok(), DAMPER | DBAR | BAR_TOK | BARAMP) {
1120                let name = match tok() {
1121                    DAMPER => "&&",
1122                    DBAR => "||",
1123                    BAR_TOK => "|",
1124                    BARAMP => "|&",
1125                    _ => "operator",
1126                };
1127                zerr(&format!("parse error near `{}'", name));
1128                return None;
1129            }
1130            par_sublist().map(|s| (SublistOp::Or, Box::new(s)))
1131        }
1132        _ => None,
1133    };
1134
1135    Some(ZshSublist { pipe, next, flags })
1136}
1137
1138/// Port of `par_sublist2(int *cmplx)` from `Src/parse.c:869`.
1139/// Secondary-sublist arm: handles the `COPROC`/`Bang` prefix
1140/// in front of a pline. Returns the WC_SUBLIST flag word added.
1141pub fn par_sublist2(cmplx: &mut i32) -> Option<i32> {
1142    // c:870 — `int f = 0;`
1143    let mut f: i32 = 0;
1144    // c:873-880 — COPROC / BANG prefix flags.
1145    if tok() == COPROC {
1146        *cmplx = 1;
1147        f |= WC_SUBLIST_COPROC as i32;
1148        zshlex();
1149    } else if tok() == BANG_TOK {
1150        *cmplx = 1;
1151        f |= WC_SUBLIST_NOT as i32;
1152        zshlex();
1153    }
1154    // c:882-883 — `if (!par_pline(cmplx) && !f) return -1;`
1155    if !par_pipe_wordcode(cmplx) && f == 0 {
1156        return None;
1157    }
1158    // c:885 — `return f;`
1159    Some(f)
1160}
1161
1162/// Parse a pipeline
1163/// Parse a pipeline (cmds joined by `|` / `|&`). Direct port of
1164/// zsh/Src/parse.c:894 `par_pline`. AST: ZshPipe { cmds: Vec<ZshCommand> }.
1165/// C emits WC_PIPE wordcodes per command; same flow.
1166fn par_pline() -> Option<ZshPipe> {
1167    let lineno = toklineno();
1168    let cmd = par_cmd()?;
1169
1170    // Check for | or |&
1171    let mut merge_stderr = false;
1172    let next = match tok() {
1173        BAR_TOK | BARAMP => {
1174            merge_stderr = tok() == BARAMP;
1175            zshlex();
1176            skip_separators();
1177            // c:Src/parse.c:par_pline — pipe-operators require a
1178            // command on each side. After consuming `|`/`|&`,
1179            // C zsh's recursive par_pline call returns -1 (parse
1180            // error) when the next token is another pipe-operator
1181            // — `a | | b` errors with `parse error near `|''`.
1182            // zshrs's `par_pline()?` silently returned None on
1183            // missing command, dropping the rest of the input
1184            // without diagnosing the empty-pipe-operand. Bug #171
1185            // in docs/BUGS.md.
1186            if matches!(tok(), BAR_TOK | BARAMP) {
1187                let name = if tok() == BARAMP { "|&" } else { "|" };
1188                zerr(&format!("parse error near `{}'", name));
1189                return None;
1190            }
1191            par_pline().map(Box::new)
1192        }
1193        _ => None,
1194    };
1195
1196    Some(ZshPipe {
1197        cmd,
1198        next,
1199        lineno,
1200        merge_stderr,
1201    })
1202}
1203
1204/// Parse a command
1205/// Parse a command — dispatches by leading token (FOR / CASE /
1206/// IF / WHILE / UNTIL / REPEAT / FUNC / DINBRACK / DINPAR /
1207/// Inpar subshell / Inbrace current-shell / TIME / NOCORRECT,
1208/// else simple). Direct port of zsh/Src/parse.c:958 `par_cmd`.
1209fn par_cmd() -> Option<ZshCommand> {
1210    // Parse leading redirections
1211    let mut redirs = Vec::new();
1212    while IS_REDIROP(tok()) {
1213        if let Some(redir) = par_redir() {
1214            redirs.push(redir);
1215        }
1216    }
1217
1218    // c:Src/parse.c:970-1030 par_cmd — push the open construct onto the
1219    // cmdstack for the duration of its sub-parse, then pop. The cmdstack
1220    // drives `%_` prompt expansion, so this is what makes a multi-line
1221    // construct's PS2 render `for> ` / `while> ` / `case> ` across
1222    // continuation lines instead of a bare `> `. (The cmdstack does NOT
1223    // drive tokenization in zshrs — the lexer's only reads are the
1224    // debug-only DPUTS "cmdstack not empty" asserts — so wrapping the AST
1225    // dispatch is behaviorally inert outside the prompt.) IF is excluded
1226    // to match C, where par_if manages its own cmdstack (CS_IF/CS_IFTHEN);
1227    // DINPAR/TIME/INOUTPAR are not cmdstack constructs in C either.
1228    let cmd = match tok() {
1229        FOR => {
1230            cmdpush(CS_FOR as u8); // c:972
1231            let c = par_for();
1232            cmdpop(); // c:974
1233            c
1234        }
1235        FOREACH => {
1236            cmdpush(CS_FOREACH as u8); // c:977
1237            let c = par_for();
1238            cmdpop(); // c:979
1239            c
1240        }
1241        SELECT => {
1242            cmdpush(CS_SELECT as u8); // c:983
1243            let c = parse_select();
1244            cmdpop(); // c:985
1245            c
1246        }
1247        CASE => {
1248            cmdpush(CS_CASE as u8); // c:988
1249            let c = par_case();
1250            cmdpop(); // c:990
1251            c
1252        }
1253        IF => par_if(), // c:992-994 — par_if manages its own cmdstack
1254        WHILE => {
1255            cmdpush(CS_WHILE as u8); // c:996
1256            let c = par_while(false);
1257            cmdpop(); // c:998
1258            c
1259        }
1260        UNTIL => {
1261            cmdpush(CS_UNTIL as u8); // c:1001
1262            let c = par_while(true);
1263            cmdpop(); // c:1003
1264            c
1265        }
1266        REPEAT => {
1267            cmdpush(CS_REPEAT as u8); // c:1006
1268            let c = par_repeat();
1269            cmdpop(); // c:1008
1270            c
1271        }
1272        INPAR_TOK => {
1273            cmdpush(CS_SUBSH as u8); // c:1012
1274            let c = par_subsh();
1275            cmdpop(); // c:1014
1276            c
1277        }
1278        INOUTPAR => parse_anon_funcdef(),
1279        INBRACE_TOK => {
1280            cmdpush(CS_CURSH as u8); // c:1017
1281            let c = parse_cursh();
1282            cmdpop(); // c:1019
1283            c
1284        }
1285        FUNC => {
1286            cmdpush(CS_FUNCDEF as u8); // c:1022
1287            let c = par_funcdef();
1288            cmdpop(); // c:1024
1289            c
1290        }
1291        DINBRACK => {
1292            cmdpush(CS_COND as u8); // c:1027
1293            let c = par_cond();
1294            cmdpop(); // c:1029
1295            c
1296        }
1297        DINPAR => parse_arith(),
1298        TIME => par_time(),
1299        _ => par_simple(redirs),
1300    };
1301
1302    // Parse trailing redirections. For Simple commands the redirs were
1303    // already captured inside par_simple; for compound forms (Cursh,
1304    // Subsh, If, While, etc.) we collect them here and wrap in
1305    // ZshCommand::Redirected so compile_zsh can scope-bracket them.
1306    if let Some(inner) = cmd {
1307        let mut trailing: Vec<ZshRedir> = Vec::new();
1308        while IS_REDIROP(tok()) {
1309            if let Some(redir) = par_redir() {
1310                trailing.push(redir);
1311            }
1312        }
1313        // c:Src/parse.c:par_cmd — compound forms (Cursh `{...}`, Subsh
1314        // `(...)`, If/While/Until/For/Case/Select/Repeat/Funcdef) must
1315        // be followed by a valid sublist/list separator (`;`, `\n`,
1316        // `&`, `|`, `&&`, `||`, redirect-op) — STRING_LEX after a
1317        // compound is a parse error. zshrs's outer par_list loop
1318        // silently treated trailing words as a new command, masking
1319        // syntax errors like `{ echo a; } b c`. Mirror C's strict
1320        // post-compound terminator check. Bug #146 in docs/BUGS.md.
1321        if !matches!(inner, ZshCommand::Simple(_)) && tok() == STRING_LEX {
1322            let bad = tokstr().unwrap_or_default();
1323            zerr(&format!("parse error near `{}'", bad));
1324            // Reset state before returning so the outer loop's None
1325            // detection unwinds cleanly.
1326            set_incmdpos(true);
1327            set_incasepat(0);
1328            set_incond(0);
1329            set_intypeset(false);
1330            return None;
1331        }
1332        // c:1072-1075 — every par_cmd tail resets the lexer state
1333        // toggles so the NEXT command starts in cmd position with
1334        // case/cond/typeset off. par_simple/par_cond set `incmdpos=0`
1335        // during their bodies; without this reset the next iteration
1336        // of the outer par_list loop sees `if` / `done` / `select`
1337        // etc. as plain strings and the AST collapses.
1338        set_incmdpos(true);
1339        set_incasepat(0);
1340        set_incond(0);
1341        set_intypeset(false);
1342        if trailing.is_empty() {
1343            return Some(inner);
1344        }
1345        // Simple already absorbed its own redirs (compile path expects
1346        // them on ZshSimple), so don't double-wrap.
1347        if matches!(inner, ZshCommand::Simple(_)) {
1348            if let ZshCommand::Simple(mut s) = inner {
1349                s.redirs.extend(trailing);
1350                return Some(ZshCommand::Simple(s));
1351            }
1352            unreachable!()
1353        }
1354        return Some(ZshCommand::Redirected(Box::new(inner), trailing));
1355    }
1356    // Same reset on the empty-cmd branch (mirror c:1072 unconditional
1357    // path — the C function only returns 0 above when the dispatch
1358    // produced no command, and falls through to the reset block).
1359    set_incmdpos(true);
1360    set_incasepat(0);
1361    set_incond(0);
1362    set_intypeset(false);
1363
1364    None
1365}
1366
1367/// Parse for/foreach loop
1368/// Parse `for NAME in WORDS; do BODY; done` (foreach style) AND
1369/// `for ((init; cond; incr)) do BODY done` (c-style). Direct port
1370/// of zsh/Src/parse.c:1087 `par_for`. parse_for_cstyle is the
1371/// inner branch for the `((...))` arithmetic-header variant
1372/// (parse.c:1100-1140 inside par_for).
1373fn par_for() -> Option<ZshCommand> {
1374    let is_foreach = tok() == FOREACH;
1375    // c:1094-1095 (Src/parse.c, par_for) — set `infor=2` (only when
1376    // tok==FOR) so the lexer's `(` peek at lex.c:784-789
1377    // (`if (infor) { ... return DINPAR; }`) routes the arith-for
1378    // body through dbparens semicolon-splitting instead of the
1379    // `cmd_or_math` whole-body capture path. Without this, `for ((
1380    // i=0; i<3; i++ ))` lexed as a single `((arith))` expression
1381    // and parse_for_cstyle's second zshlex got an empty/wrong tok.
1382    //
1383    // The companion C statement `incmdpos = 0;` at c:1094 isn't
1384    // mirrored here: zshrs's parser doesn't otherwise touch
1385    // LEX_INCMDPOS at this boundary, and forcing it false breaks
1386    // the SELECT case where downstream tokenization relied on the
1387    // inherited state. The C parser maintains incmdpos inline at
1388    // every grammar transition (parse.c:617, :791, :1072, :1145,
1389    // :1154, :1161, ...); without porting those companion sites a
1390    // single explicit reset here is more harmful than helpful.
1391    set_infor(if tok() == FOR { 2 } else { 0 }); // c:1095
1392    zshlex(); // c:1096
1393
1394    // Check for C-style: for (( init; cond; step ))
1395    if tok() == DINPAR {
1396        // c:1110-1111 — close out infor / cmdpos after parse_for_cstyle
1397        // has consumed the init/cond/step triple. Done inside the
1398        // helper itself so we honour the C ordering.
1399        return parse_for_cstyle();
1400    }
1401
1402    // c:1116 — `infor = 0;` immediately on entering the foreach
1403    // branch. Without this, `infor` stays at 2 (set at c:1095 when
1404    // tok==FOR) for the rest of par_for, and the lexer's `((`
1405    // peek at lex.c:786 routes every subsequent `((...))` inside
1406    // the loop body through dbparens — so `for x in a; do (( 1
1407    // )); done` and `if (( 1 )) { … }` inside the do-body both
1408    // mis-lexed as a c-style for header.
1409    set_infor(0); // c:1116
1410
1411    // Get variable name(s). zsh parse.c par_for accepts multiple
1412    // identifier tokens before `in`/`(`/newline — `for k v in ...`
1413    // assigns each iteration's pair of values to k and v in turn.
1414    // We store the names space-joined since variable identifiers
1415    // can't contain whitespace.
1416    let mut names: Vec<String> = Vec::new();
1417    while tok() == STRING_LEX {
1418        let v = tokstr().unwrap_or_default();
1419        if v == "in" {
1420            break;
1421        }
1422        names.push(v);
1423        zshlex();
1424    }
1425    if names.is_empty() {
1426        zerr("expected variable name in for");
1427        return None;
1428    }
1429    let var = names.join(" ");
1430
1431    // Skip newlines
1432    skip_separators();
1433
1434    // Get list. The lexer-port quirk: `for x (a b c)` arrives as a
1435    // single String token with the parens lexed-as-content
1436    // (`<Inpar>a b c<Outpar>`) instead of as separate Inpar/String/
1437    // Outpar tokens. Detect that shape and split it manually.
1438    let list = if tok() == STRING_LEX
1439        && tokstr()
1440            .map(|s| s.starts_with('\u{88}') && s.ends_with('\u{8a}'))
1441            .unwrap_or(false)
1442    {
1443        let raw = tokstr().unwrap_or_default();
1444        // Strip leading Inpar + trailing Outpar. KEEP the inner
1445        // content tokenized — `for x ({1..3}) …` has `{1..3}` as
1446        // Inbrace+content+Outbrace markers, which compile_word_str
1447        // needs to detect and brace-expand. Untokenizing here would
1448        // collapse the markers to plain `{` `}` chars and the brace-
1449        // expansion pass (which strictly requires Inbrace TOKEN per
1450        // Src/glob.c:hasbraces) would skip the word entirely.
1451        // Split only on UNTOKENIZED whitespace at the top level —
1452        // tokenized characters (TOKEN range \u{84}..\u{a1}) are part
1453        // of one word; bare ASCII spaces / tabs separate words.
1454        let inner = &raw[raw.char_indices().nth(1).map(|(i, _)| i).unwrap_or(0)
1455            ..raw
1456                .char_indices()
1457                .last()
1458                .map(|(i, _)| i)
1459                .unwrap_or(raw.len())];
1460        let mut words: Vec<String> = Vec::new();
1461        let mut cur = String::new();
1462        for c in inner.chars() {
1463            if c == ' ' || c == '\t' || c == '\n' {
1464                if !cur.is_empty() {
1465                    words.push(std::mem::take(&mut cur));
1466                }
1467            } else {
1468                cur.push(c);
1469            }
1470        }
1471        if !cur.is_empty() {
1472            words.push(cur);
1473        }
1474        zshlex();
1475        ForList::Words(words)
1476    } else if tok() == STRING_LEX {
1477        let s = tokstr();
1478        if s.map(|s| s == "in").unwrap_or(false) {
1479            // c:Src/parse.c:1147-1154 — after consuming `in`, the
1480            // for-list reads in WORD position, not command position.
1481            // Reset incmdpos=false so the lexer's LX2_INBRACE arm
1482            // (lex.rs:1791) treats a leading `{` as the brace-
1483            // expansion marker (`bct++; add(Inbrace)`) instead of
1484            // returning STRING("{") + promoting to INBRACE_TOK.
1485            // Without this, `for i in {1..3}` saw `{` as the body-
1486            // opener brace, so the word-collection loop got an
1487            // empty word list and the loop body silently ran 0
1488            // iterations.
1489            set_incmdpos(false);
1490            zshlex();
1491            let mut words = Vec::new();
1492            while tok() == STRING_LEX {
1493                let _ts_s = tokstr();
1494                if let Some(s) = _ts_s.as_deref() {
1495                    words.push(s.to_string());
1496                }
1497                zshlex();
1498            }
1499            // c:Src/parse.c:1162 — `incmdpos = 1;` after the
1500            // wordlist + SEPER are consumed, so the next token
1501            // (`do` / `{` body opener) lexes at command position.
1502            set_incmdpos(true);
1503            ForList::Words(words)
1504        } else {
1505            ForList::Positional
1506        }
1507    } else if tok() == INPAR_TOK {
1508        // for var (...) — `for x ({1..3})`: inside the parens, the
1509        // list is in WORD position so `{` must lex as the brace-
1510        // expansion Inbrace marker, NOT as a body-opener INBRACE_TOK.
1511        // Without resetting incmdpos before the next zshlex, the
1512        // lexer's LX2_INBRACE arm promotes `{` to INBRACE_TOK and
1513        // the word-collection loop exits empty, giving
1514        // `for x ({1..3})` an empty iteration.
1515        set_incmdpos(false);
1516        zshlex();
1517        let mut words = Vec::new();
1518        while tok() == STRING_LEX || tok() == SEPER {
1519            if tok() == STRING_LEX {
1520                let _ts_s = tokstr();
1521                if let Some(s) = _ts_s.as_deref() {
1522                    words.push(s.to_string());
1523                }
1524            }
1525            zshlex();
1526        }
1527        if tok() == OUTPAR_TOK {
1528            // After the `)` of a for-list, the next token is the
1529            // body opener — `do`/`{`. zsh's lexer needs incmdpos
1530            // set so `{` lexes as Inbrace (not as a literal). C
1531            // analogue: parse.c::par_for sets `incmdpos = 1`
1532            // after consuming the Outpar before the body parse.
1533            set_incmdpos(true);
1534            zshlex();
1535        }
1536        ForList::Words(words)
1537    } else {
1538        ForList::Positional
1539    };
1540
1541    // Skip to body
1542    skip_separators();
1543
1544    // Parse body
1545    let body = parse_loop_body(is_foreach, false)?;
1546
1547    Some(ZshCommand::For(ZshFor {
1548        var,
1549        list,
1550        body: Box::new(body),
1551        is_select: false,
1552    }))
1553}
1554
1555/// Parse case statement
1556/// Parse `case WORD in PATTERN) BODY ;; ... esac`. Direct port
1557/// of zsh/Src/parse.c:1209 `par_case`. Each case arm is a
1558/// (pattern_list, body, terminator) tuple where terminator is
1559/// `;;` (default), `;&` (fallthrough), or `;|` (continue testing).
1560fn par_case() -> Option<ZshCommand> {
1561    // C par_case (parse.c:1209-1241). Order of state toggles
1562    // matters — the lexer reads the case word in `incmdpos=0`
1563    // (so it's not promoted to a reswd), then the `in`/`{` in
1564    // `incmdpos=1, noaliases=1, nocorrect=1` (so the `in` literal
1565    // isn't alias-expanded or spell-corrected), then sets
1566    // `incasepat=1, incmdpos=0` before the first pattern.
1567    set_incmdpos(false);
1568    zshlex(); // skip 'case'
1569
1570    let word = match tok() {
1571        STRING_LEX => {
1572            let w = tokstr().unwrap_or_default();
1573            // c:1222 — `incmdpos = 1;` before the next zshlex so the
1574            // `in` keyword is recognised. c:1223-1225 — save+force
1575            // noaliases / nocorrect.
1576            set_incmdpos(true);
1577            let ona = noaliases();
1578            let onc = nocorrect();
1579            set_noaliases(true);
1580            set_nocorrect(1);
1581            zshlex();
1582            // Restore noaliases/nocorrect after the `in`-or-`{` token
1583            // is in hand; both are unconditionally restored at c:1238-1239.
1584            let restore = |ona: bool, onc: i32| {
1585                set_noaliases(ona);
1586                set_nocorrect(onc);
1587            };
1588            (w, ona, onc, restore)
1589        }
1590        _ => {
1591            zerr("expected word after case");
1592            return None;
1593        }
1594    };
1595    let (word, ona, onc, restore) = word;
1596
1597    skip_separators();
1598
1599    // Expect 'in' or {
1600    let use_brace = tok() == INBRACE_TOK;
1601    if tok() == STRING_LEX {
1602        let s = tokstr();
1603        if s.map(|s| s != "in").unwrap_or(true) {
1604            // c:1228-1232 — restore noaliases/nocorrect on error path.
1605            restore(ona, onc);
1606            zerr("expected 'in' in case");
1607            return None;
1608        }
1609    } else if !use_brace {
1610        restore(ona, onc);
1611        zerr("expected 'in' or '{' in case");
1612        return None;
1613    }
1614    // c:1236-1239 — `incasepat = 1; incmdpos = 0; noaliases = ona;
1615    // nocorrect = onc;` — set the case-pattern context AND restore
1616    // alias/correct state BEFORE the zshlex that consumes `in`/`{`.
1617    set_incasepat(1);
1618    set_incmdpos(false);
1619    restore(ona, onc);
1620    zshlex();
1621
1622    let mut arms = Vec::new();
1623    const MAX_ARMS: usize = 10_000;
1624
1625    loop {
1626        if arms.len() > MAX_ARMS {
1627            zerr("par_case: too many arms");
1628            break;
1629        }
1630
1631        // Set incasepat BEFORE skipping separators so lexer knows we're in case pattern context
1632        // This affects how [ and | are lexed
1633        set_incasepat(1);
1634
1635        skip_separators();
1636
1637        // Check for end
1638        // Note: 'esac' might be String "esac" if incasepat > 0 prevents reserved word recognition
1639        let is_esac = tok() == ESAC
1640            || (tok() == STRING_LEX && tokstr().map(|s| s == "esac").unwrap_or(false));
1641        if (use_brace && tok() == OUTBRACE_TOK) || (!use_brace && is_esac) {
1642            set_incasepat(0);
1643            zshlex();
1644            break;
1645        }
1646
1647        // Also break on EOF. c:Src/parse.c:1209 par_case requires
1648        // ESAC (or `}` in brace form) to close the block — reaching
1649        // ENDINPUT without either is a parse error (`case ... esack`
1650        // typo absorbs `esack` as part of the body and silently
1651        // terminates rc=0 otherwise). Bug #400.
1652        if tok() == ENDINPUT || tok() == LEXERR {
1653            set_incasepat(0);
1654            crate::ported::utils::zerr("unmatched `case'");
1655            yyerror(0);
1656            break;
1657        }
1658
1659        // c:1250 — `if (tok == INPAR) zshlex();` — leading-paren
1660        // skip path. Used when the lexer DID return INPAR_TOK (e.g.
1661        // SHGLOB or incmdpos forced it). In the normal case-pattern
1662        // path the lexer absorbs `(...)` into one Stringg and the
1663        // hack at c:1322 strips the surrounding parens later. Both
1664        // paths land here.
1665        let leading_inpar_consumed = tok() == INPAR_TOK;
1666        if leading_inpar_consumed {
1667            zshlex();
1668        }
1669
1670        // c:1255-1262 — read pattern STRING. zsh's parser falls
1671        // straight into the STRING reader after the optional INPAR.
1672        // BAR before any pattern means empty string.
1673        let mut patterns = Vec::new();
1674        // Tracks whether the c:1322-1354 hack has fired (paren-
1675        // wrapped Stringg absorbed by the lexer). When it has, the
1676        // closing `)` was already absorbed — no separate OUTPAR
1677        // arm-close to consume.
1678        let mut absorbed_outpar = false;
1679
1680        // Nested-paren pattern: the user wrote `((alt|alt)tail)` —
1681        // the leading arm-INPAR was consumed; the NEXT token is also
1682        // INPAR. zinit.zsh:2946 hits this with `((add-|)fpath)`
1683        // meaning "fpath or add-fpath".
1684        //
1685        // The current lexer (cmd_or_math rewind path interacting
1686        // with gettokstr) drops ONE of the two `)` chars on the
1687        // way out, so the token stream is INPAR INPAR STRING BAR
1688        // STRING OUTPAR — only ONE OUTPAR for two source `)`. We
1689        // can't reconstruct the exact pattern from these tokens.
1690        // Proper fix needs to land in lex.rs (cmd_or_math rewind +
1691        // gettokstr LX2_INPAR/OUTPAR interplay) — see docs/BUGS.md
1692        // entry "case-pattern nested-paren lexer gap".
1693        //
1694        // Workaround: consume every token up to and INCLUDING the
1695        // arm-closing OUTPAR, build a best-effort pattern string,
1696        // set `absorbed_outpar = true` so the post-pattern OUTPAR
1697        // check below skips. The resulting glob may match a slightly
1698        // wider set than the original (`(add-|fpath)` vs
1699        // `(add-|)fpath`) but the rest of the script parses + runs.
1700        // Unblocking sourcing zinit-style configs is the priority.
1701        if leading_inpar_consumed && tok() == INPAR_TOK {
1702            let mut buf = String::new();
1703            let mut depth = 0i32;
1704            loop {
1705                match tok() {
1706                    INPAR_TOK => {
1707                        buf.push('(');
1708                        depth += 1;
1709                        zshlex();
1710                    }
1711                    OUTPAR_TOK => {
1712                        // The single OUTPAR token in the stream is
1713                        // simultaneously the inner glob-group close
1714                        // AND the arm close (lexer collapses both).
1715                        // Consume it, balance the inner depth, exit.
1716                        // `absorbed_outpar = true` tells the shared
1717                        // post-pattern check below to skip its own
1718                        // consume.
1719                        if depth > 0 {
1720                            buf.push(')');
1721                            depth -= 1;
1722                        }
1723                        zshlex();
1724                        break;
1725                    }
1726                    STRING_LEX => {
1727                        if let Some(s) = tokstr() {
1728                            if s == "esac" {
1729                                break;
1730                            }
1731                            buf.push_str(&s);
1732                        }
1733                        set_incasepat(2);
1734                        zshlex();
1735                    }
1736                    BAR_TOK => {
1737                        buf.push('|');
1738                        set_incasepat(1);
1739                        zshlex();
1740                    }
1741                    _ => break,
1742                }
1743            }
1744            patterns.push(buf);
1745            set_incasepat(0);
1746            set_incmdpos(true);
1747            absorbed_outpar = true;
1748        }
1749
1750        // Skip the legacy STRING/BAR pattern-read loop when the
1751        // nested-paren branch above already populated `patterns` and
1752        // consumed the arm-close. Otherwise the legacy loop would
1753        // greedily absorb the body's first command token (`echo`) as
1754        // another alt-pattern.
1755        if !patterns.is_empty() && absorbed_outpar {
1756            // skip to body parse
1757        } else {
1758        loop {
1759            if tok() == STRING_LEX {
1760                let s = tokstr();
1761                if s.as_deref().map(|s| s == "esac").unwrap_or(false) {
1762                    break;
1763                }
1764                let mut str_val = s.unwrap_or_default();
1765
1766                // c:1322-1354 hack: when this is the first alt AND
1767                // the string starts with the Inpar marker, the lexer
1768                // absorbed the whole `(...)` as one token. Chuck the
1769                // blanks around `|`/parens at depth 1 (c:1332-1338 —
1770                // `( d | e )` must become `(d|e)`), then strip the
1771                // surrounding parens — the remainder IS the pattern.
1772                // The closing arm-paren was absorbed too, so we don't
1773                // expect a separate OUTPAR token afterward.
1774                if patterns.is_empty() && str_val.starts_with(crate::ported::zsh_h::Inpar) {
1775                    use crate::ported::zsh_h::{Bar, Inpar, Outpar};
1776                    let meta = crate::ported::zsh_h::Meta as char;
1777                    let blank = |c: char| c.is_ascii() && crate::ported::ztype_h::iblank(c as u8);
1778                    let mut chars: Vec<char> = str_val.chars().collect();
1779                    let mut pct = 0i32;
1780                    let mut i = 0usize;
1781                    let mut end_idx: Option<usize> = None;
1782                    while i < chars.len() {
1783                        if chars[i] == Inpar {
1784                            pct += 1;
1785                        }
1786                        if pct == 1 {
1787                            // c:1332-1334 — chuck blanks AFTER `|`/`(`.
1788                            if chars[i] == Bar || chars[i] == Inpar {
1789                                while i + 1 < chars.len() && blank(chars[i + 1]) {
1790                                    chars.remove(i + 1);
1791                                }
1792                            }
1793                            // c:1335-1338 — chuck blanks BEFORE `|`/`)`
1794                            // (not Meta-escaped blanks).
1795                            if chars[i] == Bar || chars[i] == Outpar {
1796                                while i >= 1
1797                                    && blank(chars[i - 1])
1798                                    && (i < 2 || chars[i - 2] != meta)
1799                                {
1800                                    chars.remove(i - 1);
1801                                    i -= 1;
1802                                }
1803                            }
1804                        }
1805                        if chars[i] == Outpar {
1806                            pct -= 1;
1807                            if pct == 0 {
1808                                end_idx = Some(i);
1809                                break;
1810                            }
1811                        }
1812                        i += 1;
1813                    }
1814                    if let Some(idx) = end_idx {
1815                        // c:1346-1352 — the surrounding-paren strip is only
1816                        // valid when the WHOLE string is `(...)` (close-paren
1817                        // is the last char). C asserts this with
1818                        // `DPUTS(*str != Inpar || str[sl-1] != Outpar)` and
1819                        // `YYERRORV` on `if (*s || pct ...)` when anything
1820                        // follows the matched close-paren. When trailing
1821                        // content exists (`(a|b)*`, `(a|b)c`), the `(...)` is
1822                        // a glob GROUP, not the optional case-opener — keep
1823                        // the whole string verbatim as one pattern and let
1824                        // the separate arm-closing `)` (OUTPAR_TOK) be
1825                        // consumed below. Stripping here turned `(a|b)*` into
1826                        // `a|b*` ("a" or "b*"), breaking OS-dispatch case arms.
1827                        if idx == chars.len() - 1 {
1828                            chars.remove(idx);
1829                            chars.remove(0);
1830                            str_val = chars.into_iter().collect();
1831                            absorbed_outpar = true;
1832                        }
1833                    }
1834                }
1835                patterns.push(str_val);
1836                if absorbed_outpar {
1837                    // c:Src/parse.c:1300-1302 — after a whole-`(...)`
1838                    // pattern the next token may be the body's first
1839                    // command word; C lexes it with `incasepat = -1;
1840                    // incmdpos = 1;` so assignments (`out+=hit`)
1841                    // become ENVSTRING instead of a plain STRING
1842                    // (lex.c:1229-1230 gates ENVSTRING on incmdpos).
1843                    set_incasepat(-1);
1844                    set_incmdpos(true);
1845                } else {
1846                    set_incasepat(2);
1847                }
1848                zshlex();
1849                // When the hack fired the closing `)` is already
1850                // consumed; don't read alt-`|` continuations either.
1851                if absorbed_outpar {
1852                    break;
1853                }
1854            } else if tok() != BAR_TOK {
1855                break;
1856            }
1857
1858            if tok() == BAR_TOK {
1859                set_incasepat(1);
1860                zshlex();
1861            } else {
1862                break;
1863            }
1864        }
1865        }  // end else of "skip legacy loop when nested-paren branch fired"
1866        set_incasepat(0);
1867
1868        // c:1305 — expect OUTPAR (arm-close) when the hack didn't
1869        // already swallow it.
1870        //
1871        // Bug #34 in docs/BUGS.md: the absorbed-pattern hack assumed
1872        // the leading `(` and the case-arm closing `)` were both
1873        // absorbed into the single STRING token. That's true for
1874        // `(x))` (the inner `)` closes the absorbed group; the second
1875        // `)` is the arm closer) only when the lexer slurps BOTH.
1876        // The Rust lexer slurps just `(x|y)` (one balanced pair); the
1877        // second `)` arrives as a separate OUTPAR_TOK that must still
1878        // be consumed as the case-arm closer. Detect and consume it.
1879        if !absorbed_outpar {
1880            if tok() != OUTPAR_TOK {
1881                zerr("expected ')' in case pattern");
1882                return None;
1883            }
1884            // c:Src/parse.c:1257-1258 — `if (tok != STRING)
1885            // YYERRORV(oecused);` C requires at least one pattern
1886            // STRING before `)`. zshrs accepted empty `case x in)`
1887            // and silently fell through to the next iteration with
1888            // an empty pattern arm, swallowing the rest of the
1889            // script. Reject the empty-pattern shape unless a
1890            // leading INPAR was consumed (the `(pat)` form has
1891            // already validated the pattern inside). Bug #161 in
1892            // docs/BUGS.md.
1893            if patterns.is_empty() && !leading_inpar_consumed {
1894                zerr("parse error near `)'");
1895                return None;
1896            }
1897            set_incmdpos(true);
1898            zshlex();
1899            // When the lexer emitted a separate INPAR_TOK at the
1900            // arm start (consumed via `leading_inpar_consumed`
1901            // above), the OUTPAR_TOK we just consumed closed the
1902            // alternation GROUP. If the next token is ALSO
1903            // OUTPAR_TOK, the user wrote `(pat))` and that second
1904            // `)` is the case-arm closer that still needs to be
1905            // consumed before body parsing. Bug #34 in
1906            // docs/BUGS.md.
1907            if leading_inpar_consumed && tok() == OUTPAR_TOK {
1908                zshlex();
1909            }
1910        } else if tok() == OUTPAR_TOK {
1911            // The lexer absorbed `(pat)` as the pattern but left the
1912            // case-arm closing `)` as a separate OUTPAR_TOK. Consume
1913            // it now so body parsing starts at the body, not at `)`.
1914            set_incmdpos(true);
1915            zshlex();
1916        } else {
1917            set_incmdpos(true);
1918        }
1919
1920        // Parse body. Pass end_tokens explicitly so the body's
1921        // parser stops at DSEMI/SEMIAMP/SEMIBAR/ESAC without
1922        // tripping parse_program_until's orphan-terminator check
1923        // (line 7131) which only fires when end_tokens is None.
1924        // Without this, a case arm whose body has no trailing
1925        // `;;` before `esac` (last arm — zsh accepts the dangling
1926        // form) produced "parse error near orphan terminator" on
1927        // the closing `esac`. zsh's par_case at parse.c:1318 sets
1928        // up the case-arm reader to recognize the same terminator
1929        // set; the Rust port was passing the implicit-None and
1930        // hitting the top-level orphan check.
1931        let body = parse_program_until(Some(&[DSEMI, SEMIAMP, SEMIBAR, ESAC]), false);
1932
1933        // Get terminator. Set incasepat=1 BEFORE the zshlex
1934        // advance so the next token (the next arm's pattern, like
1935        // `[a-z]`) gets tokenized in pattern context. Without
1936        // this, a `[`-prefixed pattern after the FIRST arm became
1937        // Inbrack instead of String and the pattern-loop bailed
1938        // out with "expected ')' in case pattern".
1939        // c:Src/parse.c:1391 — `incasepat = 1; incmdpos = 0;` BEFORE
1940        // the zshlex that advances past `;;` to the next arm's first
1941        // token. The incmdpos=0 setting is what makes the lexer
1942        // absorb the next arm's `((add-|)fpath)` into a single STRING
1943        // (gettokstr's LX2_INPAR path at lex.c:1080+ runs only when
1944        // incmdpos is FALSE — at incmdpos=1 the lexer emits raw
1945        // INPAR_TOK for the inner `(` and par_case's pattern-read
1946        // loop has no path to recover). Our Rust port previously set
1947        // only incasepat=1 and not incmdpos=0, which forced
1948        // multi-arm patterns like `((add-|)fpath)` to fail with
1949        // "expected ')' in case pattern" — surfaced on
1950        // zinit.zsh:2946.
1951        let terminator = match tok() {
1952            DSEMI => {
1953                set_incasepat(1);
1954                set_incmdpos(false);
1955                zshlex();
1956                CaseTerm::Break
1957            }
1958            SEMIAMP => {
1959                set_incasepat(1);
1960                set_incmdpos(false);
1961                zshlex();
1962                CaseTerm::Continue
1963            }
1964            SEMIBAR => {
1965                set_incasepat(1);
1966                set_incmdpos(false);
1967                zshlex();
1968                CaseTerm::TestNext
1969            }
1970            _ => CaseTerm::Break,
1971        };
1972
1973        if !patterns.is_empty() {
1974            arms.push(CaseArm {
1975                patterns,
1976                body,
1977                terminator,
1978            });
1979        }
1980    }
1981
1982    Some(ZshCommand::Case(ZshCase { word, arms }))
1983}
1984
1985/// Parse if statement
1986/// Parse `if COND; then BODY; [elif COND; then BODY;]* [else BODY;] fi`.
1987/// Direct port of zsh/Src/parse.c:1411 `par_if`. The C source
1988/// emits WC_IF wordcodes per arm; zshrs builds an AST chain of
1989/// (cond, then_body) tuples plus an optional else_body.
1990fn par_if() -> Option<ZshCommand> {
1991    zshlex(); // skip 'if'
1992
1993    // c:1414 — `par_save_list(cmplx);` — the cond is an ORDINARY
1994    // list (same grammar as par_while's cond): a leading `{ … }`
1995    // block is the cond's first command (`if { false } { body }`,
1996    // `if { CMD } || { CMD }; then …` — p10k.zsh:8376), and the
1997    // brace-form BODY opener is reachable because a list followed
1998    // by `{` without a separator ends (the chaining rule in
1999    // parse_program_until). Only THEN needs to be in the stop set
2000    // (`then` at command position can't start a command).
2001    // fast-syntax-highlighting.plugin.zsh:365-375 is the canonical
2002    // real-world load: `if [[ … ]] { … } elif { type curl … } { … }`.
2003    let cond = Box::new(parse_program_until(Some(&[THEN]), false));
2004
2005    skip_separators();
2006
2007    // Expect 'then' or {
2008    let use_brace = tok() == INBRACE_TOK;
2009    if tok() != THEN && !use_brace {
2010        zerr("expected 'then' or '{' after if condition");
2011        return None;
2012    }
2013    zshlex();
2014
2015    // Parse then-body - stops at else/elif/fi, or } if using brace syntax
2016    let then = if use_brace {
2017        let body = parse_program_until(Some(&[OUTBRACE_TOK]), false);
2018        if tok() == OUTBRACE_TOK {
2019            zshlex();
2020            // c:Src/parse.c:1469-1470 — `zshlex(); incmdpos = 1;`. C
2021            // par_if explicitly resets incmdpos to 1 after consuming
2022            // OUTBRACE so subsequent commands (`; echo after`, `else`
2023            // following the `}` without a `;`) are at command position
2024            // and tokenize correctly.
2025            set_incmdpos(true);
2026        }
2027        Box::new(body)
2028    } else {
2029        Box::new(parse_program_until(Some(&[ELSE, ELIF, FI]), false))
2030    };
2031
2032    // c:Src/parse.c:1471-1472 — `if (tok == SEPER) break;`. For
2033    // brace-form `if … { … }`, C par_if breaks out of the outer
2034    // construct loop WITHOUT consuming the SEPER when one
2035    // immediately follows the closing `}`. The SEPER stays in the
2036    // lexer for the OUTER par_list to consume as the if-statement's
2037    // list separator. Our Rust port's loop below calls skip_separators
2038    // which would eat that SEPER and leave tok=STRING(next-command),
2039    // triggering par_cmd's "STRING_LEX after compound = parse error"
2040    // check at line ~1170. Without this early-exit, brace-form
2041    //   if [[ … ]] { … }; echo after
2042    // failed with `parse error near 'echo'` on zinit.zsh:1422.
2043    //
2044    // We only need this when use_brace is true and the next token
2045    // is a separator (SEPER / NEWLIN / SEMI) AND it's NOT followed
2046    // by ELSE/ELIF (those would extend the construct). Since the
2047    // brace-form already consumed its closing `}` (saw_terminator
2048    // is true), early-return is safe.
2049    if use_brace && matches!(tok(), SEPER | NEWLIN | SEMI | ENDINPUT) {
2050        return Some(ZshCommand::If(ZshIf {
2051            cond,
2052            then,
2053            elif: Vec::new(),
2054            else_: None,
2055        }));
2056    }
2057
2058    // Parse elif and else. zsh accepts the SAME elif/else
2059    // continuations for both classic `then/fi` AND the brace
2060    // form `{ ... } elif ... { ... } else { ... }`. Direct port
2061    // of zsh/Src/parse.c:1417-1500 par_if where the elif/else
2062    // arms are checked AFTER the body close regardless of which
2063    // delimiter style opened the block. Without this, zinit's
2064    //   if [[ -z $sel ]] { ... } else { ... }
2065    // hung the parser — `else` was treated as an external
2066    // command following the if-statement, which the lexer state
2067    // mis-classified inside the still-open function body.
2068    //
2069    // For brace-form: skip the `fi` consumption at the end of
2070    // the loop (no `fi` after a brace block), and `else` may
2071    // arrive after a `}` close. Skip-separators between the
2072    // body close and the elif/else token.
2073    let mut elif = Vec::new();
2074    let mut else_ = None;
2075    // c:Src/parse.c:1501-1504 — `if (tok != FI) { cmdpop(); YYERRORV; }`.
2076    // The C parser fails the whole if-construct when the body close
2077    // isn't seen. zshrs's loop fell through silently on ENDINPUT, so
2078    // `if true; then echo yes` (no `fi`) was accepted. Track whether
2079    // we hit a real terminator and error after the loop if not.
2080    let mut saw_terminator = use_brace; // `{ … }` body already consumed its close
2081
2082    {
2083        loop {
2084            skip_separators();
2085
2086            match tok() {
2087                ELIF => {
2088                    zshlex();
2089                    // c:1414 — elif cond is an ordinary list like the
2090                    // if cond above: `elif { type curl &>/dev/null }
2091                    // { … }` parses the brace block as the COND's
2092                    // command, and the body `{` ends the list via the
2093                    // no-separator chaining rule. INBRACE_TOK in the
2094                    // stop set made the cond parse EMPTY and the cond
2095                    // block masquerade as the body (fsh:370).
2096                    let econd = parse_program_until(Some(&[THEN]), false);
2097                    skip_separators();
2098
2099                    let elif_use_brace = tok() == INBRACE_TOK;
2100                    if tok() != THEN && !elif_use_brace {
2101                        zerr("expected 'then' after elif");
2102                        return None;
2103                    }
2104                    zshlex();
2105
2106                    // elif body stops at else/elif/fi or } if using braces
2107                    let ebody = if elif_use_brace {
2108                        let body = parse_program_until(Some(&[OUTBRACE_TOK]), false);
2109                        if tok() == OUTBRACE_TOK {
2110                            zshlex();
2111                            saw_terminator = true; // brace close on elif
2112                        }
2113                        body
2114                    } else {
2115                        parse_program_until(Some(&[ELSE, ELIF, FI]), false)
2116                    };
2117
2118                    elif.push((econd, ebody));
2119                }
2120                ELSE => {
2121                    zshlex();
2122                    skip_separators();
2123
2124                    // Brace-form `else { … }` is only legal when the
2125                    // PARENT IF itself was opened brace-form (`if cond
2126                    // { … }`). For a `then`-form if, `else { stmt }`
2127                    // is `else` followed by a brace-group STATEMENT
2128                    // that's part of the else body's statement list,
2129                    // which still terminates at `fi`. p10k.zsh:5575
2130                    // hits exactly this shape:
2131                    //   else
2132                    //     { local v=($(<$file)) } 2>/dev/null
2133                    //   fi
2134                    // The prior port unconditionally consumed the `{`
2135                    // as else-brace-opener, then expected the `fi`
2136                    // outside the if construct → "expected `done'".
2137                    let else_use_brace = use_brace && tok() == INBRACE_TOK;
2138                    if else_use_brace {
2139                        zshlex();
2140                    }
2141
2142                    // else body stops at 'fi' or '}'
2143                    else_ = Some(Box::new(if else_use_brace {
2144                        let body = parse_program_until(Some(&[OUTBRACE_TOK]), false);
2145                        if tok() == OUTBRACE_TOK {
2146                            zshlex();
2147                            saw_terminator = true;
2148                        }
2149                        body
2150                    } else {
2151                        parse_program_until(Some(&[FI]), false)
2152                    }));
2153
2154                    // Consume the 'fi' if present (not for brace syntax)
2155                    if !else_use_brace && tok() == FI {
2156                        zshlex();
2157                        saw_terminator = true;
2158                    }
2159                    break;
2160                }
2161                FI => {
2162                    // Brace-form `if ... { ... }` is already terminated by
2163                    // its closing `}`. Do NOT consume `fi` here — it belongs
2164                    // to an enclosing then-form if. Without this gate, a
2165                    // brace-form if inside a then-form if's body would steal
2166                    // the outer `fi`, leaving the outer parser to see
2167                    // "unterminated if". This bit zinit-install.zsh:978
2168                    // where `if (( … )) {` (brace) inside `if … ; then …`
2169                    // (then-form) ate the outer `fi`.
2170                    if use_brace {
2171                        break;
2172                    }
2173                    zshlex();
2174                    saw_terminator = true;
2175                    break;
2176                }
2177                _ => break,
2178            }
2179        }
2180    }
2181
2182    if !saw_terminator {
2183        // c:1501-1504 — YYERRORV when the if-construct never closed.
2184        zerr("parse error: unterminated if");
2185        return None;
2186    }
2187
2188    Some(ZshCommand::If(ZshIf {
2189        cond,
2190        then,
2191        elif,
2192        else_,
2193    }))
2194}
2195
2196/// Parse while/until loop
2197/// Parse `while COND; do BODY; done` and `until COND; do BODY; done`.
2198/// Direct port of zsh/Src/parse.c:1521 `par_while`. The
2199/// `until` variant is the same loop with the condition negated.
2200fn par_while(until: bool) -> Option<ZshCommand> {
2201    zshlex(); // skip while/until
2202
2203    // c:1521-1551 par_while — the condition's parser must stop at
2204    // `do` or `{`. Without an explicit end-token set, parse_program
2205    // consumes the brace-form body as additional condition lists,
2206    // leaving parse_loop_body with nothing — `while (( i++ < 3 )) {
2207    // echo $i }` silently parsed but executed nothing.
2208    // c:1528 — `par_save_list(cmplx);` — the cond is an ORDINARY
2209    // list: a leading `{...}` block is the cond's first command
2210    // (`while {false} {body}`), and the body INBRACE is reachable
2211    // because a list followed by `{` without a separator ends (the
2212    // chaining rule in parse_program_until). DOLOOP stays in the
2213    // stop set because `do` at command position can't start a
2214    // command. INBRACE_TOK was previously (wrongly) in the set too,
2215    // which made a brace-form COND parse as an empty cond + body.
2216    let cond = Box::new(parse_program_until(Some(&[DOLOOP]), false));
2217
2218    skip_separators();
2219    let body = parse_loop_body(false, false)?;
2220
2221    // c:Src/parse.c:1521-1551 par_while — WC_WHILE wordcode is tagged
2222    // with WC_WHILE_TYPE differentiating WHILE vs UNTIL at the wordcode
2223    // layer. The AST mirror in zsh_ast.rs has separate Until(ZshWhile)
2224    // and While(ZshWhile) variants; route by the `until` flag here so
2225    // downstream pattern-matchers can distinguish without poking
2226    // inside the payload's bool.
2227    let w = ZshWhile {
2228        cond,
2229        body: Box::new(body),
2230        until,
2231    };
2232    Some(if until {
2233        ZshCommand::Until(w) // c:1521 (WC_WHILE_TYPE = WC_WHILE_UNTIL)
2234    } else {
2235        ZshCommand::While(w) // c:1521 (WC_WHILE_TYPE = WC_WHILE_WHILE)
2236    })
2237}
2238
2239/// Parse repeat loop
2240/// Parse `repeat N; do BODY; done`. Direct port of
2241/// zsh/Src/parse.c:1565 `par_repeat`. The C source supports
2242/// the SHORTLOOPS short-form `repeat N CMD` (no do/done) — zshrs's
2243/// parser doesn't yet special-case that variant.
2244fn par_repeat() -> Option<ZshCommand> {
2245    zshlex(); // skip 'repeat'
2246
2247    let count = match tok() {
2248        STRING_LEX => {
2249            let c = tokstr().unwrap_or_default();
2250            zshlex();
2251            c
2252        }
2253        _ => {
2254            zerr("expected count after repeat");
2255            return None;
2256        }
2257    };
2258
2259    skip_separators();
2260    // c:1600 — par_repeat's short-form gate is wider: it unlocks
2261    // when SHORTLOOPS OR SHORTREPEAT is set (vs SHORTLOOPS alone for
2262    // for/while). Pass `is_repeat=true` so parse_loop_body
2263    // applies that widened gate.
2264    let body = parse_loop_body(false, true)?;
2265
2266    Some(ZshCommand::Repeat(ZshRepeat {
2267        count,
2268        body: Box::new(body),
2269    }))
2270}
2271
2272/// Parse (...) subshell
2273/// Parse a subshell `( ... )`. Direct port of zsh/Src/parse.c:1619
2274/// `par_subsh`. Body parses as a normal list; the subshell wrapper
2275/// fork-isolates execution in the executor.
2276fn par_subsh() -> Option<ZshCommand> {
2277    zshlex(); // skip (
2278    // c:Src/parse.c:par_subsh — `parse_event(OUTPAR)` parses until
2279    // the matching `)`. zshrs's previous port called bare
2280    // `parse_program()` (parse_program_until(None, false)) which has no
2281    // way to know it should stop at OUTPAR_TOK — at top-level
2282    // that's fine (the outer loop just sees an extra OUTPAR after
2283    // the inner body), but the parse_event-equivalent's new
2284    // yyerror-on-unconsumed-token behavior at parse_program_until's
2285    // None arm now reports a spurious "parse error near `)'" when
2286    // the construct ends. Pass OUTPAR_TOK so parse_program_until
2287    // stops cleanly at the closing paren.
2288    let prog = parse_program_until(Some(&[OUTPAR_TOK]), false);
2289    if tok() == OUTPAR_TOK {
2290        zshlex();
2291    }
2292    Some(ZshCommand::Subsh(Box::new(prog)))
2293}
2294
2295/// Parse function definition
2296/// Parse `function NAME { BODY }` or `NAME () { BODY }`. Direct
2297/// port of zsh/Src/parse.c:1672 `par_funcdef`. zsh handles
2298/// the multiple keyword shapes (function FOO, FOO (), function FOO ()),
2299/// the optional `[fname1 fname2 ...]` for multi-name function defs,
2300/// and the `function FOO () { ... }` traditional/POSIX hybrid form.
2301fn par_funcdef() -> Option<ZshCommand> {
2302    zshlex(); // skip 'function'
2303
2304    let mut names = Vec::new();
2305    let mut tracing = false;
2306
2307    // Handle options like -T and function names. Two subtleties:
2308    //
2309    //   1. Flags: zsh's lexer encodes a leading `-` as
2310    //      `zsh_h::Dash` (`\u{9b}`, `Src/zsh.h:182`) inside the String tokstr.
2311    //      The previous `s.starts_with('-')` check failed for
2312    //      `\u{9b}T`, so `function -T NAME { body }` slipped the
2313    //      `-T` token into `names` and the function got registered
2314    //      as `T` plus the intended `NAME`.
2315    //
2316    //   2. Body opener: zsh's lexer emits the opening `{` as a
2317    //      String (not INBRACE_TOK) when it follows the String
2318    //      NAME — the preceding name token resets incmdpos to
2319    //      false, and only `{` immediately followed by `}` (the
2320    //      empty-body case) gets promoted to Inbrace. The funcdef
2321    //      parser must recognise the bare-`{` String as the body
2322    //      opener; otherwise `function NAME { body }` falls through
2323    //      to `_ => break`, no body parses, and the FuncDef never
2324    //      lands in the AST. This is consistent with C zsh's
2325    //      par_funcdef which knows it's in funcdef-header context
2326    //      and accepts the brace either way.
2327    loop {
2328        match tok() {
2329            STRING_LEX => {
2330                let _ts_s = tokstr()?;
2331                let s = _ts_s.as_str();
2332                // c:1702 — `if ((*tokstr == Inbrace || *tokstr == '{') && !tokstr[1])`.
2333                // Body opener can be either the literal `{` (early-return
2334                // path at lex.c:1141-1144 / lex.rs LX2_INBRACE cmdpos
2335                // branch) or the Inbrace marker `\u{8f}` (lex.c:1420
2336                // post-switch add(c) where c was rewritten via lextok2).
2337                if s == "{" || s == "\u{8f}" {
2338                    break;
2339                }
2340                let first = s.chars().next();
2341                if matches!(first, Some('-') | Some('+')) || matches!(first, Some(c) if c == Dash) {
2342                    if s.contains('T') {
2343                        tracing = true;
2344                    }
2345                    zshlex();
2346                    continue;
2347                }
2348                // c:Src/exec.c::execcmd_args — function name tokens
2349                // in `function NAME { ... }` form go through globbing
2350                // at parse time. zsh's `function with[bracket] { ... }`
2351                // triggers a glob expansion of `with[bracket]`; no file
2352                // matches → "no matches found: NAME" + rc=1 (when
2353                // NOMATCH is set, the default). Bug #536: zshrs accepted
2354                // the literal bracket-containing name and registered
2355                // the function silently. Mirror C by probing for glob
2356                // metachars on the name; if present AND no file
2357                // matches, emit the diagnostic and abort the parse.
2358                let has_glob_chars = s.chars().any(|c| {
2359                    matches!(
2360                        c,
2361                        '[' | ']'
2362                            | '*'
2363                            | '?'
2364                            | crate::ported::zsh_h::Inbrack
2365                            | crate::ported::zsh_h::Outbrack
2366                            | crate::ported::zsh_h::Star
2367                            | crate::ported::zsh_h::Quest
2368                    )
2369                });
2370                if has_glob_chars && crate::ported::zsh_h::isset(crate::ported::zsh_h::NOMATCH) {
2371                    // Probe the funcname pattern through the canonical
2372                    // glob entry — C `zglob(args, firstnode(args), 0)`
2373                    // (c:3318). zglob runs the tokenized word and, under
2374                    // NOMATCH with no match (nullglob off), emits
2375                    // "no matches found: PAT" + errflag itself
2376                    // (c:1876-1880). Tokenize first (the funcname word
2377                    // still holds literal `*`/`?`; haswilds keys on the
2378                    // Star/Quest tokens) and propagate the abort.
2379                    let mut probe = vec![s.to_string()];
2380                    crate::ported::glob::tokenize(&mut probe[0]);
2381                    crate::ported::glob::zglob(&mut probe, 0, 0);
2382                    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2383                        & crate::ported::utils::ERRFLAG_ERROR
2384                        != 0
2385                    {
2386                        return None;
2387                    }
2388                }
2389                names.push(s.to_string());
2390                zshlex();
2391            }
2392            INBRACE_TOK | INOUTPAR | SEPER | NEWLIN => break,
2393            _ => break,
2394        }
2395    }
2396
2397    // Optional ()
2398    let saw_paren = tok() == INOUTPAR;
2399    if saw_paren {
2400        zshlex();
2401    }
2402
2403    skip_separators();
2404
2405    // Body opener: real Inbrace OR a String containing the literal `{`
2406    // (early-return path) OR a String containing the Inbrace marker
2407    // `\u{8f}` (bct++ path post-switch add). C parse.c:1702 handles
2408    // both string forms via `*tokstr == Inbrace || *tokstr == '{'`.
2409    let body_opener_is_string_brace =
2410        tok() == STRING_LEX && tokstr().map(|s| s == "{" || s == "\u{8f}").unwrap_or(false);
2411    if tok() == INBRACE_TOK || body_opener_is_string_brace {
2412        // Capture body_start BEFORE the lexer advances past the
2413        // first body token. After the previous zshlex consumed
2414        // `{`, lexer.pos points just past `{` (which is where the
2415        // body source starts). The next `zshlex()` would advance
2416        // past the first token (`echo`), making body_start land
2417        // mid-body and lose the first word — `typeset -f f` would
2418        // print `a; echo b` for `{ echo a; echo b }`.
2419        // c:Src/parse.c:1690-1706 — par_funcdef requires a clean
2420        //   body-opener brace when the anonymous form `function {body}`
2421        //   is used (no names AND no `()`). zsh's lexer keeps the `{`
2422        //   as its own STRING token via the lex.c:1141-1144 early-
2423        //   return at command position, but the body brace must be
2424        //   followed by whitespace for the inner par_list to find a
2425        //   matching OUTBRACE — without a separator, the closing `}`
2426        //   gets merged into the last word (`X}`) and par_list ends
2427        //   without OUTBRACE, which C zsh reports as `parse error near
2428        //   \`}'`. zshrs's lexer has the same `bct` semantics; reject
2429        //   here at the parse step so the funcdef doesn't silently run
2430        //   with the stray `}` attached. With names or `()` present,
2431        //   the body brace is allowed even without a separator
2432        //   (`function name {body}` and `function () {body}` both work
2433        //   in zsh). Bug #60 in docs/BUGS.md.
2434        if names.is_empty() && !saw_paren {
2435            // Peek the next source byte after the current lexer position
2436            // (`{` was just tokenized — `pos()` points just past it).
2437            // A whitespace separator means proper `function { body }`
2438            // form; anything else is the malformed `function {body}`
2439            // shape zsh rejects.
2440            let next_byte = input_slice(pos(), pos() + 1)
2441                .and_then(|s| s.bytes().next())
2442                .unwrap_or(b' ');
2443            if !matches!(next_byte, b' ' | b'\t' | b'\n' | b';') {
2444                zerr("parse error near `}'"); // c:Src/parse.c YYERRORV
2445                return None;
2446            }
2447        }
2448        let body_start = pos();
2449        zshlex();
2450        // c:Src/parse.c — func body terminates at OUTBRACE_TOK.
2451        // Explicit end-token keeps the inner parse from hitting the
2452        // top-level stray-`}` arm (#168). Bug #167 family.
2453        let body = parse_program_until(Some(&[OUTBRACE_TOK]), false);
2454        // c:Src/parse.c:1733-1737 — `if (tok != OUTBRACE) { cmdpop();
2455        // ... YYERRORV(oecused); }`. Hard-error on missing close brace
2456        // so `function f { echo hi` doesn't silently register a half-
2457        // parsed body. Bug #405.
2458        if tok() != OUTBRACE_TOK {
2459            zerr("parse error: expected `}'");
2460            return None;
2461        }
2462        let body_end = pos().saturating_sub(1);
2463        let body_source = input_slice(body_start, body_end)
2464            .map(|s| {
2465                // Lexer's pos() may have advanced past `}` AND skipped
2466                // trailing whitespace/newlines before returning the
2467                // OUTBRACE_TOK to us, so the slice up to `pos - 1`
2468                // includes the `}` and any preceding whitespace.
2469                // Strip the trailing `}` and any preceding structural
2470                // separator (`;`, `\n`) — C zsh's getpermtext walks
2471                // the wordcode list and emits each command WITHOUT
2472                // the trailing `;`/`\n` that lives in the input.
2473                let t = s.trim();
2474                let t = t.strip_suffix('}').unwrap_or(t).trim_end();
2475                let t = t
2476                    .trim_end_matches(|c: char| c == ';' || c == '\n')
2477                    .trim_end();
2478                t.to_string()
2479            })
2480            .filter(|s| !s.is_empty());
2481        zshlex();
2482
2483        // Anonymous form `function () { body } a b c` (with `()`) or
2484        // `function { body } a b c` (zsh-only shorthand, no `()`). No
2485        // name was collected. Mirror parse_anon_funcdef: synthesize
2486        // `_zshrs_anon_N`, collect trailing args, set auto_call_args
2487        // so compile_funcdef registers + immediately calls the
2488        // function with the args as positional params.
2489        if names.is_empty() {
2490            let mut args = Vec::new();
2491            while tok() == STRING_LEX {
2492                if let Some(s) = tokstr() {
2493                    args.push(s);
2494                }
2495                zshlex();
2496            }
2497            static ANON_COUNTER: AtomicUsize = AtomicUsize::new(0);
2498            let n = ANON_COUNTER.fetch_add(1, Ordering::Relaxed);
2499            let name = format!("_zshrs_anon_kw_{}", n);
2500            return Some(ZshCommand::FuncDef(ZshFuncDef {
2501                names: vec![name],
2502                body: Box::new(body),
2503                tracing,
2504                auto_call_args: Some(args),
2505                body_source,
2506            }));
2507        }
2508
2509        Some(ZshCommand::FuncDef(ZshFuncDef {
2510            names,
2511            body: Box::new(body),
2512            tracing,
2513            auto_call_args: None,
2514            body_source,
2515        }))
2516    } else if unset(SHORTLOOPS) {
2517        // c:Src/parse.c:1742 — `else if (unset(SHORTLOOPS)) YYERRORV`.
2518        // Braceless short body (`function f () CMD`) requires SHORTLOOPS.
2519        zerr("parse error: short function body form requires SHORTLOOPS option");
2520        None
2521    } else {
2522        // c:Src/parse.c:1747-1748 — `else par_list1(&c)`. The short body
2523        // is ONE sublist (a single command), NOT a full list. The prior
2524        // `par_list(false)` greedily consumed past the `;` (so `function
2525        // foo () print bar; foo` swallowed the trailing `foo` call and
2526        // errored "parse error near `foo'"). Use par_cmd for the single
2527        // command, matching the brace-less `foo() CMD` path
2528        // (parse_inline_funcdef). Bug surface: C04funcdef `function foo ()
2529        // print bar`.
2530        par_cmd().map(|cmd| {
2531            let list = ZshList {
2532                sublist: ZshSublist {
2533                    pipe: ZshPipe {
2534                        cmd,
2535                        next: None,
2536                        lineno: lineno(),
2537                        merge_stderr: false,
2538                    },
2539                    next: None,
2540                    flags: SublistFlags::default(),
2541                },
2542                flags: ListFlags::default(),
2543            };
2544            ZshCommand::FuncDef(ZshFuncDef {
2545                names,
2546                body: Box::new(ZshProgram { lists: vec![list] }),
2547                tracing,
2548                auto_call_args: None,
2549                body_source: None,
2550            })
2551        })
2552    }
2553}
2554
2555/// Parse time command
2556/// Parse `time CMD` (POSIX time keyword). Direct port of
2557/// zsh/Src/parse.c:1787 `par_time`. The `time` keyword
2558/// times the execution of the following pipeline / cmd.
2559fn par_time() -> Option<ZshCommand> {
2560    zshlex(); // skip 'time'
2561
2562    // Check if there's a pipeline to time
2563    if tok() == SEPER || tok() == NEWLIN || tok() == ENDINPUT {
2564        Some(ZshCommand::Time(None))
2565    } else {
2566        let sublist = par_sublist();
2567        Some(ZshCommand::Time(sublist.map(Box::new)))
2568    }
2569}
2570
2571/// Port of `par_dinbrack(void)` from `Src/parse.c:1810`. Body
2572/// parser inside `[[ ... ]]` — calls `par_cond` to emit the
2573/// condition wordcode then advances past `]]`.
2574pub fn par_dinbrack() -> Option<()> {
2575    // c:1810
2576    set_incond(1); // c:1814
2577    set_incmdpos(false); // c:1815
2578    zshlex(); // c:1816
2579    let _ = par_cond(); // c:1817
2580    if tok() != DOUTBRACK {
2581        // c:1818
2582        crate::ported::utils::zerr("missing ]]");
2583        yyerror(0);
2584        return None;
2585    }
2586    set_incond(0); // c:1820
2587    set_incmdpos(true); // c:1821
2588    zshlex(); // c:1822
2589    Some(())
2590}
2591
2592/// Parse a simple command
2593/// Parse a simple command (assignments + words + redirections).
2594/// Direct port of zsh/Src/parse.c:1836 `par_simple` —
2595/// the largest single function in parse.c. Handles ENVSTRING/
2596/// ENVARRAY assignments at command head, intermixed redirs,
2597/// typeset-style multi-assignment commands, and the trailing
2598/// inout-par `()` that converts a simple command into an inline
2599/// function definition.
2600fn par_simple(mut redirs: Vec<ZshRedir>) -> Option<ZshCommand> {
2601    let mut assigns = Vec::new();
2602    let mut words = Vec::new();
2603
2604    // c:1934-1974 — `{var}>file` brace-FD detection is wired
2605    // INSIDE the words loop below (parse.rs:4940-4956) rather than
2606    // here at the head. The words-loop site sees the tok=STRING
2607    // `{varname}` followed by a REDIROP and routes into par_redir
2608    // with redir.varid populated. C does it inline at the start of
2609    // each STRING/TYPESET arm iteration; functionally equivalent.
2610
2611    // c:1843-1846 — leading-NOCORRECT prefix: `nocorrect echo hello`
2612    // emits a NOCORRECT token at the start of par_simple. C sets
2613    // `nocorrect = 1` and skips past via the `zshlex();` at the
2614    // for-loop tail (c:1907). zshrs's par_simple (AST) had no
2615    // NOCORRECT arm so the token was silently dropped and the
2616    // following command line evaporated — `nocorrect echo hello`
2617    // produced empty output.
2618    while tok() == NOCORRECT {
2619        set_nocorrect(1); // c:1846
2620        zshlex(); // c:1907 (loop-tail zshlex)
2621    }
2622
2623    // Parse leading assignments
2624    while tok() == ENVSTRING || tok() == ENVARRAY {
2625        if let Some(assign) = parse_assign() {
2626            assigns.push(assign);
2627        }
2628        zshlex();
2629    }
2630
2631    // Parse words and redirections
2632    loop {
2633        match tok() {
2634            ENVSTRING | ENVARRAY if words.is_empty() => {
2635                // c:1843-1907 — still in command position (no command
2636                // word collected yet): a `name=val` token here is a
2637                // PRE-COMMAND assignment, even when it follows a
2638                // redirection (`a=b 2>/dev/null c=d`). C's single
2639                // par_simple loop collects assignments and redirs in any
2640                // order until the command word; zshrs split that into a
2641                // leading-assignments while-loop (above) plus this main
2642                // loop, and the leading loop stopped at the first redir.
2643                // Without this arm the trailing `c=d` fell into the
2644                // typeset-word path below and was silently dropped, so
2645                // `a=b 2>/dev/null c=d` set neither parameter.
2646                if let Some(assign) = parse_assign() {
2647                    assigns.push(assign);
2648                }
2649                zshlex();
2650            }
2651            ENVSTRING | ENVARRAY => {
2652                // Mid-command assignment-shape arg under typeset
2653                // / declare / local / etc. (intypeset gates the
2654                // lexer to emit Envstring/Envarray for `name=val`
2655                // and `name=()` past the command name). Parse the
2656                // assignment, then emit a synthetic word
2657                // `NAME=value` (scalar) or `NAME=( … )` (array)
2658                // string so typeset's builtin arg list sees the
2659                // assignment-shape arg. Avoids the inline-env
2660                // scope path that mistakenly treats it like a
2661                // pre-cmd `X=Y cmd` assignment.
2662                if let Some(assign) = parse_assign() {
2663                    let synthetic = match &assign.value {
2664                        ZshAssignValue::Scalar(v) => format!("{}={}", assign.name, v),
2665                        ZshAssignValue::Array(elems) => {
2666                            // c:Src/builtin.c — assoc paren-init `h=( "" v
2667                            //   k2 v2 )` must preserve empty-string
2668                            //   elements (zsh stores key="" + value="v").
2669                            //   The bin_typeset paren-init splitter at
2670                            //   `builtin.rs:4358` recognizes the
2671                            //   REJOIN_SEP (`\u{1f}`) sentinel between
2672                            //   array elements and skips the leading/
2673                            //   trailing parens trim; using it here
2674                            //   round-trips empties end-to-end through
2675                            //   the synthetic-arg rebuild. Space-join
2676                            //   collapses adjacent empties (`(` + `""` +
2677                            //   `empty-val` becomes `( empty-val`) so
2678                            //   bin_typeset never sees the empty key.
2679                            //   Bug #93 in docs/BUGS.md.
2680                            let mut buf = String::with_capacity(
2681                                assign.name.len() + 4 + elems.iter().map(|e| e.len() + 1).sum::<usize>(),
2682                            );
2683                            buf.push_str(&assign.name);
2684                            buf.push_str("=(");
2685                            for elem in elems {
2686                                buf.push('\u{1f}');
2687                                buf.push_str(elem);
2688                            }
2689                            buf.push('\u{1f}');
2690                            buf.push(')');
2691                            buf
2692                        }
2693                    };
2694                    words.push(synthetic);
2695                }
2696                zshlex();
2697            }
2698            STRING_LEX | TYPESET => {
2699                let s = tokstr();
2700                if let Some(s) = s {
2701                    words.push(s);
2702                }
2703                // c:1929 — `incmdpos = 0;` so the next zshlex() does
2704                // not re-promote `{`/`[[`/reserved words at the
2705                // continuation position. Without this, `echo {a,b}`
2706                // re-lexes `{` as INBRACE_TOK (current-shell block)
2707                // and the brace expansion never reaches par_simple.
2708                set_incmdpos(false);
2709                // c:1931-1932 — `if (tok == TYPESET) intypeset = is_typeset = 1;`
2710                // Multi-assign `typeset a=1 b=2` relies on the lexer
2711                // re-emitting `b=2` as ENVSTRING; that path is gated
2712                // on `intypeset`. Without this, follow-on assignment
2713                // words arrive as STRING and the typeset builtin's
2714                // multi-assign form silently degrades.
2715                if tok() == TYPESET {
2716                    set_intypeset(true);
2717                }
2718                zshlex();
2719                // Check for function definition foo() { ... }
2720                if words.len() == 1 && tok() == INOUTPAR {
2721                    return parse_inline_funcdef(std::mem::take(&mut words));
2722                }
2723                // `{name}>file` named-fd redirect: the lexer doesn't
2724                // recognize this shape, so the bare word `{name}`
2725                // arrives as a String. If it matches `{IDENT}` and
2726                // the NEXT token is a redirop, pop it off as the
2727                // varid for that redir.
2728                if !words.is_empty() && IS_REDIROP(tok()) {
2729                    let last = words.last().unwrap();
2730                    let untoked = super::lex::untokenize(last);
2731                    if untoked.starts_with('{') && untoked.ends_with('}') && untoked.len() > 2 {
2732                        let name = &untoked[1..untoked.len() - 1];
2733                        if !name.is_empty()
2734                            && name.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
2735                            && name
2736                                .chars()
2737                                .next()
2738                                .map(|c| c == '_' || c.is_ascii_alphabetic())
2739                                .unwrap_or(false)
2740                        {
2741                            let varid = name.to_string();
2742                            words.pop();
2743                            if let Some(mut redir) = par_redir() {
2744                                redir.varid = Some(varid);
2745                                redirs.push(redir);
2746                            }
2747                            continue;
2748                        }
2749                    }
2750                }
2751            }
2752            _ if IS_REDIROP(tok()) => {
2753                match par_redir() {
2754                    Some(redir) => redirs.push(redir),
2755                    None => break, // Error in redir parsing, stop
2756                }
2757            }
2758            INOUTPAR if !words.is_empty() => {
2759                // c:2055-2057 — `if (!isset(MULTIFUNCDEF) && argc > 1)
2760                // YYERROR(oecused);` — multi-name funcdef gate:
2761                // `f1 f2() { ... }` defines f1 AND f2 to the same
2762                // body, but only when MULTIFUNCDEF is set.
2763                if !isset(MULTIFUNCDEF) && words.len() > 1 {
2764                    zerr("parse error: multiple names in function definition without MULTIFUNCDEF");
2765                    return None;
2766                }
2767                // c:2061-2068 — `if (isset(EXECOPT) && hasalias &&
2768                // !isset(ALIASFUNCDEF) && argc && hasalias !=
2769                // input_hasalias()) { zwarn(...); YYERROR(...); }`
2770                // Alias-as-funcdef warning. zshrs's parser doesn't
2771                // track `hasalias` (alias-expansion provenance
2772                // during parse) yet, so `had_alias` stays false —
2773                // the gate is wired here as a marker so the canonical
2774                // C predicate is visible. Once alias-provenance lands,
2775                // swap `false` for the actual provenance compare.
2776                let had_alias = false;
2777                if isset(EXECOPT) && had_alias && !isset(ALIASFUNCDEF) && !words.is_empty() {
2778                    crate::ported::utils::zwarn("defining function based on alias `(unknown)'");
2779                    return None;
2780                }
2781                // foo() { ... } / multi-name `f1 f2 f3() { ... }` style
2782                // function. c:Src/parse.c:2055-2068 — under MULTIFUNCDEF
2783                // every preceding word names the SAME body; pass them all
2784                // so each is registered (the gate above already rejected
2785                // multi-name without MULTIFUNCDEF). Previously only the
2786                // last word (`words.pop()`) was defined, so `f1 f2 f3()
2787                // {...}` left f1/f2 as "command not found".
2788                return parse_inline_funcdef(std::mem::take(&mut words));
2789            }
2790            _ => break,
2791        }
2792    }
2793
2794    if assigns.is_empty() && words.is_empty() && redirs.is_empty() {
2795        return None;
2796    }
2797
2798    Some(ZshCommand::Simple(ZshSimple {
2799        assigns,
2800        words,
2801        redirs,
2802    }))
2803}
2804
2805/// Parse a redirection
2806/// Parse a redirection (>file, <file, >>file, <<HEREDOC, etc.).
2807/// Direct port of zsh/Src/parse.c:2229 `par_redir`. Returns
2808/// a ZshRedir node carrying the operator type, fd, target word
2809/// (or here-doc body / pipe-redir command), and any `{var}` style
2810/// fd-binding parameter.
2811fn par_redir() -> Option<ZshRedir> {
2812    par_redir_with_id(None)
2813}
2814
2815/// Wire a here-document body onto the redirection token that
2816/// requested it. Direct port of zsh/Src/parse.c:2347
2817/// `setheredoc`. Called when a heredoc terminator has been
2818/// matched and the body is ready to be attached to the redir.
2819///
2820/// zshrs port note: zsh's setheredoc patches the wordcode
2821/// in-place via `pc[1] = ecstrcode(doc); pc[2] = ecstrcode(term);`.
2822/// zshrs threads heredoc bodies through `HereDocInfo` structs
2823/// attached inline during the post-parse `fill_heredoc_bodies` walk.
2824/// This method is the AST-side equivalent: writes back to the
2825/// matching redir node by index.
2826/// Port of `setheredoc(int pc, int type, char *str, char *termstr,
2827/// char *munged_termstr)` from `Src/parse.c:2347-2355`. Patches the
2828/// pending heredoc redir at `pc` with its body string + raw and
2829/// munged terminator forms.
2830pub fn setheredoc(pc: usize, redir_type: i32, doc: &str, term: &str, munged_term: &str) {
2831    // zshrs-only guard: AST-path heredocs use `pc = -1 as usize`
2832    // (i.e. `usize::MAX`) as a sentinel meaning "no wordcode slot to
2833    // patch". C never passes a negative pc since the wordcode emitter
2834    // is always active. Skip silently for the AST-only case.
2835    if pc == usize::MAX {
2836        return;
2837    }
2838    // c:2350 — `int varid = WC_REDIR_VARID(ecbuf[pc]) ? REDIR_VARID_MASK : 0;`
2839    let cur = ECBUF.with_borrow(|b| b.get(pc).copied().unwrap_or(0));
2840    let varid = if WC_REDIR_VARID(cur) != 0 {
2841        REDIR_VARID_MASK
2842    } else {
2843        0
2844    };
2845    // c:2351 — `ecbuf[pc] = WCB_REDIR(type | REDIR_FROM_HEREDOC_MASK | varid);`
2846    let new_header = WCB_REDIR((redir_type | REDIR_FROM_HEREDOC_MASK | varid) as wordcode);
2847    // c:2352 — `ecbuf[pc + 2] = ecstrcode(str);`
2848    let coded_str = ecstrcode(doc);
2849    // c:2353 — `ecbuf[pc + 3] = ecstrcode(termstr);`
2850    let coded_term = ecstrcode(term);
2851    // c:2354 — `ecbuf[pc + 4] = ecstrcode(munged_termstr);`
2852    let coded_munged = ecstrcode(munged_term);
2853    ECBUF.with_borrow_mut(|b| {
2854        b[pc] = new_header;
2855        b[pc + 2] = coded_str;
2856        b[pc + 3] = coded_term;
2857        b[pc + 4] = coded_munged;
2858    });
2859}
2860
2861/// Parse a wordlist for `for ... in WORDS;`. Direct port of
2862/// zsh/Src/parse.c:2362 `par_wordlist`. Reads STRING tokens
2863/// until the next SEPER / SEMI / NEWLIN.
2864pub fn par_wordlist() -> Vec<String> {
2865    let mut out = Vec::new();
2866    // parse.c:2362-2378 — collect STRINGs into the wordlist.
2867    while tok() == STRING_LEX {
2868        if let Some(text) = tokstr() {
2869            out.push(text);
2870        }
2871        zshlex();
2872    }
2873    out
2874}
2875
2876/// Parse a newline-separated wordlist. Direct port of
2877/// zsh/Src/parse.c:2379 `par_nl_wordlist`. Like
2878/// par_wordlist but tolerates leading/trailing newlines.
2879pub fn par_nl_wordlist() -> Vec<String> {
2880    // parse.c:2380-2381 — skip leading newlines.
2881    while tok() == NEWLIN {
2882        zshlex();
2883    }
2884    let out = par_wordlist();
2885    // parse.c:2395-2397 — skip trailing newlines.
2886    while tok() == NEWLIN {
2887        zshlex();
2888    }
2889    out
2890}
2891
2892/// `COND_SEP()` macro from `Src/parse.c:2433`. True when the current
2893/// token is a separator usable inside `[[ … ]]` (newline / semi /
2894/// `&`). C uses it to skip optional whitespace between cond terms.
2895#[inline]
2896pub fn COND_SEP() -> bool {
2897    matches!(tok(), NEWLIN | SEMI | AMPER)
2898}
2899
2900/// Parse [[ ... ]] conditional
2901/// Parse `[[ EXPR ]]` conditional expression. Direct port of
2902/// zsh/Src/parse.c:2409 `par_cond` (and helpers par_cond_1,
2903/// par_cond_2, par_cond_double, par_cond_triple, par_cond_multi
2904/// at parse.c:2434-2731). Expression operators: `||` `&&` `!`
2905/// + unary tests (-f, -d, -n, -z, etc.) + binary tests (=, !=,
2906///   <, >, ==, =~, -eq, -ne, -lt, -le, -gt, -ge, -nt, -ot, -ef).
2907fn par_cond() -> Option<ZshCommand> {
2908    // C par_dinbrack (parse.c:1810-1822) wraps the body parse with
2909    // `incond = 1; incmdpos = 0;` BEFORE the first zshlex past `[[`,
2910    // and resets to `incond = 0; incmdpos = 1;` after `]]`. Without
2911    // `incond = 1`, lex.c does not promote `]]` to DOUTBRACK and the
2912    // cond body bleeds past the close bracket — the parser then
2913    // sees `]]` as a separate STRING command. Every `if [[ ... ]]; then`
2914    // failed with `command not found: ]]` before this fix.
2915    set_incond(1);
2916    set_incmdpos(false);
2917    zshlex(); // skip [[
2918              // Empty cond `[[ ]]` is a parse error in zsh — emit the
2919              // diagnostic and return None so the caller produces a
2920              // non-zero exit. Without this, `[[ ]]` silently passed and
2921              // returned exit 0.
2922    if tok() == DOUTBRACK {
2923        zerr("parse error near `]]'");
2924        set_incond(0);
2925        set_incmdpos(true);
2926        zshlex();
2927        return None;
2928    }
2929    let cond = parse_cond_expr();
2930
2931    if tok() == DOUTBRACK {
2932        set_incond(0);
2933        set_incmdpos(true);
2934        zshlex();
2935    } else {
2936        // c:Src/parse.c:1818-1819 — `if (tok != DOUTBRACK)
2937        // YYERRORV(oecused);`. par_dinbrack hard-requires DOUTBRACK
2938        // after par_cond; anything else is a parse error and the
2939        // outer parser's yyerror at c:2747 emits `parse error near
2940        // \`%s'` using zshlextext. Bug #473: BAR (`|`) inside
2941        // `[[ ab == a|b ]]` slipped past par_cond_or (which only
2942        // checks DBAR), the cond returned cleanly, and then the
2943        // top-level parser interpreted BAR as a pipe — running `b`
2944        // as a command (security-relevant if pattern RHS is user
2945        // input). Mirror C: emit parse error and abort.
2946        let tok_text = match tok() {
2947            BAR_TOK => "|".to_string(),
2948            DBAR => "||".to_string(),
2949            AMPER => "&".to_string(),
2950            DAMPER => "&&".to_string(),
2951            SEMI => ";".to_string(),
2952            DSEMI => ";;".to_string(),
2953            NEWLIN | SEPER => String::new(),
2954            _ => tokstr().map(|s| crate::ported::lex::untokenize(&s)).unwrap_or_default(),
2955        };
2956        if tok_text.is_empty() {
2957            zerr("parse error");
2958        } else {
2959            zerr(&format!("parse error near `{}'", tok_text));
2960        }
2961        set_incond(0);
2962        set_incmdpos(true);
2963        return None;
2964    }
2965
2966    cond.map(ZshCommand::Cond)
2967}
2968
2969/// Port of `par_cond_1(void)` from `Src/parse.c:2434`. Parses one
2970/// `||`-separated cond expression. Emits `WCB_COND(COND_AND, …)`
2971/// when an `&&` is found and recurses.
2972pub fn par_cond_1() -> i32 {
2973    // c:2434
2974
2975    let p = ECUSED.with(|c| c.get()) as usize;
2976    let r = par_cond_2();
2977    while COND_SEP() {
2978        condlex();
2979    }
2980    if tok() == DAMPER {
2981        condlex();
2982        while COND_SEP() {
2983            condlex();
2984        }
2985        ecispace(p, 1);
2986        par_cond_1();
2987        let ecused = ECUSED.with(|c| c.get()) as usize;
2988        ECBUF.with(|c| {
2989            c.borrow_mut()[p] = WCB_COND(COND_AND as u32, (ecused - 1 - p) as u32);
2990        });
2991        return 1;
2992    }
2993    r
2994}
2995
2996/// Port of `par_cond_2(void)` from `Src/parse.c:2476`. The heavy
2997/// cond-term parser: handles `! cond`, `(cond)`, unary `[ -X arg ]`,
2998/// binary `[ A op B ]`, and `[ A op1 B op2 C … ]` n-ary chains.
2999pub fn par_cond_2() -> i32 {
3000    // c:2476
3001    // `n_testargs` only applies in `testlex` mode (=== /bin/test
3002    // compat). zshrs has no testlex yet, so always 0.
3003    let n_testargs: i32 = 0;
3004
3005    // c:2481 — handled inline; this Rust port skips the n_testargs
3006    // arm since zshrs invokes par_cond via [[ ... ]] only.
3007
3008    while COND_SEP() {
3009        condlex();
3010    }
3011    if tok() == BANG_TOK {
3012        // c:2522 — `[[ ! cond ]]`
3013        condlex();
3014        ecadd(WCB_COND(COND_NOT as u32, 0));
3015        return par_cond_2();
3016    }
3017    if tok() == INPAR_TOK {
3018        // c:2533 — `[[ (cond) ]]`
3019        condlex();
3020        while COND_SEP() {
3021            condlex();
3022        }
3023        let r = par_cond();
3024        while COND_SEP() {
3025            condlex();
3026        }
3027        if tok() != OUTPAR_TOK {
3028            crate::ported::utils::zerr("missing )");
3029            yyerror(0);
3030            return 0;
3031        }
3032        condlex();
3033        return r.map_or(0, |_| 1);
3034    }
3035    let s1 = tokstr().unwrap_or_default();
3036    // c:2549 — `dble = (s1 && IS_DASH(*s1) && (!n_testargs ||
3037    // strspn(s1+1, "abcd...") == 1) && !s1[2]);` — IS_DASH covers
3038    // BOTH `-` and Dash (`\u{9b}`). The raw tokstr inside `[[ ... ]]`
3039    // carries Dash as a marker byte, so `starts_with('-')` alone
3040    // matches only ASCII dashes and misses every `-z`, `-d`, `-r`
3041    // etc. — every such cond emitted the AST-only `condition
3042    // expected` error from par_cond_double. Use IS_DASH and count
3043    // chars (Dash is a single code point) instead of bytes.
3044    let s1_chars: Vec<char> = s1.chars().collect();
3045    let dble = !s1_chars.is_empty()
3046        && IS_DASH(s1_chars[0])
3047        && s1_chars.len() == 2
3048        && "abcdefghknoprstuvwxzLONGS".contains(s1_chars[1]);
3049    if tok() != STRING_LEX {
3050        if !s1.is_empty() && tok() != LEXERR && (!dble || n_testargs != 0) {
3051            // c:2486-2497 — `if (n_testargs == 1)` block: under
3052            // POSIXBUILTINS-off, `[ -t ]` rewrites to `[ -t 1 ]`
3053            // (ksh behavior). The C gate is `unset(POSIXBUILTINS)
3054            // && check_cond(s1, "t")`. zshrs's parser has
3055            // n_testargs=0 (no testlex), so this rewrite path is
3056            // unreachable from zshrs's [[ ]] / [ ] entry points;
3057            // wired here as a marker for parity. When testlex is
3058            // ported the call below activates.
3059            if n_testargs == 1 && unset(POSIXBUILTINS) && check_cond(&s1, "t") {
3060                condlex();
3061                return par_cond_double(&s1, "1");
3062            }
3063            // c:2557 — `[[ STRING ]]` re-interpreted as `[[ -n STRING ]]`.
3064            condlex();
3065            while COND_SEP() {
3066                condlex();
3067            }
3068            return par_cond_double("-n", &s1);
3069        }
3070        crate::ported::utils::zerr("condition expected");
3071        yyerror(0);
3072        return 0;
3073    }
3074    condlex();
3075    while COND_SEP() {
3076        condlex();
3077    }
3078    if tok() == INANG_TOK || tok() == OUTANG_TOK {
3079        // c:2576 — `<` / `>` string compare.
3080        let xtok = tok();
3081        condlex();
3082        while COND_SEP() {
3083            condlex();
3084        }
3085        if tok() != STRING_LEX {
3086            crate::ported::utils::zerr("string expected");
3087            yyerror(0);
3088            return 0;
3089        }
3090        let s3 = tokstr().unwrap_or_default();
3091        condlex();
3092        while COND_SEP() {
3093            condlex();
3094        }
3095        let op = if xtok == INANG_TOK {
3096            COND_STRLT
3097        } else {
3098            COND_STRGTR
3099        };
3100        ecadd(WCB_COND(op as u32, 0));
3101        ecstr(&s1);
3102        ecstr(&s3);
3103        return 1;
3104    }
3105    if tok() != STRING_LEX {
3106        // c:2592 — only one operand seen → `[ -n s1 ]`.
3107        if tok() != LEXERR {
3108            if !dble || n_testargs != 0 {
3109                return par_cond_double("-n", &s1);
3110            }
3111            return par_cond_multi(&s1, &[]);
3112        }
3113        crate::ported::utils::zerr("syntax error");
3114        yyerror(0);
3115        return 0;
3116    }
3117    let s2 = tokstr().unwrap_or_default();
3118    set_incond(incond() + 1);
3119    condlex();
3120    while COND_SEP() {
3121        condlex();
3122    }
3123    set_incond(incond() - 1);
3124    // c:Src/parse.c:2598-2600 — `if (!n_testargs) dble = (s2 &&
3125    // IS_DASH(*s2) && !s2[2]);` — RECOMPUTE dble based on s2 once
3126    // it's been read, so `[[ A -X B ]]` is treated as a 2-arg cond
3127    // `[ -X B ]` (par_cond_double) rather than a 3-arg triple. This
3128    // is what routes `[[ "" -a "x" ]]` to par_cond_double("", "-a")
3129    // → COND_ERROR "parse error: condition expected: ". Without
3130    // this, the original `dble` from s1 stayed false, the parser
3131    // grabbed s3 and built COND_MODI silently. parity bug #25.
3132    let s2_chars: Vec<char> = s2.chars().collect();
3133    let dble = !s2_chars.is_empty() && IS_DASH(s2_chars[0]) && s2_chars.len() == 2;
3134    if tok() == STRING_LEX && !dble {
3135        let s3 = tokstr().unwrap_or_default();
3136        condlex();
3137        while COND_SEP() {
3138            condlex();
3139        }
3140        if tok() == STRING_LEX {
3141            // c:2615 — n-ary `[ A op B C D ... ]`.
3142            let mut l: Vec<String> = vec![s2, s3];
3143            while tok() == STRING_LEX {
3144                l.push(tokstr().unwrap_or_default());
3145                condlex();
3146                while COND_SEP() {
3147                    condlex();
3148                }
3149            }
3150            return par_cond_multi(&s1, &l);
3151        }
3152        return par_cond_triple(&s1, &s2, &s3);
3153    }
3154    par_cond_double(&s1, &s2)
3155}
3156
3157/// Port of `par_cond_double(char *a, char *b)` from `Src/parse.c:2626`.
3158/// Emits wordcode for unary cond `[ -X b ]` or modular `[ -mod b ]`.
3159pub fn par_cond_double(a: &str, b: &str) -> i32 {
3160    // c:2628 — `if (!IS_DASH(a[0]) || !a[1])` — char-based, since
3161    // Dash is a single code point (`\u{9b}`) and `a.len() < 2` on
3162    // BYTES would still pass for "-z" but fail for the marker form
3163    // `\u{9b}z` (2 bytes). Walk by chars.
3164    let ac: Vec<char> = a.chars().collect();
3165    if ac.is_empty() || !IS_DASH(ac[0]) || ac.len() < 2 {
3166        // c:Src/parse.c:2629 COND_ERROR macro expansion:
3167        //   zwarn(...); herrflush(); errflag |= ERRFLAG_ERROR;
3168        //   YYERROR(ecused) /* sets tok = LEXERR */
3169        // The YYERROR portion is critical — without it the outer
3170        // parser keeps walking the wordcode and execution proceeds
3171        // (e.g. `[[ "" -a "x" ]] && echo m || echo n` runs the
3172        // `|| echo n` branch). Setting LEXERR aborts the upper
3173        // parse so the whole line is rejected, matching zsh's
3174        // observable behavior of stdout="" on parse error.
3175        zerr(&format!("parse error: condition expected: {}", a));
3176        errflag.fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::SeqCst);
3177        set_tok(LEXERR);
3178        return 1;
3179    }
3180    // c:2630 — `else if (!a[2] && strspn(a+1, "abcd...zhLONGS") == 1)`
3181    let unary_set = "abcdefgknoprstuvwxzhLONGS";
3182    if ac.len() == 2 && unary_set.contains(ac[1]) {
3183        // c:2631 — `ecadd(WCB_COND(a[1], 0));` uses the raw cond-op
3184        // letter byte as the opcode payload. Use the ASCII char's
3185        // code-point value directly — every letter in `unary_set`
3186        // fits in 7 bits.
3187        ecadd(WCB_COND(ac[1] as u32, 0));
3188        ecstr(b);
3189    } else {
3190        ecadd(WCB_COND(COND_MOD as u32, 1));
3191        ecstr(a);
3192        ecstr(b);
3193    }
3194    1
3195}
3196
3197/// Port of `get_cond_num(char *tst)` from `Src/parse.c:2643`. Returns
3198/// the index of `tst` in `{"nt","ot","ef","eq","ne","lt","gt","le","ge"}`
3199/// or `-1` if not a recognized binary cond operator.
3200pub fn get_cond_num(tst: &str) -> i32 {
3201    // c:2643
3202    const CONDSTRS: [&str; 9] = [
3203        "nt", "ot", "ef", "eq", "ne", "lt", "gt", "le", "ge", // c:2647
3204    ];
3205    for (i, &c) in CONDSTRS.iter().enumerate() {
3206        if c == tst {
3207            return i as i32; // c:2654
3208        }
3209    }
3210    -1 // c:2656
3211}
3212
3213/// par_time's `static int inpartime` guard at C parse.c:1038
3214/// preventing infinite recursion on `time time foo`. The wordcode
3215/// path keeps this as a thread_local since C uses a function-level
3216/// `static int` (per-process; per-evaluator semantically matches).
3217thread_local! {
3218    static PARSER_INPARTIME: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
3219}
3220
3221/// Port of `par_cond_triple(char *a, char *b, char *c)` from
3222/// `Src/parse.c:2659`. Emits wordcode for the binary forms
3223/// `[ A op B ]` — `=` / `==` / `!=` / `<` / `>` / `=~` / `-X`.
3224///
3225/// C does `(b[0] == Equals || b[0] == '=')` etc., matching BOTH the
3226/// raw ASCII operator char AND its tokenized marker form per
3227/// `Src/zsh.h:159-194`:
3228///   Equals = `\u{8d}`, Outang = `\u{95}`, Inang  = `\u{94}`,
3229///   Tilde  = `\u{98}`, Bang   = `\u{9c}`, Dash   = `\u{9b}`.
3230/// Inside `[[ ... ]]` the lexer emits the marker bytes — comparing
3231/// against literal-only `b"=="` misses every cond op.
3232/// (The previous Rust port had the doc comment values wrong:
3233/// Outang=0x8e was actually Bar; Inang=0x91 was Inbrack;
3234/// Tilde=0x96 was OutangProc; Bang=0x8b was Outparmath. The code
3235/// itself uses the correct const names, so this was a docs-only fix.)
3236pub fn par_cond_triple(a: &str, b: &str, c: &str) -> i32 {
3237    // c:2659
3238    let bc: Vec<char> = b.chars().collect();
3239    let is_eq = |ch: char| ch == '=' || ch == Equals;
3240    let is_gt = |ch: char| ch == '>' || ch == Outang;
3241    let is_lt = |ch: char| ch == '<' || ch == Inang;
3242    let is_tilde = |ch: char| ch == '~' || ch == Tilde;
3243    let is_bang = |ch: char| ch == '!' || ch == Bang;
3244
3245    // c:2663 — `(b[0] == Equals || b[0] == '=') && !b[1]` → `=` (single).
3246    if bc.len() == 1 && is_eq(bc[0]) {
3247        ecadd(WCB_COND(COND_STREQ as u32, 0));
3248        ecstr(a);
3249        ecstr(c);
3250        let np = ECNPATS.with(|cc| {
3251            let v = cc.get();
3252            cc.set(v + 1);
3253            v
3254        }) as u32;
3255        ecadd(np);
3256        return 1;
3257    }
3258    // c:2668-2673 — `(t0 = b[0]=='>' || Outang) || b[0]=='<' || Inang`.
3259    if bc.len() == 1 && (is_gt(bc[0]) || is_lt(bc[0])) {
3260        let op = if is_gt(bc[0]) {
3261            COND_STRGTR
3262        } else {
3263            COND_STRLT
3264        };
3265        ecadd(WCB_COND(op as u32, 0));
3266        ecstr(a);
3267        ecstr(c);
3268        let np = ECNPATS.with(|cc| {
3269            let v = cc.get();
3270            cc.set(v + 1);
3271            v
3272        }) as u32;
3273        ecadd(np);
3274        return 1;
3275    }
3276    // c:2674-2679 — `==` STRDEQ.
3277    if bc.len() == 2 && is_eq(bc[0]) && is_eq(bc[1]) {
3278        ecadd(WCB_COND(COND_STRDEQ as u32, 0));
3279        ecstr(a);
3280        ecstr(c);
3281        let np = ECNPATS.with(|cc| {
3282            let v = cc.get();
3283            cc.set(v + 1);
3284            v
3285        }) as u32;
3286        ecadd(np);
3287        return 1;
3288    }
3289    // c:2680-2684 — `!=` STRNEQ.
3290    if bc.len() == 2 && is_bang(bc[0]) && is_eq(bc[1]) {
3291        ecadd(WCB_COND(COND_STRNEQ as u32, 0));
3292        ecstr(a);
3293        ecstr(c);
3294        let np = ECNPATS.with(|cc| {
3295            let v = cc.get();
3296            cc.set(v + 1);
3297            v
3298        }) as u32;
3299        ecadd(np);
3300        return 1;
3301    }
3302    // c:2685-2691 — `=~` REGEX (no pattern slot — implicit COND_MODI).
3303    if bc.len() == 2 && is_eq(bc[0]) && is_tilde(bc[1]) {
3304        ecadd(WCB_COND(COND_REGEX as u32, 0));
3305        ecstr(a);
3306        ecstr(c);
3307        return 1;
3308    }
3309    // c:2692-2702 — `-OP` numeric-or-modular cond (e.g. `-eq`, `-nt`).
3310    if !bc.is_empty() && IS_DASH(bc[0]) {
3311        let rest: String = bc[1..].iter().collect();
3312        let t = get_cond_num(&rest);
3313        if t > -1 {
3314            ecadd(WCB_COND((t + COND_NT) as u32, 0));
3315            ecstr(a);
3316            ecstr(c);
3317            return 1;
3318        }
3319        ecadd(WCB_COND(COND_MODI as u32, 0));
3320        ecstr(b);
3321        ecstr(a);
3322        ecstr(c);
3323        return 1;
3324    }
3325    // c:2703-2707 — `-mod A B C` modular cond on `a`.
3326    let ac: Vec<char> = a.chars().collect();
3327    if !ac.is_empty() && IS_DASH(ac[0]) && ac.len() > 1 {
3328        ecadd(WCB_COND(COND_MOD as u32, 2));
3329        ecstr(a);
3330        ecstr(b);
3331        ecstr(c);
3332        return 1;
3333    }
3334    zerr(&format!("condition expected: {}", b));
3335    1
3336}
3337
3338/// Port of `par_cond_multi(char *a, LinkList l)` from `Src/parse.c:2716`.
3339/// Emits wordcode for `[ -OP A B C … ]` n-ary cond (alternation).
3340pub fn par_cond_multi(a: &str, l: &[String]) -> i32 {
3341    // c:2716 — `if (!IS_DASH(a[0]) || !a[1])`; same Dash/`-` dual
3342    // matching as par_cond_double, char-walked because Dash is a
3343    // single code point.
3344    let ac: Vec<char> = a.chars().collect();
3345    if ac.is_empty() || !IS_DASH(ac[0]) || ac.len() < 2 {
3346        zerr(&format!("condition expected: {}", a));
3347        return 1;
3348    }
3349    ecadd(WCB_COND(COND_MOD as u32, l.len() as u32));
3350    ecstr(a);
3351    for item in l {
3352        ecstr(item);
3353    }
3354    1
3355}
3356
3357/// Emit a parser-level error. Direct port of zsh/Src/parse.c
3358/// 2733-2766 `yyerror`. C version fills a per-event error buffer
3359/// and sets errflag. zshrs pushes onto errors which the
3360/// caller drains via parse()'s Result return.
3361/// WARNING: param-name divergence — Rust takes `&str message`, C takes
3362/// Port of `static void yyerror(int noerr)` from `Src/parse.c:2733`.
3363///
3364/// Faithful C body (verbatim):
3365/// ```c
3366/// int t0; char *t;
3367/// if ((t = dupstring(zshlextext))) untokenize(t);
3368/// for (t0 = 0; t0 != 20; t0++)
3369///     if (!t || !t[t0] || t[t0] == '\n') break;
3370/// if (!(histdone & HISTFLAG_NOEXEC) && !(errflag & ERRFLAG_INT)) {
3371///     if (t0) {
3372///         t = metafy(t, t0, META_STATIC);
3373///         zwarn("parse error near `%s%s'", t, t0 == 20 ? "..." : "");
3374///     } else
3375///         zwarn("parse error");
3376/// }
3377/// if (!noerr && noerrs != 2)
3378///     errflag |= ERRFLAG_ERROR;
3379/// ```
3380///
3381/// `zshlextext` is the C lexer's current-token text (`Src/lex.c:170`
3382/// `char *tokstr`); zshrs's equivalent is `lex::tokstr()`. The "20"
3383/// is C's tail-truncation length for the error message.
3384pub fn yyerror(noerr: i32) {
3385    // c:2733
3386    // c:2738 — `if ((t = dupstring(zshlextext))) untokenize(t);`.
3387    // In C, `zshlextext` falls back to `tokstrings[tok]` (lex.c:1965)
3388    // for punctuation tokens that didn't capture a tokstr — that's
3389    // how "parse error near `)'" gets the `)` for OUTPAR. Mirror by
3390    // consulting `lex::tokstring(tok())` when the captured tokstr is
3391    // None.
3392    let t_opt: Option<String> = match crate::ported::lex::tokstr() {
3393        Some(raw) => Some(crate::ported::lex::untokenize(&raw).to_string()),
3394        None => {
3395            let t = crate::ported::lex::tok();
3396            let i = t as usize;
3397            if i < crate::ported::lex::tokstrings.len() {
3398                crate::ported::lex::tokstrings[i].map(|s| s.to_string())
3399            } else {
3400                None
3401            }
3402        }
3403    };
3404    let t_bytes: Vec<u8> = t_opt
3405        .as_ref()
3406        .map(|s| s.as_bytes().to_vec())
3407        .unwrap_or_default();
3408
3409    // c:2741-2743 — `for (t0 = 0; t0 != 20; t0++) if (!t || !t[t0]
3410    //   || t[t0] == '\n') break;`
3411    let mut t0: usize = 0;
3412    while t0 != 20 {
3413        // c:2741
3414        let stop = t_opt.is_none()
3415            || t0 >= t_bytes.len()
3416            || t_bytes[t0] == 0
3417            || t_bytes[t0] == b'\n';
3418        if stop {
3419            break;
3420        }
3421        t0 += 1;
3422    }
3423
3424    // c:2744 — `if (!(histdone & HISTFLAG_NOEXEC) && !(errflag &
3425    //   ERRFLAG_INT))`. The HISTFLAG_NOEXEC gate suppresses warnings
3426    //   from history-recall paths that aren't actually executing.
3427    let histdone_v = crate::ported::hist::histdone.load(Ordering::SeqCst);
3428    let hist_noexec =
3429        (histdone_v & crate::ported::zsh_h::HISTFLAG_NOEXEC as i32) != 0;
3430    let int_flagged =
3431        (errflag.load(Ordering::SeqCst) & crate::ported::zsh_h::ERRFLAG_INT) != 0;
3432    if !hist_noexec && !int_flagged {
3433        // c:2744
3434        if t0 != 0 {
3435            // c:2745
3436            // c:2746 — `t = metafy(t, t0, META_STATIC);` — re-metafy
3437            //   the truncated head so embedded Meta bytes display
3438            //   correctly. The Rust port already holds an untokenized
3439            //   string; use the byte slice [0..t0] directly.
3440            let head =
3441                std::str::from_utf8(&t_bytes[..t0]).unwrap_or_default();
3442            let suffix = if t0 == 20 { "..." } else { "" };
3443            crate::ported::utils::zwarn(&format!(
3444                "parse error near `{}{}'",
3445                head, suffix
3446            )); // c:2747
3447        } else {
3448            // c:2748
3449            crate::ported::utils::zwarn("parse error"); // c:2749
3450        }
3451    }
3452    // c:2751 — `if (!noerr && noerrs != 2) errflag |= ERRFLAG_ERROR;`.
3453    //   The `noerrs != 2` gate (suppress-only-fatal-errors) is preserved
3454    //   for parity with zerr/zwarn's matching check.
3455    let noerrs_v = *crate::ported::utils::noerrs_lock().lock().unwrap();
3456    if noerr == 0 && noerrs_v != 2 {
3457        // c:2751
3458        errflag.fetch_or(
3459            crate::ported::zsh_h::ERRFLAG_ERROR,
3460            Ordering::SeqCst,
3461        ); // c:2752
3462    }
3463}
3464
3465
3466// ============================================================
3467// Eprog runtime ops (parse.c:2767-2853)
3468//
3469// dupeprog / useeprog / freeeprog are zsh's reference-counting
3470// helpers for executable programs. zshrs's AST is owned by
3471// value (Rust ownership); cloning is a tree-deep copy via
3472// Clone, "use" is a no-op (the executor borrows the AST), and
3473// "free" is automatic on drop.
3474// ============================================================
3475
3476/// Duplicate an Eprog. Direct port of zsh/Src/parse.c:2813
3477/// Port of `Eprog dupeprog(Eprog p, int heap)` from
3478/// `Src/parse.c:2767`. Deep-copies the wordcode array, string
3479/// table, and pattern-prog slots. `dummy_eprog` is returned
3480/// unchanged. `heap`-allocated copies get `nref = -1` (never
3481/// freed); real ones get `nref = 1`.
3482pub fn dupeprog(p: &eprog, heap: bool) -> eprog {
3483    // c:2774-2775 — `if (p == &dummy_eprog) return p;` — caller-
3484    // observable identity in C uses a pointer compare; Rust's
3485    // equivalent is "if it has the dummy's shape (single WCB_END
3486    // word and no strs), return a copy of the same shape".
3487    // c:2796-2797 — `for (i = r->npats; i--; pp++) *pp = dummy_patprog1;`
3488    // C uses `dummy_patprog1` as a placeholder; the Rust port has
3489    // `Vec<Patprog>` (Box<patprog>) — synthesize an equivalent zero-
3490    // initialized patprog for each slot (resolved later by
3491    // pattern.c::patcompile-on-first-use).
3492    let dummy_pat = || crate::ported::zsh_h::patprog {
3493        startoff: 0,
3494        size: 0,
3495        mustoff: 0,
3496        patmlen: 0,
3497        globflags: 0,
3498        globend: 0,
3499        flags: 0,
3500        patnpar: 0,
3501        patstartch: 0,
3502    };
3503    let r = eprog {
3504        // c:2778 — `flags = (heap ? EF_HEAP : EF_REAL) | (p->flags & EF_RUN);`
3505        flags: (if heap { EF_HEAP } else { EF_REAL }) | (p.flags & EF_RUN),
3506        len: p.len,
3507        npats: p.npats,
3508        // c:2787 — `nref = heap ? -1 : 1;`
3509        nref: if heap { -1 } else { 1 },
3510        prog: p.prog.clone(),
3511        strs: p.strs.clone(),
3512        pats: (0..p.npats).map(|_| Box::new(dummy_pat())).collect(),
3513        shf: None,
3514        dump: None,
3515    };
3516    r
3517}
3518
3519/// Port of `void useeprog(Eprog p)` from `Src/parse.c:2813`.
3520/// `if (p && p != &dummy_eprog && p->nref >= 0) p->nref++;` —
3521/// pin a real (non-heap, non-dummy) Eprog so it survives the
3522/// next `freeeprog`.
3523pub fn useeprog(p: &mut eprog) {
3524    // c:2815 — `if (p && p != &dummy_eprog && p->nref >= 0)`
3525    if p.nref >= 0 {
3526        p.nref += 1; // c:2816
3527    }
3528}
3529
3530/// Port of `void freeeprog(Eprog p)` from `Src/parse.c:2823`.
3531/// Refcount-decrement; when it hits zero, drops the pattern progs,
3532/// decrements the dump refcount if any, and releases the eprog.
3533/// `dummy_eprog` is never freed. Heap-eprogs (`nref < 0`) are
3534/// never freed either — they live as long as the heap arena.
3535pub fn freeeprog(p: &mut eprog) {
3536    // c:2829 — `if (p && p != &dummy_eprog) { ... }`
3537    if p.nref > 0 {
3538        p.nref -= 1; // c:2832
3539        if p.nref == 0 {
3540            // c:2833-2840 — drop pats, dump refcount, then the eprog.
3541            // Rust's Drop handles the per-field cleanup; we just
3542            // need to decrement the dump count first.
3543            if let Some(dump) = p.dump.take() {
3544                let dumped = (*dump).clone();
3545                decrdumpcount(&dumped); // c:2837
3546            }
3547            p.prog.clear();
3548            p.strs = None;
3549            p.pats.clear();
3550        }
3551    }
3552}
3553
3554// =============================================================================
3555// Wordcode read helpers — used by text.rs's `gettext2` and exec dispatch
3556// to walk a compiled Eprog without re-running the parser. These are the
3557// only `Src/parse.c` functions ported so far in this file; the recursive-
3558// descent parser (par_event / par_list / par_cmd / par_*) follows
3559// below as free ported at module scope.
3560// =============================================================================
3561
3562/// Port of `ecgetstr(Estate s, int dup, int *tokflag)` from `Src/parse.c:2855`.
3563/// `s->pc` advances through the wordcode buffer; `s->strs` indexes the
3564/// string pool. Returns the interned string (or a 1-3-char literal
3565/// inlined directly into the wordcode word).
3566pub fn ecgetstr(s: &mut estate, dup: i32, tokflag: Option<&mut i32>) -> String {
3567    let prog = &s.prog.prog;
3568    if s.pc >= prog.len() {
3569        return String::new();
3570    }
3571    let c = prog[s.pc]; // c:2858 `wordcode c = *s->pc++;`
3572    s.pc += 1;
3573    if let Some(tf) = tokflag {
3574        *tf = i32::from((c & 1) != 0); // c:2880 `*tokflag = (c & 1);`
3575    }
3576    if c == 6 || c == 7 {
3577        // c:2861 `if (c == 6 || c == 7) r = "";`
3578        return String::new();
3579    }
3580    let r: String = if (c & 2) != 0 {
3581        // c:2862 — `else if (c & 2)`
3582        // c:2863-2868 — 3-byte inline string packed into the wordcode
3583        // word; followed by `buf[3] = '\0'; r = dupstring(buf);`.
3584        // C's `dupstring` uses `strlen(buf)` which TRUNCATES at the
3585        // first NUL byte — short strings of 1 or 2 chars get padded
3586        // with NULs and truncated cleanly. The previous Rust port
3587        // used `retain(|&x| x != 0)` which would silently SPLICE OUT
3588        // an interior NUL (e.g. `[a, 0, b]` → "ab"), diverging from
3589        // C's strlen-truncate (`[a, 0, b]` → "a"). Fix: truncate at
3590        // first NUL to match C exactly.
3591        let b0 = ((c >> 3) & 0xff) as u8;
3592        let b1 = ((c >> 11) & 0xff) as u8;
3593        let b2 = ((c >> 19) & 0xff) as u8;
3594        let v = [b0, b1, b2];
3595        let end = v.iter().position(|&x| x == 0).unwrap_or(v.len()); // c:2869 strlen(buf)
3596        // C reads raw bytes (token codes included) — widen via the
3597        // wordcode-pool bridge, never from_utf8_lossy (which mangles
3598        // raw token bytes from C-zsh-written .zwc dumps to U+FFFD).
3599        crate::zwc::wordcode_pool_str(&v[..end])
3600    } else {
3601        // c:2877 `else r = s->strs + (c >> 2);`
3602        let off = (c >> 2) as usize + s.strs_offset;
3603        let strs_bytes = s.strs.as_deref().unwrap_or("").as_bytes();
3604        if off >= strs_bytes.len() {
3605            String::new()
3606        } else {
3607            let tail = &strs_bytes[off..];
3608            let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len());
3609            crate::zwc::wordcode_pool_str(&tail[..end])
3610        }
3611    };
3612    // c:2891 `return ((dup == EC_DUP || (dup && (c & 1))) ? dupstring(r) : r);`
3613    // Rust owns the String already; `dup` flag has no observable effect.
3614    let _ = (dup, EC_DUP, EC_NODUP);
3615    r
3616}
3617
3618// ============================================================
3619// Wordcode runtime getters (parse.c:2853-3060)
3620//
3621// Direct ports of the wordcode-read helpers (ecrawstr,
3622// ecgetstr, ecgetarr, ecgetredirs, ecgetlist, eccopyredirs).
3623// Read packed wordcode out of an Eprog at execution time.
3624// Used by exec_wordcode and the wordcode-walking dispatch in
3625// src/vm_helper.
3626// ============================================================
3627
3628/// Port of `ecrawstr(Eprog p, Wordcode pc, int *tokflag)` from
3629/// `Src/parse.c:2891`. Like `ecgetstr` but reads at the given pc
3630/// without advancing — caller steps `pc` separately.
3631pub fn ecrawstr(p: &eprog, pc: usize, tokflag: Option<&mut i32>) -> String {
3632    if pc >= p.prog.len() {
3633        return String::new();
3634    }
3635    let c = p.prog[pc]; // c:2894
3636    if let Some(tf) = tokflag {
3637        *tf = i32::from((c & 1) != 0); // c:2898/2906/2912
3638    }
3639    if c == 6 || c == 7 {
3640        // c:2897
3641        return String::new();
3642    }
3643    if (c & 2) != 0 {
3644        // c:2902-2906 — same 3-byte inline string as ecgetstr, then
3645        // `buf[3] = '\0'; return dupstring(buf);` — truncate at first
3646        // NUL via strlen (NOT splice out interior NULs).
3647        let b0 = ((c >> 3) & 0xff) as u8;
3648        let b1 = ((c >> 11) & 0xff) as u8;
3649        let b2 = ((c >> 19) & 0xff) as u8;
3650        let v = [b0, b1, b2];
3651        let end = v.iter().position(|&x| x == 0).unwrap_or(v.len()); // c:2906 strlen(buf)
3652        // Raw-byte widening — see ecgetstr (same C-convention bridge).
3653        crate::zwc::wordcode_pool_str(&v[..end])
3654    } else {
3655        // c:2911
3656        let off = (c >> 2) as usize;
3657        let strs_bytes = p.strs.as_deref().unwrap_or("").as_bytes();
3658        if off >= strs_bytes.len() {
3659            return String::new();
3660        }
3661        let tail = &strs_bytes[off..];
3662        let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len());
3663        crate::zwc::wordcode_pool_str(&tail[..end])
3664    }
3665}
3666
3667/// Port of `ecgetarr(Estate s, int num, int dup, int *tokflag)` from
3668/// `Src/parse.c:2917`. Reads `num` strings from wordcode at `s->pc`
3669/// and OR-folds each entry's token flag into `*tokflag`.
3670pub fn ecgetarr(s: &mut estate, num: usize, dup: i32, tokflag: Option<&mut i32>) -> Vec<String> {
3671    let mut ret: Vec<String> = Vec::with_capacity(num); // c:2922
3672    let mut tf: i32 = 0;
3673    for _ in 0..num {
3674        // c:2924 `while (num--)`
3675        let mut tmp = 0;
3676        ret.push(ecgetstr(s, dup, Some(&mut tmp))); // c:2925
3677        tf |= tmp; // c:2926
3678    }
3679    if let Some(out) = tokflag {
3680        // c:2929
3681        *out = tf;
3682    }
3683    ret
3684}
3685
3686/// Port of `ecgetlist(Estate s, int num, int dup, int *tokflag)` from
3687/// `Src/parse.c:2937`. Same shape as `ecgetarr` but C returns
3688/// `LinkList`; zshrs uses `Vec<String>` for both.
3689pub fn ecgetlist(s: &mut estate, num: usize, dup: i32, tokflag: Option<&mut i32>) -> Vec<String> {
3690    if num == 0 {
3691        // c:2949-2952
3692        if let Some(tf) = tokflag {
3693            *tf = 0;
3694        }
3695        return Vec::new();
3696    }
3697    ecgetarr(s, num, dup, tokflag)
3698}
3699
3700/// Port of `ecgetredirs(Estate s)` from `Src/parse.c:2959`.
3701///
3702/// `strs` must be the same tail `ecgetstr` uses (`s->strs` / `estate.strs` from offset).
3703/// WARNING: param names don't match C — Rust=(prog, strs, pc) vs C=(s)
3704pub fn ecgetredirs(s: &mut estate) -> Vec<redir> {
3705    let mut ret: Vec<redir> = Vec::new(); // c:2959 `LinkList ret = newlinklist();`
3706    let prog_len = s.prog.prog.len();
3707    if s.pc >= prog_len {
3708        return ret;
3709    }
3710    let mut code = s.prog.prog[s.pc]; // c:2962 `wordcode code = *s->pc++;`
3711    s.pc += 1;
3712
3713    loop {
3714        if wc_code(code) != WC_REDIR {
3715            // c:2988-2989 `s->pc--` then break from while
3716            s.pc = s.pc.saturating_sub(1);
3717            break;
3718        }
3719
3720        let typ = WC_REDIR_TYPE(code); // c:2967 `r->type = WC_REDIR_TYPE(code);`
3721        if s.pc >= prog_len {
3722            break;
3723        }
3724        let fd1_w = s.prog.prog[s.pc]; // c:2968 `r->fd1 = *s->pc++;`
3725        s.pc += 1;
3726
3727        let name = ecgetstr(s, EC_DUP, None); // c:2969 `r->name = ecgetstr(...)`
3728
3729        let (flags, here_terminator, munged_here_terminator) = if WC_REDIR_FROM_HEREDOC(code) != 0 {
3730            // c:2970-2973
3731            let term = ecgetstr(s, EC_DUP, None);
3732            let munged = ecgetstr(s, EC_DUP, None);
3733            (REDIRF_FROM_HEREDOC, Some(term), Some(munged))
3734        } else {
3735            // c:2974-2977
3736            (0, None, None)
3737        };
3738
3739        let varid = if WC_REDIR_VARID(code) != 0 {
3740            // c:2979-2980
3741            Some(ecgetstr(s, EC_DUP, None))
3742        } else {
3743            None // c:2981-2982
3744        };
3745
3746        ret.push(redir {
3747            // c:2965-2982 fields + c:2984 `addlinknode`
3748            typ,
3749            flags,
3750            fd1: fd1_w as i32,
3751            fd2: 0,
3752            name: Some(name),
3753            varid,
3754            here_terminator,
3755            munged_here_terminator,
3756        });
3757
3758        if s.pc >= prog_len {
3759            break;
3760        }
3761        code = s.prog.prog[s.pc]; // c:2986 `code = *s->pc++;`
3762        s.pc += 1;
3763    }
3764
3765    ret // c:2990 `return ret`
3766}
3767
3768/// Port of `eccopyredirs(Estate s)` from `Src/parse.c:3003`. Reads
3769/// the WC_REDIR run at `s->pc`, counts the wordcodes needed,
3770/// reserves space in `ecbuf` via `ecispace`, then re-walks `s->pc`
3771/// re-emitting each redir's wordcodes into the reserved slot —
3772/// finally calls `bld_eprog(0)` to package the result as an Eprog.
3773pub fn eccopyredirs(s: &mut estate) -> Option<eprog> {
3774    let prog_len = s.prog.prog.len();
3775    if s.pc >= prog_len {
3776        return None;
3777    }
3778    // c:3007-3009 — `if (wc_code(*pc) != WC_REDIR) return NULL;`
3779    let first_code = s.prog.prog[s.pc];
3780    if wc_code(first_code) != WC_REDIR {
3781        return None;
3782    }
3783    // c:3011 — `init_parse();`
3784    init_parse();
3785
3786    // c:3013-3027 — count wordcodes the redir run will need.
3787    // Each WC_REDIR contributes `code + fd1 + name` = 3, plus
3788    // `+2` if WC_REDIR_FROM_HEREDOC (terminator + munged), plus
3789    // `+1` if WC_REDIR_VARID.
3790    let mut probe = s.pc;
3791    let mut ncodes = 0usize;
3792    loop {
3793        if probe >= prog_len {
3794            break;
3795        }
3796        let code = s.prog.prog[probe];
3797        if wc_code(code) != WC_REDIR {
3798            break;
3799        }
3800        let mut ncode = if WC_REDIR_FROM_HEREDOC(code) != 0 {
3801            5
3802        } else {
3803            3
3804        };
3805        if WC_REDIR_VARID(code) != 0 {
3806            ncode += 1;
3807        }
3808        probe += ncode;
3809        ncodes += ncode;
3810    }
3811
3812    // c:3028-3029 — `r = ecused; ecispace(r, ncodes);`
3813    let r0 = ECUSED.get() as usize;
3814    ecispace(r0, ncodes);
3815
3816    // c:3031-3053 — re-walk `s->pc` and write into ecbuf[r..].
3817    let mut r = r0;
3818    loop {
3819        if s.pc >= prog_len {
3820            break;
3821        }
3822        let code = s.prog.prog[s.pc];
3823        if wc_code(code) != WC_REDIR {
3824            break;
3825        }
3826        s.pc += 1;
3827        // c:3036 — `ecbuf[r++] = code;`
3828        ECBUF.with_borrow_mut(|buf| {
3829            if r >= buf.len() {
3830                buf.resize(r + 1, 0);
3831            }
3832            buf[r] = code;
3833        });
3834        r += 1;
3835        // c:3038 — `ecbuf[r++] = *s->pc++;` (the fd1 word)
3836        let fd1 = s.prog.prog[s.pc];
3837        s.pc += 1;
3838        ECBUF.with_borrow_mut(|buf| {
3839            if r >= buf.len() {
3840                buf.resize(r + 1, 0);
3841            }
3842            buf[r] = fd1;
3843        });
3844        r += 1;
3845        // c:3041 — `ecbuf[r++] = ecstrcode(ecgetstr(s, EC_NODUP, NULL));`
3846        let name = ecgetstr(s, EC_NODUP, None);
3847        let nc = ecstrcode(&name);
3848        ECBUF.with_borrow_mut(|buf| {
3849            if r >= buf.len() {
3850                buf.resize(r + 1, 0);
3851            }
3852            buf[r] = nc;
3853        });
3854        r += 1;
3855        // c:3042-3047 — heredoc terminators.
3856        if WC_REDIR_FROM_HEREDOC(code) != 0 {
3857            let term = ecgetstr(s, EC_NODUP, None);
3858            let tc = ecstrcode(&term);
3859            ECBUF.with_borrow_mut(|buf| {
3860                if r >= buf.len() {
3861                    buf.resize(r + 1, 0);
3862                }
3863                buf[r] = tc;
3864            });
3865            r += 1;
3866            let munged = ecgetstr(s, EC_NODUP, None);
3867            let mc = ecstrcode(&munged);
3868            ECBUF.with_borrow_mut(|buf| {
3869                if r >= buf.len() {
3870                    buf.resize(r + 1, 0);
3871                }
3872                buf[r] = mc;
3873            });
3874            r += 1;
3875        }
3876        // c:3048-3049 — varid.
3877        if WC_REDIR_VARID(code) != 0 {
3878            let varid = ecgetstr(s, EC_NODUP, None);
3879            let vc = ecstrcode(&varid);
3880            ECBUF.with_borrow_mut(|buf| {
3881                if r >= buf.len() {
3882                    buf.resize(r + 1, 0);
3883                }
3884                buf[r] = vc;
3885            });
3886            r += 1;
3887        }
3888    }
3889
3890    // c:3056 — `return bld_eprog(0);` — `bld_eprog` appends the
3891    // WC_END marker and packages ECBUF/ECSTRS into an Eprog.
3892    Some(bld_eprog(false))
3893}
3894
3895/// Port of `init_eprog(void)` from `Src/parse.c:3069`. Sets up
3896/// `dummy_eprog_code = WCB_END(); dummy_eprog.len = sizeof(wordcode);
3897/// dummy_eprog.prog = &dummy_eprog_code; dummy_eprog.strs = NULL;`.
3898/// Called once at shell startup (init_main → init_misc → init_eprog).
3899pub fn init_eprog() {
3900    let mut d = DUMMY_EPROG.lock().unwrap();
3901    d.prog = vec![WCB_END()]; // c:3071/3073
3902    d.len = size_of::<wordcode>() as i32; // c:3072
3903    d.strs = None; // c:3074
3904    d.flags = 0;
3905    d.npats = 0;
3906    d.nref = 0;
3907}
3908
3909// =====================================================================
3910// `bin_zcompile` and wordcode-dump helpers — port of `Src/parse.c:3104+`.
3911//
3912// The wordcode dump format (`.zwc`) is a serialized parse tree zsh can
3913// `mmap()` and dispatch from without re-parsing on every shell start.
3914// File layout (one struct = `FD_PRELEN` `u32`s):
3915//   - `pre[0]` = magic word (FD_MAGIC native byte-order, FD_OMAGIC
3916//     opposite byte-order).
3917//   - `pre[1]` = packed `{flags(8) | other_offset(24)}` byte field.
3918//   - `pre[2..12]` = `ZSH_VERSION` C-string padded to 40 bytes.
3919//   - `pre[12]` = `fdheaderlen` (total prelude+header word count).
3920//   - Then a sequence of `struct fdhead` records, one per function,
3921//     each followed by its NUL-terminated name (padded to 4-byte).
3922//   - Then the wordcode bytes for every function back-to-back.
3923//
3924// On a little-endian host writing a dump twice: first `FD_MAGIC` for
3925// native readers, then re-walks the body byte-swapped and emits a
3926// second `FD_OMAGIC` copy so big-endian readers can mmap it too.
3927// =====================================================================
3928
3929// File-format constants — port of `Src/parse.c:3104-3150`.
3930
3931/// `#define FD_EXT ".zwc"` from `Src/parse.c:3104`.
3932pub const FD_EXT: &str = ".zwc";
3933
3934/// `#define FD_MINMAP 4096` from `Src/parse.c:3105`. mmap threshold
3935/// — `-M` mode only kicks in when the wordcode body is at least
3936/// this many bytes (otherwise read(2) is preferred).
3937pub const FD_MINMAP: usize = 4096;
3938
3939/// `#define FD_PRELEN 12` from `Src/parse.c:3107`. File-header
3940/// length in u32 words: magic + packed-flags-byte + 10 version words.
3941pub const FD_PRELEN: usize = 12;
3942
3943/// `#define FD_MAGIC 0x04050607` from `Src/parse.c:3108`. Sentinel
3944/// for native-byte-order dumps.
3945pub const FD_MAGIC: u32 = 0x04050607;
3946
3947/// `#define FD_OMAGIC 0x07060504` from `Src/parse.c:3109`. Sentinel
3948/// for opposite-byte-order dumps (byte-swapped FD_MAGIC).
3949pub const FD_OMAGIC: u32 = 0x07060504;
3950
3951/// `#define FDF_MAP 1` from `Src/parse.c:3111`. Bit set when the
3952/// dump should be `mmap()`-ed (`-M` flag) vs read normally (`-R`).
3953pub const FDF_MAP: u32 = 1;
3954
3955/// `#define FDF_OTHER 2` from `Src/parse.c:3112`. Bit indicating
3956/// this dump has an opposite-byte-order copy at `fdother(f)`.
3957pub const FDF_OTHER: u32 = 2;
3958
3959/// Port of `struct fdhead` from `Src/parse.c:3116`. One per function
3960/// inside a wordcode dump. All fields are `wordcode` (u32).
3961#[allow(non_camel_case_types)]
3962#[derive(Debug, Clone, Copy)]
3963pub struct fdhead {
3964    /// Offset (in u32 words) to the start of this function's
3965    /// wordcode body inside the dump.
3966    pub start: u32, // c:3117
3967    /// Wordcode-byte length of the body (excludes pattern-prog slots).
3968    pub len: u32, // c:3118
3969    /// Number of compiled patterns the body references.
3970    pub npats: u32, // c:3119
3971    /// Offset of the string table inside `prog->prog`.
3972    pub strs: u32, // c:3120
3973    /// Header-record length in u32 words (record + name).
3974    pub hlen: u32, // c:3121
3975    /// Packed `{ kshload_bits(2) | name_tail_offset(30) }` field.
3976    pub flags: u32, // c:3122
3977}
3978
3979/// `#define FDHF_KSHLOAD 1` from `Src/parse.c:3149`. Function-header
3980/// flag word — `-k` ksh-style autoload marker.
3981pub const FDHF_KSHLOAD: u32 = 1;
3982
3983/// `#define FDHF_ZSHLOAD 2` from `Src/parse.c:3150`. `-z` zsh-style
3984/// autoload marker.
3985pub const FDHF_ZSHLOAD: u32 = 2;
3986
3987/// Port of `struct wcfunc` from `Src/parse.c:3158`. Build-time
3988/// per-function aggregate before write_dump emits it: the function
3989/// (or source-file) name, the compiled `Eprog` from `parse_string`,
3990/// and the FDHF_* autoload-style flag word.
3991#[allow(non_camel_case_types)]
3992#[derive(Debug, Clone)]
3993pub struct wcfunc {
3994    pub name: String, // c:3159
3995    /// Compiled program (`Eprog prog` c:3160) — wordcode + strs +
3996    /// npats as built by `bld_eprog`.
3997    pub prog: eprog, // c:3160
3998    pub flags: u32, // c:3161
3999}
4000
4001/// Port of `dump_find_func(Wordcode h, char *name)` from
4002/// `Src/parse.c:3167`. Walks the header table inside a loaded
4003/// dump for a function with the given basename; returns the
4004/// matching `fdhead` record (C returns the `FDHead` pointer).
4005pub fn dump_find_func(h: &[u32], name: &str) -> Option<fdhead> {
4006    // c:3167
4007    let header_words = fdheaderlen(h) as usize;
4008    let end = header_words; // walking u32 offsets, end-exclusive
4009    let mut cur = firstfdhead_offset();
4010    while cur < end {
4011        if let Some(fh) = read_fdhead(h, cur) {
4012            let full = fdname(h, cur);
4013            let tail = fdhtail(&fh) as usize;
4014            let basename = if tail <= full.len() {
4015                &full[tail..]
4016            } else {
4017                ""
4018            };
4019            if basename == name {
4020                return Some(fh); // c:3173 `return n;`
4021            }
4022            cur = nextfdhead_offset(h, cur);
4023        } else {
4024            break;
4025        }
4026    }
4027    None // c:3175
4028}
4029
4030/// Port of `bin_zcompile(char *nam, char **args, Options ops, UNUSED(int func))`
4031/// from `Src/parse.c:3180`. Validates the option set, then dispatches
4032/// to one of: `-t` (test/list), `-c`/`-a` (dump current functions),
4033/// or the default (compile source files to `.zwc`).
4034pub fn bin_zcompile(
4035    nam: &str, // c:3180
4036    args: &[String],
4037    ops: &crate::ported::zsh_h::options,
4038    _func: i32,
4039) -> i32 {
4040    // c:3185-3192 — illegal-combination guard.
4041    if (OPT_ISSET(ops, b'k') && OPT_ISSET(ops, b'z'))
4042        || (OPT_ISSET(ops, b'R') && OPT_ISSET(ops, b'M'))
4043        || (OPT_ISSET(ops, b'c')
4044            && (OPT_ISSET(ops, b'U') || OPT_ISSET(ops, b'k') || OPT_ISSET(ops, b'z')))
4045        || (!(OPT_ISSET(ops, b'c') || OPT_ISSET(ops, b'a')) && OPT_ISSET(ops, b'm'))
4046    {
4047        zwarnnam(nam, "illegal combination of options"); // c:3192
4048        return 1;
4049    }
4050
4051    // c:3194 — `-c`/`-a` + KSHAUTOLOAD warning.
4052    if (OPT_ISSET(ops, b'c') || OPT_ISSET(ops, b'a')) && isset(crate::ported::zsh_h::KSHAUTOLOAD) {
4053        zwarnnam(nam, "functions will use zsh style autoloading"); // c:3195
4054    }
4055
4056    // c:3196-3197 — flag word from `-k` / `-z`.
4057    let flags: u32 = if OPT_ISSET(ops, b'k') {
4058        FDHF_KSHLOAD
4059    } else if OPT_ISSET(ops, b'z') {
4060        FDHF_ZSHLOAD
4061    } else {
4062        0
4063    };
4064
4065    // c:3199 — `-t` test/list mode.
4066    if OPT_ISSET(ops, b't') {
4067        // c:3199
4068        if args.is_empty() {
4069            zwarnnam(nam, "too few arguments"); // c:3202
4070            return 1;
4071        }
4072        let dump_name = if args[0].ends_with(FD_EXT) {
4073            args[0].clone()
4074        } else {
4075            format!("{}{}", args[0], FD_EXT)
4076        };
4077        let f = match load_dump_header(nam, &dump_name, 1) {
4078            // c:3206
4079            Some(buf) => buf,
4080            None => return 1,
4081        };
4082        // c:3209 — per-function check.
4083        if args.len() > 1 {
4084            for name in &args[1..] {
4085                // c:3210
4086                if dump_find_func(&f, name).is_none() {
4087                    // c:3212
4088                    return 1;
4089                }
4090            }
4091            return 0;
4092        }
4093        // c:3215-3221 — listing arm. Walk every fdhead, print
4094        // each function's full name. C uses `fdname(h)` which
4095        // includes the path prefix; matches our `fdname()` impl.
4096        let mapped = if (fdflags(&f) & FDF_MAP) != 0 {
4097            "mapped"
4098        } else {
4099            "read"
4100        };
4101        println!("zwc file ({}) for zsh-{}", mapped, fdversion(&f));
4102        let header_words = fdheaderlen(&f) as usize;
4103        let mut cur = firstfdhead_offset();
4104        while cur < header_words {
4105            if read_fdhead(&f, cur).is_none() {
4106                break;
4107            }
4108            println!("{}", fdname(&f, cur));
4109            cur = nextfdhead_offset(&f, cur);
4110        }
4111        return 0;
4112    }
4113
4114    if args.is_empty() {
4115        zwarnnam(nam, "too few arguments"); // c:3226
4116        return 1;
4117    }
4118
4119    // c:3228 — map mode discriminant.
4120    let map: i32 = if OPT_ISSET(ops, b'M') {
4121        2
4122    } else if OPT_ISSET(ops, b'R') {
4123        0
4124    } else {
4125        1
4126    };
4127
4128    // c:3230-3236 — single-file default-mode short path.
4129    if args.len() == 1 && !(OPT_ISSET(ops, b'c') || OPT_ISSET(ops, b'a')) {
4130        let dump = format!("{}{}", args[0], FD_EXT);
4131        return build_dump(nam, &dump, args, OPT_ISSET(ops, b'U') as i32, map, flags);
4132    }
4133
4134    // c:3239-3247 — multi-file or `-c`/`-a` mode.
4135    let dump = if args[0].ends_with(FD_EXT) {
4136        args[0].clone()
4137    } else {
4138        format!("{}{}", args[0], FD_EXT)
4139    };
4140    let rest = &args[1..];
4141    if OPT_ISSET(ops, b'c') || OPT_ISSET(ops, b'a') {
4142        let what =
4143            (if OPT_ISSET(ops, b'c') { 1 } else { 0 }) | (if OPT_ISSET(ops, b'a') { 2 } else { 0 });
4144        build_cur_dump(nam, &dump, rest, OPT_ISSET(ops, b'm') as i32, map, what)
4145    } else {
4146        build_dump(nam, &dump, rest, OPT_ISSET(ops, b'U') as i32, map, flags)
4147    }
4148}
4149
4150/// Port of `load_dump_header(char *nam, char *name, int err)` from
4151/// `Src/parse.c:3258`. Opens the file, reads + validates the magic
4152/// and version, then slurps the full header table into memory.
4153/// Returns the header u32-array on success or None on any failure
4154/// (emitting C-shaped warnings when `err != 0`).
4155pub fn load_dump_header(nam: &str, name: &str, err: i32) -> Option<Vec<u32>> {
4156    // c:3258
4157
4158    let mut f = match File::open(name) {
4159        // c:3263
4160        Ok(h) => h,
4161        Err(_) => {
4162            if err != 0 {
4163                zwarnnam(nam, &format!("can't open zwc file: {}", name)); // c:3265
4164            }
4165            return None;
4166        }
4167    };
4168
4169    // Read FD_PRELEN+1 u32 words = 52 bytes.
4170    let mut buf_bytes = vec![0u8; (FD_PRELEN + 1) * 4];
4171    if f.read_exact(&mut buf_bytes).is_err() {
4172        if err != 0 {
4173            zwarnnam(nam, &format!("invalid zwc file: {}", name)); // c:3277
4174        }
4175        return None;
4176    }
4177    let mut buf: Vec<u32> = buf_bytes
4178        .chunks_exact(4)
4179        .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
4180        .collect();
4181
4182    // c:3270 — magic + version check against `ZSH_VERSION` (C global;
4183    // zshrs mirrors it in `patchlevel::ZSH_VERSION`).
4184    let magic_ok = fdmagic(&buf) == FD_MAGIC || fdmagic(&buf) == FD_OMAGIC;
4185    let v_ok = fdversion(&buf) == crate::ported::patchlevel::ZSH_VERSION;
4186    if !magic_ok {
4187        if err != 0 {
4188            zwarnnam(nam, &format!("invalid zwc file: {}", name)); // c:3277
4189        }
4190        return None;
4191    }
4192    if !v_ok {
4193        if err != 0 {
4194            zwarnnam(
4195                nam,
4196                &format!(
4197                    "zwc file has wrong version (zsh-{}): {}", // c:3274
4198                    fdversion(&buf),
4199                    name
4200                ),
4201            );
4202        }
4203        return None;
4204    }
4205
4206    // c:3285 — if magic matches host byte order, head len is `pre[FD_PRELEN]`.
4207    // Else seek to `fdother(buf)` and re-read.
4208    if fdmagic(&buf) != FD_MAGIC {
4209        let other = fdother(&buf) as u64; // c:3290
4210        if f.seek(SeekFrom::Start(other)).is_err() || f.read_exact(&mut buf_bytes).is_err() {
4211            zwarnnam(nam, &format!("invalid zwc file: {}", name)); // c:3295
4212            return None;
4213        }
4214        buf = buf_bytes
4215            .chunks_exact(4)
4216            .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
4217            .collect();
4218    }
4219
4220    let total_words = fdheaderlen(&buf) as usize; // c:3286/3299
4221    if total_words < FD_PRELEN + 1 {
4222        zwarnnam(nam, &format!("invalid zwc file: {}", name));
4223        return None;
4224    }
4225
4226    // Read the remaining header words.
4227    let mut head: Vec<u32> = Vec::with_capacity(total_words);
4228    head.extend_from_slice(&buf);
4229    let remaining_words = total_words - (FD_PRELEN + 1);
4230    if remaining_words > 0 {
4231        let mut rest_bytes = vec![0u8; remaining_words * 4]; // c:3305
4232        if f.read_exact(&mut rest_bytes).is_err() {
4233            zwarnnam(nam, &format!("invalid zwc file: {}", name)); // c:3307
4234            return None;
4235        }
4236        for c in rest_bytes.chunks_exact(4) {
4237            head.push(u32::from_le_bytes([c[0], c[1], c[2], c[3]]));
4238        }
4239    }
4240    Some(head) // c:3311
4241}
4242
4243/// Port of `fdswap(Wordcode p, int n)` from `Src/parse.c:3318`.
4244/// Byte-swap each u32 in `p[..n]` in place. Used when writing the
4245/// opposite-byte-order copy of a wordcode dump.
4246pub fn fdswap(p: &mut [u32]) {
4247    // c:3318
4248    for w in p.iter_mut() {
4249        *w = w.swap_bytes();
4250    }
4251}
4252
4253/// Port of `write_dump(int dfd, LinkList progs, int map, int hlen, int tlen)`
4254/// from `Src/parse.c:3334`. Writes the prelude + header records +
4255/// body wordcode bytes to the dump file descriptor.
4256///
4257/// Two passes: first native-byte-order (`FD_MAGIC`), then opposite-
4258/// byte-order (`FD_OMAGIC`) so big-endian readers can mmap the
4259/// same file. Bodies are byte-swapped via `fdswap` on the second pass.
4260pub fn write_dump(
4261    dfd: &mut File, // c:3334
4262    progs: &[wcfunc],
4263    mut map: i32,
4264    hlen: i32,
4265    tlen: i32,
4266) -> std::io::Result<()> {
4267    // c:3345-3346 — `if (map == 1) map = (tlen >= FD_MINMAP);`
4268    if map == 1 {
4269        map = ((tlen as usize) >= FD_MINMAP) as i32;
4270    }
4271
4272    // C `sizeof(Patprog)` — pointer size (see bld_eprog len arithmetic).
4273    let patprog_size = size_of::<*const u8>() as i32;
4274
4275    let mut other = 0u32; // c:3338
4276    let ohlen = hlen;
4277
4278    loop {
4279        // c:3349 — `for (ohlen = hlen; ; hlen = ohlen)`.
4280        let mut cur_hlen = ohlen;
4281        // c:3348 — `memset(pre, 0, sizeof(wordcode) * FD_PRELEN);`
4282        let mut pre = vec![0u32; FD_PRELEN];
4283        pre[0] = if other != 0 { FD_OMAGIC } else { FD_MAGIC }; // c:3350
4284        let flags = (if map != 0 { FDF_MAP } else { 0 }) | other;
4285        fdsetflags(&mut pre, flags as u8); // c:3351
4286        fdsetother(&mut pre, tlen as u32); // c:3352
4287                                           // c:3353 — copy ZSH_VERSION C-string into pre[2..].
4288        let ver = crate::ported::patchlevel::ZSH_VERSION.as_bytes();
4289        for (i, &b) in ver.iter().enumerate() {
4290            let word = 2 + i / 4;
4291            let shift = (i % 4) * 8;
4292            pre[word] |= (b as u32) << shift;
4293        }
4294        // c:3354 — write prelude.
4295        for w in &pre {
4296            dfd.write_all(&w.to_le_bytes())?;
4297        }
4298        // c:3356 — per-fn header records.
4299        for wcf in progs {
4300            let n = &wcf.name;
4301            let prog = &wcf.prog;
4302            // c:3362-3363 — body length in bytes excluding the
4303            // pattern-prog slots: `prog->len - (prog->npats *
4304            // sizeof(Patprog))`.
4305            let len_bytes = prog.len - prog.npats * patprog_size;
4306            let mut head = fdhead {
4307                start: cur_hlen as u32, // c:3360
4308                len: len_bytes as u32,  // c:3363
4309                npats: prog.npats as u32, // c:3364
4310                // c:3365 — `head.strs = prog->strs - ((char *) prog->prog);`
4311                // In bld_eprog's layout strs sits right after the code
4312                // words, so the byte offset is ecused * 4.
4313                strs: (prog.prog.len() * 4) as u32, // c:3365
4314                hlen: ((FDHEAD_WORDS as u32) + ((n.len() as u32 + 4) / 4)), // c:3366
4315                flags: 0,
4316            };
4317            // c:3361 — `hlen += (prog->len - npats*sizeof(Patprog) +
4318            //                    sizeof(wordcode) - 1) / sizeof(wordcode);`
4319            cur_hlen += (len_bytes + 3) / 4;
4320            // c:3368-3371 — name tail offset from path basename.
4321            let tail = n.rfind('/').map(|p| p + 1).unwrap_or(0);
4322            head.flags = fdhbldflags(wcf.flags, tail as u32); // c:3372
4323                                                              // c:3373 — opposite-byte-order swap on second pass.
4324            let mut head_words: Vec<u32> = vec![
4325                head.start, head.len, head.npats, head.strs, head.hlen, head.flags,
4326            ];
4327            if other != 0 {
4328                fdswap(&mut head_words);
4329            }
4330            for w in &head_words {
4331                dfd.write_all(&w.to_le_bytes())?;
4332            }
4333            // c:3376-3379 — write the name + NUL, then pad to a word
4334            // boundary with the leading bytes of `head` (C: `write_loop
4335            // (dfd, (char *)&head, sizeof(wordcode) - tmp);`).
4336            dfd.write_all(n.as_bytes())?;
4337            dfd.write_all(&[0u8])?;
4338            let tmp = (n.len() + 1) & 3;
4339            if tmp != 0 {
4340                let head_bytes = head_words[0].to_le_bytes();
4341                dfd.write_all(&head_bytes[..4 - tmp])?;
4342            }
4343        }
4344        // c:3381-3388 — per-fn bodies: code words then the strs region,
4345        // padded to a word boundary. `tmp = (prog->len - npats*
4346        // sizeof(Patprog) + sizeof(wordcode) - 1) / sizeof(wordcode);
4347        // write_loop(dfd, (char *)prog->prog, tmp * sizeof(wordcode));`
4348        for wcf in progs {
4349            let prog = &wcf.prog;
4350            let len_bytes = (prog.len - prog.npats * patprog_size) as usize;
4351            let tmp = (len_bytes + 3) / 4;
4352            // c:3386-3387 — on the other pass only the code words are
4353            // swapped (`fdswap(prog->prog, ((Wordcode) prog->strs) -
4354            // prog->prog)`); the strs chars are byte-order neutral.
4355            let mut body_bytes: Vec<u8> = Vec::with_capacity(tmp * 4);
4356            for &w in &prog.prog {
4357                let w = if other != 0 { w.swap_bytes() } else { w };
4358                body_bytes.extend_from_slice(&w.to_le_bytes());
4359            }
4360            if let Some(s) = &prog.strs {
4361                body_bytes.extend_from_slice(s.as_bytes());
4362            }
4363            // C reads up to 3 bytes of heap slop past strs; emit NULs
4364            // (readers never look past head.len so the value is free).
4365            body_bytes.resize(tmp * 4, 0);
4366            dfd.write_all(&body_bytes)?;
4367        }
4368        if other != 0 {
4369            // c:3389
4370            break;
4371        }
4372        other = FDF_OTHER; // c:3391
4373    }
4374    Ok(())
4375}
4376
4377/// Port of `build_dump(char *nam, char *dump, char **files, int ali, int map, int flags)`
4378/// from `Src/parse.c:3396`. Source-file → wordcode dump compiler:
4379/// parses each source file via `parse_string` and serializes the
4380/// resulting `Eprog`s through `write_dump` into `<dump>.zwc`.
4381pub fn build_dump(
4382    nam: &str, // c:3397
4383    dump: &str,
4384    files: &[String],
4385    ali: i32,
4386    map: i32,
4387    flags: u32,
4388) -> i32 {
4389    use crate::ported::utils::{errflag, ERRFLAG_ERROR};
4390    use std::os::unix::fs::OpenOptionsExt;
4391    use std::sync::atomic::Ordering;
4392
4393    // c:3403-3404 — append FD_EXT unless already suffixed.
4394    let dump: String = if dump.ends_with(FD_EXT) {
4395        dump.to_string()
4396    } else {
4397        format!("{}{}", dump, FD_EXT)
4398    };
4399
4400    // c:3406 — `unlink(dump);`
4401    let _ = fs::remove_file(&dump);
4402    // c:3407-3410 — `open(dump, O_WRONLY|O_CREAT, 0444)`.
4403    let mut dfd = match fs::OpenOptions::new()
4404        .write(true)
4405        .create(true)
4406        .mode(0o444)
4407        .open(&dump)
4408    {
4409        Ok(f) => f,
4410        Err(_) => {
4411            zwarnnam(nam, &format!("can't write zwc file: {}", dump)); // c:3408
4412            return 1;
4413        }
4414    };
4415
4416    let patprog_size = size_of::<*const u8>() as i32; // C sizeof(Patprog)
4417    let ona = crate::ported::lex::noaliases(); // c:3398 `ona = noaliases`
4418    crate::ported::lex::set_noaliases(ali != 0); // c:3412 `noaliases = ali;`
4419
4420    let mut progs: Vec<wcfunc> = Vec::new(); // c:3411
4421    let mut flags = flags;
4422    let mut hlen = FD_PRELEN as i32; // c:3414
4423    let mut tlen: i32 = 0;
4424
4425    for fname in files {
4426        // c:3418-3425 — `-k` / `-z` pseudo-args flip the autoload style.
4427        if check_cond(fname, "k") {
4428            flags = (flags & !(FDHF_KSHLOAD | FDHF_ZSHLOAD)) | FDHF_KSHLOAD; // c:3419
4429            continue;
4430        } else if check_cond(fname, "z") {
4431            flags = (flags & !(FDHF_KSHLOAD | FDHF_ZSHLOAD)) | FDHF_ZSHLOAD; // c:3422
4432            continue;
4433        }
4434        // c:3426-3437 — open + fstat + S_ISREG + read the whole file.
4435        let fnam = crate::ported::utils::unmeta(fname); // c:3426
4436        let is_reg = fs::metadata(&fnam).map(|m| m.is_file()).unwrap_or(false);
4437        let bytes = if is_reg { fs::read(&fnam).ok() } else { None };
4438        let bytes = match bytes {
4439            Some(b) => b,
4440            None => {
4441                zwarnnam(nam, &format!("can't open file: {}", fname)); // c:3432
4442                crate::ported::lex::set_noaliases(ona); // c:3433
4443                let _ = fs::remove_file(&dump); // c:3434
4444                return 1;
4445            }
4446        };
4447        // c:3450 — `file = metafy(file, flen, META_REALLOC);` — keep
4448        // raw bytes intact through the &str boundary (see bld_eprog's
4449        // from_utf8_unchecked rationale).
4450        let raw = unsafe { String::from_utf8_unchecked(bytes) };
4451        let file = crate::ported::utils::metafy(&raw);
4452
4453        // c:3452-3460 — parse; any error aborts the whole dump.
4454        let prog = crate::ported::exec::parse_string(&file, 1);
4455        let errored = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
4456        let prog = match prog {
4457            Some(p) if !errored => p,
4458            _ => {
4459                errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed); // c:3453
4460                zwarnnam(nam, &format!("can't read file: {}", fname)); // c:3456
4461                crate::ported::lex::set_noaliases(ona); // c:3457
4462                let _ = fs::remove_file(&dump); // c:3458
4463                return 1;
4464            }
4465        };
4466
4467        // c:3467-3472 — accumulate header + body length budgets.
4468        let flen = (fname.len() as i32 + 4) / 4; // c:3469
4469        hlen += (FDHEAD_WORDS as i32) + flen; // c:3470
4470        tlen += (prog.len - prog.npats * patprog_size + 3) / 4; // c:3471
4471
4472        // c:3463-3466 — wcfunc node.
4473        let wcf_flags = if (prog.flags & EF_RUN) != 0 {
4474            FDHF_KSHLOAD // c:3465
4475        } else {
4476            flags
4477        };
4478        progs.push(wcfunc {
4479            name: fname.clone(),
4480            prog,
4481            flags: wcf_flags,
4482        });
4483    }
4484    crate::ported::lex::set_noaliases(ona); // c:3474
4485
4486    let tlen = (tlen + hlen) * 4; // c:3476
4487
4488    // c:3478 — `write_dump(dfd, progs, map, hlen, tlen);` (void in C).
4489    let _ = write_dump(&mut dfd, &progs, map, hlen, tlen);
4490
4491    0 // c:3482
4492}
4493
4494/// Port of `cur_add_func(char *nam, Shfunc shf, LinkList names, LinkList progs, int *hlen, int *tlen, int what)`
4495/// from `Src/parse.c:3489`. Adds a shfunc to the in-build dump
4496/// progs+names lists. Stub: `Eprog` for the function body isn't
4497/// yet wired through `shfunc.funcdef` to be serializable here.
4498pub fn cur_add_func(
4499    nam: &str, // c:3489
4500    shf_name: &str,
4501    shf_flags: i32,
4502    names: &mut Vec<String>,
4503    progs: &mut Vec<wcfunc>,
4504    hlen: &mut i32,
4505    tlen: &mut i32,
4506    what: i32,
4507) -> i32 {
4508    let is_undef = (shf_flags as u32 & PM_UNDEFINED) != 0;
4509    if is_undef {
4510        if (what & 2) == 0 {
4511            // c:3498
4512            zwarnnam(nam, &format!("function is not loaded: {}", shf_name));
4513            return 1;
4514        }
4515        // c:3503 — would call `getfpfunc` to load body for dump.
4516        zwarnnam(nam, &format!("can't load function: {}", shf_name));
4517        return 1;
4518    } else if (what & 1) == 0 {
4519        zwarnnam(nam, &format!("function is already loaded: {}", shf_name)); // c:3514
4520        return 1;
4521    }
4522    // c:3517 — would `dupeprog(shf->funcdef)`. Stub: empty program.
4523    let wcf = wcfunc {
4524        name: shf_name.to_string(),
4525        flags: FDHF_ZSHLOAD,
4526        prog: eprog::default(),
4527    };
4528    progs.push(wcf);
4529    names.push(shf_name.to_string());
4530
4531    // c:3526 — bump hlen / tlen.
4532    let name_words = (shf_name.len() as i32 + 4) / 4;
4533    *hlen += (FDHEAD_WORDS as i32) + name_words;
4534    *tlen += 0; // body is empty in stub; real path adds prog->len in words.
4535
4536    0
4537}
4538
4539/// Port of `build_cur_dump(char *nam, char *dump, char **names, int match, int map, int what)`
4540/// from `Src/parse.c:3536`. Compiles currently-loaded functions
4541/// (`-c` for functions, `-a` for aliases) into a `.zwc` dump.
4542/// Same wordcode-emit dependency as `build_dump`.
4543pub fn build_cur_dump(
4544    nam: &str, // c:3536
4545    dump: &str,
4546    _names: &[String],
4547    _match_: i32,
4548    _map: i32,
4549    _what: i32,
4550) -> i32 {
4551    zwarnnam(
4552        nam,
4553        &format!("{}: wordcode dump-current emit not yet ported", dump),
4554    );
4555    1
4556}
4557
4558/// Port of `zwcstat(char *filename, struct stat *buf)` from
4559/// `Src/parse.c:3656`. Stats a `.zwc` file, falling back to
4560/// `.zwc.old` if the primary doesn't exist (zsh uses the `.old`
4561/// suffix to keep a previous dump readable while a rewrite is in
4562/// progress).
4563pub fn zwcstat(filename: &str) -> Option<fs::Metadata> {
4564    // c:3656
4565    if let Ok(m) = fs::metadata(filename) {
4566        return Some(m);
4567    }
4568    let old = format!("{}.old", filename);
4569    fs::metadata(&old).ok()
4570}
4571
4572/// Port of `load_dump_file(char *dump, struct stat *sbuf, int other, int len)`
4573/// from `Src/parse.c:3675`. Reads (or mmap()'s) a complete `.zwc`
4574/// file into memory. Returns the u32 buffer or None on I/O error.
4575pub fn load_dump_file(
4576    dump: &str, // c:3675
4577    _sbuf: &fs::Metadata,
4578    other: i32,
4579    _len: usize,
4580) -> Option<Vec<u32>> {
4581    let mut f = File::open(dump).ok()?;
4582    if other != 0 {
4583        f.seek(SeekFrom::Start(other as u64)).ok()?;
4584    }
4585    let mut bytes = Vec::new();
4586    f.read_to_end(&mut bytes).ok()?;
4587    Some(
4588        bytes
4589            .chunks_exact(4)
4590            .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
4591            .collect(),
4592    )
4593}
4594
4595/// Port of `try_dump_file(char *path, char *name, char *file, int *ksh, int test_only)`
4596/// from `Src/parse.c:3746`. Tries to load function `name` from a
4597/// `.zwc` digest (`<path>.zwc`) or per-function compiled file
4598/// (`<file>.zwc`) when each is newer than its uncompiled source.
4599pub fn try_dump_file(
4600    path: &str,
4601    name: &str,
4602    file: &str, // c:3746
4603    test_only: bool,
4604) -> Option<(eprog, i32)> {
4605    use std::fs;
4606
4607    // c:3753-3758 — if path ends in .zwc, treat as direct digest.
4608    if path.ends_with(FD_EXT) {
4609        crate::ported::signals::queue_signals();
4610        let result = fs::metadata(path)
4611            .ok()
4612            .and_then(|m| check_dump_file(path, &m, name, test_only));
4613        unqueue_signals();
4614        return result;
4615    }
4616
4617    // c:3759-3760 — dig = "<path>.zwc", wc = "<file>.zwc".
4618    let dig = format!("{}{}", path, FD_EXT);
4619    let wc = format!("{}{}", file, FD_EXT);
4620
4621    // c:3762-3764 — zwcstat(dig, &std); stat(wc, &stc); stat(file, &stn);
4622    let std_meta = fs::metadata(&dig);
4623    let stc_meta = fs::metadata(&wc);
4624    let stn_meta = fs::metadata(file);
4625
4626    crate::ported::signals::queue_signals();
4627
4628    // zsh compares `st_mtime` (time_t SECONDS) with `>=` (c:3772-3780). Use
4629    // second-granularity mtime (MetadataExt::mtime, imported at top) — NOT
4630    // SystemTime (`.modified()`), whose nanosecond precision disagrees with zsh
4631    // whenever the dump and source share a second but differ in nsec, making
4632    // autoload .zwc preference flaky.
4633
4634    // c:3771-3777 — use the digest when it is >= both the per-fn .zwc and the
4635    // source (or those are absent).
4636    if let Ok(std_m) = &std_meta {
4637        let dig_s = std_m.mtime();
4638        let dig_ge_wc = stc_meta.as_ref().map_or(true, |c| dig_s >= c.mtime()); // c:3772
4639        let dig_ge_src = stn_meta.as_ref().map_or(true, |n| dig_s >= n.mtime()); // c:3773
4640        if dig_ge_wc && dig_ge_src {
4641            if let Some(prog) = check_dump_file(&dig, std_m, name, test_only) {
4642                unqueue_signals();
4643                return Some(prog);
4644            }
4645        }
4646    }
4647
4648    // c:3779-3784 — try per-function .zwc when it is >= the source (or absent).
4649    if let Ok(stc_m) = &stc_meta {
4650        let wc_s = stc_m.mtime();
4651        let src_newer_or_missing = stn_meta.as_ref().map_or(true, |n| wc_s >= n.mtime()); // c:3780
4652        if src_newer_or_missing {
4653            if let Some(prog) = check_dump_file(&wc, stc_m, name, test_only) {
4654                unqueue_signals();
4655                return Some(prog);
4656            }
4657        }
4658    }
4659
4660    unqueue_signals(); // c:3787
4661    None // c:3788
4662}
4663
4664/// Port of `try_source_file(char *file)` from `Src/parse.c:3795`.
4665/// Returns an Eprog (the wordcode dump body) if `<file>.zwc` exists
4666/// and is newer than `<file>`, else None. The dump entry searched is
4667/// the file's basename (`tail`), matching how `zcompile` names
4668/// source-file entries.
4669pub fn try_source_file(file: &str) -> Option<eprog> {
4670    // c:3795
4671
4672    // c:3802-3805 — if ((tail = strrchr(file, '/'))) tail++; else tail = file;
4673    let tail = match file.rfind('/') {
4674        Some(i) => &file[i + 1..],
4675        None => file,
4676    };
4677
4678    // c:3807-3812 — if (strsfx(FD_EXT, file)) { ... return check_dump_file(file, NULL, tail, NULL, 0); }
4679    if file.ends_with(FD_EXT) {
4680        crate::ported::signals::queue_signals(); // c:3808
4681        let meta = fs::metadata(file);
4682        let prog = match meta {
4683            Ok(m) => check_dump_file(file, &m, tail, false).map(|(p, _)| p), // c:3809
4684            Err(_) => None,
4685        };
4686        unqueue_signals(); // c:3810
4687        return prog;
4688    }
4689
4690    // c:3813 — wc = dyncat(file, FD_EXT);
4691    let wc = format!("{}{}", file, FD_EXT);
4692
4693    // c:3815-3816 — rc = stat(wc, &stc); rn = stat(file, &stn);
4694    let stc = fs::metadata(&wc);
4695    let stn = fs::metadata(file);
4696
4697    crate::ported::signals::queue_signals(); // c:3818
4698                                             // c:3819-3823 — if (!rc && (rn || stc.st_mtime >= stn.st_mtime) && (prog = check_dump_file(...))) return prog;
4699    if let Ok(meta_c) = &stc {
4700        // c:3819 — `stc.st_mtime >= stn.st_mtime`: second-granularity (time_t,
4701        // via MetadataExt::mtime imported at top), not SystemTime nsec, to agree
4702        // with zsh on equal-second boundaries.
4703        let newer_than_src = match (&stc, &stn) {
4704            (Ok(c), Ok(n)) => c.mtime() >= n.mtime(),
4705            (Ok(_), Err(_)) => true, // c:3819 — `rn` (src missing) ⇒ accept .zwc
4706            _ => false,
4707        };
4708        if newer_than_src {
4709            let prog = check_dump_file(&wc, meta_c, tail, false); // c:3820
4710            if let Some((p, _)) = prog {
4711                unqueue_signals(); // c:3821
4712                return Some(p); // c:3822
4713            }
4714        }
4715    }
4716    unqueue_signals(); // c:3824
4717    None // c:3825
4718}
4719
4720/// Port of `Eprog check_dump_file(char *file, struct stat *sbuf,
4721/// char *name, int *ksh, int test_only)` from `Src/parse.c:3833`.
4722/// Walks the `dumps` mmap list looking for `(dev, ino)` matching
4723/// `sbuf`; on miss, calls `load_dump_header` to read the .zwc
4724/// header. Then `dump_find_func(d, name)` locates the function
4725/// table entry. Returns the wordcode slice + ksh-load flag.
4726///
4727/// ```c
4728/// Eprog
4729/// check_dump_file(char *file, struct stat *sbuf, char *name,
4730///                 int *ksh, int test_only)
4731/// {
4732///     int isrec = 0;
4733///     Wordcode d;
4734///     FDHead h;
4735///     FuncDump f;
4736///     struct stat lsbuf;
4737///     if (!sbuf) {
4738///         if (zwcstat(file, &lsbuf)) return NULL;
4739///         sbuf = &lsbuf;
4740///     }
4741///   rec:
4742///     d = NULL;
4743///     for (f = dumps; f; f = f->next)
4744///         if (f->dev == sbuf->st_dev && f->ino == sbuf->st_ino)
4745///             { d = f->map; break; }
4746///     if (!f && (isrec || !(d = load_dump_header(NULL, file, 0))))
4747///         return NULL;
4748///     if ((h = dump_find_func(d, name))) {
4749///         if (test_only) return &dummy_eprog;
4750///         /* allocate Eprog from f->map at h offset, incrdumpcount,
4751///            return prog */
4752///     }
4753///     return NULL;
4754/// }
4755/// ```
4756/// Rust port returns `Option<(eprog, i32)>` instead of the C
4757/// `Eprog` pointer + `*ksh` out-param: tuple element 0 is the
4758/// loaded program (wordcode + string table per the fdhead record),
4759/// element 1 is the ksh-load mode exactly as C writes `*ksh`:
4760/// `FDHF_KSHLOAD → 2`, `FDHF_ZSHLOAD → 0`, neither → `1`
4761/// (c:3954-3956).
4762pub fn check_dump_file(
4763    // c:3833
4764    file: &str,
4765    sbuf: &fs::Metadata,
4766    name: &str,
4767    test_only: bool,
4768) -> Option<(eprog, i32)> {
4769    use std::os::unix::fs::MetadataExt;
4770
4771    // c:3842-3846 — `if (!sbuf) { zwcstat(file, &lsbuf); sbuf = &lsbuf; }`
4772    // Rust takes sbuf by &Metadata — never null.
4773    let dev = sbuf.dev(); // c:3859
4774    let ino = sbuf.ino(); // c:3859
4775
4776    // c:3854 — `d = NULL;`
4777    let mut d: Option<Vec<u32>> = None;
4778    let mut found_mmap = false; // c:3858 `for (f = dumps; f; ...)`
4779
4780    // c:3858-3862 — walk DUMPS for matching dev/ino.
4781    {
4782        let dumps_guard = DUMPS.lock().expect("dumps poisoned");
4783        for f in dumps_guard.iter() {
4784            // c:3858
4785            if f.dev == dev && f.ino == ino {
4786                // c:3859
4787                d = Some(f.map.clone()); // c:3860
4788                found_mmap = true;
4789                break; // c:3861
4790            }
4791        }
4792    }
4793
4794    // c:3870-3871 — `if (!f && (isrec || !(d = load_dump_header(NULL, file, 0)))) return NULL;`
4795    if !found_mmap {
4796        // c:3870
4797        match load_dump_header("", file, 0) {
4798            // c:3870 load_dump_header
4799            Some(loaded) => d = Some(loaded),
4800            None => return None, // c:3871
4801        }
4802    }
4803
4804    // c:3873 — `if ((h = dump_find_func(d, name)))`
4805    let dump = d?;
4806    let h = dump_find_func(&dump, name)?; // c:3873
4807
4808    // c:3876-3879 — `if (test_only) return &dummy_eprog;`
4809    if test_only {
4810        // c:3876
4811        return Some((eprog::default(), 0)); // c:3879 dummy
4812    }
4813
4814    // c:3954-3956 — `*ksh = ((fdhflags(h) & FDHF_KSHLOAD) ? 2 :
4815    //                        ((fdhflags(h) & FDHF_ZSHLOAD) ? 0 : 1));`
4816    let ksh = if (fdhflags(&h) & FDHF_KSHLOAD) != 0 {
4817        2
4818    } else if (fdhflags(&h) & FDHF_ZSHLOAD) != 0 {
4819        0
4820    } else {
4821        1
4822    };
4823
4824    // c:3919-3958 — the read (non-mmap) branch: open the file, seek
4825    // to the function's wordcode, read `h->len` bytes (wordcode +
4826    // string pool), and build an EF_REAL Eprog around it. zshrs has
4827    // no mmap'd EF_MAP variant — DUMPS entries store the file words
4828    // in `map`, so both branches funnel into the same read-and-copy.
4829    //
4830    //   if ((fd = open(file, O_RDONLY)) < 0 ||
4831    //       lseek(fd, ((h->start * sizeof(wordcode)) +
4832    //                  ((fdflags(d) & FDF_OTHER) ? fdother(d) : 0)), 0) < 0)
4833    //       return NULL;
4834    //   d = (Wordcode) zalloc(h->len + po);
4835    //   if (read(fd, ((char *) d) + po, h->len) != (int)h->len) return NULL;
4836    //   prog->flags = EF_REAL;
4837    //   prog->len = h->len + po;
4838    //   prog->npats = np = h->npats;
4839    //   prog->prog = (Wordcode) (((char *) d) + po);
4840    //   prog->strs = ((char *) prog->prog) + h->strs;
4841    let body_off = (h.start as u64) * 4
4842        + if (fdflags(&dump) & FDF_OTHER) != 0 {
4843            fdother(&dump) as u64 // c:3924
4844        } else {
4845            0
4846        };
4847    let mut f = File::open(file).ok()?; // c:3922
4848    f.seek(SeekFrom::Start(body_off)).ok()?; // c:3923
4849    let mut bytes = vec![0u8; h.len as usize];
4850    if f.read_exact(&mut bytes).is_err() {
4851        // c:3931 `read(...) != h->len`
4852        return None;
4853    }
4854    // `h->strs` is the byte offset of the string pool inside the read
4855    // region; everything before it is wordcode.
4856    let strs_off = (h.strs as usize).min(bytes.len());
4857    let prog_words: Vec<u32> = bytes[..strs_off]
4858        .chunks_exact(4)
4859        .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
4860        .collect();
4861    // SAFETY: same byte-not-char convention as `bld_eprog` (c:566) —
4862    // consumers index `strs` by byte offset and never require UTF-8.
4863    let strs_string = unsafe { String::from_utf8_unchecked(bytes[strs_off..].to_vec()) };
4864    let po = h.npats as usize * size_of::<*const u8>(); // c:3920
4865    let prog = eprog {
4866        flags: EF_REAL,                  // c:3941
4867        len: (h.len as usize + po) as i32, // c:3942
4868        npats: h.npats as i32,           // c:3943
4869        nref: 1,                         // c:3944
4870        pats: Vec::new(),                // c:3945/3952 dummy_patprog1 fill
4871        prog: prog_words,                // c:3946
4872        strs: Some(strs_string),         // c:3947
4873        shf: None,                       // c:3948
4874        dump: None,                      // c:3949
4875    };
4876
4877    // c:3899 — incrdumpcount(f) on the mmap-cache hit path.
4878    if found_mmap {
4879        let dumps_guard = DUMPS.lock().expect("dumps poisoned");
4880        if let Some(f) = dumps_guard.iter().find(|f| f.dev == dev && f.ino == ino) {
4881            incrdumpcount(f); // c:3899
4882        }
4883    }
4884
4885    Some((prog, ksh)) // c:3958
4886}
4887
4888/// Port of `incrdumpcount(FuncDump f)` from `Src/parse.c:3970/4021`.
4889/// `f->count++;` — refcount-up a loaded dump entry. The Rust port
4890/// keys lookup by `filename` because Rust can't raw-pointer-compare
4891/// funcdump values inside a `Mutex<Vec<...>>`; same observable
4892/// effect (the count of the matching entry increments).
4893pub fn incrdumpcount(f: &funcdump) {
4894    // c:3970 — `f->count++;`
4895    if let Some(d) = DUMPS
4896        .lock()
4897        .unwrap()
4898        .iter_mut()
4899        .find(|d| d.filename.as_deref() == f.filename.as_deref())
4900    {
4901        d.count += 1; // c:3973
4902    }
4903}
4904
4905/// Port of `freedump(FuncDump f)` from `Src/parse.c:3976`. Public
4906/// helper for the rare external caller; locks the dumps mutex and
4907/// drops the entry with the given filename.
4908pub fn freedump(f: &funcdump) {
4909    // c:3976
4910    let mut g = DUMPS.lock().unwrap();
4911    if let Some(name) = f.filename.as_deref() {
4912        freedump_locked(&mut g, name);
4913    }
4914}
4915
4916/// Port of `decrdumpcount(FuncDump f)` from `Src/parse.c:3988/4026`.
4917/// `f->count--; if (!f->count) { unlink from dumps; freedump(f); }`.
4918pub fn decrdumpcount(f: &funcdump) {
4919    // c:3988
4920    let key = f.filename.clone();
4921    let mut g = DUMPS.lock().unwrap();
4922    let mut hit_zero: Option<String> = None;
4923    for d in g.iter_mut() {
4924        if d.filename == key {
4925            d.count -= 1; // c:3991
4926            if d.count == 0 {
4927                // c:3992
4928                hit_zero = d.filename.clone();
4929            }
4930            break;
4931        }
4932    }
4933    if let Some(name) = hit_zero {
4934        // c:3994-4001
4935        freedump_locked(&mut g, &name);
4936    }
4937}
4938
4939/// Port of `closedumps(void)` from `Src/parse.c:4008/4033`. Walks
4940/// `dumps` freeing every entry. Called on shell exit (exec.c:522).
4941pub fn closedumps() {
4942    // c:4008
4943    let mut g = DUMPS.lock().unwrap();
4944    g.clear(); // c:4011-4014 `while (dumps) { ... freedump(...); ... }`
4945}
4946
4947/// Port of `dump_autoload(char *nam, char *file, int on, Options ops, int func)`
4948/// from `Src/parse.c:4042`. Registers every function in a `.zwc`
4949/// for autoload via `shfunctab`.
4950pub fn dump_autoload(
4951    nam: &str,
4952    file: &str, // c:4042
4953    on: i32,
4954    ops: &crate::ported::zsh_h::options,
4955    func: i32,
4956) -> i32 {
4957    use crate::ported::zsh_h::shfunc;
4958    let mut ret = 0; // c:4047
4959
4960    // c:4049-4050 — if (!strsfx(FD_EXT, file)) file = dyncat(file, FD_EXT);
4961    let file_owned;
4962    let file = if !file.ends_with(FD_EXT) {
4963        file_owned = format!("{}{}", file, FD_EXT);
4964        file_owned.as_str()
4965    } else {
4966        file
4967    };
4968
4969    // c:4052-4053 — if (!(h = load_dump_header(nam, file, 1))) return 1;
4970    let h = match load_dump_header(nam, file, 1) {
4971        Some(buf) => buf,
4972        None => return 1,
4973    };
4974
4975    // c:4055-4056 — for (n = firstfdhead(h); n < e; n = nextfdhead(n))
4976    let hlen = fdheaderlen(&h) as usize; // c:4055
4977    let mut n_off = firstfdhead_offset();
4978    while n_off < hlen {
4979        let head = match read_fdhead(&h, n_off) {
4980            Some(hd) => hd,
4981            None => break,
4982        };
4983        // c:4057-4061 — shf = zshcalloc; shf->node.flags = on; ...addnode(fdname + fdhtail)
4984        let name_full = fdname(&h, n_off);
4985        let tail = fdhtail(&head) as usize;
4986        let basename: String = name_full.chars().skip(tail).collect();
4987        let mut shf = shfunc {
4988            node: crate::ported::zsh_h::hashnode {
4989                next: None,
4990                nam: basename.clone(),
4991                flags: on, // c:4058
4992            },
4993            filename: None,
4994            lineno: 0,
4995            funcdef: None,
4996            redir: None,
4997            sticky: None, // c:4060 NULL
4998            body: None,
4999        };
5000        // c:4059 — shf->funcdef = mkautofn(shf);  (placeholder Eprog ptr)
5001        let _ = crate::ported::builtin::mkautofn(&mut shf as *mut _);
5002        // c:4061 — shfunctab->addnode(...)
5003        let snapshot = shf.clone();
5004        {
5005            let mut tab = crate::ported::hashtable::shfunctab_lock()
5006                .write()
5007                .expect("shfunctab poisoned");
5008            tab.add(shf);
5009        }
5010        // c:4062-4063 — if (OPT_ISSET(ops,'X') && eval_autoload(...)) ret = 1;
5011        if OPT_ISSET(ops, b'X') {
5012            let mut shf_ref = snapshot;
5013            if crate::ported::builtin::eval_autoload(&mut shf_ref as *mut _, &basename, ops, func)
5014                != 0
5015            {
5016                ret = 1;
5017            }
5018        }
5019        n_off = nextfdhead_offset(&h, n_off);
5020    }
5021    let _ = nam;
5022    ret // c:4065
5023}
5024
5025/// Port of C `struct eccstr` (zsh.h:836) — the long-string dedup BST
5026/// node. The dedup-walk and cmp logic in `ecstrcode` is faithful to
5027/// parse.c:447-453 including the conditional cmp chain
5028/// (nfunc → hashval → strcmp), so corpus inputs where C's eccstr BST walk
5029/// finds-or-misses match get the same outcome on the Rust side.
5030struct EccstrNode {
5031    left: Option<Box<EccstrNode>>,
5032    right: Option<Box<EccstrNode>>,
5033    /// C-byte form of the string (single byte per char ≤ 0xff).
5034    /// Owned because Rust doesn't have C zsh's "stable pointers into
5035    /// the lexer's tokstr arena" — every tokstr lives as a fresh
5036    /// Rust String allocation.
5037    str: Vec<u8>,
5038    /// Wordcode-encoded offset: `(byte_offset << 2) | token_bit`.
5039    /// Same shape as `Eccstr::offs` (parse.c:459).
5040    offs: u32,
5041    /// Absolute byte offset in the final strs region (= `ecsoffs` at
5042    /// insert time). C `Eccstr::aoffs` (parse.c:464). copy_ecstr uses
5043    /// THIS for the write position — distinct from `offs` which is
5044    /// ecssub-relative and collides across funcdef scopes.
5045    aoffs: u32,
5046    /// `nfunc` snapshot at insert time. Per-function namespace key
5047    /// — top-level scripts use 0; each funcdef bumps it.
5048    nfunc: i32,
5049    /// Hash of `str` computed via zsh's `hasher` (hashtable.c:86).
5050    hashval: u32,
5051}
5052// === end AST relocation ===
5053
5054// Parser state lives in file-scope thread_locals:
5055//   - LEX_* (lexer side, matching Src/lex.c file-statics)
5056//   - ECBUF / ECLEN / ECUSED / ECNPATS / ECSOFFS / ECSSUB / ECNFUNC /
5057//     ECSTRS_INDEX / ECSTRS_REVERSE (wordcode-emission state, matching
5058//     Src/parse.c file-statics)
5059//
5060// Callers use the free-fn entry points directly:
5061//   crate::ported::parse::parse_init(input);
5062//   let prog = crate::ported::parse::parse();
5063
5064const MAX_RECURSION_DEPTH: usize = 500;
5065
5066/// Direct port of `struct parse_stack` at `Src/zsh.h:3099-3109`.
5067/// Used by `parse_context_save` / `parse_context_restore`
5068/// (parse.c:295-355) to snapshot per-parse-call state so a nested
5069/// parse (e.g. inside command substitution) doesn't clobber the
5070/// outer parse.
5071///
5072/// A second port of `struct parse_stack` exists at
5073/// `crate::ported::zsh_h::parse_stack` (zsh.h:1066) using canonical
5074/// Wordcode / Eccstr / `struct heredocs` types — that port is unused
5075/// today and will become authoritative when Phase 9b (PORT_PLAN.md)
5076/// wires wordcode emission. This local version uses the working-set
5077/// shapes (`Vec<HereDoc>`, stubbed wordcode fields) suited to zshrs's
5078/// pre-wordcode AST architecture; the consolidation happens in P9b.
5079#[allow(non_camel_case_types)]
5080#[derive(Debug, Default, Clone)]
5081pub struct parse_stack {
5082    // ── Direct port of struct parse_stack at zsh.h:3099-3109 ──
5083    /// Pending heredocs awaiting body collection (canonical C
5084    /// linked-list shape). C: `struct heredocs *hdocs` (zsh.h:3100).
5085    /// Mirrors `parse::HDOCS` thread_local across nested parses.
5086    pub hdocs: Option<Box<crate::ported::zsh_h::heredocs>>,
5087    /// !!! WARNING: NOT IN PARSE_STACK — Rust-only AST-glue !!!
5088    /// Snapshot of `lex::LEX_HEREDOCS` (the parallel Rust-only Vec
5089    /// carrying terminator / strip_tabs / quoted metadata).
5090    /// Saved/restored alongside the canonical `hdocs` so nested
5091    /// parses get a clean AST view. C's parse_stack has no analog
5092    /// because C tracks terminator metadata implicitly via tokstr.
5093    pub lex_heredocs: Vec<HereDoc>,
5094    /// C: `int incmdpos` (zsh.h:3102).
5095    pub incmdpos: bool,
5096    /// C: `int aliasspaceflag` (zsh.h:3103).
5097    pub aliasspaceflag: i32,
5098    /// C: `int incond` (zsh.h:3104).
5099    pub incond: i32,
5100    /// C: `int inredir` (zsh.h:3105).
5101    pub inredir: bool,
5102    /// C: `int incasepat` (zsh.h:3106).
5103    pub incasepat: i32,
5104    /// C: `int isnewlin` (zsh.h:3107).
5105    pub isnewlin: i32,
5106    /// C: `int infor` (zsh.h:3108).
5107    pub infor: i32,
5108    /// C: `int inrepeat_` (zsh.h:3109).
5109    pub inrepeat_: i32,
5110    /// C: `int intypeset` (zsh.h:3110).
5111    pub intypeset: bool,
5112    // ── Wordcode-buffer state — STUB until Phase 9b ──
5113    // C `Wordcode ecbuf` (zsh.h:3112) + `Eccstr ecstrs` (zsh.h:3113) +
5114    // `int eclen/ecused/ecnpats/ecsoffs/ecssub/ecnfunc` (zsh.h:3112-3114).
5115    // zshrs hasn't emitted wordcode yet — these fields exist to
5116    // preserve the C shape but read/write nothing until P9b lands.
5117    pub eclen: i32,
5118    pub ecused: i32,
5119    pub ecnpats: i32,
5120    pub ecbuf: Option<Vec<u32>>,
5121    pub ecstrs: Option<Vec<u8>>,
5122    pub ecsoffs: i32,
5123    pub ecssub: i32,
5124    pub ecnfunc: i32,
5125}
5126
5127// Old uppercase Rust-only `ParseStack` is gone. Compat alias so
5128// existing call sites (context.rs) keep resolving until the
5129// rename ripples through.
5130/// `ParseStack` type alias.
5131#[allow(non_camel_case_types)]
5132pub type ParseStack = parse_stack;
5133
5134/// `mod_export struct eprog dummy_eprog;` from `Src/parse.c:3066`.
5135/// Placeholder Eprog used by `shf->funcdef = &dummy_eprog;` in
5136/// builtin.c when clearing a stale autoload stub. Held in a Mutex
5137/// so `init_eprog` can set it once at shell startup.
5138pub static DUMMY_EPROG: std::sync::Mutex<eprog> = std::sync::Mutex::new(eprog {
5139    flags: 0,
5140    len: 0,
5141    npats: 0,
5142    nref: 0,
5143    prog: Vec::new(),
5144    strs: None,
5145    pats: Vec::new(),
5146    shf: None,
5147    dump: None,
5148});
5149
5150/// Walk every ZshRedir in the program and, for any with a `heredoc_idx`,
5151/// pull the body+terminator out of `bodies` and stuff into `heredoc`.
5152/// `bodies[i]` corresponds to the i-th heredoc registered by the lexer
5153/// during scanning (in source order).
5154fn fill_heredoc_bodies(prog: &mut ZshProgram, bodies: &[HereDocInfo]) {
5155    for list in &mut prog.lists {
5156        fill_in_sublist(&mut list.sublist, bodies);
5157    }
5158}
5159
5160fn fill_in_sublist(sub: &mut ZshSublist, bodies: &[HereDocInfo]) {
5161    fill_in_pipe(&mut sub.pipe, bodies);
5162    if let Some(next) = &mut sub.next {
5163        fill_in_sublist(&mut next.1, bodies);
5164    }
5165}
5166
5167fn fill_in_pipe(pipe: &mut ZshPipe, bodies: &[HereDocInfo]) {
5168    fill_in_command(&mut pipe.cmd, bodies);
5169    if let Some(next) = &mut pipe.next {
5170        fill_in_pipe(next, bodies);
5171    }
5172}
5173
5174fn fill_in_command(cmd: &mut ZshCommand, bodies: &[HereDocInfo]) {
5175    match cmd {
5176        ZshCommand::Simple(s) => {
5177            for r in &mut s.redirs {
5178                if let Some(idx) = r.heredoc_idx {
5179                    if let Some(info) = bodies.get(idx) {
5180                        r.heredoc = Some(info.clone());
5181                    }
5182                }
5183            }
5184        }
5185        ZshCommand::Subsh(p) | ZshCommand::Cursh(p) => fill_heredoc_bodies(p, bodies),
5186        ZshCommand::FuncDef(f) => fill_heredoc_bodies(&mut f.body, bodies),
5187        ZshCommand::If(i) => {
5188            fill_heredoc_bodies(&mut i.cond, bodies);
5189            fill_heredoc_bodies(&mut i.then, bodies);
5190            for (c, b) in &mut i.elif {
5191                fill_heredoc_bodies(c, bodies);
5192                fill_heredoc_bodies(b, bodies);
5193            }
5194            if let Some(e) = &mut i.else_ {
5195                fill_heredoc_bodies(e, bodies);
5196            }
5197        }
5198        ZshCommand::While(w) | ZshCommand::Until(w) => {
5199            fill_heredoc_bodies(&mut w.cond, bodies);
5200            fill_heredoc_bodies(&mut w.body, bodies);
5201        }
5202        ZshCommand::For(f) => fill_heredoc_bodies(&mut f.body, bodies),
5203        ZshCommand::Case(c) => {
5204            for arm in &mut c.arms {
5205                fill_heredoc_bodies(&mut arm.body, bodies);
5206            }
5207        }
5208        ZshCommand::Repeat(r) => fill_heredoc_bodies(&mut r.body, bodies),
5209        ZshCommand::Time(Some(sublist)) => fill_in_sublist(sublist, bodies),
5210        ZshCommand::Try(t) => {
5211            fill_heredoc_bodies(&mut t.try_block, bodies);
5212            fill_heredoc_bodies(&mut t.always, bodies);
5213        }
5214        ZshCommand::Redirected(inner, redirs) => {
5215            for r in redirs {
5216                if let Some(idx) = r.heredoc_idx {
5217                    if let Some(info) = bodies.get(idx) {
5218                        r.heredoc = Some(info.clone());
5219                    }
5220                }
5221            }
5222            fill_in_command(inner, bodies);
5223        }
5224        ZshCommand::Time(None) | ZshCommand::Cond(_) | ZshCommand::Arith(_) => {}
5225    }
5226}
5227
5228/// If `list` is a Simple containing one word that ends in the
5229/// `<Inpar><Outpar>` token pair (the lexer-port encoding of `()`),
5230/// return the bare name. Used by `parse_program_until` to detect
5231/// `name() {body}` style function definitions where the lexer
5232/// hasn't split the `()` from the name.
5233/// Detect the `name() …` shape inside a Simple. Returns the function
5234/// name and (when the body was already inlined into the same Simple,
5235/// e.g. `foo() echo hi`) the rest of the words as the body's argv.
5236/// Returns None for non-funcdef shapes.
5237fn simple_name_with_inoutpar(list: &ZshList) -> Option<(Vec<String>, Vec<String>)> {
5238    if list.flags.async_ || list.sublist.next.is_some() {
5239        return None;
5240    }
5241    let pipe = &list.sublist.pipe;
5242    if pipe.next.is_some() {
5243        return None;
5244    }
5245    let simple = match &pipe.cmd {
5246        ZshCommand::Simple(s) => s,
5247        _ => return None,
5248    };
5249    if simple.words.is_empty() || !simple.assigns.is_empty() {
5250        return None;
5251    }
5252    let suffix = "\u{88}\u{8a}"; // Inpar + Outpar
5253                                 // Find the FIRST word ending in `()`. zsh accepts the
5254                                 // multi-name shorthand `fna fnb fnc() { body }` (parse.c:
5255                                 // par_funcdef wordlist) — words[0..i-1] are extra names,
5256                                 // words[i] is `lastname()`. Words after are the body argv
5257                                 // (one-line shorthand, `name() cmd args`).
5258    let par_idx = simple.words.iter().position(|w| w.ends_with(suffix))?;
5259    let mut names: Vec<String> = Vec::with_capacity(par_idx + 1);
5260    for w in &simple.words[..par_idx] {
5261        // Earlier names must be bare identifiers, NOT contain
5262        // tokens that imply they're not function names (no `()`,
5263        // no quotes, no expansions). zsh's lexer enforces this
5264        // at the wordlist level; we approximate by requiring the
5265        // word be an identifier-shaped token after untokenize.
5266        let bare = super::lex::untokenize(w);
5267        let valid = !bare.is_empty()
5268            && bare
5269                .chars()
5270                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '$');
5271        if !valid {
5272            return None;
5273        }
5274        names.push(bare);
5275    }
5276    let last = &simple.words[par_idx];
5277    let bare = &last[..last.len() - suffix.len()];
5278    if bare.is_empty() {
5279        return None;
5280    }
5281    names.push(super::lex::untokenize(bare));
5282    let rest = simple.words[par_idx + 1..].to_vec();
5283    Some((names, rest))
5284}
5285
5286/// Initialize parser state for a fresh parse of `input`.
5287/// Free-fn entry point — resets parser thread_locals and loads input.
5288pub fn parse_init(input: &str) {
5289    // Seed the option defaults the parser/lexer inspect. Real zsh
5290    // installs these via `install_emulation_defaults` (options.c:172)
5291    // at shell startup; zshrs's parse-only test entry path bypasses
5292    // init_main, so we mirror the `zsh` emulation defaults here.
5293    //
5294    // Under `cfg(test)` (lib unit tests share one process) we
5295    // unconditionally OVERWRITE these on every parse_init so cross-
5296    // test option pollution (a prior test that flipped one of these
5297    // and panicked before its restore ran) doesn't leak into the
5298    // parser's reserved-word recognition / one-liner detection.
5299    //
5300    // In the REAL shell this overwrite is WRONG: parse_init runs for
5301    // every -c string, eval, and cmd-subst body, so resetting
5302    // `posixbuiltins` here silently wiped `zshrs -o POSIX_BUILTINS`
5303    // and made `setopt posix_builtins` evaporate at the next parse
5304    // (A04redirect.ztst POSIX_BUILTINS chunks). C zsh's parser READS
5305    // options; it never writes them. Production seeds only entries
5306    // that are missing entirely (parse-only test paths that bypass
5307    // init_main still get the zsh-emulation defaults).
5308    let overwrite = cfg!(test);
5309    for (name, default) in [
5310        ("shortloops", true),
5311        ("shortrepeat", false),
5312        ("multifuncdef", true),
5313        ("aliasfuncdef", false),
5314        ("ignorebraces", false),
5315        ("cshjunkieloops", false),
5316        ("posixbuiltins", false),
5317        ("execopt", true),
5318        ("kshautoload", false),
5319        ("aliases", true),
5320    ] {
5321        if overwrite || crate::ported::options::opt_state_get(name).is_none() {
5322            crate::ported::options::opt_state_set(name, default);
5323        }
5324    }
5325    lex_init(input);
5326}
5327
5328/// P9b decoder (wordcode-pipeline variant): direct port of
5329/// `ecgetstr(Estate s, int dup, int *tokflag)` from
5330/// `Src/parse.c:2855-2890`. Reads a wordcode at `pc`, decodes the
5331/// encoded string back to owned String. Returns (string,
5332/// pc_after_consumed). Distinct from the existing `ecgetstr` (which
5333/// takes a separate strs buffer for text.rs) — this variant uses
5334/// the live ECSTRS_REVERSE HashMap populated at ecstrcode time.
5335pub fn ecgetstr_wordcode(buf: &[u32], pc: usize) -> (String, usize) {
5336    if pc >= buf.len() {
5337        return (String::new(), pc);
5338    }
5339    let c = buf[pc];
5340    let next = pc + 1;
5341    // parse.c:2862-2863 — empty-string sentinels.
5342    if c == 6 || c == 7 {
5343        return (String::new(), next);
5344    }
5345    // parse.c:2864-2871 — inline-packed short string.
5346    if (c & 2) != 0 {
5347        let b0 = ((c >> 3) & 0xff) as u8;
5348        let b1 = ((c >> 11) & 0xff) as u8;
5349        let b2 = ((c >> 19) & 0xff) as u8;
5350        let mut bytes: Vec<u8> = Vec::new();
5351        for b in [b0, b1, b2] {
5352            if b == 0 {
5353                break;
5354            }
5355            bytes.push(b);
5356        }
5357        return (String::from_utf8_lossy(&bytes).into_owned(), next);
5358    }
5359    // parse.c:2872-2873 — long string via offs lookup. Map value is
5360    // metafied Vec<u8>; convert back to display String. Unmetafy is
5361    // the caller's job (the wordcode-parity dumper does it; other
5362    // callers may want raw bytes).
5363    let s = ECSTRS_REVERSE
5364        .with_borrow(|m| m.get(&c).cloned())
5365        .map(|v| String::from_utf8_lossy(&v).into_owned())
5366        .unwrap_or_default();
5367    (s, next)
5368}
5369
5370/// Parse the complete input. Direct port of `parse_event` /
5371/// `par_list` from `Src/parse.c:614-720`. On syntax error,
5372/// sets `errflag |= ERRFLAG_ERROR` (via `zerr`) and returns the
5373/// partial program — callers check `errflag` to detect failure,
5374/// matching C's `Eprog parse_event(...)` + `if (errflag) {...}`.
5375pub fn parse() -> ZshProgram {
5376    zshlex();
5377
5378    let mut program = parse_program_until(None, false);
5379
5380    // Post-pass: wire heredoc bodies (collected by the inline NEWLIN
5381    // walk in zshlex into LEX_HEREDOCS) back into ZshRedir.heredoc
5382    // fields via heredoc_idx. No C analog — LEX_HEREDOCS is the
5383    // Rust-only AST-glue Vec.
5384    let bodies: Vec<HereDocInfo> = LEX_HEREDOCS
5385        .with_borrow(|v| v.clone())
5386        .into_iter()
5387        .map(|h| HereDocInfo {
5388            content: h.content,
5389            terminator: h.terminator,
5390            quoted: h.quoted,
5391        })
5392        .collect();
5393    if !bodies.is_empty() {
5394        fill_heredoc_bodies(&mut program, &bodies);
5395    }
5396
5397    program
5398}
5399
5400/// Wordcode-emission top-level driver. Closest C analog is
5401/// `parse_list(void)` at `Src/parse.c:697-712`: init_parse +
5402/// zshlex + par_list(&c) + bld_eprog. This entry omits init_parse
5403/// and bld_eprog (caller responsibilities) and inlines a guard
5404/// loop around par_list_wordcode for cases where the lexer leaves
5405/// a non-ENDINPUT terminator (LEXERR, missing close-token, etc.).
5406pub fn par_event_wordcode() -> usize {
5407    let start = ECUSED.get() as usize;
5408    // C `parse_list` (parse.c:697-712) calls par_list ONCE — par_list's
5409    // own goto-rec loop handles all SEPER-separated sublists. The
5410    // outer loop here exists for safety against early-return cases
5411    // (LEXERR, missing terminator) but normally par_list_wordcode
5412    // consumes everything in one call.
5413    let mut cmplx: i32 = 0;
5414    while tok() != ENDINPUT && tok() != LEXERR {
5415        par_list_wordcode(&mut cmplx);
5416        match tok() {
5417            SEMI | NEWLIN | AMPER | AMPERBANG | SEPER => {
5418                zshlex();
5419            }
5420            _ => break,
5421        }
5422    }
5423    // No trailing `ecadd(WCB_END())` here: C's `par_list` (c:769)
5424    // emits none — the single terminating `WCB_END` comes from
5425    // `bld_eprog` (c:555). Emitting one here too made every program
5426    // one word longer than C's (double END), breaking .zwc dump
5427    // byte-parity with `zcompile`.
5428    start
5429}
5430
5431/// Port of `par_list(int *cmplx)` from `Src/parse.c:769-803`.
5432/// `list : { SEPER } [ sublist [ { SEPER | AMPER | AMPERBANG } list ] ]`.
5433/// True line-by-line port: takes `cmplx: &mut i32` matching C's
5434/// `int *cmplx` out-parameter, uses stack-local `c` per iteration
5435/// like C (so inner sublist cmplx is independent of outer).
5436pub fn par_list_wordcode(cmplx: &mut i32) {
5437    // c:773 — `int p, lp = -1, c;`
5438    let mut p: usize;
5439    let mut lp: i32 = -1;
5440    let mut c: i32;
5441    loop {
5442        // c:775 `rec:` — c:777-778 `while (tok == SEPER) zshlex();`
5443        while tok() == SEPER {
5444            zshlex();
5445        }
5446        // c:780 — `p = ecadd(0);`
5447        p = ecadd(0);
5448        // c:781 — `c = 0;`
5449        c = 0;
5450        // c:783 — `if (par_sublist(&c)) { ... }`
5451        if par_sublist_wordcode(&mut c) {
5452            // c:784 — `*cmplx |= c;`
5453            *cmplx |= c;
5454            // c:785 — `if (tok == SEPER || tok == AMPER || tok == AMPERBANG)`
5455            let t = tok();
5456            if t == SEPER || t == AMPER || t == AMPERBANG {
5457                // c:786-787 — `if (tok != SEPER) *cmplx = 1;`
5458                if t != SEPER {
5459                    *cmplx = 1;
5460                }
5461                // c:788-790 — `set_list_code(p, ..., c);`
5462                let z = if t == SEPER {
5463                    Z_SYNC
5464                } else if t == AMPER {
5465                    Z_ASYNC
5466                } else {
5467                    Z_ASYNC | Z_DISOWN
5468                };
5469                set_list_code(p, z, c != 0);
5470                // c:791 — `incmdpos = 1;`
5471                set_incmdpos(true);
5472                // c:792-794 — `do { zshlex(); } while (tok == SEPER);`
5473                loop {
5474                    zshlex();
5475                    if tok() != SEPER {
5476                        break;
5477                    }
5478                }
5479                // c:795 — `lp = p;` c:796 — `goto rec;`
5480                lp = p as i32;
5481                continue;
5482            } else {
5483                // c:798 — `set_list_code(p, (Z_SYNC | Z_END), c);`
5484                set_list_code(p, Z_SYNC | Z_END, c != 0);
5485            }
5486        } else {
5487            // c:800-802 — `ecused--; if (lp >= 0) ecbuf[lp] |= wc_bdata(Z_END);`
5488            ECUSED.set((ECUSED.get() - 1).max(0));
5489            if lp >= 0 {
5490                ECBUF.with_borrow_mut(|b| {
5491                    if (lp as usize) < b.len() {
5492                        b[lp as usize] |= wc_bdata(Z_END as wordcode);
5493                    }
5494                });
5495            }
5496        }
5497        break;
5498    }
5499}
5500
5501/// Port of `par_list1(int *cmplx)` from `Src/parse.c:806-817`.
5502/// Single-sublist variant used by funcdef bodies and the short
5503/// `for`/`while`/`repeat` forms — exactly one sublist with
5504/// `Z_SYNC|Z_END`, no chain.
5505pub fn par_list1_wordcode(cmplx: &mut i32) {
5506    // c:810 — `int p = ecadd(0), c = 0;`
5507    let p = ecadd(0);
5508    let mut c: i32 = 0;
5509    // c:812 — `if (par_sublist(&c)) { ... }`
5510    if par_sublist_wordcode(&mut c) {
5511        // c:813 — `set_list_code(p, (Z_SYNC | Z_END), c);`
5512        set_list_code(p, Z_SYNC | Z_END, c != 0);
5513        // c:814 — `*cmplx |= c;`
5514        *cmplx |= c;
5515    } else {
5516        // c:816 — `ecused--;`
5517        ECUSED.set((ECUSED.get() - 1).max(0));
5518    }
5519}
5520
5521/// Port of `par_save_list(C)` macro from `Src/parse.c:475-480`.
5522///   do { int eu = ecused; par_list(C); if (eu == ecused) ecadd(WCB_END()); } while (0)
5523pub fn par_save_list_wordcode(cmplx: &mut i32) {
5524    let eu = ECUSED.get();
5525    par_list_wordcode(cmplx);
5526    if ECUSED.get() == eu {
5527        ecadd(WCB_END());
5528    }
5529}
5530
5531/// Port of `par_save_list1(C)` macro from `Src/parse.c:481-486`.
5532pub fn par_save_list1_wordcode(cmplx: &mut i32) {
5533    let eu = ECUSED.get();
5534    par_list1_wordcode(cmplx);
5535    if ECUSED.get() == eu {
5536        ecadd(WCB_END());
5537    }
5538}
5539
5540/// Port of `par_sublist(int *cmplx)` from `Src/parse.c:823-865`.
5541/// `sublist : sublist2 [ ( DBAR | DAMPER ) { SEPER } sublist ]`.
5542/// Emits a WCB_SUBLIST header, recurses into par_sublist2 for
5543/// the !/coproc prefix + pipeline, then chains via DBAR (`||`)
5544/// or DAMPER (`&&`) recursively. Returns true if at least one
5545/// pipeline was emitted.
5546pub fn par_sublist_wordcode(cmplx: &mut i32) -> bool {
5547    // c:827 — `int f, p, c = 0;`
5548    let mut c: i32 = 0;
5549    // c:829 — `p = ecadd(0);`
5550    let p = ecadd(0);
5551    // c:831 — `if ((f = par_sublist2(&c)) != -1) { ... }`
5552    match par_sublist2(&mut c) {
5553        Some(f) => {
5554            // c:832 — `int e = ecused;`
5555            let e = ECUSED.get() as usize;
5556            // c:834 — `*cmplx |= c;`
5557            *cmplx |= c;
5558            if tok() == DBAR || tok() == DAMPER {
5559                // c:836 — `enum lextok qtok = tok;`
5560                let qtok = tok();
5561                // c:839 — `cmdpush(tok == DBAR ? CS_CMDOR : CS_CMDAND);`
5562                cmdpush(if qtok == DBAR {
5563                    CS_CMDOR as u8
5564                } else {
5565                    CS_CMDAND as u8
5566                });
5567                // c:840 — `zshlex();`
5568                zshlex();
5569                // c:841-842 — `while (tok == SEPER) zshlex();`
5570                while tok() == SEPER {
5571                    zshlex();
5572                }
5573                // c:843 — `sl = par_sublist(cmplx);`
5574                let sl = par_sublist_wordcode(cmplx);
5575                // c:844-847 — `set_sublist_code(p, (sl ? ... : WC_SUBLIST_END),
5576                // f, (e - 1 - p), c);`
5577                let st = if sl {
5578                    if qtok == DBAR {
5579                        WC_SUBLIST_OR
5580                    } else {
5581                        WC_SUBLIST_AND
5582                    }
5583                } else {
5584                    WC_SUBLIST_END
5585                };
5586                set_sublist_code(p, st as i32, f, (e - 1 - p) as i32, c != 0);
5587                // c:848 — `cmdpop();`
5588                cmdpop();
5589            } else {
5590                // c:850-853 — `if (tok == AMPER || tok == AMPERBANG)
5591                // { c = 1; *cmplx |= c; }`
5592                if tok() == AMPER || tok() == AMPERBANG {
5593                    c = 1;
5594                    *cmplx |= c;
5595                }
5596                // c:854 — `set_sublist_code(p, WC_SUBLIST_END, f,
5597                // (e - 1 - p), c);`
5598                set_sublist_code(p, WC_SUBLIST_END as i32, f, (e - 1 - p) as i32, c != 0);
5599            }
5600            // c:856 — `return 1;`
5601            true
5602        }
5603        None => {
5604            // c:858-859 — `ecused--; return 0;`
5605            ECUSED.set((ECUSED.get() - 1).max(0));
5606            false
5607        }
5608    }
5609}
5610
5611/// Port of `par_pline(int *cmplx)` from `Src/parse.c:894-955`.
5612/// `pline : cmd [ ( BAR | BARAMP ) { SEPER } pline ]`. Emits a
5613/// WCB_PIPE header (mid for chain links, end for the last cmd)
5614/// plus the optional BARAMP `2>&1` synthetic redir.
5615/// Port of `par_pline(int *cmplx)` from `Src/parse.c:893-947`.
5616/// (Named `par_pipe_wordcode` to disambiguate from the AST
5617/// `par_pline` at parse.rs:3744 — semantically the same `pline`
5618/// production.)
5619pub fn par_pipe_wordcode(cmplx: &mut i32) -> bool {
5620    // c:897 — `zlong line = toklineno;`
5621    let line = toklineno() as i64;
5622    // c:899 — `p = ecadd(0);`
5623    let p = ecadd(0);
5624    // c:901-904 — `if (!par_cmd(cmplx, 0)) { ecused--; return 0; }`
5625    if !par_cmd_wordcode(cmplx, 0) {
5626        ECUSED.set((ECUSED.get() - 1).max(0));
5627        return false;
5628    }
5629    if tok() == BAR_TOK {
5630        // c:906 — `*cmplx = 1;`
5631        *cmplx = 1;
5632        // c:907 — `cmdpush(CS_PIPE);`
5633        cmdpush(CS_PIPE as u8);
5634        // c:908 — `zshlex();`
5635        zshlex();
5636        // c:909-910 — `while (tok == SEPER) zshlex();`
5637        while tok() == SEPER {
5638            zshlex();
5639        }
5640        // c:911 — `ecbuf[p] = WCB_PIPE(WC_PIPE_MID, line>=0 ? line+1 : 0);`
5641        ECBUF.with_borrow_mut(|b| {
5642            if p < b.len() {
5643                b[p] = WCB_PIPE(
5644                    WC_PIPE_MID,
5645                    if line >= 0 { (line + 1) as wordcode } else { 0 },
5646                );
5647            }
5648        });
5649        // c:912 — `ecispace(p+1, 1);`
5650        ecispace(p + 1, 1);
5651        // c:913 — `ecbuf[p+1] = ecused - 1 - p;`
5652        let used = ECUSED.get() as usize;
5653        ECBUF.with_borrow_mut(|b| {
5654            if p + 1 < b.len() {
5655                b[p + 1] = (used.saturating_sub(1 + p)) as wordcode;
5656            }
5657        });
5658        // c:914-916 — `if (!par_pline(cmplx)) { tok = LEXERR; }`
5659        if !par_pipe_wordcode(cmplx) {
5660            set_tok(LEXERR);
5661        }
5662        // c:917 — `cmdpop();`
5663        cmdpop();
5664        true
5665    } else if tok() == BARAMP {
5666        // c:920-923 — walk past inline WC_REDIR to find r.
5667        let mut r = p + 1;
5668        loop {
5669            let code = ECBUF.with_borrow(|b| b.get(r).copied().unwrap_or(0));
5670            if wc_code(code) != WC_REDIR {
5671                break;
5672            }
5673            r += WC_REDIR_WORDS(code) as usize;
5674        }
5675        // c:925-928 — `ecispace(r, 3);` + synthetic `2>&1` redir
5676        ecispace(r, 3);
5677        ECBUF.with_borrow_mut(|b| {
5678            if r + 2 < b.len() {
5679                b[r] = WCB_REDIR(REDIR_MERGEOUT as wordcode);
5680                b[r + 1] = 2;
5681                b[r + 2] = ecstrcode("1");
5682            }
5683        });
5684        // c:930 — `*cmplx = 1;`
5685        *cmplx = 1;
5686        cmdpush(CS_ERRPIPE as u8);
5687        zshlex();
5688        while tok() == SEPER {
5689            zshlex();
5690        }
5691        ECBUF.with_borrow_mut(|b| {
5692            if p < b.len() {
5693                b[p] = WCB_PIPE(
5694                    WC_PIPE_MID,
5695                    if line >= 0 { (line + 1) as wordcode } else { 0 },
5696                );
5697            }
5698        });
5699        ecispace(p + 1, 1);
5700        let used = ECUSED.get() as usize;
5701        ECBUF.with_borrow_mut(|b| {
5702            if p + 1 < b.len() {
5703                b[p + 1] = (used.saturating_sub(1 + p)) as wordcode;
5704            }
5705        });
5706        if !par_pipe_wordcode(cmplx) {
5707            set_tok(LEXERR);
5708        }
5709        cmdpop();
5710        true
5711    } else {
5712        // c:944 — `ecbuf[p] = WCB_PIPE(WC_PIPE_END, line>=0 ? line+1 : 0);`
5713        ECBUF.with_borrow_mut(|b| {
5714            if p < b.len() {
5715                b[p] = WCB_PIPE(
5716                    WC_PIPE_END,
5717                    if line >= 0 { (line + 1) as wordcode } else { 0 },
5718                );
5719            }
5720        });
5721        true
5722    }
5723}
5724
5725/// Port of `par_cmd(int *cmplx, int zsh_construct)` from
5726/// `Src/parse.c:958-1085`. Parses leading + trailing redirs and
5727/// dispatches on the current token to the right par_* builder.
5728/// Returns false only when no command was emitted (no redirs +
5729/// par_simple returned 0).
5730/// Port of `par_cmd(int *cmplx, int zsh_construct)` from
5731/// `Src/parse.c:957-1077`.
5732pub fn par_cmd_wordcode(cmplx: &mut i32, zsh_construct: i32) -> bool {
5733    // c:960 — `int r, nr = 0;`
5734    let mut nr: i32 = 0;
5735    // c:962 — `r = ecused;`
5736    let mut r: usize = ECUSED.get() as usize;
5737    // c:964-968 — leading redirs.
5738    if IS_REDIROP(tok()) {
5739        // c:965 — `*cmplx = 1;`
5740        *cmplx = 1;
5741        // c:966-968 — `while (IS_REDIROP(tok)) { nr += par_redir(&r, NULL); }`
5742        while IS_REDIROP(tok()) {
5743            nr += par_redir_wordcode(&mut r, None);
5744        }
5745    }
5746    // c:970-1066 — token-dispatch switch.
5747    match tok() {
5748        FOR => {
5749            cmdpush(CS_FOR as u8);
5750            par_for_wordcode(cmplx);
5751            cmdpop();
5752        }
5753        FOREACH => {
5754            cmdpush(CS_FOREACH as u8);
5755            par_for_wordcode(cmplx);
5756            cmdpop();
5757        }
5758        SELECT => {
5759            // c:982 — `*cmplx = 1;`
5760            *cmplx = 1;
5761            cmdpush(CS_SELECT as u8);
5762            par_for_wordcode(cmplx);
5763            cmdpop();
5764        }
5765        CASE => {
5766            cmdpush(CS_CASE as u8);
5767            par_case_wordcode(cmplx);
5768            cmdpop();
5769        }
5770        IF => {
5771            par_if_wordcode(cmplx);
5772        }
5773        WHILE => {
5774            cmdpush(CS_WHILE as u8);
5775            par_while_wordcode(cmplx);
5776            cmdpop();
5777        }
5778        UNTIL => {
5779            cmdpush(CS_UNTIL as u8);
5780            par_while_wordcode(cmplx);
5781            cmdpop();
5782        }
5783        REPEAT => {
5784            cmdpush(CS_REPEAT as u8);
5785            par_repeat_wordcode(cmplx);
5786            cmdpop();
5787        }
5788        INPAR_TOK => {
5789            // c:1011 — `*cmplx = 1;`
5790            *cmplx = 1;
5791            cmdpush(CS_SUBSH as u8);
5792            par_subsh_wordcode(cmplx, zsh_construct);
5793            cmdpop();
5794        }
5795        INBRACE_TOK => {
5796            cmdpush(CS_CURSH as u8);
5797            par_subsh_wordcode(cmplx, zsh_construct);
5798            cmdpop();
5799        }
5800        FUNC => {
5801            cmdpush(CS_FUNCDEF as u8);
5802            par_funcdef_wordcode(cmplx);
5803            cmdpop();
5804        }
5805        DINBRACK => {
5806            cmdpush(CS_COND as u8);
5807            par_cond_wordcode();
5808            cmdpop();
5809        }
5810        DINPAR => {
5811            par_arith_wordcode();
5812        }
5813        TIME => {
5814            // c:1037-1050 — `static int inpartime` guard so
5815            // `time time foo` doesn't recurse infinitely.
5816            if !PARSER_INPARTIME.with(|c| c.get()) {
5817                // c:1041 — `*cmplx = 1;`
5818                *cmplx = 1;
5819                PARSER_INPARTIME.with(|c| c.set(true));
5820                par_time_wordcode();
5821                PARSER_INPARTIME.with(|c| c.set(false));
5822            } else {
5823                set_tok(STRING_LEX);
5824                let sr = par_simple_wordcode(cmplx, nr);
5825                if sr == 0 && nr == 0 {
5826                    return false;
5827                }
5828                if sr > 1 {
5829                    *cmplx = 1;
5830                    r += (sr - 1) as usize;
5831                }
5832            }
5833        }
5834        _ => {
5835            // c:1054 — `if (!(sr = par_simple(cmplx, nr)))`
5836            let sr = par_simple_wordcode(cmplx, nr);
5837            if sr == 0 {
5838                if nr == 0 {
5839                    return false;
5840                }
5841            } else if sr > 1 {
5842                // c:1060-1061 — `*cmplx = 1; r += sr - 1;`
5843                *cmplx = 1;
5844                r += (sr - 1) as usize;
5845            }
5846        }
5847    }
5848    // c:1067-1071 — trailing redirs.
5849    // c:1067 — `if (IS_REDIROP(tok)) { *cmplx = 1; while (...) (void)par_redir(&r, NULL); }`
5850    if IS_REDIROP(tok()) {
5851        *cmplx = 1;
5852        while IS_REDIROP(tok()) {
5853            let _ = par_redir_wordcode(&mut r, None);
5854        }
5855    }
5856    // c:1072-1075 — `incmdpos=1; incasepat=0; incond=0; intypeset=0;`
5857    set_incmdpos(true);
5858    set_incasepat(0);
5859    set_incond(0);
5860    set_intypeset(false);
5861    let _ = r;
5862    // c:1076 — `return 1;`
5863    true
5864}
5865
5866/// Port of `par_for(int *cmplx)` from `Src/parse.c:1086-1198`.
5867pub fn par_for_wordcode(cmplx: &mut i32) {
5868    // c:1089 — `int oecused = ecused, csh = (tok == FOREACH), p, sel = (tok == SELECT);`
5869    let _oecused = ECUSED.get() as usize;
5870    let csh = tok() == FOREACH;
5871    let sel = tok() == SELECT;
5872    let p: usize;
5873    // c:1090 — `int type;`
5874    let r#type: wordcode;
5875
5876    // c:1092 — `p = ecadd(0);`
5877    p = ecadd(0);
5878
5879    // c:1094 — `incmdpos = 0;`
5880    set_incmdpos(false);
5881    // c:1095 — `infor = tok == FOR ? 2 : 0;`
5882    set_infor(if tok() == FOR { 2 } else { 0 });
5883    // c:1096 — `zshlex();`
5884    zshlex();
5885    // c:1097 — `if (tok == DINPAR) {`
5886    if tok() == DINPAR {
5887        // c:1098 — `zshlex();`
5888        zshlex();
5889        // c:1099-1100 — `if (tok != DINPAR) YYERRORV(oecused);`
5890        if tok() != DINPAR {
5891            zerr("par_for: expected init");
5892            return;
5893        }
5894        // c:1101 — `ecstr(tokstr);`
5895        ecstr(&tokstr().unwrap_or_default());
5896        // c:1102 — `zshlex();`
5897        zshlex();
5898        // c:1103-1104
5899        if tok() != DINPAR {
5900            zerr("par_for: expected cond");
5901            return;
5902        }
5903        // c:1105
5904        ecstr(&tokstr().unwrap_or_default());
5905        // c:1106
5906        zshlex();
5907        // c:1107-1108
5908        if tok() != DOUTPAR {
5909            zerr("par_for: expected ))");
5910            return;
5911        }
5912        // c:1109
5913        ecstr(&tokstr().unwrap_or_default());
5914        // c:1110 — `infor = 0;`
5915        set_infor(0);
5916        // c:1111 — `incmdpos = 1;`
5917        set_incmdpos(true);
5918        // c:1112 — `zshlex();`
5919        zshlex();
5920        // c:1113 — `type = WC_FOR_COND;`
5921        r#type = WC_FOR_COND;
5922    } else {
5923        // c:1115 — `int np = 0, n, posix_in, ona = noaliases, onc = nocorrect;`
5924        let mut np: usize = 0;
5925        let mut n: u32;
5926        let posix_in: bool;
5927        let ona = noaliases();
5928        let onc = nocorrect();
5929        // c:1116 — `infor = 0;`
5930        set_infor(0);
5931        // c:1117-1118 — `if (tok != STRING || !isident(tokstr)) YYERRORV(oecused);`
5932        if tok() != STRING_LEX || !crate::ported::params::isident(&tokstr().unwrap_or_default()) {
5933            zerr("par_for: expected identifier");
5934            return;
5935        }
5936        // c:1119-1120 — `if (!sel) np = ecadd(0);`
5937        if !sel {
5938            np = ecadd(0);
5939        }
5940        // c:1121 — `n = 0;`
5941        n = 0;
5942        // c:1122 — `incmdpos = 1;`
5943        set_incmdpos(true);
5944        // c:1123 — `noaliases = nocorrect = 1;`
5945        set_noaliases(true);
5946        set_nocorrect(1);
5947        // c:1124 — `for (;;) {`
5948        loop {
5949            // c:1125 — `n++;`
5950            n += 1;
5951            // c:1126 — `ecstr(tokstr);`
5952            ecstr(&tokstr().unwrap_or_default());
5953            // c:1127 — `zshlex();`
5954            zshlex();
5955            // c:1128-1129 — `if (tok != STRING || !strcmp(tokstr, "in") || sel) break;`
5956            if tok() != STRING_LEX || tokstr().as_deref() == Some("in") || sel {
5957                break;
5958            }
5959            // c:1130-1135 — `if (!isident(tokstr) || errflag) { ... YYERRORV; }`
5960            if !crate::ported::params::isident(&tokstr().unwrap_or_default())
5961                || (errflag.load(Ordering::Relaxed) & 1) != 0
5962            {
5963                set_noaliases(ona);
5964                set_nocorrect(onc);
5965                zerr("par_for: expected identifier in name list");
5966                return;
5967            }
5968        }
5969        // c:1137-1138 — `noaliases = ona; nocorrect = onc;`
5970        set_noaliases(ona);
5971        set_nocorrect(onc);
5972        // c:1139-1140 — `if (!sel) ecbuf[np] = n;`
5973        if !sel {
5974            ECBUF.with_borrow_mut(|b| {
5975                b[np] = n;
5976            });
5977        }
5978        // c:1141 — `posix_in = isnewlin;`
5979        posix_in = isnewlin() != 0;
5980        // c:1142-1143 — `while (isnewlin) zshlex();`
5981        while isnewlin() != 0 {
5982            zshlex();
5983        }
5984        // c:1144 — `if (tok == STRING && !strcmp(tokstr, "in")) {`
5985        if tok() == STRING_LEX && tokstr().as_deref() == Some("in") {
5986            // c:1145 — `incmdpos = 0;`
5987            set_incmdpos(false);
5988            // c:1146 — `zshlex();`
5989            zshlex();
5990            // c:1147 — `np = ecadd(0);`
5991            np = ecadd(0);
5992            // c:1148 — `n = par_wordlist();`
5993            let n2 = par_wordlist_wordcode();
5994            // c:1149-1150 — `if (tok != SEPER) YYERRORV(oecused);`
5995            if tok() != SEPER {
5996                zerr("par_for: expected separator after `in`");
5997                return;
5998            }
5999            // c:1151 — `ecbuf[np] = n;`
6000            ECBUF.with_borrow_mut(|b| {
6001                b[np] = n2 as wordcode;
6002            });
6003            // c:1152 — `type = (sel ? WC_SELECT_LIST : WC_FOR_LIST);`
6004            r#type = if sel { WC_SELECT_LIST } else { WC_FOR_LIST };
6005        } else if !posix_in && tok() == INPAR_TOK {
6006            // c:1153-1154 — `else if (!posix_in && tok == INPAR)`
6007            // c:1154 — `incmdpos = 0;`
6008            set_incmdpos(false);
6009            // c:1155 — `zshlex();`
6010            zshlex();
6011            // c:1156 — `np = ecadd(0);`
6012            np = ecadd(0);
6013            // c:1157 — `n = par_nl_wordlist();`
6014            let n2 = par_nl_wordlist_wordcode();
6015            // c:1158-1159 — `if (tok != OUTPAR) YYERRORV(oecused);`
6016            if tok() != OUTPAR_TOK {
6017                zerr("par_for: expected `)`");
6018                return;
6019            }
6020            // c:1160 — `ecbuf[np] = n;`
6021            ECBUF.with_borrow_mut(|b| {
6022                b[np] = n2 as wordcode;
6023            });
6024            // c:1161 — `incmdpos = 1;`
6025            set_incmdpos(true);
6026            // c:1162 — `zshlex();`
6027            zshlex();
6028            // c:1163 — `type = (sel ? WC_SELECT_LIST : WC_FOR_LIST);`
6029            r#type = if sel { WC_SELECT_LIST } else { WC_FOR_LIST };
6030        } else {
6031            // c:1165 — `type = (sel ? WC_SELECT_PPARAM : WC_FOR_PPARAM);`
6032            r#type = if sel { WC_SELECT_PPARAM } else { WC_FOR_PPARAM };
6033        }
6034        let _ = np;
6035    }
6036    // c:1167 — `incmdpos = 1;`
6037    set_incmdpos(true);
6038    // c:1168-1169 — `while (tok == SEPER) zshlex();`
6039    while tok() == SEPER {
6040        zshlex();
6041    }
6042    // c:1170-1193 — body dispatch (inline in C, factored here for
6043    // reuse by par_while/par_repeat — same control flow, same calls).
6044    par_loop_body_wordcode(cmplx, csh);
6045    // c:1195-1197 — `ecbuf[p] = (sel ? WCB_SELECT(...) : WCB_FOR(...));`
6046    let used = ECUSED.get() as usize;
6047    let off = used.saturating_sub(1 + p) as wordcode;
6048    ECBUF.with_borrow_mut(|b| {
6049        b[p] = if sel {
6050            WCB_SELECT(r#type, off)
6051        } else {
6052            WCB_FOR(r#type, off)
6053        };
6054    });
6055}
6056
6057/// Port of `par_wordlist(void)` from `Src/parse.c:2361-2371` —
6058/// emits wordcode form. Returns the number of strings emitted.
6059fn par_wordlist_wordcode() -> u32 {
6060    // c:2364 — `int num = 0;`
6061    let mut num: u32 = 0;
6062    // c:2365 — `while (tok == STRING) {`
6063    while tok() == STRING_LEX {
6064        // c:2366 — `ecstr(tokstr);`
6065        ecstr(&tokstr().unwrap_or_default());
6066        // c:2367 — `num++;`
6067        num += 1;
6068        // c:2368 — `zshlex();`
6069        zshlex();
6070    }
6071    // c:2370 — `return num;`
6072    num
6073}
6074
6075/// Port of `par_nl_wordlist(void)` from `Src/parse.c:2378-2390` —
6076/// emits wordcode form. Like par_wordlist but tolerates SEPER
6077/// between words.
6078fn par_nl_wordlist_wordcode() -> u32 {
6079    // c:2381 — `int num = 0;`
6080    let mut num: u32 = 0;
6081    // c:2383 — `while (tok == STRING || tok == SEPER) {`
6082    while tok() == STRING_LEX || tok() == SEPER || tok() == NEWLIN {
6083        // c:2384-2387 — `if (tok != SEPER) { ecstr(tokstr); num++; }`
6084        if tok() == STRING_LEX {
6085            ecstr(&tokstr().unwrap_or_default());
6086            num += 1;
6087        }
6088        // c:2388 — `zshlex();`
6089        zshlex();
6090    }
6091    // c:2390 — `return num;`
6092    num
6093}
6094
6095/// Body dispatch shared by par_for / par_while / par_repeat.
6096/// Direct port of `Src/parse.c:1170-1194`.
6097fn par_loop_body_wordcode(cmplx: &mut i32, csh: bool) {
6098    if tok() == DOLOOP {
6099        zshlex();
6100        // c:1172 — `par_save_list(cmplx);`
6101        par_save_list_wordcode(cmplx);
6102        if tok() != DONE {
6103            zerr("missing `done`");
6104            return;
6105        }
6106        set_incmdpos(false);
6107        zshlex();
6108    } else if tok() == INBRACE_TOK {
6109        zshlex();
6110        // c:1179 — `par_save_list(cmplx);`
6111        par_save_list_wordcode(cmplx);
6112        if tok() != OUTBRACE_TOK {
6113            zerr("missing `}`");
6114            return;
6115        }
6116        set_incmdpos(false);
6117        zshlex();
6118    } else if csh || isset(CSHJUNKIELOOPS) {
6119        // c:1185 — `par_save_list(cmplx);`
6120        par_save_list_wordcode(cmplx);
6121        if tok() != ZEND {
6122            zerr("missing `end`");
6123            return;
6124        }
6125        set_incmdpos(false);
6126        zshlex();
6127    } else if unset(SHORTLOOPS) {
6128        zerr("short loop form requires SHORTLOOPS");
6129    } else {
6130        // c:1193 — `par_save_list1(cmplx);`
6131        par_save_list1_wordcode(cmplx);
6132    }
6133}
6134
6135/// `select` shares par_for body (c:983-985 routes SELECT to par_for).
6136pub fn par_select_wordcode(cmplx: &mut i32) {
6137    par_for_wordcode(cmplx);
6138}
6139
6140/// Port of `par_case(int *cmplx)` from `Src/parse.c:1208-1400`.
6141pub fn par_case_wordcode(_cmplx: &mut i32) {
6142    // c:1211 — `int oecused = ecused, brflag, p, pp, palts, type, nalts;`
6143    let _oecused = ECUSED.get() as usize;
6144    let brflag: bool;
6145    let p: usize;
6146    let mut pp: usize;
6147    let mut palts: usize;
6148    let mut r#type: wordcode;
6149    let mut nalts: u32;
6150    // c:1212 — `int ona, onc;`
6151    let ona: bool;
6152    let onc: i32;
6153
6154    // c:1214 — `p = ecadd(0);`
6155    p = ecadd(0);
6156
6157    // c:1216 — `incmdpos = 0;`
6158    set_incmdpos(false);
6159    // c:1217 — `zshlex();`
6160    zshlex();
6161    // c:1218-1219 — `if (tok != STRING) YYERRORV(oecused);`
6162    if tok() != STRING_LEX {
6163        zerr("par_case: expected scrutinee");
6164        return;
6165    }
6166    // c:1220 — `ecstr(tokstr);`
6167    ecstr(&tokstr().unwrap_or_default());
6168
6169    // c:1222 — `incmdpos = 1;`
6170    set_incmdpos(true);
6171    // c:1223-1224 — `ona = noaliases; onc = nocorrect;`
6172    ona = noaliases();
6173    onc = nocorrect();
6174    // c:1225 — `noaliases = nocorrect = 1;`
6175    set_noaliases(true);
6176    set_nocorrect(1);
6177    // c:1226 — `zshlex();`
6178    zshlex();
6179    // c:1227-1228 — `while (tok == SEPER) zshlex();`
6180    while tok() == SEPER {
6181        zshlex();
6182    }
6183    // c:1229 — `if (!(tok == STRING && !strcmp(tokstr, "in")) && tok != INBRACE)`
6184    if !(tok() == STRING_LEX && tokstr().as_deref() == Some("in")) && tok() != INBRACE_TOK {
6185        // c:1231-1233 — restore noaliases/nocorrect + ERROR
6186        set_noaliases(ona);
6187        set_nocorrect(onc);
6188        zerr("par_case: expected `in` or `{`");
6189        return;
6190    }
6191    // c:1235 — `brflag = (tok == INBRACE);`
6192    brflag = tok() == INBRACE_TOK;
6193    // c:1236 — `incasepat = 1;`
6194    set_incasepat(1);
6195    // c:1237 — `incmdpos = 0;`
6196    set_incmdpos(false);
6197    // c:1238-1239 — `noaliases = ona; nocorrect = onc;`
6198    set_noaliases(ona);
6199    set_nocorrect(onc);
6200    // c:1240 — `zshlex();`
6201    zshlex();
6202
6203    // c:1242 — `for (;;) {`
6204    'arms: loop {
6205        // c:1243 — `char *str;`
6206        let mut str: String;
6207        // c:1244 — `int skip_zshlex;`
6208        let skip_zshlex: bool;
6209
6210        // c:1246-1247 — `while (tok == SEPER) zshlex();`
6211        while tok() == SEPER {
6212            zshlex();
6213        }
6214        // c:1248-1249 — `if (tok == OUTBRACE) break;`
6215        if tok() == OUTBRACE_TOK {
6216            break 'arms;
6217        }
6218        // c:1250-1251 — `if (tok == INPAR) zshlex();`
6219        if tok() == INPAR_TOK {
6220            zshlex();
6221        }
6222        // c:1252-1254 — `if (tok == BAR) { str = ""; skip_zshlex = 1; }`
6223        if tok() == BAR_TOK {
6224            str = String::new();
6225            skip_zshlex = true;
6226        } else {
6227            // c:1256-1257 — `if (tok != STRING) YYERRORV(oecused);`
6228            if tok() != STRING_LEX {
6229                zerr("par_case: expected pattern");
6230                return;
6231            }
6232            // c:1258-1259 — `if (!strcmp(tokstr, "esac")) break;`
6233            if tokstr().as_deref() == Some("esac") {
6234                break 'arms;
6235            }
6236            // c:1260 — `str = dupstring(tokstr);`
6237            str = tokstr().unwrap_or_default();
6238            // c:1261 — `skip_zshlex = 0;`
6239            skip_zshlex = false;
6240        }
6241        // c:1263 — `type = WC_CASE_OR;`
6242        r#type = WC_CASE_OR;
6243        // c:1264-1266 — `pp = ecadd(0); palts = ecadd(0); nalts = 0;`
6244        pp = ecadd(0);
6245        palts = ecadd(0);
6246        nalts = 0;
6247        // c:1300 — `incasepat = -1;`
6248        set_incasepat(-1);
6249        // c:1301 — `incmdpos = 1;`
6250        set_incmdpos(true);
6251        // c:1302-1303 — `if (!skip_zshlex) zshlex();`
6252        if !skip_zshlex {
6253            zshlex();
6254        }
6255        // c:1304 — `for (;;) {`
6256        loop {
6257            // c:1305-1313 — `if (tok == OUTPAR) { ecstr(str);
6258            //   ecadd(ecnpats++); nalts++; incasepat = 0;
6259            //   incmdpos = 1; zshlex(); break; }`
6260            if tok() == OUTPAR_TOK {
6261                ecstr(&str);
6262                let np = ECNPATS.with(|cc| {
6263                    let v = cc.get();
6264                    cc.set(v + 1);
6265                    v
6266                }) as u32;
6267                ecadd(np);
6268                nalts += 1;
6269                set_incasepat(0);
6270                set_incmdpos(true);
6271                zshlex();
6272                break;
6273            }
6274            // c:1314-1320 — `else if (tok == BAR) { ecstr(str);
6275            //   ecadd(ecnpats++); nalts++; incasepat = 1;
6276            //   incmdpos = 0; }`
6277            else if tok() == BAR_TOK {
6278                ecstr(&str);
6279                let np = ECNPATS.with(|cc| {
6280                    let v = cc.get();
6281                    cc.set(v + 1);
6282                    v
6283                }) as u32;
6284                ecadd(np);
6285                nalts += 1;
6286                set_incasepat(1);
6287                set_incmdpos(false);
6288            }
6289            // c:1321-1357 — else { ... `(...)` whole-pattern hack:
6290            // the lexer absorbed a complete `(...)` as one STRING
6291            // (str[0] == Inpar) and the current tok is already the
6292            // body's first token. Massage blanks around `|`/parens
6293            // at depth 1, validate balance, strip the surrounding
6294            // parens; the remainder IS the pattern. }
6295            else {
6296                use crate::ported::zsh_h::{Bar, Inpar, Outpar};
6297                if nalts == 0 && str.starts_with(Inpar) {
6298                    let meta = crate::ported::zsh_h::Meta as char;
6299                    let blank = |c: char| c.is_ascii() && crate::ported::ztype_h::iblank(c as u8);
6300                    let mut chars: Vec<char> = str.chars().collect();
6301                    // c:1323 — `int pct = 0, sl;`
6302                    let mut pct = 0i32;
6303                    let mut i = 0usize;
6304                    // c:1326-1344 — scan/massage loop. `s` ↔ `i`;
6305                    // chuck(p) ↔ chars.remove(idx).
6306                    let mut early_break = false;
6307                    while i < chars.len() {
6308                        if chars[i] == Inpar {
6309                            pct += 1;
6310                        }
6311                        // c:1329-1330 — `if (!pct) break;` (char past
6312                        // the balanced close → trailing garbage).
6313                        if pct == 0 {
6314                            early_break = true;
6315                            break;
6316                        }
6317                        if pct == 1 {
6318                            // c:1332-1334 — chuck blanks AFTER `|`/`(`.
6319                            if chars[i] == Bar || chars[i] == Inpar {
6320                                while i + 1 < chars.len() && blank(chars[i + 1]) {
6321                                    chars.remove(i + 1);
6322                                }
6323                            }
6324                            // c:1335-1338 — chuck blanks BEFORE `|`/`)`
6325                            // (not Meta-escaped blanks).
6326                            if chars[i] == Bar || chars[i] == Outpar {
6327                                while i >= 1
6328                                    && blank(chars[i - 1])
6329                                    && (i < 2 || chars[i - 2] != meta)
6330                                {
6331                                    chars.remove(i - 1);
6332                                    i -= 1;
6333                                }
6334                            }
6335                        }
6336                        if chars[i] == Outpar {
6337                            pct -= 1;
6338                        }
6339                        i += 1;
6340                    }
6341                    // c:1345-1346 — `if (*s || pct || s == str)
6342                    // YYERRORV(oecused);`
6343                    if early_break || pct != 0 || chars.is_empty() {
6344                        zerr("par_case: expected `)` or `|`");
6345                        return;
6346                    }
6347                    // c:1347-1352 — strip surrounding `(...)`.
6348                    chars.pop();
6349                    chars.remove(0);
6350                    let stripped: String = chars.into_iter().collect();
6351                    // c:1353-1355 — `ecstr(str); ecadd(ecnpats++); nalts++;`
6352                    ecstr(&stripped);
6353                    let np = ECNPATS.with(|cc| {
6354                        let v = cc.get();
6355                        cc.set(v + 1);
6356                        v
6357                    }) as u32;
6358                    ecadd(np);
6359                    nalts += 1;
6360                    // c:1356 — `break;` — tok is already the body's
6361                    // first token; fall through to par_save_list.
6362                    break;
6363                }
6364                // c:1358 — `YYERRORV(oecused);`
6365                zerr("par_case: expected `)` or `|`");
6366                return;
6367            }
6368
6369            // c:1359 — `zshlex();`
6370            zshlex();
6371            // c:1360-1377 — switch on next tok.
6372            match tok() {
6373                STRING_LEX => {
6374                    // c:1361-1365
6375                    str = tokstr().unwrap_or_default();
6376                    zshlex();
6377                }
6378                OUTPAR_TOK | BAR_TOK => {
6379                    // c:1367-1371 — empty string
6380                    str = String::new();
6381                }
6382                _ => {
6383                    // c:1374-1376 — `YYERRORV(oecused);`
6384                    zerr("par_case: expected pattern, `)` or `|`");
6385                    return;
6386                }
6387            }
6388        }
6389        // c:1379 — `incasepat = 0;`
6390        set_incasepat(0);
6391        // c:1380 — `par_save_list(cmplx);`
6392        par_save_list_wordcode(_cmplx);
6393        // c:1381-1384 — terminator → arm type
6394        if tok() == SEMIAMP {
6395            r#type = WC_CASE_AND;
6396        } else if tok() == SEMIBAR {
6397            r#type = WC_CASE_TESTAND;
6398        }
6399        // c:1385 — `ecbuf[pp] = WCB_CASE(type, ecused - 1 - pp);`
6400        let used = ECUSED.get() as usize;
6401        ECBUF.with_borrow_mut(|b| {
6402            b[pp] = WCB_CASE(r#type, (used.saturating_sub(1 + pp)) as wordcode);
6403        });
6404        // c:1386 — `ecbuf[palts] = nalts;`
6405        ECBUF.with_borrow_mut(|b| {
6406            b[palts] = nalts;
6407        });
6408        // c:1387-1388 — terminator (ESAC w/o brace OR OUTBRACE w/ brace) → break
6409        if (tok() == ESAC && !brflag) || (tok() == OUTBRACE_TOK && brflag) {
6410            break 'arms;
6411        }
6412        // c:1389-1390 — `if (tok != DSEMI && tok != SEMIAMP && tok != SEMIBAR) YYERRORV;`
6413        if tok() != DSEMI && tok() != SEMIAMP && tok() != SEMIBAR {
6414            zerr("par_case: expected `;;`, `;&`, or `;|`");
6415            return;
6416        }
6417        // c:1391 — `incasepat = 1;`
6418        set_incasepat(1);
6419        // c:1392 — `incmdpos = 0;`
6420        set_incmdpos(false);
6421        // c:1393 — `zshlex();`
6422        zshlex();
6423    }
6424    // c:1395 — `incmdpos = 1;`
6425    set_incmdpos(true);
6426    // c:1396 — `incasepat = 0;`
6427    set_incasepat(0);
6428    // c:1397 — `zshlex();`
6429    zshlex();
6430
6431    // c:1399 — `ecbuf[p] = WCB_CASE(WC_CASE_HEAD, ecused - 1 - p);`
6432    let used = ECUSED.get() as usize;
6433    ECBUF.with_borrow_mut(|b| {
6434        b[p] = WCB_CASE(WC_CASE_HEAD, (used.saturating_sub(1 + p)) as wordcode);
6435    });
6436}
6437
6438/// Port of `par_if(int *cmplx)` from `Src/parse.c:1410-1512`.
6439pub fn par_if_wordcode(cmplx: &mut i32) {
6440    // c:1413 — `int oecused = ecused, p, pp, type, usebrace = 0;`
6441    let _oecused = ECUSED.get() as usize;
6442    let p: usize;
6443    let mut pp: usize = 0;
6444    let mut r#type: wordcode = WC_IF_IF;
6445    let mut usebrace: i32 = 0;
6446    // c:1414 — `enum lextok xtok;`
6447    let mut xtok: lextok;
6448    // c:1415 — `unsigned char nc;`
6449    let nc: u8;
6450    let _ = nc;
6451
6452    // c:1417 — `p = ecadd(0);`
6453    p = ecadd(0);
6454
6455    // c:1419 — `for (;;) {`
6456    loop {
6457        // c:1420 — `xtok = tok;`
6458        xtok = tok();
6459        // c:1421 — `cmdpush(xtok == IF ? CS_IF : CS_ELIF);`
6460        cmdpush(if xtok == IF {
6461            CS_IF as u8
6462        } else {
6463            CS_ELIF as u8
6464        });
6465        // c:1422-1426 — `if (xtok == FI) { incmdpos = 0; zshlex(); break; }`
6466        if xtok == FI {
6467            set_incmdpos(false);
6468            zshlex();
6469            break;
6470        }
6471        // c:1427 — `zshlex();`
6472        zshlex();
6473        // c:1428-1429 — `if (xtok == ELSE) break;`
6474        if xtok == ELSE {
6475            break;
6476        }
6477        // c:1430-1431 — `while (tok == SEPER) zshlex();`
6478        while tok() == SEPER {
6479            zshlex();
6480        }
6481        // c:1432-1435 — `if (!(xtok == IF || xtok == ELIF)) { cmdpop(); YYERRORV; }`
6482        if !(xtok == IF || xtok == ELIF) {
6483            cmdpop();
6484            zerr("par_if: expected `if` or `elif`");
6485            return;
6486        }
6487        // c:1436 — `pp = ecadd(0);`
6488        pp = ecadd(0);
6489        // c:1437 — `type = (xtok == IF ? WC_IF_IF : WC_IF_ELIF);`
6490        r#type = if xtok == IF { WC_IF_IF } else { WC_IF_ELIF };
6491        // c:1438 — `par_save_list(cmplx);` — condition body
6492        par_save_list_wordcode(cmplx);
6493        // c:1439 — `incmdpos = 1;`
6494        set_incmdpos(true);
6495        // c:1440-1443 — `if (tok == ENDINPUT) { cmdpop(); YYERRORV; }`
6496        if tok() == ENDINPUT {
6497            cmdpop();
6498            zerr("par_if: unexpected end-of-input after condition");
6499            return;
6500        }
6501        // c:1444-1445 — `while (tok == SEPER) zshlex();`
6502        while tok() == SEPER {
6503            zshlex();
6504        }
6505        // c:1446 — `xtok = FI;` — pre-set so the post-loop check works
6506        xtok = FI;
6507        // c:1447 — `nc = cmdstack[cmdsp - 1] == CS_IF ? CS_IFTHEN : CS_ELIFTHEN;`
6508        // (Not tracked separately in zshrs cmdstack — derive from cur top
6509        // by reading CMDSTACK; for safety use CS_IFTHEN as default.)
6510        // We don't have a way to read top easily — match by tracking
6511        // whether we just pushed CS_IF or CS_ELIF.
6512        // For wordcode emission this only affects cmdstack debug output;
6513        // not the emitted wordcode. Use CS_IFTHEN.
6514        let nc_local: u8 = CS_IFTHEN as u8;
6515        if tok() == THEN {
6516            // c:1448-1456 — THEN branch
6517            // c:1449 — `usebrace = 0;`
6518            usebrace = 0;
6519            // c:1450 — `cmdpop();`
6520            cmdpop();
6521            // c:1451 — `cmdpush(nc);`
6522            cmdpush(nc_local);
6523            // c:1452 — `zshlex();`
6524            zshlex();
6525            // c:1453 — `par_save_list(cmplx);` — then body
6526            par_save_list_wordcode(cmplx);
6527            // c:1454 — `ecbuf[pp] = WCB_IF(type, ecused - 1 - pp);`
6528            let used = ECUSED.get() as usize;
6529            ECBUF.with_borrow_mut(|b| {
6530                b[pp] = WCB_IF(r#type, (used.saturating_sub(1 + pp)) as wordcode);
6531            });
6532            // c:1455 — `incmdpos = 1;`
6533            set_incmdpos(true);
6534            // c:1456 — `cmdpop();`
6535            cmdpop();
6536        } else if tok() == INBRACE_TOK {
6537            // c:1457-1473 — INBRACE branch
6538            // c:1458 — `usebrace = 1;`
6539            usebrace = 1;
6540            // c:1459 — `cmdpop();`
6541            cmdpop();
6542            // c:1460 — `cmdpush(nc);`
6543            cmdpush(nc_local);
6544            // c:1461 — `zshlex();`
6545            zshlex();
6546            // c:1462 — `par_save_list(cmplx);`
6547            par_save_list_wordcode(cmplx);
6548            // c:1463-1466 — `if (tok != OUTBRACE) { cmdpop(); YYERRORV; }`
6549            if tok() != OUTBRACE_TOK {
6550                cmdpop();
6551                zerr("par_if: expected `}`");
6552                return;
6553            }
6554            // c:1467 — `ecbuf[pp] = WCB_IF(type, ecused - 1 - pp);`
6555            let used = ECUSED.get() as usize;
6556            ECBUF.with_borrow_mut(|b| {
6557                b[pp] = WCB_IF(r#type, (used.saturating_sub(1 + pp)) as wordcode);
6558            });
6559            // c:1469 — `zshlex();`
6560            zshlex();
6561            // c:1470 — `incmdpos = 1;`
6562            set_incmdpos(true);
6563            // c:1471-1472 — `if (tok == SEPER) break;`
6564            if tok() == SEPER {
6565                break;
6566            }
6567            // c:1473 — `cmdpop();`
6568            cmdpop();
6569        } else if unset(SHORTLOOPS) {
6570            // c:1474-1476 — `cmdpop(); YYERRORV;`
6571            cmdpop();
6572            zerr("par_if: short body requires SHORTLOOPS");
6573            return;
6574        } else {
6575            // c:1477-1484 — short loop form
6576            // c:1478 — `cmdpop();`
6577            cmdpop();
6578            // c:1479 — `cmdpush(nc);`
6579            cmdpush(nc_local);
6580            // c:1480 — `par_save_list1(cmplx);`
6581            par_save_list1_wordcode(cmplx);
6582            // c:1481 — `ecbuf[pp] = WCB_IF(type, ecused - 1 - pp);`
6583            let used = ECUSED.get() as usize;
6584            ECBUF.with_borrow_mut(|b| {
6585                b[pp] = WCB_IF(r#type, (used.saturating_sub(1 + pp)) as wordcode);
6586            });
6587            // c:1482 — `incmdpos = 1;`
6588            set_incmdpos(true);
6589            // c:1483 — `break;`
6590            break;
6591        }
6592    }
6593    // c:1486 — `cmdpop();`
6594    cmdpop();
6595    // c:1487 — `if (xtok == ELSE || tok == ELSE) {`
6596    if xtok == ELSE || tok() == ELSE {
6597        // c:1488 — `pp = ecadd(0);`
6598        pp = ecadd(0);
6599        // c:1489 — `cmdpush(CS_ELSE);`
6600        cmdpush(CS_ELSE as u8);
6601        // c:1490-1491 — `while (tok == SEPER) zshlex();`
6602        while tok() == SEPER {
6603            zshlex();
6604        }
6605        // c:1492-1498 — `if (tok == INBRACE && usebrace) { ... } else { ... }`
6606        if tok() == INBRACE_TOK && usebrace != 0 {
6607            // c:1493 — `zshlex();`
6608            zshlex();
6609            // c:1494 — `par_save_list(cmplx);`
6610            par_save_list_wordcode(cmplx);
6611            // c:1495-1498 — `if (tok != OUTBRACE) { cmdpop(); YYERRORV; }`
6612            if tok() != OUTBRACE_TOK {
6613                cmdpop();
6614                zerr("par_if: else expected `}`");
6615                return;
6616            }
6617        } else {
6618            // c:1500 — `par_save_list(cmplx);`
6619            par_save_list_wordcode(cmplx);
6620            // c:1501-1504 — `if (tok != FI) { cmdpop(); YYERRORV; }`
6621            if tok() != FI {
6622                cmdpop();
6623                zerr("par_if: else expected `fi`");
6624                return;
6625            }
6626        }
6627        // c:1506 — `incmdpos = 0;`
6628        set_incmdpos(false);
6629        // c:1507 — `ecbuf[pp] = WCB_IF(WC_IF_ELSE, ecused - 1 - pp);`
6630        let used = ECUSED.get() as usize;
6631        ECBUF.with_borrow_mut(|b| {
6632            b[pp] = WCB_IF(WC_IF_ELSE, (used.saturating_sub(1 + pp)) as wordcode);
6633        });
6634        // c:1508 — `zshlex();`
6635        zshlex();
6636        // c:1509 — `cmdpop();`
6637        cmdpop();
6638    }
6639    // c:1511 — `ecbuf[p] = WCB_IF(WC_IF_HEAD, ecused - 1 - p);`
6640    let used = ECUSED.get() as usize;
6641    ECBUF.with_borrow_mut(|b| {
6642        b[p] = WCB_IF(WC_IF_HEAD, (used.saturating_sub(1 + p)) as wordcode);
6643    });
6644}
6645
6646/// Port of `par_while(int *cmplx)` from `Src/parse.c:1520-1557`.
6647pub fn par_while_wordcode(cmplx: &mut i32) {
6648    // c:1523 — `int oecused = ecused, p;`
6649    let _oecused = ECUSED.get() as usize;
6650    let p: usize;
6651    // c:1524 — `int type = (tok == UNTIL ? WC_WHILE_UNTIL : WC_WHILE_WHILE);`
6652    let r#type: wordcode = if tok() == UNTIL {
6653        WC_WHILE_UNTIL
6654    } else {
6655        WC_WHILE_WHILE
6656    };
6657
6658    // c:1526 — `p = ecadd(0);`
6659    p = ecadd(0);
6660    // c:1527 — `zshlex();`
6661    zshlex();
6662    // c:1528 — `par_save_list(cmplx);` — condition.
6663    par_save_list_wordcode(cmplx);
6664    // c:1529 — `incmdpos = 1;`
6665    set_incmdpos(true);
6666    // c:1530-1531 — `while (tok == SEPER) zshlex();`
6667    while tok() == SEPER {
6668        zshlex();
6669    }
6670    // c:1532-1545 — body dispatch (inlined in C; we factor via
6671    // par_loop_body_wordcode since for/while/repeat share this
6672    // identical block).
6673    if tok() == DOLOOP {
6674        // c:1533 — `zshlex();`
6675        zshlex();
6676        // c:1534 — `par_save_list(cmplx);`
6677        par_save_list_wordcode(cmplx);
6678        // c:1535-1536 — `if (tok != DONE) YYERRORV(oecused);`
6679        if tok() != DONE {
6680            zerr("par_while: expected `done`");
6681            return;
6682        }
6683        // c:1537 — `incmdpos = 0;`
6684        set_incmdpos(false);
6685        // c:1538 — `zshlex();`
6686        zshlex();
6687    } else if tok() == INBRACE_TOK {
6688        // c:1540 — `zshlex();`
6689        zshlex();
6690        // c:1541 — `par_save_list(cmplx);`
6691        par_save_list_wordcode(cmplx);
6692        // c:1542-1543 — `if (tok != OUTBRACE) YYERRORV(oecused);`
6693        if tok() != OUTBRACE_TOK {
6694            zerr("par_while: expected `}`");
6695            return;
6696        }
6697        // c:1544 — `incmdpos = 0;`
6698        set_incmdpos(false);
6699        // c:1545 — `zshlex();`
6700        zshlex();
6701    } else if isset(CSHJUNKIELOOPS) {
6702        // c:1546-1550
6703        par_save_list_wordcode(cmplx);
6704        if tok() != ZEND {
6705            zerr("par_while: expected `end`");
6706            return;
6707        }
6708        zshlex();
6709    } else if unset(SHORTLOOPS) {
6710        // c:1551-1552 — `YYERRORV(oecused);`
6711        zerr("par_while: short body requires SHORTLOOPS");
6712        return;
6713    } else {
6714        // c:1554 — `par_save_list1(cmplx);`
6715        par_save_list1_wordcode(cmplx);
6716    }
6717
6718    // c:1556 — `ecbuf[p] = WCB_WHILE(type, ecused - 1 - p);`
6719    let used = ECUSED.get() as usize;
6720    ECBUF.with_borrow_mut(|b| {
6721        b[p] = WCB_WHILE(r#type, (used.saturating_sub(1 + p)) as wordcode);
6722    });
6723}
6724
6725/// `until` shares par_while body — tok==UNTIL flips the type.
6726pub fn par_until_wordcode(cmplx: &mut i32) {
6727    par_while_wordcode(cmplx);
6728}
6729
6730/// Port of `par_repeat(int *cmplx)` from `Src/parse.c:1564-1606`.
6731pub fn par_repeat_wordcode(cmplx: &mut i32) {
6732    // c:1567 — `/* ### what to do about inrepeat_ here? */`
6733    // c:1568 — `int oecused = ecused, p;`
6734    let _oecused = ECUSED.get() as usize;
6735    let p: usize;
6736
6737    // c:1570 — `p = ecadd(0);`
6738    p = ecadd(0);
6739
6740    // c:1572 — `incmdpos = 0;`
6741    set_incmdpos(false);
6742    // c:1573 — `zshlex();`
6743    zshlex();
6744    // c:1574-1575 — `if (tok != STRING) YYERRORV(oecused);`
6745    if tok() != STRING_LEX {
6746        zerr("par_repeat: expected count");
6747        return;
6748    }
6749    // c:1576 — `ecstr(tokstr);`
6750    ecstr(&tokstr().unwrap_or_default());
6751    // c:1577 — `incmdpos = 1;`
6752    set_incmdpos(true);
6753    // c:1578 — `zshlex();`
6754    zshlex();
6755    // c:1579-1580 — `while (tok == SEPER) zshlex();`
6756    while tok() == SEPER {
6757        zshlex();
6758    }
6759    // c:1581-1604 — body dispatch (inlined here matching C exactly).
6760    if tok() == DOLOOP {
6761        // c:1582-1587
6762        zshlex();
6763        par_save_list_wordcode(cmplx);
6764        if tok() != DONE {
6765            zerr("par_repeat: expected `done`");
6766            return;
6767        }
6768        set_incmdpos(false);
6769        zshlex();
6770    } else if tok() == INBRACE_TOK {
6771        // c:1589-1594
6772        zshlex();
6773        par_save_list_wordcode(cmplx);
6774        if tok() != OUTBRACE_TOK {
6775            zerr("par_repeat: expected `}`");
6776            return;
6777        }
6778        set_incmdpos(false);
6779        zshlex();
6780    } else if isset(CSHJUNKIELOOPS) {
6781        // c:1596-1599
6782        par_save_list_wordcode(cmplx);
6783        if tok() != ZEND {
6784            zerr("par_repeat: expected `end`");
6785            return;
6786        }
6787        zshlex();
6788    } else if unset(SHORTLOOPS) && unset(SHORTREPEAT) {
6789        // c:1601-1602 — par_repeat needs BOTH SHORTLOOPS and SHORTREPEAT
6790        // unset to refuse short form (more permissive than par_while).
6791        zerr("par_repeat: short body requires SHORTLOOPS or SHORTREPEAT");
6792        return;
6793    } else {
6794        // c:1604 — `par_save_list1(cmplx);`
6795        par_save_list1_wordcode(cmplx);
6796    }
6797
6798    // c:1606 — `ecbuf[p] = WCB_REPEAT(ecused - 1 - p);`
6799    let used = ECUSED.get() as usize;
6800    ECBUF.with_borrow_mut(|b| {
6801        b[p] = WCB_REPEAT((used.saturating_sub(1 + p)) as wordcode);
6802    });
6803}
6804
6805/// Port of `par_funcdef(int *cmplx)` from `Src/parse.c:1672-1779`.
6806///
6807/// The `function NAME { ... }` form. Emits a WCB_FUNCDEF header
6808/// followed by a names-count slot, the names themselves, four
6809/// metadata slots (string-area start, string-area length, npats,
6810/// do_tracing), then the body wordcode, then WCB_END.
6811///
6812/// Critical: saves/resets `ecnpats` + `ecssub` + `ecsoffs` around
6813/// the body parse so per-function pattern counts don't leak into
6814/// the enclosing scope's `ecnpats` accumulator (parse.c:1723-1758).
6815pub fn par_funcdef_wordcode(cmplx: &mut i32) {
6816    // c:1674 — `int oecused = ecused, num = 0, onp, p, c = 0;`
6817    let _oecused = ECUSED.get() as usize;
6818    let mut num: i32 = 0;
6819    let onp: i32;
6820    let p: usize;
6821    let mut c: i32 = 0;
6822    // c:1675 — `int so, oecssub = ecssub;`
6823    let so: i32;
6824    let oecssub = ECSSUB.get();
6825    // c:1676 — `zlong oldlineno = lineno;`
6826    let oldlineno = lineno();
6827    // c:1677 — `int do_tracing = 0;`
6828    let mut do_tracing: i32 = 0;
6829
6830    // c:1679 — `lineno = 0;`
6831    set_lineno(0);
6832    // c:1680 — `nocorrect = 1;`
6833    set_nocorrect(1);
6834    // c:1681 — `incmdpos = 0;`
6835    set_incmdpos(false);
6836    // c:1682 — `zshlex();`
6837    zshlex();
6838
6839    // c:1684 — `p = ecadd(0);`
6840    p = ecadd(0);
6841    // c:1685 — `ecadd(0); /* p + 1 */`
6842    let p1 = ecadd(0);
6843
6844    // c:1687-1699 — `Consume an initial (-T), (--), or (-T --).`
6845    // c:1690 — `if (tok == STRING && tokstr[0] == Dash) {`
6846    if tok() == STRING_LEX {
6847        let s = tokstr().unwrap_or_default();
6848        let bytes = s.as_bytes();
6849        // C: `tokstr[0] == Dash` (Dash = 0x9b = 0xc2 0x9b in UTF-8).
6850        // First byte of UTF-8 `\u{9b}` is 0xc2; the char `'-'` is 0x2d.
6851        // Match either form.
6852        let first_is_dash = (bytes.len() >= 2 && bytes[0] == 0xc2 && bytes[1] == 0x9b)
6853            || (bytes.len() >= 1 && bytes[0] == b'-');
6854        if first_is_dash {
6855            // c:1691-1694 — `if (tokstr[1] == 'T' && !tokstr[2]) { ++do_tracing; zshlex(); }`
6856            // After the leading dash byte(s), check remaining bytes.
6857            let after_dash = if bytes.len() >= 2 && bytes[0] == 0xc2 && bytes[1] == 0x9b {
6858                &bytes[2..]
6859            } else {
6860                &bytes[1..]
6861            };
6862            if after_dash.len() == 1 && after_dash[0] == b'T' {
6863                do_tracing += 1;
6864                zshlex();
6865            }
6866            // c:1695-1698 — `if (tok == STRING && tokstr[0] == Dash &&
6867            //                  tokstr[1] == Dash && !tokstr[2]) zshlex();`
6868            if tok() == STRING_LEX {
6869                let s2 = tokstr().unwrap_or_default();
6870                let b2 = s2.as_bytes();
6871                let mut idx = 0;
6872                let mut dashes = 0;
6873                while idx < b2.len() && dashes < 2 {
6874                    if b2[idx] == 0xc2 && idx + 1 < b2.len() && b2[idx + 1] == 0x9b {
6875                        idx += 2;
6876                        dashes += 1;
6877                    } else if b2[idx] == b'-' {
6878                        idx += 1;
6879                        dashes += 1;
6880                    } else {
6881                        break;
6882                    }
6883                }
6884                if dashes == 2 && idx == b2.len() {
6885                    zshlex();
6886                }
6887            }
6888        }
6889    }
6890
6891    // c:1701-1709 — names loop.
6892    // `while (tok == STRING) { if ((*tokstr == Inbrace || *tokstr == '{')
6893    //   && !tokstr[1]) { tok = INBRACE; break; } ecstr(tokstr); num++; zshlex(); }`
6894    while tok() == STRING_LEX {
6895        let s = tokstr().unwrap_or_default();
6896        let bytes = s.as_bytes();
6897        // First byte tests for Inbrace marker (0x8f → UTF-8 `0xc2 0x8f`) or `{`,
6898        // and length-1 check (`!tokstr[1]`).
6899        let is_inbrace_only = (bytes.len() == 1 && bytes[0] == b'{')
6900            || (bytes.len() == 2 && bytes[0] == 0xc2 && bytes[1] == 0x8f);
6901        if is_inbrace_only {
6902            set_tok(INBRACE_TOK);
6903            break;
6904        }
6905        ecstr(&s);
6906        num += 1;
6907        zshlex();
6908    }
6909
6910    // c:1711-1714 — four metadata placeholder slots.
6911    let m2 = ecadd(0);
6912    let m3 = ecadd(0);
6913    let m4 = ecadd(0);
6914    let m5 = ecadd(0);
6915
6916    // c:1716 — `nocorrect = 0;`
6917    set_nocorrect(0);
6918    // c:1717 — `incmdpos = 1;`
6919    set_incmdpos(true);
6920    // c:1718-1719 — `if (tok == INOUTPAR) zshlex();`
6921    if tok() == INOUTPAR {
6922        zshlex();
6923    }
6924    // c:1720-1721 — `while (tok == SEPER) zshlex();`
6925    while tok() == SEPER {
6926        zshlex();
6927    }
6928
6929    // c:1723 — `ecnfunc++;`
6930    ECNFUNC.set(ECNFUNC.get() + 1);
6931    // c:1724 — `ecssub = so = ecsoffs;`
6932    so = ECSOFFS.get();
6933    ECSSUB.set(so);
6934    // c:1725 — `onp = ecnpats;`
6935    onp = ECNPATS.with(|cc| cc.get());
6936    // c:1726 — `ecnpats = 0;`
6937    ECNPATS.with(|cc| cc.set(0));
6938
6939    // c:1728 — `if (tok == INBRACE) {`
6940    if tok() == INBRACE_TOK {
6941        // c:1729 — `zshlex();`
6942        zshlex();
6943        // c:1730 — `par_list(&c);`
6944        par_list_wordcode(&mut c);
6945        // c:1731-1736 — `if (tok != OUTBRACE) { lineno += oldlineno; ... }`
6946        if tok() != OUTBRACE_TOK {
6947            set_lineno(lineno() + oldlineno);
6948            ECNPATS.with(|cc| cc.set(onp));
6949            ECSSUB.set(oecssub);
6950            zerr("par_funcdef: expected `}`");
6951            return;
6952        }
6953        // c:1737-1740 — `if (num == 0) { incmdpos = 0; }`
6954        if num == 0 {
6955            set_incmdpos(false);
6956        }
6957        // c:1741 — `zshlex();`
6958        zshlex();
6959    } else if unset(SHORTLOOPS) {
6960        // c:1742-1746 — `lineno += oldlineno; ecnpats = onp; ecssub = oecssub; YYERRORV`
6961        set_lineno(lineno() + oldlineno);
6962        ECNPATS.with(|cc| cc.set(onp));
6963        ECSSUB.set(oecssub);
6964        zerr("par_funcdef: short body requires SHORTLOOPS");
6965        return;
6966    } else {
6967        // c:1748 — `par_list1(&c);`
6968        par_list1_wordcode(&mut c);
6969    }
6970
6971    // c:1750 — `ecadd(WCB_END());`
6972    ecadd(WCB_END());
6973    // c:1751-1754 — fill the 4 metadata slots
6974    let cur_sofs = ECSOFFS.get();
6975    let body_npats = ECNPATS.with(|cc| cc.get());
6976    ECBUF.with_borrow_mut(|b| {
6977        b[m2] = (so - oecssub) as wordcode;
6978        b[m3] = (cur_sofs - so) as wordcode;
6979        b[m4] = body_npats as wordcode;
6980        b[m5] = do_tracing as wordcode;
6981    });
6982    // c:1755 — `ecbuf[p + 1] = num;`
6983    ECBUF.with_borrow_mut(|b| {
6984        b[p1] = num as wordcode;
6985    });
6986
6987    // c:1757 — `ecnpats = onp;`
6988    ECNPATS.with(|cc| cc.set(onp));
6989    // c:1758 — `ecssub = oecssub;`
6990    ECSSUB.set(oecssub);
6991    // c:1759 — `ecnfunc++;`
6992    ECNFUNC.set(ECNFUNC.get() + 1);
6993
6994    // c:1761 — `ecbuf[p] = WCB_FUNCDEF(ecused - 1 - p);`
6995    let used = ECUSED.get() as usize;
6996    ECBUF.with_borrow_mut(|b| {
6997        b[p] = WCB_FUNCDEF((used.saturating_sub(1 + p)) as wordcode);
6998    });
6999
7000    // c:1763-1777 — anonymous-function trailing args (num == 0 case).
7001    if num == 0 {
7002        // c:1766 — `int parg = ecadd(0);`
7003        let parg = ecadd(0);
7004        // c:1767 — `ecadd(0);`
7005        ecadd(0);
7006        // c:1768-1772 — `while (tok == STRING) { ecstr(tokstr); num++; zshlex(); }`
7007        while tok() == STRING_LEX {
7008            ecstr(&tokstr().unwrap_or_default());
7009            num += 1;
7010            zshlex();
7011        }
7012        // c:1773-1774 — `if (num > 0) *cmplx = 1;`
7013        if num > 0 {
7014            *cmplx = 1;
7015        }
7016        // c:1775 — `ecbuf[parg] = ecused - parg;`
7017        // c:1776 — `ecbuf[parg+1] = num;`
7018        let used2 = ECUSED.get() as usize;
7019        ECBUF.with_borrow_mut(|b| {
7020            b[parg] = (used2 - parg) as wordcode;
7021            b[parg + 1] = num as wordcode;
7022        });
7023    }
7024    // c:1778 — `lineno += oldlineno;`
7025    set_lineno(lineno() + oldlineno);
7026}
7027
7028/// Size of `struct fdhead` in `wordcode` (u32) units. Used by all
7029/// the header-walk macros below.
7030pub const FDHEAD_WORDS: usize = size_of::<fdhead>() / 4;
7031
7032/// `Src/parse.c:1619-1665`. Handles both `(...)` subshell and
7033/// `{...}` brace group (cursh) plus optional `always { ... }`
7034/// trailing block. C uses a single function with `zsh_construct=1`
7035/// for `{...}` and 0 for `(...)`.
7036pub fn par_subsh_wordcode(cmplx: &mut i32, zsh_construct: i32) {
7037    // c:1621 — `enum lextok otok = tok;`
7038    let otok = tok();
7039    // c:1622 — `int oecused = ecused, p, pp;`
7040    let _oecused = ECUSED.get() as usize;
7041    let p: usize;
7042    let pp: usize;
7043
7044    // c:1624 — `p = ecadd(0);`
7045    p = ecadd(0);
7046    // c:1625 — `/* Extra word only needed for always block */`
7047    // c:1626 — `pp = ecadd(0);`
7048    pp = ecadd(0);
7049    // c:1627 — `zshlex();`
7050    zshlex();
7051    // c:1628 — `par_list(cmplx);`
7052    par_list_wordcode(cmplx);
7053    // c:1629 — `ecadd(WCB_END());`
7054    ecadd(WCB_END());
7055    // c:1630-1631 — `if (tok != ((otok == INPAR) ? OUTPAR : OUTBRACE))
7056    // YYERRORV(oecused);`
7057    if tok()
7058        != (if otok == INPAR_TOK {
7059            OUTPAR_TOK
7060        } else {
7061            OUTBRACE_TOK
7062        })
7063    {
7064        zerr("par_subsh: missing closing token");
7065        return;
7066    }
7067    // c:1632 — `incmdpos = !zsh_construct;`
7068    set_incmdpos(zsh_construct == 0);
7069    // c:1633 — `zshlex();`
7070    zshlex();
7071
7072    // c:1635 — `/* Optional always block. No intervening SEPERs allowed. */`
7073    // c:1636 — `if (otok == INBRACE && tok == STRING && !strcmp(tokstr, "always")) {`
7074    if otok == INBRACE_TOK && tok() == STRING_LEX && tokstr().as_deref() == Some("always") {
7075        // c:1637 — `ecbuf[pp] = WCB_TRY(ecused - 1 - pp);`
7076        let used = ECUSED.get() as usize;
7077        ECBUF.with_borrow_mut(|b| {
7078            b[pp] = WCB_TRY((used.saturating_sub(1 + pp)) as wordcode);
7079        });
7080        // c:1638 — `incmdpos = 1;`
7081        set_incmdpos(true);
7082        // c:1639-1641 — `do { zshlex(); } while (tok == SEPER);`
7083        loop {
7084            zshlex();
7085            if tok() != SEPER {
7086                break;
7087            }
7088        }
7089
7090        // c:1643-1644 — `if (tok != INBRACE) YYERRORV(oecused);`
7091        if tok() != INBRACE_TOK {
7092            zerr("par_subsh: 'always' expects `{`");
7093            return;
7094        }
7095        // c:1645 — `cmdpop();`
7096        cmdpop();
7097        // c:1646 — `cmdpush(CS_ALWAYS);`
7098        cmdpush(CS_ALWAYS as u8);
7099
7100        // c:1648 — `zshlex();`
7101        zshlex();
7102        // c:1649 — `par_save_list(cmplx);`
7103        par_save_list_wordcode(cmplx);
7104        // c:1650-1651 — `while (tok == SEPER) zshlex();`
7105        while tok() == SEPER {
7106            zshlex();
7107        }
7108
7109        // c:1653 — `incmdpos = 1;`
7110        set_incmdpos(true);
7111
7112        // c:1655-1656 — `if (tok != OUTBRACE) YYERRORV(oecused);`
7113        if tok() != OUTBRACE_TOK {
7114            zerr("par_subsh: 'always' block missing `}`");
7115            return;
7116        }
7117        // c:1657 — `zshlex();`
7118        zshlex();
7119        // c:1658 — `ecbuf[p] = WCB_TRY(ecused - 1 - p);`
7120        let used = ECUSED.get() as usize;
7121        ECBUF.with_borrow_mut(|b| {
7122            b[p] = WCB_TRY((used.saturating_sub(1 + p)) as wordcode);
7123        });
7124    } else {
7125        // c:1660-1661 — `ecbuf[p] = (otok == INPAR ? WCB_SUBSH(...) : WCB_CURSH(...));`
7126        let used = ECUSED.get() as usize;
7127        let off = used.saturating_sub(1 + p);
7128        ECBUF.with_borrow_mut(|b| {
7129            b[p] = if otok == INPAR_TOK {
7130                WCB_SUBSH(off as wordcode)
7131            } else {
7132                WCB_CURSH(off as wordcode)
7133            };
7134        });
7135    }
7136}
7137
7138/// Port of `par_time(void)` from `Src/parse.c:1787`. `time PIPE`
7139/// emits WCB_TIMED(WC_TIMED_PIPE) + the sublist code; bare `time`
7140/// with no pipeline emits WCB_TIMED(WC_TIMED_EMPTY).
7141pub fn par_time_wordcode() {
7142    // c:1791 — `zshlex();`
7143    zshlex();
7144    // c:1793-1794 — `p = ecadd(0); ecadd(0);`
7145    let p = ecadd(0);
7146    ecadd(0);
7147    // c:1795 — `if ((f = par_sublist2(&c)) < 0)`
7148    let mut c = 0i32;
7149    let f = par_sublist2(&mut c);
7150    match f {
7151        Some(flags) => {
7152            // c:1799 — `ecbuf[p] = WCB_TIMED(WC_TIMED_PIPE);`
7153            ECBUF.with_borrow_mut(|b| {
7154                if p < b.len() {
7155                    b[p] = WCB_TIMED(WC_TIMED_PIPE);
7156                }
7157            });
7158            // c:1800 — `set_sublist_code(p+1, WC_SUBLIST_END, f,
7159            // ecused-2-p, c);`
7160            let used = ECUSED.get() as usize;
7161            let skip = used.saturating_sub(2 + p) as i32;
7162            set_sublist_code(p + 1, WC_SUBLIST_END as i32, flags, skip, c != 0);
7163        }
7164        None => {
7165            // c:1796-1798 — `ecused--; ecbuf[p] = WCB_TIMED(WC_TIMED_EMPTY);`
7166            ECUSED.set((ECUSED.get() - 1).max(0));
7167            ECBUF.with_borrow_mut(|b| {
7168                if p < b.len() {
7169                    b[p] = WCB_TIMED(WC_TIMED_EMPTY);
7170                }
7171            });
7172        }
7173    }
7174}
7175
7176/// Port of `par_dinbrack(void)` from `Src/parse.c:1810`. Wraps
7177/// `par_cond` (the cond-expression emitter at parse.c:2409) with
7178/// the `[[ ... ]]` framing: incond/incmdpos toggles + DOUTBRACK
7179/// expectation.
7180pub fn par_cond_wordcode() {
7181    let oecused = ECUSED.get();
7182    // c:1814 — `incond = 1;`
7183    set_incond(1);
7184    // c:1815 — `incmdpos = 0;`
7185    set_incmdpos(false);
7186    // c:1816 — `zshlex();` past `[[`.
7187    zshlex();
7188    // c:1817 — `par_cond();` — call the no-skip cond-expression
7189    // entry that EMITS WORDCODE (par_cond_top → par_cond_1 →
7190    // par_cond_2 → par_cond_double/triple/multi). NOT the AST
7191    // `par_cond` at parse.rs:4644 which is a misnamed `par_dinbrack`
7192    // that skips `[[` AND `]]` and returns a ZshCommand AST node
7193    // instead of pushing WC_COND opcodes. NOT `parse_cond_expr`
7194    // either — that's also AST-only, returning ZshCond. With
7195    // `parse_cond_expr` here, every `[[ ... ]]` test produced ZERO
7196    // wordcode payload and parity dropped ~148 words on /etc/zshrc.
7197    let _ = par_cond_top();
7198    // c:1818-1819 — `if (tok != DOUTBRACK) YYERRORV(oecused);`
7199    if tok() != DOUTBRACK {
7200        let _ = oecused;
7201        zerr("missing ]]");
7202        return;
7203    }
7204    // c:1820 — `incond = 0;`
7205    set_incond(0);
7206    // c:1821 — `incmdpos = 1;`
7207    set_incmdpos(true);
7208    // c:1822 — `zshlex();` past `]]`.
7209    zshlex();
7210}
7211
7212/// Port of the `case DINPAR:` arm of `par_cmd` from
7213/// `Src/parse.c:1031-1034`:
7214/// ```c
7215/// ecadd(WCB_ARITH());
7216/// ecstr(tokstr);
7217/// zshlex();
7218/// ```
7219/// `(( EXPR ))` arithmetic at command position — emits the ARITH
7220/// opcode followed by the interned EXPR string, then advances past
7221/// the DINPAR token (which already carries the body text).
7222pub fn par_arith_wordcode() {
7223    // c:1032 — `ecadd(WCB_ARITH());`
7224    ecadd(WCB_ARITH());
7225    // c:1033 — `ecstr(tokstr);` — interns the expression string and
7226    // appends its strcode index to the wordcode buffer.
7227    let expr = tokstr().unwrap_or_default();
7228    ecstr(&expr);
7229    // c:1034 — `zshlex();`
7230    zshlex();
7231}
7232
7233/// Port of `par_simple(int *cmplx, int nr)` from
7234/// `Src/parse.c:1836-2227`. Emits WC_SIMPLE + word count +
7235/// interned string offsets. Returns `0` when nothing was emitted,
7236/// otherwise `1 + (number of code words consumed by redirections)`.
7237/// The full C body handles assignments (ENVSTRING/ENVARRAY),
7238/// inline `{var}>file` brace-FDs, prefix modifiers (NOCORRECT etc),
7239/// and `name() { body }` funcdef detection — those paths are
7240/// progressively wired into the AST parser; this wordcode-emitter
7241/// covers the simple `cmd args...` case + interleaved redirs.
7242pub fn par_simple_wordcode(cmplx: &mut i32, mut nr: i32) -> i32 {
7243    // c:1838-1841 — `int oecused = ecused, isnull = 1, r, argc = 0,
7244    //   p, isfunc = 0, sr = 0;`
7245    //   `int c = *cmplx, nrediradd, assignments = 0, ppost = 0,
7246    //   is_typeset = 0;`
7247    // c is the SAVED initial cmplx so INOUTPAR can restore via
7248    // `*cmplx = c;` at c:2070.
7249    let _oecused = ECUSED.get() as usize;
7250    let c_saved = *cmplx;
7251    let mut isnull = true;
7252    let mut argc: u32 = 0;
7253    let mut sr: i32 = 0;
7254    let mut assignments = false;
7255    let mut isfunc = false;
7256
7257    // c:1843 — `r = ecused;` — saves the offset where redirs get
7258    // INSERTED (via ecispace). Each redir shifts later words DOWN
7259    // by ncodes, so the SIMPLE placeholder at `p` (set later) must
7260    // also bump by ncodes when a redir lands. C uses `&r` to pass
7261    // the cursor by reference; Rust uses a mutable local + manual
7262    // bumps after each par_redir_wordcode call.
7263    let mut r: usize = ECUSED.get() as usize;
7264
7265    // c:1844-1919 — pre-cmd loop: NOCORRECT, ENVSTRING (scalar
7266    // assigns), ENVARRAY (array assigns), IS_REDIROP. Loops until
7267    // a non-assignment token is seen.
7268    loop {
7269        match tok() {
7270            NOCORRECT => {
7271                // c:1846-1849
7272                *cmplx = 1;
7273                set_nocorrect(1);
7274            }
7275            ENVSTRING => {
7276                // c:1848-1898 — scalar assignment `name=value` or
7277                // `name+=value`. Emits WCB_ASSIGN(SCALAR, NEW|INC, 0)
7278                // followed by ecstr(name), ecstr(value).
7279                let raw = tokstr().unwrap_or_default();
7280                // Find first of Inbrack / '=' / '+' (the C scan at
7281                // c:1851-1853). Inside Inbrack we skipparens — i.e.
7282                // skip `name[...]` index, then continue.
7283                // c:1851-1853 — `for (ptr = tokstr; *ptr && *ptr != Inbrack
7284                // && *ptr != '=' && *ptr != '+'; ptr++); if (*ptr == Inbrack)
7285                // skipparens(Inbrack, Outbrack, &ptr);`. Walk to the first
7286                // `[`/`=`/`+`/Equals-token, then if we landed on `[`, skip
7287                // the balanced `name[index]` pair via skipparens.
7288                let bytes: Vec<char> = raw.chars().collect();
7289                let raw_str: String = bytes.iter().collect();
7290                let mut idx = 0usize;
7291                while idx < bytes.len() {
7292                    let ch = bytes[idx];
7293                    if ch == '\u{91}' /* Inbrack */
7294                        || ch == '=' || ch == '+' || ch == '\u{8d}'
7295                    /* Equals */
7296                    {
7297                        break;
7298                    }
7299                    idx += 1;
7300                }
7301                if idx < bytes.len() && bytes[idx] == '\u{91}'
7302                /* Inbrack */
7303                {
7304                    // c:1855 — `skipparens(Inbrack, Outbrack, &ptr);`.
7305                    let byte_off: usize = bytes[..idx].iter().map(|c| c.len_utf8()).sum();
7306                    let mut cursor: &str = &raw_str[byte_off..];
7307                    let _ = crate::ported::utils::skipparens('\u{91}', '\u{92}', &mut cursor);
7308                    let consumed = raw_str.len() - byte_off - cursor.len();
7309                    let advance_chars = raw_str[byte_off..byte_off + consumed].chars().count();
7310                    idx += advance_chars;
7311                    // Continue scanning for `=` / `+` after the `]`.
7312                    while idx < bytes.len() {
7313                        let ch = bytes[idx];
7314                        if ch == '=' || ch == '+' || ch == '\u{8d}' {
7315                            break;
7316                        }
7317                        idx += 1;
7318                    }
7319                }
7320                let is_inc = idx < bytes.len() && bytes[idx] == '+';
7321                // c:1856-1858 — `if (*ptr == '+') { *ptr++ = '\0';
7322                // ecadd(WCB_ASSIGN(SCALAR, INC, 0)); } else WCB_NEW`
7323                // C nulls the `+` AT THAT POSITION then advances ptr.
7324                // `name` is bytes BEFORE the `+`, NOT including it.
7325                let name_end = idx;
7326                if is_inc {
7327                    idx += 1;
7328                }
7329                let flag = if is_inc { WC_ASSIGN_INC } else { WC_ASSIGN_NEW };
7330                ecadd(WCB_ASSIGN(WC_ASSIGN_SCALAR, flag, 0));
7331                // c:1860 — `if (*ptr == '=') { *ptr = '\0'; str = ptr + 1; }
7332                //          else equalsplit(tokstr, &str);`
7333                let name: String = bytes[..name_end].iter().collect();
7334                let str_off = if idx < bytes.len() && (bytes[idx] == '=' || bytes[idx] == '\u{8d}')
7335                {
7336                    idx + 1
7337                } else {
7338                    idx
7339                };
7340                let value: String = bytes[str_off..].iter().collect();
7341                // c:1866-1877 — scan value for `=(`/`<(`/`>(` (proc
7342                // subst); if found, bump cmplx (suppresses Z_SIMPLE).
7343                let vbytes: Vec<char> = value.chars().collect();
7344                for (i, ch) in vbytes.iter().enumerate() {
7345                    if i + 1 < vbytes.len() && vbytes[i + 1] == '\u{88}'
7346                    /* Inpar */
7347                    {
7348                        if *ch == '\u{8d}' /* Equals */
7349                            || *ch == '\u{94}' /* Inang */
7350                            || *ch == '\u{96}'
7351                        /* OutangProc */
7352                        {
7353                            *cmplx = 1;
7354                            break;
7355                        }
7356                    }
7357                }
7358                ecstr(&name);
7359                ecstr(&value);
7360                isnull = false;
7361                assignments = true;
7362            }
7363            ENVARRAY => {
7364                // c:1883-1908 — array assignment `name=( ... )` in the
7365                // pre-cmd loop (no `typeset`-style typeset_force flag).
7366                // c:1884 — `int oldcmdpos = incmdpos, n, type2;`
7367                let oldcmdpos = incmdpos();
7368                let n: u32;
7369                let type2: wordcode;
7370                let p: usize;
7371
7372                // c:1886-1889 — `array setting is cmplx because it can
7373                //   contain process substitutions`
7374                // c:1890 — `*cmplx = c = 1;`
7375                *cmplx = 1;
7376                // c:1891 — `p = ecadd(0);`
7377                p = ecadd(0);
7378                // c:1892 — `incmdpos = 0;`
7379                set_incmdpos(false);
7380                // c:1893-1897 — `+=` detection: if tokstr ends in `+`,
7381                // strip the `+` and use WC_ASSIGN_INC; else WC_ASSIGN_NEW.
7382                let raw = tokstr().unwrap_or_default();
7383                let (name, t2) = if raw.ends_with('+') {
7384                    (raw[..raw.len() - 1].to_string(), WC_ASSIGN_INC)
7385                } else {
7386                    (raw.clone(), WC_ASSIGN_NEW)
7387                };
7388                type2 = t2;
7389                // c:1898 — `ecstr(tokstr);` (tokstr now NUL-trimmed)
7390                ecstr(&name);
7391                // c:1899 — `cmdpush(CS_ARRAY);`
7392                cmdpush(CS_ARRAY as u8);
7393                // c:1900 — `zshlex();`
7394                zshlex();
7395                // c:1901 — `n = par_nl_wordlist();`
7396                n = par_nl_wordlist_wordcode();
7397                // c:1902 — `ecbuf[p] = WCB_ASSIGN(WC_ASSIGN_ARRAY, type2, n);`
7398                ECBUF.with_borrow_mut(|b| {
7399                    b[p] = WCB_ASSIGN(WC_ASSIGN_ARRAY, type2, n);
7400                });
7401                // c:1903 — `cmdpop();`
7402                cmdpop();
7403                // c:1904-1905 — `if (tok != OUTPAR) YYERROR(oecused);`
7404                if tok() != OUTPAR_TOK {
7405                    zerr("par_simple: expected `)' after array assignment");
7406                    return 0;
7407                }
7408                // c:1906 — `incmdpos = oldcmdpos;`
7409                set_incmdpos(oldcmdpos);
7410                // c:1907 — `isnull = 0;`
7411                isnull = false;
7412                // c:1908 — `assignments = 1;`
7413                assignments = true;
7414            }
7415            t if IS_REDIROP(t) => {
7416                // c:1900-1904 — `*cmplx = c = 1; nr += par_redir(&r,
7417                // NULL); continue;`. The wordcode-emitting redir is
7418                // distinct from the AST par_redir — it INSERTS
7419                // WCB_REDIR + fd + ecstrcode(name) at offset `r`
7420                // via ecispace, shifting any later words down.
7421                *cmplx = 1;
7422                let added = par_redir_wordcode(&mut r, None);
7423                if added == 0 {
7424                    break;
7425                }
7426                nr += added;
7427                continue;
7428            }
7429            _ => break,
7430        }
7431        zshlex(); // c:1907 `zshlex();`
7432    }
7433
7434    // c:1920-1921 — `if (tok == AMPER || tok == AMPERBANG) YYERROR;`
7435    if tok() == AMPER || tok() == AMPERBANG {
7436        zerr("par_simple: unexpected &");
7437        return 0;
7438    }
7439
7440    // c:1923 — `p = ecadd(WCB_SIMPLE(0));`
7441    let mut p = ecadd(WCB_SIMPLE(0));
7442
7443    // c:1924-2105 — main words loop. is_typeset tracks whether the
7444    // outer command was `typeset`/`export`/etc. so the final
7445    // placeholder gets WCB_TYPESET instead of WCB_SIMPLE.
7446    let mut is_typeset = false;
7447    let mut postassigns: u32 = 0;
7448    let mut ppost: usize = 0;
7449    loop {
7450        match tok() {
7451            STRING_LEX | TYPESET => {
7452                // c:1926 — `int redir_var = 0;`
7453                let mut redir_var = false;
7454                // c:1928-1929 — `*cmplx = 1; incmdpos = 0;`
7455                *cmplx = 1;
7456                set_incmdpos(false);
7457                // c:1931-1932 — TYPESET → intypeset = is_typeset = 1.
7458                if tok() == TYPESET {
7459                    set_intypeset(true);
7460                    is_typeset = true;
7461                }
7462                let s = tokstr().unwrap_or_default();
7463                // c:1934-1974 — `{var}>file` brace-FD detection.
7464                // `if (!isset(IGNOREBRACES) && *tokstr == Inbrace)`
7465                let bytes = s.as_bytes();
7466                let first_is_inbrace = (bytes.len() >= 2 && bytes[0] == 0xc2 && bytes[1] == 0x8f)
7467                    || (bytes.len() >= 1 && bytes[0] == b'{');
7468                if !isset(IGNOREBRACES) && first_is_inbrace {
7469                    // c:1937-1938 — `char *eptr = tokstr + strlen(tokstr) - 1;`
7470                    //                `char *ptr = eptr;`
7471                    // C tests `*eptr == Outbrace` (0x90 marker or `}`) AND
7472                    // there's content between `{` and `}` (`ptr > tokstr + 1`).
7473                    let last_two_outbrace = bytes.len() >= 2
7474                        && (bytes.ends_with(&[0xc2, 0x90]) || bytes.last() == Some(&b'}'));
7475                    let opener_len = if bytes.len() >= 2 && bytes[0] == 0xc2 && bytes[1] == 0x8f {
7476                        2
7477                    } else {
7478                        1
7479                    };
7480                    let closer_len = if bytes.len() >= 2 && bytes.ends_with(&[0xc2, 0x90]) {
7481                        2
7482                    } else if bytes.last() == Some(&b'}') {
7483                        1
7484                    } else {
7485                        0
7486                    };
7487                    if last_two_outbrace && bytes.len() > opener_len + closer_len {
7488                        // c:1944 — `if (itype_end(tokstr+1, IIDENT, 0) >= ptr)`
7489                        // Inner content is the identifier between `{` and `}`.
7490                        let inner_start = opener_len;
7491                        let inner_end = bytes.len() - closer_len;
7492                        let inner = &s[inner_start..inner_end];
7493                        if !inner.is_empty() && crate::ported::params::isident(inner) {
7494                            // c:1946-1948 — `char *idstring = dupstrpfx(...);`
7495                            //                `redir_var = 1; zshlex();`
7496                            let idstring = inner.to_string();
7497                            redir_var = true;
7498                            zshlex();
7499                            // c:1953-1958 — `if (IS_REDIROP(tok) && tokfd == -1)
7500                            //   { *cmplx = c = 1; nrediradd = par_redir(&r, id);
7501                            //     p += nrediradd; sr += nrediradd; }`
7502                            if IS_REDIROP(tok()) && tokfd() == -1 {
7503                                *cmplx = 1;
7504                                let nrediradd = par_redir_wordcode(&mut r, Some(&idstring));
7505                                p += nrediradd as usize;
7506                                sr += nrediradd;
7507                            } else if postassigns > 0 {
7508                                // c:1959-1966 — postassigns path: emit
7509                                // WCB_ASSIGN(SCALAR, INC, 0) + name + ""
7510                                postassigns += 1;
7511                                ecadd(WCB_ASSIGN(WC_ASSIGN_SCALAR, WC_ASSIGN_INC, 0));
7512                                ecstr(&s);
7513                                ecstr("");
7514                            } else {
7515                                // c:1968-1972 — `else { ecstr(toksave); argc++; }`
7516                                ecstr(&s);
7517                                argc += 1;
7518                            }
7519                        }
7520                    }
7521                }
7522                if !redir_var {
7523                    // c:1977-1996 — normal (non-redir-var) STRING/TYPESET.
7524                    if postassigns > 0 {
7525                        // c:1979-1989 — typeset with bare-name arg → INC
7526                        postassigns += 1;
7527                        ecadd(WCB_ASSIGN(WC_ASSIGN_SCALAR, WC_ASSIGN_INC, 0));
7528                        ecstr(&s);
7529                        ecstr("");
7530                    } else {
7531                        ecstr(&s);
7532                        argc += 1;
7533                    }
7534                    zshlex();
7535                }
7536                isnull = false;
7537            }
7538            ENVSTRING => {
7539                // c:2005-2026 — mid-cmd ENVSTRING (under intypeset
7540                // context). Emits WCB_ASSIGN(SCALAR, NEW, 0) then
7541                // ecstr(name) + ecstr(value), tracking the first
7542                // postassign offset in `ppost` (which the trailing
7543                // WCB_TYPESET header points to).
7544                if postassigns == 0 {
7545                    ppost = ecadd(0);
7546                }
7547                postassigns += 1;
7548                // c:2010-2014 — `for (ptr = tokstr; *ptr && *ptr != Inbrack
7549                // && *ptr != '=' && *ptr != '+'; ptr++); if (*ptr == Inbrack)
7550                // skipparens(Inbrack, Outbrack, &ptr);`.
7551                let raw = tokstr().unwrap_or_default();
7552                let bytes: Vec<char> = raw.chars().collect();
7553                let mut idx = 0usize;
7554                while idx < bytes.len() {
7555                    let ch = bytes[idx];
7556                    if ch == '\u{91}' /* Inbrack */
7557                        || ch == '=' || ch == '+' || ch == '\u{8d}'
7558                    /* Equals */
7559                    {
7560                        break;
7561                    }
7562                    idx += 1;
7563                }
7564                if idx < bytes.len() && bytes[idx] == '\u{91}'
7565                /* Inbrack */
7566                {
7567                    // c:2014 — `skipparens(Inbrack, Outbrack, &ptr);`.
7568                    let byte_off: usize = bytes[..idx].iter().map(|c| c.len_utf8()).sum();
7569                    let mut cursor: &str = &raw[byte_off..];
7570                    let _ = crate::ported::utils::skipparens('\u{91}', '\u{92}', &mut cursor);
7571                    let consumed = raw.len() - byte_off - cursor.len();
7572                    let advance_chars = raw[byte_off..byte_off + consumed].chars().count();
7573                    idx += advance_chars;
7574                    while idx < bytes.len() {
7575                        let ch = bytes[idx];
7576                        if ch == '=' || ch == '+' || ch == '\u{8d}' {
7577                            break;
7578                        }
7579                        idx += 1;
7580                    }
7581                }
7582                let name: String = bytes[..idx].iter().collect();
7583                let str_off = if idx < bytes.len() && (bytes[idx] == '=' || bytes[idx] == '\u{8d}')
7584                {
7585                    idx + 1
7586                } else {
7587                    idx
7588                };
7589                let value: String = bytes[str_off..].iter().collect();
7590                ecadd(WCB_ASSIGN(WC_ASSIGN_SCALAR, WC_ASSIGN_NEW, 0));
7591                ecstr(&name);
7592                ecstr(&value);
7593                isnull = false;
7594                zshlex();
7595            }
7596            ENVARRAY => {
7597                // c:2027-2050 — mid-cmd ENVARRAY (typeset N=(…) form).
7598                // C tracks postassigns + ppost the same as ENVSTRING,
7599                // but the inner emit is WCB_ASSIGN(ARRAY, NEW, n)
7600                // with `n` patched in after par_nl_wordlist consumes
7601                // the elements. C also toggles intypeset=0 around the
7602                // wordlist so the lexer doesn't try to re-emit
7603                // assignments inside the array.
7604                *cmplx = 1;
7605                if postassigns == 0 {
7606                    ppost = ecadd(0);
7607                }
7608                postassigns += 1;
7609                let parr = ecadd(0);
7610                let raw = tokstr().unwrap_or_default();
7611                let is_inc = raw.ends_with('+');
7612                let name = if is_inc {
7613                    &raw[..raw.len() - 1]
7614                } else {
7615                    raw.as_str()
7616                };
7617                let flag = if is_inc { WC_ASSIGN_INC } else { WC_ASSIGN_NEW };
7618                ecstr(name);
7619                cmdpush(CS_ARRAY as u8);
7620                set_intypeset(false);
7621                zshlex();
7622                // c:2044 — `n = par_nl_wordlist();` (parse.c:2379-2391).
7623                // SEPER + NEWLIN both allowed between elements.
7624                let mut nelem = 0u32;
7625                loop {
7626                    let t = tok();
7627                    if t != STRING_LEX && t != SEPER && t != NEWLIN {
7628                        break;
7629                    }
7630                    if t == STRING_LEX {
7631                        ecstr(&tokstr().unwrap_or_default());
7632                        nelem += 1;
7633                    }
7634                    zshlex();
7635                }
7636                ECBUF.with_borrow_mut(|b| {
7637                    if parr < b.len() {
7638                        b[parr] = WCB_ASSIGN(WC_ASSIGN_ARRAY, flag, nelem);
7639                    }
7640                });
7641                cmdpop();
7642                set_intypeset(true);
7643                if tok() != OUTPAR_TOK {
7644                    zerr("expected `)' after array assignment");
7645                    return 0;
7646                }
7647                isnull = false;
7648                zshlex();
7649            }
7650            t if IS_REDIROP(t) => {
7651                // c:1999-2010 — `nrediradd = par_redir(&r, NULL);
7652                // p += nrediradd; if (ppost) ppost += nrediradd;
7653                // sr += nrediradd;`
7654                *cmplx = 1;
7655                let added = par_redir_wordcode(&mut r, None);
7656                if added == 0 {
7657                    break;
7658                }
7659                p += added as usize;
7660                if ppost != 0 {
7661                    ppost += added as usize;
7662                }
7663                sr += added;
7664            }
7665            INOUTPAR => {
7666                // c:2051 — `} else if (tok == INOUTPAR) {`
7667                // c:2052 — `zlong oldlineno = lineno;`
7668                let oldlineno = lineno();
7669                // c:2053 — `int onp, so, oecssub = ecssub;`
7670                let oecssub = ECSSUB.get();
7671                // c:2055-2057 — `if (!isset(MULTIFUNCDEF) && argc > 1) YYERROR;`
7672                if !isset(MULTIFUNCDEF) && argc > 1 {
7673                    zerr("par_simple: too many function names for funcdef");
7674                    return 0;
7675                }
7676                // c:2058-2060 — `if (assignments || postassigns) YYERROR;`
7677                if assignments || postassigns > 0 {
7678                    zerr("par_simple: assignments before funcdef");
7679                    return 0;
7680                }
7681                // c:2061-2068 — hasalias check + zwarn — skipped (no
7682                // alias tracking on the wordcode path).
7683
7684                // c:2070 — `*cmplx = c;`
7685                *cmplx = c_saved;
7686                // c:2071 — `lineno = 0;`
7687                set_lineno(0);
7688                // c:2072 — `incmdpos = 1;`
7689                set_incmdpos(true);
7690                // c:2073 — `cmdpush(CS_FUNCDEF);`
7691                cmdpush(CS_FUNCDEF as u8);
7692                // c:2074 — `zshlex();`
7693                zshlex();
7694                // c:2075-2076 — `while (tok == SEPER) zshlex();`
7695                while tok() == SEPER {
7696                    zshlex();
7697                }
7698                // c:2079 — `ecispace(p + 1, 1); ecbuf[p+1] = argc;
7699                // ecadd(0)*4`. Insert the argc word at p+1, then
7700                // append 4 placeholder words.
7701                ecispace(p + 1, 1);
7702                ECBUF.with_borrow_mut(|b| {
7703                    if p + 1 < b.len() {
7704                        b[p + 1] = argc;
7705                    }
7706                });
7707                // c:2080-2083 — four metadata placeholder slots.
7708                ecadd(0);
7709                ecadd(0);
7710                ecadd(0);
7711                ecadd(0);
7712
7713                // c:2085 — `ecnfunc++;`
7714                ECNFUNC.set(ECNFUNC.get() + 1);
7715                // c:2086 — `ecssub = so = ecsoffs;`
7716                let so = ECSOFFS.get();
7717                ECSSUB.set(so);
7718                // c:2087 — `onp = ecnpats;`
7719                let onp = ECNPATS.with(|cc| cc.get());
7720                // c:2088 — `ecnpats = 0;`
7721                ECNPATS.with(|cc| cc.set(0));
7722
7723                // c:2091 — `int c = 0;` — INNER cmplx for the body
7724                // parse. Local to each branch; C's enclosing *cmplx
7725                // is NOT modified by the body.
7726                let mut body_c: i32 = 0;
7727                // c:2090 — `if (tok == INBRACE) {`
7728                if tok() == INBRACE_TOK {
7729                    // c:2093 — `zshlex();`
7730                    zshlex();
7731                    // c:2094 — `par_list(&c);`
7732                    par_list_wordcode(&mut body_c);
7733                    // c:2095-2101 — `if (tok != OUTBRACE) { cmdpop();
7734                    //   lineno += oldlineno; ecnpats = onp;
7735                    //   ecssub = oecssub; YYERROR; }`
7736                    if tok() != OUTBRACE_TOK {
7737                        cmdpop();
7738                        set_lineno(lineno() + oldlineno);
7739                        ECNPATS.with(|cc| cc.set(onp));
7740                        ECSSUB.set(oecssub);
7741                        zerr("par_simple: funcdef expected `}`");
7742                        return 0;
7743                    }
7744                    // c:2102-2105 — `if (argc == 0) incmdpos = 0;`
7745                    if argc == 0 {
7746                        set_incmdpos(false);
7747                    }
7748                    // c:2106 — `zshlex();`
7749                    zshlex();
7750                } else {
7751                    // c:2107-2132 — short-body funcdef form: `f() cmd`
7752                    // or `() cmd`. Wraps single par_cmd result in a
7753                    // synthetic WC_LIST / WC_SUBLIST /
7754                    // WC_PIPE(WC_PIPE_END, 0) header trio.
7755                    let ll = ecadd(0);
7756                    let sl = ecadd(0);
7757                    ecadd(WCB_PIPE(WC_PIPE_END, 0));
7758                    let ok = par_cmd_wordcode(&mut body_c, if argc == 0 { 1 } else { 0 });
7759                    if !ok {
7760                        cmdpop();
7761                        zerr("par_simple: funcdef short-body: missing command");
7762                        return 0;
7763                    }
7764                    if argc == 0 {
7765                        // c:2118-2127 — anonymous funcdef may take args
7766                        // after the body; first one already read.
7767                        set_incmdpos(false);
7768                    }
7769                    // c:2130-2131 — inner sublist/list use inner cmplx.
7770                    let used = ECUSED.get() as usize;
7771                    set_sublist_code(
7772                        sl,
7773                        WC_SUBLIST_END as i32,
7774                        0,
7775                        (used.saturating_sub(1 + sl)) as i32,
7776                        body_c != 0,
7777                    );
7778                    set_list_code(ll, Z_SYNC | Z_END, body_c != 0);
7779                }
7780                let _ = body_c;
7781                // c:2133 — `cmdpop();`
7782                cmdpop();
7783
7784                // c:2135 — `ecadd(WCB_END());`
7785                ecadd(WCB_END());
7786                // c:2136-2139 — fill 4 metadata slots at p+argc+2..5
7787                let p_argc = (p + (argc as usize) + 2) as usize;
7788                let cur_so = ECSOFFS.get();
7789                let np_now = ECNPATS.with(|cc| cc.get());
7790                ECBUF.with_borrow_mut(|b| {
7791                    b[p_argc] = (so - oecssub) as wordcode;
7792                    b[p_argc + 1] = (cur_so - so) as wordcode;
7793                    b[p_argc + 2] = np_now as wordcode;
7794                    b[p_argc + 3] = 0;
7795                });
7796
7797                // c:2141-2143 — `ecnpats = onp; ecssub = oecssub; ecnfunc++;`
7798                ECNPATS.with(|cc| cc.set(onp));
7799                ECSSUB.set(oecssub);
7800                ECNFUNC.set(ECNFUNC.get() + 1);
7801
7802                // c:2145 — `ecbuf[p] = WCB_FUNCDEF(ecused - 1 - p);`
7803                let used = ECUSED.get() as usize;
7804                let header_off = used.saturating_sub(1 + p) as wordcode;
7805                ECBUF.with_borrow_mut(|b| {
7806                    b[p] = WCB_FUNCDEF(header_off);
7807                });
7808
7809                // c:2147-2172 — `if (argc == 0) { /* anonymous fn args */ }`
7810                if argc == 0 {
7811                    // c:2150 — `int parg = ecadd(0);`
7812                    let mut parg = ecadd(0);
7813                    // c:2151 — `ecadd(0);`
7814                    ecadd(0);
7815                    // c:2152 — `while (tok == STRING || IS_REDIROP(tok)) {`
7816                    while tok() == STRING_LEX || IS_REDIROP(tok()) {
7817                        if tok() == STRING_LEX {
7818                            // c:2155-2157
7819                            ecstr(&tokstr().unwrap_or_default());
7820                            argc += 1;
7821                            zshlex();
7822                        } else {
7823                            // c:2159-2165 — *cmplx=c=1; nrediradd=par_redir;
7824                            // p += nrediradd; ppost += nrediradd if ppost;
7825                            // sr += nrediradd; parg += nrediradd;
7826                            *cmplx = 1;
7827                            let added = par_redir_wordcode(&mut r, None);
7828                            if added == 0 {
7829                                break;
7830                            }
7831                            p += added as usize;
7832                            if ppost != 0 {
7833                                ppost += added as usize;
7834                            }
7835                            sr += added;
7836                            parg += added as usize;
7837                        }
7838                    }
7839                    // c:2168-2169 — `if (argc > 0) *cmplx = 1;`
7840                    if argc > 0 {
7841                        *cmplx = 1;
7842                    }
7843                    // c:2170 — `ecbuf[parg] = ecused - parg;`
7844                    // c:2171 — `ecbuf[parg+1] = argc;`
7845                    let used2 = ECUSED.get() as usize;
7846                    ECBUF.with_borrow_mut(|b| {
7847                        b[parg] = (used2 - parg) as wordcode;
7848                        b[parg + 1] = argc;
7849                    });
7850                }
7851                // c:2173 — `lineno += oldlineno;`
7852                set_lineno(lineno() + oldlineno);
7853
7854                // c:2175-2177 — `isfunc = 1; isnull = 0; break;`
7855                isfunc = true;
7856                isnull = false;
7857                break;
7858            }
7859            _ => break,
7860        }
7861    }
7862
7863    // c:2173-2176 — `if (isnull && !(sr + nr)) { ecused = oecused;
7864    // return 0; }` — undo everything including pre-cmd assignments
7865    // if no actual command word emerged.
7866    if isnull && sr + nr == 0 && !assignments {
7867        ECUSED.set(p as i32);
7868        return 0;
7869    }
7870    // c:2186-2187 — `incmdpos = 1; intypeset = 0;` — reset before
7871    // the placeholder patch so the next-token lex doesn't carry
7872    // typeset/incond state.
7873    set_incmdpos(true);
7874    set_intypeset(false);
7875    // c:2189-2199 — `if (!isfunc) { if (is_typeset) ecbuf[p] =
7876    // WCB_TYPESET(argc); else ecbuf[p] = WCB_SIMPLE(argc); }`.
7877    // When isfunc=true the INOUTPAR branch already wrote WCB_FUNCDEF
7878    // at p; do NOT clobber it.
7879    if !isfunc {
7880        let header = if is_typeset {
7881            if postassigns > 0 {
7882                ECBUF.with_borrow_mut(|b| {
7883                    if ppost < b.len() {
7884                        b[ppost] = postassigns;
7885                    }
7886                });
7887            } else {
7888                ecadd(0);
7889            }
7890            WCB_TYPESET(argc)
7891        } else {
7892            WCB_SIMPLE(argc)
7893        };
7894        ECBUF.with_borrow_mut(|b| {
7895            if p < b.len() {
7896                b[p] = header;
7897            }
7898        });
7899    }
7900    1 + sr
7901}
7902
7903/// Port of `par_redir(int *rp, char *idstring)` from
7904/// `Src/parse.c:2229-2345` — the wordcode-emitting variant that
7905/// pushes WCB_REDIR + fd + ecstrcode(name) into ECBUF. Distinct
7906/// from the AST `par_redir` (parse.rs:3771) which builds a
7907/// ZshRedir struct for the AST executor pipeline.
7908///
7909/// Returns the number of wordcodes added (3 for the basic shape,
7910/// 4 with idstring, 5 for HEREDOC[DASH] which carries the
7911/// terminator strings inline). Returns 0 on parse error.
7912///
7913/// `idstring` mirrors C's `char *idstring` parameter — `None` =
7914/// NULL (no `{var}>file` brace-FD shape), `Some(id)` = the captured
7915/// `{var}` name. C callers without a var pass NULL inline; Rust
7916/// callers do the same with `None`.
7917fn par_redir_wordcode(rp: &mut usize, idstring: Option<&str>) -> i32 {
7918    // c:2231 — `int r = *rp, type, fd1, oldcmdpos, oldnc, ncodes;`
7919    let r: usize = *rp;
7920    let mut r#type: i32;
7921    let fd1: i32;
7922    let oldcmdpos: bool;
7923    let oldnc: i32;
7924    let mut ncodes: usize;
7925    // c:2232 — `char *name;`
7926    let name: String;
7927
7928    // c:2234 — `oldcmdpos = incmdpos;`
7929    oldcmdpos = incmdpos();
7930    // c:2235 — `incmdpos = 0;`
7931    set_incmdpos(false);
7932    // c:2236 — `oldnc = nocorrect;`
7933    oldnc = nocorrect();
7934    // c:2237-2238 — `if (tok != INANG && tok != INOUTANG) nocorrect = 1;`
7935    if tok() != INANG_TOK && tok() != INOUTANG {
7936        set_nocorrect(1);
7937    }
7938    // c:2239 — `type = redirtab[tok - OUTANG];`
7939    // Map current redirop token to redirtab index — matches order of
7940    // C `enum { OUTANG, OUTANGBANG, DOUTANG, DOUTANGBANG, INANG,
7941    // INOUTANG, DINANG, DINANGDASH, INANGAMP, OUTANGAMP, AMPOUTANG,
7942    // OUTANGAMPBANG, DOUTANGAMP, DOUTANGAMPBANG, TRINANG }`.
7943    r#type = match tok() {
7944        OUTANG_TOK => REDIR_WRITE,
7945        OUTANGBANG => REDIR_WRITENOW,
7946        DOUTANG => REDIR_APP,
7947        DOUTANGBANG => REDIR_APPNOW,
7948        INANG_TOK => REDIR_READ,
7949        INOUTANG => REDIR_READWRITE,
7950        DINANG => REDIR_HEREDOC,
7951        DINANGDASH => REDIR_HEREDOCDASH,
7952        INANGAMP => REDIR_MERGEIN,
7953        OUTANGAMP => REDIR_MERGEOUT,
7954        AMPOUTANG => REDIR_ERRWRITE,
7955        OUTANGAMPBANG => REDIR_ERRWRITENOW,
7956        DOUTANGAMP => REDIR_ERRAPP,
7957        DOUTANGAMPBANG => REDIR_ERRAPPNOW,
7958        TRINANG => REDIR_HERESTR,
7959        _ => {
7960            set_incmdpos(oldcmdpos);
7961            set_nocorrect(oldnc);
7962            return 0;
7963        }
7964    };
7965    // c:2240 — `fd1 = tokfd;`
7966    fd1 = tokfd();
7967    // c:2241 — `zshlex();`
7968    zshlex();
7969    // c:2242-2243 — `if (tok != STRING && tok != ENVSTRING) YYERROR(ecused);`
7970    if tok() != STRING_LEX && tok() != ENVSTRING {
7971        set_incmdpos(oldcmdpos);
7972        set_nocorrect(oldnc);
7973        zerr("expected word after redirection");
7974        return 0;
7975    }
7976    // c:2244 — `incmdpos = oldcmdpos;`
7977    set_incmdpos(oldcmdpos);
7978    // c:2245 — `nocorrect = oldnc;`
7979    set_nocorrect(oldnc);
7980
7981    // c:2248-2249 — `if (fd1 == -1) fd1 = IS_READFD(type) ? 0 : 1;`
7982    let fd1 = if fd1 == -1 {
7983        if is_readfd(r#type) {
7984            0
7985        } else {
7986            1
7987        }
7988    } else {
7989        fd1
7990    };
7991
7992    // c:2251 — `name = tokstr;`
7993    name = tokstr().unwrap_or_default();
7994
7995    // c:2253-2321 — switch on type:
7996    match r#type {
7997        // c:2254-2300 — REDIR_HEREDOC / REDIR_HEREDOCDASH
7998        x if x == REDIR_HEREDOC || x == REDIR_HEREDOCDASH => {
7999            // c:2257 — `struct heredocs **hd;`
8000            // c:2258 — `int htype = type;`
8001            let htype = r#type;
8002            // c:2260-2261 — `if (strchr(tokstr, '\n')) YYERROR(ecused);`
8003            if name.contains('\n') {
8004                zerr("here-doc terminator contains newline");
8005                return 0;
8006            }
8007            // c:2263-2273 — `ncodes = 5; if (idstring) { type |= MASK; ncodes = 6; }`
8008            if idstring.is_some() {
8009                r#type |= REDIR_VARID_MASK;
8010                ncodes = 6;
8011            } else {
8012                ncodes = 5;
8013            }
8014            // c:2277 — `ecispace(r, ncodes);`
8015            ecispace(r, ncodes);
8016            // c:2278 — `*rp = r + ncodes;`
8017            *rp = r + ncodes;
8018            // c:2279 — `ecbuf[r] = WCB_REDIR(type | REDIR_FROM_HEREDOC_MASK);`
8019            ECBUF.with_borrow_mut(|b| {
8020                b[r] = WCB_REDIR((r#type | REDIR_FROM_HEREDOC_MASK) as wordcode);
8021                // c:2280 — `ecbuf[r + 1] = fd1;`
8022                b[r + 1] = fd1 as wordcode;
8023            });
8024            // c:2282-2286 — r+2..4 are filled later by setheredoc.
8025            // c:2287-2288 — `if (idstring) ecbuf[r + 5] = ecstrcode(idstring);`
8026            if let Some(id) = idstring {
8027                let coded = ecstrcode(id);
8028                ECBUF.with_borrow_mut(|b| {
8029                    b[r + 5] = coded;
8030                });
8031            }
8032            // c:2290-2296 — `for (hd = &hdocs; *hd; hd = &(*hd)->next);
8033            //                 *hd = zalloc(sizeof(struct heredocs));
8034            //                 (*hd)->next = NULL;
8035            //                 (*hd)->type = htype;
8036            //                 (*hd)->pc = r;
8037            //                 (*hd)->str = tokstr;`
8038            HDOCS.with_borrow_mut(|head| {
8039                let mut cur = head;
8040                while cur.is_some() {
8041                    cur = &mut cur.as_mut().unwrap().next; // c:2290
8042                }
8043                *cur = Some(Box::new(crate::ported::zsh_h::heredocs {
8044                    // c:2292-2296
8045                    next: None,
8046                    typ: htype,
8047                    pc: r as i32,
8048                    str: Some(name.clone()),
8049                }));
8050            });
8051            // c:2298 — `zshlex();`
8052            zshlex();
8053            // c:2299 — `return ncodes;`
8054            return ncodes as i32;
8055        }
8056        // c:2301-2308 — REDIR_WRITE / REDIR_WRITENOW
8057        x if x == REDIR_WRITE || x == REDIR_WRITENOW => {
8058            // c:2303-2305 — `if (tokstr[0] == OutangProc && tokstr[1] == Inpar)
8059            //                  type = REDIR_OUTPIPE;`
8060            let nb: Vec<char> = name.chars().collect();
8061            if nb.len() >= 2 && nb[0] == '\u{96}' && nb[1] == '\u{88}' {
8062                r#type = REDIR_OUTPIPE;
8063            } else if nb.len() >= 2 && nb[0] == '\u{94}' && nb[1] == '\u{88}' {
8064                // c:2306-2307 — `else if (tokstr[0] == Inang && tokstr[1] == Inpar) YYERROR;`
8065                zerr("par_redir: < before >");
8066                return 0;
8067            }
8068        }
8069        // c:2309-2315 — REDIR_READ
8070        x if x == REDIR_READ => {
8071            let nb: Vec<char> = name.chars().collect();
8072            if nb.len() >= 2 && nb[0] == '\u{94}' && nb[1] == '\u{88}' {
8073                r#type = REDIR_INPIPE;
8074            } else if nb.len() >= 2 && nb[0] == '\u{96}' && nb[1] == '\u{88}' {
8075                zerr("par_redir: > before <");
8076                return 0;
8077            }
8078        }
8079        // c:2316-2320 — REDIR_READWRITE
8080        x if x == REDIR_READWRITE => {
8081            let nb: Vec<char> = name.chars().collect();
8082            if nb.len() >= 2 && (nb[0] == '\u{94}' || nb[0] == '\u{96}') && nb[1] == '\u{88}' {
8083                r#type = if nb[0] == '\u{94}' {
8084                    REDIR_INPIPE
8085                } else {
8086                    REDIR_OUTPIPE
8087                };
8088            }
8089        }
8090        _ => {}
8091    }
8092    // c:2322 — `zshlex();`
8093    zshlex();
8094
8095    // c:2326-2333 — `if (idstring) { type |= MASK; ncodes = 4; } else ncodes = 3;`
8096    if idstring.is_some() {
8097        r#type |= REDIR_VARID_MASK;
8098        ncodes = 4;
8099    } else {
8100        ncodes = 3;
8101    }
8102
8103    // c:2334 — `ecispace(r, ncodes);`
8104    ecispace(r, ncodes);
8105    // c:2335 — `*rp = r + ncodes;`
8106    *rp = r + ncodes;
8107    // c:2336 — `ecbuf[r] = WCB_REDIR(type);`
8108    let coded_name = ecstrcode(&name);
8109    ECBUF.with_borrow_mut(|b| {
8110        b[r] = WCB_REDIR(r#type as wordcode);
8111        // c:2337 — `ecbuf[r + 1] = fd1;`
8112        b[r + 1] = fd1 as wordcode;
8113        // c:2338 — `ecbuf[r + 2] = ecstrcode(name);`
8114        b[r + 2] = coded_name;
8115    });
8116    // c:2339-2340 — `if (idstring) ecbuf[r + 3] = ecstrcode(idstring);`
8117    if let Some(id) = idstring {
8118        let coded_id = ecstrcode(id);
8119        ECBUF.with_borrow_mut(|b| {
8120            b[r + 3] = coded_id;
8121        });
8122    }
8123    // c:2342 — `return ncodes;`
8124    ncodes as i32
8125}
8126
8127/// Port of `IS_READFD(type)` macro from `Src/zsh.h` — determines
8128/// default fd (0 for read-ish, 1 for write-ish) when none specified.
8129fn is_readfd(t: i32) -> bool {
8130    matches!(
8131        t,
8132        x if x == REDIR_READ
8133            || x == REDIR_READWRITE
8134            || x == REDIR_MERGEIN
8135            || x == REDIR_HEREDOC
8136            || x == REDIR_HEREDOCDASH
8137            || x == REDIR_HERESTR
8138    )
8139}
8140
8141/// Parse a program (list of lists)
8142/// Parse a complete program (top-level entry). Calls
8143/// parse_program_until with no end-token sentinel. Direct port of
8144/// zsh/Src/parse.c:614-720 `parse_event` / `par_list` /
8145/// `par_event` flow. C distinguishes COND_EVENT (single command
8146/// for here-string) from full event parse; zshrs's parse_program
8147/// is the full-event entry.
8148fn parse_program() -> ZshProgram {
8149    parse_program_until(None, false)
8150}
8151
8152/// Parse a program until we hit an end token
8153/// Parse a program until one of `end_tokens` is seen (or EOF).
8154/// Drives par_list in a loop. C equivalent: the body of par_event
8155/// (parse.c:635-695) iterating par_list against the lexer.
8156///
8157/// `single_event` mirrors C par_event's `endtok == ENDINPUT` top-level
8158/// mode: when true, parse ONE event (the sublists `;`/`&`-chained on one
8159/// logical line, including any multi-line compound) and STOP at the
8160/// terminating top-level newline WITHOUT reading the next line — so
8161/// loop() executes the event then re-reads for the next, interleaving
8162/// input with execution exactly as zsh does. Whole-program and
8163/// nested-body parses pass false (multi-list to EOF / end token).
8164fn parse_program_until(end_tokens: Option<&[lextok]>, single_event: bool) -> ZshProgram {
8165    let mut lists = Vec::new();
8166
8167    loop {
8168        // Skip separators
8169        while tok() == SEPER || tok() == NEWLIN {
8170            zshlex();
8171        }
8172
8173        if tok() == ENDINPUT {
8174            break;
8175        }
8176        if tok() == LEXERR {
8177            // c:Src/parse.c:671-680 par_event — when the lexer
8178            // returned LEXERR (e.g. unbalanced `$((1+(2))` math
8179            // sub, unterminated string, etc.), C emits `yyerror(1)`
8180            // and sets errflag so the script aborts with a parse
8181            // error diagnostic + non-zero exit. zshrs's
8182            // parse_program_until previously just `break`'d on
8183            // LEXERR, silently swallowing the malformed input and
8184            // exiting rc=0 — so `$((1+(2))` ran as if it were
8185            // empty. Bug #529 in docs/BUGS.md. Emit yyerror
8186            // mirroring the C behaviour; the broken script then
8187            // surfaces the parse error to the caller.
8188            // c:Src/parse.c — empty-msg yyerror call mapped to the
8189            // C-faithful `yyerror(0)` (zshrs's previous shape).
8190            yyerror(0);
8191            break;
8192        }
8193
8194        // Check for end tokens
8195        if let Some(end_toks) = end_tokens {
8196            if end_toks.contains(&tok()) {
8197                break;
8198            }
8199        }
8200
8201        // Also stop at these tokens when not explicitly looking for them
8202        // Note: Else/Elif/Then are NOT here - they're handled by par_if
8203        // to allow nested if statements inside case arms, loops, etc.
8204        //
8205        // c:Src/parse.c:par_event — when an orphan terminator (DONE
8206        // outside a loop, FI outside an if, ESAC outside a case)
8207        // appears at the top level (end_tokens=None), C errors via
8208        // YYERROR. zshrs's `break` silently accepted `done`/`fi`/
8209        // `esac` as no-op input. Error at the outermost call so
8210        // unscoped terminators don't sneak through; nested calls
8211        // still break cleanly via the end_tokens contains-check
8212        // above.
8213        match tok() {
8214            DONE | FI | ESAC | DOLOOP if end_tokens.is_none() => {
8215                // c:Src/parse.c:par_event — emit the specific token
8216                // name (`done`, `fi`, `esac`, `do`) so error-parsing
8217                // tools can identify the unmatched terminator. C zsh
8218                // writes `parse error near \`<tok>'`; the Rust port
8219                // was emitting a generic "orphan terminator" string.
8220                // Bug #142, #413.
8221                let name = match tok() {
8222                    DONE => "done",
8223                    FI => "fi",
8224                    ESAC => "esac",
8225                    DOLOOP => "do",
8226                    _ => "orphan terminator",
8227                };
8228                zerr(&format!("parse error near `{}'", name));
8229                break;
8230            }
8231            DSEMI | SEMIAMP | SEMIBAR if end_tokens.is_none() => {
8232                // c:Src/parse.c:par_event — case-arm terminators
8233                // (`;;`, `;&`, `;|`) outside a case construct are a
8234                // parse error. zshrs's `break` silently accepted them
8235                // at top level, truncating the rest of the script.
8236                // Bug #141 in docs/BUGS.md.
8237                let name = match tok() {
8238                    DSEMI => ";;",
8239                    SEMIAMP => ";&",
8240                    SEMIBAR => ";|",
8241                    _ => "case terminator",
8242                };
8243                zerr(&format!("parse error near `{}'", name));
8244                break;
8245            }
8246            OUTBRACE_TOK if end_tokens.is_none() => {
8247                // c:Src/parse.c:par_event — orphan `}` (no matching
8248                // `{` opener) at top level is a parse error. zshrs's
8249                // generic break swallowed it silently, leaving the
8250                // `echo a` in `echo a }` running and ignoring the
8251                // stray brace. Bug #168 in docs/BUGS.md.
8252                zerr("parse error near `}'");
8253                break;
8254            }
8255            OUTBRACE_TOK | DSEMI | SEMIAMP | SEMIBAR | DONE | FI | ESAC | ZEND => break,
8256            _ => {}
8257        }
8258
8259        match par_list(single_event) {
8260            Some((list, terminated)) => {
8261                let detected = simple_name_with_inoutpar(&list);
8262                let was_detected = detected.is_some();
8263                lists.push(list);
8264                // Synthesize a FuncDef for the `name() { body }` shape
8265                // at parse time so body_source is captured while the
8266                // lexer still has the input. The lexer port emits
8267                // `name(` as a single Word ending in `<Inpar><Outpar>`,
8268                // so the Simple list is followed by an Inbrace once
8269                // separators are skipped. For `name() cmd args` the
8270                // body has already been swallowed into the same
8271                // Simple's words tail — synthesize directly from there.
8272                if let Some((names, body_argv)) = detected {
8273                    if !body_argv.is_empty() {
8274                        // One-line body already in the Simple. Build
8275                        // a Simple from body_argv as the function body.
8276                        lists.pop();
8277                        let body_simple = ZshCommand::Simple(ZshSimple {
8278                            assigns: Vec::new(),
8279                            words: body_argv,
8280                            redirs: Vec::new(),
8281                        });
8282                        let body_list = ZshList {
8283                            sublist: ZshSublist {
8284                                pipe: ZshPipe {
8285                                    cmd: body_simple,
8286                                    next: None,
8287                                    lineno: lineno(),
8288                                    merge_stderr: false,
8289                                },
8290                                next: None,
8291                                flags: SublistFlags::default(),
8292                            },
8293                            flags: ListFlags::default(),
8294                        };
8295                        let funcdef = ZshCommand::FuncDef(ZshFuncDef {
8296                            names,
8297                            body: Box::new(ZshProgram {
8298                                lists: vec![body_list],
8299                            }),
8300                            tracing: false,
8301                            auto_call_args: None,
8302                            body_source: None,
8303                        });
8304                        let synthetic = ZshList {
8305                            sublist: ZshSublist {
8306                                pipe: ZshPipe {
8307                                    cmd: funcdef,
8308                                    next: None,
8309                                    lineno: lineno(),
8310                                    merge_stderr: false,
8311                                },
8312                                next: None,
8313                                flags: SublistFlags::default(),
8314                            },
8315                            flags: ListFlags::default(),
8316                        };
8317                        lists.push(synthetic);
8318                        continue;
8319                    }
8320                    // Else: words.len() == 1 (only the trailing `name()`
8321                    // word), brace body follows. `names` may carry
8322                    // multiple identifiers from the `fna fnb fnc()`
8323                    // shorthand — all share the same brace body per
8324                    // src/zsh/Src/parse.c:1666 par_funcdef wordlist.
8325                    // Skip separators on the real lexer; safe because
8326                    // parse_program's next iteration would also skip them.
8327                    while tok() == SEPER || tok() == NEWLIN {
8328                        zshlex();
8329                    }
8330                    if tok() == INBRACE_TOK {
8331                        // Capture body_start BEFORE the lexer
8332                        // advances past the first body token. The
8333                        // outer zshlex() consumed `{`; lexer.pos
8334                        // is now right after `{`. The next
8335                        // `zshlex()` would advance past `echo`,
8336                        // making body_start land mid-body and
8337                        // lose the first word — `typeset -f f`
8338                        // printed `a; echo b` instead of
8339                        // `echo a; echo b` for `f() { echo a;
8340                        // echo b }`.
8341                        let body_start = pos();
8342                        zshlex();
8343                        // c:Src/parse.c — synth funcdef body terminates
8344                        // at OUTBRACE_TOK. Explicit end-token avoids
8345                        // the top-level stray-`}` arm. Bug #167/#168.
8346                        let body = parse_program_until(Some(&[OUTBRACE_TOK]), false);
8347                        let body_end = if tok() == OUTBRACE_TOK {
8348                            pos().saturating_sub(1)
8349                        } else {
8350                            pos()
8351                        };
8352                        let body_source = input_slice(body_start, body_end)
8353                            .map(|s| s.trim().to_string())
8354                            .filter(|s| !s.is_empty());
8355                        if tok() == OUTBRACE_TOK {
8356                            zshlex();
8357                        }
8358                        // Replace the Simple list with a FuncDef list.
8359                        lists.pop();
8360                        let funcdef = ZshCommand::FuncDef(ZshFuncDef {
8361                            names,
8362                            body: Box::new(body),
8363                            tracing: false,
8364                            auto_call_args: None,
8365                            body_source,
8366                        });
8367                        let synthetic = ZshList {
8368                            sublist: ZshSublist {
8369                                pipe: ZshPipe {
8370                                    cmd: funcdef,
8371                                    next: None,
8372                                    lineno: lineno(),
8373                                    merge_stderr: false,
8374                                },
8375                                next: None,
8376                                flags: SublistFlags::default(),
8377                            },
8378                            flags: ListFlags::default(),
8379                        };
8380                        lists.push(synthetic);
8381                    } else if !matches!(tok(), ENDINPUT | OUTBRACE_TOK | SEPER | NEWLIN) {
8382                        // No-brace one-line body: `foo() echo hello`.
8383                        // Parse a single command for the body.
8384                        let body_cmd = par_cmd();
8385                        if let Some(cmd) = body_cmd {
8386                            let body_list = ZshList {
8387                                sublist: ZshSublist {
8388                                    pipe: ZshPipe {
8389                                        cmd,
8390                                        next: None,
8391                                        lineno: lineno(),
8392                                        merge_stderr: false,
8393                                    },
8394                                    next: None,
8395                                    flags: SublistFlags::default(),
8396                                },
8397                                flags: ListFlags::default(),
8398                            };
8399                            lists.pop();
8400                            let funcdef = ZshCommand::FuncDef(ZshFuncDef {
8401                                names: names.clone(),
8402                                body: Box::new(ZshProgram {
8403                                    lists: vec![body_list],
8404                                }),
8405                                tracing: false,
8406                                auto_call_args: None,
8407                                body_source: None,
8408                            });
8409                            let synthetic = ZshList {
8410                                sublist: ZshSublist {
8411                                    pipe: ZshPipe {
8412                                        cmd: funcdef,
8413                                        next: None,
8414                                        lineno: lineno(),
8415                                        merge_stderr: false,
8416                                    },
8417                                    next: None,
8418                                    flags: SublistFlags::default(),
8419                                },
8420                                flags: ListFlags::default(),
8421                            };
8422                            lists.push(synthetic);
8423                        }
8424                    }
8425                }
8426                // c:769-803 + c:644-682 — lists only chain across
8427                // explicit SEPER/AMPER/AMPERBANG. When par_list ended
8428                // WITHOUT a terminator, C's grammar says the list is
8429                // over: nested contexts (while-cond, brace bodies)
8430                // return to the caller with the dangling token (so
8431                // `while {false} {print no}` hands the second `{` to
8432                // par_while as the loop body), and the TOP level
8433                // (par_event, c:671-680) yyerrors — `{false} {print
8434                // no}` is `parse error near \`{'` in zsh. The funcdef
8435                // synthesis branch above manages its own following
8436                // tokens (`f() { body }` legitimately has INBRACE
8437                // right after the Simple), so it is exempt.
8438                if !was_detected && !terminated {
8439                    match tok() {
8440                        // End-of-input / lex error: loop top handles.
8441                        ENDINPUT | LEXERR => {}
8442                        // Construct closers and orphan terminators:
8443                        // the loop-top match already errors/breaks
8444                        // for these with the right diagnostics.
8445                        OUTBRACE_TOK | DSEMI | SEMIAMP | SEMIBAR | DONE | FI | ESAC | ZEND
8446                        | DOLOOP | ELSE | ELIF | THEN => {}
8447                        t if end_tokens.map_or(false, |e| e.contains(&t)) => {}
8448                        _ if end_tokens.is_some() => {
8449                            // Nested list context: C par_list just
8450                            // ends; the construct parser (par_while
8451                            // body dispatch, par_subsh closer check)
8452                            // deals with the token.
8453                            break;
8454                        }
8455                        offending => {
8456                            // Top level: C par_event yyerror(1) +
8457                            // errflag, c:671-682. Same tokstr
8458                            // injection as the None arm below so the
8459                            // "near `X'" tail survives punctuation
8460                            // tokens with no tokstr.
8461                            if crate::ported::lex::tokstr().is_none() {
8462                                let i = offending as usize;
8463                                if i < crate::ported::lex::tokstrings.len() {
8464                                    if let Some(s) = crate::ported::lex::tokstrings[i] {
8465                                        crate::ported::lex::set_tokstr(Some(s.to_string()));
8466                                    }
8467                                }
8468                            }
8469                            set_tok(LEXERR); // c:672
8470                            yyerror(1);
8471                            let noerrs_v =
8472                                *crate::ported::utils::noerrs_lock().lock().unwrap();
8473                            if noerrs_v != 2 {
8474                                errflag.fetch_or(
8475                                    crate::ported::zsh_h::ERRFLAG_ERROR,
8476                                    Ordering::SeqCst,
8477                                );
8478                            }
8479                            break;
8480                        }
8481                    }
8482                }
8483            }
8484            None => {
8485                // c:Src/parse.c:644-645 par_event — `if (tok ==
8486                // ENDINPUT) return 0;`. End-of-input is NORMAL — no
8487                // diagnostic. The yyerror path at c:670-682 fires only
8488                // when par_sublist actually failed (the C `!r` branch).
8489                //
8490                // zshrs's previous fix here unconditionally called
8491                // yyerror on every None return, breaking legitimate
8492                // end-of-input scenarios like `(echo sub)` (par_list
8493                // returns None after the subshell parser consumes the
8494                // whole construct, leaving tok at ENDINPUT).
8495                if tok() == ENDINPUT {
8496                    break;
8497                }
8498                // c:Src/parse.c:671-680 par_event — par_sublist failed:
8499                // emit the canonical yyerror with the "near `X'" tail
8500                // derived from zshlextext/tokstr().
8501                //
8502                // c:Src/lex.c:1965 — `zshlextext = tokstrings[tok]`
8503                // is set DURING zshlex, before set_tok(LEXERR) here.
8504                // zshrs's zshlex doesn't update LEX_TOKSTR for
8505                // single-char punctuation tokens (OUTPAR/INPAR/etc.),
8506                // so by the time yyerror runs, tokstr() is None and
8507                // the tail "near `)'" is lost. Inject the current
8508                // tok's canonical text into LEX_TOKSTR here so the
8509                // C-faithful yyerror lookup finds it. Mirrors the
8510                // visible effect of C's zshlextext fallback.
8511                let already_flagged = (errflag.load(Ordering::SeqCst)
8512                    & crate::ported::zsh_h::ERRFLAG_ERROR)
8513                    != 0;
8514                let offending_tok = tok();
8515                if crate::ported::lex::tokstr().is_none() {
8516                    let i = offending_tok as usize;
8517                    if i < crate::ported::lex::tokstrings.len() {
8518                        if let Some(s) = crate::ported::lex::tokstrings[i] {
8519                            crate::ported::lex::set_tokstr(Some(
8520                                s.to_string(),
8521                            ));
8522                        }
8523                    }
8524                }
8525                set_tok(LEXERR); // c:672
8526                yyerror(if already_flagged { 0 } else { 1 });
8527                // c:Src/parse.c:679-680 — `if (noerrs != 2) errflag |=
8528                // ERRFLAG_ERROR;`. C sets errflag explicitly after the
8529                // yyerror(1) print-only branch. Without this, the
8530                // caller (`execute_script_zsh_pipeline` here, `bin_eval`
8531                // for the parse_string path) can't distinguish "no
8532                // parse error" from "parse error already printed", so
8533                // $? stays at 0 after `eval ')foo'`.
8534                let noerrs_v =
8535                    *crate::ported::utils::noerrs_lock().lock().unwrap();
8536                if noerrs_v != 2 {
8537                    errflag.fetch_or(
8538                        crate::ported::zsh_h::ERRFLAG_ERROR,
8539                        Ordering::SeqCst,
8540                    );
8541                }
8542                break;
8543            }
8544        }
8545
8546        // c:Src/parse.c par_event — single-event (top-level, endtok ==
8547        // ENDINPUT) mode stops at the terminating top-level newline. The
8548        // SEPER par_list arm above left `tok` AT the newline-SEPER (not
8549        // consumed, LEX_ISNEWLIN > 0), so break before the loop-top
8550        // separator skip would `zshlex()` past it and read the next
8551        // line. A `;`-SEPER (LEX_ISNEWLIN <= 0) or `&` was consumed by
8552        // par_list and leaves a different token here, so chaining keeps
8553        // looping (same logical line). Multi-line compounds were already
8554        // fully consumed inside par_list, so their internal newlines
8555        // never reach this check.
8556        if single_event
8557            && tok() == SEPER
8558            && crate::ported::lex::LEX_ISNEWLIN.with(|c| c.get()) > 0
8559        {
8560            break;
8561        }
8562    }
8563
8564    ZshProgram { lists }
8565}
8566
8567/// Parse an assignment
8568/// Parse an assignment word `NAME=value` or `NAME=(arr items)`.
8569/// Sub-routine of par_simple. The C source handles assignments
8570/// inline in par_simple via the ENVSTRING/ENVARRAY token paths
8571/// (parse.c:1842-2000ish); zshrs splits it out to a dedicated
8572/// helper for clarity.
8573fn parse_assign() -> Option<ZshAssign> {
8574    // Helper: locate the Equals-marker that delimits NAME from
8575    // VALUE in an assignment-shaped tokstr. The lexer META-encodes
8576    // EVERY `=` (including those inside `${var%%=foo}` strip
8577    // patterns or `[idx]=...` subscripts), so a naive
8578    // `tokstr.find(Equals)` would split at the first inner `=`
8579    // and break the whole assignment. Walk the string skipping
8580    // brace and bracket depth so the assignment's `=` (the one
8581    // after the last `]` of the LHS subscript / or after the
8582    // bare name) is the one we land on.
8583    fn find_assign_equals(s: &str) -> Option<usize> {
8584        let target = Equals;
8585        let mut brace = 0i32;
8586        let mut bracket = 0i32;
8587        let mut paren = 0i32;
8588        for (i, c) in s.char_indices() {
8589            match c {
8590                    '{' | '\u{8f}' /* Inbrace */ => brace += 1,
8591                    '}' | '\u{90}' /* Outbrace */ => {
8592                        if brace > 0 {
8593                            brace -= 1;
8594                        }
8595                    }
8596                    '[' | '\u{91}' /* Inbrack */ => bracket += 1,
8597                    ']' | '\u{92}' /* Outbrack */ => {
8598                        if bracket > 0 {
8599                            bracket -= 1;
8600                        }
8601                    }
8602                    '(' | '\u{88}' /* Inpar */ => paren += 1,
8603                    ')' | '\u{8a}' /* Outpar */ => {
8604                        if paren > 0 {
8605                            paren -= 1;
8606                        }
8607                    }
8608                    _ if c == target && brace == 0 && bracket == 0 && paren == 0 => {
8609                        return Some(i);
8610                    }
8611                    _ => {}
8612                }
8613        }
8614        None
8615    }
8616
8617    let _ts_tokstr = tokstr()?;
8618    let tokstr = _ts_tokstr.as_str();
8619
8620    // Parse name=value or name+=value.
8621    let (name, value_str, append) = if tok() == ENVARRAY {
8622        let (name, append) = if let Some(stripped) = tokstr.strip_suffix('+') {
8623            (stripped, true)
8624        } else {
8625            (tokstr, false)
8626        };
8627        (name.to_string(), String::new(), append)
8628    } else if let Some(pos) = find_assign_equals(tokstr) {
8629        let name_part = &tokstr[..pos];
8630        let (name, append) = if let Some(stripped) = name_part.strip_suffix('+') {
8631            (stripped, true)
8632        } else {
8633            (name_part, false)
8634        };
8635        (
8636            name.to_string(),
8637            tokstr[pos + Equals.len_utf8()..].to_string(),
8638            append,
8639        )
8640    } else if let Some(pos) = tokstr.find('=') {
8641        // Fallback to literal '=' for compatibility
8642        let name_part = &tokstr[..pos];
8643        let (name, append) = if let Some(stripped) = name_part.strip_suffix('+') {
8644            (stripped, true)
8645        } else {
8646            (name_part, false)
8647        };
8648        (name.to_string(), tokstr[pos + 1..].to_string(), append)
8649    } else {
8650        return None;
8651    };
8652
8653    let value = if tok() == ENVARRAY {
8654        // Array assignment: name=(...)
8655        // c:Src/parse.c:1895 par_simple ENVARRAY arm:
8656        //   `int oldcmdpos = incmdpos; ... incmdpos = 0; ... zshlex();`
8657        // Reset incmdpos to false BEFORE the array body's first lex so
8658        // a leading `{...}` (brace expansion) doesn't trip the
8659        // empty-buf+incmdpos rule at lex.c:1141 that returns `{` as
8660        // STRING and lets the reswd_lookup promote it to INBRACE_TOK.
8661        let oldcmdpos = crate::ported::lex::incmdpos();
8662        crate::ported::lex::set_incmdpos(false);
8663        // c:Src/parse.c:2041-2046 — `intypeset = 0;` BEFORE reading
8664        // the array body, restored to the saved value after. Without
8665        // this, an element containing `=` (`typeset -a w=( VAR=1
8666        // sudo )`) re-triggers the lexer's intypeset assignment
8667        // detection and lexes `VAR=1` as ENVSTRING instead of a plain
8668        // STRING element — the body loop ends early on the non-STRING
8669        // token and the parser errors "near `)'".
8670        let old_intypeset = crate::ported::lex::intypeset();
8671        crate::ported::lex::set_intypeset(false);
8672        let mut elements = Vec::new();
8673        zshlex(); // skip past token
8674
8675        let mut arr_iters = 0;
8676        const MAX_ARRAY_ELEMENTS: usize = 10_000;
8677        while matches!(tok(), STRING_LEX | SEPER | NEWLIN) {
8678            arr_iters += 1;
8679            if arr_iters > MAX_ARRAY_ELEMENTS {
8680                zerr("array assignment exceeded maximum elements");
8681                break;
8682            }
8683            if tok() == STRING_LEX {
8684                let _ts_s = crate::ported::lex::tokstr();
8685                if let Some(s) = _ts_s.as_deref() {
8686                    elements.push(s.to_string());
8687                }
8688            }
8689            zshlex();
8690        }
8691        // c:Src/parse.c — `incmdpos = oldcmdpos;` (restore at end of arm)
8692        crate::ported::lex::set_incmdpos(oldcmdpos);
8693        // c:Src/parse.c:2046 — `intypeset = 1;` (restore for any
8694        // follow-on `b=( … )` in `typeset a=(…) b=(…)`).
8695        crate::ported::lex::set_intypeset(old_intypeset);
8696
8697        // The closing Outpar is consumed here. The outer par_simple
8698        // loop will then `zshlex()` past whatever follows (typically
8699        // a separator or the next word) — calling zshlex twice in
8700        // tandem (here AND in par_simple) over-advances and merges
8701        // a following `name() { … }` funcdef into the same Simple.
8702        // We only consume Outpar; let the caller handle the rest.
8703        // Without this guard `g=(o1); f() { :; }` parsed as one
8704        // Simple with assigns=[g] and words=["f()"] (one token).
8705        if tok() == OUTPAR_TOK {
8706            // Note: do NOT zshlex() here. par_simple's `lexer
8707            // .zshlex()` after `parse_assign` returns advances past
8708            // the Outpar onto the next significant token.
8709            //
8710            // Force `incmdpos=true` so the next zshlex() recognizes
8711            // a follow-up `b=(...)` / `b=val` as Envarray/Envstring.
8712            // The lexer flips incmdpos to false on bare Outpar (which
8713            // is correct for subshell-close context), but for an
8714            // array-assignment close more assigns/words may follow.
8715            set_incmdpos(true);
8716        }
8717
8718        ZshAssignValue::Array(elements)
8719    } else {
8720        ZshAssignValue::Scalar(value_str)
8721    };
8722
8723    Some(ZshAssign {
8724        name,
8725        value,
8726        append,
8727    })
8728}
8729
8730/// AST `par_redir` variant accepting an idstring for the
8731/// `{var}>file` brace-FD shape. C signature
8732/// `par_redir(int *rp, char *idstring)` (parse.c:2229). The
8733/// idstring is stored in the resulting ZshRedir.varid for the
8734/// executor to bind the named variable to the chosen fd.
8735fn par_redir_with_id(idstring: Option<&str>) -> Option<ZshRedir> {
8736    let varid: Option<String> = idstring.map(|s| s.to_string());
8737    let rtype = match tok() {
8738        OUTANG_TOK => REDIR_WRITE,
8739        OUTANGBANG => REDIR_WRITENOW,
8740        DOUTANG => REDIR_APP,
8741        DOUTANGBANG => REDIR_APPNOW,
8742        INANG_TOK => REDIR_READ,
8743        INOUTANG => REDIR_READWRITE,
8744        DINANG => REDIR_HEREDOC,
8745        DINANGDASH => REDIR_HEREDOCDASH,
8746        TRINANG => REDIR_HERESTR,
8747        INANGAMP => REDIR_MERGEIN,
8748        OUTANGAMP => REDIR_MERGEOUT,
8749        AMPOUTANG => REDIR_ERRWRITE,
8750        OUTANGAMPBANG => REDIR_ERRWRITENOW,
8751        DOUTANGAMP => REDIR_ERRAPP,
8752        DOUTANGAMPBANG => REDIR_ERRAPPNOW,
8753        _ => return None,
8754    };
8755
8756    let fd = if tokfd() >= 0 {
8757        tokfd()
8758    } else if matches!(
8759        rtype,
8760        REDIR_READ
8761            | REDIR_READWRITE
8762            | REDIR_MERGEIN
8763            | REDIR_HEREDOC
8764            | REDIR_HEREDOCDASH
8765            | REDIR_HERESTR
8766    ) {
8767        0
8768    } else {
8769        1
8770    };
8771
8772    // c:2234-2245 — save/restore incmdpos and nocorrect around the
8773    // zshlex that consumes the redir target word:
8774    //   oldcmdpos = incmdpos; incmdpos = 0;
8775    //   oldnc = nocorrect;
8776    //   if (tok != INANG && tok != INOUTANG) nocorrect = 1;
8777    //   ... zshlex; check tok; ...
8778    //   incmdpos = oldcmdpos; nocorrect = oldnc;
8779    // Without this, a redir target lexes in the parent's incmdpos
8780    // (re-promoting `{` / reswords) AND with parent nocorrect (so
8781    // spelling-correction wrongly runs inside `> $(cmd)` etc.).
8782    let oldcmdpos = incmdpos();
8783    set_incmdpos(false);
8784    let oldnc = nocorrect();
8785    let cur = tok();
8786    if cur != INANG_TOK && cur != INOUTANG {
8787        set_nocorrect(1);
8788    }
8789    zshlex();
8790
8791    let name = match tok() {
8792        STRING_LEX | ENVSTRING => {
8793            let n = tokstr().unwrap_or_default();
8794            // c:2244-2245 — restore incmdpos / nocorrect right after
8795            // the redir target word is confirmed, BEFORE the trailing
8796            // zshlex advances past it. The advance itself is deferred
8797            // below so REDIR_HEREDOC[DASH] can push onto HDOCS first
8798            // (matching the wordcode variant at parse.rs:6894-6908) —
8799            // otherwise the NEWLIN drained by that zshlex sees an
8800            // empty HDOCS list and gethere never collects the body.
8801            set_incmdpos(oldcmdpos);
8802            set_nocorrect(oldnc);
8803            n
8804        }
8805        _ => {
8806            set_incmdpos(oldcmdpos);
8807            set_nocorrect(oldnc);
8808            zerr("expected word after redirection");
8809            return None;
8810        }
8811    };
8812
8813    // Heredoc terminator capture. C parse.c:2254-2317 par_redir builds
8814    // a `struct heredocs` entry here for REDIR_HEREDOC[DASH]. zshrs
8815    // pushes onto HDOCS (canonical C linked list, c:2290-2296) AND
8816    // onto LEX_HEREDOCS (Rust-only AST-glue Vec carrying parsed-out
8817    // terminator/strip_tabs/quoted metadata for downstream AST
8818    // consumers). Quoted terminators (`<<'EOF'` / `<<"EOF"` / `<<\EOF`)
8819    // disable expansion in the body — Snull `\u{9d}` marks single-quote,
8820    // Dnull `\u{9e}` marks double-quote, Bnull `\u{9f}` marks
8821    // backslash-escaped chars.
8822    let heredoc_idx = if matches!(rtype, REDIR_HEREDOC | REDIR_HEREDOCDASH) {
8823        let strip_tabs = rtype == REDIR_HEREDOCDASH;
8824        let quoted = name.contains('\u{9d}')
8825            || name.contains('\u{9e}')
8826            || name.contains('\u{9f}')
8827            || name.starts_with('\'')
8828            || name.starts_with('"');
8829        let term = name
8830            .chars()
8831            .filter(|c| {
8832                *c != '\'' && *c != '"' && *c != '\u{9d}' && *c != '\u{9e}' && *c != '\u{9f}'
8833            })
8834            .collect::<String>();
8835        // c:2290-2296 — `for (hd = &hdocs; *hd; hd = &(*hd)->next);
8836        //                 *hd = zalloc(sizeof(struct heredocs));
8837        //                 (*hd)->next = NULL;
8838        //                 (*hd)->type = htype;
8839        //                 (*hd)->pc = r;
8840        //                 (*hd)->str = tokstr;`
8841        // AST path has no wordcode pc to patch; use -1 sentinel so the
8842        // inline NEWLIN walk in `zshlex()` skips the setheredoc call.
8843        HDOCS.with_borrow_mut(|head| {
8844            let mut cur = head;
8845            while cur.is_some() {
8846                cur = &mut cur.as_mut().unwrap().next; // c:2290
8847            }
8848            *cur = Some(Box::new(crate::ported::zsh_h::heredocs {
8849                // c:2292-2296
8850                next: None,
8851                typ: rtype,
8852                pc: -1,
8853                str: Some(name.clone()),
8854            }));
8855        });
8856        // zshrs-only: push parallel AST-glue entry onto LEX_HEREDOCS.
8857        let idx = LEX_HEREDOCS.with_borrow_mut(|v| {
8858            v.push(HereDoc {
8859                terminator: term,
8860                strip_tabs,
8861                content: String::new(),
8862                quoted,
8863                processed: false,
8864            });
8865            v.len() - 1
8866        });
8867        Some(idx)
8868    } else {
8869        None
8870    };
8871
8872    // c:2298 (heredoc) / c:2322 (other redirs) — final zshlex() advance
8873    // past the redir target word. MUST run after the HDOCS push above
8874    // so the heredoc-drain inside this zshlex sees the new entry. For
8875    // non-heredoc forms the order is irrelevant; consolidating to a
8876    // single tail-call here matches the wordcode variant.
8877    zshlex();
8878
8879    Some(ZshRedir {
8880        rtype,
8881        fd,
8882        name,
8883        heredoc: None,
8884        varid,
8885        heredoc_idx,
8886    })
8887}
8888
8889/// Parse C-style for loop: for (( init; cond; step ))
8890/// Parse the c-style `for ((init; cond; incr)) do BODY done`.
8891/// Inner branch of zsh/Src/parse.c:1100-1140 inside par_for.
8892/// Recognized when the token after FOR is DINPAR (the `((`
8893/// detected by gettok via dbparens setup).
8894fn parse_for_cstyle() -> Option<ZshCommand> {
8895    // We're at (( (Dinpar None) - the opening ((
8896    // Lexer returns:
8897    //   Dinpar None     - opening ((
8898    //   Dinpar "init"   - init expression, semicolon consumed
8899    //   Dinpar "cond"   - cond expression, semicolon consumed
8900    //   Doutpar "step"  - step expression, closing )) consumed
8901    zshlex(); // Get init: Dinpar "i=0"
8902
8903    if tok() != DINPAR {
8904        zerr("expected init expression in for ((");
8905        return None;
8906    }
8907    let init = tokstr().unwrap_or_default();
8908
8909    zshlex(); // Get cond: Dinpar "i<10"
8910
8911    if tok() != DINPAR {
8912        zerr("expected condition in for ((");
8913        return None;
8914    }
8915    let cond = tokstr().unwrap_or_default();
8916
8917    zshlex(); // Get step: Doutpar "i++"
8918
8919    if tok() != DOUTPAR {
8920        zerr("expected )) in for");
8921        return None;
8922    }
8923    let step = tokstr().unwrap_or_default();
8924
8925    // c:1110 — `infor = 0;` before the body opener. The companion
8926    // `incmdpos = 1;` at c:1111 is intentionally skipped here for
8927    // the same reason c:1094's `incmdpos = 0;` is skipped in
8928    // par_for above — zshrs doesn't mirror the full
8929    // incmdpos state-machine inline.
8930    set_infor(0); // c:1110
8931    zshlex(); // Move past ))
8932
8933    skip_separators();
8934    let body = parse_loop_body(false, false)?;
8935
8936    Some(ZshCommand::For(ZshFor {
8937        var: String::new(),
8938        list: ForList::CStyle { init, cond, step },
8939        body: Box::new(body),
8940        is_select: false,
8941    }))
8942}
8943
8944/// Parse select loop (same syntax as for)
8945/// Parse `select NAME in WORDS; do BODY; done`. Same shape as
8946/// `for NAME in WORDS; do ...` but with menu-prompt semantics in
8947/// the executor. C equivalent: the SELECT case in par_for at
8948/// parse.c:1087-1207 (selects share parser flow with foreach).
8949fn parse_select() -> Option<ZshCommand> {
8950    // `select` shares par_for's grammar (var, words, body) but the
8951    // compile path is different (interactive prompt loop).
8952    match par_for()? {
8953        ZshCommand::For(mut f) => {
8954            f.is_select = true;
8955            Some(ZshCommand::For(f))
8956        }
8957        other => Some(other),
8958    }
8959}
8960
8961/// Parse loop body (do...done, {...}, or shortloop)
8962/// Parse the `do BODY done` body of a for/while/until/select/
8963/// repeat loop. Direct equivalent of zsh's parse.c handling
8964/// inside the loop builders — they all consume DOLOOP, parse a
8965/// list until DONE, and return the list. The `foreach_style`
8966/// flag signals foreach (where short-form `for NAME in WORDS;
8967/// CMD` may skip do/done) vs c-style (which always requires
8968/// do/done).
8969///
8970/// `is_repeat` widens the SHORTLOOPS gate so `SHORTREPEAT` also
8971/// unlocks the short form for `repeat N CMD` (per c:1600
8972/// `unset(SHORTLOOPS) && unset(SHORTREPEAT)`).
8973fn parse_loop_body(foreach_style: bool, is_repeat: bool) -> Option<ZshProgram> {
8974    // c:1180-1194 — body dispatch order per par_for:
8975    //   `do ... done` (DOLOOP) — primary form.
8976    //   `{ ... }`   (INBRACE) — alternate.
8977    //   csh/CSHJUNKIELOOPS — terminator is `end`.
8978    //   else if (unset(SHORTLOOPS)) — YYERROR.
8979    //   else — short form (single command).
8980    if tok() == DOLOOP {
8981        zshlex();
8982        // Body parse must declare DONE as an end-token so the
8983        // parse_program_until top-level orphan-DONE guard doesn't
8984        // mis-fire on the legitimate loop terminator.
8985        let body = parse_program_until(Some(&[DONE]), false);
8986        // c:Src/parse.c:1182-1183 / :1535-1536 / :1597-1598 —
8987        // `if (tok != DONE) YYERRORV(oecused);`. zshrs previously
8988        // silently accepted EOF as a substitute for `done`, so
8989        // `for i in a; do echo hi; don` ran the loop with `don` as
8990        // a command (which then failed "command not found") instead
8991        // of erroring at parse time. Bug #403, #404.
8992        if tok() != DONE {
8993            zerr("parse error: expected `done'");
8994            return None;
8995        }
8996        zshlex();
8997        Some(body)
8998    } else if tok() == INBRACE_TOK {
8999        zshlex();
9000        let body = parse_program_until(Some(&[OUTBRACE_TOK]), false);
9001        // c:Src/parse.c:1186 / :1539 — `if (tok != OUTBRACE) YYERRORV`.
9002        if tok() != OUTBRACE_TOK {
9003            zerr("parse error: expected `}'");
9004            return None;
9005        }
9006        zshlex();
9007        Some(body)
9008    } else if foreach_style || isset(CSHJUNKIELOOPS) {
9009        // c:1184 / 1546 / 1595 — `else if (csh || isset(CSHJUNKIELOOPS))`.
9010        let body = parse_program_until(Some(&[ZEND]), false);
9011        // c:1190 / 1548 — `if (tok != ZEND) YYERRORV`.
9012        if tok() != ZEND {
9013            zerr("parse error: expected `end'");
9014            return None;
9015        }
9016        zshlex();
9017        Some(body)
9018    } else {
9019        // c:1190 / 1474 / 1551 / 1600 — short-form gate. C bails
9020        // with YYERROR when `unset(SHORTLOOPS) && (!is_repeat ||
9021        // unset(SHORTREPEAT))`. zshrs's option machinery isn't
9022        // initialised at parse-test time (no `init_main` →
9023        // `install_emulation_defaults`), so a strict port here
9024        // body. parse_init seeds SHORTLOOPS=on mirroring C
9025        // `install_emulation_defaults`, so this fires only when a
9026        // script explicitly disabled the option.
9027        if unset(SHORTLOOPS) && (!is_repeat || unset(SHORTREPEAT)) {
9028            zerr("parse error: short loop form requires SHORTLOOPS option");
9029            return None;
9030        }
9031        // c:Src/parse.c:1604 / :1474 / :1551 — short form calls
9032        // par_save_list1 → par_list1 → par_sublist, which parses
9033        // ONE sublist and leaves the trailing SEPER untouched for
9034        // the outer par_list to consume. zshrs previously routed
9035        // through par_list() which consumes the trailing `;`/`\n`
9036        // separator — that swallowed the separator between the
9037        // loop's body command and the next outer command, so
9038        // `repeat 2 print x; print y` parsed as repeat-then-eof
9039        // and par_cmd's post-compound STRING_LEX guard at parse.rs
9040        // line 1170 fired "parse error near `print'". Bug #593.
9041        par_list1().map(|sublist| ZshProgram {
9042            lists: vec![ZshList {
9043                sublist,
9044                flags: ListFlags::default(),
9045            }],
9046        })
9047    }
9048}
9049
9050/// `() { body } arg1 arg2 …` — anonymous function. Defines a fresh
9051/// function named `_zshrs_anon_N`, invokes it with the args, and the
9052/// body runs with positional params set. Implemented as the desugared
9053/// pair (FuncDef + Simple call) so the compile path doesn't need new
9054/// machinery.
9055/// Parse an anonymous function definition `() { BODY }` followed
9056/// by call args. zsh treats `() { echo hi; } a b c` as defining
9057/// and immediately calling an anon fn with args a/b/c. C
9058/// equivalent: the INOUTPAR shape in par_simple at parse.c:1836+
9059/// triggers an anon-funcdef path.
9060fn parse_anon_funcdef() -> Option<ZshCommand> {
9061    zshlex(); // skip ()
9062    skip_separators();
9063    // No `{` after `()` → bare empty subshell shape `()`. Fall back
9064    // to a Subsh with an empty program so the status is 0 (matches
9065    // zsh's `()` no-op behavior).
9066    if tok() != INBRACE_TOK {
9067        return Some(ZshCommand::Subsh(Box::new(ZshProgram {
9068            lists: Vec::new(),
9069        })));
9070    }
9071    zshlex(); // skip {
9072    // c:Src/parse.c:par_subsh — anon `() { … }` body must terminate at
9073    // OUTBRACE_TOK. Pass it as the explicit end-token so the inner
9074    // parse stops cleanly at `}` rather than hitting the top-level
9075    // stray-`}` arm (#168). Bug #167 family.
9076    let body = parse_program_until(Some(&[OUTBRACE_TOK]), false);
9077    // c:Src/parse.c:1733-1737 — same `if (tok != OUTBRACE) YYERRORV`
9078    // gate as the named-funcdef path. Bug #405 sibling.
9079    if tok() != OUTBRACE_TOK {
9080        zerr("parse error: expected `}'");
9081        return None;
9082    }
9083    zshlex();
9084    // Collect trailing args AND redirections, interleaved in any order,
9085    // until a separator. zsh's anon-fn form `() { body } a b c` runs
9086    // body with $1=a, $2=b, $3=c; redirs apply to that invocation:
9087    // `() { read v; print $1 $v } <input1 Shirley >output1 dude`
9088    // (c:Src/parse.c par_simple — the anon function is a simple command
9089    // whose words and redirs follow the body in any order). The previous
9090    // port collected ONLY leading STRING args here and left redirs to
9091    // par_cmd's trailing loop, so an arg AFTER a redir (`<in arg`) was
9092    // never reached and hit par_cmd's "parse error near `<arg>'" gate.
9093    let mut args = Vec::new();
9094    let mut redirs: Vec<ZshRedir> = Vec::new();
9095    loop {
9096        let t = tok();
9097        if t == STRING_LEX {
9098            if let Some(s) = tokstr() {
9099                args.push(s);
9100            }
9101            zshlex();
9102        } else if IS_REDIROP(t) {
9103            match par_redir() {
9104                Some(r) => redirs.push(r),
9105                None => break,
9106            }
9107        } else {
9108            break;
9109        }
9110    }
9111
9112    // Generate a unique name. Module-level static would be cleaner but
9113    // a thread-local atomic is enough — anonymous functions are
9114    // ephemeral and the name isn't user-visible.
9115    static ANON_COUNTER: AtomicUsize = AtomicUsize::new(0);
9116    let n = ANON_COUNTER.fetch_add(1, Ordering::Relaxed);
9117    let name = format!("_zshrs_anon_{}", n);
9118    let funcdef = ZshCommand::FuncDef(ZshFuncDef {
9119        names: vec![name],
9120        body: Box::new(body),
9121        tracing: false,
9122        auto_call_args: Some(args),
9123        body_source: None,
9124    });
9125    // Wrap in Redirected so the redirs bracket the single invocation
9126    // (compile_zsh.rs:929 wraps the body in a WithRedirectsBegin/End
9127    // scope for Redirected(FuncDef, …)). par_cmd's trailing-redir loop
9128    // then finds none and returns this node unwrapped.
9129    if redirs.is_empty() {
9130        Some(funcdef)
9131    } else {
9132        Some(ZshCommand::Redirected(Box::new(funcdef), redirs))
9133    }
9134}
9135
9136/// Parse {...} cursh
9137/// Parse a current-shell brace block `{ BODY }`. C source
9138/// par_cmd at parse.c:958-1085 handles Inbrace → emit WC_CURSH
9139/// and recurses into the list. zshrs's parse_cursh extracts that
9140/// arm into a dedicated method.
9141fn parse_cursh() -> Option<ZshCommand> {
9142    zshlex(); // skip {
9143    // c:Src/parse.c:par_subsh — pass OUTBRACE_TOK as the explicit
9144    // body terminator so the inner parse stops cleanly at `}` rather
9145    // than falling through the top-level `OUTBRACE_TOK if
9146    // end_tokens.is_none()` arm (which errors on stray `}` per bug
9147    // #168). Bug #167 in docs/BUGS.md.
9148    let prog = parse_program_until(Some(&[OUTBRACE_TOK]), false);
9149
9150    // c:Src/parse.c:par_subsh — `{ … }` requires a matching `}`.
9151    // C errors via YYERRORV when the body parse returns without
9152    // seeing OUTBRACE_TOK (parse.c:1623 inbrack check). zshrs's
9153    // previous behavior silently returned `Cursh(prog)` and ran the
9154    // body as if the braces were absent. Bug #167 in docs/BUGS.md.
9155    if tok() != OUTBRACE_TOK {
9156        // Reuse the "parse error near `<tok>'" shape from #142/#161.
9157        // The offending token is whatever follows the unclosed brace
9158        // body. For EOF (`{ echo a` at end of input) C zsh errors
9159        // near the LAST consumed body token; we use the current
9160        // tokstr() or fall back to a "}" hint.
9161        let near = tokstr().unwrap_or_else(|| "}".to_string());
9162        zerr(&format!("parse error near `{}'", near));
9163        return None;
9164    }
9165    // Check for { ... } always { ... }. Direct port of zsh's
9166    // par_subsh at parse.c:1612-1660 — note the two `incmdpos = 1`
9167    // forces (parse.c:1632, 1637): after consuming the closing
9168    // Outbrace AND after matching the `always` keyword, the parser
9169    // explicitly resets command position so the next `{` lexes as
9170    // Inbrace. Without these resets the lexer's String-clears-cmdpos
9171    // rule (lex.rs:976-983) leaves the second `{` in word position,
9172    // turning `always { ... }` into a Simple `{` `echo` … and the
9173    // try/always pairing is silently lost.
9174    {
9175        set_incmdpos(true); // parse.c:1632 incmdpos = !zsh_construct
9176        zshlex();
9177
9178        // Check for 'always'
9179        if tok() == STRING_LEX {
9180            let s = tokstr();
9181            if s.map(|s| s == "always").unwrap_or(false) {
9182                set_incmdpos(true); // parse.c:1637 incmdpos = 1
9183                zshlex();
9184                skip_separators();
9185
9186                if tok() == INBRACE_TOK {
9187                    zshlex();
9188                    // c:Src/parse.c — always-clause body terminates at
9189                    // OUTBRACE_TOK. Bug #167/#168 family.
9190                    let always = parse_program_until(Some(&[OUTBRACE_TOK]), false);
9191                    if tok() == OUTBRACE_TOK {
9192                        zshlex();
9193                    }
9194                    return Some(ZshCommand::Try(ZshTry {
9195                        try_block: Box::new(prog),
9196                        always: Box::new(always),
9197                    }));
9198                }
9199            }
9200        }
9201    }
9202
9203    Some(ZshCommand::Cursh(Box::new(prog)))
9204}
9205
9206/// Parse inline function definition: name() { ... }
9207/// Parse the inline form `NAME () { BODY }` (POSIX-style funcdef
9208/// without the `function` keyword). The name has already been
9209/// consumed and pushed by par_simple before this method fires.
9210/// C source: handled inline in par_simple's INOUTPAR-after-name
9211/// arm (parse.c:1836-2228).
9212fn parse_inline_funcdef(names: Vec<String>) -> Option<ZshCommand> {
9213    // par_simple's STRING loop left `incmdpos = 0`; the funcdef body
9214    // `{ ... }` requires `incmdpos = 1` so the lexer recognises `{`
9215    // as INBRACE_TOK (current-shell block opener) instead of a
9216    // literal `{` STRING. Without this, `myfunc() { echo body }`
9217    // parsed the body as the single STRING `"{"`, then `echo body`
9218    // fell out at top level. Mirrors the C path where par_cmd's
9219    // dispatcher (parse.c:958) is called with `incmdpos = 1` for
9220    // the funcdef body.
9221    set_incmdpos(true);
9222    // Skip ()
9223    if tok() == INOUTPAR {
9224        zshlex();
9225    }
9226
9227    skip_separators();
9228
9229    // Parse body
9230    if tok() == INBRACE_TOK {
9231        // Same body_start-before-zshlex fix as par_funcdef.
9232        let body_start = pos();
9233        zshlex();
9234        // c:Src/parse.c — inline funcdef body terminates at OUTBRACE_TOK.
9235        // Explicit end-token keeps the inner parse from hitting the
9236        // top-level stray-`}` arm (#168). Bug #167 family.
9237        let body = parse_program_until(Some(&[OUTBRACE_TOK]), false);
9238        // c:Src/parse.c:1733-1737 — `if (tok != OUTBRACE) { cmdpop();
9239        // lineno += oldlineno; ecnpats = onp; ecssub = oecssub;
9240        // YYERRORV(oecused); }`. Without this gate, `f() { echo hi`
9241        // silently registered as a complete fn with body `echo hi`.
9242        // Bug #405.
9243        if tok() != OUTBRACE_TOK {
9244            zerr("parse error: expected `}'");
9245            return None;
9246        }
9247        let body_end = pos().saturating_sub(1);
9248        let body_source = input_slice(body_start, body_end)
9249            .map(|s| {
9250                // Lexer's pos() may have advanced past `}` AND skipped
9251                // trailing whitespace/newlines before returning the
9252                // OUTBRACE_TOK to us, so the slice up to `pos - 1`
9253                // includes the `}` and any preceding whitespace.
9254                // Strip the trailing `}` and any preceding structural
9255                // separator (`;`, `\n`) — C zsh's getpermtext walks
9256                // the wordcode list and emits each command WITHOUT
9257                // the trailing `;`/`\n` that lives in the input.
9258                let t = s.trim();
9259                let t = t.strip_suffix('}').unwrap_or(t).trim_end();
9260                let t = t
9261                    .trim_end_matches(|c: char| c == ';' || c == '\n')
9262                    .trim_end();
9263                t.to_string()
9264            })
9265            .filter(|s| !s.is_empty());
9266        zshlex();
9267        Some(ZshCommand::FuncDef(ZshFuncDef {
9268            names,
9269            body: Box::new(body),
9270            tracing: false,
9271            auto_call_args: None,
9272            body_source,
9273        }))
9274    } else if unset(SHORTLOOPS) {
9275        // c:1742 — `else if (unset(SHORTLOOPS)) YYERRORV(oecused);` —
9276        // funcdef short body (`name() cmd` without `{...}`) only
9277        // accepted when SHORTLOOPS is set. parse_init seeds
9278        // SHORTLOOPS=on so this fires only when a script
9279        // explicitly disabled the option.
9280        zerr("parse error: short function body form requires SHORTLOOPS option");
9281        None
9282    } else {
9283        match par_cmd() {
9284            Some(cmd) => {
9285                let list = ZshList {
9286                    sublist: ZshSublist {
9287                        pipe: ZshPipe {
9288                            cmd,
9289                            next: None,
9290                            lineno: lineno(),
9291                            merge_stderr: false,
9292                        },
9293                        next: None,
9294                        flags: SublistFlags::default(),
9295                    },
9296                    flags: ListFlags::default(),
9297                };
9298                Some(ZshCommand::FuncDef(ZshFuncDef {
9299                    names,
9300                    body: Box::new(ZshProgram { lists: vec![list] }),
9301                    tracing: false,
9302                    auto_call_args: None,
9303                    body_source: None,
9304                }))
9305            }
9306            None => None,
9307        }
9308    }
9309}
9310
9311/// Parse conditional expression
9312/// Top of `[[ ]]` cond-expression parsing — entry to recursive
9313/// descent (or → and → not → primary). Direct port of zsh's
9314/// par_cond_1 at parse.c:2434-2475.
9315fn parse_cond_expr() -> Option<ZshCond> {
9316    parse_cond_or()
9317}
9318
9319/// Cond-expression `||` level. C: inside par_cond_1 at
9320/// parse.c:2434-2475 (the `cond_or` ladder).
9321fn parse_cond_or() -> Option<ZshCond> {
9322    let left = parse_cond_and()?;
9323    skip_cond_separators();
9324
9325    if tok() == DBAR {
9326        zshlex();
9327        skip_cond_separators();
9328        parse_cond_or().map(|right| ZshCond::Or(Box::new(left), Box::new(right)))
9329    } else {
9330        Some(left)
9331    }
9332}
9333
9334/// Cond-expression `&&` level. C: par_cond_2 at parse.c:2476-2625.
9335fn parse_cond_and() -> Option<ZshCond> {
9336    let left = parse_cond_not()?;
9337    skip_cond_separators();
9338
9339    if tok() == DAMPER {
9340        zshlex();
9341        skip_cond_separators();
9342        parse_cond_and().map(|right| ZshCond::And(Box::new(left), Box::new(right)))
9343    } else {
9344        Some(left)
9345    }
9346}
9347
9348/// `static FuncDump dumps;` from `Src/parse.c:3652` — head of the
9349/// loaded-`.zwc` linked list. C walks `dumps`/`p->next` directly;
9350/// the Rust port uses a `Mutex<Vec<funcdump>>` indexed by filename
9351/// so refcount ops can find an entry without raw-pointer compare.
9352pub static DUMPS: std::sync::Mutex<Vec<funcdump>> = std::sync::Mutex::new(Vec::new());
9353
9354/// Cond-expression `!` negation level. C: handled inside
9355/// par_cond_2 at parse.c:2476-2625 via the Bang token check.
9356fn parse_cond_not() -> Option<ZshCond> {
9357    skip_cond_separators();
9358
9359    // ! can be either BANG_TOK or String "!"
9360    let is_not =
9361        tok() == BANG_TOK || (tok() == STRING_LEX && tokstr().map(|s| s == "!").unwrap_or(false));
9362    if is_not {
9363        zshlex();
9364        let inner = parse_cond_not()?;
9365        return Some(ZshCond::Not(Box::new(inner)));
9366    }
9367
9368    if tok() == INPAR_TOK {
9369        zshlex();
9370        skip_cond_separators();
9371        // c:Src/parse.c:2534-2547 par_cond_2 INPAR branch — empty
9372        // body `[[ ( ) ]]` makes the inner par_cond's recursive
9373        // par_cond_2 see OUTPAR with no leading STRING/BANG/INPAR
9374        // and YYERROR immediately. Mirror that here: if the very
9375        // next token after `(` (post separator skip) is `)`, emit
9376        // a parse error so the script aborts cleanly instead of
9377        // silently swallowing every following command. Bug #538.
9378        if tok() == OUTPAR_TOK {
9379            crate::ported::utils::zerr("condition expected");
9380            yyerror(0);
9381            return None;
9382        }
9383        let inner = parse_cond_expr()?;
9384        skip_cond_separators();
9385        if tok() == OUTPAR_TOK {
9386            zshlex();
9387        }
9388        return Some(inner);
9389    }
9390
9391    parse_cond_primary()
9392}
9393
9394/// Cond-expression primary: unary tests (-f, -d, ...), binary
9395/// tests (=, !=, <, >, ==, =~, -eq, -ne, ...), and parenthesized
9396/// sub-expressions. Direct port of par_cond_double / par_cond_triple
9397/// / par_cond_multi at parse.c:2626-2731 (chosen by arg count).
9398fn parse_cond_primary() -> Option<ZshCond> {
9399    let s1 = match tok() {
9400        STRING_LEX => {
9401            let s = tokstr().unwrap_or_default();
9402            zshlex();
9403            s
9404        }
9405        _ => return None,
9406    };
9407
9408    skip_cond_separators();
9409
9410    // Check for unary operator. zsh's lexer tokenizes leading `-` as
9411    // `zsh_h::Dash` (`\u{9b}`, `Src/zsh.h:182`) inside gettokstr (lex.c:1390-1400
9412    // LX2_DASH — `-` always becomes Dash, untokenized later). Match
9413    // either form here, and use char-count not byte-count since Dash
9414    // is 2 UTF-8 bytes (`\xc2\x9b`).
9415    //
9416    // c:Src/parse.c par_cond — when the leading token is `-` followed
9417    // ENTIRELY by digits (`-5`, `-123`), it's a numeric literal
9418    // operand, not a unary test flag. zsh's parser checks the C
9419    // `isdigit` of the trailing chars to disambiguate; without the
9420    // check, `[[ -5 -lt -3 ]]` reads `-5` as a one-arg test flag,
9421    // then `-lt` as the operand, then `-3` as a leftover token —
9422    // emitting "unknown condition: -5" and falling through to a
9423    // command-not-found dispatch on `-3`. Bug #121 in docs/BUGS.md.
9424    let s1_chars: Vec<char> = s1.chars().collect();
9425    let is_negative_number = s1_chars.len() >= 2
9426        && IS_DASH(s1_chars[0])
9427        && s1_chars[1..].iter().all(|c| c.is_ascii_digit());
9428    if s1_chars.len() == 2 && IS_DASH(s1_chars[0]) && !is_negative_number {
9429        let s2 = match tok() {
9430            STRING_LEX => {
9431                let s = tokstr().unwrap_or_default();
9432                zshlex();
9433                s
9434            }
9435            _ => {
9436                // c:Src/parse.c par_cond_2 — when the leading `-X`
9437                // is a 2-char dash form, zsh ALWAYS treats it as a
9438                // unary test op (the operand-missing case errors
9439                // immediately with `unknown condition: -X`). Don't
9440                // fall back to `Unary("-n", "-X")` — that path
9441                // silently let `[[ -z ]]` evaluate as
9442                // `[[ -n "-z" ]]` → true. Bug #480/#481.
9443                //
9444                // Convert Dash (\u{9b}) back to ASCII `-` for the
9445                // user-visible diagnostic so it reads "unknown
9446                // condition: -z" not "unknown condition: <Dash>z".
9447                let display: String = s1.chars().map(|c| {
9448                    if IS_DASH(c) { '-' } else { c }
9449                }).collect();
9450                crate::ported::utils::zerr(&format!(
9451                    "unknown condition: {}",
9452                    display
9453                ));
9454                return None;
9455            }
9456        };
9457        return Some(ZshCond::Unary(s1, s2));
9458    }
9459
9460    // Check for binary operator. Direct port of zsh/Src/parse.c:2601-2603:
9461    //   incond++;  /* parentheses do globbing */
9462    //   do condlex(); while (COND_SEP());
9463    //   incond--;  /* parentheses do grouping */
9464    // The bump makes the lexer treat `(` as a literal character inside
9465    // the RHS word (e.g. `[[ x =~ (foo) ]]`) instead of returning Inpar
9466    // and splitting the regex into multiple tokens.
9467    let op = match tok() {
9468        STRING_LEX => {
9469            let s = tokstr().unwrap_or_default();
9470            set_incond(incond() + 1);
9471            zshlex();
9472            set_incond(incond() - 1);
9473            s
9474        }
9475        INANG_TOK => {
9476            set_incond(incond() + 1);
9477            zshlex();
9478            set_incond(incond() - 1);
9479            "<".to_string()
9480        }
9481        OUTANG_TOK => {
9482            set_incond(incond() + 1);
9483            zshlex();
9484            set_incond(incond() - 1);
9485            ">".to_string()
9486        }
9487        _ => return Some(ZshCond::Unary("-n".to_string(), s1)),
9488    };
9489
9490    skip_cond_separators();
9491
9492    // c:Src/parse.c:2601-2625 par_cond_2 — only the documented binary
9493    // operators are accepted inside `[[ ... ]]`. zsh rejects ksh/bash
9494    // forms `-a` (logical AND) and `-o` (logical OR) with a parse
9495    // error ("condition expected") because they're not in the
9496    // par_cond_2 binary-op set — zsh uses `&&` / `||` instead.
9497    // Verified: `zsh -fc '[[ "" -a "x" ]]'` → exit 1, "parse error:
9498    // condition expected: ...". Without this gate, zshrs silently
9499    // built ZshCond::Binary("", "-a", "x") and ran an unknown-op
9500    // path that always evaluated false.
9501    // c:Src/parse.c:2601-2625 par_cond_2 — `-a` / `-o` n-ary chain
9502    // operators are not valid binary operators inside `[[ ... ]]`
9503    // (zsh uses `&&` / `||` instead). Match both the ASCII `-a`/
9504    // `-o` form and the tokenized `Dash+a`/`Dash+o` form that the
9505    // lexer emits inside cond bodies (Dash = \u{9b}, Src/zsh.h:182).
9506    let op_chars: Vec<char> = op.chars().collect();
9507    let is_dash_a_or_o =
9508        op_chars.len() == 2 && IS_DASH(op_chars[0]) && (op_chars[1] == 'a' || op_chars[1] == 'o');
9509    if is_dash_a_or_o {
9510        crate::ported::utils::zerr(&format!("parse error: condition expected: {}", s1));
9511        crate::ported::utils::errflag.fetch_or(
9512            crate::ported::zsh_h::ERRFLAG_ERROR,
9513            std::sync::atomic::Ordering::Relaxed,
9514        );
9515        set_tok(LEXERR);
9516        return None;
9517    }
9518
9519    let s2 = match tok() {
9520        STRING_LEX => {
9521            let s = tokstr().unwrap_or_default();
9522            zshlex();
9523            s
9524        }
9525        _ => {
9526            // c:Src/parse.c par_cond_2 — when a binary op is
9527            // recognized but the RHS operand is missing, zsh emits
9528            // `parse error: condition expected: <LHS>` at par_cond_2's
9529            // missing-rhs branch. zshrs's previous fallback returned
9530            // `Binary(s1, op, "")` which silently evaluated as if the
9531            // RHS were empty string → rc=1. Bug #482.
9532            //
9533            // Convert Dash (\u{9b}) back to ASCII `-` in the LHS
9534            // display so the diagnostic reads cleanly.
9535            let display: String = s1.chars().map(|c| {
9536                if IS_DASH(c) { '-' } else { c }
9537            }).collect();
9538            crate::ported::utils::zerr(&format!(
9539                "parse error: condition expected: {}",
9540                display
9541            ));
9542            crate::ported::utils::errflag.fetch_or(
9543                crate::ported::zsh_h::ERRFLAG_ERROR,
9544                std::sync::atomic::Ordering::Relaxed,
9545            );
9546            set_tok(LEXERR);
9547            return None;
9548        }
9549    };
9550
9551    // c:Src/parse.c:2685-2691 par_cond_triple —
9552    //   `(b[0] == Equals || b[0] == '=') && (b[1] == '~' || b[1] == Tilde)
9553    //    && !b[2]` → COND_REGEX.
9554    // The lexer emits the TOKEN forms inside cond bodies (`=` at word
9555    // start → Equals `\u{8d}`, `~` → Tilde `\u{98}`), so an ASCII-only
9556    // `op == "=~"` check missed every real `[[ x =~ pat ]]` and fell
9557    // through to Binary.
9558    let opc: Vec<char> = op.chars().collect();
9559    let is_regex_op = opc.len() == 2
9560        && (opc[0] == '=' || opc[0] == Equals)
9561        && (opc[1] == '~' || opc[1] == Tilde);
9562    // c:2659-2710 par_cond_triple — only the documented binary operators are
9563    // valid. String ops: `=`/`==`/`!=`/`<`/`>` (and their tokenized forms
9564    // Equals/Bang/Inang/Outang). Dash ops (`-eq`, `-nt`, `-pcre-match`, any
9565    // `-X`) parse-accept and the evaluator reports "unknown condition" if
9566    // unsupported. A `-mod A B C` (s1 is a dash op) is the COND_MOD form. Any
9567    // other middle word is `COND_ERROR("condition expected: %s", op)` — a parse
9568    // error that YYERRORs (errflag + LEXERR) so the line aborts. Without this
9569    // gate `[[ a b c ]]` silently built Binary("a","b","c") and ran on.
9570    let is_eq = |c: char| c == '=' || c == Equals;
9571    let is_bang = |c: char| c == '!' || c == Bang;
9572    let is_recognized_op = (opc.len() == 1
9573        && (is_eq(opc[0]) || matches!(opc[0], '<' | '>') || opc[0] == Inang || opc[0] == Outang))
9574        || (opc.len() == 2 && is_eq(opc[0]) && is_eq(opc[1]))
9575        || (opc.len() == 2 && is_bang(opc[0]) && is_eq(opc[1]))
9576        || (!opc.is_empty() && IS_DASH(opc[0]))
9577        || (!s1_chars.is_empty() && IS_DASH(s1_chars[0]));
9578    if is_regex_op {
9579        Some(ZshCond::Regex(s1, s2))
9580    } else if is_recognized_op {
9581        Some(ZshCond::Binary(s1, op, s2))
9582    } else {
9583        let display: String = op.chars().map(|c| if IS_DASH(c) { '-' } else { c }).collect();
9584        crate::ported::utils::zerr(&format!("condition expected: {}", display));
9585        crate::ported::utils::errflag.fetch_or(
9586            crate::ported::zsh_h::ERRFLAG_ERROR,
9587            std::sync::atomic::Ordering::Relaxed,
9588        );
9589        set_tok(LEXERR);
9590        None
9591    }
9592}
9593
9594fn skip_cond_separators() {
9595    while tok() == SEPER && {
9596        let s = tokstr();
9597        s.map(|s| !s.contains(';')).unwrap_or(true)
9598    } {
9599        zshlex();
9600    }
9601}
9602
9603/// Parse (( ... )) arithmetic command
9604/// Parse `(( EXPR ))` arithmetic command. C source: parse.c:1810-1834
9605/// `par_dinbrack` (despite the name; the function actually handles
9606/// DINPAR `(( ))` blocks too).
9607fn parse_arith() -> Option<ZshCommand> {
9608    let expr = tokstr().unwrap_or_default();
9609    zshlex();
9610    Some(ZshCommand::Arith(expr))
9611}
9612
9613/// Skip separator tokens
9614fn skip_separators() {
9615    while tok() == SEPER || tok() == NEWLIN {
9616        zshlex();
9617    }
9618}
9619
9620// `fdheaderlen` / `fdmagic` / `fdflags` / etc. macros from
9621// `Src/parse.c:3125-3152`. C uses raw pointer arithmetic on a
9622// `Wordcode` (= `u32 *`); the Rust port takes a slice and indexes.
9623
9624/// Port of `fdheaderlen(f)` macro (`Src/parse.c:3125`) — header
9625/// length in u32 words (read from prelude word `FD_PRELEN`).
9626#[inline]
9627pub fn fdheaderlen(f: &[u32]) -> u32 {
9628    f[FD_PRELEN]
9629}
9630
9631/// Port of `fdmagic(f)` macro (`Src/parse.c:3127`) — first prelude
9632/// word, either `FD_MAGIC` or `FD_OMAGIC`.
9633#[inline]
9634pub fn fdmagic(f: &[u32]) -> u32 {
9635    f[0]
9636}
9637
9638/// Port of `fdflags(f)` macro (`Src/parse.c:3131`) — low byte of
9639/// the packed `pre[1]` word.
9640#[inline]
9641pub fn fdflags(f: &[u32]) -> u32 {
9642    // `pre[1]` is a u32 viewed as 4 bytes; flags = byte 0.
9643    f[1] & 0xff
9644}
9645
9646/// Port of `fdsetflags(f, v)` macro (`Src/parse.c:3132`) — write
9647/// the low byte of `pre[1]`.
9648#[inline]
9649pub fn fdsetflags(f: &mut [u32], v: u8) {
9650    f[1] = (f[1] & !0xff) | (v as u32);
9651}
9652
9653/// Port of `fdother(f)` macro (`Src/parse.c:3133`) — high 24 bits
9654/// of `pre[1]`, holds the byte-offset to the opposite-byte-order
9655/// dump copy.
9656#[inline]
9657pub fn fdother(f: &[u32]) -> u32 {
9658    (f[1] >> 8) & 0x00ff_ffff
9659}
9660
9661/// Port of `fdsetother(f, o)` macro (`Src/parse.c:3134`).
9662#[inline]
9663pub fn fdsetother(f: &mut [u32], o: u32) {
9664    f[1] = (f[1] & 0xff) | ((o & 0x00ff_ffff) << 8);
9665}
9666
9667/// Port of `fdversion(f)` macro (`Src/parse.c:3140`) — read the
9668/// `ZSH_VERSION` C-string from `pre[2..]`.
9669pub fn fdversion(f: &[u32]) -> String {
9670    let bytes: Vec<u8> = f[2..]
9671        .iter()
9672        .take(10)
9673        .flat_map(|w| w.to_le_bytes().into_iter())
9674        .collect();
9675    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
9676    String::from_utf8_lossy(&bytes[..end]).into_owned()
9677}
9678
9679/// Port of `firstfdhead(f)` macro (`Src/parse.c:3142`) — pointer
9680/// to the first `struct fdhead` past the prelude.
9681#[inline]
9682pub fn firstfdhead_offset() -> usize {
9683    FD_PRELEN
9684}
9685
9686/// Port of `nextfdhead(f)` macro (`Src/parse.c:3143`) — advance to
9687/// the next header by reading the current `hlen` slot.
9688#[inline]
9689pub fn nextfdhead_offset(f: &[u32], cur: usize) -> usize {
9690    cur + (f[cur + 4] as usize) // .hlen is field 4 of fdhead
9691}
9692
9693/// Port of `fdhflags(f)` macro (`Src/parse.c:3145`) — low 2 bits
9694/// of the header's `flags` field (the kshload/zshload marker).
9695#[inline]
9696pub fn fdhflags(h: &fdhead) -> u32 {
9697    h.flags & 0x3
9698}
9699
9700/// Port of `fdhtail(f)` macro (`Src/parse.c:3146`) — high 30 bits
9701/// of `flags`, byte offset from the name start to its basename.
9702#[inline]
9703pub fn fdhtail(h: &fdhead) -> u32 {
9704    h.flags >> 2
9705}
9706
9707/// Port of `fdhbldflags(f, t)` macro (`Src/parse.c:3147`) — pack
9708/// `(flags, tail)` into one u32 (low 2 bits = flags, high 30 = tail).
9709#[inline]
9710pub fn fdhbldflags(flags: u32, tail: u32) -> u32 {
9711    flags | (tail << 2)
9712}
9713
9714/// Port of `fdname(f)` macro (`Src/parse.c:3152`) — name string
9715/// follows the fdhead record immediately. Reads bytes from the
9716/// dump buffer until NUL.
9717pub fn fdname(buf: &[u32], header_offset: usize) -> String {
9718    let name_word_off = header_offset + FDHEAD_WORDS;
9719    let bytes: Vec<u8> = buf[name_word_off..]
9720        .iter()
9721        .flat_map(|w| w.to_le_bytes().into_iter())
9722        .take_while(|&b| b != 0)
9723        .collect();
9724    String::from_utf8_lossy(&bytes).into_owned()
9725}
9726
9727/// Decode a `fdhead` record at the given u32-word offset in the
9728/// dump buffer. Used by the header-walk loops in `bin_zcompile -t`.
9729pub fn read_fdhead(buf: &[u32], offset: usize) -> Option<fdhead> {
9730    if offset + FDHEAD_WORDS > buf.len() {
9731        return None;
9732    }
9733    Some(fdhead {
9734        start: buf[offset],
9735        len: buf[offset + 1],
9736        npats: buf[offset + 2],
9737        strs: buf[offset + 3],
9738        hlen: buf[offset + 4],
9739        flags: buf[offset + 5],
9740    })
9741}
9742
9743/// Port of `freedump(FuncDump f)` from `Src/parse.c:3976`. C
9744/// `munmap`s, `zclose`s the fd, and frees the struct. The Rust
9745/// port relies on Drop for the `funcdump` (no mmap held in this
9746/// port — `addr`/`map` are byte-offset placeholders), so the
9747/// equivalent is removing the entry from the dumps list. Called
9748/// by `decrdumpcount` when the refcount hits zero (c:3988) and
9749/// by `closedumps` when shutting down (c:4008).
9750fn freedump_locked(g: &mut std::sync::MutexGuard<'_, Vec<funcdump>>, filename: &str) {
9751    // c:3976
9752    g.retain(|d| d.filename.as_deref() != Some(filename));
9753}
9754
9755// =====================================================================
9756// Remaining `Src/parse.c` ports (this section finishes the file).
9757//
9758// Several of these emit into the C-wordcode buffer (`ECBUF`/etc.) and
9759// are kept for completeness — the live zshrs runtime uses the
9760// `ZshProgram` AST path instead, but `bin_zcompile` (`-c`/`-a` modes)
9761// and any future `.zwc`-emit pipeline both call into these.
9762// =====================================================================
9763
9764/// `ecstr(s)` helper — `ecadd(ecstrcode(s))`. Mirrors the C macro at
9765/// `Src/parse.c:482` used everywhere by the par_* emitters.
9766#[inline]
9767pub fn ecstr(s: &str) {
9768    let code = ecstrcode(s);
9769    ecadd(code);
9770}
9771
9772/// Port of `condlex` function-pointer global from `Src/parse.c`. C
9773/// flips this between `zshlex` and `testlex` depending on whether
9774/// we're inside `[[ ]]` vs `/bin/test` builtin. zshrs has no
9775/// separate `testlex` yet, so this just defers to `zshlex`.
9776#[inline]
9777pub fn condlex() {
9778    zshlex();
9779}
9780
9781fn copy_ecstr_walk(node: &Option<Box<EccstrNode>>, p: &mut [u8]) {
9782    let mut cur = node.as_ref();
9783    while let Some(n) = cur {
9784        // c:540 — `memcpy(p + s->aoffs, s->str, strlen(s->str) + 1);`
9785        let off = n.aoffs as usize;
9786        let need = off + n.str.len() + 1;
9787        if need <= p.len() {
9788            p[off..off + n.str.len()].copy_from_slice(&n.str);
9789            p[off + n.str.len()] = 0;
9790        }
9791        // c:541 — `copy_ecstr(s->left, p);`
9792        copy_ecstr_walk(&n.left, p);
9793        // c:542 — `s = s->right;`
9794        cur = n.right.as_ref();
9795    }
9796}
9797
9798/// Port of `par_cond(void)` from `Src/parse.c:2409`. Top-level cond
9799/// OR-chain — drives `par_cond_1` and stitches `||`-separated terms
9800/// with `WCB_COND(COND_OR, …)`. This is the missing top of the
9801/// wordcode cond chain: `par_cond_wordcode` (the par_dinbrack port)
9802/// must call into HERE so that `[[ a || b ]]` and friends land
9803/// real WC_COND opcodes in `ecbuf`. Without this, the wordcode
9804/// emitter for `[[ ... ]]` produced zero words and parity dropped
9805/// 148 words on `/etc/zshrc` alone.
9806pub fn par_cond_top() -> i32 {
9807    // c:2411 — `int p = ecused, r;`
9808    let p = ECUSED.with(|c| c.get()) as usize;
9809    let r = par_cond_1();
9810    while COND_SEP() {
9811        condlex();
9812    }
9813    if tok() == DBAR {
9814        // c:2417 — `condlex(); while (COND_SEP()) condlex();`
9815        condlex();
9816        while COND_SEP() {
9817            condlex();
9818        }
9819        // c:2420-2422 — `ecispace(p, 1); par_cond(); ecbuf[p] =
9820        // WCB_COND(COND_OR, ecused-1-p);`
9821        ecispace(p, 1);
9822        par_cond_top();
9823        let ecused = ECUSED.with(|c| c.get()) as usize;
9824        ECBUF.with(|c| {
9825            c.borrow_mut()[p] = WCB_COND(COND_OR as u32, (ecused - 1 - p) as u32);
9826        });
9827        return 1;
9828    }
9829    r
9830}
9831
9832/// Port of `static int check_cond(const char *input, const char *cond)`
9833/// from `Src/parse.c:2459`. True iff `input` is the two-char `-X`
9834/// form whose `X` matches `cond` — used by par_cond_2 to detect
9835/// `-a` / `-o` n-ary chain operators and by build_dump for `-k` /
9836/// `-z`. C: `return !IS_DASH(input[0]) ? 0 : !strcmp(input+1, cond);`.
9837fn check_cond(input: &str, cond: &str) -> bool {
9838    let mut chars = input.chars();
9839    match chars.next() {
9840        Some(c) if IS_DASH(c) => chars.as_str() == cond,
9841        _ => false,
9842    }
9843}
9844
9845#[cfg(test)]
9846mod tests {
9847    use super::*;
9848    use crate::utils::{errflag, ERRFLAG_ERROR};
9849    use std::fs;
9850    use std::path::Path;
9851    use std::sync::atomic::Ordering;
9852    use std::sync::mpsc;
9853    use std::thread;
9854    use std::time::Duration;
9855
9856    /// `try_source_file` MUST refuse a stale `.zwc` cache when the
9857    /// uncompiled source has been modified more recently. The C body
9858    /// at c:3819 reads `stc.st_mtime >= stn.st_mtime` — explicitly
9859    /// `>=`, meaning only an equal-or-newer zwc is acceptable.
9860    ///
9861    /// A regression that ignored the mtime check (or used the wrong
9862    /// direction) would silently keep loading the OLD compiled body
9863    /// after the user edited the source file — every `source foo.zsh`
9864    /// would replay yesterday's code, the worst-class shell bug.
9865    ///
9866    /// Pin: create source + .zwc, then touch source to make it
9867    /// newer. try_source_file must return None.
9868    #[test]
9869    fn try_source_file_skips_stale_zwc() {
9870        let _g = crate::test_util::global_state_lock();
9871        let dir = tempfile::tempdir().expect("tempdir");
9872        let src = dir.path().join("script.zsh");
9873        let zwc = dir.path().join("script.zsh.zwc");
9874        // Create zwc FIRST (older), then source (newer).
9875        fs::write(&zwc, b"placeholder zwc").unwrap();
9876        thread::sleep(Duration::from_millis(20));
9877        fs::write(&src, b"echo hi").unwrap();
9878
9879        let result = try_source_file(src.to_str().unwrap());
9880        assert!(
9881            result.is_none(),
9882            "c:3819 — stale .zwc (older than source) MUST be rejected; \
9883             got {:?}",
9884            result
9885        );
9886    }
9887
9888    /// `try_source_file` returns None when no `.zwc` exists for the
9889    /// requested file (c:3819 `if let Ok(meta_c) = &stc` gate fails).
9890    /// This is the common case — most user scripts don't ship with
9891    /// a pre-compiled `.zwc`. The fn returning None lets the caller
9892    /// fall through to the source-read path. A regression that
9893    /// returned `Some(file)` on missing `.zwc` would route every
9894    /// `source foo.zsh` through `check_dump_file` against a
9895    /// non-existent file and crash.
9896    #[test]
9897    fn try_source_file_returns_none_when_no_zwc() {
9898        let _g = crate::test_util::global_state_lock();
9899        let dir = tempfile::tempdir().expect("tempdir");
9900        let src = dir.path().join("plain.zsh");
9901        fs::write(&src, b"echo hi").unwrap();
9902        // No .zwc sibling.
9903
9904        let result = try_source_file(src.to_str().unwrap());
9905        assert!(
9906            result.is_none(),
9907            "c:3819 gate fails when stat(wc) returns Err → None"
9908        );
9909    }
9910
9911    /// Test helper. Mirrors zsh's `errflag` save/clear/check pattern
9912    /// around a parse — see `Src/init.c:loop` which clears errflag
9913    /// before parse_event() and tests it after. Returns `Err` if the
9914    /// parse set `ERRFLAG_ERROR`; otherwise `Ok(program)`.
9915    fn parse(input: &str) -> Result<ZshProgram, String> {
9916        let saved = errflag.load(Ordering::Relaxed);
9917        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
9918        parse_init(input);
9919        let prog = crate::ported::parse::parse();
9920        let had_err = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
9921        // Restore prior error bits; don't carry our new error into the
9922        // outer test runner.
9923        errflag.store(saved, Ordering::Relaxed);
9924        if had_err {
9925            Err("parse error".to_string())
9926        } else {
9927            Ok(prog)
9928        }
9929    }
9930
9931    #[test]
9932    fn test_simple_command() {
9933        let _g = crate::test_util::global_state_lock();
9934        let prog = parse("echo hello world").unwrap();
9935        assert_eq!(prog.lists.len(), 1);
9936        match &prog.lists[0].sublist.pipe.cmd {
9937            ZshCommand::Simple(s) => {
9938                assert_eq!(s.words, vec!["echo", "hello", "world"]);
9939            }
9940            _ => panic!("expected simple command"),
9941        }
9942    }
9943
9944    #[test]
9945    fn test_pipeline() {
9946        let _g = crate::test_util::global_state_lock();
9947        let prog = parse("ls | grep foo | wc -l").unwrap();
9948        assert_eq!(prog.lists.len(), 1);
9949
9950        let pipe = &prog.lists[0].sublist.pipe;
9951        assert!(pipe.next.is_some());
9952
9953        let pipe2 = pipe.next.as_ref().unwrap();
9954        assert!(pipe2.next.is_some());
9955    }
9956
9957    #[test]
9958    fn test_and_or() {
9959        let _g = crate::test_util::global_state_lock();
9960        let prog = parse("cmd1 && cmd2 || cmd3").unwrap();
9961        let sublist = &prog.lists[0].sublist;
9962
9963        assert!(sublist.next.is_some());
9964        let (op, _) = sublist.next.as_ref().unwrap();
9965        assert_eq!(*op, SublistOp::And);
9966    }
9967
9968    #[test]
9969    fn test_if_then() {
9970        let _g = crate::test_util::global_state_lock();
9971        let prog = parse("if test -f foo; then echo yes; fi").unwrap();
9972        match &prog.lists[0].sublist.pipe.cmd {
9973            ZshCommand::If(_) => {}
9974            _ => panic!("expected if command"),
9975        }
9976    }
9977
9978    #[test]
9979    fn test_for_loop() {
9980        let _g = crate::test_util::global_state_lock();
9981        let prog = parse("for i in a b c; do echo $i; done").unwrap();
9982        match &prog.lists[0].sublist.pipe.cmd {
9983            ZshCommand::For(f) => {
9984                assert_eq!(f.var, "i");
9985                match &f.list {
9986                    ForList::Words(w) => assert_eq!(w, &vec!["a", "b", "c"]),
9987                    _ => panic!("expected word list"),
9988                }
9989            }
9990            _ => panic!("expected for command"),
9991        }
9992    }
9993
9994    #[test]
9995    fn test_case() {
9996        let _g = crate::test_util::global_state_lock();
9997        let prog = parse("case $x in a) echo a;; b) echo b;; esac").unwrap();
9998        match &prog.lists[0].sublist.pipe.cmd {
9999            ZshCommand::Case(c) => {
10000                assert_eq!(c.arms.len(), 2);
10001            }
10002            _ => panic!("expected case command"),
10003        }
10004    }
10005
10006    #[test]
10007    fn test_function() {
10008        let _g = crate::test_util::global_state_lock();
10009        // First test just parsing "function foo" to see what happens
10010        let prog = parse("function foo { }").unwrap();
10011        match &prog.lists[0].sublist.pipe.cmd {
10012            ZshCommand::FuncDef(f) => {
10013                assert_eq!(f.names, vec!["foo"]);
10014            }
10015            _ => panic!(
10016                "expected function, got {:?}",
10017                prog.lists[0].sublist.pipe.cmd
10018            ),
10019        }
10020    }
10021
10022    #[test]
10023    fn test_redirection() {
10024        let _g = crate::test_util::global_state_lock();
10025        let prog = parse("echo hello > file.txt").unwrap();
10026        match &prog.lists[0].sublist.pipe.cmd {
10027            ZshCommand::Simple(s) => {
10028                assert_eq!(s.redirs.len(), 1);
10029                assert_eq!(s.redirs[0].rtype, REDIR_WRITE);
10030            }
10031            _ => panic!("expected simple command"),
10032        }
10033    }
10034
10035    #[test]
10036    fn test_assignment() {
10037        let _g = crate::test_util::global_state_lock();
10038        let prog = parse("FOO=bar echo $FOO").unwrap();
10039        match &prog.lists[0].sublist.pipe.cmd {
10040            ZshCommand::Simple(s) => {
10041                assert_eq!(s.assigns.len(), 1);
10042                assert_eq!(s.assigns[0].name, "FOO");
10043            }
10044            _ => panic!("expected simple command"),
10045        }
10046    }
10047
10048    #[test]
10049    fn test_parse_completion_function() {
10050        let _g = crate::test_util::global_state_lock();
10051        let input = r#"_2to3_fixes() {
10052  local -a fixes
10053  fixes=( ${${(M)${(f)"$(2to3 --list-fixes 2>/dev/null)"}:#*}//[[:space:]]/} )
10054  (( ${#fixes} )) && _describe -t fixes 'fix' fixes
10055}"#;
10056        let result = parse(input);
10057        assert!(
10058            result.is_ok(),
10059            "Failed to parse completion function: {:?}",
10060            result.err()
10061        );
10062        let prog = result.unwrap();
10063        assert!(
10064            !prog.lists.is_empty(),
10065            "Expected at least one list in program"
10066        );
10067    }
10068
10069    #[test]
10070    fn test_parse_array_with_complex_elements() {
10071        let _g = crate::test_util::global_state_lock();
10072        let input = r#"arguments=(
10073  '(- * :)'{-h,--help}'[show this help message and exit]'
10074  {-d,--doctests_only}'[fix up doctests only]'
10075  '*:filename:_files'
10076)"#;
10077        let result = parse(input);
10078        assert!(
10079            result.is_ok(),
10080            "Failed to parse array assignment: {:?}",
10081            result.err()
10082        );
10083    }
10084
10085    #[test]
10086    fn test_parse_full_completion_file() {
10087        let _g = crate::test_util::global_state_lock();
10088        let input = r##"#compdef 2to3
10089
10090# zsh completions for '2to3'
10091
10092_2to3_fixes() {
10093  local -a fixes
10094  fixes=( ${${(M)${(f)"$(2to3 --list-fixes 2>/dev/null)"}:#*}//[[:space:]]/} )
10095  (( ${#fixes} )) && _describe -t fixes 'fix' fixes
10096}
10097
10098local -a arguments
10099
10100arguments=(
10101  '(- * :)'{-h,--help}'[show this help message and exit]'
10102  {-d,--doctests_only}'[fix up doctests only]'
10103  {-f,--fix}'[each FIX specifies a transformation; default: all]:fix name:_2to3_fixes'
10104  {-j,--processes}'[run 2to3 concurrently]:number: '
10105  {-x,--nofix}'[prevent a transformation from being run]:fix name:_2to3_fixes'
10106  {-l,--list-fixes}'[list available transformations]'
10107  {-p,--print-function}'[modify the grammar so that print() is a function]'
10108  {-v,--verbose}'[more verbose logging]'
10109  '--no-diffs[do not show diffs of the refactoring]'
10110  {-w,--write}'[write back modified files]'
10111  {-n,--nobackups}'[do not write backups for modified files]'
10112  {-o,--output-dir}'[put output files in this directory instead of overwriting]:directory:_directories'
10113  {-W,--write-unchanged-files}'[also write files even if no changes were required]'
10114  '--add-suffix[append this string to all output filenames]:suffix: '
10115  '*:filename:_files'
10116)
10117
10118_arguments -s -S $arguments
10119"##;
10120        let result = parse(input);
10121        assert!(
10122            result.is_ok(),
10123            "Failed to parse full completion file: {:?}",
10124            result.err()
10125        );
10126        let prog = result.unwrap();
10127        // Should have parsed successfully with at least one statement
10128        assert!(!prog.lists.is_empty(), "Expected at least one list");
10129    }
10130
10131    #[test]
10132    fn test_parse_logs_sh() {
10133        let _g = crate::test_util::global_state_lock();
10134        let input = r#"#!/usr/bin/env bash
10135shopt -s globstar
10136
10137if [[ $(uname) == Darwin ]]; then
10138    tail -f /var/log/**/*.log /var/log/**/*.out | lolcat
10139else
10140    if [[ $ZPWR_DISTRO_NAME == raspbian ]]; then
10141        tail -f /var/log/**/*.log | lolcat
10142    else
10143        printf "Unsupported...\n" >&2
10144    fi
10145fi
10146"#;
10147        let result = parse(input);
10148        assert!(
10149            result.is_ok(),
10150            "Failed to parse logs.sh: {:?}",
10151            result.err()
10152        );
10153    }
10154
10155    #[test]
10156    fn test_parse_case_with_glob() {
10157        let _g = crate::test_util::global_state_lock();
10158        let input = r#"case "$ZPWR_OS_TYPE" in
10159    darwin*)  open_cmd='open'
10160      ;;
10161    cygwin*)  open_cmd='cygstart'
10162      ;;
10163    linux*)
10164        open_cmd='xdg-open'
10165      ;;
10166esac"#;
10167        let result = parse(input);
10168        assert!(
10169            result.is_ok(),
10170            "Failed to parse case with glob: {:?}",
10171            result.err()
10172        );
10173    }
10174
10175    #[test]
10176    fn test_parse_case_with_nested_if() {
10177        let _g = crate::test_util::global_state_lock();
10178        // Test case with nested if and glob patterns
10179        let input = r##"function zpwrGetOpenCommand(){
10180    local open_cmd
10181    case "$ZPWR_OS_TYPE" in
10182        darwin*)  open_cmd='open' ;;
10183        cygwin*)  open_cmd='cygstart' ;;
10184        linux*)
10185            if [[ "$_zpwr_uname_r" != *icrosoft* ]];then
10186                open_cmd='nohup xdg-open'
10187            fi
10188            ;;
10189    esac
10190}"##;
10191        let result = parse(input);
10192        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
10193    }
10194
10195    #[test]
10196    fn test_parse_zpwr_scripts() {
10197        let _g = crate::test_util::global_state_lock();
10198        let scripts_dir = Path::new("/Users/wizard/.zpwr/scripts");
10199        if !scripts_dir.exists() {
10200            eprintln!("Skipping test: scripts directory not found");
10201            return;
10202        }
10203
10204        let mut total = 0;
10205        let mut passed = 0;
10206        let mut failed_files = Vec::new();
10207        let mut timeout_files = Vec::new();
10208
10209        for ext in &["sh", "zsh"] {
10210            let pattern = scripts_dir.join(format!("*.{}", ext));
10211            if let Ok(entries) = glob::glob(pattern.to_str().unwrap()) {
10212                for entry in entries.flatten() {
10213                    total += 1;
10214                    let file_path = entry.display().to_string();
10215                    let content = match fs::read_to_string(&entry) {
10216                        Ok(c) => c,
10217                        Err(e) => {
10218                            failed_files.push((file_path, format!("read error: {}", e)));
10219                            continue;
10220                        }
10221                    };
10222
10223                    // Parse with timeout
10224                    let content_clone = content.clone();
10225                    let (tx, rx) = mpsc::channel();
10226                    let handle = thread::spawn(move || {
10227                        let result = parse(&content_clone);
10228                        let _ = tx.send(result);
10229                    });
10230
10231                    match rx.recv_timeout(Duration::from_secs(2)) {
10232                        Ok(Ok(_)) => passed += 1,
10233                        Ok(Err(err)) => {
10234                            failed_files.push((file_path, err));
10235                        }
10236                        Err(_) => {
10237                            timeout_files.push(file_path);
10238                            // Thread will be abandoned
10239                        }
10240                    }
10241                }
10242            }
10243        }
10244
10245        eprintln!("\n=== ZPWR Scripts Parse Results ===");
10246        eprintln!("Passed: {}/{}", passed, total);
10247
10248        if !timeout_files.is_empty() {
10249            eprintln!("\nTimeout files (>2s):");
10250            for file in &timeout_files {
10251                eprintln!("  {}", file);
10252            }
10253        }
10254
10255        if !failed_files.is_empty() {
10256            eprintln!("\nFailed files:");
10257            for (file, err) in &failed_files {
10258                eprintln!("  {} - {}", file, err);
10259            }
10260        }
10261
10262        // Allow some failures initially, but track progress
10263        let pass_rate = if total > 0 {
10264            (passed as f64 / total as f64) * 100.0
10265        } else {
10266            0.0
10267        };
10268        eprintln!("Pass rate: {:.1}%", pass_rate);
10269
10270        // Require at least 50% pass rate for now
10271        assert!(pass_rate >= 50.0, "Pass rate too low: {:.1}%", pass_rate);
10272    }
10273
10274    /// c:2643 — `get_cond_num` returns 0..=8 for the canonical binary
10275    /// test operators in order `nt ot ef eq ne lt gt le ge`. The
10276    /// index IS the wordcode opcode dispatch key; flipping any entry
10277    /// would silently mis-dispatch `[[ a -eq b ]]` to a different op.
10278    #[test]
10279    fn get_cond_num_canonical_order_matches_dispatch_table() {
10280        let _g = crate::test_util::global_state_lock();
10281        assert_eq!(get_cond_num("nt"), 0);
10282        assert_eq!(get_cond_num("ot"), 1);
10283        assert_eq!(get_cond_num("ef"), 2);
10284        assert_eq!(get_cond_num("eq"), 3);
10285        assert_eq!(get_cond_num("ne"), 4);
10286        assert_eq!(get_cond_num("lt"), 5);
10287        assert_eq!(get_cond_num("gt"), 6);
10288        assert_eq!(get_cond_num("le"), 7);
10289        assert_eq!(get_cond_num("ge"), 8);
10290    }
10291
10292    /// c:2643 — unknown operator returns -1 (sentinel for "not in the
10293    /// binary set"). Regression returning 0 silently would alias
10294    /// every unknown op to `-nt`, dispatching to the wrong handler.
10295    #[test]
10296    fn get_cond_num_unknown_operator_returns_minus_one() {
10297        let _g = crate::test_util::global_state_lock();
10298        assert_eq!(get_cond_num("xx"), -1);
10299        assert_eq!(get_cond_num(""), -1);
10300        assert_eq!(get_cond_num("eqnt"), -1, "exact-match required");
10301        assert_eq!(
10302            get_cond_num("NT"),
10303            -1,
10304            "case-sensitive — uppercase rejected"
10305        );
10306    }
10307
10308    /// c:2628 — `par_cond_double` requires arg `a` to start with `-`
10309    /// AND have at least one more char. Empty string OR single `-`
10310    /// must error (return 1 via zerr). Regression accepting empty
10311    /// would dispatch `[[ "" string ]]` as a unary test.
10312    #[test]
10313    fn par_cond_double_rejects_short_or_non_dash_first_arg() {
10314        let _g = crate::test_util::global_state_lock();
10315        // empty
10316        let _ = par_cond_double("", "b");
10317        // not-dash
10318        let _ = par_cond_double("foo", "b");
10319        // bare dash
10320        let _ = par_cond_double("-", "b");
10321        // All three must NOT crash + return 1 (error path).
10322    }
10323
10324    /// c:2647 CONDSTRS table — exhaustive iteration: every entry's
10325    /// index round-trips through get_cond_num. A regression that
10326    /// drops an entry would let `[[ a -ef b ]]` silently mis-dispatch.
10327    #[test]
10328    fn get_cond_num_round_trips_for_every_table_entry() {
10329        let _g = crate::test_util::global_state_lock();
10330        for (i, op) in ["nt", "ot", "ef", "eq", "ne", "lt", "gt", "le", "ge"]
10331            .iter()
10332            .enumerate()
10333        {
10334            assert_eq!(get_cond_num(op) as usize, i, "{op} must map to index {i}");
10335        }
10336    }
10337
10338    /// c:2643 — `get_cond_num` is byte-exact: a partial-prefix string
10339    /// must NOT match. `e` (one char) is not `eq`. Catches a
10340    /// regression using `starts_with` instead of equality.
10341    #[test]
10342    fn get_cond_num_partial_prefix_does_not_match() {
10343        let _g = crate::test_util::global_state_lock();
10344        assert_eq!(get_cond_num("e"), -1);
10345        assert_eq!(get_cond_num("eq2"), -1);
10346        assert_eq!(get_cond_num("n"), -1);
10347    }
10348
10349    /// c:2628 — `par_cond_double` checks `IS_DASH(ac[0])` so any
10350    /// non-dash first char fails. The lexed Dash sentinel `\u{9b}`
10351    /// MUST be accepted alongside ASCII `-` (the lexer emits it
10352    /// inside `[[ ... ]]`). Regression dropping the sentinel form
10353    /// would break every cond expression after lexing.
10354    #[test]
10355    fn par_cond_double_accepts_lexed_dash_sentinel() {
10356        let _g = crate::test_util::global_state_lock();
10357        // First char being the Dash sentinel + valid unary letter
10358        // must NOT trigger the "condition expected" error path.
10359        // We can't easily probe the wordcode emission here, but
10360        // the function MUST return without panic for both forms.
10361        let _ = par_cond_double("-z", "foo");
10362        let _ = par_cond_double("\u{9b}z", "foo");
10363    }
10364
10365    /// c:2643 — case sensitivity: uppercase `EQ` MUST NOT match `eq`.
10366    /// zsh's `[[ a -EQ b ]]` is documented as a parse error (only
10367    /// lowercase variants are recognised). Regression doing
10368    /// case-insensitive lookup would silently accept it.
10369    #[test]
10370    fn get_cond_num_is_case_sensitive() {
10371        let _g = crate::test_util::global_state_lock();
10372        assert_eq!(get_cond_num("EQ"), -1);
10373        assert_eq!(get_cond_num("Eq"), -1);
10374        assert_eq!(get_cond_num("eQ"), -1);
10375        // Lowercase still works.
10376        assert_eq!(get_cond_num("eq"), 3);
10377    }
10378
10379    /// `Src/parse.c:2862-2868` — `ecgetstr` inline-3-byte case packs
10380    /// up to 3 chars into bits 3-26 of the wordcode word, then C emits
10381    /// `buf[3] = '\0'; r = dupstring(buf);`. `dupstring` uses `strlen`
10382    /// so the resulting string TRUNCATES at the first NUL byte —
10383    /// short strings of 1 or 2 chars get their tail NUL-padded and
10384    /// silently dropped by strlen.
10385    ///
10386    /// The previous Rust port used `retain(|&x| x != 0)` which SPLICES
10387    /// OUT interior NULs (so `[a, 0, b]` would yield "ab" instead of
10388    /// C's "a"). Verify both endpoints work correctly:
10389    ///   * 1-char string ("a", 0, 0)        → "a"   (strlen-truncate)
10390    ///   * 2-char string ("ab", 0)          → "ab"  (strlen-truncate)
10391    ///   * 3-char string ("abc")            → "abc" (full)
10392    ///   * pathological ("a", 0, "b")       → "a"   (NOT "ab")
10393    #[test]
10394    fn ecgetstr_inline_string_truncates_at_first_nul_like_c_strlen() {
10395        let _g = crate::test_util::global_state_lock();
10396        // Build a wordcode word with `c & 2 != 0` (inline-string flag)
10397        // and the 3 bytes packed at offsets 3, 11, 19. `c & 1` is the
10398        // tokflag; clear it for this test.
10399        fn pack_inline(b0: u8, b1: u8, b2: u8) -> u32 {
10400            // c:2862 layout — bit0 = tokflag (0 here), bit1 = inline (1),
10401            // bits 3-10 = b0, bits 11-18 = b1, bits 19-26 = b2.
10402            (2u32) | ((b0 as u32) << 3) | ((b1 as u32) << 11) | ((b2 as u32) << 19)
10403        }
10404        let mk_state = |word: u32| -> estate {
10405            let p = eprog {
10406                flags: 0,
10407                len: 1,
10408                npats: 0,
10409                nref: 0,
10410                pats: Vec::new(),
10411                prog: vec![word],
10412                strs: None,
10413                shf: None,
10414                dump: None,
10415            };
10416            estate {
10417                prog: Box::new(p),
10418                pc: 0,
10419                strs: None,
10420                strs_offset: 0,
10421            }
10422        };
10423
10424        // 1-char: ('a', 0, 0) → "a"
10425        let mut st = mk_state(pack_inline(b'a', 0, 0));
10426        assert_eq!(
10427            ecgetstr(&mut st, 0, None),
10428            "a",
10429            "c:2869 strlen truncates 1-char inline at the NUL tail"
10430        );
10431
10432        // 2-char: ('a', 'b', 0) → "ab"
10433        let mut st = mk_state(pack_inline(b'a', b'b', 0));
10434        assert_eq!(
10435            ecgetstr(&mut st, 0, None),
10436            "ab",
10437            "c:2869 strlen truncates 2-char inline at the NUL tail"
10438        );
10439
10440        // 3-char: ('a', 'b', 'c') → "abc"
10441        let mut st = mk_state(pack_inline(b'a', b'b', b'c'));
10442        assert_eq!(
10443            ecgetstr(&mut st, 0, None),
10444            "abc",
10445            "c:2869 full 3-byte inline preserved"
10446        );
10447
10448        // Pathological: ('a', 0, 'b') → "a" (NOT "ab" from retain-splice)
10449        let mut st = mk_state(pack_inline(b'a', 0, b'b'));
10450        assert_eq!(
10451            ecgetstr(&mut st, 0, None),
10452            "a",
10453            "c:2869 strlen STOPS at first NUL; must not splice 'b' through"
10454        );
10455    }
10456
10457    /// Pin: `init_parse_status` resets ALL six lexer-parser flags
10458    /// per `Src/parse.c:500-502`. Specifically `inrepeat_ = 0` at
10459    /// c:501 was previously missing in the Rust port. Pin every
10460    /// reset so a future regression that drops one is caught.
10461    #[test]
10462    fn init_parse_status_resets_all_lexer_parser_flags() {
10463        let _g = crate::test_util::global_state_lock();
10464        // Dirty every flag to a non-default value.
10465        set_incasepat(5);
10466        set_incond(7);
10467        set_inredir(true);
10468        set_infor(3);
10469        set_intypeset(true);
10470        set_inrepeat(2);
10471        set_incmdpos(false);
10472        // Reset.
10473        init_parse_status();
10474        // c:500-502 — every flag back to its default.
10475        assert_eq!(incasepat(), 0, "c:500 — incasepat = 0");
10476        assert_eq!(incond(), 0, "c:500 — incond = 0");
10477        assert!(!inredir(), "c:500 — inredir = 0");
10478        assert_eq!(infor(), 0, "c:500 — infor = 0");
10479        assert!(!intypeset(), "c:500 — intypeset = 0");
10480        assert_eq!(
10481            inrepeat(),
10482            0,
10483            "c:501 — inrepeat_ = 0 (was previously missing)"
10484        );
10485        assert!(incmdpos(), "c:502 — incmdpos = 1");
10486    }
10487
10488    // ═══════════════════════════════════════════════════════════════════
10489    // AST shape tests — feed source through parse(), walk the resulting
10490    // ZshProgram, assert structural properties. Each test uses the local
10491    // `parse(input)` helper that errors cleanly on parse failure.
10492    // Anchor: where applicable, behavior matches `zsh -n -c '...'`
10493    // (parse-only, no execution — which would error on syntax issues).
10494    // ═══════════════════════════════════════════════════════════════════
10495
10496    /// Empty input → ZshProgram with no lists.
10497    #[test]
10498    fn parse_empty_source_yields_zero_lists() {
10499        let _g = crate::test_util::global_state_lock();
10500        let prog = parse("").unwrap();
10501        assert_eq!(prog.lists.len(), 0);
10502    }
10503
10504    /// Comment-only input → no lists (comments are skipped at lex level).
10505    #[test]
10506    fn parse_only_comment_yields_zero_lists() {
10507        let _g = crate::test_util::global_state_lock();
10508        let prog = parse("# this is just a comment").unwrap();
10509        assert_eq!(prog.lists.len(), 0, "comments alone produce no cmds");
10510    }
10511
10512    /// Three commands separated by `;` → three lists.
10513    #[test]
10514    fn parse_three_semicolon_separated_commands_yield_three_lists() {
10515        let _g = crate::test_util::global_state_lock();
10516        let prog = parse("a; b; c").unwrap();
10517        assert_eq!(prog.lists.len(), 3);
10518    }
10519
10520    /// Background command — async flag set on the list.
10521    #[test]
10522    fn parse_background_command_sets_async_flag() {
10523        let _g = crate::test_util::global_state_lock();
10524        let prog = parse("sleep 1 &").unwrap();
10525        assert_eq!(prog.lists.len(), 1);
10526        assert!(
10527            prog.lists[0].flags.async_,
10528            "trailing `&` must set async_ flag"
10529        );
10530    }
10531
10532    /// Pipe count: `a | b | c | d` → 4 stages.
10533    #[test]
10534    fn parse_four_stage_pipeline_has_three_next_links() {
10535        let _g = crate::test_util::global_state_lock();
10536        let prog = parse("a | b | c | d").unwrap();
10537        let mut pipe = &prog.lists[0].sublist.pipe;
10538        let mut count = 1;
10539        while let Some(next) = &pipe.next {
10540            pipe = next;
10541            count += 1;
10542        }
10543        assert_eq!(count, 4, "4 commands should produce 4 pipe stages");
10544    }
10545
10546    /// `|&` between pipeline stages sets merge_stderr.
10547    #[test]
10548    fn parse_pipe_amp_sets_merge_stderr() {
10549        let _g = crate::test_util::global_state_lock();
10550        let prog = parse("a |& b").unwrap();
10551        let pipe = &prog.lists[0].sublist.pipe;
10552        assert!(pipe.next.is_some());
10553        assert!(pipe.merge_stderr, "|& must set merge_stderr");
10554    }
10555
10556    /// `cmd1 || cmd2`: sublist.next is Some with `Or`.
10557    #[test]
10558    fn parse_or_operator_sets_sublist_op_or() {
10559        let _g = crate::test_util::global_state_lock();
10560        let prog = parse("cmd1 || cmd2").unwrap();
10561        let sublist = &prog.lists[0].sublist;
10562        let (op, _) = sublist.next.as_ref().expect("must have next");
10563        assert_eq!(*op, SublistOp::Or);
10564    }
10565
10566    /// `! cmd` sets the not flag on the sublist.
10567    #[test]
10568    fn parse_bang_negation_sets_sublist_not_flag() {
10569        let _g = crate::test_util::global_state_lock();
10570        let prog = parse("! false").unwrap();
10571        let sublist = &prog.lists[0].sublist;
10572        assert!(sublist.flags.not, "`!` prefix must set sublist.flags.not");
10573    }
10574
10575    // ── Compound commands ────────────────────────────────────────────
10576    /// `while cond; do body; done` → ZshCommand::While.
10577    #[test]
10578    fn parse_while_loop_yields_while_command() {
10579        let _g = crate::test_util::global_state_lock();
10580        let prog = parse("while true; do echo x; done").unwrap();
10581        assert!(matches!(
10582            prog.lists[0].sublist.pipe.cmd,
10583            ZshCommand::While(_)
10584        ));
10585    }
10586
10587    /// `until cond; do body; done` → ZshCommand::Until.
10588    /// Anchor: `zsh -n -c 'until false; do echo; done'` accepts and parses
10589    /// as an until-loop. zshrs accepts but emits a DIFFERENT AST variant
10590    /// (not Until). Bug — until loop is mis-classified.
10591    #[test]
10592    fn parse_until_loop_yields_until_command_anchored_to_zsh() {
10593        let _g = crate::test_util::global_state_lock();
10594        let prog = parse("until false; do echo x; done").unwrap();
10595        assert!(
10596            matches!(prog.lists[0].sublist.pipe.cmd, ZshCommand::Until(_)),
10597            "zsh parses `until` as Until variant; zshrs uses different variant: {:?}",
10598            prog.lists[0].sublist.pipe.cmd
10599        );
10600    }
10601
10602    /// `(cmd)` → Subsh variant.
10603    #[test]
10604    fn parse_parens_yield_subsh_command() {
10605        let _g = crate::test_util::global_state_lock();
10606        let prog = parse("(echo hi)").unwrap();
10607        assert!(matches!(
10608            prog.lists[0].sublist.pipe.cmd,
10609            ZshCommand::Subsh(_)
10610        ));
10611    }
10612
10613    /// `{ cmd; }` → Cursh (current-shell) command.
10614    #[test]
10615    fn parse_braces_yield_cursh_command() {
10616        let _g = crate::test_util::global_state_lock();
10617        let prog = parse("{ echo hi; }").unwrap();
10618        assert!(matches!(
10619            prog.lists[0].sublist.pipe.cmd,
10620            ZshCommand::Cursh(_)
10621        ));
10622    }
10623
10624    /// `[[ a == b ]]` → ZshCommand::Cond.
10625    #[test]
10626    fn parse_double_brackets_yield_cond_command() {
10627        let _g = crate::test_util::global_state_lock();
10628        let prog = parse("[[ a == b ]]").unwrap();
10629        assert!(matches!(
10630            prog.lists[0].sublist.pipe.cmd,
10631            ZshCommand::Cond(_)
10632        ));
10633    }
10634
10635    /// `(( 1 + 2 ))` → ZshCommand::Arith.
10636    #[test]
10637    fn parse_double_parens_yield_arith_command() {
10638        let _g = crate::test_util::global_state_lock();
10639        let prog = parse("(( 1 + 2 ))").unwrap();
10640        assert!(matches!(
10641            prog.lists[0].sublist.pipe.cmd,
10642            ZshCommand::Arith(_)
10643        ));
10644    }
10645
10646    /// `repeat 3 do echo x; done` → ZshCommand::Repeat.
10647    #[test]
10648    fn parse_repeat_loop_yields_repeat_command() {
10649        let _g = crate::test_util::global_state_lock();
10650        let prog = parse("repeat 3 do echo x; done").unwrap();
10651        assert!(matches!(
10652            prog.lists[0].sublist.pipe.cmd,
10653            ZshCommand::Repeat(_)
10654        ));
10655    }
10656
10657    // ── Function definitions ─────────────────────────────────────────
10658    /// `name() { body; }` → FuncDef variant.
10659    #[test]
10660    fn parse_paren_funcdef_yields_funcdef_command() {
10661        let _g = crate::test_util::global_state_lock();
10662        let prog = parse("greet() { echo hi; }").unwrap();
10663        assert!(matches!(
10664            prog.lists[0].sublist.pipe.cmd,
10665            ZshCommand::FuncDef(_)
10666        ));
10667    }
10668
10669    /// `function name { body; }` → FuncDef variant (zsh keyword form).
10670    #[test]
10671    fn parse_function_keyword_funcdef_yields_funcdef_command() {
10672        let _g = crate::test_util::global_state_lock();
10673        let prog = parse("function greet { echo hi; }").unwrap();
10674        assert!(matches!(
10675            prog.lists[0].sublist.pipe.cmd,
10676            ZshCommand::FuncDef(_)
10677        ));
10678    }
10679
10680    /// Syntax error — `if` without `fi` → parse returns Err.
10681    /// Anchor: `echo 'if true; then echo' | zsh -n` → "parse error".
10682    #[test]
10683    fn parse_unterminated_if_returns_error_anchored_to_zsh() {
10684        let _g = crate::test_util::global_state_lock();
10685        let r = parse("if true; then echo yes");
10686        assert!(r.is_err(), "zsh -n: parse error near `\\n`");
10687    }
10688
10689    /// Syntax error — bare `done` without `for/while/until` → error.
10690    /// Anchor: `echo done | zsh -n` → "parse error near `done`".
10691    #[test]
10692    fn parse_orphan_done_returns_error_anchored_to_zsh() {
10693        let _g = crate::test_util::global_state_lock();
10694        let r = parse("done");
10695        assert!(r.is_err(), "zsh -n: parse error near `done`");
10696    }
10697
10698    /// Simple command's words are metafied at the AST layer (matches
10699    /// zsh's internal representation: `-` lexes to `Dash` = 0x9b, `*`
10700    /// to `Star`, etc.). zsh untokenizes via `untokenize()` BEFORE
10701    /// surfacing words at execution time (Src/exec.c:execcmd_args).
10702    /// This test pins the round-trip: `untokenize(word)` recovers the
10703    /// user-visible form. If parse-time unmetafy ever lands the
10704    /// untokenize call becomes a no-op; the test stays green either
10705    /// way. Companion test below pins the metafied internal form.
10706    #[test]
10707    fn parse_simple_command_words_unmetafied_like_zsh_anchored() {
10708        let _g = crate::test_util::global_state_lock();
10709        let prog = parse("ls -la /tmp").unwrap();
10710        match &prog.lists[0].sublist.pipe.cmd {
10711            ZshCommand::Simple(s) => {
10712                let untok: Vec<String> = s
10713                    .words
10714                    .iter()
10715                    .map(|w| crate::ported::lex::untokenize(w))
10716                    .collect();
10717                assert_eq!(
10718                    untok,
10719                    vec!["ls", "-la", "/tmp"],
10720                    "untokenize(word) must yield the user-visible form"
10721                );
10722            }
10723            other => panic!("expected Simple, got {other:?}"),
10724        }
10725    }
10726
10727    /// Pin the OBSERVED zshrs contract: simple-command word array
10728    /// contains metafied bytes. This is the active (passing) version
10729    /// of the anchor above — it documents zshrs's current internal
10730    /// representation. If zshrs starts unmetafying at parse time, this
10731    /// test will FAIL and the anchor-style test above will start passing.
10732    #[test]
10733    fn parse_simple_command_words_metafied_internal_form() {
10734        let _g = crate::test_util::global_state_lock();
10735        let prog = parse("ls -la /tmp").unwrap();
10736        match &prog.lists[0].sublist.pipe.cmd {
10737            ZshCommand::Simple(s) => {
10738                assert_eq!(s.words.len(), 3);
10739                assert_eq!(s.words[0], "ls");
10740                assert_eq!(s.words[2], "/tmp");
10741                // s.words[1] contains the metafied `-` (`\u{9b}` Dash byte)
10742                // followed by "la". Don't pin the exact byte form (it
10743                // may change); pin that the length is right.
10744                assert_eq!(s.words[1].chars().count(), 3, "`-la` is 3 chars");
10745                assert!(s.words[1].ends_with("la"));
10746            }
10747            other => panic!("expected Simple, got {other:?}"),
10748        }
10749    }
10750
10751    // ─── zsh-corpus pins for parser: structural shapes ────────────────
10752
10753    /// Empty input — parse succeeds, lists may be empty.
10754    #[test]
10755    fn parse_corpus_empty_input_no_error() {
10756        let _g = crate::test_util::global_state_lock();
10757        let prog = parse("").unwrap();
10758        assert!(
10759            prog.lists.is_empty() || prog.lists.len() <= 1,
10760            "empty input → 0 or 1 list, got {}",
10761            prog.lists.len()
10762        );
10763    }
10764
10765    /// Comment-only input parses as empty.
10766    #[test]
10767    fn parse_corpus_comment_only_no_error() {
10768        let _g = crate::test_util::global_state_lock();
10769        let r = parse("# just a comment");
10770        assert!(r.is_ok(), "comment-only parse should succeed");
10771    }
10772
10773    /// `cmd1; cmd2` — two top-level lists or two sublists.
10774    #[test]
10775    fn parse_corpus_semicolon_separates_commands() {
10776        let _g = crate::test_util::global_state_lock();
10777        let prog = parse("echo a; echo b").unwrap();
10778        // We pin: parse produces > 0 lists/sublists; details vary.
10779        assert!(!prog.lists.is_empty(), "non-empty parse");
10780    }
10781
10782    /// `a && b` — DAMPER joins into a sublist chain.
10783    #[test]
10784    fn parse_corpus_logical_and_parses() {
10785        let _g = crate::test_util::global_state_lock();
10786        let r = parse("true && false");
10787        assert!(r.is_ok(), "`a && b` parses cleanly");
10788    }
10789
10790    /// `a || b` — DBAR.
10791    #[test]
10792    fn parse_corpus_logical_or_parses() {
10793        let _g = crate::test_util::global_state_lock();
10794        let r = parse("false || true");
10795        assert!(r.is_ok(), "`a || b` parses cleanly");
10796    }
10797
10798    /// `a | b` pipeline.
10799    #[test]
10800    fn parse_corpus_pipeline_parses() {
10801        let _g = crate::test_util::global_state_lock();
10802        let r = parse("echo hi | cat");
10803        assert!(r.is_ok(), "`a | b` parses");
10804    }
10805
10806    /// `if true; then echo x; fi` — basic if-then-fi block.
10807    #[test]
10808    fn parse_corpus_if_then_fi_parses() {
10809        let _g = crate::test_util::global_state_lock();
10810        let r = parse("if true; then echo x; fi");
10811        assert!(r.is_ok(), "if/then/fi parses cleanly");
10812    }
10813
10814    /// `for i in 1 2 3; do echo $i; done`.
10815    #[test]
10816    fn parse_corpus_for_do_done_parses() {
10817        let _g = crate::test_util::global_state_lock();
10818        let r = parse("for i in 1 2 3; do echo $i; done");
10819        assert!(r.is_ok(), "for/do/done parses cleanly");
10820    }
10821
10822    /// `while true; do break; done`.
10823    #[test]
10824    fn parse_corpus_while_do_done_parses() {
10825        let _g = crate::test_util::global_state_lock();
10826        let r = parse("while true; do break; done");
10827        assert!(r.is_ok(), "while/do/done parses cleanly");
10828    }
10829
10830    /// `case x in (a) echo A;; esac` — case statement.
10831    #[test]
10832    fn parse_corpus_case_esac_parses() {
10833        let _g = crate::test_util::global_state_lock();
10834        let r = parse("case x in (a) echo A;; esac");
10835        assert!(r.is_ok(), "case/esac parses cleanly");
10836    }
10837
10838    /// Function definition `f() { echo x }`.
10839    #[test]
10840    fn parse_corpus_function_def_parses() {
10841        let _g = crate::test_util::global_state_lock();
10842        let r = parse("f() { echo x }");
10843        assert!(r.is_ok(), "f() {{ ... }} parses cleanly");
10844    }
10845
10846    /// `(subshell echo a)` — subshell.
10847    #[test]
10848    fn parse_corpus_subshell_parens_parses() {
10849        let _g = crate::test_util::global_state_lock();
10850        let r = parse("( echo a )");
10851        assert!(r.is_ok(), "subshell parses cleanly");
10852    }
10853
10854    // ═══════════════════════════════════════════════════════════════════
10855    // C-parity tests pinning Src/parse.c. Tests that capture KNOWN
10856    // ZSHRS BUGS use #[ignore = "ZSHRS BUG: …"].
10857    // ═══════════════════════════════════════════════════════════════════
10858
10859    /// `empty_eprog(p)` returns true on an eprog with empty `prog`.
10860    /// C `Src/parse.c:584`:
10861    ///   `return (!p || !p->prog || *p->prog == WCB_END());`
10862    /// Rust port at parse.rs:685 — `p.prog.is_empty() || p.prog[0] == WCB_END()`.
10863    #[test]
10864    fn empty_eprog_empty_prog_returns_true() {
10865        let _g = crate::test_util::global_state_lock();
10866        let p = crate::ported::zsh_h::eprog::default();
10867        assert!(empty_eprog(&p), "empty prog vec → empty_eprog true");
10868    }
10869
10870    /// `empty_eprog(p)` returns true when first wordcode is WCB_END.
10871    /// C: `*p->prog == WCB_END()`.
10872    #[test]
10873    fn empty_eprog_first_wcb_end_returns_true() {
10874        let _g = crate::test_util::global_state_lock();
10875        let mut p = crate::ported::zsh_h::eprog::default();
10876        p.prog.push(WCB_END());
10877        assert!(empty_eprog(&p), "prog[0]==WCB_END → empty_eprog true");
10878    }
10879
10880    /// `empty_eprog(p)` returns false for non-empty non-END prog.
10881    #[test]
10882    fn empty_eprog_non_empty_non_end_returns_false() {
10883        let _g = crate::test_util::global_state_lock();
10884        let mut p = crate::ported::zsh_h::eprog::default();
10885        // Push some non-END wordcode (1 is arbitrary non-zero, not WCB_END).
10886        p.prog.push(1);
10887        assert!(!empty_eprog(&p), "non-END first opcode → false");
10888    }
10889
10890    /// `ecstrcode("")` returns a wordcode for the empty string. C
10891    /// `Src/parse.c:346-ish` ecstrcode interns strings in `ecbuf`.
10892    /// Pin: same call returns same wordcode (deterministic intern).
10893    #[test]
10894    fn ecstrcode_empty_string_returns_deterministic_code() {
10895        let _g = crate::test_util::global_state_lock();
10896        init_parse();
10897        let a = ecstrcode("");
10898        let b = ecstrcode("");
10899        assert_eq!(a, b, "intern of '' must be deterministic");
10900    }
10901
10902    /// `ecstrcode` of two different strings returns different codes.
10903    #[test]
10904    fn ecstrcode_distinct_strings_get_distinct_codes() {
10905        let _g = crate::test_util::global_state_lock();
10906        init_parse();
10907        let a = ecstrcode("foo");
10908        let b = ecstrcode("bar");
10909        // Should differ — if equal, intern table collapsed two different
10910        // strings to the same key (bug).
10911        assert_ne!(a, b, "different strings must intern to different codes");
10912    }
10913
10914    /// `parse_event(ENDINPUT)` on empty input returns None.
10915    /// C `Src/parse.c:715-ish` — empty token stream → no program.
10916    #[test]
10917    fn parse_event_empty_returns_none() {
10918        let _g = crate::test_util::global_state_lock();
10919        init_parse();
10920        // Empty input typically yields no program; needs lex state.
10921        let r = parse_event(crate::ported::lex::ENDINPUT);
10922        assert!(r.is_none(), "no tokens → no event");
10923    }
10924
10925    // ═══════════════════════════════════════════════════════════════════
10926    // Additional C-parity tests for Src/parse.c.
10927    // ═══════════════════════════════════════════════════════════════════
10928
10929    /// c:399 — `ecadd(c)` returns the index where `c` was placed,
10930    /// not the post-increment value. Sequential ecadd calls return
10931    /// strictly increasing indices.
10932    #[test]
10933    fn ecadd_returns_strictly_increasing_indices() {
10934        let _g = crate::test_util::global_state_lock();
10935        init_parse();
10936        let i0 = ecadd(0xDEAD);
10937        let i1 = ecadd(0xBEEF);
10938        let i2 = ecadd(0xC0DE);
10939        assert!(
10940            i1 > i0,
10941            "ecadd indices must strictly increase, got {i0} then {i1}"
10942        );
10943        assert!(
10944            i2 > i1,
10945            "ecadd indices must strictly increase, got {i1} then {i2}"
10946        );
10947        assert_eq!(i1, i0 + 1, "consecutive ecadds advance by 1");
10948        assert_eq!(i2, i1 + 1, "consecutive ecadds advance by 1");
10949    }
10950
10951    /// c:413 — `ecdel(p)` removes one wordcode, shrinks ecused by 1.
10952    /// Pin: subsequent ecadd reuses freed slot (ecused decreased).
10953    #[test]
10954    fn ecdel_shrinks_ecused_by_one() {
10955        let _g = crate::test_util::global_state_lock();
10956        init_parse();
10957        let _i0 = ecadd(0xA);
10958        let i1 = ecadd(0xB);
10959        let _i2 = ecadd(0xC);
10960        let next_before = ECUSED.get();
10961        ecdel(i1);
10962        let next_after = ECUSED.get();
10963        assert_eq!(
10964            next_after,
10965            next_before - 1,
10966            "ecdel must decrement ecused by exactly 1"
10967        );
10968    }
10969
10970    /// c:399-405 — `ecadd` after exhausting buffer must grow it (no
10971    /// panic on push past current eclen). Pin: 1000 adds don't crash.
10972    #[test]
10973    fn ecadd_grows_buffer_on_demand() {
10974        let _g = crate::test_util::global_state_lock();
10975        init_parse();
10976        for i in 0..1000 {
10977            ecadd(i as u32);
10978        }
10979        // No panic = grow path works.
10980        assert!(ECUSED.get() >= 1000, "1000 adds → ecused ≥ 1000");
10981    }
10982
10983    /// c:426 — `ecstrcode` of short strings (≤4 bytes) returns a
10984    /// packed inline wordcode (not an offset into the string region).
10985    /// Pin: identical short strings get identical codes.
10986    #[test]
10987    fn ecstrcode_short_strings_are_deterministic() {
10988        let _g = crate::test_util::global_state_lock();
10989        init_parse();
10990        let a = ecstrcode("ab");
10991        let b = ecstrcode("ab");
10992        assert_eq!(a, b, "same short string must intern to same code");
10993    }
10994
10995    /// c:426 — long strings (>4 bytes) hit the deduped string region.
10996    /// Pin: same long string returns same code on repeat (registry
10997    /// dedupes).
10998    #[test]
10999    fn ecstrcode_long_strings_dedupe_in_registry() {
11000        let _g = crate::test_util::global_state_lock();
11001        init_parse();
11002        let a = ecstrcode("a-much-longer-test-string");
11003        let b = ecstrcode("a-much-longer-test-string");
11004        assert_eq!(a, b, "registry must dedupe identical long strings");
11005    }
11006
11007    /// `clear_hdocs()` is idempotent — calling twice in a row leaves
11008    /// HDOCS = None and LEX_HEREDOCS empty.
11009    #[test]
11010    fn clear_hdocs_is_idempotent() {
11011        let _g = crate::test_util::global_state_lock();
11012        clear_hdocs();
11013        clear_hdocs();
11014        HDOCS.with_borrow(|h| assert!(h.is_none(), "HDOCS must be None"));
11015        LEX_HEREDOCS.with_borrow(|v| assert!(v.is_empty(), "LEX_HEREDOCS must be empty"));
11016    }
11017
11018    /// `init_parse()` resets parse state to known empty defaults.
11019    /// Multiple init_parse calls are safe (idempotent).
11020    #[test]
11021    fn init_parse_is_idempotent() {
11022        let _g = crate::test_util::global_state_lock();
11023        init_parse();
11024        init_parse();
11025        // No panic = pass.
11026    }
11027
11028    /// `empty_eprog` returns true for a default-constructed eprog
11029    /// (empty prog vec).
11030    #[test]
11031    fn empty_eprog_true_for_empty_prog() {
11032        let _g = crate::test_util::global_state_lock();
11033        let p = eprog {
11034            prog: Vec::new(),
11035            ..Default::default()
11036        };
11037        assert!(empty_eprog(&p), "empty prog vec → empty eprog");
11038    }
11039
11040    /// `empty_eprog` returns true when prog[0] == WCB_END().
11041    #[test]
11042    fn empty_eprog_true_for_end_only_prog() {
11043        let _g = crate::test_util::global_state_lock();
11044        let p = eprog {
11045            prog: vec![WCB_END()],
11046            ..Default::default()
11047        };
11048        assert!(empty_eprog(&p), "WCB_END as first opcode → empty");
11049    }
11050
11051    /// `ecadjusthere(p, d)` is safe to call when HDOCS is None.
11052    #[test]
11053    fn ecadjusthere_safe_when_hdocs_none() {
11054        let _g = crate::test_util::global_state_lock();
11055        clear_hdocs();
11056        // No panic = pass.
11057        ecadjusthere(0, 0);
11058        ecadjusthere(100, -5);
11059        ecadjusthere(0, 10);
11060    }
11061
11062    /// `ecispace(p, n)` with n=0 is a no-op.
11063    #[test]
11064    fn ecispace_zero_n_is_noop() {
11065        let _g = crate::test_util::global_state_lock();
11066        init_parse();
11067        let before = ECUSED.get();
11068        ecispace(0, 0);
11069        let after = ECUSED.get();
11070        assert_eq!(before, after, "ecispace(_, 0) must not advance ecused");
11071    }
11072
11073    // ═══════════════════════════════════════════════════════════════════
11074    // Additional C-parity tests for Src/parse.c
11075    // c:146 parse_context_save / c:191 parse_context_restore /
11076    // c:225 ecadjusthere / c:293 ecadd / c:346 ecstrcode / c:574 init_parse /
11077    // c:685 empty_eprog / c:693 clear_hdocs / c:786 parse_list / c:815 parse_cond
11078    // c:2234 par_wordlist / c:2249 par_nl_wordlist
11079    // ═══════════════════════════════════════════════════════════════════
11080
11081    /// c:293 — `ecadd` returns usize (compile-time type pin).
11082    #[test]
11083    fn ecadd_returns_usize_type() {
11084        let _g = crate::test_util::global_state_lock();
11085        init_parse();
11086        let _: usize = ecadd(0);
11087    }
11088
11089    /// c:346 — `ecstrcode` returns u32 (compile-time type pin).
11090    #[test]
11091    fn ecstrcode_returns_u32_type() {
11092        let _g = crate::test_util::global_state_lock();
11093        init_parse();
11094        let _: u32 = ecstrcode("");
11095    }
11096
11097    /// c:346 — `ecstrcode("")` empty string is safe.
11098    #[test]
11099    fn ecstrcode_empty_string_no_panic() {
11100        let _g = crate::test_util::global_state_lock();
11101        init_parse();
11102        let _ = ecstrcode("");
11103    }
11104
11105    /// c:346 — `ecstrcode` is deterministic for same input.
11106    #[test]
11107    fn ecstrcode_is_deterministic() {
11108        let _g = crate::test_util::global_state_lock();
11109        init_parse();
11110        for s in ["", "a", "abc", "hello world"] {
11111            let first = ecstrcode(s);
11112            for _ in 0..3 {
11113                assert_eq!(
11114                    ecstrcode(s),
11115                    first,
11116                    "ecstrcode({:?}) must be deterministic",
11117                    s
11118                );
11119            }
11120        }
11121    }
11122
11123    /// c:786 — `parse_list` returns Option<eprog>.
11124    #[test]
11125    fn parse_list_returns_option_eprog_type() {
11126        let _g = crate::test_util::global_state_lock();
11127        init_parse();
11128        let _: Option<eprog> = parse_list();
11129    }
11130
11131    /// c:815 — `parse_cond` returns Option<eprog>.
11132    #[test]
11133    fn parse_cond_returns_option_eprog_type() {
11134        let _g = crate::test_util::global_state_lock();
11135        init_parse();
11136        let _: Option<eprog> = parse_cond();
11137    }
11138
11139    /// c:2234 — `par_wordlist` returns Vec<String>.
11140    #[test]
11141    fn par_wordlist_returns_vec_string_type() {
11142        let _g = crate::test_util::global_state_lock();
11143        init_parse();
11144        let _: Vec<String> = par_wordlist();
11145    }
11146
11147    /// c:2249 — `par_nl_wordlist` returns Vec<String>.
11148    #[test]
11149    fn par_nl_wordlist_returns_vec_string_type() {
11150        let _g = crate::test_util::global_state_lock();
11151        init_parse();
11152        let _: Vec<String> = par_nl_wordlist();
11153    }
11154
11155    /// c:693 — `clear_hdocs` deterministic state after call (no-panic).
11156    #[test]
11157    fn clear_hdocs_deterministic_after_call() {
11158        let _g = crate::test_util::global_state_lock();
11159        clear_hdocs();
11160        clear_hdocs();
11161    }
11162
11163    /// c:225 — `ecadjusthere(0, 0)` is a no-op (no delta).
11164    #[test]
11165    fn ecadjusthere_zero_delta_no_panic() {
11166        let _g = crate::test_util::global_state_lock();
11167        ecadjusthere(0, 0);
11168    }
11169
11170    /// c:225 — `ecadjusthere` is safe for arbitrary positions.
11171    #[test]
11172    fn ecadjusthere_arbitrary_pos_no_panic() {
11173        let _g = crate::test_util::global_state_lock();
11174        for p in [0usize, 1, 100, 9999] {
11175            ecadjusthere(p, 0);
11176            ecadjusthere(p, 1);
11177            ecadjusthere(p, -1);
11178        }
11179    }
11180
11181    // ═══════════════════════════════════════════════════════════════════
11182    // Additional C-parity tests for Src/parse.c FD_* accessors
11183    // c:3127 fdmagic / c:3131 fdflags / c:3133 fdother / c:3140 fdversion /
11184    // c:3145 fdhflags / c:3146 fdhtail / c:3147 fdhbldflags
11185    // ═══════════════════════════════════════════════════════════════════
11186
11187    fn build_fd_header() -> Vec<u32> {
11188        let mut buf = vec![0u32; FD_PRELEN + 32];
11189        buf[0] = FD_MAGIC; // pre[0] magic
11190        buf[1] = (0x12u32) | (0x00ABCDEFu32 << 8); // flags=0x12, other=0xABCDEF
11191                                                   // Embed version string starting at pre[2].
11192        let ver = b"5.9\0";
11193        for (i, chunk) in ver.chunks(4).enumerate() {
11194            let mut word = [0u8; 4];
11195            word[..chunk.len()].copy_from_slice(chunk);
11196            buf[2 + i] = u32::from_le_bytes(word);
11197        }
11198        buf[FD_PRELEN - 1] = (FD_PRELEN as u32) + 8; // header-len slot
11199        buf
11200    }
11201
11202    /// c:3127 — `fdmagic(f)` returns pre[0] verbatim.
11203    #[test]
11204    fn fdmagic_returns_pre_zero_word() {
11205        let buf = build_fd_header();
11206        assert_eq!(fdmagic(&buf), FD_MAGIC, "fdmagic = pre[0]");
11207    }
11208
11209    /// c:3131 — `fdflags` extracts low byte of pre[1].
11210    #[test]
11211    fn fdflags_low_byte_extraction() {
11212        let buf = build_fd_header();
11213        assert_eq!(fdflags(&buf), 0x12, "flags = pre[1] & 0xff");
11214    }
11215
11216    /// c:3133 — `fdother` extracts high 24 bits of pre[1].
11217    #[test]
11218    fn fdother_high_24_bits_extraction() {
11219        let buf = build_fd_header();
11220        assert_eq!(
11221            fdother(&buf),
11222            0x00ABCDEF,
11223            "other = pre[1] >> 8 & 0x00ffffff"
11224        );
11225    }
11226
11227    /// c:3132 — `fdsetflags` writes low byte, preserves high 24 bits.
11228    #[test]
11229    fn fdsetflags_preserves_high_24_bits() {
11230        let mut buf = build_fd_header();
11231        let other_before = fdother(&buf);
11232        fdsetflags(&mut buf, 0x42);
11233        assert_eq!(fdflags(&buf), 0x42, "new flags written");
11234        assert_eq!(fdother(&buf), other_before, "high 24 bits preserved");
11235    }
11236
11237    /// c:3134 — `fdsetother` writes high 24 bits, preserves low byte.
11238    #[test]
11239    fn fdsetother_preserves_low_byte() {
11240        let mut buf = build_fd_header();
11241        let flags_before = fdflags(&buf);
11242        fdsetother(&mut buf, 0x00DEADBE);
11243        assert_eq!(fdother(&buf), 0x00DEADBE, "new other written");
11244        assert_eq!(fdflags(&buf), flags_before, "low byte preserved");
11245    }
11246
11247    /// c:3134 — `fdsetother` clamps to 24 bits (caller-passed high bits dropped).
11248    #[test]
11249    fn fdsetother_clamps_to_24_bits() {
11250        let mut buf = build_fd_header();
11251        fdsetother(&mut buf, 0xFF_FFFF_FF);
11252        // Only the low 24 bits land in `other`.
11253        assert_eq!(fdother(&buf), 0x00FF_FFFF, "high bits dropped");
11254    }
11255
11256    /// c:3140 — `fdversion(buf)` returns String (compile-time type pin).
11257    #[test]
11258    fn fdversion_returns_string_type() {
11259        let buf = build_fd_header();
11260        let _: String = fdversion(&buf);
11261    }
11262
11263    /// c:3140 — `fdversion` reads the NUL-terminated string from pre[2..].
11264    #[test]
11265    fn fdversion_reads_until_nul() {
11266        let buf = build_fd_header();
11267        assert_eq!(fdversion(&buf), "5.9", "version read until NUL");
11268    }
11269
11270    /// c:3145 — `fdhflags(h)` returns low 2 bits of flags.
11271    #[test]
11272    fn fdhflags_low_two_bits() {
11273        let h = fdhead {
11274            start: 0,
11275            len: 0,
11276            npats: 0,
11277            strs: 0,
11278            hlen: 0,
11279            flags: 0b1011, // tail=2, kshload bits = 0b11
11280        };
11281        assert_eq!(fdhflags(&h), 0b11, "flags = h.flags & 0x3");
11282    }
11283
11284    /// c:3146 — `fdhtail(h)` returns high 30 bits (shifted right by 2).
11285    #[test]
11286    fn fdhtail_shift_right_two() {
11287        let h = fdhead {
11288            start: 0,
11289            len: 0,
11290            npats: 0,
11291            strs: 0,
11292            hlen: 0,
11293            flags: (0x12_3456 << 2) | 0x3,
11294        };
11295        assert_eq!(fdhtail(&h), 0x12_3456, "tail = h.flags >> 2");
11296    }
11297
11298    /// c:3147 — `fdhbldflags(flags, tail)` packs into single u32.
11299    #[test]
11300    fn fdhbldflags_packs_flags_low_tail_high() {
11301        let packed = fdhbldflags(0x3, 0x42);
11302        assert_eq!(packed & 0x3, 0x3, "low 2 bits = flags");
11303        assert_eq!(packed >> 2, 0x42, "high 30 bits = tail");
11304    }
11305
11306    /// c:3145-3147 — `fdhflags(h)`+`fdhtail(h)` round-trip via fdhbldflags.
11307    #[test]
11308    fn fdh_round_trip_via_bldflags() {
11309        for (flags, tail) in [(0u32, 0u32), (1, 100), (2, 0xABC), (3, 0xFFFF)] {
11310            let packed = fdhbldflags(flags, tail);
11311            let h = fdhead {
11312                start: 0,
11313                len: 0,
11314                npats: 0,
11315                strs: 0,
11316                hlen: 0,
11317                flags: packed,
11318            };
11319            assert_eq!(fdhflags(&h), flags, "flags round-trips");
11320            assert_eq!(fdhtail(&h), tail, "tail round-trips");
11321        }
11322    }
11323
11324    /// c:8271 — `firstfdhead_offset()` returns FD_PRELEN constant.
11325    #[test]
11326    fn firstfdhead_offset_returns_prelen() {
11327        assert_eq!(
11328            firstfdhead_offset(),
11329            FD_PRELEN,
11330            "first header starts after prelude"
11331        );
11332    }
11333
11334    /// c:3127 — `fdmagic` differentiates FD_MAGIC from FD_OMAGIC.
11335    #[test]
11336    fn fdmagic_differentiates_magic_omagic() {
11337        let mut buf = vec![FD_MAGIC; FD_PRELEN];
11338        assert_eq!(fdmagic(&buf), FD_MAGIC);
11339        buf[0] = FD_OMAGIC;
11340        assert_eq!(fdmagic(&buf), FD_OMAGIC, "swapped magic readable");
11341        assert_ne!(FD_MAGIC, FD_OMAGIC, "the two magics differ");
11342    }
11343}