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