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}