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    /// c:Src/parse.c:1931-1932 — the command word was the TYPESET reserved
140    /// word (`typeset`, `local`, `export`, … while enabled and unquoted), so
141    /// C emits WC_TYPESET and its `NAME=value` arguments are assignments.
142    /// The same name reached any other way (`builtin typeset`, `\typeset`,
143    /// after `disable -r typeset`) is an ordinary BINF_MAGICEQUALS builtin
144    /// (c:Src/exec.c:3353-3355). Absent in older serialized trees, where
145    /// every typeset-family command was treated as the reserved word.
146    #[serde(default = "typeset_reswd_default")]
147    pub typeset_reswd: bool,
148    /// zshrs-only (no C counterpart): the alias this command's FIRST word came out
149    /// of, with the number of words in that alias's body. The alias is gone from
150    /// the text by the time the command runs, so this is how `intercept … NAME`
151    /// advises an alias: the arguments after the body are the ones the user typed.
152    #[serde(default)]
153    pub via_alias: Option<(String, usize)>,
154}
155
156fn typeset_reswd_default() -> bool {
157    true
158}
159
160/// An assignment
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ZshAssign {
163    /// `name` field.
164    pub name: String,
165    /// `value` field.
166    pub value: ZshAssignValue,
167    pub append: bool, // +=
168}
169/// `ZshAssignValue` — see variants.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub enum ZshAssignValue {
172    /// `Scalar` variant.
173    Scalar(String),
174    /// `Array` variant.
175    Array(Vec<String>),
176}
177
178/// A redirection
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ZshRedir {
181    /// `rtype` field.
182    pub rtype: i32,
183    /// `fd` field.
184    pub fd: i32,
185    /// `name` field.
186    pub name: String,
187    /// `heredoc` field.
188    pub heredoc: Option<HereDocInfo>,
189    pub varid: Option<String>, // {var}>file
190    /// Index into the lexer-side `HEREDOCS` thread_local for body lookup. Filled in by
191    /// `parse_redirection` for Heredoc/HeredocDash, then resolved into
192    /// `heredoc.content` by `fill_heredoc_bodies` after process_heredocs
193    /// has run for the line.
194    #[serde(skip)]
195    pub heredoc_idx: Option<usize>,
196}
197
198/// For loop
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ZshFor {
201    /// `var` field.
202    pub var: String,
203    /// `list` field.
204    pub list: ForList,
205    /// `body` field.
206    pub body: Box<ZshProgram>,
207    /// True if this was parsed as `select` rather than `for`. Both share
208    /// the same parser, so the compiler routes on this flag.
209    #[serde(default)]
210    pub is_select: bool,
211}
212/// `ForList` — see variants.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub enum ForList {
215    /// `Words` variant.
216    Words(Vec<String>),
217    /// `CStyle` variant.
218    CStyle {
219        init: String,
220        cond: String,
221        step: String,
222    },
223    /// `Positional` variant.
224    Positional,
225}
226
227/// Case statement
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ZshCase {
230    /// `word` field.
231    pub word: String,
232    /// `arms` field.
233    pub arms: Vec<CaseArm>,
234}
235/// `CaseArm` — see fields for layout.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct CaseArm {
238    /// `patterns` field.
239    pub patterns: Vec<String>,
240    /// `body` field.
241    pub body: ZshProgram,
242    /// `terminator` field.
243    pub terminator: CaseTerm,
244}
245/// `CaseTerm` — see variants.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247pub enum CaseTerm {
248    /// `Break` variant.
249    Break, // ;;
250    /// `Continue` variant.
251    Continue, // ;&
252    /// `TestNext` variant.
253    TestNext, // ;|
254}
255
256/// If statement
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct ZshIf {
259    /// `cond` field.
260    pub cond: Box<ZshProgram>,
261    /// `then` field.
262    pub then: Box<ZshProgram>,
263    /// `elif` field.
264    pub elif: Vec<(ZshProgram, ZshProgram)>,
265    /// `else_` field.
266    pub else_: Option<Box<ZshProgram>>,
267}
268
269/// While/Until loop
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct ZshWhile {
272    /// `cond` field.
273    pub cond: Box<ZshProgram>,
274    /// `body` field.
275    pub body: Box<ZshProgram>,
276    /// `until` field.
277    pub until: bool,
278}
279
280/// Repeat loop
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct ZshRepeat {
283    /// `count` field.
284    pub count: String,
285    /// `body` field.
286    pub body: Box<ZshProgram>,
287}
288
289/// Function definition
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct ZshFuncDef {
292    /// `names` field.
293    pub names: Vec<String>,
294    /// `body` field.
295    pub body: Box<ZshProgram>,
296    /// `tracing` field.
297    pub tracing: bool,
298    /// True for the `function name { … }` keyword spelling, false for the POSIX
299    /// `name() { … }` spelling. ksh93 treats the two differently (function-local
300    /// EXIT traps), so the spelling travels to registration.
301    #[serde(default)]
302    pub keyword_form: bool,
303    /// Anonymous-function call args. `() { body } a b` parses as a
304    /// FuncDef (auto-named) with `auto_call_args = Some(vec!["a", "b"])`.
305    /// compile_funcdef registers the function then emits a Simple call
306    /// with these args.
307    #[serde(default)]
308    pub auto_call_args: Option<Vec<String>>,
309    /// Original source text of the function body (the bytes between
310    /// `{` and `}`, without the braces themselves), captured at parse
311    /// time. Populated for `function name { body }` and `function name() { body }`
312    /// forms; left None for the synthesized inline-funcdef recovery
313    /// path. ZshCompiler::compile_funcdef forwards it to
314    /// `BUILTIN_REGISTER_COMPILED_FN` so introspection (`whence`, `which`,
315    /// `${functions[name]}`) has canonical source text.
316    #[serde(default)]
317    pub body_source: Option<String>,
318}
319
320/// Conditional expression [[ ... ]]
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub enum ZshCond {
323    /// `Not` variant.
324    Not(Box<ZshCond>),
325    /// `And` variant.
326    And(Box<ZshCond>, Box<ZshCond>),
327    /// `Or` variant.
328    Or(Box<ZshCond>, Box<ZshCond>),
329    /// `Unary` variant.
330    Unary(String, String), // -f file, -n str, etc.
331    /// `Binary` variant.
332    Binary(String, String, String), // str = pat, a -eq b, etc.
333    /// `Regex` variant.
334    Regex(String, String), // str =~ regex
335    /// Module/completion condition: `[[ -prefix PAT ]]`, `[[ -after PAT ]]`,
336    /// etc. Any multi-char `-X` operator in a `[[ ]]` becomes a module
337    /// condition (C `COND_MOD`, parse.c:2634) — the operator name (with its
338    /// leading dash) plus its operand words. Registration/validity is checked
339    /// at EVAL time (unknown names → "unknown condition: -X"), not at parse.
340    ModCond(String, Vec<String>),
341}
342
343/// Try/always block
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct ZshTry {
346    /// `try_block` field.
347    pub try_block: Box<ZshProgram>,
348    /// `always` field.
349    pub always: Box<ZshProgram>,
350}
351
352/// Zsh parameter expansion flags
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub enum ZshParamFlag {
355    /// `Lower` variant.
356    Lower, // L - lowercase
357    /// `Upper` variant.
358    Upper, // U - uppercase
359    /// `Capitalize` variant.
360    Capitalize, // C - capitalize words
361    /// `Join` variant.
362    Join(String), // j:sep: - join array with separator
363    /// `JoinNewline` variant.
364    JoinNewline, // F - join with newlines
365    /// `Split` variant.
366    Split(String), // s:sep: - split string into array
367    /// `SplitLines` variant.
368    SplitLines, // f - split on newlines
369    /// `SplitWords` variant.
370    SplitWords, // z - split into words (shell parsing)
371    /// `Type` variant.
372    Type, // t - type of variable
373    /// `Words` variant.
374    Words, // w - word splitting
375    /// `Quote` variant.
376    Quote, // qq - single-quote always
377    /// `QuoteIfNeeded` variant.
378    QuoteIfNeeded, // q+ - single-quote only if needed
379    /// `DoubleQuote` variant.
380    DoubleQuote, // qqq - double-quote
381    /// `DollarQuote` variant.
382    DollarQuote, // qqqq - $'...' style
383    /// `QuoteBackslash` variant.
384    QuoteBackslash, // q / b / B - backslash-escape special chars
385    /// `Unique` variant.
386    Unique, // u - unique elements only
387    /// `Reverse` variant.
388    Reverse, // O - reverse sort
389    /// `Sort` variant.
390    Sort, // o - sort
391    /// `NumericSort` variant.
392    NumericSort, // n - numeric sort
393    /// `IndexSort` variant.
394    IndexSort, // a - sort in array index order
395    /// `Keys` variant.
396    Keys, // k - associative array keys
397    /// `Values` variant.
398    Values, // v - associative array values
399    /// `Length` variant.
400    Length, // # - length (character codes)
401    /// `CountChars` variant.
402    CountChars, // c - count total characters
403    /// `Expand` variant.
404    Expand, // e - perform shell expansions
405    /// `PromptExpand` variant.
406    PromptExpand, // % - expand prompt escapes
407    /// `PromptExpandFull` variant.
408    PromptExpandFull, // %% - full prompt expansion
409    /// `Visible` variant.
410    Visible, // V - make non-printable chars visible
411    /// `Directory` variant.
412    Directory, // D - substitute directory names
413    /// `Head` variant.
414    Head(usize), // [1,n] - first n elements
415    /// `Tail` variant.
416    Tail(usize), // [-n,-1] - last n elements
417    /// `PadLeft` variant.
418    PadLeft(usize, char), // l:len:fill: - pad left
419    /// `PadRight` variant.
420    PadRight(usize, char), // r:len:fill: - pad right
421    /// `Width` variant.
422    Width(usize), // m - use width for padding
423    /// `Match` variant.
424    Match, // M - include matched portion
425    /// `Remove` variant.
426    Remove, // R - include non-matched portion (complement of M)
427    /// `Subscript` variant.
428    Subscript, // S - subscript scanning
429    /// `Parameter` variant.
430    Parameter, // P - use value as parameter name (indirection)
431    /// `Glob` variant.
432    Glob, // ~ - glob patterns in pattern
433    /// `@` flag — force array-context behavior even inside DQ. zsh's
434    /// `"${(@o)arr}"` keeps the sort active and splices each element as
435    /// its own word. Without this, the array-only flags became no-ops
436    /// in DQ.
437    At,
438}
439
440/// List operator (for shell command lists)
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
442pub enum ListOp {
443    /// `And` variant.
444    And, // &&
445    /// `Or` variant.
446    Or, // ||
447    /// `Semi` variant.
448    Semi, // ;
449    /// `Amp` variant.
450    Amp, // &
451    /// `Newline` variant.
452    Newline, // \n
453}
454
455/// Shell word - can be simple literal or complex expansion
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub enum ShellWord {
458    /// Plain text token. Most ZWC-decoded words land here. Goes through
459    /// `expand_string` (plus glob/tilde/etc. as text-level transforms) for
460    /// final output.
461    Literal(String),
462    /// Concatenation of sub-words. ZWC array decoding produces this with
463    /// child Literals; nothing else constructs it now that the legacy
464    /// hand-rolled parser is gone.
465    Concat(Vec<ShellWord>),
466}
467
468/// Variable modifier for parameter expansion
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub enum VarModifier {
471    /// `Default` variant.
472    Default(ShellWord),
473    /// `DefaultAssign` variant.
474    DefaultAssign(ShellWord),
475    /// `Error` variant.
476    Error(ShellWord),
477    /// `Alternate` variant.
478    Alternate(ShellWord),
479    /// `Length` variant.
480    Length,
481    /// `Substring` variant.
482    Substring(i64, Option<i64>),
483    /// `RemovePrefix` variant.
484    RemovePrefix(ShellWord),
485    /// `RemovePrefixLong` variant.
486    RemovePrefixLong(ShellWord),
487    /// `RemoveSuffix` variant.
488    RemoveSuffix(ShellWord),
489    /// `RemoveSuffixLong` variant.
490    RemoveSuffixLong(ShellWord),
491    /// `Replace` variant.
492    Replace(ShellWord, ShellWord),
493    /// `ReplaceAll` variant.
494    ReplaceAll(ShellWord, ShellWord),
495    /// `${var/#pat/repl}` — anchored at start (prefix only).
496    /// Per Src/subst.c paramsubst's `/`-arm with SUB_START.
497    ReplacePrefix(ShellWord, ShellWord),
498    /// `${var/%pat/repl}` — anchored at end (suffix only).
499    /// Per Src/subst.c paramsubst's `/`-arm with SUB_END.
500    ReplaceSuffix(ShellWord, ShellWord),
501    /// `Upper` variant.
502    Upper,
503    /// `Lower` variant.
504    Lower,
505}
506
507/// Shell command - the old shell_ast compatible type
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub enum ShellCommand {
510    /// `Simple` variant.
511    Simple(SimpleCommand),
512    /// `Pipeline` variant.
513    Pipeline(Vec<ShellCommand>, bool),
514    List(Vec<(ShellCommand, ListOp)>),
515    /// `Compound` variant.
516    Compound(CompoundCommand),
517    /// `FunctionDef` variant.
518    FunctionDef(String, Box<ShellCommand>),
519}
520
521/// Simple command with assignments, words, and redirects
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct SimpleCommand {
524    /// `assignments` field.
525    pub assignments: Vec<(String, ShellWord, bool)>,
526    /// `words` field.
527    pub words: Vec<ShellWord>,
528    /// `redirects` field.
529    pub redirects: Vec<Redirect>,
530}
531
532/// Redirect
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct Redirect {
535    /// `fd` field.
536    pub fd: Option<i32>,
537    /// `op` field.
538    pub op: RedirectOp,
539    /// `target` field.
540    pub target: ShellWord,
541    /// `heredoc_content` field.
542    pub heredoc_content: Option<String>,
543    /// `fd_var` field.
544    pub fd_var: Option<String>,
545}
546
547/// Redirect operator
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
549pub enum RedirectOp {
550    /// `Write` variant.
551    Write,
552    /// `Append` variant.
553    Append,
554    /// `Read` variant.
555    Read,
556    /// `ReadWrite` variant.
557    ReadWrite,
558    /// `Clobber` variant.
559    Clobber,
560    /// `DupRead` variant.
561    DupRead,
562    /// `DupWrite` variant.
563    DupWrite,
564    /// `HereDoc` variant.
565    HereDoc,
566    /// `HereString` variant.
567    HereString,
568    /// `WriteBoth` variant.
569    WriteBoth,
570    /// `AppendBoth` variant.
571    AppendBoth,
572}
573
574/// Compound command
575#[derive(Debug, Clone, Serialize, Deserialize)]
576pub enum CompoundCommand {
577    /// `BraceGroup` variant.
578    BraceGroup(Vec<ShellCommand>),
579    /// `Subshell` variant.
580    Subshell(Vec<ShellCommand>),
581    /// `If` variant.
582    If {
583        conditions: Vec<(Vec<ShellCommand>, Vec<ShellCommand>)>,
584        else_part: Option<Vec<ShellCommand>>,
585    },
586    /// `For` variant.
587    For {
588        var: String,
589        words: Option<Vec<ShellWord>>,
590        body: Vec<ShellCommand>,
591    },
592    /// `ForArith` variant.
593    ForArith {
594        init: String,
595        cond: String,
596        step: String,
597        body: Vec<ShellCommand>,
598    },
599    /// `While` variant.
600    While {
601        condition: Vec<ShellCommand>,
602        body: Vec<ShellCommand>,
603    },
604    /// `Until` variant.
605    Until {
606        condition: Vec<ShellCommand>,
607        body: Vec<ShellCommand>,
608    },
609    /// `Case` variant.
610    Case {
611        word: ShellWord,
612        cases: Vec<(Vec<ShellWord>, Vec<ShellCommand>, CaseTerminator)>,
613    },
614    /// `Select` variant.
615    Select {
616        var: String,
617        words: Option<Vec<ShellWord>>,
618        body: Vec<ShellCommand>,
619    },
620    /// `Coproc` variant.
621    Coproc {
622        name: Option<String>,
623        body: Box<ShellCommand>,
624    },
625    /// repeat N do ... done
626    Repeat {
627        count: String,
628        body: Vec<ShellCommand>,
629    },
630    /// { try-block } always { always-block }
631    Try {
632        try_body: Vec<ShellCommand>,
633        always_body: Vec<ShellCommand>,
634    },
635    /// `Arith` variant.
636    Arith(String),
637    /// `WithRedirects` variant.
638    WithRedirects(Box<ShellCommand>, Vec<Redirect>),
639}
640
641/// Case terminator
642#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
643pub enum CaseTerminator {
644    /// `Break` variant.
645    Break,
646    /// `Fallthrough` variant.
647    Fallthrough,
648    /// `Continue` variant.
649    Continue,
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    // === Default impls for flag structs (load-bearing for parser init) ===
657
658    #[test]
659    fn list_flags_default_all_false() {
660        let f = ListFlags::default();
661        assert!(!f.async_, "default ListFlags.async_ must be false");
662        assert!(!f.disown, "default ListFlags.disown must be false");
663    }
664
665    #[test]
666    fn sublist_flags_default_all_false() {
667        let f = SublistFlags::default();
668        assert!(!f.coproc);
669        assert!(!f.not);
670    }
671
672    // === Serde round-trip: zshrs cache format must survive across versions ===
673
674    #[test]
675    fn zsh_program_empty_round_trips() {
676        // Empty program is the parse result for an empty input.
677        let p = ZshProgram { lists: vec![] };
678        let json = serde_json::to_string(&p).expect("serialize");
679        let back: ZshProgram = serde_json::from_str(&json).expect("deserialize");
680        assert_eq!(back.lists.len(), 0);
681    }
682
683    #[test]
684    fn zsh_simple_round_trips_with_assigns_and_redirs() {
685        // Simple command: FOO=bar BAZ=qux echo hi >out
686        let simple = ZshSimple {
687            assigns: vec![
688                ZshAssign {
689                    name: "FOO".to_string(),
690                    value: ZshAssignValue::Scalar("bar".to_string()),
691                    append: false,
692                },
693                ZshAssign {
694                    name: "BAZ".to_string(),
695                    value: ZshAssignValue::Scalar("qux".to_string()),
696                    append: true,
697                },
698            ],
699            words: vec!["echo".to_string(), "hi".to_string()],
700            redirs: vec![ZshRedir {
701                rtype: 0,
702                fd: 1,
703                name: "out".to_string(),
704                heredoc: None,
705                varid: None,
706                heredoc_idx: None,
707            }],
708            typeset_reswd: false,
709            via_alias: None,
710        };
711        let json = serde_json::to_string(&simple).expect("serialize");
712        let back: ZshSimple = serde_json::from_str(&json).expect("deserialize");
713        assert_eq!(back.assigns.len(), 2);
714        assert_eq!(back.assigns[0].name, "FOO");
715        assert!(back.assigns[1].append, "+= flag must round-trip");
716        assert_eq!(back.words, vec!["echo", "hi"]);
717        assert_eq!(back.redirs.len(), 1);
718        assert_eq!(back.redirs[0].name, "out");
719    }
720
721    #[test]
722    fn assign_value_array_variant_round_trips() {
723        let v = ZshAssignValue::Array(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
724        let json = serde_json::to_string(&v).expect("serialize");
725        let back: ZshAssignValue = serde_json::from_str(&json).expect("deserialize");
726        match back {
727            ZshAssignValue::Array(items) => assert_eq!(items, vec!["a", "b", "c"]),
728            ZshAssignValue::Scalar(s) => panic!("expected Array, got Scalar({s:?})"),
729        }
730    }
731
732    #[test]
733    fn for_list_c_style_round_trips() {
734        let fl = ForList::CStyle {
735            init: "i=0".to_string(),
736            cond: "i<10".to_string(),
737            step: "i++".to_string(),
738        };
739        let json = serde_json::to_string(&fl).expect("serialize");
740        let back: ForList = serde_json::from_str(&json).expect("deserialize");
741        match back {
742            ForList::CStyle { init, cond, step } => {
743                assert_eq!(init, "i=0");
744                assert_eq!(cond, "i<10");
745                assert_eq!(step, "i++");
746            }
747            _ => panic!("expected CStyle variant"),
748        }
749    }
750
751    #[test]
752    fn for_list_positional_round_trips() {
753        // Positional: `for x do ... done` (no in-words clause).
754        let fl = ForList::Positional;
755        let json = serde_json::to_string(&fl).expect("serialize");
756        let back: ForList = serde_json::from_str(&json).expect("deserialize");
757        assert!(matches!(back, ForList::Positional));
758    }
759
760    #[test]
761    fn case_terminator_all_variants_round_trip() {
762        // ;; / ;& / ;| terminator variants — each must survive serde.
763        for t in [CaseTerm::Break, CaseTerm::Continue, CaseTerm::TestNext] {
764            let json = serde_json::to_string(&t).expect("serialize");
765            let back: CaseTerm = serde_json::from_str(&json).expect("deserialize");
766            // CaseTerm is Copy + PartialEq, so equality is meaningful.
767            assert_eq!(back, t);
768        }
769    }
770
771    #[test]
772    fn sublist_op_round_trips_both_variants() {
773        for op in [SublistOp::And, SublistOp::Or] {
774            let json = serde_json::to_string(&op).expect("serialize");
775            let back: SublistOp = serde_json::from_str(&json).expect("deserialize");
776            assert_eq!(back, op);
777        }
778    }
779
780    #[test]
781    fn list_op_round_trips_all_variants() {
782        for op in [
783            ListOp::And,
784            ListOp::Or,
785            ListOp::Semi,
786            ListOp::Amp,
787            ListOp::Newline,
788        ] {
789            let json = serde_json::to_string(&op).expect("serialize");
790            let back: ListOp = serde_json::from_str(&json).expect("deserialize");
791            assert_eq!(back, op);
792        }
793    }
794
795    #[test]
796    fn shell_word_concat_round_trips_nested() {
797        // Concat is the AST shape used for word-internal sub-expansions —
798        // verify nesting survives.
799        let w = ShellWord::Concat(vec![
800            ShellWord::Literal("foo".to_string()),
801            ShellWord::Literal("bar".to_string()),
802            ShellWord::Concat(vec![ShellWord::Literal("baz".to_string())]),
803        ]);
804        let json = serde_json::to_string(&w).expect("serialize");
805        let back: ShellWord = serde_json::from_str(&json).expect("deserialize");
806        match back {
807            ShellWord::Concat(parts) => {
808                assert_eq!(parts.len(), 3, "outer concat must preserve element count");
809                match &parts[2] {
810                    ShellWord::Concat(inner) => assert_eq!(inner.len(), 1),
811                    _ => panic!("nested concat lost"),
812                }
813            }
814            _ => panic!("expected Concat top-level"),
815        }
816    }
817
818    #[test]
819    fn zsh_cond_nested_serialization() {
820        // [[ -f file && ! -d dir ]] type compound — verify nested
821        // And/Not survive a round-trip.
822        let c = ZshCond::And(
823            Box::new(ZshCond::Unary("-f".to_string(), "file".to_string())),
824            Box::new(ZshCond::Not(Box::new(ZshCond::Unary(
825                "-d".to_string(),
826                "dir".to_string(),
827            )))),
828        );
829        let json = serde_json::to_string(&c).expect("serialize");
830        let back: ZshCond = serde_json::from_str(&json).expect("deserialize");
831        match back {
832            ZshCond::And(lhs, rhs) => {
833                assert!(matches!(*lhs, ZshCond::Unary(_, _)));
834                assert!(matches!(*rhs, ZshCond::Not(_)));
835            }
836            _ => panic!("expected And at root"),
837        }
838    }
839
840    #[test]
841    fn redirect_op_all_variants_round_trip() {
842        for op in [
843            RedirectOp::Write,
844            RedirectOp::Append,
845            RedirectOp::Read,
846            RedirectOp::ReadWrite,
847            RedirectOp::Clobber,
848            RedirectOp::DupRead,
849            RedirectOp::DupWrite,
850            RedirectOp::HereDoc,
851            RedirectOp::HereString,
852            RedirectOp::WriteBoth,
853            RedirectOp::AppendBoth,
854        ] {
855            let json = serde_json::to_string(&op).expect("serialize");
856            let back: RedirectOp = serde_json::from_str(&json).expect("deserialize");
857            assert_eq!(back, op);
858        }
859    }
860
861    #[test]
862    fn zsh_funcdef_serde_default_fields() {
863        // ZshFuncDef has #[serde(default)] on auto_call_args and
864        // body_source — JSON missing those fields must still decode.
865        let json = r#"{
866            "names": ["myfn"],
867            "body": { "lists": [] },
868            "tracing": false
869        }"#;
870        let fd: ZshFuncDef = serde_json::from_str(json).expect("default fields must apply");
871        assert_eq!(fd.names, vec!["myfn"]);
872        assert!(fd.auto_call_args.is_none());
873        assert!(fd.body_source.is_none());
874        assert!(!fd.tracing);
875    }
876
877    #[test]
878    fn zsh_pipe_merge_stderr_default_when_missing() {
879        // `merge_stderr` defaults to false via #[serde(default)] —
880        // older cache entries lacking the field must still decode.
881        let json = r#"{
882            "cmd": { "Simple": { "assigns": [], "words": ["x"], "redirs": [] } },
883            "next": null,
884            "lineno": 1
885        }"#;
886        let pipe: ZshPipe = serde_json::from_str(json).expect("default must apply");
887        assert!(!pipe.merge_stderr);
888        assert_eq!(pipe.lineno, 1);
889    }
890}