Skip to main content

shuck_ast/
ast.rs

1//! AST types for parsed bash scripts
2//!
3//! These types define the abstract syntax tree for bash scripts.
4//! All command nodes include source location spans for error messages and $LINENO.
5
6#![allow(dead_code)]
7
8use crate::{
9    Name,
10    span::{Position, Span, TextRange},
11};
12use std::{
13    borrow::Cow,
14    fmt,
15    ops::{Deref, DerefMut},
16};
17
18fn assert_string_write(result: fmt::Result) {
19    if result.is_err() {
20        unreachable!("writing into a String should not fail");
21    }
22}
23
24/// Source-backed text for AST nodes that need stable spans but only occasionally
25/// need owned cooked text.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SourceText {
28    span: Span,
29    cooked: Option<Box<str>>,
30}
31
32impl SourceText {
33    pub fn source(span: Span) -> Self {
34        Self { span, cooked: None }
35    }
36
37    pub fn cooked(span: Span, text: impl Into<Box<str>>) -> Self {
38        Self {
39            span,
40            cooked: Some(text.into()),
41        }
42    }
43
44    pub fn span(&self) -> Span {
45        self.span
46    }
47
48    pub fn slice<'a>(&'a self, source: &'a str) -> &'a str {
49        self.cooked
50            .as_deref()
51            .unwrap_or_else(|| self.span.slice(source))
52    }
53
54    pub fn is_source_backed(&self) -> bool {
55        self.cooked.is_none()
56    }
57
58    pub fn rebased(&mut self, base: Position) {
59        self.span = self.span.rebased(base);
60    }
61}
62
63impl From<Span> for SourceText {
64    fn from(span: Span) -> Self {
65        Self::source(span)
66    }
67}
68
69impl From<&str> for SourceText {
70    fn from(value: &str) -> Self {
71        Self::cooked(Span::new(), value)
72    }
73}
74
75impl From<String> for SourceText {
76    fn from(value: String) -> Self {
77        Self::cooked(Span::new(), value)
78    }
79}
80
81/// Literal text within a word part.
82///
83/// Most literals can be recovered directly from the containing part node span.
84/// Owned text is kept only for cooked or synthetic literals.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum LiteralText {
87    Source,
88    Owned(Box<str>),
89    CookedSource(Box<str>),
90}
91
92impl LiteralText {
93    pub fn source() -> Self {
94        Self::Source
95    }
96
97    pub fn owned(text: impl Into<Box<str>>) -> Self {
98        Self::Owned(text.into())
99    }
100
101    pub fn cooked_source(text: impl Into<Box<str>>) -> Self {
102        Self::CookedSource(text.into())
103    }
104
105    pub fn as_str<'a>(&'a self, source: &'a str, span: Span) -> &'a str {
106        match self {
107            Self::Source => span.slice(source),
108            Self::Owned(text) | Self::CookedSource(text) => text.as_ref(),
109        }
110    }
111
112    pub fn syntax_str<'a>(&'a self, source: &'a str, span: Span) -> &'a str {
113        match self {
114            Self::Source | Self::CookedSource(_) => span.slice(source),
115            Self::Owned(text) => text.as_ref(),
116        }
117    }
118
119    pub fn eq_str(&self, source: &str, span: Span, other: &str) -> bool {
120        self.as_str(source, span) == other
121    }
122
123    pub fn is_source_backed(&self) -> bool {
124        matches!(self, Self::Source | Self::CookedSource(_))
125    }
126
127    pub fn is_empty(&self) -> bool {
128        matches!(self, Self::Owned(text) | Self::CookedSource(text) if text.is_empty())
129    }
130}
131
132impl From<&str> for LiteralText {
133    fn from(value: &str) -> Self {
134        Self::owned(value)
135    }
136}
137
138impl From<String> for LiteralText {
139    fn from(value: String) -> Self {
140        Self::owned(value)
141    }
142}
143
144impl PartialEq<str> for LiteralText {
145    fn eq(&self, other: &str) -> bool {
146        matches!(self, Self::Owned(text) | Self::CookedSource(text) if text.as_ref() == other)
147    }
148}
149
150impl PartialEq<&str> for LiteralText {
151    fn eq(&self, other: &&str) -> bool {
152        self == *other
153    }
154}
155
156/// A shell comment located by its byte range in the source.
157///
158/// The comment text (without the leading `#`) is obtained by slicing the
159/// source: `comment.range.slice(source)`.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub struct Comment {
162    pub range: TextRange,
163}
164
165/// A complete bash source file.
166#[derive(Debug, Clone)]
167pub struct File {
168    pub body: StmtSeq,
169    /// Source span of the entire file.
170    pub span: Span,
171}
172
173/// A sequence of statements plus comments that belong around that sequence.
174#[derive(Debug, Clone)]
175pub struct StmtSeq {
176    /// Comments before the first statement in this sequence.
177    pub leading_comments: Vec<Comment>,
178    /// Statements in source order.
179    pub stmts: Vec<Stmt>,
180    /// Comments after the final statement and before the enclosing terminator.
181    pub trailing_comments: Vec<Comment>,
182    /// Source span covering the full sequence.
183    pub span: Span,
184}
185
186impl StmtSeq {
187    pub fn len(&self) -> usize {
188        self.stmts.len()
189    }
190
191    pub fn is_empty(&self) -> bool {
192        self.stmts.is_empty()
193    }
194
195    pub fn as_slice(&self) -> &[Stmt] {
196        &self.stmts
197    }
198
199    pub fn as_mut_slice(&mut self) -> &mut [Stmt] {
200        &mut self.stmts
201    }
202
203    pub fn iter(&self) -> std::slice::Iter<'_, Stmt> {
204        self.stmts.iter()
205    }
206
207    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Stmt> {
208        self.stmts.iter_mut()
209    }
210
211    pub fn first(&self) -> Option<&Stmt> {
212        self.stmts.first()
213    }
214
215    pub fn last(&self) -> Option<&Stmt> {
216        self.stmts.last()
217    }
218}
219
220impl std::ops::Index<usize> for StmtSeq {
221    type Output = Stmt;
222
223    fn index(&self, index: usize) -> &Self::Output {
224        &self.stmts[index]
225    }
226}
227
228impl std::ops::IndexMut<usize> for StmtSeq {
229    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
230        &mut self.stmts[index]
231    }
232}
233
234/// A statement terminator in a surrounding sequence.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum StmtTerminator {
237    Semicolon,
238    Background(BackgroundOperator),
239}
240
241/// Surface spellings for background-like list operators.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum BackgroundOperator {
244    Plain,
245    Pipe,
246    Bang,
247}
248
249/// A single shell statement together with statement-local syntax.
250#[derive(Debug, Clone)]
251pub struct Stmt {
252    /// Own-line comments immediately preceding this statement.
253    pub leading_comments: Vec<Comment>,
254    /// The statement payload.
255    pub command: Command,
256    /// Whether this statement was prefixed with `!`.
257    pub negated: bool,
258    /// Redirections attached to the statement.
259    pub redirects: Box<[Redirect]>,
260    /// Optional `;` or `&` terminator in the containing sequence.
261    pub terminator: Option<StmtTerminator>,
262    /// Source span of the terminator token when present.
263    pub terminator_span: Option<Span>,
264    /// Trailing inline comment on the statement line.
265    pub inline_comment: Option<Comment>,
266    /// Source span of the full statement.
267    pub span: Span,
268}
269
270/// A single command in the script.
271#[derive(Debug, Clone)]
272#[allow(clippy::large_enum_variant)]
273pub enum Command {
274    /// A simple command (e.g., `echo hello`)
275    Simple(SimpleCommand),
276
277    /// A builtin command with a dedicated typed AST node
278    Builtin(BuiltinCommand),
279
280    /// A declaration builtin clause (`declare`, `local`, `export`, `readonly`, `typeset`)
281    Decl(DeclClause),
282
283    /// A binary shell command such as `a && b`, `a || b`, or `a | b`.
284    Binary(BinaryCommand),
285
286    /// A compound command (if, for, while, case, etc.).
287    Compound(CompoundCommand),
288
289    /// A function definition
290    Function(FunctionDef),
291
292    /// An anonymous zsh function command such as `function { ... }` or `() ...`.
293    AnonymousFunction(AnonymousFunctionCommand),
294}
295
296/// A simple command with arguments.
297#[derive(Debug, Clone)]
298pub struct SimpleCommand {
299    /// Command name
300    pub name: Word,
301    /// Command arguments
302    pub args: Vec<Word>,
303    /// Variable assignments before the command
304    pub assignments: Box<[Assignment]>,
305    /// Source span of this command
306    pub span: Span,
307}
308
309/// A declaration builtin clause such as `declare`, `local`, `export`, `readonly`, or `typeset`.
310#[derive(Debug, Clone)]
311pub struct DeclClause {
312    /// Declaration builtin variant.
313    pub variant: Name,
314    /// Source span of the declaration builtin name.
315    pub variant_span: Span,
316    /// Parsed declaration operands.
317    pub operands: Vec<DeclOperand>,
318    /// Variable assignments before the declaration clause.
319    pub assignments: Box<[Assignment]>,
320    /// Source span of this command.
321    pub span: Span,
322}
323
324/// A typed operand inside a declaration clause.
325#[derive(Debug, Clone)]
326pub enum DeclOperand {
327    /// A literal option word such as `-a` or `+x`.
328    Flag(Word),
329    /// A bare variable name or indexed reference.
330    Name(VarRef),
331    /// A typed assignment operand.
332    Assignment(Assignment),
333    /// A word whose runtime expansion may produce a flag, name, or assignment.
334    Dynamic(Word),
335}
336
337/// How a subscript should be interpreted by downstream consumers.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum SubscriptInterpretation {
340    Indexed,
341    Associative,
342    Contextual,
343}
344
345/// The syntactic shape of a parsed subscript.
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum SubscriptKind {
348    Ordinary,
349    Selector(SubscriptSelector),
350}
351
352/// Array selector variants like `[@]` and `[*]`.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum SubscriptSelector {
355    At,
356    Star,
357}
358
359impl SubscriptSelector {
360    pub const fn as_char(self) -> char {
361        match self {
362            Self::At => '@',
363            Self::Star => '*',
364        }
365    }
366}
367
368/// A typed array subscript or selector.
369#[derive(Debug, Clone)]
370pub struct Subscript {
371    pub text: SourceText,
372    /// Original subscript syntax when it differs from the cooked semantic text.
373    pub raw: Option<SourceText>,
374    pub kind: SubscriptKind,
375    pub interpretation: SubscriptInterpretation,
376    /// Parsed word view of the original subscript syntax.
377    pub word_ast: Option<Word>,
378    /// Typed arithmetic view of this subscript when it parses as arithmetic.
379    pub arithmetic_ast: Option<ArithmeticExprNode>,
380}
381
382impl Subscript {
383    pub fn span(&self) -> Span {
384        self.text.span()
385    }
386
387    pub fn syntax_source_text(&self) -> &SourceText {
388        self.raw.as_ref().unwrap_or(&self.text)
389    }
390
391    pub fn syntax_text<'a>(&'a self, source: &'a str) -> &'a str {
392        self.syntax_source_text().slice(source)
393    }
394
395    pub fn is_array_selector(&self) -> bool {
396        matches!(self.kind, SubscriptKind::Selector(_))
397    }
398
399    pub fn selector(&self) -> Option<SubscriptSelector> {
400        match self.kind {
401            SubscriptKind::Ordinary => None,
402            SubscriptKind::Selector(selector) => Some(selector),
403        }
404    }
405
406    pub fn is_source_backed(&self) -> bool {
407        self.syntax_source_text().is_source_backed()
408    }
409
410    pub fn word_ast(&self) -> Option<&Word> {
411        self.word_ast.as_ref()
412    }
413}
414
415/// A variable reference with an optional typed subscript.
416#[derive(Debug, Clone)]
417pub struct VarRef {
418    pub name: Name,
419    pub name_span: Span,
420    pub subscript: Option<Subscript>,
421    pub span: Span,
422}
423
424impl VarRef {
425    pub fn has_array_selector(&self) -> bool {
426        self.subscript
427            .as_ref()
428            .is_some_and(Subscript::is_array_selector)
429    }
430
431    pub fn is_source_backed(&self) -> bool {
432        self.subscript
433            .as_ref()
434            .is_none_or(Subscript::is_source_backed)
435    }
436}
437
438/// Builtin commands with dedicated AST nodes.
439#[derive(Debug, Clone)]
440pub enum BuiltinCommand {
441    /// `break [N]`
442    Break(BreakCommand),
443    /// `continue [N]`
444    Continue(ContinueCommand),
445    /// `return [N]`
446    Return(ReturnCommand),
447    /// `exit [N]`
448    Exit(ExitCommand),
449}
450
451/// `break [N]`
452#[derive(Debug, Clone)]
453pub struct BreakCommand {
454    /// Optional loop depth argument
455    pub depth: Option<Word>,
456    /// Additional operands preserved for fidelity
457    pub extra_args: Vec<Word>,
458    /// Variable assignments before the builtin
459    pub assignments: Box<[Assignment]>,
460    /// Source span of this command
461    pub span: Span,
462}
463
464/// `continue [N]`
465#[derive(Debug, Clone)]
466pub struct ContinueCommand {
467    /// Optional loop depth argument
468    pub depth: Option<Word>,
469    /// Additional operands preserved for fidelity
470    pub extra_args: Vec<Word>,
471    /// Variable assignments before the builtin
472    pub assignments: Box<[Assignment]>,
473    /// Source span of this command
474    pub span: Span,
475}
476
477/// `return [N]`
478#[derive(Debug, Clone)]
479pub struct ReturnCommand {
480    /// Optional return code argument
481    pub code: Option<Word>,
482    /// Additional operands preserved for fidelity
483    pub extra_args: Vec<Word>,
484    /// Variable assignments before the builtin
485    pub assignments: Box<[Assignment]>,
486    /// Source span of this command
487    pub span: Span,
488}
489
490/// `exit [N]`
491#[derive(Debug, Clone)]
492pub struct ExitCommand {
493    /// Optional exit code argument
494    pub code: Option<Word>,
495    /// Additional operands preserved for fidelity
496    pub extra_args: Vec<Word>,
497    /// Variable assignments before the builtin
498    pub assignments: Box<[Assignment]>,
499    /// Source span of this command
500    pub span: Span,
501}
502
503/// A binary shell command such as `a && b`, `a || b`, or `a | b`.
504#[derive(Debug, Clone)]
505pub struct BinaryCommand {
506    pub left: Box<Stmt>,
507    pub op: BinaryOp,
508    pub op_span: Span,
509    pub right: Box<Stmt>,
510    pub span: Span,
511}
512
513/// Binary shell operators with statement-level operands.
514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
515pub enum BinaryOp {
516    And,
517    Or,
518    Pipe,
519    PipeAll,
520}
521
522/// Compound commands (control structures).
523#[derive(Debug, Clone)]
524pub enum CompoundCommand {
525    /// If statement
526    If(IfCommand),
527    /// For loop
528    For(ForCommand),
529    /// Zsh repeat loop
530    Repeat(RepeatCommand),
531    /// Zsh foreach loop
532    Foreach(ForeachCommand),
533    /// C-style for loop: for ((init; cond; step))
534    ArithmeticFor(Box<ArithmeticForCommand>),
535    /// While loop
536    While(WhileCommand),
537    /// Until loop
538    Until(UntilCommand),
539    /// Case statement
540    Case(CaseCommand),
541    /// Select loop
542    Select(SelectCommand),
543    /// Subshell (commands in parentheses)
544    Subshell(StmtSeq),
545    /// Brace group
546    BraceGroup(StmtSeq),
547    /// Arithmetic command ((expression))
548    Arithmetic(ArithmeticCommand),
549    /// Time command - measure execution time
550    Time(TimeCommand),
551    /// Conditional expression [[ ... ]]
552    Conditional(ConditionalCommand),
553    /// Coprocess: `coproc [NAME] command`
554    Coproc(CoprocCommand),
555    /// Zsh always/finally-style cleanup block.
556    Always(AlwaysCommand),
557}
558
559/// Coprocess command - runs a command with bidirectional communication.
560///
561/// In the sandboxed model, the coprocess runs synchronously and its
562/// stdout is buffered for later reading via the NAME array FDs.
563/// `NAME[0]` = virtual read FD, `NAME[1]` = virtual write FD, `NAME_PID` = virtual PID.
564#[derive(Debug, Clone)]
565pub struct CoprocCommand {
566    /// Coprocess name (defaults to "COPROC")
567    pub name: Name,
568    /// Source span of the explicit coprocess name, when present.
569    pub name_span: Option<Span>,
570    /// The command to run as a coprocess
571    pub body: Box<Stmt>,
572    /// Source span of this command
573    pub span: Span,
574}
575
576/// Zsh `always` command - run `always_body` after `body`.
577#[derive(Debug, Clone)]
578pub struct AlwaysCommand {
579    pub body: StmtSeq,
580    pub always_body: StmtSeq,
581    pub span: Span,
582}
583
584/// Time command - wraps a command and measures its execution time.
585///
586/// Note: Shuck only supports wall-clock time measurement.
587/// User/system CPU time is not tracked (always reported as 0).
588/// This is a known incompatibility with bash.
589#[derive(Debug, Clone)]
590pub struct TimeCommand {
591    /// Use POSIX output format (-p flag)
592    pub posix_format: bool,
593    /// The command to time (optional - timing with no command is valid)
594    pub command: Option<Box<Stmt>>,
595    /// Source span of this command
596    pub span: Span,
597}
598
599/// Bash conditional command `[[ ... ]]`.
600#[derive(Debug, Clone)]
601pub struct ConditionalCommand {
602    /// The parsed conditional expression.
603    pub expression: ConditionalExpr,
604    /// Source span of the full `[[ ... ]]` command.
605    pub span: Span,
606    /// Source span of the opening `[[`.
607    pub left_bracket_span: Span,
608    /// Source span of the closing `]]`.
609    pub right_bracket_span: Span,
610}
611
612/// A node within a `[[ ... ]]` conditional expression.
613#[derive(Debug, Clone)]
614pub enum ConditionalExpr {
615    Binary(ConditionalBinaryExpr),
616    Unary(ConditionalUnaryExpr),
617    Parenthesized(ConditionalParenExpr),
618    Word(Word),
619    Pattern(Pattern),
620    Regex(Word),
621    VarRef(Box<VarRef>),
622}
623
624impl ConditionalExpr {
625    /// Source span of this conditional expression node.
626    pub fn span(&self) -> Span {
627        match self {
628            Self::Binary(expr) => expr.span(),
629            Self::Unary(expr) => expr.span(),
630            Self::Parenthesized(expr) => expr.span(),
631            Self::Word(word) | Self::Regex(word) => word.span,
632            Self::Pattern(pattern) => pattern.span,
633            Self::VarRef(var_ref) => var_ref.span,
634        }
635    }
636}
637
638/// A binary `[[ ... ]]` expression like `a == b` or `x && y`.
639#[derive(Debug, Clone)]
640pub struct ConditionalBinaryExpr {
641    pub left: Box<ConditionalExpr>,
642    pub op: ConditionalBinaryOp,
643    pub op_span: Span,
644    pub right: Box<ConditionalExpr>,
645}
646
647impl ConditionalBinaryExpr {
648    pub fn span(&self) -> Span {
649        self.left.span().merge(self.right.span())
650    }
651}
652
653/// A unary `[[ ... ]]` expression like `! x` or `-n "$x"`.
654#[derive(Debug, Clone)]
655pub struct ConditionalUnaryExpr {
656    pub op: ConditionalUnaryOp,
657    pub op_span: Span,
658    pub expr: Box<ConditionalExpr>,
659}
660
661impl ConditionalUnaryExpr {
662    pub fn span(&self) -> Span {
663        self.op_span.merge(self.expr.span())
664    }
665}
666
667/// A parenthesized `[[ ... ]]` sub-expression.
668#[derive(Debug, Clone)]
669pub struct ConditionalParenExpr {
670    pub left_paren_span: Span,
671    pub expr: Box<ConditionalExpr>,
672    pub right_paren_span: Span,
673}
674
675impl ConditionalParenExpr {
676    pub fn span(&self) -> Span {
677        self.left_paren_span.merge(self.right_paren_span)
678    }
679}
680
681/// Binary operators allowed inside `[[ ... ]]`.
682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
683pub enum ConditionalBinaryOp {
684    RegexMatch,
685    NewerThan,
686    OlderThan,
687    SameFile,
688    ArithmeticEq,
689    ArithmeticNe,
690    ArithmeticLe,
691    ArithmeticGe,
692    ArithmeticLt,
693    ArithmeticGt,
694    And,
695    Or,
696    PatternEqShort,
697    PatternEq,
698    PatternNe,
699    LexicalBefore,
700    LexicalAfter,
701}
702
703impl ConditionalBinaryOp {
704    pub fn as_str(self) -> &'static str {
705        match self {
706            Self::RegexMatch => "=~",
707            Self::NewerThan => "-nt",
708            Self::OlderThan => "-ot",
709            Self::SameFile => "-ef",
710            Self::ArithmeticEq => "-eq",
711            Self::ArithmeticNe => "-ne",
712            Self::ArithmeticLe => "-le",
713            Self::ArithmeticGe => "-ge",
714            Self::ArithmeticLt => "-lt",
715            Self::ArithmeticGt => "-gt",
716            Self::And => "&&",
717            Self::Or => "||",
718            Self::PatternEqShort => "=",
719            Self::PatternEq => "==",
720            Self::PatternNe => "!=",
721            Self::LexicalBefore => "<",
722            Self::LexicalAfter => ">",
723        }
724    }
725}
726
727/// Unary operators allowed inside `[[ ... ]]`.
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub enum ConditionalUnaryOp {
730    Exists,
731    RegularFile,
732    Directory,
733    CharacterSpecial,
734    BlockSpecial,
735    NamedPipe,
736    Socket,
737    Symlink,
738    Sticky,
739    SetGroupId,
740    SetUserId,
741    GroupOwned,
742    UserOwned,
743    Modified,
744    Readable,
745    Writable,
746    Executable,
747    NonEmptyFile,
748    FdTerminal,
749    EmptyString,
750    NonEmptyString,
751    OptionSet,
752    VariableSet,
753    ReferenceVariable,
754    Not,
755}
756
757impl ConditionalUnaryOp {
758    pub fn as_str(self) -> &'static str {
759        match self {
760            Self::Exists => "-e",
761            Self::RegularFile => "-f",
762            Self::Directory => "-d",
763            Self::CharacterSpecial => "-c",
764            Self::BlockSpecial => "-b",
765            Self::NamedPipe => "-p",
766            Self::Socket => "-S",
767            Self::Symlink => "-L",
768            Self::Sticky => "-k",
769            Self::SetGroupId => "-g",
770            Self::SetUserId => "-u",
771            Self::GroupOwned => "-G",
772            Self::UserOwned => "-O",
773            Self::Modified => "-N",
774            Self::Readable => "-r",
775            Self::Writable => "-w",
776            Self::Executable => "-x",
777            Self::NonEmptyFile => "-s",
778            Self::FdTerminal => "-t",
779            Self::EmptyString => "-z",
780            Self::NonEmptyString => "-n",
781            Self::OptionSet => "-o",
782            Self::VariableSet => "-v",
783            Self::ReferenceVariable => "-R",
784            Self::Not => "!",
785        }
786    }
787}
788
789/// If statement.
790#[derive(Debug, Clone)]
791pub struct IfCommand {
792    pub condition: StmtSeq,
793    pub then_branch: StmtSeq,
794    pub elif_branches: Vec<(StmtSeq, StmtSeq)>,
795    pub else_branch: Option<StmtSeq>,
796    pub syntax: IfSyntax,
797    /// Source span of this command
798    pub span: Span,
799}
800
801/// Surface syntax preserved for an `if` command.
802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
803pub enum IfSyntax {
804    ThenFi {
805        then_span: Span,
806        fi_span: Span,
807    },
808    Brace {
809        left_brace_span: Span,
810        right_brace_span: Span,
811    },
812}
813
814/// For loop.
815#[derive(Debug, Clone)]
816pub struct ForCommand {
817    pub targets: Vec<ForTarget>,
818    pub words: Option<Vec<Word>>,
819    pub body: StmtSeq,
820    pub syntax: ForSyntax,
821    /// Source span of this command
822    pub span: Span,
823}
824
825/// One loop target in a `for` header.
826#[derive(Debug, Clone)]
827pub struct ForTarget {
828    /// Source-preserving target surface as it appeared in the loop header.
829    pub word: Word,
830    /// Normalized identifier when the target is a plain shell name.
831    pub name: Option<Name>,
832    pub span: Span,
833}
834
835/// Surface syntax preserved for a `for` command.
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum ForSyntax {
838    InDoDone {
839        in_span: Option<Span>,
840        do_span: Span,
841        done_span: Span,
842    },
843    InDirect {
844        in_span: Option<Span>,
845    },
846    InBrace {
847        in_span: Option<Span>,
848        left_brace_span: Span,
849        right_brace_span: Span,
850    },
851    ParenDoDone {
852        left_paren_span: Span,
853        right_paren_span: Span,
854        do_span: Span,
855        done_span: Span,
856    },
857    ParenDirect {
858        left_paren_span: Span,
859        right_paren_span: Span,
860    },
861    ParenBrace {
862        left_paren_span: Span,
863        right_paren_span: Span,
864        left_brace_span: Span,
865        right_brace_span: Span,
866    },
867}
868
869/// Zsh repeat loop.
870#[derive(Debug, Clone)]
871pub struct RepeatCommand {
872    pub count: Word,
873    pub body: StmtSeq,
874    pub syntax: RepeatSyntax,
875    /// Source span of this command
876    pub span: Span,
877}
878
879/// Surface syntax preserved for a `repeat` command.
880#[derive(Debug, Clone, Copy, PartialEq, Eq)]
881pub enum RepeatSyntax {
882    DoDone {
883        do_span: Span,
884        done_span: Span,
885    },
886    Direct,
887    Brace {
888        left_brace_span: Span,
889        right_brace_span: Span,
890    },
891}
892
893/// Zsh foreach loop.
894#[derive(Debug, Clone)]
895pub struct ForeachCommand {
896    pub variable: Name,
897    pub variable_span: Span,
898    pub words: Vec<Word>,
899    pub body: StmtSeq,
900    pub syntax: ForeachSyntax,
901    /// Source span of this command
902    pub span: Span,
903}
904
905/// Surface syntax preserved for a `foreach` command.
906#[derive(Debug, Clone, Copy, PartialEq, Eq)]
907pub enum ForeachSyntax {
908    ParenBrace {
909        left_paren_span: Span,
910        right_paren_span: Span,
911        left_brace_span: Span,
912        right_brace_span: Span,
913    },
914    InDoDone {
915        in_span: Span,
916        do_span: Span,
917        done_span: Span,
918    },
919}
920
921/// Select loop.
922#[derive(Debug, Clone)]
923pub struct SelectCommand {
924    pub variable: Name,
925    pub variable_span: Span,
926    pub words: Vec<Word>,
927    pub body: StmtSeq,
928    /// Source span of this command
929    pub span: Span,
930}
931
932/// Arithmetic command `(( expr ))`.
933#[derive(Debug, Clone)]
934pub struct ArithmeticCommand {
935    pub span: Span,
936    pub left_paren_span: Span,
937    pub expr_span: Option<Span>,
938    /// Typed arithmetic view of `expr_span`.
939    pub expr_ast: Option<ArithmeticExprNode>,
940    pub right_paren_span: Span,
941}
942
943/// C-style arithmetic for loop: for ((init; cond; step)); do body; done
944#[derive(Debug, Clone)]
945pub struct ArithmeticForCommand {
946    pub left_paren_span: Span,
947    pub init_span: Option<Span>,
948    /// Typed arithmetic view of `init_span`.
949    pub init_ast: Option<ArithmeticExprNode>,
950    pub first_semicolon_span: Span,
951    pub condition_span: Option<Span>,
952    /// Typed arithmetic view of `condition_span`.
953    pub condition_ast: Option<ArithmeticExprNode>,
954    pub second_semicolon_span: Span,
955    pub step_span: Option<Span>,
956    /// Typed arithmetic view of `step_span`.
957    pub step_ast: Option<ArithmeticExprNode>,
958    pub right_paren_span: Span,
959    /// Loop body
960    pub body: StmtSeq,
961    /// Source span of this command
962    pub span: Span,
963}
964
965/// A typed arithmetic expression plus its source span.
966#[derive(Debug, Clone)]
967pub struct ArithmeticExprNode {
968    pub kind: ArithmeticExpr,
969    pub span: Span,
970}
971
972impl ArithmeticExprNode {
973    pub fn new(kind: ArithmeticExpr, span: Span) -> Self {
974        Self { kind, span }
975    }
976}
977
978/// A typed arithmetic expression used by shell arithmetic contexts.
979#[derive(Debug, Clone)]
980pub enum ArithmeticExpr {
981    /// Numeric literal spelling such as `42`, `16#ff`, or `'a'`.
982    Number(SourceText),
983    /// Bare arithmetic variable reference such as `i`.
984    Variable(Name),
985    /// Indexed arithmetic reference such as `arr[i + 1]`.
986    Indexed {
987        name: Name,
988        index: Box<ArithmeticExprNode>,
989    },
990    /// Shell-evaluated primary such as `$x`, `${x}`, `"3"`, or `$(cmd)`.
991    ShellWord(Word),
992    Parenthesized {
993        expression: Box<ArithmeticExprNode>,
994    },
995    Unary {
996        op: ArithmeticUnaryOp,
997        expr: Box<ArithmeticExprNode>,
998    },
999    Postfix {
1000        expr: Box<ArithmeticExprNode>,
1001        op: ArithmeticPostfixOp,
1002    },
1003    Binary {
1004        left: Box<ArithmeticExprNode>,
1005        op: ArithmeticBinaryOp,
1006        right: Box<ArithmeticExprNode>,
1007    },
1008    Conditional {
1009        condition: Box<ArithmeticExprNode>,
1010        then_expr: Box<ArithmeticExprNode>,
1011        else_expr: Box<ArithmeticExprNode>,
1012    },
1013    Assignment {
1014        target: ArithmeticLvalue,
1015        op: ArithmeticAssignOp,
1016        value: Box<ArithmeticExprNode>,
1017    },
1018}
1019
1020/// Assignment target inside arithmetic.
1021#[derive(Debug, Clone)]
1022pub enum ArithmeticLvalue {
1023    Variable(Name),
1024    Indexed {
1025        name: Name,
1026        index: Box<ArithmeticExprNode>,
1027    },
1028}
1029
1030/// Prefix unary arithmetic operators.
1031#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1032pub enum ArithmeticUnaryOp {
1033    PreIncrement,
1034    PreDecrement,
1035    Plus,
1036    Minus,
1037    LogicalNot,
1038    BitwiseNot,
1039}
1040
1041/// Postfix arithmetic operators.
1042#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1043pub enum ArithmeticPostfixOp {
1044    Increment,
1045    Decrement,
1046}
1047
1048/// Binary arithmetic operators ordered by normal shell arithmetic precedence.
1049#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1050pub enum ArithmeticBinaryOp {
1051    Comma,
1052    Power,
1053    Multiply,
1054    Divide,
1055    Modulo,
1056    Add,
1057    Subtract,
1058    ShiftLeft,
1059    ShiftRight,
1060    LessThan,
1061    LessThanOrEqual,
1062    GreaterThan,
1063    GreaterThanOrEqual,
1064    Equal,
1065    NotEqual,
1066    BitwiseAnd,
1067    BitwiseXor,
1068    BitwiseOr,
1069    LogicalAnd,
1070    LogicalOr,
1071}
1072
1073/// Arithmetic assignment operators.
1074#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1075pub enum ArithmeticAssignOp {
1076    Assign,
1077    AddAssign,
1078    SubAssign,
1079    MulAssign,
1080    DivAssign,
1081    ModAssign,
1082    ShiftLeftAssign,
1083    ShiftRightAssign,
1084    AndAssign,
1085    XorAssign,
1086    OrAssign,
1087}
1088
1089/// While loop.
1090#[derive(Debug, Clone)]
1091pub struct WhileCommand {
1092    pub condition: StmtSeq,
1093    pub body: StmtSeq,
1094    /// Source span of this command
1095    pub span: Span,
1096}
1097
1098/// Until loop.
1099#[derive(Debug, Clone)]
1100pub struct UntilCommand {
1101    pub condition: StmtSeq,
1102    pub body: StmtSeq,
1103    /// Source span of this command
1104    pub span: Span,
1105}
1106
1107/// Case statement.
1108#[derive(Debug, Clone)]
1109pub struct CaseCommand {
1110    pub word: Word,
1111    pub cases: Vec<CaseItem>,
1112    /// Source span of this command
1113    pub span: Span,
1114}
1115
1116/// Terminator for a case item.
1117#[derive(Debug, Clone, Copy, PartialEq)]
1118pub enum CaseTerminator {
1119    /// `;;` — stop matching
1120    Break,
1121    /// `;&` — fall through to next case body unconditionally
1122    FallThrough,
1123    /// `;;&` — continue checking remaining patterns
1124    Continue,
1125    /// `;|` — continue scanning remaining patterns in zsh
1126    ContinueMatching,
1127}
1128
1129/// A single case item.
1130#[derive(Debug, Clone)]
1131pub struct CaseItem {
1132    pub patterns: Vec<Pattern>,
1133    pub body: StmtSeq,
1134    pub terminator: CaseTerminator,
1135    /// Source span of the case terminator token when present.
1136    pub terminator_span: Option<Span>,
1137}
1138
1139/// One parsed function header entry.
1140#[derive(Debug, Clone)]
1141pub struct FunctionHeaderEntry {
1142    pub word: Word,
1143    pub static_name: Option<Name>,
1144}
1145
1146impl FunctionHeaderEntry {
1147    pub fn static_name_span(&self) -> Option<Span> {
1148        self.static_name.as_ref().map(|_| self.word.span)
1149    }
1150}
1151
1152/// Surface syntax preserved for a named function declaration.
1153#[derive(Debug, Clone, Default)]
1154pub struct FunctionHeader {
1155    pub function_keyword_span: Option<Span>,
1156    pub entries: Vec<FunctionHeaderEntry>,
1157    pub trailing_parens_span: Option<Span>,
1158}
1159
1160impl FunctionHeader {
1161    pub fn uses_function_keyword(&self) -> bool {
1162        self.function_keyword_span.is_some()
1163    }
1164
1165    pub fn has_trailing_parens(&self) -> bool {
1166        self.trailing_parens_span.is_some()
1167    }
1168
1169    pub fn has_name_parens(&self) -> bool {
1170        self.has_trailing_parens()
1171    }
1172
1173    pub fn static_names(&self) -> impl Iterator<Item = &Name> + '_ {
1174        self.entries
1175            .iter()
1176            .filter_map(|entry| entry.static_name.as_ref())
1177    }
1178
1179    pub fn static_name_entries(&self) -> impl Iterator<Item = (&Name, Span)> + '_ {
1180        self.entries.iter().filter_map(|entry| {
1181            entry
1182                .static_name
1183                .as_ref()
1184                .map(|name| (name, entry.word.span))
1185        })
1186    }
1187
1188    pub fn span(&self) -> Span {
1189        let mut span = self.function_keyword_span.unwrap_or_default();
1190        for entry in &self.entries {
1191            span = merge_non_empty_span(span, entry.word.span);
1192        }
1193        if let Some(parens_span) = self.trailing_parens_span {
1194            span = merge_non_empty_span(span, parens_span);
1195        }
1196        span
1197    }
1198}
1199
1200/// Function definition.
1201#[derive(Debug, Clone)]
1202pub struct FunctionDef {
1203    pub header: FunctionHeader,
1204    pub body: Box<Stmt>,
1205    /// Source span of this function definition
1206    pub span: Span,
1207}
1208
1209impl FunctionDef {
1210    pub fn uses_function_keyword(&self) -> bool {
1211        self.header.uses_function_keyword()
1212    }
1213
1214    pub fn has_trailing_parens(&self) -> bool {
1215        self.header.has_trailing_parens()
1216    }
1217
1218    pub fn has_name_parens(&self) -> bool {
1219        self.has_trailing_parens()
1220    }
1221
1222    pub fn static_names(&self) -> impl Iterator<Item = &Name> + '_ {
1223        self.header.static_names()
1224    }
1225
1226    pub fn static_name_entries(&self) -> impl Iterator<Item = (&Name, Span)> + '_ {
1227        self.header.static_name_entries()
1228    }
1229}
1230
1231/// Preserved surface for an anonymous zsh function command.
1232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1233pub enum AnonymousFunctionSurface {
1234    FunctionKeyword { function_keyword_span: Span },
1235    Parens { parens_span: Span },
1236}
1237
1238impl AnonymousFunctionSurface {
1239    pub fn uses_function_keyword(&self) -> bool {
1240        matches!(self, Self::FunctionKeyword { .. })
1241    }
1242
1243    pub fn parens_span(self) -> Option<Span> {
1244        match self {
1245            Self::FunctionKeyword { .. } => None,
1246            Self::Parens { parens_span } => Some(parens_span),
1247        }
1248    }
1249}
1250
1251/// Anonymous zsh function command.
1252#[derive(Debug, Clone)]
1253pub struct AnonymousFunctionCommand {
1254    pub surface: AnonymousFunctionSurface,
1255    pub body: Box<Stmt>,
1256    pub args: Vec<Word>,
1257    pub span: Span,
1258}
1259
1260impl AnonymousFunctionCommand {
1261    pub fn uses_function_keyword(&self) -> bool {
1262        self.surface.uses_function_keyword()
1263    }
1264}
1265
1266fn merge_non_empty_span(current: Span, next: Span) -> Span {
1267    match (current == Span::new(), next == Span::new()) {
1268        (true, _) => next,
1269        (_, true) => current,
1270        (false, false) => current.merge(next),
1271    }
1272}
1273
1274/// Original syntax form for command substitution.
1275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1276pub enum CommandSubstitutionSyntax {
1277    DollarParen,
1278    Backtick,
1279}
1280
1281/// Original syntax form for arithmetic expansion.
1282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1283pub enum ArithmeticExpansionSyntax {
1284    DollarParenParen,
1285    LegacyBracket,
1286}
1287
1288/// Selector form for `${!prefix@}` versus `${!prefix*}`.
1289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1290pub enum PrefixMatchKind {
1291    At,
1292    Star,
1293}
1294
1295impl PrefixMatchKind {
1296    pub const fn as_char(self) -> char {
1297        match self {
1298            Self::At => '@',
1299            Self::Star => '*',
1300        }
1301    }
1302}
1303
1304/// Unified parameter-expansion family for `${...}` forms.
1305#[derive(Debug, Clone)]
1306pub struct ParameterExpansion {
1307    pub syntax: ParameterExpansionSyntax,
1308    pub span: Span,
1309    pub raw_body: SourceText,
1310}
1311
1312impl ParameterExpansion {
1313    pub fn is_zsh(&self) -> bool {
1314        matches!(self.syntax, ParameterExpansionSyntax::Zsh(_))
1315    }
1316
1317    pub fn bourne(&self) -> Option<&BourneParameterExpansion> {
1318        match &self.syntax {
1319            ParameterExpansionSyntax::Bourne(syntax) => Some(syntax),
1320            ParameterExpansionSyntax::Zsh(_) => None,
1321        }
1322    }
1323
1324    pub fn zsh(&self) -> Option<&ZshParameterExpansion> {
1325        match &self.syntax {
1326            ParameterExpansionSyntax::Bourne(_) => None,
1327            ParameterExpansionSyntax::Zsh(syntax) => Some(syntax),
1328        }
1329    }
1330}
1331
1332#[derive(Debug, Clone)]
1333#[allow(clippy::large_enum_variant)]
1334pub enum ParameterExpansionSyntax {
1335    Bourne(BourneParameterExpansion),
1336    Zsh(ZshParameterExpansion),
1337}
1338
1339#[derive(Debug, Clone)]
1340#[allow(clippy::large_enum_variant)]
1341pub enum BourneParameterExpansion {
1342    Access {
1343        reference: VarRef,
1344    },
1345    Length {
1346        reference: VarRef,
1347    },
1348    Indices {
1349        reference: VarRef,
1350    },
1351    Indirect {
1352        reference: VarRef,
1353        operator: Option<ParameterOp>,
1354        operand: Option<SourceText>,
1355        operand_word_ast: Option<Word>,
1356        colon_variant: bool,
1357    },
1358    PrefixMatch {
1359        prefix: Name,
1360        kind: PrefixMatchKind,
1361    },
1362    Slice {
1363        reference: VarRef,
1364        offset: SourceText,
1365        offset_ast: Option<ArithmeticExprNode>,
1366        offset_word_ast: Word,
1367        length: Option<SourceText>,
1368        length_ast: Option<ArithmeticExprNode>,
1369        length_word_ast: Option<Word>,
1370    },
1371    Operation {
1372        reference: VarRef,
1373        operator: ParameterOp,
1374        operand: Option<SourceText>,
1375        operand_word_ast: Option<Word>,
1376        colon_variant: bool,
1377    },
1378    Transformation {
1379        reference: VarRef,
1380        operator: char,
1381    },
1382}
1383
1384impl BourneParameterExpansion {
1385    pub fn operand_word_ast(&self) -> Option<&Word> {
1386        match self {
1387            Self::Indirect {
1388                operand_word_ast, ..
1389            }
1390            | Self::Operation {
1391                operand_word_ast, ..
1392            } => operand_word_ast.as_ref(),
1393            Self::Access { .. }
1394            | Self::Length { .. }
1395            | Self::Indices { .. }
1396            | Self::PrefixMatch { .. }
1397            | Self::Slice { .. }
1398            | Self::Transformation { .. } => None,
1399        }
1400    }
1401
1402    pub fn offset_word_ast(&self) -> Option<&Word> {
1403        match self {
1404            Self::Slice {
1405                offset_word_ast, ..
1406            } => Some(offset_word_ast),
1407            Self::Access { .. }
1408            | Self::Length { .. }
1409            | Self::Indices { .. }
1410            | Self::Indirect { .. }
1411            | Self::PrefixMatch { .. }
1412            | Self::Operation { .. }
1413            | Self::Transformation { .. } => None,
1414        }
1415    }
1416
1417    pub fn length_word_ast(&self) -> Option<&Word> {
1418        match self {
1419            Self::Slice {
1420                length_word_ast, ..
1421            } => length_word_ast.as_ref(),
1422            Self::Access { .. }
1423            | Self::Length { .. }
1424            | Self::Indices { .. }
1425            | Self::Indirect { .. }
1426            | Self::PrefixMatch { .. }
1427            | Self::Operation { .. }
1428            | Self::Transformation { .. } => None,
1429        }
1430    }
1431}
1432
1433#[derive(Debug, Clone)]
1434pub struct ZshParameterExpansion {
1435    pub target: ZshExpansionTarget,
1436    pub modifiers: Vec<ZshModifier>,
1437    pub length_prefix: Option<Span>,
1438    pub operation: Option<ZshExpansionOperation>,
1439}
1440
1441#[derive(Debug, Clone)]
1442#[allow(clippy::large_enum_variant)]
1443pub enum ZshExpansionTarget {
1444    Reference(VarRef),
1445    Nested(Box<ParameterExpansion>),
1446    Word(Word),
1447    Empty,
1448}
1449
1450#[derive(Debug, Clone)]
1451pub struct ZshModifier {
1452    pub name: char,
1453    pub argument: Option<SourceText>,
1454    pub argument_word_ast: Option<Word>,
1455    pub argument_delimiter: Option<char>,
1456    pub span: Span,
1457}
1458
1459impl ZshModifier {
1460    pub fn argument_word_ast(&self) -> Option<&Word> {
1461        self.argument_word_ast.as_ref()
1462    }
1463}
1464
1465#[derive(Debug, Clone)]
1466pub enum ZshExpansionOperation {
1467    PatternOperation {
1468        kind: ZshPatternOp,
1469        operand: SourceText,
1470        operand_word_ast: Word,
1471    },
1472    Defaulting {
1473        kind: ZshDefaultingOp,
1474        operand: SourceText,
1475        operand_word_ast: Word,
1476        colon_variant: bool,
1477    },
1478    TrimOperation {
1479        kind: ZshTrimOp,
1480        operand: SourceText,
1481        operand_word_ast: Word,
1482    },
1483    ReplacementOperation {
1484        kind: ZshReplacementOp,
1485        pattern: SourceText,
1486        pattern_word_ast: Word,
1487        replacement: Option<SourceText>,
1488        replacement_word_ast: Option<Word>,
1489    },
1490    Slice {
1491        offset: SourceText,
1492        offset_word_ast: Word,
1493        length: Option<SourceText>,
1494        length_word_ast: Option<Word>,
1495    },
1496    Unknown {
1497        text: SourceText,
1498        word_ast: Word,
1499    },
1500}
1501
1502impl ZshExpansionOperation {
1503    pub fn operand_word_ast(&self) -> Option<&Word> {
1504        match self {
1505            Self::PatternOperation {
1506                operand_word_ast, ..
1507            }
1508            | Self::Defaulting {
1509                operand_word_ast, ..
1510            }
1511            | Self::TrimOperation {
1512                operand_word_ast, ..
1513            } => Some(operand_word_ast),
1514            Self::ReplacementOperation { .. } | Self::Slice { .. } => None,
1515            Self::Unknown { word_ast, .. } => Some(word_ast),
1516        }
1517    }
1518
1519    pub fn pattern_word_ast(&self) -> Option<&Word> {
1520        match self {
1521            Self::ReplacementOperation {
1522                pattern_word_ast, ..
1523            } => Some(pattern_word_ast),
1524            Self::PatternOperation { .. }
1525            | Self::Defaulting { .. }
1526            | Self::TrimOperation { .. }
1527            | Self::Slice { .. }
1528            | Self::Unknown { .. } => None,
1529        }
1530    }
1531
1532    pub fn replacement_word_ast(&self) -> Option<&Word> {
1533        match self {
1534            Self::ReplacementOperation {
1535                replacement_word_ast,
1536                ..
1537            } => replacement_word_ast.as_ref(),
1538            Self::PatternOperation { .. }
1539            | Self::Defaulting { .. }
1540            | Self::TrimOperation { .. }
1541            | Self::Slice { .. }
1542            | Self::Unknown { .. } => None,
1543        }
1544    }
1545
1546    pub fn offset_word_ast(&self) -> Option<&Word> {
1547        match self {
1548            Self::Slice {
1549                offset_word_ast, ..
1550            } => Some(offset_word_ast),
1551            Self::PatternOperation { .. }
1552            | Self::Defaulting { .. }
1553            | Self::TrimOperation { .. }
1554            | Self::ReplacementOperation { .. }
1555            | Self::Unknown { .. } => None,
1556        }
1557    }
1558
1559    pub fn length_word_ast(&self) -> Option<&Word> {
1560        match self {
1561            Self::Slice {
1562                length_word_ast, ..
1563            } => length_word_ast.as_ref(),
1564            Self::PatternOperation { .. }
1565            | Self::Defaulting { .. }
1566            | Self::TrimOperation { .. }
1567            | Self::ReplacementOperation { .. }
1568            | Self::Unknown { .. } => None,
1569        }
1570    }
1571
1572    pub fn source_text(&self) -> Option<&SourceText> {
1573        match self {
1574            Self::PatternOperation { operand, .. }
1575            | Self::Defaulting { operand, .. }
1576            | Self::TrimOperation { operand, .. } => Some(operand),
1577            Self::ReplacementOperation { .. } | Self::Slice { .. } => None,
1578            Self::Unknown { text, .. } => Some(text),
1579        }
1580    }
1581}
1582
1583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1584pub enum ZshPatternOp {
1585    Filter,
1586}
1587
1588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1589pub enum ZshDefaultingOp {
1590    UseDefault,
1591    AssignDefault,
1592    UseReplacement,
1593    Error,
1594}
1595
1596#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1597pub enum ZshTrimOp {
1598    RemovePrefixShort,
1599    RemovePrefixLong,
1600    RemoveSuffixShort,
1601    RemoveSuffixLong,
1602}
1603
1604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1605pub enum ZshReplacementOp {
1606    ReplaceFirst,
1607    ReplaceAll,
1608    ReplacePrefix,
1609    ReplaceSuffix,
1610}
1611
1612/// A zsh glob word with ordered pattern/control segments and an optional
1613/// terminal qualifier suffix.
1614#[derive(Debug, Clone)]
1615pub struct ZshQualifiedGlob {
1616    pub span: Span,
1617    pub segments: Vec<ZshGlobSegment>,
1618    pub qualifiers: Option<ZshGlobQualifierGroup>,
1619}
1620
1621/// Ordered surface-preserving segments inside a zsh glob word.
1622#[derive(Debug, Clone)]
1623pub enum ZshGlobSegment {
1624    Pattern(Pattern),
1625    InlineControl(ZshInlineGlobControl),
1626}
1627
1628/// Supported inline zsh glob control groups for this parser pass.
1629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1630pub enum ZshInlineGlobControl {
1631    CaseInsensitive { span: Span },
1632    Backreferences { span: Span },
1633    StartAnchor { span: Span },
1634    EndAnchor { span: Span },
1635}
1636
1637/// Surface form for a terminal zsh glob qualifier suffix.
1638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1639pub enum ZshGlobQualifierKind {
1640    Classic,
1641    HashQ,
1642}
1643
1644/// One terminal zsh qualifier suffix for a glob word.
1645#[derive(Debug, Clone)]
1646pub struct ZshGlobQualifierGroup {
1647    pub span: Span,
1648    pub kind: ZshGlobQualifierKind,
1649    pub fragments: Vec<ZshGlobQualifier>,
1650}
1651
1652/// Lightweight, surface-preserving fragments inside a trailing zsh glob
1653/// qualifier group.
1654#[derive(Debug, Clone)]
1655pub enum ZshGlobQualifier {
1656    Negation {
1657        span: Span,
1658    },
1659    Flag {
1660        name: char,
1661        span: Span,
1662    },
1663    LetterSequence {
1664        text: SourceText,
1665        span: Span,
1666    },
1667    NumericArgument {
1668        span: Span,
1669        start: SourceText,
1670        end: Option<SourceText>,
1671    },
1672}
1673
1674/// Brace expansion surface form recognized inside a word.
1675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1676pub enum BraceExpansionKind {
1677    CommaList,
1678    Sequence,
1679}
1680
1681/// Quoting context for brace-like syntax inside a word.
1682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1683pub enum BraceQuoteContext {
1684    Unquoted,
1685    DoubleQuoted,
1686    SingleQuoted,
1687}
1688
1689/// Parser-owned classification for brace-like syntax inside a word.
1690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1691pub enum BraceSyntaxKind {
1692    Expansion(BraceExpansionKind),
1693    Literal,
1694    TemplatePlaceholder,
1695}
1696
1697/// A brace-like surface-syntax occurrence inside a word.
1698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1699pub struct BraceSyntax {
1700    pub kind: BraceSyntaxKind,
1701    pub span: Span,
1702    pub quote_context: BraceQuoteContext,
1703}
1704
1705impl BraceSyntax {
1706    pub const fn expansion_kind(self) -> Option<BraceExpansionKind> {
1707        match self.kind {
1708            BraceSyntaxKind::Expansion(kind) => Some(kind),
1709            BraceSyntaxKind::Literal | BraceSyntaxKind::TemplatePlaceholder => None,
1710        }
1711    }
1712
1713    pub const fn is_recognized_expansion(self) -> bool {
1714        matches!(self.kind, BraceSyntaxKind::Expansion(_))
1715    }
1716
1717    pub const fn expands(self) -> bool {
1718        self.is_recognized_expansion() && matches!(self.quote_context, BraceQuoteContext::Unquoted)
1719    }
1720
1721    pub const fn treated_literally(self) -> bool {
1722        !self.expands()
1723    }
1724}
1725
1726/// A word part paired with its source span.
1727#[derive(Debug, Clone)]
1728pub struct WordPartNode {
1729    pub kind: WordPart,
1730    pub span: Span,
1731}
1732
1733impl WordPartNode {
1734    pub fn new(kind: WordPart, span: Span) -> Self {
1735        Self { kind, span }
1736    }
1737}
1738
1739/// A word (potentially with expansions).
1740#[derive(Debug, Clone)]
1741pub struct Word {
1742    pub parts: Vec<WordPartNode>,
1743    /// Source span of this word
1744    pub span: Span,
1745    /// Parser-owned brace surface classification for this word.
1746    pub brace_syntax: Vec<BraceSyntax>,
1747}
1748
1749impl Word {
1750    /// Create a simple literal word.
1751    pub fn literal(s: impl Into<String>) -> Self {
1752        Self::literal_with_span(s, Span::new())
1753    }
1754
1755    /// Create a simple literal word with an explicit source span.
1756    pub fn literal_with_span(s: impl Into<String>, span: Span) -> Self {
1757        Self {
1758            parts: vec![WordPartNode::new(
1759                WordPart::Literal(LiteralText::owned(s.into())),
1760                span,
1761            )],
1762            span,
1763            brace_syntax: Vec::new(),
1764        }
1765    }
1766
1767    /// Create a quoted literal word (no brace/glob expansion).
1768    pub fn quoted_literal(s: impl Into<String>) -> Self {
1769        Self::quoted_literal_with_span(s, Span::new())
1770    }
1771
1772    /// Create a quoted literal word with an explicit source span.
1773    pub fn quoted_literal_with_span(s: impl Into<String>, span: Span) -> Self {
1774        Self {
1775            parts: vec![WordPartNode::new(
1776                WordPart::SingleQuoted {
1777                    value: SourceText::cooked(span, s.into()),
1778                    dollar: false,
1779                },
1780                span,
1781            )],
1782            span,
1783            brace_syntax: Vec::new(),
1784        }
1785    }
1786
1787    /// Create a source-backed literal word.
1788    pub fn source_literal_with_spans(span: Span, part_span: Span) -> Self {
1789        Self {
1790            parts: vec![WordPartNode::new(
1791                WordPart::Literal(LiteralText::source()),
1792                part_span,
1793            )],
1794            span,
1795            brace_syntax: Vec::new(),
1796        }
1797    }
1798
1799    /// Create a quoted source-backed literal word.
1800    pub fn quoted_source_literal_with_spans(span: Span, part_span: Span) -> Self {
1801        Self {
1802            parts: vec![WordPartNode::new(
1803                WordPart::SingleQuoted {
1804                    value: SourceText::source(part_span),
1805                    dollar: false,
1806                },
1807                span,
1808            )],
1809            span,
1810            brace_syntax: Vec::new(),
1811        }
1812    }
1813
1814    /// Set the source span on an existing word.
1815    pub fn with_span(mut self, span: Span) -> Self {
1816        let previous_span = self.span;
1817        self.span = span;
1818        if let [part] = self.parts.as_mut_slice()
1819            && part.span == previous_span
1820        {
1821            part.span = span;
1822        }
1823        self
1824    }
1825
1826    /// Get the source span for a specific word part.
1827    pub fn part_span(&self, index: usize) -> Option<Span> {
1828        self.parts.get(index).map(|part| part.span)
1829    }
1830
1831    /// Get a specific word part.
1832    pub fn part(&self, index: usize) -> Option<&WordPart> {
1833        self.parts.get(index).map(|part| &part.kind)
1834    }
1835
1836    /// Iterate over word parts and their spans together.
1837    pub fn parts_with_spans(&self) -> impl Iterator<Item = (&WordPart, Span)> + '_ {
1838        self.parts.iter().map(|part| (&part.kind, part.span))
1839    }
1840
1841    pub fn brace_syntax(&self) -> &[BraceSyntax] {
1842        &self.brace_syntax
1843    }
1844
1845    pub fn has_active_brace_expansion(&self) -> bool {
1846        self.brace_syntax.iter().copied().any(BraceSyntax::expands)
1847    }
1848
1849    pub fn is_fully_quoted(&self) -> bool {
1850        matches!(self.parts.as_slice(), [part] if part.kind.is_quoted())
1851    }
1852
1853    /// Returns the inner content span for a fully quoted word in the original source.
1854    pub fn quoted_content_span_in_source(&self, source: &str) -> Option<Span> {
1855        if !self.is_fully_quoted() {
1856            return None;
1857        }
1858
1859        let raw = self.span.slice(source);
1860        let quote = raw.chars().next()?;
1861        if !matches!(quote, '"' | '\'') {
1862            return None;
1863        }
1864
1865        let body = raw.strip_prefix(quote)?.strip_suffix(quote)?;
1866        if body.is_empty() {
1867            return None;
1868        }
1869
1870        let start = self.span.start.advanced_by(&raw[..quote.len_utf8()]);
1871        let end = start.advanced_by(body);
1872        Some(Span::from_positions(start, end))
1873    }
1874
1875    pub fn is_fully_double_quoted(&self) -> bool {
1876        matches!(
1877            self.parts.as_slice(),
1878            [WordPartNode {
1879                kind: WordPart::DoubleQuoted { .. },
1880                ..
1881            }]
1882        )
1883    }
1884
1885    pub fn has_quoted_parts(&self) -> bool {
1886        self.parts.iter().any(|part| part.kind.is_quoted())
1887    }
1888
1889    /// Returns this word's fully static decoded text when it contains no
1890    /// runtime expansions.
1891    pub fn try_static_text<'a>(&'a self, source: &'a str) -> Option<Cow<'a, str>> {
1892        static_word_text(self, source)
1893    }
1894
1895    /// Render this word using exact source slices when available and owned cooked
1896    /// text only where the parser normalized the input.
1897    pub fn render(&self, source: &str) -> String {
1898        let mut rendered = String::new();
1899        self.render_to_buf(source, &mut rendered);
1900        rendered
1901    }
1902
1903    /// Render this word as shell syntax, preserving quote delimiters and other
1904    /// syntactic wrappers when they are represented in the AST.
1905    pub fn render_syntax(&self, source: &str) -> String {
1906        let mut rendered = String::new();
1907        self.render_syntax_to_buf(source, &mut rendered);
1908        rendered
1909    }
1910
1911    /// Render this word into an existing buffer using exact source slices when
1912    /// available and owned cooked text only where the parser normalized the input.
1913    pub fn render_to_buf(&self, source: &str, rendered: &mut String) {
1914        assert_string_write(self.fmt_with_source_mode(rendered, Some(source), RenderMode::Decoded));
1915    }
1916
1917    /// Render this word as shell syntax into an existing buffer, preserving
1918    /// quote delimiters and other syntactic wrappers when they are represented
1919    /// in the AST.
1920    pub fn render_syntax_to_buf(&self, source: &str, rendered: &mut String) {
1921        assert_string_write(self.fmt_with_source_mode(rendered, Some(source), RenderMode::Syntax));
1922    }
1923
1924    fn fmt_with_source_mode(
1925        &self,
1926        f: &mut impl fmt::Write,
1927        source: Option<&str>,
1928        mode: RenderMode,
1929    ) -> fmt::Result {
1930        if matches!(mode, RenderMode::Syntax)
1931            && let Some(source) = source
1932            && word_prefers_whole_source_slice_in_syntax(self)
1933            && let Some(slice) = syntax_source_slice(self.span, source)
1934        {
1935            if slice.contains('\n') {
1936                f.write_str(slice)?;
1937            } else {
1938                f.write_str(trim_unescaped_trailing_whitespace(slice))?;
1939            }
1940            return Ok(());
1941        }
1942
1943        for (part, span) in self.parts_with_spans() {
1944            fmt_word_part_with_source_mode(f, part, span, source, mode)?;
1945        }
1946
1947        Ok(())
1948    }
1949}
1950
1951impl fmt::Display for Word {
1952    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1953        self.fmt_with_source_mode(f, None, RenderMode::Decoded)
1954    }
1955}
1956
1957/// Returns a word's fully static decoded text when it contains no runtime
1958/// expansions.
1959pub fn static_word_text<'a>(word: &'a Word, source: &'a str) -> Option<Cow<'a, str>> {
1960    try_static_word_parts_text(&word.parts, source)
1961}
1962
1963/// Returns a command word's fully static decoded command name when it contains
1964/// no runtime expansions.
1965///
1966/// Unlike [`static_word_text`], this applies the extra backslash decoding used
1967/// when a shell parses a command name position.
1968pub fn static_command_name_text<'a>(word: &'a Word, source: &'a str) -> Option<Cow<'a, str>> {
1969    try_static_command_name_parts_text(&word.parts, source, StaticCommandNameContext::Unquoted)
1970}
1971
1972/// The result of looking for a static shell precommand wrapper.
1973#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1974pub enum StaticCommandWrapperTarget {
1975    /// The current word is not a supported precommand wrapper.
1976    NotWrapper,
1977    /// The current word is a wrapper, and the optional index points at its command target.
1978    Wrapper {
1979        /// The wrapped command index, or `None` when the wrapper mode does not execute a target.
1980        target_index: Option<usize>,
1981    },
1982}
1983
1984/// Resolves the target index for common shell precommand wrappers.
1985///
1986/// This handles wrappers shared by parsing, semantic analysis, and linter facts:
1987/// `noglob`, `command`, `builtin`, and `exec`. The callback should return the
1988/// already-static text for a word index, or `None` when that word is dynamic.
1989pub fn static_command_wrapper_target_index<'a>(
1990    word_count: usize,
1991    current_index: usize,
1992    current_name: &str,
1993    mut static_text_at: impl FnMut(usize) -> Option<Cow<'a, str>>,
1994) -> StaticCommandWrapperTarget {
1995    let target_index = match current_name {
1996        "noglob" => next_word_index(word_count, current_index),
1997        "command" => command_wrapper_target_index(word_count, current_index, &mut static_text_at),
1998        "builtin" => builtin_wrapper_target_index(word_count, current_index, &mut static_text_at),
1999        "exec" => exec_wrapper_target_index(word_count, current_index, &mut static_text_at),
2000        _ => return StaticCommandWrapperTarget::NotWrapper,
2001    };
2002
2003    StaticCommandWrapperTarget::Wrapper { target_index }
2004}
2005
2006/// Returns fully static decoded text for a contiguous slice of word parts when
2007/// the slice contains no runtime expansions.
2008pub fn try_static_word_parts_text<'a>(
2009    parts: &'a [WordPartNode],
2010    source: &'a str,
2011) -> Option<Cow<'a, str>> {
2012    if let [part] = parts {
2013        return try_static_word_part_text(part, source);
2014    }
2015
2016    let mut result = String::new();
2017    collect_static_word_parts_text(parts, source, &mut result).then_some(Cow::Owned(result))
2018}
2019
2020fn try_static_word_part_text<'a>(part: &'a WordPartNode, source: &'a str) -> Option<Cow<'a, str>> {
2021    match &part.kind {
2022        WordPart::Literal(text) => Some(Cow::Borrowed(text.as_str(source, part.span))),
2023        WordPart::SingleQuoted { value, .. } => Some(Cow::Borrowed(value.slice(source))),
2024        WordPart::DoubleQuoted { parts, .. } => try_static_word_parts_text(parts, source),
2025        _ => None,
2026    }
2027}
2028
2029fn collect_static_word_parts_text(parts: &[WordPartNode], source: &str, out: &mut String) -> bool {
2030    for part in parts {
2031        match &part.kind {
2032            WordPart::Literal(text) => out.push_str(text.as_str(source, part.span)),
2033            WordPart::SingleQuoted { value, .. } => out.push_str(value.slice(source)),
2034            WordPart::DoubleQuoted { parts, .. } => {
2035                if !collect_static_word_parts_text(parts, source, out) {
2036                    return false;
2037                }
2038            }
2039            _ => return false,
2040        }
2041    }
2042
2043    true
2044}
2045
2046#[derive(Clone, Copy)]
2047enum StaticCommandNameContext {
2048    Unquoted,
2049    DoubleQuoted,
2050}
2051
2052fn try_static_command_name_parts_text<'a>(
2053    parts: &'a [WordPartNode],
2054    source: &'a str,
2055    context: StaticCommandNameContext,
2056) -> Option<Cow<'a, str>> {
2057    if let [part] = parts {
2058        return try_static_command_name_part_text(part, source, context);
2059    }
2060
2061    let mut result = String::new();
2062    collect_static_command_name_parts_text(parts, source, context, &mut result)
2063        .then_some(Cow::Owned(result))
2064}
2065
2066fn try_static_command_name_part_text<'a>(
2067    part: &'a WordPartNode,
2068    source: &'a str,
2069    context: StaticCommandNameContext,
2070) -> Option<Cow<'a, str>> {
2071    match &part.kind {
2072        WordPart::Literal(text) => Some(decode_static_command_literal(
2073            text.as_str(source, part.span),
2074            context,
2075        )),
2076        WordPart::SingleQuoted { value, .. } => Some(Cow::Borrowed(value.slice(source))),
2077        WordPart::DoubleQuoted { parts, .. } => try_static_command_name_parts_text(
2078            parts,
2079            source,
2080            StaticCommandNameContext::DoubleQuoted,
2081        ),
2082        _ => None,
2083    }
2084}
2085
2086fn collect_static_command_name_parts_text(
2087    parts: &[WordPartNode],
2088    source: &str,
2089    context: StaticCommandNameContext,
2090    out: &mut String,
2091) -> bool {
2092    for part in parts {
2093        match &part.kind {
2094            WordPart::Literal(text) => {
2095                append_static_command_literal(text.as_str(source, part.span), context, out);
2096            }
2097            WordPart::SingleQuoted { value, .. } => out.push_str(value.slice(source)),
2098            WordPart::DoubleQuoted { parts, .. } => {
2099                if !collect_static_command_name_parts_text(
2100                    parts,
2101                    source,
2102                    StaticCommandNameContext::DoubleQuoted,
2103                    out,
2104                ) {
2105                    return false;
2106                }
2107            }
2108            _ => return false,
2109        }
2110    }
2111
2112    true
2113}
2114
2115fn decode_static_command_literal(text: &str, context: StaticCommandNameContext) -> Cow<'_, str> {
2116    let Some(first_escape) = first_static_command_literal_escape(text.as_bytes()) else {
2117        return Cow::Borrowed(text);
2118    };
2119
2120    let mut result = String::with_capacity(text.len());
2121    append_decoded_static_command_literal(text, first_escape, context, &mut result);
2122    Cow::Owned(result)
2123}
2124
2125fn append_static_command_literal(text: &str, context: StaticCommandNameContext, out: &mut String) {
2126    let Some(first_escape) = first_static_command_literal_escape(text.as_bytes()) else {
2127        out.push_str(text);
2128        return;
2129    };
2130
2131    append_decoded_static_command_literal(text, first_escape, context, out);
2132}
2133
2134fn append_decoded_static_command_literal(
2135    text: &str,
2136    first_escape: usize,
2137    context: StaticCommandNameContext,
2138    out: &mut String,
2139) {
2140    let bytes = text.as_bytes();
2141    let mut copy_start = 0usize;
2142    let mut index = first_escape;
2143
2144    while index < bytes.len() {
2145        if bytes[index] != b'\\' {
2146            index += 1;
2147            continue;
2148        }
2149
2150        out.push_str(&text[copy_start..index]);
2151        index += 1;
2152
2153        let Some(&next) = bytes.get(index) else {
2154            out.push('\\');
2155            return;
2156        };
2157
2158        match context {
2159            StaticCommandNameContext::Unquoted => {
2160                copy_start = if next == b'\n' { index + 1 } else { index };
2161            }
2162            StaticCommandNameContext::DoubleQuoted => match next {
2163                b'$' | b'`' | b'"' | b'\\' => copy_start = index,
2164                b'\n' => copy_start = index + 1,
2165                _ => {
2166                    out.push('\\');
2167                    copy_start = index;
2168                }
2169            },
2170        }
2171
2172        index += 1;
2173    }
2174
2175    out.push_str(&text[copy_start..]);
2176}
2177
2178fn first_static_command_literal_escape(bytes: &[u8]) -> Option<usize> {
2179    bytes.iter().position(|&byte| byte == b'\\')
2180}
2181
2182fn next_word_index(word_count: usize, current_index: usize) -> Option<usize> {
2183    let index = current_index + 1;
2184    (index < word_count).then_some(index)
2185}
2186
2187fn command_wrapper_target_index<'a>(
2188    word_count: usize,
2189    current_index: usize,
2190    static_text_at: &mut impl FnMut(usize) -> Option<Cow<'a, str>>,
2191) -> Option<usize> {
2192    let mut index = current_index + 1;
2193
2194    while index < word_count {
2195        let Some(arg) = static_text_at(index) else {
2196            return Some(index);
2197        };
2198
2199        if arg == "--" {
2200            return next_word_index(word_count, index);
2201        }
2202
2203        if let Some(options) = arg.strip_prefix('-') {
2204            if options.is_empty() {
2205                return Some(index);
2206            }
2207
2208            let mut lookup_mode = false;
2209            for option in options.chars() {
2210                match option {
2211                    'p' => {}
2212                    'v' | 'V' => lookup_mode = true,
2213                    _ => return None,
2214                }
2215            }
2216
2217            if lookup_mode {
2218                return None;
2219            }
2220
2221            index += 1;
2222            continue;
2223        }
2224
2225        return Some(index);
2226    }
2227
2228    None
2229}
2230
2231fn builtin_wrapper_target_index<'a>(
2232    word_count: usize,
2233    current_index: usize,
2234    static_text_at: &mut impl FnMut(usize) -> Option<Cow<'a, str>>,
2235) -> Option<usize> {
2236    let index = current_index + 1;
2237    if index >= word_count {
2238        return None;
2239    }
2240
2241    let Some(arg) = static_text_at(index) else {
2242        return Some(index);
2243    };
2244
2245    if arg == "--" {
2246        return next_word_index(word_count, index);
2247    }
2248
2249    if arg.starts_with('-') && arg != "-" {
2250        return None;
2251    }
2252
2253    Some(index)
2254}
2255
2256fn exec_wrapper_target_index<'a>(
2257    word_count: usize,
2258    current_index: usize,
2259    static_text_at: &mut impl FnMut(usize) -> Option<Cow<'a, str>>,
2260) -> Option<usize> {
2261    let mut index = current_index + 1;
2262
2263    while index < word_count {
2264        let Some(arg) = static_text_at(index) else {
2265            return Some(index);
2266        };
2267
2268        if arg == "--" {
2269            return next_word_index(word_count, index);
2270        }
2271
2272        if let Some(options) = arg.strip_prefix('-') {
2273            if options.is_empty() {
2274                return Some(index);
2275            }
2276
2277            let mut consumed_words = 1;
2278            for (offset, option) in options.char_indices() {
2279                match option {
2280                    'c' | 'l' => {}
2281                    'a' => {
2282                        let has_attached_name = offset + option.len_utf8() < options.len();
2283                        if !has_attached_name {
2284                            if index + consumed_words >= word_count {
2285                                return None;
2286                            }
2287                            consumed_words += 1;
2288                        }
2289                        break;
2290                    }
2291                    _ => return None,
2292                }
2293            }
2294
2295            index += consumed_words;
2296            continue;
2297        }
2298
2299        return Some(index);
2300    }
2301
2302    None
2303}
2304
2305/// Returns whether `name` is a shell variable identifier.
2306pub fn is_shell_variable_name(name: &str) -> bool {
2307    let mut chars = name.chars();
2308    match chars.next() {
2309        Some(first) if first == '_' || first.is_ascii_alphabetic() => {
2310            chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
2311        }
2312        _ => false,
2313    }
2314}
2315
2316/// Returns whether the word is a single variable-like expansion part.
2317pub fn word_is_standalone_variable_like(word: &Word) -> bool {
2318    match word.parts.as_slice() {
2319        [part] => matches!(
2320            part.kind,
2321            WordPart::Variable(_)
2322                | WordPart::Parameter(_)
2323                | WordPart::ParameterExpansion { .. }
2324                | WordPart::Length(_)
2325                | WordPart::ArrayAccess(_)
2326                | WordPart::ArrayLength(_)
2327                | WordPart::ArrayIndices(_)
2328                | WordPart::Substring { .. }
2329                | WordPart::ArraySlice { .. }
2330                | WordPart::IndirectExpansion { .. }
2331                | WordPart::PrefixMatch { .. }
2332                | WordPart::Transformation { .. }
2333        ),
2334        _ => false,
2335    }
2336}
2337
2338/// Returns whether the word captures only the previous command status.
2339pub fn word_is_standalone_status_capture(word: &Word) -> bool {
2340    matches!(word.parts.as_slice(), [part] if is_standalone_status_capture_part(&part.kind))
2341}
2342
2343fn is_standalone_status_capture_part(part: &WordPart) -> bool {
2344    match part {
2345        WordPart::Variable(name) => name.as_str() == "?",
2346        WordPart::DoubleQuoted { parts, .. } => {
2347            matches!(parts.as_slice(), [part] if is_standalone_status_capture_part(&part.kind))
2348        }
2349        WordPart::Parameter(parameter) => matches!(
2350            parameter.bourne(),
2351            Some(BourneParameterExpansion::Access { reference })
2352                if reference.name.as_str() == "?" && reference.subscript.is_none()
2353        ),
2354        _ => false,
2355    }
2356}
2357
2358/// Whether a heredoc body expands shell syntax.
2359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2360pub enum HeredocBodyMode {
2361    Literal,
2362    Expanding,
2363}
2364
2365impl HeredocBodyMode {
2366    pub const fn expands(self) -> bool {
2367        matches!(self, Self::Expanding)
2368    }
2369}
2370
2371/// A heredoc body part paired with its source span.
2372#[derive(Debug, Clone)]
2373pub struct HeredocBodyPartNode {
2374    pub kind: HeredocBodyPart,
2375    pub span: Span,
2376}
2377
2378impl HeredocBodyPartNode {
2379    pub fn new(kind: HeredocBodyPart, span: Span) -> Self {
2380        Self { kind, span }
2381    }
2382}
2383
2384/// Parts of a heredoc body.
2385#[derive(Debug, Clone)]
2386pub enum HeredocBodyPart {
2387    Literal(LiteralText),
2388    Variable(Name),
2389    CommandSubstitution {
2390        body: StmtSeq,
2391        syntax: CommandSubstitutionSyntax,
2392    },
2393    ArithmeticExpansion {
2394        expression: SourceText,
2395        expression_ast: Option<ArithmeticExprNode>,
2396        expression_word_ast: Word,
2397        syntax: ArithmeticExpansionSyntax,
2398    },
2399    Parameter(Box<ParameterExpansion>),
2400}
2401
2402/// A parsed heredoc body with its expansion mode.
2403#[derive(Debug, Clone)]
2404pub struct HeredocBody {
2405    pub mode: HeredocBodyMode,
2406    pub source_backed: bool,
2407    pub parts: Vec<HeredocBodyPartNode>,
2408    pub span: Span,
2409}
2410
2411impl HeredocBody {
2412    pub fn literal_with_span(s: impl Into<String>, span: Span) -> Self {
2413        Self {
2414            mode: HeredocBodyMode::Literal,
2415            source_backed: true,
2416            parts: vec![HeredocBodyPartNode::new(
2417                HeredocBodyPart::Literal(LiteralText::owned(s.into())),
2418                span,
2419            )],
2420            span,
2421        }
2422    }
2423
2424    pub fn source_literal_with_spans(span: Span, part_span: Span) -> Self {
2425        Self {
2426            mode: HeredocBodyMode::Literal,
2427            source_backed: true,
2428            parts: vec![HeredocBodyPartNode::new(
2429                HeredocBodyPart::Literal(LiteralText::source()),
2430                part_span,
2431            )],
2432            span,
2433        }
2434    }
2435
2436    pub fn with_mode(mut self, mode: HeredocBodyMode) -> Self {
2437        self.mode = mode;
2438        self
2439    }
2440
2441    pub fn with_source_backed(mut self, source_backed: bool) -> Self {
2442        self.source_backed = source_backed;
2443        self
2444    }
2445
2446    pub fn part_span(&self, index: usize) -> Option<Span> {
2447        self.parts.get(index).map(|part| part.span)
2448    }
2449
2450    pub fn part(&self, index: usize) -> Option<&HeredocBodyPart> {
2451        self.parts.get(index).map(|part| &part.kind)
2452    }
2453
2454    pub fn parts_with_spans(&self) -> impl Iterator<Item = (&HeredocBodyPart, Span)> + '_ {
2455        self.parts.iter().map(|part| (&part.kind, part.span))
2456    }
2457
2458    pub fn render(&self, source: &str) -> String {
2459        let mut rendered = String::new();
2460        self.render_to_buf(source, &mut rendered);
2461        rendered
2462    }
2463
2464    pub fn render_syntax(&self, source: &str) -> String {
2465        let mut rendered = String::new();
2466        self.render_syntax_to_buf(source, &mut rendered);
2467        rendered
2468    }
2469
2470    pub fn render_to_buf(&self, source: &str, rendered: &mut String) {
2471        assert_string_write(self.fmt_with_source(rendered, Some(source)));
2472    }
2473
2474    pub fn render_syntax_to_buf(&self, source: &str, rendered: &mut String) {
2475        assert_string_write(self.fmt_with_source(rendered, Some(source)));
2476    }
2477
2478    fn fmt_with_source(&self, f: &mut impl fmt::Write, source: Option<&str>) -> fmt::Result {
2479        let source = source.filter(|_| self.source_backed);
2480        if let Some(source) = source
2481            && heredoc_body_prefers_whole_source_slice(self)
2482            && let Some(slice) = syntax_source_slice(self.span, source)
2483        {
2484            f.write_str(slice)?;
2485            return Ok(());
2486        }
2487
2488        for (part, span) in self.parts_with_spans() {
2489            fmt_heredoc_body_part_with_source(f, part, span, source)?;
2490        }
2491
2492        Ok(())
2493    }
2494}
2495
2496impl fmt::Display for HeredocBody {
2497    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2498        self.fmt_with_source(f, None)
2499    }
2500}
2501
2502/// A shell pattern in a pattern-sensitive context such as `case`, `[[ ... == ... ]]`,
2503/// or parameter pattern operators.
2504#[derive(Debug, Clone)]
2505pub struct Pattern {
2506    pub parts: Vec<PatternPartNode>,
2507    pub span: Span,
2508}
2509
2510impl Pattern {
2511    /// Get the source span for a specific pattern part.
2512    pub fn part_span(&self, index: usize) -> Option<Span> {
2513        self.parts.get(index).map(|part| part.span)
2514    }
2515
2516    pub fn is_source_backed(&self) -> bool {
2517        self.parts
2518            .iter()
2519            .all(|part| pattern_part_is_source_backed(&part.kind))
2520    }
2521
2522    /// Iterate over pattern parts and their spans together.
2523    pub fn parts_with_spans(&self) -> impl Iterator<Item = (&PatternPart, Span)> + '_ {
2524        self.parts.iter().map(|part| (&part.kind, part.span))
2525    }
2526
2527    /// Render this pattern using exact source slices when available and owned cooked
2528    /// text only where the parser normalized the input.
2529    pub fn render(&self, source: &str) -> String {
2530        let mut rendered = String::new();
2531        self.render_to_buf(source, &mut rendered);
2532        rendered
2533    }
2534
2535    /// Render this pattern as shell syntax, preserving quoted fragments when
2536    /// they are represented in the AST.
2537    pub fn render_syntax(&self, source: &str) -> String {
2538        let mut rendered = String::new();
2539        self.render_syntax_to_buf(source, &mut rendered);
2540        rendered
2541    }
2542
2543    /// Render this pattern into an existing buffer using exact source slices
2544    /// when available and owned cooked text only where the parser normalized
2545    /// the input.
2546    pub fn render_to_buf(&self, source: &str, rendered: &mut String) {
2547        assert_string_write(self.fmt_with_source_mode(rendered, Some(source), RenderMode::Decoded));
2548    }
2549
2550    /// Render this pattern as shell syntax into an existing buffer, preserving
2551    /// quoted fragments when they are represented in the AST.
2552    pub fn render_syntax_to_buf(&self, source: &str, rendered: &mut String) {
2553        assert_string_write(self.fmt_with_source_mode(rendered, Some(source), RenderMode::Syntax));
2554    }
2555
2556    fn fmt_with_source_mode(
2557        &self,
2558        f: &mut impl fmt::Write,
2559        source: Option<&str>,
2560        mode: RenderMode,
2561    ) -> fmt::Result {
2562        if matches!(mode, RenderMode::Syntax)
2563            && let Some(source) = source
2564            && pattern_prefers_whole_source_slice_in_syntax(self)
2565            && let Some(slice) = syntax_source_slice(self.span, source)
2566        {
2567            if slice.contains('\n') {
2568                f.write_str(slice)?;
2569            } else {
2570                f.write_str(trim_unescaped_trailing_whitespace(slice))?;
2571            }
2572            return Ok(());
2573        }
2574
2575        for (part, span) in self.parts_with_spans() {
2576            fmt_pattern_part_with_source_mode(f, part, span, source, mode)?;
2577        }
2578
2579        Ok(())
2580    }
2581}
2582
2583impl fmt::Display for Pattern {
2584    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2585        self.fmt_with_source_mode(f, None, RenderMode::Decoded)
2586    }
2587}
2588
2589/// The extglob operator for a pattern group.
2590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2591pub enum PatternGroupKind {
2592    ZeroOrOne,
2593    ZeroOrMore,
2594    OneOrMore,
2595    ExactlyOne,
2596    NoneOf,
2597}
2598
2599impl PatternGroupKind {
2600    pub fn prefix(self) -> char {
2601        match self {
2602            Self::ZeroOrOne => '?',
2603            Self::ZeroOrMore => '*',
2604            Self::OneOrMore => '+',
2605            Self::ExactlyOne => '@',
2606            Self::NoneOf => '!',
2607        }
2608    }
2609}
2610
2611/// A pattern part paired with its source span.
2612#[derive(Debug, Clone)]
2613pub struct PatternPartNode {
2614    pub kind: PatternPart,
2615    pub span: Span,
2616}
2617
2618impl PatternPartNode {
2619    pub fn new(kind: PatternPart, span: Span) -> Self {
2620        Self { kind, span }
2621    }
2622}
2623
2624/// Parts of a pattern.
2625#[derive(Debug, Clone)]
2626pub enum PatternPart {
2627    Literal(LiteralText),
2628    AnyString,
2629    AnyChar,
2630    CharClass(SourceText),
2631    Group {
2632        kind: PatternGroupKind,
2633        patterns: Vec<Pattern>,
2634    },
2635    Word(Word),
2636}
2637
2638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2639enum RenderMode {
2640    Decoded,
2641    Syntax,
2642}
2643
2644fn syntax_source_slice(span: Span, source: &str) -> Option<&str> {
2645    (span.start.offset < span.end.offset && span.end.offset <= source.len())
2646        .then(|| span.slice(source))
2647}
2648
2649fn word_prefers_whole_source_slice_in_syntax(word: &Word) -> bool {
2650    matches!(
2651        word.parts.as_slice(),
2652        [part] if part.span == word.span && top_level_word_part_prefers_source_slice_in_syntax(&part.kind)
2653    )
2654}
2655
2656fn top_level_word_part_prefers_source_slice_in_syntax(part: &WordPart) -> bool {
2657    match part {
2658        WordPart::Literal(text) => text.is_source_backed(),
2659        WordPart::SingleQuoted { value, .. } => value.is_source_backed(),
2660        WordPart::DoubleQuoted { parts, .. } => parts.iter().all(|part| match &part.kind {
2661            WordPart::Literal(_) => true,
2662            other => part_prefers_source_slice_in_syntax(other) && part_is_source_backed(other),
2663        }),
2664        _ => part_prefers_source_slice_in_syntax(part) && part_is_source_backed(part),
2665    }
2666}
2667
2668fn pattern_prefers_whole_source_slice_in_syntax(pattern: &Pattern) -> bool {
2669    !pattern.parts.is_empty()
2670        && pattern
2671            .parts
2672            .iter()
2673            .all(|part| top_level_pattern_part_prefers_source_slice_in_syntax(&part.kind))
2674}
2675
2676fn heredoc_body_prefers_whole_source_slice(body: &HeredocBody) -> bool {
2677    !body.parts.is_empty()
2678        && body
2679            .parts
2680            .iter()
2681            .all(|part| heredoc_body_part_is_source_backed(&part.kind))
2682}
2683
2684fn top_level_pattern_part_prefers_source_slice_in_syntax(part: &PatternPart) -> bool {
2685    match part {
2686        PatternPart::Literal(_) | PatternPart::AnyString | PatternPart::AnyChar => true,
2687        PatternPart::CharClass(text) => text.is_source_backed(),
2688        PatternPart::Group { patterns, .. } => patterns
2689            .iter()
2690            .all(pattern_prefers_whole_source_slice_in_syntax),
2691        PatternPart::Word(word) => word_prefers_whole_source_slice_in_syntax(word),
2692    }
2693}
2694
2695fn display_source_text<'a>(text: Option<&'a SourceText>, source: Option<&'a str>) -> &'a str {
2696    match (text, source) {
2697        (Some(text), Some(source)) => text.slice(source),
2698        (
2699            Some(SourceText {
2700                cooked: Some(text), ..
2701            }),
2702            None,
2703        ) => text.as_ref(),
2704        (Some(_), None) => "...",
2705        (None, _) => "",
2706    }
2707}
2708
2709fn display_subscript_text<'a>(subscript: &'a Subscript, source: Option<&'a str>) -> Cow<'a, str> {
2710    match (source, subscript.selector()) {
2711        (Some(source), _) => Cow::Borrowed(subscript.syntax_text(source)),
2712        (None, Some(selector)) => Cow::Owned(selector.as_char().to_string()),
2713        (None, None) => Cow::Borrowed(display_source_text(
2714            Some(subscript.syntax_source_text()),
2715            source,
2716        )),
2717    }
2718}
2719
2720fn fmt_var_ref_with_source(
2721    f: &mut impl fmt::Write,
2722    reference: &VarRef,
2723    source: Option<&str>,
2724) -> fmt::Result {
2725    write!(f, "{}", reference.name)?;
2726    if let Some(subscript) = &reference.subscript {
2727        write!(f, "[{}]", display_subscript_text(subscript, source))?;
2728    }
2729    Ok(())
2730}
2731
2732/// Parts of a word.
2733#[derive(Debug, Clone)]
2734pub enum WordPart {
2735    /// Literal text
2736    Literal(LiteralText),
2737    /// Zsh glob with one classic trailing qualifier group such as `*(.)`.
2738    ZshQualifiedGlob(ZshQualifiedGlob),
2739    /// Single-quoted literal content, including `$'...'` ANSI-C quoting.
2740    SingleQuoted { value: SourceText, dollar: bool },
2741    /// Double-quoted content with nested expansions.
2742    DoubleQuoted {
2743        parts: Vec<WordPartNode>,
2744        dollar: bool,
2745    },
2746    /// Variable expansion ($VAR or ${VAR})
2747    Variable(Name),
2748    /// Command substitution ($(...)) or legacy backticks.
2749    CommandSubstitution {
2750        body: StmtSeq,
2751        syntax: CommandSubstitutionSyntax,
2752    },
2753    /// Arithmetic expansion ($((...)) or legacy $[...]).
2754    ArithmeticExpansion {
2755        expression: SourceText,
2756        /// Typed arithmetic view of `expression`.
2757        expression_ast: Option<ArithmeticExprNode>,
2758        /// Parsed shell-word view of `expression`.
2759        expression_word_ast: Word,
2760        syntax: ArithmeticExpansionSyntax,
2761    },
2762    /// Unified parameter-expansion family for `${...}` forms.
2763    Parameter(ParameterExpansion),
2764    /// Parameter expansion with operator ${var:-default}, ${var:=default}, etc.
2765    /// `colon_variant` distinguishes `:-` (unset-or-empty) from `-` (unset-only).
2766    ParameterExpansion {
2767        reference: VarRef,
2768        operator: ParameterOp,
2769        operand: Option<SourceText>,
2770        operand_word_ast: Option<Word>,
2771        colon_variant: bool,
2772    },
2773    /// Length expansion ${#var}
2774    Length(VarRef),
2775    /// Array element access `${arr[index]}` or `${arr[@]}` or `${arr[*]}`
2776    ArrayAccess(VarRef),
2777    /// Array length `${#arr[@]}` or `${#arr[*]}`
2778    ArrayLength(VarRef),
2779    /// Array indices `${!arr[@]}` or `${!arr[*]}`
2780    ArrayIndices(VarRef),
2781    /// Substring extraction `${var:offset}` or `${var:offset:length}`
2782    Substring {
2783        reference: VarRef,
2784        offset: SourceText,
2785        /// Typed arithmetic view of `offset` when it parses as arithmetic.
2786        offset_ast: Option<ArithmeticExprNode>,
2787        /// Parsed shell-word view of `offset`.
2788        offset_word_ast: Word,
2789        length: Option<SourceText>,
2790        /// Typed arithmetic view of `length` when it parses as arithmetic.
2791        length_ast: Option<ArithmeticExprNode>,
2792        /// Parsed shell-word view of `length`.
2793        length_word_ast: Option<Word>,
2794    },
2795    /// Array slice `${arr[@]:offset:length}`
2796    ArraySlice {
2797        reference: VarRef,
2798        offset: SourceText,
2799        /// Typed arithmetic view of `offset` when it parses as arithmetic.
2800        offset_ast: Option<ArithmeticExprNode>,
2801        /// Parsed shell-word view of `offset`.
2802        offset_word_ast: Word,
2803        length: Option<SourceText>,
2804        /// Typed arithmetic view of `length` when it parses as arithmetic.
2805        length_ast: Option<ArithmeticExprNode>,
2806        /// Parsed shell-word view of `length`.
2807        length_word_ast: Option<Word>,
2808    },
2809    /// Indirect expansion `${!var}` - expands to value of variable named by var's value
2810    /// Optionally composed with an operator: `${!var:-default}`, `${!var:=val}`, etc.
2811    IndirectExpansion {
2812        reference: VarRef,
2813        operator: Option<ParameterOp>,
2814        operand: Option<SourceText>,
2815        operand_word_ast: Option<Word>,
2816        colon_variant: bool,
2817    },
2818    /// Prefix matching `${!prefix*}` or `${!prefix@}` - names of variables with given prefix
2819    PrefixMatch { prefix: Name, kind: PrefixMatchKind },
2820    /// Process substitution <(cmd) or >(cmd)
2821    ProcessSubstitution {
2822        /// The commands to run
2823        body: StmtSeq,
2824        /// True for <(cmd), false for >(cmd)
2825        is_input: bool,
2826    },
2827    /// Parameter transformation `${var@op}` where op is Q, E, P, A, K, a, u, U, L
2828    Transformation { reference: VarRef, operator: char },
2829}
2830
2831impl WordPart {
2832    pub fn is_quoted(&self) -> bool {
2833        matches!(self, Self::SingleQuoted { .. } | Self::DoubleQuoted { .. })
2834    }
2835}
2836
2837/// Compound array literal assigned with `(...)`.
2838#[derive(Debug, Clone)]
2839pub struct ArrayExpr {
2840    pub kind: ArrayKind,
2841    pub elements: Vec<ArrayElem>,
2842    pub span: Span,
2843}
2844
2845/// The array flavor implied by the current parse context.
2846#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2847pub enum ArrayKind {
2848    Indexed,
2849    Associative,
2850    Contextual,
2851}
2852
2853/// A compound-array value word plus parser-owned surface metadata.
2854#[derive(Debug, Clone)]
2855pub struct ArrayValueWord {
2856    pub word: Word,
2857    pub has_top_level_unquoted_comma: bool,
2858}
2859
2860impl ArrayValueWord {
2861    pub fn new(word: Word, has_top_level_unquoted_comma: bool) -> Self {
2862        Self {
2863            word,
2864            has_top_level_unquoted_comma,
2865        }
2866    }
2867
2868    pub fn has_top_level_unquoted_comma(&self) -> bool {
2869        self.has_top_level_unquoted_comma
2870    }
2871
2872    pub fn span(&self) -> Span {
2873        self.word.span
2874    }
2875}
2876
2877impl From<Word> for ArrayValueWord {
2878    fn from(word: Word) -> Self {
2879        Self::new(word, false)
2880    }
2881}
2882
2883impl Deref for ArrayValueWord {
2884    type Target = Word;
2885
2886    fn deref(&self) -> &Self::Target {
2887        &self.word
2888    }
2889}
2890
2891impl DerefMut for ArrayValueWord {
2892    fn deref_mut(&mut self) -> &mut Self::Target {
2893        &mut self.word
2894    }
2895}
2896
2897/// An element inside a compound array literal.
2898#[derive(Debug, Clone)]
2899pub enum ArrayElem {
2900    Sequential(ArrayValueWord),
2901    Keyed {
2902        key: Subscript,
2903        value: ArrayValueWord,
2904    },
2905    KeyedAppend {
2906        key: Subscript,
2907        value: ArrayValueWord,
2908    },
2909}
2910
2911impl ArrayElem {
2912    pub fn span(&self) -> Span {
2913        match self {
2914            Self::Sequential(word) => word.span(),
2915            Self::Keyed { key, value } | Self::KeyedAppend { key, value } => {
2916                key.span().merge(value.span())
2917            }
2918        }
2919    }
2920
2921    pub fn value(&self) -> &ArrayValueWord {
2922        match self {
2923            Self::Sequential(word) => word,
2924            Self::Keyed { value, .. } | Self::KeyedAppend { value, .. } => value,
2925        }
2926    }
2927
2928    pub fn value_mut(&mut self) -> &mut ArrayValueWord {
2929        match self {
2930            Self::Sequential(word) => word,
2931            Self::Keyed { value, .. } | Self::KeyedAppend { value, .. } => value,
2932        }
2933    }
2934}
2935
2936fn fmt_literal_text(
2937    f: &mut impl fmt::Write,
2938    text: &LiteralText,
2939    span: Span,
2940    source: Option<&str>,
2941) -> fmt::Result {
2942    match source {
2943        Some(source) => f.write_str(text.as_str(source, span)),
2944        None => match text {
2945            LiteralText::Source => f.write_str("<source>"),
2946            LiteralText::Owned(text) | LiteralText::CookedSource(text) => f.write_str(text),
2947        },
2948    }
2949}
2950
2951fn fmt_double_quoted_literal_text(
2952    f: &mut impl fmt::Write,
2953    text: &LiteralText,
2954    span: Span,
2955    source: Option<&str>,
2956) -> fmt::Result {
2957    let rendered = match source {
2958        Some(source) => text.as_str(source, span),
2959        None => match text {
2960            LiteralText::Source => "<source>",
2961            LiteralText::Owned(text) | LiteralText::CookedSource(text) => text,
2962        },
2963    };
2964
2965    for ch in rendered.chars() {
2966        match ch {
2967            '"' | '\\' | '$' | '`' => {
2968                f.write_char('\\')?;
2969                f.write_char(ch)?;
2970            }
2971            _ => f.write_char(ch)?,
2972        }
2973    }
2974
2975    Ok(())
2976}
2977
2978fn fmt_pattern_part_with_source_mode(
2979    f: &mut impl fmt::Write,
2980    part: &PatternPart,
2981    span: Span,
2982    source: Option<&str>,
2983    mode: RenderMode,
2984) -> fmt::Result {
2985    match part {
2986        PatternPart::Literal(text) => match (mode, source) {
2987            (RenderMode::Syntax, Some(source))
2988                if text.is_source_backed() && span.end.offset <= source.len() =>
2989            {
2990                f.write_str(text.syntax_str(source, span))?
2991            }
2992            _ => fmt_literal_text(f, text, span, source)?,
2993        },
2994        PatternPart::AnyString => f.write_str("*")?,
2995        PatternPart::AnyChar => f.write_str("?")?,
2996        PatternPart::CharClass(text) => match source {
2997            Some(source) if span.end.offset <= source.len() => f.write_str(span.slice(source))?,
2998            _ => f.write_str(display_source_text(Some(text), source))?,
2999        },
3000        PatternPart::Group { kind, patterns } => {
3001            write!(f, "{}(", kind.prefix())?;
3002            let mut patterns = patterns.iter();
3003            if let Some(pattern) = patterns.next() {
3004                pattern.fmt_with_source_mode(f, source, mode)?;
3005                for pattern in patterns {
3006                    f.write_str("|")?;
3007                    pattern.fmt_with_source_mode(f, source, mode)?;
3008                }
3009            }
3010            f.write_str(")")?;
3011        }
3012        PatternPart::Word(word) => word.fmt_with_source_mode(f, source, mode)?,
3013    }
3014
3015    Ok(())
3016}
3017
3018fn fmt_word_part_with_source_mode(
3019    f: &mut impl fmt::Write,
3020    part: &WordPart,
3021    span: Span,
3022    source: Option<&str>,
3023    mode: RenderMode,
3024) -> fmt::Result {
3025    if matches!(mode, RenderMode::Syntax)
3026        && let Some(source) = source
3027        && part_prefers_source_slice_in_syntax(part)
3028        && part_is_source_backed(part)
3029        && span.end.offset <= source.len()
3030    {
3031        f.write_str(span.slice(source))?;
3032        return Ok(());
3033    }
3034
3035    match part {
3036        WordPart::Literal(text) => match (mode, source) {
3037            (RenderMode::Syntax, Some(source))
3038                if text.is_source_backed() && span.end.offset <= source.len() =>
3039            {
3040                f.write_str(trim_unescaped_trailing_whitespace(span.slice(source)))?;
3041            }
3042            _ => fmt_literal_text(f, text, span, source)?,
3043        },
3044        WordPart::ZshQualifiedGlob(glob) => {
3045            if let Some(source) = source
3046                && zsh_qualified_glob_is_source_backed(glob)
3047                && glob.span.end.offset <= source.len()
3048            {
3049                f.write_str(trim_unescaped_trailing_whitespace(glob.span.slice(source)))?;
3050            } else {
3051                for segment in &glob.segments {
3052                    fmt_zsh_glob_segment_with_source(f, segment, source)?;
3053                }
3054                if let Some(qualifiers) = &glob.qualifiers {
3055                    fmt_zsh_glob_qualifier_group_with_source(f, qualifiers, source)?;
3056                }
3057            }
3058        }
3059        WordPart::SingleQuoted { value, dollar } => match mode {
3060            RenderMode::Decoded => f.write_str(display_source_text(Some(value), source))?,
3061            RenderMode::Syntax => match source {
3062                Some(source)
3063                    if value.is_source_backed()
3064                        && part_is_source_backed(part)
3065                        && span.end.offset <= source.len() =>
3066                {
3067                    f.write_str(span.slice(source))?;
3068                }
3069                _ => {
3070                    if *dollar {
3071                        f.write_str("$")?;
3072                    }
3073                    f.write_str("'")?;
3074                    f.write_str(display_source_text(Some(value), source))?;
3075                    f.write_str("'")?;
3076                }
3077            },
3078        },
3079        WordPart::DoubleQuoted { parts, dollar } => match mode {
3080            RenderMode::Decoded => {
3081                for part in parts {
3082                    fmt_word_part_with_source_mode(f, &part.kind, part.span, source, mode)?;
3083                }
3084            }
3085            RenderMode::Syntax => match source {
3086                Some(source) if part_is_source_backed(part) && span.end.offset <= source.len() => {
3087                    f.write_str(span.slice(source))?;
3088                }
3089                _ => {
3090                    if *dollar {
3091                        f.write_str("$")?;
3092                    }
3093                    f.write_str("\"")?;
3094                    for part in parts {
3095                        match &part.kind {
3096                            // Re-escape literal text when reconstructing a quoted word from
3097                            // cooked AST parts so we do not emit raw quote delimiters.
3098                            WordPart::Literal(text) => {
3099                                fmt_double_quoted_literal_text(f, text, part.span, source)?;
3100                            }
3101                            _ => {
3102                                fmt_word_part_with_source_mode(
3103                                    f, &part.kind, part.span, source, mode,
3104                                )?;
3105                            }
3106                        }
3107                    }
3108                    f.write_str("\"")?;
3109                }
3110            },
3111        },
3112        WordPart::Variable(name) => write!(f, "${}", name)?,
3113        WordPart::CommandSubstitution { body, syntax } => match source {
3114            Some(source) if span.end.offset <= source.len() => f.write_str(span.slice(source))?,
3115            _ => match syntax {
3116                CommandSubstitutionSyntax::DollarParen => write!(f, "$({:?})", body)?,
3117                CommandSubstitutionSyntax::Backtick => write!(f, "`{:?}`", body)?,
3118            },
3119        },
3120        WordPart::ArithmeticExpansion {
3121            expression, syntax, ..
3122        } => match source {
3123            Some(source) if expression.is_source_backed() && span.end.offset <= source.len() => {
3124                f.write_str(span.slice(source))?
3125            }
3126            _ => match syntax {
3127                ArithmeticExpansionSyntax::DollarParenParen => {
3128                    write!(f, "$(({}))", display_source_text(Some(expression), source))?
3129                }
3130                ArithmeticExpansionSyntax::LegacyBracket => {
3131                    write!(f, "$[{}]", display_source_text(Some(expression), source))?
3132                }
3133            },
3134        },
3135        WordPart::Parameter(parameter) => {
3136            write!(
3137                f,
3138                "${{{}}}",
3139                display_source_text(Some(&parameter.raw_body), source)
3140            )?;
3141        }
3142        WordPart::ParameterExpansion {
3143            reference,
3144            operator,
3145            operand,
3146            colon_variant,
3147            ..
3148        } => match operator {
3149            ParameterOp::UseDefault => {
3150                let c = if *colon_variant { ":" } else { "" };
3151                write!(f, "${{")?;
3152                fmt_var_ref_with_source(f, reference, source)?;
3153                write!(
3154                    f,
3155                    "{}-{}}}",
3156                    c,
3157                    display_source_text(operand.as_ref(), source)
3158                )?
3159            }
3160            ParameterOp::AssignDefault => {
3161                let c = if *colon_variant { ":" } else { "" };
3162                write!(f, "${{")?;
3163                fmt_var_ref_with_source(f, reference, source)?;
3164                write!(
3165                    f,
3166                    "{}={}}}",
3167                    c,
3168                    display_source_text(operand.as_ref(), source)
3169                )?
3170            }
3171            ParameterOp::UseReplacement => {
3172                let c = if *colon_variant { ":" } else { "" };
3173                write!(f, "${{")?;
3174                fmt_var_ref_with_source(f, reference, source)?;
3175                write!(
3176                    f,
3177                    "{}+{}}}",
3178                    c,
3179                    display_source_text(operand.as_ref(), source)
3180                )?
3181            }
3182            ParameterOp::Error => {
3183                let c = if *colon_variant { ":" } else { "" };
3184                write!(f, "${{")?;
3185                fmt_var_ref_with_source(f, reference, source)?;
3186                write!(
3187                    f,
3188                    "{}?{}}}",
3189                    c,
3190                    display_source_text(operand.as_ref(), source)
3191                )?
3192            }
3193            ParameterOp::RemovePrefixShort { pattern } => {
3194                write!(f, "${{")?;
3195                fmt_var_ref_with_source(f, reference, source)?;
3196                f.write_str("#")?;
3197                pattern.fmt_with_source_mode(f, source, mode)?;
3198                f.write_str("}")?;
3199            }
3200            ParameterOp::RemovePrefixLong { pattern } => {
3201                write!(f, "${{")?;
3202                fmt_var_ref_with_source(f, reference, source)?;
3203                f.write_str("##")?;
3204                pattern.fmt_with_source_mode(f, source, mode)?;
3205                f.write_str("}")?;
3206            }
3207            ParameterOp::RemoveSuffixShort { pattern } => {
3208                write!(f, "${{")?;
3209                fmt_var_ref_with_source(f, reference, source)?;
3210                f.write_str("%")?;
3211                pattern.fmt_with_source_mode(f, source, mode)?;
3212                f.write_str("}")?;
3213            }
3214            ParameterOp::RemoveSuffixLong { pattern } => {
3215                write!(f, "${{")?;
3216                fmt_var_ref_with_source(f, reference, source)?;
3217                f.write_str("%%")?;
3218                pattern.fmt_with_source_mode(f, source, mode)?;
3219                f.write_str("}")?;
3220            }
3221            ParameterOp::ReplaceFirst {
3222                pattern,
3223                replacement,
3224                ..
3225            } => {
3226                write!(f, "${{")?;
3227                fmt_var_ref_with_source(f, reference, source)?;
3228                f.write_str("/")?;
3229                pattern.fmt_with_source_mode(f, source, mode)?;
3230                write!(f, "/{}}}", display_source_text(Some(replacement), source))?;
3231            }
3232            ParameterOp::ReplaceAll {
3233                pattern,
3234                replacement,
3235                ..
3236            } => {
3237                write!(f, "${{")?;
3238                fmt_var_ref_with_source(f, reference, source)?;
3239                f.write_str("//")?;
3240                pattern.fmt_with_source_mode(f, source, mode)?;
3241                write!(f, "/{}}}", display_source_text(Some(replacement), source))?;
3242            }
3243            ParameterOp::UpperFirst => {
3244                write!(f, "${{")?;
3245                fmt_var_ref_with_source(f, reference, source)?;
3246                f.write_str("^}")?;
3247            }
3248            ParameterOp::UpperAll => {
3249                write!(f, "${{")?;
3250                fmt_var_ref_with_source(f, reference, source)?;
3251                f.write_str("^^}")?;
3252            }
3253            ParameterOp::LowerFirst => {
3254                write!(f, "${{")?;
3255                fmt_var_ref_with_source(f, reference, source)?;
3256                f.write_str(",}")?;
3257            }
3258            ParameterOp::LowerAll => {
3259                write!(f, "${{")?;
3260                fmt_var_ref_with_source(f, reference, source)?;
3261                f.write_str(",,}")?;
3262            }
3263        },
3264        WordPart::Length(reference) => {
3265            write!(f, "${{#")?;
3266            fmt_var_ref_with_source(f, reference, source)?;
3267            f.write_str("}")?;
3268        }
3269        WordPart::ArrayAccess(reference) => {
3270            write!(f, "${{")?;
3271            fmt_var_ref_with_source(f, reference, source)?;
3272            f.write_str("}")?;
3273        }
3274        WordPart::ArrayLength(reference) => {
3275            write!(f, "${{#")?;
3276            fmt_var_ref_with_source(f, reference, source)?;
3277            f.write_str("}")?;
3278        }
3279        WordPart::ArrayIndices(reference) => {
3280            write!(f, "${{!")?;
3281            fmt_var_ref_with_source(f, reference, source)?;
3282            f.write_str("}")?;
3283        }
3284        WordPart::Substring {
3285            reference,
3286            offset,
3287            length,
3288            ..
3289        } => {
3290            if let Some(length) = length {
3291                write!(f, "${{")?;
3292                fmt_var_ref_with_source(f, reference, source)?;
3293                write!(
3294                    f,
3295                    ":{}:{}}}",
3296                    display_source_text(Some(offset), source),
3297                    display_source_text(Some(length), source)
3298                )?
3299            } else {
3300                write!(f, "${{")?;
3301                fmt_var_ref_with_source(f, reference, source)?;
3302                write!(f, ":{}}}", display_source_text(Some(offset), source))?
3303            }
3304        }
3305        WordPart::ArraySlice {
3306            reference,
3307            offset,
3308            length,
3309            ..
3310        } => {
3311            if let Some(length) = length {
3312                write!(f, "${{")?;
3313                fmt_var_ref_with_source(f, reference, source)?;
3314                write!(
3315                    f,
3316                    ":{}:{}}}",
3317                    display_source_text(Some(offset), source),
3318                    display_source_text(Some(length), source)
3319                )?
3320            } else {
3321                write!(f, "${{")?;
3322                fmt_var_ref_with_source(f, reference, source)?;
3323                write!(f, ":{}}}", display_source_text(Some(offset), source))?
3324            }
3325        }
3326        WordPart::IndirectExpansion {
3327            reference,
3328            operator,
3329            operand,
3330            colon_variant,
3331            ..
3332        } => {
3333            let mut reference_syntax = String::new();
3334            fmt_var_ref_with_source(&mut reference_syntax, reference, source)?;
3335            if let Some(op) = operator {
3336                let c = if *colon_variant { ":" } else { "" };
3337                let op_char = match op {
3338                    ParameterOp::UseDefault => "-",
3339                    ParameterOp::AssignDefault => "=",
3340                    ParameterOp::UseReplacement => "+",
3341                    ParameterOp::Error => "?",
3342                    _ => "",
3343                };
3344                write!(
3345                    f,
3346                    "${{!{}{}{}{}}}",
3347                    reference_syntax,
3348                    c,
3349                    op_char,
3350                    display_source_text(operand.as_ref(), source)
3351                )?
3352            } else {
3353                write!(f, "${{!{}}}", reference_syntax)?
3354            }
3355        }
3356        WordPart::PrefixMatch { prefix, kind } => write!(f, "${{!{}{}}}", prefix, kind.as_char())?,
3357        WordPart::ProcessSubstitution { body, is_input } => match source {
3358            Some(source) if span.end.offset <= source.len() => f.write_str(span.slice(source))?,
3359            _ => {
3360                let prefix = if *is_input { "<" } else { ">" };
3361                write!(f, "{}({:?})", prefix, body)?
3362            }
3363        },
3364        WordPart::Transformation {
3365            reference,
3366            operator,
3367        } => {
3368            write!(f, "${{")?;
3369            fmt_var_ref_with_source(f, reference, source)?;
3370            write!(f, "@{}}}", operator)?;
3371        }
3372    }
3373
3374    Ok(())
3375}
3376
3377fn fmt_heredoc_body_part_with_source(
3378    f: &mut impl fmt::Write,
3379    part: &HeredocBodyPart,
3380    span: Span,
3381    source: Option<&str>,
3382) -> fmt::Result {
3383    if let Some(source) = source
3384        && heredoc_body_part_is_source_backed(part)
3385        && span.end.offset <= source.len()
3386    {
3387        f.write_str(span.slice(source))?;
3388        return Ok(());
3389    }
3390
3391    match part {
3392        HeredocBodyPart::Literal(text) => fmt_literal_text(f, text, span, source)?,
3393        HeredocBodyPart::Variable(name) => write!(f, "${}", name)?,
3394        HeredocBodyPart::CommandSubstitution { body, syntax } => match syntax {
3395            CommandSubstitutionSyntax::DollarParen => write!(f, "$({:?})", body)?,
3396            CommandSubstitutionSyntax::Backtick => write!(f, "`{:?}`", body)?,
3397        },
3398        HeredocBodyPart::ArithmeticExpansion {
3399            expression, syntax, ..
3400        } => match syntax {
3401            ArithmeticExpansionSyntax::DollarParenParen => {
3402                write!(f, "$(({}))", display_source_text(Some(expression), source))?
3403            }
3404            ArithmeticExpansionSyntax::LegacyBracket => {
3405                write!(f, "$[{}]", display_source_text(Some(expression), source))?
3406            }
3407        },
3408        HeredocBodyPart::Parameter(parameter) => {
3409            write!(
3410                f,
3411                "${{{}}}",
3412                display_source_text(Some(&parameter.raw_body), source)
3413            )?;
3414        }
3415    }
3416
3417    Ok(())
3418}
3419
3420fn part_prefers_source_slice_in_syntax(part: &WordPart) -> bool {
3421    matches!(
3422        part,
3423        WordPart::Variable(_)
3424            | WordPart::ZshQualifiedGlob(_)
3425            | WordPart::CommandSubstitution { .. }
3426            | WordPart::ArithmeticExpansion { .. }
3427            | WordPart::Parameter(_)
3428            | WordPart::ParameterExpansion { .. }
3429            | WordPart::Length(_)
3430            | WordPart::ArrayAccess(_)
3431            | WordPart::ArrayLength(_)
3432            | WordPart::ArrayIndices(_)
3433            | WordPart::Substring { .. }
3434            | WordPart::ArraySlice { .. }
3435            | WordPart::IndirectExpansion { .. }
3436            | WordPart::PrefixMatch { .. }
3437            | WordPart::ProcessSubstitution { .. }
3438            | WordPart::Transformation { .. }
3439    )
3440}
3441
3442fn trim_unescaped_trailing_whitespace(text: &str) -> &str {
3443    let mut end = text.len();
3444    while end > 0 {
3445        let Some((whitespace_start, ch)) = text[..end].char_indices().next_back() else {
3446            break;
3447        };
3448        if !ch.is_whitespace() {
3449            break;
3450        }
3451
3452        let backslash_count = text.as_bytes()[..whitespace_start]
3453            .iter()
3454            .rev()
3455            .take_while(|byte| **byte == b'\\')
3456            .count();
3457        if backslash_count % 2 == 1 {
3458            break;
3459        }
3460
3461        end = whitespace_start;
3462    }
3463
3464    &text[..end]
3465}
3466
3467fn part_is_source_backed(part: &WordPart) -> bool {
3468    match part {
3469        WordPart::Literal(text) => text.is_source_backed(),
3470        WordPart::ZshQualifiedGlob(glob) => zsh_qualified_glob_is_source_backed(glob),
3471        WordPart::SingleQuoted { value, .. } => value.is_source_backed(),
3472        WordPart::DoubleQuoted { parts, .. } => {
3473            parts.iter().all(|part| part_is_source_backed(&part.kind))
3474        }
3475        WordPart::Parameter(parameter) => parameter.raw_body.is_source_backed(),
3476        WordPart::ArithmeticExpansion { expression, .. } => expression.is_source_backed(),
3477        WordPart::ParameterExpansion {
3478            reference,
3479            operand,
3480            operator,
3481            ..
3482        } => {
3483            reference.is_source_backed()
3484                && operator_is_source_backed(operator)
3485                && operand.as_ref().is_none_or(SourceText::is_source_backed)
3486        }
3487        WordPart::Length(reference)
3488        | WordPart::ArrayAccess(reference)
3489        | WordPart::ArrayLength(reference)
3490        | WordPart::ArrayIndices(reference)
3491        | WordPart::Transformation { reference, .. } => reference.is_source_backed(),
3492        WordPart::Substring {
3493            reference,
3494            offset: index,
3495            ..
3496        }
3497        | WordPart::ArraySlice {
3498            reference,
3499            offset: index,
3500            ..
3501        } => reference.is_source_backed() && index.is_source_backed(),
3502        WordPart::IndirectExpansion {
3503            reference,
3504            operand,
3505            operator,
3506            ..
3507        } => {
3508            reference.is_source_backed()
3509                && operator.is_none()
3510                && operand.as_ref().is_none_or(SourceText::is_source_backed)
3511        }
3512        WordPart::CommandSubstitution { .. }
3513        | WordPart::Variable(_)
3514        | WordPart::PrefixMatch { .. }
3515        | WordPart::ProcessSubstitution { .. } => true,
3516    }
3517}
3518
3519fn heredoc_body_part_is_source_backed(part: &HeredocBodyPart) -> bool {
3520    match part {
3521        HeredocBodyPart::Literal(text) => text.is_source_backed(),
3522        HeredocBodyPart::Variable(_) | HeredocBodyPart::CommandSubstitution { .. } => true,
3523        HeredocBodyPart::ArithmeticExpansion { expression, .. } => expression.is_source_backed(),
3524        HeredocBodyPart::Parameter(parameter) => parameter.raw_body.is_source_backed(),
3525    }
3526}
3527
3528fn pattern_part_is_source_backed(part: &PatternPart) -> bool {
3529    match part {
3530        PatternPart::Literal(text) => text.is_source_backed(),
3531        PatternPart::AnyString | PatternPart::AnyChar => true,
3532        PatternPart::CharClass(text) => text.is_source_backed(),
3533        PatternPart::Group { patterns, .. } => patterns.iter().all(Pattern::is_source_backed),
3534        PatternPart::Word(word) => word
3535            .parts
3536            .iter()
3537            .all(|part| part_is_source_backed(&part.kind)),
3538    }
3539}
3540
3541fn zsh_qualified_glob_is_source_backed(glob: &ZshQualifiedGlob) -> bool {
3542    glob.segments.iter().all(zsh_glob_segment_is_source_backed)
3543        && glob
3544            .qualifiers
3545            .as_ref()
3546            .is_none_or(zsh_glob_qualifier_group_is_source_backed)
3547}
3548
3549fn zsh_glob_segment_is_source_backed(segment: &ZshGlobSegment) -> bool {
3550    match segment {
3551        ZshGlobSegment::Pattern(pattern) => pattern.is_source_backed(),
3552        ZshGlobSegment::InlineControl(control) => zsh_inline_glob_control_is_source_backed(control),
3553    }
3554}
3555
3556fn zsh_inline_glob_control_is_source_backed(_control: &ZshInlineGlobControl) -> bool {
3557    true
3558}
3559
3560fn fmt_zsh_glob_segment_with_source(
3561    f: &mut impl fmt::Write,
3562    segment: &ZshGlobSegment,
3563    source: Option<&str>,
3564) -> fmt::Result {
3565    match segment {
3566        ZshGlobSegment::Pattern(pattern) => {
3567            pattern.fmt_with_source_mode(f, source, RenderMode::Syntax)
3568        }
3569        ZshGlobSegment::InlineControl(control) => {
3570            fmt_zsh_inline_glob_control_with_source(f, control, source)
3571        }
3572    }
3573}
3574
3575fn fmt_zsh_inline_glob_control_with_source(
3576    f: &mut impl fmt::Write,
3577    control: &ZshInlineGlobControl,
3578    _source: Option<&str>,
3579) -> fmt::Result {
3580    match control {
3581        ZshInlineGlobControl::CaseInsensitive { .. } => f.write_str("(#i)"),
3582        ZshInlineGlobControl::Backreferences { .. } => f.write_str("(#b)"),
3583        ZshInlineGlobControl::StartAnchor { .. } => f.write_str("(#s)"),
3584        ZshInlineGlobControl::EndAnchor { .. } => f.write_str("(#e)"),
3585    }
3586}
3587
3588fn zsh_glob_qualifier_group_is_source_backed(group: &ZshGlobQualifierGroup) -> bool {
3589    group
3590        .fragments
3591        .iter()
3592        .all(zsh_glob_qualifier_is_source_backed)
3593}
3594
3595fn zsh_glob_qualifier_is_source_backed(fragment: &ZshGlobQualifier) -> bool {
3596    match fragment {
3597        ZshGlobQualifier::Negation { .. } | ZshGlobQualifier::Flag { .. } => true,
3598        ZshGlobQualifier::LetterSequence { text, .. } => text.is_source_backed(),
3599        ZshGlobQualifier::NumericArgument { start, end, .. } => {
3600            start.is_source_backed() && end.as_ref().is_none_or(SourceText::is_source_backed)
3601        }
3602    }
3603}
3604
3605fn fmt_zsh_glob_qualifier_group_with_source(
3606    f: &mut impl fmt::Write,
3607    group: &ZshGlobQualifierGroup,
3608    source: Option<&str>,
3609) -> fmt::Result {
3610    match group.kind {
3611        ZshGlobQualifierKind::Classic => f.write_str("(")?,
3612        ZshGlobQualifierKind::HashQ => f.write_str("(#q")?,
3613    }
3614    for fragment in &group.fragments {
3615        match fragment {
3616            ZshGlobQualifier::Negation { .. } => f.write_str("^")?,
3617            ZshGlobQualifier::Flag { name, .. } => write!(f, "{name}")?,
3618            ZshGlobQualifier::LetterSequence { text, .. } => {
3619                f.write_str(display_source_text(Some(text), source))?;
3620            }
3621            ZshGlobQualifier::NumericArgument { start, end, .. } => {
3622                f.write_str("[")?;
3623                f.write_str(display_source_text(Some(start), source))?;
3624                if let Some(end) = end {
3625                    f.write_str(",")?;
3626                    f.write_str(display_source_text(Some(end), source))?;
3627                }
3628                f.write_str("]")?;
3629            }
3630        }
3631    }
3632    f.write_str(")")
3633}
3634
3635fn operator_is_source_backed(operator: &ParameterOp) -> bool {
3636    match operator {
3637        ParameterOp::RemovePrefixShort { pattern }
3638        | ParameterOp::RemovePrefixLong { pattern }
3639        | ParameterOp::RemoveSuffixShort { pattern }
3640        | ParameterOp::RemoveSuffixLong { pattern } => pattern.is_source_backed(),
3641        ParameterOp::ReplaceFirst {
3642            pattern,
3643            replacement,
3644            ..
3645        }
3646        | ParameterOp::ReplaceAll {
3647            pattern,
3648            replacement,
3649            ..
3650        } => pattern.is_source_backed() && replacement.is_source_backed(),
3651        _ => true,
3652    }
3653}
3654
3655/// Parameter expansion operators
3656#[derive(Debug, Clone)]
3657pub enum ParameterOp {
3658    /// :- use default if unset/empty
3659    UseDefault,
3660    /// := assign default if unset/empty
3661    AssignDefault,
3662    /// :+ use replacement if set
3663    UseReplacement,
3664    /// :? error if unset/empty
3665    Error,
3666    /// # remove prefix (shortest)
3667    RemovePrefixShort { pattern: Pattern },
3668    /// ## remove prefix (longest)
3669    RemovePrefixLong { pattern: Pattern },
3670    /// % remove suffix (shortest)
3671    RemoveSuffixShort { pattern: Pattern },
3672    /// %% remove suffix (longest)
3673    RemoveSuffixLong { pattern: Pattern },
3674    /// / pattern replacement (first occurrence)
3675    ReplaceFirst {
3676        pattern: Pattern,
3677        replacement: SourceText,
3678        replacement_word_ast: Word,
3679    },
3680    /// // pattern replacement (all occurrences)
3681    ReplaceAll {
3682        pattern: Pattern,
3683        replacement: SourceText,
3684        replacement_word_ast: Word,
3685    },
3686    /// ^ uppercase first char
3687    UpperFirst,
3688    /// ^^ uppercase all chars
3689    UpperAll,
3690    /// , lowercase first char
3691    LowerFirst,
3692    /// ,, lowercase all chars
3693    LowerAll,
3694}
3695
3696impl ParameterOp {
3697    pub fn replacement_word_ast(&self) -> Option<&Word> {
3698        match self {
3699            Self::ReplaceFirst {
3700                replacement_word_ast,
3701                ..
3702            }
3703            | Self::ReplaceAll {
3704                replacement_word_ast,
3705                ..
3706            } => Some(replacement_word_ast),
3707            Self::UseDefault
3708            | Self::AssignDefault
3709            | Self::UseReplacement
3710            | Self::Error
3711            | Self::RemovePrefixShort { .. }
3712            | Self::RemovePrefixLong { .. }
3713            | Self::RemoveSuffixShort { .. }
3714            | Self::RemoveSuffixLong { .. }
3715            | Self::UpperFirst
3716            | Self::UpperAll
3717            | Self::LowerFirst
3718            | Self::LowerAll => None,
3719        }
3720    }
3721}
3722
3723/// I/O redirection.
3724#[derive(Debug, Clone)]
3725pub struct Redirect {
3726    /// File descriptor (default: 1 for output, 0 for input)
3727    pub fd: Option<i32>,
3728    /// Variable name for `{var}` fd-variable redirects (e.g. `exec {myfd}>&-`)
3729    pub fd_var: Option<Name>,
3730    /// Source span of `{name}` in fd-variable redirects.
3731    pub fd_var_span: Option<Span>,
3732    /// Type of redirection
3733    pub kind: RedirectKind,
3734    /// Source span of this redirection
3735    pub span: Span,
3736    /// Redirect payload.
3737    pub target: RedirectTarget,
3738}
3739
3740impl Redirect {
3741    /// Returns the word target for non-heredoc redirects.
3742    pub fn word_target(&self) -> Option<&Word> {
3743        match &self.target {
3744            RedirectTarget::Word(word) => Some(word),
3745            RedirectTarget::Heredoc(_) => None,
3746        }
3747    }
3748
3749    /// Returns the mutable word target for non-heredoc redirects.
3750    pub fn word_target_mut(&mut self) -> Option<&mut Word> {
3751        match &mut self.target {
3752            RedirectTarget::Word(word) => Some(word),
3753            RedirectTarget::Heredoc(_) => None,
3754        }
3755    }
3756
3757    /// Returns heredoc metadata and body when this redirect is a heredoc.
3758    pub fn heredoc(&self) -> Option<&Heredoc> {
3759        match &self.target {
3760            RedirectTarget::Word(_) => None,
3761            RedirectTarget::Heredoc(heredoc) => Some(heredoc),
3762        }
3763    }
3764
3765    /// Returns mutable heredoc metadata and body when this redirect is a heredoc.
3766    pub fn heredoc_mut(&mut self) -> Option<&mut Heredoc> {
3767        match &mut self.target {
3768            RedirectTarget::Word(_) => None,
3769            RedirectTarget::Heredoc(heredoc) => Some(heredoc),
3770        }
3771    }
3772}
3773
3774/// Redirect payload.
3775#[derive(Debug, Clone)]
3776pub enum RedirectTarget {
3777    /// Standard redirect operand like a path or file descriptor.
3778    Word(Word),
3779    /// Heredoc delimiter metadata plus decoded body.
3780    Heredoc(Heredoc),
3781}
3782
3783/// Heredoc delimiter metadata and decoded body.
3784#[derive(Debug, Clone)]
3785pub struct Heredoc {
3786    pub delimiter: HeredocDelimiter,
3787    pub body: HeredocBody,
3788}
3789
3790/// Parsed heredoc delimiter metadata.
3791#[derive(Debug, Clone)]
3792pub struct HeredocDelimiter {
3793    /// Raw delimiter word with original quoting preserved.
3794    pub raw: Word,
3795    /// Cooked delimiter string after quote removal.
3796    pub cooked: String,
3797    /// Source span of the delimiter token.
3798    pub span: Span,
3799    /// Whether the delimiter used shell quoting.
3800    pub quoted: bool,
3801    /// Whether the body should be decoded for expansions.
3802    pub expands_body: bool,
3803    /// Whether `<<-` tab stripping applies.
3804    pub strip_tabs: bool,
3805}
3806
3807/// Types of redirections.
3808#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3809pub enum RedirectKind {
3810    /// > - redirect output
3811    Output,
3812    /// >| - force redirect output (clobber, bypasses noclobber)
3813    Clobber,
3814    /// >> - append output
3815    Append,
3816    /// < - redirect input
3817    Input,
3818    /// <> - redirect input and output
3819    ReadWrite,
3820    /// << - here document
3821    HereDoc,
3822    /// <<- - here document with leading tab stripping
3823    HereDocStrip,
3824    /// <<< - here string
3825    HereString,
3826    /// >& - duplicate output fd
3827    DupOutput,
3828    /// <& - duplicate input fd
3829    DupInput,
3830    /// &> - redirect both stdout and stderr
3831    OutputBoth,
3832}
3833
3834/// Variable assignment.
3835#[derive(Debug, Clone)]
3836pub struct Assignment {
3837    pub target: VarRef,
3838    pub value: AssignmentValue,
3839    /// Whether this is an append assignment (+=)
3840    pub append: bool,
3841    /// Source span of this assignment
3842    pub span: Span,
3843}
3844
3845/// Value in an assignment - scalar or array
3846#[derive(Debug, Clone)]
3847pub enum AssignmentValue {
3848    /// Scalar value: VAR=value
3849    Scalar(Word),
3850    /// Array value: VAR=(a b c)
3851    Compound(ArrayExpr),
3852}
3853
3854#[cfg(test)]
3855mod tests {
3856    use std::borrow::Cow;
3857
3858    use super::*;
3859
3860    fn word(parts: Vec<WordPart>) -> Word {
3861        let span = Span::new();
3862        Word {
3863            parts: parts
3864                .into_iter()
3865                .map(|part| WordPartNode::new(part, span))
3866                .collect(),
3867            span,
3868            brace_syntax: Vec::new(),
3869        }
3870    }
3871
3872    fn pattern(parts: Vec<PatternPart>) -> Pattern {
3873        let span = Span::new();
3874        Pattern {
3875            parts: parts
3876                .into_iter()
3877                .map(|part| PatternPartNode::new(part, span))
3878                .collect(),
3879            span,
3880        }
3881    }
3882
3883    fn plain_ref(name: &str) -> VarRef {
3884        let span = Span::new();
3885        VarRef {
3886            name: name.into(),
3887            name_span: span,
3888            subscript: None,
3889            span,
3890        }
3891    }
3892
3893    fn indexed_ref(name: &str, index: &str) -> VarRef {
3894        let span = Span::new();
3895        VarRef {
3896            name: name.into(),
3897            name_span: span,
3898            subscript: Some(Subscript {
3899                text: index.into(),
3900                raw: None,
3901                kind: SubscriptKind::Ordinary,
3902                interpretation: SubscriptInterpretation::Contextual,
3903                word_ast: None,
3904                arithmetic_ast: None,
3905            }),
3906            span,
3907        }
3908    }
3909
3910    fn selector_ref(name: &str, selector: SubscriptSelector) -> VarRef {
3911        let span = Span::new();
3912        VarRef {
3913            name: name.into(),
3914            name_span: span,
3915            subscript: Some(Subscript {
3916                text: selector.as_char().to_string().into(),
3917                raw: None,
3918                kind: SubscriptKind::Selector(selector),
3919                interpretation: SubscriptInterpretation::Contextual,
3920                word_ast: None,
3921                arithmetic_ast: None,
3922            }),
3923            span,
3924        }
3925    }
3926
3927    fn assignment(target: VarRef, value: AssignmentValue) -> Assignment {
3928        Assignment {
3929            target,
3930            value,
3931            append: false,
3932            span: Span::new(),
3933        }
3934    }
3935
3936    fn stmt(command: Command) -> Stmt {
3937        Stmt {
3938            leading_comments: vec![],
3939            command,
3940            negated: false,
3941            redirects: Box::default(),
3942            terminator: None,
3943            terminator_span: None,
3944            inline_comment: None,
3945            span: Span::new(),
3946        }
3947    }
3948
3949    fn stmt_with_redirects(command: Command, redirects: Vec<Redirect>) -> Stmt {
3950        Stmt {
3951            redirects: redirects.into_boxed_slice(),
3952            ..stmt(command)
3953        }
3954    }
3955
3956    fn stmt_seq(stmts: Vec<Stmt>) -> StmtSeq {
3957        StmtSeq {
3958            leading_comments: vec![],
3959            stmts,
3960            trailing_comments: vec![],
3961            span: Span::new(),
3962        }
3963    }
3964
3965    fn simple_command(name: &str, args: Vec<Word>) -> SimpleCommand {
3966        SimpleCommand {
3967            name: Word::literal(name),
3968            args,
3969            assignments: Box::default(),
3970            span: Span::new(),
3971        }
3972    }
3973
3974    fn simple_stmt(name: &str, args: Vec<Word>) -> Stmt {
3975        stmt(Command::Simple(simple_command(name, args)))
3976    }
3977
3978    #[test]
3979    fn word_try_static_text_borrows_simple_static_words() {
3980        assert!(matches!(
3981            Word::literal("plain").try_static_text(""),
3982            Some(Cow::Borrowed("plain"))
3983        ));
3984
3985        let single_quoted = word(vec![WordPart::SingleQuoted {
3986            value: "single".into(),
3987            dollar: false,
3988        }]);
3989        assert!(matches!(
3990            single_quoted.try_static_text(""),
3991            Some(Cow::Borrowed("single"))
3992        ));
3993    }
3994
3995    #[test]
3996    fn word_try_static_text_concatenates_nested_static_parts() {
3997        let span = Span::new();
3998        let word = Word {
3999            parts: vec![
4000                WordPartNode::new(WordPart::Literal(LiteralText::owned("foo")), span),
4001                WordPartNode::new(
4002                    WordPart::DoubleQuoted {
4003                        parts: vec![WordPartNode::new(
4004                            WordPart::Literal(LiteralText::owned("bar")),
4005                            span,
4006                        )],
4007                        dollar: false,
4008                    },
4009                    span,
4010                ),
4011            ],
4012            span,
4013            brace_syntax: Vec::new(),
4014        };
4015
4016        assert!(matches!(
4017            word.try_static_text(""),
4018            Some(Cow::Owned(ref value)) if value == "foobar"
4019        ));
4020    }
4021
4022    #[test]
4023    fn word_try_static_text_rejects_runtime_expansions() {
4024        let variable = word(vec![WordPart::Variable("name".into())]);
4025        assert!(variable.try_static_text("").is_none());
4026    }
4027
4028    #[test]
4029    fn command_name_text_decodes_unquoted_backslashes() {
4030        assert_eq!(
4031            decode_static_command_literal("\\foo\\ bar\\\nqux", StaticCommandNameContext::Unquoted)
4032                .as_ref(),
4033            "foo barqux"
4034        );
4035    }
4036
4037    #[test]
4038    fn command_name_text_decodes_double_quoted_backslashes_selectively() {
4039        assert_eq!(
4040            decode_static_command_literal("\\$foo\\q\\\\", StaticCommandNameContext::DoubleQuoted)
4041                .as_ref(),
4042            "$foo\\q\\"
4043        );
4044    }
4045
4046    #[test]
4047    fn command_name_text_concatenates_nested_static_parts() {
4048        let span = Span::new();
4049        let word = Word {
4050            parts: vec![
4051                WordPartNode::new(WordPart::Literal(LiteralText::owned("\\foo")), span),
4052                WordPartNode::new(
4053                    WordPart::DoubleQuoted {
4054                        parts: vec![WordPartNode::new(
4055                            WordPart::Literal(LiteralText::owned("\\$bar")),
4056                            span,
4057                        )],
4058                        dollar: false,
4059                    },
4060                    span,
4061                ),
4062            ],
4063            span,
4064            brace_syntax: Vec::new(),
4065        };
4066
4067        assert_eq!(
4068            static_command_name_text(&word, "").as_deref(),
4069            Some("foo$bar")
4070        );
4071    }
4072
4073    #[test]
4074    fn shell_variable_name_helper_matches_identifier_rules() {
4075        assert!(is_shell_variable_name("name"));
4076        assert!(is_shell_variable_name("_name123"));
4077        assert!(!is_shell_variable_name("1name"));
4078        assert!(!is_shell_variable_name("name-value"));
4079    }
4080
4081    #[test]
4082    fn word_is_standalone_variable_like_matches_single_expansion_words() {
4083        assert!(word_is_standalone_variable_like(&word(vec![
4084            WordPart::Variable("name".into())
4085        ])));
4086        assert!(word_is_standalone_variable_like(&word(vec![
4087            WordPart::Length(plain_ref("name"))
4088        ])));
4089        assert!(!word_is_standalone_variable_like(&word(vec![
4090            WordPart::Literal(LiteralText::owned("prefix")),
4091            WordPart::Variable("name".into()),
4092        ])));
4093        assert!(!word_is_standalone_variable_like(&word(vec![
4094            WordPart::DoubleQuoted {
4095                parts: vec![WordPartNode::new(
4096                    WordPart::Variable("name".into()),
4097                    Span::new(),
4098                )],
4099                dollar: false,
4100            }
4101        ])));
4102    }
4103
4104    #[test]
4105    fn word_is_standalone_status_capture_handles_plain_quoted_and_parameter_forms() {
4106        assert!(word_is_standalone_status_capture(&word(vec![
4107            WordPart::Variable("?".into())
4108        ])));
4109        assert!(word_is_standalone_status_capture(&word(vec![
4110            WordPart::DoubleQuoted {
4111                parts: vec![WordPartNode::new(
4112                    WordPart::Variable("?".into()),
4113                    Span::new(),
4114                )],
4115                dollar: false,
4116            }
4117        ])));
4118        assert!(word_is_standalone_status_capture(&word(vec![
4119            WordPart::Parameter(ParameterExpansion {
4120                syntax: ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access {
4121                    reference: plain_ref("?"),
4122                }),
4123                span: Span::new(),
4124                raw_body: "?".into(),
4125            })
4126        ])));
4127        assert!(!word_is_standalone_status_capture(&word(vec![
4128            WordPart::Variable("name".into())
4129        ])));
4130        assert!(!word_is_standalone_status_capture(&word(vec![
4131            WordPart::Literal(LiteralText::owned("status=")),
4132            WordPart::Variable("?".into()),
4133        ])));
4134    }
4135
4136    fn span_for_source(source: &str) -> Span {
4137        Span::from_positions(
4138            Position {
4139                line: 1,
4140                column: 1,
4141                offset: 0,
4142            },
4143            Position {
4144                line: 1,
4145                column: source.chars().count() + 1,
4146                offset: source.len(),
4147            },
4148        )
4149    }
4150
4151    // --- Word ---
4152
4153    #[test]
4154    fn word_literal_creates_unquoted_word() {
4155        let w = Word::literal("hello");
4156        assert_eq!(w.parts.len(), 1);
4157        assert!(matches!(w.part(0), Some(WordPart::Literal(s)) if s == "hello"));
4158    }
4159
4160    #[test]
4161    fn word_literal_empty_string() {
4162        let w = Word::literal("");
4163        assert!(matches!(w.part(0), Some(WordPart::Literal(s)) if s.is_empty()));
4164    }
4165
4166    #[test]
4167    fn literal_text_owned_compares_equal_to_str() {
4168        let text = LiteralText::owned("hello");
4169
4170        assert!(text == "hello");
4171        assert!(text != "world");
4172    }
4173
4174    #[test]
4175    fn literal_text_source_does_not_compare_equal_to_str_without_source() {
4176        let text = LiteralText::source();
4177
4178        assert!(!text.is_empty());
4179        assert!(text != "hello");
4180    }
4181
4182    #[test]
4183    fn literal_text_eq_str_uses_source_for_source_backed_literals() {
4184        let source = "hello";
4185        let span = span_for_source(source);
4186        let text = LiteralText::source();
4187
4188        assert!(text.eq_str(source, span, "hello"));
4189        assert!(!text.eq_str(source, span, "world"));
4190    }
4191
4192    #[test]
4193    fn word_quoted_literal_creates_single_quoted_part() {
4194        let w = Word::quoted_literal("world");
4195        assert_eq!(w.parts.len(), 1);
4196        assert!(matches!(
4197            w.part(0),
4198            Some(WordPart::SingleQuoted { dollar: false, .. })
4199        ));
4200        assert_eq!(format!("{w}"), "world");
4201    }
4202
4203    #[test]
4204    fn word_display_literal() {
4205        let w = Word::literal("echo");
4206        assert_eq!(format!("{w}"), "echo");
4207    }
4208
4209    #[test]
4210    fn word_render_syntax_preserves_cooked_double_quoted_literal() {
4211        let w = word(vec![WordPart::DoubleQuoted {
4212            parts: vec![WordPartNode::new(
4213                WordPart::Literal(LiteralText::owned("hello".to_string())),
4214                Span::new(),
4215            )],
4216            dollar: false,
4217        }]);
4218        assert_eq!(w.render_syntax(""), "\"hello\"");
4219    }
4220
4221    #[test]
4222    fn word_render_syntax_reescapes_cooked_double_quoted_literal_text() {
4223        let w = word(vec![WordPart::DoubleQuoted {
4224            parts: vec![WordPartNode::new(
4225                WordPart::Literal(LiteralText::owned(
4226                    "quoted \"value\" uses $HOME and `pwd` with \\".to_string(),
4227                )),
4228                Span::new(),
4229            )],
4230            dollar: false,
4231        }]);
4232
4233        assert_eq!(
4234            w.render_syntax(""),
4235            "\"quoted \\\"value\\\" uses \\$HOME and \\`pwd\\` with \\\\\""
4236        );
4237    }
4238
4239    #[test]
4240    fn word_render_syntax_preserves_nested_parameter_expansion_inside_double_quotes() {
4241        let w = word(vec![WordPart::DoubleQuoted {
4242            parts: vec![
4243                WordPartNode::new(
4244                    WordPart::Literal(LiteralText::owned("N/A: version \"".to_string())),
4245                    Span::new(),
4246                ),
4247                WordPartNode::new(
4248                    WordPart::ParameterExpansion {
4249                        reference: plain_ref("PREFIXED_VERSION"),
4250                        operator: ParameterOp::UseDefault,
4251                        operand: Some("$PROVIDED_VERSION".into()),
4252                        operand_word_ast: Some(word(vec![WordPart::Variable(
4253                            "PROVIDED_VERSION".into(),
4254                        )])),
4255                        colon_variant: true,
4256                    },
4257                    Span::new(),
4258                ),
4259                WordPartNode::new(
4260                    WordPart::Literal(LiteralText::owned("\" is not yet installed.".to_string())),
4261                    Span::new(),
4262                ),
4263            ],
4264            dollar: false,
4265        }]);
4266
4267        assert_eq!(
4268            w.render_syntax(""),
4269            "\"N/A: version \\\"${PREFIXED_VERSION:-$PROVIDED_VERSION}\\\" is not yet installed.\""
4270        );
4271    }
4272
4273    #[test]
4274    fn word_render_syntax_preserves_source_backed_braced_variable() {
4275        let span = Span::from_positions(
4276            Position {
4277                line: 1,
4278                column: 1,
4279                offset: 0,
4280            },
4281            Position {
4282                line: 1,
4283                column: 5,
4284                offset: 4,
4285            },
4286        );
4287        let w = Word {
4288            parts: vec![WordPartNode::new(WordPart::Variable("1".into()), span)],
4289            span,
4290            brace_syntax: Vec::new(),
4291        };
4292
4293        assert_eq!(w.render_syntax("${1}"), "${1}");
4294    }
4295
4296    #[test]
4297    fn word_render_syntax_trims_source_backed_literal_delimiters() {
4298        let span = Span::from_positions(
4299            Position {
4300                line: 1,
4301                column: 1,
4302                offset: 0,
4303            },
4304            Position {
4305                line: 1,
4306                column: 5,
4307                offset: 4,
4308            },
4309        );
4310        let w = Word {
4311            parts: vec![WordPartNode::new(
4312                WordPart::Literal(LiteralText::source()),
4313                span,
4314            )],
4315            span,
4316            brace_syntax: Vec::new(),
4317        };
4318
4319        assert_eq!(w.render_syntax("foo "), "foo");
4320    }
4321
4322    #[test]
4323    fn word_render_syntax_prefers_whole_word_source_slice() {
4324        let source = "\"source \\\"$fzf_base/shell/completion.${shell}\\\"\"";
4325        let span = span_for_source(source);
4326        let w = Word {
4327            parts: vec![WordPartNode::new(
4328                WordPart::DoubleQuoted {
4329                    parts: vec![WordPartNode::new(
4330                        WordPart::Literal(LiteralText::owned(
4331                            "source \"$fzf_base/shell/completion.${shell}\"".to_string(),
4332                        )),
4333                        span,
4334                    )],
4335                    dollar: false,
4336                },
4337                span,
4338            )],
4339            span,
4340            brace_syntax: Vec::new(),
4341        };
4342
4343        assert_eq!(w.render_syntax(source), source);
4344    }
4345
4346    #[test]
4347    fn word_render_to_buf_appends_to_existing_contents() {
4348        let word = word(vec![
4349            WordPart::Literal("hello ".into()),
4350            WordPart::Variable("USER".into()),
4351        ]);
4352        let mut rendered = String::from("prefix:");
4353
4354        word.render_to_buf("hello $USER", &mut rendered);
4355
4356        assert_eq!(rendered, "prefix:hello $USER");
4357        assert_eq!(rendered["prefix:".len()..], word.render("hello $USER"));
4358    }
4359
4360    #[test]
4361    fn word_render_syntax_to_buf_matches_render_syntax() {
4362        let source = "\"hello\"";
4363        let span = span_for_source(source);
4364        let word = Word {
4365            parts: vec![WordPartNode::new(
4366                WordPart::DoubleQuoted {
4367                    parts: vec![WordPartNode::new(
4368                        WordPart::Literal(LiteralText::owned("hello".to_string())),
4369                        span,
4370                    )],
4371                    dollar: false,
4372                },
4373                span,
4374            )],
4375            span,
4376            brace_syntax: Vec::new(),
4377        };
4378        let mut rendered = String::from("prefix:");
4379
4380        word.render_syntax_to_buf(source, &mut rendered);
4381
4382        assert_eq!(rendered, format!("prefix:{}", word.render_syntax(source)));
4383    }
4384
4385    #[test]
4386    fn word_display_variable() {
4387        let w = word(vec![WordPart::Variable("HOME".into())]);
4388        assert_eq!(format!("{w}"), "$HOME");
4389    }
4390
4391    #[test]
4392    fn word_display_arithmetic_expansion() {
4393        let w = word(vec![WordPart::ArithmeticExpansion {
4394            expression: "1+2".into(),
4395            expression_ast: None,
4396            expression_word_ast: Word::literal("1+2"),
4397            syntax: ArithmeticExpansionSyntax::DollarParenParen,
4398        }]);
4399        assert_eq!(format!("{w}"), "$((1+2))");
4400    }
4401
4402    #[test]
4403    fn word_display_length() {
4404        let w = word(vec![WordPart::Length(plain_ref("var"))]);
4405        assert_eq!(format!("{w}"), "${#var}");
4406    }
4407
4408    #[test]
4409    fn word_display_array_access() {
4410        let w = word(vec![WordPart::ArrayAccess(indexed_ref("arr", "0"))]);
4411        assert_eq!(format!("{w}"), "${arr[0]}");
4412    }
4413
4414    #[test]
4415    fn word_display_array_length() {
4416        let w = word(vec![WordPart::ArrayLength(selector_ref(
4417            "arr",
4418            SubscriptSelector::At,
4419        ))]);
4420        assert_eq!(format!("{w}"), "${#arr[@]}");
4421    }
4422
4423    #[test]
4424    fn word_display_array_indices() {
4425        let w = word(vec![WordPart::ArrayIndices(selector_ref(
4426            "arr",
4427            SubscriptSelector::At,
4428        ))]);
4429        assert_eq!(format!("{w}"), "${!arr[@]}");
4430    }
4431
4432    #[test]
4433    fn word_display_substring_with_length() {
4434        let w = word(vec![WordPart::Substring {
4435            reference: plain_ref("var"),
4436            offset: "2".into(),
4437            offset_ast: None,
4438            offset_word_ast: Word::literal("2"),
4439            length: Some("3".into()),
4440            length_ast: None,
4441            length_word_ast: Some(Word::literal("3")),
4442        }]);
4443        assert_eq!(format!("{w}"), "${var:2:3}");
4444    }
4445
4446    #[test]
4447    fn word_display_substring_without_length() {
4448        let w = word(vec![WordPart::Substring {
4449            reference: plain_ref("var"),
4450            offset: "2".into(),
4451            offset_ast: None,
4452            offset_word_ast: Word::literal("2"),
4453            length: None,
4454            length_ast: None,
4455            length_word_ast: None,
4456        }]);
4457        assert_eq!(format!("{w}"), "${var:2}");
4458    }
4459
4460    #[test]
4461    fn word_display_array_slice_with_length() {
4462        let w = word(vec![WordPart::ArraySlice {
4463            reference: selector_ref("arr", SubscriptSelector::At),
4464            offset: "1".into(),
4465            offset_ast: None,
4466            offset_word_ast: Word::literal("1"),
4467            length: Some("2".into()),
4468            length_ast: None,
4469            length_word_ast: Some(Word::literal("2")),
4470        }]);
4471        assert_eq!(format!("{w}"), "${arr[@]:1:2}");
4472    }
4473
4474    #[test]
4475    fn word_display_array_slice_without_length() {
4476        let w = word(vec![WordPart::ArraySlice {
4477            reference: selector_ref("arr", SubscriptSelector::At),
4478            offset: "1".into(),
4479            offset_ast: None,
4480            offset_word_ast: Word::literal("1"),
4481            length: None,
4482            length_ast: None,
4483            length_word_ast: None,
4484        }]);
4485        assert_eq!(format!("{w}"), "${arr[@]:1}");
4486    }
4487
4488    #[test]
4489    fn word_display_indirect_expansion() {
4490        let w = word(vec![WordPart::IndirectExpansion {
4491            reference: plain_ref("ref"),
4492            operator: None,
4493            operand: None,
4494            operand_word_ast: None,
4495            colon_variant: false,
4496        }]);
4497        assert_eq!(format!("{w}"), "${!ref}");
4498    }
4499
4500    #[test]
4501    fn word_display_prefix_match() {
4502        let w = word(vec![WordPart::PrefixMatch {
4503            prefix: "MY_".into(),
4504            kind: PrefixMatchKind::Star,
4505        }]);
4506        assert_eq!(format!("{w}"), "${!MY_*}");
4507    }
4508
4509    #[test]
4510    fn word_display_prefix_match_at() {
4511        let w = word(vec![WordPart::PrefixMatch {
4512            prefix: "MY_".into(),
4513            kind: PrefixMatchKind::At,
4514        }]);
4515        assert_eq!(format!("{w}"), "${!MY_@}");
4516    }
4517
4518    #[test]
4519    fn word_render_syntax_preserves_raw_quoted_subscript() {
4520        let w = word(vec![WordPart::ArrayAccess(VarRef {
4521            name: "assoc".into(),
4522            name_span: Span::new(),
4523            subscript: Some(Subscript {
4524                text: "key".into(),
4525                raw: Some("\"key\"".into()),
4526                kind: SubscriptKind::Ordinary,
4527                interpretation: SubscriptInterpretation::Associative,
4528                word_ast: None,
4529                arithmetic_ast: None,
4530            }),
4531            span: Span::new(),
4532        })]);
4533        assert_eq!(format!("{w}"), "${assoc[\"key\"]}");
4534        assert_eq!(w.render_syntax(""), "${assoc[\"key\"]}");
4535    }
4536
4537    #[test]
4538    fn word_display_transformation() {
4539        let w = word(vec![WordPart::Transformation {
4540            reference: plain_ref("var"),
4541            operator: 'Q',
4542        }]);
4543        assert_eq!(format!("{w}"), "${var@Q}");
4544    }
4545
4546    #[test]
4547    fn word_display_multiple_parts() {
4548        let w = word(vec![
4549            WordPart::Literal("hello ".into()),
4550            WordPart::Variable("USER".into()),
4551        ]);
4552        assert_eq!(format!("{w}"), "hello $USER");
4553    }
4554
4555    #[test]
4556    fn pattern_display_multiple_parts() {
4557        let p = pattern(vec![
4558            PatternPart::Literal("file".into()),
4559            PatternPart::AnyString,
4560            PatternPart::CharClass("[[:digit:]]".into()),
4561        ]);
4562        assert_eq!(format!("{p}"), "file*[[:digit:]]");
4563    }
4564
4565    #[test]
4566    fn pattern_render_syntax_prefers_whole_pattern_source_slice() {
4567        let source = "Darwin\\ arm64*";
4568        let span = span_for_source(source);
4569        let p = Pattern {
4570            parts: vec![PatternPartNode::new(
4571                PatternPart::Literal(LiteralText::owned("Darwin arm64*".to_string())),
4572                span,
4573            )],
4574            span,
4575        };
4576
4577        assert_eq!(p.render_syntax(source), source);
4578    }
4579
4580    #[test]
4581    fn pattern_render_to_buf_appends_to_existing_contents() {
4582        let pattern = pattern(vec![
4583            PatternPart::Literal("file".into()),
4584            PatternPart::AnyString,
4585            PatternPart::CharClass("[[:digit:]]".into()),
4586        ]);
4587        let source = "file*[[:digit:]]";
4588        let mut rendered = String::from("prefix:");
4589
4590        pattern.render_to_buf(source, &mut rendered);
4591
4592        assert_eq!(rendered, format!("prefix:{}", pattern.render(source)));
4593    }
4594
4595    #[test]
4596    fn pattern_render_syntax_to_buf_matches_render_syntax() {
4597        let source = "Darwin\\ arm64*";
4598        let span = span_for_source(source);
4599        let pattern = Pattern {
4600            parts: vec![PatternPartNode::new(
4601                PatternPart::Literal(LiteralText::owned("Darwin arm64*".to_string())),
4602                span,
4603            )],
4604            span,
4605        };
4606        let mut rendered = String::from("prefix:");
4607
4608        pattern.render_syntax_to_buf(source, &mut rendered);
4609
4610        assert_eq!(
4611            rendered,
4612            format!("prefix:{}", pattern.render_syntax(source))
4613        );
4614    }
4615
4616    #[test]
4617    fn pattern_display_extglob_group() {
4618        let p = pattern(vec![PatternPart::Group {
4619            kind: PatternGroupKind::ExactlyOne,
4620            patterns: vec![
4621                pattern(vec![PatternPart::Literal("foo".into())]),
4622                pattern(vec![PatternPart::Literal("bar".into())]),
4623            ],
4624        }]);
4625        assert_eq!(format!("{p}"), "@(foo|bar)");
4626    }
4627
4628    #[test]
4629    fn word_display_parameter_expansion_use_default_colon() {
4630        let w = word(vec![WordPart::ParameterExpansion {
4631            reference: plain_ref("var"),
4632            operator: ParameterOp::UseDefault,
4633            operand: Some("fallback".into()),
4634            operand_word_ast: Some(Word::literal("fallback")),
4635            colon_variant: true,
4636        }]);
4637        assert_eq!(format!("{w}"), "${var:-fallback}");
4638    }
4639
4640    #[test]
4641    fn word_display_parameter_expansion_use_default_no_colon() {
4642        let w = word(vec![WordPart::ParameterExpansion {
4643            reference: plain_ref("var"),
4644            operator: ParameterOp::UseDefault,
4645            operand: Some("fallback".into()),
4646            operand_word_ast: Some(Word::literal("fallback")),
4647            colon_variant: false,
4648        }]);
4649        assert_eq!(format!("{w}"), "${var-fallback}");
4650    }
4651
4652    #[test]
4653    fn word_display_parameter_expansion_assign_default() {
4654        let w = word(vec![WordPart::ParameterExpansion {
4655            reference: plain_ref("var"),
4656            operator: ParameterOp::AssignDefault,
4657            operand: Some("val".into()),
4658            operand_word_ast: Some(Word::literal("val")),
4659            colon_variant: true,
4660        }]);
4661        assert_eq!(format!("{w}"), "${var:=val}");
4662    }
4663
4664    #[test]
4665    fn word_display_parameter_expansion_use_replacement() {
4666        let w = word(vec![WordPart::ParameterExpansion {
4667            reference: plain_ref("var"),
4668            operator: ParameterOp::UseReplacement,
4669            operand: Some("alt".into()),
4670            operand_word_ast: Some(Word::literal("alt")),
4671            colon_variant: true,
4672        }]);
4673        assert_eq!(format!("{w}"), "${var:+alt}");
4674    }
4675
4676    #[test]
4677    fn word_display_parameter_expansion_error() {
4678        let w = word(vec![WordPart::ParameterExpansion {
4679            reference: plain_ref("var"),
4680            operator: ParameterOp::Error,
4681            operand: Some("msg".into()),
4682            operand_word_ast: Some(Word::literal("msg")),
4683            colon_variant: true,
4684        }]);
4685        assert_eq!(format!("{w}"), "${var:?msg}");
4686    }
4687
4688    #[test]
4689    fn word_display_parameter_expansion_prefix_suffix() {
4690        // RemovePrefixShort
4691        let w = word(vec![WordPart::ParameterExpansion {
4692            reference: plain_ref("var"),
4693            operator: ParameterOp::RemovePrefixShort {
4694                pattern: pattern(vec![PatternPart::Literal("pat".into())]),
4695            },
4696            operand: None,
4697            operand_word_ast: None,
4698            colon_variant: false,
4699        }]);
4700        assert_eq!(format!("{w}"), "${var#pat}");
4701
4702        // RemovePrefixLong
4703        let w = word(vec![WordPart::ParameterExpansion {
4704            reference: plain_ref("var"),
4705            operator: ParameterOp::RemovePrefixLong {
4706                pattern: pattern(vec![PatternPart::Literal("pat".into())]),
4707            },
4708            operand: None,
4709            operand_word_ast: None,
4710            colon_variant: false,
4711        }]);
4712        assert_eq!(format!("{w}"), "${var##pat}");
4713
4714        // RemoveSuffixShort
4715        let w = word(vec![WordPart::ParameterExpansion {
4716            reference: plain_ref("var"),
4717            operator: ParameterOp::RemoveSuffixShort {
4718                pattern: pattern(vec![PatternPart::Literal("pat".into())]),
4719            },
4720            operand: None,
4721            operand_word_ast: None,
4722            colon_variant: false,
4723        }]);
4724        assert_eq!(format!("{w}"), "${var%pat}");
4725
4726        // RemoveSuffixLong
4727        let w = word(vec![WordPart::ParameterExpansion {
4728            reference: plain_ref("var"),
4729            operator: ParameterOp::RemoveSuffixLong {
4730                pattern: pattern(vec![PatternPart::Literal("pat".into())]),
4731            },
4732            operand: None,
4733            operand_word_ast: None,
4734            colon_variant: false,
4735        }]);
4736        assert_eq!(format!("{w}"), "${var%%pat}");
4737    }
4738
4739    #[test]
4740    fn word_display_parameter_expansion_replace() {
4741        let w = word(vec![WordPart::ParameterExpansion {
4742            reference: plain_ref("var"),
4743            operator: ParameterOp::ReplaceFirst {
4744                pattern: pattern(vec![PatternPart::Literal("old".into())]),
4745                replacement: "new".into(),
4746                replacement_word_ast: Word::literal("new"),
4747            },
4748            operand: None,
4749            operand_word_ast: None,
4750            colon_variant: false,
4751        }]);
4752        assert_eq!(format!("{w}"), "${var/old/new}");
4753
4754        let w = word(vec![WordPart::ParameterExpansion {
4755            reference: plain_ref("var"),
4756            operator: ParameterOp::ReplaceAll {
4757                pattern: pattern(vec![PatternPart::Literal("old".into())]),
4758                replacement: "new".into(),
4759                replacement_word_ast: Word::literal("new"),
4760            },
4761            operand: None,
4762            operand_word_ast: None,
4763            colon_variant: false,
4764        }]);
4765        assert_eq!(format!("{w}"), "${var//old/new}");
4766    }
4767
4768    #[test]
4769    fn word_display_parameter_expansion_case() {
4770        let check = |op: ParameterOp, expected: &str| {
4771            let w = word(vec![WordPart::ParameterExpansion {
4772                reference: plain_ref("var"),
4773                operator: op,
4774                operand: None,
4775                operand_word_ast: None,
4776                colon_variant: false,
4777            }]);
4778            assert_eq!(format!("{w}"), expected);
4779        };
4780        check(ParameterOp::UpperFirst, "${var^}");
4781        check(ParameterOp::UpperAll, "${var^^}");
4782        check(ParameterOp::LowerAll, "${var,,}");
4783    }
4784
4785    // --- SimpleCommand ---
4786
4787    #[test]
4788    fn simple_command_construction() {
4789        let cmd = simple_command("ls", vec![Word::literal("-la")]);
4790        assert_eq!(format!("{}", cmd.name), "ls");
4791        assert_eq!(cmd.args.len(), 1);
4792        assert_eq!(format!("{}", cmd.args[0]), "-la");
4793    }
4794
4795    #[test]
4796    fn statement_redirects_are_stored_on_stmt() {
4797        let cmd = stmt_with_redirects(
4798            Command::Simple(simple_command("echo", vec![Word::literal("hi")])),
4799            vec![Redirect {
4800                fd: Some(1),
4801                fd_var: None,
4802                fd_var_span: None,
4803                kind: RedirectKind::Output,
4804                span: Span::new(),
4805                target: RedirectTarget::Word(Word::literal("out.txt")),
4806            }],
4807        );
4808        assert_eq!(cmd.redirects.len(), 1);
4809        assert_eq!(cmd.redirects[0].fd, Some(1));
4810        assert_eq!(cmd.redirects[0].kind, RedirectKind::Output);
4811    }
4812
4813    #[test]
4814    fn simple_command_with_assignments() {
4815        let cmd = SimpleCommand {
4816            assignments: vec![assignment(
4817                plain_ref("FOO"),
4818                AssignmentValue::Scalar(Word::literal("bar")),
4819            )]
4820            .into_boxed_slice(),
4821            ..simple_command("env", vec![])
4822        };
4823        assert_eq!(cmd.assignments.len(), 1);
4824        assert_eq!(cmd.assignments[0].target.name, "FOO");
4825        assert!(!cmd.assignments[0].append);
4826    }
4827
4828    // --- BuiltinCommand ---
4829
4830    #[test]
4831    fn builtin_break_command_construction() {
4832        let cmd = BuiltinCommand::Break(BreakCommand {
4833            depth: Some(Word::literal("2")),
4834            extra_args: vec![Word::literal("extra")],
4835            assignments: Box::default(),
4836            span: Span::new(),
4837        });
4838
4839        if let BuiltinCommand::Break(command) = &cmd {
4840            assert_eq!(command.depth.as_ref().unwrap().to_string(), "2");
4841            assert_eq!(command.extra_args.len(), 1);
4842            assert_eq!(command.extra_args[0].to_string(), "extra");
4843        } else {
4844            panic!("expected Break builtin");
4845        }
4846    }
4847
4848    #[test]
4849    fn builtin_return_command_with_redirects_and_assignments() {
4850        let cmd = stmt_with_redirects(
4851            Command::Builtin(BuiltinCommand::Return(ReturnCommand {
4852                code: Some(Word::literal("42")),
4853                extra_args: vec![],
4854                assignments: vec![assignment(
4855                    plain_ref("FOO"),
4856                    AssignmentValue::Scalar(Word::literal("bar")),
4857                )]
4858                .into_boxed_slice(),
4859                span: Span::new(),
4860            })),
4861            vec![Redirect {
4862                fd: None,
4863                fd_var: None,
4864                fd_var_span: None,
4865                kind: RedirectKind::Output,
4866                span: Span::new(),
4867                target: RedirectTarget::Word(Word::literal("out.txt")),
4868            }],
4869        );
4870
4871        if let Command::Builtin(BuiltinCommand::Return(command)) = &cmd.command {
4872            assert_eq!(command.code.as_ref().unwrap().to_string(), "42");
4873            assert_eq!(command.assignments.len(), 1);
4874            assert_eq!(cmd.redirects.len(), 1);
4875        } else {
4876            panic!("expected Return builtin");
4877        }
4878    }
4879
4880    // --- BinaryCommand ---
4881
4882    #[test]
4883    fn binary_command_construction() {
4884        let pipe = BinaryCommand {
4885            left: Box::new(simple_stmt("ls", vec![])),
4886            op: BinaryOp::Pipe,
4887            op_span: Span::new(),
4888            right: Box::new(simple_stmt("grep", vec![Word::literal("foo")])),
4889            span: Span::new(),
4890        };
4891        assert_eq!(pipe.op, BinaryOp::Pipe);
4892        assert!(matches!(pipe.left.command, Command::Simple(_)));
4893        assert!(matches!(pipe.right.command, Command::Simple(_)));
4894    }
4895
4896    #[test]
4897    fn stmt_negated() {
4898        let mut command = simple_stmt("echo", vec![Word::literal("hi")]);
4899        command.negated = true;
4900        assert!(command.negated);
4901    }
4902
4903    // --- StmtSeq ---
4904
4905    #[test]
4906    fn stmt_seq_with_multiple_statements() {
4907        let list = stmt_seq(vec![
4908            simple_stmt("true", vec![]),
4909            simple_stmt("echo", vec![Word::literal("ok")]),
4910        ]);
4911        assert_eq!(list.len(), 2);
4912        assert!(matches!(list[0].command, Command::Simple(_)));
4913    }
4914
4915    // --- BinaryOp / StmtTerminator ---
4916
4917    #[test]
4918    fn statement_operators_equality() {
4919        assert_eq!(BinaryOp::And, BinaryOp::And);
4920        assert_eq!(BinaryOp::Or, BinaryOp::Or);
4921        assert_eq!(BinaryOp::Pipe, BinaryOp::Pipe);
4922        assert_eq!(BinaryOp::PipeAll, BinaryOp::PipeAll);
4923        assert_ne!(BinaryOp::And, BinaryOp::Or);
4924        assert_eq!(StmtTerminator::Semicolon, StmtTerminator::Semicolon);
4925        assert_eq!(
4926            StmtTerminator::Background(BackgroundOperator::Plain),
4927            StmtTerminator::Background(BackgroundOperator::Plain)
4928        );
4929    }
4930
4931    // --- RedirectKind ---
4932
4933    #[test]
4934    fn redirect_kind_equality() {
4935        assert_eq!(RedirectKind::Output, RedirectKind::Output);
4936        assert_eq!(RedirectKind::Append, RedirectKind::Append);
4937        assert_eq!(RedirectKind::Input, RedirectKind::Input);
4938        assert_eq!(RedirectKind::ReadWrite, RedirectKind::ReadWrite);
4939        assert_eq!(RedirectKind::HereDoc, RedirectKind::HereDoc);
4940        assert_eq!(RedirectKind::HereDocStrip, RedirectKind::HereDocStrip);
4941        assert_eq!(RedirectKind::HereString, RedirectKind::HereString);
4942        assert_eq!(RedirectKind::DupOutput, RedirectKind::DupOutput);
4943        assert_eq!(RedirectKind::DupInput, RedirectKind::DupInput);
4944        assert_eq!(RedirectKind::OutputBoth, RedirectKind::OutputBoth);
4945        assert_ne!(RedirectKind::Output, RedirectKind::Append);
4946    }
4947
4948    // --- Redirect ---
4949
4950    #[test]
4951    fn redirect_default_fd_none() {
4952        let r = Redirect {
4953            fd: None,
4954            fd_var: None,
4955            fd_var_span: None,
4956            kind: RedirectKind::Input,
4957            span: Span::new(),
4958            target: RedirectTarget::Word(Word::literal("input.txt")),
4959        };
4960        assert!(r.fd.is_none());
4961        assert_eq!(r.kind, RedirectKind::Input);
4962    }
4963
4964    #[test]
4965    fn redirect_exposes_word_target() {
4966        let redirect = Redirect {
4967            fd: None,
4968            fd_var: None,
4969            fd_var_span: None,
4970            kind: RedirectKind::Output,
4971            span: Span::new(),
4972            target: RedirectTarget::Word(Word::literal("out.txt")),
4973        };
4974
4975        assert_eq!(redirect.word_target().unwrap().to_string(), "out.txt");
4976        assert!(redirect.heredoc().is_none());
4977    }
4978
4979    #[test]
4980    fn redirect_exposes_heredoc_payload() {
4981        let delimiter = HeredocDelimiter {
4982            raw: Word::quoted_literal("EOF"),
4983            cooked: "EOF".to_owned(),
4984            span: Span::new(),
4985            quoted: true,
4986            expands_body: false,
4987            strip_tabs: false,
4988        };
4989        let redirect = Redirect {
4990            fd: None,
4991            fd_var: None,
4992            fd_var_span: None,
4993            kind: RedirectKind::HereDoc,
4994            span: Span::new(),
4995            target: RedirectTarget::Heredoc(Heredoc {
4996                delimiter,
4997                body: HeredocBody::literal_with_span("body", Span::new())
4998                    .with_mode(HeredocBodyMode::Literal),
4999            }),
5000        };
5001
5002        let heredoc = redirect.heredoc().expect("expected heredoc payload");
5003        assert_eq!(heredoc.delimiter.cooked, "EOF");
5004        assert!(heredoc.delimiter.quoted);
5005        assert!(redirect.word_target().is_none());
5006    }
5007
5008    // --- Assignment ---
5009
5010    #[test]
5011    fn assignment_scalar() {
5012        let a = assignment(plain_ref("X"), AssignmentValue::Scalar(Word::literal("1")));
5013        assert_eq!(a.target.name, "X");
5014        assert!(a.target.subscript.is_none());
5015        assert!(!a.append);
5016    }
5017
5018    #[test]
5019    fn assignment_array() {
5020        let a = assignment(
5021            plain_ref("ARR"),
5022            AssignmentValue::Compound(ArrayExpr {
5023                kind: ArrayKind::Indexed,
5024                elements: vec![
5025                    ArrayElem::Sequential(Word::literal("a").into()),
5026                    ArrayElem::Sequential(Word::literal("b").into()),
5027                    ArrayElem::Sequential(Word::literal("c").into()),
5028                ],
5029                span: Span::new(),
5030            }),
5031        );
5032        if let AssignmentValue::Compound(array) = &a.value {
5033            assert_eq!(array.elements.len(), 3);
5034        } else {
5035            panic!("expected Compound");
5036        }
5037    }
5038
5039    #[test]
5040    fn assignment_append() {
5041        let mut a = assignment(
5042            plain_ref("PATH"),
5043            AssignmentValue::Scalar(Word::literal("/usr/bin")),
5044        );
5045        a.append = true;
5046        assert!(a.append);
5047    }
5048
5049    #[test]
5050    fn assignment_indexed() {
5051        let a = assignment(
5052            indexed_ref("arr", "0"),
5053            AssignmentValue::Scalar(Word::literal("val")),
5054        );
5055        assert_eq!(
5056            a.target
5057                .subscript
5058                .as_ref()
5059                .map(|subscript| subscript.syntax_text("")),
5060            Some("0")
5061        );
5062    }
5063
5064    // --- CaseTerminator ---
5065
5066    #[test]
5067    fn case_terminator_equality() {
5068        assert_eq!(CaseTerminator::Break, CaseTerminator::Break);
5069        assert_eq!(CaseTerminator::FallThrough, CaseTerminator::FallThrough);
5070        assert_eq!(CaseTerminator::Continue, CaseTerminator::Continue);
5071        assert_eq!(
5072            CaseTerminator::ContinueMatching,
5073            CaseTerminator::ContinueMatching
5074        );
5075        assert_ne!(CaseTerminator::Break, CaseTerminator::FallThrough);
5076    }
5077
5078    // --- Compound commands ---
5079
5080    #[test]
5081    fn if_command_construction() {
5082        let if_cmd = IfCommand {
5083            condition: stmt_seq(vec![]),
5084            then_branch: stmt_seq(vec![]),
5085            elif_branches: vec![],
5086            else_branch: None,
5087            syntax: IfSyntax::ThenFi {
5088                then_span: Span::new(),
5089                fi_span: Span::new(),
5090            },
5091            span: Span::new(),
5092        };
5093        assert!(if_cmd.else_branch.is_none());
5094        assert!(if_cmd.elif_branches.is_empty());
5095    }
5096
5097    #[test]
5098    fn for_command_without_words() {
5099        let for_cmd = ForCommand {
5100            targets: vec![ForTarget {
5101                word: Word::literal("i"),
5102                name: Some("i".into()),
5103                span: Span::new(),
5104            }],
5105            words: None,
5106            body: stmt_seq(vec![]),
5107            syntax: ForSyntax::InDoDone {
5108                in_span: None,
5109                do_span: Span::new(),
5110                done_span: Span::new(),
5111            },
5112            span: Span::new(),
5113        };
5114        assert!(for_cmd.words.is_none());
5115        assert_eq!(for_cmd.targets[0].word.render(""), "i");
5116        assert_eq!(for_cmd.targets[0].name.as_deref(), Some("i"));
5117    }
5118
5119    #[test]
5120    fn for_command_with_words() {
5121        let for_cmd = ForCommand {
5122            targets: vec![ForTarget {
5123                word: Word::literal("x"),
5124                name: Some("x".into()),
5125                span: Span::new(),
5126            }],
5127            words: Some(vec![Word::literal("1"), Word::literal("2")]),
5128            body: stmt_seq(vec![]),
5129            syntax: ForSyntax::InDoDone {
5130                in_span: Some(Span::new()),
5131                do_span: Span::new(),
5132                done_span: Span::new(),
5133            },
5134            span: Span::new(),
5135        };
5136        assert_eq!(for_cmd.words.as_ref().unwrap().len(), 2);
5137    }
5138
5139    #[test]
5140    fn arithmetic_for_command() {
5141        let cmd = ArithmeticForCommand {
5142            left_paren_span: Span::new(),
5143            init_span: Some(Span::new()),
5144            init_ast: None,
5145            first_semicolon_span: Span::new(),
5146            condition_span: Some(Span::new()),
5147            condition_ast: None,
5148            second_semicolon_span: Span::new(),
5149            step_span: Some(Span::new()),
5150            step_ast: None,
5151            right_paren_span: Span::new(),
5152            body: stmt_seq(vec![]),
5153            span: Span::new(),
5154        };
5155        assert!(cmd.init_span.is_some());
5156        assert!(cmd.condition_span.is_some());
5157        assert!(cmd.step_span.is_some());
5158    }
5159
5160    #[test]
5161    fn function_def_construction() {
5162        let func = FunctionDef {
5163            header: FunctionHeader {
5164                function_keyword_span: None,
5165                entries: vec![FunctionHeaderEntry {
5166                    word: Word::literal("my_func"),
5167                    static_name: Some("my_func".into()),
5168                }],
5169                trailing_parens_span: Some(Span::new()),
5170            },
5171            body: Box::new(simple_stmt("echo", vec![Word::literal("hello")])),
5172            span: Span::new(),
5173        };
5174        assert_eq!(func.static_names().next().unwrap(), "my_func");
5175    }
5176
5177    // --- File ---
5178
5179    #[test]
5180    fn file_empty() {
5181        let file = File {
5182            body: stmt_seq(vec![]),
5183            span: Span::new(),
5184        };
5185        assert!(file.body.is_empty());
5186    }
5187
5188    // --- Command enum variants ---
5189
5190    #[test]
5191    fn command_variants_constructible() {
5192        let simple = Command::Simple(simple_command("echo", vec![]));
5193        assert!(matches!(simple, Command::Simple(_)));
5194
5195        let pipe = Command::Binary(BinaryCommand {
5196            left: Box::new(simple_stmt("echo", vec![])),
5197            op: BinaryOp::Pipe,
5198            op_span: Span::new(),
5199            right: Box::new(simple_stmt("cat", vec![])),
5200            span: Span::new(),
5201        });
5202        assert!(matches!(pipe, Command::Binary(_)));
5203
5204        let builtin = Command::Builtin(BuiltinCommand::Exit(ExitCommand {
5205            code: Some(Word::literal("1")),
5206            extra_args: vec![],
5207            assignments: Box::default(),
5208            span: Span::new(),
5209        }));
5210        assert!(matches!(builtin, Command::Builtin(_)));
5211
5212        let compound = Command::Compound(CompoundCommand::BraceGroup(stmt_seq(vec![])));
5213        assert!(matches!(compound, Command::Compound(_)));
5214
5215        let func = Command::Function(FunctionDef {
5216            header: FunctionHeader {
5217                function_keyword_span: None,
5218                entries: vec![FunctionHeaderEntry {
5219                    word: Word::literal("f"),
5220                    static_name: Some("f".into()),
5221                }],
5222                trailing_parens_span: Some(Span::new()),
5223            },
5224            body: Box::new(simple_stmt("true", vec![])),
5225            span: Span::new(),
5226        });
5227        assert!(matches!(func, Command::Function(_)));
5228
5229        let anonymous = Command::AnonymousFunction(AnonymousFunctionCommand {
5230            surface: AnonymousFunctionSurface::Parens {
5231                parens_span: Span::new(),
5232            },
5233            body: Box::new(simple_stmt("true", vec![])),
5234            args: vec![Word::literal("x")],
5235            span: Span::new(),
5236        });
5237        assert!(matches!(anonymous, Command::AnonymousFunction(_)));
5238    }
5239
5240    // --- CompoundCommand variants ---
5241
5242    #[test]
5243    fn compound_command_subshell() {
5244        let cmd = CompoundCommand::Subshell(stmt_seq(vec![]));
5245        assert!(matches!(cmd, CompoundCommand::Subshell(_)));
5246    }
5247
5248    #[test]
5249    fn compound_command_arithmetic() {
5250        let cmd = CompoundCommand::Arithmetic(ArithmeticCommand {
5251            span: Span::new(),
5252            left_paren_span: Span::new(),
5253            expr_span: Some(Span::new()),
5254            expr_ast: None,
5255            right_paren_span: Span::new(),
5256        });
5257        assert!(matches!(cmd, CompoundCommand::Arithmetic(_)));
5258    }
5259
5260    #[test]
5261    fn compound_command_conditional() {
5262        let cmd = CompoundCommand::Conditional(ConditionalCommand {
5263            expression: ConditionalExpr::Unary(ConditionalUnaryExpr {
5264                op: ConditionalUnaryOp::RegularFile,
5265                op_span: Span::new(),
5266                expr: Box::new(ConditionalExpr::Word(Word::literal("file"))),
5267            }),
5268            span: Span::new(),
5269            left_bracket_span: Span::new(),
5270            right_bracket_span: Span::new(),
5271        });
5272        if let CompoundCommand::Conditional(command) = &cmd {
5273            let ConditionalExpr::Unary(expr) = &command.expression else {
5274                panic!("expected unary conditional");
5275            };
5276            assert_eq!(expr.op, ConditionalUnaryOp::RegularFile);
5277        } else {
5278            panic!("expected Conditional");
5279        }
5280    }
5281
5282    #[test]
5283    fn time_command_construction() {
5284        let cmd = TimeCommand {
5285            posix_format: true,
5286            command: None,
5287            span: Span::new(),
5288        };
5289        assert!(cmd.posix_format);
5290        assert!(cmd.command.is_none());
5291    }
5292}