Skip to main content

zsh/ported/
context.rs

1//! context.c - context save and restore
2//!
3//! Port of Src/context.c
4//!
5//! This short file provides a home for the stack of saved contexts.
6//! The actions for saving and restoring are encapsulated within
7//! individual modules. After P7-P8 dissolved the ZshLexer and ZshParser structs into
8//! free ported + thread_locals, the save/restore signatures simplified —
9//! the lexer/parser parameters went away. Callers just call zcontext_*
10//! and the underlying thread_local state is what's saved/restored.
11
12use super::parse::ParseStack;
13use crate::hist::{hist_context_restore, hist_context_save};
14use crate::lex::{lex_context_restore, lex_context_save};
15use crate::parse::{parse_context_restore, parse_context_save};
16use crate::ported::zsh_h::{hist_stack, lex_stack, ZCONTEXT_HIST, ZCONTEXT_LEX, ZCONTEXT_PARSE};
17use crate::signals_h::{queue_signals, unqueue_signals};
18use crate::DPUTS;
19use std::sync::Mutex;
20
21/// Port of `struct context_stack` from Src/context.c:38-44.
22#[allow(non_camel_case_types)]
23pub struct context_stack {
24    // c:52
25    pub next: Option<Box<context_stack>>, // c:52
26    pub hist_stack: hist_stack,           // c:52
27    pub lex_stack: lex_stack,             // c:52
28    pub parse_stack: ParseStack,          // c:52
29}
30
31/// Port of `void zcontext_save_partial(int parts)` from Src/context.c:52.
32#[allow(non_snake_case)]
33pub fn zcontext_save_partial(parts: i32) {
34    // c:52
35    queue_signals(); // c:52
36
37    let mut cs = Box::new(context_stack {
38        // c:58
39        next: None,
40        hist_stack: hist_stack {
41            histactive: 0,
42            histdone: 0,
43            stophist: 0,
44            hlinesz: 0,
45            defev: 0,
46            hline: None,
47            hptr: None,
48            chwords: Vec::new(),
49            chwordlen: 0,
50            chwordpos: 0,
51            csp: 0,
52            hist_keep_comment: 0,
53        },
54        lex_stack: lex_stack::default(),
55        parse_stack: ParseStack::default(),
56    });
57
58    let mut head = cstack.lock().unwrap();
59
60    let toplevel: i32 = if head.is_none() { 1 } else { 0 }; // !cstack
61    if (parts & ZCONTEXT_HIST) != 0 {
62        // c:60
63        hist_context_save(&mut cs.hist_stack, toplevel); // c:61
64    }
65    if (parts & ZCONTEXT_LEX) != 0 {
66        // c:63
67        lex_context_save(&mut cs.lex_stack);
68    }
69    if (parts & ZCONTEXT_PARSE) != 0 {
70        // c:80
71        parse_context_save(&mut cs.parse_stack);
72    }
73
74    cs.next = head.take(); // c:89
75    *head = Some(cs); // c:89
76
77    unqueue_signals(); // c:89
78}
79
80/// Port of `void zcontext_save(void)` from Src/context.c:80.
81pub fn zcontext_save() {
82    // c:80
83    zcontext_save_partial(ZCONTEXT_HIST | ZCONTEXT_LEX | ZCONTEXT_PARSE);
84}
85
86/// Port of `void zcontext_restore_partial(int parts)` from Src/context.c:89.
87pub fn zcontext_restore_partial(parts: i32) {
88    // c:89
89    let mut head = cstack.lock().unwrap();
90    // c:93 — DPUTS(!cstack, "BUG: zcontext_restore() without zcontext_save()")
91    DPUTS!(
92        head.is_none(),
93        "BUG: zcontext_restore() without zcontext_save()"
94    ); // c:93
95    let mut cs = match head.take() {
96        // c:91
97        Some(cs) => cs,
98        None => {
99            return;
100        }
101    };
102
103    queue_signals(); // c:95
104    *head = cs.next.take(); // c:96
105    let toplevel: i32 = if head.is_none() { 1 } else { 0 };
106
107    if (parts & ZCONTEXT_HIST) != 0 {
108        // c:98
109        hist_context_restore(&cs.hist_stack, toplevel); // c:99
110    }
111    if (parts & ZCONTEXT_LEX) != 0 {
112        // c:101
113        lex_context_restore(&mut cs.lex_stack);
114    }
115    if (parts & ZCONTEXT_PARSE) != 0 {
116        // c:117
117        parse_context_restore(&cs.parse_stack);
118    }
119
120    drop(cs); // c:117
121
122    unqueue_signals(); // c:117
123}
124
125/// Port of `void zcontext_restore(void)` from Src/context.c:117.
126pub fn zcontext_restore() {
127    // c:117
128    zcontext_restore_partial(ZCONTEXT_HIST | ZCONTEXT_LEX | ZCONTEXT_PARSE);
129}
130
131/// Port of `static struct context_stack *cstack` from Src/context.c:52.
132///
133/// !!! WARNING: RUST-ONLY ADAPTATION — C HAS ONE THREAD, ONE STACK !!!
134/// The lexer/parser state a frame saves lives in thread_locals, so the stack
135/// holding those frames is per-thread too. As a process-global, a worker-pool
136/// parse (async hooks, autoload backfill) interleaved its save/restore with
137/// the main thread's: the main thread's restore popped a worker's frame, whose
138/// hist snapshot held `histactive == 0`, and the command line being typed lost
139/// its history record (executed, but never entered in the ring or the store).
140/// `lock()` keeps the old `Mutex` call shape for every caller.
141#[allow(non_camel_case_types)]
142struct PerThreadCstack;
143static cstack: PerThreadCstack = PerThreadCstack; // c:52
144
145impl PerThreadCstack {
146    fn lock(
147        &self,
148    ) -> std::sync::LockResult<std::sync::MutexGuard<'static, Option<Box<context_stack>>>> {
149        thread_local! {
150            // One leaked slot per thread: threads are few and long-lived.
151            static SLOT: &'static Mutex<Option<Box<context_stack>>> =
152                Box::leak(Box::new(Mutex::new(None)));
153        }
154        SLOT.with(|slot| *slot).lock()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::lex::{lex_init, set_toklineno, toklineno, LEX_DBPARENS};
162
163    fn reset_cstack() {
164        *cstack.lock().unwrap() = None;
165    }
166
167    /// A save/restore pair on another thread must not touch this thread's
168    /// stack: with one process-global stack, a worker-pool parse popped the
169    /// main thread's frame and zeroed `histactive` for the line being typed.
170    #[test]
171    fn context_stack_is_per_thread() {
172        let _g = crate::test_util::global_state_lock();
173        reset_cstack();
174        lex_init("");
175        zcontext_save();
176        assert!(cstack.lock().unwrap().is_some());
177        std::thread::spawn(|| {
178            assert!(cstack.lock().unwrap().is_none(), "new thread starts empty");
179            zcontext_save();
180            zcontext_restore();
181            assert!(cstack.lock().unwrap().is_none());
182        })
183        .join()
184        .unwrap();
185        assert!(
186            cstack.lock().unwrap().is_some(),
187            "the other thread's save/restore left this thread's frame alone"
188        );
189        zcontext_restore();
190        assert!(cstack.lock().unwrap().is_none());
191    }
192
193    #[test]
194    fn save_restore_balances_stack() {
195        let _g = crate::test_util::global_state_lock();
196        reset_cstack();
197        lex_init("");
198        zcontext_save();
199        assert!(cstack.lock().unwrap().is_some());
200        zcontext_restore();
201        assert!(cstack.lock().unwrap().is_none());
202    }
203
204    #[test]
205    fn nested_saves_pop_lifo() {
206        let _g = crate::test_util::global_state_lock();
207        reset_cstack();
208        lex_init("");
209        zcontext_save();
210        zcontext_save();
211        zcontext_restore();
212        assert!(cstack.lock().unwrap().is_some());
213        zcontext_restore();
214        assert!(cstack.lock().unwrap().is_none());
215    }
216
217    #[test]
218    fn restore_without_save_is_noop() {
219        let _g = crate::test_util::global_state_lock();
220        reset_cstack();
221        lex_init("");
222        zcontext_restore();
223        assert!(cstack.lock().unwrap().is_none());
224    }
225
226    #[test]
227    fn lex_save_restore_roundtrips_state() {
228        let _g = crate::test_util::global_state_lock();
229        reset_cstack();
230        lex_init("echo hello");
231        LEX_DBPARENS.set(true);
232        set_toklineno(42);
233        zcontext_save();
234        assert!(LEX_DBPARENS.with(|c| c.get()));
235        assert_eq!(toklineno(), 42);
236        zcontext_restore();
237        assert!(LEX_DBPARENS.with(|c| c.get()));
238        assert_eq!(toklineno(), 42);
239    }
240
241    /// `Src/zsh.h:491-495` — `ZCONTEXT_HIST = (1<<0)`,
242    /// `ZCONTEXT_LEX = (1<<1)`, `ZCONTEXT_PARSE = (1<<2)`. The three
243    /// bits must be distinct and non-overlapping; `zcontext_save_partial`
244    /// at c:60/63/66 AND-tests each independently and a flag collision
245    /// would silently double-save one substrate and skip another on
246    /// every nested context (heredoc-inside-eval, `read -E`, …).
247    #[test]
248    fn zcontext_flag_bits_are_distinct_and_nonzero() {
249        let _g = crate::test_util::global_state_lock();
250        // Pin the exact C constants from Src/zsh.h:491-495.
251        assert_eq!(ZCONTEXT_HIST, 1 << 0);
252        assert_eq!(ZCONTEXT_LEX, 1 << 1);
253        assert_eq!(ZCONTEXT_PARSE, 1 << 2);
254        // Bitfield discipline: no overlapping bits.
255        assert_eq!(ZCONTEXT_HIST & ZCONTEXT_LEX, 0);
256        assert_eq!(ZCONTEXT_HIST & ZCONTEXT_PARSE, 0);
257        assert_eq!(ZCONTEXT_LEX & ZCONTEXT_PARSE, 0);
258    }
259
260    /// `Src/context.c:52-72` — `zcontext_save_partial(parts)` allocates
261    /// the frame BEFORE the `parts &` gates at c:60/63/66, then pushes
262    /// at c:70-71 unconditionally (`cs->next = cstack; cstack = cs`).
263    /// `parts == 0` is a legitimate caller pattern (probe-save); the
264    /// push must still fire. Regression that early-exits on a zero
265    /// mask would mis-balance the stack for any such caller.
266    #[test]
267    fn zcontext_save_partial_with_zero_mask_still_pushes_frame() {
268        let _g = crate::test_util::global_state_lock();
269        reset_cstack();
270        lex_init("");
271        zcontext_save_partial(0);
272        assert!(
273            cstack.lock().unwrap().is_some(),
274            "c:70-71 push must fire even when no parts requested"
275        );
276        zcontext_restore_partial(0);
277        assert!(
278            cstack.lock().unwrap().is_none(),
279            "c:96 pop must mirror the push regardless of parts mask"
280        );
281    }
282
283    /// `Src/context.c:89-96` — `zcontext_restore_partial` reads
284    /// `cs = cstack` at c:91 and a `DPUTS(!cstack, ...)` at c:93 fires
285    /// in C debug builds. Release C dereferences a NULL `cs` — UB —
286    /// but the Rust port chose to silently no-op on empty stack
287    /// (`head.take()` returns None → early return). Pin the no-op so
288    /// a refactor doesn't suddenly start panicking on unbalanced
289    /// restore calls.
290    #[test]
291    fn zcontext_restore_partial_on_empty_stack_is_noop() {
292        let _g = crate::test_util::global_state_lock();
293        reset_cstack();
294        zcontext_restore_partial(0);
295        zcontext_restore_partial(ZCONTEXT_HIST);
296        assert!(cstack.lock().unwrap().is_none());
297    }
298
299    /// `Src/context.c:52` — Deep LIFO check. 5 saves pushed; 5
300    /// restores must drain to empty. Pin the depth-handling so a
301    /// regression that uses a fixed-size buffer instead of the
302    /// linked stack would surface on the third+ push.
303    #[test]
304    fn deep_save_restore_lifo_drains_to_empty() {
305        let _g = crate::test_util::global_state_lock();
306        reset_cstack();
307        lex_init("");
308        for _ in 0..5 {
309            zcontext_save();
310        }
311        // Stack now has 5 frames
312        for i in (0..5).rev() {
313            assert!(
314                cstack.lock().unwrap().is_some(),
315                "stack must still have entries before restore #{}",
316                5 - i
317            );
318            zcontext_restore();
319        }
320        assert!(
321            cstack.lock().unwrap().is_none(),
322            "5 restores must drain the 5 saves to empty"
323        );
324    }
325
326    /// `Src/context.c:80` — `zcontext_save()` is a convenience
327    /// wrapper for `zcontext_save_partial(ALL)`. Pin the alias
328    /// contract: a full-mask partial-save followed by any restore
329    /// yields the same final state as save→restore.
330    #[test]
331    fn zcontext_save_equals_save_partial_full_mask() {
332        let _g = crate::test_util::global_state_lock();
333        let full = ZCONTEXT_HIST | ZCONTEXT_LEX | ZCONTEXT_PARSE;
334
335        reset_cstack();
336        lex_init("");
337        zcontext_save();
338        zcontext_restore();
339        let after_full = cstack.lock().unwrap().is_none();
340
341        reset_cstack();
342        lex_init("");
343        zcontext_save_partial(full);
344        zcontext_restore_partial(full);
345        let after_partial = cstack.lock().unwrap().is_none();
346
347        assert_eq!(
348            after_full, after_partial,
349            "save/restore must equal save_partial(ALL)/restore_partial(ALL)"
350        );
351    }
352
353    /// `Src/context.c:52` — Many save calls with NO matching restores
354    /// must not corrupt or panic, just grow the stack. Pin defensive
355    /// behavior for shell-script abort paths where partial state
356    /// rewinds may skip restores.
357    #[test]
358    fn many_saves_without_restore_grow_stack_safely() {
359        let _g = crate::test_util::global_state_lock();
360        reset_cstack();
361        lex_init("");
362        for _ in 0..20 {
363            zcontext_save();
364        }
365        // Stack should still be intact, top frame accessible
366        assert!(cstack.lock().unwrap().is_some());
367        // Now drain — must not panic
368        for _ in 0..20 {
369            zcontext_restore();
370        }
371        assert!(cstack.lock().unwrap().is_none());
372    }
373
374    // ─── zsh-corpus pins for context save/restore ───────────────────
375
376    /// `zcontext_save` then `zcontext_restore` leaves the stack empty.
377    #[test]
378    fn context_corpus_save_then_restore_returns_empty() {
379        let _g = crate::test_util::global_state_lock();
380        reset_cstack();
381        lex_init("");
382        zcontext_save();
383        zcontext_restore();
384        assert!(
385            cstack.lock().unwrap().is_none(),
386            "save+restore leaves no stack"
387        );
388    }
389
390    /// Nested save/restore is correctly LIFO.
391    #[test]
392    fn context_corpus_nested_save_restore_lifo() {
393        let _g = crate::test_util::global_state_lock();
394        reset_cstack();
395        lex_init("");
396        zcontext_save();
397        zcontext_save();
398        zcontext_save();
399        zcontext_restore();
400        zcontext_restore();
401        zcontext_restore();
402        assert!(cstack.lock().unwrap().is_none(), "all 3 levels drained");
403    }
404
405    /// `zcontext_save_partial(1)` + `zcontext_restore_partial(1)`
406    /// round-trips without panic.
407    #[test]
408    fn context_corpus_save_partial_round_trip() {
409        let _g = crate::test_util::global_state_lock();
410        reset_cstack();
411        lex_init("");
412        zcontext_save_partial(1);
413        zcontext_restore_partial(1);
414    }
415
416    // ═══════════════════════════════════════════════════════════════════
417    // Additional C-parity tests for Src/context.c.
418    // ═══════════════════════════════════════════════════════════════════
419
420    /// c:80 — `zcontext_save` pushes onto stack (cstack becomes Some).
421    #[test]
422    fn zcontext_save_pushes_onto_stack() {
423        let _g = crate::test_util::global_state_lock();
424        reset_cstack();
425        lex_init("");
426        zcontext_save();
427        assert!(
428            cstack.lock().unwrap().is_some(),
429            "save should leave cstack with one frame"
430        );
431        zcontext_restore();
432    }
433
434    /// c:117 — `zcontext_restore` pops stack (cstack returns to None).
435    #[test]
436    fn zcontext_restore_pops_to_none_when_balanced() {
437        let _g = crate::test_util::global_state_lock();
438        reset_cstack();
439        lex_init("");
440        zcontext_save();
441        zcontext_restore();
442        assert!(
443            cstack.lock().unwrap().is_none(),
444            "single save+restore returns cstack to None"
445        );
446    }
447
448    /// c:80 — multiple saves build a stack of N frames.
449    #[test]
450    fn zcontext_save_multiple_builds_stack() {
451        let _g = crate::test_util::global_state_lock();
452        reset_cstack();
453        lex_init("");
454        zcontext_save();
455        zcontext_save();
456        zcontext_save();
457        // Walk the linked-list and count.
458        let head = cstack.lock().unwrap();
459        let mut count = 0;
460        let mut cur = head.as_ref();
461        while let Some(node) = cur {
462            count += 1;
463            cur = node.next.as_ref();
464        }
465        drop(head);
466        assert_eq!(count, 3, "3 saves → 3-deep stack");
467        zcontext_restore();
468        zcontext_restore();
469        zcontext_restore();
470    }
471
472    /// c:52 — `zcontext_save_partial(0)` saves NOTHING from the
473    /// constituent sub-stacks but still pushes a frame.
474    #[test]
475    fn zcontext_save_partial_zero_still_pushes_frame() {
476        let _g = crate::test_util::global_state_lock();
477        reset_cstack();
478        lex_init("");
479        zcontext_save_partial(0);
480        assert!(
481            cstack.lock().unwrap().is_some(),
482            "even parts=0 pushes a frame for restore symmetry"
483        );
484        zcontext_restore_partial(0);
485    }
486
487    /// c:89 — `zcontext_restore_partial(0)` on empty stack DPUTS warns
488    /// but doesn't panic.
489    #[test]
490    fn zcontext_restore_on_empty_no_panic() {
491        let _g = crate::test_util::global_state_lock();
492        reset_cstack();
493        // No prior save — restore should not panic.
494        zcontext_restore_partial(0);
495    }
496
497    /// c:52 — `zcontext_save_partial(ZCONTEXT_HIST)` + restore round-trip.
498    #[test]
499    fn zcontext_save_hist_only_round_trip() {
500        let _g = crate::test_util::global_state_lock();
501        reset_cstack();
502        lex_init("");
503        zcontext_save_partial(crate::ported::zsh_h::ZCONTEXT_HIST);
504        zcontext_restore_partial(crate::ported::zsh_h::ZCONTEXT_HIST);
505        assert!(cstack.lock().unwrap().is_none());
506    }
507
508    /// c:52 — `zcontext_save_partial(ZCONTEXT_LEX)` + restore round-trip.
509    #[test]
510    fn zcontext_save_lex_only_round_trip() {
511        let _g = crate::test_util::global_state_lock();
512        reset_cstack();
513        lex_init("");
514        zcontext_save_partial(crate::ported::zsh_h::ZCONTEXT_LEX);
515        zcontext_restore_partial(crate::ported::zsh_h::ZCONTEXT_LEX);
516        assert!(cstack.lock().unwrap().is_none());
517    }
518
519    /// c:52 — `zcontext_save_partial(ZCONTEXT_PARSE)` + restore round-trip.
520    #[test]
521    fn zcontext_save_parse_only_round_trip() {
522        let _g = crate::test_util::global_state_lock();
523        reset_cstack();
524        lex_init("");
525        zcontext_save_partial(crate::ported::zsh_h::ZCONTEXT_PARSE);
526        zcontext_restore_partial(crate::ported::zsh_h::ZCONTEXT_PARSE);
527        assert!(cstack.lock().unwrap().is_none());
528    }
529
530    // ═══════════════════════════════════════════════════════════════════
531    // Additional C-parity tests for Src/context.c partial save/restore
532    // semantics + ZCONTEXT_* flag invariants.
533    // ═══════════════════════════════════════════════════════════════════
534
535    /// c:491-495 — ZCONTEXT_HIST/LEX/PARSE are distinct single bits.
536    #[test]
537    fn zcontext_flag_bits_pin_to_one_two_four() {
538        assert_eq!(crate::ported::zsh_h::ZCONTEXT_HIST, 1, "c:491 = 1<<0");
539        assert_eq!(crate::ported::zsh_h::ZCONTEXT_LEX, 2, "c:493 = 1<<1");
540        assert_eq!(crate::ported::zsh_h::ZCONTEXT_PARSE, 4, "c:495 = 1<<2");
541    }
542
543    /// c:491-495 — every ZCONTEXT_* is a single bit (power of two).
544    #[test]
545    fn zcontext_flags_are_single_bits() {
546        for &v in &[
547            crate::ported::zsh_h::ZCONTEXT_HIST,
548            crate::ported::zsh_h::ZCONTEXT_LEX,
549            crate::ported::zsh_h::ZCONTEXT_PARSE,
550        ] {
551            assert!((v as u32).is_power_of_two(), "{} must be a single bit", v);
552        }
553    }
554
555    /// c:52 — `zcontext_save_partial(0)` followed by restore should
556    /// drain the stack to empty (the c:60-78 branches all skip when
557    /// no flags match, but the frame still pushes per c:89).
558    #[test]
559    fn zcontext_save_partial_zero_round_trip_pops() {
560        let _g = crate::test_util::global_state_lock();
561        reset_cstack();
562        lex_init("");
563        zcontext_save_partial(0);
564        assert!(cstack.lock().unwrap().is_some(), "zero-mask still pushes");
565        zcontext_restore_partial(0);
566        assert!(cstack.lock().unwrap().is_none(), "restore drains to None");
567    }
568
569    /// c:52 — save HIST then restore with PARSE alone should still
570    /// pop the frame (c:91 unconditional take), but NOT restore HIST.
571    /// Pins the asymmetric-mask edge case from c:96.
572    #[test]
573    fn zcontext_save_hist_restore_with_parse_mask_pops_frame() {
574        let _g = crate::test_util::global_state_lock();
575        reset_cstack();
576        lex_init("");
577        zcontext_save_partial(crate::ported::zsh_h::ZCONTEXT_HIST);
578        assert!(cstack.lock().unwrap().is_some());
579        zcontext_restore_partial(crate::ported::zsh_h::ZCONTEXT_PARSE);
580        assert!(cstack.lock().unwrap().is_none(), "frame popped regardless");
581    }
582
583    /// c:80 — `zcontext_save` and `zcontext_save_partial(HIST|LEX|PARSE)`
584    /// must each push exactly one frame.
585    #[test]
586    fn zcontext_save_shorthand_pushes_one_frame() {
587        let _g = crate::test_util::global_state_lock();
588        reset_cstack();
589        lex_init("");
590        zcontext_save();
591        // Stack must have one frame (top != None, top.next == None).
592        {
593            let head = cstack.lock().unwrap();
594            assert!(head.is_some(), "save pushed a frame");
595            assert!(head.as_ref().unwrap().next.is_none(), "stack depth = 1");
596        }
597        zcontext_restore();
598        assert!(cstack.lock().unwrap().is_none());
599
600        zcontext_save_partial(
601            crate::ported::zsh_h::ZCONTEXT_HIST
602                | crate::ported::zsh_h::ZCONTEXT_LEX
603                | crate::ported::zsh_h::ZCONTEXT_PARSE,
604        );
605        {
606            let head = cstack.lock().unwrap();
607            assert!(head.is_some(), "full-mask save_partial pushed a frame");
608            assert!(head.as_ref().unwrap().next.is_none(), "stack depth = 1");
609        }
610        zcontext_restore();
611    }
612
613    /// c:117 — `zcontext_restore` shorthand drains exactly one frame.
614    #[test]
615    fn zcontext_restore_drains_exactly_one_frame() {
616        let _g = crate::test_util::global_state_lock();
617        reset_cstack();
618        lex_init("");
619        zcontext_save();
620        zcontext_save();
621        // Two pushes.
622        zcontext_restore();
623        // One frame remains.
624        assert!(cstack.lock().unwrap().is_some());
625        zcontext_restore();
626        assert!(cstack.lock().unwrap().is_none());
627    }
628
629    /// c:91 — `zcontext_restore` shorthand on empty stack is a no-op
630    /// (matches DPUTS-trip path: warn + return without panic).
631    #[test]
632    fn zcontext_restore_shorthand_on_empty_no_panic() {
633        let _g = crate::test_util::global_state_lock();
634        reset_cstack();
635        zcontext_restore();
636        assert!(cstack.lock().unwrap().is_none());
637    }
638
639    /// c:52 — `ZCONTEXT_HIST | ZCONTEXT_LEX` combo (no PARSE) round-trips.
640    #[test]
641    fn zcontext_save_hist_lex_combo_round_trip() {
642        let _g = crate::test_util::global_state_lock();
643        reset_cstack();
644        lex_init("");
645        let mask = crate::ported::zsh_h::ZCONTEXT_HIST | crate::ported::zsh_h::ZCONTEXT_LEX;
646        zcontext_save_partial(mask);
647        zcontext_restore_partial(mask);
648        assert!(cstack.lock().unwrap().is_none());
649    }
650
651    /// c:52 — `ZCONTEXT_LEX | ZCONTEXT_PARSE` combo (no HIST) round-trips.
652    #[test]
653    fn zcontext_save_lex_parse_combo_round_trip() {
654        let _g = crate::test_util::global_state_lock();
655        reset_cstack();
656        lex_init("");
657        let mask = crate::ported::zsh_h::ZCONTEXT_LEX | crate::ported::zsh_h::ZCONTEXT_PARSE;
658        zcontext_save_partial(mask);
659        zcontext_restore_partial(mask);
660        assert!(cstack.lock().unwrap().is_none());
661    }
662
663    /// c:91 — multiple restores on empty stack stay no-op (no DPUTS panic).
664    #[test]
665    fn many_restores_on_empty_no_panic() {
666        let _g = crate::test_util::global_state_lock();
667        reset_cstack();
668        for _ in 0..10 {
669            zcontext_restore_partial(0);
670        }
671        assert!(cstack.lock().unwrap().is_none());
672    }
673
674    /// c:91 — DPUTS WARN-only: restore on empty stack must not crash.
675    #[test]
676    fn restore_on_empty_via_full_shorthand_no_panic() {
677        let _g = crate::test_util::global_state_lock();
678        reset_cstack();
679        zcontext_restore();
680        zcontext_restore_partial(crate::ported::zsh_h::ZCONTEXT_HIST);
681        zcontext_restore_partial(
682            crate::ported::zsh_h::ZCONTEXT_HIST | crate::ported::zsh_h::ZCONTEXT_LEX,
683        );
684        assert!(cstack.lock().unwrap().is_none());
685    }
686
687    // ═══════════════════════════════════════════════════════════════════
688    // Additional C-parity pins for Src/context.c
689    // c:52 zcontext_save_partial / c:80 zcontext_save /
690    // c:89 zcontext_restore_partial / c:126 zcontext_restore /
691    // zsh.h:491-495 ZCONTEXT_* flags
692    // ═══════════════════════════════════════════════════════════════════
693
694    /// c:52 — `zcontext_save_partial(ZCONTEXT_HIST)` pushes exactly one frame.
695    #[test]
696    fn zcontext_save_partial_hist_pushes_one_frame() {
697        let _g = crate::test_util::global_state_lock();
698        reset_cstack();
699        zcontext_save_partial(crate::ported::zsh_h::ZCONTEXT_HIST);
700        assert!(
701            cstack.lock().unwrap().is_some(),
702            "frame must exist after save"
703        );
704        zcontext_restore_partial(crate::ported::zsh_h::ZCONTEXT_HIST);
705        assert!(cstack.lock().unwrap().is_none(), "frame must be drained");
706    }
707
708    /// c:80-83 — `zcontext_save()` saves all three (HIST|LEX|PARSE).
709    #[test]
710    fn zcontext_save_full_then_restore_full_clears_stack() {
711        let _g = crate::test_util::global_state_lock();
712        reset_cstack();
713        zcontext_save();
714        assert!(cstack.lock().unwrap().is_some());
715        zcontext_restore();
716        assert!(cstack.lock().unwrap().is_none());
717    }
718
719    /// c:52 — three saves push three frames; three restores drain all.
720    #[test]
721    fn zcontext_save_three_then_restore_three_clears_stack() {
722        let _g = crate::test_util::global_state_lock();
723        reset_cstack();
724        zcontext_save();
725        zcontext_save();
726        zcontext_save();
727        zcontext_restore();
728        zcontext_restore();
729        zcontext_restore();
730        assert!(
731            cstack.lock().unwrap().is_none(),
732            "all 3 frames must drain (LIFO)"
733        );
734    }
735
736    /// c:80 — `zcontext_save` is the shorthand for the OR of all three.
737    #[test]
738    fn zcontext_save_equals_or_of_three_flags_behavior() {
739        let _g = crate::test_util::global_state_lock();
740        reset_cstack();
741        zcontext_save();
742        let depth1 = cstack.lock().unwrap().is_some();
743        reset_cstack();
744        zcontext_save_partial(
745            crate::ported::zsh_h::ZCONTEXT_HIST
746                | crate::ported::zsh_h::ZCONTEXT_LEX
747                | crate::ported::zsh_h::ZCONTEXT_PARSE,
748        );
749        let depth2 = cstack.lock().unwrap().is_some();
750        assert_eq!(
751            depth1, depth2,
752            "shorthand and explicit OR must produce same push behavior"
753        );
754        zcontext_restore();
755    }
756
757    /// zsh.h:491-495 — ZCONTEXT_HIST|LEX|PARSE = 0b111 = 7.
758    #[test]
759    fn zcontext_all_flags_or_equals_seven() {
760        let all = crate::ported::zsh_h::ZCONTEXT_HIST
761            | crate::ported::zsh_h::ZCONTEXT_LEX
762            | crate::ported::zsh_h::ZCONTEXT_PARSE;
763        assert_eq!(all, 7, "HIST|LEX|PARSE must equal 0b111 = 7");
764    }
765
766    /// zsh.h:491-495 — flags are pairwise distinct.
767    #[test]
768    fn zcontext_flags_pairwise_distinct() {
769        use crate::ported::zsh_h::{ZCONTEXT_HIST, ZCONTEXT_LEX, ZCONTEXT_PARSE};
770        let codes = [ZCONTEXT_HIST, ZCONTEXT_LEX, ZCONTEXT_PARSE];
771        let unique: std::collections::HashSet<_> = codes.iter().copied().collect();
772        assert_eq!(unique.len(), codes.len(), "ZCONTEXT_* must be distinct");
773    }
774
775    /// c:52 / c:89 — save/restore is LIFO (nested frames).
776    #[test]
777    fn zcontext_save_restore_is_lifo() {
778        let _g = crate::test_util::global_state_lock();
779        reset_cstack();
780        zcontext_save();
781        zcontext_save();
782        assert!(cstack.lock().unwrap().is_some());
783        zcontext_restore();
784        assert!(cstack.lock().unwrap().is_some(), "one frame still on stack");
785        zcontext_restore();
786        assert!(cstack.lock().unwrap().is_none(), "all frames drained");
787    }
788
789    /// c:52 — `zcontext_save_partial(-1)` (all-bits) doesn't panic.
790    #[test]
791    fn zcontext_save_partial_all_bits_no_panic() {
792        let _g = crate::test_util::global_state_lock();
793        reset_cstack();
794        zcontext_save_partial(-1);
795        zcontext_restore_partial(-1);
796        assert!(cstack.lock().unwrap().is_none());
797    }
798
799    /// c:91 — `zcontext_restore_partial(-1)` on empty doesn't panic.
800    #[test]
801    fn zcontext_restore_partial_all_bits_on_empty_no_panic() {
802        let _g = crate::test_util::global_state_lock();
803        reset_cstack();
804        zcontext_restore_partial(-1);
805        assert!(cstack.lock().unwrap().is_none());
806    }
807
808    /// c:52 — many saves then full drain leaves stack empty.
809    #[test]
810    fn zcontext_many_saves_then_full_drain_clean() {
811        let _g = crate::test_util::global_state_lock();
812        reset_cstack();
813        for _ in 0..20 {
814            zcontext_save();
815        }
816        for _ in 0..20 {
817            zcontext_restore();
818        }
819        assert!(
820            cstack.lock().unwrap().is_none(),
821            "after 20 paired save/restore, stack must be empty"
822        );
823    }
824
825    /// c:91 — restore with subset mask of saved flags still drains frame.
826    #[test]
827    fn zcontext_restore_with_subset_mask_drains_frame() {
828        let _g = crate::test_util::global_state_lock();
829        reset_cstack();
830        zcontext_save_partial(
831            crate::ported::zsh_h::ZCONTEXT_HIST | crate::ported::zsh_h::ZCONTEXT_LEX,
832        );
833        zcontext_restore_partial(crate::ported::zsh_h::ZCONTEXT_HIST);
834        assert!(
835            cstack.lock().unwrap().is_none(),
836            "restore with subset mask still pops the frame"
837        );
838    }
839}