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