Skip to main content

zsh/extensions/
zsh_ast.rs

1//! Zsh AST types — Rust-only, NOT in zsh C.
2//!
3//! zsh C does NOT have an AST tree. Its parser emits a flat wordcode
4//! stream (`Wordcode ecbuf[]`) directly via `par_event` → `par_list` →
5//! `par_sublist` → `par_pline` → `par_cmd` → `par_simple` / `par_redir`
6//! (Src/parse.c:485-3000). The wordcode is consumed by `execlist` /
7//! `execpline` / `execcmd` in `Src/exec.c` via `WC_KIND`/`wc_code`/
8//! `wc_data` macros walking `ecbuf`.
9//!
10//! zshrs built an AST tree as an intermediate step on the way to
11//! wordcode. This file holds those Rust-only AST node types.
12//! Originally lived in `src/ported/parse.rs` but relocated here for
13//! P9e of the PORT_PLAN.md migration to make their non-C-faithful
14//! nature explicit.
15//!
16//! Phase 9c (par_* wordcode emission) + Phase 9d (vm_helper wordcode
17//! consumer rewrite) will eventually retire these types entirely —
18//! the parser will emit wordcode directly and the executor will read
19//! wordcode directly, matching the C pipeline. Until then, the AST
20//! tree is the working IR.
21
22pub use crate::extensions::heredoc_ast::HereDocInfo;
23use serde::{Deserialize, Serialize};
24
25/// AST node for a complete program (list of commands)
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ZshProgram {
28    /// `lists` field.
29    pub lists: Vec<ZshList>,
30}
31
32/// A list is a sequence of sublists separated by ; or & or newline
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ZshList {
35    /// `sublist` field.
36    pub sublist: ZshSublist,
37    /// `flags` field.
38    pub flags: ListFlags,
39}
40/// `ListFlags` — see fields for layout.
41#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
42pub struct ListFlags {
43    /// Run asynchronously (&)
44    pub async_: bool,
45    /// Disown after running (&| or &!)
46    pub disown: bool,
47}
48
49/// A sublist is pipelines connected by && or ||
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ZshSublist {
52    /// `pipe` field.
53    pub pipe: ZshPipe,
54    /// `next` field.
55    pub next: Option<(SublistOp, Box<ZshSublist>)>,
56    /// `flags` field.
57    pub flags: SublistFlags,
58}
59/// `SublistOp` — see variants.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub enum SublistOp {
62    /// `And` variant.
63    And, // &&
64    /// `Or` variant.
65    Or, // ||
66}
67/// `SublistFlags` — see fields for layout.
68#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
69pub struct SublistFlags {
70    /// Coproc
71    pub coproc: bool,
72    /// Negated with !
73    pub not: bool,
74}
75
76/// A pipeline is commands connected by |
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ZshPipe {
79    /// `cmd` field.
80    pub cmd: ZshCommand,
81    /// `next` field.
82    pub next: Option<Box<ZshPipe>>,
83    /// `lineno` field.
84    pub lineno: u64,
85    /// `|&` between this stage and the next — merge stderr into the
86    /// pipe so the next stage's stdin sees both stdout AND stderr from
87    /// this stage. When `next` is None this flag is meaningless.
88    #[serde(default)]
89    pub merge_stderr: bool,
90}
91
92/// A command
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub enum ZshCommand {
95    /// `Simple` variant.
96    Simple(ZshSimple),
97    /// `Subsh` variant.
98    Subsh(Box<ZshProgram>), // (list)
99    /// `Cursh` variant.
100    Cursh(Box<ZshProgram>), // {list}
101    /// `For` variant.
102    For(ZshFor),
103    /// `Case` variant.
104    Case(ZshCase),
105    /// `If` variant.
106    If(ZshIf),
107    /// `While` variant.
108    While(ZshWhile),
109    /// `Until` variant.
110    Until(ZshWhile),
111    /// `Repeat` variant.
112    Repeat(ZshRepeat),
113    /// `FuncDef` variant.
114    FuncDef(ZshFuncDef),
115    /// `Time` variant.
116    Time(Option<Box<ZshSublist>>),
117    /// `Cond` variant.
118    Cond(ZshCond), // [[ ... ]]
119    /// `Arith` variant.
120    Arith(String), // (( ... ))
121    /// `Try` variant.
122    Try(ZshTry), // { ... } always { ... }
123    /// Compound command with trailing redirects:
124    /// `{ cmd } 2>&1`, `(...) >file`, `if ...; fi >file`, etc.
125    /// Simple commands carry redirects in their own struct; this wrapper
126    /// is only used for compound forms.
127    Redirected(Box<ZshCommand>, Vec<ZshRedir>),
128}
129
130/// A simple command (assignments, words, redirections)
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ZshSimple {
133    /// `assigns` field.
134    pub assigns: Vec<ZshAssign>,
135    /// `words` field.
136    pub words: Vec<String>,
137    /// `redirs` field.
138    pub redirs: Vec<ZshRedir>,
139}
140
141/// An assignment
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ZshAssign {
144    /// `name` field.
145    pub name: String,
146    /// `value` field.
147    pub value: ZshAssignValue,
148    pub append: bool, // +=
149}
150/// `ZshAssignValue` — see variants.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub enum ZshAssignValue {
153    /// `Scalar` variant.
154    Scalar(String),
155    /// `Array` variant.
156    Array(Vec<String>),
157}
158
159/// A redirection
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ZshRedir {
162    /// `rtype` field.
163    pub rtype: i32,
164    /// `fd` field.
165    pub fd: i32,
166    /// `name` field.
167    pub name: String,
168    /// `heredoc` field.
169    pub heredoc: Option<HereDocInfo>,
170    pub varid: Option<String>, // {var}>file
171    /// Index into the lexer-side `HEREDOCS` thread_local for body lookup. Filled in by
172    /// `parse_redirection` for Heredoc/HeredocDash, then resolved into
173    /// `heredoc.content` by `fill_heredoc_bodies` after process_heredocs
174    /// has run for the line.
175    #[serde(skip)]
176    pub heredoc_idx: Option<usize>,
177}
178
179/// For loop
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ZshFor {
182    /// `var` field.
183    pub var: String,
184    /// `list` field.
185    pub list: ForList,
186    /// `body` field.
187    pub body: Box<ZshProgram>,
188    /// True if this was parsed as `select` rather than `for`. Both share
189    /// the same parser, so the compiler routes on this flag.
190    #[serde(default)]
191    pub is_select: bool,
192}
193/// `ForList` — see variants.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub enum ForList {
196    /// `Words` variant.
197    Words(Vec<String>),
198    /// `CStyle` variant.
199    CStyle {
200        init: String,
201        cond: String,
202        step: String,
203    },
204    /// `Positional` variant.
205    Positional,
206}
207
208/// Case statement
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct ZshCase {
211    /// `word` field.
212    pub word: String,
213    /// `arms` field.
214    pub arms: Vec<CaseArm>,
215}
216/// `CaseArm` — see fields for layout.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct CaseArm {
219    /// `patterns` field.
220    pub patterns: Vec<String>,
221    /// `body` field.
222    pub body: ZshProgram,
223    /// `terminator` field.
224    pub terminator: CaseTerm,
225}
226/// `CaseTerm` — see variants.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228pub enum CaseTerm {
229    /// `Break` variant.
230    Break, // ;;
231    /// `Continue` variant.
232    Continue, // ;&
233    /// `TestNext` variant.
234    TestNext, // ;|
235}
236
237/// If statement
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct ZshIf {
240    /// `cond` field.
241    pub cond: Box<ZshProgram>,
242    /// `then` field.
243    pub then: Box<ZshProgram>,
244    /// `elif` field.
245    pub elif: Vec<(ZshProgram, ZshProgram)>,
246    /// `else_` field.
247    pub else_: Option<Box<ZshProgram>>,
248}
249
250/// While/Until loop
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ZshWhile {
253    /// `cond` field.
254    pub cond: Box<ZshProgram>,
255    /// `body` field.
256    pub body: Box<ZshProgram>,
257    /// `until` field.
258    pub until: bool,
259}
260
261/// Repeat loop
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ZshRepeat {
264    /// `count` field.
265    pub count: String,
266    /// `body` field.
267    pub body: Box<ZshProgram>,
268}
269
270/// Function definition
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct ZshFuncDef {
273    /// `names` field.
274    pub names: Vec<String>,
275    /// `body` field.
276    pub body: Box<ZshProgram>,
277    /// `tracing` field.
278    pub tracing: bool,
279    /// Anonymous-function call args. `() { body } a b` parses as a
280    /// FuncDef (auto-named) with `auto_call_args = Some(vec!["a", "b"])`.
281    /// compile_funcdef registers the function then emits a Simple call
282    /// with these args.
283    #[serde(default)]
284    pub auto_call_args: Option<Vec<String>>,
285    /// Original source text of the function body (the bytes between
286    /// `{` and `}`, without the braces themselves), captured at parse
287    /// time. Populated for `function name { body }` and `function name() { body }`
288    /// forms; left None for the synthesized inline-funcdef recovery
289    /// path. ZshCompiler::compile_funcdef forwards it to
290    /// `BUILTIN_REGISTER_COMPILED_FN` so introspection (`whence`, `which`,
291    /// `${functions[name]}`) has canonical source text.
292    #[serde(default)]
293    pub body_source: Option<String>,
294}
295
296/// Conditional expression [[ ... ]]
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub enum ZshCond {
299    /// `Not` variant.
300    Not(Box<ZshCond>),
301    /// `And` variant.
302    And(Box<ZshCond>, Box<ZshCond>),
303    /// `Or` variant.
304    Or(Box<ZshCond>, Box<ZshCond>),
305    /// `Unary` variant.
306    Unary(String, String), // -f file, -n str, etc.
307    /// `Binary` variant.
308    Binary(String, String, String), // str = pat, a -eq b, etc.
309    /// `Regex` variant.
310    Regex(String, String), // str =~ regex
311}
312
313/// Try/always block
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ZshTry {
316    /// `try_block` field.
317    pub try_block: Box<ZshProgram>,
318    /// `always` field.
319    pub always: Box<ZshProgram>,
320}
321
322/// Zsh parameter expansion flags
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub enum ZshParamFlag {
325    /// `Lower` variant.
326    Lower, // L - lowercase
327    /// `Upper` variant.
328    Upper, // U - uppercase
329    /// `Capitalize` variant.
330    Capitalize, // C - capitalize words
331    /// `Join` variant.
332    Join(String), // j:sep: - join array with separator
333    /// `JoinNewline` variant.
334    JoinNewline, // F - join with newlines
335    /// `Split` variant.
336    Split(String), // s:sep: - split string into array
337    /// `SplitLines` variant.
338    SplitLines, // f - split on newlines
339    /// `SplitWords` variant.
340    SplitWords, // z - split into words (shell parsing)
341    /// `Type` variant.
342    Type, // t - type of variable
343    /// `Words` variant.
344    Words, // w - word splitting
345    /// `Quote` variant.
346    Quote, // qq - single-quote always
347    /// `QuoteIfNeeded` variant.
348    QuoteIfNeeded, // q+ - single-quote only if needed
349    /// `DoubleQuote` variant.
350    DoubleQuote, // qqq - double-quote
351    /// `DollarQuote` variant.
352    DollarQuote, // qqqq - $'...' style
353    /// `QuoteBackslash` variant.
354    QuoteBackslash, // q / b / B - backslash-escape special chars
355    /// `Unique` variant.
356    Unique, // u - unique elements only
357    /// `Reverse` variant.
358    Reverse, // O - reverse sort
359    /// `Sort` variant.
360    Sort, // o - sort
361    /// `NumericSort` variant.
362    NumericSort, // n - numeric sort
363    /// `IndexSort` variant.
364    IndexSort, // a - sort in array index order
365    /// `Keys` variant.
366    Keys, // k - associative array keys
367    /// `Values` variant.
368    Values, // v - associative array values
369    /// `Length` variant.
370    Length, // # - length (character codes)
371    /// `CountChars` variant.
372    CountChars, // c - count total characters
373    /// `Expand` variant.
374    Expand, // e - perform shell expansions
375    /// `PromptExpand` variant.
376    PromptExpand, // % - expand prompt escapes
377    /// `PromptExpandFull` variant.
378    PromptExpandFull, // %% - full prompt expansion
379    /// `Visible` variant.
380    Visible, // V - make non-printable chars visible
381    /// `Directory` variant.
382    Directory, // D - substitute directory names
383    /// `Head` variant.
384    Head(usize), // [1,n] - first n elements
385    /// `Tail` variant.
386    Tail(usize), // [-n,-1] - last n elements
387    /// `PadLeft` variant.
388    PadLeft(usize, char), // l:len:fill: - pad left
389    /// `PadRight` variant.
390    PadRight(usize, char), // r:len:fill: - pad right
391    /// `Width` variant.
392    Width(usize), // m - use width for padding
393    /// `Match` variant.
394    Match, // M - include matched portion
395    /// `Remove` variant.
396    Remove, // R - include non-matched portion (complement of M)
397    /// `Subscript` variant.
398    Subscript, // S - subscript scanning
399    /// `Parameter` variant.
400    Parameter, // P - use value as parameter name (indirection)
401    /// `Glob` variant.
402    Glob, // ~ - glob patterns in pattern
403    /// `@` flag — force array-context behavior even inside DQ. zsh's
404    /// `"${(@o)arr}"` keeps the sort active and splices each element as
405    /// its own word. Without this, the array-only flags became no-ops
406    /// in DQ.
407    At,
408}
409
410/// List operator (for shell command lists)
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
412pub enum ListOp {
413    /// `And` variant.
414    And, // &&
415    /// `Or` variant.
416    Or, // ||
417    /// `Semi` variant.
418    Semi, // ;
419    /// `Amp` variant.
420    Amp, // &
421    /// `Newline` variant.
422    Newline, // \n
423}
424
425/// Shell word - can be simple literal or complex expansion
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub enum ShellWord {
428    /// Plain text token. Most ZWC-decoded words land here. Goes through
429    /// `expand_string` (plus glob/tilde/etc. as text-level transforms) for
430    /// final output.
431    Literal(String),
432    /// Concatenation of sub-words. ZWC array decoding produces this with
433    /// child Literals; nothing else constructs it now that the legacy
434    /// hand-rolled parser is gone.
435    Concat(Vec<ShellWord>),
436}
437
438/// Variable modifier for parameter expansion
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub enum VarModifier {
441    /// `Default` variant.
442    Default(ShellWord),
443    /// `DefaultAssign` variant.
444    DefaultAssign(ShellWord),
445    /// `Error` variant.
446    Error(ShellWord),
447    /// `Alternate` variant.
448    Alternate(ShellWord),
449    /// `Length` variant.
450    Length,
451    /// `Substring` variant.
452    Substring(i64, Option<i64>),
453    /// `RemovePrefix` variant.
454    RemovePrefix(ShellWord),
455    /// `RemovePrefixLong` variant.
456    RemovePrefixLong(ShellWord),
457    /// `RemoveSuffix` variant.
458    RemoveSuffix(ShellWord),
459    /// `RemoveSuffixLong` variant.
460    RemoveSuffixLong(ShellWord),
461    /// `Replace` variant.
462    Replace(ShellWord, ShellWord),
463    /// `ReplaceAll` variant.
464    ReplaceAll(ShellWord, ShellWord),
465    /// `${var/#pat/repl}` — anchored at start (prefix only).
466    /// Per Src/subst.c paramsubst's `/`-arm with SUB_START.
467    ReplacePrefix(ShellWord, ShellWord),
468    /// `${var/%pat/repl}` — anchored at end (suffix only).
469    /// Per Src/subst.c paramsubst's `/`-arm with SUB_END.
470    ReplaceSuffix(ShellWord, ShellWord),
471    /// `Upper` variant.
472    Upper,
473    /// `Lower` variant.
474    Lower,
475}
476
477/// Shell command - the old shell_ast compatible type
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub enum ShellCommand {
480    /// `Simple` variant.
481    Simple(SimpleCommand),
482    /// `Pipeline` variant.
483    Pipeline(Vec<ShellCommand>, bool),
484    List(Vec<(ShellCommand, ListOp)>),
485    /// `Compound` variant.
486    Compound(CompoundCommand),
487    /// `FunctionDef` variant.
488    FunctionDef(String, Box<ShellCommand>),
489}
490
491/// Simple command with assignments, words, and redirects
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct SimpleCommand {
494    /// `assignments` field.
495    pub assignments: Vec<(String, ShellWord, bool)>,
496    /// `words` field.
497    pub words: Vec<ShellWord>,
498    /// `redirects` field.
499    pub redirects: Vec<Redirect>,
500}
501
502/// Redirect
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct Redirect {
505    /// `fd` field.
506    pub fd: Option<i32>,
507    /// `op` field.
508    pub op: RedirectOp,
509    /// `target` field.
510    pub target: ShellWord,
511    /// `heredoc_content` field.
512    pub heredoc_content: Option<String>,
513    /// `fd_var` field.
514    pub fd_var: Option<String>,
515}
516
517/// Redirect operator
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
519pub enum RedirectOp {
520    /// `Write` variant.
521    Write,
522    /// `Append` variant.
523    Append,
524    /// `Read` variant.
525    Read,
526    /// `ReadWrite` variant.
527    ReadWrite,
528    /// `Clobber` variant.
529    Clobber,
530    /// `DupRead` variant.
531    DupRead,
532    /// `DupWrite` variant.
533    DupWrite,
534    /// `HereDoc` variant.
535    HereDoc,
536    /// `HereString` variant.
537    HereString,
538    /// `WriteBoth` variant.
539    WriteBoth,
540    /// `AppendBoth` variant.
541    AppendBoth,
542}
543
544/// Compound command
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub enum CompoundCommand {
547    /// `BraceGroup` variant.
548    BraceGroup(Vec<ShellCommand>),
549    /// `Subshell` variant.
550    Subshell(Vec<ShellCommand>),
551    /// `If` variant.
552    If {
553        conditions: Vec<(Vec<ShellCommand>, Vec<ShellCommand>)>,
554        else_part: Option<Vec<ShellCommand>>,
555    },
556    /// `For` variant.
557    For {
558        var: String,
559        words: Option<Vec<ShellWord>>,
560        body: Vec<ShellCommand>,
561    },
562    /// `ForArith` variant.
563    ForArith {
564        init: String,
565        cond: String,
566        step: String,
567        body: Vec<ShellCommand>,
568    },
569    /// `While` variant.
570    While {
571        condition: Vec<ShellCommand>,
572        body: Vec<ShellCommand>,
573    },
574    /// `Until` variant.
575    Until {
576        condition: Vec<ShellCommand>,
577        body: Vec<ShellCommand>,
578    },
579    /// `Case` variant.
580    Case {
581        word: ShellWord,
582        cases: Vec<(Vec<ShellWord>, Vec<ShellCommand>, CaseTerminator)>,
583    },
584    /// `Select` variant.
585    Select {
586        var: String,
587        words: Option<Vec<ShellWord>>,
588        body: Vec<ShellCommand>,
589    },
590    /// `Coproc` variant.
591    Coproc {
592        name: Option<String>,
593        body: Box<ShellCommand>,
594    },
595    /// repeat N do ... done
596    Repeat {
597        count: String,
598        body: Vec<ShellCommand>,
599    },
600    /// { try-block } always { always-block }
601    Try {
602        try_body: Vec<ShellCommand>,
603        always_body: Vec<ShellCommand>,
604    },
605    /// `Arith` variant.
606    Arith(String),
607    /// `WithRedirects` variant.
608    WithRedirects(Box<ShellCommand>, Vec<Redirect>),
609}
610
611/// Case terminator
612#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
613pub enum CaseTerminator {
614    /// `Break` variant.
615    Break,
616    /// `Fallthrough` variant.
617    Fallthrough,
618    /// `Continue` variant.
619    Continue,
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    // === Default impls for flag structs (load-bearing for parser init) ===
627
628    #[test]
629    fn list_flags_default_all_false() {
630        let f = ListFlags::default();
631        assert!(!f.async_, "default ListFlags.async_ must be false");
632        assert!(!f.disown, "default ListFlags.disown must be false");
633    }
634
635    #[test]
636    fn sublist_flags_default_all_false() {
637        let f = SublistFlags::default();
638        assert!(!f.coproc);
639        assert!(!f.not);
640    }
641
642    // === Serde round-trip: zshrs cache format must survive across versions ===
643
644    #[test]
645    fn zsh_program_empty_round_trips() {
646        // Empty program is the parse result for an empty input.
647        let p = ZshProgram { lists: vec![] };
648        let json = serde_json::to_string(&p).expect("serialize");
649        let back: ZshProgram = serde_json::from_str(&json).expect("deserialize");
650        assert_eq!(back.lists.len(), 0);
651    }
652
653    #[test]
654    fn zsh_simple_round_trips_with_assigns_and_redirs() {
655        // Simple command: FOO=bar BAZ=qux echo hi >out
656        let simple = ZshSimple {
657            assigns: vec![
658                ZshAssign {
659                    name: "FOO".to_string(),
660                    value: ZshAssignValue::Scalar("bar".to_string()),
661                    append: false,
662                },
663                ZshAssign {
664                    name: "BAZ".to_string(),
665                    value: ZshAssignValue::Scalar("qux".to_string()),
666                    append: true,
667                },
668            ],
669            words: vec!["echo".to_string(), "hi".to_string()],
670            redirs: vec![ZshRedir {
671                rtype: 0,
672                fd: 1,
673                name: "out".to_string(),
674                heredoc: None,
675                varid: None,
676                heredoc_idx: None,
677            }],
678        };
679        let json = serde_json::to_string(&simple).expect("serialize");
680        let back: ZshSimple = serde_json::from_str(&json).expect("deserialize");
681        assert_eq!(back.assigns.len(), 2);
682        assert_eq!(back.assigns[0].name, "FOO");
683        assert!(back.assigns[1].append, "+= flag must round-trip");
684        assert_eq!(back.words, vec!["echo", "hi"]);
685        assert_eq!(back.redirs.len(), 1);
686        assert_eq!(back.redirs[0].name, "out");
687    }
688
689    #[test]
690    fn assign_value_array_variant_round_trips() {
691        let v = ZshAssignValue::Array(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
692        let json = serde_json::to_string(&v).expect("serialize");
693        let back: ZshAssignValue = serde_json::from_str(&json).expect("deserialize");
694        match back {
695            ZshAssignValue::Array(items) => assert_eq!(items, vec!["a", "b", "c"]),
696            ZshAssignValue::Scalar(s) => panic!("expected Array, got Scalar({s:?})"),
697        }
698    }
699
700    #[test]
701    fn for_list_c_style_round_trips() {
702        let fl = ForList::CStyle {
703            init: "i=0".to_string(),
704            cond: "i<10".to_string(),
705            step: "i++".to_string(),
706        };
707        let json = serde_json::to_string(&fl).expect("serialize");
708        let back: ForList = serde_json::from_str(&json).expect("deserialize");
709        match back {
710            ForList::CStyle { init, cond, step } => {
711                assert_eq!(init, "i=0");
712                assert_eq!(cond, "i<10");
713                assert_eq!(step, "i++");
714            }
715            _ => panic!("expected CStyle variant"),
716        }
717    }
718
719    #[test]
720    fn for_list_positional_round_trips() {
721        // Positional: `for x do ... done` (no in-words clause).
722        let fl = ForList::Positional;
723        let json = serde_json::to_string(&fl).expect("serialize");
724        let back: ForList = serde_json::from_str(&json).expect("deserialize");
725        assert!(matches!(back, ForList::Positional));
726    }
727
728    #[test]
729    fn case_terminator_all_variants_round_trip() {
730        // ;; / ;& / ;| terminator variants — each must survive serde.
731        for t in [CaseTerm::Break, CaseTerm::Continue, CaseTerm::TestNext] {
732            let json = serde_json::to_string(&t).expect("serialize");
733            let back: CaseTerm = serde_json::from_str(&json).expect("deserialize");
734            // CaseTerm is Copy + PartialEq, so equality is meaningful.
735            assert_eq!(back, t);
736        }
737    }
738
739    #[test]
740    fn sublist_op_round_trips_both_variants() {
741        for op in [SublistOp::And, SublistOp::Or] {
742            let json = serde_json::to_string(&op).expect("serialize");
743            let back: SublistOp = serde_json::from_str(&json).expect("deserialize");
744            assert_eq!(back, op);
745        }
746    }
747
748    #[test]
749    fn list_op_round_trips_all_variants() {
750        for op in [
751            ListOp::And,
752            ListOp::Or,
753            ListOp::Semi,
754            ListOp::Amp,
755            ListOp::Newline,
756        ] {
757            let json = serde_json::to_string(&op).expect("serialize");
758            let back: ListOp = serde_json::from_str(&json).expect("deserialize");
759            assert_eq!(back, op);
760        }
761    }
762
763    #[test]
764    fn shell_word_concat_round_trips_nested() {
765        // Concat is the AST shape used for word-internal sub-expansions —
766        // verify nesting survives.
767        let w = ShellWord::Concat(vec![
768            ShellWord::Literal("foo".to_string()),
769            ShellWord::Literal("bar".to_string()),
770            ShellWord::Concat(vec![ShellWord::Literal("baz".to_string())]),
771        ]);
772        let json = serde_json::to_string(&w).expect("serialize");
773        let back: ShellWord = serde_json::from_str(&json).expect("deserialize");
774        match back {
775            ShellWord::Concat(parts) => {
776                assert_eq!(parts.len(), 3, "outer concat must preserve element count");
777                match &parts[2] {
778                    ShellWord::Concat(inner) => assert_eq!(inner.len(), 1),
779                    _ => panic!("nested concat lost"),
780                }
781            }
782            _ => panic!("expected Concat top-level"),
783        }
784    }
785
786    #[test]
787    fn zsh_cond_nested_serialization() {
788        // [[ -f file && ! -d dir ]] type compound — verify nested
789        // And/Not survive a round-trip.
790        let c = ZshCond::And(
791            Box::new(ZshCond::Unary("-f".to_string(), "file".to_string())),
792            Box::new(ZshCond::Not(Box::new(ZshCond::Unary(
793                "-d".to_string(),
794                "dir".to_string(),
795            )))),
796        );
797        let json = serde_json::to_string(&c).expect("serialize");
798        let back: ZshCond = serde_json::from_str(&json).expect("deserialize");
799        match back {
800            ZshCond::And(lhs, rhs) => {
801                assert!(matches!(*lhs, ZshCond::Unary(_, _)));
802                assert!(matches!(*rhs, ZshCond::Not(_)));
803            }
804            _ => panic!("expected And at root"),
805        }
806    }
807
808    #[test]
809    fn redirect_op_all_variants_round_trip() {
810        for op in [
811            RedirectOp::Write,
812            RedirectOp::Append,
813            RedirectOp::Read,
814            RedirectOp::ReadWrite,
815            RedirectOp::Clobber,
816            RedirectOp::DupRead,
817            RedirectOp::DupWrite,
818            RedirectOp::HereDoc,
819            RedirectOp::HereString,
820            RedirectOp::WriteBoth,
821            RedirectOp::AppendBoth,
822        ] {
823            let json = serde_json::to_string(&op).expect("serialize");
824            let back: RedirectOp = serde_json::from_str(&json).expect("deserialize");
825            assert_eq!(back, op);
826        }
827    }
828
829    #[test]
830    fn zsh_funcdef_serde_default_fields() {
831        // ZshFuncDef has #[serde(default)] on auto_call_args and
832        // body_source — JSON missing those fields must still decode.
833        let json = r#"{
834            "names": ["myfn"],
835            "body": { "lists": [] },
836            "tracing": false
837        }"#;
838        let fd: ZshFuncDef = serde_json::from_str(json).expect("default fields must apply");
839        assert_eq!(fd.names, vec!["myfn"]);
840        assert!(fd.auto_call_args.is_none());
841        assert!(fd.body_source.is_none());
842        assert!(!fd.tracing);
843    }
844
845    #[test]
846    fn zsh_pipe_merge_stderr_default_when_missing() {
847        // `merge_stderr` defaults to false via #[serde(default)] —
848        // older cache entries lacking the field must still decode.
849        let json = r#"{
850            "cmd": { "Simple": { "assigns": [], "words": ["x"], "redirs": [] } },
851            "next": null,
852            "lineno": 1
853        }"#;
854        let pipe: ZshPipe = serde_json::from_str(json).expect("default must apply");
855        assert!(!pipe.merge_stderr);
856        assert_eq!(pipe.lineno, 1);
857    }
858}