Skip to main content

zsh/ported/
lex.rs

1//! Zsh lexical analyzer - Direct port from zsh/Src/lex.c
2//!
3//! This lexer tokenizes zsh shell input into a stream of tokens.
4//! It handles all zsh-specific syntax including:
5//! - Single/double/dollar quotes
6//! - Command substitution $(...)  and `...`
7//! - Arithmetic $((...))
8//! - Parameter expansion ${...}
9//! - Process substitution <(...) >(...)
10//! - Here documents
11//! - All redirection operators
12//! - Comments
13//! - Continuation lines
14
15pub use super::zsh_h::{
16    lextok, AMPER, AMPERBANG, AMPOUTANG, BANG_TOK, BARAMP, BAR_TOK, CASE, COPROC, DAMPER, DBAR,
17    DINANG, DINANGDASH, DINBRACK, DINPAR, DOLOOP, DONE, DOUTANG, DOUTANGAMP, DOUTANGAMPBANG,
18    DOUTANGBANG, DOUTBRACK, DOUTPAR, DSEMI, ELIF, ELSE, ENDINPUT, ENVARRAY, ENVSTRING, ESAC, FI,
19    FOR, FOREACH, FUNC, IF, INANGAMP, INANG_TOK, INBRACE_TOK, INOUTANG, INOUTPAR, INPAR_TOK,
20    IS_REDIROP, LEXERR, LEXFLAGS_ACTIVE, LEXFLAGS_COMMENTS, LEXFLAGS_COMMENTS_KEEP,
21    LEXFLAGS_COMMENTS_STRIP, LEXFLAGS_NEWLINE, LEXFLAGS_ZLE, NEWLIN, NOCORRECT, NULLTOK, OUTANGAMP,
22    OUTANGAMPBANG, OUTANGBANG, OUTANG_TOK, OUTBRACE_TOK, OUTPAR_TOK, REPEAT, SELECT, SEMI, SEMIAMP,
23    SEMIBAR, SEPER, STRING_LEX, THEN, TIME, TRINANG, TYPESET, UNTIL, WHILE, ZEND,
24};
25pub use crate::heredoc_ast::HereDoc;
26use crate::ported::context::{zcontext_restore, zcontext_save};
27use crate::ported::hashtable::{aliastab_lock, reswdtab_lock, sufaliastab_lock};
28use crate::ported::hist::{hist_in_word, strinbeg, strinend};
29use crate::ported::input::{inpop, inpush};
30use crate::ported::parse::HDOCS;
31use crate::ported::prompt::{cmdpop, cmdpush, CMDSTACK};
32use crate::ported::string::dupstring_wlen;
33use crate::ported::utils::{errflag, spckword, zerr, ERRFLAG_ERROR};
34use crate::ported::zle::compcore::{WB, WE};
35use crate::ported::zsh_h::{
36    alias, interact, isset, lex_stack, lexbufstate, unset, Bang, Bar, Bnull, Bnullkeep, Comma,
37    Dash, Dnull, Equals, Hat, Inang, Inbrace, Inbrack, Inpar, Inparmath, Marker, Meta, Nularg,
38    Outang, OutangProc, Outbrace, Outbrack, Outpar, Outparmath, Pound, Qstring, Qtick, Quest,
39    Snull, Star, Stringg, Tick, Tilde, ALIASESOPT, CORRECT, CORRECTALL, CSHJUNKIEQUOTES, CS_BQUOTE,
40    CS_BRACE, CS_BRACEPAR, CS_CMDSUBST, CS_CURSH, CS_DQUOTE, CS_HEREDOC, CS_HEREDOCD, CS_MATH,
41    CS_MATHSUBST, CS_QUOTE, ERRFLAG_INT, HISTALLOWCLOBBER, IGNOREBRACES, IGNORECLOSEBRACES,
42    INP_ALIAS, INP_CONT, INTERACTIVECOMMENTS, KSHGLOB, POSIXALIASES, RCQUOTES, SHGLOB, SHINSTDIN,
43    SHORTLOOPS,
44    SHORTREPEAT, ZCONTEXT_LEX, ZCONTEXT_PARSE,
45};
46use crate::ported::ztype_h::itok;
47use crate::DPUTS;
48use serde::{Deserialize, Serialize};
49use std::collections::VecDeque;
50use std::sync::atomic::Ordering;
51
52/// zsh/Src/lex.c:216 `lex_context_save`. After save, the lexer
53/// is in a clean state suitable for parsing a nested input (command
54/// substitution body, here-doc terminator, eval'd string).
55pub fn lex_context_save(ls: &mut lex_stack) {
56    // c:218-239 — copy live state into the stack. Mirrors C
57    // field-by-field; `toplevel` param dropped because C does
58    // `(void)toplevel;` (unused).
59    ls.dbparens = LEX_DBPARENS.get() as i32;
60    ls.isfirstln = LEX_ISFIRSTLN.get() as i32;
61    ls.isfirstch = LEX_ISFIRSTCH.get() as i32;
62    ls.lexflags = LEX_LEXFLAGS.get();
63    ls.tok = tok();
64    ls.tokstr = LEX_TOKSTR.with_borrow_mut(|t| t.take());
65    // `zshlextext` (c:225) — pointer alias of `tokstr` after
66    // untokenization. zshrs derives it on demand from `tokstr` +
67    // `untokenize` so there's no separate global to stash.
68    ls.zshlextext = None;
69    LEX_LEXBUF.with_borrow_mut(|b| {
70        ls.lexbuf.ptr = b.ptr.take();
71        ls.lexbuf.siz = b.siz;
72        ls.lexbuf.len = b.len;
73    });
74    ls.lex_add_raw = LEX_LEX_ADD_RAW.get();
75    ls.tokstr_raw = LEX_TOKSTR_RAW.with_borrow_mut(|t| t.take());
76    LEX_LEXBUF_RAW.with_borrow_mut(|b| {
77        ls.lexbuf_raw.ptr = b.ptr.take();
78        ls.lexbuf_raw.siz = b.siz;
79        ls.lexbuf_raw.len = b.len;
80    });
81    ls.lexstop = LEX_LEXSTOP.get() as i32;
82    ls.toklineno = LEX_TOKLINENO.get() as i64;
83    // NOTE: LEX_UNGET_BUF is deliberately NOT stashed here — `$(...)`
84    // bodies use it as a cross-context handoff into the nested parse
85    // (see the hungetc flow at lex.rs `all_plain` / gettokstr), so a
86    // blanket take here truncates every cmdsubst body. Nested lexes
87    // that must not see the suspended parse's ungets (the
88    // syntax-highlight walk) isolate it at their own call site.
89
90    // c:235-238 — reset live state to defaults so a nested parse
91    // starts from a clean slate. tokstr/lexbuf zeroed; lexbuf.siz
92    // reset to 256 (the C-source initial alloc); raw buffers
93    // wiped, lex_add_raw cleared.
94    set_tokstr(None);
95    LEX_LEXBUF.with_borrow_mut(|b| {
96        b.ptr = Some(String::with_capacity(256));
97        b.siz = 256;
98        b.len = 0;
99    });
100    LEX_TOKSTR_RAW.with_borrow_mut(|t| *t = None);
101    LEX_LEXBUF_RAW.with_borrow_mut(|b| {
102        b.ptr = None;
103        b.siz = 0;
104        b.len = 0;
105    });
106    LEX_LEX_ADD_RAW.set(0);
107}
108
109/// zsh/Src/lex.c:245 `lex_context_restore`. Inverse of
110/// `lex_context_save`. Called after the nested parse completes.
111pub fn lex_context_restore(ls: &mut lex_stack) {
112    // c:249-261 — copy stack state back into live fields.
113    LEX_DBPARENS.set(ls.dbparens != 0);
114    LEX_ISFIRSTLN.set(ls.isfirstln != 0);
115    LEX_ISFIRSTCH.set(ls.isfirstch != 0);
116    LEX_LEXFLAGS.set(ls.lexflags);
117    set_tok(ls.tok);
118    set_tokstr(ls.tokstr.take());
119    // ls.zshlextext discarded — derived from tokstr (see save).
120    let _ = ls.zshlextext.take();
121    LEX_LEXBUF.with_borrow_mut(|b| {
122        b.ptr = Some(ls.lexbuf.ptr.take().unwrap_or_default());
123        b.siz = ls.lexbuf.siz;
124        b.len = ls.lexbuf.len;
125    });
126    LEX_LEX_ADD_RAW.set(ls.lex_add_raw);
127    LEX_TOKSTR_RAW.with_borrow_mut(|t| *t = ls.tokstr_raw.take());
128    LEX_LEXBUF_RAW.with_borrow_mut(|b| {
129        b.ptr = ls.lexbuf_raw.ptr.take();
130        b.siz = ls.lexbuf_raw.siz;
131        b.len = ls.lexbuf_raw.len;
132    });
133    LEX_LEXSTOP.set(ls.lexstop != 0);
134    LEX_TOKLINENO.set(ls.toklineno as u64);
135}
136
137/// Main lexer entry point — fetch the next token. Direct port of
138/// zsh/Src/lex.c:266 `zshlex`. Loop body matches the C source
139/// `do { ... } while (tok != ENDINPUT && exalias())` at lex.c:270-276,
140/// followed by here-doc draining (lex.c:278-306), newline tracking
141/// (lex.c:307-310), and SEMI/NEWLIN→SEPER folding (lex.c:311-312).
142pub fn zshlex() {
143    // lex.c:268-269 — early-out on prior LEXERR.
144    if tok() == LEXERR {
145        return;
146    }
147
148    // lex.c:270-276 — `do { ... } while (tok != ENDINPUT && exalias())`.
149    // The do-while re-runs gettok when exalias re-injects alias text;
150    // exalias also performs reswdtab keyword promotion (`{` → INBRACE,
151    // `if` → IF, etc.) and spell-correction. Wired one-pass for now —
152    // alias re-injection loop is a follow-up.
153    loop {
154        // lex.c:271-272 — bump inrepeat counter for `repeat N {}`
155        // detection.
156        if LEX_INREPEAT.get() > 0 {
157            LEX_INREPEAT.set(LEX_INREPEAT.get() + 1);
158        }
159        // lex.c:273-274 — `if (inrepeat_ == 3 && (isset(SHORTLOOPS) ||
160        // isset(SHORTREPEAT))) incmdpos = 1;` — at the third token after
161        // `repeat`, SHORTLOOPS/SHORTREPEAT options force back into
162        // command position so the loop body can start without an
163        // explicit `{`.
164        if LEX_INREPEAT.get() == 3 && (isset(SHORTLOOPS) || isset(SHORTREPEAT)) {
165            LEX_INCMDPOS.set(true);
166        }
167
168        // lex.c:275 — `tok = gettok();`
169        let _t = gettok();
170        set_tok(_t);
171
172        // lex.c:276 — `} while (tok != ENDINPUT && exalias());`
173        if tok() == ENDINPUT || !exalias() {
174            break;
175        }
176    }
177
178    // lex.c:277 — `nocorrect &= 1;` — clear bit 1 (lookahead-only)
179    // so the persistent low bit survives but the per-word bit is
180    // dropped.
181    LEX_NOCORRECT.set(LEX_NOCORRECT.get() & 1);
182
183    // lex.c:278-306 — drain pending here-documents at the start of
184    // a new line. Line-by-line port: walks the canonical `hdocs`
185    // linked list (`parse::HDOCS` mirrors `Src/parse.c:84 struct
186    // heredocs *hdocs;`), calls `gethere` to read each body, calls
187    // `setheredoc` to patch the wordcode redir slot. Two zshrs-only
188    // lines (annotated below) bridge body content + processed flag
189    // into the parallel AST-glue `LEX_HEREDOCS` Vec.
190    if tok() == NEWLIN || tok() == ENDINPUT {
191        // c:279 — `while (hdocs)`
192        while let Some(mut node) = HDOCS.with_borrow_mut(|h| h.take()) {
193            // c:280 — `struct heredocs *next = hdocs->next;`
194            let next: Option<Box<crate::ported::zsh_h::heredocs>> = node.next.take();
195            // c:281 — `char *doc, *munged_term;`
196            let doc: Option<String>;
197            let mut munged_term: String;
198
199            // c:283 — `hwbegin(0);` (history-build cursor — zshrs no-op)
200            // c:284 — `cmdpush(hdocs->type == REDIR_HEREDOC ? CS_HEREDOC : CS_HEREDOCD);`
201            cmdpush(if node.typ == crate::ported::zsh_h::REDIR_HEREDOC {
202                CS_HEREDOC as u8
203            } else {
204                CS_HEREDOCD as u8
205            });
206            // c:285 — `munged_term = dupstring(hdocs->str);`
207            munged_term = crate::ported::mem::dupstring(node.str.as_deref().unwrap_or(""));
208            // c:286 — `STOPHIST` (history-disable scope — zshrs no-op)
209            // c:287 — `doc = gethere(&munged_term, hdocs->type);`
210            doc = crate::ported::exec::gethere(&mut munged_term, node.typ);
211            // c:288 — `ALLOWHIST`
212            // c:289 — `cmdpop();`
213            cmdpop();
214            // c:290 — `hwend();`
215
216            // c:291 — `if (!doc)`
217            let Some(doc) = doc else {
218                // c:292 — `zerr("here document too large");`
219                zerr("here document too large");
220                // c:293-297 — while (hdocs) { next = hdocs->next; zfree(hdocs); hdocs = next; }
221                HDOCS.with_borrow_mut(|h| *h = None);
222                // c:298 — `tok = LEXERR;`
223                set_tok(LEXERR);
224                // c:299 — break out of the while.
225                break;
226            };
227            // c:301-302 — `setheredoc(hdocs->pc, REDIR_HERESTR, doc,
228            //                         hdocs->str, munged_term);`
229            crate::ported::parse::setheredoc(
230                node.pc as usize,
231                crate::ported::zsh_h::REDIR_HERESTR,
232                &doc,
233                node.str.as_deref().unwrap_or(""),
234                &munged_term,
235            );
236            // zshrs-only: write body into the parallel AST-glue
237            // LEX_HEREDOCS entry so `fill_heredoc_bodies` (parse.rs)
238            // wires it onto the matching ZshRedir.heredoc field.
239            // No C counterpart — LEX_HEREDOCS is Rust-only state.
240            LEX_HEREDOCS.with_borrow_mut(|v| {
241                for h in v.iter_mut() {
242                    if !h.processed {
243                        h.content = doc;
244                        h.processed = true;
245                        return;
246                    }
247                }
248            });
249            // c:303 — `zfree(hdocs, sizeof(struct heredocs));`
250            drop(node);
251            // c:304 — `hdocs = next;`
252            HDOCS.with_borrow_mut(|h| *h = next);
253        }
254    }
255
256    // lex.c:307-310 — track whether we just saw a newline.
257    // C uses `inbufct` to distinguish "newline at EOF" (=1)
258    // from "newline mid-input" (=-1); zshrs reads `pos < len`.
259    if tok() != NEWLIN {
260        LEX_ISNEWLIN.set(0);
261    } else {
262        LEX_ISNEWLIN.set(if LEX_POS.get() < LEX_INPUT.with_borrow(|s| s.len()) {
263            -1
264        } else {
265            1
266        });
267    }
268
269    // lex.c:311-312 — fold SEMI / NEWLIN into SEPER unless
270    // LEXFLAGS_NEWLINE is set to preserve newlines (used by
271    // ZLE for completion of partial lines).
272    if tok() == SEMI || (tok() == NEWLIN && LEX_LEXFLAGS.get() & LEXFLAGS_NEWLINE == 0) {
273        set_tok(SEPER);
274    }
275
276    // C zshlex (lex.c:266-310) does NOT update incmdpos / inredir /
277    // oldpos / infor / intypeset / incondpat. Those updates live in:
278    //   - ctxtlex (lex.c:319-368, mirrored at fn ctxtlex below) for
279    //     incmdpos / inredir / oldpos / infor;
280    //   - parse.c call sites for intypeset (parse.c:1932/2042/2047);
281    //   - cond.c par_cond_* for incondpat (Rust-only state — C tracks
282    //     pattern context implicitly via the cond grammar walker).
283    // Earlier zshrs port had duplicated the ctxtlex switch + an
284    // incondpat tracker into zshlex so the parser would get those
285    // updates "for free"; that broke the C-faithful contract of
286    // zshlex. Removed.
287}
288
289/// Lex next token AND update per-context flags. Direct port of
290/// zsh/Src/lex.c:317 `ctxtlex`. The post-token state machine
291/// at lex.c:322-358 sets `incmdpos` based on the token shape:
292/// list separators / pipes / control keywords reset to cmd-pos;
293/// word-shaped tokens leave cmd-pos. Redirections (lex.c:361-368)
294/// stash prior incmdpos and force the redir target to non-cmd-pos.
295pub fn ctxtlex() {
296    // lex.c:319 — static `oldpos` cache for redir-target restore
297    // is captured per-call here as `oldpos` below (zshrs's parser
298    // re-enters ctxtlex per token, no need for static persistence).
299
300    // lex.c:321 — `zshlex();` to advance to the next token.
301    zshlex();
302
303    // c:322-358 — post-token incmdpos switch. C lists the
304    // arms exactly as enumerated below; `intypeset` is NOT set
305    // here in C (it lives in parse.c:1932/2042/2047, ported in
306    // parse.rs at the typeset-call sites).
307    match tok() {
308        // c:323-343 — separators / openers / conjunctions /
309        // control keywords — back into cmd-pos so the next token
310        // can be a fresh command.
311        SEPER | NEWLIN | SEMI | DSEMI | SEMIAMP | SEMIBAR | AMPER | AMPERBANG | INPAR_TOK
312        | INBRACE_TOK | DBAR | DAMPER | BAR_TOK | BARAMP | INOUTPAR | DOLOOP | THEN | ELIF
313        | ELSE | DOUTBRACK => {
314            LEX_INCMDPOS.set(true);
315        }
316        // c:345-353 — word/value-shaped tokens leave cmd-pos.
317        STRING_LEX | TYPESET | ENVARRAY | OUTPAR_TOK | CASE | DINBRACK => {
318            LEX_INCMDPOS.set(false);
319        }
320        // c:354-357 — `default: break;` — keep compiler happy.
321        _ => {}
322    }
323
324    // lex.c:359-360 — `infor` decay. FOR sets infor=2 so the next
325    // DINPAR can detect c-style for. After any non-DINPAR, decay
326    // to 0 (or back to 2 if we just saw FOR again).
327    if tok() != DINPAR {
328        LEX_INFOR.set(if tok() == FOR { 2 } else { 0 });
329    }
330
331    // lex.c:361-368 — redir-target context dance. After consuming
332    // a redir operator, the following token (the file path) sees
333    // incmdpos=0 even when its inherent shape would put it back
334    // in cmd-pos. After the redir target, restore from oldpos
335    // (struct field — must persist across zshlex calls).
336    if IS_REDIROP(tok()) || tok() == FOR || tok() == FOREACH || tok() == SELECT {
337        LEX_INREDIR.set(true);
338        LEX_OLDPOS.set(LEX_INCMDPOS.get());
339        LEX_INCMDPOS.set(false);
340    } else if LEX_INREDIR.get() {
341        LEX_INCMDPOS.set(LEX_OLDPOS.get());
342        LEX_INREDIR.set(false);
343    }
344}
345
346// RedirType / CondType — flat `REDIR_*` (`Src/zsh.h:377-408`) and
347// `COND_*` (`Src/zsh.h:660-679`) constants already live in
348// `super::zsh_h`. Do NOT wrap them in Rust enums here — the wrapper
349// is a fake abstraction (no C counterpart).
350//
351// LX1_* / LX2_* — flat `#define`s in `Src/lex.c:371-405`. The
352// lexer's `gettok` body uses these as the action table for the
353// first / second-tier character dispatch.
354
355/// `#define LX1_BKSLASH 0` (Src/lex.c:371).
356pub const LX1_BKSLASH: u8 = 0;
357/// `#define LX1_COMMENT 1` (Src/lex.c:372).
358pub const LX1_COMMENT: u8 = 1;
359/// `#define LX1_NEWLIN 2` (Src/lex.c:373).
360pub const LX1_NEWLIN: u8 = 2;
361/// `#define LX1_SEMI 3` (Src/lex.c:374).
362pub const LX1_SEMI: u8 = 3;
363/// `#define LX1_AMPER 5` (Src/lex.c:375).
364pub const LX1_AMPER: u8 = 5;
365/// `#define LX1_BAR 6` (Src/lex.c:376).
366pub const LX1_BAR: u8 = 6;
367/// `#define LX1_INPAR 7` (Src/lex.c:377).
368pub const LX1_INPAR: u8 = 7;
369/// `#define LX1_OUTPAR 8` (Src/lex.c:378).
370pub const LX1_OUTPAR: u8 = 8;
371/// `#define LX1_INANG 13` (Src/lex.c:379).
372pub const LX1_INANG: u8 = 13;
373/// `#define LX1_OUTANG 14` (Src/lex.c:380).
374pub const LX1_OUTANG: u8 = 14;
375/// `#define LX1_OTHER 15` (Src/lex.c:381).
376pub const LX1_OTHER: u8 = 15;
377
378/// `#define LX2_BREAK 0` (Src/lex.c:383).
379pub const LX2_BREAK: u8 = 0;
380/// `#define LX2_OUTPAR 1` (Src/lex.c:384).
381pub const LX2_OUTPAR: u8 = 1;
382/// `#define LX2_BAR 2` (Src/lex.c:385).
383pub const LX2_BAR: u8 = 2;
384/// `#define LX2_STRING 3` (Src/lex.c:386).
385pub const LX2_STRING: u8 = 3;
386/// `#define LX2_INBRACK 4` (Src/lex.c:387).
387pub const LX2_INBRACK: u8 = 4;
388/// `#define LX2_OUTBRACK 5` (Src/lex.c:388).
389pub const LX2_OUTBRACK: u8 = 5;
390/// `#define LX2_TILDE 6` (Src/lex.c:389).
391pub const LX2_TILDE: u8 = 6;
392/// `#define LX2_INPAR 7` (Src/lex.c:390).
393pub const LX2_INPAR: u8 = 7;
394/// `#define LX2_INBRACE 8` (Src/lex.c:391).
395pub const LX2_INBRACE: u8 = 8;
396/// `#define LX2_OUTBRACE 9` (Src/lex.c:392).
397pub const LX2_OUTBRACE: u8 = 9;
398/// `#define LX2_OUTANG 10` (Src/lex.c:393).
399pub const LX2_OUTANG: u8 = 10;
400/// `#define LX2_INANG 11` (Src/lex.c:394).
401pub const LX2_INANG: u8 = 11;
402/// `#define LX2_EQUALS 12` (Src/lex.c:395).
403pub const LX2_EQUALS: u8 = 12;
404/// `#define LX2_BKSLASH 13` (Src/lex.c:396).
405pub const LX2_BKSLASH: u8 = 13;
406/// `#define LX2_QUOTE 14` (Src/lex.c:397).
407pub const LX2_QUOTE: u8 = 14;
408/// `#define LX2_DQUOTE 15` (Src/lex.c:398).
409pub const LX2_DQUOTE: u8 = 15;
410/// `#define LX2_BQUOTE 16` (Src/lex.c:399).
411pub const LX2_BQUOTE: u8 = 16;
412/// `#define LX2_COMMA 17` (Src/lex.c:400).
413pub const LX2_COMMA: u8 = 17;
414/// `#define LX2_DASH 18` (Src/lex.c:401).
415pub const LX2_DASH: u8 = 18;
416/// `#define LX2_BANG 19` (Src/lex.c:402).
417pub const LX2_BANG: u8 = 19;
418/// `#define LX2_OTHER 20` (Src/lex.c:403).
419pub const LX2_OTHER: u8 = 20;
420/// `#define LX2_META 21` (Src/lex.c:404).
421pub const LX2_META: u8 = 21;
422
423/// Port of `void initlextabs(void)` from `Src/lex.c:410`. Builds the
424/// three byte-keyed action tables the lexer dispatches on.
425///
426/// `lexact1` — char → `LX1_*` action for the top-level `gettok`
427/// dispatch. Default `LX1_OTHER`; the 14 chars in `"\\q\n;!&|(){}[]<>"`
428/// get table-index actions in order.
429///
430/// `lexact2` — char → `LX2_*` action for `gettokstr`'s in-word
431/// dispatch. Default `LX2_OTHER`; the 21 chars in
432/// `";)|$[]~({}><=\\\\\'\"\`,-!"` map by index, plus `&` → `LX2_BREAK`
433/// and the Meta byte → `LX2_META`.
434///
435/// `lextok2` — char → byte-token. Identity except for the 8 magic
436/// chars that need byte-token replacement (`*`/`?`/`{`/`[`/`$`/`~`/
437/// `#`/`^` → Star/Quest/Inbrace/Inbrack/Stringg/Tilde/Pound/Hat).
438pub fn initlextabs() {
439    // c:410
440    let a1 = LEXACT1.get_or_init(|| std::sync::Mutex::new([0u8; 256]));
441    let a2 = LEXACT2.get_or_init(|| std::sync::Mutex::new([0u8; 256]));
442    let t2 = LEXTOK2.get_or_init(|| std::sync::Mutex::new([0u8; 256]));
443    let mut a1 = a1.lock().unwrap();
444    let mut a2 = a2.lock().unwrap();
445    let mut t2 = t2.lock().unwrap();
446    // c:413-417 — seed defaults.
447    for i in 0..256 {
448        a1[i] = LX1_OTHER;
449        a2[i] = LX2_OTHER;
450        t2[i] = i as u8;
451    }
452    // c:418-419 — overwrite indexed punctuation.
453    let lx1 = b"\\q\n;!&|(){}[]<>";
454    for (i, &c) in lx1.iter().enumerate() {
455        a1[c as usize] = i as u8;
456    }
457    let lx2 = b";)|$[]~({}><=\\'\"`,-!";
458    for (i, &c) in lx2.iter().enumerate() {
459        a2[c as usize] = i as u8;
460    }
461    // c:422-423 — special overrides.
462    a2[b'&' as usize] = LX2_BREAK;
463    a2[Meta as usize] = LX2_META;
464    // c:424-431 — byte-token map for the 8 magic chars.
465    t2[b'*' as usize] = Star as u8;
466    t2[b'?' as usize] = Quest as u8;
467    t2[b'{' as usize] = Inbrace as u8;
468    t2[b'[' as usize] = Inbrack as u8;
469    t2[b'$' as usize] = Stringg as u8;
470    t2[b'~' as usize] = Tilde as u8;
471    t2[b'#' as usize] = Pound as u8;
472    t2[b'^' as usize] = Hat as u8;
473}
474
475/// Initialize lexical state. Direct port of zsh/Src/lex.c:441
476/// `lexinit`. Resets dbparens / nocorrect / lexstop and sets `tok`
477/// to ENDINPUT so the next gettok starts from a known baseline.
478/// Note: `lex_init(input)` already sets equivalent defaults; this
479/// function exists for the rare case a caller wants to reset the
480/// lexer state mid-parse without re-loading input.
481pub fn lexinit() {
482    // lex.c:443 — `nocorrect = dbparens = lexstop = 0;`
483    LEX_NOCORRECT.set(0);
484    LEX_DBPARENS.set(false);
485    LEX_LEXSTOP.set(false);
486    // C has ONE `lexstop`; zshrs splits it into LEX_LEXSTOP (gates
487    // gettok) and input.rs `lexstop` (gates ingetc). The single C
488    // `lexstop = 0` here must zero BOTH halves — same paired-reset rule
489    // as inpush (input.rs:654-655). Without the input-side reset, a
490    // nested string parse (cmd-subst / eval via parse_isolated) that
491    // drained its input left `input::lexstop = true`; on the faithful
492    // single-event loop()/parse_event reader, the next iteration's
493    // ingetc then short-circuited to EOF and the shell exited after the
494    // first command containing a `$(…)`.
495    crate::ported::input::lexstop.with(|c| c.set(false));
496    // lex.c:444 — `tok = ENDINPUT;`
497    set_tok(ENDINPUT);
498}
499
500/// Add character to token buffer
501/// Port of `add(int c)` from `Src/lex.c:451`.
502fn add(c: char) {
503    LEX_LEXBUF.with_borrow_mut(|b| b.add(c));
504}
505
506/// Determine if (( is arithmetic or command
507/// Decide whether `( ... )` after a `$` is a math expression
508/// `$((...))` or a command substitution `$(...)`. Direct port of
509/// zsh/Src/lex.c:495 `cmd_or_math`. Tries dquote_parse first;
510/// if it succeeds AND the next char is `)` (closing the second
511/// paren of `(( ))`), it's math. Otherwise rewinds and treats as
512/// a command substitution.
513fn cmd_or_math() -> i32 {
514    // dash/ash have NO `(( ))` arithmetic command — `((` is just two nested
515    // subshells `( (`. Real dash runs `(( 1 + 1 ))` as the command `1` (with
516    // args `+ 1`) inside two subshells → "1: not found", non-zero. Force the
517    // command (subshell) path here so zshrs --dash/--ash matches: push back the
518    // second `(` (already consumed by the LX1_INPAR caller) and return CMD, as
519    // the rewind path below does. Runs BEFORE cmdpush so there is no cmdpop
520    // imbalance. `for ((…))` is unaffected (it returns DINPAR earlier, before
521    // cmd_or_math), and `$(( ))` arithmetic uses a different path entirely.
522    // Found by the per-mode dash-strictness sweep.
523    if crate::dash_mode::dash_strict() {
524        hungetc('(');
525        LEX_LEXSTOP.set(false);
526        return CMD_OR_MATH_CMD;
527    }
528    let oldlen = LEX_LEXBUF.with_borrow(|b| b.buf_len());
529
530    // c:501 — `cmdpush(cs_type);` — the C source takes a `cs_type`
531    // arg (CS_MATH or CS_MATHSUBST); zshrs's lone caller (gettok
532    // LX1_INPAR `((`) uses CS_MATH. The matching `cmdpop()` fires
533    // both on the math path (after success) and on the rewind path
534    // (before falling through to skipcomm, which has its own
535    // CS_CMDSUBST push/pop).
536    cmdpush(CS_MATH as u8);
537
538    // Per lex.c:498-518 — `cmd_or_math` calls `dquote_parse(')')`
539    // which fills lexbuf with ONLY the inner expression, then checks
540    // for the closing `)`. The surrounding `((` / `))` are NOT added
541    // to lexbuf.
542    if dquote_parse(')', false).is_err() {
543        // c:506 — `cmdpop();` before rewind to command-parse path.
544        cmdpop();
545        // Back up and try as command
546        while LEX_LEXBUF.with_borrow(|b| b.buf_len()) > oldlen {
547            if let Some(c) = LEX_LEXBUF.with_borrow_mut(|b| b.pop()) {
548                hungetc(c);
549            }
550        }
551        hungetc('(');
552        LEX_LEXSTOP.set(false);
553        // c:Src/lex.c:519-520 — `hungetc('('); return errflag ?
554        // CMD_OR_MATH_ERR : CMD_OR_MATH_CMD;`. The C path does NOT
555        // call skipcomm here — the caller (LX1_INPAR arm) returns
556        // INPAR_TOK and the parser walks the remaining content as
557        // ordinary tokens. Calling skipcomm() consumed the entire
558        // subshell body, so `(() { echo X; })` lost the inner anon-fn
559        // tokens. Bug #196.
560        return CMD_OR_MATH_CMD;
561    }
562
563    // Check for closing ) — matches C lex.c:511-512: success-with-`)`
564    // means `((..))` was math. Don't add `)` to lexbuf.
565    let c = hgetc();
566    if c == Some(')') {
567        // c:506 — `cmdpop();` on math success.
568        cmdpop();
569        return CMD_OR_MATH_MATH;
570    }
571
572    // c:506 — `cmdpop();` before rewind to command-parse path.
573    cmdpop();
574    // c:Src/lex.c:513-516 — CMD path: push back the peeked char,
575    // then push back the `)` that dquote_parse consumed (this is the
576    // `c = ')'; hungetc(c);` sequence at C line 515-518 below the
577    // `if (!c)` block; the fall-through into the outer `hungetc(c)`
578    // at line 522 puts the `)` back into the input stream so the
579    // next zshlex re-emits it as OUTPAR_TOK).
580    //
581    // Without this hungetc, the inner `)` is lost from the stream
582    // permanently. For source `((a|)b)` the token stream loses
583    // the inner OUTPAR — emits INPAR INPAR STRING(a) BAR STRING(b)
584    // OUTPAR instead of the correct INPAR INPAR STRING(a) BAR
585    // OUTPAR STRING(b) OUTPAR. That broke par_case on every
586    // `((alt|alt)tail)` pattern — including zinit.zsh:2946
587    // `((add-|)fpath)` which fails as "expected ')' in case pattern".
588    if let Some(c) = c {
589        hungetc(c);
590    }
591    LEX_LEXSTOP.set(false);
592    hungetc(')'); // c:515-518 — the `)` dquote_parse consumed
593
594    // Back up token
595    while LEX_LEXBUF.with_borrow(|b| b.buf_len()) > oldlen {
596        if let Some(c) = LEX_LEXBUF.with_borrow_mut(|b| b.pop()) {
597            hungetc(c);
598        }
599    }
600    hungetc('(');
601
602    // c:Src/lex.c:519-520 — same as above: return CMD_OR_MATH_CMD
603    // without skipcomm. Bug #196.
604    CMD_OR_MATH_CMD
605}
606
607/// Parse `$(...)` or `$((...))` after the `$` has been consumed.
608/// Direct port of zsh/Src/lex.c:540 `cmd_or_math_sub`. Reads
609/// the next char to discriminate: a leading `(` plus successful
610/// math parse via `cmd_or_math` → arithmetic substitution (with
611/// the open-paren retroactively rewritten to Inparmath); else
612/// command substitution via skipcomm.
613fn cmd_or_math_sub() -> i32 {
614    loop {
615        let c = hgetc();
616        if c == Some('\\') {
617            let c2 = hgetc();
618            if c2 != Some('\n') {
619                if let Some(c2) = c2 {
620                    hungetc(c2);
621                }
622                hungetc('\\');
623                LEX_LEXSTOP.set(false);
624                return if skipcomm().is_err() {
625                    CMD_OR_MATH_ERR
626                } else {
627                    CMD_OR_MATH_CMD
628                };
629            }
630            // Line continuation, try again (loop instead of recursion)
631            continue;
632        }
633
634        // Not a line continuation, process normally
635        if c == Some('(') {
636            // c:555-557 — `lexpos = lexbuf.ptr - tokstr; add(Inpar); add('(');`
637            // — lexpos is captured BEFORE the Inpar/`(` go in so a
638            // later Inparmath rewrite can target the Inpar byte.
639            let lexpos = LEX_LEXBUF.with_borrow(|b| b.buf_len());
640            add(Inpar);
641            add('(');
642            // After Inpar+`(`, we record the lexbuf length so the
643            // failure-path rewind knows where `dquote_parse`'s output
644            // ends and where our Inpar+`(` begin. Mirrors C's split
645            // between `cmd_or_math`'s `oldlen` (inside the inner call,
646            // = lexpos + 2 bytes in C) and the outer `lexbuf.ptr -= 2`
647            // that strips Inpar+`(` after `cmd_or_math` returns.
648            let after_open = LEX_LEXBUF.with_borrow(|b| b.buf_len());
649
650            // Inline of `cmd_or_math(CS_MATHSUBST)` (Src/lex.c:495).
651            // Done inline rather than calling our existing
652            // `cmd_or_math()` because that helper additionally calls
653            // `skipcomm()` on its failure path (over-port for the
654            // gettok `((` caller), which would double-consume the
655            // body when chained here.
656            cmdpush(CS_MATHSUBST as u8);
657            let dq_ok = dquote_parse(')', false).is_ok();
658            cmdpop();
659
660            if dq_ok {
661                // c:511 — `c = hgetc(); if (c == ')') return MATH;`
662                let c2 = hgetc();
663                if c2 == Some(')') {
664                    // c:559-562 — confirmed math: rewrite Inpar →
665                    // Inparmath at lexpos, append closing `)`. Inpar
666                    // and Inparmath are both 2-byte UTF-8 (`\u{88}` /
667                    // `\u{89}`); set_char_at swaps in place.
668                    LEX_LEXBUF.with_borrow_mut(|b| b.set_char_at(lexpos, Inparmath));
669                    add(')');
670                    return CMD_OR_MATH_MATH;
671                }
672                if let Some(c2) = c2 {
673                    hungetc(c2);
674                }
675                LEX_LEXSTOP.set(false);
676                // c:516 — `c = ')';` — fall through to the rewind
677                // path; the `)` that dquote_parse consumed needs
678                // to be hungetc'd. We synthesize that by setting up
679                // the loop and the final hungetc('(') as below; the
680                // `)` is implicit in `dquote_parse` having stopped
681                // at it (it was consumed but not added to lexbuf,
682                // matching C where `c = ')'` is what gets hungetc'd
683                // first before the rewind-everything loop).
684                hungetc(')');
685            } else if LEX_LEXSTOP.get() {
686                // c:519 — `else if (lexstop) return CMD_OR_MATH_ERR;`
687                return CMD_OR_MATH_ERR;
688            } else {
689                // c:522 — `hungetc(c); lexstop = 0;` — push back the
690                // char dquote_parse stopped on (caller-side handled
691                // via `dquote_parse` return value in C; in Rust we
692                // approximate by pushing nothing here — dquote_parse
693                // already left the stream positioned at the offending
694                // char, but our impl signals failure through Err
695                // without consuming it).
696                LEX_LEXSTOP.set(false);
697            }
698
699            // c:524-528 — `while (lexbuf.len > oldlen) { ... }; hungetc('(');`
700            // — back up everything dquote_parse appended to lexbuf
701            // and hungetc each in reverse order, then hungetc the
702            // single `(` that opened the math construct. The Inpar+`(`
703            // we placed BEFORE the inner call are NOT touched here;
704            // they're stripped from lexbuf after this block via direct
705            // pop (no hungetc) so they don't go back onto the input.
706            //
707            // Bug #4 in docs/BUGS.md: the previous Rust port popped
708            // ALL the way down to `lexpos` (before Inpar+`(`) and
709            // hungetc'd those bytes back, putting an Inpar token on
710            // the input stream that derailed `skipcomm`. The faithful
711            // port pops only down to `after_open` and then strips
712            // Inpar+`(` from lexbuf without hungetc'ing them.
713            while LEX_LEXBUF.with_borrow(|b| b.buf_len()) > after_open {
714                if let Some(ch) = LEX_LEXBUF.with_borrow_mut(|b| b.pop()) {
715                    hungetc(ch);
716                } else {
717                    break;
718                }
719            }
720            hungetc('(');
721            LEX_LEXSTOP.set(false);
722            // c:565-566 — `lexbuf.ptr -= 2; lexbuf.len -= 2;` — drop
723            // the Inpar+`(` we added at the top without putting them
724            // on the input stream.
725            LEX_LEXBUF.with_borrow_mut(|b| {
726                b.pop();
727                b.pop();
728            });
729        } else {
730            if let Some(c) = c {
731                hungetc(c);
732            }
733            LEX_LEXSTOP.set(false);
734        }
735
736        return if skipcomm().is_err() {
737            CMD_OR_MATH_ERR
738        } else {
739            CMD_OR_MATH_CMD
740        };
741    }
742}
743
744// ============================================================================
745// Additional parsing functions ported from lex.c
746// ============================================================================
747
748/// Check whether we're looking at valid numeric globbing syntax
749/// `<N-M>` / `<N->` / `<-M>` / `<->`. Call pointing just after the
750/// Port of `static int isnumglob(void)` from `Src/lex.c:581`.
751/// Called pointing just after the opening `<`. Looks ahead to
752/// detect zsh's numeric-glob shape `<[0-9]*-[0-9]*>` (e.g. `<->`,
753/// `<1-5>`, `<10->`). Returns `true` when the shape matches.
754/// Leaves the input stream in the same position regardless of
755/// outcome — all consumed chars are `hungetc`'d back.
756///
757/// C body:
758/// ```c
759/// while (1) {
760///     c = hgetc();
761///     if (lexstop) { lexstop = 0; break; }
762///     tbuf[n++] = c;
763///     if (!idigit(c)) {
764///         if (c != ec) break;
765///         if (ec == '>') { ret = 1; break; }
766///         ec = '>';
767///     }
768/// }
769/// while (n--) hungetc(tbuf[n]);
770/// return ret;
771/// ```
772pub fn isnumglob() -> bool {
773    // c:581 (Src/lex.c)
774    // c:583 — `int c, ec = '-', ret = 0;`. `ec` is the expected
775    // non-digit char: starts at `-`, flips to `>` after the dash.
776    let mut ec: char = '-'; // c:583
777    let mut ret = false; // c:583
778                         // c:585 — char buffer for the rewind at c:606-607.
779    let mut buf: Vec<char> = Vec::new();
780
781    loop {
782        // c:587
783        let cn = hgetc(); // c:588
784        if LEX_LEXSTOP.get() {
785            // c:589
786            LEX_LEXSTOP.set(false); // c:590
787            break; // c:591
788        }
789        let Some(cn) = cn else { break };
790        buf.push(cn); // c:593
791        if !cn.is_ascii_digit() {
792            // c:594 !idigit(c)
793            if cn != ec {
794                // c:595
795                break; // c:596
796            }
797            if ec == '>' {
798                // c:597
799                ret = true; // c:598
800                break; // c:599
801            }
802            ec = '>'; // c:601
803        }
804    }
805    // c:606-607 — `while (n--) hungetc(tbuf[n]);` — rewind in
806    // reverse order so the next hgetc sees the same bytes.
807    while let Some(ch) = buf.pop() {
808        hungetc(ch);
809    }
810    ret // c:609
811}
812
813// SPECCHARS / PATCHARS — port of `Src/zsh.h:228, 232`. Use
814// `super::zsh_h::{SPECCHARS, PATCHARS}` directly; no duplicate here.
815// IS_DASH() — port of `Src/zsh.h:242` `#define IS_DASH(x)`. Use
816// `super::zsh_h::IS_DASH(c)` at call sites.
817
818// Reserved-word table — the canonical port of `Src/hashtable.c:1076`
819// `static struct reswd reswds[]` lives in `ported::hashtable::reswd_table`
820// (built in `reswd_table::new()` at hashtable.rs:561 with the 31 entries
821// from `reswds[]`). The lexer queries it via `reswdtab_lock()` from
822// `check_reserved_word()` below; no duplicate table here.
823
824// =============================================================================
825// End of inlined `tokens.rs`. Original lex.rs body follows.
826// =============================================================================
827
828// `lexflags` — port of `mod_export int lexflags;` (`Src/lex.c:151`).
829// Carries the LEXFLAGS_ACTIVE/ZLE/COMMENTS_KEEP/COMMENTS_STRIP/NEWLINE
830// bit flags from `Src/zsh.h:2293-2315`. The constants live in
831// `super::zsh_h:2532-2537`; access via plain `&` / `|` ops, not a
832// Rust struct.
833
834// `struct LexBuf` (fake Rust-only paraphrase) DELETED. The canonical
835// port of `struct lexbufstate` (zsh.h:3069-3079) lives at
836// `crate::ported::zsh_h::lexbufstate` with fields `{ptr: Option<String>,
837// siz: i32, len: i32}` — same shape as C. The LEX_LEXBUF /
838// LEX_LEXBUF_RAW thread_locals use this canonical type directly.
839// The convenience methods below are Rust-only
840// helpers wrapping the flat operations C inlines at lex.c:451+ (`add`,
841// etc.) — they're carried here as helpers rather than re-inlining
842// ~50 lines of ptr/len/siz arithmetic across 24 call sites.
843
844// WARNING: NOT IN LEX.C — Rust-only convenience over C's flat lexbuf
845// operations at lex.c:451+ (`add`, plus inline `*lexbuf.ptr = '\0'`
846// terminator-writes, `lexbuf.len > oldlen` truncation loops, etc.).
847// C operates on the file-static `lexbuf` global directly with raw
848// ptr arithmetic; these methods package the equivalent ops on the
849// owned-String buffer that zshrs's `lexbufstate.ptr: Option<String>`
850// carries. Each method's body cites the C source location it mirrors.
851impl lexbufstate {
852    /// New lex buffer with the C-source initial alloc (lex.c:210
853    /// `static struct lexbufstate lexbuf = { NULL, 256, 0 };`). ptr
854    /// is initialized Some("") so subsequent operations can unwrap
855    /// without a None check.
856    pub(crate) fn new() -> Self {
857        Self {
858            ptr: Some(String::with_capacity(256)),
859            siz: 256,
860            len: 0,
861        }
862    }
863
864    /// Mirrors C `add(int c)` at lex.c:451: push char, bump len,
865    /// double siz on overflow. Skips the C `hrealloc` since Rust's
866    /// String already manages capacity, but matches the doubling
867    /// policy for parity with how downstream code observes `siz`.
868    pub(crate) fn add(&mut self, c: char) {
869        if let Some(p) = self.ptr.as_mut() {
870            p.push(c);
871            self.len = p.len() as i32;
872            if self.len >= self.siz {
873                self.siz *= 2;
874                let want = self.siz as usize;
875                let have = p.capacity();
876                if want > have {
877                    p.reserve(want - have);
878                }
879            }
880        }
881    }
882
883    /// Reset buffer — clears ptr contents, zeros len, leaves siz.
884    /// Mirrors lex.c:235 `tokstr = zshlextext = lexbuf.ptr = NULL;
885    /// lexbuf.siz = 256;` plus gettokstr's `lexbuf.ptr = hcalloc(...)`
886    /// (lex.c:632/953): the buffer is ALLOCATED here when absent so the
887    /// following `add()`s accumulate. Without the allocate-when-None
888    /// arm, `add()` (which only pushes when `ptr` is Some) silently
889    /// no-ops whenever the lexer is entered without a prior `lex_init`
890    /// — e.g. the interactive REPL reading off SHIN via ingetc — so
891    /// every token came out empty.
892    pub(crate) fn clear(&mut self) {
893        match self.ptr.as_mut() {
894            Some(p) => p.clear(),
895            None => self.ptr = Some(String::with_capacity(self.siz.max(256) as usize)),
896        }
897        self.len = 0;
898    }
899
900    /// View buffer as &str. Empty if ptr is None.
901    pub(crate) fn as_str(&self) -> &str {
902        self.ptr.as_deref().unwrap_or("")
903    }
904
905    /// Length in chars (NOT bytes). Reads len field which `add()`
906    /// keeps in sync; mirrors C `lexbuf.len`.
907    pub(crate) fn buf_len(&self) -> usize {
908        self.len as usize
909    }
910
911    /// Pop the last char. Mirrors C lex.c:524-526 hungetc-driven
912    /// shrink: `lexbuf.len--; *--lexbuf.ptr = ...`.
913    pub(crate) fn pop(&mut self) -> Option<char> {
914        let c = self.ptr.as_mut().and_then(|p| p.pop());
915        if c.is_some() {
916            self.len -= 1;
917        }
918        c
919    }
920
921    /// Replace the char at BYTE position `byte_idx` in lexbuf. The
922    /// lexbuf's `len` field tracks BYTE length (matching C's
923    /// `lexbuf.len` which indexes the raw `tokstr[]` array), and
924    /// `buf_len()` returns the same. So when `cmd_or_math_sub`
925    /// saves `lexpos = buf_len()` before `add(Inpar)`, lexpos is
926    /// the byte offset where the Inpar marker landed.
927    ///
928    /// Mirrors C's `tokstr[lexpos] = MARKER` rewrites (lex.c:560,
929    /// cmd_or_math_sub) that retroactively patch a marker byte
930    /// after the surrounding parse confirms the context. The
931    /// caller must guarantee the new char's UTF-8 byte width
932    /// matches the current char's width at that offset — used
933    /// only for swapping equally-sized markers (e.g. Inpar
934    /// `\u{88}` → Inparmath `\u{89}`, both 2 UTF-8 bytes).
935    pub(crate) fn set_char_at(&mut self, byte_idx: usize, c: char) {
936        let Some(buf) = self.ptr.as_mut() else { return };
937        if byte_idx >= buf.len() {
938            return;
939        }
940        // Walk to the char-start at byte_idx (must be on a UTF-8
941        // boundary). Find the char length so we can replace exactly
942        // that many bytes.
943        if !buf.is_char_boundary(byte_idx) {
944            return;
945        }
946        let old_byte_end = buf[byte_idx..]
947            .chars()
948            .next()
949            .map(|ch| byte_idx + ch.len_utf8());
950        let Some(old_byte_end) = old_byte_end else {
951            return;
952        };
953        let mut new_bytes = [0u8; 4];
954        let new_str = c.encode_utf8(&mut new_bytes);
955        if new_str.len() == old_byte_end - byte_idx {
956            let new_owned = new_str.to_string();
957            buf.replace_range(byte_idx..old_byte_end, &new_owned);
958        }
959    }
960}
961
962// Per-heredoc state — Rust-only AST-glue, NOT in lex.c. Canonical home
963// is `src/extensions/heredoc_ast.rs`; re-exported here so existing
964// `crate::lex::HereDoc` / `crate::parse::HereDocInfo` call sites keep
965// resolving. Both die in Phase 9e (PORT_PLAN.md) when the wordcode
966// port reinstates C's `struct heredocs` shape (zsh.h:1152) +
967// `gethere()` body collection.
968
969// =============================================================================
970// Lexer state — thread-local file-statics matching zsh's lex.c file-statics.
971// Each field maps to a `static` in `Src/lex.c` (or `Src/zsh.h` for the few
972// `extern`-declared ones). Per-evaluator: each worker thread tokenizing its
973// own input needs its own state (bucket-1 per PORT_PLAN.md).
974// =============================================================================
975thread_local! {
976    /// Input source (owned). C uses input-stack `struct inputstack` +
977    /// `hgetc()` (lex.c:input.c); zshrs P7 collapses to an owned String
978    /// until the inputstack subsystem is ported.
979    pub static LEX_INPUT: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
980    /// `LEX_POS` static.
981    pub static LEX_POS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
982    /// `LEX_UNGET_BUF` static.
983    pub static LEX_UNGET_BUF: std::cell::RefCell<std::collections::VecDeque<char>>
984        = const { std::cell::RefCell::new(std::collections::VecDeque::new()) };
985    /// `char *tokstr` (lex.c:170).
986    pub static LEX_TOKSTR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
987    /// `enum lextok tok` (lex.c:180).
988    pub static LEX_TOK: std::cell::Cell<lextok> = const { std::cell::Cell::new(ENDINPUT) };
989    /// `int tokfd` (lex.c:191).
990    pub static LEX_TOKFD: std::cell::Cell<i32> = const { std::cell::Cell::new(-1) };
991    /// `zlong toklineno` (lex.c:198).
992    pub static LEX_TOKLINENO: std::cell::Cell<u64> = const { std::cell::Cell::new(1) };
993    /// `LEX_LINENO` static.
994    pub static LEX_LINENO: std::cell::Cell<u64> = const { std::cell::Cell::new(1) };
995    /// `int lexstop` (lex.c:175).
996    pub static LEX_LEXSTOP: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
997    /// `int incmdpos` (lex.c:122 + extern in zsh.h).
998    pub static LEX_INCMDPOS: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
999    /// `int incond` (lex.c:127).
1000    pub static LEX_INCOND: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1001    /// In pattern context (RHS of == != =~ in [[ ]]) — zshrs extension
1002    /// over the bare incond state.
1003    pub static LEX_INCONDPAT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1004    /// `int incasepat` (lex.c:130).
1005    pub static LEX_INCASEPAT: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1006    /// `int inredir` (lex.c:126).
1007    pub static LEX_INREDIR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1008    /// Saved incmdpos from before a redirop/for/foreach/select. Mirrors
1009    /// `static int oldpos` in ctxtlex (lex.c:319).
1010    pub static LEX_OLDPOS: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
1011    /// `int infor` (lex.c:128).
1012    pub static LEX_INFOR: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1013    /// `int inrepeat_` (lex.c:129).
1014    pub static LEX_INREPEAT: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1015    /// `int intypeset` (lex.c:131).
1016    pub static LEX_INTYPESET: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1017    /// `int dbparens` (lex.c:141).
1018    pub static LEX_DBPARENS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1019    /// `int noaliases` (lex.c:135).
1020    pub static LEX_NOALIASES: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1021    /// `int inalmore` (lex.c:80). Set by `inpoptop` (input.c:775)
1022    /// when a drained non-global alias body ends in a space —
1023    /// "aliases should be expanded, as if we are continuing after
1024    /// an alias" (input.c:63) — so the NEXT word is alias-eligible
1025    /// even outside command position (`alias sudo='sudo '` chains).
1026    /// Consulted by `checkalias` (lex.c:1917); cleared by `exalias`
1027    /// (lex.c:2016).
1028    pub static LEX_INALMORE: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1029    /// `int nocorrect` (lex.c:144).
1030    pub static LEX_NOCORRECT: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1031    /// `int nocomments` (lex.c:148).
1032    pub static LEX_NOCOMMENTS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1033    /// `int lexflags` (lex.c:118).
1034    pub static LEX_LEXFLAGS: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1035    /// `int aliasspaceflag` (parse.c:39, zsh.h:3103). Set to 1 by the
1036    /// alias-expansion path in `cmd_or_math` (lex.c:1930) when an
1037    /// expanded non-global alias starts with a space; consulted by
1038    /// hist.c:1428 to suppress history entries the same way a literal
1039    /// space-prefixed command line is. Cleared by `parse_event`
1040    /// (parse.c:618). parse_context_save/restore preserve across
1041    /// nested parses.
1042    pub static LEX_ALIAS_SPACE_FLAG: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1043    /// `mod_export int wordbeg` (lex.c:123). The cursor-relative
1044    /// start index of the in-flight word, set in gettok when
1045    /// LEXFLAGS_ZLE is active. Consumed by `gotword`.
1046    pub static LEX_WORDBEG: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1047    /// `mod_export int parbegin` (lex.c:126). Marks start of a
1048    /// `(...)` substitution under ZLE so completion can recurse in.
1049    /// `-1` when no substitution is open.
1050    pub static LEX_PARBEGIN: std::cell::Cell<i32> = const { std::cell::Cell::new(-1) };
1051    /// `mod_export int parend` (lex.c:129). Marks end of a `(...)`
1052    /// substitution under ZLE. `-1` when no substitution closed.
1053    pub static LEX_PAREND: std::cell::Cell<i32> = const { std::cell::Cell::new(-1) };
1054    /// `int isfirstln` (lex.c:114).
1055    pub static LEX_ISFIRSTLN: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
1056    /// `int isfirstch` (lex.c:116).
1057    pub static LEX_ISFIRSTCH: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
1058    /// !!! WARNING: NOT IN LEX.C — Rust-only AST-glue Vec !!!
1059    /// Runs parallel to the canonical C `struct heredocs *hdocs;`
1060    /// linked list at `parse::HDOCS` (port of `Src/parse.c:84`).
1061    /// Carries terminator / strip_tabs / quoted metadata that C
1062    /// stores implicitly via tokstr; zshrs's AST consumer
1063    /// (`fill_heredoc_bodies` in parse.rs) reads it via the
1064    /// `heredoc_idx` field on `ZshRedir`.
1065    pub static LEX_HEREDOCS: std::cell::RefCell<Vec<HereDoc>> = const { std::cell::RefCell::new(Vec::new()) };
1066    /// `struct lexbufstate lexbuf` (lex.c:210).
1067    pub static LEX_LEXBUF: std::cell::RefCell<lexbufstate> = const { std::cell::RefCell::new(
1068        lexbufstate { ptr: None, siz: 0, len: 0 }
1069    )};
1070    /// `int isnewlin` (lex.c:119).
1071    pub static LEX_ISNEWLIN: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1072    /// `int lex_add_raw` (lex.c:161).
1073    pub static LEX_LEX_ADD_RAW: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
1074    /// `static char *tokstr_raw` (lex.c:165).
1075    pub static LEX_TOKSTR_RAW: std::cell::RefCell<Option<String>> =
1076        const { std::cell::RefCell::new(None) };
1077    /// `struct lexbufstate lexbuf_raw` (lex.c:166).
1078    pub static LEX_LEXBUF_RAW: std::cell::RefCell<lexbufstate> = const { std::cell::RefCell::new(
1079        lexbufstate { ptr: None, siz: 0, len: 0 }
1080    )};
1081}
1082
1083/// Get the next token. Direct port of `gettok(void)` from
1084/// `Src/lex.c:613-936`. Reads chars via hgetc, dispatches on the
1085/// leading char through the `lexact1[]` table at c:725. The 23
1086/// LX1_* arms below mirror C's `switch (lexact1[STOUC(c)])` body
1087/// one-for-one; the LX1_OTHER fallthrough at c:918 routes to
1088/// `gettokstr` for word-shape lexing.
1089fn gettok() -> lextok {
1090    // c:617 — `int peekfd = -1;`. Local fd-prefix accumulator.
1091    // Written into the global `tokfd` ONLY at redir-arm exit
1092    // (c:753, 864, 913) — mirrored in lex_inang / lex_outang.
1093    let mut peekfd: i32 = -1;
1094    // c:620 — `tokstr = NULL;`. Reset before each token.
1095    set_tokstr(None);
1096
1097    // c:622 — `while (iblank(c = hgetc()) && !lexstop);` — skip
1098    // leading blanks (space/tab, NOT newline). Keep `c` from the
1099    // loop; don't unget+reread.
1100    let c = loop {
1101        match hgetc() {
1102            // `ch as u8` truncates a multibyte codepoint into the blank range
1103            // (U+4E09 → 0x09); only ASCII can be blank.
1104            Some(ch) if ch.is_ascii() && crate::ztype_h::iblank(ch as u8) => continue,
1105            Some(ch) => break ch,
1106            None => {
1107                // c:624-625 — `if (lexstop) return (errflag) ?
1108                // LEXERR : ENDINPUT;`
1109                use std::sync::atomic::Ordering;
1110                LEX_LEXSTOP.set(true);
1111                return if errflag.load(Ordering::Relaxed) != 0 {
1112                    LEXERR
1113                } else {
1114                    ENDINPUT
1115                };
1116            }
1117        }
1118    };
1119
1120    // c:623 — `toklineno = lineno;` runs BEFORE the lexstop check
1121    // (matches C ordering: assign first, then check stop).
1122    LEX_TOKLINENO.set(LEX_LINENO.get());
1123    // c:626 — `isfirstln = 0;`
1124    LEX_ISFIRSTLN.set(false);
1125
1126    // c:627-628 — `if ((lexflags & LEXFLAGS_ZLE) && !(inbufflags
1127    // & INP_ALIAS)) wordbeg = inbufct - (qbang && c == bangchar);`
1128    // ZLE word-begin tracking; consumed by `gotword` (c:1882).
1129    let qbang_at_bang = crate::ported::hist::qbang.load(std::sync::atomic::Ordering::SeqCst)
1130        && c as i32 == crate::ported::hist::bangchar.load(std::sync::atomic::Ordering::SeqCst);
1131    let qbang_adj: i32 = if qbang_at_bang { 1 } else { 0 };
1132    if (LEX_LEXFLAGS.get() & LEXFLAGS_ZLE) != 0
1133        && (crate::ported::input::inbufflags.with(|f| f.get()) & INP_ALIAS) == 0
1134    {
1135        LEX_WORDBEG.set(crate::ported::input::inbufct.with(|c| c.get()) - qbang_adj);
1136    }
1137    // c:630 — `hwbegin(-1-(qbang && c == bangchar));` — start a
1138    // new history word. C `hwbegin` is a function pointer flipped
1139    // by `hbegin()` between `ihwbegin` (real, hist.c:1656) and
1140    // `nohwb` (no-op). zshrs doesn't flip the pointer yet but
1141    // `ihwbegin` itself is a no-op when history is inactive
1142    // (`stophist == 2` or `histactive & HA_INWORD`), so calling it
1143    // unconditionally matches the inactive case.
1144    crate::ported::hist::ihwbegin(-1 - qbang_adj);
1145
1146    // c:631-648 — `if (dbparens)` block, inlined verbatim from
1147    // gettok body. Lexes the body of `(( ... ))` arithmetic.
1148    if LEX_DBPARENS.get() {
1149        // c:632 — `lexbuf.len = 0; lexbuf.ptr = tokstr = hcalloc(...);`
1150        LEX_LEXBUF.with_borrow_mut(|b| b.clear());
1151        // c:633 — `hungetc(c);`
1152        hungetc(c);
1153        // c:634-637 — `cmdpush(CS_MATH); c = dquote_parse(infor ?
1154        // ';' : ')', 0); cmdpop();`
1155        let end_char = if LEX_INFOR.get() > 0 { ';' } else { ')' };
1156        cmdpush(CS_MATH as u8);
1157        let parse_ok = dquote_parse(end_char, false).is_ok();
1158        cmdpop();
1159        if !parse_ok {
1160            return LEXERR;
1161        }
1162        // c:638 — `*lexbuf.ptr = '\0';`
1163        set_tokstr(Some(LEX_LEXBUF.with_borrow(|b| b.as_str().to_string())));
1164        // c:639-642 — `if (!c && infor) { infor--; return DINPAR; }`
1165        if LEX_INFOR.get() > 0 {
1166            LEX_INFOR.set(LEX_INFOR.get() - 1);
1167            return DINPAR;
1168        }
1169        // c:643-646 — `if (c || (c = hgetc()) != ')') { hungetc(c);
1170        // return LEXERR; }`
1171        match hgetc() {
1172            Some(')') => {
1173                // c:647 — `dbparens = 0;` / c:648 — `return DOUTPAR;`
1174                LEX_DBPARENS.set(false);
1175                return DOUTPAR;
1176            }
1177            c => {
1178                if let Some(c) = c {
1179                    hungetc(c);
1180                }
1181                return LEXERR;
1182            }
1183        }
1184    }
1185
1186    // treats `2` as the fd to redirect. Three shapes: `N>`/`N<`
1187    // (single redir), `N&>` (errwrite), or anything else (push
1188    // back, treat as literal digit). The digit is captured into
1189    // `peekfd` and `c` is rewritten to the operator char so the
1190    // LX1 dispatch sees the redir, not the digit.
1191    let mut c = c;
1192    // ASCII-only: `c as u8` on a multibyte char truncates into the digit range
1193    // (U+4E30 → 0x30 = '0'), which would misread a CJK word as a redirection fd.
1194    if c.is_ascii() && crate::ztype_h::idigit(c as u8) {
1195        let d = hgetc();
1196        match d {
1197            Some('&') => {
1198                let e = hgetc();
1199                if e == Some('>') {
1200                    // c:653-657 — `N&>` shape detected.
1201                    peekfd = (c as u8 - b'0') as i32;
1202                    hungetc('>');
1203                    c = '&';
1204                } else {
1205                    // c:658-661 — not `N&>`, push everything back.
1206                    if let Some(e) = e {
1207                        hungetc(e);
1208                    }
1209                    hungetc('&');
1210                }
1211            }
1212            Some('>') | Some('<') => {
1213                // c:662-664 — `N>` or `N<` shape detected.
1214                peekfd = (c as u8 - b'0') as i32;
1215                c = d.unwrap();
1216            }
1217            Some(d) => {
1218                // c:665-668 — not a redir prefix, push back.
1219                hungetc(d);
1220            }
1221            None => {}
1222        }
1223        LEX_LEXSTOP.set(false);
1224    }
1225
1226    // c:678 — `if (c == hashchar && !nocomments &&
1227    //   (isset(INTERACTIVECOMMENTS) ||
1228    //    ((!lexflags || (lexflags & LEXFLAGS_COMMENTS)) && !expanding &&
1229    //     (!interact || unset(SHINSTDIN) || strin))))`.
1230    //
1231    // Comments only fire when `#` is at word-start AND one of:
1232    //   1. INTERACTIVECOMMENTS option is set; OR
1233    //   2. Non-interactive: lexflags allows comments AND not currently
1234    //      expanding AND (not interactive OR not reading stdin OR
1235    //      reading from a string).
1236    //
1237    // c:679-681 — `(!lexflags || (lexflags & LEXFLAGS_COMMENTS)) &&
1238    //   !expanding && (!interact || unset(SHINSTDIN) || strin)`.
1239    // `expanding` (hist.c:65) and `strin` (input.c) ARE ported; the
1240    // `|| strin` term is load-bearing: when a file is sourced from an
1241    // INTERACTIVE shell, `interact` is set and SHINSTDIN is set, so
1242    // without `strin` the `#` in `.zshrc`/`.zshenv`/`~/.cargo/env`
1243    // (`#!/bin/sh`, `# comment`) was parsed as a command. `strin` is
1244    // set while reading a sourced file / string-eval, which is exactly
1245    // when comments must still strip regardless of interactivity.
1246    let lexflags = LEX_LEXFLAGS.get();
1247    let expanding = crate::ported::hist::expanding.load(std::sync::atomic::Ordering::SeqCst) != 0;
1248    let strin = crate::ported::input::strin.with(|c| c.get()) != 0;
1249    let allow_comment_via_flags = (lexflags == 0 || (lexflags & LEXFLAGS_COMMENTS) != 0)
1250        && !expanding
1251        && (!interact() || unset(SHINSTDIN) || strin);
1252    if c as i32 == crate::ported::hist::hashchar.load(std::sync::atomic::Ordering::SeqCst)
1253        && !LEX_NOCOMMENTS.get()
1254        && (isset(INTERACTIVECOMMENTS) || allow_comment_via_flags)
1255    {
1256        // c:686-707 — comment body. Under LEXFLAGS_COMMENTS_KEEP,
1257        // capture the comment text as a STRING token; under
1258        // LEXFLAGS_COMMENTS_STRIP, return ENDINPUT at EOF; default
1259        // is to read-and-drop the comment and return NEWLIN.
1260        if LEX_LEXFLAGS.get() & LEXFLAGS_COMMENTS_KEEP != 0 {
1261            LEX_LEXBUF.with_borrow_mut(|b| b.clear());
1262            add('#');
1263        }
1264        loop {
1265            let c = hgetc();
1266            match c {
1267                Some('\n') | None => break,
1268                Some(c) => {
1269                    if LEX_LEXFLAGS.get() & LEXFLAGS_COMMENTS_KEEP != 0 {
1270                        add(c);
1271                    }
1272                }
1273            }
1274        }
1275        if LEX_LEXFLAGS.get() & LEXFLAGS_COMMENTS_KEEP != 0 {
1276            set_tokstr(Some(LEX_LEXBUF.with_borrow(|b| b.as_str().to_string())));
1277            if !LEX_LEXSTOP.get() {
1278                hungetc('\n');
1279            }
1280            return STRING_LEX;
1281        }
1282        if LEX_LEXFLAGS.get() & LEXFLAGS_COMMENTS_STRIP != 0 && LEX_LEXSTOP.get() {
1283            return ENDINPUT;
1284        }
1285        return NEWLIN;
1286    }
1287
1288    // c:725 — `switch (lexact1[STOUC(c)])` table-driven dispatch.
1289    let act = lexact1_get(c);
1290    match act {
1291        LX1_BKSLASH => {
1292            let d = hgetc();
1293            if d == Some('\n') {
1294                // Line continuation - get next token
1295                return gettok();
1296            }
1297            if let Some(d) = d {
1298                hungetc(d);
1299            }
1300            LEX_LEXSTOP.set(false);
1301            gettokstr(c, false)
1302        }
1303
1304        LX1_NEWLIN => NEWLIN,
1305
1306        LX1_SEMI => {
1307            let d = hgetc();
1308            match d {
1309                Some(';') => DSEMI,
1310                Some('&') => SEMIAMP,
1311                Some('|') => SEMIBAR,
1312                _ => {
1313                    if let Some(d) = d {
1314                        hungetc(d);
1315                    }
1316                    LEX_LEXSTOP.set(false);
1317                    SEMI
1318                }
1319            }
1320        }
1321
1322        LX1_AMPER => {
1323            let d = hgetc();
1324            match d {
1325                Some('&') => DAMPER,
1326                Some('!') | Some('|') => AMPERBANG,
1327                Some('>') => {
1328                    // c:753 — `tokfd = peekfd;` before the `&>` shape
1329                    // continues into the LX1_OUTANG-like dispatch.
1330                    LEX_TOKFD.set(peekfd);
1331                    let e = hgetc();
1332                    match e {
1333                        Some('!') | Some('|') => OUTANGAMPBANG,
1334                        Some('>') => {
1335                            let f = hgetc();
1336                            match f {
1337                                Some('!') | Some('|') => DOUTANGAMPBANG,
1338                                _ => {
1339                                    if let Some(f) = f {
1340                                        hungetc(f);
1341                                    }
1342                                    LEX_LEXSTOP.set(false);
1343                                    DOUTANGAMP
1344                                }
1345                            }
1346                        }
1347                        _ => {
1348                            if let Some(e) = e {
1349                                hungetc(e);
1350                            }
1351                            LEX_LEXSTOP.set(false);
1352                            AMPOUTANG
1353                        }
1354                    }
1355                }
1356                _ => {
1357                    if let Some(d) = d {
1358                        hungetc(d);
1359                    }
1360                    LEX_LEXSTOP.set(false);
1361                    AMPER
1362                }
1363            }
1364        }
1365
1366        LX1_BAR => {
1367            let d = hgetc();
1368            match d {
1369                Some('|') if LEX_INCASEPAT.get() <= 0 => DBAR,
1370                Some('&') => BARAMP,
1371                _ => {
1372                    if let Some(d) = d {
1373                        hungetc(d);
1374                    }
1375                    LEX_LEXSTOP.set(false);
1376                    BAR_TOK
1377                }
1378            }
1379        }
1380
1381        LX1_INPAR => {
1382            let d = hgetc();
1383            match d {
1384                Some('(') => {
1385                    if LEX_INFOR.get() > 0 {
1386                        LEX_DBPARENS.set(true);
1387                        return DINPAR;
1388                    }
1389                    // c:788 — `if (incmdpos || (isset(SHGLOB) &&
1390                    // !isset(KSHGLOB)))` — under SHGLOB-without-KSHGLOB,
1391                    // `((` is also math/subshell-eligible even when
1392                    // not at command position.
1393                    if LEX_INCMDPOS.get() || (isset(SHGLOB) && unset(KSHGLOB)) {
1394                        // Could be (( arithmetic )) or ( subshell )
1395                        LEX_LEXBUF.with_borrow_mut(|b| b.clear());
1396                        match cmd_or_math() {
1397                            CMD_OR_MATH_MATH => {
1398                                set_tokstr(Some(
1399                                    LEX_LEXBUF.with_borrow(|b| b.as_str().to_string()),
1400                                ));
1401                                return DINPAR;
1402                            }
1403                            CMD_OR_MATH_CMD => {
1404                                set_tokstr(None);
1405                                return INPAR_TOK;
1406                            }
1407                            CMD_OR_MATH_ERR | _ => return LEXERR,
1408                        }
1409                    }
1410                    hungetc('(');
1411                    LEX_LEXSTOP.set(false);
1412                    gettokstr('(', false)
1413                }
1414                Some(')') => INOUTPAR,
1415                _ => {
1416                    if let Some(d) = d {
1417                        hungetc(d);
1418                    }
1419                    LEX_LEXSTOP.set(false);
1420                    // c:821 — `if (!(isset(SHGLOB) || incond == 1 ||
1421                    // incmdpos)) break; return INPAR;` — at word
1422                    // boundary `(` tokenizes as Inpar when SHGLOB or
1423                    // incond==1 or incmdpos. Otherwise breaks out
1424                    // (falls through to gettokstr so `(` starts a
1425                    // Stringg — typical for unquoted glob args like
1426                    // `ls (^foo)*`).
1427                    //
1428                    // Case-pattern context (incasepat>0): C source does
1429                    // NOT add incasepat here — the `(` is absorbed into
1430                    // gettokstr as part of the pattern, and par_case
1431                    // detects the leading-paren form by checking if the
1432                    // resulting str starts with the Inpar marker
1433                    // (parse.c:1322 hack). Mirror exactly: do not gate
1434                    // on incasepat.
1435                    // c:parse.c:2601 — par_cond bumps `incond` (to >1) around
1436                    // the RHS of a `[[ ]]` binary op so "parentheses do
1437                    // globbing" (a literal pattern/regex char), not grouping.
1438                    // In that bumped state `(` must be a literal even under
1439                    // SHGLOB (ksh/bash emulation) — otherwise `[[ x =~ (a)(b)
1440                    // ]]` splits the regex into INPAR tokens and par_cond's
1441                    // single-STRING RHS read fails with "condition expected".
1442                    // Verified against zsh: `emulate ksh; [[ abc =~ (a)(b) ]]`
1443                    // works. Native zsh (no SHGLOB) already reached this via
1444                    // the `incond == 1` miss; the explicit `> 1` guard makes
1445                    // it hold under SHGLOB too.
1446                    if LEX_INCOND.get() > 1 {
1447                        gettokstr('(', false)
1448                    } else if isset(SHGLOB) || LEX_INCOND.get() == 1 || LEX_INCMDPOS.get() {
1449                        INPAR_TOK
1450                    } else {
1451                        gettokstr('(', false)
1452                    }
1453                }
1454            }
1455        }
1456
1457        LX1_OUTPAR => OUTPAR_TOK,
1458
1459        // c:826-864 — `case LX1_INANG:` body. In pattern context
1460        // (`incondpat`/`incasepat`), `<` is literal and falls
1461        // through to gettokstr (zshrs-only guard for `[[ < ]]`).
1462        LX1_INANG => {
1463            if LEX_INCONDPAT.get() || LEX_INCASEPAT.get() > 0 {
1464                return gettokstr(c, false);
1465            }
1466            let d = hgetc();
1467            let peek = match d {
1468                Some('(') if crate::dash_mode::dash_strict() => {
1469                    // !!! DASH-STRICT GATE (no C counterpart) !!!
1470                    // dash/ash have no `<(...)` process substitution. Unget the
1471                    // `(` so `<` stays a plain input redirection; the `(` then
1472                    // sits where a redirection target (filename) is expected, so
1473                    // the parser rejects it — matching /bin/dash's
1474                    // "Syntax error: \"(\" unexpected". Twin of the `<<<`
1475                    // here-string gate below. Found by the dash-strictness sweep.
1476                    hungetc('(');
1477                    LEX_LEXSTOP.set(false);
1478                    INANG_TOK
1479                }
1480                Some('(') => {
1481                    // c:828-832 — `<(...)` process substitution.
1482                    // Fall through to gettokstr; tokfd write doesn't
1483                    // apply (process-sub, not a redir).
1484                    hungetc('(');
1485                    LEX_LEXSTOP.set(false);
1486                    return gettokstr('<', false);
1487                }
1488                Some('>') => INOUTANG,
1489                Some('<') => {
1490                    let e = hgetc();
1491                    match e {
1492                        Some('(') => {
1493                            hungetc('(');
1494                            hungetc('<');
1495                            INANG_TOK
1496                        }
1497                        Some('<') if crate::dash_mode::dash_strict() => {
1498                            // !!! DASH-STRICT GATE (no C counterpart) !!!
1499                            // dash has no `<<<` here-string. Unget the third
1500                            // `<` so `<<` stays a here-document operator and
1501                            // the dangling `<` becomes a stray redirection the
1502                            // parser rejects — matching /bin/dash's
1503                            // "Syntax error: redirection unexpected".
1504                            hungetc('<');
1505                            LEX_LEXSTOP.set(false);
1506                            DINANG
1507                        }
1508                        Some('<') => TRINANG,
1509                        Some('-') => DINANGDASH,
1510                        _ => {
1511                            if let Some(e) = e {
1512                                hungetc(e);
1513                            }
1514                            LEX_LEXSTOP.set(false);
1515                            DINANG
1516                        }
1517                    }
1518                }
1519                Some('&') => INANGAMP,
1520                _ => {
1521                    // c:858-862 — `hungetc(d); if (isnumglob()) goto
1522                    // unpeekfd; peek = INANG;`. `<digits-digits>` is
1523                    // zsh's numeric-glob pattern (e.g., `<-> `, `<1-5>`,
1524                    // `<10->`). isnumglob looks ahead to confirm the
1525                    // shape and rewinds. When it matches, the entire
1526                    // `<...>` is a glob, NOT a redir — fall through to
1527                    // gettokstr so it lexes as a literal STRING token.
1528                    //
1529                    // Without this, `[[ $1 = <-> ]]` had the lexer
1530                    // consume `<` as INANG and try to take the next
1531                    // token as the redir target, blowing up with
1532                    // "expected word after redirection". Critical for
1533                    // any zsh code using <-> / <N-M> globs (e.g.
1534                    // zinit.zsh:1983).
1535                    if let Some(d) = d {
1536                        hungetc(d);
1537                    }
1538                    if isnumglob() {
1539                        // c:860
1540                        // c:861 — `goto unpeekfd;` — restore peekfd
1541                        // and fall through to gettokstr at LX1 break.
1542                        if peekfd != -1 {
1543                            // c:832
1544                            hungetc(c); // c:833
1545                            return gettokstr(
1546                                ((b'0' as i32) + peekfd) as u8   // c:834
1547                                as char,
1548                                false,
1549                            );
1550                        }
1551                        LEX_LEXSTOP.set(false);
1552                        return gettokstr(c, false);
1553                    }
1554                    LEX_LEXSTOP.set(false);
1555                    INANG_TOK
1556                }
1557            };
1558            // c:864 — `tokfd = peekfd; return peek;`.
1559            LEX_TOKFD.set(peekfd);
1560            peek
1561        }
1562
1563        // c:866-914 — `case LX1_OUTANG:` body.
1564        LX1_OUTANG => {
1565            if LEX_INCONDPAT.get() || LEX_INCASEPAT.get() > 0 {
1566                return gettokstr(c, false);
1567            }
1568            let d = hgetc();
1569            let peek = match d {
1570                Some('(') if crate::dash_mode::dash_strict() => {
1571                    // !!! DASH-STRICT GATE (no C counterpart) !!!
1572                    // dash/ash have no `>(...)` process substitution. Unget the
1573                    // `(` so `>` stays a plain output redirection and the `(`
1574                    // becomes an unexpected redirection target → parser rejects
1575                    // it, matching /bin/dash. Twin of the `<(` gate above.
1576                    hungetc('(');
1577                    LEX_LEXSTOP.set(false);
1578                    OUTANG_TOK
1579                }
1580                Some('(') => {
1581                    // `>(...)` process substitution.
1582                    hungetc('(');
1583                    LEX_LEXSTOP.set(false);
1584                    return gettokstr('>', false);
1585                }
1586                Some('&') => {
1587                    let e = hgetc();
1588                    match e {
1589                        Some('!') | Some('|') => OUTANGAMPBANG,
1590                        _ => {
1591                            if let Some(e) = e {
1592                                hungetc(e);
1593                            }
1594                            LEX_LEXSTOP.set(false);
1595                            OUTANGAMP
1596                        }
1597                    }
1598                }
1599                Some('!') | Some('|') => OUTANGBANG,
1600                Some('>') => {
1601                    let e = hgetc();
1602                    match e {
1603                        Some('&') => {
1604                            let f = hgetc();
1605                            match f {
1606                                Some('!') | Some('|') => DOUTANGAMPBANG,
1607                                _ => {
1608                                    if let Some(f) = f {
1609                                        hungetc(f);
1610                                    }
1611                                    LEX_LEXSTOP.set(false);
1612                                    DOUTANGAMP
1613                                }
1614                            }
1615                        }
1616                        Some('!') | Some('|') => DOUTANGBANG,
1617                        Some('(') => {
1618                            hungetc('(');
1619                            hungetc('>');
1620                            OUTANG_TOK
1621                        }
1622                        _ => {
1623                            if let Some(e) = e {
1624                                hungetc(e);
1625                            }
1626                            LEX_LEXSTOP.set(false);
1627                            // c:903 — `if (isset(HISTALLOWCLOBBER))
1628                            // hwaddc('|');`
1629                            if isset(HISTALLOWCLOBBER) {
1630                                hwaddc('|');
1631                            }
1632                            DOUTANG
1633                        }
1634                    }
1635                }
1636                _ => {
1637                    if let Some(d) = d {
1638                        hungetc(d);
1639                    }
1640                    LEX_LEXSTOP.set(false);
1641                    // c:910 — `if (!incond && isset(HISTALLOWCLOBBER))
1642                    // hwaddc('|');`
1643                    if LEX_INCOND.get() == 0 && isset(HISTALLOWCLOBBER) {
1644                        hwaddc('|');
1645                    }
1646                    OUTANG_TOK
1647                }
1648            };
1649            // c:913 — `tokfd = peekfd; return peek;`.
1650            LEX_TOKFD.set(peekfd);
1651            peek
1652        }
1653
1654        // c:918 — `case LX1_OTHER: return gettokstr(c, 0);`. All
1655        // non-redir, non-separator chars (including `{`/`}`/`[`/`]`)
1656        // fall into gettokstr; the LX2_* arms handle word shape.
1657        // Reserved-word promotion (`{` → INBRACE, `[[` → DINBRACK,
1658        // etc.) happens in exalias via reswdtab lookup (c:2002-2005).
1659        _ => gettokstr(c, false),
1660    }
1661}
1662
1663/// Get rest of token string
1664/// Port of `gettokstr(int c, int sub)` from `Src/lex.c:937`.
1665fn gettokstr(c: char, sub: bool) -> lextok {
1666    let mut bct = 0; // brace count
1667    let mut pct = 0; // parenthesis count
1668    let mut brct = 0; // bracket count
1669    let mut in_brace_param = 0;
1670    // c:940 — `int cmdsubst = 0;`. Tracks whether we entered the
1671    // brace from `$(...)` command substitution (relevant for the
1672    // IGNOREBRACES check at lex.c:1138).
1673    let mut cmdsubst: bool = false;
1674    let _ = &mut cmdsubst; // suppress unused-warn until LX2_STRING wires it
1675    let mut peek = STRING_LEX;
1676    let mut intpos = 1;
1677    let mut unmatched = '\0';
1678    let mut c = c;
1679
1680    if !sub {
1681        LEX_LEXBUF.with_borrow_mut(|b| b.clear());
1682    }
1683
1684    loop {
1685        // C's lexer walks BYTES, so `inblank(c)` can only ever fire on a byte —
1686        // and no byte of a UTF-8 multibyte character is 0x20 or 0x09. Rust walks
1687        // `char`s, so casting one to u8 TRUNCATES the codepoint: `三` (U+4E09)
1688        // became 0x09 = TAB and `丠` (U+4E20) became 0x20 = SPACE, so an
1689        // unquoted CJK word was split apart as if it were whitespace
1690        // (`print -r -- 三` printed nothing). Only ASCII can be blank.
1691        let inbl = c.is_ascii() && crate::ztype_h::inblank(c as u8);
1692
1693        if inbl && in_brace_param == 0 && pct == 0 {
1694            // Whitespace outside brace param ends token
1695            break;
1696        }
1697
1698        // c:954 — `switch (lexact2[STOUC(c)])` table-driven dispatch.
1699        // Each LX2_* arm below mirrors one C `case` from Src/lex.c.
1700        // Char-with-context guards (`bct > 0`, `brct > 0`, etc.) stay
1701        // inline where C uses them.
1702        match lexact2_get(c) {
1703            // c:982 — `case LX2_OUTPAR:` — `)`.
1704            //
1705            // c:989 — `if ((sub || in_brace_param) && isset(SHGLOB)) break;`
1706            // Under SHGLOB, a `)` inside `${...}` or substitution context
1707            // ends the token rather than being added.
1708            LX2_OUTPAR => {
1709                // c:Src/lex.c:989-990 — `if ((sub || in_brace_param) &&
1710                // isset(SHGLOB)) break;`. As with LX2_INPAR above, C's `break`
1711                // exits the SWITCH and falls through to `add(c)` (c:1417) with
1712                // `c` still the raw `)`; it does NOT end the token. LX2_BAR
1713                // just below already ports this shape correctly with `add(c)`.
1714                // Bug #1052.
1715                if (sub || in_brace_param > 0) && isset(SHGLOB) {
1716                    add(c);
1717                } else if in_brace_param > 0 || sub {
1718                    add(Outpar);
1719                } else if pct > 0 {
1720                    pct -= 1;
1721                    add(Outpar);
1722                } else {
1723                    break;
1724                }
1725            }
1726
1727            // c:1001 — `case LX2_BAR:`.
1728            //
1729            // c:1005 — `if (unset(SHGLOB) || (!sub && !in_brace_param))
1730            // c = Bar;` — under SHGLOB inside substitution/brace
1731            // param, `|` is left as literal `|` not tokenised to `Bar`.
1732            LX2_BAR => {
1733                if pct == 0 && in_brace_param == 0 {
1734                    if sub {
1735                        add(c);
1736                    } else {
1737                        break;
1738                    }
1739                } else if unset(SHGLOB) || (!sub && in_brace_param == 0) {
1740                    add(Bar);
1741                } else {
1742                    add(c);
1743                }
1744            }
1745
1746            // c:1019 — `case LX2_STRING:` — `$`.
1747            LX2_STRING => {
1748                let e = hgetc();
1749                match e {
1750                    Some('\\') => {
1751                        let f = hgetc();
1752                        if f != Some('\n') {
1753                            if let Some(f) = f {
1754                                hungetc(f);
1755                            }
1756                            hungetc('\\');
1757                            add(Stringg);
1758                        } else {
1759                            // Line continuation after $
1760                            continue;
1761                        }
1762                    }
1763                    Some('[') if crate::dash_mode::dash_strict() => {
1764                        // !!! DASH-STRICT GATE (no C counterpart) !!! dash/ash
1765                        // have no `$[...]` arithmetic substitution (a deprecated
1766                        // bash/zsh form; POSIX uses `$(( ))`). The `$` is a
1767                        // literal dollar and `[...]` ordinary text, so
1768                        // `echo $[1+2]` prints `$[1+2]` like /bin/dash. Emit
1769                        // `Bnull '$'` (mirrors the `$'...'` gate above) and push
1770                        // the `[` back so it re-lexes as a literal bracket.
1771                        hungetc('[');
1772                        LEX_LEXSTOP.set(false);
1773                        add(Bnull);
1774                        add('$');
1775                    }
1776                    Some('[') => {
1777                        // c:1023 — `$[...]` arithmetic substitution.
1778                        // C: `cmdpush(CS_MATHSUBST); ... c =
1779                        // dquote_parse(']', sub); cmdpop();`
1780                        add(Stringg);
1781                        add(Inbrack);
1782                        cmdpush(CS_MATHSUBST as u8);
1783                        let r = dquote_parse(']', sub);
1784                        cmdpop();
1785                        if r.is_err() {
1786                            peek = LEXERR;
1787                            break;
1788                        }
1789                        add(Outbrack);
1790                    }
1791                    Some('(') => {
1792                        // $(...) or $((...))
1793                        add(Stringg);
1794                        match cmd_or_math_sub() {
1795                            CMD_OR_MATH_CMD => add(Outpar),
1796                            CMD_OR_MATH_MATH => add(Outparmath),
1797                            CMD_OR_MATH_ERR | _ => {
1798                                peek = LEXERR;
1799                                break;
1800                            }
1801                        }
1802                    }
1803                    Some('{') => {
1804                        // c:1049-1057 — `${...}` parameter expansion.
1805                        // C does `add(c)` where `c` was already
1806                        // mapped by `lextok2[c]` at switch entry from
1807                        // `$` (0x24) to Stringg (`\u{85}`). Rust's
1808                        // switch dispatches on the LX2_* class but
1809                        // doesn't pre-map `c`, so `add(c)` here would
1810                        // store the raw `$` byte. Use the marker
1811                        // directly to match C's emitted strs section.
1812                        add(Stringg);
1813                        add(Inbrace);
1814                        bct += 1;
1815                        cmdpush(CS_BRACEPAR as u8);
1816                        if in_brace_param == 0 {
1817                            in_brace_param = bct;
1818                        }
1819                    }
1820                    Some('\'') if crate::dash_mode::dash_strict() => {
1821                        // !!! DASH-STRICT GATE (no C counterpart) !!!
1822                        // dash has no `$'...'` ANSI-C quoting: the `$` is a
1823                        // literal dollar and the following `'` opens an
1824                        // ordinary single-quoted string, so `printf %s $'\t'`
1825                        // yields `$\t` exactly like /bin/dash.
1826                        //
1827                        // The `$` MUST be emitted as an escaped literal
1828                        // (`Bnull '$'`), NOT as `Stringg`: the re-lexed
1829                        // single quote emits a leading `Snull`, and the byte
1830                        // pair `Stringg Snull` is precisely what getkeystring
1831                        // (lex.rs:4910) decodes as a `$'...'` region — which
1832                        // would re-enable the ANSI-C decode we are suppressing.
1833                        // `Bnull '$'` is a plain literal dollar that cannot
1834                        // combine with the following `Snull`.
1835                        hungetc('\'');
1836                        LEX_LEXSTOP.set(false);
1837                        add(Bnull);
1838                        add('$');
1839                    }
1840                    Some('\'') => {
1841                        // $'...' ANSI-C escape syntax.
1842                        // Port of Src/lex.c:1284-1314 (LX2_QUOTE
1843                        // branch when prev char was `String`):
1844                        // only `\\` and `\'` emit a `Bnull`
1845                        // marker (so getkeystring later
1846                        // recognizes them as user-literal); any
1847                        // other `\X` emits a literal `\` + the
1848                        // following char so getkeystring's
1849                        // standard `\n`/`\x`/`\u`/... decoding
1850                        // can fire.
1851                        //
1852                        // c:1285 — the C source detects $'...' via
1853                        // `strquote = (lexbuf.len && lexbuf.ptr[-1]
1854                        // == String)`, i.e. by looking back for the
1855                        // Stringg marker the top-level `$` handler
1856                        // emitted. So the marker here MUST be
1857                        // Stringg (`\u{85}`), not Qstring (`\u{8c}`,
1858                        // which is `$` inside double quotes). Using
1859                        // Qstring made getkeystring's $'...' detect
1860                        // fail and the strs section diverge from C.
1861                        add(Stringg);
1862                        add(Snull);
1863                        loop {
1864                            let ch = hgetc();
1865                            match ch {
1866                                Some('\'') => break,
1867                                Some('\\') => {
1868                                    let next = hgetc();
1869                                    match next {
1870                                        Some(n) => {
1871                                            if n == '\\' || n == '\'' {
1872                                                add(Bnull);
1873                                            } else {
1874                                                add('\\');
1875                                            }
1876                                            add(n);
1877                                        }
1878                                        None => {
1879                                            LEX_LEXSTOP.set(true);
1880                                            unmatched = '\'';
1881                                            // c:1320 — LEXERR only when not ZLE/bufferwords.
1882                                            if LEX_LEXFLAGS.get() & LEXFLAGS_ACTIVE == 0 {
1883                                                peek = LEXERR;
1884                                            }
1885                                            break;
1886                                        }
1887                                    }
1888                                }
1889                                Some(ch) => add(ch),
1890                                None => {
1891                                    LEX_LEXSTOP.set(true);
1892                                    unmatched = '\'';
1893                                    // c:1320 — LEXERR only when not ZLE/bufferwords.
1894                                    if LEX_LEXFLAGS.get() & LEXFLAGS_ACTIVE == 0 {
1895                                        peek = LEXERR;
1896                                    }
1897                                    break;
1898                                }
1899                            }
1900                        }
1901                        if unmatched != '\0' {
1902                            break;
1903                        }
1904                        add(Snull);
1905                    }
1906                    Some('"') => {
1907                        // $"..." localized string. Same shape as a
1908                        // plain "..." but flagged via Stringg+Dnull
1909                        // (NOT Qstring) so the dollar prefix marker
1910                        // sits in the strs section as `\u{85}\u{9e}…`.
1911                        // Qstring (`\u{8c}`) is reserved for $X
1912                        // sequences encountered INSIDE double quotes
1913                        // (c:1524, 1546, 1551 inside dquote_parse);
1914                        // top-level `$"…"` uses Stringg per
1915                        // C lex.c's $-dispatch.
1916                        add(Stringg);
1917                        add(Dnull);
1918                        if dquote_parse('"', sub).is_err() {
1919                            peek = LEXERR;
1920                            break;
1921                        }
1922                        add(Dnull);
1923                    }
1924                    _ => {
1925                        if let Some(e) = e {
1926                            hungetc(e);
1927                        }
1928                        LEX_LEXSTOP.set(false);
1929                        add(Stringg);
1930                    }
1931                }
1932            }
1933
1934            // c:1057 — `case LX2_INBRACK:` — `[`.
1935            LX2_INBRACK => {
1936                if in_brace_param == 0 {
1937                    brct += 1;
1938                }
1939                add(Inbrack);
1940            }
1941
1942            // c:1063 — `case LX2_OUTBRACK:` — `]`.
1943            LX2_OUTBRACK => {
1944                if in_brace_param == 0 && brct > 0 {
1945                    brct -= 1;
1946                }
1947                add(Outbrack);
1948            }
1949
1950            // c:1078 — `case LX2_INPAR:` — `(`.
1951            LX2_INPAR => {
1952                // c:1079-1086 — under SHGLOB, `(` inside `${...}` or
1953                // substitution context breaks; and `(` after a non-
1954                // empty lexbuf is a break unless KSHGLOB is also set
1955                // (KSH-style `name(...)` pattern matching).
1956                // c:Src/lex.c:1080-1081 — `if (sub || in_brace_param) break;`.
1957                // That `break` leaves the SWITCH, not the token loop: `c` is
1958                // still the literal `(`, so the common `add(c)` after the
1959                // switch (c:1417) emits it and lexing CONTINUES to the closing
1960                // `}`. A Rust `break` exits the lexer loop and ENDS the token,
1961                // so `${(t)PATH}` under SHGLOB stopped at the `(` with
1962                // in_brace_param still 1 and died "closing brace expected".
1963                // The `goto brk` case below is a REAL token break and stays a
1964                // Rust `break`. Bug #1052.
1965                let mut shglob_literal_inpar = false;
1966                if isset(SHGLOB) {
1967                    if sub || in_brace_param > 0 {
1968                        shglob_literal_inpar = true;
1969                    }
1970                    // c:1084 — `!KSHGLOB && lexbuf.len → goto brk`. In sh
1971                    // emulation (SHGLOB, no KSHGLOB) a `(` after existing
1972                    // content ends the word (ksh function-def heuristic). But
1973                    // in the `[[ ]]` cond-RHS PATTERN context (par_cond bumps
1974                    // incond >1 so "parentheses do globbing"), adjacent groups
1975                    // like `[[ x =~ (a)(b) ]]` must stay ONE regex word — the
1976                    // second `(` is a literal, not a word break. Verified vs
1977                    // zsh: `emulate sh; [[ ab =~ (a)(b) ]]` works. Without this
1978                    // exception, `--bash` (KSHGLOB off) split the regex and
1979                    // par_cond failed with "condition expected".
1980                    else if unset(KSHGLOB)
1981                        && LEX_INCOND.get() <= 1
1982                        && LEX_LEXBUF.with_borrow(|b| b.len) > 0
1983                    {
1984                        break;
1985                    }
1986                }
1987                if shglob_literal_inpar {
1988                    // c:1417 `add(c)` with `c` still the raw `(`.
1989                    add(c);
1990                } else {
1991                // c:1086-1135 — when `(` appears inside a Stringg and
1992                // is immediately followed by `)`, the string
1993                // terminates at the `(`. The `()` is then re-lexed as
1994                // a separate INOUTPAR token. This handles function
1995                // definitions: `name()` lexes as Stringg `name` +
1996                // INOUTPAR `()`, not Stringg `name()`.
1997                //
1998                // c:1112-1131 — under SHGLOB, a `(` followed by
1999                // whitespace at the start of a command-position word
2000                // (no nested brackets/braces) is a ksh function
2001                // definition signal — same break-out behaviour.
2002                if in_brace_param == 0 && !sub {
2003                    let e = hgetc();
2004                    if let Some(ch) = e {
2005                        hungetc(ch);
2006                    }
2007                    LEX_LEXSTOP.set(false);
2008                    let is_inblank = matches!(e, Some(' ' | '\t'));
2009                    if e == Some(')')
2010                        || (isset(SHGLOB)
2011                            && is_inblank
2012                            && bct == 0
2013                            && brct == 0
2014                            && intpos > 0
2015                            && LEX_INCMDPOS.get())
2016                    {
2017                        // `name()` (or KSH-style `name( ... )`)
2018                        // — terminate Stringg at `(` so the
2019                        // following `()` re-lexes as INOUTPAR.
2020                        break;
2021                    }
2022                }
2023                    if in_brace_param == 0 {
2024                        pct += 1;
2025                    }
2026                    add(Inpar);
2027                }
2028            }
2029
2030            // c:1137 — `case LX2_INBRACE:` — `{`.
2031            //
2032            // c:1138 — `if ((isset(IGNOREBRACES) && !cmdsubst) || sub)
2033            // c = '{';` — with IGNOREBRACES (or in substitution
2034            // context), `{` is added as a literal `{`, not tokenised
2035            // as Inbrace, and bct is NOT incremented.
2036            //
2037            // c:1141 — `if (!lexbuf.len && incmdpos) { add('{'); ...
2038            // return STRING; }` — at command position with an empty
2039            // buffer, return immediately as STRING("{") so the post-
2040            // lex `reswdtab` lookup can promote it to INBRACE_TOK
2041            // (the `{` keyword entry in `reswdtab`).
2042            //
2043            // c:1146 — `if (in_brace_param) cmdpush(CS_BRACE); bct++;`
2044            // — when entering a brace inside a `${...}` (or any other
2045            // bct++ path), push a CS_BRACE context for prompt/completion
2046            // tracking. After the switch, C falls through to `add(c)`
2047            // at lex.c:1420, where `c` was rewritten via `lextok2[c]`
2048            // (lex.c:964) to the `Inbrace` marker (lex.c:429:
2049            // `lextok2['{'] = Inbrace`). The Rust port doesn't have the
2050            // pre-switch `c = lextok2[c]` rewrite OR the post-switch
2051            // `add(c)` — both arms must be inlined per LX2 case.
2052            LX2_INBRACE => {
2053                if (isset(IGNOREBRACES) && !cmdsubst) || sub {
2054                    add('{');
2055                } else {
2056                    if LEX_LEXBUF.with_borrow(|b| b.len) == 0 && LEX_INCMDPOS.get() {
2057                        // c:1141-1144 — `add('{'); *lexbuf.ptr = '\0'; return STRING;`
2058                        // Direct return; C does NOT fall through to the
2059                        // post-loop `hungetc(c)` because `{` was fully
2060                        // consumed.
2061                        add('{');
2062                        set_tokstr(Some(LEX_LEXBUF.with_borrow(|b| b.as_str().to_string())));
2063                        return STRING_LEX;
2064                    }
2065                    if in_brace_param > 0 {
2066                        cmdpush(CS_BRACE as u8);
2067                    }
2068                    // Track braces for both ${...} param expansion and {...} brace expansion
2069                    bct += 1;
2070                    // c:1420 — `add(c)` after switch, with c = lextok2['{']
2071                    // = Inbrace (c:429). Inlined here since the Rust port
2072                    // skipped the unconditional post-switch add.
2073                    add(Inbrace);
2074                }
2075            }
2076
2077            // c:1152 — `case LX2_OUTBRACE:` — `}`.
2078            //
2079            // c:1153 — `if ((isset(IGNOREBRACES) || sub) && !in_brace_param)
2080            // break;` — under IGNOREBRACES (or substitution
2081            // context), and not inside a `${...}`, the `}` is added
2082            // as a literal `}` (via the LX2_OTHER-style fallthrough).
2083            //
2084            // c:1158-1162 — `if (in_brace_param) cmdpop(); if (bct--
2085            // == in_brace_param) { if (cmdsubst) cmdpop();
2086            // in_brace_param = cmdsubst = in_pattern = 0; }` — pop
2087            // the matching CS_BRACE (and CS_BRACEPAR if closing the
2088            // outermost ${...}).
2089            LX2_OUTBRACE => {
2090                if (isset(IGNOREBRACES) || sub) && in_brace_param == 0 {
2091                    add('}');
2092                } else if in_brace_param > 0 {
2093                    cmdpop(); // matches CS_BRACE or CS_BRACEPAR
2094                    if bct == in_brace_param {
2095                        if cmdsubst {
2096                            cmdpop();
2097                        }
2098                        in_brace_param = 0;
2099                        cmdsubst = false;
2100                    }
2101                    bct -= 1;
2102                    add(Outbrace);
2103                } else if bct > 0 {
2104                    // Closing a brace expansion like {a,b}. c:1165 —
2105                    // `c = Outbrace;` then falls through to the
2106                    // switch-exit `add(c)`. C maps `}` to Outbrace
2107                    // when bct>0 so the wordcode `strs` section sees
2108                    // the marker byte, not the raw `}`. Without this
2109                    // the strs section emits `{a,b,c}` literally
2110                    // instead of `{a,b,c\x90` and wordcode parity
2111                    // breaks on every brace expansion in the corpus.
2112                    bct -= 1;
2113                    add(Outbrace);
2114                } else {
2115                    // c:1156 — `if (!bct) break;` breaks out of the
2116                    // SWITCH (not the loop), then falls through to
2117                    // c:1420 `add(c)`. So a stray `}` (no matching `{`)
2118                    // is added as a literal `}` to the lexbuf, and the
2119                    // post-lex `s == "}"` check at zshlex promotes it
2120                    // to OUTBRACE_TOK. The previous Rust port broke
2121                    // out of the outer loop here, dropping `}` and
2122                    // returning STRING("") which never promoted.
2123                    add(c);
2124                }
2125            }
2126
2127            LX2_OUTANG => {
2128                // In pattern context (incondpat), > is literal
2129                if in_brace_param > 0 || sub || LEX_INCONDPAT.get() || LEX_INCASEPAT.get() > 0 {
2130                    add(c);
2131                } else {
2132                    let e = hgetc();
2133                    if e != Some('(') {
2134                        if let Some(e) = e {
2135                            hungetc(e);
2136                        }
2137                        LEX_LEXSTOP.set(false);
2138                        break;
2139                    }
2140                    // >(...)
2141                    add(OutangProc);
2142                    if skipcomm().is_err() {
2143                        peek = LEXERR;
2144                        break;
2145                    }
2146                    add(Outpar);
2147                }
2148            }
2149
2150            // c:1187 — `case LX2_INANG:` — `<` body, direct port.
2151            // C order: SHGLOB+sub guard, then `<(...)` proc-sub
2152            // (only when NOT in brace_param/sub), then isnumglob,
2153            // then the in_brace_param/sub guard that keeps `<` as
2154            // a literal character.
2155            LX2_INANG => {
2156                // c:1188 — `if (isset(SHGLOB) && sub) break;`. Same
2157                // switch-vs-loop distinction the c:1209 case below already
2158                // documents: C's `break` falls through to the post-switch
2159                // `add(c)` (c:1417) with `c` still the raw `<`, so the token
2160                // continues and the `<` is LITERAL. Ending the token here made
2161                // `emulate sh -c '${a[(r)<->]}'` keep numeric-range-glob
2162                // semantics and match `1`, where zsh compares against the
2163                // literal text `<->` and finds nothing. Bug #1052.
2164                if isset(SHGLOB) && sub {
2165                    add(c);
2166                } else {
2167                // c:1190-1198 — `e = hgetc(); if (!(in_brace_param ||
2168                // sub) && e == '(') { add(Inang); skipcomm(); c =
2169                // Outpar; break; }`. `<(...)` process-sub only when
2170                // outside brace_param/sub — inside, `<` stays literal.
2171                let e = hgetc(); // c:1190
2172                if !(in_brace_param > 0 || sub) && e == Some('(') {
2173                    // c:1191
2174                    add(Inang); // c:1192
2175                    if skipcomm().is_err() {
2176                        // c:1193
2177                        peek = LEXERR;
2178                        break;
2179                    }
2180                    add(Outpar); // c:1197 c=Outpar
2181                } else {
2182                    // c:1200 — `hungetc(e);`.
2183                    if let Some(e) = e {
2184                        hungetc(e);
2185                    }
2186                    // c:1201-1207 — isnumglob → emit Inang/.../Outang.
2187                    if LEX_INCONDPAT.get() || LEX_INCASEPAT.get() > 0 {
2188                        // [[ ]] / case pattern context: `<` literal.
2189                        // Original zshrs note: real zsh's wordcode has
2190                        // Inang/Outang markers even inside ${var//pat/repl}
2191                        // so isnumglob still fires below — but cond /
2192                        // case patterns stay raw.
2193                        add(c);
2194                    } else if isnumglob() {
2195                        // c:1201
2196                        // c:1202-1206 — emit Inang…Outang markers.
2197                        add(Inang); // c:1202
2198                        while let Some(ch) = hgetc() {
2199                            // c:1203
2200                            if ch == '>' {
2201                                break;
2202                            }
2203                            add(ch); // c:1204
2204                        }
2205                        add(Outang); // c:1205
2206                    } else {
2207                        // c:1208 — `lexstop = 0;`.
2208                        LEX_LEXSTOP.set(false); // c:1208
2209                                                // c:1209-1210 — `if (in_brace_param || sub) break;`
2210                                                // exits the C switch and falls to the
2211                                                // post-switch `add(c)`. In Rust the LX2_INANG
2212                                                // arm doesn't fall to a shared add — add `<`
2213                                                // explicitly here so it lands in the token
2214                                                // buffer. Inside `${...}` and `$(...)` /
2215                                                // backticks, bare `<` is literal — required
2216                                                // for patterns like `${arr[@]:#<no-data>}`
2217                                                // (zinit.zsh:2507) which excludes elements
2218                                                // matching the literal `<no-data>`.
2219                        if in_brace_param > 0 || sub {
2220                            // c:1209
2221                            add(c);
2222                        } else {
2223                            // c:1211 — `goto brk;` outside brace_param/sub
2224                            // ends the token (bare `<` is a redirect).
2225                            break;
2226                        }
2227                    }
2228                }
2229                }
2230            }
2231
2232            LX2_EQUALS => {
2233                if !sub {
2234                    if intpos > 0 {
2235                        // At start of token, check for =(...) process substitution
2236                        let e = hgetc();
2237                        if e == Some('(') {
2238                            add(Equals);
2239                            if skipcomm().is_err() {
2240                                peek = LEXERR;
2241                                break;
2242                            }
2243                            add(Outpar);
2244                        } else {
2245                            if let Some(e) = e {
2246                                hungetc(e);
2247                            }
2248                            LEX_LEXSTOP.set(false);
2249                            add(Equals);
2250                        }
2251                    } else if peek != ENVSTRING
2252                        && (LEX_INCMDPOS.get() || LEX_INTYPESET.get())
2253                        && bct == 0
2254                        && brct == 0
2255                        && LEX_INCASEPAT.get() <= 0
2256                    {
2257                        // c:1229-1230 — C gates only on
2258                        // `(incmdpos || intypeset) && !bct && !brct`;
2259                        // incasepat is not consulted. par_case sets
2260                        // incasepat=-1 with incmdpos=1 before lexing
2261                        // the token after a whole-`(...)` case
2262                        // pattern (parse.c:1300-1302) — that token
2263                        // may be the body's first word, and
2264                        // `out+=hit` there must still become
2265                        // ENVSTRING. Keep blocking only the >0
2266                        // in-pattern states.
2267                        // Check for VAR=value assignment (but not in case pattern context)
2268                        let tok_so_far = LEX_LEXBUF.with_borrow(|b| b.as_str().to_string());
2269                        if is_valid_assignment_target(&tok_so_far) {
2270                            let next = hgetc();
2271                            if next == Some('(') && crate::dash_mode::dash_strict() {
2272                                // !!! DASH-STRICT GATE (no C counterpart) !!!
2273                                // dash has no arrays; `name=(...)` is a hard
2274                                // syntax error ("( unexpected"). Emit LEXERR
2275                                // so the parse fails like /bin/dash instead
2276                                // of lexing an ENVARRAY assignment.
2277                                peek = LEXERR;
2278                                break;
2279                            }
2280                            if next == Some('(') {
2281                                // VAR=(...) array assignment. Per zsh
2282                                // (lex.c emits ENVARRAY with tokstr =
2283                                // just the variable name, NOT
2284                                // including the `=`). The `=` and
2285                                // `(` are consumed by the lexer; the
2286                                // parser knows ENVARRAY means assign-
2287                                // array and reads the body that
2288                                // follows.
2289                                set_tokstr(Some(
2290                                    LEX_LEXBUF.with_borrow(|b| b.as_str().to_string()),
2291                                ));
2292                                return ENVARRAY;
2293                            }
2294                            if let Some(next) = next {
2295                                hungetc(next);
2296                            }
2297                            LEX_LEXSTOP.set(false);
2298                            peek = ENVSTRING;
2299                            intpos = 2;
2300                            add(Equals);
2301                        } else {
2302                            add(Equals);
2303                        }
2304                    } else {
2305                        add(Equals);
2306                    }
2307                } else {
2308                    add(Equals);
2309                }
2310            }
2311
2312            // c:1322 — `case LX2_BKSLASH:` — `\`.
2313            LX2_BKSLASH => {
2314                let next = hgetc();
2315                if next == Some('\n') {
2316                    // Line continuation
2317                    let next = hgetc();
2318                    if let Some(next) = next {
2319                        c = next;
2320                        continue;
2321                    }
2322                    break;
2323                } else {
2324                    add(Bnull);
2325                    if let Some(next) = next {
2326                        add(next);
2327                    }
2328                }
2329            }
2330
2331            // c:1257 — `case LX2_QUOTE:` — `'`.
2332            //
2333            // c:1307 — `else if (!sub && isset(CSHJUNKIEQUOTES) &&
2334            // c == '\n')` — under CSHJUNKIEQUOTES, a `\n` inside a
2335            // single-quoted string terminates the string (unless
2336            // preceded by `\`, in which case both are stripped).
2337            //
2338            // c:1328 — `e = hgetc(); if (e != '\'' || unset(RCQUOTES)
2339            // || strquote) break; add(c);` — under RCQUOTES,
2340            // a doubled `''` inside a single-quoted string is an
2341            // escaped literal `'`, not the end of the string. We
2342            // re-open the loop and add a literal `'`.
2343            LX2_QUOTE => {
2344                // c:1288 — `cmdpush(CS_QUOTE);`. Pops at c:1322 (inside
2345                // RCQUOTES `''` re-entry) and c:1330 (final break).
2346                cmdpush(CS_QUOTE as u8);
2347                // Single quoted string - everything literal until '
2348                add(Snull);
2349                loop {
2350                    let inner_loop_done = loop {
2351                        let ch = hgetc();
2352                        match ch {
2353                            Some('\'') => break false,
2354                            Some('\n') if !sub && isset(CSHJUNKIEQUOTES) => {
2355                                // CSHJUNKIEQUOTES: bare \n terminates.
2356                                // If preceded by `\`, the backslash is
2357                                // stripped (we approximate by peeking
2358                                // back at the buffer).
2359                                let last_was_bslash =
2360                                    LEX_LEXBUF.with_borrow(|b| b.as_str().ends_with('\\'));
2361                                if last_was_bslash {
2362                                    LEX_LEXBUF.with_borrow_mut(|b| {
2363                                        if let Some(s) = b.ptr.as_mut() {
2364                                            s.pop();
2365                                            b.len = s.len() as i32;
2366                                        }
2367                                    });
2368                                } else {
2369                                    break true; // terminate outer
2370                                }
2371                            }
2372                            Some(ch) => add(ch),
2373                            None => {
2374                                LEX_LEXSTOP.set(true);
2375                                unmatched = '\'';
2376                                peek = LEXERR;
2377                                break true;
2378                            }
2379                        }
2380                    };
2381                    if inner_loop_done || unmatched != '\0' {
2382                        break;
2383                    }
2384                    // c:1328 — RCQUOTES `''` → literal `'`.
2385                    if unset(RCQUOTES) {
2386                        break;
2387                    }
2388                    let e = hgetc();
2389                    if e != Some('\'') {
2390                        if let Some(e) = e {
2391                            hungetc(e);
2392                        }
2393                        LEX_LEXSTOP.set(false);
2394                        break;
2395                    }
2396                    add('\'');
2397                }
2398                // c:1330 — `cmdpop();` matches c:1288 push.
2399                cmdpop();
2400                if unmatched != '\0' {
2401                    break;
2402                }
2403                add(Snull);
2404            }
2405
2406            // c:1334 — `case LX2_DQUOTE:` — `"`.
2407            //
2408            // c:1338-1340 — `cmdpush(CS_DQUOTE); c = dquote_parse('"',
2409            // sub); cmdpop();`
2410            LX2_DQUOTE => {
2411                // Double quoted string
2412                add(Dnull);
2413                cmdpush(CS_DQUOTE as u8);
2414                let r = dquote_parse('"', sub);
2415                cmdpop();
2416                if r.is_err() {
2417                    unmatched = '"';
2418                    if LEX_LEXFLAGS.get() & LEXFLAGS_ACTIVE == 0 {
2419                        peek = LEXERR;
2420                    }
2421                    break;
2422                }
2423                add(Dnull);
2424            }
2425
2426            // c:1351 — `case LX2_BQUOTE:` — `` ` ``. Push/pop at
2427            // c:1352, 1379.
2428            //
2429            // c:1362 — `else if (!sub && isset(CSHJUNKIEQUOTES))
2430            // add(c);` — under CSHJUNKIEQUOTES, a `\<newline>` inside
2431            // backticks keeps the literal newline (line continuation
2432            // is NOT applied).
2433            //
2434            // c:1365 — `if (!sub && isset(CSHJUNKIEQUOTES) &&
2435            // c == '\n') break;` — under CSHJUNKIEQUOTES, a bare `\n`
2436            // inside backticks terminates the substitution.
2437            LX2_BQUOTE => {
2438                // Backtick command substitution
2439                add(Tick);
2440                cmdpush(CS_BQUOTE as u8);
2441                loop {
2442                    let ch = hgetc();
2443                    match ch {
2444                        Some('`') => break,
2445                        Some('\\') => {
2446                            let next = hgetc();
2447                            match next {
2448                                Some('\n') => {
2449                                    if !sub && isset(CSHJUNKIEQUOTES) {
2450                                        add('\n');
2451                                    }
2452                                    // Line continuation (default)
2453                                    continue;
2454                                }
2455                                Some(c) if c == '`' || c == '\\' || c == '$' => {
2456                                    add(Bnull);
2457                                    add(c);
2458                                }
2459                                Some(c) => {
2460                                    add('\\');
2461                                    add(c);
2462                                }
2463                                None => break,
2464                            }
2465                        }
2466                        Some('\n') if !sub && isset(CSHJUNKIEQUOTES) => {
2467                            // CSHJUNKIEQUOTES: bare \n terminates.
2468                            break;
2469                        }
2470                        Some(ch) => add(ch),
2471                        None => {
2472                            LEX_LEXSTOP.set(true);
2473                            unmatched = '`';
2474                            // c:1383 — LEXERR only when not ZLE/bufferwords.
2475                            if LEX_LEXFLAGS.get() & LEXFLAGS_ACTIVE != 0 { /* tolerate */ } else {
2476                            peek = LEXERR;
2477                            }
2478                            break;
2479                        }
2480                    }
2481                }
2482                // c:1379 — `cmdpop();` matches c:1352 push.
2483                cmdpop();
2484                if unmatched != '\0' {
2485                    break;
2486                }
2487                add(Tick);
2488            }
2489
2490            // c:1044 — `case LX2_TILDE:` — `~`.
2491            LX2_TILDE => {
2492                add(Tilde);
2493            }
2494
2495            // c:1162 — `case LX2_COMMA:` — `,`.
2496            //
2497            // c:1163 — `if (unset(IGNOREBRACES) && !sub && bct > in_brace_param)
2498            // c = Comma;` — only emit Comma (brace-expansion sep)
2499            // when IGNOREBRACES is off, not in substitution context,
2500            // and we are deeper than the current `${...}` brace.
2501            // Otherwise emit literal `,` (falls through to LX2_OTHER).
2502            LX2_COMMA if unset(IGNOREBRACES) && !sub && bct > in_brace_param => {
2503                add(Comma);
2504            }
2505            LX2_COMMA => {
2506                add(c);
2507            }
2508
2509            // c:1394 — `case LX2_DASH:` — `-`.
2510            LX2_DASH => {
2511                add(Dash);
2512            }
2513
2514            // c:1400 — `case LX2_BANG:` — `!`.
2515            LX2_BANG if brct > 0 => {
2516                add(Bang);
2517            }
2518
2519            // c:967 — `case LX2_BREAK:` — `;` and `&`.
2520            //
2521            // Direct port of C: `if (!in_brace_param && !sub) goto brk;`.
2522            // C does NOT consult `brct` here — bare `[` in an assignment
2523            // RHS like `a=foo[bar; echo` does NOT prevent `;` from
2524            // terminating the word. zsh keeps `foo[bar` as the literal
2525            // value (the `[` is unmatched and globbing reports
2526            // "no matches" only when expanded, not at lex time).
2527            // The previous Rust port added `&& brct == 0` which made
2528            // `;` part of the token whenever an unmatched `[` was
2529            // seen — `a=foo[bar; echo done` produced `a=foo[bar;`
2530            // as the single ENVSTRING, swallowing the next command.
2531            // Bug #604. `pct == 0` is also dropped — `(...)` grouping
2532            // is handled by the outer parser's command structure, not
2533            // by `;` being mid-token here.
2534            LX2_BREAK if in_brace_param == 0 => {
2535                break;
2536            }
2537            LX2_BREAK => {
2538                add(c);
2539            }
2540
2541            // c:1411 — `case LX2_OTHER:` — fallthrough.
2542            // C translates via `c = lextok2[STOUC(c)]` then adds —
2543            // turning `*` → `Star`, `?` → `Quest`, `#` → `Pound`,
2544            // `^` → `Hat` (other byte-token chars `{`, `[`, `$`,
2545            // `~` already have their own LX2_* arms).
2546            //
2547            // zshrs preserves the existing `\n` early-termination
2548            // behavior at top level — C handles `\n` in `gettok`,
2549            // not `gettokstr`, but our gettok hands off mid-word
2550            // through `lex_initial_other`, so `\n` can land here.
2551            LX2_OTHER => {
2552                if c == '\n' && in_brace_param == 0 && pct == 0 && brct == 0 {
2553                    break;
2554                }
2555                // Multibyte UTF-8 codepoints (>= 256) pass through
2556                // verbatim — lextok2 only maps the 256-entry byte
2557                // table (C's `lextok2[STOUC(c)]` truncates to u8).
2558                // Previously `lextok2_get(c) as char` truncated
2559                // codepoint to low byte (日 U+65E5 → å U+00E5),
2560                // mangling every unquoted multibyte assignment value
2561                // (`a=日本; echo $a` printed "å,"). The table only
2562                // matters for ASCII glob metacharacters; everything
2563                // else is identity.
2564                if (c as u32) >= 256 {
2565                    add(c);
2566                } else {
2567                    add(lextok2_get(c) as char);
2568                }
2569            }
2570
2571            _ => {
2572                add(c);
2573            }
2574        }
2575
2576        c = match hgetc() {
2577            Some(c) => c,
2578            None => {
2579                LEX_LEXSTOP.set(true);
2580                break;
2581            }
2582        };
2583
2584        if intpos > 0 {
2585            intpos -= 1;
2586        }
2587    }
2588
2589    // Put back the character that ended the token
2590    if !LEX_LEXSTOP.get() {
2591        hungetc(c);
2592    }
2593
2594    // c:1445-1446 — `if (unmatched && !(lexflags & LEXFLAGS_ACTIVE))
2595    //                  zerr("unmatched %c", unmatched);`
2596    if unmatched != '\0' && LEX_LEXFLAGS.get() & LEXFLAGS_ACTIVE == 0 {
2597        zerr(&format!("unmatched {}", unmatched));
2598    }
2599
2600    // c:1447-1453 — `zerr("closing brace expected");` when in_brace_param
2601    // is still open at end of token. Suppressed while actively lexing for ZLE
2602    // completion (LEXFLAGS_ACTIVE): `echo ${PA<Tab>` is an open brace at the
2603    // cursor, not a syntax error — zsh completes it to `${PATH}`.
2604    if in_brace_param > 0 && LEX_LEXFLAGS.get() & LEXFLAGS_ACTIVE == 0 {
2605        zerr("closing brace expected");
2606    }
2607    // c:1453-1469 — `} else if (unset(IGNOREBRACES) && !sub &&
2608    //   lexbuf.len > 1 && peek == STRING && lexbuf.ptr[-1] == '}' &&
2609    //   lexbuf.ptr[-2] != Bnull) {`
2610    // c:1457 — /* hack to get {foo} command syntax work */
2611    // A word ENDING in an unmatched literal `}` (bct==0 left it in
2612    // the buffer un-rewritten — lextok2['}'] is identity, c:419)
2613    // sheds the `}` and pushes it back so the next gettok lexes it
2614    // as the standalone `}` word → reswdtab OUTBRACE. This is what
2615    // makes `{print hi}` / `f(){print x}` close their blocks and
2616    // `echo hi}` a parse error, while mid-word `a}b` stays literal.
2617    else if unset(IGNOREBRACES)
2618        && !sub
2619        && LEX_LEXBUF.with_borrow(|b| b.buf_len()) > 1
2620        && peek == STRING_LEX
2621        && LEX_LEXBUF.with_borrow(|b| {
2622            let mut it = b.as_str().chars().rev();
2623            it.next() == Some('}') && it.next() != Some(Bnull)
2624        })
2625    {
2626        // c:1463-1464 — `int lar = lex_add_raw; lex_add_raw =
2627        // lexbuf_raw.len > 0 && lexbuf_raw.ptr[-1] == '}';` — only
2628        // strip the raw-mirror `}` if the raw buffer also ends in
2629        // one (alias expansion can desynchronize them, per the C
2630        // comment "Just go with it, OK?").
2631        let lar = LEX_LEX_ADD_RAW.get();
2632        LEX_LEX_ADD_RAW.set(
2633            LEX_LEXBUF_RAW.with_borrow(|b| {
2634                (b.len > 0 && b.ptr.as_deref().unwrap_or("").ends_with('}')) as i32
2635            }),
2636        );
2637        // c:1465-1466 — `lexbuf.ptr--; lexbuf.len--;`
2638        LEX_LEXBUF.with_borrow_mut(|b| {
2639            b.pop();
2640        });
2641        // c:1467-1468 — `lexstop = 0; hungetc('}');`
2642        LEX_LEXSTOP.set(false);
2643        hungetc('}');
2644        // c:1469 — `lex_add_raw = lar;`
2645        LEX_LEX_ADD_RAW.set(lar);
2646    }
2647
2648    set_tokstr(Some(LEX_LEXBUF.with_borrow(|b| b.as_str().to_string())));
2649    peek
2650}
2651
2652/// Parse the body of a double-quoted string (or any context that
2653/// uses double-quote tokenization — `(( ))`, `${...}`, `$( ( ) )`).
2654/// Direct port of `dquote_parse` from `Src/lex.c:1486`. Reads chars
2655/// until `endchar` is seen at depth 0, handling escapes, `${...}`,
2656/// `$(...)`, backtick, `$((...))`, and inner `"..."`.
2657fn dquote_parse(endchar: char, sub: bool) -> Result<(), ()> {
2658    let mut pct = 0; // parenthesis count
2659    let mut brct = 0; // bracket count
2660    let mut bct = 0; // brace count (for ${...})
2661    let mut intick = false; // inside backtick
2662    let is_math = endchar == ')' || endchar == ']' || LEX_INFOR.get() > 0;
2663
2664    // c:1643-1657 — on exit, drain matched-but-unpopped pushes:
2665    // every CS_BQUOTE (if `intick`), CS_BRACEPAR + (CS_CURSH when
2666    // it was set) (for each remaining bct level). Wrapped via
2667    // closure so success + every early Err goes through cleanup.
2668    let cleanup = |intick: bool, bct: i32| {
2669        if intick {
2670            cmdpop();
2671        }
2672        for _ in 0..bct {
2673            cmdpop(); // CS_BRACEPAR for each remaining `{`.
2674        }
2675    };
2676
2677    loop {
2678        let c = hgetc();
2679        let c = match c {
2680            Some(c) if c == endchar && !intick && bct == 0 => {
2681                if is_math && (pct > 0 || brct > 0) {
2682                    add(c);
2683                    if c == ')' {
2684                        pct -= 1;
2685                    } else if c == ']' {
2686                        brct -= 1;
2687                    }
2688                    continue;
2689                }
2690                cleanup(intick, bct);
2691                return Ok(());
2692            }
2693            Some(c) => c,
2694            None => {
2695                LEX_LEXSTOP.set(true);
2696                cleanup(intick, bct);
2697                return Err(());
2698            }
2699        };
2700
2701        match c {
2702            // c:1499 — `case '\\':`.
2703            '\\' => {
2704                let next = hgetc();
2705                match next {
2706                    Some('\n') => {
2707                        // c:1515 — `else if (sub || unset(CSHJUNKIEQUOTES)
2708                        // || endchar != '"') continue;` — under
2709                        // CSHJUNKIEQUOTES inside `"..."`, `\<newline>`
2710                        // is NOT line continuation; it falls through
2711                        // to add literal `\n`.
2712                        if sub || unset(CSHJUNKIEQUOTES) || endchar != '"' {
2713                            continue;
2714                        }
2715                        add('\n');
2716                    }
2717                    Some(c)
2718                        if c == '$'
2719                            || c == '\\'
2720                            || (c == '}' && !intick && bct > 0)
2721                            || c == endchar
2722                            || c == '`'
2723                            || (endchar == ']'
2724                                && (c == '['
2725                                    || c == ']'
2726                                    || c == '('
2727                                    || c == ')'
2728                                    || c == '{'
2729                                    || c == '}'
2730                                    || (c == '"' && sub))) =>
2731                    {
2732                        // c:Src/lex.c:1501-1513 — C's `\X` arm in
2733                        // dquote_parse emits `Bnull X` exactly when
2734                        // `X` is in the special-escape list. The
2735                        // literal `{`/`}` that may follow `\$` is NOT
2736                        // in C's list at 1501, so C emits a raw
2737                        // `{`/`}` next. The subst-time scanner at
2738                        // subst.rs:2920 already gates raw `{` count
2739                        // by checking `prev2 != Bnull && prev2 != '\\'`
2740                        // (a two-char look-back, not a one-char check),
2741                        // so `Bnull $ {` reads as escaped and stays
2742                        // uncounted. Symmetric for the closing `}`:
2743                        // the `\}` immediately after `y` emits its
2744                        // own `Bnull }` via THIS arm (since `}` is
2745                        // in the special list when `!intick && bct > 0`),
2746                        // and the scanner's `prev != Bnull` gate keeps
2747                        // it uncounted. No extra Bnull insertion needed.
2748                        add(Bnull);
2749                        add(c);
2750                    }
2751                    Some(c) => {
2752                        add('\\');
2753                        hungetc(c);
2754                        continue;
2755                    }
2756                    None => {
2757                        add('\\');
2758                    }
2759                }
2760            }
2761
2762            // c:1517 — `case '\n': err = !sub && isset(CSHJUNKIEQUOTES)
2763            // && endchar == '"';` — under CSHJUNKIEQUOTES, a bare `\n`
2764            // inside `"..."` is an error (unterminated string).
2765            '\n' if !sub && isset(CSHJUNKIEQUOTES) && endchar == '"' => {
2766                return Err(());
2767            }
2768
2769            '$' => {
2770                if intick {
2771                    add(c);
2772                    continue;
2773                }
2774                let next = hgetc();
2775                match next {
2776                    Some('(') => {
2777                        add(Qstring);
2778                        match cmd_or_math_sub() {
2779                            CMD_OR_MATH_CMD => add(Outpar),
2780                            CMD_OR_MATH_MATH => add(Outparmath),
2781                            CMD_OR_MATH_ERR | _ => return Err(()),
2782                        }
2783                    }
2784                    Some('[') => {
2785                        // c:1541 — `cmdpush(CS_MATHSUBST); err =
2786                        // dquote_parse(']', sub); cmdpop();`
2787                        add(Stringg);
2788                        add(Inbrack);
2789                        cmdpush(CS_MATHSUBST as u8);
2790                        let r = dquote_parse(']', sub);
2791                        cmdpop();
2792                        r?;
2793                        add(Outbrack);
2794                    }
2795                    Some('{') => {
2796                        // c:1548 — `cmdpush(CS_BRACEPAR); bct++;`
2797                        add(Qstring);
2798                        add(Inbrace);
2799                        cmdpush(CS_BRACEPAR as u8);
2800                        bct += 1;
2801                    }
2802                    Some('$') => {
2803                        add(Qstring);
2804                        add('$');
2805                    }
2806                    _ => {
2807                        if let Some(next) = next {
2808                            hungetc(next);
2809                        }
2810                        LEX_LEXSTOP.set(false);
2811                        add(Qstring);
2812                    }
2813                }
2814            }
2815
2816            '}' => {
2817                if intick || bct == 0 {
2818                    add(c);
2819                } else {
2820                    // c:1575/1577 — `cmdpop()` for inner brace, plus
2821                    // matching CS_BRACEPAR pop on the outermost
2822                    // closer.
2823                    add(Outbrace);
2824                    cmdpop();
2825                    bct -= 1;
2826                }
2827            }
2828
2829            // c:1583 — `case '`':` — backtick toggle.
2830            // c:1585 — `cmdpush(CS_BQUOTE)` on entry, c:1588
2831            // `cmdpop()` on exit.
2832            '`' => {
2833                add(Qtick);
2834                if intick {
2835                    cmdpop();
2836                } else {
2837                    cmdpush(CS_BQUOTE as u8);
2838                }
2839                intick = !intick;
2840            }
2841
2842            '(' => {
2843                if !is_math || bct == 0 {
2844                    pct += 1;
2845                }
2846                add(c);
2847            }
2848
2849            ')' => {
2850                if !is_math || bct == 0 {
2851                    if pct == 0 && is_math {
2852                        return Err(());
2853                    }
2854                    pct -= 1;
2855                }
2856                add(c);
2857            }
2858
2859            '[' => {
2860                if !is_math || bct == 0 {
2861                    brct += 1;
2862                }
2863                add(c);
2864            }
2865
2866            ']' => {
2867                if !is_math || bct == 0 {
2868                    if brct == 0 && is_math {
2869                        return Err(());
2870                    }
2871                    brct -= 1;
2872                }
2873                add(c);
2874            }
2875
2876            '"' => {
2877                if intick || (endchar != '"' && bct == 0) {
2878                    add(c);
2879                } else if bct > 0 {
2880                    // c:1620 — `cmdpush(CS_DQUOTE); err =
2881                    // dquote_parse('"', sub); cmdpop();`
2882                    add(Dnull);
2883                    cmdpush(CS_DQUOTE as u8);
2884                    let r = dquote_parse('"', sub);
2885                    cmdpop();
2886                    r?;
2887                    add(Dnull);
2888                } else {
2889                    return Err(());
2890                }
2891            }
2892
2893            _ => {
2894                add(c);
2895            }
2896        }
2897    }
2898}
2899
2900/// Tokenize a string as if in double quotes (error-reporting variant).
2901/// Port of `parsestr(char **s)` from `Src/lex.c:1694`.
2902/// C body:
2903/// ```c
2904/// int err;
2905/// if ((err = parsestrnoerr(s))) {                                  // c:1698
2906///     untokenize(*s);                                              // c:1699
2907///     if (!(errflag & ERRFLAG_INT)) {                              // c:1700
2908///         if (err > 32 && err < 127)                               // c:1701
2909///             zerr("parse error near `%c'", err);                  // c:1702
2910///         else
2911///             zerr("parse error");                                 // c:1704
2912///         tok = LEXERR;                                            // c:1705
2913///     }
2914/// }
2915/// return err;
2916/// ```
2917pub fn parsestr(s: &str) -> Result<String, String> {
2918    // c:1694
2919    match parsestrnoerr(s) {
2920        // c:1698
2921        Ok(result) => Ok(result),
2922        Err(msg) => {
2923            let untok = untokenize(s); // c:1699
2924            let _ = untok;
2925            let ef = errflag // c:1700
2926                .load(std::sync::atomic::Ordering::Relaxed);
2927            if (ef & crate::ported::zsh_h::ERRFLAG_INT) == 0 {
2928                // c:1700
2929                // c:1701-1704 — `if (err > 32 && err < 127)` switches between
2930                // "parse error near `%c'" and bare "parse error". The
2931                // Err(msg) string carries the diagnostic already formatted
2932                // by dquote_parse / gettokstr; emit it via zerr to match
2933                // C's stderr behaviour.
2934                zerr(&msg); // c:1702/1704
2935                set_tok(LEXERR); // c:1705
2936            }
2937            Err(msg)
2938        }
2939    }
2940}
2941
2942/// Port of `parsestrnoerr(char **s)` from `Src/lex.c:1713`.
2943///
2944/// C body:
2945/// ```c
2946/// zcontext_save();
2947/// untokenize(*s);
2948/// inpush(dupstring_wlen(*s, l), 0, NULL);
2949/// strinbeg(0);
2950/// lexbuf.len = 0; lexbuf.ptr = tokstr = *s; lexbuf.siz = l + 1;
2951/// err = dquote_parse('\0', 1);
2952/// if (tokstr) *s = tokstr;
2953/// *lexbuf.ptr = '\0';
2954/// strinend();
2955/// inpop();
2956/// zcontext_restore();
2957/// return err;
2958/// ```
2959///
2960/// Drives the real `dquote_parse` (with `endchar='\0'`, `sub=true`)
2961/// through the nested-lex-context machinery so `${...}`, `$(...)`,
2962/// `$((...))`, backticks, etc. tokenize recursively the same way
2963/// they do during a normal command parse. Returns the tokenized
2964/// string on success.
2965pub fn parsestrnoerr(s: &str) -> Result<String, String> {
2966    // c:1716 `untokenize(*s);` — C's untokenize (Src/exec.c:2077 +
2967    // Src/lex.c:38 ztokens) maps EVERY itok char to its ASCII
2968    // original: Dnull → `"`, Snull → `'`, Bnull → `\`. The Rust
2969    // plain `untokenize` deliberately STRIPS Snull/Dnull (see the
2970    // lex.rs untokenize doc block) — wrong for this call site: the
2971    // re-lex below must see the quote chars so a tokenized assoc
2972    // subscript like `Dnull a␠b Dnull` (from `${H["a b"]}`) round-
2973    // trips to the literal 5-char key `"a b"`. dquote_parse with
2974    // endchar='\0' adds a bare `"` literally (c:Src/lex.c:1615-1617
2975    // `if (intick || (endchar != '"' && !bct)) break;` → add(c)),
2976    // so the quotes survive to getarg's lookup as C intends. The
2977    // full ztokens mapping also matters for Qstring → `$`: the
2978    // dquote_parse re-lex below re-tokenizes nested `${k}`
2979    // (Src/lex.c:1519-1556 `case '$'`); a raw 0x8c marker would pass
2980    // through unrecognized and a nested-param subscript like
2981    // `${H["${k}"]}` would never expand `$k`.
2982    let untok = crate::ported::lex::untokenize_ztokens(s); // c:1716 `untokenize(*s);`
2983    let dup = dupstring_wlen(&untok, untok.len()); // c:1717
2984                                                   // c:1715 `zcontext_save();`
2985    zcontext_save();
2986    // Drain LEX_INPUT/LEX_POS so hgetc's two-input bridge prefers the
2987    // freshly-pushed inbuf frame (via inpush below) instead of double-
2988    // reading our content from both LEX_INPUT and inbuf. Restored
2989    // after dquote_parse returns.
2990    let saved_lex_input = LEX_INPUT.with_borrow(|s| s.clone());
2991    let saved_lex_pos = LEX_POS.get();
2992    // Append the '\0' sentinel C's dupstring_wlen carries — dquote_parse
2993    // returns Ok when hgetc returns endchar ('\0' here); without the
2994    // terminator hgetc returns None at EOF and dquote_parse errors.
2995    let mut input_with_nul = dup.clone();
2996    input_with_nul.push('\0');
2997    LEX_INPUT.with_borrow_mut(|b| b.clear());
2998    LEX_POS.set(0);
2999    LEX_LEXSTOP.set(false);
3000    // c:1717 `inpush(dupstring_wlen(*s, l), 0, NULL);`
3001    // Push the body AFTER clearing LEX_INPUT so hgetc reads only from
3002    // the inbuf frame (avoids double-counting characters that appear in
3003    // both LEX_INPUT and inbuf).
3004    inpush(&input_with_nul, 0, None);
3005    // c:1718 `strinbeg(0);`
3006    strinbeg(0);
3007    // c:1719-1721 — seed lexbuf with the input string so dquote_parse's
3008    // `add()` writes append onto our copy. `lexbuf.ptr/siz/len` are
3009    // reset; tokstr is aliased to the buffer.
3010    LEX_LEXBUF.with_borrow_mut(|b| {
3011        b.ptr = Some(String::with_capacity(untok.len() + 1));
3012        b.siz = (untok.len() + 1) as i32;
3013        b.len = 0;
3014    });
3015    set_tokstr(None);
3016    // c:1722 `err = dquote_parse('\0', 1);`
3017    let parse_err = dquote_parse('\0', true).is_err();
3018    // c:1723-1725 — `if (tokstr) *s = tokstr; *lexbuf.ptr = '\0';`
3019    let result = LEX_LEXBUF.with_borrow(|b| b.as_str().to_string());
3020    // c:1726 `strinend();`
3021    strinend();
3022    // c:1727 `inpop();`
3023    inpop();
3024    // Restore LEX_INPUT/LEX_POS so the outer lexer resumes where it
3025    // left off. Pairs with the swap above.
3026    LEX_INPUT.with_borrow_mut(|b| *b = saved_lex_input);
3027    LEX_POS.set(saved_lex_pos);
3028    // c:1730 — DPUTS(cmdsp, "BUG: parsestr: cmdstack not empty.")
3029    DPUTS!(
3030        // c:1730
3031        CMDSTACK.with(|s| !s.borrow().is_empty()), // c:1730
3032        "BUG: parsestr: cmdstack not empty."       // c:1730
3033    );
3034    // c:1729 `zcontext_restore();`
3035    zcontext_restore();
3036    if parse_err {
3037        // C parsestrnoerr (lex.c:1713) returns the offending char so the
3038        // caller (parsestr at lex.c:1694) can format `zerr("parse error
3039        // near \`%c'", err)`. zshrs returns Err(message); the diagnostic
3040        // already went through zerr at the dquote_parse / gettokstr
3041        // failure site, so a generic message is sufficient here.
3042        Err("parse error".to_string())
3043    } else {
3044        Ok(result)
3045    }
3046}
3047
3048/// Parse a subscript in string s. Return the position after the
3049/// closing bracket, or None on error.
3050///
3051/// Direct port of zsh/Src/lex.c:1743 `parse_subscript`. The C
3052/// source uses dupstring_wlen + inpush + dquote_parse to lex the
3053/// subscript through the main lexer; zshrs implements a focused
3054/// bracket-balancing walker that handles the same nesting rules
3055/// (`[...]`, `(...)`, `{...}`) without re-entering the lexer.
3056///
3057/// zshrs port note: zsh's parse_subscript also handles a `sub`
3058/// flag that controls whether `$` and quotes are tokenized — that
3059/// flag isn't exposed here. Most callers don't need it; the few
3060/// that do (parameter expansion's `${var[expr]}`) handle the
3061/// quote-aware lex separately at the expansion layer.
3062pub fn parse_subscript(s: &str, endchar: char) -> Option<usize> {
3063    // c:1746 `if (!*s || *s == endchar) return 0;`
3064    if s.is_empty() || s.starts_with(endchar) {
3065        return None;
3066    }
3067    let l = s.len();
3068    let untok = untokenize(s); // c:1749 `untokenize(t = dupstring_wlen(s, l));`
3069    let dup = dupstring_wlen(&untok, untok.len());
3070    // c:1748 `zcontext_save();`
3071    zcontext_save();
3072    // c:1750 `inpush(t, 0, NULL);`
3073    inpush(&dup, 0, None);
3074    // c:1751 `strinbeg(0);`
3075    strinbeg(0);
3076    // c:1763-1765 — seed lexbuf and run dquote_parse with the
3077    // caller's `endchar` + `sub=false` (zshrs's API omits the C `sub`
3078    // arg — all current callers pass 0).
3079    LEX_LEXBUF.with_borrow_mut(|b| {
3080        b.ptr = Some(String::with_capacity(l + 1));
3081        b.siz = (l + 1) as i32;
3082        b.len = 0;
3083    });
3084    let parse_err = dquote_parse(endchar, false).is_err();
3085    let toklen = LEX_LEXBUF.with_borrow(|b| b.len) as usize;
3086    // c:1771 — DPUTS(toklen > l, "Bad length for parsed subscript")
3087    DPUTS!(toklen > l, "Bad length for parsed subscript"); // c:1771
3088                                                           // c:1779 `strinend();` / c:1780 `inpop();` / c:1782
3089                                                           // `zcontext_restore();`
3090    strinend();
3091    inpop();
3092    // c:1785 — DPUTS(cmdsp, "BUG: parse_subscript: cmdstack not empty.")
3093    DPUTS!(
3094        // c:1785
3095        CMDSTACK.with(|s| !s.borrow().is_empty()), // c:1785
3096        "BUG: parse_subscript: cmdstack not empty."  // c:1785
3097    );
3098    zcontext_restore();
3099    if parse_err {
3100        return None;
3101    }
3102    Some(toklen)
3103}
3104
3105/// Tokenize a string as if it were a normal command-line argument
3106/// but it may contain separators. Used for ${...%...} substitutions.
3107///
3108/// Direct port of zsh/Src/lex.c:1796 `parse_subst_string`.
3109/// zsh's version sets `noaliases = 1` + `lexflags = 0` + uses
3110/// zcontext_save/inpush/strinbeg → dquote_parse('\0', 1) →
3111/// strinend/inpop/zcontext_restore. zshrs's standalone walker
3112/// produces the same Bnull/Snull/Dnull/Inpar/Inbrack markers
3113/// without re-entering the lexer.
3114///
3115/// zshrs port note: the C source returns int (0=ok, char value =
3116/// where it stopped on error); zshrs returns Result<String,String>
3117/// returning the tokenized text directly. Lossy for callers that
3118/// need to know the exact stop position, but nothing in zshrs's
3119/// expansion layer uses that yet.
3120pub fn parse_subst_string(s: &str) -> Result<String, String> {
3121    // c:1802 `if (!*s || !strcmp(s, nulstring)) return 0;`. C nulstring
3122    // is `{Nularg, 0}` (0xa1, NUL) — defined in Src/subst.c:36. A
3123    // single-Nularg input is the empty-arg sentinel and returns
3124    // success without parsing. The previous Rust port missed the
3125    // nulstring check so a Nularg-only input would attempt re-lex,
3126    // surfacing a spurious parse error.
3127    if s.is_empty() || s == Nularg.to_string() {
3128        // c:1802
3129        return Ok(String::new());
3130    }
3131    let l = s.len();
3132    let untok = untokenize(s); // c:1804
3133    let dup = dupstring_wlen(&untok, untok.len());
3134    // c:1803 `zcontext_save();`
3135    zcontext_save();
3136    // c:1805 `inpush(dupstring_wlen(s, l), 0, NULL);`
3137    inpush(&dup, 0, None);
3138    // c:1806 `strinbeg(0);`
3139    strinbeg(0);
3140    // c:1807-1809 — seed lexbuf with the input string.
3141    LEX_LEXBUF.with_borrow_mut(|b| {
3142        b.ptr = Some(String::with_capacity(l + 1));
3143        b.siz = (l + 1) as i32;
3144        b.len = 0;
3145    });
3146    set_tokstr(None);
3147    // c:1810 `c = hgetc();` / c:1811 `ctok = gettokstr(c, 1);`
3148    let c0 = hgetc();
3149    let ctok = match c0 {
3150        Some(ch) => gettokstr(ch, true),
3151        None => LEXERR,
3152    };
3153    // c:1813 — `err = errflag;`. Snapshot PRE-strinend errflag so we
3154    // can restore it post-zcontext_restore (parse-time errflag bits
3155    // must not leak to the caller).
3156    let err = errflag.load(Ordering::Relaxed); // c:1813
3157    let result = LEX_LEXBUF.with_borrow(|b| b.as_str().to_string());
3158    // c:1814 `strinend();`
3159    strinend();
3160    // c:1815 `inpop();`
3161    inpop();
3162    // c:1816 — DPUTS(cmdsp, "BUG: parse_subst_string: cmdstack not empty.")
3163    DPUTS!(
3164        // c:1816
3165        CMDSTACK.with(|s| !s.borrow().is_empty()), // c:1816 cmdsp != 0
3166        "BUG: parse_subst_string: cmdstack not empty."  // c:1816
3167    );
3168    // c:1817 `zcontext_restore();`
3169    zcontext_restore();
3170    // c:1819 — `errflag = err | (errflag & ERRFLAG_INT);`. Restore the
3171    // saved errflag, OR'ing in any ERRFLAG_INT bit set during parse
3172    // (user interrupt must survive). The previous Rust port skipped
3173    // this restore — parse-time ERRFLAG_ERROR bits leaked to callers,
3174    // causing the next exec-engine check to abort on a stale flag.
3175    let post_err = errflag.load(Ordering::Relaxed); // c:1819
3176    errflag.store(err | (post_err & ERRFLAG_INT), Ordering::Relaxed); // c:1819
3177    if ctok == LEXERR {
3178        // c:1820
3179        // Diagnostic already emitted via zerr at the failure site.
3180        return Err("parse error".to_string());
3181    }
3182    Ok(result)
3183}
3184
3185/// Mark the current word as the one ZLE was looking for. Direct
3186/// port of `gotword(void)` from `Src/lex.c:1882`. Computes the
3187/// new-word-end (`nwe`) and new-word-begin (`nwb`) line positions
3188/// based on `zlemetall`, `inbufct`, `addedx`, and `wordbeg`, then
3189/// — if the cursor (`zlemetacs`) falls inside that range — writes
3190/// `wb`/`we` and clears `lexflags`.
3191pub fn gotword() {
3192    let zlemetacs = crate::ported::zle::compcore::ZLEMETACS.load(Ordering::SeqCst);
3193    let zlemetall = crate::ported::zle::compcore::ZLEMETALL.load(Ordering::SeqCst);
3194    let addedx = crate::ported::zle::compcore::ADDEDX.load(Ordering::SeqCst);
3195    let inbufct = crate::ported::input::inbufct.with(|c| c.get());
3196    let wordbeg = LEX_WORDBEG.get();
3197
3198    // c:1884 — `int nwe = zlemetall + 1 - inbufct + (addedx == 2 ? 1 : 0);`
3199    let nwe = zlemetall + 1 - inbufct + if addedx == 2 { 1 } else { 0 };
3200    // c:1885 — `if (zlemetacs <= nwe)`
3201    if zlemetacs <= nwe {
3202        // c:1886 — `int nwb = zlemetall - wordbeg + addedx;`
3203        let nwb = zlemetall - wordbeg + addedx;
3204        // c:1887-1893 — `if (zlemetacs >= nwb) { wb = nwb; we = nwe; }
3205        // else { wb = zlemetacs + addedx; if (we < wb) we = wb; }`.
3206        if zlemetacs >= nwb {
3207            WB.store(nwb, Ordering::SeqCst);
3208            WE.store(nwe, Ordering::SeqCst);
3209        } else {
3210            let wb_new = zlemetacs + addedx;
3211            WB.store(wb_new, Ordering::SeqCst);
3212            let we_cur = WE.load(Ordering::SeqCst);
3213            if we_cur < wb_new {
3214                WE.store(wb_new, Ordering::SeqCst);
3215            }
3216        }
3217        // c:1895 — `lexflags = 0;`
3218        LEX_LEXFLAGS.set(0);
3219    }
3220}
3221
3222/// Direct port of `checkalias(void)` at `Src/lex.c:1902`. No
3223/// parameters in C — reads `aliastab`/`sufaliastab` directly.
3224/// zshrs threads `lextext` in because it's already untokenized at
3225/// the call site; C re-derives it from `zshlextext`. Returns true
3226/// if the lookup matched (regular or suffix alias) AND the alias
3227/// text was successfully injected back into the input stream for
3228/// re-lexing.
3229/// WARNING: param names don't match C — Rust=(lextext) vs C=()
3230fn checkalias(lextext: &str) -> bool {
3231    // lex.c:1906-1907 — guard on null lextext.
3232    if lextext.is_empty() {
3233        return false;
3234    }
3235
3236    // c:1909 — `if (!noaliases && isset(ALIASESOPT) &&
3237    //   (!isset(POSIXALIASES) ||
3238    //    (tok == STRING && !reswdtab->getnode(reswdtab, zshlextext))))`.
3239    //
3240    // Three gates: (1) lexer hasn't set noaliases; (2) ALIASESOPT
3241    // option is on; (3) under POSIX_ALIASES, the token must be a
3242    // STRING AND not a reserved word.
3243    if LEX_NOALIASES.get() || !isset(ALIASESOPT) {
3244        return false;
3245    }
3246    if isset(POSIXALIASES) {
3247        if tok() != STRING_LEX {
3248            return false;
3249        }
3250        let is_reswd = reswdtab_lock()
3251            .read()
3252            .expect("reswdtab poisoned")
3253            .get(lextext)
3254            .is_some();
3255        if is_reswd {
3256            return false;
3257        }
3258    }
3259
3260    // lex.c:1914-1933 — regular alias lookup. C: `an = (Alias)
3261    // aliastab->getnode(aliastab, zshlextext);`
3262    let alias_clone: Option<alias> = {
3263        let guard = aliastab_lock().read().expect("aliastab poisoned");
3264        guard.get(lextext).cloned()
3265    };
3266    if let Some(alias) = alias_clone {
3267        let is_global = (alias.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL) != 0;
3268        // c:1915-1917 — `if (an && !an->inuse && ((an->node.flags &
3269        // ALIAS_GLOBAL) || (incmdpos && tok == STRING) || inalmore))`
3270        // — `inalmore` extends eligibility to the word following a
3271        // trailing-space alias body (`alias sudo='sudo '` chaining).
3272        if alias.inuse == 0
3273            && (is_global
3274                || (LEX_INCMDPOS.get() && tok() == STRING_LEX)
3275                || LEX_INALMORE.get() != 0)
3276        {
3277            // c:1918-1927 — `if (!lexstop) { int c = hgetc();
3278            // hungetc(c); if (!iblank(c)) inpush(" ", INP_ALIAS, 0); }`
3279            // — if the next char isn't blank, insert a space so the
3280            // alias body can't accidentally join the following word.
3281            //
3282            // c:1919-1922 — "Tokens that don't require a space after,
3283            // get one, because they are treated as if preceded by one."
3284            //
3285            // C consumes one char via hgetc then un-consumes it, so the
3286            // check uses the actual next char from the ACTIVE input
3287            // source — the unget queue, the inbuf/instack (stdin loop,
3288            // interactive, alias re-lex), or the LEX_INPUT window.
3289            // The previous Rust version peeked LEX_INPUT[pos] directly,
3290            // which never sees inbuf-stack content: for stdin/interactive
3291            // lines (`git status` with `alias git=hub`) the pending
3292            // "status" lives in inbuf, peek() returned None, the
3293            // separator push was skipped, and the alias body fused with
3294            // the following word → `hubstatus`.
3295            if !LEX_LEXSTOP.get() {
3296                // c:1918
3297                if let Some(c) = hgetc() {
3298                    // c:1923
3299                    hungetc(c); // c:1924
3300                    // !!! ORDERING DIVERGENCE ADAPTER — C's inungetc
3301                    // returns the char to the CURRENT input frame
3302                    // (inbufptr--), which the alias inpush below then
3303                    // covers, so the terminator is read AFTER the alias
3304                    // body. zshrs's hungetc pushes into LEX_UNGET_BUF,
3305                    // which hgetc drains BEFORE any inbuf frame — so the
3306                    // terminator (blank / `;` / `\n`) would be consumed
3307                    // ahead of the alias text: `alias git=hub; git
3308                    // status` fused to `hubstatus`, and the line's `\n`
3309                    // ran early (PS2 prompt mid-command). Re-route the
3310                    // pending ungets into an INP_CONT frame pushed UNDER
3311                    // the separator/alias frames so read order matches C:
3312                    // alias body → separator → terminator → line rest.
3313                    // Only plain chars are re-routed — ingetc skips itok
3314                    // bytes (input.rs c:328), which must stay in
3315                    // LEX_UNGET_BUF to survive.
3316                    let all_plain = LEX_UNGET_BUF.with_borrow(|b| {
3317                        b.iter()
3318                            .all(|&ch| !((ch as u32) < 256 && crate::ztype_h::itok(ch as u8)))
3319                    });
3320                    if all_plain {
3321                        let pending: String =
3322                            LEX_UNGET_BUF.with_borrow_mut(|b| b.drain(..).collect());
3323                        if !pending.is_empty() {
3324                            inpush(&pending, INP_CONT, None);
3325                        }
3326                    }
3327                    // ASCII-only: see the truncation note in `gettokstr`.
3328                    if !(c.is_ascii() && crate::ztype_h::iblank(c as u8)) {
3329                        // c:1925
3330                        inpush(" ", INP_ALIAS, None); // c:1926
3331                    }
3332                }
3333                // hgetc() == None → EOF: C's ingetc returns ' ' under
3334                // lexstop (input.c:322), iblank(' ') → no push. Same
3335                // net effect; nothing to unget.
3336            }
3337            // c:1928 — `inpush(an->text, INP_ALIAS, an);`
3338            inpush(&alias.text, INP_ALIAS, Some(lextext.to_string()));
3339            // c:1929-1930 — `if (an->text[0] == ' ' && !(an->node.flags & ALIAS_GLOBAL))
3340            //                  aliasspaceflag = 1;`
3341            // Drives HISTIGNORESPACE's alias-leading-space suppression
3342            // path (hist.c:1428): without it, `alias g='echo hi'; g`
3343            // gets history-logged even with HISTIGNORESPACE set when
3344            // the alias body starts with a space.
3345            if !is_global && alias.text.starts_with(' ') {
3346                LEX_ALIAS_SPACE_FLAG.set(1);
3347            }
3348            // c:1929 — `an->inuse = 1;`.
3349            let mut guard = aliastab_lock().write().expect("aliastab poisoned");
3350            if let Some(a) = guard.get_mut(lextext) {
3351                a.inuse = 1;
3352            }
3353            drop(guard);
3354            LEX_LEXSTOP.set(false);
3355            return true;
3356        }
3357    }
3358
3359    // lex.c:1934-1943 — suffix-alias lookup. The token must end
3360    // with `.SUFFIX`, the suffix name must be a registered
3361    // suffix-alias, AND the lexer must be in command position.
3362    if LEX_INCMDPOS.get() {
3363        if let Some(dot_pos) = lextext.rfind('.') {
3364            if dot_pos > 0 && dot_pos + 1 < lextext.len() {
3365                let suffix = &lextext[dot_pos + 1..];
3366                let alias_clone: Option<alias> = {
3367                    let guard = sufaliastab_lock().read().expect("sufaliastab poisoned");
3368                    guard.get(suffix).cloned()
3369                };
3370                if let Some(alias) = alias_clone {
3371                    if alias.inuse == 0 {
3372                        // c:1938-1940 — three inpush calls in order:
3373                        // the original word, a space, the alias text.
3374                        // inpush stacks LIFO so the original word is
3375                        // popped FIRST (re-emitted to extend the
3376                        // current token), then space, then the alias
3377                        // body. C does it the same way.
3378                        inpush(lextext, INP_ALIAS, Some(suffix.to_string()));
3379                        inpush(" ", INP_ALIAS, None);
3380                        inpush(&alias.text, INP_ALIAS, None);
3381                        // c:1941 — `an->inuse = 1;`.
3382                        let mut guard = sufaliastab_lock().write().expect("sufaliastab poisoned");
3383                        if let Some(a) = guard.get_mut(suffix) {
3384                            a.inuse = 1;
3385                        }
3386                        drop(guard);
3387                        LEX_LEXSTOP.set(false);
3388                        return true;
3389                    }
3390                }
3391            }
3392        }
3393    }
3394
3395    false
3396}
3397
3398/// Run alias / reserved-word expansion on the just-lexed token.
3399/// Direct port of zsh/Src/lex.c:1949-2021 `exalias`. Returns true
3400/// if an alias was injected (the caller's loop should re-run
3401/// gettok to consume the injected text).
3402///
3403/// C source flow:
3404///   1. Spell-correct (lex.c:1958-1962) — disabled in zshrs.
3405///   2. If tokstr is None: set lextext from `tokstrings[tok]` and
3406///      checkalias against that (lex.c:1964-1969).
3407///   3. Otherwise: untokenize tokstr into a working copy (lex.c:
3408///      1971-1980).
3409///   4. ZLE word-tracking: call gotword() if LEXFLAGS_ZLE
3410///      (lex.c:1982-1991).
3411///   5. Stringg tokens: try checkalias, then reservation lookup
3412///      (lex.c:1993-2015).
3413///   6. Clear inalmore (lex.c:2016).
3414///
3415/// Direct port of `exalias(void)` at `Src/lex.c:1953`. No
3416/// parameters — reads global `aliastab`/`sufaliastab`/`reswdtab`
3417/// directly, mirroring C.
3418pub fn exalias() -> bool {
3419    // lex.c:1957 — `hwend()` closes the history WORD for the token gettok
3420    // just produced (zshlex loops `gettok(); while (... exalias())`, so
3421    // this fires once per token). Paired with the `ihwbegin` at the top of
3422    // gettok, it records word boundaries into `chwords`; `hend` then keys
3423    // off `chwordpos` (> 2 means ≥ 1 real word) to decide whether to save
3424    // the line. The prior port stubbed it, leaving chwordpos stuck at 1, so
3425    // every interactive line was rejected by hend's `chwordpos <= 2` gate
3426    // and history recorded nothing.
3427    crate::ported::hist::ihwend(); // c:1957
3428
3429    // c:1958-1962 — full faithful gate:
3430    //   if (interact && isset(SHINSTDIN) && !strin && incasepat <= 0
3431    //    && tok == STRING && !nocorrect && !(inbufflags & INP_ALIAS)
3432    //    && !hist_is_in_word()
3433    //    && (isset(CORRECTALL) || (isset(CORRECT) && incmdpos)))
3434    //       spckword(&tokstr, 1, incmdpos, 1);
3435    let inbufflags_alias = (crate::ported::input::inbufflags.with(|f| f.get()) & INP_ALIAS) != 0;
3436    let strin_set = crate::ported::input::strin.with(|c| c.get()) != 0;
3437    if interact()
3438        && isset(SHINSTDIN)
3439        && !strin_set
3440        && LEX_INCASEPAT.get() <= 0
3441        && tok() == STRING_LEX
3442        && LEX_NOCORRECT.get() == 0
3443        && !inbufflags_alias
3444        && crate::ported::hist::hist_is_in_word() == 0
3445        && (isset(CORRECTALL) || (isset(CORRECT) && LEX_INCMDPOS.get()))
3446    {
3447        // c:1962 — `spckword(&tokstr, 1, incmdpos, 1);`. The canonical
3448        // port at utils.rs::spckword scans the right hashtables
3449        // internally.
3450        if let Some(word) = tokstr() {
3451            let mut buf = if has_token(&word) {
3452                untokenize(&word)
3453            } else {
3454                word.clone()
3455            };
3456            crate::ported::utils::spckword(
3457                &mut buf,
3458                1,                                      // c:1962 hist=1
3459                if LEX_INCMDPOS.get() { 1 } else { 0 }, // c:1962 cmd=incmdpos
3460                1,                                      // c:1962 ask=1
3461            );
3462            if buf != word {
3463                set_tokstr(Some(buf));
3464            }
3465        }
3466    }
3467
3468    // lex.c:1964-1969 — bare-token path (no tokstr).
3469    if LEX_TOKSTR.with_borrow(|t| t.is_none()) {
3470        // lex.c:1965 — `zshlextext = tokstrings[tok];` — for tokens
3471        // like SEMI/AMPER/etc. the canonical text comes from a
3472        // static table.
3473        if tok() == NEWLIN {
3474            return false;
3475        }
3476        // Use punctuation-token text; unknown tokens skip alias.
3477        let text = match tok() {
3478            SEMI => ";",
3479            AMPER => "&",
3480            BAR_TOK => "|",
3481            _ => return false,
3482        };
3483        return checkalias(text);
3484    }
3485
3486    let tokstr = tokstr().unwrap();
3487    // lex.c:1973-1980 — untokenize: convert the lexer's internal
3488    // tokenized form (Pound..ztokens shifts) into the literal
3489    // shell text. Call the global helper.
3490    let lextext = if has_token(&tokstr) {
3491        untokenize(&tokstr)
3492    } else {
3493        tokstr.clone()
3494    };
3495
3496    // lex.c:1982-1991 — ZLE word-tracking for completion.
3497    if LEX_LEXFLAGS.get() & LEXFLAGS_ZLE != 0 {
3498        let zp = LEX_LEXFLAGS.get();
3499        gotword();
3500        // lex.c:1986-1990 — if gotword cleared lexflags, the cursor
3501        // word has been reached; abort exalias so completion can
3502        // capture the partial token unchanged.
3503        // lex.c:1986 — `(zp & LEXFLAGS_ZLE) && !lexflags` — gotword
3504        // fully cleared lexflags (not just ZLE) when the cursor word
3505        // was reached.
3506        if (zp & LEXFLAGS_ZLE) != 0 && LEX_LEXFLAGS.get() == 0 {
3507            return false;
3508        }
3509    }
3510
3511    // lex.c:1993-2015 — Stringg-token alias / reswd check.
3512    if tok() == STRING_LEX {
3513        // c:1995 — `if ((zshlextext != copy || !isset(POSIXALIASES))
3514        //   && checkalias())` — under POSIX_ALIASES, only run
3515        // alias expansion on tokens that came in literal (`zshlextext
3516        // == copy` means no tokenisation/untokenisation happened).
3517        // zshrs always passes the untokenised `lextext`; if the
3518        // original tokstr had tokens AND POSIXALIASES is on, skip
3519        // the alias check (matches C `zshlextext != copy`).
3520        let had_tokens = has_token(&tokstr);
3521        if (!had_tokens || !isset(POSIXALIASES)) && checkalias(&lextext) {
3522            return true;
3523        }
3524
3525        // c:2002 — reserved-word lookup. Fires when in command
3526        // position OR when the text is bare `}` and IGNOREBRACES
3527        // / IGNORECLOSEBRACES are both unset (so `}` ends a brace
3528        // block).
3529        //
3530        // c:Src/lex.c:1973-1980 (un-tokenize loop): C's untokenize
3531        // REPLACES Snull/Dnull/Bnull with the literal quote chars
3532        // (`'`, `"`, `\\`) via `ztokens`. So C's lextext for `"}"` is
3533        // the 3-byte string `"}"` (quotes intact). The
3534        // `zshlextext[0] == '}' && !zshlextext[1]` check at c:2003
3535        // therefore FAILS for any quoted `}` and the reswd lookup
3536        // never fires.
3537        //
3538        // zshrs's untokenize at lex.rs:4275 STRIPS Snull/Dnull/Bnull
3539        // entirely (intentional — many call sites rely on the
3540        // markers being gone), so lextext for `"}"` is just `}` (1
3541        // byte). To still reject quoted `}` from the reswd path, we
3542        // gate `is_close_brace_special` on the original tokstr NOT
3543        // containing any quote-marker bytes. Bug #14 in BUGS.md:
3544        // `[[ x == "}" ]]` and `echo "}"` both used to silently
3545        // discard the `}` because exalias promoted the quoted `}`
3546        // to OUTBRACE_TOK.
3547        let tokstr_has_quote_marker = tokstr
3548            .chars()
3549            .any(|c| c == Snull || c == Dnull || c == Bnull);
3550        let is_close_brace_special = lextext == "}"
3551            && unset(IGNOREBRACES)
3552            && unset(IGNORECLOSEBRACES)
3553            && !tokstr_has_quote_marker;
3554        // lex.c:2002-2014 — the C structure is
3555        //     if ((incmdpos || ...) && (rw = reswd_lookup)) { ... }
3556        //     else if (incond && lextext == "]]") { ... }
3557        //     else if (incond == 1 && lextext == "!") { ... }
3558        // i.e. the cond `]]`/`!` branches are reached when the reswd
3559        // lookup FAILS — even if `incmdpos` is true. The previous
3560        // Rust port gated on `incmdpos` alone, so any `]]` reached
3561        // inside `[[ ... ]]` with incmdpos still true (the lexer
3562        // doesn't auto-reset incmdpos after `[[` in all paths) was
3563        // left as a STRING `]]` and par_cond_2 errored with
3564        // `condition expected:`. Restructure to mirror C: look up the
3565        // reswd ONLY when the gating allows, and treat a failed
3566        // lookup the same as a non-gated path so the cond branches
3567        // get their turn.
3568        let reswd_path_eligible = LEX_INCMDPOS.get() || is_close_brace_special;
3569        // c:Src/lex.c:1973-1980 — C's untokenize keeps Snull/Dnull/Bnull
3570        // quote markers AS THEIR LITERAL QUOTE CHARS in `lextext` (via
3571        // the ztokens table). So C's lextext for `"if"` is the 4-byte
3572        // string `"if"` (quotes intact) and reswd_lookup against `"if"`
3573        // never matches the bare reswd `if`. zshrs's untokenize at
3574        // lex.rs:4275 STRIPS those markers entirely (so lextext is
3575        // `if`), which would spuriously promote a quoted token to a
3576        // reserved word. Bug #19 in docs/BUGS.md: quoted patterns like
3577        // `"!"`, `"if"`, `"{"`, `"}"` in non-first case branches
3578        // parsed as the corresponding reswd (BANG_TOK / IF / INBRACE
3579        // / OUTBRACE) and triggered "expected ')' in case pattern".
3580        // Same root cause as bug #14's `is_close_brace_special` gate
3581        // above: when the original tokstr carries any Snull/Dnull/Bnull
3582        // marker, the text WAS quoted by the user and must keep its
3583        // literal meaning — reswd promotion is suppressed.
3584        let rw_tok: Option<lextok> = if reswd_path_eligible && !tokstr_has_quote_marker {
3585            let guard = reswdtab_lock().read().expect("reswdtab poisoned");
3586            guard.get(&lextext).map(|r| r.token)
3587        } else {
3588            None
3589        };
3590        // !!! DASH-STRICT GATE (no C counterpart) !!! dash has none of the
3591        // zsh/bash/ksh reserved words `[[` / `function` / `coproc`; each is an
3592        // ordinary command word there (`[[`/`coproc` → "not found", `function`
3593        // → the following `{` is a syntax error). Suppress ONLY these three
3594        // promotions — every POSIX reserved word (if/then/while/for/case/until/
3595        // do/done/{/}/…) stays intact. The `]]`/`!`-in-cond branches below are
3596        // gated on LEX_INCOND, which never rises now. Found by the per-mode
3597        // dash-strictness sweep.
3598        let rw_tok = if crate::dash_mode::dash_strict()
3599            && matches!(rw_tok, Some(DINBRACK) | Some(FUNC) | Some(COPROC))
3600        {
3601            None
3602        } else {
3603            rw_tok
3604        };
3605        if let Some(rwtok) = rw_tok {
3606            set_tok(rwtok);
3607            if rwtok == REPEAT {
3608                LEX_INREPEAT.set(1);
3609            }
3610            if rwtok == DINBRACK {
3611                LEX_INCOND.set(1);
3612            }
3613        } else if LEX_INCOND.get() > 0 && lextext == "]]" {
3614            // lex.c:2010-2012 — `]]` closes the cond expression.
3615            set_tok(DOUTBRACK);
3616            LEX_INCOND.set(0);
3617        } else if LEX_INCOND.get() == 1 && lextext == "!" && !tokstr_has_quote_marker {
3618            // lex.c:2013-2014 — `!` inside `[[ ]]` is the Bang
3619            // negation, not a literal. Gate on
3620            // `tokstr_has_quote_marker` so QUOTED `"!"` (which
3621            // carries Snull/Dnull/Bnull markers in the original
3622            // tokstr) stays as a literal STRING token. Same fix
3623            // shape as #14/#19 above: any user-applied quotes
3624            // suppress the special-token promotion. Bug #283 in
3625            // docs/BUGS.md.
3626            set_tok(BANG_TOK);
3627        }
3628    }
3629
3630    // lex.c:2016 — `inalmore = 0;` — alias-more flag clears once a
3631    // token makes it through exalias without being re-injected as
3632    // an alias (checkalias returning true short-circuits before
3633    // this point via the `return true` above).
3634    LEX_INALMORE.set(0); // c:2016
3635
3636    false
3637}
3638
3639/// Append a char to the raw-input capture buffer. Direct port of
3640/// zsh/Src/lex.c:2025 `zshlex_raw_add`. Called from hgetc
3641/// when `lex_add_raw` is nonzero so cmd-sub bodies (`$(...)`,
3642/// `<(...)`, `>(...)`) can be replayed verbatim without re-lexing.
3643pub fn zshlex_raw_add(c: char) {
3644    // lex.c:2027-2028 — guard on lex_add_raw flag.
3645    if LEX_LEX_ADD_RAW.get() == 0 {
3646        return;
3647    }
3648    // lex.c:2030-2038 — append to lexbuf_raw. The C source manages
3649    // explicit ptr/len/siz with hrealloc; Rust's String handles
3650    // resize automatically.
3651    LEX_LEXBUF_RAW.with_borrow_mut(|b| b.add(c));
3652}
3653
3654/// Pop the last char from the raw-input capture buffer. Direct
3655/// port of zsh/Src/lex.c:2043 `zshlex_raw_back`. Called when
3656/// the lexer ungets a char that was just captured raw — the raw
3657/// buffer must mirror the live input so this undoes the last add.
3658pub fn zshlex_raw_back() {
3659    // lex.c:2045-2046 — guard.
3660    if LEX_LEX_ADD_RAW.get() == 0 {
3661        return;
3662    }
3663    // lex.c:2047-2048 — `lexbuf_raw.ptr--; lexbuf_raw.len--;`
3664    LEX_LEXBUF_RAW.with_borrow_mut(|b| b.pop());
3665}
3666
3667/// Mark the current raw-buffer offset (for restore later). Direct
3668/// port of zsh/Src/lex.c:2053 `zshlex_raw_mark`. Returns
3669/// `len + offset` so callers can restore via `back_to_mark`.
3670pub fn zshlex_raw_mark(offset: i64) -> i64 {
3671    // lex.c:2055-2056 — guard.
3672    if LEX_LEX_ADD_RAW.get() == 0 {
3673        return 0;
3674    }
3675    // lex.c:2057 — `return lexbuf_raw.len + offset;`
3676    (LEX_LEXBUF_RAW.with_borrow(|b| b.buf_len()) as i64) + offset
3677}
3678
3679/// Restore raw-buffer offset to a previously-saved mark. Direct
3680/// port of zsh/Src/lex.c:2062 `zshlex_raw_back_to_mark`.
3681/// Truncates the raw buffer to `mark` bytes — undoes any captures
3682/// since the mark was taken (used when a speculative parse fails
3683/// and the lexer rolls back).
3684pub fn zshlex_raw_back_to_mark(mark: i64) {
3685    // lex.c:2064-2065 — guard.
3686    if LEX_LEX_ADD_RAW.get() == 0 {
3687        return;
3688    }
3689    // lex.c:2066-2067 — `lexbuf_raw.ptr = tokstr_raw + mark;
3690    // lexbuf_raw.len = mark;` — String::truncate handles both.
3691    let m = mark.max(0) as usize;
3692    LEX_LEXBUF_RAW.with_borrow_mut(|b| {
3693        if let Some(p) = b.ptr.as_mut() {
3694            p.truncate(m);
3695        }
3696        b.len = m as i32;
3697    });
3698}
3699
3700/// Skip over `(...)` for command-style substitutions: `$(...)`,
3701/// `<(...)`, `>(...)`. Direct port of zsh/Src/lex.c:2080-end
3702/// `skipcomm`. Per the C source comment: "we'll parse the input
3703/// until we find an unmatched closing parenthesis. However, we'll
3704/// throw away the result of the parsing and just keep the string
3705/// we've built up on the way."
3706///
3707/// zshrs port note: the C source uses zcontext_save/restore +
3708/// strinbeg/inpush to set up an isolated lex context for the
3709/// throw-away parse. zshrs's standalone walker tracks paren
3710/// depth directly without re-entering the parser. Same
3711/// invariant: stops at the matching `)`.
3712fn skipcomm() -> Result<(), ()> {
3713    // c:2094-2225 — `skipcomm`. Captures the verbatim text of a
3714    // `$(...)` / `<(...)` / `>(...)` body into the parent token via
3715    // C's lex_add_raw / lexbuf_raw mechanism (lex.c:2098-2149):
3716    //   1. add(Inpar) — outer lexbuf gets `(` (the marker form).
3717    //   2. Copy outer tokstr/lexbuf into new_tokstr/new_lexbuf so the
3718    //      raw buffer starts seeded with the prefix already lexed
3719    //      (e.g. `$(` plus anything before).
3720    //   3. zcontext_save_partial — saves AND resets lexbuf, lexbuf_raw,
3721    //      lex_add_raw to fresh.
3722    //   4. tokstr_raw = new_tokstr; lexbuf_raw = new_lexbuf — the raw
3723    //      buffer now mirrors the outer's pre-call lexbuf.
3724    //   5. lex_add_raw = old + 1 — turns on raw-recording so every
3725    //      char hgetc reads also lands in lexbuf_raw.
3726    //   6. Walk the inner body via hgetc/add. lexbuf gets the
3727    //      throw-away tokenized form; lexbuf_raw accumulates the
3728    //      verbatim chars.
3729    //   7. Capture new_tokstr/new_lexbuf from the raw buffer.
3730    //   8. zcontext_restore_partial restores outer lex state.
3731    //   9. If outer lex_add_raw == 0: tokstr = new_tokstr; lexbuf =
3732    //      new_lexbuf — outer's lexbuf is REPLACED with the captured
3733    //      raw body (which already contains the `$(` prefix from step
3734    //      4 plus the body chars). If outer lex_add_raw != 0 (nested
3735    //      cmd-sub), propagate the raw vars.
3736    let new_lex_add_raw = LEX_LEX_ADD_RAW.get() + 1;
3737    let outer_was_recording = LEX_LEX_ADD_RAW.get() != 0;
3738
3739    cmdpush(CS_CMDSUBST as u8);
3740    add(Inpar);
3741
3742    // c:2096-2143 — save outer tokstr/lexbuf into the variables that
3743    // will become tokstr_raw/lexbuf_raw post-save.
3744    let new_tokstr_init: Option<String>;
3745    let new_lexbuf_init_ptr: Option<String>;
3746    let new_lexbuf_init_siz: i32;
3747    let new_lexbuf_init_len: i32;
3748    if outer_was_recording {
3749        // Nested: propagate the existing raw buffers.
3750        new_tokstr_init = LEX_TOKSTR_RAW.with_borrow_mut(|t| t.take());
3751        let (p, s, l) = LEX_LEXBUF_RAW.with_borrow_mut(|b| (b.ptr.take(), b.siz, b.len));
3752        new_lexbuf_init_ptr = p;
3753        new_lexbuf_init_siz = s;
3754        new_lexbuf_init_len = l;
3755    } else {
3756        // Top-level: seed raw with current tokstr/lexbuf.
3757        new_tokstr_init = tokstr();
3758        let (p, s, l) = LEX_LEXBUF.with_borrow(|b| (b.ptr.clone(), b.siz, b.len));
3759        new_lexbuf_init_ptr = p;
3760        new_lexbuf_init_siz = s;
3761        new_lexbuf_init_len = l;
3762    }
3763
3764    crate::ported::context::zcontext_save_partial(ZCONTEXT_LEX | ZCONTEXT_PARSE);
3765    hist_in_word(1);
3766
3767    // c:2147-2149 — install seeded raw buffers + enable recording.
3768    set_tokstr(new_tokstr_init);
3769    LEX_LEXBUF.with_borrow_mut(|b| {
3770        b.ptr = new_lexbuf_init_ptr.clone();
3771        b.siz = if new_lexbuf_init_siz == 0 {
3772            256
3773        } else {
3774            new_lexbuf_init_siz
3775        };
3776        b.len = new_lexbuf_init_len;
3777    });
3778    LEX_TOKSTR_RAW.with_borrow_mut(|t| *t = tokstr());
3779    LEX_LEXBUF_RAW.with_borrow_mut(|b| {
3780        b.ptr = new_lexbuf_init_ptr;
3781        b.siz = if new_lexbuf_init_siz == 0 {
3782            256
3783        } else {
3784            new_lexbuf_init_siz
3785        };
3786        b.len = new_lexbuf_init_len;
3787    });
3788    LEX_LEX_ADD_RAW.set(new_lex_add_raw);
3789
3790    // RAII: cleanup on every exit path. Captures the raw body, restores
3791    // outer lex state, then (if outer wasn't recording) overwrites the
3792    // restored outer lexbuf with the raw body — the trick that makes
3793    // the parent token contain the verbatim `$(...)` text.
3794    struct SkipcommGuard {
3795        outer_was_recording: bool,
3796    }
3797    impl Drop for SkipcommGuard {
3798        fn drop(&mut self) {
3799            // c:2185-2186 — capture the raw form before restore.
3800            let new_tokstr = LEX_TOKSTR_RAW.with_borrow_mut(|t| t.take());
3801            let (new_lexbuf_ptr, new_lexbuf_siz, new_lexbuf_len) =
3802                LEX_LEXBUF_RAW.with_borrow_mut(|b| (b.ptr.take(), b.siz, b.len));
3803            let new_lexstop = LEX_LEXSTOP.get();
3804
3805            hist_in_word(0);
3806            crate::ported::context::zcontext_restore_partial(ZCONTEXT_LEX | ZCONTEXT_PARSE);
3807
3808            // c:2196-2217 — splice raw back into outer lexbuf, or
3809            // propagate to outer raw if outer was recording.
3810            if self.outer_was_recording {
3811                LEX_TOKSTR_RAW.with_borrow_mut(|t| *t = new_tokstr);
3812                LEX_LEXBUF_RAW.with_borrow_mut(|b| {
3813                    b.ptr = new_lexbuf_ptr;
3814                    b.siz = new_lexbuf_siz;
3815                    b.len = new_lexbuf_len;
3816                });
3817            } else {
3818                // c:2204-2207 — strip the trailing `)` that hgetc
3819                // recorded into the raw buffer (closing paren).
3820                let mut final_ptr = new_lexbuf_ptr;
3821                let mut final_len = new_lexbuf_len;
3822                if !new_lexstop {
3823                    if let Some(ref mut s) = final_ptr {
3824                        if s.ends_with(')') {
3825                            s.pop();
3826                            final_len -= 1;
3827                        }
3828                    }
3829                }
3830                set_tokstr(final_ptr.clone());
3831                LEX_LEXBUF.with_borrow_mut(|b| {
3832                    b.ptr = final_ptr;
3833                    b.siz = new_lexbuf_siz;
3834                    b.len = final_len;
3835                });
3836            }
3837            cmdpop();
3838        }
3839    }
3840    let _guard = SkipcommGuard {
3841        outer_was_recording,
3842    };
3843
3844    let mut pct = 1;
3845    let mut start = true;
3846
3847    // c:Src/lex.c skipcomm (ZSH_OLD path) — the C source's pct
3848    // counter mis-tracks `case PAT)` as a cmdsub close because
3849    // the case pattern's `)` decrements pct prematurely. C zsh's
3850    // NEW skipcomm (default since 5.0.x) recursively re-parses the
3851    // body so case/esac context is known. zshrs's port still rides
3852    // the OLD pct counter. Bridge the gap with a `case`-keyword
3853    // depth tracker: when between `case <word> in` and `esac`,
3854    // each `)` is a pattern close and must NOT decrement pct.
3855    // Word boundaries come from a small accumulator that flushes
3856    // on whitespace / structural separators. Bug #291.
3857    let mut word_buf = String::with_capacity(8);
3858    let mut case_depth: i32 = 0;
3859    // 0 = no recent `case`; 1 = saw `case`, expecting subject word;
3860    // 2 = saw subject word, expecting `in`.
3861    let mut case_pending: i32 = 0;
3862
3863    loop {
3864        let c = hgetc();
3865        let c = match c {
3866            Some(c) => c,
3867            None => {
3868                LEX_LEXSTOP.set(true);
3869                return Err(());
3870            }
3871        };
3872
3873        // Only ASCII can be blank — see the note in `gettokstr`: `c as u8` on a
3874        // multibyte char truncates the codepoint into the blank range.
3875        let iswhite = c.is_ascii() && crate::ztype_h::inblank(c as u8);
3876
3877        // Word boundary keyword tracking.
3878        let is_word_terminator =
3879            iswhite || c == '\n' || c == ';' || c == '&' || c == '|' || c == '(' || c == ')';
3880        if is_word_terminator && !word_buf.is_empty() {
3881            match word_buf.as_str() {
3882                "case" => case_pending = 1,
3883                "in" if case_pending == 2 => {
3884                    case_depth += 1;
3885                    case_pending = 0;
3886                }
3887                "esac" => {
3888                    if case_depth > 0 {
3889                        case_depth -= 1;
3890                    }
3891                    case_pending = 0;
3892                }
3893                _ => match case_pending {
3894                    1 => case_pending = 2,
3895                    2 => case_pending = 0,
3896                    _ => {}
3897                },
3898            }
3899            word_buf.clear();
3900        } else if !is_word_terminator {
3901            word_buf.push(c);
3902        }
3903
3904        match c {
3905            '(' => {
3906                pct += 1;
3907                add(c);
3908            }
3909            ')' => {
3910                // c:Bug #291 — inside a case block, `)` closes a
3911                // pattern (not the cmdsub).
3912                if case_depth > 0 {
3913                    add(c);
3914                } else {
3915                    pct -= 1;
3916                    if pct == 0 {
3917                        return Ok(());
3918                    }
3919                    add(c);
3920                }
3921            }
3922            '\\' => {
3923                add(c);
3924                if let Some(c) = hgetc() {
3925                    add(c);
3926                }
3927            }
3928            '\'' => {
3929                add(c);
3930                loop {
3931                    let ch = hgetc();
3932                    match ch {
3933                        Some('\'') => {
3934                            add('\'');
3935                            break;
3936                        }
3937                        Some(ch) => add(ch),
3938                        None => {
3939                            LEX_LEXSTOP.set(true);
3940                            return Err(());
3941                        }
3942                    }
3943                }
3944            }
3945            '"' => {
3946                add(c);
3947                loop {
3948                    let ch = hgetc();
3949                    match ch {
3950                        Some('"') => {
3951                            add('"');
3952                            break;
3953                        }
3954                        Some('\\') => {
3955                            add('\\');
3956                            if let Some(ch) = hgetc() {
3957                                add(ch);
3958                            }
3959                        }
3960                        Some(ch) => add(ch),
3961                        None => {
3962                            LEX_LEXSTOP.set(true);
3963                            return Err(());
3964                        }
3965                    }
3966                }
3967            }
3968            '`' => {
3969                add(c);
3970                loop {
3971                    let ch = hgetc();
3972                    match ch {
3973                        Some('`') => {
3974                            add('`');
3975                            break;
3976                        }
3977                        Some('\\') => {
3978                            add('\\');
3979                            if let Some(ch) = hgetc() {
3980                                add(ch);
3981                            }
3982                        }
3983                        Some(ch) => add(ch),
3984                        None => {
3985                            LEX_LEXSTOP.set(true);
3986                            return Err(());
3987                        }
3988                    }
3989                }
3990            }
3991            '#' if start => {
3992                add(c);
3993                // Skip comment to end of line
3994                loop {
3995                    let ch = hgetc();
3996                    match ch {
3997                        Some('\n') => {
3998                            add('\n');
3999                            break;
4000                        }
4001                        Some(ch) => add(ch),
4002                        None => break,
4003                    }
4004                }
4005            }
4006            _ => {
4007                add(c);
4008            }
4009        }
4010
4011        start = iswhite;
4012    }
4013}
4014
4015/// Port of `static const char ztokens[]` from `Src/lex.c:80`.
4016pub const ztokens: &str = "#$^*(())$=|{}[]`<>>?~`,-!'\"\\\\";
4017
4018// `enum lextok` — port of `Src/zsh.h:304-371`. The full constant set
4019// (`NULLTOK`, `SEPER`, …, `TYPESET`) and the `lextok` type alias live
4020// in `super::zsh_h:198-262`. Re-export here so external callers can
4021// keep saying `lex::lextok` / `tokens::lextok` without reaching into
4022// `zsh_h::` directly. `IS_REDIROP()` (port of `Src/zsh.h:408`
4023// `#define IS_REDIROP`) lives in `zsh_h:318`.
4024
4025/// `static unsigned char lexact1[256]` from `Src/lex.c:406`. Per-byte
4026/// action table for the first-tier dispatch in `gettok`. Init'd by
4027/// `initlextabs()`.
4028pub static LEXACT1: std::sync::OnceLock<std::sync::Mutex<[u8; 256]>> = std::sync::OnceLock::new();
4029/// `static unsigned char lexact2[256]` from `Src/lex.c:406`. Per-byte
4030/// action table for the second-tier dispatch in `gettokstr`.
4031pub static LEXACT2: std::sync::OnceLock<std::sync::Mutex<[u8; 256]>> = std::sync::OnceLock::new();
4032/// `static unsigned char lextok2[256]` from `Src/lex.c:406`. Per-byte
4033/// token-character map: maps `*` → `Star`, `?` → `Quest`, etc.
4034pub static LEXTOK2: std::sync::OnceLock<std::sync::Mutex<[u8; 256]>> = std::sync::OnceLock::new();
4035
4036/// Sentinel: true once `initlextabs()` has populated the tables.
4037static LEX_TABS_INITED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
4038
4039#[inline]
4040fn ensure_tabs_inited() {
4041    if !LEX_TABS_INITED.load(std::sync::atomic::Ordering::Acquire) {
4042        initlextabs();
4043        LEX_TABS_INITED.store(true, std::sync::atomic::Ordering::Release);
4044    }
4045}
4046
4047/// Table accessor for `lexact1[c]`. Mirrors C's `lexact1[STOUC(c)]`
4048/// macro at `Src/lex.c:725`.
4049#[inline]
4050pub fn lexact1_get(c: char) -> u8 {
4051    let idx = c as u32;
4052    if idx >= 256 {
4053        return LX1_OTHER;
4054    }
4055    ensure_tabs_inited();
4056    let table = LEXACT1.get().unwrap();
4057    table.lock().unwrap()[idx as usize]
4058}
4059
4060/// Table accessor for `lexact2[c]`. Mirrors C's `lexact2[STOUC(c)]`
4061/// macro at `Src/lex.c:919` (the dispatch inside `gettokstr`).
4062#[inline]
4063pub fn lexact2_get(c: char) -> u8 {
4064    let idx = c as u32;
4065    if idx >= 256 {
4066        return LX2_OTHER;
4067    }
4068    ensure_tabs_inited();
4069    let table = LEXACT2.get().unwrap();
4070    table.lock().unwrap()[idx as usize]
4071}
4072
4073/// Table accessor for `lextok2[c]`. Mirrors C's `lextok2[STOUC(c)]`
4074/// at `Src/lex.c:919+` — used to translate a glob metacharacter to
4075/// its byte-token form (`*` → `Star`, etc.).
4076#[inline]
4077pub fn lextok2_get(c: char) -> u8 {
4078    let idx = c as u32;
4079    if idx >= 256 {
4080        return c as u8;
4081    }
4082    ensure_tabs_inited();
4083    let table = LEXTOK2.get().unwrap();
4084    table.lock().unwrap()[idx as usize]
4085}
4086
4087/// The Zsh Lexer.
4088///
4089// All lexer state lives in the file-scope `LEX_*` thread_local statics
4090// above, each one matching a `static` in `Src/lex.c`. There's no
4091// holder struct — callers use the free ported directly:
4092//
4093//    lex_init(input);
4094//    zshlex();
4095//   let tok =  tok();
4096//
4097// Accessor ported named after the former field identifiers (`tok()`,
4098// `tokstr()`, `set_tok(v)`, etc.) provide read/write into LEX_*.
4099
4100// ─── Accessor ported for the LEX_* thread_locals (Src/lex.c file-statics) ───
4101/// `toklineno` — see implementation.
4102pub fn toklineno() -> u64 {
4103    LEX_TOKLINENO.get()
4104}
4105/// `set_toklineno` — see implementation.
4106pub fn set_toklineno(v: u64) {
4107    LEX_TOKLINENO.set(v);
4108}
4109/// `tokfd` — see implementation.
4110pub fn tokfd() -> i32 {
4111    LEX_TOKFD.get()
4112}
4113/// `set_tokfd` — see implementation.
4114pub fn set_tokfd(v: i32) {
4115    LEX_TOKFD.set(v);
4116}
4117/// `isnewlin` — see implementation.
4118pub fn isnewlin() -> i32 {
4119    LEX_ISNEWLIN.get()
4120}
4121/// `set_isnewlin` — see implementation.
4122pub fn set_isnewlin(v: i32) {
4123    LEX_ISNEWLIN.set(v);
4124}
4125/// `inrepeat` — see implementation.
4126pub fn inrepeat() -> i32 {
4127    LEX_INREPEAT.get()
4128}
4129/// `set_inrepeat` — see implementation.
4130pub fn set_inrepeat(v: i32) {
4131    LEX_INREPEAT.set(v);
4132}
4133/// `infor` — see implementation.
4134pub fn infor() -> i32 {
4135    LEX_INFOR.get()
4136}
4137/// `set_infor` — see implementation.
4138pub fn set_infor(v: i32) {
4139    LEX_INFOR.set(v);
4140}
4141/// `inredir` — see implementation.
4142pub fn inredir() -> bool {
4143    LEX_INREDIR.get()
4144}
4145/// `set_inredir` — see implementation.
4146pub fn set_inredir(v: bool) {
4147    LEX_INREDIR.set(v);
4148}
4149/// `intypeset` — see implementation.
4150pub fn intypeset() -> bool {
4151    LEX_INTYPESET.get()
4152}
4153/// `set_intypeset` — see implementation.
4154pub fn set_intypeset(v: bool) {
4155    LEX_INTYPESET.set(v);
4156}
4157/// `lineno` — see implementation.
4158pub fn lineno() -> u64 {
4159    LEX_LINENO.get()
4160}
4161/// `set_lineno` — see implementation.
4162pub fn set_lineno(v: u64) {
4163    LEX_LINENO.set(v);
4164}
4165/// `incmdpos` — see implementation.
4166pub fn incmdpos() -> bool {
4167    LEX_INCMDPOS.get()
4168}
4169/// `set_incmdpos` — see implementation.
4170pub fn set_incmdpos(v: bool) {
4171    LEX_INCMDPOS.set(v);
4172}
4173/// Port of `int nocorrect` from `Src/lex.c:144`. Getter/setter for
4174/// the spelling-correction suppression flag. `par_redir` saves and
4175/// restores this around the zshlex that consumes the redir target.
4176pub fn nocorrect() -> i32 {
4177    LEX_NOCORRECT.get()
4178}
4179/// `set_nocorrect` — see implementation.
4180pub fn set_nocorrect(v: i32) {
4181    LEX_NOCORRECT.set(v);
4182}
4183/// Port of `int noaliases` from `Src/lex.c:135`. Suppresses alias
4184/// expansion. par_case saves and restores this around the case-word
4185/// + `in` lex so the literal `in` keyword isn't alias-expanded.
4186pub fn noaliases() -> bool {
4187    LEX_NOALIASES.get()
4188}
4189/// `set_noaliases` — see implementation.
4190pub fn set_noaliases(v: bool) {
4191    LEX_NOALIASES.set(v);
4192}
4193/// `incond` — see implementation.
4194pub fn incond() -> i32 {
4195    LEX_INCOND.get()
4196}
4197/// `set_incond` — see implementation.
4198pub fn set_incond(v: i32) {
4199    LEX_INCOND.set(v);
4200}
4201
4202// `hashchar` / `bangchar` / `hatchar` canonical port of
4203// `unsigned char hashchar, bangchar, hatchar;` (`Src/params.c:132`)
4204// lives at `crate::ported::hist::{hashchar, bangchar, hatchar}` as
4205// `AtomicI32` so `histcharssetfn` (`Src/params.c:5095-5097`) can
4206// update them when `$HISTCHARS` changes. Local stale-const copies
4207// removed — readers now go through the atomic load directly.
4208/// `incasepat` — see implementation.
4209pub fn incasepat() -> i32 {
4210    LEX_INCASEPAT.get()
4211}
4212/// `set_incasepat` — see implementation.
4213pub fn set_incasepat(v: i32) {
4214    LEX_INCASEPAT.set(v);
4215}
4216/// Port of `mod_export char *tokstrings[WHILE + 1]` from
4217/// `Src/lex.c:171-205`. Canonical text for each punctuation token —
4218/// used by `zshlex` (lex.c:1965 `zshlextext = tokstrings[tok]`) when
4219/// no tokstr was captured, and by `yyerror` (parse.c:2738) to format
4220/// "parse error near `X'" tails.
4221///
4222/// Indexed by lextok value (C's `tokstrings[tok]`). Entries the C
4223/// initializer doesn't set are `None`; the array bound is WHILE+1
4224/// matching C exactly.
4225#[allow(non_upper_case_globals)]
4226pub static tokstrings: [Option<&'static str>; (WHILE + 1) as usize] = {
4227    let mut t: [Option<&'static str>; (WHILE + 1) as usize] = [None; (WHILE + 1) as usize];
4228    t[SEPER as usize] = Some(";"); // c:173
4229    t[NEWLIN as usize] = Some("\\n"); // c:174
4230    t[SEMI as usize] = Some(";"); // c:175
4231    t[DSEMI as usize] = Some(";;"); // c:176
4232    t[AMPER as usize] = Some("&"); // c:177
4233    t[INPAR_TOK as usize] = Some("("); // c:178
4234    t[OUTPAR_TOK as usize] = Some(")"); // c:179
4235    t[DBAR as usize] = Some("||"); // c:180
4236    t[DAMPER as usize] = Some("&&"); // c:181
4237    t[OUTANG_TOK as usize] = Some(">"); // c:182
4238    t[OUTANGBANG as usize] = Some(">|"); // c:183
4239    t[DOUTANG as usize] = Some(">>"); // c:184
4240    t[DOUTANGBANG as usize] = Some(">>|"); // c:185
4241    t[INANG_TOK as usize] = Some("<"); // c:186
4242    t[INOUTANG as usize] = Some("<>"); // c:187
4243    t[DINANG as usize] = Some("<<"); // c:188
4244    t[DINANGDASH as usize] = Some("<<-"); // c:189
4245    t[INANGAMP as usize] = Some("<&"); // c:190
4246    t[OUTANGAMP as usize] = Some(">&"); // c:191
4247    t[AMPOUTANG as usize] = Some("&>"); // c:192
4248    t[OUTANGAMPBANG as usize] = Some("&>|"); // c:193
4249    t[DOUTANGAMP as usize] = Some(">>&"); // c:194
4250    t[DOUTANGAMPBANG as usize] = Some(">>&|"); // c:195
4251    t[TRINANG as usize] = Some("<<<"); // c:196
4252    t[BAR_TOK as usize] = Some("|"); // c:197
4253    t[BARAMP as usize] = Some("|&"); // c:198
4254    t[INOUTPAR as usize] = Some("()"); // c:199
4255    t[DINPAR as usize] = Some("(("); // c:200
4256    t[DOUTPAR as usize] = Some("))"); // c:201
4257    t[AMPERBANG as usize] = Some("&|"); // c:202
4258    t[SEMIAMP as usize] = Some(";&"); // c:203
4259    t[SEMIBAR as usize] = Some(";|"); // c:204
4260    t
4261};
4262
4263/// `char *tokstr` accessors — direct port of lex.c:170 file-static.
4264pub fn tokstr() -> Option<String> {
4265    LEX_TOKSTR.with_borrow(|t| t.clone())
4266}
4267/// `set_tokstr` — see implementation.
4268pub fn set_tokstr(v: Option<String>) {
4269    LEX_TOKSTR.with_borrow_mut(|t| *t = v);
4270}
4271/// `enum lextok tok` accessors — direct port of lex.c:180 file-static.
4272pub fn tok() -> lextok {
4273    LEX_TOK.get()
4274}
4275/// `set_tok` — see implementation.
4276pub fn set_tok(v: lextok) {
4277    LEX_TOK.set(v);
4278}
4279/// `pos` — see implementation.
4280pub fn pos() -> usize {
4281    LEX_POS.get()
4282}
4283/// `set_pos` — see implementation.
4284pub fn set_pos(v: usize) {
4285    LEX_POS.set(v);
4286}
4287/// Slice the input source from `start..end` — used by parse.rs to
4288/// capture function body source text. Returns None if out-of-range.
4289pub fn input_slice(start: usize, end: usize) -> Option<String> {
4290    LEX_INPUT.with_borrow(|s| s.get(start..end).map(String::from))
4291}
4292
4293/// Create a new lexer for the given input
4294pub fn lex_init(input: &str) {
4295    // Ensure `typtab[]` is initialised so `iblank()` / `inblank()` /
4296    // `idigit()` / etc. (called throughout the lexer) work. C zsh
4297    // calls `inittyptab()` once at shell startup in `init_main()`
4298    // (init.c:1287); zshrs lex tests bypass that path so we kick
4299    // it here too. `inittyptab` is idempotent (sets `ZTF_INIT`).
4300    crate::ported::utils::inittyptab();
4301    // Reset migrated thread-locals so a fresh lexer instance
4302    // starts from a clean slate (same as the C source's
4303    // file-static initializers in lex.c).
4304    LEX_UNGET_BUF.with_borrow_mut(|b| b.clear());
4305    LEX_LEXBUF.with_borrow_mut(|b| *b = lexbufstate::new());
4306    LEX_LEXBUF_RAW.with_borrow_mut(|b| *b = lexbufstate::new());
4307    // P7-batch-3 fields: reset to their C-source initial values.
4308    LEX_LEXSTOP.set(false);
4309    LEX_INCONDPAT.set(false);
4310    LEX_OLDPOS.set(true);
4311    LEX_DBPARENS.set(false);
4312    // NOT reset here. C's `lexinit` (c:441-445) resets nocorrect/dbparens/lexstop
4313    // but deliberately leaves `noaliases` alone: it is a plain global
4314    // (Src/lex.c:135), saved and restored by its callers. `loadautofn`
4315    // (Src/exec.c:5684-5704) sets it from the function's PM_UNALIASED bit and
4316    // then parses the autoloaded file — so resetting it inside lexinit would
4317    // clobber the flag before the parse it exists to govern, which is why
4318    // `autoload -U` failed to suppress alias expansion in the loaded body.
4319    LEX_NOCORRECT.set(0);
4320    LEX_NOCOMMENTS.set(false);
4321    LEX_LEXFLAGS.set(0);
4322    LEX_ISFIRSTLN.set(true);
4323    LEX_LEX_ADD_RAW.set(0);
4324    // P7-batch-4 resets.
4325    LEX_TOKFD.set(-1);
4326    LEX_TOKLINENO.set(1);
4327    LEX_INREDIR.set(false);
4328    LEX_INFOR.set(0);
4329    LEX_INREPEAT.set(0);
4330    LEX_INTYPESET.set(false);
4331    LEX_ISNEWLIN.set(0);
4332    // P7-batch-5 resets.
4333    LEX_LINENO.set(1);
4334    LEX_INCMDPOS.set(true);
4335    LEX_INCOND.set(0);
4336    LEX_INCASEPAT.set(0);
4337    // P7-batch-6 reset.
4338    LEX_HEREDOCS.with_borrow_mut(|v| v.clear());
4339    // P7-batch-7 resets.
4340    LEX_TOKSTR.with_borrow_mut(|t| *t = None);
4341    LEX_TOK.set(ENDINPUT);
4342    // P7-batch-8: input + pos.
4343    LEX_INPUT.with_borrow_mut(|s| {
4344        s.clear();
4345        s.push_str(input);
4346    });
4347    LEX_POS.set(0);
4348}
4349
4350// Direct port of the anonymous enum at `Src/lex.c:483-487`:
4351//   enum { CMD_OR_MATH_CMD, CMD_OR_MATH_MATH, CMD_OR_MATH_ERR };
4352// `cmd_or_math()` and `cmd_or_math_sub()` return one of these as `int`.
4353// Following the same flat-const pattern zshrs uses for lextok
4354// (zsh_h.rs:198-251) so call sites read the C identifier verbatim.
4355/// `CMD_OR_MATH_CMD` constant.
4356pub const CMD_OR_MATH_CMD: i32 = 0;
4357/// `CMD_OR_MATH_MATH` constant.
4358pub const CMD_OR_MATH_MATH: i32 = 1;
4359/// `CMD_OR_MATH_ERR` constant.
4360pub const CMD_OR_MATH_ERR: i32 = 2;
4361
4362/// Check recursion depth; returns true if exceeded
4363#[inline]
4364/// Get next character from input
4365pub(crate) fn hgetc() -> Option<char> {
4366    // Re-read from unget_buf: increment lineno on `\n` HERE
4367    // too. hungetc() decremented lineno when the char was put
4368    // back; without a matching increment on the way out, every
4369    // `\n` that's ungetted-then-reread leaves lineno
4370    // permanently one short. Symptom: $LINENO stuck at 1 in
4371    // every script statement because the parser ungets the
4372    // separating newline once between statements.
4373    if let Some(c) = LEX_UNGET_BUF.with_borrow_mut(|b| b.pop_front()) {
4374        if c == '\n' {
4375            LEX_LINENO.set(LEX_LINENO.get() + 1);
4376        }
4377        // c:input.c:360-361 — every char returned by ingetc feeds
4378        // the raw buffer when lex_add_raw is on. Re-reads from the
4379        // unget queue count the same as fresh reads; the matching
4380        // `zshlex_raw_back()` call in hungetc removed the prior
4381        // record, so this restores it.
4382        zshlex_raw_add(c);
4383        // c:input.c:327 — re-reading the un-gotten char consumes it like
4384        // any other read (C's ingetc `inbufct--`). Mirror it so the
4385        // hungetc(+1)/reread(-1) pair stays balanced; scoped to
4386        // LEXFLAGS_ZLE for the same reason as the hungetc restore.
4387        if LEX_LEXFLAGS.get() & LEXFLAGS_ZLE != 0 {
4388            crate::ported::input::inbufct.with(|ct| ct.set(ct.get() - 1));
4389        }
4390        return Some(c);
4391    }
4392
4393    // Two-input-system bridge (c:Src/input.c): zshrs's lexer has its
4394    // own LEX_INPUT/LEX_POS char-window while input.rs maintains
4395    // ingetc's inbuf stack (used by inpush for alias / here-string /
4396    // eval content). Prefer the inbuf stack WHEN it has any content
4397    // (inbufct > 0). If inbufct == 0 but there's a pending instack
4398    // frame to pop (INP_CONT'd lower frame e.g. exalias's leading
4399    // " " separator after the body drained), ingetc's c:296
4400    // `if (inbufflags & INP_CONT) { inpoptop(); continue; }` arm
4401    // handles the pop. Then unwind to LEX_INPUT for the remainder.
4402    let pos = LEX_POS.get();
4403    let inbufct = crate::ported::input::inbufct.with(|c| c.get());
4404    // Also probe instack via the flags-on-current-frame: if the
4405    // CURRENT frame has INP_CONT, ingetc will pop and continue. The
4406    // bridge needs to call ingetc to trigger that pop even when
4407    // inbufct == 0.
4408    let flags = crate::ported::input::inbufflags.with(|f| f.get());
4409    let inp_cont_pending = (flags & crate::ported::zsh_h::INP_CONT) != 0;
4410    let try_inbuf = inbufct > 0 || inp_cont_pending;
4411
4412    // c:Src/lex.c — the lexer reads characters via `hgetc`, which C points
4413    // at `ihgetc` (hist.c:418) when history is on. For the inbuf/ingetc
4414    // stack source (interactive input) we DELEGATE to the ported `ihgetc`
4415    // (ingetc → histsubchar → hwaddc → addtoline) so `!`-expansion and the
4416    // history-line build are CALLED, not re-inlined here — but only when
4417    // history is active (stophist==0 && !INP_ALIAS). The Rust-only
4418    // LEX_INPUT window (`-c` / cmd-subst / eval, where history is off) is
4419    // read directly. `ihgetc` returns C's `int`; it signals EOF / a bad
4420    // `!`-reference by setting `lexstop`, which we map back to the Option
4421    // API's `None`.
4422    let hist_active = crate::ported::hist::stophist.load(Ordering::SeqCst) == 0
4423        && (flags & crate::ported::zsh_h::INP_ALIAS) == 0;
4424    let read_stack = || -> Option<char> {
4425        if hist_active {
4426            let nc = crate::ported::hist::ihgetc(); // c:418
4427            if crate::ported::input::lexstop.with(|s| s.get()) {
4428                None
4429            } else {
4430                char::from_u32(nc as u32)
4431            }
4432        } else {
4433            crate::ported::input::ingetc()
4434        }
4435    };
4436
4437    let from_inbuf = if try_inbuf { read_stack() } else { None };
4438    let c = if let Some(c) = from_inbuf {
4439        c
4440    } else if let Some(c) = LEX_INPUT.with_borrow(|s| s.get(pos..).and_then(|t| t.chars().next())) {
4441        LEX_POS.set(pos + c.len_utf8());
4442        c
4443    } else {
4444        // inbuf + LEX_INPUT both empty. If a prior read already set lexstop
4445        // (real EOF, or a bad `!`-expansion), stop now WITHOUT re-reading —
4446        // re-entering ihgetc here would `hwaddc` a stray char into chline.
4447        // (An inbuf drain while `strin` is set — e.g. an alias body ending
4448        // mid-`eval` — ALSO sets lexstop, but never reaches here: the
4449        // LEX_INPUT branch above still holds the post-alias text.) No
4450        // lexstop → genuine refill: read the next line via the same
4451        // history-aware path (triggers inputline). `?` is None at EOF.
4452        if crate::ported::input::lexstop.with(|s| s.get()) {
4453            return None;
4454        }
4455        read_stack()?
4456    };
4457
4458    if c == '\n' {
4459        LEX_LINENO.set(LEX_LINENO.get() + 1);
4460    }
4461
4462    // c:input.c:360-361 — `if (!lexstop) zshlex_raw_add(lastc);`
4463    // Every char read from input also feeds the raw buffer when
4464    // lex_add_raw is on (used by skipcomm to capture verbatim
4465    // `$(...)` body text into the parent token). (The history-line build —
4466    // C `hwaddc`/`addtoline` at ihgetc c:459-460 — is done INSIDE the
4467    // `ihgetc` call above, not duplicated here.)
4468    zshlex_raw_add(c);
4469
4470    Some(c)
4471}
4472
4473/// Put character back into input. Direct port of `inungetc(int c)`
4474/// from `Src/input.c:546-583`. Critical: C decrements lineno on
4475/// `\n` ungetc UNCONDITIONALLY (modulo input-flags). Rust's
4476/// previous `LEX_LINENO.get() > 1` guard caused off-by-one drift
4477/// when lineno was set to 0 (via par_funcdef's `set_lineno(0)`)
4478/// then a `\n` got read+ungetted: hungetc wouldn't decrement
4479/// (because LEX_LINENO==1 fails `> 1`), but the subsequent re-read
4480/// in hgetc DOES increment, leaving LEX_LINENO=2 instead of 1.
4481fn hungetc(c: char) {
4482    LEX_UNGET_BUF.with_borrow_mut(|b| b.push_front(c));
4483    if c == '\n' {
4484        // c:input.c:561-562 — `if (((inbufflags & INP_LINENO) ||
4485        // !strin) && c == '\n') lineno--;`
4486        let cur = LEX_LINENO.get();
4487        if cur > 0 {
4488            LEX_LINENO.set(cur - 1);
4489        }
4490    }
4491    LEX_LEXSTOP.set(false);
4492    // c:input.c:549,609 — `inungetc` calls `zshlex_raw_back()` so
4493    // the un-gotten char isn't double-counted in lexbuf_raw on
4494    // re-read. hgetc will re-add it next time it's pulled.
4495    zshlex_raw_back();
4496    // c:input.c:558-559 — `inbufptr--; inbufct++;`. C's ungetc pushes the
4497    // char back into inbuf AND restores `inbufct`, so a read-then-unget
4498    // leaves `inbufct` counting the char as un-consumed. `gotword`
4499    // (c:lex.c:1884) reads that restored count via
4500    // `nwe = zlemetall + 1 - inbufct` to place the completion word END.
4501    // The Rust bridge ungets via LEX_UNGET_BUF (not inbuf) and so never
4502    // restored `inbufct`; a word-terminating char read-then-ungotten
4503    // then left `inbufct` one low, inflating `we`/`swe` by one and
4504    // dropping the leading separator of a `compset -q` ignored suffix.
4505    // Scoped to LEXFLAGS_ZLE: only the completion lexer (set_comp_sep /
4506    // get_comp_string, fed entirely through inpush -> inbuf) reads
4507    // `inbufct` for word positions; normal parsing may read the
4508    // Rust-only LEX_INPUT window where `inbufct` isn't tracked, so it
4509    // must not be perturbed. Paired with the matching `inbufct--` on the
4510    // unget re-read in hgetc so the count stays balanced.
4511    if LEX_LEXFLAGS.get() & LEXFLAGS_ZLE != 0 {
4512        crate::ported::input::inbufct.with(|ct| ct.set(ct.get() + 1));
4513    }
4514}
4515
4516/// Peek at next character without consuming
4517#[allow(dead_code)]
4518fn peek() -> Option<char> {
4519    if let Some(c) = LEX_UNGET_BUF.with_borrow(|b| b.front().copied()) {
4520        return Some(c);
4521    }
4522    LEX_INPUT.with_borrow(|s| s[LEX_POS.get()..].chars().next())
4523}
4524
4525/// Port of `void (*hwaddc)(int)` from `Src/hist.c:43` — function-
4526/// pointer that the history layer flips between `ihwaddc` (real
4527/// append at hist.c:357, ported at `hist::ihwaddc`) when history
4528/// is active, and `nohw` (dummy at hist.c:1062) when it isn't.
4529/// Setup happens in `hbegin()` at hist.c:1141/1151. zshrs doesn't
4530/// flip the pointer yet, but `ihwaddc` is itself a no-op when
4531/// `chline` is empty, so calling it unconditionally matches C
4532/// behavior for the history-inactive case. The HISTALLOWCLOBBER
4533/// caller sites at `Src/lex.c:903, 910` write `|` so the history
4534/// line records the implicit clobber as `>|`/`>>|`.
4535#[inline]
4536fn hwaddc(c: char) {
4537    crate::ported::hist::ihwaddc(c as i32);
4538}
4539
4540/// Check if a string is a valid assignment target (identifier or array ref).
4541///
4542/// zsh accepts identifier (`[A-Za-z_][A-Za-z0-9_]*`) optionally followed by
4543/// a `[...]` subscript. Bare digits are NOT a valid lvalue (rejected at
4544/// `if c.is_ascii_digit()` below — array index expressions like `arr[2]`
4545/// are caught by the subscript handler, not here). And the first char
4546/// must NOT be a zsh internal token byte — `$=foo` (where `$` becomes
4547/// the Stringg token 0x85) is parameter substitution with the `=` flag,
4548/// NOT an envstring assignment.
4549fn is_valid_assignment_target(s: &str) -> bool {
4550    let mut chars = s.chars().peekable();
4551
4552    // Reject leading token byte — `$VAR=` is parameter substitution,
4553    // not assignment. Same for `*=`, `?=`, etc.
4554    if let Some(&c) = chars.peek() {
4555        if itok(c as u8) {
4556            return false;
4557        }
4558    }
4559
4560    // Leading digit: an all-digit name is a positional-parameter
4561    // assignment (`2=X` → $2). c:Src/params.c:1336-1340 isident treats
4562    // an all-digit name as a valid identifier; the lexer's assignment
4563    // detection (c:1233-1242) then accepts the optional `+` for the
4564    // augment form. The prior port required ALL digits with NO trailing
4565    // `+`, so `2=X` lexed as ENVSTRING but `2+=end` did not (it became a
4566    // command word → "command not found: 2+=end").
4567    if let Some(&c) = chars.peek() {
4568        if c.is_ascii_digit() {
4569            while let Some(&c) = chars.peek() {
4570                if !c.is_ascii_digit() {
4571                    break;
4572                }
4573                chars.next();
4574            }
4575            // c:1241-1242 — `if (*t == '+') t++;` optional `+=` form.
4576            // dash-strict has no `+=` (see identifier path below).
4577            if chars.peek() == Some(&'+') && !crate::dash_mode::dash_strict() {
4578                chars.next();
4579            }
4580            return chars.peek().is_none();
4581        }
4582    }
4583
4584    // c:1233 — `t = itype_end(t, INAMESPC, 0);` — walk past
4585    // identifier chars (alpha/digit/_).
4586    let mut has_ident = false;
4587    let mut after_ident_byte = 0usize;
4588    {
4589        let mut sub_chars = s.chars().peekable();
4590        let mut consumed_bytes = 0usize;
4591        while let Some(&c) = sub_chars.peek() {
4592            if c == Inbrack || c == '[' || c == '+' {
4593                break;
4594            }
4595            // Both classifiers are BYTE tables, so a `char as u8` cast truncates.
4596            // `iident` is ASCII-only; `itok` must stay reachable for the token
4597            // codepoints themselves (Pound 0x84 … Nularg 0xa1, all < 0x100), so
4598            // it is gated on the codepoint fitting in a byte rather than on
4599            // is_ascii — otherwise `二` (U+4E8C → 0x8C) would masquerade as a
4600            // token and be swallowed into an identifier.
4601            // c:1233 itype_end(t, INAMESPC, 0) → wcsitype(IIDENT) accepts any
4602            // non-ASCII alphanumeric under MULTIBYTE (and not POSIXIDENTIFIERS)
4603            // — c:utils.c:4347-4350. So `日=x` / `café=y` are assignments, not
4604            // commands. The full codepoint is tested (not `c as u8`), so a
4605            // multibyte char can't collide with a token byte (0x84..0xa1).
4606            // Bug #1021 (assignment leg).
4607            let c_is_ident = (c.is_ascii() && crate::ztype_h::iident(c as u8))
4608                || (!c.is_ascii()
4609                    && c.is_alphanumeric()
4610                    && isset(crate::ported::zsh_h::MULTIBYTE)
4611                    && !isset(crate::ported::zsh_h::POSIXIDENTIFIERS));
4612            let c_is_tok = (c as u32) < 0x100 && itok(c as u8);
4613            if !c_is_ident && c != Stringg && !c_is_tok {
4614                return false;
4615            }
4616            has_ident = true;
4617            consumed_bytes += c.len_utf8();
4618            sub_chars.next();
4619        }
4620        after_ident_byte = consumed_bytes;
4621    }
4622    // c:1236-1238 — `if (t < lexbuf.ptr) skipparens(Inbrack, Outbrack, &t);`.
4623    // When the identifier doesn't span the whole buffer, the remainder
4624    // must be a `[index]` subscript — balanced via skipparens.
4625    let mut cursor: &str = &s[after_ident_byte..];
4626    if cursor.starts_with(Inbrack) || cursor.starts_with('[') {
4627        let bal = crate::ported::utils::skipparens(Inbrack, Outbrack, &mut cursor);
4628        let _ = bal; // C ignores the return value here — `t` is advanced regardless.
4629    }
4630    // c:1241-1242 — `if (*t == '+') t++;` — optional `+=` form.
4631    // !!! DASH-STRICT GATE (no C counterpart) !!! dash has no `+=`
4632    // compound assignment; `x+=b` is a command word (→ "not found").
4633    // Leaving the `+` unconsumed makes `cursor` non-empty below so the
4634    // whole `x+` fails the assignment test, matching /bin/dash.
4635    if let Some(c) = cursor.chars().next() {
4636        if c == '+' && !crate::dash_mode::dash_strict() {
4637            cursor = &cursor[1..];
4638        }
4639    }
4640    // c:1243 — `if (t == lexbuf.ptr) ...` — full buffer consumed means
4641    // this is a valid assignment target.
4642    has_ident && cursor.is_empty()
4643}
4644
4645/// Untokenize a string - convert tokenized chars back to original
4646///
4647/// Port of untokenize(char *s) from exec.c (but used by lexer too)
4648/// Like `untokenize`, but maps Snull → `'` and Dnull → `"` instead of
4649/// stripping them. Used by callers that need the source form including
4650/// quoting (e.g. arithmetic-substitution detection in compile_zsh).
4651///
4652/// Token-byte detection routes through the canonical ITOK range
4653/// `Pound..=Nularg` = `0x84..=0xa1` (per `Src/zsh.h:159-205` +
4654/// `Src/ztype.h:52`). The previous Rust port used `(0x83..=0x9f)`
4655/// which was BOTH too inclusive (0x83 = Meta, IMETA-only, NOT ITOK)
4656/// and too narrow (missing 0xa0 Bnullkeep and 0xa1 Nularg).
4657pub fn untokenize_preserve_quotes(s: &str) -> String {
4658    let mut result = String::with_capacity(s.len() + 4);
4659    for c in s.chars() {
4660        let cu = c as u32;
4661        if (0x84..=0xa1).contains(&cu) {
4662            // c:52 (Src/ztype.h) ITOK range
4663            match c {
4664                c if c == Pound => result.push('#'),
4665                c if c == Stringg => result.push('$'),
4666                c if c == Hat => result.push('^'),
4667                c if c == Star => result.push('*'),
4668                c if c == Inpar => result.push('('),
4669                c if c == Outpar => result.push(')'),
4670                c if c == Inparmath => result.push('('),
4671                c if c == Outparmath => result.push(')'),
4672                // c:167 — Qstring is the DQ-context `$` marker.
4673                // Preserve in this variant so the downstream
4674                // `stringsubst` qt detection at Src/subst.c:283
4675                // (`if ((qt = c == Qstring) || c == String)`) fires
4676                // and paramsubst sees qt=true. Untokenizing to plain
4677                // `$` would lose the DQ context for the
4678                // BUILTIN_EXPAND_TEXT singsub path, breaking
4679                // DQ-wrapped flag forms like `"${(o)arr}"` (should
4680                // join to scalar, not sort+splat per c:3033 sepjoin
4681                // + c:4245 isarr-gated sort).
4682                c if c == Qstring => result.push(c),
4683                c if c == Equals => result.push('='),
4684                c if c == Bar => result.push('|'),
4685                c if c == Inbrace => result.push('{'),
4686                c if c == Outbrace => result.push('}'),
4687                c if c == Inbrack => result.push('['),
4688                c if c == Outbrack => result.push(']'),
4689                c if c == Tick => result.push('`'),
4690                c if c == Inang => result.push('<'),
4691                c if c == Outang => result.push('>'),
4692                c if c == OutangProc => result.push('>'),
4693                c if c == Quest => result.push('?'),
4694                c if c == Tilde => result.push('~'),
4695                c if c == Qtick => result.push('`'),
4696                c if c == Comma => result.push(','),
4697                c if c == Dash => result.push('-'),
4698                c if c == Bang => result.push('!'),
4699                c if c == Snull => result.push('\''),
4700                c if c == Dnull => result.push('"'),
4701                c if c == Bnull => result.push('\\'),
4702                _ => {
4703                    let idx = c as usize;
4704                    if idx < ztokens.len() {
4705                        result.push(ztokens.chars().nth(idx).unwrap_or(c));
4706                    } else {
4707                        result.push(c);
4708                    }
4709                }
4710            }
4711        } else {
4712            result.push(c);
4713        }
4714    }
4715    result
4716}
4717
4718/// Decode `\X` escape sequences for `$'...'` content.
4719/// Port of `getkeystring(char *s, int *len, int how, int *misc)` from Src/utils.c:6915 with the
4720/// `GETKEYS_DOLLARS_QUOTE` flag — handles the `\n`/`\t`/`\r`/`\e`/
4721/// `\E`/`\a`/`\b`/`\f`/`\v`/`\xNN`/`\uNNNN`/`\UNNNNNNNN`/octal/`\\`/`\'`
4722/// arms the C source recognizes inside dollar-single-quoted
4723/// strings. Walks `chars[start..]` until `Snull` is hit, returns
4724/// `(decoded, end_idx)` where `end_idx` points at the terminating
4725/// `Snull`. `Bnull \\` and `Bnull '` are user-literal `\` / `'`
4726/// per Src/lex.c:1303.
4727fn getkeystring_dollar_quote(chars: &[char], start: usize) -> (String, usize) {
4728    let mut out = String::new();
4729    let mut i = start;
4730    while i < chars.len() {
4731        let c = chars[i];
4732        if c == Snull {
4733            return (out, i);
4734        }
4735        if c == Bnull {
4736            // Bnull marks a user-literal `\\` or `\'` per
4737            // Src/lex.c:1303-1306. The next char is the literal.
4738            i += 1;
4739            if i < chars.len() {
4740                out.push(chars[i]);
4741                i += 1;
4742            }
4743            continue;
4744        }
4745        if c == '\\' && i + 1 < chars.len() {
4746            let nc = chars[i + 1];
4747            match nc {
4748                'a' => {
4749                    out.push('\x07');
4750                    i += 2;
4751                }
4752                'b' => {
4753                    out.push('\x08');
4754                    i += 2;
4755                }
4756                'e' | 'E' => {
4757                    out.push('\x1b');
4758                    i += 2;
4759                }
4760                'f' => {
4761                    out.push('\x0c');
4762                    i += 2;
4763                }
4764                'n' => {
4765                    out.push('\n');
4766                    i += 2;
4767                }
4768                'r' => {
4769                    out.push('\r');
4770                    i += 2;
4771                }
4772                't' => {
4773                    out.push('\t');
4774                    i += 2;
4775                }
4776                'v' => {
4777                    out.push('\x0b');
4778                    i += 2;
4779                }
4780                '\\' | '\'' | '"' => {
4781                    out.push(nc);
4782                    i += 2;
4783                }
4784                'x' => {
4785                    // \xNN — up to 2 hex digits per Src/utils.c:7156
4786                    let mut val: u32 = 0;
4787                    let mut consumed = 2; // \x
4788                    let mut got = 0;
4789                    while got < 2 && i + consumed < chars.len() {
4790                        let h = chars[i + consumed];
4791                        if let Some(d) = h.to_digit(16) {
4792                            val = val * 16 + d;
4793                            consumed += 1;
4794                            got += 1;
4795                        } else {
4796                            break;
4797                        }
4798                    }
4799                    if got == 0 {
4800                        // No hex digits — emit literal `\x` per
4801                        // Src/utils.c:7160-7163 fallthrough
4802                        out.push('\\');
4803                        out.push('x');
4804                    } else {
4805                        // c:Src/utils.c — `\xNN` is one raw BYTE, not
4806                        // a Unicode codepoint (the old char::from_u32
4807                        // re-encoded 0xNN >= 0x80 as two UTF-8
4808                        // bytes); metafied per c:Src/utils.c:7289-
4809                        // 7294. Bug #127.
4810                        {
4811                            // c:Src/utils.c metafy byte-encode step:
4812                            // `if (imeta(c)) {{ *p++ = Meta; *p++ = c ^ 32; }}`
4813                            let b_ = (val & 0xff) as u8;
4814                            if b_ < 0x80 {
4815                                out.push(b_ as char);
4816                            } else {
4817                                out.push('\u{83}');
4818                                out.push(char::from(b_ ^ 32));
4819                            }
4820                        }
4821                    }
4822                    i += consumed;
4823                }
4824                'u' | 'U' => {
4825                    let n = if nc == 'u' { 4 } else { 8 };
4826                    let mut val: u32 = 0;
4827                    let mut consumed = 2; // \u or \U
4828                    let mut got = 0;
4829                    while got < n && i + consumed < chars.len() {
4830                        let h = chars[i + consumed];
4831                        if let Some(d) = h.to_digit(16) {
4832                            val = val * 16 + d;
4833                            consumed += 1;
4834                            got += 1;
4835                        } else {
4836                            break;
4837                        }
4838                    }
4839                    if let Some(ch) = char::from_u32(val) {
4840                        out.push(ch);
4841                    }
4842                    i += consumed;
4843                }
4844                '0'..='7' => {
4845                    // Octal — up to 3 digits per Src/utils.c:7156
4846                    let mut val: u32 = 0;
4847                    let mut consumed = 1; // skip backslash
4848                    let mut got = 0;
4849                    while got < 3 && i + consumed < chars.len() {
4850                        let h = chars[i + consumed];
4851                        if let Some(d) = h.to_digit(8) {
4852                            val = val * 8 + d;
4853                            consumed += 1;
4854                            got += 1;
4855                        } else {
4856                            break;
4857                        }
4858                    }
4859                    // c:Src/utils.c — octal escape is one raw BYTE
4860                    // (`$'\377'` = 0xff), metafied per
4861                    // c:Src/utils.c:7289-7294. Bug #127.
4862                    {
4863                        // c:Src/utils.c metafy byte-encode step:
4864                        // `if (imeta(c)) {{ *p++ = Meta; *p++ = c ^ 32; }}`
4865                        let b_ = (val & 0xff) as u8;
4866                        if b_ < 0x80 {
4867                            out.push(b_ as char);
4868                        } else {
4869                            out.push('\u{83}');
4870                            out.push(char::from(b_ ^ 32));
4871                        }
4872                    }
4873                    i += consumed;
4874                }
4875                'C' | 'M' => {
4876                    // c:Src/utils.c:7029-7052 — `\C` and `\M` set
4877                    // `control` / `meta` flags; the optional `-`
4878                    // separator is consumed; then the NEXT char (or
4879                    // chained `\C`/`\M` modifier) is read and the
4880                    // mask applied at c:7265-7275 (control → `& 0x9f`,
4881                    // meta → `| 0x80`). `\C-?` is special-cased to
4882                    // 0x7f. Bug #113 in docs/BUGS.md: the previous
4883                    // Rust port dropped `\C` / `\M` into the
4884                    // unknown-escape default branch, so `$'\C-a'`
4885                    // emitted literal `C-a` instead of byte 0x01.
4886                    let mut control = nc == 'C';
4887                    let mut meta = nc == 'M';
4888                    let mut j = i + 2;
4889                    // Consume any chain of `-`, additional `\C`/`\M`
4890                    // modifiers (e.g. `\M-\C-x` → meta+control on x).
4891                    loop {
4892                        if j < chars.len() && chars[j] == '-' {
4893                            j += 1;
4894                            continue;
4895                        }
4896                        if j + 1 < chars.len()
4897                            && chars[j] == '\\'
4898                            && (chars[j + 1] == 'C' || chars[j + 1] == 'M')
4899                        {
4900                            if chars[j + 1] == 'C' {
4901                                control = true;
4902                            } else {
4903                                meta = true;
4904                            }
4905                            j += 2;
4906                            continue;
4907                        }
4908                        break;
4909                    }
4910                    if j >= chars.len() {
4911                        // Malformed — preserve literal per
4912                        // Src/utils.c:7050 fallthrough.
4913                        out.push('\\');
4914                        out.push(nc);
4915                        i += 2;
4916                        continue;
4917                    }
4918                    // Read one base char (allowing nested `\xNN` /
4919                    // `\NNN` / `\u…` / literal). For simplicity,
4920                    // accept either a literal char or a one-char
4921                    // escape.
4922                    let (mut ch, advance): (char, usize) =
4923                        if chars[j] == '\\' && j + 1 < chars.len() {
4924                            let nn = chars[j + 1];
4925                            match nn {
4926                                'a' => ('\x07', 2),
4927                                'b' => ('\x08', 2),
4928                                'e' | 'E' => ('\x1b', 2),
4929                                'f' => ('\x0c', 2),
4930                                'n' => ('\n', 2),
4931                                'r' => ('\r', 2),
4932                                't' => ('\t', 2),
4933                                'v' => ('\x0b', 2),
4934                                '\\' | '\'' | '"' => (nn, 2),
4935                                _ => (nn, 2), // unknown — take literal char
4936                            }
4937                        } else {
4938                            (chars[j], 1)
4939                        };
4940                    let mut byte = ch as u32;
4941                    if control {
4942                        // c:7265-7269 — `\C-?` → 0x7f; else AND 0x9f.
4943                        if byte == '?' as u32 {
4944                            byte = 0x7f;
4945                        } else {
4946                            byte &= 0x9f;
4947                        }
4948                    }
4949                    if meta {
4950                        // c:7272-7274 — OR 0x80.
4951                        byte |= 0x80;
4952                    }
4953                    // c:Src/utils.c:7265-7275 — the masked result is
4954                    // one raw BYTE (`$'\M-i'` = 0xe9), metafied per
4955                    // c:7289-7294. Multibyte base chars (> 0xff after
4956                    // masking) keep the codepoint form. Bug #127.
4957                    if byte <= 0xff {
4958                        {
4959                            // c:Src/utils.c metafy byte-encode step:
4960                            // `if (imeta(c)) {{ *p++ = Meta; *p++ = c ^ 32; }}`
4961                            let b_ = byte as u8;
4962                            if b_ < 0x80 {
4963                                out.push(b_ as char);
4964                            } else {
4965                                out.push('\u{83}');
4966                                out.push(char::from(b_ ^ 32));
4967                            }
4968                        }
4969                    } else {
4970                        ch = char::from_u32(byte).unwrap_or('\0');
4971                        out.push(ch);
4972                    }
4973                    i = j + advance;
4974                }
4975                _ => {
4976                    // Unknown escape — keep `\` per
4977                    // Src/utils.c:7180-7185 default branch
4978                    out.push('\\');
4979                    out.push(nc);
4980                    i += 2;
4981                }
4982            }
4983            continue;
4984        }
4985        out.push(c);
4986        i += 1;
4987    }
4988    (out, i)
4989}
4990/// `untokenize` — see implementation.
4991pub fn untokenize(s: &str) -> String {
4992    let mut result = String::with_capacity(s.len());
4993    let chars: Vec<char> = s.chars().collect();
4994    let mut i = 0;
4995
4996    while i < chars.len() {
4997        let c = chars[i];
4998        // A Meta byte (U+0083) escapes the FOLLOWING char: zshrs metafies at the
4999        // CHARACTER level, so a raw high byte that C leaves untouched (0x81 is
5000        // not `imeta`) is stored here as the pair (U+0083, byte ^ 0x20). That
5001        // continuation char can land in the ITOK range — 0x81 -> 0xA1 = Nularg —
5002        // and would be wrongly stripped as a token below. Copy the pair verbatim
5003        // and never untokenize the continuation. (Only fires on metafied
5004        // high-byte content; a plain tokenized string has no U+0083.)
5005        if c as u32 == 0x83 && i + 1 < chars.len() {
5006            result.push(c);
5007            result.push(chars[i + 1]);
5008            i += 2;
5009            continue;
5010        }
5011        // C `itok()` (Src/ztype.h:52 + Src/utils.c:4198-4201) covers
5012        // the canonical TOKEN range Pound (0x84) through Nularg (0x9d
5013        // via Snull's chain to Nularg=0xa1). The previous Rust port
5014        // used `(0x83..=0x9f)` which both:
5015        //   * INCLUDED 0x83 = META (metafy lead byte, IMETA-only, NOT
5016        //     ITOK), and
5017        //   * EXCLUDED 0xa0 (Bnullkeep) and 0xa1 (Nularg) — both
5018        //     legitimate ITOK bytes per zsh.h:199-205.
5019        // Match C exactly: ITOK is 0x84..=0xa1 (Pound..Nularg).
5020        // Marker (0xa2) is intentionally NOT in the range — C's untokenize
5021        // never strips it (it's IMETA-only per Src/utils.c:4197).
5022        let cu = c as u32;
5023        if (0x84..=0xa1).contains(&cu) {
5024            // `Qstring Snull` opens a `$'...'` ANSI-C-quoted region.
5025            // Per Src/subst.c:301-304, when `stringsubst()` hits an
5026            // `Snull` it calls `stringsubstquote()` (line 206) which
5027            // calls `getkeystring(s+2, ...)` over the content,
5028            // skipping the leading `Qstring Snull` and stopping at
5029            // the closing `Snull`. zshrs's pipeline runs untokenize
5030            // at points where C runs subst, so we apply the same
5031            // decoding inline here. Result: the entire `$'...'`
5032            // region is replaced by its decoded content with no
5033            // `$`/`'`/marker remnants.
5034            // c:Src/lex.c top-level `$'...'` lexer emits `Stringg`
5035            // (`\u{85}`) before the opening `Snull`, while DQ-context
5036            // `$'...'` would emit Qstring (`\u{8c}`). Accept both so
5037            // untokenize handles top-level and DQ-context ANSI-C
5038            // strings the same way.
5039            if (c == Qstring || c == Stringg) && i + 1 < chars.len() && chars[i + 1] == Snull {
5040                let (decoded, end) = getkeystring_dollar_quote(&chars, i + 2);
5041                result.push_str(&decoded);
5042                // `end` points at the closing `Snull` (or end of
5043                // string if unterminated); skip past it.
5044                i = if end < chars.len() { end + 1 } else { end };
5045                continue;
5046            }
5047            // Convert token back to original character
5048            match c {
5049                c if c == Pound => result.push('#'),
5050                c if c == Stringg => result.push('$'),
5051                c if c == Hat => result.push('^'),
5052                c if c == Star => result.push('*'),
5053                c if c == Inpar => result.push('('),
5054                c if c == Outpar => result.push(')'),
5055                c if c == Inparmath => result.push('('),
5056                c if c == Outparmath => result.push(')'),
5057                c if c == Qstring => result.push('$'),
5058                c if c == Equals => result.push('='),
5059                c if c == Bar => result.push('|'),
5060                c if c == Inbrace => result.push('{'),
5061                c if c == Outbrace => result.push('}'),
5062                c if c == Inbrack => result.push('['),
5063                c if c == Outbrack => result.push(']'),
5064                c if c == Tick => result.push('`'),
5065                c if c == Inang => result.push('<'),
5066                c if c == Outang => result.push('>'),
5067                c if c == OutangProc => result.push('>'),
5068                c if c == Quest => result.push('?'),
5069                c if c == Tilde => result.push('~'),
5070                c if c == Qtick => result.push('`'),
5071                c if c == Comma => result.push(','),
5072                c if c == Dash => result.push('-'),
5073                c if c == Bang => result.push('!'),
5074                c if c == Snull || c == Dnull => {
5075                    // c:Src/exec.c:2077 + Src/lex.c:38 ztokens — C
5076                    // maps Snull → `'` / Dnull → `"` because C's
5077                    // untokenize fires on user-facing text (xtrace
5078                    // output, error messages, command lookups). The
5079                    // quote chars belong in that output.
5080                    //
5081                    // zshrs splits the responsibility across TWO
5082                    // functions to match the Rust pipeline's call
5083                    // shape:
5084                    //   - `untokenize` (THIS function) — strips
5085                    //     Snull / Dnull because it's invoked on the
5086                    //     SUBSTITUTION STREAM (subst.rs:4438 rest-
5087                    //     walker, multsub paths, etc.) where the
5088                    //     lex's quote-pair markers must NOT reappear
5089                    //     as literal `'`/`"` in the value text.
5090                    //   - `untokenize_preserve_quotes` (lex.rs:4179)
5091                    //     — maps Snull → `'`, Dnull → `"`, Bnull →
5092                    //     `\` per C's ztokens table exactly. Used
5093                    //     for assoc-key lookup (where keys can
5094                    //     contain literal `'`/`"`), xtrace, error
5095                    //     reporting.
5096                    //
5097                    // The split is deliberate, not a partial port.
5098                    // Calling a value-stream caller through
5099                    // `untokenize_preserve_quotes` would leak stray
5100                    // quote chars into stored values; calling a
5101                    // print-side caller through `untokenize` would
5102                    // drop quote chars from the displayed text.
5103                    // Each call site picks the variant that matches
5104                    // its semantic contract.
5105                }
5106                // c:Src/exec.c:2077-2099 + Src/lex.c:38 ztokens —
5107                // `ztokens[Bnull - Pound]` (index 27) = `\` literal.
5108                // C maps Bnull → `\` so downstream patcompile sees the
5109                // escape. The previous Rust port STRIPPED Bnull, which
5110                // collapsed `[\\]` / `[\{]` / `[\}]` patterns to
5111                // `[\]` / `[{]` / `[}]` before patcompile (subst.rs
5112                // `${msg//PAT/REPL}` path) and tripped "bad pattern".
5113                // Surfacing site: zinit zi-log message formatter
5114                // (zinit.zsh:2191).
5115                c if c == Bnull => result.push('\\'), // c:Src/lex.c:38
5116                // c:2089 — `if (c != Nularg) *p++ = ztokens[c - Pound];`
5117                // Nularg gets dropped (no replacement char emitted).
5118                c if c == Nularg => {
5119                    // Skip — matches C's c != Nularg gate.
5120                }
5121                _ => {
5122                    // Unknown token, try ztokens lookup
5123                    let idx = c as usize;
5124                    if idx < ztokens.len() {
5125                        result.push(ztokens.chars().nth(idx).unwrap_or(c));
5126                    } else {
5127                        result.push(c);
5128                    }
5129                }
5130            }
5131        } else {
5132            result.push(c);
5133        }
5134        i += 1;
5135    }
5136
5137    result
5138}
5139
5140/// Check if a string contains any token characters.
5141/// Port of `int has_token(const char *s)` from `Src/utils.c:2282` —
5142/// `while (*s) if (itok(*s++)) return 1; return 0;`.
5143///
5144/// `itok(c)` per `Src/ztype.h:52` is `STOUC(c) >= Pound && STOUC(c)
5145/// <= Nularg`, i.e. the closed range `Pound..=Nularg` =
5146/// `0x84..=0xa1` (per `zsh.h:159-205`). The previous Rust port used
5147/// `(0x83..=0x9f)` which was BOTH:
5148///   * too inclusive (0x83 = Meta, IMETA-only, NOT ITOK per
5149///     ztype_init's typtab population at `utils.c:4197`); and
5150///   * too narrow (excluded 0xa0 Bnullkeep and 0xa1 Nularg, both
5151///     legitimate ITOK bytes — so a string containing Nularg from
5152///     `$''` lexing would falsely report "no token").
5153/// Route through the canonical `ztype_h::itok` so future ITOK
5154/// changes propagate automatically (typtab is a runtime table).
5155pub fn has_token(s: &str) -> bool {
5156    // c:2282 (Src/utils.c)
5157    s.bytes().any(itok)
5158}
5159
5160#[cfg(test)]
5161mod tokens_tests {
5162    use crate::ported::hashtable::reswdtab_lock;
5163    use crate::ported::zsh_h::{
5164        Bnull, Dnull, Snull, DINANG, IF, IS_REDIROP, OUTANG_TOK, STRING_LEX, THEN,
5165    };
5166
5167    #[test]
5168    fn test_token_values() {
5169        let _g = crate::test_util::global_state_lock();
5170        assert_eq!(Snull as u32, 0x9d);
5171        assert_eq!(Dnull as u32, 0x9e);
5172        assert_eq!(Bnull as u32, 0x9f);
5173    }
5174
5175    #[test]
5176    fn test_reserved_words() {
5177        let _g = crate::test_util::global_state_lock();
5178        // Reserved-word lookup goes through the canonical `reswdtab`
5179        // (port of `Src/hashtable.c:1076 reswds[]`).
5180        let tab = reswdtab_lock().read().unwrap();
5181        assert_eq!(tab.get("if").map(|r| r.token), Some(IF));
5182        assert_eq!(tab.get("then").map(|r| r.token), Some(THEN));
5183        assert!(tab.get("notakeyword").is_none());
5184    }
5185
5186    #[test]
5187    fn test_redirop() {
5188        let _g = crate::test_util::global_state_lock();
5189        assert!(IS_REDIROP(OUTANG_TOK));
5190        assert!(IS_REDIROP(DINANG));
5191        assert!(!IS_REDIROP(IF));
5192        assert!(!IS_REDIROP(STRING_LEX));
5193    }
5194}
5195
5196#[cfg(test)]
5197mod tests {
5198    use super::*;
5199
5200    #[test]
5201    fn test_simple_command() {
5202        let _g = crate::test_util::global_state_lock();
5203        let _ = lex_init("echo hello");
5204        zshlex();
5205        assert_eq!(tok(), STRING_LEX);
5206        assert_eq!(tokstr(), Some("echo".to_string()));
5207
5208        zshlex();
5209        assert_eq!(tok(), STRING_LEX);
5210        assert_eq!(tokstr(), Some("hello".to_string()));
5211
5212        zshlex();
5213        assert_eq!(tok(), ENDINPUT);
5214    }
5215
5216    #[test]
5217    fn test_pipeline() {
5218        let _g = crate::test_util::global_state_lock();
5219        let _ = lex_init("ls | grep foo");
5220        zshlex();
5221        assert_eq!(tok(), STRING_LEX);
5222
5223        zshlex();
5224        assert_eq!(tok(), BAR_TOK);
5225
5226        zshlex();
5227        assert_eq!(tok(), STRING_LEX);
5228
5229        zshlex();
5230        assert_eq!(tok(), STRING_LEX);
5231    }
5232
5233    #[test]
5234    fn test_redirections() {
5235        let _g = crate::test_util::global_state_lock();
5236        let _ = lex_init("echo > file");
5237        zshlex();
5238        assert_eq!(tok(), STRING_LEX);
5239
5240        zshlex();
5241        assert_eq!(tok(), OUTANG_TOK);
5242
5243        zshlex();
5244        assert_eq!(tok(), STRING_LEX);
5245    }
5246
5247    #[test]
5248    fn test_heredoc() {
5249        let _g = crate::test_util::global_state_lock();
5250        let _ = lex_init("cat << EOF");
5251        zshlex();
5252        assert_eq!(tok(), STRING_LEX);
5253
5254        zshlex();
5255        assert_eq!(tok(), DINANG);
5256
5257        zshlex();
5258        assert_eq!(tok(), STRING_LEX);
5259    }
5260
5261    #[test]
5262    fn test_single_quotes() {
5263        let _g = crate::test_util::global_state_lock();
5264        let _ = lex_init("echo 'hello world'");
5265        zshlex();
5266        assert_eq!(tok(), STRING_LEX);
5267
5268        zshlex();
5269        assert_eq!(tok(), STRING_LEX);
5270        // Should contain Snull markers around literal content
5271        assert!(tokstr().is_some());
5272    }
5273
5274    #[test]
5275    fn test_function_tokens() {
5276        let _g = crate::test_util::global_state_lock();
5277        // C zsh's par_funcdef (parse.c:1681, 1717) explicitly toggles
5278        // `incmdpos` around the function header: 0 before reading the
5279        // name, 1 before the body opener. The Rust zshlex auto-updates
5280        // cmdpos C-ctxtlex-style (lex.rs:1492-1553), so a STRING name
5281        // sets incmdpos=false. To assert the body opener lexes as
5282        // INBRACE (rather than STRING with the Inbrace marker), we
5283        // have to mimic par_funcdef's incmdpos=1 reset by hand.
5284        let _ = lex_init("function foo { }");
5285        zshlex();
5286        assert_eq!(tok(), FUNC, "expected Func, got {:?}", tok());
5287
5288        zshlex();
5289        assert_eq!(
5290            tok(),
5291            STRING_LEX,
5292            "expected String for 'foo', got {:?}",
5293            tok()
5294        );
5295        assert_eq!(tokstr(), Some("foo".to_string()));
5296
5297        // par_funcdef equivalent: parse.c:1717 `incmdpos = 1;` before
5298        // the body opener.
5299        set_incmdpos(true);
5300        zshlex();
5301        assert_eq!(
5302            tok(),
5303            INBRACE_TOK,
5304            "expected Inbrace, got {:?} tokstr={:?}",
5305            tok(),
5306            tokstr()
5307        );
5308
5309        zshlex();
5310        assert_eq!(
5311            tok(),
5312            OUTBRACE_TOK,
5313            "expected Outbrace, got {:?} tokstr={:?} incmdpos={}",
5314            tok(),
5315            tokstr(),
5316            incmdpos()
5317        );
5318    }
5319
5320    #[test]
5321    fn test_double_quotes() {
5322        let _g = crate::test_util::global_state_lock();
5323        let _ = lex_init("echo \"hello $name\"");
5324        zshlex();
5325        assert_eq!(tok(), STRING_LEX);
5326
5327        zshlex();
5328        assert_eq!(tok(), STRING_LEX);
5329        // Should contain tokenized content
5330        assert!(tokstr().is_some());
5331    }
5332
5333    #[test]
5334    fn test_command_substitution() {
5335        let _g = crate::test_util::global_state_lock();
5336        let _ = lex_init("echo $(pwd)");
5337        zshlex();
5338        assert_eq!(tok(), STRING_LEX);
5339
5340        zshlex();
5341        assert_eq!(tok(), STRING_LEX);
5342    }
5343
5344    #[test]
5345    fn test_env_assignment() {
5346        let _g = crate::test_util::global_state_lock();
5347        let _ = lex_init("FOO=bar echo");
5348        set_incmdpos(true);
5349        zshlex();
5350        assert_eq!(tok(), ENVSTRING, "tok={:?} tokstr={:?}", tok(), tokstr());
5351
5352        zshlex();
5353        assert_eq!(tok(), STRING_LEX);
5354    }
5355
5356    #[test]
5357    fn test_array_assignment() {
5358        let _g = crate::test_util::global_state_lock();
5359        let _ = lex_init("arr=(a b c)");
5360        set_incmdpos(true);
5361        zshlex();
5362        assert_eq!(tok(), ENVARRAY);
5363    }
5364
5365    #[test]
5366    fn test_process_substitution() {
5367        let _g = crate::test_util::global_state_lock();
5368        let _ = lex_init("diff <(ls) >(cat)");
5369        zshlex();
5370        assert_eq!(tok(), STRING_LEX);
5371
5372        zshlex();
5373        assert_eq!(tok(), STRING_LEX);
5374        // <(ls) is tokenized into the string
5375
5376        zshlex();
5377        assert_eq!(tok(), STRING_LEX);
5378        // >(cat) is tokenized
5379    }
5380
5381    #[test]
5382    fn test_arithmetic() {
5383        let _g = crate::test_util::global_state_lock();
5384        let _ = lex_init("echo $((1+2))");
5385        zshlex();
5386        assert_eq!(tok(), STRING_LEX);
5387
5388        zshlex();
5389        assert_eq!(tok(), STRING_LEX);
5390    }
5391
5392    #[test]
5393    fn test_semicolon_variants() {
5394        let _g = crate::test_util::global_state_lock();
5395        let _ = lex_init("case x in a) cmd;; b) cmd;& c) cmd;| esac");
5396
5397        // Skip to first ;;
5398        loop {
5399            zshlex();
5400            if tok() == DSEMI || tok() == ENDINPUT {
5401                break;
5402            }
5403        }
5404        assert_eq!(tok(), DSEMI);
5405
5406        // Find ;&
5407        loop {
5408            zshlex();
5409            if tok() == SEMIAMP || tok() == ENDINPUT {
5410                break;
5411            }
5412        }
5413        assert_eq!(tok(), SEMIAMP);
5414
5415        // Find ;|
5416        loop {
5417            zshlex();
5418            if tok() == SEMIBAR || tok() == ENDINPUT {
5419                break;
5420            }
5421        }
5422        assert_eq!(tok(), SEMIBAR);
5423    }
5424
5425    /// c:3952 — `untokenize` removes Marker/Bnull/Snull tokens left
5426    /// by the param-substitution pipeline. A plain string passes
5427    /// through unchanged; tokenised input gets its sentinels stripped.
5428    /// Regression that fails to strip would leak Marker bytes (0x84)
5429    /// into user-visible output.
5430    #[test]
5431    fn untokenize_passes_plain_string_through() {
5432        let _g = crate::test_util::global_state_lock();
5433        assert_eq!(untokenize("hello"), "hello");
5434        assert_eq!(untokenize(""), "");
5435        assert_eq!(untokenize("a/b/c"), "a/b/c");
5436    }
5437
5438    /// `Src/exec.c:2079-2106` — `untokenize(s)` walks the string and
5439    /// replaces ITOK bytes (Pound=\u{84} through Nularg=\u{a1} per
5440    /// `Src/zsh.h:159-191`) using the `ztokens` table; Nularg is
5441    /// dropped entirely (no replacement char).
5442    ///
5443    /// The previous Rust port called Pound "Marker" in this test —
5444    /// incorrect. Marker is `\u{a2}` (Src/zsh.h:224) and is OUTSIDE
5445    /// the ITOK range — C's untokenize doesn't touch it. Pound
5446    /// (`\u{84}`) IS ITOK and gets replaced. Pin the canonical
5447    /// contract: Pound replaced (or stripped), but text not in
5448    /// the ITOK range passes through verbatim.
5449    #[test]
5450    fn untokenize_strips_marker_sentinels() {
5451        let _g = crate::test_util::global_state_lock();
5452        // Pound = \u{84} per zsh.h:159. ITOK byte; untokenize should
5453        // strip or replace it (the literal byte must NOT survive).
5454        let with_pound = format!("a{}b", Pound);
5455        let cleaned = untokenize(&with_pound);
5456        assert!(
5457            !cleaned.contains(Pound),
5458            "Pound (\\u{{84}}) sentinel must be replaced (got {cleaned:?})"
5459        );
5460        // Marker = \u{a2} per zsh.h:224. NOT in ITOK range. C's
5461        // untokenize doesn't touch it — passes through verbatim.
5462        let with_marker = format!("x{}y", Marker);
5463        let cleaned = untokenize(&with_marker);
5464        assert!(
5465            cleaned.contains(Marker),
5466            "Marker (\\u{{a2}}) is NOT ITOK; must pass through untokenize verbatim"
5467        );
5468    }
5469
5470    /// `Src/utils.c:4198-4201` — ITOK range is Pound..Nularg
5471    /// = `\u{84}..=\u{a1}`. The previous Rust port's untokenize used
5472    /// `(0x83..=0x9f)` — too inclusive on the low end (META=0x83 is
5473    /// IMETA-only, never ITOK) and too narrow on the high end
5474    /// (excluded Bnullkeep=0xa0 and Nularg=0xa1, both ITOK).
5475    ///
5476    /// Pin the META exclusion AND Nularg drop. Bnullkeep is in the
5477    /// ITOK range but falls past the C ztokens[] array (`c - Pound`
5478    /// = 28, array len = 28); the Rust port falls through to `push(c)`
5479    /// for unknown tokens — matching C's `*p++ = ztokens[c - Pound]`
5480    /// (reads past array, UB; effectively drops via the implicit NUL).
5481    /// We don't assert specific Bnullkeep output to avoid pinning the
5482    /// C UB behavior.
5483    #[test]
5484    fn untokenize_range_matches_c_itok_endpoints() {
5485        let _g = crate::test_util::global_state_lock();
5486        // META (\u{83}) is IMETA-only, NOT ITOK. Must pass through.
5487        let with_meta = format!("a{}b", '\u{83}');
5488        let cleaned = untokenize(&with_meta);
5489        assert!(
5490            cleaned.contains('\u{83}'),
5491            "c:4197 — META (\\u{{83}}) is IMETA-only, never ITOK"
5492        );
5493        // Nularg (\u{a1}) IS ITOK. C's untokenize SKIPS it (no
5494        // replacement char per c:2089 `if (c != Nularg)`).
5495        let with_nularg = format!("a{}b", Nularg);
5496        let cleaned = untokenize(&with_nularg);
5497        assert!(
5498            !cleaned.contains(Nularg),
5499            "c:2089 — Nularg (\\u{{a1}}) must be DROPPED by untokenize"
5500        );
5501    }
5502
5503    /// `untokenize_preserve_quotes` keeps quote sentinels (Bnull/Snull)
5504    /// in place for the param-subst-internal flow. Plain input still
5505    /// round-trips unchanged.
5506    #[test]
5507    fn untokenize_preserve_quotes_plain_input_unchanged() {
5508        let _g = crate::test_util::global_state_lock();
5509        assert_eq!(untokenize_preserve_quotes("foo"), "foo");
5510        assert_eq!(untokenize_preserve_quotes(""), "");
5511    }
5512
5513    /// `set_toklineno` + `toklineno` round-trip. The token-line
5514    /// counter drives every "line N: parse error" message; a regression
5515    /// where set doesn't stick would silently zero out line numbers.
5516    #[test]
5517    fn toklineno_set_then_get_round_trips() {
5518        let _g = crate::test_util::global_state_lock();
5519        let saved = toklineno();
5520        set_toklineno(12345);
5521        assert_eq!(toklineno(), 12345);
5522        set_toklineno(saved);
5523    }
5524
5525    /// `tokfd` set/get round-trip. Catches a regression where set
5526    /// stores into a different slot than read.
5527    #[test]
5528    fn tokfd_set_then_get_round_trips() {
5529        let _g = crate::test_util::global_state_lock();
5530        let saved = tokfd();
5531        set_tokfd(7);
5532        assert_eq!(tokfd(), 7);
5533        set_tokfd(saved);
5534    }
5535
5536    /// `lexact1_get` is a const-table accessor for the per-char
5537    /// type/action byte. Out-of-table chars must return safely without
5538    /// panic — the high unicode + nul edge cases.
5539    #[test]
5540    fn lexact1_get_handles_high_chars_without_panic() {
5541        let _g = crate::test_util::global_state_lock();
5542        let _ = lexact1_get('a');
5543        let _ = lexact1_get('\0');
5544        let _ = lexact1_get('\u{ffff}');
5545    }
5546
5547    /// `isnumglob` recognises `<N-N>` numeric range glob syntax.
5548    /// `<1-10>` matches numbers 1..=10. Regression dropping detection
5549    /// would break every script using zsh's documented numeric-glob.
5550    #[test]
5551    fn isnumglob_recognises_numeric_range_pattern() {
5552        let _g = crate::test_util::global_state_lock();
5553        // Tests drive the streaming port via lex_init → isnumglob,
5554        // matching C's hgetc/hungetc model exactly.
5555        lex_init("1-10>");
5556        assert!(isnumglob(), "<1-10> shape recognised");
5557        lex_init("0-100>");
5558        assert!(isnumglob());
5559        lex_init("9-9>");
5560        assert!(isnumglob(), "single-value range");
5561    }
5562
5563    /// `isnumglob` rejects malformed shapes: missing closing `>`,
5564    /// missing dash, or non-digit content. Regression accepting
5565    /// these would let `<abc-def>` parse as a numglob.
5566    #[test]
5567    fn isnumglob_rejects_malformed_shapes() {
5568        let _g = crate::test_util::global_state_lock();
5569        lex_init("1-10");
5570        assert!(!isnumglob(), "missing closing > → not numglob");
5571        lex_init("1-");
5572        assert!(!isnumglob(), "no closing");
5573        lex_init("abc>");
5574        assert!(!isnumglob(), "non-digit content");
5575        lex_init(">");
5576        assert!(!isnumglob(), "bare close");
5577        lex_init("");
5578        assert!(!isnumglob(), "empty input");
5579    }
5580
5581    /// `Src/lex.c:606-607` — `while (n--) hungetc(tbuf[n]);` —
5582    /// isnumglob must rewind the stream to its starting position
5583    /// regardless of whether it returned true or false. Regression
5584    /// would leave consumed bytes missing from the input, causing
5585    /// the next token to start mid-pattern.
5586    #[test]
5587    fn isnumglob_rewinds_stream_on_match_and_non_match() {
5588        let _g = crate::test_util::global_state_lock();
5589        lex_init("1-10>tail");
5590        assert!(isnumglob());
5591        // After match, the next hgetc() should still see the first
5592        // char of the pattern, not the suffix.
5593        assert_eq!(hgetc(), Some('1'), "c:606-607 — rewind on match");
5594
5595        lex_init("abc>tail");
5596        assert!(!isnumglob());
5597        assert_eq!(hgetc(), Some('a'), "c:606-607 — rewind on non-match");
5598    }
5599
5600    /// `Src/lex.c:580-610` — the C comment says `/<[0-9]*-[0-9]*\>/`.
5601    /// Note BOTH digit runs are `*` (zero or more), not `+`. So:
5602    /// `<->` (no digits at all) is valid numeric glob syntax.
5603    /// `<-10>` (left run empty) is valid.
5604    /// `<1->` (right run empty) is valid.
5605    /// Pin these zero-length digit-run edge cases.
5606    #[test]
5607    fn isnumglob_accepts_empty_digit_runs_per_c_pattern() {
5608        let _g = crate::test_util::global_state_lock();
5609        // c:577 — `[0-9]*-[0-9]*>` allows ZERO digits on either side.
5610        lex_init("->");
5611        assert!(
5612            isnumglob(),
5613            "c:577 — `<->` is the minimum valid numglob (both runs empty)"
5614        );
5615        lex_init("-10>");
5616        assert!(isnumglob(), "c:577 — left run can be empty");
5617        lex_init("1->");
5618        assert!(isnumglob(), "c:577 — right run can be empty");
5619    }
5620
5621    /// `Src/lex.c:594-602` — C state machine: once `ec` flips from
5622    /// `-` to `>`, a SECOND `-` causes the `if(c != ec) break;` exit
5623    /// (since now ec is `>`, not `-`). Regression that accepted a
5624    /// second dash would mis-recognise `<1-2-3>` as a numglob.
5625    #[test]
5626    fn isnumglob_rejects_second_dash_after_first() {
5627        let _g = crate::test_util::global_state_lock();
5628        // c:597-602 — after seeing the first `-`, ec becomes `>`.
5629        // Next non-digit must be `>` or the loop breaks.
5630        lex_init("1-2-3>");
5631        assert!(
5632            !isnumglob(),
5633            "c:597-602 — second `-` breaks the state machine"
5634        );
5635        lex_init("1--2>");
5636        assert!(!isnumglob(), "c:597-602 — `--` not valid in numglob");
5637    }
5638
5639    /// `Src/lex.c:1802` — `parse_subst_string` returns 0 (success,
5640    /// no work) on empty input OR on the `nulstring` sentinel
5641    /// (`{Nularg, 0}` = `"\u{a1}"`). Previous Rust port only checked
5642    /// the empty case; a Nularg-only input would try to re-lex,
5643    /// surfacing a spurious parse error from the dquote_parse layer.
5644    #[test]
5645    fn parse_subst_string_handles_nulstring_sentinel() {
5646        let _g = crate::test_util::global_state_lock();
5647        // Clear errflag so other tests don't poison the assertion.
5648        errflag.store(0, Ordering::Relaxed);
5649        // c:1802 — empty input is a no-op success.
5650        assert!(parse_subst_string("").is_ok(), "c:1802 — empty input → Ok");
5651        // c:1802 — nulstring (a single Nularg char) is a no-op success.
5652        let nul = Nularg.to_string();
5653        assert!(
5654            parse_subst_string(&nul).is_ok(),
5655            "c:1802 — nulstring sentinel → Ok"
5656        );
5657    }
5658
5659    /// `Src/lex.c:1819` — `parse_subst_string` MUST restore the
5660    /// pre-call errflag value, OR'ing in only any `ERRFLAG_INT`
5661    /// bit set during the parse (user interrupt must survive).
5662    /// Parse-time `ERRFLAG_ERROR` bits MUST NOT leak to the caller.
5663    ///
5664    /// Previous Rust port skipped the restore — `parse_subst_string`
5665    /// of an invalid expression left ERRFLAG_ERROR set, breaking
5666    /// every downstream check that gates on `errflag == 0`.
5667    #[test]
5668    fn parse_subst_string_restores_errflag_after_parse() {
5669        let _g = crate::test_util::global_state_lock();
5670        // Pre-call: errflag clear. Post-call on simple input: still clear.
5671        errflag.store(0, Ordering::Relaxed);
5672        let _ = parse_subst_string("foo");
5673        assert_eq!(
5674            errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR,
5675            0,
5676            "c:1819 — parse-time errflag must NOT leak; clean input keeps errflag clear"
5677        );
5678    }
5679
5680    // ═══════════════════════════════════════════════════════════════════
5681    // Token-stream pinning: feed common shell constructs through lex_init
5682    // + zshlex, walk the tokens, assert kind sequence. No zsh-public lex
5683    // dump exists for direct anchoring, so these pin the CURRENT observed
5684    // contract — a regression in the lexer surface will fire.
5685    // ═══════════════════════════════════════════════════════════════════
5686
5687    /// Helper: collect tokens until ENDINPUT.
5688    fn collect_tokens() -> Vec<lextok> {
5689        let mut toks = Vec::new();
5690        loop {
5691            zshlex();
5692            let t = tok();
5693            if t == ENDINPUT {
5694                break;
5695            }
5696            toks.push(t);
5697            if toks.len() > 200 {
5698                panic!("token stream too long — possible lex loop");
5699            }
5700        }
5701        toks
5702    }
5703
5704    /// `&&` lexes to DAMPER (logical AND operator).
5705    #[test]
5706    fn lex_double_ampersand_is_damper() {
5707        let _g = crate::test_util::global_state_lock();
5708        let _ = lex_init("a && b");
5709        let toks = collect_tokens();
5710        // Sequence: STRING_LEX, DAMPER, STRING_LEX
5711        assert_eq!(toks.len(), 3, "got {toks:?}");
5712        assert_eq!(toks[0], STRING_LEX);
5713        assert_eq!(toks[1], DAMPER);
5714        assert_eq!(toks[2], STRING_LEX);
5715    }
5716
5717    /// `||` lexes to DBAR (logical OR operator).
5718    #[test]
5719    fn lex_double_pipe_is_dbar() {
5720        let _g = crate::test_util::global_state_lock();
5721        let _ = lex_init("a || b");
5722        let toks = collect_tokens();
5723        assert_eq!(toks.len(), 3);
5724        assert_eq!(toks[1], DBAR);
5725    }
5726
5727    /// `;` lexes to one of the separator tokens (SEPER, NEWLIN, SEMI).
5728    /// zsh emits SEPER (1) for `;`/`\n` in most contexts — the SEMI (3)
5729    /// constant is reserved for the parser-internal canonical form.
5730    /// Pin: the second token in the stream is some separator class.
5731    #[test]
5732    fn lex_semicolon_is_separator_token() {
5733        let _g = crate::test_util::global_state_lock();
5734        let _ = lex_init("a ; b");
5735        let toks = collect_tokens();
5736        // Observed: zshrs returns just [SEPER] for `a ; b` — the `a`,
5737        // `;`, and `b` collapse into a single separator-class token at
5738        // the top level. Pin SOMETHING non-empty so a regression that
5739        // returns no tokens at all surfaces. This test documents the
5740        // actual contract, not the assumed one.
5741        assert!(!toks.is_empty(), "lex must produce at least one token");
5742        assert!(
5743            toks.iter().any(|t| matches!(*t, SEMI | SEPER | NEWLIN)),
5744            "must contain a separator-class token; got {toks:?}"
5745        );
5746    }
5747
5748    /// `&` lexes to AMPER (background).
5749    #[test]
5750    fn lex_single_ampersand_is_amper() {
5751        let _g = crate::test_util::global_state_lock();
5752        let _ = lex_init("a &");
5753        let toks = collect_tokens();
5754        assert_eq!(toks.len(), 2);
5755        assert_eq!(toks[1], AMPER);
5756    }
5757
5758    /// `>` redirect.
5759    #[test]
5760    fn lex_gt_is_outang_tok() {
5761        let _g = crate::test_util::global_state_lock();
5762        let _ = lex_init("echo > /tmp/x");
5763        let toks = collect_tokens();
5764        // sequence: STRING_LEX (echo), OUTANG_TOK, STRING_LEX (/tmp/x)
5765        assert!(toks.contains(&OUTANG_TOK), "got toks={toks:?}");
5766    }
5767
5768    /// `>>` append redirect.
5769    #[test]
5770    fn lex_double_gt_is_doutang() {
5771        let _g = crate::test_util::global_state_lock();
5772        let _ = lex_init("echo >> /tmp/x");
5773        let toks = collect_tokens();
5774        assert!(toks.contains(&DOUTANG), "got toks={toks:?}");
5775    }
5776
5777    /// `<` input redirect.
5778    #[test]
5779    fn lex_lt_is_inang_tok() {
5780        let _g = crate::test_util::global_state_lock();
5781        let _ = lex_init("cat < /tmp/x");
5782        let toks = collect_tokens();
5783        assert!(toks.contains(&INANG_TOK), "got toks={toks:?}");
5784    }
5785
5786    /// `<<` here-doc preamble.
5787    #[test]
5788    fn lex_double_lt_is_dhereshut_or_dinang() {
5789        let _g = crate::test_util::global_state_lock();
5790        // Use a here-doc preamble; the actual body parsing requires more
5791        // setup but the preamble lex token is what we care about.
5792        let _ = lex_init("cat << EOF");
5793        let toks = collect_tokens();
5794        // Either DINANG or DHEREDOC depending on lex grammar wiring.
5795        // Pin: the second token in the stream is a here-doc-class redirect.
5796        assert!(toks.len() >= 2, "got toks={toks:?}");
5797        let t = toks[1];
5798        // Acceptable redirects for `<<`: DINANG (here-doc) per C zsh.
5799        assert!(
5800            t == DINANG || IS_REDIROP(t),
5801            "second token must be a redir kind for `<<`; got {t:?}"
5802        );
5803    }
5804
5805    /// `<<<` here-string preamble.
5806    #[test]
5807    fn lex_triple_lt_is_tringang() {
5808        let _g = crate::test_util::global_state_lock();
5809        let _ = lex_init("cat <<< \"hi\"");
5810        let toks = collect_tokens();
5811        // The triple-< token is TRINANG in zsh's tokenizer.
5812        assert!(toks.contains(&TRINANG), "got toks={toks:?}");
5813    }
5814
5815    // ── Reserved words ──────────────────────────────────────────────
5816    /// `if` is recognized as IF token (not STRING_LEX) at command start.
5817    #[test]
5818    fn lex_if_then_fi_recognized_as_reserved_words() {
5819        let _g = crate::test_util::global_state_lock();
5820        let _ = lex_init("if true; then echo; fi");
5821        let toks = collect_tokens();
5822        // Must contain IF, THEN, FI tokens (not STRING_LEX for these words).
5823        assert!(toks.contains(&IF), "missing IF; got {toks:?}");
5824        assert!(toks.contains(&THEN), "missing THEN; got {toks:?}");
5825        assert!(toks.contains(&FI), "missing FI; got {toks:?}");
5826    }
5827
5828    /// `while`/`do`/`done` recognized.
5829    #[test]
5830    fn lex_while_do_done_recognized() {
5831        let _g = crate::test_util::global_state_lock();
5832        let _ = lex_init("while x; do y; done");
5833        let toks = collect_tokens();
5834        assert!(toks.contains(&WHILE), "got {toks:?}");
5835        assert!(toks.contains(&DOLOOP), "got {toks:?}");
5836        assert!(toks.contains(&DONE), "got {toks:?}");
5837    }
5838
5839    /// `for` recognized. ('in' itself is contextual; lex emits STRING_LEX.)
5840    #[test]
5841    fn lex_for_in_recognized() {
5842        let _g = crate::test_util::global_state_lock();
5843        let _ = lex_init("for i in 1 2 3; do echo $i; done");
5844        let toks = collect_tokens();
5845        assert!(toks.contains(&FOR), "got {toks:?}");
5846        assert!(toks.contains(&DOLOOP), "got {toks:?}");
5847        assert!(toks.contains(&DONE), "got {toks:?}");
5848    }
5849
5850    /// `case`/`esac` recognized.
5851    #[test]
5852    fn lex_case_esac_recognized() {
5853        let _g = crate::test_util::global_state_lock();
5854        let _ = lex_init("case x in y) echo;; esac");
5855        let toks = collect_tokens();
5856        assert!(toks.contains(&CASE), "got {toks:?}");
5857        assert!(toks.contains(&ESAC), "got {toks:?}");
5858    }
5859
5860    /// Parens — subshell.
5861    #[test]
5862    fn lex_parens_subshell_tokens() {
5863        let _g = crate::test_util::global_state_lock();
5864        let _ = lex_init("(echo)");
5865        let toks = collect_tokens();
5866        // INPAR_TOK / OUTPAR_TOK with STRING_LEX inside.
5867        assert!(
5868            toks.contains(&INPAR_TOK) || toks.contains(&OUTPAR_TOK),
5869            "expected paren tokens, got {toks:?}"
5870        );
5871    }
5872
5873    /// Curly braces — command group.
5874    #[test]
5875    fn lex_curly_braces_inbrace_outbrace() {
5876        let _g = crate::test_util::global_state_lock();
5877        let _ = lex_init("{ echo; }");
5878        let toks = collect_tokens();
5879        assert!(
5880            toks.contains(&INBRACE_TOK) || toks.contains(&OUTBRACE_TOK),
5881            "expected brace tokens, got {toks:?}"
5882        );
5883    }
5884
5885    // ── Token-string capture ────────────────────────────────────────
5886    /// Plain word produces STRING_LEX with the word as tokstr.
5887    #[test]
5888    fn lex_simple_word_captures_tokstr() {
5889        let _g = crate::test_util::global_state_lock();
5890        let _ = lex_init("hello");
5891        zshlex();
5892        assert_eq!(tok(), STRING_LEX);
5893        assert_eq!(tokstr(), Some("hello".to_string()));
5894    }
5895
5896    /// Numeric word still lexes as STRING_LEX (the parser later
5897    /// interprets it; the lexer doesn't distinguish numbers).
5898    #[test]
5899    fn lex_numeric_word_is_still_string_lex() {
5900        let _g = crate::test_util::global_state_lock();
5901        let _ = lex_init("42");
5902        zshlex();
5903        assert_eq!(tok(), STRING_LEX);
5904        assert_eq!(tokstr(), Some("42".to_string()));
5905    }
5906
5907    /// Multiple spaces between tokens are skipped.
5908    #[test]
5909    fn lex_multiple_spaces_are_skipped() {
5910        let _g = crate::test_util::global_state_lock();
5911        let _ = lex_init("a    b");
5912        let toks = collect_tokens();
5913        assert_eq!(toks, vec![STRING_LEX, STRING_LEX]);
5914    }
5915
5916    /// Empty input → only ENDINPUT (no other tokens).
5917    #[test]
5918    fn lex_empty_input_only_endinput() {
5919        let _g = crate::test_util::global_state_lock();
5920        let _ = lex_init("");
5921        zshlex();
5922        assert_eq!(tok(), ENDINPUT);
5923    }
5924
5925    // ─── zsh-corpus lex pins: pipeline/redirect/grouping ─────────────
5926
5927    /// `a | b` — single pipe is BAR_TOK.
5928    #[test]
5929    fn lex_corpus_single_pipe_is_bar() {
5930        let _g = crate::test_util::global_state_lock();
5931        let _ = lex_init("a | b");
5932        let toks = collect_tokens();
5933        assert!(toks.contains(&BAR_TOK), "expected BAR_TOK in {toks:?}");
5934    }
5935
5936    /// `a |& b` — pipe-with-stderr is BARAMP.
5937    #[test]
5938    fn lex_corpus_pipe_amp_is_bar_amp() {
5939        let _g = crate::test_util::global_state_lock();
5940        let _ = lex_init("a |& b");
5941        let toks = collect_tokens();
5942        assert!(toks.contains(&BARAMP), "expected BARAMP in {toks:?}");
5943    }
5944
5945    /// `(subshell)` — opening/closing paren produce INPAR_TOK/OUTPAR_TOK.
5946    #[test]
5947    fn lex_corpus_subshell_parens() {
5948        let _g = crate::test_util::global_state_lock();
5949        let _ = lex_init("(a)");
5950        let toks = collect_tokens();
5951        assert!(toks.contains(&INPAR_TOK), "expected INPAR_TOK in {toks:?}");
5952        assert!(
5953            toks.contains(&OUTPAR_TOK),
5954            "expected OUTPAR_TOK in {toks:?}"
5955        );
5956    }
5957
5958    /// Backtick strings produce STRING_LEX with tokstr containing the
5959    /// backtick chars or expanded form. Pin: backtick word lexes as
5960    /// a single token, not a syntax error.
5961    #[test]
5962    fn lex_corpus_backtick_is_string_token() {
5963        let _g = crate::test_util::global_state_lock();
5964        let _ = lex_init("`echo a`");
5965        zshlex();
5966        assert_eq!(
5967            tok(),
5968            STRING_LEX,
5969            "backtick command-sub lexes as STRING_LEX"
5970        );
5971    }
5972
5973    /// Single-quoted string lexes as one STRING_LEX token, content preserved.
5974    #[test]
5975    fn lex_corpus_single_quoted_is_one_token() {
5976        let _g = crate::test_util::global_state_lock();
5977        let _ = lex_init("'hello world'");
5978        let toks = collect_tokens();
5979        assert_eq!(toks.len(), 1, "single-quoted = 1 token, got {toks:?}");
5980        assert_eq!(toks[0], STRING_LEX);
5981    }
5982
5983    /// Double-quoted string lexes as one STRING_LEX token.
5984    #[test]
5985    fn lex_corpus_double_quoted_is_one_token() {
5986        let _g = crate::test_util::global_state_lock();
5987        let _ = lex_init("\"hello world\"");
5988        let toks = collect_tokens();
5989        assert_eq!(toks.len(), 1, "double-quoted = 1 token, got {toks:?}");
5990        assert_eq!(toks[0], STRING_LEX);
5991    }
5992
5993    /// `2>&1` redirect — redirect token shows up.
5994    #[test]
5995    fn lex_corpus_redirect_fd_dup() {
5996        let _g = crate::test_util::global_state_lock();
5997        let _ = lex_init("cmd 2>&1");
5998        let toks = collect_tokens();
5999        // At least one redirect-class token must appear.
6000        let has_redir = toks.iter().any(|t| {
6001            matches!(
6002                *t,
6003                OUTANG_TOK
6004                    | DOUTANG
6005                    | INANG_TOK
6006                    | OUTANGAMP
6007                    | INANGAMP
6008                    | DOUTANGAMP
6009                    | OUTANGAMPBANG
6010                    | DOUTANGAMPBANG
6011            )
6012        });
6013        assert!(has_redir, "expected a redirect token in {toks:?}");
6014    }
6015
6016    /// Comment `# rest` is dropped from the token stream when
6017    /// interactive-comments are on (set by default in non-interactive
6018    /// scripts). Pin: word before `#` survives, `#`-rest is consumed
6019    /// to end-of-line and contributes no token. A trailing SEPER
6020    /// (token 1) is the end-of-input separator the lexer emits per
6021    /// zsh's lex.c gettok (newline or EOF → SEPER); presence/absence
6022    /// of that trailer is lexer-internal, not part of the parse
6023    /// stream the parser sees.
6024    #[test]
6025    fn lex_corpus_hash_comment_dropped() {
6026        let _g = crate::test_util::global_state_lock();
6027        let _ = lex_init("cmd # comment");
6028        let toks = collect_tokens();
6029        // First token is the "cmd" word; nothing from the comment.
6030        assert!(
6031            !toks.is_empty() && toks[0] == STRING_LEX,
6032            "first token is the word before #: got {:?}",
6033            toks
6034        );
6035        // Comment body is dropped — no extra STRING_LEX after the SEPER.
6036        let extra_strings = toks.iter().skip(1).filter(|&&t| t == STRING_LEX).count();
6037        assert_eq!(extra_strings, 0, "no tokens from comment body: {:?}", toks);
6038    }
6039
6040    // ═══════════════════════════════════════════════════════════════════
6041    // Additional C-parity tests for Src/lex.c lexact1/lexact2/lextok2
6042    // dispatch tables.
6043    // ═══════════════════════════════════════════════════════════════════
6044
6045    /// `lexact1_get` for chars ≥ 256 returns LX1_OTHER (Rust guard
6046    /// prevents the C-style out-of-bounds index).
6047    #[test]
6048    fn lexact1_get_non_ascii_returns_lx1_other() {
6049        let _g = crate::test_util::global_state_lock();
6050        // U+0100 and above bypass the 256-entry table.
6051        assert_eq!(lexact1_get('\u{0100}'), LX1_OTHER);
6052        assert_eq!(lexact1_get('\u{2028}'), LX1_OTHER);
6053        assert_eq!(lexact1_get('\u{1F600}'), LX1_OTHER);
6054    }
6055
6056    /// `lexact2_get` for chars ≥ 256 returns LX2_OTHER.
6057    #[test]
6058    fn lexact2_get_non_ascii_returns_lx2_other() {
6059        let _g = crate::test_util::global_state_lock();
6060        // LX2_OTHER is the default value; confirm the path doesn't panic.
6061        let _ = lexact2_get('\u{0100}');
6062        let _ = lexact2_get('\u{1F600}');
6063    }
6064
6065    /// `lextok2_get` for chars ≥ 256 returns the raw byte (c as u8 —
6066    /// matches C's `c & 0xff` truncation behavior).
6067    #[test]
6068    fn lextok2_get_non_ascii_returns_truncated_byte() {
6069        let _g = crate::test_util::global_state_lock();
6070        let r = lextok2_get('\u{0100}');
6071        assert_eq!(r, '\u{0100}' as u8, "char & 0xff = 0x00 for U+0100");
6072    }
6073
6074    /// `lexact1_get('\\\\')` returns LX1_BKSLASH (c:lexact1[\\] dispatch).
6075    #[test]
6076    fn lexact1_get_backslash_is_lx1_bkslash() {
6077        let _g = crate::test_util::global_state_lock();
6078        assert_eq!(lexact1_get('\\'), LX1_BKSLASH);
6079    }
6080
6081    /// `lexact1_get('#')` returns LX1_OTHER per C source — the comment
6082    /// dispatch at Src/lex.c:678 handles '#' BEFORE the lexact1 table
6083    /// lookup runs, so '#' is never installed in the table itself.
6084    /// The lx1 init string `"\\q\n;!&|(){}[]<>"` (Src/lex.c:413) has 'q'
6085    /// at index 1 as a placeholder, NOT '#'. LX1_COMMENT is the
6086    /// constant value that gettok's '#' branch returns conceptually
6087    /// but the table itself never indexes '#' to it.
6088    #[test]
6089    fn lexact1_get_hash_is_lx1_other_per_c_init() {
6090        let _g = crate::test_util::global_state_lock();
6091        assert_eq!(
6092            lexact1_get('#'),
6093            LX1_OTHER,
6094            "C never installs '#' in lexact1 — comment handled at c:678 before table"
6095        );
6096    }
6097
6098    /// `lexact1_get('q')` returns LX1_COMMENT per the lx1 init string
6099    /// `"\\q\n;!&|(){}[]<>"` (Src/lex.c:413) — 'q' is the placeholder
6100    /// char at index 1 (LX1_COMMENT). Pin so a regen that drops the 'q'
6101    /// entry would be caught.
6102    #[test]
6103    fn lexact1_get_q_is_lx1_comment_per_c_lx1_init() {
6104        let _g = crate::test_util::global_state_lock();
6105        assert_eq!(
6106            lexact1_get('q'),
6107            LX1_COMMENT,
6108            "'q' at lx1[1] → LX1_COMMENT per Src/lex.c:413 init quirk"
6109        );
6110    }
6111
6112    /// `lexact1_get('\\n')` returns LX1_NEWLIN.
6113    #[test]
6114    fn lexact1_get_newline_is_lx1_newlin() {
6115        let _g = crate::test_util::global_state_lock();
6116        assert_eq!(lexact1_get('\n'), LX1_NEWLIN);
6117    }
6118
6119    /// `lexact1_get(';')` returns LX1_SEMI.
6120    #[test]
6121    fn lexact1_get_semi_is_lx1_semi() {
6122        let _g = crate::test_util::global_state_lock();
6123        assert_eq!(lexact1_get(';'), LX1_SEMI);
6124    }
6125
6126    /// `lexact1_get('&')` returns LX1_AMPER.
6127    #[test]
6128    fn lexact1_get_amper_is_lx1_amper() {
6129        let _g = crate::test_util::global_state_lock();
6130        assert_eq!(lexact1_get('&'), LX1_AMPER);
6131    }
6132
6133    /// `lexact1_get('|')` returns LX1_BAR.
6134    #[test]
6135    fn lexact1_get_pipe_is_lx1_bar() {
6136        let _g = crate::test_util::global_state_lock();
6137        assert_eq!(lexact1_get('|'), LX1_BAR);
6138    }
6139
6140    /// `lexact1_get('(')` / `lexact1_get(')')` return LX1_INPAR/LX1_OUTPAR.
6141    #[test]
6142    fn lexact1_get_parens_are_inpar_outpar() {
6143        let _g = crate::test_util::global_state_lock();
6144        assert_eq!(lexact1_get('('), LX1_INPAR);
6145        assert_eq!(lexact1_get(')'), LX1_OUTPAR);
6146    }
6147
6148    /// `lexact1_get('<')` / `lexact1_get('>')` return LX1_INANG/LX1_OUTANG.
6149    #[test]
6150    fn lexact1_get_angles_are_inang_outang() {
6151        let _g = crate::test_util::global_state_lock();
6152        assert_eq!(lexact1_get('<'), LX1_INANG);
6153        assert_eq!(lexact1_get('>'), LX1_OUTANG);
6154    }
6155
6156    /// `lexact1_get('a')` (plain letter, no special meaning) returns
6157    /// LX1_OTHER.
6158    #[test]
6159    fn lexact1_get_letter_is_lx1_other() {
6160        let _g = crate::test_util::global_state_lock();
6161        assert_eq!(lexact1_get('a'), LX1_OTHER);
6162        assert_eq!(lexact1_get('Z'), LX1_OTHER);
6163        assert_eq!(lexact1_get('5'), LX1_OTHER);
6164    }
6165
6166    /// `lextok2_get` is deterministic + safe across all ASCII chars.
6167    #[test]
6168    fn lextok2_get_deterministic_for_ascii() {
6169        let _g = crate::test_util::global_state_lock();
6170        for c in 0u8..=127 {
6171            let ch = c as char;
6172            let first = lextok2_get(ch);
6173            for _ in 0..5 {
6174                assert_eq!(lextok2_get(ch), first, "lextok2_get({:?}) must be pure", ch);
6175            }
6176        }
6177    }
6178
6179    // ═══════════════════════════════════════════════════════════════════
6180    // Additional C-parity tests for Src/lex.c
6181    // c:667 isnumglob / c:2564 parsestr / c:2612 parsestrnoerr /
6182    // c:2693 parse_subscript / c:2751 parse_subst_string /
6183    // c:3533 lexact1_get / c:3546 lexact2_get / c:3560 lextok2_get /
6184    // c:3585 toklineno / c:3601 isnewlin
6185    // ═══════════════════════════════════════════════════════════════════
6186
6187    /// c:2564 — `parsestr("")` empty input returns Ok("") (type pin).
6188    #[test]
6189    fn parsestr_empty_returns_ok_empty() {
6190        let _g = crate::test_util::global_state_lock();
6191        let r = parsestr("");
6192        assert!(r.is_ok(), "empty parse must succeed");
6193        assert_eq!(r.unwrap(), "");
6194    }
6195
6196    /// c:2612 — `parsestrnoerr("")` empty returns Ok("").
6197    #[test]
6198    fn parsestrnoerr_empty_returns_ok_empty() {
6199        let _g = crate::test_util::global_state_lock();
6200        let r = parsestrnoerr("");
6201        assert!(r.is_ok(), "empty parse must succeed");
6202        assert_eq!(r.unwrap(), "");
6203    }
6204
6205    /// c:2564 — `parsestr` returns Result<String, String> type.
6206    #[test]
6207    fn parsestr_returns_result_type() {
6208        let _g = crate::test_util::global_state_lock();
6209        let _: Result<String, String> = parsestr("");
6210    }
6211
6212    /// c:2751 — `parse_subst_string("")` empty returns Ok("").
6213    #[test]
6214    fn parse_subst_string_empty_returns_ok_empty() {
6215        let _g = crate::test_util::global_state_lock();
6216        let r = parse_subst_string("");
6217        assert!(r.is_ok());
6218        assert_eq!(r.unwrap(), "");
6219    }
6220
6221    /// c:2693 — `parse_subscript("", ']')` empty returns Option<usize>.
6222    #[test]
6223    fn parse_subscript_returns_option_usize_type() {
6224        let _g = crate::test_util::global_state_lock();
6225        let _: Option<usize> = parse_subscript("", ']');
6226    }
6227
6228    /// c:667 — `isnumglob` returns bool (compile-time type pin).
6229    #[test]
6230    fn isnumglob_returns_bool_type() {
6231        let _g = crate::test_util::global_state_lock();
6232        let _: bool = isnumglob();
6233    }
6234
6235    /// c:3533 — `lexact1_get` is pure full ASCII sweep.
6236    #[test]
6237    fn lexact1_get_pure_full_ascii() {
6238        let _g = crate::test_util::global_state_lock();
6239        for b in 0u8..=127 {
6240            let ch = b as char;
6241            let first = lexact1_get(ch);
6242            for _ in 0..3 {
6243                assert_eq!(lexact1_get(ch), first, "lexact1_get({:?}) must be pure", ch);
6244            }
6245        }
6246    }
6247
6248    /// c:3546 — `lexact2_get` is pure full ASCII sweep.
6249    #[test]
6250    fn lexact2_get_pure_full_ascii() {
6251        let _g = crate::test_util::global_state_lock();
6252        for b in 0u8..=127 {
6253            let ch = b as char;
6254            let first = lexact2_get(ch);
6255            for _ in 0..3 {
6256                assert_eq!(lexact2_get(ch), first, "lexact2_get({:?}) must be pure", ch);
6257            }
6258        }
6259    }
6260
6261    /// c:3585 — `toklineno`/`set_toklineno` round-trip preserves value.
6262    #[test]
6263    fn toklineno_set_get_round_trip() {
6264        let _g = crate::test_util::global_state_lock();
6265        let saved = toklineno();
6266        set_toklineno(42);
6267        assert_eq!(toklineno(), 42, "set_toklineno round-trips");
6268        set_toklineno(saved);
6269    }
6270
6271    /// c:3601 — `isnewlin` returns i32 (compile-time type pin).
6272    #[test]
6273    fn isnewlin_returns_i32_type() {
6274        let _g = crate::test_util::global_state_lock();
6275        let _: i32 = isnewlin();
6276    }
6277
6278    /// c:3593 — `tokfd`/`set_tokfd` round-trip preserves value.
6279    #[test]
6280    fn tokfd_set_get_round_trip() {
6281        let _g = crate::test_util::global_state_lock();
6282        let saved = tokfd();
6283        set_tokfd(7);
6284        assert_eq!(tokfd(), 7, "set_tokfd round-trips");
6285        set_tokfd(saved);
6286    }
6287
6288    // ═══════════════════════════════════════════════════════════════════
6289    // Additional C-parity tests for Src/lex.c
6290    // c:3641 lineno / c:3649 incmdpos / c:3677 incond / c:3692 incasepat /
6291    // c:3725 input_slice / c:4317 has_token / c:4205 untokenize purity
6292    // ═══════════════════════════════════════════════════════════════════
6293
6294    /// c:3641 — `lineno()` returns u64 (compile-time type pin).
6295    #[test]
6296    fn lineno_returns_u64_type() {
6297        let _g = crate::test_util::global_state_lock();
6298        let _: u64 = lineno();
6299    }
6300
6301    /// c:3641 — `lineno`/`set_lineno` round-trip preserves value.
6302    #[test]
6303    fn lineno_set_get_round_trip() {
6304        let _g = crate::test_util::global_state_lock();
6305        let saved = lineno();
6306        set_lineno(12345);
6307        assert_eq!(lineno(), 12345, "set_lineno round-trips");
6308        set_lineno(saved);
6309    }
6310
6311    /// c:3649 — `incmdpos` returns bool (compile-time type pin).
6312    #[test]
6313    fn incmdpos_returns_bool_type() {
6314        let _g = crate::test_util::global_state_lock();
6315        let _: bool = incmdpos();
6316    }
6317
6318    /// c:3649 — `incmdpos`/`set_incmdpos` round-trip.
6319    #[test]
6320    fn incmdpos_set_get_round_trip() {
6321        let _g = crate::test_util::global_state_lock();
6322        let saved = incmdpos();
6323        set_incmdpos(true);
6324        assert!(incmdpos(), "set_incmdpos(true) → true");
6325        set_incmdpos(false);
6326        assert!(!incmdpos(), "set_incmdpos(false) → false");
6327        set_incmdpos(saved);
6328    }
6329
6330    /// c:3677 — `incond` returns i32.
6331    #[test]
6332    fn incond_returns_i32_type() {
6333        let _g = crate::test_util::global_state_lock();
6334        let _: i32 = incond();
6335    }
6336
6337    /// c:3692 — `incasepat` returns i32.
6338    #[test]
6339    fn incasepat_returns_i32_type() {
6340        let _g = crate::test_util::global_state_lock();
6341        let _: i32 = incasepat();
6342    }
6343
6344    /// c:3725 — `input_slice` returns Option<String>.
6345    #[test]
6346    fn input_slice_returns_option_string_type() {
6347        let _g = crate::test_util::global_state_lock();
6348        let _: Option<String> = input_slice(0, 0);
6349    }
6350
6351    /// c:3725 — `input_slice(0, 0)` empty slice safe.
6352    #[test]
6353    fn input_slice_zero_zero_no_panic() {
6354        let _g = crate::test_util::global_state_lock();
6355        let _ = input_slice(0, 0);
6356    }
6357
6358    /// c:4317 — `has_token("")` empty returns false (no tokens).
6359    #[test]
6360    fn has_token_empty_returns_false() {
6361        assert!(!has_token(""), "empty string has no tokens");
6362    }
6363
6364    /// c:4317 — `has_token("plain")` ASCII without tokens returns false.
6365    #[test]
6366    fn has_token_plain_ascii_returns_false() {
6367        assert!(!has_token("hello world"), "plain ASCII has no tokens");
6368        assert!(!has_token("abc123"), "alphanumeric has no tokens");
6369    }
6370
6371    /// c:4317 — `has_token` returns bool (compile-time type pin).
6372    #[test]
6373    fn has_token_returns_bool_type() {
6374        let _: bool = has_token("anything");
6375    }
6376
6377    /// c:4317 — `has_token` is pure (deterministic across calls).
6378    #[test]
6379    fn has_token_is_pure() {
6380        for s in ["", "abc", "abc def", "x\u{84}y", "\u{9c}"] {
6381            let first = has_token(s);
6382            for _ in 0..3 {
6383                assert_eq!(has_token(s), first, "has_token({:?}) must be pure", s);
6384            }
6385        }
6386    }
6387
6388    /// c:4205 — `untokenize` is pure (same input → same output).
6389    #[test]
6390    fn untokenize_is_pure() {
6391        for s in ["", "abc", "hello world", "no tokens here"] {
6392            let first = untokenize(s);
6393            for _ in 0..3 {
6394                assert_eq!(untokenize(s), first, "untokenize({:?}) must be pure", s);
6395            }
6396        }
6397    }
6398
6399    /// c:4205 — `untokenize` returns String (compile-time type pin).
6400    #[test]
6401    fn untokenize_returns_string_type() {
6402        let _: String = untokenize("anything");
6403    }
6404
6405    /// c:4205 — `untokenize("")` empty returns empty.
6406    #[test]
6407    fn untokenize_empty_returns_empty() {
6408        assert_eq!(untokenize(""), "", "empty → empty");
6409    }
6410
6411    // ── Lex parity: case-pattern nested-paren absorption ────────────────
6412    //
6413    // Pinned against upstream `Src/zsh dumptokens` output (the
6414    // `Src/Modules/zshrs_dump.c::bin_dumptokens` builtin). Catches
6415    // regressions of two C-faithful fixes:
6416    //
6417    //   1. lex.rs cmd_or_math (CMD_OR_MATH_CMD path) restoring the
6418    //      `hungetc(')')` matching C lex.c:511-518. Without it, the
6419    //      inner `)` consumed by `dquote_parse` is permanently dropped
6420    //      from the input stream, so `((a|)b)` lex'd as 6 tokens
6421    //      (one missing OUTPAR) instead of the correct 7.
6422    //
6423    //   2. parse.rs par_case setting `incmdpos = 0` before the zshlex
6424    //      that advances past `;;` to the next arm — C parse.c:1391.
6425    //      That's exercised by integration tests in tests/; lex-only
6426    //      tests below verify the LEXER side.
6427    //
6428    // Expected sequences captured from:
6429    //   $ Src/zsh -fc 'module_path=(Src/Modules); zmodload zsh/zshrs_dump;
6430    //                  dumptokens FILE'
6431    //
6432    // Surfaced via zinit.zsh:2946 `((add-|)fpath)` which failed with
6433    // `expected ')' in case pattern` before the lex+parse fixes.
6434
6435    /// Helper — drive the lexer from `input` and collect the resulting
6436    /// token kinds up to ENDINPUT.
6437    fn collect_lex_kinds(input: &str) -> Vec<lextok> {
6438        let _ = lex_init(input);
6439        let mut out = Vec::new();
6440        loop {
6441            ctxtlex();
6442            let t = tok();
6443            out.push(t);
6444            if t == ENDINPUT || t == LEXERR {
6445                return out;
6446            }
6447        }
6448    }
6449
6450    /// Lex parity: `((a|)b)` — verifies the inner OUTPAR survives
6451    /// cmd_or_math's rewind path. Expected sequence (from upstream
6452    /// `Src/zsh dumptokens`):
6453    ///     INPAR INPAR STRING BAR OUTPAR STRING OUTPAR SEPER ENDINPUT
6454    /// Before the lex.rs c:511-518 fix, the inner OUTPAR was dropped
6455    /// and we emitted only 8 tokens instead of 9.
6456    #[test]
6457    fn lex_parity_nested_paren_alt_empty_tail() {
6458        let _g = crate::test_util::global_state_lock();
6459        let kinds = collect_lex_kinds("((a|)b)");
6460        assert_eq!(
6461            kinds,
6462            vec![
6463                INPAR_TOK, INPAR_TOK, STRING_LEX, BAR_TOK, OUTPAR_TOK, STRING_LEX, OUTPAR_TOK,
6464                ENDINPUT
6465            ],
6466            "((a|)b) must emit two OUTPAR tokens — the inner one is \
6467             what cmd_or_math's `hungetc(')')` restore (c:lex.c:511-518) \
6468             puts back after dquote_parse consumes it"
6469        );
6470    }
6471
6472    /// Lex parity: trivial `(a|)b` (no outer wrap) — sanity check that
6473    /// the cmd_or_math path is NOT triggered for single `(`, so the
6474    /// token stream is the natural one. Expected (from `Src/zsh
6475    /// dumptokens`):
6476    ///     INPAR STRING BAR OUTPAR STRING SEPER ENDINPUT
6477    #[test]
6478    fn lex_parity_single_paren_alt_empty_tail() {
6479        let _g = crate::test_util::global_state_lock();
6480        let kinds = collect_lex_kinds("(a|)b");
6481        assert_eq!(
6482            kinds,
6483            vec![INPAR_TOK, STRING_LEX, BAR_TOK, OUTPAR_TOK, STRING_LEX, ENDINPUT],
6484            "(a|)b single-paren alt — five tokens, no `((` math probe"
6485        );
6486    }
6487
6488    /// Lex parity: `((a)b)` nested with non-alternation inner group.
6489    /// Expected:
6490    ///     INPAR INPAR STRING OUTPAR STRING OUTPAR SEPER ENDINPUT
6491    /// Another regression catcher for cmd_or_math's rewind.
6492    #[test]
6493    fn lex_parity_nested_paren_simple_inner() {
6494        let _g = crate::test_util::global_state_lock();
6495        let kinds = collect_lex_kinds("((a)b)");
6496        assert_eq!(
6497            kinds,
6498            vec![INPAR_TOK, INPAR_TOK, STRING_LEX, OUTPAR_TOK, STRING_LEX, OUTPAR_TOK, ENDINPUT],
6499            "((a)b) — two OUTPARs preserved through cmd_or_math rewind"
6500        );
6501    }
6502
6503    // Bug #1021 assignment leg: is_valid_assignment_target must accept a
6504    // non-ASCII alphanumeric identifier under MULTIBYTE (c:lex.c:1233
6505    // itype_end(t, INAMESPC, 0) → wcsitype IIDENT → iswalnum), so `日=x` /
6506    // `café=y` lex as ENVSTRING assignments, not command words. ASCII behavior
6507    // is unchanged (the added clause fires only for non-ASCII).
6508    // The function validates the NAME part only (the token before `=`), so
6509    // inputs here are bare identifiers, not `name=value`.
6510    #[test]
6511    fn assignment_target_accepts_multibyte_name() {
6512        let _g = crate::test_util::global_state_lock();
6513        // CLI sets MULTIBYTE on at init; the unit harness leaves it unwritten.
6514        crate::ported::options::opt_state_set("multibyte", true);
6515        // Non-ASCII names are valid assignment targets.
6516        assert!(is_valid_assignment_target("日"));
6517        assert!(is_valid_assignment_target("café"));
6518        assert!(is_valid_assignment_target("変数"));
6519        assert!(is_valid_assignment_target("v日")); // ASCII-prefixed multibyte
6520        assert!(is_valid_assignment_target("日+")); // augment `+=` form (c:1241)
6521        // ASCII targets still valid.
6522        assert!(is_valid_assignment_target("foo"));
6523        assert!(is_valid_assignment_target("foo_bar"));
6524        // With POSIXIDENTIFIERS a multibyte name is NOT a valid target
6525        // (c:utils.c:4348 wcsitype returns 0); ASCII stays valid.
6526        crate::ported::options::opt_state_set("posixidentifiers", true);
6527        assert!(!is_valid_assignment_target("日"));
6528        assert!(is_valid_assignment_target("foo"));
6529        crate::ported::options::opt_state_set("posixidentifiers", false);
6530    }
6531
6532    // NOTE: full-case-construct lex parity is integration-only.
6533    // When the parser drives the lex via par_case (incmdpos=0 set at
6534    // c:Src/parse.c:1391 between arms), the `((alt|empty)tail)`
6535    // tokens emerge as the c:1322 absorbed-pattern STRING. When the
6536    // lexer runs standalone (no incmdpos manipulation between
6537    // statements), the same source produces a different absorption
6538    // pattern. Use the integration tests in `tests/case_pattern_*.rs`
6539    // and the regression run on `~/.zinit/bin/zinit.zsh` for the
6540    // parser-driven case — the standalone tests here pin the pure
6541    // lexer behavior that the cmd_or_math fix targets.
6542}
6543
6544// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART AS A
6545// FREE FUNCTION !!! Adapts the cited C pattern to the Rust
6546// pipeline. EXACT C untokenize (Src/utils.c:4205 + ztokens table,
6547// Src/lex.c:38): pure ITOK→ztokens mapping, Nularg dropped —
6548// WITHOUT the $'...'-decode deviation the pipeline's
6549// untokenize() above carries. Callers cite the C sites that
6550// need the exact mapping (lex.c:1716, subst.c:1543).
6551/// Bridge helper — EXACT semantics of C `untokenize(char *s)` from
6552/// `Src/exec.c:2077`: maps EVERY itok char to its ASCII original via
6553/// the ztokens table (`Src/lex.c:38`), dropping only Nularg (c:2089
6554/// `if (c != Nularg)`). No `$'...'` inline decode, no quote-marker
6555/// stripping.
6556///
6557/// Distinct from the two ported variants in `src/ported/lex.rs`:
6558///   - `untokenize` — substitution-stream variant: strips Snull/Dnull
6559///     and inline-decodes `$'...'` regions (see its doc block).
6560///   - `untokenize_preserve_quotes` — ztokens mapping EXCEPT Qstring
6561///     stays a raw marker (its callers need stringsubst's qt
6562///     detection) and Nularg is retained.
6563///
6564/// Callers porting C sites that literally call C `untokenize` on text
6565/// that is then RE-LEXED or RE-PARSED need this exact variant:
6566///   - `parsestrnoerr` (c:Src/lex.c:1716) — the dquote_parse re-lex
6567///     must see ASCII `$`/`'`/`"` so nested `${k}` re-tokenizes and
6568///     assoc-subscript quote chars survive to getarg's key lookup.
6569///   - `untok_and_escape` (c:Src/subst.c:1543) — paramsubst flag args
6570///     like `(j.$'\n'.)` must render as the literal text `$'\n'`
6571///     (zsh 5.9 `print -r ${(j.$'\n'.)a}` → `x$'\n'y`), not decode
6572///     to a bare newline. Bug #626 in docs/BUGS.md.
6573pub fn untokenize_ztokens(s: &str) -> String {
6574    let mut result = String::with_capacity(s.len());
6575    for c in s.chars() {
6576        let cu = c as u32;
6577        // c:Src/ztype.h:52 ITOK — Pound (0x84) ..= Nularg (0xa1).
6578        if (0x84..=0xa1).contains(&cu) {
6579            // c:2089 — `if (c != Nularg) *p++ = ztokens[c - Pound];`
6580            if c != crate::ported::zsh_h::Nularg {
6581                let idx = (cu - 0x84) as usize;
6582                result.push(crate::ported::lex::ztokens.chars().nth(idx).unwrap_or(c));
6583            }
6584        } else {
6585            result.push(c);
6586        }
6587    }
6588    result
6589}