Skip to main content

zsh/ported/
hist.rs

1//! hist.c - history mechanism
2//!
3//! Port of Src/hist.c
4//!
5//! The history lines are kept in a hash, and also doubly-linked in a ring.   // c:98
6
7use crate::ported::glob::{getmatch, remnulargs};
8use crate::ported::hashtable::addhistnode;
9use crate::ported::input::{ingetc, inputsetline, inungetc};
10use crate::ported::lex::{
11    lexinit, parse_subst_string, untokenize, LEX_ISFIRSTCH, LEX_LEXSTOP,
12};
13use crate::ported::options::dosetopt;
14use crate::ported::parse::init_parse_status;
15use crate::ported::signals::unqueue_signals;
16use crate::ported::subst::{equalsubstr, singsub};
17use crate::ported::utils::{errflag, zerr, zmonotime, zsleep_random, ERRFLAG_ERROR};
18use crate::ported::zle::compcore::ZLEMETACS;
19use crate::ported::zsh_h::{
20    hashnode, hist_stack, histent, isset, Pound, BANGHIST, CASMOD_CAPS, CASMOD_LOWER, CASMOD_NONE,
21    CASMOD_UPPER, CSHJUNKIEHISTORY, ERRFLAG_INT, HFILE_FAST, HFILE_USE_OPTIONS,
22    HISTEXPIREDUPSFIRST, HISTFLAG_DONE, HISTFLAG_NOEXEC, HISTFLAG_RECALL, HISTFLAG_SETTY,
23    HISTIGNOREALLDUPS, HISTIGNOREDUPS, HISTIGNORESPACE, HISTNOFUNCTIONS, HISTNOSTORE,
24    HISTREDUCEBLANKS, HISTSUBSTPATTERN, HISTVERIFY, HIST_DUP, HIST_FOREIGN, HIST_NOWRITE, HIST_OLD,
25    HIST_TMPSTORE, INCAPPENDHISTORY, INCAPPENDHISTORYTIME, INP_ALIAS, INP_HIST, INTERACTIVE,
26    SHAREHISTORY, SHINSTDIN, SUB_END, SUB_GLOBAL, SUB_LONG, SUB_REST, SUB_RETFAIL, SUB_START,
27    SUB_SUBSTR,
28};
29use crate::ported::zsh_system_h::IS_DIRSEP;
30use crate::ported::ztype_h::itok;
31use crate::signals::queue_signals;
32use crate::{DPUTS, DPUTS1};
33use std::io::Write;
34use std::os::unix::io::AsRawFd;
35use std::path::Component::*;
36use std::sync::atomic::Ordering::SeqCst;
37use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, AtomicU32, AtomicUsize, Ordering};
38use std::sync::Mutex;
39
40// NOTE: `inbufflags` and `inbufct` are NOT imported because the
41// hist.rs body uses `let inbufflags = ...` shadowing — Rust treats
42// imported names as constant-patterns in `let` LHS, breaking that
43// idiom. They stay as `crate::ported::input::inbufflags` until
44// the shadowing pattern is refactored.
45
46// Bits of histactive variable                                               // c:137
47/// Port of `HA_ACTIVE` from Src/hist.c:138. History mechanism is active.
48pub const HA_ACTIVE: u32 = 1 << 0; // c:138
49/// Port of `HA_NOINC` from Src/hist.c:139. Don't store, curhist not incremented.
50pub const HA_NOINC: u32 = 1 << 1; // c:139
51/// Port of `HA_INWORD` from Src/hist.c:140. We're inside a word.
52pub const HA_INWORD: u32 = 1 << 2; // c:140
53/// Port of `HA_UNGET` from Src/hist.c:142. Recursively ungetting.
54pub const HA_UNGET: u32 = 1 << 3; // c:142
55
56/// Port of `static zlong defev` from Src/hist.c:210.
57static defev: AtomicI64 = AtomicI64::new(0); // c:210
58
59/// Port of `static int hist_keep_comment` from Src/hist.c:217.
60static hist_keep_comment: AtomicI32 = AtomicI32::new(0); // c:217
61
62/// Port of `static int histsave_stack_size` from Src/hist.c:239.
63static histsave_stack_size: AtomicI32 = AtomicI32::new(0); // c:239
64
65/// Port of `static int histsave_stack_pos` from Src/hist.c:240.
66static histsave_stack_pos: AtomicI32 = AtomicI32::new(0); // c:240
67
68/// Port of `static zlong histfile_linect` from Src/hist.c:242.
69static histfile_linect: AtomicI64 = AtomicI64::new(0); // c:242
70
71// =========================================================================
72// Functions from hist.c
73// =========================================================================
74
75/// Port of `hist_context_save()` from `Src/hist.c:248`. — C decl `hist_context_save(struct hist_stack *hs, int toplevel)`.
76pub fn hist_context_save(hs: &mut hist_stack, toplevel: i32) {
77    // c:248
78    // !!! WARNING: RUST-ONLY GUARD — NO C COUNTERPART !!!
79    // The history line state is process-global. A worker-pool parse
80    // (zcontext_save in parse_isolated_at) must neither snapshot nor clear
81    // the main thread's live line; hbegin/hend leave it alone there too.
82    if crate::worker::in_worker_thread() {
83        return;
84    }
85    if toplevel != 0 {
86        // c:248
87        // top level, make this version visible to ZLE                       // c:251
88        *zle_chline.lock().unwrap() = Some(chline.lock().unwrap().clone()); // c:252
89                                                                            // ensure line stored is NULL-terminated — implicit in String        // c:253-255
90    }
91    hs.histactive = histactive.load(SeqCst) as i32; // c:257
92    hs.histdone = histdone.load(SeqCst); // c:258
93    hs.stophist = stophist.load(SeqCst); // c:259
94    hs.hline = Some(chline.lock().unwrap().clone()); // c:260
95    hs.hptr = Some(hptr.load(SeqCst).to_string()); // c:261
96    hs.chwords = chwords.lock().unwrap().clone(); // c:262
97    hs.chwordlen = chwordlen.load(SeqCst); // c:263
98    hs.chwordpos = chwordpos.load(SeqCst); // c:264
99                                           // hs->hgetc / hungetc / hwaddc / hwbegin / hwabort / hwend / addtoline  // c:265-271
100                                           // are runtime-mutable function-pointer globals in C; the Rust port
101                                           // dispatches statically via crate::ported::input.
102    hs.hlinesz = hlinesz.load(SeqCst); // c:272
103    hs.defev = defev.load(SeqCst); // c:273
104    hs.hist_keep_comment = hist_keep_comment.load(SeqCst); // c:274
105                                                           // hs->cstack = cmdstack; hs->csp = cmdsp;                               // c:296-282
106    hs.csp = 0;
107
108    stophist.store(0, SeqCst); // c:296
109    chline.lock().unwrap().clear(); // c:296
110    hptr.store(0, SeqCst); // c:296
111    histactive.store(0, SeqCst); // c:296
112                                 // cmdstack = zalloc(CMDSTACKSZ); cmdsp = 0;                             // c:296-289
113}
114
115/// Port of `hist_context_restore()` from `Src/hist.c:296`. — C decl `hist_context_restore(const struct hist_stack *hs, int toplevel)`.
116pub fn hist_context_restore(hs: &hist_stack, toplevel: i32) {
117    // c:296
118    // !!! WARNING: RUST-ONLY GUARD — NO C COUNTERPART !!!
119    // Paired with hist_context_save: a pool thread saved nothing, and writing
120    // its snapshot back would overwrite the main thread's live histactive,
121    // chline and stophist.
122    if crate::worker::in_worker_thread() {
123        return;
124    }
125    if toplevel != 0 {
126        // c:296
127        // c:299 — Back to top level: don't need special ZLE value
128        // c:300 — DPUTS(hs->hline != zle_chline, "BUG: Ouch, wrong chline for ZLE")
129        DPUTS!(
130            // c:300
131            hs.hline != *zle_chline.lock().unwrap(), // c:300
132            "BUG: Ouch, wrong chline for ZLE"        // c:300
133        );
134        *zle_chline.lock().unwrap() = None; // c:301
135    }
136    histactive.store(hs.histactive as u32, SeqCst); // c:303
137    histdone.store(hs.histdone, SeqCst); // c:304
138    stophist.store(hs.stophist, SeqCst); // c:305
139    *chline.lock().unwrap() = hs.hline.clone().unwrap_or_default(); // c:306
140    hptr.store(
141        hs.hptr.as_ref().and_then(|s| s.parse().ok()).unwrap_or(0), // c:307
142        SeqCst,
143    );
144    *chwords.lock().unwrap() = hs.chwords.clone(); // c:308
145    chwordlen.store(hs.chwordlen, SeqCst); // c:309
146    chwordpos.store(hs.chwordpos, SeqCst); // c:310
147                                           // hgetc / hungetc / hwaddc / hwbegin / hwabort / hwend / addtoline      // c:311-317
148    hlinesz.store(hs.hlinesz, SeqCst); // c:318
149    defev.store(hs.defev, SeqCst); // c:339
150    hist_keep_comment.store(hs.hist_keep_comment, SeqCst); // c:339
151                                                           // cmdstack = hs->cstack; cmdsp = hs->csp;                               // c:339-324
152}
153
154/// Port of `hist_in_word()` from `Src/hist.c:339`. — C decl `hist_in_word(int yesno)`.
155pub fn hist_in_word(yesno: i32) {
156    if yesno != 0 {
157        histactive.fetch_or(HA_INWORD, SeqCst);
158    } else {
159        histactive.fetch_and(!HA_INWORD, SeqCst);
160    }
161}
162
163/// Port of `hist_is_in_word()` from `Src/hist.c:349`. — C decl `hist_is_in_word(void)`.
164pub fn hist_is_in_word() -> i32 {
165    if (histactive.load(SeqCst) & HA_INWORD) != 0 {
166        1
167    } else {
168        0
169    }
170}
171
172/// Port of `ihwaddc()` from `Src/hist.c:357`. — C decl `ihwaddc(int c)`.
173///
174/// C body:
175/// ```c
176/// if (chline && !(errflag || lexstop) &&
177///     (inbufflags & (INP_ALIAS|INP_HIST)) != INP_ALIAS) {
178///     if (c == bangchar && stophist < 2 && qbang)
179///         hwaddc('\\');
180///     *hptr++ = c;
181///     if (hptr - chline >= hlinesz) {
182///         int oldsiz = hlinesz;
183///         chline = realloc(chline, hlinesz = oldsiz + 64);
184///         hptr = chline + oldsiz;
185///     }
186/// }
187/// ```
188pub fn ihwaddc(c: i32) {
189    // c:357
190    // Rust-only: pool threads run C's `hwaddc = nohw` arm (c:1141).
191    if crate::worker::in_worker_thread() {
192        return;
193    }
194    // c:360-361 — guard: history line must exist, no error/lex stop,
195    // and we're not strictly inside alias-expansion-only input.
196    if errflag.load(SeqCst) != 0 || lexstop.load(SeqCst) {
197        return;
198    }
199    let inbufflags = crate::ported::input::inbufflags.with(|f| f.get());
200    if (inbufflags & (INP_ALIAS | INP_HIST)) == INP_ALIAS {
201        return;
202    }
203    // C guard: `if (chline && ...)` — chline must be ALLOCATED (non-NULL),
204    // which is the active state hbegin() sets (`hlinesz = 64`), NOT "the
205    // buffer has bytes yet". The prior `chline.is_empty()` proxy was a
206    // chicken-and-egg: an active-but-empty chline (right after hbegin)
207    // rejected the FIRST char, so the line never started building and
208    // interactive history recorded nothing. Use the real allocated flag.
209    if hlinesz.load(SeqCst) == 0 {
210        return; // c:360 — inactive history (chline == NULL)
211    }
212    // c:362-368 — `*hptr++ = c;`. C writes the byte at the hptr
213    // cursor then advances; the qbang escape arm also writes via
214    // the recursive `hwaddc('\\');` call.
215    //
216    // The previous Rust port called `chline.push(c)` which only
217    // APPENDS — after `hwrep` rewinds hptr to mid-buffer, new
218    // pushes would land at chline.end instead of overwriting the
219    // word being replaced. Mirror C exactly: write each byte at
220    // the hptr position (growing only when hptr == chline.len()),
221    // then advance hptr.
222    let bc = bangchar.load(SeqCst);
223    let qbang_active = c == bc && stophist.load(SeqCst) < 2 && qbang.load(SeqCst);
224    {
225        let mut buf = chline.lock().expect("chline poisoned");
226        let bytes = unsafe { buf.as_mut_vec() };
227        let mut pos = hptr.load(SeqCst);
228        // Mirror the recursive `hwaddc('\\');` at c:366 — also writes
229        // at hptr + advances.
230        if qbang_active {
231            if pos < bytes.len() {
232                bytes[pos] = b'\\';
233            }
234            // c:366
235            else {
236                while bytes.len() < pos {
237                    bytes.push(0);
238                }
239                bytes.push(b'\\');
240            }
241            pos += 1;
242        }
243        // c:368 — `*hptr++ = c;`. C's input stream is METAFIED BYTES so
244        // a single-byte store is right there; zshrs's ingetc yields full
245        // Unicode chars, so encode the char's UTF-8 bytes at the cursor.
246        // The previous `c as u8` TRUNCATED every non-ASCII char — ★
247        // (U+2605) stored as 0x05, é (U+00E9) as a lone invalid 0xE9 —
248        // corrupting the ring and $HISTFILE for any multibyte line.
249        let mut utf8 = [0u8; 4];
250        let enc: &[u8] = match char::from_u32(c as u32) {
251            Some(ch) => ch.encode_utf8(&mut utf8).as_bytes(),
252            None => {
253                utf8[0] = c as u8;
254                &utf8[..1]
255            }
256        };
257        for &b in enc {
258            if pos < bytes.len() {
259                bytes[pos] = b;
260            } else {
261                while bytes.len() < pos {
262                    bytes.push(0);
263                }
264                bytes.push(b);
265            }
266            pos += 1;
267        }
268        hptr.store(pos, SeqCst);
269    }
270    // c:370-374 — resize tracking. Rust `String` grows on `push`
271    // automatically, but `hlinesz` mirrors C's allocation count
272    // for any caller that reads it (e.g. `hwend()`). C condition
273    // is `hptr - chline >= hlinesz`; mirror with the canonical
274    // `hptr` global (matches the c:1658 / c:1693 fixes).
275    let cur_off = hptr.load(SeqCst) as i32; // c:381 hptr - chline
276    let sz = hlinesz.load(SeqCst);
277    if cur_off >= sz {
278        // c:381
279        let new_sz = sz + 64;
280        hlinesz.store(new_sz, SeqCst); // c:384
281    }
282}
283
284/// Port of `iaddtoline()` from `Src/hist.c:397`. — C decl `iaddtoline(int c)`.
285///
286/// Adds a character to the ZLE input line while `doexpandhist` rewrites
287/// it, and tracks the new cursor position (`excs`) across the expansion.
288/// Called from `ihgetc` and from `gettok` for characters in comments.
289///
290/// ```c
291/// if (!expanding || lexstop) return;
292/// if (qbang && c == bangchar && stophist < 2) {
293///     exlast--;
294///     zleentry(ZLE_CMD_ADD_TO_LINE, '\\');
295/// }
296/// if (excs > zlemetacs) {
297///     excs += 1 + inbufct - exlast;
298///     if (excs < zlemetacs) excs = zlemetacs;
299/// }
300/// exlast = inbufct;
301/// zleentry(ZLE_CMD_ADD_TO_LINE, c);
302/// ```
303pub fn iaddtoline(c: i32) {
304    // c:397
305    // Rust-only: pool threads run C's `addtoline = nohw` arm (c:1145).
306    if crate::worker::in_worker_thread() {
307        return;
308    }
309    // c:399 — `if (!expanding || lexstop) return;`.
310    if expanding.load(SeqCst) == 0 || lexstop.load(SeqCst) {
311        return;
312    }
313    // c:401-404 — bang-escape under qbang.
314    let bc = bangchar.load(SeqCst);
315    if qbang.load(SeqCst) && c == bc && stophist.load(SeqCst) < 2 {
316        exlast.fetch_sub(1, SeqCst); // c:402
317        // c:403 — zleentry(ZLE_CMD_ADD_TO_LINE, '\\')
318        crate::ported::zle::zle_main::zle_main_entry(
319            crate::ported::zsh_h::ZLE_CMD_ADD_TO_LINE,
320            &mut crate::ported::zle::zle_main::zle_main_entry_args::AddToLine(b'\\' as i32),
321        );
322    }
323    // c:405-411 — `if (excs > zlemetacs) { excs += 1 + inbufct - exlast;
324    // if (excs < zlemetacs) excs = zlemetacs; }`.
325    //
326    // !!! UNIT NOTE !!! zshrs's `ingetc` yields whole chars and `inbufct`
327    // counts chars, while `excs`/`zlemetacs` are byte offsets into the
328    // metafied line. A char of `n` bytes is `n` byte-reads in C (each one
329    // contributing `1 + (-1) = 0`), so the `n - 1` extra bytes are added
330    // back to keep a plain read a net zero on `excs`.
331    let zlemetacs_v = ZLEMETACS.load(SeqCst);
332    let excs_v = excs.load(SeqCst);
333    if excs_v > zlemetacs_v {
334        // c:405
335        let inbufct_now = crate::ported::input::inbufct.with(|c| c.get());
336        let exlast_v = exlast.load(SeqCst);
337        let extra = char::from_u32(c as u32).map_or(0, |ch| ch.len_utf8() as i32 - 1);
338        let mut new_excs = excs_v + 1 + inbufct_now - exlast_v + extra; // c:406
339        if new_excs < zlemetacs_v {
340            // c:407 this case could be handled better but it is so rare
341            // that it does not worth it
342            new_excs = zlemetacs_v; // c:410
343        }
344        excs.store(new_excs, SeqCst);
345    }
346    // c:412 — `exlast = inbufct;`
347    let inbufct_v = crate::ported::input::inbufct.with(|cnt| cnt.get());
348    exlast.store(inbufct_v, SeqCst); // c:412
349    // c:413 — zleentry(ZLE_CMD_ADD_TO_LINE, c)
350    crate::ported::zle::zle_main::zle_main_entry(
351        crate::ported::zsh_h::ZLE_CMD_ADD_TO_LINE,
352        &mut crate::ported::zle::zle_main::zle_main_entry_args::AddToLine(c),
353    );
354}
355
356/// Port of `safeinungetc()` from `Src/hist.c:467`. — C decl `safeinungetc(int c)`.
357/// ```c
358/// static void
359/// safeinungetc(int c)
360/// {
361///     if (lexstop)
362///         lexstop = 0;
363///     else
364///         inungetc(c);
365/// }
366/// ```
367pub fn safeinungetc(c: i32) {
368    // c:467
369    if lexstop.load(SeqCst) {
370        // c:469
371        lexstop.store(false, SeqCst); // c:470
372    } else {
373        // c:471
374        if let Some(ch) = char::from_u32(c as u32) {
375            // c:472 inungetc(c)
376            inungetc(ch);
377        }
378    }
379}
380
381/// Port of `ihgetc()` from `Src/hist.c:418`. — C decl `ihgetc(void)`.
382/// Returns the next
383/// character from the input stream, optionally performing history
384/// expansion via `histsubchar`. Sets `lexstop`/`errflag` and bumps
385/// `qbang` per the bang-escape rules.
386/// ```c
387/// static int
388/// ihgetc(void)
389/// {
390///     int c = ingetc();
391///     if (exit_pending) { lexstop = 1; errflag |= ERRFLAG_ERROR; return ' '; }
392///     qbang = 0;
393///     if (!stophist && !(inbufflags & INP_ALIAS)) {
394///         c = histsubchar(c);
395///         if (c < 0) { lexstop = 1; errflag |= ERRFLAG_ERROR; return ' '; }
396///     }
397///     if ((inbufflags & INP_HIST) && !stophist) {
398///         qbang = 0;
399///         if (c == '\\' && !(qbang = (c = ingetc()) == bangchar))
400///             safeinungetc(c), c = '\\';
401///     } else if (stophist || (inbufflags & INP_ALIAS))
402///         qbang = c == bangchar && (stophist < 2);
403///     hwaddc(c);
404///     addtoline(c);
405///     return c;
406/// }
407/// ```
408pub fn ihgetc() -> i32 {
409    // c:418
410    // Rust-only: pool threads run C's `hgetc = ingetc` arm (c:1139).
411    if crate::worker::in_worker_thread() {
412        return ingetc().map(|ch| ch as i32).unwrap_or(b' ' as i32);
413    }
414    // c:420 — `int c = ingetc();`. C's ingetc returns the byte 32 (' ')
415    // WITH lexstop set at EOF (input.c:322). zshrs's ingetc signals EOF as
416    // `None`, so map None → ' ' (NOT -1): a negative `c` is reserved for
417    // the histsubchar bad-`!`-expansion path at c:432 (which sets errflag),
418    // and conflating EOF with it spuriously flags an error on a clean
419    // Ctrl-D / end-of-input.
420    let mut c: i32 = ingetc() // c:420 int c = ingetc();
421        .map(|ch| ch as i32)
422        .unwrap_or(b' ' as i32);
423    if exit_pending.load(SeqCst) {
424        // c:422
425        lexstop.store(true, SeqCst); // c:424
426        errflag.fetch_or(
427            // c:425 errflag |= ERRFLAG_ERROR
428            ERRFLAG_ERROR,
429            SeqCst,
430        );
431        return b' ' as i32; // c:426
432    }
433    qbang.store(false, SeqCst); // c:428 qbang = 0
434    let inbufflags_v = crate::ported::input::inbufflags.with(|f| f.get());
435    if stophist.load(SeqCst) == 0                                  // c:429 !stophist
436        && (inbufflags_v & INP_ALIAS) == 0
437    // c:429 !(inbufflags & INP_ALIAS)
438    {
439        c = histsubchar(c); // c:431 c = histsubchar(c)
440        if c < 0 {
441            // c:432
442            lexstop.store(true, SeqCst); // c:434
443            errflag.fetch_or(
444                // c:435
445                ERRFLAG_ERROR,
446                SeqCst,
447            );
448            return b' ' as i32; // c:436
449        }
450    }
451    let inbufflags_v = crate::ported::input::inbufflags.with(|f| f.get());
452    let bc = bangchar.load(SeqCst);
453    if (inbufflags_v & INP_HIST) != 0 && stophist.load(SeqCst) == 0 {
454        // c:439
455        // c:447 qbang = 0
456        qbang.store(false, SeqCst);
457        if c == b'\\' as i32 {
458            // c:448 c == '\\'
459            let g = ingetc() // c:448 c = ingetc()
460                .map(|ch| ch as i32)
461                .unwrap_or(-1);
462            if g == bc {
463                // c:448 qbang = (c == bangchar)
464                qbang.store(true, SeqCst);
465                c = g;
466            } else {
467                // c:449 safeinungetc(c), c = '\\';
468                safeinungetc(g);
469                c = b'\\' as i32;
470            }
471        }
472    } else if stophist.load(SeqCst) != 0                           // c:450 stophist
473        || (inbufflags_v & INP_ALIAS) != 0
474    // c:450 (inbufflags & INP_ALIAS)
475    {
476        // c:458 qbang = c == bangchar && (stophist < 2)
477        let v = c == bc && stophist.load(SeqCst) < 2;
478        qbang.store(v, SeqCst);
479    }
480    ihwaddc(c); // c:459 hwaddc(c)
481    iaddtoline(c); // c:460 addtoline(c)
482    c // c:462 return c
483}
484
485/// Port of `unsigned char hatchar` from `Src/params.c:132`. Caret used
486/// as the substitution-shortcut lead character on first column; init'd
487/// to `'^'` at `Src/init.c:1102`. Read by `histsubchar` (c:618).
488/// NOTE on placement: canonical home per PORT.md Rule C would be
489/// `params.rs`, alongside `bangchar`/`hashchar`. Kept here to mirror
490/// the existing (pre-2026-05) placement of `bangchar` at hist.rs:1858;
491/// move-all-three is a follow-up cleanup.
492pub static hatchar: AtomicI32 = AtomicI32::new(b'^' as i32); // c:params.c:132
493
494/// Port of `unsigned char hashchar` from `Src/params.c:132`. Comment-
495/// start character; init'd to `'#'` at `Src/init.c:1101`. Read by
496/// `gettokstr` (`Src/lex.c:678`). Atomic so `histcharssetfn`
497/// (`Src/params.c:5097`) can update it dynamically when `$HISTCHARS`
498/// changes.
499pub static hashchar: AtomicI32 = AtomicI32::new(b'#' as i32); // c:params.c:132
500
501/// Port of `static int marg` from `Src/hist.c:599`. Argument index of the
502/// most-recent `!?str?` event match; `-1` when no match has happened.
503pub static marg: AtomicI32 = AtomicI32::new(-1); // c:599
504
505/// Port of `static zlong mev` from `Src/hist.c:600`. Event number of the
506/// most-recent `!?str?` event match; `-1` when no match has happened.
507pub static mev: AtomicI64 = AtomicI64::new(-1); // c:600
508
509/// Port of `histsubchar()` from `Src/hist.c:595`. — C decl `histsubchar(int c)`.
510/// Implements `^foo^bar` and full `!` history expansion: walks the input
511/// stream pulling event-spec, word-designator, and modifier characters,
512/// looks up the matching history entry, applies any modifiers, then
513/// pushes the resulting string back onto the input stack via `inpush`.
514/// Returns the first character of the expanded result or `-1` on error.
515///
516/// ```c
517/// static int
518/// histsubchar(int c)
519/// {
520///     int farg, evset = -1, larg, argc, cflag = 0, bflag = 0;
521///     zlong ev;
522///     static int marg = -1;
523///     static zlong mev = -1;
524///     char *buf, *ptr;
525///     char *sline;
526///     int lexraw_mark;
527///     Histent ehist;
528///     size_t buflen;
529///     /* ... see Src/hist.c:594-983 for full body ... */
530/// }
531/// ```
532pub fn histsubchar(c_in: i32) -> i32 {
533    // c:595
534    let mut c: i32 = c_in;
535    let mut farg: i32; // c:597
536    let mut evset: i32 = -1; // c:597
537    let mut larg: i32;
538    let mut argc: i32; // c:597
539    let mut cflag: i32 = 0; // c:597
540    let mut bflag: i32 = 0; // c:597
541    let mut ev: i64; // c:598
542    let mut buf: String; // c:601 char *buf, *ptr
543    let mut sline: String; // c:602
544    // c:615 — if accumulating raw input for use in command substitution, mark
545    // the history text (including the character being substituted) for removal.
546    let lexraw_mark: i64 = crate::ported::lex::zshlex_raw_mark(-1); // c:603,615
547
548    // c:618 — `^foo^bar` shortcut: only valid on first column of input.
549    let hat = hatchar.load(SeqCst);
550    if LEX_ISFIRSTCH.with(|f| f.get()) && c == hat {
551        // c:618
552        let mut gbal: i32 = 0; // c:619
553                               // c:622 — clear isfirstch
554        LEX_ISFIRSTCH.with(|f| f.set(false)); // c:622
555                                              // c:623 — push hatchar back so getargs parses the leading ^.
556        if let Some(ch) = char::from_u32(hat as u32) {
557            inungetc(ch); // c:623
558        }
559        let ehist = match gethist(defev.load(SeqCst)) {
560            // c:624
561            Some(h) => h,
562            None => return -1, // c:626
563        };
564        let argc_local = getargc(&ehist) as usize;
565        sline = match getargs(&ehist, 0, argc_local.saturating_sub(0)) {
566            // c:625
567            Some(s) => s,
568            None => return -1, // c:626
569        };
570
571        if getsubsargs(&sline, &mut gbal, &mut cflag) != 0 {
572            // c:628
573            return substfailed(); // c:629
574        }
575        if hsubl.lock().unwrap().is_none() {
576            // c:630
577            return -1; // c:631
578        }
579        let in_pat = hsubl.lock().unwrap().clone().unwrap_or_default();
580        let out_pat = hsubr.lock().unwrap().clone().unwrap_or_default();
581        // c:632 — `if (subst(&sline, hsubl, hsubr, gbal, 0))`.  The
582        // verdict is subst's STATUS, not whether `sline` changed:
583        // `^old^old` substitutes successfully and leaves the line
584        // looking exactly as it did.  `forcepat` is 0 here — the
585        // `^a^b` form has no `:S` spelling.
586        if subst(&mut sline, &in_pat, &out_pat, gbal, 0) != 0 {
587            return substfailed(); // c:633
588        }
589    } else {
590        // c:636 — !c shortcut: first-column flag clears unless c==' '.
591        if c != b' ' as i32 {
592            // c:636
593            LEX_ISFIRSTCH.with(|f| f.set(false)); // c:637
594        }
595        let bc = bangchar.load(SeqCst);
596        if c == b'\\' as i32 {
597            // c:638
598            let g = ingetc() // c:639 ingetc()
599                .map(|ch| ch as i32)
600                .unwrap_or(-1);
601            if g != bc {
602                // c:641
603                safeinungetc(g); // c:642
604            } else {
605                // c:643
606                qbang.store(true, SeqCst); // c:644
607                return bc; // c:645 return bangchar
608            }
609        }
610        if c != bc {
611            // c:648
612            return c; // c:649
613        }
614        // c:650 — `*hptr = '\0'` truncates chline at the current position.
615        let pos = hptr.load(SeqCst); // c:650
616        {
617            let mut cl = chline.lock().unwrap();
618            if pos < cl.len() {
619                cl.truncate(pos);
620            }
621        }
622        c = ingetc() // c:651
623            .map(|ch| ch as i32)
624            .unwrap_or(-1);
625        if c == b'{' as i32 {
626            // c:651
627            bflag = 1; // c:652
628            cflag = 1;
629            c = ingetc() // c:653
630                .map(|ch| ch as i32)
631                .unwrap_or(-1);
632        }
633        if c == b'"' as i32 {
634            // c:655 c == '\"'
635            stophist.store(1, SeqCst); // c:656
636            return ingetc() // c:657 return ingetc()
637                .map(|ch| ch as i32)
638                .unwrap_or(-1);
639        }
640        // c:659 — (!cflag && inblank(c)) || c == '=' || c == '(' || lexstop
641        let is_blank = (c as u8 as char).is_ascii_whitespace();
642        if (cflag == 0 && is_blank) || c == b'=' as i32 || c == b'(' as i32 || lexstop.load(SeqCst)
643        // c:659
644        {
645            safeinungetc(c); // c:660
646            return bc; // c:661 return bangchar
647        }
648        cflag = 0; // c:663
649        let mut buflen: usize = 265; // c:664
650        buf = String::with_capacity(buflen); // c:664 zhalloc
651
652        // c:666-727 — read event-spec into buf.
653        queue_signals(); // c:668
654        if c == b'?' as i32 {
655            // c:669
656            loop {
657                // c:670
658                c = ingetc() // c:671 ingetc()
659                    .map(|ch| ch as i32)
660                    .unwrap_or(-1);
661                if c == b'?' as i32 || c == b'\n' as i32 || lexstop.load(SeqCst) {
662                    // c:672
663                    break; // c:673
664                } else {
665                    buf.push(c as u8 as char); // c:675 *ptr++ = c
666                    if buf.len() >= buflen {
667                        // c:676
668                        buflen *= 2; // c:679 buflen *= 2
669                        buf.reserve(buflen);
670                    }
671                }
672            }
673            if c != b'\n' as i32 && !lexstop.load(SeqCst) {
674                // c:683
675                c = ingetc() // c:684
676                    .map(|ch| ch as i32)
677                    .unwrap_or(-1);
678            }
679            // c:685 *ptr = '\0' — Rust String is already terminated.
680            *hsubl.lock().unwrap() = Some(buf.clone()); // c:686 hsubl = ztrdup(buf)
681                                                        // c:686 — `mev = ev = hconsearch(hsubl = ztrdup(buf), &marg);`
682                                                        // hconsearch returns Option<(histnum, marg)> per C's
683                                                        // out-pointer pair.
684            let (ev_val, marg_val) = match hconsearch(&buf) {
685                // c:686
686                Some((e, m)) => (e, m),
687                None => (-1, -1),
688            };
689            ev = ev_val;
690            mev.store(ev, SeqCst); // c:686 mev = ev
691            marg.store(marg_val, SeqCst); // c:686 marg out-arg
692            evset = 0; // c:687
693            if ev == -1 {
694                // c:688
695                herrflush(); // c:689
696                unqueue_signals(); // c:690
697                zerr(&format!("no such event: {}", buf)); // c:691
698                return -1; // c:692
699            }
700        } else {
701            // c:694
702            // c:697 — collect event spec until terminator.
703            loop {
704                let is_term = (c as u8 as char).is_ascii_whitespace()
705                    || c == b';' as i32
706                    || c == b':' as i32
707                    || c == b'^' as i32
708                    || c == b'$' as i32
709                    || c == b'*' as i32
710                    || c == b'%' as i32
711                    || c == b'}' as i32
712                    || c == b'\'' as i32
713                    || c == b'"' as i32
714                    || c == b'`' as i32
715                    || lexstop.load(SeqCst); // c:698-700
716                if is_term {
717                    break;
718                }
719                if !buf.is_empty() {
720                    // c:702
721                    if c == b'-' as i32 {
722                        break;
723                    } // c:703-704
724                    let first = buf.as_bytes()[0];
725                    if (first.is_ascii_digit() || first == b'-')             // c:705
726                        && !(c as u8).is_ascii_digit()
727                    // c:705 !idigit(c)
728                    {
729                        break; // c:706
730                    }
731                }
732                buf.push(c as u8 as char); // c:708
733                if buf.len() >= buflen {
734                    // c:709
735                    buflen *= 2; // c:712
736                    buf.reserve(buflen);
737                }
738                if c == b'#' as i32 || c == bc {
739                    // c:714
740                    c = ingetc() // c:715
741                        .map(|ch| ch as i32)
742                        .unwrap_or(-1);
743                    break; // c:716
744                }
745                c = ingetc() // c:718
746                    .map(|ch| ch as i32)
747                    .unwrap_or(-1);
748            }
749            if buf.is_empty()                                                // c:720
750                && (c == b'}' as i32 || c == b';' as i32 || c == b'\'' as i32
751                    || c == b'"' as i32 || c == b'`' as i32)
752            {
753                safeinungetc(c); // c:723
754                unqueue_signals(); // c:724
755                return bc; // c:725
756            }
757            // c:727 *ptr = 0 — handled by Rust String
758            if buf.is_empty() {
759                // c:728
760                if c != b'%' as i32 {
761                    // c:729
762                    if isset(CSHJUNKIEHISTORY) {
763                        // c:730
764                        ev = addhistnum(curhist.load(SeqCst), -1, HIST_FOREIGN as i32);
765                    } else {
766                        // c:732
767                        ev = defev.load(SeqCst); // c:733
768                    }
769                    if c == b':' as i32 && evset == -1 {
770                        // c:734
771                        evset = 0; // c:735
772                    } else {
773                        evset = 1; // c:737
774                    }
775                } else {
776                    // c:738
777                    if marg.load(SeqCst) != -1 {
778                        // c:739
779                        ev = mev.load(SeqCst); // c:740
780                    } else {
781                        // c:741
782                        ev = defev.load(SeqCst); // c:742
783                    }
784                    evset = 0; // c:743
785                }
786            } else if let Ok(t0) = buf.trim().parse::<i64>() {
787                // c:745 zstrtol(buf, NULL, 10)
788                if t0 != 0 {
789                    ev = if t0 < 0 {
790                        // c:746
791                        addhistnum(curhist.load(SeqCst), t0 as i32, HIST_FOREIGN as i32)
792                    } else {
793                        t0
794                    };
795                    evset = 1; // c:747
796                } else if buf.as_bytes()[0] == bc as u8 {
797                    // c:748 *buf == bangchar
798                    ev = addhistnum(curhist.load(SeqCst), -1, HIST_FOREIGN as i32); // c:749
799                    evset = 1; // c:750
800                } else if buf.as_bytes()[0] == b'#' {
801                    // c:751
802                    ev = curhist.load(SeqCst); // c:752
803                    evset = 1; // c:753
804                } else {
805                    // c:754
806                    match hcomsearch(&buf) {
807                        // c:754
808                        Some(e) => {
809                            ev = e;
810                            evset = 1;
811                        }
812                        None => {
813                            herrflush(); // c:755
814                            unqueue_signals(); // c:756
815                            zerr(&format!("event not found: {}", buf)); // c:757
816                            return -1; // c:758
817                        }
818                    }
819                }
820            } else if buf.as_bytes()[0] == bc as u8 {
821                // c:748 *buf == bangchar
822                ev = addhistnum(curhist.load(SeqCst), -1, HIST_FOREIGN as i32);
823                evset = 1;
824            } else if buf.as_bytes()[0] == b'#' {
825                // c:751
826                ev = curhist.load(SeqCst);
827                evset = 1;
828            } else {
829                // c:754
830                match hcomsearch(&buf) {
831                    Some(e) => {
832                        ev = e;
833                        evset = 1;
834                    }
835                    None => {
836                        herrflush();
837                        unqueue_signals();
838                        zerr(&format!("event not found: {}", buf));
839                        return -1;
840                    }
841                }
842            }
843        }
844
845        // c:765 — fetch the resolved history entry.
846        defev.store(ev, SeqCst); // c:765 defev = ev
847        let mut ehist = match gethist(ev) {
848            // c:765
849            Some(h) => h,
850            None => {
851                unqueue_signals(); // c:766
852                return -1; // c:767
853            }
854        };
855        argc = getargc(&ehist) as i32; // c:771
856
857        // c:772 — word-designator parsing.
858        if c == b':' as i32 {
859            // c:772
860            cflag = 1; // c:773
861            c = ingetc() // c:774
862                .map(|ch| ch as i32)
863                .unwrap_or(-1);
864            if c == b'%' as i32 && marg.load(SeqCst) != -1 {
865                // c:775
866                if evset == 0 {
867                    // c:776
868                    ehist = match gethist(mev.load(SeqCst)) {
869                        // c:777
870                        Some(h) => {
871                            defev.store(mev.load(SeqCst), SeqCst);
872                            h
873                        }
874                        None => {
875                            unqueue_signals();
876                            return -1;
877                        }
878                    };
879                    argc = getargc(&ehist) as i32; // c:778
880                } else {
881                    // c:779
882                    herrflush(); // c:780
883                    unqueue_signals(); // c:781
884                    zerr("ambiguous history reference"); // c:782
885                    return -1; // c:783
886                }
887            }
888        }
889        // c:788
890        if c == b'*' as i32 {
891            // c:788
892            farg = 1; // c:789
893            larg = argc; // c:790
894            cflag = 0; // c:791
895        } else {
896            // c:792
897            if let Some(ch) = char::from_u32(c as u32) {
898                inungetc(ch); // c:793
899            }
900            let r = getargspec(argc, marg.load(SeqCst), evset); // c:794
901            larg = r;
902            farg = r;
903            if larg == -2 {
904                // c:795
905                unqueue_signals(); // c:796
906                return -1; // c:797
907            }
908            if farg != -1 {
909                // c:799
910                cflag = 0; // c:800
911            }
912            c = ingetc() // c:801
913                .map(|ch| ch as i32)
914                .unwrap_or(-1);
915            if c == b'*' as i32 {
916                // c:802
917                cflag = 0; // c:803
918                larg = argc; // c:804
919            } else if c == b'-' as i32 {
920                // c:805
921                cflag = 0; // c:806
922                larg = getargspec(argc, marg.load(SeqCst), evset); // c:807
923                if larg == -2 {
924                    // c:808
925                    unqueue_signals(); // c:809
926                    return -1; // c:810
927                }
928                if larg == -1 {
929                    // c:812
930                    larg = argc - 1; // c:813
931                }
932            } else {
933                // c:814
934                if let Some(ch) = char::from_u32(c as u32) {
935                    inungetc(ch); // c:815
936                }
937            }
938        }
939        if farg == -1 {
940            // c:817
941            farg = 0; // c:818
942        }
943        if larg == -1 {
944            // c:819
945            larg = argc; // c:820
946        }
947        sline = match getargs(&ehist, farg as usize, larg as usize) {
948            // c:821
949            Some(s) => s,
950            None => {
951                unqueue_signals(); // c:822
952                return -1; // c:823
953            }
954        };
955        unqueue_signals(); // c:825
956    }
957
958    // c:830 — modifier loop.
959    loop {
960        c = if cflag != 0 {
961            b':' as i32
962        } else {
963            // c:831
964            ingetc().map(|ch| ch as i32).unwrap_or(-1)
965        };
966        cflag = 0; // c:832
967        if c == b':' as i32 {
968            // c:833
969            let mut gbal: i32 = 0; // c:834
970            c = ingetc() // c:836
971                .map(|ch| ch as i32)
972                .unwrap_or(-1);
973            if c == b'g' as i32 {
974                // c:836
975                gbal = 1; // c:837
976                c = ingetc() // c:838
977                    .map(|ch| ch as i32)
978                    .unwrap_or(-1);
979                if c != b's' as i32 && c != b'S' as i32 && c != b'&' as i32 {
980                    // c:839
981                    zerr("'s' or '&' modifier expected after 'g'"); // c:840
982                    return -1; // c:841
983                }
984            }
985            match c as u8 {
986                b'p' => {
987                    // c:845
988                    histdone.store(HISTFLAG_DONE | HISTFLAG_NOEXEC, SeqCst);
989                    // c:846
990                }
991                b'a' => {
992                    // c:848
993                    match chabspath(&sline) {
994                        // c:849
995                        Some(new) => sline = new,
996                        None => {
997                            herrflush(); // c:850
998                            zerr("modifier failed: a"); // c:851
999                            return -1; // c:852
1000                        }
1001                    }
1002                }
1003                b'A' => {
1004                    // c:856
1005                    match chrealpath(&sline, b'A', false) {
1006                        // c:857 chrealpath(&sline, 'A', 0)
1007                        Some(new) => sline = new,
1008                        None => {
1009                            herrflush(); // c:858
1010                            zerr("modifier failed: A"); // c:859
1011                            return -1; // c:860
1012                        }
1013                    }
1014                }
1015                b'c' => {
1016                    // c:863
1017                    match equalsubstr(&sline, false, false) {
1018                        // c:864
1019                        Some(new) => sline = new,
1020                        None => {
1021                            herrflush(); // c:865
1022                            zerr("modifier failed: c"); // c:866
1023                            return -1; // c:867
1024                        }
1025                    }
1026                }
1027                b'h' => {
1028                    // c:870
1029                    let count = digitcount(); // c:871
1030                    sline = remtpath(&sline, count);
1031                }
1032                b'e' => {
1033                    // c:877
1034                    sline = rembutext(&sline); // c:878
1035                }
1036                b'r' => {
1037                    // c:884
1038                    sline = remtext(&sline); // c:885
1039                }
1040                b't' => {
1041                    // c:891
1042                    let count = digitcount(); // c:892
1043                    sline = remlpaths(&sline, count);
1044                }
1045                b's' | b'S' => {
1046                    // c:898-899
1047                    hsubpatopt.store((c == b'S' as i32) as i32, SeqCst); // c:900
1048                    if getsubsargs(&sline, &mut gbal, &mut cflag) != 0 {
1049                        // c:901
1050                        return -1; // c:902
1051                    }
1052                    // fall through to '&'                                   // c:902
1053                    let (in_pat, out_pat) =
1054                        (hsubl.lock().unwrap().clone(), hsubr.lock().unwrap().clone());
1055                    if let (Some(ip), Some(op)) = (in_pat, out_pat) {
1056                        // c:904
1057                        // c:905 — branch on subst's STATUS. `hsubpatopt`
1058                        // is the `forcepat` argument: `:S` forces the
1059                        // pattern path even without HIST_SUBST_PATTERN.
1060                        if subst(&mut sline, &ip, &op, gbal, hsubpatopt.load(SeqCst)) != 0 {
1061                            return substfailed(); // c:906
1062                        }
1063                    } else {
1064                        // c:907
1065                        herrflush(); // c:908
1066                        zerr("no previous substitution"); // c:909
1067                        return -1; // c:910
1068                    }
1069                }
1070                b'&' => {
1071                    // c:903
1072                    let (in_pat, out_pat) =
1073                        (hsubl.lock().unwrap().clone(), hsubr.lock().unwrap().clone());
1074                    if let (Some(ip), Some(op)) = (in_pat, out_pat) {
1075                        // c:905 — the `:&` arm C falls into from `:s`,
1076                        // so it repeats the same status branch.
1077                        if subst(&mut sline, &ip, &op, gbal, hsubpatopt.load(SeqCst)) != 0 {
1078                            return substfailed(); // c:906
1079                        }
1080                    } else {
1081                        herrflush();
1082                        zerr("no previous substitution");
1083                        return -1;
1084                    }
1085                }
1086                b'q' => {
1087                    // c:913
1088                    sline = quote(&sline); // c:914
1089                }
1090                b'Q' => {
1091                    // c:916
1092                    // c:918-923 — `int one = noerrs, oef = errflag; noerrs = 1;
1093                    // parse_subst_string(sline); noerrs = one;
1094                    // errflag = oef | (errflag & ERRFLAG_INT);`
1095                    let one = *crate::ported::utils::noerrs_lock().lock().unwrap();
1096                    let oef = errflag.load(SeqCst);
1097                    *crate::ported::utils::noerrs_lock().lock().unwrap() = 1;
1098                    if let Ok(t) = parse_subst_string(&sline) {
1099                        sline = t; // c:921 (in-place in C)
1100                    }
1101                    *crate::ported::utils::noerrs_lock().lock().unwrap() = one;
1102                    errflag.store(oef | (errflag.load(SeqCst) & ERRFLAG_INT), SeqCst); // c:923
1103                    let mut s = sline.clone();
1104                    remnulargs(&mut s); // c:924
1105                    sline = untokenize(&s); // c:925
1106                }
1107                b'x' => {
1108                    // c:928
1109                    sline = quotebreak(&sline); // c:929
1110                }
1111                b'l' => {
1112                    // c:931
1113                    sline = casemodify(&sline, CASMOD_LOWER); // c:932
1114                }
1115                b'u' => {
1116                    // c:934
1117                    sline = casemodify(&sline, CASMOD_UPPER); // c:935
1118                }
1119                b'P' => {
1120                    // c:937
1121                    if !sline.starts_with('/') {
1122                        // c:938
1123                        let here = crate::ported::compat::zgetcwd(); // c:939
1124                        sline = if here.ends_with('/') {
1125                            // c:940
1126                            crate::ported::string::dyncat(&here, &sline) // c:943
1127                        } else {
1128                            // c:941
1129                            // c:941 zhtricat(metafy(here, -1, META_HEAPDUP), "/", sline)
1130                            format!("{}/{}", here, sline) // c:941
1131                        }; // c:944
1132                    }
1133                    match crate::ported::utils::xsymlink(&sline) {
1134                        // c:945
1135                        Some(new) => sline = new,
1136                        None => {} // C ignores xsymlink failure (returns NULL → keep sline)
1137                    }
1138                }
1139                _ => {
1140                    // c:947 default
1141                    herrflush(); // c:948
1142                    zerr(&format!("invalid modifier: {}", c as u8 as char)); // c:949
1143                    return -1; // c:950
1144                }
1145            }
1146        } else {
1147            // c:952
1148            if c != b'}' as i32 || bflag == 0 {
1149                // c:953
1150                if let Some(ch) = char::from_u32(c as u32) {
1151                    inungetc(ch); // c:954
1152                }
1153            }
1154            if c != b'}' as i32 && bflag != 0 {
1155                // c:955
1156                zerr("'}' expected"); // c:956
1157                return -1; // c:957
1158            }
1159            break; // c:959
1160        }
1161    }
1162
1163    crate::ported::lex::zshlex_raw_back_to_mark(lexraw_mark); // c:963
1164
1165    // c:970-976 — push the expanded value onto the input stack as INP_HIST.
1166    lexstop.store(false, SeqCst); // c:970
1167    crate::ported::input::inpush(&sline, INP_HIST, None); // c:976
1168    histdone.fetch_or(HISTFLAG_DONE, SeqCst); // c:977
1169    if isset(HISTVERIFY) {
1170        // c:978
1171        histdone.fetch_or(HISTFLAG_NOEXEC | HISTFLAG_RECALL, SeqCst); // c:979
1172    }
1173
1174    // c:982 — return ingetc() so caller sees the first char of expansion.
1175    ingetc().map(|ch| ch as i32).unwrap_or(-1)
1176}
1177
1178/// Port of `herrflush()` from `Src/hist.c:477`. — C decl `herrflush(void)`.
1179///
1180/// C body drains the input buffer after a history-expansion error,
1181/// feeding the consumed chars into the history-build cursor
1182/// (`hwaddc` + `addtoline`) so the history line still records the
1183/// raw input that failed. Without this drain, the history entry
1184/// for a failed `!ev` would be truncated at the failure point.
1185///
1186/// The previous Rust port left the drain loop as a no-op comment,
1187/// claiming the deps weren't ported. They ARE: `ihwaddc` /
1188/// `iaddtoline` live at hist.rs above; `strin` / `LEX_LEX_ADD_RAW`
1189/// are file-statics on this module + lex.rs.
1190pub fn herrflush() {
1191    // c:477
1192    // c:479 — `inpopalias();`
1193    crate::ported::input::inpopalias();
1194
1195    // c:481-482 — `if (lexstop) return;`. Go through the `lexstop` handle
1196    // (hist.rs, below) rather than one half of it, so this reads the same
1197    // C variable that `ihgetc` writes.
1198    if lexstop.load(SeqCst) {
1199        return;
1200    }
1201
1202    // c:494-500 — drain the input buffer when expanding history for
1203    // ZLE (the `!strin || lex_add_raw` arm covers the two cases where
1204    // the input must be flushed into the history line:
1205    //   - non-ZLE non-string input (`!strin` true);
1206    //   - ZLE with raw-recording (`lex_add_raw != 0`).
1207    //
1208    // C:
1209    //   while (inbufct && (!strin || lex_add_raw)) {
1210    //       int c = ingetc();
1211    //       if (!lexstop) { hwaddc(c); addtoline(c); }
1212    //   }
1213    //
1214    // !!! WARNING: SPLIT INPUT BUFFERS !!! C has one input buffer: `hungetc`
1215    // puts characters back into `inbuf`, so this drain reads them first, and
1216    // `ingetc` records each character in the raw buffer while `lex_add_raw`
1217    // is set (c:Src/input.c:360-361). This port keeps the lexer's pushback in
1218    // `lex::LEX_UNGET_BUF` and does the raw recording in `lex::hgetc`, not in
1219    // `input::ingetc`. While skipcomm is recording a command substitution
1220    // (c:Src/lex.c:2217), read the pushback through `hgetc` (its pushback
1221    // arm records raw and does no history expansion) and record the rest
1222    // below. Otherwise the drained input vanished from the word:
1223    // `${(z)…}` of `echo $(|||) bar` gave `$(|||` for zsh's `$(|||) bar`.
1224    let recording_raw = crate::ported::lex::LEX_LEX_ADD_RAW.get() != 0;
1225    if recording_raw {
1226        while crate::ported::lex::LEX_UNGET_BUF.with_borrow(|b| !b.is_empty()) {
1227            crate::ported::lex::hgetc();
1228        }
1229    }
1230    loop {
1231        let inbufct = crate::ported::input::inbufct.with(|c| c.get());
1232        if inbufct <= 0 {
1233            break;
1234        }
1235        let strin_v = strin.load(SeqCst);
1236        let lex_add_raw = crate::ported::lex::LEX_LEX_ADD_RAW.get();
1237        if !(strin_v == 0 || lex_add_raw != 0) {
1238            // c:494 (!strin || lex_add_raw)
1239            break;
1240        }
1241        let ch = ingetc(); // c:495 ingetc()
1242        let c = ch.map(|ch| ch as i32).unwrap_or(-1);
1243        if !lexstop.load(SeqCst) {
1244            // c:496 if (!lexstop)
1245            if recording_raw {
1246                if let Some(ch) = ch {
1247                    crate::ported::lex::zshlex_raw_add(ch); // c:Src/input.c:360-361
1248                }
1249            }
1250            ihwaddc(c); // c:497 hwaddc(c)
1251            iaddtoline(c); // c:498 addtoline(c)
1252        }
1253    }
1254}
1255
1256/// Port of `getargc()` from `Src/hist.c:556`. — C decl `getargc(Histent ehist)`.
1257/// C body: `return ehist->nwords ? ehist->nwords-1 : 0;`
1258/// Returns the number of word designators (nwords - 1, since word 0
1259/// is the command name and arguments start at word 1).
1260pub fn getargc(entry: &histent) -> usize {
1261    // c:558
1262    if entry.nwords > 0 {
1263        (entry.nwords - 1) as usize
1264    } else {
1265        0
1266    }
1267}
1268
1269/// Port of `substfailed()` from `Src/hist.c:563`. — C decl `substfailed(void)`.
1270/// ```c
1271/// static int
1272/// substfailed(void)
1273/// {
1274///     herrflush();
1275///     zerr("substitution failed");
1276///     return -1;
1277/// }
1278/// ```
1279pub fn substfailed() -> i32 {
1280    // c:563
1281    herrflush(); // c:565
1282    zerr("substitution failed"); // c:566
1283    -1 // c:567
1284}
1285
1286/// Port of `digitcount()` from `Src/hist.c:574`. — C decl `digitcount(void)`.
1287///
1288/// C body:
1289/// ```c
1290/// int c = ingetc(), count;
1291/// if (idigit(c)) {
1292///     count = 0;
1293///     do {
1294///         count = 10 * count + (c - '0');
1295///         c = ingetc();
1296///     } while (idigit(c));
1297/// } else
1298///     count = 0;
1299/// inungetc(c);
1300/// return count;
1301/// ```
1302///
1303/// "Return a count given by decimal digits after a modifier."
1304/// Pulls characters off the INPUT STREAM via `ingetc`/`inungetc`,
1305/// NOT from a passed-in string. Called from c:871 (`:h` modifier)
1306/// and c:892 (`:t` modifier) to parse the digit count after the
1307/// modifier letter.
1308///
1309/// The previous Rust port was a complete fabrication: signature was
1310/// `(s: &str) -> usize` counting leading digits in an argument
1311/// string. No real caller — the C function streams from input, not
1312/// a string. Pin the C signature exactly.
1313pub fn digitcount() -> i32 {
1314    // c:574
1315    let mut c: i32 = ingetc() // c:576 ingetc()
1316        .map(|ch| ch as i32)
1317        .unwrap_or(-1);
1318    let mut count: i32;
1319    if c >= 0 && (c as u8 as char).is_ascii_digit() {
1320        // c:578 idigit(c)
1321        count = 0; // c:579
1322        loop {
1323            count = 10 * count + (c - b'0' as i32); // c:581
1324            c = ingetc() // c:582 ingetc()
1325                .map(|ch| ch as i32)
1326                .unwrap_or(-1);
1327            if c < 0 || !(c as u8 as char).is_ascii_digit() {
1328                // c:583
1329                break;
1330            }
1331        }
1332    } else {
1333        count = 0; // c:586
1334    }
1335    if c >= 0 {
1336        if let Some(ch) = char::from_u32(c as u32) {
1337            // c:587 inungetc(c)
1338            inungetc(ch);
1339        }
1340    }
1341    count // c:588
1342}
1343
1344/// Port of `strinbeg()` from `Src/hist.c:1033`. — C decl `strinbeg(int dohist)`.
1345///
1346/// C body:
1347///     strin++;
1348///     hbegin(dohist);
1349///     lexinit();
1350///     init_parse_status();
1351pub fn strinbeg(dohist: i32) {
1352    // c:1033
1353    // !!! RUST-ONLY GUARD: the history-side `strin` is process-global and
1354    // the main thread's hbegin reads it (`&& !strin`, c:1163); a worker-pool
1355    // parse bumping it made a real command line look like string input.
1356    // Pool threads keep only the thread-local input-side copy below.
1357    if !crate::worker::in_worker_thread() {
1358        strin.fetch_add(1, SeqCst); // c:1035
1359    }
1360                                // C has ONE `strin`; zshrs splits it into hist.rs `strin` (history
1361                                // logic above/below) and input.rs `strin` (the copy `ingetc` checks
1362                                // at input.rs:390 to decide "string input drained → EOF" vs "read
1363                                // more SHIN"). The single C `strin++` must bump BOTH — same
1364                                // paired-global rule as `lexstop`. Without the input-side bump, a
1365                                // nested string parse (cmd-subst body via parse_isolated) that
1366                                // drained its LEX_INPUT fell through to `inputline()` and STOLE the
1367                                // outer reader's next SHIN line.
1368    crate::ported::input::strin.with(|s| s.set(s.get() + 1));
1369    hbegin(dohist); // c:1036
1370    lexinit(); // c:1037
1371    init_parse_status(); // c:1042
1372}
1373
1374/// Port of `strinend()` from `Src/hist.c:1049`. — C decl `strinend(void)`.
1375///
1376/// C body:
1377///     hend(NULL);
1378///     DPUTS(!strin, "BUG: strinend() called without strinbeg()");
1379///     strin--;
1380///     isfirstch = 1;
1381///     histdone = 0;
1382///
1383/// `isfirstch = 1` and `histdone = 0` resets are critical for the
1384/// `^foo^bar`-style histsubchar shortcut that keys on `isfirstch`
1385/// in the next strinbeg-driven parse.
1386pub fn strinend() {
1387    // c:1049
1388    hend(None); // c:1051
1389    // Pool threads never bumped the global `strin` or opened a history line
1390    // (see strinbeg / hbegin), so they leave both alone here too.
1391    let main_thread = !crate::worker::in_worker_thread();
1392    if main_thread {
1393        // c:1052 — DPUTS(!strin, "BUG: strinend() called without strinbeg()")
1394        DPUTS!(
1395            // c:1052
1396            strin.load(Ordering::SeqCst) == 0, // c:1052 !strin
1397            "BUG: strinend() called without strinbeg()"  // c:1052
1398        );
1399        strin.fetch_sub(1, SeqCst); // c:1053
1400    }
1401    // Mirror the input-side `strin` decrement (see strinbeg note).
1402    crate::ported::input::strin.with(|s| s.set(s.get() - 1));
1403    LEX_ISFIRSTCH.with(|f| f.set(true)); // c:1054 isfirstch = 1
1404    if main_thread {
1405        histdone.store(0, SeqCst); // c:1055 histdone = 0
1406    }
1407}
1408
1409/// Port of `nohw()` from `Src/hist.c:1062`. — C decl `nohw(UNUSED(int c))`.
1410pub fn nohw(_c: i32) { /* do nothing */
1411} // c:1062
1412
1413/// Port of `nohwabort()` from `Src/hist.c:1067`. — C decl `nohwabort(void)`.
1414pub fn nohwabort() { /* do nothing */
1415} // c:1067
1416
1417/// Port of `nohwe()` from `Src/hist.c:1072`. — C decl `nohwe(void)`.
1418pub fn nohwe() { /* do nothing */
1419} // c:1072
1420
1421/// Port of `ihwbegin()` from `Src/hist.c:1656`. — C decl `ihwbegin(int offset)`.
1422///
1423/// C gate:
1424/// ```c
1425/// if (stophist == 2 || (histactive & HA_INWORD) ||
1426///     (inbufflags & (INP_ALIAS|INP_HIST)) == INP_ALIAS)
1427///     return;
1428/// ```
1429///
1430/// The previous Rust port checked only `stophist == 2 || HA_INWORD`,
1431/// missing the `INP_ALIAS-only` gate. Effect: word-position
1432/// recording fired during alias expansion (when `INP_ALIAS` is set
1433/// and `INP_HIST` is not), capturing alias-substituted bytes as
1434/// fresh words. The C source skips alias-only input from the
1435/// history-word table because aliases are pre-expansion and
1436/// shouldn't show up as user-typed words in `!:N` etc.
1437pub fn ihwbegin(offset: i32) {
1438    // c:1656
1439    // !!! WARNING: RUST-ONLY GUARD — NO C COUNTERPART !!!
1440    // C flips `hgetc`/`hungetc`/`hwaddc`/`hwbegin`/`hwabort`/`hwend`/
1441    // `addtoline` to their no-op twins (`ingetc`/`inungetc`/`nohw`/
1442    // `nohwabort`/`nohwe`) whenever `stophist == 2` (c:1134-1145) —
1443    // that is how a non-history parse avoids touching the history line
1444    // buffers. `stophist`, `chline`, `hptr`, `chwords` and `chwordpos`
1445    // are PROCESS globals in this port, so a worker-pool thread cannot
1446    // set `stophist = 2` without also disabling the interactive shell's
1447    // history. Background parses (compinit's bytecode backfill compiles
1448    // ~47k autoload bodies on the pool) are never history events, so a
1449    // pool thread behaves as if `stophist == 2`. Without this, the
1450    // worker's `gettok` → `ihwbegin` raced `chwordpos`/`chwords` with
1451    // the main thread's live command line and panicked in `Vec::resize`
1452    // ("capacity overflow"), poisoning the mutex and killing the shell.
1453    // Same guard on the other six hooks (c:1139-1145).
1454    if crate::worker::in_worker_thread() {
1455        return;
1456    }
1457    // c:1658 — `int pos = hptr - chline + offset;`. The C `hptr` is
1458    // the current write position into the chline buffer; `pos` is
1459    // the byte offset from buffer start + caller-supplied offset.
1460    // The previous Rust port used `chline.lock().unwrap().len()`
1461    // which is only equal to `hptr - chline` when hptr is at end —
1462    // any lexer rewind (e.g. backquote, comment-resume) shifts hptr
1463    // earlier and the chline.len() reading would record the WRONG
1464    // word start, producing off-by-many history word offsets.
1465    let hptr_val = hptr.load(SeqCst); // c:1658
1466    let stop = stophist.load(SeqCst);
1467    let active = histactive.load(SeqCst);
1468    let inflags = crate::ported::input::inbufflags.with(|f| f.get());
1469    // c:1659 — `(inbufflags & (INP_ALIAS|INP_HIST)) == INP_ALIAS`
1470    // means "alias-only input (no history layered above)".
1471    if stop == 2 || (active & HA_INWORD) != 0 || (inflags & (INP_ALIAS | INP_HIST)) == INP_ALIAS
1472    // c:1659
1473    {
1474        return;
1475    }
1476    let pos = chwordpos.load(SeqCst);
1477    // `pos > 0` is a RUST-ONLY floor on C's bare `if (chwordpos%2)`
1478    // (c:1662). C is single-threaded, so `chwordpos` is an invariantly
1479    // non-negative index into `chwords`; zshrs runs parallel lexers
1480    // against the same atomic (see the race note in ihwend below), and
1481    // an interleaving that drives it negative makes `-1 % 2 == -1`
1482    // "odd", so this decrement walks it further down. Everything
1483    // downstream then indexes `chwords[chwordpos]`, and a negative i32
1484    // `as usize` wraps to ~2^64 — `words.resize(idx + 1, 0)` panicked
1485    // with "attempt to add with overflow", poisoning `chwords` for the
1486    // rest of the process. Under C's invariant the guard never fires.
1487    if pos % 2 != 0 && pos > 0 {
1488        // c:1662 chwordpos%2
1489        chwordpos.fetch_sub(1, SeqCst); // c:1663
1490    }
1491    // c:1664-1665 — DPUTS1(pos < 0, "History word position < 0 in %s",
1492    //                      dupstrpfx(chline, hptr-chline))
1493    let word_pos = (hptr_val as i32) + offset; // c:1664 pos
1494    DPUTS1!(
1495        // c:1664
1496        word_pos < 0,                      // c:1664
1497        "History word position < 0 in {}", // c:1664
1498        {
1499            // c:1665 dupstrpfx(chline, hptr-chline)
1500            let line = chline.lock().unwrap(); // c:1665
1501            line.chars().take(hptr_val).collect::<String>() // c:1665
1502        }
1503    );
1504    // c:1666 — `if (pos < 0) pos = 0;`. The .max(0) clamp.
1505    let start = word_pos.max(0) as i16; // c:1658
1506    let mut words = chwords.lock().unwrap();
1507    // `.max(0)` before the cast — a negative i32 `as usize` wraps to a
1508    // near-`usize::MAX` index and the `idx + 1` below then overflows.
1509    // See the floor comment above.
1510    let idx = chwordpos.load(SeqCst).max(0) as usize;
1511    if words.len() <= idx {
1512        words.resize(idx + 1, 0);
1513    }
1514    words[idx] = start; // c:1668
1515    chwordpos.fetch_add(1, SeqCst); // c:1668 chwordpos++
1516}
1517
1518/// Port of `linkcurline()` from `Src/hist.c:1079`. — C decl `linkcurline(void)`.
1519pub fn linkcurline() {
1520    // c:1079
1521    let new_hist = curhist.fetch_add(1, SeqCst) + 1; // c:1093 ++curhist
1522    let mut cur = curline.lock().unwrap();
1523    *cur = Some(make_histent(new_hist, String::new())); // c:1093 curline.histnum
1524                                                        // Splicing into the ring (c:1081-1088) is encoded by the Vec::insert
1525                                                        // at hist_ring index 0 done by hend() on commit. The sentinel itself
1526                                                        // lives in `curline` until then.
1527}
1528
1529/// Port of `unlinkcurline()` from `Src/hist.c:1093`. — C decl `unlinkcurline(void)`.
1530pub fn unlinkcurline() {
1531    // c:1093-1102 — C splices the STATIC `curline` struct out of the
1532    // ring; its fields (histnum included) survive. Two readers depend
1533    // on that during command execution: bin_fc's default-range check
1534    // `curline.histnum == curhist` (builtin.c:1581 — excludes the
1535    // in-flight fc command from `fc -l`) and loop()'s preexec-hook
1536    // same-event check (init.c:195). zshrs's ring never physically
1537    // holds the curline sentinel, so the splice itself is a no-op —
1538    // the previous `*curline = None` DROPPED histnum and broke both.
1539    curhist.fetch_sub(1, SeqCst); // c:1103
1540}
1541
1542/// Port of `hbegin()` from `Src/hist.c:1110`. — C decl `hbegin(int dohist)`.
1543pub fn hbegin(dohist: i32) {
1544    // c:1110
1545    // c:1114 — `isfirstln = isfirstch = 1;`. These live in lex.rs LEX_*
1546    // thread_locals. hbegin runs at the top of every loop() iteration, so
1547    // resetting them here makes the NEXT command start on its first line —
1548    // the PS1 prompt, not the PS2 continuation prompt. The prior port left
1549    // this to "the caller", but no caller did it, so every interactive
1550    // prompt after the first rendered as `>` (PS2).
1551    crate::ported::lex::LEX_ISFIRSTLN.with(|c| c.set(true));
1552    crate::ported::lex::LEX_ISFIRSTCH.with(|c| c.set(true));
1553
1554    // !!! WARNING: RUST-ONLY GUARD — NO C COUNTERPART !!!
1555    // Everything below writes the PROCESS-global history state
1556    // (histactive, histdone, stophist, chline, curhist via linkcurline),
1557    // which C, having one thread, owns outright. A worker-pool parse
1558    // (async precmd hooks, autoload backfill) runs this concurrently with
1559    // the main thread's command line: its HA_NOINC hend cleared histactive
1560    // between the main line's hbegin and hend, so the main hend took the
1561    // unlinkcurline arm and the next command reused an event number —
1562    // `fc -l` then looped forever on the duplicate. A pool parse records
1563    // no history, so it leaves that state alone (same rule as ihwaddc).
1564    if crate::worker::in_worker_thread() {
1565        return;
1566    }
1567
1568    errflag.fetch_and(
1569        // c:1115
1570        !ERRFLAG_ERROR,
1571        Ordering::Relaxed,
1572    );
1573    histdone.store(0, SeqCst); // c:1116
1574                               // c:1117 — `isset(INTERACTIVE)` / `isset(SHINSTDIN)`.
1575    let interact = isset(INTERACTIVE);
1576    let shinstdin = isset(SHINSTDIN);
1577    if dohist == 0 {
1578        // c:1117
1579        stophist.store(2, SeqCst); // c:1118
1580    } else if dohist != 2 {
1581        // c:1119
1582        stophist.store(
1583            if !interact || !shinstdin { 2 } else { 0 }, // c:1120
1584            SeqCst,
1585        );
1586    } else {
1587        // c:1121
1588        stophist.store(0, SeqCst); // c:1122
1589    }
1590
1591    if stophist.load(SeqCst) == 2 {
1592        // c:1134
1593        chline.lock().unwrap().clear(); // c:1135 chline = NULL
1594        hptr.store(0, SeqCst); // c:1135 hptr = NULL
1595        hlinesz.store(0, SeqCst); // c:1136
1596        chwords.lock().unwrap().clear(); // c:1137
1597        chwordlen.store(0, SeqCst); // c:1138
1598                                    // hgetc/hungetc/hwaddc/hwbegin/hwabort/hwend/addtoline are       c:1139-1145
1599                                    // function-pointer slots in C; Rust dispatches statically.
1600    } else {
1601        // c:1146
1602        let mut buf = chline.lock().unwrap(); // c:1147
1603        buf.clear();
1604        buf.reserve(64);
1605        hlinesz.store(64, SeqCst); // c:1147
1606        drop(buf);
1607        let mut w = chwords.lock().unwrap(); // c:1148
1608        w.clear();
1609        w.reserve(64);
1610        chwordlen.store(64, SeqCst);
1611        drop(w);
1612        // hgetc/hungetc/hwaddc/hwbegin/hwabort/hwend/addtoline c:1149-1155 — see c:1139.
1613        if !isset(BANGHIST) {
1614            // c:1156
1615            stophist.store(4, SeqCst); // c:1157
1616        }
1617    }
1618    chwordpos.store(0, SeqCst); // c:1159
1619
1620    {
1621        // c:1161
1622        let mut ring = hist_ring.lock().unwrap();
1623        if let Some(top) = ring.first_mut() {
1624            if top.ftim == 0 && strin.load(SeqCst) == 0 {
1625                top.ftim = std::time::SystemTime::now()
1626                    .duration_since(std::time::UNIX_EPOCH)
1627                    .map(|d| d.as_secs() as i64)
1628                    .unwrap_or(0); // c:1162
1629            }
1630        }
1631    }
1632    if (dohist == 2 || (interact && shinstdin))                              // c:1163
1633        && strin.load(SeqCst) == 0
1634    {
1635        histactive.store(HA_ACTIVE, SeqCst); // c:1164
1636                                             // c:1165 — `attachtty(mypgrp);` reclaims the controlling
1637                                             // terminal for the shell's pgrp at the start of a fresh
1638                                             // history-recording line.
1639        let mypgrp = *crate::ported::jobs::MYPGRP
1640            .get_or_init(|| Mutex::new(0))
1641            .lock()
1642            .expect("mypgrp poisoned");
1643        crate::ported::utils::attachtty(mypgrp); // c:1165
1644        linkcurline(); // c:1166
1645        defev.store(
1646            addhistnum(
1647                curhist.load(SeqCst), // c:1167
1648                -1,
1649                HIST_FOREIGN as i32,
1650            ),
1651            SeqCst,
1652        );
1653    } else {
1654        histactive.store(HA_ACTIVE | HA_NOINC, SeqCst); // c:1169
1655    }
1656
1657    if isset(INCAPPENDHISTORYTIME) // c:1189
1658        && !isset(SHAREHISTORY)
1659        && !isset(INCAPPENDHISTORY)
1660        && (histactive.load(SeqCst) & HA_NOINC) == 0
1661        && strin.load(SeqCst) == 0
1662        && histsave_stack_pos.load(SeqCst) == 0
1663    {
1664        let hf = resolve_histfile(); // c:1192
1665        savehistfile(
1666            hf.as_deref(),
1667            0                                        // c:1193
1668            | HFILE_USE_OPTIONS as i32
1669            | HFILE_FAST as i32,
1670        );
1671    }
1672}
1673
1674/// Port of `histreduceblanks()` from `Src/hist.c:1199`. — C decl `histreduceblanks(void)`.
1675///
1676/// Operates on the global `chline` / `chwords` / `chwordpos` exactly as C
1677/// does: each recorded word is moved left to close the gap before it,
1678/// carrying the ONE separator byte that followed it, so quoted text inside a
1679/// word (`"a  b"`) is never touched and a tab separator stays a tab. Leading
1680/// spaces survive only under HIST_IGNORE_SPACE (c:1204-1205), which is what
1681/// keeps an ignore-space line recognisable as one.
1682///
1683/// The previous port collapsed every blank run in the raw string and trimmed
1684/// both ends, so ` print   "a  b"` was stored — and handed to preexec as
1685/// `$1` — as `print "a b"`.
1686pub fn histreduceblanks() {
1687    // c:1199
1688    let mut line = chline.lock().unwrap();
1689    let mut words = chwords.lock().unwrap();
1690    let wpos = (chwordpos.load(SeqCst).max(0) as usize).min(words.len());
1691    let mut buf: Vec<u8> = std::mem::take(&mut *line).into_bytes();
1692    // C reads a NUL-terminated buffer; the Rust String ends at its length.
1693    let at = |b: &[u8], i: usize| b.get(i).copied().unwrap_or(0);
1694
1695    let mut spacecount = 0usize; // c:1201
1696    if isset(HISTIGNORESPACE) {
1697        // c:1204
1698        while at(&buf, spacecount) == b' ' {
1699            spacecount += 1; // c:1205
1700        }
1701    }
1702
1703    let mut len = spacecount; // c:1207
1704    let mut i = 0usize;
1705    while i + 1 < wpos {
1706        len += (words[i + 1] - words[i]).max(0) as usize // c:1208
1707            + (i > 0 && words[i] > words[i - 1]) as usize; // c:1209
1708        i += 2;
1709    }
1710    if at(&buf, len) == 0 {
1711        // c:1211
1712        *line = String::from_utf8(buf).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
1713        return; // c:1212
1714    }
1715
1716    /* Remember where the delimited words end */
1717    let lastptr = if wpos != 0 {
1718        // c:1215
1719        words[wpos - 1].max(0) as usize // c:1216
1720    } else {
1721        0 // c:1218
1722    };
1723
1724    let mut pos = spacecount; // c:1220
1725    i = 0;
1726    while i + 1 < wpos {
1727        let len = (words[i + 1] - words[i]).max(0) as usize; // c:1221
1728        let needblank = (i + 2 < wpos && words[i + 2] > words[i + 1]) as usize; // c:1222
1729        let from = words[i].max(0) as usize;
1730        if pos != from {
1731            // c:1223
1732            let end = (from + len + needblank).min(buf.len());
1733            if from < end {
1734                buf.copy_within(from..end, pos); // c:1224 memmove
1735            }
1736            words[i] = pos as i16; // c:1225
1737            words[i + 1] = (pos + len) as i16; // c:1226
1738        }
1739        pos += len + needblank; // c:1228
1740        i += 2;
1741    }
1742
1743    /*
1744     * A terminating comment isn't recorded as a word.
1745     * Only truncate the line if just whitespace remains.
1746     */
1747    let tail = buf.get(lastptr..).unwrap_or(&[]);
1748    let trunc_ok = tail
1749        .iter()
1750        .take_while(|&&b| b != 0)
1751        .all(|&b| b == b' ' || b == b'\t'); // c:1235-1241 inblank
1752    if trunc_ok {
1753        buf.truncate(pos); // c:1243 chline[pos] = '\0'
1754    } else if pos < lastptr {
1755        // c:1245-1248 — copy the tail from lastptr down to pos.
1756        buf.drain(pos..lastptr.min(buf.len()));
1757    }
1758    *line = String::from_utf8(buf).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
1759}
1760
1761#[cfg(test)]
1762use reduce_blanks_test_util::{reduce_blanks, reduce_line};
1763
1764/// Test-only drivers for `histreduceblanks`, shared by the test modules below.
1765#[cfg(test)]
1766mod reduce_blanks_test_util {
1767    use super::*;
1768
1769    /// Run `histreduceblanks` over `line` whose recorded words are the
1770    /// `[start, end)` byte pairs in `words`, restoring the history globals.
1771    /// The caller must hold `test_util::global_state_lock`.
1772    pub(crate) fn reduce_blanks(line: &str, words: &[i16], ignorespace: bool) -> String {
1773        let saved = (
1774            chline.lock().unwrap().clone(),
1775            chwords.lock().unwrap().clone(),
1776            chwordpos.load(SeqCst),
1777            isset(HISTIGNORESPACE),
1778        );
1779        *chline.lock().unwrap() = line.to_string();
1780        *chwords.lock().unwrap() = words.to_vec();
1781        chwordpos.store(words.len() as i32, SeqCst);
1782        dosetopt(HISTIGNORESPACE, ignorespace as i32, 0);
1783        histreduceblanks();
1784        let out = chline.lock().unwrap().clone();
1785        *chline.lock().unwrap() = saved.0;
1786        *chwords.lock().unwrap() = saved.1;
1787        chwordpos.store(saved.2, SeqCst);
1788        dosetopt(HISTIGNORESPACE, saved.3 as i32, 0);
1789        out
1790    }
1791
1792    /// [`reduce_blanks`] with the words `histsplitwords` finds in `line`
1793    /// (blank-separated spans), HIST_IGNORE_SPACE off.
1794    pub(crate) fn reduce_line(line: &str) -> String {
1795        let words: Vec<i16> = histsplitwords(line, false)
1796            .iter()
1797            .flat_map(|&(s, e)| [s as i16, e as i16])
1798            .collect();
1799        reduce_blanks(line, &words, false)
1800    }
1801}
1802
1803/// Port of `histremovedups()` from `Src/hist.c:1254`. — C decl `histremovedups(void)`.
1804///
1805/// C body:
1806/// ```c
1807/// Histent he, next;
1808/// for (he = hist_ring; he; he = next) {
1809///     next = up_histent(he);
1810///     if (he->node.flags & HIST_DUP)
1811///         freehistnode(&he->node);
1812/// }
1813/// ```
1814///
1815/// The previous Rust port did NAME-based deduplication using a
1816/// HashSet — totally different semantic. C removes entries that
1817/// have the HIST_DUP flag set (which the history-add path sets
1818/// only when HIST_IGNORE_ALL_DUPS / HIST_IGNORE_DUPS marked the
1819/// entry as a duplicate). With HIST_IGNORE_ALL_DUPS off, identical
1820/// commands stay in the ring intentionally; the Rust port would
1821/// have aggressively pruned them anyway.
1822pub fn histremovedups() {
1823    // c:1254
1824    let mut ring = hist_ring.lock().unwrap();
1825    ring.retain(|h| (h.node.flags as u32 & HIST_DUP) == 0); // c:1259-1260
1826    let new_ct = ring.len() as i64;
1827    drop(ring);
1828    histlinect.store(new_ct, SeqCst);
1829}
1830
1831/// Port of `addhistnum()` from `Src/hist.c:1266`. — C decl `addhistnum(zlong hl, int n, int xflags)`.
1832pub fn addhistnum(hl: i64, mut n: i32, xflags: i32) -> i64 {
1833    // c:1266
1834    let dir: i32 = if n < 0 {
1835        -1
1836    } else if n > 0 {
1837        1
1838    } else {
1839        0
1840    }; // c:1266
1841    let he = gethistent(hl, dir); // c:1269
1842    let he = match he {
1843        None => return 0, // c:1271-1272
1844        Some(h) => h,
1845    };
1846    if he != hl {
1847        // c:1273
1848        n -= dir; // c:1274
1849    }
1850    let final_he = if n != 0 {
1851        // c:1275
1852        movehistent(he, n, xflags as u32) // c:1276
1853    } else {
1854        Some(he)
1855    };
1856    match final_he {
1857        // c:1277
1858        None => {
1859            if dir < 0 {
1860                // c:1278
1861                firsthist() - 1
1862            } else {
1863                curhist.load(SeqCst) + 1
1864            }
1865        }
1866        Some(h) => h, // c:1279
1867    }
1868}
1869
1870/// Port of `movehistent()` from `Src/hist.c:1284`. — C decl `movehistent(Histent he, int n, int xflags)`.
1871///
1872/// The previous Rust port omitted the `checkcurline(he)` call at
1873/// c:1298 — when the walk lands on the in-flight history entry,
1874/// C flushes the current chline/chwordpos/chwords build state into
1875/// `curline` so the caller sees fresh word data. Without it, code
1876/// that walks back to the current entry and then reads `curline`
1877/// got stale word data.
1878pub fn movehistent(start: i64, mut n: i32, xflags: u32) -> Option<i64> {
1879    // c:1284
1880    let mut cur = start;
1881    while n < 0 {
1882        // c:1286
1883        cur = up_histent(cur)?; // c:1287
1884        if let Some(e) = ring_get(cur) {
1885            if (e.node.flags as u32 & xflags) == 0 {
1886                // c:1289
1887                n += 1; // c:1290
1888            }
1889        }
1890    }
1891    while n > 0 {
1892        // c:1292
1893        cur = down_histent(cur)?; // c:1293
1894        if let Some(e) = ring_get(cur) {
1895            if (e.node.flags as u32 & xflags) == 0 {
1896                // c:1295
1897                n -= 1; // c:1296
1898            }
1899        }
1900    }
1901    // c:1298 — `checkcurline(he);` flushes in-flight build state
1902    // into `curline` if the walk landed on the active history entry.
1903    if let Some(e) = ring_get(cur) {
1904        checkcurline(&e); // c:1298
1905    }
1906    Some(cur) // c:1299
1907}
1908
1909/// Port of `up_histent()` from `Src/hist.c:1304`. — C decl `up_histent(Histent he)`.
1910pub fn up_histent(current: i64) -> Option<i64> {
1911    // c:1304
1912    let pos = ring_position(current)?; // c:1306 !he
1913    (pos + 1 < ring_len()).then(|| ring_at(pos + 1)) // c:1306 he->up == hist_ring? NULL : he->up
1914}
1915
1916/// Port of `down_histent()` from `Src/hist.c:1311`. — C decl `down_histent(Histent he)`.
1917pub fn down_histent(current: i64) -> Option<i64> {
1918    // c:1311
1919    let pos = ring_position(current)?;
1920    (pos > 0).then(|| ring_at(pos - 1)) // c:1313 he == hist_ring? NULL : he->down
1921}
1922
1923/// Port of `gethistent()` from `Src/hist.c:1318`. — C decl `gethistent(zlong ev, int nearmatch)`.
1924pub fn gethistent(ev: i64, nearmatch: i32) -> Option<i64> {
1925    // c:1318
1926    if ring_len() == 0 {
1927        return None;
1928    }
1929    if ring_get(ev).is_some() {
1930        return Some(ev);
1931    }
1932    if nearmatch == 0 {
1933        return None;
1934    }
1935    let mut best_older: Option<i64> = None;
1936    let mut best_newer: Option<i64> = None;
1937    for i in 0..ring_len() {
1938        let n = ring_at(i);
1939        if n < ev && best_older.map_or(true, |b| n > b) {
1940            best_older = Some(n);
1941        } else if n > ev && best_newer.map_or(true, |b| n < b) {
1942            best_newer = Some(n);
1943        }
1944    }
1945    if nearmatch < 0 {
1946        best_older
1947    } else {
1948        best_newer
1949    }
1950}
1951
1952/// Port of `putoldhistentryontop()` from `Src/hist.c:1347`. — C decl `putoldhistentryontop(short keep_going)`.
1953/// Rotate the next-to-expire entry to the head of the ring so that
1954/// the subsequent expire/save pass evicts it. When
1955/// `HISTEXPIREDUPSFIRST` is set and the candidate is not already a
1956/// duplicate, walk forward up to `savehistsiz` slots looking for the
1957/// next entry that IS a duplicate (so dup entries get evicted first).
1958/// `keep_going` advances the per-call cursor instead of resetting
1959/// it — used by save loops that expire multiple entries in succession.
1960pub fn putoldhistentryontop(keep_going: i32) -> i32 {
1961    // c:1347
1962
1963    thread_local! {
1964        // c:1349 — `static Histent next = NULL;` (per-evaluator cursor).
1965        static NEXT_IDX: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
1966        // c:1356 — `static zlong max_unique_ct = 0;`
1967        static MAX_UNIQUE_CT: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
1968    }
1969
1970    let mut ring = hist_ring.lock().unwrap();
1971    if ring.is_empty() {
1972        return 0;
1973    }
1974
1975    // c:1350 — `he = (keep_going || !hist_ring) ? next : hist_ring->down;`
1976    //          In the Vec model the oldest entry is at the tail.
1977    let mut idx: Option<usize> = if keep_going != 0 {
1978        NEXT_IDX.with(|c| c.get())
1979    } else {
1980        Some(ring.len() - 1) // oldest = last element
1981    };
1982
1983    // c:1352-1354 — `if (he) next = he->down; else return;`
1984    let mut cur_idx = match idx {
1985        Some(i) if i < ring.len() => i,
1986        _ => return 0,
1987    };
1988    // Advance `next` to the slot one step further into the past — in the
1989    // ring that's one position closer to the head (newer).
1990    idx = if cur_idx == 0 {
1991        None
1992    } else {
1993        Some(cur_idx - 1)
1994    };
1995    NEXT_IDX.with(|c| c.set(idx));
1996
1997    // c:1355-1370 — HISTEXPIREDUPSFIRST: skip non-dups until we find one.
1998    let exp_dups_first = isset(HISTEXPIREDUPSFIRST);
1999    if exp_dups_first && (ring[cur_idx].node.flags as u32 & HIST_DUP) == 0 {
2000        if keep_going == 0 {
2001            // c:1357-1358 — `if (!keep_going) max_unique_ct = savehistsiz;`
2002            MAX_UNIQUE_CT.with(|c| c.set(savehistsiz.load(SeqCst) as i64));
2003        }
2004        loop {
2005            let cur = MAX_UNIQUE_CT.with(|c| {
2006                let v = c.get();
2007                c.set(v - 1);
2008                v
2009            });
2010            if cur <= 0 {
2011                // c:1360-1365 — give up: reset to ring head (oldest).
2012                MAX_UNIQUE_CT.with(|c| c.set(0));
2013                cur_idx = ring.len() - 1;
2014                NEXT_IDX.with(|c| {
2015                    c.set(if cur_idx == 0 {
2016                        None
2017                    } else {
2018                        Some(cur_idx - 1)
2019                    })
2020                });
2021                break;
2022            }
2023            // c:1367-1368 — `he = next; next = he->down;`
2024            cur_idx = match NEXT_IDX.with(|c| c.get()) {
2025                Some(i) if i < ring.len() => i,
2026                _ => return 0,
2027            };
2028            let nxt = if cur_idx == 0 {
2029                None
2030            } else {
2031                Some(cur_idx - 1)
2032            };
2033            NEXT_IDX.with(|c| c.set(nxt));
2034            // c:1369 — `} while (!(he->node.flags & HIST_DUP));`
2035            if (ring[cur_idx].node.flags as u32 & HIST_DUP) != 0 {
2036                break;
2037            }
2038        }
2039    }
2040
2041    // c:1372-1382 — splice the chosen entry to ring head (position 0).
2042    if cur_idx < ring.len() && cur_idx != 0 {
2043        let entry = ring.remove(cur_idx);
2044        ring.insert(0, entry);
2045    }
2046    1
2047}
2048
2049/// Port of `prepnexthistent()` from `Src/hist.c:1387`. — C decl `prepnexthistent(void)`.
2050pub fn prepnexthistent() -> i64 {
2051    // c:1387
2052    let cap = histsiz.load(SeqCst);
2053    if cap > 0 && histlinect.load(SeqCst) >= cap {
2054        if let Some(oldest) = ring_oldest() {
2055            // c:1410 — `freehistdata(hist_ring, 0)`: drop the evicted
2056            // entry's histtab key. Without it every unique command text
2057            // stayed in histtab for the life of the shell.
2058            let idx = hist_ring.lock().unwrap().iter().position(|h| h.histnum == oldest);
2059            if let Some(idx) = idx {
2060                crate::ported::hashtable::freehistdata(idx, 0);
2061            }
2062            // Drop oldest from ring
2063            let mut ring = hist_ring.lock().unwrap();
2064            ring.retain(|h| h.histnum != oldest);
2065            histlinect.fetch_sub(1, SeqCst);
2066        }
2067    }
2068    let n = curhist.fetch_add(1, SeqCst) + 1;
2069    n
2070}
2071
2072/// Port of `should_ignore_line()` from `Src/hist.c:1425`. — C decl `should_ignore_line(Eprog prog)`.
2073fn should_ignore_line(prog: Option<&[u8]>) -> i32 {
2074    // c:1425
2075    let line = chline.lock().unwrap().clone();
2076    if isset(HISTIGNORESPACE) {
2077        // c:1427
2078        // c:1428 — `if (*chline == ' ' || aliasspaceflag)`. The
2079        // aliasspaceflag arm fires when the lexer expanded a
2080        // non-global alias whose body starts with a space (set at
2081        // lex.c:1930 / lex.rs LEX_ALIAS_SPACE_FLAG).
2082        let alias_space = crate::ported::lex::LEX_ALIAS_SPACE_FLAG.with(|c| c.get()) != 0;
2083        if line.starts_with(' ') || alias_space {
2084            return 1; // c:1429
2085        }
2086    }
2087    if prog.is_none() {
2088        // c:1432
2089        return 0; // c:1433
2090    }
2091    if isset(HISTNOFUNCTIONS) {
2092        // c:1435
2093        // Inspecting an Eprog requires the wordcode VM port — leave the
2094        // funcdef detection to the executor; conservatively return 0.    // c:1436-1440
2095        return 0;
2096    }
2097    if isset(HISTNOSTORE) {
2098        // c:1443
2099        // getjobtext(prog, NULL) — text reconstruction also needs the
2100        // wordcode VM. Apply the simpler text-based filters on chline
2101        // for the cases the C code carves out.
2102        let mut b: &str = &line;
2103        let mut saw_builtin = false;
2104        if let Some(rest) = b.strip_prefix("builtin ") {
2105            // c:1446
2106            b = rest;
2107            saw_builtin = true;
2108        }
2109        if (b == "history" || b.starts_with("history "))                     // c:1451
2110            && (saw_builtin /* || shfunctab.getnode("history").is_none() */)
2111        {
2112            return 1; // c:1453
2113        }
2114        if (b == "r" || b.starts_with("r "))                                 // c:1454
2115            && (saw_builtin /* || shfunctab.getnode("r").is_none() */)
2116        {
2117            return 1;
2118        }
2119        if let Some(rest) = b.strip_prefix("fc -") {
2120            // c:1457
2121            if (saw_builtin/* || shfunctab.getnode("fc").is_none() */)
2122                && rest
2123                    .chars()
2124                    .take_while(|c| c.is_ascii_alphabetic())
2125                    .any(|c| c == 'l')
2126            {
2127                return 1; // c:1474
2128            }
2129        }
2130    }
2131    0 // c:1474
2132}
2133
2134/// Port of `hend()` from `Src/hist.c:1474`. — C decl `hend(Eprog prog)`.
2135pub fn hend(prog: Option<&[u8]>) -> i32 {
2136    // c:1474
2137    // !!! WARNING: RUST-ONLY GUARD — NO C COUNTERPART !!!
2138    // A worker-pool parse never opened a history line (hbegin returns early
2139    // on pool threads), so there is nothing to end; running the body would
2140    // read and clear the main thread's histactive and could unlinkcurline
2141    // its curhist. Same answer as the c:1494-1501 HA_NOINC exit.
2142    if crate::worker::in_worker_thread() {
2143        return 1;
2144    }
2145    let stack_pos = histsave_stack_pos.load(SeqCst); // c:1474
2146    let mut save: i32 = 1; // c:1484
2147    let mut hookret: i32 = 0;
2148
2149    // DPUTS(stophist != 2 && !(inbufflags & INP_ALIAS) && !chline,       c:1487
2150    //       "BUG: chline is NULL in hend()");
2151    crate::ported::signals::queue_signals(); // c:1489
2152    if (histdone.load(SeqCst) & HISTFLAG_SETTY) != 0 {
2153        // c:1491 settyinfo(&shttyinfo) — restore the cooked terminal
2154        // baseline captured by zsetterm before ZLE went raw. The
2155        // interactive read uses ZLRF_NOSETTY (input.c:418), so ZLE's own
2156        // trashzle deliberately does NOT restore the tty; the restore is
2157        // deferred to here, when the input line's history finalizes, right
2158        // before the command executes. Without it the tty stayed in ZLE
2159        // raw mode across command execution — `cat` never saw EOF on `^D`,
2160        // interactive programs got raw single-byte input. shttyinfo now
2161        // lands as utils::SHTTYINFO.
2162        if let Some(ti) = crate::ported::utils::SHTTYINFO.lock().ok().and_then(|g| *g) {
2163            crate::ported::utils::settyinfo(&ti);
2164        }
2165    }
2166    let active = histactive.load(SeqCst);
2167    if (active & HA_NOINC) == 0 {
2168        // c:1492
2169        unlinkcurline(); // c:1493
2170    }
2171    if (active & HA_NOINC) != 0 {
2172        // c:1494
2173        chline.lock().unwrap().clear(); // c:1495 zfree(chline)
2174        chwords.lock().unwrap().clear(); // c:1496 zfree(chwords)
2175        hptr.store(0, SeqCst); // c:1497
2176        histactive.store(0, SeqCst); // c:1499
2177        unqueue_signals(); // c:1500
2178        return 1; // c:1501
2179    }
2180    let cur_ignore_all = if isset(HISTIGNOREALLDUPS) { 1 } else { 0 }; // c:1503
2181    let prev_ignore_all = hist_ignore_all_dups.load(SeqCst);
2182    if prev_ignore_all != cur_ignore_all                                     // c:1503
2183        && {
2184            hist_ignore_all_dups.store(cur_ignore_all, SeqCst);    // c:1504
2185            cur_ignore_all != 0
2186        }
2187    {
2188        histremovedups(); // c:1505
2189    }
2190    // c:1507-1514 — `if (hptr) { DPUTS(hptr < chline, "History end
2191    // pointer off start of line"); *hptr = '\0'; }` — C terminates the
2192    // history line AT the cursor, so the stored line is `chline[0..hptr]`.
2193    //
2194    // NOT applied here, deliberately. zshrs's `hptr` is only a faithful
2195    // end-of-line marker on the plain lexing path; on the history-
2196    // EXPANSION path it is not. C feeds the expansion back through
2197    // `inpush(sline, INP_HIST, NULL)` + `return ingetc()` (c:976-983) so
2198    // every expanded character re-enters `ihgetc` and is re-`hwaddc`'d
2199    // (c:459), keeping `hptr` at the true end. zshrs splits lexer input
2200    // across `input::inbuf` and the Rust-only `LEX_INPUT`/`LEX_UNGET_BUF`
2201    // window, and on the INP_HIST re-read the two disagree: `hptr` ends up
2202    // BEHIND the expanded text that `ihwaddc` already wrote into `chline`.
2203    // Truncating at it therefore drops the expansion — `echo M !:6` stored
2204    // as `echo M ` instead of `echo M E` (verified: `fc -l` after
2205    // `zshrs --zsh -fis` with the truncation applied). Plain lines were
2206    // unaffected, which is what pins the gap to the INP_HIST re-read.
2207    //
2208    // Until that path keeps `hptr` honest, taking the whole buffer is the
2209    // strictly safer divergence: it can only ever include MORE than C
2210    // would, and today `hptr == chline.len()` on every non-expansion line.
2211    let chline_text = chline.lock().unwrap().clone();
2212    if !chline_text.is_empty() {
2213        // c:1515
2214        let save_errflag = errflag // c:1517
2215            .load(Ordering::Relaxed);
2216        errflag.store(0, Ordering::Relaxed); // c:1518
2217        let args = vec!["zshaddhistory".to_string(), chline_text.clone()]; // c:1520-1521
2218                                                                           // c:1522 — `callhookfunc("zshaddhistory", hookargs, 1, &hookret);`.
2219                                                                           // `hookret` is the HOOK's return value (the 4th out-param `*retval =
2220                                                                           // ret`, 0 when no hook ran), NOT callhookfunc's return (`stat`,
2221                                                                           // which is 1 whenever no hook exists). The prior port used the
2222                                                                           // `stat` return and passed NULL for retval, so with no zshaddhistory
2223                                                                           // hook (e.g. `zsh -f`) hookret was 1 → hend's `else if (hookret)`
2224                                                                           // set save=-1 (HIST_TMPSTORE) → every line was dropped by the next
2225                                                                           // command's tmpstore-purge. Pass &hookret and read the out-param.
2226        crate::ported::utils::callhookfunc(
2227            "zshaddhistory",
2228            Some(&args),
2229            1,
2230            &mut hookret as *mut i32,
2231        );
2232        let new_errflag = (errflag // c:1524-1525
2233            .load(Ordering::Relaxed)
2234            & !ERRFLAG_ERROR)
2235            | save_errflag;
2236        errflag.store(new_errflag, Ordering::Relaxed);
2237    }
2238    let hf = resolve_histfile(); // c:1528
2239    if isset(SHAREHISTORY)                                                   // c:1529
2240        && lockhistfile(hf.as_deref(), 0) == 0
2241    {
2242        readhistfile(
2243            hf.as_deref(),
2244            0, // c:1530
2245            HFILE_USE_OPTIONS as i32 | HFILE_FAST as i32,
2246        );
2247        // c:1531 — `curline.histnum = curhist+1;`. readhistfile just
2248        // appended other sessions' lines and advanced curhist; without this
2249        // the next prepnexthistent numbers this line past curline.histnum,
2250        // and loop()'s preexec check (c:Src/init.c:206) passes `$1` empty.
2251        if let Some(cl) = curline.lock().unwrap().as_mut() {
2252            cl.histnum = curhist.load(SeqCst) + 1; // c:1531
2253        }
2254    }
2255    let flag = histdone.load(SeqCst); // c:1533
2256    histdone.store(0, SeqCst); // c:1534
2257    let hptr_pos = hptr.load(SeqCst);
2258    let mut text = chline_text;
2259    if hptr_pos < 1 {
2260        // c:1535 hptr < chline + 1
2261        save = 0; // c:1536
2262    } else {
2263        if text.ends_with('\n') {
2264            // c:1538 hptr[-1] == '\n'
2265            if text.len() > 1 {
2266                // c:1539 chline[1]
2267                text.pop(); // c:1540 *--hptr = '\0'
2268                if hptr.load(SeqCst) > 0 {
2269                    hptr.fetch_sub(1, SeqCst);
2270                }
2271            } else {
2272                save = 0; // c:1542
2273            }
2274        }
2275        if chwordpos.load(SeqCst) <= 2                             // c:1544
2276            && hist_keep_comment.load(SeqCst) == 0
2277        {
2278            save = 0; // c:1545
2279        } else if should_ignore_line(prog) != 0 {
2280            // c:1546
2281            save = -1; // c:1547
2282        } else if hookret == 2 {
2283            // c:1548
2284            save = -2; // c:1549
2285        } else if hookret != 0 {
2286            // c:1550
2287            save = -1; // c:1551
2288        }
2289    }
2290    if (flag & (HISTFLAG_DONE | HISTFLAG_RECALL)) != 0 {
2291        // c:1553
2292        let ptr = text.clone(); // c:1556 ztrdup(chline)
2293        if (flag & (HISTFLAG_DONE | HISTFLAG_RECALL)) == HISTFLAG_DONE {
2294            // c:1557
2295            // c:1558-1560 — `zputs(ptr, shout); fputc('\n', shout);
2296            // fflush(shout);`. The echo of a `!`-expanded line goes to
2297            // `shout` — the TERMINAL (`fdopen(SHTTY)`, c:Src/init.c:748),
2298            // or stderr when there is no tty (c:738) — NEVER to stdout.
2299            // The previous port used `print!`, so every history expansion
2300            // injected an extra line into the script's captured stdout:
2301            // `zsh -fis <<<'print foo\nprint !!'` printed the expanded
2302            // `print foo` line ahead of its output where zsh prints
2303            // nothing on stdout at all.
2304            //
2305            // `shout` is resolved the way `init_shout` does it: the tty fd
2306            // when the shell has one (c:Src/init.c:748 `shout =
2307            // fdopen(SHTTY, "w")`), stderr when it does not (c:738 `shout
2308            // = stderr`). Resolved here rather than through
2309            // `extensions::shout::write` because that helper falls back to
2310            // fd 1, which is the very thing this fix is removing.
2311            let shout_fd = {
2312                let t = crate::ported::init::SHTTY.load(SeqCst);
2313                if t >= 0 {
2314                    t // c:Src/init.c:748
2315                } else {
2316                    2 // c:Src/init.c:738 — shout = stderr
2317                }
2318            };
2319            let mut out = crate::ported::utils::unmetafy_str(&ptr); // c:1558 zputs
2320            out.push(b'\n'); // c:1559 fputc('\n', shout)
2321            let _ = crate::ported::utils::write_loop(shout_fd, &out); // c:1560 fflush
2322        }
2323        if (flag & HISTFLAG_RECALL) != 0 {
2324            // c:1562
2325            // c:1563 — `zpushnode(bufstack, ptr)` — push the expanded
2326            // history line onto the buf-stack so ZLE recalls it on
2327            // the next prompt.
2328            crate::ported::zle::zle_main::BUFSTACK
2329                .lock()
2330                .unwrap()
2331                .insert(0, ptr.clone()); // c:1563
2332            save = 0; // c:1564
2333        }
2334    }
2335    if save != 0 || text.starts_with(' ') {
2336        // c:1568
2337        // Walk up the ring skipping HIST_FOREIGN entries; if the topmost
2338        // non-foreign entry is HIST_TMPSTORE, drop it.                     // c:1569-1576
2339        let mut ring = hist_ring.lock().unwrap();
2340        let mut idx: usize = 0;
2341        while idx < ring.len() && (ring[idx].node.flags as u32 & HIST_FOREIGN) != 0 {
2342            idx += 1;
2343        }
2344        if idx < ring.len() && (ring[idx].node.flags as u32 & HIST_TMPSTORE) != 0 {
2345            if idx == 0 {
2346                // c:1573 he == hist_ring
2347                // c:1574 — `curline.histnum = curhist--;`
2348                let old = curhist.fetch_sub(1, SeqCst);
2349                if let Some(cl) = curline.lock().unwrap().as_mut() {
2350                    cl.histnum = old; // c:1574
2351                }
2352            }
2353            ring.remove(idx); // c:1575 freehistnode
2354            histlinect.fetch_sub(1, SeqCst);
2355        }
2356    }
2357    if save != 0 {
2358        // c:1578
2359        // chwordpos parity guard — if odd, hwend() to close.            // c:1583-1587
2360        if chwordpos.load(SeqCst) % 2 != 0 {
2361            ihwend();
2362        }
2363        // Strip trailing \n which we already nulled out.                    // c:1589-1595
2364        let cwp = chwordpos.load(SeqCst);
2365        if cwp > 1 {
2366            let words = chwords.lock().unwrap();
2367            let last = words.get((cwp - 2) as usize).copied().unwrap_or(0);
2368            // C: !chline[chwords[chwordpos-2]] — index past end after NUL.
2369            if (last as usize) >= text.len() {
2370                // c:1590
2371                drop(words);
2372                chwordpos.fetch_sub(2, SeqCst);
2373            } else {
2374                drop(words);
2375            }
2376            if isset(HISTREDUCEBLANKS) {
2377                // c:1593
2378                // C edits `chline` in place; `text` is this port's copy of
2379                // it (with the trailing newline already dropped, c:1540),
2380                // so hand it to the buffer histreduceblanks works on and
2381                // take the result back.
2382                *chline.lock().unwrap() = text.clone();
2383                histreduceblanks(); // c:1594
2384                text = chline.lock().unwrap().clone();
2385            }
2386        }
2387        let newflags: u32 = if save == -1 {
2388            HIST_TMPSTORE
2389        }
2390        // c:1596-1601
2391        else if save == -2 {
2392            HIST_NOWRITE
2393        } else {
2394            0
2395        };
2396        let mut he_idx: Option<usize> = None;
2397        let mut overwrite_old: u32 = 0;
2398        if (isset(HISTIGNOREDUPS) || isset(HISTIGNOREALLDUPS))                // c:1602
2399            && save > 0
2400        {
2401            let ring = hist_ring.lock().unwrap();
2402            if let Some(top) = ring.first() {
2403                if top.node.nam == text {
2404                    // c:1603 histstrcmp
2405                    overwrite_old = top.node.flags as u32 & HIST_OLD; // c:1610
2406                    he_idx = Some(0);
2407                    // c:1612 — `curline.histnum = curhist;`. The line reuses
2408                    // the top entry instead of taking a new number, so
2409                    // curline must name that entry or preexec's `$1`
2410                    // (c:Src/init.c:206) goes empty for an ignored duplicate.
2411                    if let Some(cl) = curline.lock().unwrap().as_mut() {
2412                        cl.histnum = curhist.load(SeqCst); // c:1612
2413                    }
2414                }
2415            }
2416        }
2417        let now = std::time::SystemTime::now()
2418            .duration_since(std::time::UNIX_EPOCH)
2419            .map(|d| d.as_secs() as i64)
2420            .unwrap_or(0);
2421        let cwp = chwordpos.load(SeqCst);
2422        let chwords_snapshot: Vec<i16> = chwords.lock().unwrap().clone();
2423        let nwords = (cwp / 2) as i32;
2424        if let Some(0) = he_idx {
2425            // c:1609 reuse top
2426            let mut ring = hist_ring.lock().unwrap();
2427            if let Some(top) = ring.first_mut() {
2428                top.node.nam = text.clone(); // c:1616
2429                top.stim = now; // c:1617
2430                top.ftim = 0; // c:1618
2431                top.node.flags = (newflags | overwrite_old) as i32; // c:1619
2432                top.nwords = nwords; // c:1621
2433                top.words = if cwp > 0 {
2434                    chwords_snapshot[..cwp as usize].to_vec() // c:1622-1623
2435                } else {
2436                    Vec::new()
2437                };
2438            }
2439        } else {
2440            let n = prepnexthistent(); // c:1614
2441            let mut he = make_histent(n, text.clone());
2442            he.stim = now;
2443            he.ftim = 0;
2444            he.node.flags = newflags as i32;
2445            he.nwords = nwords;
2446            if cwp > 0 {
2447                he.words = chwords_snapshot[..cwp as usize].to_vec();
2448            }
2449            let mut ring = hist_ring.lock().unwrap();
2450            ring.insert(0, he);
2451            histlinect.fetch_add(1, SeqCst);
2452            drop(ring); // release ring: addhistnode re-locks hist_ring (non-reentrant)
2453            if (newflags & HIST_TMPSTORE) == 0 {
2454                // c:1625
2455                // addhistnode(histtab, he->node.nam, he) — hashtable wiring c:1626
2456                // routes through addhistnode.
2457                addhistnode(&text, n as i32);
2458            }
2459        }
2460        // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
2461        // zshrs's DEFAULT history store is the SQLite index
2462        // (src/extensions/history.rs); write every accepted interactive
2463        // line. `newflags == 0` excludes HIST_TMPSTORE (leading space /
2464        // zshaddhistory reject) and HIST_NOWRITE — the same policy the
2465        // flat-$HISTFILE write applies. The $HISTFILE path elsewhere in
2466        // this fn is untouched (user HISTFILE/SAVEHIST overrides keep
2467        // full zsh-compat behavior alongside the index). Duration/exit
2468        // land later via history_sqlite_finish (preprompt).
2469        // SAVEHIST=0 means "save nothing" (zshparam), so a session that
2470        // sets it — every PTY harness does — stays out of the store.
2471        if newflags == 0
2472            && crate::ported::zsh_h::interact()
2473            && savehistsiz.load(SeqCst) > 0
2474        {
2475            crate::history::history_sqlite_add(&text);
2476        }
2477    }
2478    chline.lock().unwrap().clear(); // c:1628 zfree(chline)
2479    chwords.lock().unwrap().clear(); // c:1629 zfree(chwords)
2480    hptr.store(0, SeqCst); // c:1630
2481    histactive.store(0, SeqCst); // c:1632
2482
2483    let share = isset(SHAREHISTORY);
2484    let do_inc = if share {
2485        histfileIsLocked() != 0 // c:1636
2486    } else {
2487        isset(INCAPPENDHISTORY)                                              // c:1637
2488            || (isset(INCAPPENDHISTORYTIME)                                  // c:1637
2489                && histsave_stack_pos.load(SeqCst) != 0) // c:1638
2490    };
2491    if do_inc {
2492        savehistfile(
2493            hf.as_deref(),
2494            0                                        // c:1639
2495            | HFILE_USE_OPTIONS as i32
2496            | HFILE_FAST as i32,
2497        );
2498    }
2499    unlockhistfile(hf.as_deref().unwrap_or("")); // c:1640
2500
2501    while histsave_stack_pos.load(SeqCst) > stack_pos {
2502        // c:1645
2503        pophiststack(); // c:1646
2504    }
2505    hist_keep_comment.store(0, SeqCst); // c:1647
2506    unqueue_signals(); // c:1648
2507    if (flag & HISTFLAG_NOEXEC) != 0 || errflag.load(Ordering::Relaxed) != 0 {
2508        0 // c:1649
2509    } else {
2510        1
2511    }
2512}
2513
2514/// Port of `ihwabort()` from `Src/hist.c:1675`. — C decl `ihwabort(void)`.
2515pub fn ihwabort() {
2516    // c:1675
2517    // Rust-only: pool threads run C's `hwabort = nohwabort` arm (c:1143).
2518    if crate::worker::in_worker_thread() {
2519        return;
2520    }
2521    let pos = chwordpos.load(SeqCst);
2522    // `pos > 0` floor — see the ihwbegin note (c:1662).
2523    if pos % 2 != 0 && pos > 0 {
2524        chwordpos.fetch_sub(1, SeqCst);
2525    }
2526    hist_keep_comment.store(1, SeqCst);
2527}
2528
2529/// Port of `ihwend()` from `Src/hist.c:1686`. — C decl `ihwend(void)`.
2530///
2531/// Same gate as `ihwbegin` at c:1688:
2532///   stophist == 2 || (histactive & HA_INWORD) ||
2533///   (inbufflags & (INP_ALIAS|INP_HIST)) == INP_ALIAS
2534///
2535/// The previous Rust port missed the INP_ALIAS-only arm
2536/// (matching the `ihwbegin` divergence). Words started during
2537/// alias expansion got closed too — corrupting the chwords
2538/// table when an alias expansion ended.
2539pub fn ihwend() {
2540    // c:1686
2541    // Rust-only: pool threads run C's `hwend = nohwe` arm (c:1144).
2542    if crate::worker::in_worker_thread() {
2543        return;
2544    }
2545    let stop = stophist.load(SeqCst);
2546    let active = histactive.load(SeqCst);
2547    let inflags = crate::ported::input::inbufflags.with(|f| f.get());
2548    if stop == 2 || (active & HA_INWORD) != 0 || (inflags & (INP_ALIAS | INP_HIST)) == INP_ALIAS
2549    // c:1688
2550    {
2551        return;
2552    }
2553    // c:1691 — `if (chwordpos%2 && chline)`. Even chwordpos means we're between
2554    // words (no in-flight word to close); a NULL `chline` (empty buffer here)
2555    // means the history line has been freed. The previous port dropped the
2556    // `&& chline` half of this guard, so after `hend()` cleared the buffer a
2557    // fall-through still indexed `chwords[chwordpos-1]` out of bounds and
2558    // panicked (worker-thread OOB → poisoned the chwords mutex → main-thread
2559    // cascade in hend). Restore both halves.
2560    let pos = chwordpos.load(SeqCst);
2561    if pos % 2 == 0 || chline.lock().unwrap().is_empty() {
2562        return;
2563    }
2564    // c:1693 — `if (hptr > chline + chwords[chwordpos-1])`. The
2565    // previous Rust port used `chline.lock().unwrap().len()` for
2566    // the cursor position; that only equals `hptr - chline` when
2567    // hptr is at end of the buffer. Any lexer rewind would mis-
2568    // close the word boundary. Read the canonical `hptr` global
2569    // matching the `ihwbegin` fix at c:1658.
2570    let cur = hptr.load(SeqCst) as i16; // c:1693
2571    let mut words = chwords.lock().unwrap();
2572    let start_idx = (pos - 1) as usize;
2573    // chwordpos (atomic) and chwords (mutex) are separate primitives; parallel
2574    // lexers can leave chwordpos ahead of a chwords that another thread just
2575    // cleared. Single-threaded C indexes `chwords[chwordpos-1]` directly; guard
2576    // the bound here so the race degrades to "scrub that word" instead of an OOB.
2577    if start_idx < words.len() && cur > words[start_idx] {
2578        // c:1693
2579        let end_idx = pos as usize;
2580        if words.len() <= end_idx {
2581            words.resize(end_idx + 1, 0);
2582        }
2583        words[end_idx] = cur; // c:1694
2584        chwordpos.fetch_add(1, SeqCst);
2585    } else if chwordpos.load(SeqCst) > 0 {
2586        // c:1700 — `chwordpos--;` "scrub that last word, doesn't exist".
2587        // Guarded against a racing decrement having already reached 0 —
2588        // see the ihwbegin note (c:1662).
2589        chwordpos.fetch_sub(1, SeqCst);
2590    }
2591}
2592
2593/// Port of `histbackword()` from `Src/hist.c:1711`. — C decl `histbackword(void)`.
2594///
2595/// C body:
2596/// ```c
2597/// if (!(chwordpos%2) && chwordpos)
2598///     hptr = chline + chwords[chwordpos-1];
2599/// ```
2600///
2601/// Go back to immediately after the last word, skipping space.
2602/// Operates on the globals `chwordpos`, `chwords`, `chline`, `hptr`.
2603///
2604/// The previous Rust port had a completely different signature
2605/// (`(line: &str, pos: usize) -> usize`) and walked back through
2606/// ASCII whitespace — a stand-alone scan helper unrelated to the
2607/// C function. C operates on the GLOBAL history-line cursor via
2608/// `chwords[chwordpos-1]`, not an arbitrary text scan.
2609///
2610/// No call sites in src/ used the Rust signature, so renaming is
2611/// safe. Pin the C semantic: rewind `hptr` to the start of the
2612/// previous word when we're at a word-boundary.
2613pub fn histbackword() {
2614    // c:1711
2615    let pos = chwordpos.load(SeqCst);
2616    // c:1714 — `if (!(chwordpos%2) && chwordpos)`. Both conditions
2617    // — even (word boundary) AND non-zero.
2618    if pos % 2 == 0 && pos != 0 {
2619        // c:1714
2620        let words = chwords.lock().unwrap();
2621        let idx = (pos - 1) as usize;
2622        if idx < words.len() {
2623            // c:1715 — `hptr = chline + chwords[chwordpos-1]`. Rust's
2624            // hptr is an offset into chline; assign the cached
2625            // word-start offset (clamp negative to 0 since hptr
2626            // is AtomicUsize, matching C's pointer-arithmetic
2627            // semantic where chline + negative-offset is UB and
2628            // would have been clamped by the parser earlier).
2629            let off = (words[idx] as i32).max(0) as usize;
2630            hptr.store(off, SeqCst); // c:1715
2631        }
2632    }
2633}
2634
2635/// Port of `hwget()` from `Src/hist.c:1721`. — C decl `hwget(char **startptr)`.
2636pub fn hwget() -> Option<(i32, String)> {
2637    let pos = chwordpos.load(SeqCst);
2638    // c:1729 — DPUTS(1, "BUG: hwget() called with no words"); arm fires
2639    // when chwordpos == 0 (the C `if (!chwordpos)` branch at c:1728).
2640    if pos == 0 {
2641        DPUTS!(true, "BUG: hwget() called with no words"); // c:1729
2642        return None;
2643    }
2644    // c:1734 — DPUTS(1, "BUG: hwget() called in middle of word")
2645    if pos % 2 != 0 {
2646        DPUTS!(true, "BUG: hwget() called in middle of word"); // c:1734
2647        return None;
2648    }
2649    let words = chwords.lock().unwrap();
2650    let start_idx = (pos - 2) as usize;
2651    let end_idx = (pos - 1) as usize;
2652    if end_idx >= words.len() {
2653        return None;
2654    }
2655    let start = words[start_idx];
2656    let end = words[end_idx];
2657    let line = chline.lock().unwrap();
2658    let s = (start.max(0)) as usize;
2659    let e = (end.max(0) as usize).min(line.len());
2660    if s > e || s >= line.len() {
2661        return None;
2662    }
2663    Some((start as i32, line[s..e].to_string()))
2664}
2665
2666/// Port of `hwrep()` from `Src/hist.c:1748`. — C decl `hwrep(char *rep)`.
2667///
2668/// C body:
2669/// ```c
2670/// char *start;
2671/// hwget(&start);
2672/// if (!strcmp(rep, start)) return;
2673/// hptr = start;
2674/// chwordpos = chwordpos - 2;
2675/// hwbegin(0);
2676/// qbang = 1;
2677/// while (*rep) hwaddc(*rep++);
2678/// hwend();
2679/// ```
2680///
2681/// Replace the current history word with `rep` (the LAST recorded
2682/// word — `chwordpos - 2` indexes its start). The previous Rust
2683/// port had a completely different signature `(entry, replacement,
2684/// word_idx)` operating on a completed `histent` struct with
2685/// whitespace-split replacement. C operates on the IN-FLIGHT
2686/// `chline` / `chwordpos` / `hptr` globals — a fundamentally
2687/// different abstraction.
2688///
2689/// No call sites in src/ used the Rust signature, so renaming is
2690/// safe. Port faithfully to operate on the globals.
2691pub fn hwrep(rep: &str) {
2692    // c:1748
2693    // c:1752 — `hwget(&start)` — get the current word's start offset.
2694    let (start_off, start_text) = match hwget() {
2695        Some(v) => v,
2696        None => return,
2697    };
2698    // c:1754 — `if (!strcmp(rep, start)) return;` — no change, skip.
2699    if rep == start_text {
2700        return;
2701    }
2702    // c:1756 — `hptr = start; chwordpos -= 2;`. Rewind to the start
2703    // of the word we're rewriting; the open word slot is conceptually
2704    // re-opened by the chwordpos decrement.
2705    hptr.store(start_off.max(0) as usize, SeqCst); // c:1756
2706    chwordpos.fetch_sub(2, SeqCst); // c:1757
2707                                    // c:1758 — `hwbegin(0);` re-open at current hptr (no offset).
2708    ihwbegin(0);
2709    // c:1759 — `qbang = 1;` mark word as bang-bearing so subsequent
2710    // ihwaddc bang-escapes correctly.
2711    qbang.store(true, SeqCst); // c:1759
2712                               // c:1760 — `while (*rep) hwaddc(*rep++);` — push each byte.
2713    for b in rep.bytes() {
2714        ihwaddc(b as i32);
2715    }
2716    // c:1761 — `hwend();` close the word slot.
2717    ihwend();
2718}
2719
2720/// Port of `hgetline()` from `Src/hist.c:1769`. — C decl `hgetline(void)`.
2721///
2722/// C body:
2723/// ```c
2724/// if (!chline || hptr == chline) return NULL;
2725/// *hptr = '\0';
2726/// ret = dupstring(chline);
2727/// hptr = chline;
2728/// chwordpos = 0;
2729/// return ret;
2730/// ```
2731///
2732/// "Get the entire current line, deleting it in the history."
2733/// Used by `pushlineoredit()` (zle_hist.c:856) to grab the
2734/// in-flight line for ZLE editing without committing it to
2735/// the history ring.
2736///
2737/// The previous Rust port took a `&histent` and returned its
2738/// name — fundamentally different from the global chline
2739/// truncation C performs. Operate on the canonical globals
2740/// (`chline`, `hptr`, `chwordpos`) and return `Option<String>`
2741/// (None for the C NULL case at c:1777).
2742///
2743/// No Rust callers used the old (entry) signature.
2744pub fn hgetline() -> Option<String> {
2745    // c:1769
2746    let hp = hptr.load(SeqCst);
2747    let line = chline.lock().unwrap();
2748    // c:1777 — `if (!chline || hptr == chline) return NULL;`
2749    if line.is_empty() || hp == 0 {
2750        return None;
2751    }
2752    // c:1779 — `*hptr = '\0';` truncate at hptr. In Rust, slice
2753    // the substring [0..hp].
2754    let truncated = if hp <= line.len() {
2755        line[..hp].to_string()
2756    } else {
2757        line.clone()
2758    };
2759    drop(line);
2760    // c:1780 — `ret = dupstring(chline);` (already a copy via
2761    // Rust .to_string()).
2762    // c:1783-1784 — reset line: hptr = 0, chwordpos = 0.
2763    hptr.store(0, SeqCst);
2764    chwordpos.store(0, SeqCst);
2765    Some(truncated) // c:1786
2766}
2767
2768/// Port of `getargspec()` from `Src/hist.c:1793`. — C decl `getargspec(int argc, int marg, int evset)`.
2769/// Reads a word-designator off the input stream via `ingetc`, returning
2770/// the resolved word index, `-1` for "no designator present" (caller
2771/// must default), or `-2` for hard error.
2772/// ```c
2773/// static int
2774/// getargspec(int argc, int marg, int evset)
2775/// {
2776///     int c, ret = -1;
2777///     if ((c = ingetc()) == '0') return 0;
2778///     if (idigit(c)) {
2779///         ret = 0;
2780///         while (idigit(c)) {
2781///             ret = ret * 10 + c - '0';
2782///             if (ret < 0) { herrflush(); zerr("no such word in event"); return -2; }
2783///             c = ingetc();
2784///         }
2785///         inungetc(c);
2786///     } else if (c == '^') ret = 1;
2787///     else if (c == '$') ret = argc;
2788///     else if (c == '%') {
2789///         if (evset) { herrflush(); zerr("Ambiguous history reference"); return -2; }
2790///         if (marg == -1) { herrflush(); zerr("%% with no previous word matched"); return -2; }
2791///         ret = marg;
2792///     } else inungetc(c);
2793///     return ret;
2794/// }
2795/// ```
2796pub fn getargspec(argc: i32, marg_arg: i32, evset: i32) -> i32 {
2797    // c:1793
2798    let mut c: i32 = ingetc() // c:1797 ingetc()
2799        .map(|ch| ch as i32)
2800        .unwrap_or(-1);
2801    let mut ret: i32 = -1; // c:1795
2802    if c == b'0' as i32 {
2803        // c:1797
2804        return 0; // c:1798
2805    }
2806    if (c as u8 as char).is_ascii_digit() {
2807        // c:1799 idigit(c)
2808        ret = 0; // c:1800
2809        while (c as u8 as char).is_ascii_digit() {
2810            // c:1801
2811            ret = ret * 10 + c - b'0' as i32; // c:1802
2812            if ret < 0 {
2813                // c:1803
2814                herrflush(); // c:1804
2815                zerr("no such word in event"); // c:1805
2816                return -2; // c:1806
2817            }
2818            c = ingetc() // c:1808 ingetc()
2819                .map(|ch| ch as i32)
2820                .unwrap_or(-1);
2821        }
2822        if let Some(ch) = char::from_u32(c as u32) {
2823            // c:1810 inungetc(c)
2824            inungetc(ch);
2825        }
2826    } else if c == b'^' as i32 {
2827        // c:1811
2828        ret = 1; // c:1812
2829    } else if c == b'$' as i32 {
2830        // c:1813
2831        ret = argc; // c:1814
2832    } else if c == b'%' as i32 {
2833        // c:1815
2834        if evset != 0 {
2835            // c:1816
2836            herrflush(); // c:1817
2837            zerr("Ambiguous history reference"); // c:1818
2838            return -2; // c:1819
2839        }
2840        if marg_arg == -1 {
2841            // c:1821
2842            herrflush(); // c:1822
2843            zerr("%% with no previous word matched"); // c:1823
2844            return -2; // c:1824
2845        }
2846        ret = marg_arg; // c:1826
2847    } else {
2848        // c:1827
2849        if let Some(ch) = char::from_u32(c as u32) {
2850            // c:1828 inungetc(c)
2851            inungetc(ch);
2852        }
2853    }
2854    ret // c:1829
2855}
2856
2857/// Port of `hconsearch()` from `Src/hist.c:1836`. — C decl `hconsearch(char *str, int *marg)`.
2858///
2859/// C body (c:1836-1853):
2860/// ```c
2861/// for (he = up_histent(hist_ring); he; he = up_histent(he)) {
2862///     if (he->node.flags & HIST_FOREIGN) continue;
2863///     if ((s = strstr(he->node.nam, str))) {
2864///         int pos = s - he->node.nam;
2865///         while (t1 < he->nwords && he->words[2*t1] <= pos)
2866///             t1++;
2867///         *marg = t1 - 1;
2868///         return he->histnum;
2869///     }
2870/// }
2871/// return -1;
2872/// ```
2873///
2874/// Two divergences in the previous Rust port:
2875///   1. Took an extra `start: Option<i64>` parameter that has no
2876///      C counterpart — C always starts from `up_histent(hist_ring)`.
2877///   2. Dropped the `*marg` output — caller code at c:686 needs
2878///      the matching word index to update `marg`/`hsubl` state.
2879///      Without it, the caller hardcoded `margbox=0` losing the
2880///      actual word position.
2881///
2882/// Rust adaptation: returns `Option<(histnum, marg)>` instead of
2883/// the C in/out pair; None mirrors the C `return -1` miss path.
2884/// WARNING: Rust returns a tuple — C uses an out-pointer for marg.
2885pub fn hconsearch(needle: &str) -> Option<(i64, i32)> {
2886    // c:1836
2887    // c:1842 — `for (he = up_histent(hist_ring); he; he = up_histent(he))`.
2888    // The C `hist_ring` is the doubly-linked-list sentinel; iterating
2889    // `up_histent(hist_ring)` walks from the newest real entry toward
2890    // older ones. Rust storage is a Vec with newest at position 0;
2891    // walk positions 0..ring_len for the same effect.
2892    let ring = hist_ring.lock().expect("hist_ring poisoned");
2893    for entry in ring.iter() {
2894        if (entry.node.flags as u32 & HIST_FOREIGN) != 0 {
2895            // c:1843
2896            continue; // c:1844
2897        }
2898        if let Some(pos) = entry.node.nam.find(needle) {
2899            // c:1845 strstr
2900            // c:1846 — `int pos = s - he->node.nam;`
2901            let mut t1: i32 = 0; // c:1838
2902            while t1 < entry.nwords {
2903                // c:1847
2904                let slot_pos = entry.words.get((2 * t1) as usize).copied().unwrap_or(0) as usize;
2905                if slot_pos > pos {
2906                    // c:1847 he->words[2*t1] <= pos
2907                    break;
2908                }
2909                t1 += 1; // c:1848
2910            }
2911            return Some((entry.histnum, t1 - 1)); // c:1849-1850
2912        }
2913    }
2914    None // c:1853
2915}
2916
2917/// Port of `hcomsearch()` from `Src/hist.c:1860`. — C decl `hcomsearch(char *str)`.
2918pub fn hcomsearch(prefix: &str) -> Option<i64> {
2919    let mut cur = curhist.load(SeqCst);
2920    while let Some(prev) = up_histent(cur) {
2921        cur = prev;
2922        if let Some(entry) = ring_get(cur) {
2923            if (entry.node.flags as u32 & HIST_FOREIGN) != 0 {
2924                continue;
2925            }
2926            if entry.node.nam.starts_with(prefix) {
2927                return Some(cur);
2928            }
2929        }
2930    }
2931    None
2932}
2933
2934/// Port of `chabspath()` from `Src/hist.c:1878`. — C decl `chabspath(char **junkptr)`.
2935pub fn chabspath(input: &str) -> Option<String> {
2936    if input.is_empty() {
2937        return Some(String::new());
2938    }
2939    let mut path = if !input.starts_with('/') {
2940        let cwd = std::env::current_dir().ok()?;
2941        let cwd_s = cwd.to_string_lossy().into_owned();
2942        if cwd_s.ends_with('/') {
2943            format!("{}{}", cwd_s, input)
2944        } else {
2945            format!("{}/{}", cwd_s, input)
2946        }
2947    } else {
2948        input.to_string()
2949    };
2950    let chars: Vec<char> = path.chars().collect();
2951    let mut out: Vec<char> = Vec::with_capacity(chars.len());
2952    let mut i = 0;
2953    while i < chars.len() {
2954        let c = chars[i];
2955        if c == '/' {
2956            out.push('/');
2957            i += 1;
2958            while i < chars.len() && chars[i] == '/' {
2959                i += 1;
2960            }
2961        } else if c == '.'
2962            && i + 1 < chars.len()
2963            && chars[i + 1] == '.'
2964            && (i + 2 == chars.len() || chars[i + 2] == '/')
2965        {
2966            if out.len() <= 1 {
2967                // c:1945 — `else return 0;` is reached ONLY when dest is still
2968                // at the very start (nothing accumulated at all).
2969                if out.is_empty() {
2970                    return None;
2971                }
2972                // c:1941-1944 — `else if (dest == *junkptr + 1) { current += 2; }`
2973                // At the ROOT, `..` is SKIPPED: it is consumed and nothing is
2974                // written, so `/..` collapses to `/` (POSIX: the parent of / is
2975                // /). Returning an error here instead made chabspath fail, and
2976                // the modifier dispatch reports a failed chabspath as
2977                // `unrecognized modifier 'a'` — so `${f:a}` on `/..` errored
2978                // out where zsh prints `/`. `:A` was unaffected because it
2979                // reaches realpath(3), which resolves `/..` to `/` on its own,
2980                // which is why only `:a` showed it.
2981                if out == ['/'] {
2982                    // c:1943 — `current += 2;` and NOTHING else: the `..` is
2983                    // consumed but the '/' that follows it is NOT. That slash
2984                    // is then copied by the '/' arm on top of the root already
2985                    // in `out`, so zsh really does yield a DOUBLE slash here:
2986                    //     /../x  :a → //x        (not /x)
2987                    //     /..    :a → /
2988                    // C's own comment flags it ("This might break with
2989                    // Cygwin's leading double slashes?"). Consuming the slash
2990                    // as well would be the tidier-looking answer and the wrong
2991                    // one.
2992                    i += 2;
2993                    continue;
2994                }
2995                out.push('.');
2996                out.push('.');
2997            } else if out.len() >= 3 && &out[out.len() - 3..] == &['.', '.', '/'] {
2998                out.push('.');
2999                out.push('.');
3000            } else {
3001                if out.last() == Some(&'/') && out.len() > 1 {
3002                    out.pop();
3003                }
3004                while out.last().map(|c| *c != '/').unwrap_or(false) {
3005                    out.pop();
3006                }
3007            }
3008            i += 2;
3009            if i < chars.len() && chars[i] == '/' {
3010                i += 1;
3011            }
3012        } else if c == '.' && (i + 1 == chars.len() || chars[i + 1] == '/') {
3013            i += 1;
3014            while i < chars.len() && chars[i] == '/' {
3015                i += 1;
3016            }
3017        } else {
3018            out.push(c);
3019            i += 1;
3020        }
3021    }
3022    while out.len() > 1 && out.last() == Some(&'/') {
3023        out.pop();
3024    }
3025    path = out.into_iter().collect();
3026    if path.is_empty() {
3027        Some("/".to_string())
3028    } else {
3029        Some(path)
3030    }
3031}
3032
3033/// Port of `chrealpath()` from `Src/hist.c:1971`. — C decl `chrealpath(char **junkptr, char mode, int use_heap)`.
3034/// Mode 'A' → chabspath then realpath; mode 'P' → realpath only.
3035/// Handles non-existent paths by walking parent prefixes until one
3036/// resolves, then re-appending the remaining tail (matches the C
3037/// fallback at c:2027-2030).
3038///
3039/// Signature mirrors C (Rule B): `path`, `mode` ('A' or 'P'),
3040/// `use_heap` (ignored — Rust strings are heap by default).
3041pub fn chrealpath(path: &str, mode: u8, _use_heap: bool) -> Option<String> {
3042    // c:1971
3043    // c:1983 — DPUTS1(mode != 'A' && mode != 'P', "chrealpath: mode='%c' is invalid", mode)
3044    DPUTS1!(
3045        // c:1983
3046        mode != b'A' && mode != b'P', // c:1983
3047        "chrealpath: mode='{}' is invalid",
3048        mode as char // c:1983
3049    );
3050    // c:1985-1986 — if (!**junkptr) return 1; (empty input is success)
3051    if path.is_empty() {
3052        return Some(String::new());
3053    }
3054
3055    // c:1988-1990 — if (mode == 'A') chabspath; here we accept that
3056    //               callers wanting absolutize-first do it themselves.
3057    //               Caller may pass either mode; the partial-realpath
3058    //               body is the only mode-independent semantic.
3059
3060    // c:1999-2000 — if (**junkptr != '/') return 0;
3061    //               Only absolute paths are valid input.
3062    if !path.starts_with('/') {
3063        return None;
3064    }
3065
3066    // c:2002-2003 — untokenize + unmetafy.  Rust strings already UTF-8.
3067
3068    // c:2008-2030 — loop: try realpath(p); on failure walk `nonreal`
3069    //               backward to the previous '/' and retry the shorter
3070    //               prefix. The bytes after each '/' boundary are
3071    //               recorded as the non-real tail to re-splice later.
3072    let bytes = path.as_bytes();
3073    let mut prefix_end = bytes.len();
3074    let mut real: Option<String> = None;
3075    loop {
3076        let trial = &path[..prefix_end];
3077        if let Ok(canonical) = std::fs::canonicalize(trial) {
3078            real = Some(canonical.to_string_lossy().into_owned());
3079            break;
3080        }
3081        // walk `prefix_end` back to the previous '/'
3082        let mut i = prefix_end.saturating_sub(1);
3083        while i > 0 && bytes[i] != b'/' {
3084            i -= 1;
3085        }
3086        if i == 0 {
3087            // c:2020-2024 — `if (nonreal == *junkptr) { real = NULL; break; }`
3088            // Nothing along the path resolved. C's `nonreal` has walked back to
3089            // the START of the string, so after the '/'-restoration loop below
3090            // it spans the WHOLE input and c:2047 hands that back untouched.
3091            // Leaving prefix_end where it was instead returned a TRUNCATED
3092            // suffix: `/a/b/../c` came back as `/b/../c`.
3093            prefix_end = 0;
3094            break;
3095        }
3096        prefix_end = i;
3097    }
3098
3099    // c:2032-2037 — the nul-bytes inside `nonreal` get rewritten back to
3100    //               '/'. In Rust we never overwrite, so the tail is just
3101    //               the suffix from `prefix_end..`.
3102    let tail = &path[prefix_end..];
3103
3104    // c:2040-2048 — splice real + tail (or just tail when realpath fails
3105    //               on every prefix).
3106    match real {
3107        Some(r) => Some(format!("{}{}", r, tail)),
3108        None => Some(tail.to_string()),
3109    }
3110}
3111
3112/// Port of `remtpath()` from `Src/hist.c:2056`. — C decl `remtpath(char **junkptr, int count)`.
3113/// Implements the `:h` modifier (and `:hN` via `digitcount()`).
3114///
3115/// The C body walks one cursor (`str`) backwards from `strend(*junkptr)`
3116/// and only ever NUL-terminates the buffer in the two branches that
3117/// actually cut (c:2090 and c:2115). Every other exit returns the buffer
3118/// UNCHANGED — which is why `${x:h4}` on `/a/b/c/` keeps its trailing
3119/// slash: the trailing-slash trim at c:2060-2062 moves the cursor but
3120/// writes nothing. This port keeps that shape (cursor + late cut) rather
3121/// than pre-trimming the string, because pre-trimming leaks into the
3122/// "full string needed" exits.
3123///
3124/// The Rust signature returns the resulting string instead of mutating
3125/// `*junkptr`, so C's in-place `*str = '\0'` becomes a `s[..idx]` slice.
3126///
3127/// !!! RUST-ONLY DETAIL !!! C reads `str[1]` / `**junkptr` freely because
3128/// the buffer is NUL-terminated; `byte()` below reproduces that by
3129/// yielding `'\0'` for any index at/after the end. `strend()`
3130/// (Src/string.c:196) returns the string itself when empty, so the
3131/// cursor starts at index 0 — i.e. on that implicit NUL — for `""`.
3132pub fn remtpath(s: &str, count: i32) -> String {
3133    // c:2056
3134    let b = s.as_bytes();
3135    // Nul-aware byte accessor: mirrors C reading through the terminator.
3136    let byte = |i: isize| -> char {
3137        if i >= 0 && (i as usize) < b.len() {
3138            b[i as usize] as char
3139        } else {
3140            '\0'
3141        }
3142    };
3143    // c:2058 — `char *str = strend(*junkptr);` (last char, or the string
3144    // itself when empty — Src/string.c:196).
3145    let mut str_: isize = if b.is_empty() {
3146        0
3147    } else {
3148        b.len() as isize - 1
3149    };
3150
3151    // c:2060 — /* ignore trailing slashes */
3152    while str_ >= 0 && IS_DIRSEP(byte(str_)) {
3153        str_ -= 1; // c:2062
3154    }
3155    if count == 0 {
3156        // c:2063
3157        // c:2064 — /* skip filename */
3158        while str_ >= 0 && !IS_DIRSEP(byte(str_)) {
3159            str_ -= 1; // c:2066
3160        }
3161    }
3162    if str_ < 0 {
3163        // c:2068
3164        // c:2069-2072 — `/` vs `.` decided by the FIRST byte of the
3165        // ORIGINAL buffer. Empty input's first byte is the NUL, not a
3166        // dirsep, so C yields `.`.
3167        return if IS_DIRSEP(byte(0)) { "/" } else { "." }.to_string();
3168    }
3169
3170    if count != 0 {
3171        // c:2076
3172        // c:2078-2082 — /* Return this many components, so start from the
3173        //  front. Leading slash counts as one component, consistent with
3174        //  behaviour of repeated applications of :h. */
3175        let mut count = count;
3176        let mut strp: isize = 0; // c:2083 `char *strp = *junkptr;`
3177        while strp < str_ {
3178            // c:2084
3179            if IS_DIRSEP(byte(strp)) {
3180                // c:2085
3181                count -= 1;
3182                if count <= 0 {
3183                    // c:2086
3184                    if strp == 0 {
3185                        // c:2087 `if (strp == *junkptr)`
3186                        strp += 1; // c:2088
3187                    }
3188                    return s[..strp as usize].to_string(); // c:2089 `*strp = '\0'`
3189                }
3190                // c:2093 — /* Count consecutive separators as one */
3191                while IS_DIRSEP(byte(strp + 1)) {
3192                    strp += 1; // c:2095
3193                }
3194            }
3195            strp += 1; // c:2097
3196        }
3197
3198        // c:2100 — /* Full string needed */ — nothing was NUL'd, so the
3199        // caller sees the ORIGINAL buffer, trailing slashes included.
3200        return s.to_string(); // c:2101
3201    }
3202
3203    // c:2104 — /* repeated slashes are considered like a single slash */
3204    while str_ > 0 && IS_DIRSEP(byte(str_ - 1)) {
3205        str_ -= 1; // c:2106
3206    }
3207    // c:2107 — /* never erase the root slash */
3208    if str_ == 0 {
3209        // c:2108
3210        str_ += 1; // c:2109
3211                   // c:2110-2112 — /* Leading doubled slashes (`//') have a special
3212                   //  meaning on cygwin and some old flavor of UNIX, so we do not
3213                   //  assimilate them to a single slash.  However a greater number
3214                   //  is ok to squeeze. */
3215        if IS_DIRSEP(byte(str_)) && !IS_DIRSEP(byte(str_ + 1)) {
3216            str_ += 1; // c:2114
3217        }
3218    }
3219    s[..str_ as usize].to_string() // c:2115 `*str = '\0'`
3220}
3221
3222/// Port of `remtext()` from `Src/hist.c:2122`. — C decl `remtext(char **junkptr)`.
3223///
3224/// C body (c:2122-2132):
3225/// ```c
3226/// for (str = strend(*junkptr); str >= *junkptr && !IS_DIRSEP(*str); --str)
3227///     if (*str == '.') {
3228///         *str = '\0';
3229///         return 1;
3230///     }
3231/// return 0;
3232/// ```
3233///
3234/// Walks backward from the end of the string looking for `.`,
3235/// stopping at the rightmost `/` or the start of the string. When
3236/// found, truncates the string at the `.` (drops `.` AND the
3237/// extension). The Rust signature returns the truncated string
3238/// rather than mutating + returning int.
3239///
3240/// Edge cases pinned by C: `.hidden` → `""`, `foo/.bashrc` → `foo/`.
3241/// The previous Rust port guarded with `dot_pos > 0` which kept the
3242/// leading-dot files unchanged — a divergence from C's behavior.
3243pub fn remtext(s: &str) -> String {
3244    // c:2122
3245    // c:2126 — find the rightmost `/` to bound the scan (C walks back
3246    // from end until it hits a dirsep). Everything after that slash
3247    // (or the whole string if no slash) is the basename region.
3248    let (prefix, basename) = match s.rfind('/') {
3249        Some(i) => (&s[..=i], &s[i + 1..]),
3250        None => ("", s),
3251    };
3252    // c:2127 — `if (*str == '.')`. Find rightmost `.` in basename;
3253    // truncate there. C makes no exception for leading dot.
3254    if let Some(dot_pos) = basename.rfind('.') {
3255        // c:2127
3256        return format!("{}{}", prefix, &basename[..dot_pos]); // c:2128
3257    }
3258    s.to_string() // c:2131
3259}
3260
3261/// Port of `rembutext()` from `Src/hist.c:2136`. — C decl `rembutext(char **junkptr)`.
3262pub fn rembutext(s: &str) -> String {
3263    // c:2136
3264    if let Some(slash_pos) = s.rfind('/') {
3265        let after_slash = &s[slash_pos + 1..];
3266        if let Some(dot_pos) = after_slash.rfind('.') {
3267            return after_slash[dot_pos + 1..].to_string();
3268        }
3269        return String::new();
3270    }
3271    if let Some(dot_pos) = s.rfind('.') {
3272        return s[dot_pos + 1..].to_string();
3273    }
3274    String::new()
3275}
3276
3277/// Port of `remlpaths()` from `Src/hist.c:2152`. — C decl `remlpaths(char **junkptr, int count)`.
3278/// Keeps the last `count` path components (the `:t` modifier family).
3279///
3280/// The C body walks a single cursor backwards from `strend(*junkptr)`
3281/// and, on the separator that ends the wanted range, cuts in place
3282/// (`*str = '\0'; *junkptr = dupstring(str + 1)`). This port keeps that
3283/// shape — one backward byte scan, one allocation for the result — so
3284/// the modifier costs a scan of the string instead of a split + Vec +
3285/// join per element.
3286///
3287/// C behavior (c:2165-2172): when `--count > 0` and the cursor has
3288/// already reached the start of the string (`str > *junkptr` fails),
3289/// the function returns 1 ("whole string needed") WITHOUT cutting, so
3290/// the caller observes the input (leading slash included) apart from
3291/// the trailing-separator trim c:2156-2161 already applied in place.
3292/// c:2182-2185 (`str <= *junkptr` → `return 0`) reaches the same
3293/// result by a different route.
3294///
3295/// `count == 0` needs no special case: C tests `--count > 0`, which is
3296/// false for both 0 and 1, so both cut at the first separator from the
3297/// right — c:574's `digitcount()` default of 0 means "one component".
3298pub fn remlpaths(s: &str, count: i32) -> String {
3299    // c:2152
3300    let b = s.as_bytes();
3301    // c:2154 — `char *str = strend(*junkptr);` — the LAST character
3302    // (string.c:196), or the string itself when empty. The cursor is an
3303    // isize because C lets it walk one step before `*junkptr`.
3304    if b.is_empty() {
3305        return String::new();
3306    }
3307    let mut count = count;
3308    let mut str_ = b.len() as isize - 1;
3309    // `end` is where C's in-place NUL lands: everything from `end` on is
3310    // already cut off the buffer the caller sees.
3311    let mut end = b.len();
3312
3313    // c:2156-2161 — trailing separators are trimmed off the input first.
3314    if IS_DIRSEP(b[str_ as usize] as char) {
3315        while str_ >= 0 && IS_DIRSEP(b[str_ as usize] as char) {
3316            str_ -= 1; // c:2159
3317        }
3318        end = (str_ + 1) as usize; // c:2160 `str[1] = '\0'`
3319    }
3320
3321    loop {
3322        // c:2162-2163
3323        while str_ >= 0 {
3324            // c:2164
3325            if IS_DIRSEP(b[str_ as usize] as char) {
3326                count -= 1;
3327                if count > 0 {
3328                    // c:2165 `if (--count > 0)`
3329                    if str_ > 0 {
3330                        // c:2166-2168 — more components wanted; keep scanning
3331                        // left from before this separator.
3332                        str_ -= 1;
3333                        break;
3334                    }
3335                    // c:2169-2172 — whole string needed.
3336                    return s[..end].to_string();
3337                }
3338                // c:2174-2176 — cut here; the result starts after the
3339                // separator, which is why the leading slash is dropped.
3340                return s[str_ as usize + 1..end].to_string();
3341            }
3342            str_ -= 1;
3343        }
3344        // c:2179-2181 — count consecutive separators as 1.
3345        while str_ >= 0 && IS_DIRSEP(b[str_ as usize] as char) {
3346            str_ -= 1;
3347        }
3348        if str_ <= 0 {
3349            break; // c:2182-2183
3350        }
3351    }
3352    // c:2185 — `return 0`: no cut, the caller keeps the (trimmed) input.
3353    s[..end].to_string()
3354}
3355
3356/// Port of `casemodify()` from `Src/hist.c:2196`. — C decl `casemodify(char *str, int how)`.
3357///
3358/// Both arms call the C library's classification and mapping functions,
3359/// as C does: the wide-character `iswupper`/`iswlower`/`iswalnum`/
3360/// `towupper`/`towlower` with MULTIBYTE set, the single-byte
3361/// `isupper`/`islower`/`toupper`/`tolower` without it. Which letters fold,
3362/// and to what, is locale state (`LC_CTYPE`), not a property of Unicode the
3363/// port could re-derive: titlecase U+01C5 `Dž` folds both ways under glibc
3364/// and not at all under macOS libc, matching each platform's zsh.
3365pub fn casemodify(s: &str, how: i32) -> String {
3366    // c:2196
3367    // c:2200 — `int nextupper = 1;`. Start expecting a leading uppercase.
3368    let mut nextupper = true;
3369    if !crate::ported::options::opt_state_get("multibyte").unwrap_or(true) {
3370        // c:2276-2320 — the byte loop. C calls the C library's single-byte
3371        // `isupper`/`tolower`/`islower`/`toupper` directly, so this calls the
3372        // same functions: they are locale state, not a property anything here
3373        // could re-derive, and zshrs already runs C's `setlocale(LC_ALL, "")`
3374        // (c:Src/init.c:1208) at startup, so both shells read one table.
3375        //
3376        // Those calls are why byte mode is not just "the same fold on smaller
3377        // units". Under en_US.UTF-8 on macOS `toupper(0xb5)` answers 0x39c —
3378        // wider than the `char` C stores it in — so `${(U)}` on a value ending
3379        // `ce b5` emits `ce 9c`. That truncation is upstream behavior, and
3380        // storing the low byte reproduces it rather than papering over it.
3381        let bytes = crate::ported::utils::unmetafy_str(s);
3382        let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
3383        // The result so far: byte runs between bare tokens, in the port's
3384        // `String` form (valid text verbatim, other bytes as `Meta` pairs).
3385        let mut result = String::with_capacity(bytes.len());
3386        let mut pending_meta = false;
3387        // c:2276 — `while (*str)`. The stream is already demetafied, which is
3388        // C's c:2279-2281 `if (*str == Meta) { c = str[1] ^ 32; str += 2; }`.
3389        for &b in &bytes {
3390            let mut c = b as i32;
3391            let mut modified = false; // c:2278 `int mod = 0;`
3392            match how {
3393                x if x == CASMOD_LOWER => {
3394                    // c:2285-2290 — `if (isupper(c)) { c = tolower(c); mod = 1; }`
3395                    if unsafe { libc::isupper(c) } != 0 {
3396                        c = unsafe { libc::tolower(c) };
3397                        modified = true;
3398                    }
3399                }
3400                x if x == CASMOD_UPPER => {
3401                    // c:2292-2297 — `if (islower(c)) { c = toupper(c); mod = 1; }`
3402                    if unsafe { libc::islower(c) } != 0 {
3403                        c = unsafe { libc::toupper(c) };
3404                        modified = true;
3405                    }
3406                }
3407                x if x == CASMOD_CAPS => {
3408                    // c:2299-2313 — word-boundary tracking on zsh's own
3409                    // `ialnum` typtab (c:2301), not the libc class.
3410                    if !crate::ported::ztype_h::ialnum(b) {
3411                        nextupper = true;
3412                    } else if nextupper {
3413                        if unsafe { libc::islower(c) } != 0 {
3414                            c = unsafe { libc::toupper(c) };
3415                            modified = true;
3416                        }
3417                        nextupper = false;
3418                    } else if unsafe { libc::isupper(c) } != 0 {
3419                        c = unsafe { libc::tolower(c) };
3420                        modified = true;
3421                    }
3422                }
3423                _ /* CASMOD_NONE */ => {}
3424            }
3425            // c:2315-2319 — `if (mod && imeta(c)) { *ptr2++ = Meta; *ptr2++ =
3426            // c ^ 32; } else *ptr2++ = c;`. C is re-establishing METAFICATION
3427            // on the byte it just folded, because its output buffer is a
3428            // metafied `char *`. This buffer is the LOGICAL byte stream, so
3429            // the escape is applied once, at the conversion below — emitting
3430            // the pair here as well would metafy it twice and leak `83 bc`
3431            // where zsh prints `9c`.
3432            //
3433            // C stores the int into a `char`, so a mapping that answers wider
3434            // than a byte keeps only its low byte (`toupper(0xb5)` → 0x39c →
3435            // 0x9c); the mask reproduces that.
3436            //
3437            // An UNMODIFIED byte goes out bare (`else *ptr2++ = c`), even when
3438            // it is an `imeta` data byte the input carried as `Meta` +
3439            // `byte ^ 32`. In C's metafied buffer a bare `itok` byte (0x84..0xa1) IS
3440            // a token (`Marker`, 0xa2, is `imeta` but no token and prints as
3441            // itself), so `${(L)v}` on a value holding one hands later stages a
3442            // token where the input held data (zsh: `unsetopt multibyte;
3443            // v=一二三; print ${(L)v}` prints `一\xe4\xba$\xe4\xb8(`: the
3444            // String and Outpar tokens untokenize). A bare `Meta` makes the
3445            // next stored byte read as escaped. Both are kept so the result
3446            // reads as zsh's does.
3447            let byte = (c & 0xff) as u8;
3448            if pending_meta {
3449                // c:Src/utils.c unmetafy — `Meta` + y reads as y ^ 32. A bare
3450                // token y is untokenized first (c:Src/exec.c untokenize, via
3451                // `ztokens`), so the pair reads as that glyph ^ 32: zsh turns
3452                // the data bytes 83 95 into 1e (`>` ^ 32), not b5.
3453                pending_meta = false;
3454                let glyph = (!modified && crate::ported::ztype_h::itok(byte))
3455                    .then(|| crate::ported::glob::ZTOKENS.as_bytes().get((byte - 0x84) as usize))
3456                    .flatten();
3457                out.push(glyph.copied().unwrap_or(byte) ^ 32);
3458            } else if !modified && byte == crate::ported::zsh_h::Meta {
3459                pending_meta = true;
3460            } else if let Some(tok) =
3461                (!modified && crate::ported::ztype_h::itok(byte))
3462                    .then(|| crate::token_char::token_char_from_byte(byte))
3463                    .flatten()
3464            {
3465                result.push_str(&crate::script_bytes::decode_script_bytes(&out));
3466                out.clear();
3467                result.push(tok);
3468            } else {
3469                out.push(byte);
3470            }
3471        }
3472        // c:2321-2322 — `*ptr2 = '\0'; return str2;`. Back to the port's
3473        // `String` form: well-formed text verbatim, anything else escaped as
3474        // `Meta` + `byte ^ 32`, the representation `unmetafy_str` decodes.
3475        // This is a whole-buffer conversion, not a per-unit one, so it cannot
3476        // come from `mb_metacharlenconv` — C has the same split, building the
3477        // buffer byte-by-byte in the loop and returning it once here.
3478        result.push_str(&crate::script_bytes::decode_script_bytes(&out));
3479        return result;
3480    }
3481    // c:2204-2275 — the wide-character loop.
3482    let bytes = crate::ported::utils::unmetafy_str(s);
3483    let mut out: Vec<u8> = Vec::with_capacity(2 * bytes.len());
3484    let mut pos = 0;
3485    while pos < bytes.len() {
3486        // c:2212 — `int len = mb_metacharlenconv(str, &wc), mod = 0, len2;`
3487        let (len, wc, _) = crate::ported::utils::mb_metacharlenconv(&bytes[pos..]);
3488        let len = len.max(1);
3489        let unit = &bytes[pos..pos + len];
3490        pos += len;
3491        // c:2218-2224 — `if (wc == WEOF)`: copy the bytes, not alphanumeric.
3492        let Some(c) = wc else {
3493            out.extend_from_slice(unit);
3494            nextupper = true;
3495            continue;
3496        };
3497        let mut w = c as u32 as WintT;
3498        let mut modified = false; // c:2212 `mod = 0`
3499        // SAFETY: the wide-character class/mapping functions take any
3500        // `wint_t` and only read the current locale.
3501        unsafe {
3502            match how {
3503                x if x == CASMOD_LOWER => {
3504                    // c:2226-2229 — `if (iswupper(wc)) { wc = towlower(wc); mod = 1; }`
3505                    if iswupper(w) != 0 {
3506                        w = towlower(w);
3507                        modified = true;
3508                    }
3509                }
3510                x if x == CASMOD_UPPER => {
3511                    // c:2232-2236 — `if (iswlower(wc)) { wc = towupper(wc); mod = 1; }`
3512                    if iswlower(w) != 0 {
3513                        w = towupper(w);
3514                        modified = true;
3515                    }
3516                }
3517                x if x == CASMOD_CAPS => {
3518                    // c:2241-2242 — `if (IS_COMBINING(wc)) break;`
3519                    // (`wc != 0 && WCWIDTH(wc) == 0`, Src/zsh.h:3343).
3520                    let combining = (c as u32) != 0
3521                        && unicode_width::UnicodeWidthChar::width(c).unwrap_or(1) == 0;
3522                    if combining {
3523                    } else if iswalnum(w) == 0 {
3524                        // c:2243-2244
3525                        nextupper = true;
3526                    } else if nextupper {
3527                        // c:2245-2250
3528                        if iswlower(w) != 0 {
3529                            w = towupper(w);
3530                            modified = true;
3531                        }
3532                        nextupper = false;
3533                    } else if iswupper(w) != 0 {
3534                        // c:2251-2254
3535                        w = towlower(w);
3536                        modified = true;
3537                    }
3538                }
3539                _ /* CASMOD_NONE */ => {}
3540            }
3541        }
3542        // c:2258-2272 — `if (mod && (len2 = wcrtomb(mbstr, wc, &ps)) > 0)`
3543        // emits the mapped character's bytes; otherwise the original bytes.
3544        let mut mb = [0 as libc::c_char; 16];
3545        let mut ps = crate::ported::utils::MBSTATE_ZERO;
3546        let len2 = if modified {
3547            unsafe {
3548                crate::ported::utils::wcrtomb(
3549                    mb.as_mut_ptr(),
3550                    w as libc::wchar_t,
3551                    &mut ps as *mut _ as *mut libc::c_void,
3552                )
3553            }
3554        } else {
3555            0
3556        };
3557        if modified && len2 > 0 && len2 != usize::MAX {
3558            out.extend(mb[..len2].iter().map(|&b| b as u8));
3559        } else {
3560            out.extend_from_slice(unit);
3561        }
3562    }
3563    // c:2321-2322 — `*ptr2 = '\0'; return str2;`. Valid text stays text; a
3564    // byte that decodes as nothing (a WEOF unit copied through) goes back to
3565    // its `Meta` + `byte ^ 32` form, which `unmetafy_str` reverses.
3566    crate::script_bytes::decode_script_bytes(&out)
3567}
3568
3569
3570/// libc `wint_t`: `unsigned int` on glibc/musl, `__int32_t` on Darwin.
3571#[cfg(target_vendor = "apple")]
3572type WintT = libc::c_int;
3573#[cfg(not(target_vendor = "apple"))]
3574type WintT = libc::c_uint;
3575
3576extern "C" {
3577    // <wctype.h> — the libc crate does not bind these on unix targets.
3578    fn iswupper(wc: WintT) -> libc::c_int;
3579    fn iswlower(wc: WintT) -> libc::c_int;
3580    fn iswalnum(wc: WintT) -> libc::c_int;
3581    fn towupper(wc: WintT) -> WintT;
3582    fn towlower(wc: WintT) -> WintT;
3583}
3584
3585/*
3586 * Substitute "in" for "out" in "*strptr" and update "*strptr".
3587 * If "gbal", do global substitution.
3588 *
3589 * This returns a result from the heap.  There seems to have
3590 * been some confusion on this point.
3591 */
3592/// Port of `subst()` from `Src/hist.c:2336`. — C decl
3593/// `int subst(char **strptr, char *in, char *out, int gbal, int forcepat)`.
3594///
3595/// !!! WARNING: RUST-ONLY ADAPTATION OF THE SIGNATURE !!!
3596///
3597/// C carries the result out through `char **strptr` and RETURNS A
3598/// STATUS: 0 when the substitution was performed, 1 when it was not.
3599/// The previous Rust port returned the resulting `String` and no
3600/// status, so all three callers had to guess at the status by asking
3601/// whether the string had CHANGED — which is a different question. A
3602/// substitution whose replacement equals what it replaced (`^old^old`,
3603/// `!!:s/x/x/`) succeeds in C and reports `substitution failed` under
3604/// that guess. `strptr` is therefore `&mut String` here (C's out-
3605/// pointer) and the return value is C's own 0/1 status.
3606///
3607/// Two behaviours the previous port did not have:
3608///
3609///   * `#` / `%` are anchors ONLY on the pattern path — i.e. only when
3610///     `HIST_SUBST_PATTERN` is set or `forcepat` is on (`:S`). Under
3611///     plain `:s` they are ordinary characters, and C never looks at
3612///     them (c:2344 gates the whole block). The old port applied them
3613///     unconditionally, so `!!:s/#print/echo/` substituted where zsh
3614///     reports `substitution failed`.
3615///   * the pattern path itself did not exist: the match was always
3616///     `strstr`, so a glob in the pattern never matched, and `&` in
3617///     the replacement was expanded by `convamps` even on the pattern
3618///     path, where C leaves it literal.
3619pub fn subst(strptr: &mut String, in_: &str, out: &str, gbal: i32, forcepat: i32) -> i32 {
3620    // c:2338 char *str = *strptr, *substcut, *sptr;
3621    let str_ = strptr.clone();
3622    let mut gbal = gbal; // c:2336
3623    let substcut: Option<usize>; // c:2338
3624    let mut off: usize; // c:2339 int off, inlen, outlen
3625    let inlen: usize;
3626    let outlen: usize;
3627
3628    // c:2341 — `if (!*in) in = str, gbal = 0;`  An empty pattern means
3629    // the whole line is the pattern, and a whole-line match cannot
3630    // repeat, so global is turned off with it.
3631    let in_owned: String = if in_.is_empty() {
3632        gbal = 0; // c:2342
3633        str_.clone() // c:2342
3634    } else {
3635        in_.to_string()
3636    };
3637    let mut in_: &str = &in_owned;
3638
3639    if isset(HISTSUBSTPATTERN) || forcepat != 0 {
3640        // c:2344
3641        let mut fl = SUB_LONG | SUB_REST | SUB_RETFAIL; // c:2345
3642        let oldin = in_; // c:2346
3643        if gbal != 0 {
3644            // c:2347
3645            fl |= SUB_GLOBAL; // c:2348
3646        }
3647        // c:2349 — `if (*in == '#' || *in == Pound)`.  The check covers
3648        // BOTH the literal `#` and the tokenized `Pound` (0x84) the
3649        // lexer emits for a `#` inside a parsed substitution body.
3650        if let Some(rest) = in_.strip_prefix('#').or_else(|| in_.strip_prefix(Pound)) {
3651            // c:2350 — anchor at head, flag needed if SUB_END is also set
3652            fl |= SUB_START; // c:2351
3653            in_ = rest; // c:2352 in++
3654        }
3655        if let Some(rest) = in_.strip_prefix('%') {
3656            // c:2354
3657            // c:2355 — anchor at tail
3658            in_ = rest; // c:2356 in++
3659            fl |= SUB_END; // c:2357
3660        }
3661        if in_ == oldin {
3662            // c:2359
3663            // c:2360 — no anchor, substring match
3664            fl |= SUB_SUBSTR; // c:2361
3665        }
3666        // c:2363-2364 — `if (in == str) in = dupstring(in);`  C has to
3667        // copy because `in` may alias the buffer `getmatch` is about to
3668        // rewrite through `strptr`; `in_owned` is already a separate
3669        // Rust String, so the aliasing cannot happen.
3670        let in_parsed = match parse_subst_string(in_) {
3671            // c:2365
3672            Ok(s) => s,
3673            Err(_) => return 1, // c:2366
3674        };
3675        if errflag.load(SeqCst) != 0 {
3676            // c:2365
3677            return 1; // c:2366
3678        }
3679        let out_parsed = match parse_subst_string(out) {
3680            // c:2367
3681            Ok(s) => s,
3682            Err(_) => return 1, // c:2368
3683        };
3684        if errflag.load(SeqCst) != 0 {
3685            // c:2367
3686            return 1; // c:2368
3687        }
3688        let in_subbed = singsub(&in_parsed); // c:2369
3689        if getmatch(strptr, &in_subbed, fl, 1, Some(&out_parsed)) != 0 {
3690            // c:2370
3691            return 0; // c:2371
3692        }
3693    } else {
3694        substcut = str_.find(in_); // c:2373
3695        if let Some(pos) = substcut {
3696            inlen = in_.len(); // c:2374
3697            let sptr = convamps(out, in_); // c:2375
3698            outlen = sptr.len(); // c:2376
3699
3700            // c:2378-2384 — replace, then (when global) keep looking
3701            // from just past the text that was written in, so the
3702            // replacement is never re-scanned.
3703            let mut cur = str_; // c:2382 *strptr
3704            let mut substcut = pos; // c:2379
3705            loop {
3706                off = substcut + outlen; // c:2380
3707                cur = format!("{}{}{}", &cur[..substcut], sptr, &cur[substcut + inlen..]); // c:2382
3708                                                                                          // c:2383 str = *strptr + off
3709                if gbal == 0 {
3710                    // c:2384
3711                    break;
3712                }
3713                match cur[off..].find(in_) {
3714                    // c:2384
3715                    Some(p) => substcut = off + p,
3716                    None => break,
3717                }
3718            }
3719            *strptr = cur;
3720
3721            return 0; // c:2386
3722        }
3723    }
3724
3725    1 // c:2390
3726}
3727
3728/// Port of `convamps()` from `Src/hist.c:2395`. — C decl `convamps(char *out, char *in, int inlen)`.
3729fn convamps(out: &str, in_pattern: &str) -> String {
3730    let mut result = String::with_capacity(out.len());
3731    let mut chars = out.chars().peekable();
3732    while let Some(c) = chars.next() {
3733        if c == '\\' {
3734            if let Some(&next) = chars.peek() {
3735                result.push(next);
3736                chars.next();
3737            }
3738        } else if c == '&' {
3739            result.push_str(in_pattern);
3740        } else {
3741            result.push(c);
3742        }
3743    }
3744    result
3745}
3746
3747/// Port of `checkcurline()` from `Src/hist.c:2422`. — C decl `checkcurline(Histent he)`.
3748///
3749/// C body (c:2421-2429):
3750/// ```c
3751/// static void checkcurline(Histent he)
3752/// {
3753///     if (he->histnum == curhist && (histactive & HA_ACTIVE)) {
3754///         curline.node.nam = chline;
3755///         curline.nwords = chwordpos/2;
3756///         curline.words = chwords;
3757///     }
3758/// }
3759/// ```
3760///
3761/// The previous Rust port was a complete fabrication: signature was
3762/// `(line: &str) -> i32` returning whether the head of the ring's
3763/// name matched the argument — none of which appears in the C body.
3764/// No caller existed because the bogus signature fit nothing.
3765///
3766/// Real C behaviour: when `he` is the current in-flight history
3767/// entry (matches `curhist` AND the history machinery is active),
3768/// flush the in-progress chline/chwordpos/chwords build state into
3769/// the `curline` placeholder so the caller sees the latest words.
3770/// Called from `movehistent` per c:1298 before returning.
3771///
3772/// WARNING: Rust adaptation diverges in storage shape — C uses
3773/// pointer-aliasing (curline.node.nam = chline) whereas Rust
3774/// stores cloned values into the `curline` Mutex. The observable
3775/// effect is the same after the function returns: a caller reading
3776/// `curline` sees the latest chline/chwords snapshot.
3777pub fn checkcurline(he: &histent) {
3778    // c:2421
3779    let curhist_val = curhist.load(SeqCst); // c:2424
3780    let active = histactive.load(SeqCst); // c:2424
3781    if he.histnum == curhist_val && (active & HA_ACTIVE) != 0 {
3782        // c:2424
3783        let chline_val = chline.lock().expect("chline poisoned").clone(); // c:2425
3784        let chwordpos_val = chwordpos.load(SeqCst); // c:2426
3785        let chwords_val = chwords.lock().expect("chwords poisoned").clone(); // c:2427
3786        let mut cl = curline.lock().expect("curline poisoned");
3787        // Build a fresh histent snapshot mirroring the C field
3788        // aliasing — name = chline, nwords = chwordpos/2,
3789        // words = chwords.
3790        *cl = Some(histent {
3791            node: hashnode {
3792                next: None,
3793                nam: chline_val, // c:2425
3794                flags: 0,
3795            },
3796            up: None,
3797            down: None,
3798            zle_text: None,
3799            stim: 0,
3800            ftim: 0,
3801            words: chwords_val,        // c:2427
3802            nwords: chwordpos_val / 2, // c:2426
3803            histnum: he.histnum,
3804        });
3805    }
3806}
3807
3808/// Port of `quietgethist()` from `Src/hist.c:2433`. — C decl `quietgethist(int ev)`.
3809pub fn quietgethist(ev: i64) -> Option<histent> {
3810    // c:2433
3811    // Lazy-history chokepoint: a miss BELOW the loaded floor pages the
3812    // older part of the HISTFILE in on demand (extensions/history_lazy
3813    // — the file is never slurped whole at startup).
3814    let hit = ring_get(ev);
3815    if hit.is_none()
3816        && ev > 0
3817        && crate::history_lazy::has_older()
3818        && ev < crate::history_lazy::floor_num()
3819    {
3820        crate::history_lazy::page_older_until(ev);
3821        return ring_get(ev);
3822    }
3823    hit
3824}
3825
3826/// Port of `gethist()` from `Src/hist.c:2440`. — C decl `gethist(int ev)`.
3827pub fn gethist(ev: i64) -> Option<histent> {
3828    // c:2440
3829    let mut ret = quietgethist(ev);
3830    // c:1318-1343 — C's ring also holds the `curline` sentinel that
3831    // linkcurline (c:1079-1089) splices in while a line is being read,
3832    // and gethistent's `checkcurline(he)` (c:1342) points it at the text
3833    // read so far; checkcurline (c:2424) gates on the same `histnum ==
3834    // curhist && HA_ACTIVE` pair. zshrs keeps the sentinel outside its
3835    // ring, so `curhist` missed and `!#` (c:751-753, `ev = curhist`)
3836    // reported "no such event" instead of the current line.
3837    //
3838    // !!! Resolved here, not in quietgethist: the ZLE navigation-list
3839    // sync (zle_main.rs) walks quietgethist up to curhist and relies on
3840    // the in-flight line being absent, or a PS2 read would record the
3841    // partial text as that event.
3842    if ret.is_none() && ev == curhist.load(SeqCst) && (histactive.load(SeqCst) & HA_ACTIVE) != 0 {
3843        let sentinel = curline.lock().unwrap().as_ref().map(clone_histent);
3844        if let Some(he) = sentinel {
3845            checkcurline(&he); // c:1342
3846            ret = curline.lock().unwrap().as_ref().map(clone_histent);
3847        }
3848    }
3849    if ret.is_none() {
3850        herrflush();
3851        zerr(&format!("no such event: {}", ev));
3852    }
3853    ret
3854}
3855
3856/// Port of `getargs()` from `Src/hist.c:2454`. — C decl `getargs(Histent elist, int arg1, int arg2)`.
3857///
3858/// C body (c:2456-2482):
3859/// ```c
3860/// short *words = elist->words;
3861/// int pos1, pos2, nwords = elist->nwords;
3862///
3863/// if (arg2 < arg1 || arg1 >= nwords || arg2 >= nwords) {
3864///     herrflush();
3865///     zerr("no such word in event");
3866///     return NULL;
3867/// }
3868/// if (arg1 == 0 && arg2 == nwords - 1)
3869///     return dupstring(elist->node.nam);
3870///
3871/// pos1 = words[2*arg1];
3872/// pos2 = words[2*arg2+1];
3873///
3874/// /* a word has to be at least one character long, so if the position
3875///  * of a word is less than its index, we've overflowed our signed
3876///  * short integer word range and the recorded position is garbage. */
3877/// if (pos1 < 0 || pos1 < arg1 || pos2 < 0 || pos2 < arg2) {
3878///     herrflush();
3879///     zerr("history event too long, can't index requested words");
3880///     return NULL;
3881/// }
3882/// return dupstrpfx(elist->node.nam + pos1, pos2 - pos1);
3883/// ```
3884///
3885/// Notes:
3886///   - `nwords` reads `elist->nwords` directly (authoritative).
3887///   - Overflow check `pos1 < arg1 || pos2 < arg2` detects signed-
3888///     short overflow: since each word must be ≥1 char long, the
3889///     start-byte index must be ≥ the 0-based word index; a
3890///     too-small stored position signals i16 overflow on history
3891///     lines >32KB.
3892pub fn getargs(entry: &histent, arg1: usize, arg2: usize) -> Option<String> {
3893    // c:2454
3894    let nwords = entry.nwords as usize; // c:2457 nwords = elist->nwords
3895    if arg2 < arg1 || arg1 >= nwords || arg2 >= nwords {
3896        // c:2459
3897        herrflush(); // c:2461
3898        zerr("no such word in event"); // c:2462
3899        return None; // c:2463
3900    }
3901    // c:2466-2467 — `if (arg1 == 0 && arg2 == nwords - 1) return dupstring(nam);`
3902    if arg1 == 0 && arg2 == nwords - 1 {
3903        return Some(entry.node.nam.clone()); // c:2467
3904    }
3905    let pos1_raw = entry.words.get(2 * arg1).copied().unwrap_or(-1); // c:2469 pos1 = words[2*arg1]
3906    let pos2_raw = entry.words.get(2 * arg2 + 1).copied().unwrap_or(-1); // c:2470 pos2 = words[2*arg2+1]
3907                                                                         // c:2476 — C signed-short overflow detection: any negative
3908                                                                         // position OR a position less than its corresponding word
3909                                                                         // index means the i16 storage wrapped on a >32KB history line.
3910    if pos1_raw < 0
3911        || (pos1_raw as i64) < (arg1 as i64)
3912        || pos2_raw < 0
3913        || (pos2_raw as i64) < (arg2 as i64)
3914    {
3915        // c:2476
3916        herrflush(); // c:2477
3917        zerr(
3918            "history event too long, can't index requested words", // c:2478
3919        );
3920        return None; // c:2479
3921    }
3922    let pos1 = pos1_raw as usize;
3923    let pos2 = pos2_raw as usize;
3924    // c:2481 — `dupstrpfx(elist->node.nam + pos1, pos2 - pos1)`.
3925    // Rust slice indexing requires pos1 <= pos2 <= len; both are
3926    // satisfied since both passed the c:2476 overflow check and
3927    // are bounded by the underlying string length per insert
3928    // contract. Guard with .get() so a malformed entry doesn't
3929    // panic.
3930    entry.node.nam.get(pos1..pos2).map(|s| s.to_string()) // c:2481
3931}
3932
3933/// Port of `quote()` from `Src/hist.c:2486`. — C decl `quote(char **tr)`.
3934/// Wraps `*tr` in single quotes; `'` inside becomes `'\''` and any
3935/// `inblank(c)` (space/tab/newline) outside quotes that wasn't
3936/// preceded by `\` becomes `'<c>'`.
3937///
3938/// Previous Rust port used `c.is_whitespace()` — broader than C's
3939/// `inblank()` which is the narrow typtab INBLANK class (space, tab,
3940/// newline ONLY). Drift would silently quote CR/FF/VT/NBSP/etc.
3941/// chars that C leaves alone.
3942pub fn quote(s: &str) -> String {
3943    // c:2486
3944    let bytes: Vec<char> = s.chars().collect();
3945    let mut out = String::with_capacity(bytes.len() + 3);
3946    out.push('\'');
3947    let mut inquotes = false;
3948    let mut prev: char = '\0';
3949    for &c in bytes.iter() {
3950        // c:2499 — `inblank(*ptr)` is narrow space/tab/newline.
3951        let is_inblank = matches!(c, ' ' | '\t' | '\n');
3952        if c == '\'' {
3953            inquotes = !inquotes;
3954            out.push('\'');
3955            out.push('\\');
3956            out.push('\'');
3957            out.push('\'');
3958        } else if is_inblank && !inquotes && prev != '\\' {
3959            // c:2514
3960            out.push('\'');
3961            out.push(c);
3962            out.push('\'');
3963        } else {
3964            out.push(c);
3965        }
3966        prev = c;
3967    }
3968    out.push('\'');
3969    out
3970}
3971
3972/// Port of `quotebreak()` from `Src/hist.c:2527`. — C decl `quotebreak(char **tr)`.
3973/// Same
3974/// shape as `quote` but `inblank` chars get the `'<c>'` break-out
3975/// treatment whether or not they're inside quotes.
3976///
3977/// `c.is_whitespace()` → `inblank(c)` fix per the same divergence
3978/// pattern as `quote` above.
3979pub fn quotebreak(s: &str) -> String {
3980    // c:2527
3981    let mut result = String::with_capacity(s.len() + 10);
3982    result.push('\'');
3983    for c in s.chars() {
3984        // c:2548 — `inblank(*ptr)` narrow set.
3985        let is_inblank = matches!(c, ' ' | '\t' | '\n');
3986        if c == '\'' {
3987            result.push_str("'\\''");
3988        } else if is_inblank {
3989            result.push('\'');
3990            result.push(c);
3991            result.push('\'');
3992        } else {
3993            result.push(c);
3994        }
3995    }
3996    result.push('\'');
3997    result
3998}
3999
4000/// Port of `hdynread()` from `Src/hist.c:2562`. — C decl `hdynread(int stop)`.
4001/// C body is
4002/// inside `#if 0` (the in-tree `hdynread2` variant is the live one), but
4003/// the name-parity port mirrors the disabled body: read input chars via
4004/// `ingetc()` until `stop`/newline/lexstop, honoring `\\`-escape, and
4005/// emit a `delimiter expected` error if newline is hit first.
4006pub fn hdynread(stop: i32) -> Option<String> {
4007    // c:2562
4008    let stop_c = stop as u8 as char; // c:2562 int stop
4009    let mut buf = String::with_capacity(256); // c:2564 bsiz=256
4010    let mut c: Option<char>; // c:2564 int c
4011    loop {
4012        c = ingetc(); // c:2568
4013        match c {
4014            None => break,
4015            Some(ch) if ch == stop_c => break, // c:2568
4016            Some('\n') => break,               // c:2568
4017            Some(ch) => {
4018                if lexstop.load(SeqCst) {
4019                    break;
4020                } // c:2568
4021                let mut written = ch;
4022                if ch == '\\' {
4023                    // c:2569
4024                    if let Some(nxt) = ingetc() {
4025                        // c:2570
4026                        written = nxt;
4027                    } else {
4028                        break;
4029                    }
4030                }
4031                buf.push(written); // c:2571
4032            }
4033        }
4034    }
4035    if let Some('\n') = c {
4036        // c:2578
4037        inungetc('\n'); // c:2579
4038        zerr("delimiter expected"); // c:2580
4039        return None; // c:2582
4040    }
4041    Some(buf) // c:2584
4042}
4043
4044/// Port of `ihungetc()` from `Src/hist.c:989`. — C decl `ihungetc(int c)`.
4045/// Push back `c` into the lexer input stream while history-rewriting
4046/// is in progress: also rewinds chline (`hptr--`), undoes the
4047/// `expanding`-driven `zlemetacs`/`zlemetall` advance, and tracks the
4048/// `qbang` state for `\!` re-escape on the next pass. Loops while
4049/// `qbang` keeps firing (which can re-trigger via the `c='\\'` step
4050/// at the bottom).
4051pub fn ihungetc(c: i32) {
4052    // c:989
4053    // Rust-only: pool threads run C's `hungetc = inungetc` arm (c:1140).
4054    if crate::worker::in_worker_thread() {
4055        if let Some(ch) = char::from_u32(c as u32) {
4056            crate::ported::input::inungetc(ch);
4057        }
4058        return;
4059    }
4060    let mut c = char::from_u32(c as u32).unwrap_or(c as u8 as char); // c:991 int c
4061    let mut doit = 1; // c:991 doit = 1
4062    while !lexstop.load(SeqCst)                         // c:993 while (!lexstop && !errflag)
4063        && errflag.load(SeqCst) == 0
4064    {
4065        let hp = hptr.load(SeqCst);
4066        let line = chline.lock().unwrap().clone();
4067        let line_b = line.as_bytes();
4068        let stop = stophist.load(SeqCst);
4069        let inflags = crate::ported::input::inbufflags.with(|f| f.get());
4070        let active = histactive.load(SeqCst);
4071        if hp >= 2 && hp <= line_b.len()                                     // c:994-997
4072            && line_b[hp - 1] != c as u8 && stop < 4
4073            && line_b[hp - 1] == b'\n' && line_b[hp - 2] == b'\\'
4074            && (active & HA_UNGET) == 0
4075            && (inflags & (INP_ALIAS | INP_HIST)) != INP_ALIAS
4076        {
4077            histactive.fetch_or(HA_UNGET, SeqCst); // c:998
4078            inungetc('\n'); // c:999 hungetc('\n') — default = inungetc (c:1140)
4079            inungetc('\\'); // c:1000
4080            histactive.fetch_and(!HA_UNGET, SeqCst);
4081            // c:1001
4082        }
4083        if expanding.load(SeqCst) != 0 {
4084            // c:1004 if (expanding)
4085            // c:1005-1006 — `zlemetacs--; zlemetall--;`. C ungets one BYTE; zshrs
4086            // ungets a whole char, which `zleaddtoline` added as `len_utf8` bytes.
4087            let w = c.len_utf8() as i32;
4088            ZLEMETACS.fetch_sub(w, SeqCst); // c:1005 zlemetacs--
4089            crate::ported::zle::compcore::ZLEMETALL.fetch_sub(w, SeqCst); // c:1006 zlemetall--
4090            exlast.fetch_add(1, SeqCst); // c:1007 exlast++
4091        }
4092        if (inflags & (INP_ALIAS | INP_HIST)) != INP_ALIAS {
4093            // c:1009
4094            // c:1010 — DPUTS(hptr <= chline, "BUG: hungetc attempted at buffer start")
4095            DPUTS!(hp <= 0, "BUG: hungetc attempted at buffer start"); // c:1010
4096                                                                       // c:1012 — DPUTS(*hptr != (char) c, "BUG: wrong character in hungetc() ")
4097            DPUTS!(
4098                // c:1012
4099                hp > 0 && line_b.get(hp - 1).copied() != Some(c as u8), // c:1012
4100                "BUG: wrong character in hungetc() "                    // c:1012
4101            );
4102            let new_hp = hp.saturating_sub(c.len_utf8()); // ihwaddc advanced hptr by len_utf8
4103            hptr.store(new_hp, SeqCst); // c:1011 hptr--
4104            let bangchar_v = bangchar.load(SeqCst) as u8;
4105            let qb = c == bangchar_v as char && stop < 2                       // c:1014-1015
4106                && new_hp > 0 && line_b.get(new_hp - 1).copied() == Some(b'\\');
4107            qbang.store(qb, SeqCst);
4108        } else {
4109            qbang.store(false, SeqCst); // c:1018 No active bangs in aliases
4110        }
4111        if doit != 0 {
4112            // c:1020
4113            inungetc(c); // c:1021
4114        }
4115        if !qbang.load(SeqCst) {
4116            return;
4117        } // c:1022
4118        let inflags2 = crate::ported::input::inbufflags.with(|f| f.get());
4119        doit = if stophist.load(SeqCst) == 0            // c:1023-1024
4120            && ((inflags2 & INP_HIST) != 0 || (inflags2 & INP_ALIAS) == 0)
4121        {
4122            1
4123        } else {
4124            0
4125        };
4126        c = '\\'; // c:1025
4127    }
4128}
4129
4130/// Port of `getsubsargs()` from `Src/hist.c:518`. — C decl `getsubsargs(UNUSED(char *subline), int *gbalp, int *cflagp)`.
4131/// Parses the substitution arguments of a
4132/// `!:s/old/new/`-style history modifier: reads the delimiter via
4133/// ingetc, slurps `old` and `new` chunks, stores them in `hsubl`/`hsubr`
4134/// globals, then peeks the trailing `:G` (global) or fall-through char.
4135/// Returns 0 on success, 1 on a bad-expansion (empty old chunk).
4136/// WARNING: param names don't match C — Rust=(_subline, gbalp, cflagp) vs C=(subline, gbalp, cflagp)
4137pub fn getsubsargs(_subline: &str, gbalp: &mut i32, cflagp: &mut i32) -> i32 {
4138    // c:518
4139    let del = match ingetc() {
4140        // c:524 del = ingetc()
4141        Some(c) => c,
4142        None => return 1,
4143    };
4144    let ptr1 = hdynread2(del); // c:524
4145    // c:525-526 — `if (!ptr1) return 1;` cannot fire: hdynread2 always
4146    // returns the buffer it zalloc's at c:2593, never NULL.
4147    let ptr2 = hdynread2(del); // c:527
4148    if !ptr1.is_empty() {
4149        // c:530
4150        *hsubl.lock().unwrap() = Some(ptr1); // c:531-532 zsfree(hsubl); hsubl = ptr1
4151    } else if hsubl.lock().unwrap().is_none() {
4152        // c:533 fail silently
4153        return 0; // c:536
4154    }
4155    *hsubr.lock().unwrap() = Some(ptr2); // c:539-540 zsfree(hsubr); hsubr = ptr2
4156    let follow = ingetc(); // c:541 follow = ingetc()
4157    if follow == Some(':') {
4158        // c:542
4159        let next = ingetc(); // c:543
4160        if next == Some('G') {
4161            *gbalp = 1;
4162        }
4163        // c:544-545
4164        else {
4165            if let Some(c) = next {
4166                inungetc(c);
4167            } // c:547 inungetc
4168            *cflagp = 1; // c:548
4169        }
4170    } else if let Some(c) = follow {
4171        inungetc(c); // c:551 inungetc(follow)
4172    }
4173    0 // c:553
4174}
4175
4176/// Port of `hdynread2()` from `Src/hist.c:2590`. — C decl `hdynread2(int stop)`.
4177///
4178/// Read one `:s` / `^foo^bar` argument from the INPUT STREAM, up to the
4179/// delimiter `stop`, a newline, or end of input.
4180///
4181/// A newline ends the argument but is NOT consumed: c:2606-2607 puts it
4182/// back. That is what lets the closing delimiter be omitted —
4183/// `^old^new<Return>` reads `new` up to the newline, and that newline
4184/// still has to be in the stream afterwards to end the command. The
4185/// reader `getsubsargs` used to carry inline swallowed it, so the
4186/// expanded line had no terminator and the lexer read the NEXT line as
4187/// more of the same command: at a prompt `^old^new` echoed nothing, ran
4188/// nothing and sat in a continuation read; under `-s` it glued
4189/// `print new xx` onto the following line.
4190pub fn hdynread2(stop: char) -> String {
4191    // c:2590
4192    let mut buf = String::with_capacity(256); // c:2592-2593 bsiz = 256, zalloc
4193    let mut c: Option<char>; // c:2592 int c
4194    loop {
4195        // c:2596 — `while ((c = ingetc()) != stop && c != '\n' && !lexstop)`.
4196        // The port's `ingetc` reports lexstop as None (c:Src/input.c:322).
4197        c = ingetc(); // c:2596
4198        let Some(mut ch) = c else { break };
4199        if ch == stop || ch == '\n' {
4200            break;
4201        }
4202        if ch == '\\' {
4203            // c:2597
4204            // c:2598 — `c = ingetc();` The backslash is ALWAYS dropped and
4205            // only the character after it is stored — C does not care
4206            // whether that character is the delimiter.
4207            match ingetc() {
4208                Some(n) => ch = n, // c:2598
4209                None => break,
4210            }
4211        }
4212        buf.push(ch); // c:2599 *ptr++ = c (c:2600-2603 realloc = String growth)
4213    }
4214    // c:2605 *ptr = 0 — a Rust String needs no terminator.
4215    if c == Some('\n') {
4216        // c:2606
4217        inungetc('\n'); // c:2607
4218    }
4219    buf // c:2608
4220}
4221
4222/// Port of `inithist()` from `Src/hist.c:2613`. — C decl `inithist(void)`.
4223pub fn inithist() {
4224    // c:2613
4225    histsiz.store(1000, SeqCst);
4226    savehistsiz.store(1000, SeqCst);
4227    curhist.store(0, SeqCst);
4228    histlinect.store(0, SeqCst);
4229}
4230
4231/// Port of `resizehistents()` from `Src/hist.c:2620`. — C decl `resizehistents(void)`.
4232/// free the
4233/// oldest entries until `histlinect <= histsiz` (C loops
4234/// `putoldhistentryontop(1); freehistnode(&hist_ring->node)`).
4235/// `hist_ring` is strictly DESCENDING by histnum (see ring_get), so
4236/// the oldest entries are the Vec TAIL — one `truncate`, not the
4237/// previous per-entry `ring_oldest()` scan + `retain()` rebuild,
4238/// which was O(n²): a 558k-entry HISTFILE read under the default
4239/// histsiz spun for minutes (`fc -R` looked hung).
4240pub fn resizehistents() {
4241    let cap = histsiz.load(SeqCst);
4242    let ct = histlinect.load(SeqCst);
4243    if ct <= cap {
4244        return;
4245    }
4246    let excess = (ct - cap) as usize;
4247    let len = hist_ring.lock().unwrap().len();
4248    let keep = len.saturating_sub(excess);
4249    // c:2626-2629 — each `freehistnode` runs freehistdata, which drops
4250    // the entry's histtab key; do that before cutting the tail.
4251    for idx in keep..len {
4252        crate::ported::hashtable::freehistdata(idx, 0);
4253    }
4254    let mut ring = hist_ring.lock().unwrap();
4255    ring.truncate(keep);
4256    histlinect.store(cap, SeqCst);
4257}
4258
4259/// Port of `readhistline()` from `Src/hist.c:2635`. — C decl `readhistline(int start, char **bufp, int *bufsiz, FILE *in, int *readbytes)`.
4260///
4261/// Returns one LOGICAL history line: physical lines ending in a
4262/// backslash are joined with the backslash turned into the newline it
4263/// escaped, and the single space `savehistfile` appends after a
4264/// trailing backslash run (c:3063-3068) is removed again. `lines` are
4265/// the file's physical lines without their `\n`; `ends_with_nl` says
4266/// whether the LAST one had one (fgets' `buf[len - 1] != '\n'` test).
4267/// `None` is C's `return 0` — end of file.
4268pub fn readhistline<'a, I: Iterator<Item = &'a str>>(
4269    lines: &mut std::iter::Peekable<I>,
4270    ends_with_nl: bool,
4271) -> Option<String> {
4272    let mut buf = String::new();
4273    loop {
4274        let raw = lines.next()?; // c:2638 fgets; NULL → return 0
4275        buf.push_str(raw);
4276        // c:2644-2651 — an unterminated last line is returned as is.
4277        if lines.peek().is_none() && !ends_with_nl {
4278            return Some(buf);
4279        }
4280        // c:2654 buf[len - 1] = '\0'; c:2655-2659 — a trailing
4281        // backslash escaped a newline: turn it back into one and read on.
4282        // fgets stopped at the '\n', so feof() is still false here and
4283        // C always recurses; a continuation on the last line therefore
4284        // meets fgets == NULL and the whole entry is dropped (c:2638).
4285        if buf.ends_with('\\') {
4286            buf.pop();
4287            buf.push('\n');
4288            continue;
4289        }
4290        // c:2661-2665 — backslashes followed by spaces: drop the one
4291        // space the writer added so the line did not read as continued.
4292        let trimmed_len = buf.trim_end_matches(' ').len();
4293        if trimmed_len != buf.len() && buf[..trimmed_len].ends_with('\\') {
4294            buf.pop();
4295        }
4296        return Some(buf);
4297    }
4298}
4299
4300/// Port of `readhistfile()` from `Src/hist.c:2675`. — C decl `readhistfile(char *fn, int err, int readflags)`.
4301pub fn readhistfile(fn_path: Option<&str>, _err: i32, readflags: i32) {
4302    // c:2675
4303    let path: String = match fn_path {
4304        Some(p) => p.to_string(),
4305        None => match resolve_histfile() {
4306            Some(p) => p,
4307            // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
4308            // zshrs's DEFAULT history store is the SQLite index; its
4309            // extended-history text mirror ($ZSHRS_HOME/zshrs_history,
4310            // extensions/history.rs::text_path) is kept current by the
4311            // per-command interactive sink (history_sqlite_add). Outside
4312            // --zsh mode (or with no $HISTFILE), the startup read (init.c:1573)
4313            // hydrates the ring from that mirror so up-arrow recalls
4314            // previous sessions. READ-side only: savehistfile stays
4315            // HISTFILE-gated — the engine owns all mirror writes, so a
4316            // rewrite-at-exit would duplicate every row.
4317            None => {
4318                let p = crate::history::HistoryEngine::text_path();
4319                if !p.exists() {
4320                    return;
4321                }
4322                p.to_string_lossy().to_string()
4323            }
4324        },
4325    };
4326    // c:2675 — HFILE_FAST is the incremental (share / append) read: resume at
4327    // lasthist.fpos and consume only what other shells appended, rather than
4328    // re-parsing the whole file. Under SHARE_HISTORY hend() re-reads after
4329    // EVERY command (c:1529); a full re-read there is O(file) per prompt (and,
4330    // with the Vec ring, was an O(n²) memmove hang on a large HISTFILE).
4331    let fast = readflags & HFILE_FAST as i32 != 0;
4332    use std::os::unix::fs::MetadataExt;
4333    let meta = std::fs::metadata(&path).ok();
4334    let cur_size = meta.as_ref().map(|m| m.len() as i64).unwrap_or(0);
4335    let cur_mtim = meta.as_ref().map(|m| m.mtime()).unwrap_or(0);
4336    if cur_size == 0 {
4337        return;
4338    }
4339    if fast {
4340        // Nothing changed since our last read/write — savehistfile keeps
4341        // lasthist.{fpos,fsiz,mtim} current (c:3049/3079/3080) — so there is
4342        // nothing new to pull in. Skip without opening the file.
4343        let lh = lasthist.lock().unwrap();
4344        if lh.fsiz == cur_size && lh.mtim == cur_mtim {
4345            return;
4346        }
4347    }
4348    // A fast read resumes at lasthist.fpos when the file only grew; a rewrite
4349    // or truncation (file now shorter than fpos) or a cold/full read starts at
4350    // 0. `read_end` is where we actually stopped, recorded into lasthist below.
4351    let start_pos: i64 = if fast {
4352        let fp = lasthist.lock().unwrap().fpos;
4353        if fp > 0 && fp <= cur_size {
4354            fp
4355        } else {
4356            0
4357        }
4358    } else {
4359        0
4360    };
4361    // LAZY cold read — the history is never slurped whole (user-directed
4362    // contract; see extensions/history_lazy.rs). A cold read loads ONE
4363    // newest page; everything older stays on disk and pages in on demand
4364    // (navigation below the floor, whole-history scans). Exact zsh event
4365    // numbering comes from a zero-heap streaming entry count so `fc -l`,
4366    // `!N` and HISTNO match a full eager load. The old cold path
4367    // `read_to_end`'d the entire HISTFILE (635MB / 566k entries) into a
4368    // String + one histent per line, per shell — multi-second first
4369    // prompts and hundreds of MB × N shells at fleet start.
4370    //
4371    // Paging serves the interactive ring only. Inside a pushed history
4372    // stack (histsave_stack_pos > 0) the read is savehistfile's SAVEHIST
4373    // trim (c:3113-3121): it re-reads the file and REWRITES it from the
4374    // ring, so a one-page read there truncated $HISTFILE to its newest
4375    // page on every exit, and `arm` repointed the interactive ring's
4376    // floor at the temporary stack. Read the whole file, as C does.
4377    let page_bytes = crate::history_lazy::PAGE_BYTES as i64;
4378    let tail_start: i64 = if start_pos == 0
4379        && cur_size > page_bytes
4380        && histsave_stack_pos.load(SeqCst) == 0
4381    {
4382        cur_size - page_bytes
4383    } else {
4384        start_pos
4385    };
4386    let tail_bounded = tail_start > start_pos;
4387    let total_entries: Option<u64> = if tail_bounded {
4388        crate::history_lazy::count_entries(&path)
4389    } else {
4390        None
4391    };
4392    let start_pos = tail_start;
4393    let (contents, raw_len) = {
4394        use std::io::{Read, Seek, SeekFrom};
4395        let mut f = match std::fs::File::open(&path) {
4396            Ok(f) => f,
4397            Err(_) => return,
4398        };
4399        if start_pos > 0 && f.seek(SeekFrom::Start(start_pos as u64)).is_err() {
4400            return;
4401        }
4402        // c:2718 readhistline — C reads RAW BYTES and unmetafies each
4403        // line: savehistfile writes the file METAFIED (c:3030), and
4404        // decades-old real-world HISTFILEs also carry non-UTF-8 bytes
4405        // (latin1 args, binary paste). The previous `read_to_string`
4406        // errored on the first invalid byte and silently skipped the
4407        // ENTIRE file — a 26MB / 558k-line zpwr history loaded zero
4408        // entries (empty ring, no up-arrow across sessions) while small
4409        // clean-ASCII files worked. Unmetafy, then lossy-decode.
4410        // `raw_len` keeps the ON-DISK byte count: lasthist.fpos tracks
4411        // raw file offsets, and unmetafy/lossy-decode shrink the buffer
4412        // — recording the shrunk length would make the next HFILE_FAST
4413        // share-read resume short and re-read (duplicate) the tail.
4414        let mut bytes = Vec::new();
4415        if f.read_to_end(&mut bytes).is_err() {
4416            return;
4417        }
4418        let raw_len = bytes.len() as i64;
4419        crate::ported::utils::unmetafy(&mut bytes); // c:2731 unmetafy
4420        (String::from_utf8_lossy(&bytes).into_owned(), raw_len)
4421    };
4422    let read_end = start_pos + raw_len;
4423    if contents.is_empty() {
4424        // Nothing appended past fpos, but the file was touched (mtime/size
4425        // differed). Refresh lasthist so the next fast read early-outs.
4426        let mut lh = lasthist.lock().unwrap();
4427        lh.fpos = read_end;
4428        lh.fsiz = cur_size;
4429        lh.mtim = cur_mtim;
4430        return;
4431    }
4432    // c:2700-2706 — lockhistfile return codes:
4433    //   0 — lock acquired, proceed
4434    //   2 — locking failed but "reading anyway"; warn + continue
4435    //   else — locking failed hard; zerr + bail
4436    let lock_ret = lockhistfile(Some(&path), 1);
4437    if lock_ret != 0 {
4438        if lock_ret == 2 {
4439            crate::ported::utils::zwarn(&format!(
4440                "locking failed for {}: {}: reading anyway",
4441                path,
4442                std::io::Error::last_os_error()
4443            ));
4444        } else {
4445            crate::ported::utils::zerr(&format!(
4446                "locking failed for {}: {}",
4447                path,
4448                std::io::Error::last_os_error()
4449            ));
4450            return;
4451        }
4452    }
4453
4454    // c:2675 — zsh's hist_ring is a doubly-linked list, so its per-entry
4455    // "insert at head" (addhistnode) is O(1). This port stores the ring in a
4456    // Vec, where `insert(0, ..)` shifts every existing element — O(n) per line,
4457    // O(n²) over the whole file. A large HISTFILE (hundreds of thousands of
4458    // lines) plus SHARE_HISTORY (which re-reads the file in hend() after every
4459    // command, c:1529) turned that into a multi-second memmove spin per prompt.
4460    // Collect into a local batch in file order (push = O(1)), then splice the
4461    // whole run onto the front of the ring ONCE below. Repeated `insert(0)` of
4462    // e1..eN (oldest..newest) yields ring == [eN..e1, existing..]; pushing
4463    // e1..eN then reversing gives the same [eN..e1] prefix. One lock, not one
4464    // per line.
4465    let mut batch: Vec<histent> = Vec::new();
4466    let mut current: Option<(i64, i64, String)> = None;
4467    // Tail-bounded reads land mid-entry: align to the first ENTRY
4468    // boundary. Skip the (guaranteed partial) first line, then — for
4469    // extended-history files — skip continuation lines until a `: N:M;`
4470    // header starts a fresh entry. Plain-format files align at the
4471    // first full line.
4472    let mut lines_iter = contents.lines();
4473    let mut align_consumed: usize = 0;
4474    if tail_bounded {
4475        if let Some(first) = lines_iter.next() {
4476            align_consumed += first.len() + 1; // partial first line
4477        }
4478        let looks_extended = contents.contains("\n: ");
4479        if looks_extended {
4480            let mut peeked = lines_iter.clone();
4481            while let Some(l) = peeked.next() {
4482                if l.starts_with(": ") && l.contains(';') {
4483                    break;
4484                }
4485                align_consumed += l.len() + 1;
4486                let _ = lines_iter.next();
4487            }
4488        }
4489    }
4490    let mut lines_iter = lines_iter.peekable();
4491    let ends_with_nl = contents.ends_with('\n');
4492    while let Some(logical) = readhistline(&mut lines_iter, ends_with_nl) {
4493        let raw_line = logical.as_str();
4494        if let Some((stim, ftim, ref text)) = current {
4495            let n = curhist.fetch_add(1, SeqCst) + 1;
4496            let mut entry = make_histent(n, text.clone());
4497            entry.stim = stim;
4498            entry.ftim = ftim;
4499            entry.node.flags |= HIST_OLD as i32;
4500            batch.push(entry);
4501            histlinect.fetch_add(1, SeqCst);
4502            current = None;
4503        }
4504        // c:2771-2785 — `: STIM:ELAPSED;TEXT` extended header. C only
4505        // looks at the leading `:` and scans with zstrtol, so a header
4506        // missing its `:ELAPSED` part leaves the text EMPTY (pt ran to
4507        // the NUL looking for the `:`) and ftim = stim.
4508        let (stim, ftim, text): (i64, i64, &str) = if let Some(pt) = raw_line.strip_prefix(':') {
4509            let (stim, _) = crate::ported::utils::zstrtol(pt, 0); // c:2773
4510            match pt.find(':') {
4511                // c:2775-2779
4512                Some(c) => {
4513                    let pt = &pt[c + 1..];
4514                    let (ftim, _) = crate::ported::utils::zstrtol(pt, 0); // c:2776
4515                    let text = match pt.find(';') {
4516                        Some(s) => &pt[s + 1..],
4517                        None => "",
4518                    };
4519                    (stim, ftim, text)
4520                }
4521                None => (stim, stim, ""), // c:2780-2781
4522            }
4523        } else {
4524            // c:2783-2784 — a plain-format line starting with `:` was
4525            // written as `\:` (c:3057-3058) so it does not read as a header.
4526            let text = match raw_line.strip_prefix("\\:") {
4527                Some(_) => &raw_line[1..],
4528                None => raw_line,
4529            };
4530            (0, 0, text)
4531        };
4532        // c:2816-2821 — no timestamp means "now"; an ELAPSED smaller than
4533        // the start is a duration (what savehistfile writes, c:3055-3056).
4534        let (stim, ftim) = if stim == 0 {
4535            let now = std::time::SystemTime::now()
4536                .duration_since(std::time::UNIX_EPOCH)
4537                .map(|d| d.as_secs() as i64)
4538                .unwrap_or(0);
4539            (now, now)
4540        } else if ftim < stim {
4541            (stim, stim + ftim)
4542        } else {
4543            (stim, ftim)
4544        };
4545        current = Some((stim, ftim, text.to_string()));
4546    }
4547    if let Some((stim, ftim, text)) = current {
4548        let n = curhist.fetch_add(1, SeqCst) + 1;
4549        let mut entry = make_histent(n, text);
4550        entry.stim = stim;
4551        entry.ftim = ftim;
4552        entry.node.flags |= HIST_OLD as i32;
4553        batch.push(entry);
4554        histlinect.fetch_add(1, SeqCst);
4555    }
4556    // Lazy numbering: the newest page's entries carry the TAIL of the
4557    // exact event range (…, TOTAL-1, TOTAL) so numbering is identical
4558    // to a full eager load; older pages fill downward on demand.
4559    if tail_bounded {
4560        if let Some(total) = total_entries {
4561            let k = batch.len() as i64;
4562            let shift = (total as i64) - k - (curhist.load(SeqCst) - k);
4563            if shift != 0 {
4564                for e in batch.iter_mut() {
4565                    e.histnum += shift;
4566                }
4567                curhist.fetch_add(shift, SeqCst);
4568            }
4569        }
4570        let floor_num = batch.first().map(|e| e.histnum).unwrap_or(0);
4571        crate::history_lazy::arm(&path, tail_start + align_consumed as i64, floor_num);
4572    }
4573    if !batch.is_empty() {
4574        // Prepend the batch (newest-first) ahead of anything already in the
4575        // ring, matching the per-line `insert(0)` order. Single O(n) splice.
4576        batch.reverse();
4577        let mut ring = hist_ring.lock().unwrap();
4578        batch.append(&mut ring);
4579        *ring = batch;
4580    }
4581    // c:2675 — record where this read stopped. A non-fast (startup) read primes
4582    // lasthist too, so the first HFILE_FAST share-read early-outs instead of
4583    // re-reading from offset 0 and DUPLICATING every entry already in the ring.
4584    {
4585        let mut lh = lasthist.lock().unwrap();
4586        lh.fpos = read_end;
4587        lh.fsiz = cur_size;
4588        lh.mtim = cur_mtim;
4589    }
4590    unlockhistfile(&path);
4591    resizehistents();
4592}
4593
4594/// Port of `flockhistfile()` from `Src/hist.c:2881`. — C decl `flockhistfile(char *fn, int keep_trying)`.
4595pub fn flockhistfile(path: &str) -> i32 {
4596    #[cfg(unix)]
4597    {
4598        if let Ok(file) = std::fs::OpenOptions::new()
4599            .write(true)
4600            .create(true)
4601            .open(format!("{}.lock", path))
4602        {
4603            let fd = file.as_raw_fd();
4604            return unsafe {
4605                if libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) == 0 {
4606                    1
4607                } else {
4608                    0
4609                }
4610            };
4611        }
4612        0
4613    }
4614    #[cfg(not(unix))]
4615    {
4616        let _ = path;
4617        1
4618    }
4619}
4620
4621/// Port of `savehistfile()` from `Src/hist.c:2922`. — C decl `savehistfile(char *fn, int err, int writeflags)`.
4622///
4623/// The public Rust signature omits the C `err` argument; its value is
4624/// recovered from the write flags. Every silent (`err == 0`) C call
4625/// site also sets `HFILE_FAST` (the incremental / share saves at
4626/// c:1193 and c:1639), while every loud (`err == 1`) site — shell exit
4627/// (c:3961), `fc -W` / `fc -A` (c:1511/1517) and the trim recursion
4628/// (c:3121) — leaves `HFILE_FAST` clear. So `err == !(writeflags &
4629/// HFILE_FAST)` reproduces the C value at all call sites without
4630/// changing the signature seen by callers.
4631pub fn savehistfile(fn_path: Option<&str>, writeflags: i32) {
4632    // c:2922
4633    use crate::ported::zsh_h::{
4634        APPENDHISTORY, EXTENDEDHISTORY, GETHIST_DOWNWARD, HFILE_APPEND, HFILE_NO_REWRITE,
4635        HFILE_SKIPDUPS, HFILE_SKIPFOREIGN, HFILE_SKIPOLD, HISTSAVEBYCOPY, HISTSAVENODUPS,
4636    };
4637    use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
4638
4639    let mut writeflags = writeflags;
4640    // c:2927 — zlong xcurhist = curhist - !!(histactive & HA_ACTIVE);
4641    let xcurhist = curhist.load(SeqCst)
4642        - if (histactive.load(SeqCst) & HA_ACTIVE) != 0 {
4643            1
4644        } else {
4645            0
4646        };
4647    // c:2928 — int extended_history = isset(EXTENDEDHISTORY);
4648    let mut extended_history = isset(EXTENDEDHISTORY);
4649    // Recovered C `err` (see fn doc): silent when HFILE_FAST is set.
4650    let mut err = writeflags & HFILE_FAST as i32 == 0;
4651
4652    // c:2931-2933 — `if (!interact || savehistsiz <= 0 || !hist_ring
4653    //                || (!fn && !(fn = getsparam("HISTFILE")))) return;`
4654    //
4655    // `!interact` is test-pinned (non-interactive shells must never
4656    // write the user's HISTFILE). An explicit `fc -W path` is gated the
4657    // same way: with SAVEHIST unset or 0 zsh writes nothing, not even an
4658    // empty file.
4659    if !isset(INTERACTIVE) {
4660        return;
4661    }
4662    let savehistsiz_v = savehistsiz.load(SeqCst); // c:2932 savehistsiz
4663    if savehistsiz_v <= 0 {
4664        return; // c:2932 savehistsiz <= 0
4665    }
4666    if ring_len() == 0 {
4667        return; // c:2931 !hist_ring
4668    }
4669    let path: String = match fn_path {
4670        // c:2932 fn / getsparam("HISTFILE")
4671        Some(p) => p.to_string(),
4672        None => match resolve_histfile() {
4673            Some(p) => p,
4674            None => return,
4675        },
4676    };
4677    // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
4678    // An explicit `fc -W/-A $HISTFILE` outside `--zsh` mode would still
4679    // write zsh's file; see resolve_histfile.
4680    if !crate::history::histfile_writable() && crate::history::is_histfile(&path) {
4681        crate::ported::utils::zwarn(&format!("{}: not writing zsh history file", path));
4682        return;
4683    }
4684
4685    // c:2934-2951 — pick the first entry to write and take the lock.
4686    let he: Option<i64>;
4687    if writeflags & HFILE_FAST as i32 != 0 {
4688        // c:2935 — he = gethistent(lasthist.next_write_ev, GETHIST_DOWNWARD);
4689        let start_ev = lasthist.lock().unwrap().next_write_ev;
4690        let mut cur = gethistent(start_ev, GETHIST_DOWNWARD);
4691        // c:2936-2939 — advance past entries already written (HIST_OLD).
4692        while let Some(h) = cur {
4693            let flags = ring_get(h).map(|e| e.node.flags).unwrap_or(0);
4694            if flags & HIST_OLD as i32 == 0 {
4695                break;
4696            }
4697            lasthist.lock().unwrap().next_write_ev = h + 1; // c:2937
4698            cur = down_histent(h); // c:2938
4699        }
4700        he = cur;
4701        // c:2940 — if (!he || lockhistfile(fn, 0)) return;
4702        if he.is_none() || lockhistfile(Some(&path), 0) != 0 {
4703            return;
4704        }
4705        // c:2942-2943 — too many lines already: drop to a full rewrite.
4706        if histfile_linect.load(SeqCst) > savehistsiz_v + savehistsiz_v / 5 {
4707            writeflags &= !(HFILE_FAST as i32);
4708        }
4709    } else {
4710        // c:2946-2949 — if (lockhistfile(fn, 1)) { zerr(...); return; }
4711        // (ret == 2 means "couldn't lock but proceed anyway".)
4712        let lret = lockhistfile(Some(&path), 1);
4713        if lret != 0 && lret != 2 {
4714            crate::ported::utils::zerr(&format!(
4715                "locking failed for {}: {}",
4716                path,
4717                std::io::Error::last_os_error()
4718            ));
4719            return;
4720        }
4721        // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
4722        // The interactive ring is paged (extensions/history_lazy.rs): only
4723        // the newest page is loaded at startup. C's ring always holds the
4724        // whole file, so a full write from a partial ring would cut the
4725        // file down to that page. Page everything in first. A pushed stack
4726        // (histsave_stack_pos > 0) is read whole and never paged.
4727        if histsave_stack_pos.load(SeqCst) == 0 && crate::history_lazy::has_older() {
4728            crate::history_lazy::page_older_until(0);
4729        }
4730        he = ring_oldest(); // c:2950 he = hist_ring->down;
4731    }
4732
4733    // c:2952-2962 — HFILE_USE_OPTIONS derives append/skip flags from options.
4734    if writeflags & HFILE_USE_OPTIONS as i32 != 0 {
4735        if isset(APPENDHISTORY)
4736            || isset(INCAPPENDHISTORY)
4737            || isset(INCAPPENDHISTORYTIME)
4738            || isset(SHAREHISTORY)
4739        {
4740            writeflags |= (HFILE_APPEND | HFILE_SKIPOLD) as i32; // c:2955
4741        } else {
4742            histfile_linect.store(0, SeqCst); // c:2957
4743        }
4744        if isset(HISTSAVENODUPS) {
4745            writeflags |= HFILE_SKIPDUPS as i32; // c:2959
4746        }
4747        if isset(SHAREHISTORY) {
4748            extended_history = true; // c:2961
4749        }
4750    }
4751
4752    // c:2963-3016 — open the destination: append, plain truncate, or
4753    // HISTSAVEBYCOPY (write `<fn>.new` then rename over `fn`).
4754    let umpath = crate::ported::utils::unmeta(&path);
4755    let append_mode = writeflags & HFILE_APPEND as i32 != 0;
4756    let mut tmpfile: Option<String> = None;
4757    let out: Option<std::fs::File> = if append_mode {
4758        // c:2964-2967 — open(fn, O_CREAT|O_WRONLY|O_APPEND, 0600)
4759        std::fs::OpenOptions::new()
4760            .create(true)
4761            .append(true)
4762            .mode(0o600)
4763            .open(&umpath)
4764            .ok()
4765    } else if !isset(HISTSAVEBYCOPY) {
4766        // c:2968-2971 — open(fn, O_CREAT|O_WRONLY|O_TRUNC, 0600)
4767        std::fs::OpenOptions::new()
4768            .write(true)
4769            .create(true)
4770            .truncate(true)
4771            .mode(0o600)
4772            .open(&umpath)
4773            .ok()
4774    } else {
4775        // c:2972-3015 — safe write through a sibling `.new` file.
4776        let tf = format!("{}.new", umpath); // c:2973 bicat(fn, ".new")
4777                                            // c:2974 — unlink(tmpfile); tolerate ENOENT.
4778        let unlink_ok = match std::fs::remove_file(&tf) {
4779            Ok(()) => true,
4780            Err(e) => e.kind() == std::io::ErrorKind::NotFound,
4781        };
4782        if !unlink_ok {
4783            None // c:2975 out = NULL;
4784        } else {
4785            let old_meta = std::fs::metadata(&umpath).ok(); // c:2978 stat(fn)
4786            let euid = unsafe { libc::geteuid() };
4787            // c:2981-2985 — rewriting must not change ownership (root exempt).
4788            let owner_change = old_meta
4789                .as_ref()
4790                .map(|m| euid != 0 && m.uid() != euid)
4791                .unwrap_or(false);
4792            if owner_change {
4793                // c:2986-2996 — skip; report only when err is set.
4794                if err {
4795                    if isset(APPENDHISTORY)
4796                        || isset(INCAPPENDHISTORY)
4797                        || isset(INCAPPENDHISTORYTIME)
4798                        || isset(SHAREHISTORY)
4799                    {
4800                        crate::ported::utils::zerr(&format!(
4801                            "rewriting {} would change its ownership -- skipped",
4802                            path
4803                        ));
4804                    } else {
4805                        crate::ported::utils::zerr(&format!(
4806                            "rewriting {} would change its ownership -- history not saved",
4807                            path
4808                        ));
4809                    }
4810                    err = false; // c:2994 err = 0; — don't also report below.
4811                }
4812                None
4813            } else {
4814                // c:2998 — open(tmpfile, O_CREAT|O_WRONLY|O_EXCL, 0600)
4815                match std::fs::OpenOptions::new()
4816                    .write(true)
4817                    .create_new(true)
4818                    .mode(0o600)
4819                    .open(&tf)
4820                {
4821                    Ok(f) => {
4822                        // c:3007-3013 — match the original owner and mode.
4823                        if let Some(ref m) = old_meta {
4824                            let fd = f.as_raw_fd();
4825                            unsafe {
4826                                let _ = libc::fchown(fd, m.uid(), m.gid()); // c:3010
4827                                let _ = libc::fchmod(fd, m.mode() as libc::mode_t);
4828                                // c:3012
4829                            }
4830                        }
4831                        tmpfile = Some(tf);
4832                        Some(f)
4833                    }
4834                    Err(_) => None, // c:3003 out = NULL;
4835                }
4836            }
4837        }
4838    };
4839
4840    let mut ret: i32 = 0;
4841    if let Some(out) = out {
4842        // c:2965/2969/2998 — C hands the descriptor to `fdopen(..., "a"/"w")`,
4843        // so `out` is a stdio `FILE *`. Every `fprintf`/`fputc` in the
4844        // c:3030-3074 write loop therefore lands in stdio's buffer and reaches
4845        // the kernel one BUFSIZ block at a time; C's only explicit boundaries
4846        // are the `fflush` at c:3077 and the `fclose` at c:3086.
4847        //
4848        // The port wrote each entry straight to the `File`, i.e. one `write(2)`
4849        // per history line. With `share_history` (or `inc_append_history`)
4850        // `hend` calls this on EVERY accepted command (c:1193, c:1639), so a
4851        // large `$HISTFILE` turned each prompt into one syscall per saved line.
4852        // Profiled with a 100k-entry ring, `repeat 40 { fc -AI }`:
4853        // `savehistfile` was 10447 samples, of which 8826 — 85% — were inside
4854        // `write(2)` itself. `BufWriter` is the stdio buffer C already had.
4855        let mut out = std::io::BufWriter::new(out);
4856        crate::ported::mem::pushheap(); // c:3021
4857
4858        // c:3018-3027 — compile the $HISTORY_IGNORE pattern once.
4859        let histpat = crate::ported::params::getsparam("HISTORY_IGNORE").and_then(|hi| {
4860            let mut s = crate::ported::string::dupstring(&hi); // c:3024 dupstring
4861            crate::ported::glob::tokenize(&mut s); // c:3024 tokenize
4862            crate::ported::glob::remnulargs(&mut s); // c:3025 remnulargs
4863            crate::ported::pattern::patcompile(&s, 0, None) // c:3026 patcompile
4864        });
4865
4866        // Running file offset for lasthist.fpos (ftell parity). Append
4867        // continues from the current size; truncate/copy start at 0.
4868        let mut fpos: i64 = if append_mode {
4869            std::fs::metadata(&umpath)
4870                .map(|m| m.len() as i64)
4871                .unwrap_or(0)
4872        } else {
4873            0
4874        };
4875        let mut start: Option<String> = None;
4876
4877        // c:3030 — for (; he && he->histnum <= xcurhist; he = down_histent(he))
4878        let mut cur = he;
4879        while let Some(h) = cur {
4880            let entry = match ring_get(h) {
4881                Some(e) => e,
4882                None => break,
4883            };
4884            if entry.histnum > xcurhist {
4885                break; // c:3030
4886            }
4887            let flags = entry.node.flags;
4888            // c:3033-3036 — skip dup/foreign/tmpstore per the write flags.
4889            if (writeflags & HFILE_SKIPDUPS as i32 != 0 && flags & HIST_DUP as i32 != 0)
4890                || (writeflags & HFILE_SKIPFOREIGN as i32 != 0 && flags & HIST_FOREIGN as i32 != 0)
4891                || flags & HIST_TMPSTORE as i32 != 0
4892            {
4893                cur = down_histent(h);
4894                continue;
4895            }
4896            // c:3037-3040 — skip entries matching $HISTORY_IGNORE.
4897            if let Some(ref pat) = histpat {
4898                if crate::ported::pattern::pattry(
4899                    pat,
4900                    &crate::ported::utils::metafy(&entry.node.nam),
4901                ) {
4902                    cur = down_histent(h);
4903                    continue;
4904                }
4905            }
4906            // c:3041-3047 — HFILE_SKIPOLD: skip old/nowrite, else mark old.
4907            if writeflags & HFILE_SKIPOLD as i32 != 0 {
4908                if flags & (HIST_OLD | HIST_NOWRITE) as i32 != 0 {
4909                    cur = down_histent(h);
4910                    continue;
4911                }
4912                if let Ok(mut ring) = hist_ring.lock() {
4913                    if let Some(e) = ring.iter_mut().find(|e| e.histnum == h) {
4914                        e.node.flags |= HIST_OLD as i32; // c:3044
4915                    }
4916                }
4917                if writeflags & HFILE_USE_OPTIONS as i32 != 0 {
4918                    lasthist.lock().unwrap().next_write_ev = entry.histnum + 1; // c:3046
4919                }
4920            }
4921            // c:3048-3052 — record write bookkeeping under USE_OPTIONS.
4922            if writeflags & HFILE_USE_OPTIONS as i32 != 0 {
4923                let mut lh = lasthist.lock().unwrap();
4924                lh.fpos = fpos; // c:3049 lasthist.fpos = ftell(out)
4925                lh.stim = entry.stim; // c:3050
4926                drop(lh);
4927                histfile_linect.fetch_add(1, SeqCst); // c:3051
4928            }
4929
4930            // c:3053-3073 — emit the command text with escaping.
4931            start = Some(entry.node.nam.clone()); // c:3053 start = he->node.nam
4932            let text = entry.node.nam.as_bytes();
4933            let mut buf: Vec<u8> = Vec::with_capacity(text.len() + 24);
4934            if extended_history {
4935                // c:3054-3056 — ": %ld:%ld;" prefix (only in extended mode).
4936                let dur = if entry.ftim != 0 {
4937                    entry.ftim - entry.stim
4938                } else {
4939                    0
4940                };
4941                let _ = write!(buf, ": {}:{};", entry.stim, dur);
4942            } else if text.first() == Some(&b':') {
4943                buf.push(b'\\'); // c:3057-3058 escape a leading ':'
4944            }
4945            // c:3060-3067 — escape embedded newlines; track trailing '\'.
4946            let mut end_backslashes = false;
4947            for &c in text {
4948                if c == b'\n' {
4949                    buf.push(b'\\'); // c:3062
4950                }
4951                end_backslashes = c == b'\\' || (end_backslashes && c == b' '); // c:3064
4952                                                                                // C writes the in-memory METAFIED text verbatim here
4953                                                                                // (metafication happened at input time, utils.c:4856);
4954                                                                                // zshrs's ring holds clean UTF-8, so metafy at write
4955                                                                                // time for byte parity with zsh's HISTFILE: IMETA bytes
4956                                                                                // ({0x00, 0x83..=0xA2}, typtab utils.c:4195-4201) are
4957                                                                                // escaped as Meta + (byte ^ 32). readhistfile's
4958                                                                                // unmetafy is the exact inverse.
4959                if crate::ported::utils::imeta_byte(c) {
4960                    buf.push(0x83); // Meta
4961                    buf.push(c ^ 32);
4962                } else {
4963                    buf.push(c); // c:3065
4964                }
4965            }
4966            if end_backslashes {
4967                buf.push(b' '); // c:3070-3071
4968            }
4969            buf.push(b'\n'); // c:3072
4970            if out.write_all(&buf).is_err() {
4971                ret = -1; // c:3065/3072 — fputc returned < 0.
4972                break;
4973            }
4974            fpos += buf.len() as i64;
4975            cur = down_histent(h);
4976        }
4977
4978        // c:3075-3085 — final size/mtime + last-written text (USE_OPTIONS).
4979        if ret >= 0 && start.is_some() && writeflags & HFILE_USE_OPTIONS as i32 != 0 {
4980            let _ = out.flush(); // c:3077 fflush(out)
4981            if let Ok(md) = out.get_ref().metadata() {
4982                let mut lh = lasthist.lock().unwrap();
4983                lh.fsiz = md.len() as i64; // c:3079
4984                lh.mtim = md.mtime(); // c:3080
4985            }
4986            lasthist.lock().unwrap().text = start.clone(); // c:3082-3083
4987        }
4988
4989        // c:3086 — fclose(out). C caught a failed write at the `fputc` that hit
4990        // it (c:3065/3072); buffering defers the error to the flush, so surface
4991        // it here instead, and only when the loop itself had not already
4992        // failed. `BufWriter`'s own drop-flush discards errors, so the flush is
4993        // explicit.
4994        if let Err(_e) = out.flush() {
4995            if ret >= 0 {
4996                ret = -1;
4997            }
4998        }
4999        drop(out);
5000
5001        if ret >= 0 {
5002            if let Some(ref tf) = tmpfile {
5003                // c:3089-3103 — rename the temp copy over the real file.
5004                if std::fs::rename(tf, &umpath).is_err() {
5005                    crate::ported::utils::zerr(&format!("can't rename {}.new to $HISTFILE", path));
5006                    ret = -1; // c:3092
5007                    err = false; // c:3093 err = 0;
5008                }
5009            }
5010
5011            // c:3106-3125 — SKIPOLD (and not FAST/NO_REWRITE): re-read the
5012            // just-written file capped to savehistsiz, then rewrite it
5013            // trimmed. This enforces SAVEHIST on the append/share paths.
5014            if ret >= 0
5015                && writeflags & HFILE_SKIPOLD as i32 != 0
5016                && writeflags & (HFILE_FAST | HFILE_NO_REWRITE) as i32 == 0
5017            {
5018                let remember_histactive = histactive.load(SeqCst); // c:3108
5019                histactive.store(0, SeqCst); // c:3111
5020                pushhiststack(None, savehistsiz_v, savehistsiz_v, -1); // c:3113
5021                if isset(HISTSAVENODUPS) {
5022                    hist_ignore_all_dups.store(1, SeqCst); // c:3115
5023                }
5024                readhistfile(Some(&path), if err { 1 } else { 0 }, 0); // c:3116
5025                hist_ignore_all_dups.store(if isset(HISTIGNOREALLDUPS) { 1 } else { 0 }, SeqCst); // c:3117
5026                if errflag.load(SeqCst) & ERRFLAG_INT != 0 {
5027                    ret = -1; // c:3119
5028                } else if histlinect.load(SeqCst) != 0 {
5029                    savehistfile(Some(&path), 0); // c:3121
5030                }
5031                pophiststack(); // c:3123
5032                histactive.store(remember_histactive, SeqCst); // c:3124
5033            }
5034        }
5035
5036        crate::ported::mem::popheap(); // c:3128
5037    } else {
5038        ret = -1; // c:3130
5039    }
5040
5041    // c:3132-3137 — report a write failure on stderr when err is set.
5042    if ret < 0 && err {
5043        if tmpfile.is_some() {
5044            crate::ported::utils::zerr(&format!(
5045                "failed to write history file {}.new: {}",
5046                path,
5047                std::io::Error::last_os_error()
5048            ));
5049        } else {
5050            crate::ported::utils::zerr(&format!(
5051                "failed to write history file {}: {}",
5052                path,
5053                std::io::Error::last_os_error()
5054            ));
5055        }
5056    }
5057    // c:3138-3139 — free(tmpfile): the owned String drops automatically.
5058
5059    unlockhistfile(&path); // c:3141
5060}
5061
5062/// Port of `int lockhistct` from Src/hist.c. Re-entrant lock counter.
5063static lockhistct: AtomicI32 = AtomicI32::new(0);
5064
5065/// Port of `checklocktime()` from `Src/hist.c:3147`. — C decl `checklocktime(char *lockfile, long *sleep_usp, time_t then)`.
5066///
5067/// Decides what to do when a history lock file already exists, given
5068/// its mtime (`then`). Returns `-1` (give up) when the lock file's
5069/// timestamp is implausibly far in the future; otherwise either sleeps
5070/// a randomised, exponentially-increasing backoff (recent lock — owner
5071/// likely still alive) or unlinks the stale lock file, returning `0`.
5072/// `sleep_usp` is doubled on each backoff so repeated calls ramp up.
5073///
5074/// Replaces the prior ad-hoc `(path, max_age_secs) -> 1|0` orphan,
5075/// which used a `.lock` suffix, an age threshold, and no backoff —
5076/// none of which match the C contract.
5077pub fn checklocktime(lockfile: &str, sleep_usp: &mut i64, then: i64) -> i32 {
5078    // c:3147
5079    let now = zmonotime(None); // c:3149
5080
5081    if now + 10 < then {
5082        // c:3151 — File is more than 10 seconds in the future?
5083        // c:3153 — `errno = EEXIST;` set for C caller-contract parity.
5084        unsafe {
5085            #[cfg(target_os = "linux")]
5086            {
5087                *libc::__errno_location() = libc::EEXIST;
5088            }
5089            #[cfg(not(target_os = "linux"))]
5090            {
5091                *libc::__error() = libc::EEXIST;
5092            }
5093        }
5094        return -1; // c:3154
5095    }
5096
5097    if now - then < 10 {
5098        // c:3157-3168 — gradually increasing backoff: sleep based on
5099        // the time spent so far, randomised to minimise clashes with
5100        // shells exiting at the same time.
5101        DPUTS!(now < then, "time flowing backwards through history"); // c:3162
5102        zsleep_random(*sleep_usp, then + 10); // c:3167
5103        *sleep_usp <<= 1; // c:3168 `*sleep_usp <<= 1;`
5104    } else {
5105        // c:3170 — `unlink(lockfile);` — the lock is stale, remove it.
5106        let _ = std::fs::remove_file(lockfile);
5107    }
5108
5109    0 // c:3172
5110}
5111
5112/// Port of `lockhistfile()` from `Src/hist.c:3182`. — C decl `lockhistfile(char *fn, int keep_trying)`.
5113/// Rust idiom replacement: `fs2::FileExt::try_lock_exclusive` covers
5114/// the C `flock` + `link`-symlink retry loop; the `keep_trying`
5115/// arg controls retry budget rather than mode flags.
5116pub fn lockhistfile(fn_path: Option<&str>, keep_trying: i32) -> i32 {
5117    // c:3182
5118    let path: String = match fn_path {
5119        // c:3182
5120        Some(p) => p.to_string(),
5121        None => match resolve_histfile() {
5122            Some(p) => p,
5123            None => return 1, // c:3189
5124        },
5125    };
5126    if lockhistct.fetch_add(1, SeqCst) > 0 {
5127        return 0;
5128    }
5129    let max_tries = if keep_trying != 0 { 30 } else { 1 };
5130    for attempt in 0..max_tries {
5131        if flockhistfile(&path) != 0 {
5132            return 0;
5133        }
5134        if attempt + 1 < max_tries {
5135            std::thread::sleep(std::time::Duration::from_millis(100));
5136        }
5137    }
5138    lockhistct.fetch_sub(1, SeqCst);
5139    if keep_trying != 0 {
5140        2
5141    } else {
5142        1
5143    }
5144}
5145
5146/// Port of `unlockhistfile()` from `Src/hist.c:3324`. — C decl `unlockhistfile(char *fn)`.
5147pub fn unlockhistfile(path: &str) {
5148    let prev = lockhistct.fetch_sub(1, SeqCst);
5149    if prev <= 0 {
5150        lockhistct.store(0, SeqCst);
5151        return;
5152    }
5153    if prev == 1 {
5154        let lockpath = format!("{}.lock", path);
5155        let _ = std::fs::remove_file(&lockpath);
5156    }
5157}
5158
5159/// Port of `histfileIsLocked()` from `Src/hist.c:3350`. — C decl `histfileIsLocked(void)`.
5160#[allow(non_snake_case)]
5161pub fn histfileIsLocked() -> i32 {
5162    if lockhistct.load(SeqCst) > 0 {
5163        1
5164    } else {
5165        0
5166    }
5167}
5168
5169/// Port of `bufferwords()` from `Src/hist.c:3385` — C decl `bufferwords(LinkList list, char *buf, int *index, int flags)`.
5170///
5171/// Runs the real lexer over a line and returns its words, as `(z)`/`(Z)`,
5172/// history word splitting and `$historywords` need. `cursor` is None for C's
5173/// `buf != NULL` arm (c:3412-3429: the cursor sits past the end) and Some for
5174/// the `buf == NULL` editor-line arm (c:3430-3462), whose line the Rust
5175/// callers pass in; the `chline` prefix of a continuation line (c:3438-3452)
5176/// is not modelled. Returns the words and C's `*index`, the word the cursor
5177/// is in.
5178pub fn bufferwords(buf: &str, cursor: Option<usize>, flags: i32) -> (Vec<String>, usize) {
5179    use crate::ported::lex::{
5180        ctxtlex, incond, noaliases, set_incmdpos, set_incond, set_noaliases, tok, tokfd, tokstr,
5181        tokstrings, untokenize_ztokens, LEX_INPUT, LEX_LEXFLAGS, LEX_NOCOMMENTS, LEX_POS,
5182    };
5183    use crate::ported::zle::compcore::{ADDEDX, WB, WE, ZLEMETALL};
5184    use crate::ported::zsh_h::{
5185        BANG_TOK, DAMPER, DBAR, DINBRACK, DINPAR, DOUTPAR, ENDINPUT, ENVARRAY, FOR, INPAR_TOK,
5186        IS_REDIROP, LEXERR, LEXFLAGS_ACTIVE, LEXFLAGS_COMMENTS_KEEP, LEXFLAGS_COMMENTS_STRIP,
5187        NEWLIN, OUTPAR_TOK, RCQUOTES,
5188    };
5189    let mut list: Vec<String> = Vec::new();
5190    // c:3387-3391
5191    let (mut num, mut cur, mut got) = (0i32, -1i32, false);
5192    let ne = *crate::ported::utils::noerrs_lock().lock().unwrap();
5193    let (owb, owe, oadx) = (WB.load(SeqCst), WE.load(SeqCst), ADDEDX.load(SeqCst));
5194    let (onc, ona) = (LEX_NOCOMMENTS.get(), noaliases());
5195    let (ocs, oll) = (ZLEMETACS.load(SeqCst), ZLEMETALL.load(SeqCst));
5196    let mut forloop = 0i32;
5197    let rcquotes = crate::ported::zsh_h::isset(RCQUOTES);
5198    let mut envarray = false;
5199
5200    // c:3397-3402 — "With RC_QUOTES, 'foo '' bar' comes back as 'foo ' bar'.
5201    // That's not very useful." — the option is off for the duration.
5202    crate::ported::options::opt_state_set("rcquotes", false);
5203    ADDEDX.store(0, SeqCst); // c:3403
5204    crate::ported::utils::set_noerrs(1); // c:3404
5205    crate::ported::context::zcontext_save(); // c:3405
5206    LEX_LEXFLAGS.set(flags | LEXFLAGS_ACTIVE); // c:3406
5207    // c:3410-3411
5208    LEX_NOCOMMENTS.set(flags & (LEXFLAGS_COMMENTS_KEEP | LEXFLAGS_COMMENTS_STRIP) == 0);
5209    // c:3415-3426 — the copy with a space appended (`addedspaceptr`).
5210    let mut p = String::with_capacity(buf.len() + 1);
5211    p.push_str(buf);
5212    p.push(' ');
5213    // The lexer reads LEX_INPUT ahead of the input stack: park it so hgetc
5214    // takes the pushed frame, exactly as parsestrnoerr does (lex.rs).
5215    let saved_lex_input = LEX_INPUT.with_borrow_mut(std::mem::take);
5216    let saved_lex_pos = LEX_POS.replace(0);
5217    LEX_LEXSTOP.set(false);
5218    crate::ported::input::inpush(&p, 0, None); // c:3427 / c:3459
5219    match cursor {
5220        None => {
5221            // c:3428-3429 — `zlemetall = strlen(p); zlemetacs = zlemetall + 1;`
5222            let ll = p.chars().count() as i32;
5223            ZLEMETALL.store(ll, SeqCst);
5224            ZLEMETACS.store(ll + 1, SeqCst);
5225        }
5226        Some(cs) => {
5227            // c:3435-3436 — `zlemetall = ll + 1; zlemetacs = cs;`
5228            ZLEMETALL.store(buf.chars().count() as i32 + 1, SeqCst);
5229            ZLEMETACS.store(cs as i32, SeqCst);
5230        }
5231    }
5232    // c:3463-3464
5233    if ZLEMETACS.load(SeqCst) != 0 {
5234        ZLEMETACS.fetch_sub(1, SeqCst);
5235    }
5236    strinbeg(0); // c:3465
5237    set_noaliases(true); // c:3466
5238    loop {
5239        // c:3468-3471
5240        if incond() != 0 {
5241            let t = tok();
5242            let rest = t != DINBRACK && t != INPAR_TOK && t != DBAR && t != DAMPER && t != BANG_TOK;
5243            set_incond(1 + rest as i32);
5244        }
5245        ctxtlex(); // c:3472
5246        let t = tok();
5247        if t == ENDINPUT || t == LEXERR {
5248            break; // c:3473-3474
5249        }
5250        // c:3479-3482 — after an array assignment, back to start-of-command.
5251        if t == OUTPAR_TOK && envarray {
5252            set_incmdpos(true);
5253            envarray = false;
5254        }
5255        // c:3483-3515 — `for (( a ; b ; c ))` arrives as FOR, DINPAR, DINPAR,
5256        // DINPAR, DOUTPAR; `forloop` counts the stages down.
5257        if t == FOR {
5258            forloop = 5;
5259        } else {
5260            match forloop {
5261                1 => {
5262                    if t != DOUTPAR {
5263                        forloop = 0;
5264                    }
5265                }
5266                2 | 3 | 4 => {
5267                    if t != DINPAR {
5268                        forloop = 0;
5269                    }
5270                }
5271                _ => {}
5272            }
5273        }
5274        if let Some(ts) = tokstr() {
5275            // c:3516-3541
5276            let raw = match t {
5277                ENVARRAY => {
5278                    envarray = true;
5279                    format!("{}=(", ts)
5280                }
5281                DINPAR if forloop != 0 => format!("{};", ts),
5282                DINPAR => format!("(({}))", ts),
5283                _ => ts,
5284            };
5285            if !raw.is_empty() {
5286                // c:3543 `untokenize(p);` — every token back to its source char.
5287                let mut w = untokenize_ztokens(&raw);
5288                // c:3544-3558 — read past the added space: drop it again.
5289                // `ingetptr() == addedspaceptr + 1` holds only when the
5290                // added space was CONSUMED. C's `hungetc` returns a
5291                // word-terminating space to `inbuf`, so an un-got space
5292                // leaves `ingetptr()` at `addedspaceptr`; this port queues it
5293                // in `LEX_UNGET_BUF` instead, so a pending pushback means the
5294                // space is still unread. Without that, `a\ ` lost its
5295                // escaped space: the terminator was the added one, un-got.
5296                if crate::ported::input::ingetptr().is_empty()
5297                    && crate::ported::lex::LEX_UNGET_BUF.with_borrow(|b| b.is_empty())
5298                    && w.ends_with(' ')
5299                {
5300                    w.pop();
5301                }
5302                list.push(w); // c:3559
5303                num += 1; // c:3560
5304            }
5305        } else if cursor.is_none() {
5306            // c:3562-3572 — `else if (buf)`: punctuation words by their text.
5307            if IS_REDIROP(t) && tokfd() >= 0 {
5308                let text = tokstrings.get(t as usize).copied().flatten().unwrap_or("");
5309                list.push(format!("{}{}", tokfd(), text)); // c:3563-3567
5310                num += 1;
5311            } else if t != NEWLIN {
5312                if let Some(text) = tokstrings.get(t as usize).copied().flatten() {
5313                    list.push(text.to_string()); // c:3568-3571
5314                    num += 1;
5315                }
5316            }
5317        }
5318        // c:3573-3582
5319        if forloop != 0 {
5320            if forloop == 1 {
5321                list.push("))".to_string());
5322            }
5323            forloop -= 1;
5324        }
5325        // c:3583-3586
5326        if !got && LEX_LEXFLAGS.get() == 0 {
5327            got = true;
5328            cur = num - 1;
5329        }
5330        if errflag.load(SeqCst) & ERRFLAG_INT != 0 {
5331            break; // c:3587
5332        }
5333    }
5334    // c:3588-3601 — a lexer error keeps the unfinished rest as one word.
5335    if cursor.is_none() && tok() == LEXERR {
5336        if let Some(ts) = tokstr().filter(|s| !s.is_empty()) {
5337            let mut w = untokenize_ztokens(&ts);
5338            if w.ends_with(' ') {
5339                w.pop();
5340            }
5341            list.push(w);
5342            num += 1;
5343        }
5344    }
5345    // c:3602-3603
5346    if cur < 0 && num > 0 {
5347        cur = num - 1;
5348    }
5349    set_noaliases(ona); // c:3604
5350    strinend(); // c:3605
5351    crate::ported::input::inpop(); // c:3606
5352    errflag.fetch_and(!ERRFLAG_ERROR, SeqCst); // c:3607
5353    LEX_NOCOMMENTS.set(onc); // c:3608
5354    crate::ported::utils::set_noerrs(ne); // c:3609
5355    LEX_INPUT.with_borrow_mut(|b| *b = saved_lex_input);
5356    LEX_POS.set(saved_lex_pos);
5357    crate::ported::context::zcontext_restore(); // c:3610
5358    ZLEMETACS.store(ocs, SeqCst); // c:3611
5359    ZLEMETALL.store(oll, SeqCst); // c:3612
5360    WB.store(owb, SeqCst); // c:3613
5361    WE.store(owe, SeqCst); // c:3614
5362    ADDEDX.store(oadx, SeqCst); // c:3615
5363    crate::ported::options::opt_state_set("rcquotes", rcquotes); // c:3616
5364    (list, cur.max(0) as usize) // c:3618-3621
5365}
5366
5367/// Port of `histsplitwords()` from `Src/hist.c:3650`. — C decl `histsplitwords(char *lineptr, short **wordsp, int *nwordsp, int *nwordposp, int uselex)`.
5368///
5369/// Returns word (start, end) byte-offset pairs. When `uselex == true`,
5370/// runs the lex tokenizer (`bufferwords`) and matches each lexed
5371/// token's text against `line` to recover its position — same shape
5372/// as the C body's outer for-loop over the wordlist (c:3671-3800).
5373/// On lex mismatch (the `bad = 1` branch at c:3776), falls back to
5374/// the simple whitespace tokenizer (matches C's `lineptr = start;
5375/// uselex = 0;` retry).
5376pub fn histsplitwords(line: &str, uselex: bool) -> Vec<(usize, usize)> {
5377    // c:3650
5378    if uselex {
5379        // c:3662-3663 — wordlist = bufferwords(NULL, lineptr, NULL, LEXFLAGS_COMMENTS_KEEP);
5380        let (lexed, _) = bufferwords(line, None, crate::ported::zsh_h::LEXFLAGS_COMMENTS_KEEP);
5381
5382        let bytes = line.as_bytes();
5383        let mut lptr: usize = 0;
5384        let mut words: Vec<(usize, usize)> = Vec::with_capacity(lexed.len());
5385        let mut bad = false;
5386
5387        for word in &lexed {
5388            // c:3679-3694 — skip blanks + `\\\n` at start.
5389            while lptr < bytes.len() {
5390                let b = bytes[lptr];
5391                if b == b' ' || b == b'\t' {
5392                    lptr += 1;
5393                } else if b == b'\\' && lptr + 1 < bytes.len() && bytes[lptr + 1] == b'\n' {
5394                    lptr += 2;
5395                } else {
5396                    break;
5397                }
5398            }
5399            let word_start = lptr;
5400            let wbytes = word.as_bytes();
5401
5402            // c:3707-3787 — match-with-backslash-newline-mid-word loop.
5403            let mut wptr: usize = 0;
5404            loop {
5405                // c:3715-3722 — semicolon vs ";;" disambiguation: a single
5406                // `;` lex token shouldn't consume both chars of `;;`.
5407                if word == ";"
5408                    && lptr + 1 < bytes.len()
5409                    && bytes[lptr] == b';'
5410                    && bytes[lptr + 1] == b';'
5411                {
5412                    // Treat this as a synthetic newline-→-`;` token: no
5413                    // line consumption (c:3721 loop_next=2, continue).
5414                    break;
5415                }
5416                // c:3709-3726 — strpfx fast path.
5417                if lptr + wbytes.len() - wptr <= bytes.len()
5418                    && bytes[lptr..lptr + wbytes.len() - wptr] == wbytes[wptr..]
5419                {
5420                    lptr += wbytes.len() - wptr;
5421                    wptr = wbytes.len();
5422                    break;
5423                }
5424                // c:3727-3787 — slow path: match char-by-char allowing
5425                // `\\\n` mid-word and the `!`→`|` substitution.
5426                let mut skipping = false;
5427                if word == ";" {
5428                    // c:3736-3740 — newline-→-`;` synthetic token.
5429                    break;
5430                }
5431                while lptr < bytes.len() {
5432                    if wptr >= wbytes.len() {
5433                        // c:3742-3750 — word ended before line: bail.
5434                        bad = true;
5435                        break;
5436                    }
5437                    if bytes[lptr] == wbytes[wptr] || (bytes[lptr] == b'!' && wbytes[wptr] == b'|')
5438                    // c:3754-3755
5439                    {
5440                        lptr += 1;
5441                        wptr += 1;
5442                        if wptr >= wbytes.len() {
5443                            break;
5444                        }
5445                    } else if bytes[lptr] == b'\\'
5446                        && lptr + 1 < bytes.len()
5447                        && bytes[lptr + 1] == b'\n'
5448                    {
5449                        // c:3759-3769 — \\\n mid-word, skip line break.
5450                        lptr += 2;
5451                        skipping = true;
5452                        break;
5453                    } else {
5454                        bad = true;
5455                        break;
5456                    }
5457                }
5458                if bad || !skipping {
5459                    break;
5460                }
5461            }
5462
5463            if bad {
5464                // c:3782-3786 — `lineptr = start; nwordpos = 0; uselex = 0;`
5465                //               Restart with non-lex fallback.
5466                return histsplitwords(line, false);
5467            }
5468            // c:3795-3796 — words[nwordpos++] = start; words[nwordpos++] = lptr;
5469            words.push((word_start, lptr));
5470        }
5471        return words;
5472    }
5473
5474    // c:3802-3830 — non-lex path: simple whitespace + `\\\n` tokenizer.
5475    let mut words = Vec::new();
5476    let bytes = line.as_bytes();
5477    let mut lptr: usize = 0;
5478    loop {
5479        // c:3804-3811 — skip leading blanks + `\\\n`.
5480        while lptr < bytes.len() {
5481            let b = bytes[lptr];
5482            if b == b' ' || b == b'\t' || b == b'\n' {
5483                lptr += 1;
5484            } else if b == b'\\' && lptr + 1 < bytes.len() && bytes[lptr + 1] == b'\n' {
5485                lptr += 2;
5486            } else {
5487                break;
5488            }
5489        }
5490        if lptr >= bytes.len() {
5491            break;
5492        }
5493        let word_start = lptr; // c:3818
5494                               // c:3819-3826 — walk until next blank; Meta-byte advances by 2.
5495        while lptr < bytes.len() {
5496            let b = bytes[lptr];
5497            if b == 0x83 /* Meta */ && lptr + 1 < bytes.len() {
5498                lptr += 2;
5499            } else if b == b' ' || b == b'\t' || b == b'\n' {
5500                break;
5501            } else {
5502                lptr += 1;
5503            }
5504        }
5505        words.push((word_start, lptr)); // c:3827
5506    }
5507    words
5508}
5509
5510/// Port of `pushhiststack()` from `Src/hist.c:3845`. — C decl `pushhiststack(char *hf, zlong hs, zlong shs, int level)`.
5511///
5512/// Saves the current history state (ring, sizes, counters, HISTFILE,
5513/// lasthist) onto the save stack, then switches to a fresh empty
5514/// history optionally backed by the new HISTFILE `hf`. Mirrored by
5515/// `pophiststack`, which restores the saved state. Returns the new
5516/// stack depth (`histsave_stack_pos`).
5517///
5518/// `inithist`'s C role (`createhisttable`, c:3890) is met by moving `histtab`
5519/// into the saved frame, which leaves a fresh empty table. C's `hist_ring == &curline` test is
5520/// `HA_ACTIVE && !HA_NOINC` here: `hbegin` links the current line only
5521/// when it does not set `HA_NOINC` (c:1163-1169), and the Vec ring never
5522/// holds the sentinel itself.
5523pub fn pushhiststack(hf: Option<&str>, hs: i64, shs: i64, level: i32) -> i32 {
5524    // c:3845
5525    // c:3848 — `int curline_in_ring = (histactive & HA_ACTIVE) && hist_ring == &curline;`
5526    let ha = histactive.load(SeqCst);
5527    let curline_in_ring = (ha & HA_ACTIVE) != 0 && (ha & HA_NOINC) == 0;
5528    if curline_in_ring {
5529        unlinkcurline(); // c:3856
5530    }
5531    // c:3862-3868 — save the OLD HISTFILE so pop can restore it. With
5532    // `hf` set, record the current HISTFILE ("" when empty/unset); with
5533    // `hf` None, record None (C `h->histfile = NULL`).
5534    let old_histfile: Option<String> = if hf.is_some() {
5535        match crate::ported::params::getsparam("HISTFILE") {
5536            Some(v) if !v.is_empty() => Some(v), // c:3863 ztrdup(HISTFILE)
5537            _ => Some(String::new()),            // c:3866 h->histfile = ""
5538        }
5539    } else {
5540        None // c:3868 h->histfile = NULL
5541    };
5542    let snap = histsave {
5543        lasthist: lasthist.lock().unwrap().clone(), // c:3861 h->lasthist = lasthist
5544        histfile: old_histfile,                     // c:3862-3868 OLD HISTFILE
5545        histtab: std::mem::take(&mut *crate::ported::hashtable::histtab_lock().write().unwrap()), // c:3869 h->histtab = histtab (inithist then starts an empty one)
5546        hist_ring: std::mem::take(&mut *hist_ring.lock().unwrap()), // c:3870 h->hist_ring = hist_ring
5547        curhist: curhist.load(SeqCst),                              // c:3871 h->curhist = curhist
5548        histlinect: histlinect.load(SeqCst),                        // c:3872
5549        histsiz: histsiz.load(SeqCst),                              // c:3873
5550        savehistsiz: savehistsiz.load(SeqCst),                      // c:3874
5551        locallevel: level,                                          // c:3875
5552    };
5553    histsave_stack.lock().unwrap().push(snap); // c:3859 histsave_stack[pos++] = *h
5554    histsave_stack_size.fetch_add(1, SeqCst);
5555    histsave_stack_pos.fetch_add(1, SeqCst);
5556    // c:3877 — `memset(&lasthist, 0, sizeof lasthist);`
5557    *lasthist.lock().unwrap() = histfile_stats {
5558        text: None,
5559        stim: 0,
5560        mtim: 0,
5561        fpos: 0,
5562        fsiz: 0,
5563        interrupted: 0,
5564        next_write_ev: 0,
5565    };
5566    // c:3878-3883 — switch HISTFILE to the new file (or unset it).
5567    if let Some(h) = hf {
5568        if !h.is_empty() {
5569            crate::ported::params::setsparam("HISTFILE", h); // c:3880 setsparam(HISTFILE, hf)
5570        } else {
5571            // c:3882 unsetparam("HISTFILE")
5572            let _ = crate::ported::params::paramtab()
5573                .write()
5574                .unwrap()
5575                .remove("HISTFILE");
5576        }
5577    }
5578    // c:3884 — `hist_ring = NULL`: already emptied by the `mem::take` above.
5579    curhist.store(0, SeqCst); // c:3885 curhist = histlinect = 0
5580    histlinect.store(0, SeqCst);
5581    // c:3886 — `if (zleactive) zleentry(ZLE_CMD_SET_HIST_LINE, curhist);`
5582    if crate::ported::builtins::sched::zleactive.load(SeqCst) != 0 {
5583        crate::ported::zle::zle_main::zle_main_entry(
5584            crate::ported::zsh_h::ZLE_CMD_SET_HIST_LINE,
5585            &mut crate::ported::zle::zle_main::zle_main_entry_args::SetHistLine(
5586                curhist.load(SeqCst),
5587            ),
5588        );
5589    }
5590    histsiz.store(hs, SeqCst); // c:3888 histsiz = hs
5591    savehistsiz.store(shs, SeqCst); // c:3889 savehistsiz = shs
5592    if curline_in_ring {
5593        linkcurline(); // c:3892
5594    }
5595    // c:3895 — return histsave_stack_pos.
5596    histsave_stack_pos.load(SeqCst)
5597}
5598
5599/// Port of `pophiststack()` from `Src/hist.c:3901`. — C decl `pophiststack(void)`.
5600///
5601/// C body:
5602/// ```c
5603/// if (histsave_stack_pos == 0) return 0;
5604/// if (curline_in_ring) unlinkcurline();
5605/// deletehashtable(histtab); zsfree(lasthist.text);
5606/// h = &histsave_stack[--histsave_stack_pos];
5607/// lasthist = h->lasthist;
5608/// if (h->histfile) {
5609///     if (*h->histfile) setsparam("HISTFILE", h->histfile);
5610///     else unsetparam("HISTFILE");
5611/// }
5612/// histtab = h->histtab;
5613/// hist_ring = h->hist_ring;
5614/// curhist = h->curhist;
5615/// if (zleactive) zleentry(ZLE_CMD_SET_HIST_LINE, curhist);
5616/// histlinect = h->histlinect;
5617/// histsiz = h->histsiz;
5618/// savehistsiz = h->savehistsiz;
5619/// if (curline_in_ring) linkcurline();
5620/// return histsave_stack_pos + 1;
5621/// ```
5622///
5623/// The previous Rust port skipped:
5624///   - The `histfile` paramtab restore (c:3920-3924) — HISTFILE
5625///     wasn't reverted when popping back out of a pushed stack
5626///     frame, so the user's HISTFILE could end up pointing at
5627///     the wrong file after `fc -p` / `fc -P`.
5628///   - Returning 0 on empty stack (c:3907) — the previous
5629///     port returned `()` and didn't signal "nothing to pop".
5630///
5631/// Return: 0 when nothing was popped, else `histsave_stack_pos + 1`
5632/// (the depth that WAS popped).
5633pub fn pophiststack() -> i32 {
5634    // c:3901
5635    // c:3904 — `int curline_in_ring = (histactive & HA_ACTIVE) && hist_ring == &curline;`
5636    // (see `pushhiststack` for the Vec-ring spelling of the test).
5637    let ha = histactive.load(SeqCst);
5638    let curline_in_ring = (ha & HA_ACTIVE) != 0 && (ha & HA_NOINC) == 0;
5639    if histsave_stack.lock().unwrap().is_empty() {
5640        return 0; // c:3907
5641    }
5642    if curline_in_ring {
5643        unlinkcurline(); // c:3910
5644    }
5645    let snap = match histsave_stack.lock().unwrap().pop() {
5646        Some(s) => s,
5647        None => return 0, // c:3907
5648    };
5649    // c:3913-3916 — `zsfree(lasthist.text); lasthist = h->lasthist;`
5650    *lasthist.lock().unwrap() = snap.lasthist.clone();
5651    // c:3920-3924 — restore HISTFILE via setsparam / unsetparam.
5652    if let Some(ref hf) = snap.histfile {
5653        if !hf.is_empty() {
5654            // c:3922 *h->histfile
5655            crate::ported::params::setsparam("HISTFILE", hf); // c:3922
5656        } else {
5657            // c:3923
5658            // Unset HISTFILE — Rust paramtab remove.
5659            let _ = crate::ported::params::paramtab()
5660                .write()
5661                .unwrap()
5662                .remove("HISTFILE"); // c:3923 unsetparam
5663        }
5664    }
5665    *crate::ported::hashtable::histtab_lock().write().unwrap() = snap.histtab; // c:3924 histtab = h->histtab (the pushed table was dropped, c:3912)
5666    *hist_ring.lock().unwrap() = snap.hist_ring; // c:3925
5667    curhist.store(snap.curhist, SeqCst); // c:3926
5668    // c:3927 — `if (zleactive) zleentry(ZLE_CMD_SET_HIST_LINE, curhist);`
5669    if crate::ported::builtins::sched::zleactive.load(SeqCst) != 0 {
5670        crate::ported::zle::zle_main::zle_main_entry(
5671            crate::ported::zsh_h::ZLE_CMD_SET_HIST_LINE,
5672            &mut crate::ported::zle::zle_main::zle_main_entry_args::SetHistLine(snap.curhist),
5673        );
5674    }
5675    histlinect.store(snap.histlinect, SeqCst); // c:3929
5676    histsiz.store(snap.histsiz, SeqCst); // c:3930
5677    savehistsiz.store(snap.savehistsiz, SeqCst); // c:3931
5678    histsave_stack_size.fetch_sub(1, SeqCst);
5679    histsave_stack_pos.fetch_sub(1, SeqCst);
5680    if curline_in_ring {
5681        linkcurline(); // c:3933
5682    }
5683    // c:3934 — `return histsave_stack_pos + 1;` (new pos after
5684    // decrement, plus 1 for the just-popped depth).
5685    histsave_stack_pos.load(SeqCst) + 1
5686}
5687
5688/// Port of `saveandpophiststack()` from `Src/hist.c:3947`. — C decl `saveandpophiststack(int pop_through, int writeflags)`.
5689///
5690/// C body:
5691/// ```c
5692/// if (pop_through <= 0) {
5693///     pop_through += histsave_stack_pos + 1;
5694///     if (pop_through <= 0) pop_through = 1;
5695/// }
5696/// while (pop_through > 1
5697///     && histsave_stack[pop_through-2].locallevel > locallevel)
5698///     pop_through--;
5699/// if (histsave_stack_pos < pop_through) return 0;
5700/// do {
5701///     if (!nohistsave) savehistfile(NULL, 1, writeflags);
5702///     pophiststack();
5703/// } while (histsave_stack_pos >= pop_through);
5704/// return 1;
5705/// ```
5706///
5707/// The previous Rust port had a `(writeflags)`-only signature and
5708/// just called `savehistfile + pophiststack` ONCE — dropping the
5709/// pop_through arg entirely. Callers from `endparamscope` (c:5863
5710/// passes pop_through=0) and `bin_fc -P` (builtin.c:1486 passes -1)
5711/// expect the C "pop ALL stack entries with locallevel > current"
5712/// semantic; the truncated port only popped one entry, leaving
5713/// outer-scope history-stack frames intact when a multi-level
5714/// scope exit happened.
5715///
5716/// Return: 1 if anything was popped, 0 if the stack was already
5717/// empty at the requested depth. The previous Rust signature
5718/// was `void`; callers (builtin.rs:1539) used `!saveandpophiststack(...)`
5719/// for the C-style truthy check.
5720///
5721/// WARNING: param names don't match C — Rust=(pop_through, writeflags)
5722/// matches C=(pop_through, writeflags) shape; callers updated.
5723pub fn saveandpophiststack(mut pop_through: i32, writeflags: i32) -> i32 {
5724    // c:3947
5725    let stack_pos = histsave_stack_pos.load(SeqCst);
5726    // c:3949-3953 — non-positive pop_through means "pop relative
5727    // to current pos": fold to an absolute index.
5728    if pop_through <= 0 {
5729        // c:3949
5730        pop_through += stack_pos + 1; // c:3950
5731        if pop_through <= 0 {
5732            // c:3951
5733            pop_through = 1;
5734        }
5735    }
5736    // c:3954-3956 — walk back while the entry at pop_through-2 was
5737    // saved at a deeper locallevel than the current scope.
5738    let cur_level = crate::ported::params::locallevel.load(Ordering::Relaxed) as i32;
5739    while pop_through > 1
5740        && histsave_stack
5741            .lock()
5742            .unwrap()
5743            .get((pop_through - 2) as usize)
5744            .is_some_and(|h| h.locallevel > cur_level)
5745    {
5746        pop_through -= 1; // c:3956
5747    }
5748    if stack_pos < pop_through {
5749        // c:3957
5750        return 0;
5751    }
5752    // c:3959-3962 — loop pop until we reach pop_through.
5753    loop {
5754        // c:3960-3961 — `if (!nohistsave) savehistfile(NULL, 1, writeflags);`.
5755        if crate::ported::exec::nohistsave.load(SeqCst) == 0 {
5756            savehistfile(None, writeflags);
5757        }
5758        pophiststack(); // c:3962
5759        if histsave_stack_pos.load(SeqCst) < pop_through {
5760            // c:3963
5761            break;
5762        }
5763    }
5764    1
5765}
5766// =========================================================================
5767// File-scope globals from hist.c
5768// =========================================================================
5769
5770// != 0 means history substitution is turned off                              // c:57
5771// (stophist is in zsh.h as an extern; we own it here.)
5772
5773/// Port of `HashTable histtab` from Src/hist.c:101.
5774/// Unused name-keeper: the live `histtab` is `hashtable::histtab_lock()`. // c:101
5775pub static histtab: Mutex<Vec<usize>> = Mutex::new(Vec::new()); // c:101
5776
5777/// Port of `mod_export Histent hist_ring` from Src/hist.c:103.
5778/// Doubly-linked ring of history entries; modelled here as a `Vec<histent>`
5779/// since each histent already has up/down pointers in the C struct.
5780pub static hist_ring: Mutex<Vec<histent>> = Mutex::new(Vec::new()); // c:103
5781
5782/// Port of `struct histent curline` from Src/hist.c:91. Sentinel
5783/// histent for the in-progress edit; spliced into the ring head by
5784/// linkcurline() and removed by unlinkcurline().
5785pub static curline: Mutex<Option<histent>> = Mutex::new(None); // c:91
5786
5787/// Port of `zlong histsiz` from Src/hist.c:108. Starts at
5788/// DEFAULT_HISTSIZE, the value setupvals assigns before anything reads it
5789/// (c:Src/init.c:1113 `histsiz = DEFAULT_HISTSIZE;`), and agrees with the
5790/// $HISTSIZE the params layer reports (params.rs histsiz_lock). At 0, the
5791/// first `fc -R` of a shell that never assigned HISTSIZE kept no entry.
5792pub static histsiz: AtomicI64 =
5793    AtomicI64::new(crate::ported::config_h::DEFAULT_HISTSIZE as i64); // c:108
5794
5795/// Port of `zlong savehistsiz` from Src/hist.c:113.
5796pub static savehistsiz: AtomicI64 = AtomicI64::new(0); // c:113
5797
5798/// Port of `int histdone` from Src/hist.c:119.
5799pub static histdone: AtomicI32 = AtomicI32::new(0); // c:119
5800
5801/// Port of `int histactive` from Src/hist.c:124.
5802pub static histactive: AtomicU32 = AtomicU32::new(0); // c:124
5803
5804/// Port of `int hist_ignore_all_dups` from Src/hist.c:130.
5805pub static hist_ignore_all_dups: AtomicI32 = AtomicI32::new(0); // c:130
5806
5807/// Port of `mod_export int hist_skip_flags` from Src/hist.c:135.
5808pub static hist_skip_flags: AtomicI32 = AtomicI32::new(0); // c:135
5809
5810/// Port of `short *chwords` from Src/hist.c:147.
5811/// Word beginning/end offsets in current history line.
5812pub static chwords: Mutex<Vec<i16>> = Mutex::new(Vec::new()); // c:147
5813
5814/// Port of `int chwordlen` from Src/hist.c:154.
5815pub static chwordlen: AtomicI32 = AtomicI32::new(0); // c:154
5816
5817/// Port of `int chwordpos` from Src/hist.c:154.
5818pub static chwordpos: AtomicI32 = AtomicI32::new(0); // c:154
5819
5820/// Port of `char *hsubl` from Src/hist.c:159.
5821/// Last `l` for `s/l/r/` history substitution.
5822pub static hsubl: Mutex<Option<String>> = Mutex::new(None); // c:159
5823
5824/// Port of `char *hsubr` from Src/hist.c:164.
5825pub static hsubr: Mutex<Option<String>> = Mutex::new(None); // c:164
5826
5827/// Port of `int hsubpatopt` from Src/hist.c:169.
5828pub static hsubpatopt: AtomicI32 = AtomicI32::new(0); // c:169
5829
5830/// Port of `mod_export char *hptr` from Src/hist.c:174.
5831/// Pointer into the history line; tracked as the byte length of `chline`.
5832pub static hptr: AtomicUsize = AtomicUsize::new(0); // c:174
5833
5834/// Port of `mod_export char *chline` from Src/hist.c:179.
5835pub static chline: Mutex<String> = Mutex::new(String::new()); // c:179
5836
5837/// Port of `mod_export char *zle_chline` from Src/hist.c:195.
5838pub static zle_chline: Mutex<Option<String>> = Mutex::new(None); // c:195
5839
5840/// Port of `int qbang` from Src/hist.c:201.
5841pub static qbang: AtomicBool = AtomicBool::new(false); // c:201
5842
5843/// Port of `int hlinesz` from Src/hist.c:206.
5844pub static hlinesz: AtomicI32 = AtomicI32::new(0); // c:206
5845
5846/// Port of `mod_export int expanding;` from Src/hist.c:65.
5847/// Non-zero while history-expansion is rewriting the current line.
5848pub static expanding: AtomicI32 = AtomicI32::new(0); // c:65
5849
5850/// Port of `mod_export int excs;` from Src/hist.c:70.
5851/// Cursor position offset accumulator used while history-expanding.
5852pub static excs: AtomicI32 = AtomicI32::new(0); // c:70
5853
5854/// Port of `mod_export int exlast;` from Src/hist.c:70.
5855/// Last `inbufct` snapshot taken at expansion start; the difference
5856/// drives the `excs` cursor advance through the rewritten line.
5857pub static exlast: AtomicI32 = AtomicI32::new(0); // c:70
5858
5859/// Port of `static struct histfile_stats lasthist` from Src/hist.c:220-226.
5860#[allow(non_camel_case_types)]
5861#[derive(Clone)]
5862pub struct histfile_stats {
5863    // c:220
5864    pub text: Option<String>, // c:221
5865    pub stim: i64,            // c:222 time_t
5866    pub mtim: i64,            // c:222
5867    pub fpos: i64,            // c:223 off_t
5868    pub fsiz: i64,            // c:223
5869    pub interrupted: i32,     // c:224
5870    pub next_write_ev: i64,   // c:225 zlong
5871}
5872static lasthist: Mutex<histfile_stats> = Mutex::new(histfile_stats {
5873    // c:226
5874    text: None,
5875    stim: 0,
5876    mtim: 0,
5877    fpos: 0,
5878    fsiz: 0,
5879    interrupted: 0,
5880    next_write_ev: 0,
5881});
5882
5883/// Port of `static struct histsave` from Src/hist.c:228-238.
5884#[allow(non_camel_case_types)]
5885pub struct histsave {
5886    // c:228
5887    pub lasthist: histfile_stats, // c:229
5888    pub histfile: Option<String>, // c:230
5889    pub histtab: std::collections::HashMap<String, i32>, // c:231
5890    pub hist_ring: Vec<histent>,  // c:232
5891    pub curhist: i64,             // c:233 zlong
5892    pub histlinect: i64,          // c:234
5893    pub histsiz: i64,             // c:235
5894    pub savehistsiz: i64,         // c:236
5895    pub locallevel: i32,          // c:237
5896}
5897
5898/// Port of `static struct histsave *histsave_stack` from Src/hist.c:238.
5899#[allow(clippy::vec_init_then_push)]
5900static histsave_stack: Mutex<Vec<histsave>> = Mutex::new(Vec::new()); // c:238
5901
5902// =========================================================================
5903// Externs from other C files used in hist.c
5904// =========================================================================
5905
5906/// Port of `int stophist` (extern from zsh.h, owned by other C files).
5907/// Track history-stop depth here so the hist module's save/restore work.
5908pub static stophist: AtomicI32 = AtomicI32::new(0);
5909
5910/// Port of `zlong curhist` (extern). Current history event number.
5911pub static curhist: AtomicI64 = AtomicI64::new(0);
5912
5913/// Port of `zlong histlinect` (extern). Number of entries currently in ring.
5914pub static histlinect: AtomicI64 = AtomicI64::new(0);
5915
5916/// Port of `char bangchar` from `Src/params.c:130`. History expansion
5917/// lead character (`!` by default).
5918pub static bangchar: AtomicI32 = AtomicI32::new(b'!' as i32);
5919
5920/// Port of `int lexstop` (declared `Src/lex.c:175`, extern in `zsh.h`) —
5921/// used by `ihgetc`/`histsubchar`/`herrflush`/`ihungetc`.
5922///
5923/// C has exactly ONE `lexstop`. zshrs splits it into two halves that are
5924/// reset in pairs (see the paired-global notes at `lex.rs:493-500` and
5925/// `input.rs:993-996`):
5926///   - `input::lexstop` — the input-side gate, `Src/input.c:322`
5927///     `if (lexstop) return ' ';`
5928///   - `lex::LEX_LEXSTOP` — the lexer-side gate on `gettok`.
5929///
5930/// `hist.rs` used to own a THIRD, private `AtomicBool` copy that nothing
5931/// outside this file ever read. That made `ihgetc`'s bad-`!`-expansion
5932/// stop (`Src/hist.c:434` `lexstop = 1;`) a write into a dead variable:
5933/// the `hgetc` bridge at `lex.rs:5069` kept seeing `input::lexstop ==
5934/// false`, accepted the `' '` that `Src/hist.c:436` returns as a real
5935/// character, and on the next refill fell through to `inputline()`
5936/// (`Src/input.c:354`) — so an interactive `print -r -- "a!b"` printed
5937/// `event not found: b` and then sat at a `dquote>` PS2 continuation
5938/// instead of discarding the line and reprompting PS1.
5939///
5940/// This zero-sized handle keeps the C spelling (`lexstop.load(…)` /
5941/// `lexstop.store(…, …)`) at every call site while addressing the real
5942/// pair: reads take the input-side half (every `lexstop` read in this
5943/// file asks "is input exhausted?"), writes set both, exactly as C's
5944/// single assignment does.
5945pub struct Lexstop;
5946
5947impl Lexstop {
5948    /// Read C's `lexstop`. The `Ordering` is accepted (and ignored) so
5949    /// call sites read like the `AtomicBool` they replace.
5950    pub fn load(&self, _order: Ordering) -> bool {
5951        crate::ported::input::lexstop.with(|c| c.get())
5952    }
5953
5954    /// Assign C's `lexstop`. One C assignment == both zshrs halves.
5955    pub fn store(&self, v: bool, _order: Ordering) {
5956        crate::ported::input::lexstop.with(|c| c.set(v));
5957        LEX_LEXSTOP.with(|c| c.set(v));
5958    }
5959}
5960
5961/// The single `lexstop` handle for this module.
5962pub static lexstop: Lexstop = Lexstop;
5963
5964/// Port of `int exit_pending` (extern). Set by SIGINT/`exit` builtin.
5965pub static exit_pending: AtomicBool = AtomicBool::new(false);
5966
5967/// Port of `int strin` from Src/hist.c — counts nested string-input
5968/// scopes (eval/source/here-string).
5969static strin: AtomicI32 = AtomicI32::new(0);
5970
5971// =========================================================================
5972// HIST_* flags (from zsh.h)
5973// =========================================================================
5974
5975// Re-export the canonical HIST_* flag bits from zsh_h.rs (which has
5976// the C-faithful values per `Src/zsh.h:2252-2258`).
5977//
5978// The previous hist.rs duplicates declared:
5979//   HIST_OLD     = 1 << 0  // = 0x01 (C: 0x02)
5980//   HIST_DUP     = 1 << 1  // = 0x02 (C: 0x08)
5981//   HIST_FOREIGN = 1 << 2  // = 0x04 (C: 0x10)
5982//   HIST_TMPSTORE= 1 << 3  // = 0x08 (C: 0x20 — overlapped with Rust HIST_DUP!)
5983//   HIST_NOWRITE = 1 << 4  // = 0x10 (C: 0x40 — overlapped with Rust HIST_FOREIGN!)
5984//
5985// Every flag was off-by-bit-shift AND the values overlapped each
5986// other in confusing ways (Rust HIST_TMPSTORE = C HIST_DUP). Any
5987// caller importing from `crate::ported::hist::HIST_DUP` was reading
5988// or writing the wrong bit; history-file write paths would skip
5989// entries the user wanted preserved, and HIST_FOREIGN/HIST_NOWRITE
5990// gates fired against unrelated bit patterns.
5991
5992// =========================================================================
5993// CASMOD_ enum (port of zsh.h:3122-3127)
5994// =========================================================================
5995
5996/// Port of `enum { CASMOD_NONE, CASMOD_UPPER, CASMOD_LOWER, CASMOD_CAPS }`
5997/// from Src/zsh.h:3122.
5998// =========================================================================
5999// HISTFLAG_* (port of zsh.h)
6000// =========================================================================
6001
6002// HISTFLAG_* moved to canonical home at zsh_h.rs:2598-2601 (port of
6003// `Src/zsh.h:2270-2273`). Re-export here so existing callers using
6004// `crate::ported::hist::HISTFLAG_*` keep compiling, and the values
6005// can never diverge from zsh_h.rs.
6006//
6007// This is the same consolidation pattern applied to the prior HIST_*
6008// flag-value drift fix and the BINF/CONDF/MFF duplicates in
6009// module.rs — single source of truth for C-pinned bit values.
6010
6011/// !!! WARNING: RUST-ONLY HELPER !!!
6012///
6013/// No upstream C counterpart — this name exists nowhere in `Src/`.
6014/// C inlines `getsparam("HISTFILE")` at each site that needs the
6015/// history file and its `fn` argument is NULL: `hbegin` (`Src/hist.c:1192`),
6016/// `hend` (`Src/hist.c:1528`), `readhistfile` (`Src/hist.c:2687`),
6017/// `savehistfile` (`Src/hist.c:2932`), `lockhistfile` (`Src/hist.c:3188`),
6018/// `unlockhistfile` (`Src/hist.c:3326`) and `pushhiststack`
6019/// (`Src/hist.c:3863`). Approved Rust-only helper:
6020/// `tests/data/fake_fn_allowlist.txt:831`.
6021/// Deliberately left uncited so the port audit keeps reporting it.
6022/// Direct port of C's `getsparam("HISTFILE")` lookup used inside
6023/// `lockhistfile()` (c:3188) and `readhistfile()` / `savehistfile()`
6024/// when their `fn` arg is NULL. C reads from paramtab; was reading
6025/// the OS env which never carries the shell-private HISTFILE param.
6026///
6027/// Outside `--zsh` mode this returns None: `$HISTFILE` is zsh's file and
6028/// zshrs's history lives in the SQLite store
6029/// (`extensions/history.rs::histfile_writable`). Every implicit
6030/// read/write/lock then takes its HISTFILE-unset path — the startup read
6031/// hydrates from the store's text mirror, and nothing writes the file.
6032fn resolve_histfile() -> Option<String> {
6033    if !crate::history::histfile_writable() {
6034        return None;
6035    }
6036    crate::ported::params::getsparam("HISTFILE")
6037}
6038
6039// =========================================================================
6040// Helper inline accessors for the ring (private — match C internal use)
6041// =========================================================================
6042
6043/// !!! WARNING: RUST-ONLY HELPER !!!
6044///
6045/// No upstream C counterpart — this name exists nowhere in `Src/`.
6046/// C's ring is the circular doubly-linked `Histent` list rooted at
6047/// `mod_export Histent hist_ring;` (`Src/hist.c:103`), so C walks it
6048/// with `he->up` / `he->down` pointer chases inline at every call site
6049/// (e.g. `gethistent`, `Src/hist.c:1318`). zshrs stores the ring as a
6050/// `Vec<histent>` under a `Mutex`, so the pointer chase becomes this
6051/// lookup. Approved Rust-only helper:
6052/// `tests/data/fake_fn_allowlist.txt:832`.
6053/// Deliberately left uncited so the port audit keeps reporting it.
6054fn ring_get(ev: i64) -> Option<histent> {
6055    let ring = hist_ring.lock().unwrap();
6056    // hist_ring is strictly DESCENDING by histnum: entries are only ever added
6057    // via `insert(0, ...)` with the monotonically increasing `curhist` counter,
6058    // so index 0 holds the newest (highest) histnum. A linear scan here walked
6059    // all ~40k entries on every lookup — and the full ring on every miss — which
6060    // made each prompt slower as history grew (profiled hot path: ring_get →
6061    // Iter::next). Binary search is O(log n); the comparator is `ev.cmp(elem)`
6062    // to match the descending order.
6063    match ring.binary_search_by(|h| ev.cmp(&h.histnum)) {
6064        Ok(idx) => Some(clone_histent(&ring[idx])),
6065        Err(_) => None,
6066    }
6067}
6068
6069/// !!! WARNING: RUST-ONLY HELPER !!!
6070///
6071/// No upstream C counterpart — this name exists nowhere in `Src/`.
6072/// C hands out a borrowed `Histent` pointer straight into the ring
6073/// (`struct histent`, `Src/zsh.h:2234`); Rust's `Mutex` guard cannot
6074/// outlive the lock, so readers get an owned copy instead. Approved
6075/// Rust-only helper: `tests/data/fake_fn_allowlist.txt:833`.
6076/// Deliberately left uncited so the port audit keeps reporting it.
6077fn clone_histent(h: &histent) -> histent {
6078    histent {
6079        node: hashnode {
6080            next: None,
6081            nam: h.node.nam.clone(),
6082            flags: h.node.flags,
6083        },
6084        up: None,
6085        down: None,
6086        zle_text: h.zle_text.clone(),
6087        stim: h.stim,
6088        ftim: h.ftim,
6089        words: h.words.clone(),
6090        nwords: h.nwords,
6091        histnum: h.histnum,
6092    }
6093}
6094
6095/// !!! WARNING: RUST-ONLY HELPER !!!
6096///
6097/// No upstream C counterpart — this name exists nowhere in `Src/`.
6098/// C has no index into `hist_ring` (`Src/hist.c:103`) at all: it holds
6099/// the `Histent` pointer itself and moves with `up`/`down`
6100/// (`up_histent`, `Src/hist.c:1304`). Approved Rust-only helper:
6101/// `tests/data/fake_fn_allowlist.txt:834`.
6102/// Deliberately left uncited so the port audit keeps reporting it.
6103fn ring_position(ev: i64) -> Option<usize> {
6104    // hist_ring is strictly descending by histnum (see ring_get); O(log n)
6105    // binary search instead of an O(n) linear `position` scan.
6106    hist_ring
6107        .lock()
6108        .unwrap()
6109        .binary_search_by(|h| ev.cmp(&h.histnum))
6110        .ok()
6111}
6112
6113/// !!! WARNING: RUST-ONLY HELPER !!!
6114///
6115/// No upstream C counterpart — this name exists nowhere in `Src/`.
6116/// C reads `he->histnum` off the `Histent` it already holds
6117/// (`struct histent`, `Src/zsh.h:2234`); the Vec-backed ring needs an
6118/// index read under the lock. Approved Rust-only helper:
6119/// `tests/data/fake_fn_allowlist.txt:835`.
6120/// Deliberately left uncited so the port audit keeps reporting it.
6121fn ring_at(idx: usize) -> i64 {
6122    hist_ring.lock().unwrap()[idx].histnum
6123}
6124
6125/// !!! WARNING: RUST-ONLY HELPER !!!
6126///
6127/// No upstream C counterpart — this name exists nowhere in `Src/`.
6128/// C tracks the ring size in the global `zlong histlinect`
6129/// (`Src/hist.c` extern, declared in `Src/zsh.h`) rather than measuring
6130/// the list. Approved Rust-only helper:
6131/// `tests/data/fake_fn_allowlist.txt:836`.
6132/// Deliberately left uncited so the port audit keeps reporting it.
6133fn ring_len() -> usize {
6134    hist_ring.lock().unwrap().len()
6135}
6136
6137/// !!! WARNING: RUST-ONLY HELPER !!!
6138///
6139/// No upstream C counterpart — this name exists nowhere in `Src/`.
6140/// C spells the oldest entry `hist_ring->down` inline, as the
6141/// `firsthist()` macro does (`Src/zsh.h:594`). Approved Rust-only
6142/// helper: `tests/data/fake_fn_allowlist.txt:837`.
6143/// Deliberately left uncited so the port audit keeps reporting it.
6144fn ring_oldest() -> Option<i64> {
6145    hist_ring.lock().unwrap().last().map(|h| h.histnum)
6146}
6147
6148/// !!! WARNING: RUST-ONLY HELPER !!!
6149///
6150/// No upstream C counterpart — this name exists nowhere in `Src/`.
6151/// C spells the newest entry as `hist_ring` itself
6152/// (`mod_export Histent hist_ring;`, `Src/hist.c:103`) and dereferences
6153/// it in place. Approved Rust-only helper:
6154/// `tests/data/fake_fn_allowlist.txt:838`.
6155/// Deliberately left uncited so the port audit keeps reporting it.
6156fn ring_latest() -> Option<histent> {
6157    hist_ring.lock().unwrap().first().map(clone_histent)
6158}
6159
6160/// !!! WARNING: RUST-ONLY HELPER !!!
6161///
6162/// No upstream C counterpart — this name exists nowhere in `Src/`.
6163/// C allocates a ring entry inline in `prepnexthistent` with
6164/// `he = (Histent)zshcalloc(sizeof *he);` (`Src/hist.c:1400`) and then
6165/// assigns the fields one at a time; there is no constructor function
6166/// to port. Approved Rust-only helper:
6167/// `tests/data/fake_fn_allowlist.txt:830`.
6168/// Deliberately left uncited so the port audit keeps reporting it.
6169/// Construct a fresh `histent` for the ring. Rust-port helper —
6170/// in C every call site inlines `zshcalloc(sizeof(struct histent))`
6171/// plus field-by-field assignment (hist.c:1614/2098/...) so there
6172/// is no C function to mirror. Justified in
6173/// `tests/data/fake_fn_allowlist.txt:676`.
6174pub(crate) fn make_histent(num: i64, text: String) -> histent {
6175    let now = std::time::SystemTime::now()
6176        .duration_since(std::time::UNIX_EPOCH)
6177        .map(|d| d.as_secs() as i64)
6178        .unwrap_or(0);
6179    histent {
6180        node: hashnode {
6181            next: None,
6182            nam: text,
6183            flags: 0,
6184        },
6185        up: None,
6186        down: None,
6187        zle_text: None,
6188        stim: now,
6189        ftim: now,
6190        words: Vec::new(),
6191        nwords: 0,
6192        histnum: num,
6193    }
6194}
6195
6196/// Port of `firsthist()` from `Src/zsh.h:594`. — C decl `#define firsthist()         (hist_ring? hist_ring->down->histnum : curhist)`.
6197pub fn firsthist() -> i64 {
6198    let ring = hist_ring.lock().unwrap();
6199    ring.last().map(|h| h.histnum).unwrap_or(1)
6200}
6201
6202/// !!! WARNING: RUST-ONLY HELPER !!!
6203///
6204/// No upstream C counterpart — this name exists nowhere in `Src/`.
6205/// C has no standalone modifier applier: the `:h`/`:t`/`:r`/`:e`/`:s`/
6206/// `:&`/`:q`/`:x`/`:l`/`:u`/`:A`/`:a`/`:P` chain is the `for (;;)` switch
6207/// inlined in `histsubchar` (`Src/hist.c:830-961`), which dispatches to
6208/// `chabspath` (`Src/hist.c:1878`), `chrealpath` (`Src/hist.c:1971`),
6209/// `remtpath` (`Src/hist.c:2056`), `remtext` (`Src/hist.c:2122`),
6210/// `rembutext` (`Src/hist.c:2136`), `remlpaths` (`Src/hist.c:2152`),
6211/// `subst` (`Src/hist.c:2336`), `quote` (`Src/hist.c:2486`),
6212/// `quotebreak` (`Src/hist.c:2527`) and `casemodify` (`Src/hist.c:2196`)
6213/// — all of which ARE ported above. This wrapper drives the same
6214/// dispatch for the parameter-expansion `${var:h}` path, which reads
6215/// its modifier string from an already-expanded value rather than from
6216/// `ingetc()`. Approved Rust-only helper:
6217/// `tests/data/fake_fn_allowlist.txt:1045`.
6218/// Deliberately left uncited so the port audit keeps reporting it.
6219/// Apply chained history modifiers `:X:Y...` to `val`.
6220/// Direct port of the modifier-loop body in `Src/hist.c:830-961`
6221/// (the `for (;;)` switch on `:`-prefixed mod chars). Each branch
6222/// dispatches via `chabspath`/`chrealpath`/`equalsubstr`/`remtpath`/
6223/// `rembutext`/`remtext`/`remlpaths`/`subst`/`quote`/`casemodify`/
6224/// `xsymlink`. Free fn — no executor state needed.
6225/// Apply zsh history-style modifiers to a value
6226/// Modifiers can be chained: :A:h:h
6227pub fn apply_history_modifiers(val: &str, modifiers: &str) -> String {
6228    let mut result = val.to_string();
6229    let mut chars = modifiers.chars().peekable();
6230
6231    while let Some(c) = chars.next() {
6232        match c {
6233            ':' => continue,
6234            'A' => {
6235                if let Ok(abs) = std::fs::canonicalize(&result) {
6236                    result = abs.to_string_lossy().to_string();
6237                } else {
6238                    // canonicalize() requires the path to exist. For
6239                    // non-existent paths zsh still removes `./` and
6240                    // resolves `..` lexically — `./foo` → `<cwd>/foo`,
6241                    // not `<cwd>/./foo`. Without this normalization,
6242                    // `${a:A}` for `a=./foo` left the `./` segment in
6243                    // the output even after the cwd-prefix.
6244                    let joined = if result.starts_with('/') {
6245                        std::path::PathBuf::from(&result)
6246                    } else if let Ok(cwd) = std::env::current_dir() {
6247                        cwd.join(&result)
6248                    } else {
6249                        std::path::PathBuf::from(&result)
6250                    };
6251                    let mut parts: Vec<String> = Vec::new();
6252                    for comp in joined.components() {
6253                        match comp {
6254                            CurDir => {}
6255                            ParentDir => {
6256                                parts.pop();
6257                            }
6258                            Normal(s) => parts.push(s.to_string_lossy().to_string()),
6259                            RootDir => parts.insert(0, String::new()),
6260                            Prefix(p) => {
6261                                parts.insert(0, p.as_os_str().to_string_lossy().to_string())
6262                            }
6263                        }
6264                    }
6265                    result = parts.join("/");
6266                    if result.is_empty() {
6267                        result = "/".to_string();
6268                    }
6269                }
6270            }
6271            'a' => {
6272                if !result.starts_with('/') {
6273                    if let Ok(cwd) = std::env::current_dir() {
6274                        result = cwd.join(&result).to_string_lossy().to_string();
6275                    }
6276                }
6277            }
6278            'h' => {
6279                // zsh strips trailing slashes BEFORE applying head:
6280                // `/tmp/` :h is `/`, not `/tmp`. Repeatedly trim
6281                // trailing `/` first, then drop the last segment.
6282                let trimmed = result.trim_end_matches('/');
6283                if trimmed.is_empty() {
6284                    // Pure-slash input (`/`, `//`, …) — head is `/`.
6285                    result = "/".to_string();
6286                } else if let Some(pos) = trimmed.rfind('/') {
6287                    if pos == 0 {
6288                        result = "/".to_string();
6289                    } else {
6290                        result = trimmed[..pos].to_string();
6291                    }
6292                } else {
6293                    result = ".".to_string();
6294                }
6295            }
6296            't' => {
6297                // Mirror zsh: strip trailing slashes before tail
6298                // extraction so `foo/` :t is `foo`, not the empty
6299                // segment after the slash.
6300                let trimmed = result.trim_end_matches('/');
6301                if let Some(pos) = trimmed.rfind('/') {
6302                    result = trimmed[pos + 1..].to_string();
6303                } else {
6304                    result = trimmed.to_string();
6305                }
6306            }
6307            'r' => {
6308                if let Some(dot_pos) = result.rfind('.') {
6309                    let slash_pos = result.rfind('/').map(|p| p + 1).unwrap_or(0);
6310                    if dot_pos > slash_pos {
6311                        result = result[..dot_pos].to_string();
6312                    }
6313                }
6314            }
6315            'e' => {
6316                if let Some(dot_pos) = result.rfind('.') {
6317                    let slash_pos = result.rfind('/').map(|p| p + 1).unwrap_or(0);
6318                    if dot_pos > slash_pos {
6319                        result = result[dot_pos + 1..].to_string();
6320                    } else {
6321                        result = String::new();
6322                    }
6323                } else {
6324                    result = String::new();
6325                }
6326            }
6327            'l' => {
6328                // `:l` lowercase. Direct port of
6329                // src/zsh/Src/hist.c:931-933 — calls casemodify
6330                // with CASMOD_LOWER. Use the faithful
6331                // casemodify port instead of plain to_lowercase
6332                // for Unicode-correct multibyte handling.
6333                result = casemodify(&result, CASMOD_LOWER);
6334            }
6335            'u' => {
6336                // `:u` uppercase. Port of src/zsh/Src/hist.c:934-936.
6337                result = casemodify(&result, CASMOD_UPPER);
6338            }
6339            'C' => {
6340                // `:C` capitalize. zsh-only modifier per
6341                // hist.c (see CASMOD_CAPS dispatch via
6342                // casemodify). The history-modifier loop's
6343                // legacy path didn't recognize `:C` — only the
6344                // `(C)` parameter flag did. Same semantics:
6345                // word-aware capitalization with mid-word
6346                // lowercase enforcement.
6347                result = casemodify(&result, CASMOD_CAPS);
6348            }
6349            'q' => {
6350                // zsh `:q` uses backslash quoting, not single-bslashquote
6351                // wrapping. Each shell-meta char gets a `\` prefix.
6352                let mut out = String::with_capacity(result.len() + 8);
6353                for ch in result.chars() {
6354                    if " \t\n'\"\\$`;|&<>()[]{}*?#~!".contains(ch) {
6355                        out.push('\\');
6356                    }
6357                    out.push(ch);
6358                }
6359                result = out;
6360            }
6361            'x' => {
6362                // `:x` bslashquote with word breaks. Direct port of
6363                // src/zsh/Src/hist.c:2527-2556 quotebreak —
6364                // wraps the value in single quotes, escapes
6365                // internal `'` as `'\''`, AND closes-then-reopens
6366                // SQ around each whitespace char (so `hello world`
6367                // becomes `'hello' 'world'`). Already ported as a
6368                // standalone helper in zle_hist.
6369                result = quotebreak(&result);
6370            }
6371            'Q' => {
6372                // Same shell-bslashquote-remove as the other :Q path
6373                // (hist.c remquote): strips matching `'`/`"` pairs
6374                // AND backslash escapes inside or unquoted.
6375                let bytes: Vec<char> = result.chars().collect();
6376                let mut out = String::with_capacity(result.len());
6377                let mut j = 0;
6378                let mut in_dq = false;
6379                let mut in_sq = false;
6380                while j < bytes.len() {
6381                    let c = bytes[j];
6382                    if in_sq {
6383                        if c == '\'' {
6384                            in_sq = false;
6385                        } else {
6386                            out.push(c);
6387                        }
6388                        j += 1;
6389                        continue;
6390                    }
6391                    if in_dq {
6392                        if c == '"' {
6393                            in_dq = false;
6394                        } else if c == '\\' && j + 1 < bytes.len() {
6395                            j += 1;
6396                            out.push(bytes[j]);
6397                        } else {
6398                            out.push(c);
6399                        }
6400                        j += 1;
6401                        continue;
6402                    }
6403                    match c {
6404                        '\'' => in_sq = true,
6405                        '"' => in_dq = true,
6406                        '\\' if j + 1 < bytes.len() => {
6407                            j += 1;
6408                            out.push(bytes[j]);
6409                        }
6410                        _ => out.push(c),
6411                    }
6412                    j += 1;
6413                }
6414                result = out;
6415            }
6416            'P' => {
6417                if let Ok(real) = std::fs::canonicalize(&result) {
6418                    result = real.to_string_lossy().to_string();
6419                }
6420            }
6421            'f' | 'F' | 'g' | 's' | '&' => {
6422                // c:3743 — `modify()` `:s/:g/:&/:f/:F` arm inlined per
6423                //          build.rs invariant.  `f` = repeat until no
6424                //          more changes (fixed-point); `F N` = repeat
6425                //          up to N times. zsh treats both as flags that
6426                //          PREFIX the actual `s`/`&`/`g` modifier.
6427                let mut fixed_point = false;
6428                let mut max_iters: Option<u32> = None;
6429                let mut c = c;
6430                if c == 'f' {
6431                    fixed_point = true;
6432                    c = chars.next().unwrap_or(' ');
6433                } else if c == 'F' {
6434                    // F takes a numeric argument: F N
6435                    let mut num = String::new();
6436                    while let Some(&ch) = chars.peek() {
6437                        if ch.is_ascii_digit() {
6438                            num.push(ch);
6439                            chars.next();
6440                        } else {
6441                            break;
6442                        }
6443                    }
6444                    max_iters = num.parse().ok();
6445                    c = chars.next().unwrap_or(' ');
6446                }
6447                let (global, do_parse) = match c {
6448                    's' => (false, true),
6449                    '&' => (false, false),
6450                    'g' => {
6451                        // 'g'
6452                        match chars.next() {
6453                            Some('s') => (true, true),
6454                            Some('&') => (true, false),
6455                            _ => break,
6456                        }
6457                    }
6458                    _ => break,
6459                };
6460                // c:3760 — read delimiter, parse old/new bracketed by it,
6461                //          backslash-escapes for embedded delimiters.
6462                let (pat, rep) = if do_parse {
6463                    let delim = chars.next().unwrap_or('/');
6464                    let mut old = String::new();
6465                    while let Some(&ch) = chars.peek() {
6466                        if ch == delim {
6467                            chars.next();
6468                            break;
6469                        }
6470                        chars.next();
6471                        if ch == '\\' {
6472                            if let Some(&n) = chars.peek() {
6473                                if n == delim {
6474                                    chars.next();
6475                                    old.push(delim);
6476                                    continue;
6477                                }
6478                            }
6479                        }
6480                        old.push(ch);
6481                    }
6482                    let mut new = String::new();
6483                    while let Some(&ch) = chars.peek() {
6484                        if ch == delim {
6485                            chars.next();
6486                            break;
6487                        }
6488                        chars.next();
6489                        if ch == '\\' {
6490                            if let Some(&n) = chars.peek() {
6491                                if n == delim {
6492                                    chars.next();
6493                                    new.push(delim);
6494                                    continue;
6495                                }
6496                            }
6497                        }
6498                        new.push(ch);
6499                    }
6500                    // c:3811 — cache for `:&`/`:g&`. Empty `old` re-uses
6501                    //          the previously cached value.
6502                    if !old.is_empty() {
6503                        LAST_SUBST_OLD.with(|c| *c.borrow_mut() = old.clone());
6504                        LAST_SUBST_NEW.with(|c| *c.borrow_mut() = new.clone());
6505                    }
6506                    if old.is_empty() {
6507                        let lo = LAST_SUBST_OLD.with(|c| c.borrow().clone());
6508                        let ln = LAST_SUBST_NEW.with(|c| c.borrow().clone());
6509                        (lo, ln)
6510                    } else {
6511                        (old, new)
6512                    }
6513                } else {
6514                    // c:3784 — `:&`/`:g&` reads cached last_str/last_rep.
6515                    (
6516                        LAST_SUBST_OLD.with(|c| c.borrow().clone()),
6517                        LAST_SUBST_NEW.with(|c| c.borrow().clone()),
6518                    )
6519                };
6520                if !pat.is_empty() {
6521                    // c:3830 — `subststr(s, &pat, &rep, gbal)`.
6522                    let apply = |s: &str| -> String {
6523                        if global {
6524                            s.replace(&pat, &rep)
6525                        } else {
6526                            s.replacen(&pat, &rep, 1)
6527                        }
6528                    };
6529                    if fixed_point {
6530                        // c:Src/hist.c — `:f` repeats until no more
6531                        // changes. Cap at a generous safety bound to
6532                        // avoid pathological growth on a replacement
6533                        // that re-introduces the pattern.
6534                        let cap: u32 = 10_000;
6535                        for _ in 0..cap {
6536                            let next = apply(&result);
6537                            if next == result {
6538                                break;
6539                            }
6540                            result = next;
6541                        }
6542                    } else if let Some(n) = max_iters {
6543                        for _ in 0..n {
6544                            let next = apply(&result);
6545                            if next == result {
6546                                break;
6547                            }
6548                            result = next;
6549                        }
6550                    } else {
6551                        result = apply(&result);
6552                    }
6553                }
6554            }
6555            // Bash-only modifiers — zsh rejects with "unrecognized
6556            // modifier". Match that error format. Without these arms,
6557            // unknown modifiers silently terminated the loop and the
6558            // caller saw the previous-stage value (often empty).
6559            // `F` is NOT in this set — zsh accepts `F N` as a
6560            // bounded-iteration prefix for `:s` (handled above).
6561            'U' | 'L' | 'V' | 'X' => {
6562                zerr(&format!("unrecognized modifier `{}'", c));
6563                result = String::new();
6564                break;
6565            }
6566            _ => break,
6567        }
6568    }
6569    result
6570}
6571
6572thread_local! {
6573    /// Port of file-static `last_str`/`last_rep` from
6574    /// `Src/subst.c::modify()` — the last `:s/old/new/` pair so `:&`
6575    /// and `:g&` can repeat it.
6576    static LAST_SUBST_OLD: std::cell::RefCell<String> =
6577        const { std::cell::RefCell::new(String::new()) };
6578    static LAST_SUBST_NEW: std::cell::RefCell<String> =
6579        const { std::cell::RefCell::new(String::new()) };
6580}
6581
6582#[cfg(test)]
6583mod chrealpath_tests {
6584    use super::*;
6585
6586    /// `chrealpath` MUST refuse a relative path (c:1999-2000 explicit
6587    /// `if (**junkptr != '/') return 0`). The C body has a hard
6588    /// comment at c:1997 that callers must pass absolute paths.
6589    /// A regression that resolved relative paths via the working
6590    /// directory would silently change history-modifier semantics
6591    /// (`!:A` against `foo.txt` would yield `/cwd/foo.txt` instead
6592    /// of the C-faithful "leave it alone").
6593    #[test]
6594    fn chrealpath_rejects_relative_path() {
6595        let _g = crate::test_util::global_state_lock();
6596        let r = chrealpath("relative/path", b'P', false); // c:1971
6597        assert!(
6598            r.is_none(),
6599            "c:1999-2000 — relative path MUST return None; got {:?}",
6600            r
6601        );
6602    }
6603
6604    /// `chrealpath` empty input returns Some(empty) — the c:1985-1986
6605    /// guard `if (!**junkptr) return 1` returns success (1) for the
6606    /// empty-string case, leaving `*junkptr` unchanged. Pin so a
6607    /// regression that returns None for empty doesn't silently break
6608    /// the caller's "this modifier didn't change anything" path.
6609    #[test]
6610    fn chrealpath_empty_returns_empty() {
6611        let _g = crate::test_util::global_state_lock();
6612        let r = chrealpath("", b'P', false); // c:1971
6613        assert_eq!(
6614            r.as_deref(),
6615            Some(""),
6616            "c:1985-1986 — empty input returns Some(empty), not None"
6617        );
6618    }
6619
6620    /// `chrealpath` MUST fall back to walking parent prefixes when
6621    /// the full path doesn't exist (c:2027-2030 + c:2046-2048).
6622    /// Implements `realpath` against a not-yet-created file:
6623    /// strip components until a parent resolves, then re-splice the
6624    /// unresolvable tail.
6625    ///
6626    /// Pin: `/tmp/<real>/nonexistent/tail` where `/tmp/<real>` exists
6627    /// resolves to `/tmp/<real>/nonexistent/tail` (the unresolvable
6628    /// tail is appended verbatim). On macOS `/tmp` is a symlink to
6629    /// `/private/tmp` so the resolved prefix carries the canonical
6630    /// form. The test creates a real subdir in tmp to anchor the
6631    /// resolution and verifies the unresolvable tail is preserved.
6632    #[test]
6633    fn chrealpath_partial_prefix_fallback() {
6634        let _g = crate::test_util::global_state_lock();
6635        let dir = tempfile::tempdir().expect("tempdir");
6636        // dir.path() is an absolute, canonicalized path.
6637        let probe = dir.path().join("nonexistent_sub/nonexistent_tail");
6638        let probe_str = probe.to_str().unwrap();
6639        let r = chrealpath(probe_str, b'P', false) // c:1971
6640            .expect("partial-prefix walk → Some");
6641        // The C semantics: prefix walks back to dir.path() (which
6642        // exists), and the tail "nonexistent_sub/nonexistent_tail"
6643        // gets re-spliced. Result must end with the unresolvable tail.
6644        assert!(
6645            r.ends_with("/nonexistent_sub/nonexistent_tail"),
6646            "c:2046-2048 — unresolvable tail must be re-spliced; got {:?}",
6647            r
6648        );
6649        // Result must START at a real absolute path (the existing prefix).
6650        // On macOS `dir.path()` might be `/var/folders/...` (already canonical).
6651        assert!(r.starts_with('/'), "result must be absolute; got {:?}", r);
6652    }
6653}
6654
6655#[cfg(test)]
6656mod histsplitwords_uselex_tests {
6657    use super::*;
6658
6659    /// `uselex=true` runs `bufferwords` and recovers byte offsets in
6660    /// `line`. `echo hi` → two words with the expected spans.
6661    #[test]
6662    fn uselex_matches_simple_words() {
6663        // bufferwords runs the real lexer, which needs the initialised
6664        // type table and options a shell sets up at startup.
6665        let _g = crate::test_util::global_state_lock();
6666        let line = "echo hi";
6667        let words = histsplitwords(line, true);
6668        assert_eq!(words, vec![(0, 4), (5, 7)]);
6669    }
6670
6671    /// `uselex=false` is the no-lex fast path: whitespace tokenizer.
6672    #[test]
6673    fn no_uselex_matches_simple_words() {
6674        let line = "echo hi";
6675        let words = histsplitwords(line, false);
6676        assert_eq!(words, vec![(0, 4), (5, 7)]);
6677    }
6678
6679    /// Pin the c:3782-3786 fallback: if the lexer disagrees with the
6680    /// raw line (forced bad case via shell-meta-only input the simple
6681    /// lex can't fully reconstruct), histsplitwords retries with
6682    /// uselex=false and returns a non-empty wordset.
6683    #[test]
6684    fn uselex_falls_back_on_lex_disagreement() {
6685        // bufferwords runs the real lexer, which needs the initialised
6686        // type table and options a shell sets up at startup.
6687        let _g = crate::test_util::global_state_lock();
6688        // bufferwords splits `a;b` into `["a", ";", "b"]`; raw line
6689        // matches each char-by-char, so this should succeed without
6690        // falling back. Pin success path.
6691        let line = "a;b";
6692        let words = histsplitwords(line, true);
6693        assert!(!words.is_empty(), "must produce at least one word");
6694        assert!(words.iter().all(|(s, e)| s < e && *e <= line.len()));
6695    }
6696
6697    /// Multi-char ops `&&`/`||`/`;;` survive the lex round-trip with
6698    /// their original byte spans.
6699    #[test]
6700    fn uselex_handles_compound_operators() {
6701        // bufferwords runs the real lexer, which needs the initialised
6702        // type table and options a shell sets up at startup.
6703        let _g = crate::test_util::global_state_lock();
6704        let line = "a && b";
6705        let words = histsplitwords(line, true);
6706        // Each word's span must lie within the line.
6707        for (s, e) in &words {
6708            assert!(*e <= line.len() && s < e);
6709        }
6710    }
6711
6712    /// c:3406 — bufferwords lexes with `lexflags = flags | LEXFLAGS_ACTIVE`,
6713    /// so the zshrs-only `intercept … { body }` capture (execution lexing
6714    /// only, src/extensions/intercepts.rs `wants_block`) must not fire:
6715    /// `(z)` / `(Z+c+)` split the body into zsh's words. Regression: the
6716    /// capture returned the body as ONE quoted word, and an unclosed body
6717    /// dropped `{` and everything after it. Expected lists are
6718    /// `zsh -f` output for `print -rl -- ${(z)s}`.
6719    #[test]
6720    fn bufferwords_splits_intercept_block_like_zsh() {
6721        let _g = crate::test_util::global_state_lock();
6722        use crate::ported::zsh_h::LEXFLAGS_COMMENTS_KEEP;
6723        let cases: &[(&str, &[&str])] = &[
6724            (
6725                "intercept before ls { echo hi }",
6726                &["intercept", "before", "ls", "{", "echo", "hi", "}"],
6727            ),
6728            (
6729                "intercept before ls { ech",
6730                &["intercept", "before", "ls", "{", "ech"],
6731            ),
6732            (
6733                "intercept around git { if x; then { y; }; fi }",
6734                &[
6735                    "intercept", "around", "git", "{", "if", "x", ";", "then", "{", "y", ";",
6736                    "}", ";", "fi", "}",
6737                ],
6738            ),
6739            (
6740                "intercept after * { echo $? >> log }",
6741                &["intercept", "after", "*", "{", "echo", "$?", ">>", "log", "}"],
6742            ),
6743        ];
6744        for &(line, want) in cases {
6745            for flags in [0, LEXFLAGS_COMMENTS_KEEP] {
6746                let (got, _) = bufferwords(line, None, flags);
6747                assert_eq!(got, want, "bufferwords({line:?}, flags={flags})");
6748            }
6749        }
6750    }
6751
6752    /// Trailing whitespace doesn't produce a phantom word.
6753    #[test]
6754    fn no_uselex_trailing_whitespace_no_phantom() {
6755        let words = histsplitwords("hi   ", false);
6756        assert_eq!(words, vec![(0, 2)]);
6757    }
6758
6759    /// `histsplitwords(uselex=true)` MUST distinguish a lone `;` from
6760    /// the `;;` case-terminator. The c:3715-3722 special case stops
6761    /// the strpfx fast-path when the lex token is `";"` but the line
6762    /// has `";;"` — otherwise the lex token would greedily consume
6763    /// both bytes and corrupt the case-statement's word offsets.
6764    ///
6765    /// Pin the contract: `foo ;; bar` produces words pointing at
6766    /// `foo`, `;;`, and `bar` (not `foo`, `;`, mid-stream garbage).
6767    /// A regression that skips the c:3715 disambiguation would
6768    /// produce truncated spans for the `bar` word.
6769    #[test]
6770    fn uselex_distinguishes_semicolon_from_double() {
6771        // bufferwords runs the real lexer, which needs the initialised
6772        // type table and options a shell sets up at startup.
6773        let _g = crate::test_util::global_state_lock();
6774        let line = "foo ;; bar";
6775        let words = histsplitwords(line, true);
6776        // Every word span must lie within the line.
6777        for (s, e) in &words {
6778            assert!(
6779                *e <= line.len(),
6780                "word ({},{}) overflows line len {}",
6781                s,
6782                e,
6783                line.len()
6784            );
6785            assert!(s < e, "empty span ({},{})", s, e);
6786        }
6787        // The last word must be "bar" (offsets 7..10).
6788        let last = words.last().expect("at least one word");
6789        assert_eq!(
6790            &line[last.0..last.1],
6791            "bar",
6792            "c:3715-3722 — last word must be 'bar' regardless of ;; handling, got {:?}",
6793            &line[last.0..last.1]
6794        );
6795    }
6796}
6797
6798#[cfg(test)]
6799mod subst_modifier_tests {
6800    use super::*;
6801
6802    /// Tests that touch the shared chline/hptr/chwords globals
6803    /// must serialize through this Mutex — cargo's parallel test
6804    /// runner races on these atomics otherwise.
6805    fn hist_test_lock() -> &'static Mutex<()> {
6806        static L: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
6807        L.get_or_init(|| Mutex::new(()))
6808    }
6809
6810    #[test]
6811    fn s_replaces_first_occurrence() {
6812        let _g = crate::test_util::global_state_lock();
6813        // c:3743 — `:s/old/new/` single substitution.
6814        assert_eq!(
6815            apply_history_modifiers("foo bar foo", ":s/foo/baz/"),
6816            "baz bar foo"
6817        );
6818    }
6819
6820    #[test]
6821    fn gs_replaces_all_occurrences() {
6822        let _g = crate::test_util::global_state_lock();
6823        // c:3743 — `:gs/old/new/` global substitution.
6824        assert_eq!(
6825            apply_history_modifiers("foo bar foo", ":gs/foo/baz/"),
6826            "baz bar baz"
6827        );
6828    }
6829
6830    #[test]
6831    fn ampersand_repeats_last_subst() {
6832        let _g = crate::test_util::global_state_lock();
6833        // c:3784 — `:&` repeats the cached last_str/last_rep pair.
6834        // First call caches old="x" new="y"; second `:&` reuses it.
6835        let first = apply_history_modifiers("xxx", ":s/x/y/");
6836        let second = apply_history_modifiers("xxxx", ":&");
6837        assert_eq!(first, "yxx");
6838        assert_eq!(second, "yxxx");
6839    }
6840
6841    #[test]
6842    fn g_ampersand_repeats_last_subst_globally() {
6843        let _g = crate::test_util::global_state_lock();
6844        // c:3784 — `:g&` global form of `:&`.
6845        let _ = apply_history_modifiers("init", ":s/i/X/");
6846        // Now LAST_SUBST_OLD="i", LAST_SUBST_NEW="X". Global re-apply:
6847        assert_eq!(apply_history_modifiers("aiibii", ":g&"), "aXXbXX");
6848    }
6849
6850    #[test]
6851    fn s_alternate_delimiter() {
6852        let _g = crate::test_util::global_state_lock();
6853        // c:3760 — first char after `s` is the delimiter; not bound
6854        //          to `/`.
6855        assert_eq!(apply_history_modifiers("a-b-c", ":s|-|+|"), "a+b-c");
6856    }
6857
6858    #[test]
6859    fn s_escaped_delimiter_in_pattern() {
6860        let _g = crate::test_util::global_state_lock();
6861        // c:3768 — `\/` inside the pattern emits a literal `/`.
6862        assert_eq!(apply_history_modifiers("a/b", r":s/\//#/"), "a#b");
6863    }
6864
6865    /// c:1304/1311 — `up_histent` walks the hist_ring toward newer
6866    /// entries; on an empty ring there's no walk possible. None is
6867    /// the well-defined empty state. Regression where it returns
6868    /// Some(0) would make the up-history widget enter a phantom entry.
6869    #[test]
6870    fn up_histent_on_empty_ring_is_none() {
6871        let _g = crate::test_util::global_state_lock();
6872        let snapshot: Vec<_> = hist_ring.lock().unwrap().drain(..).collect();
6873        assert!(up_histent(1).is_none());
6874        assert!(down_histent(1).is_none());
6875        hist_ring.lock().unwrap().extend(snapshot);
6876    }
6877
6878    /// c:518 (getsubsargs port) — the substitution-argument parser
6879    /// returns 1 when the input stream produces no delimiter char.
6880    /// Without any input buffered, the very first ingetc() call yields
6881    /// None → return 1 BEFORE we try to read ptr1/ptr2.
6882    #[test]
6883    fn getsubsargs_returns_one_when_no_delimiter_available() {
6884        let _g = crate::test_util::global_state_lock();
6885        let mut gbal = 0i32;
6886        let mut cflag = 0i32;
6887        // No input pre-seeded; ingetc returns None on the very first
6888        // call → ptr1 is None → return 1.
6889        let r = getsubsargs("", &mut gbal, &mut cflag);
6890        assert_eq!(r, 1, "no delimiter byte → fail-fast 1");
6891        assert_eq!(gbal, 0, "no :G suffix observed");
6892        assert_eq!(cflag, 0, "no cflag set");
6893    }
6894
6895    /// `Src/hist.c:1199` — words move left carrying the ONE byte that
6896    /// followed them, so the gaps between words shrink but nothing inside a
6897    /// word changes. zsh stores `print  "a  b"   c` as `print "a  b" c` and
6898    /// `a<TAB><TAB>b   c` as `a<TAB>b c` (measured with `fc -ln -1`).
6899    #[test]
6900    fn histreduceblanks_closes_gaps_between_recorded_words_only() {
6901        let _g = crate::test_util::global_state_lock();
6902        assert_eq!(
6903            reduce_blanks("print  \"a  b\"   c", &[0, 5, 7, 13, 16, 17], false),
6904            "print \"a  b\" c",
6905            "c:1224 — a quoted word keeps its inner blanks"
6906        );
6907        assert_eq!(
6908            reduce_blanks("a\t\tb   c", &[0, 1, 3, 4, 7, 8], false),
6909            "a\tb c",
6910            "c:1224 — the separator byte that follows a word is carried, tab included"
6911        );
6912        assert_eq!(
6913            reduce_blanks("a b", &[0, 1, 2, 3], false),
6914            "a b",
6915            "c:1211 — an already-reduced line returns untouched"
6916        );
6917        assert_eq!(
6918            reduce_blanks("é  x", &[0, 2, 4, 5], false),
6919            "é x",
6920            "offsets are bytes; a multibyte word survives the move"
6921        );
6922    }
6923
6924    /// `Src/hist.c:1204-1205` / `:1235-1248` — the ends of the line.
6925    #[test]
6926    fn histreduceblanks_line_ends_follow_ignorespace_and_tail() {
6927        let _g = crate::test_util::global_state_lock();
6928        assert_eq!(
6929            reduce_blanks(" print  lead", &[1, 6, 8, 12], false),
6930            "print lead",
6931            "c:1204 — without HIST_IGNORE_SPACE the leading space goes"
6932        );
6933        assert_eq!(
6934            reduce_blanks(" print  lead", &[1, 6, 8, 12], true),
6935            " print lead",
6936            "c:1205 — HIST_IGNORE_SPACE keeps the leading spaces"
6937        );
6938        assert_eq!(
6939            reduce_blanks("x   ", &[0, 1], false),
6940            "x",
6941            "c:1243 — an all-inblank tail is truncated"
6942        );
6943        assert_eq!(
6944            reduce_blanks("print a  # c", &[0, 5, 6, 7], false),
6945            "print a  # c",
6946            "c:1245 — a comment tail is not a word and is kept as typed"
6947        );
6948        assert_eq!(
6949            reduce_blanks("print  a \r", &[0, 5, 7, 8], false),
6950            "print a \r",
6951            "c:1237 — CR is not inblank, so the tail is copied down, not cut"
6952        );
6953    }
6954
6955    /// Pin `digitcount` to its canonical C body at `Src/hist.c:573-589`.
6956    /// C reads digits FROM THE INPUT STREAM via ingetc/inungetc, NOT
6957    /// from a string argument. The previous Rust port's `(s: &str)`
6958    /// signature was a fabrication with no real caller; the C version
6959    /// is used by `:h` and `:t` modifiers at c:871/c:892 to parse
6960    /// the digit count after the modifier letter.
6961    #[test]
6962    fn digitcount_streams_from_ingetc() {
6963        let _g = crate::test_util::global_state_lock();
6964        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
6965        // Push "42abc" into the input stream; digitcount() should
6966        // parse 42 and inungetc() the 'a'. Use inputsetline to
6967        // seed the input buffer.
6968        inputsetline("42abc", 0);
6969        let n = digitcount();
6970        assert_eq!(n, 42, "c:581 — decimal digit accumulation");
6971
6972        // Next ingetc must yield 'a' (the inungetc'd terminator).
6973        let nxt = ingetc().unwrap_or('\0');
6974        assert_eq!(nxt, 'a', "c:587 — non-digit terminator was inungetc'd");
6975
6976        // No digits at all → returns 0, inungetc's the first char.
6977        inputsetline("xyz", 0);
6978        let n = digitcount();
6979        assert_eq!(n, 0, "c:586 — non-digit first char returns 0");
6980        let nxt = ingetc().unwrap_or('\0');
6981        assert_eq!(
6982            nxt, 'x',
6983            "c:587 — even the non-digit first char is inungetc'd"
6984        );
6985    }
6986
6987    /// `hist_in_word` / `hist_is_in_word` round-trip — the state flag
6988    /// the lexer flips while accumulating a word for history. C uses
6989    /// a single int; the Rust port preserves the bit-perfect contract.
6990    #[test]
6991    fn hist_in_word_round_trips() {
6992        let _g = crate::test_util::global_state_lock();
6993        hist_in_word(1);
6994        assert_eq!(hist_is_in_word(), 1);
6995        hist_in_word(0);
6996        assert_eq!(hist_is_in_word(), 0);
6997    }
6998
6999    /// c:2122 — `remtext("path/file.ext")` returns `"path/file"` —
7000    /// strips the file extension. Used by `${var:r}`. Regression
7001    /// dropping the dirname or keeping the dot would silently corrupt
7002    /// every filename-manipulation script.
7003    #[test]
7004    fn remtext_strips_extension_keeping_dirname() {
7005        let _g = crate::test_util::global_state_lock();
7006        assert_eq!(remtext("path/file.ext"), "path/file");
7007        assert_eq!(remtext("file.ext"), "file");
7008        assert_eq!(remtext("file"), "file");
7009    }
7010
7011    /// c:2122 + Doc/Zsh/expn.yo:303-310 — the `:r` modifier strips
7012    /// "a `.` followed by any number of characters (including zero)
7013    /// that are neither `.` nor `/` and that continue to the end of
7014    /// the string." Leading dot IS an extension separator: `.bashrc`
7015    /// is wholly an extension (`.` + `bashrc`), so `:r` strips it
7016    /// entirely. The C body walks backward from end stopping at `/`
7017    /// and truncates at the first `.` found — no exception for dot
7018    /// at position 0.
7019    /// Regression target: a previous Rust port guarded with
7020    /// `dot_pos > 0` keeping leading-dot files unchanged, which
7021    /// diverges from `${.bashrc:r}` in real zsh.
7022    #[test]
7023    fn remtext_strips_leading_dot_per_zsh_doc() {
7024        let _g = crate::test_util::global_state_lock();
7025        assert_eq!(
7026            remtext(".bashrc"),
7027            "",
7028            "$.bashrc:r is an extension per Doc/Zsh/expn.yo:303"
7029        );
7030        assert_eq!(
7031            remtext("path/.bashrc"),
7032            "path/",
7033            "extension scan stops at `/`, then strips at first `.`"
7034        );
7035    }
7036
7037    /// c:2136 — `rembutext("path/file.ext")` returns `"ext"` (the
7038    /// extension, dropping the body). Counterpart to `remtext`.
7039    /// Regression returning the wrong slice would break `${file:e}`.
7040    #[test]
7041    fn rembutext_returns_extension_only() {
7042        let _g = crate::test_util::global_state_lock();
7043        assert_eq!(rembutext("path/file.ext"), "ext");
7044        assert_eq!(
7045            rembutext("file.tar.gz"),
7046            "gz",
7047            "last `.` wins (extension-only is post-LAST-dot)"
7048        );
7049        assert_eq!(rembutext("file"), "");
7050    }
7051
7052    /// c:2056 — `remtpath(path, 0)` (the `${PWD:h}` no-count case)
7053    /// removes the LAST component. `remtpath("/a/b/c", 0)` → `"/a/b"`.
7054    /// This is the canonical `:h` modifier path used by every theme
7055    /// that displays `${PWD:h}`.
7056    #[test]
7057    fn remtpath_count_zero_strips_last_component() {
7058        let _g = crate::test_util::global_state_lock();
7059        assert_eq!(remtpath("/a/b/c", 0), "/a/b");
7060        assert_eq!(remtpath("/a", 0), "/");
7061        assert_eq!(remtpath("foo", 0), ".", "no slash → returns '.'");
7062    }
7063
7064    /// c:2152 — `remlpaths(path, count)` keeps the LAST `count`
7065    /// components — counterpart to remtpath. `remlpaths("/a/b/c", 2)`
7066    /// → `"b/c"`. Drives `${PWD:t}` family.
7067    #[test]
7068    fn remlpaths_keeps_last_n_components() {
7069        let _g = crate::test_util::global_state_lock();
7070        assert_eq!(remlpaths("/a/b/c", 1), "c");
7071        assert_eq!(remlpaths("/a/b/c", 2), "b/c");
7072        assert_eq!(remlpaths("/a/b/c", 3), "a/b/c");
7073    }
7074
7075    /// c:2196 — `casemodify(s, CASMOD_LOWER)` lowercases every char.
7076    /// Regression that flips the case direction would break every
7077    /// `${(L)var}` user has.
7078    #[test]
7079    fn casemodify_lower_lowercases() {
7080        let _g = crate::test_util::global_state_lock();
7081        assert_eq!(casemodify("HELLO World", CASMOD_LOWER), "hello world");
7082    }
7083
7084    /// c:2196 — `casemodify(s, CASMOD_UPPER)` uppercases.
7085    #[test]
7086    fn casemodify_upper_uppercases() {
7087        let _g = crate::test_util::global_state_lock();
7088        assert_eq!(casemodify("hello world", CASMOD_UPPER), "HELLO WORLD");
7089    }
7090
7091    /// c:2196 — `CASMOD_CAPS` capitalises FIRST letter of each word
7092    /// (word-boundary determined by `nextupper` flag flips on
7093    /// non-alpha chars). `"hello world"` → `"Hello World"`.
7094    #[test]
7095    fn casemodify_caps_capitalises_word_starts() {
7096        let _g = crate::test_util::global_state_lock();
7097        assert_eq!(casemodify("hello world", CASMOD_CAPS), "Hello World");
7098        assert_eq!(
7099            casemodify("FOO BAR", CASMOD_CAPS),
7100            "Foo Bar",
7101            "non-first letters lowercased"
7102        );
7103    }
7104
7105    /// `Src/hist.c:2486-2523` — `quote(s)` wraps in `'...'` and
7106    /// breaks out `inblank` chars (NARROW space/tab/newline ONLY)
7107    /// by closing the current quote span, emitting the blank in its
7108    /// own `'<c>'` pair, then opening a fresh quote span. CR/FF/VT/
7109    /// NBSP should NOT be broken out — they're not inblank.
7110    #[test]
7111    fn quote_breaks_only_narrow_inblank_chars() {
7112        let _g = crate::test_util::global_state_lock();
7113        // c:2514 — close current quote, emit ` ` in its own pair, reopen.
7114        assert_eq!(
7115            quote("a b"),
7116            "'a' 'b'",
7117            "c:2514 — space broken out of single-quote span"
7118        );
7119        assert_eq!(quote("a\tb"), "'a'\t'b'");
7120        assert_eq!(quote("a\nb"), "'a'\n'b'");
7121        // CR (\r) is NOT inblank per C → must stay inside the quotes.
7122        assert_eq!(
7123            quote("a\rb"),
7124            "'a\rb'",
7125            "c:2499 — CR is NOT in C's inblank set; stays inside quotes"
7126        );
7127        // NBSP (0xA0) is NOT in narrow inblank either.
7128        assert_eq!(
7129            quote("a\u{00A0}b"),
7130            "'a\u{00A0}b'",
7131            "NBSP is not inblank; must remain inside the quote span"
7132        );
7133    }
7134
7135    /// `Src/hist.c:2527-2560` — `quotebreak(s)` same as quote but
7136    /// breaks out inblank chars regardless of inquotes state. Pin
7137    /// narrow-inblank behavior (no CR/FF/NBSP breaking).
7138    #[test]
7139    fn quotebreak_uses_narrow_inblank_set() {
7140        let _g = crate::test_util::global_state_lock();
7141        assert_eq!(quotebreak("a b"), "'a' 'b'");
7142        assert_eq!(quotebreak("a\tb"), "'a'\t'b'");
7143        assert_eq!(quotebreak("a\nb"), "'a'\n'b'");
7144        // CR — NOT inblank → stays inside.
7145        assert_eq!(
7146            quotebreak("a\rb"),
7147            "'a\rb'",
7148            "CR not in inblank set, must not be broken out"
7149        );
7150        // NBSP — NOT inblank.
7151        assert_eq!(
7152            quotebreak("a\u{00A0}b"),
7153            "'a\u{00A0}b'",
7154            "NBSP not in inblank, must not be broken out"
7155        );
7156        // Form-feed (\x0C) — NOT inblank.
7157        assert_eq!(
7158            quotebreak("a\u{000C}b"),
7159            "'a\u{000C}b'",
7160            "FF not in inblank, must not be broken out"
7161        );
7162    }
7163
7164    /// Pin: `savehistfile` short-circuits when `!interact` per
7165    /// `Src/hist.c:2932`. Non-interactive shells must not write
7166    /// to the user's HISTFILE — a script running with INTERACTIVE
7167    /// off should leave the user's history untouched even when
7168    /// passed an explicit fn_path.
7169    ///
7170    /// Also pins the `savehistsiz <= 0` short-circuit. Either
7171    /// gate firing must leave the file untouched.
7172    #[test]
7173    fn savehistfile_short_circuits_on_non_interactive() {
7174        let _g = crate::test_util::global_state_lock();
7175        let dir = tempfile::tempdir().expect("tempdir");
7176        let path = dir.path().join("hist_test");
7177        let path_str = path.to_str().unwrap();
7178        // Pre-populate with content so we can detect "untouched".
7179        std::fs::write(&path, b"PRESERVED").expect("seed write");
7180        // Force INTERACTIVE off; savehistfile must NOT touch the
7181        // file regardless of fn_path.
7182        let saved = isset(INTERACTIVE);
7183        dosetopt(INTERACTIVE, 0, 0);
7184        savehistfile(Some(path_str), 0);
7185        let after = std::fs::read(&path).expect("read after");
7186        assert_eq!(
7187            after, b"PRESERVED",
7188            "c:2932 — !interact must skip write; original content preserved"
7189        );
7190        // Restore.
7191        dosetopt(INTERACTIVE, if saved { 1 } else { 0 }, 0);
7192    }
7193
7194    /// Pin: `hgetline` per `Src/hist.c:1769-1786` truncates the
7195    /// in-flight `chline` at `hptr`, resets the globals, and
7196    /// returns the captured snippet. Returns None when chline is
7197    /// empty or hptr is at the start (C returns NULL).
7198    #[test]
7199    fn hgetline_truncates_chline_and_resets_globals() {
7200        let _g = crate::test_util::global_state_lock();
7201        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7202        // Save state.
7203        let saved_chline = std::mem::take(&mut *chline.lock().unwrap());
7204        let saved_hptr = hptr.swap(0, SeqCst);
7205        let saved_chwordpos = chwordpos.swap(0, SeqCst);
7206
7207        // Empty chline → None (c:1777).
7208        assert_eq!(hgetline(), None, "c:1777 — empty chline returns None");
7209
7210        // chline = "abcdef", hptr = 0 → None (c:1777 hp == 0).
7211        *chline.lock().unwrap() = "abcdef".to_string();
7212        hptr.store(0, SeqCst);
7213        assert_eq!(hgetline(), None, "c:1777 — hptr == 0 returns None");
7214
7215        // chline = "abcdef", hptr = 3 → Some("abc"), reset hptr/pos.
7216        *chline.lock().unwrap() = "abcdef".to_string();
7217        hptr.store(3, SeqCst);
7218        chwordpos.store(2, SeqCst);
7219        let result = hgetline();
7220        assert_eq!(
7221            result,
7222            Some("abc".to_string()),
7223            "c:1779 — truncate chline at hptr=3 returns 'abc'"
7224        );
7225        assert_eq!(hptr.load(SeqCst), 0, "c:1783 — hptr reset to 0");
7226        assert_eq!(chwordpos.load(SeqCst), 0, "c:1784 — chwordpos reset to 0");
7227
7228        // Restore state.
7229        *chline.lock().unwrap() = saved_chline;
7230        hptr.store(saved_hptr, SeqCst);
7231        chwordpos.store(saved_chwordpos, SeqCst);
7232    }
7233
7234    /// Pin: `histbackword` per `Src/hist.c:1711-1715` rewinds `hptr`
7235    /// to the start of the previous word ONLY when:
7236    ///   1. `chwordpos % 2 == 0` (even position — at a word
7237    ///      boundary, not mid-word), AND
7238    ///   2. `chwordpos != 0` (at least one full word recorded).
7239    /// Otherwise no-op.
7240    #[test]
7241    fn histbackword_rewinds_hptr_on_even_boundary() {
7242        let _g = crate::test_util::global_state_lock();
7243        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7244        // Capture and reset state.
7245        let saved_pos = chwordpos.swap(0, SeqCst);
7246        let saved_hptr = hptr.swap(0, SeqCst);
7247        let saved_words = {
7248            let mut w = chwords.lock().unwrap();
7249            std::mem::take(&mut *w)
7250        };
7251        // Seed: two words at offsets [0..3] "abc" and [4..7] "def".
7252        // chwords layout: [start1, end1, start2, end2].
7253        {
7254            let mut w = chwords.lock().unwrap();
7255            *w = vec![0i16, 3, 4, 7];
7256        }
7257        // chwordpos at 4 = even = word boundary. histbackword
7258        // should set hptr = chwords[chwordpos-1] = chwords[3] = 7.
7259        chwordpos.store(4, SeqCst);
7260        hptr.store(999, SeqCst);
7261        histbackword();
7262        assert_eq!(
7263            hptr.load(SeqCst),
7264            7,
7265            "c:1715 — even chwordpos must rewind hptr to chwords[pos-1]"
7266        );
7267
7268        // chwordpos at 0 (no words recorded) — no-op.
7269        chwordpos.store(0, SeqCst);
7270        hptr.store(123, SeqCst);
7271        histbackword();
7272        assert_eq!(
7273            hptr.load(SeqCst),
7274            123,
7275            "c:1714 — chwordpos == 0 means no-op (hptr untouched)"
7276        );
7277
7278        // chwordpos at 3 (odd, mid-word) — no-op.
7279        chwordpos.store(3, SeqCst);
7280        hptr.store(456, SeqCst);
7281        histbackword();
7282        assert_eq!(
7283            hptr.load(SeqCst),
7284            456,
7285            "c:1714 — odd chwordpos means mid-word, no-op"
7286        );
7287
7288        // Restore state.
7289        chwordpos.store(saved_pos, SeqCst);
7290        hptr.store(saved_hptr, SeqCst);
7291        *chwords.lock().unwrap() = saved_words;
7292    }
7293
7294    /// Pin `ihwaddc` to the C `*hptr++ = c;` OVERWRITE semantic.
7295    /// When hptr < chline.len() (e.g. after hwrep rewinds), the byte
7296    /// goes AT the cursor, not appended. The previous Rust port used
7297    /// `String::push` which appends only — pushing past the rewound
7298    /// hptr would leave the old word bytes intact and stash new bytes
7299    /// at the chline tail.
7300    #[test]
7301    fn ihwaddc_overwrites_at_hptr_not_append() {
7302        let _g = crate::test_util::global_state_lock();
7303        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7304        // Save state.
7305        let saved_chline = chline.lock().unwrap().clone();
7306        let saved_hptr = hptr.load(SeqCst);
7307        let saved_errflag = errflag.load(SeqCst);
7308        let saved_lexstop = lexstop.load(SeqCst);
7309        let saved_inflags = crate::ported::input::inbufflags.with(|f| f.get());
7310        let saved_qbang = qbang.load(SeqCst);
7311        let saved_stophist = stophist.load(SeqCst);
7312        let saved_hlinesz = hlinesz.load(SeqCst);
7313
7314        // Set up chline="echo oldword extra", rewind hptr to 5
7315        // (start of "oldword"). Push "NEW" → chline must become
7316        // "echo NEWword extra" with hptr advanced to 8.
7317        *chline.lock().unwrap() = "echo oldword extra".to_string();
7318        hptr.store(5, SeqCst);
7319        errflag.store(0, SeqCst);
7320        lexstop.store(false, SeqCst);
7321        crate::ported::input::inbufflags.with(|f| f.set(0));
7322        qbang.store(false, SeqCst);
7323        stophist.store(0, SeqCst);
7324        hlinesz.store(64, SeqCst);
7325
7326        ihwaddc(b'N' as i32);
7327        ihwaddc(b'E' as i32);
7328        ihwaddc(b'W' as i32);
7329
7330        let cl = chline.lock().unwrap().clone();
7331        assert_eq!(
7332            cl.as_str(),
7333            "echo NEWword extra",
7334            "c:368 — *hptr++ = c writes AT cursor (NOT appends to end)"
7335        );
7336        assert_eq!(
7337            hptr.load(SeqCst),
7338            8,
7339            "c:368 — hptr advances over the three overwrites"
7340        );
7341
7342        // Restore.
7343        *chline.lock().unwrap() = saved_chline;
7344        hptr.store(saved_hptr, SeqCst);
7345        errflag.store(saved_errflag, SeqCst);
7346        lexstop.store(saved_lexstop, SeqCst);
7347        crate::ported::input::inbufflags.with(|f| f.set(saved_inflags));
7348        qbang.store(saved_qbang, SeqCst);
7349        stophist.store(saved_stophist, SeqCst);
7350        hlinesz.store(saved_hlinesz, SeqCst);
7351    }
7352
7353    /// Pin `ihwaddc` to its canonical C body at `Src/hist.c:355-389`.
7354    /// C: `*hptr++ = c;` writes the byte and advances the cursor.
7355    /// The previous Rust port pushed to chline but never updated
7356    /// the `hptr` global — every subsequent ihwbegin/ihwend read a
7357    /// stale hptr=0, so chwords boundary positions stayed pinned
7358    /// to 0 no matter how many chars were appended.
7359    #[test]
7360    fn ihwaddc_advances_hptr_on_each_push() {
7361        let _g = crate::test_util::global_state_lock();
7362        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7363        // Save state.
7364        let saved_chline = chline.lock().unwrap().clone();
7365        let saved_hptr = hptr.load(SeqCst);
7366        let saved_errflag = errflag.load(SeqCst);
7367        let saved_lexstop = lexstop.load(SeqCst);
7368        let saved_inflags = crate::ported::input::inbufflags.with(|f| f.get());
7369        let saved_qbang = qbang.load(SeqCst);
7370        let saved_bangchar = bangchar.load(SeqCst);
7371        let saved_stophist = stophist.load(SeqCst);
7372        let saved_hlinesz = hlinesz.load(SeqCst);
7373
7374        // Set up: history active, no errors, no alias-only.
7375        *chline.lock().unwrap() = "AB".to_string(); // chline must be non-empty
7376        hptr.store(2, SeqCst);
7377        errflag.store(0, SeqCst);
7378        lexstop.store(false, SeqCst);
7379        crate::ported::input::inbufflags.with(|f| f.set(0));
7380        qbang.store(false, SeqCst);
7381        stophist.store(0, SeqCst);
7382        bangchar.store(b'!' as i32, SeqCst);
7383        hlinesz.store(64, SeqCst);
7384
7385        // Push 'x' → chline grows AND hptr advances.
7386        ihwaddc(b'x' as i32);
7387        assert_eq!(
7388            chline.lock().unwrap().as_str(),
7389            "ABx",
7390            "c:368 — chline grows"
7391        );
7392        assert_eq!(hptr.load(SeqCst), 3, "c:368 — hptr advances by 1");
7393
7394        // Push 'y' → second advance.
7395        ihwaddc(b'y' as i32);
7396        assert_eq!(hptr.load(SeqCst), 4, "c:368 — hptr advances on each push");
7397
7398        // Bang-escape with qbang: qbang=true, c=bangchar → '\\' AND c
7399        // get pushed, hptr advances by 2.
7400        qbang.store(true, SeqCst);
7401        ihwaddc(b'!' as i32);
7402        assert_eq!(
7403            chline.lock().unwrap().as_str(),
7404            "ABxy\\!",
7405            "c:366 — qbang escape pushes '\\\\' before bangchar"
7406        );
7407        assert_eq!(
7408            hptr.load(SeqCst),
7409            6,
7410            "c:366+c:368 — both pushes advance hptr"
7411        );
7412
7413        // errflag set → no-op.
7414        errflag.store(1, SeqCst);
7415        let hptr_before = hptr.load(SeqCst);
7416        ihwaddc(b'z' as i32);
7417        assert_eq!(
7418            hptr.load(SeqCst),
7419            hptr_before,
7420            "c:359 — errflag short-circuits, hptr unchanged"
7421        );
7422
7423        // Restore.
7424        *chline.lock().unwrap() = saved_chline;
7425        hptr.store(saved_hptr, SeqCst);
7426        errflag.store(saved_errflag, SeqCst);
7427        lexstop.store(saved_lexstop, SeqCst);
7428        crate::ported::input::inbufflags.with(|f| f.set(saved_inflags));
7429        qbang.store(saved_qbang, SeqCst);
7430        bangchar.store(saved_bangchar, SeqCst);
7431        stophist.store(saved_stophist, SeqCst);
7432        hlinesz.store(saved_hlinesz, SeqCst);
7433    }
7434
7435    /// Pin `ihwend` to its canonical C body at `Src/hist.c:1686-1705`.
7436    /// Same family of fix as `ihwbegin`: use the `hptr` global to
7437    /// compute the cursor position, NOT `chline.len()`. C: `if (hptr
7438    /// > chline + chwords[chwordpos-1])` writes `chwords[chwordpos++]
7439    /// = hptr - chline`; the previous Rust port used chline.len()
7440    /// for the cursor.
7441    #[test]
7442    fn ihwend_uses_hptr_not_chline_len() {
7443        let _g = crate::test_util::global_state_lock();
7444        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7445        // Save state.
7446        let saved_chline = chline.lock().unwrap().clone();
7447        let saved_chwords = chwords.lock().unwrap().clone();
7448        let saved_chwordpos = chwordpos.load(SeqCst);
7449        let saved_hptr = hptr.load(SeqCst);
7450        let saved_stop = stophist.load(SeqCst);
7451        let saved_active = histactive.load(SeqCst);
7452        let saved_inflags = crate::ported::input::inbufflags.with(|f| f.get());
7453
7454        // Set up: chline is "ABCDEFGHIJ" (10 bytes), hptr at 7
7455        // (lexer rewound). chwordpos=1 (in-flight word that started
7456        // at chwords[0]=2). C expects chwords[1] = hptr-chline = 7,
7457        // not chline.len() = 10.
7458        *chline.lock().unwrap() = "ABCDEFGHIJ".to_string();
7459        *chwords.lock().unwrap() = vec![2, 0];
7460        chwordpos.store(1, SeqCst);
7461        hptr.store(7, SeqCst);
7462        stophist.store(0, SeqCst);
7463        histactive.store(0, SeqCst);
7464        crate::ported::input::inbufflags.with(|f| f.set(0));
7465
7466        ihwend();
7467
7468        assert_eq!(
7469            chwords.lock().unwrap().get(1).copied(),
7470            Some(7),
7471            "c:1694 — chwords[chwordpos] = hptr - chline = 7 (NOT chline.len()=10)"
7472        );
7473        assert_eq!(
7474            chwordpos.load(SeqCst),
7475            2,
7476            "c:1694 — chwordpos++ on successful close"
7477        );
7478
7479        // c:1700 scrub branch — hptr<=chwords[start] → chwordpos--.
7480        *chwords.lock().unwrap() = vec![5, 0];
7481        chwordpos.store(1, SeqCst);
7482        hptr.store(3, SeqCst); // hptr(3) <= chwords[0]=5
7483        ihwend();
7484        assert_eq!(
7485            chwordpos.load(SeqCst),
7486            0,
7487            "c:1700 — chwordpos-- when hptr <= chwords[chwordpos-1]"
7488        );
7489
7490        // Even chwordpos short-circuits (between-words, no in-flight).
7491        chwordpos.store(2, SeqCst);
7492        hptr.store(9, SeqCst);
7493        *chwords.lock().unwrap() = vec![0, 4, 5, 8];
7494        ihwend();
7495        assert_eq!(
7496            chwordpos.load(SeqCst),
7497            2,
7498            "c:1691 — even chwordpos short-circuits"
7499        );
7500
7501        // Restore.
7502        *chline.lock().unwrap() = saved_chline;
7503        *chwords.lock().unwrap() = saved_chwords;
7504        chwordpos.store(saved_chwordpos, SeqCst);
7505        hptr.store(saved_hptr, SeqCst);
7506        stophist.store(saved_stop, SeqCst);
7507        histactive.store(saved_active, SeqCst);
7508        crate::ported::input::inbufflags.with(|f| f.set(saved_inflags));
7509    }
7510
7511    /// Pin `histremovedups` to its canonical C body at
7512    /// `Src/hist.c:1252-1262`: removes entries with HIST_DUP flag
7513    /// SET, not name-based dedup. Also pins the HIST_DUP bit value
7514    /// to the canonical C value (0x08 per `Src/zsh.h:2255`) — the
7515    /// hist.rs duplicate had `1 << 1 = 0x02` which both overlapped
7516    /// HIST_OLD and missed real HIST_DUP entries.
7517    #[test]
7518    fn histremovedups_removes_flagged_entries_only() {
7519        let _g = crate::test_util::global_state_lock();
7520        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7521        let saved_ring = {
7522            let r = hist_ring.lock().unwrap();
7523            r.iter()
7524                .map(|h| (h.node.nam.clone(), h.histnum, h.node.flags))
7525                .collect::<Vec<_>>()
7526        };
7527        let saved_histlinect = histlinect.load(SeqCst);
7528
7529        // HIST_DUP must equal C value 0x08 (Src/zsh.h:2255).
7530        assert_eq!(
7531            HIST_DUP, 0x08,
7532            "HIST_DUP bit value must match C (0x08), got {:#x}",
7533            HIST_DUP
7534        );
7535
7536        // Build a ring with three entries, only the middle one flagged HIST_DUP.
7537        {
7538            let mut ring = hist_ring.lock().unwrap();
7539            ring.clear();
7540            for (nam, num, flags) in [
7541                ("entry1", 1i64, 0i32),
7542                ("entry2", 2i64, HIST_DUP as i32), // flagged
7543                ("entry3", 3i64, 0i32),
7544            ] {
7545                ring.push(histent {
7546                    node: hashnode {
7547                        next: None,
7548                        nam: nam.to_string(),
7549                        flags,
7550                    },
7551                    up: None,
7552                    down: None,
7553                    zle_text: None,
7554                    stim: 0,
7555                    ftim: 0,
7556                    words: vec![],
7557                    nwords: 0,
7558                    histnum: num,
7559                });
7560            }
7561        }
7562        histlinect.store(3, SeqCst);
7563
7564        histremovedups();
7565
7566        {
7567            let ring = hist_ring.lock().unwrap();
7568            assert_eq!(
7569                ring.len(),
7570                2,
7571                "c:1259-1260 — only the HIST_DUP-flagged entry is removed"
7572            );
7573            assert!(ring.iter().any(|h| h.node.nam == "entry1"));
7574            assert!(ring.iter().any(|h| h.node.nam == "entry3"));
7575            assert!(!ring.iter().any(|h| h.node.nam == "entry2"));
7576        }
7577        assert_eq!(
7578            histlinect.load(SeqCst),
7579            2,
7580            "histlinect updated after removal"
7581        );
7582
7583        // Restore ring.
7584        {
7585            let mut ring = hist_ring.lock().unwrap();
7586            ring.clear();
7587            for (nam, num, flags) in saved_ring {
7588                ring.push(histent {
7589                    node: hashnode {
7590                        next: None,
7591                        nam,
7592                        flags,
7593                    },
7594                    up: None,
7595                    down: None,
7596                    zle_text: None,
7597                    stim: 0,
7598                    ftim: 0,
7599                    words: vec![],
7600                    nwords: 0,
7601                    histnum: num,
7602                });
7603            }
7604        }
7605        histlinect.store(saved_histlinect, SeqCst);
7606    }
7607
7608    /// Pin `iaddtoline` to its canonical C body at
7609    /// `Src/hist.c:397-413`, specifically the c:405-411 ZLE cursor
7610    /// adjustment block: `excs > zlemetacs` → `excs += 1 + inbufct -
7611    /// exlast` with a clamp to zlemetacs.
7612    #[test]
7613    fn iaddtoline_adjusts_excs_relative_to_zlemetacs() {
7614        let _g = crate::test_util::global_state_lock();
7615        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7616        // Save state.
7617        let saved_chline = chline.lock().unwrap().clone();
7618        let saved_excs = excs.load(SeqCst);
7619        let saved_zlemetacs = ZLEMETACS.load(SeqCst);
7620        let saved_expanding = expanding.load(SeqCst);
7621        let saved_lexstop = lexstop.load(SeqCst);
7622        let saved_qbang = qbang.load(SeqCst);
7623        let saved_exlast = exlast.load(SeqCst);
7624        // iaddtoline now reaches zleaddtoline, which edits the metafied line.
7625        let saved_metaline = crate::ported::zle::compcore::ZLEMETALINE
7626            .get_or_init(|| Mutex::new(String::new()))
7627            .lock()
7628            .unwrap()
7629            .clone();
7630        let saved_metall = crate::ported::zle::compcore::ZLEMETALL.load(SeqCst);
7631
7632        // Set up: expanding=1, lexstop=false, qbang=0 (no bang
7633        // escape path), excs > zlemetacs so the adjustment fires.
7634        *chline.lock().unwrap() = String::new();
7635        expanding.store(1, SeqCst);
7636        lexstop.store(false, SeqCst);
7637        qbang.store(false, SeqCst);
7638        excs.store(10, SeqCst); // > zlemetacs
7639        ZLEMETACS.store(5, SeqCst);
7640        crate::ported::input::inbufct.with(|c| c.set(3));
7641        exlast.store(2, SeqCst);
7642
7643        // c:405-407 — excs(10) > zlemetacs(5), so
7644        // new_excs = 10 + 1 + 3 - 2 = 12; 12 > 5 → no clamp.
7645        iaddtoline(b'x' as i32);
7646        assert_eq!(
7647            excs.load(SeqCst),
7648            12,
7649            "c:406 — excs += 1 + inbufct - exlast (10+1+3-2=12)"
7650        );
7651
7652        // c:407-410 — clamp branch: post-add excs < zlemetacs.
7653        // Set excs=6, zlemetacs=20, inbufct=1, exlast=10.
7654        // new_excs = 6 + 1 + 1 - 10 = -2; -2 < 20 → clamp to 20.
7655        excs.store(6, SeqCst);
7656        // But guard: excs(6) > zlemetacs(20) is FALSE, so block
7657        // wouldn't fire. Need excs > zlemetacs for entry: use
7658        // excs=25, zlemetacs=20, inbufct=1, exlast=10 →
7659        // new_excs = 25+1+1-10 = 17; 17 < 20 → clamp to 20.
7660        excs.store(25, SeqCst);
7661        ZLEMETACS.store(20, SeqCst);
7662        crate::ported::input::inbufct.with(|c| c.set(1));
7663        exlast.store(10, SeqCst);
7664        iaddtoline(b'y' as i32);
7665        assert_eq!(
7666            excs.load(SeqCst),
7667            20,
7668            "c:407-410 — clamp to zlemetacs(20) when post-add excs<zlemetacs"
7669        );
7670
7671        // No adjustment when excs <= zlemetacs.
7672        excs.store(3, SeqCst);
7673        ZLEMETACS.store(10, SeqCst);
7674        crate::ported::input::inbufct.with(|c| c.set(1));
7675        exlast.store(0, SeqCst);
7676        iaddtoline(b'z' as i32);
7677        assert_eq!(
7678            excs.load(SeqCst),
7679            3,
7680            "c:405 — excs<=zlemetacs leaves excs unchanged"
7681        );
7682
7683        // Restore.
7684        *chline.lock().unwrap() = saved_chline;
7685        excs.store(saved_excs, SeqCst);
7686        ZLEMETACS.store(saved_zlemetacs, SeqCst);
7687        expanding.store(saved_expanding, SeqCst);
7688        lexstop.store(saved_lexstop, SeqCst);
7689        qbang.store(saved_qbang, SeqCst);
7690        exlast.store(saved_exlast, SeqCst);
7691        *crate::ported::zle::compcore::ZLEMETALINE
7692            .get_or_init(|| Mutex::new(String::new()))
7693            .lock()
7694            .unwrap() = saved_metaline;
7695        crate::ported::zle::compcore::ZLEMETALL.store(saved_metall, SeqCst);
7696    }
7697
7698    /// Pin `ihwbegin` to its canonical C body at `Src/hist.c:1656-1670`.
7699    /// The C computes `pos = hptr - chline + offset` — the byte
7700    /// offset of the current write head in chline plus a caller-
7701    /// supplied offset. The previous Rust port used chline.len()
7702    /// which is only equal when hptr is at end-of-buffer; any
7703    /// lexer rewind (backquote, comment, here-doc) would record
7704    /// wrong word offsets.
7705    #[test]
7706    fn ihwbegin_records_hptr_not_chline_len() {
7707        let _g = crate::test_util::global_state_lock();
7708        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7709        // Save state.
7710        let saved_chline = chline.lock().unwrap().clone();
7711        let saved_chwords = chwords.lock().unwrap().clone();
7712        let saved_chwordpos = chwordpos.load(SeqCst);
7713        let saved_hptr = hptr.load(SeqCst);
7714        let saved_stop = stophist.load(SeqCst);
7715        let saved_active = histactive.load(SeqCst);
7716        let saved_inflags = crate::ported::input::inbufflags.with(|f| f.get());
7717
7718        // Set up: chline is "ABCDEFGHIJ" (10 bytes), but hptr was
7719        // rewound to position 4 — simulating a lexer that backtracked.
7720        // C would record pos=4 (hptr-chline=4 + offset=0); the buggy
7721        // Rust port would record pos=10 (chline.len()).
7722        *chline.lock().unwrap() = "ABCDEFGHIJ".to_string();
7723        chwords.lock().unwrap().clear();
7724        chwordpos.store(0, SeqCst);
7725        hptr.store(4, SeqCst);
7726        stophist.store(0, SeqCst);
7727        histactive.store(0, SeqCst); // !HA_INWORD
7728        crate::ported::input::inbufflags.with(|f| f.set(0)); // not alias-only
7729
7730        ihwbegin(0);
7731
7732        let recorded = chwords.lock().unwrap().first().copied().unwrap_or(-1);
7733        assert_eq!(
7734            recorded, 4,
7735            "c:1658 — pos = hptr - chline + offset = 4 + 0 = 4 \
7736             (NOT chline.len()=10)"
7737        );
7738
7739        // Negative offset clamps to 0 (c:1666).
7740        chwords.lock().unwrap().clear();
7741        chwordpos.store(0, SeqCst);
7742        hptr.store(3, SeqCst);
7743        ihwbegin(-10); // pos = 3-10 = -7 → clamp to 0
7744        let recorded = chwords.lock().unwrap().first().copied().unwrap_or(-1);
7745        assert_eq!(recorded, 0, "c:1666 — pos<0 clamps to 0");
7746
7747        // Stop guard (c:1659) — `stophist == 2` short-circuits.
7748        chwords.lock().unwrap().clear();
7749        chwordpos.store(0, SeqCst);
7750        hptr.store(5, SeqCst);
7751        stophist.store(2, SeqCst);
7752        ihwbegin(0);
7753        assert!(
7754            chwords.lock().unwrap().is_empty(),
7755            "c:1659 — stophist==2 short-circuits, no record"
7756        );
7757        stophist.store(0, SeqCst);
7758
7759        // Alias-only guard (c:1659).
7760        chwords.lock().unwrap().clear();
7761        chwordpos.store(0, SeqCst);
7762        hptr.store(5, SeqCst);
7763        crate::ported::input::inbufflags.with(|f| f.set(INP_ALIAS));
7764        ihwbegin(0);
7765        assert!(
7766            chwords.lock().unwrap().is_empty(),
7767            "c:1659 — alias-only (INP_ALIAS without INP_HIST) short-circuits"
7768        );
7769
7770        // INP_ALIAS|INP_HIST does NOT short-circuit (mixed input).
7771        chwords.lock().unwrap().clear();
7772        chwordpos.store(0, SeqCst);
7773        hptr.store(7, SeqCst);
7774        crate::ported::input::inbufflags.with(|f| f.set(INP_ALIAS | INP_HIST));
7775        ihwbegin(0);
7776        let recorded = chwords.lock().unwrap().first().copied().unwrap_or(-1);
7777        assert_eq!(recorded, 7, "c:1659 — alias+hist mixed still records");
7778
7779        // Restore.
7780        *chline.lock().unwrap() = saved_chline;
7781        *chwords.lock().unwrap() = saved_chwords;
7782        chwordpos.store(saved_chwordpos, SeqCst);
7783        hptr.store(saved_hptr, SeqCst);
7784        stophist.store(saved_stop, SeqCst);
7785        histactive.store(saved_active, SeqCst);
7786        crate::ported::input::inbufflags.with(|f| f.set(saved_inflags));
7787    }
7788
7789    /// Pin `getargs` to its canonical C body at `Src/hist.c:2454-2482`.
7790    /// Covers: nwords-derived-from-field (not words.len()/2),
7791    /// arg1>arg2 reject, arg≥nwords reject, full-event fast path,
7792    /// per-word slicing, and signed-short overflow detection.
7793    #[test]
7794    fn getargs_handles_field_indexing_and_overflow() {
7795        let _g = crate::test_util::global_state_lock();
7796        // Build a histent for "echo hello world" with 3 words.
7797        // C nwords=3, words=[0,4,5,10,11,16] (start/end pairs).
7798        let he = histent {
7799            node: hashnode {
7800                next: None,
7801                nam: "echo hello world".to_string(),
7802                flags: 0,
7803            },
7804            up: None,
7805            down: None,
7806            zle_text: None,
7807            stim: 0,
7808            ftim: 0,
7809            words: vec![0, 4, 5, 10, 11, 16],
7810            nwords: 3, // c:2457 source of truth
7811            histnum: 1,
7812        };
7813
7814        // c:2459 — `arg2 < arg1` → reject.
7815        assert_eq!(getargs(&he, 2, 1), None, "c:2459 — arg2 < arg1 rejects");
7816        // c:2459 — `arg1 >= nwords` → reject.
7817        assert_eq!(
7818            getargs(&he, 3, 3),
7819            None,
7820            "c:2459 — arg1 >= nwords (3>=3) rejects"
7821        );
7822        // c:2459 — `arg2 >= nwords` → reject.
7823        assert_eq!(
7824            getargs(&he, 0, 3),
7825            None,
7826            "c:2459 — arg2 >= nwords (3>=3) rejects"
7827        );
7828
7829        // c:2466 — `arg1==0 && arg2==nwords-1` → full event fast path.
7830        assert_eq!(
7831            getargs(&he, 0, 2).as_deref(),
7832            Some("echo hello world"),
7833            "c:2467 — full-event fast path returns dupstring(nam)"
7834        );
7835
7836        // c:2469-2481 — per-word slice. word[0] = "echo" (pos 0..4).
7837        assert_eq!(
7838            getargs(&he, 0, 0).as_deref(),
7839            Some("echo"),
7840            "c:2481 — word[0] = nam[0..4]"
7841        );
7842        // word[1] = "hello" (pos 5..10).
7843        assert_eq!(
7844            getargs(&he, 1, 1).as_deref(),
7845            Some("hello"),
7846            "c:2481 — word[1] = nam[5..10]"
7847        );
7848        // word[2] = "world" (pos 11..16).
7849        assert_eq!(
7850            getargs(&he, 2, 2).as_deref(),
7851            Some("world"),
7852            "c:2481 — word[2] = nam[11..16]"
7853        );
7854        // Multi-word span: word[1..=2] = "hello world".
7855        assert_eq!(
7856            getargs(&he, 1, 2).as_deref(),
7857            Some("hello world"),
7858            "c:2481 — words[1..=2] = nam[5..16]"
7859        );
7860
7861        // c:2476 — signed-short overflow detection. Build a histent
7862        // whose stored pos[0] is negative (simulating i16 wrap on a
7863        // >32KB history line). Use nwords=2 with arg1=0,arg2=0 so the
7864        // c:2466 full-event fast path doesn't trigger.
7865        let overflow = histent {
7866            node: hashnode {
7867                next: None,
7868                nam: "ab cd".to_string(),
7869                flags: 0,
7870            },
7871            up: None,
7872            down: None,
7873            zle_text: None,
7874            stim: 0,
7875            ftim: 0,
7876            words: vec![-1, 5, 3, 5], // word[0]: pos1 < 0
7877            nwords: 2,
7878            histnum: 1,
7879        };
7880        assert_eq!(
7881            getargs(&overflow, 0, 0),
7882            None,
7883            "c:2476 — pos1 < 0 (i16 overflow) rejects"
7884        );
7885
7886        // c:2476 — `pos1 < arg1` detection (pos must be ≥ word index).
7887        let underflow = histent {
7888            node: hashnode {
7889                next: None,
7890                nam: "a b c d".to_string(),
7891                flags: 0,
7892            },
7893            up: None,
7894            down: None,
7895            zle_text: None,
7896            stim: 0,
7897            ftim: 0,
7898            // arg1=2 but pos1=1 means recorded pos < word index.
7899            // Each word must be ≥1 char so word[2] must start at pos ≥ 2.
7900            words: vec![0, 1, 2, 3, 1, 5, 6, 7],
7901            nwords: 4,
7902            histnum: 1,
7903        };
7904        assert_eq!(
7905            getargs(&underflow, 2, 2),
7906            None,
7907            "c:2476 — pos1 < arg1 (i16 overflow signal) rejects"
7908        );
7909    }
7910
7911    /// Pin `hconsearch` to its canonical C body at
7912    /// `Src/hist.c:1834-1854`. Searches up from the ring for the
7913    /// most recent entry whose text contains `needle` as a substring,
7914    /// returning `(histnum, marg)` where marg is the word index of
7915    /// the match. The previous Rust port dropped marg entirely.
7916    #[test]
7917    fn hconsearch_returns_histnum_and_word_index() {
7918        let _g = crate::test_util::global_state_lock();
7919        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
7920        // Save ring state.
7921        let saved_ring = {
7922            let r = hist_ring.lock().unwrap();
7923            r.iter()
7924                .map(|h| {
7925                    (
7926                        h.node.nam.clone(),
7927                        h.histnum,
7928                        h.words.clone(),
7929                        h.nwords,
7930                        h.node.flags,
7931                    )
7932                })
7933                .collect::<Vec<_>>()
7934        };
7935        let saved_curhist = curhist.load(SeqCst);
7936
7937        // Build a single-entry ring with "echo hello world" — 3 words.
7938        // words = [start1,end1, start2,end2, start3,end3]
7939        //          0    4       5    10      11   16
7940        {
7941            let mut ring = hist_ring.lock().unwrap();
7942            ring.clear();
7943            ring.push(histent {
7944                node: hashnode {
7945                    next: None,
7946                    nam: "echo hello world".to_string(),
7947                    flags: 0,
7948                },
7949                up: None,
7950                down: None,
7951                zle_text: None,
7952                stim: 0,
7953                ftim: 0,
7954                words: vec![0, 4, 5, 10, 11, 16],
7955                nwords: 3,
7956                histnum: 7,
7957            });
7958        }
7959        curhist.store(8, SeqCst); // up_histent will walk back to 7
7960
7961        // "hello" found at pos 5 → word index 1 (0-based).
7962        let got = hconsearch("hello");
7963        assert_eq!(
7964            got,
7965            Some((7, 1)),
7966            "c:1846-1850 — strstr at pos 5 lands in word[1] (start=5)"
7967        );
7968
7969        // "world" found at pos 11 → word index 2.
7970        let got = hconsearch("world");
7971        assert_eq!(
7972            got,
7973            Some((7, 2)),
7974            "c:1846-1850 — strstr at pos 11 lands in word[2] (start=11)"
7975        );
7976
7977        // "echo" found at pos 0 → word index 0.
7978        let got = hconsearch("echo");
7979        assert_eq!(
7980            got,
7981            Some((7, 0)),
7982            "c:1846-1850 — strstr at pos 0 lands in word[0]"
7983        );
7984
7985        // Miss → None (c:1853 return -1).
7986        let got = hconsearch("notthere");
7987        assert_eq!(got, None, "c:1853 — miss returns -1 / None");
7988
7989        // HIST_FOREIGN entries are skipped (c:1843).
7990        {
7991            let mut ring = hist_ring.lock().unwrap();
7992            ring.clear();
7993            ring.push(histent {
7994                node: hashnode {
7995                    next: None,
7996                    nam: "skip me".to_string(),
7997                    flags: HIST_FOREIGN as i32,
7998                },
7999                up: None,
8000                down: None,
8001                zle_text: None,
8002                stim: 0,
8003                ftim: 0,
8004                words: vec![0, 4, 5, 7],
8005                nwords: 2,
8006                histnum: 3,
8007            });
8008        }
8009        let got = hconsearch("skip");
8010        assert_eq!(
8011            got, None,
8012            "c:1843-1844 — HIST_FOREIGN entries continue past, miss → None"
8013        );
8014
8015        // Restore ring.
8016        {
8017            let mut ring = hist_ring.lock().unwrap();
8018            ring.clear();
8019            for (nam, histnum, words, nwords, flags) in saved_ring {
8020                ring.push(histent {
8021                    node: hashnode {
8022                        next: None,
8023                        nam,
8024                        flags,
8025                    },
8026                    up: None,
8027                    down: None,
8028                    zle_text: None,
8029                    stim: 0,
8030                    ftim: 0,
8031                    words,
8032                    nwords,
8033                    histnum,
8034                });
8035            }
8036        }
8037        curhist.store(saved_curhist, SeqCst);
8038    }
8039
8040    /// Pin `checkcurline` to the canonical C body at
8041    /// `Src/hist.c:2421-2429`: when `he.histnum == curhist` AND
8042    /// `histactive & HA_ACTIVE`, flush chline/chwordpos/chwords
8043    /// into `curline`. Both gates MUST be true; otherwise leave
8044    /// `curline` untouched.
8045    #[test]
8046    fn checkcurline_flushes_to_curline_only_when_active_and_matching() {
8047        let _g = crate::test_util::global_state_lock();
8048        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
8049        let saved_curhist = curhist.load(SeqCst);
8050        let saved_active = histactive.load(SeqCst);
8051        let saved_chline = chline.lock().unwrap().clone();
8052        let saved_chwordpos = chwordpos.load(SeqCst);
8053        let saved_chwords = chwords.lock().unwrap().clone();
8054        let saved_curline = curline.lock().unwrap().take();
8055
8056        // Set up in-flight build state.
8057        curhist.store(42, SeqCst);
8058        histactive.store(HA_ACTIVE, SeqCst);
8059        *chline.lock().unwrap() = "echo hello".to_string();
8060        chwordpos.store(4, SeqCst); // 2 words
8061        *chwords.lock().unwrap() = vec![0, 4, 5, 10];
8062        *curline.lock().unwrap() = None;
8063
8064        // Case 1: matching histnum + active → flushes.
8065        let he = histent {
8066            node: hashnode {
8067                next: None,
8068                nam: "ignored-by-checkcurline".to_string(),
8069                flags: 0,
8070            },
8071            up: None,
8072            down: None,
8073            zle_text: None,
8074            stim: 0,
8075            ftim: 0,
8076            words: vec![],
8077            nwords: 0,
8078            histnum: 42,
8079        };
8080        checkcurline(&he);
8081        {
8082            let cl = curline.lock().unwrap();
8083            let snap = cl
8084                .as_ref()
8085                .expect("c:2425-2427 — matching+active must flush a snapshot");
8086            assert_eq!(
8087                snap.node.nam, "echo hello",
8088                "c:2425 — curline.node.nam = chline"
8089            );
8090            assert_eq!(
8091                snap.nwords, 2,
8092                "c:2426 — curline.nwords = chwordpos/2 (4/2=2)"
8093            );
8094            assert_eq!(
8095                snap.words,
8096                vec![0, 4, 5, 10],
8097                "c:2427 — curline.words = chwords"
8098            );
8099        }
8100
8101        // Case 2: matching histnum but NOT active → no flush.
8102        histactive.store(0, SeqCst); // c:2424 HA_ACTIVE off
8103        *curline.lock().unwrap() = None;
8104        checkcurline(&he);
8105        assert!(
8106            curline.lock().unwrap().is_none(),
8107            "c:2424 — HA_ACTIVE cleared, no flush"
8108        );
8109
8110        // Case 3: active but mismatched histnum → no flush.
8111        histactive.store(HA_ACTIVE, SeqCst);
8112        let he2 = histent {
8113            histnum: 99,
8114            ..histent {
8115                node: hashnode {
8116                    next: None,
8117                    nam: String::new(),
8118                    flags: 0,
8119                },
8120                up: None,
8121                down: None,
8122                zle_text: None,
8123                stim: 0,
8124                ftim: 0,
8125                words: vec![],
8126                nwords: 0,
8127                histnum: 0,
8128            }
8129        };
8130        checkcurline(&he2);
8131        assert!(
8132            curline.lock().unwrap().is_none(),
8133            "c:2424 — histnum mismatch, no flush"
8134        );
8135
8136        // Restore.
8137        curhist.store(saved_curhist, SeqCst);
8138        histactive.store(saved_active, SeqCst);
8139        *chline.lock().unwrap() = saved_chline;
8140        chwordpos.store(saved_chwordpos, SeqCst);
8141        *chwords.lock().unwrap() = saved_chwords;
8142        *curline.lock().unwrap() = saved_curline;
8143    }
8144
8145    /// `Src/hist.c:2172-2175` — `remlpaths` when count exceeds the
8146    /// number of path components returns the input UNMODIFIED
8147    /// (preserving the leading slash for absolute paths). The previous
8148    /// Rust port silently stripped the leading slash via the
8149    /// `parts.iter().rev().take(min(n, parts.len()))` clamp, so `:t4`
8150    /// on `/a/b/c` returned `"a/b/c"` instead of `"/a/b/c"`. Catches
8151    /// this divergence — the C `return 1` early-exit at the leftmost
8152    /// `/` does NOT modify `*junkptr`.
8153    #[test]
8154    fn remlpaths_count_exceeds_components_preserves_leading_slash() {
8155        let _g = crate::test_util::global_state_lock();
8156        // 4 > 3 components in "/a/b/c" → return whole string verbatim.
8157        assert_eq!(
8158            remlpaths("/a/b/c", 4),
8159            "/a/b/c",
8160            "c:2172-2175 — count > components → preserve original (leading slash)"
8161        );
8162        assert_eq!(remlpaths("/a/b/c", 10), "/a/b/c");
8163        // Relative path: no leading slash, but still preserved.
8164        assert_eq!(remlpaths("a/b/c", 99), "a/b/c");
8165    }
8166
8167    /// `Src/hist.c:2156-2161` — `remlpaths` trims trailing slashes off
8168    /// the input before walking. Pin so `:t1` on `/a/b/c/` (trailing
8169    /// slash) still returns `"c"`, not `""`.
8170    #[test]
8171    fn remlpaths_trims_trailing_slashes() {
8172        let _g = crate::test_util::global_state_lock();
8173        assert_eq!(
8174            remlpaths("/a/b/c/", 1),
8175            "c",
8176            "c:2156-2161 — trailing slash trimmed before scan"
8177        );
8178        assert_eq!(remlpaths("/a/b/c///", 1), "c");
8179        assert_eq!(
8180            remlpaths("/", 1),
8181            "",
8182            "all-slashes input → empty after trim → empty result"
8183        );
8184        assert_eq!(remlpaths("", 1), "");
8185    }
8186
8187    /// `Src/hist.c:2152` — `remlpaths` with `count == 0` is a special
8188    /// case: per `digitcount()` at c:574, bare `:t` (no number) passes
8189    /// 0 which means "default to 1". The Rust port preserves that
8190    /// alias so `${PWD:t}` keeps the last component.
8191    #[test]
8192    fn remlpaths_count_zero_defaults_to_one() {
8193        let _g = crate::test_util::global_state_lock();
8194        assert_eq!(
8195            remlpaths("/a/b/c", 0),
8196            "c",
8197            "c:574 — count=0 from digitcount aliases to default 1"
8198        );
8199        // Same as explicit count=1.
8200        assert_eq!(remlpaths("/a/b/c", 0), remlpaths("/a/b/c", 1));
8201    }
8202
8203    /// `Src/hist.c:2225-2228` — CASMOD_LOWER converts uppercase chars
8204    /// to lowercase, leaves everything else unchanged.
8205    #[test]
8206    fn casemodify_lower_lowercases_uppercase_only() {
8207        let _g = crate::test_util::global_state_lock();
8208        assert_eq!(
8209            casemodify("HELLO", CASMOD_LOWER),
8210            "hello",
8211            "c:2226-2228 — uppercase → lowercase"
8212        );
8213        // Already lowercase: no change.
8214        assert_eq!(casemodify("hello", CASMOD_LOWER), "hello");
8215        // Digits + punct: untouched.
8216        assert_eq!(casemodify("123 !? abc", CASMOD_LOWER), "123 !? abc");
8217        // Unicode: 'É' (U+00C9) lowercases to 'é'.
8218        assert_eq!(casemodify("ÉLITE", CASMOD_LOWER), "élite");
8219    }
8220
8221    /// `Src/hist.c:2232-2236` — CASMOD_UPPER converts lowercase chars
8222    /// to uppercase, leaves everything else unchanged.
8223    #[test]
8224    fn casemodify_upper_uppercases_lowercase_only() {
8225        let _g = crate::test_util::global_state_lock();
8226        assert_eq!(
8227            casemodify("hello", CASMOD_UPPER),
8228            "HELLO",
8229            "c:2233-2236 — lowercase → uppercase"
8230        );
8231        assert_eq!(casemodify("HELLO", CASMOD_UPPER), "HELLO");
8232        assert_eq!(casemodify("élite", CASMOD_UPPER), "ÉLITE");
8233    }
8234
8235    /// `Src/hist.c:2239-2254` — CASMOD_CAPS title-cases at word
8236    /// boundaries (defined by `!iswalnum` chars).
8237    #[test]
8238    fn casemodify_caps_title_cases_at_word_boundaries() {
8239        let _g = crate::test_util::global_state_lock();
8240        // c:2245-2250 — first letter and post-space letter uppercase;
8241        // mid-word letters lowercase (c:2251-2253).
8242        assert_eq!(casemodify("hello world", CASMOD_CAPS), "Hello World");
8243        // c:2243-2244 — non-alphanumeric resets nextupper.
8244        assert_eq!(casemodify("foo-bar.baz", CASMOD_CAPS), "Foo-Bar.Baz");
8245        // Already capped: mid-word `LO` → `lo`.
8246        assert_eq!(casemodify("HELLO", CASMOD_CAPS), "Hello");
8247    }
8248
8249    /// `Src/hist.c:2241-2242` — `IS_COMBINING(wc)` (WCWIDTH == 0)
8250    /// makes CASMOD_CAPS short-circuit BEFORE touching nextupper.
8251    /// The previous Rust port omitted this guard — combining marks
8252    /// were classified as non-alphanumeric and reset nextupper,
8253    /// breaking word-boundary detection on accented words written
8254    /// as base char + combining mark.
8255    ///
8256    /// Test input: `"a" + COMBINING ACUTE (U+0301) + "b"`. With the
8257    /// guard: combining mark passes through, b stays lowercase
8258    /// (still inside the word). Without the guard: the combiner
8259    /// resets nextupper, so `b` would be uppercased.
8260    /// A titlecase letter folds as the platform libc says, like zsh: glibc
8261    /// under a UTF-8 codeset has `iswupper`/`iswlower` both true for `Dž`
8262    /// (U+01C5), so it folds in either direction (fuzz seeds 7/97/102/242/244);
8263    /// macOS libc, and any C/ASCII codeset, leave it unchanged.
8264    #[test]
8265    fn casemodify_folds_titlecase_both_ways() {
8266        let _ = *crate::ported::utils::MB_LOCALE_READY;
8267        // SAFETY: nl_langinfo returns a pointer to a static NUL-terminated string.
8268        let codeset = unsafe { std::ffi::CStr::from_ptr(libc::nl_langinfo(libc::CODESET)) }
8269            .to_string_lossy()
8270            .to_ascii_uppercase();
8271        let glibc_utf8 =
8272            cfg!(all(target_os = "linux", target_env = "gnu")) && codeset.replace('-', "") == "UTF8";
8273        let (upper, lower) = if glibc_utf8 {
8274            ("\u{1c4}", "\u{1c6}")
8275        } else {
8276            ("\u{1c5}", "\u{1c5}")
8277        };
8278        assert_eq!(casemodify("\u{1c5}", CASMOD_UPPER), upper);
8279        assert_eq!(casemodify("\u{1c5}", CASMOD_LOWER), lower);
8280    }
8281
8282    #[test]
8283    fn casemodify_caps_skips_combining_chars() {
8284        let _g = crate::test_util::global_state_lock();
8285        // a + combining acute (U+0301) + b — under CAPS, the
8286        // combiner must NOT reset nextupper. Expected output:
8287        // `A` + combining acute + `b` (b stays lowercase because
8288        // it's still mid-word).
8289        let input = "a\u{0301}b";
8290        let got = casemodify(input, CASMOD_CAPS);
8291        let expected = "A\u{0301}b";
8292        assert_eq!(
8293            got, expected,
8294            "c:2241-2242 — IS_COMBINING short-circuit must not break word boundary"
8295        );
8296    }
8297
8298    /// `Src/hist.c:2349` — anchor-head matcher checks BOTH ASCII `#`
8299    /// AND the tokenized `Pound` byte (0x84), and c:2344 gates the
8300    /// whole block behind `HIST_SUBST_PATTERN || forcepat`. These run
8301    /// with `forcepat = 1`, which is what `:S` passes.
8302    #[test]
8303    fn subst_anchor_head_recognises_pound_token() {
8304        let _g = crate::test_util::global_state_lock();
8305        // c:2349 — ASCII '#' anchor at head: matches only prefix.
8306        let mut s = String::from("foobar");
8307        assert_eq!(subst(&mut s, "#foo", "baz", 0, 1), 0);
8308        assert_eq!(s, "bazbar", "c:2349 ASCII '#' anchor — match at head");
8309        // c:2349 — Pound token (0x84) anchor at head: same effect.
8310        let pat = format!("{}foo", Pound);
8311        let mut s = String::from("foobar");
8312        assert_eq!(subst(&mut s, &pat, "baz", 0, 1), 0);
8313        assert_eq!(
8314            s, "bazbar",
8315            "c:2349 Pound token (0x84) anchor — match at head"
8316        );
8317        // Negative: anchored at head MUST NOT match mid-string, and
8318        // c:2390 reports that as status 1 rather than a quiet no-op.
8319        let pat = format!("{}foo", Pound);
8320        let mut s = String::from("xfoo");
8321        assert_eq!(
8322            subst(&mut s, &pat, "baz", 0, 1),
8323            1,
8324            "c:2390 anchor-head rejects non-prefix"
8325        );
8326        assert_eq!(s, "xfoo");
8327    }
8328
8329    /// `Src/hist.c:2354` — anchor-tail matcher checks `%` only
8330    /// (no tokenized counterpart in C).
8331    #[test]
8332    fn subst_anchor_tail_matches_only_suffix() {
8333        let _g = crate::test_util::global_state_lock();
8334        // c:2354 — `%foo` anchored at tail.
8335        let mut s = String::from("xxfoo");
8336        assert_eq!(subst(&mut s, "%foo", "bar", 0, 1), 0);
8337        assert_eq!(s, "xxbar", "c:2354 — '%' anchors at end of string");
8338        // Non-suffix → no match, status 1.
8339        let mut s = String::from("foox");
8340        assert_eq!(
8341            subst(&mut s, "%foo", "bar", 0, 1),
8342            1,
8343            "c:2354 — '%foo' must not match unless `foo` is at end"
8344        );
8345        assert_eq!(s, "foox");
8346    }
8347
8348    /// c:2344 — with neither `HIST_SUBST_PATTERN` nor `forcepat`, C
8349    /// never reads `#` or `%` as anchors: the `strstr` arm at c:2373
8350    /// takes the pattern literally. The previous port stripped them
8351    /// unconditionally, so `!!:s/#print/echo/` substituted where zsh
8352    /// reports `substitution failed`.
8353    #[test]
8354    fn subst_anchors_are_literal_without_pattern_mode() {
8355        let _g = crate::test_util::global_state_lock();
8356        let mut s = String::from("foobar");
8357        assert_eq!(
8358            subst(&mut s, "#foo", "baz", 0, 0),
8359            1,
8360            "c:2373 — `#foo` is the literal four characters under plain :s"
8361        );
8362        assert_eq!(s, "foobar");
8363
8364        let mut s = String::from("a#foob");
8365        assert_eq!(subst(&mut s, "#foo", "X", 0, 0), 0);
8366        assert_eq!(s, "aXb", "c:2373 — and it matches where it literally is");
8367    }
8368
8369    /// c:2386 vs c:2390 — the status says whether a substitution
8370    /// HAPPENED, which is not the same question as whether the text
8371    /// changed. `^old^old` replaces `old` with `old`: C returns 0 and
8372    /// the line runs. Deciding by comparing the strings turns that
8373    /// into `substitution failed`.
8374    #[test]
8375    fn subst_reports_success_when_the_replacement_equals_the_pattern() {
8376        let _g = crate::test_util::global_state_lock();
8377        let mut s = String::from("print old xx");
8378        assert_eq!(
8379            subst(&mut s, "old", "old", 0, 0),
8380            0,
8381            "c:2386 — a match is a success even with an identical replacement"
8382        );
8383        assert_eq!(s, "print old xx");
8384
8385        // The genuine no-match still fails.
8386        let mut s = String::from("print old xx");
8387        assert_eq!(
8388            subst(&mut s, "zzz", "q", 0, 0),
8389            1,
8390            "c:2390 — no match is the only failure"
8391        );
8392        assert_eq!(s, "print old xx");
8393    }
8394
8395    /// c:2378-2384 — `gbal` keeps scanning from just past the text it
8396    /// wrote in, so a replacement containing the pattern is not
8397    /// re-scanned and cannot loop.
8398    #[test]
8399    fn subst_global_does_not_rescan_its_own_replacement() {
8400        let _g = crate::test_util::global_state_lock();
8401        let mut s = String::from("aXbXc");
8402        assert_eq!(subst(&mut s, "X", "XX", 1, 0), 0);
8403        assert_eq!(s, "aXXbXXc", "c:2383 — resume after the replacement");
8404    }
8405
8406    /// c:2375 — `&` in the replacement stands for the pattern, but
8407    /// only on the `strstr` arm. c:2367 passes `out` straight through
8408    /// on the pattern arm, where `&` is an ordinary character.
8409    #[test]
8410    fn subst_ampersand_is_literal_in_pattern_mode() {
8411        let _g = crate::test_util::global_state_lock();
8412        let mut s = String::from("print old xx");
8413        assert_eq!(subst(&mut s, "old", "&X", 0, 0), 0);
8414        assert_eq!(s, "print oldX xx", "c:2375 — convamps expands `&`");
8415
8416        let mut s = String::from("print old xx");
8417        assert_eq!(subst(&mut s, "old", "&X", 0, 1), 0);
8418        assert_eq!(
8419            s, "print &X xx",
8420            "c:2367 — no convamps on the pattern arm, `&` stays literal"
8421        );
8422    }
8423
8424    // ═══════════════════════════════════════════════════════════════════
8425    // histreduceblanks edge cases — pure function, anchored to C semantics
8426    // (c:50, c:1240): collapse runs of space/tab to single space; trim
8427    // leading and trailing space; PRESERVE embedded newlines/CRs.
8428    // ═══════════════════════════════════════════════════════════════════
8429
8430    /// Single space stays single.
8431    #[test]
8432    fn histreduceblanks_single_space_stays_one() {
8433        let _g = crate::test_util::global_state_lock();
8434        assert_eq!(reduce_line("a b"), "a b");
8435    }
8436
8437    /// Run of spaces collapses to one.
8438    #[test]
8439    fn histreduceblanks_multi_space_collapses_to_one() {
8440        let _g = crate::test_util::global_state_lock();
8441        assert_eq!(reduce_line("a     b"), "a b");
8442    }
8443
8444    /// Tab counts as inblank — collapses to single space.
8445    #[test]
8446    fn histreduceblanks_tab_collapses_with_spaces() {
8447        let _g = crate::test_util::global_state_lock();
8448        assert_eq!(reduce_line("a \t  b"), "a b");
8449    }
8450
8451    /// Leading whitespace trimmed.
8452    #[test]
8453    fn histreduceblanks_leading_whitespace_trimmed() {
8454        let _g = crate::test_util::global_state_lock();
8455        assert_eq!(reduce_line("   hello"), "hello");
8456    }
8457
8458    /// Trailing whitespace trimmed.
8459    #[test]
8460    fn histreduceblanks_trailing_whitespace_trimmed() {
8461        let _g = crate::test_util::global_state_lock();
8462        assert_eq!(reduce_line("hello   "), "hello");
8463    }
8464
8465    /// Both leading AND trailing trimmed.
8466    #[test]
8467    fn histreduceblanks_both_ends_trimmed() {
8468        let _g = crate::test_util::global_state_lock();
8469        assert_eq!(reduce_line("  hi  "), "hi");
8470    }
8471
8472    /// Empty input → empty output.
8473    #[test]
8474    fn histreduceblanks_empty_input_returns_empty() {
8475        let _g = crate::test_util::global_state_lock();
8476        assert_eq!(reduce_line(""), "");
8477    }
8478
8479    /// All-whitespace input → empty after trim.
8480    #[test]
8481    fn histreduceblanks_all_whitespace_becomes_empty() {
8482        let _g = crate::test_util::global_state_lock();
8483        assert_eq!(reduce_line("     "), "");
8484        assert_eq!(reduce_line("\t\t  \t"), "");
8485    }
8486
8487    /// Embedded newline is NOT treated as inblank — preserved as-is.
8488    /// (c:50 — `inblank` is space/tab ONLY; newline is preserved.)
8489    #[test]
8490    fn histreduceblanks_newline_preserved_not_collapsed() {
8491        let _g = crate::test_util::global_state_lock();
8492        // Newlines stay; surrounding spaces collapse normally.
8493        let r = reduce_line("a\nb");
8494        assert_eq!(r, "a\nb", "newline must be preserved");
8495    }
8496
8497    /// Multiple newlines stay; flanking spaces don't get consumed.
8498    #[test]
8499    fn histreduceblanks_multiple_newlines_preserved() {
8500        let _g = crate::test_util::global_state_lock();
8501        // Each newline is a recorded word; nothing between words to close.
8502        let r = reduce_blanks("a\n\nb", &[0, 1, 1, 2, 2, 3, 3, 4], false);
8503        assert_eq!(r, "a\n\nb");
8504    }
8505
8506    /// Space-newline-space — the spaces flanking the newline are not
8507    /// treated as a "run" because newline breaks continuity.
8508    #[test]
8509    fn histreduceblanks_space_around_newline_preserved() {
8510        let _g = crate::test_util::global_state_lock();
8511        // Words `a`, `\n`, `b` with one-byte gaps: already minimal (c:1211).
8512        let r = reduce_blanks("a \n b", &[0, 1, 2, 3, 4, 5], false);
8513        assert_eq!(r, "a \n b");
8514    }
8515
8516    /// Mixed: leading spaces dropped (no HIST_IGNORE_SPACE), each gap reduced
8517    /// to its first byte (c:1224), all-blank tail truncated (c:1243).
8518    #[test]
8519    fn histreduceblanks_complex_input_normalizes() {
8520        let _g = crate::test_util::global_state_lock();
8521        assert_eq!(reduce_line("   a   b\t\tc   "), "a b\tc");
8522    }
8523
8524    // ═══════════════════════════════════════════════════════════════════
8525    // histsplitwords edge cases — pure tokenizer pinning byte spans.
8526    // ═══════════════════════════════════════════════════════════════════
8527
8528    /// Single word, no whitespace.
8529    #[test]
8530    fn histsplitwords_single_word_returns_one_span() {
8531        let words = histsplitwords("echo", false);
8532        assert_eq!(words, vec![(0, 4)]);
8533    }
8534
8535    /// Empty input → no words.
8536    #[test]
8537    fn histsplitwords_empty_input_returns_no_words() {
8538        let words = histsplitwords("", false);
8539        assert!(words.is_empty(), "empty line has no words; got {words:?}");
8540    }
8541
8542    /// Only whitespace → no words.
8543    #[test]
8544    fn histsplitwords_only_whitespace_returns_no_words() {
8545        let words = histsplitwords("    ", false);
8546        assert!(
8547            words.is_empty(),
8548            "whitespace-only has no words; got {words:?}"
8549        );
8550    }
8551
8552    /// Leading whitespace doesn't create phantom first word at offset 0.
8553    #[test]
8554    fn histsplitwords_leading_whitespace_skipped_no_uselex() {
8555        // "   hi" — first word starts at offset 3.
8556        let words = histsplitwords("   hi", false);
8557        assert_eq!(words, vec![(3, 5)]);
8558    }
8559
8560    /// Tab-separated words.
8561    #[test]
8562    fn histsplitwords_tab_separator_works_like_space() {
8563        let words = histsplitwords("a\tb\tc", false);
8564        // 3 words, each one char long.
8565        assert_eq!(words.len(), 3);
8566        for (s, e) in &words {
8567            assert_eq!(e - s, 1, "each word is one char; got ({s},{e})");
8568        }
8569    }
8570
8571    /// Spans never overlap and never overflow the input length.
8572    #[test]
8573    fn histsplitwords_spans_well_formed() {
8574        let line = "alpha beta gamma";
8575        for use_lex in [false, true] {
8576            let words = histsplitwords(line, use_lex);
8577            for (s, e) in &words {
8578                assert!(
8579                    s < e && *e <= line.len(),
8580                    "bad span ({s},{e}) for line len {} use_lex={}",
8581                    line.len(),
8582                    use_lex
8583                );
8584            }
8585            // Spans must be monotonically advancing.
8586            for i in 1..words.len() {
8587                assert!(
8588                    words[i].0 >= words[i - 1].1,
8589                    "spans overlap: {:?} then {:?}",
8590                    words[i - 1],
8591                    words[i]
8592                );
8593            }
8594        }
8595    }
8596
8597    // ─── zsh-corpus pins for histreduceblanks ─────────────────────
8598
8599    /// Empty input → empty output.
8600    #[test]
8601    fn hist_corpus_histreduceblanks_empty_is_empty() {
8602        let _g = crate::test_util::global_state_lock();
8603        assert_eq!(reduce_line(""), "");
8604    }
8605
8606    /// All-spaces input collapses to nothing (trimmed).
8607    #[test]
8608    fn hist_corpus_histreduceblanks_all_spaces_to_empty() {
8609        let _g = crate::test_util::global_state_lock();
8610        assert_eq!(reduce_line("     "), "");
8611        assert_eq!(reduce_line("\t\t\t"), "");
8612        assert_eq!(reduce_line(" \t \t "), "");
8613    }
8614
8615    /// Single non-space char passes through.
8616    #[test]
8617    fn hist_corpus_histreduceblanks_single_char() {
8618        let _g = crate::test_util::global_state_lock();
8619        assert_eq!(reduce_line("x"), "x");
8620    }
8621
8622    /// Tab counts as inblank; mixed space+tab runs collapse to one space.
8623    #[test]
8624    fn hist_corpus_histreduceblanks_mixed_space_tab() {
8625        let _g = crate::test_util::global_state_lock();
8626        assert_eq!(reduce_line("a \t \t b"), "a b");
8627    }
8628
8629    /// Multibyte characters pass through unchanged.
8630    #[test]
8631    fn hist_corpus_histreduceblanks_multibyte_passthrough() {
8632        let _g = crate::test_util::global_state_lock();
8633        assert_eq!(reduce_line("日 本"), "日 本");
8634        assert_eq!(reduce_line("日   本"), "日 本");
8635    }
8636
8637    /// Newlines preserved with exact count. The lexer records each newline
8638    /// as a word of its own, so adjacent words leave no gap to close (zsh
8639    /// stores `{ print a<NL><NL><NL>print  b }` as
8640    /// `{ print a<NL><NL><NL>print b }`).
8641    #[test]
8642    fn hist_corpus_histreduceblanks_newlines_exact_count() {
8643        let _g = crate::test_util::global_state_lock();
8644        assert_eq!(
8645            reduce_blanks("a\n\n\nb", &[0, 1, 1, 2, 2, 3, 3, 4, 4, 5], false),
8646            "a\n\n\nb"
8647        );
8648    }
8649
8650    /// `hist_is_in_word` round-trips with `hist_in_word`.
8651    #[test]
8652    fn hist_corpus_in_word_round_trip() {
8653        let _g = crate::test_util::global_state_lock();
8654        let saved = hist_is_in_word();
8655        hist_in_word(1);
8656        assert_eq!(hist_is_in_word(), 1);
8657        hist_in_word(0);
8658        assert_eq!(hist_is_in_word(), 0);
8659        hist_in_word(saved);
8660    }
8661
8662    /// `substfailed` returns an i32 (zsh stores -1 when no subst
8663    /// has happened yet, 0 on success, 1 on fail). Pin: returns
8664    /// without panic.
8665    #[test]
8666    fn hist_corpus_substfailed_does_not_panic() {
8667        let _g = crate::test_util::global_state_lock();
8668        let _ = substfailed();
8669    }
8670
8671    // ═══════════════════════════════════════════════════════════════════
8672    // C-parity tests pinning Src/hist.c. Tests that capture KNOWN
8673    // ZSHRS BUGS use #[ignore = "ZSHRS BUG: …"].
8674    // ═══════════════════════════════════════════════════════════════════
8675
8676    /// `getargc(entry)` returns `entry.nwords - 1` (or 0 if nwords=0).
8677    /// C `Src/hist.c:556`:
8678    ///   `return ehist->nwords ? ehist->nwords-1 : 0;`
8679    /// Fixed: previously Rust returned plain `entry.nwords` (off-by-one).
8680    #[test]
8681    fn getargc_returns_nwords_minus_one() {
8682        let _g = crate::test_util::global_state_lock();
8683        // Build a fake histent with nwords=3. C returns 2; current
8684        // Rust returns 3.
8685        let entry = histent {
8686            node: crate::ported::zsh_h::hashnode {
8687                next: None,
8688                nam: String::new(),
8689                flags: 0,
8690            },
8691            down: None,
8692            up: None,
8693            zle_text: None,
8694            stim: 0,
8695            ftim: 0,
8696            words: Vec::new(),
8697            nwords: 3,
8698            histnum: 0,
8699        };
8700        assert_eq!(
8701            getargc(&entry),
8702            2,
8703            "C returns nwords-1=2 for nwords=3; Rust off-by-one bug returns 3"
8704        );
8705    }
8706
8707    /// `getargc(entry)` with nwords=0 returns 0 (per C ternary
8708    /// guard). Rust's off-by-one bug happens to return 0 here too
8709    /// (correct value); this test pins the corner case where Rust
8710    /// and C agree by accident.
8711    #[test]
8712    fn getargc_nwords_zero_returns_zero() {
8713        let _g = crate::test_util::global_state_lock();
8714        let entry = histent {
8715            node: crate::ported::zsh_h::hashnode {
8716                next: None,
8717                nam: String::new(),
8718                flags: 0,
8719            },
8720            down: None,
8721            up: None,
8722            zle_text: None,
8723            stim: 0,
8724            ftim: 0,
8725            words: Vec::new(),
8726            nwords: 0,
8727            histnum: 0,
8728        };
8729        assert_eq!(getargc(&entry), 0, "nwords=0 → 0 (both C and Rust)");
8730    }
8731
8732    /// `getargc(entry)` with nwords=1 — C returns 0 (1-1).
8733    /// Fixed: matches C semantics now.
8734    #[test]
8735    fn getargc_nwords_one_returns_zero() {
8736        let _g = crate::test_util::global_state_lock();
8737        let entry = histent {
8738            node: crate::ported::zsh_h::hashnode {
8739                next: None,
8740                nam: String::new(),
8741                flags: 0,
8742            },
8743            down: None,
8744            up: None,
8745            zle_text: None,
8746            stim: 0,
8747            ftim: 0,
8748            words: Vec::new(),
8749            nwords: 1,
8750            histnum: 0,
8751        };
8752        assert_eq!(getargc(&entry), 0, "nwords=1 → 0 (1-1) per C");
8753    }
8754
8755    // ═══════════════════════════════════════════════════════════════════
8756    // Additional C-parity tests for Src/hist.c histreduceblanks.
8757    // ═══════════════════════════════════════════════════════════════════
8758
8759    /// c:1240 — `histreduceblanks("")` returns empty.
8760    #[test]
8761    fn histreduceblanks_empty_returns_empty() {
8762        let _g = crate::test_util::global_state_lock();
8763        assert_eq!(reduce_line(""), "");
8764    }
8765
8766    /// c:1240 — `histreduceblanks` collapses multiple spaces to one.
8767    #[test]
8768    fn histreduceblanks_collapses_runs_of_spaces() {
8769        let _g = crate::test_util::global_state_lock();
8770        assert_eq!(reduce_line("a    b"), "a b");
8771        assert_eq!(reduce_line("a  b  c"), "a b c");
8772    }
8773
8774    /// c:1224 — a gap shrinks to the ONE byte that followed the word, so a
8775    /// tab separator stays a tab (zsh stores `a<TAB><TAB>b` as `a<TAB>b`).
8776    #[test]
8777    fn histreduceblanks_collapses_tabs_to_space() {
8778        let _g = crate::test_util::global_state_lock();
8779        assert_eq!(reduce_line("a\tb"), "a\tb", "single tab gap is already minimal");
8780        assert_eq!(reduce_line("a\t\t\tb"), "a\tb", "tab run → the first tab");
8781        assert_eq!(reduce_line("a \t \tb"), "a b", "mixed run → its first byte, a space");
8782    }
8783
8784    /// c:1240 — strips leading whitespace.
8785    #[test]
8786    fn histreduceblanks_strips_leading_whitespace() {
8787        let _g = crate::test_util::global_state_lock();
8788        assert_eq!(reduce_line("   abc"), "abc");
8789        assert_eq!(reduce_line("\t\tabc"), "abc");
8790    }
8791
8792    /// c:1240 — strips trailing whitespace.
8793    #[test]
8794    fn histreduceblanks_strips_trailing_whitespace() {
8795        let _g = crate::test_util::global_state_lock();
8796        assert_eq!(reduce_line("abc   "), "abc");
8797        assert_eq!(reduce_line("abc\t\t"), "abc");
8798    }
8799
8800    /// c:1240 — pure whitespace input returns empty.
8801    #[test]
8802    fn histreduceblanks_only_whitespace_returns_empty() {
8803        let _g = crate::test_util::global_state_lock();
8804        assert_eq!(reduce_line("   "), "");
8805        assert_eq!(reduce_line("\t\t\t"), "");
8806        assert_eq!(reduce_line(" \t \t "), "");
8807    }
8808
8809    /// c:1240 — `histreduceblanks` is idempotent.
8810    #[test]
8811    fn histreduceblanks_is_idempotent() {
8812        let _g = crate::test_util::global_state_lock();
8813        for input in &["a  b", "   foo", "x\ty\tz", "  hello world  "] {
8814            let once = reduce_line(input);
8815            let twice = reduce_line(&once);
8816            assert_eq!(once, twice, "must be idempotent on {:?}", input);
8817        }
8818    }
8819
8820    /// c:1240 — single space between words is preserved (already minimal).
8821    #[test]
8822    fn histreduceblanks_single_space_preserved() {
8823        let _g = crate::test_util::global_state_lock();
8824        assert_eq!(reduce_line("a b"), "a b");
8825        assert_eq!(reduce_line("hello world"), "hello world");
8826    }
8827
8828    /// c:50 — inblank is space/tab ONLY; newline is NOT collapsed.
8829    #[test]
8830    fn histreduceblanks_preserves_newlines() {
8831        let _g = crate::test_util::global_state_lock();
8832        // Newline is not inblank → preserved (and bordering chars too).
8833        let r = reduce_line("a\nb");
8834        assert!(r.contains('\n'), "newline preserved in {:?}", r);
8835    }
8836
8837    /// `histremovedups()` is safe on an empty hist_ring.
8838    #[test]
8839    fn histremovedups_empty_ring_is_safe() {
8840        let _g = crate::test_util::global_state_lock();
8841        hist_ring.lock().unwrap().clear();
8842        histremovedups();
8843        // No panic = pass; ring stays empty.
8844        assert!(hist_ring.lock().unwrap().is_empty());
8845    }
8846
8847    /// `getargc(entry)` for nwords=5 returns 4 per C `nwords - 1`.
8848    /// Pins post-fix C-correct semantics.
8849    #[test]
8850    fn getargc_returns_nwords_minus_one_for_five() {
8851        let _g = crate::test_util::global_state_lock();
8852        let entry = histent {
8853            node: crate::ported::zsh_h::hashnode {
8854                next: None,
8855                nam: String::new(),
8856                flags: 0,
8857            },
8858            down: None,
8859            up: None,
8860            zle_text: None,
8861            stim: 0,
8862            ftim: 0,
8863            words: Vec::new(),
8864            nwords: 5,
8865            histnum: 0,
8866        };
8867        assert_eq!(getargc(&entry), 4, "C: 5-1 = 4");
8868    }
8869
8870    // ═══════════════════════════════════════════════════════════════════
8871    // Additional C-parity tests for Src/hist.c
8872    // c:141 hist_in_word / c:150 hist_is_in_word / c:1155 herrflush /
8873    // c:1221 substfailed / c:1255 digitcount / c:1549 histreduceblanks /
8874    // c:1611 addhistnum / c:1293 strinbeg / c:1313 strinend
8875    // ═══════════════════════════════════════════════════════════════════
8876
8877    /// c:141 + c:150 — `hist_in_word(N)` then `hist_is_in_word()` round-trips.
8878    #[test]
8879    fn hist_in_word_set_get_round_trip() {
8880        let _g = crate::test_util::global_state_lock();
8881        let saved = hist_is_in_word();
8882        hist_in_word(1);
8883        assert_eq!(hist_is_in_word(), 1, "set/get round-trips");
8884        hist_in_word(0);
8885        assert_eq!(hist_is_in_word(), 0, "set/get round-trips back");
8886        hist_in_word(saved);
8887    }
8888
8889    /// c:1155 — `herrflush` is idempotent.
8890    #[test]
8891    fn herrflush_idempotent() {
8892        let _g = crate::test_util::global_state_lock();
8893        for _ in 0..5 {
8894            herrflush();
8895        }
8896    }
8897
8898    /// c:1221 — `substfailed` returns -1 per C body `return -1`.
8899    #[test]
8900    fn substfailed_returns_minus_one() {
8901        let _g = crate::test_util::global_state_lock();
8902        assert_eq!(substfailed(), -1, "C body: return -1");
8903    }
8904
8905    /// c:1255 — `digitcount` returns i32 (compile-time type pin).
8906    #[test]
8907    fn digitcount_returns_i32_type() {
8908        let _g = crate::test_util::global_state_lock();
8909        let _: i32 = digitcount();
8910    }
8911
8912    /// c:1549 — `histreduceblanks("")` empty returns empty String.
8913    #[test]
8914    fn histreduceblanks_empty_returns_empty_pin() {
8915        let _g = crate::test_util::global_state_lock();
8916        assert_eq!(reduce_line(""), "");
8917    }
8918
8919    /// c:1549 — `histreduceblanks` is pure.
8920    #[test]
8921    fn histreduceblanks_is_pure() {
8922        let _g = crate::test_util::global_state_lock();
8923        for s in ["", "abc", "  spaces  ", "\ttabs\t", "no\nnewlines\nhere"] {
8924            let first = reduce_line(s);
8925            for _ in 0..3 {
8926                assert_eq!(
8927                    reduce_line(s),
8928                    first,
8929                    "reduce_line({:?}) must be pure",
8930                    s
8931                );
8932            }
8933        }
8934    }
8935
8936    /// c:1293 + c:1313 — `strinbeg(0)` + `strinend` round-trip safe.
8937    #[test]
8938    fn strinbeg_strinend_round_trip_safe() {
8939        let _g = crate::test_util::global_state_lock();
8940        strinbeg(0);
8941        strinend();
8942    }
8943
8944    /// c:1611 — `addhistnum(0, 0, 0)` zero delta returns i64 (type pin).
8945    #[test]
8946    fn addhistnum_returns_i64_type() {
8947        let _g = crate::test_util::global_state_lock();
8948        let _: i64 = addhistnum(0, 0, 0);
8949    }
8950
8951    /// c:1611 — `addhistnum(N, 0, _)` zero increment preserves N.
8952    #[test]
8953    fn addhistnum_zero_increment_returns_input() {
8954        let _g = crate::test_util::global_state_lock();
8955        // Note: behavior depends on history ring state — pin only that
8956        // result is deterministic for same input.
8957        let first = addhistnum(0, 0, 0);
8958        for _ in 0..3 {
8959            assert_eq!(
8960                addhistnum(0, 0, 0),
8961                first,
8962                "addhistnum(0, 0, 0) must be deterministic"
8963            );
8964        }
8965    }
8966
8967    /// c:1328 — `nohw(0)` no-op accepts any i32.
8968    #[test]
8969    fn nohw_full_i32_range_no_panic() {
8970        for c in [i32::MIN, -1, 0, 1, 42, i32::MAX] {
8971            nohw(c);
8972        }
8973    }
8974
8975    /// c:1332 + c:1336 — `nohwabort` + `nohwe` are no-op idempotent.
8976    #[test]
8977    fn nohwabort_nohwe_idempotent() {
8978        for _ in 0..5 {
8979            nohwabort();
8980            nohwe();
8981        }
8982    }
8983
8984    // ═══════════════════════════════════════════════════════════════════
8985    // Additional C-parity tests for Src/hist.c
8986    // c:141 hist_in_word / c:150 hist_is_in_word / c:1417 unlinkcurline /
8987    // c:3582 flockhistfile / c:3745 histfileIsLocked / c:4449 firsthist
8988    // ═══════════════════════════════════════════════════════════════════
8989
8990    /// c:150 — `hist_is_in_word` returns i32 (compile-time type pin).
8991    #[test]
8992    fn hist_is_in_word_returns_i32_type() {
8993        let _g = crate::test_util::global_state_lock();
8994        let _: i32 = hist_is_in_word();
8995    }
8996
8997    /// c:150 — `hist_is_in_word` returns 0 or 1 only.
8998    #[test]
8999    fn hist_is_in_word_returns_zero_or_one() {
9000        let _g = crate::test_util::global_state_lock();
9001        let r = hist_is_in_word();
9002        assert!(r == 0 || r == 1, "hist_is_in_word ∈ {{0,1}}, got {}", r);
9003    }
9004
9005    /// c:141 + c:150 — `hist_in_word(1)` then `hist_is_in_word() == 1`
9006    /// round-trip.
9007    #[test]
9008    fn hist_in_word_set_clear_round_trip() {
9009        let _g = crate::test_util::global_state_lock();
9010        let saved = hist_is_in_word();
9011        hist_in_word(1);
9012        assert_eq!(hist_is_in_word(), 1, "set → is_in_word=1");
9013        hist_in_word(0);
9014        assert_eq!(hist_is_in_word(), 0, "clear → is_in_word=0");
9015        hist_in_word(saved);
9016    }
9017
9018    /// c:141 — `hist_in_word(0)` idempotent on a clear state.
9019    #[test]
9020    fn hist_in_word_clear_idempotent() {
9021        let _g = crate::test_util::global_state_lock();
9022        let saved = hist_is_in_word();
9023        hist_in_word(0);
9024        for _ in 0..5 {
9025            hist_in_word(0);
9026            assert_eq!(hist_is_in_word(), 0, "still clear");
9027        }
9028        hist_in_word(saved);
9029    }
9030
9031    /// c:1417 — `unlinkcurline` is safe on empty / fresh state.
9032    #[test]
9033    fn unlinkcurline_idempotent_safe() {
9034        let _g = crate::test_util::global_state_lock();
9035        for _ in 0..5 {
9036            unlinkcurline();
9037        }
9038    }
9039
9040    /// c:3582 — `flockhistfile` returns i32 (compile-time type pin).
9041    #[test]
9042    fn flockhistfile_returns_i32_type() {
9043        let _g = crate::test_util::global_state_lock();
9044        let _: i32 = flockhistfile("/__never_exists_zshrs_xyz__");
9045    }
9046
9047    /// c:3582 — `flockhistfile(nonexistent)` returns nonzero / determ.
9048    #[test]
9049    fn flockhistfile_nonexistent_path_deterministic() {
9050        let _g = crate::test_util::global_state_lock();
9051        let first = flockhistfile("/__never_exists_zshrs_flock__");
9052        for _ in 0..3 {
9053            assert_eq!(
9054                flockhistfile("/__never_exists_zshrs_flock__"),
9055                first,
9056                "flockhistfile must be deterministic"
9057            );
9058        }
9059    }
9060
9061    /// c:3745 — `histfileIsLocked` returns i32 (compile-time type pin).
9062    #[test]
9063    fn histfileIsLocked_returns_i32_type() {
9064        let _g = crate::test_util::global_state_lock();
9065        let _: i32 = histfileIsLocked();
9066    }
9067
9068    /// c:3745 — `histfileIsLocked` returns 0 or 1 only.
9069    #[test]
9070    fn histfileIsLocked_returns_zero_or_one() {
9071        let _g = crate::test_util::global_state_lock();
9072        let r = histfileIsLocked();
9073        assert!(r == 0 || r == 1, "histfileIsLocked ∈ {{0,1}}, got {}", r);
9074    }
9075
9076    /// c:4449 — `firsthist` returns i64 (compile-time type pin).
9077    #[test]
9078    fn firsthist_returns_i64_type() {
9079        let _g = crate::test_util::global_state_lock();
9080        let _: i64 = firsthist();
9081    }
9082
9083    /// c:4449 — `firsthist` empty ring returns 1 (per c:4451 default).
9084    #[test]
9085    fn firsthist_default_when_ring_empty() {
9086        let _g = crate::test_util::global_state_lock();
9087        // The ring is process-wide and earlier tests leave entries in it, so
9088        // empty it for the assertion and put it back.
9089        let saved = std::mem::take(&mut *hist_ring.lock().unwrap());
9090        let r = firsthist();
9091        *hist_ring.lock().unwrap() = saved;
9092        assert_eq!(r, 1, "an empty ring reports the default first event");
9093    }
9094
9095    /// c:4449 — `firsthist` is deterministic on stable ring.
9096    #[test]
9097    fn firsthist_is_deterministic() {
9098        let _g = crate::test_util::global_state_lock();
9099        let first = firsthist();
9100        for _ in 0..3 {
9101            assert_eq!(
9102                firsthist(),
9103                first,
9104                "firsthist must be deterministic on unchanged ring"
9105            );
9106        }
9107    }
9108
9109    /// c:1221 — `substfailed` returns i32 (compile-time type pin).
9110    #[test]
9111    fn substfailed_returns_i32_type() {
9112        let _g = crate::test_util::global_state_lock();
9113        let _: i32 = substfailed();
9114    }
9115
9116    /// Ring eviction must drop the evicted entry's histtab key
9117    /// (c:1410 `freehistdata(hist_ring, 0)`, c:2626-2629 `freehistnode`),
9118    /// and must leave a key that belongs to a newer entry alone.
9119    #[test]
9120    fn eviction_unhashes_evicted_entry_only() {
9121        use crate::ported::hashtable::{addhistnode, histtab_lock};
9122        let _g = crate::test_util::global_state_lock();
9123        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
9124        let saved_ring = std::mem::take(&mut *hist_ring.lock().unwrap());
9125        let saved_tab = std::mem::take(&mut *histtab_lock().write().unwrap());
9126        let saved = (histsiz.load(SeqCst), histlinect.load(SeqCst), curhist.load(SeqCst));
9127
9128        // prepnexthistent: ring full at histsiz=2, oldest "a" is evicted.
9129        *hist_ring.lock().unwrap() = vec![make_histent(2, "b".into()), make_histent(1, "a".into())];
9130        addhistnode("a", 1);
9131        addhistnode("b", 2);
9132        histsiz.store(2, SeqCst);
9133        histlinect.store(2, SeqCst);
9134        curhist.store(2, SeqCst);
9135        prepnexthistent();
9136        assert!(histtab_lock().read().unwrap().get("a").is_none(), "evicted key kept");
9137        assert_eq!(histtab_lock().read().unwrap().get("b"), Some(&2));
9138
9139        // resizehistents: evicted file entry 4 shares text with hashed entry 5.
9140        histtab_lock().write().unwrap().clear();
9141        *hist_ring.lock().unwrap() = vec![make_histent(5, "x".into()), make_histent(4, "x".into())];
9142        addhistnode("x", 5);
9143        histsiz.store(1, SeqCst);
9144        histlinect.store(2, SeqCst);
9145        resizehistents();
9146        assert_eq!(hist_ring.lock().unwrap().len(), 1);
9147        assert_eq!(histtab_lock().read().unwrap().get("x"), Some(&5), "newer entry's key dropped");
9148
9149        *hist_ring.lock().unwrap() = saved_ring;
9150        *histtab_lock().write().unwrap() = saved_tab;
9151        histsiz.store(saved.0, SeqCst);
9152        histlinect.store(saved.1, SeqCst);
9153        curhist.store(saved.2, SeqCst);
9154    }
9155
9156    /// c:3869/c:3890/c:3924 — a pushed history stack starts with an empty
9157    /// `histtab` and the pop gives back the saved one. `saveandpophiststack`
9158    /// (c:3960) must not save the history file while `nohistsave` is set.
9159    #[test]
9160    fn histstack_push_pop_swaps_histtab() {
9161        use crate::ported::hashtable::{addhistnode, histtab_lock};
9162        let _g = crate::test_util::global_state_lock();
9163        let _g = hist_test_lock().lock().unwrap_or_else(|e| e.into_inner());
9164        let saved_ring = std::mem::take(&mut *hist_ring.lock().unwrap());
9165        let saved_tab = std::mem::take(&mut *histtab_lock().write().unwrap());
9166        let saved_active = histactive.swap(0, SeqCst);
9167
9168        addhistnode("keep", 7);
9169        pushhiststack(None, 10, 10, 0);
9170        assert!(histtab_lock().read().unwrap().is_empty(), "pushed stack must start empty");
9171        addhistnode("scratch", 8);
9172
9173        crate::ported::exec::nohistsave.store(1, SeqCst);
9174        assert_eq!(saveandpophiststack(1, 0), 1);
9175        crate::ported::exec::nohistsave.store(0, SeqCst);
9176
9177        let tab = histtab_lock().read().unwrap();
9178        assert_eq!(tab.get("keep"), Some(&7), "pop must restore the saved table");
9179        assert!(tab.get("scratch").is_none(), "pop must drop the pushed table");
9180        drop(tab);
9181
9182        histactive.store(saved_active, SeqCst);
9183        *hist_ring.lock().unwrap() = saved_ring;
9184        *histtab_lock().write().unwrap() = saved_tab;
9185    }
9186}