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