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}