Skip to main content

shuck_linter/facts/
conditionals.rs

1use super::*;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ConditionalOperatorFamily {
5    StringUnary,
6    StringBinary,
7    Regex,
8    Logical,
9    Other,
10}
11
12#[derive(Debug, Clone, Copy)]
13pub struct ConditionalOperandFact<'a> {
14    expression: &'a ConditionalExpr,
15    class: TestOperandClass,
16    word: Option<&'a Word>,
17    word_classification: Option<WordClassification>,
18}
19
20impl<'a> ConditionalOperandFact<'a> {
21    pub fn expression(&self) -> &'a ConditionalExpr {
22        self.expression
23    }
24
25    pub fn class(&self) -> TestOperandClass {
26        self.class
27    }
28
29    pub fn word(&self) -> Option<&'a Word> {
30        self.word
31    }
32
33    pub fn is_optional_first_positional_parameter(self) -> bool {
34        self.word
35            .is_some_and(word_is_optional_first_positional_parameter)
36    }
37
38    pub fn word_classification(&self) -> Option<WordClassification> {
39        self.word_classification
40    }
41
42    pub fn quote(&self) -> Option<WordQuote> {
43        self.word_classification
44            .map(|classification| classification.quote)
45    }
46}
47
48pub(crate) fn word_is_optional_first_positional_parameter(word: &Word) -> bool {
49    let [part] = word.parts.as_slice() else {
50        return false;
51    };
52
53    word_part_is_optional_first_positional_parameter(&part.kind)
54}
55
56pub(crate) fn word_part_is_optional_first_positional_parameter(part: &WordPart) -> bool {
57    match part {
58        WordPart::Variable(name) => name.as_str() == "1",
59        WordPart::DoubleQuoted { parts, .. } => {
60            let [part] = parts.as_slice() else {
61                return false;
62            };
63            word_part_is_optional_first_positional_parameter(&part.kind)
64        }
65        WordPart::Parameter(parameter) => parameter_is_optional_first_positional(parameter),
66        WordPart::ParameterExpansion {
67            reference,
68            operator,
69            ..
70        } => {
71            reference_is_first_positional_parameter(reference)
72                && matches!(operator.as_ref(), ParameterOp::UseDefault)
73        }
74        WordPart::Literal(_)
75        | WordPart::ZshQualifiedGlob(_)
76        | WordPart::SingleQuoted { .. }
77        | WordPart::CommandSubstitution { .. }
78        | WordPart::ArithmeticExpansion { .. }
79        | WordPart::Length(_)
80        | WordPart::ArrayAccess(_)
81        | WordPart::ArrayLength(_)
82        | WordPart::ArrayIndices(_)
83        | WordPart::Substring { .. }
84        | WordPart::ArraySlice { .. }
85        | WordPart::IndirectExpansion { .. }
86        | WordPart::PrefixMatch { .. }
87        | WordPart::ProcessSubstitution { .. }
88        | WordPart::Transformation { .. } => false,
89    }
90}
91
92pub(crate) fn parameter_is_optional_first_positional(parameter: &ParameterExpansion) -> bool {
93    match &parameter.syntax {
94        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) => {
95            reference_is_first_positional_parameter(reference)
96        }
97        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
98            reference,
99            operator,
100            ..
101        }) => {
102            reference_is_first_positional_parameter(reference)
103                && matches!(operator.as_ref(), ParameterOp::UseDefault)
104        }
105        ParameterExpansionSyntax::Zsh(syntax) => {
106            matches!(
107                &syntax.target,
108                ZshExpansionTarget::Reference(reference)
109                    if reference_is_first_positional_parameter(reference)
110            ) && match &syntax.operation {
111                None => true,
112                Some(ZshExpansionOperation::Defaulting { kind, .. }) => {
113                    matches!(kind, shuck_ast::ZshDefaultingOp::UseDefault)
114                }
115                Some(
116                    ZshExpansionOperation::PatternOperation { .. }
117                    | ZshExpansionOperation::TrimOperation { .. }
118                    | ZshExpansionOperation::ReplacementOperation { .. }
119                    | ZshExpansionOperation::Slice { .. }
120                    | ZshExpansionOperation::Unknown { .. },
121                ) => false,
122            }
123        }
124        ParameterExpansionSyntax::Bourne(
125            BourneParameterExpansion::Length { .. }
126            | BourneParameterExpansion::Indices { .. }
127            | BourneParameterExpansion::Indirect { .. }
128            | BourneParameterExpansion::PrefixMatch { .. }
129            | BourneParameterExpansion::Slice { .. }
130            | BourneParameterExpansion::Transformation { .. },
131        ) => false,
132    }
133}
134
135pub(crate) fn reference_is_first_positional_parameter(reference: &VarRef) -> bool {
136    reference.subscript.is_none() && reference.name.as_str() == "1"
137}
138
139#[derive(Debug, Clone, Copy)]
140pub struct ConditionalBareWordFact<'a> {
141    expression: &'a ConditionalExpr,
142    operand: ConditionalOperandFact<'a>,
143}
144
145impl<'a> ConditionalBareWordFact<'a> {
146    pub fn expression(&self) -> &'a ConditionalExpr {
147        self.expression
148    }
149
150    pub fn operand(&self) -> ConditionalOperandFact<'a> {
151        self.operand
152    }
153}
154
155#[derive(Debug, Clone, Copy)]
156pub struct ConditionalUnaryFact<'a> {
157    expression: &'a ConditionalExpr,
158    op: ConditionalUnaryOp,
159    operator_family: ConditionalOperatorFamily,
160    operand: ConditionalOperandFact<'a>,
161}
162
163impl<'a> ConditionalUnaryFact<'a> {
164    pub fn expression(&self) -> &'a ConditionalExpr {
165        self.expression
166    }
167
168    pub fn operator_span(&self) -> Span {
169        let ConditionalExpr::Unary(expression) = self.expression else {
170            unreachable!("conditional unary fact should wrap a unary expression");
171        };
172
173        expression.op_span
174    }
175
176    pub fn op(&self) -> ConditionalUnaryOp {
177        self.op
178    }
179
180    pub fn is_empty_string_test(&self) -> bool {
181        self.operator_family == ConditionalOperatorFamily::StringUnary
182            && self.op == ConditionalUnaryOp::EmptyString
183    }
184
185    pub fn operator_family(&self) -> ConditionalOperatorFamily {
186        self.operator_family
187    }
188
189    pub fn operand(&self) -> ConditionalOperandFact<'a> {
190        self.operand
191    }
192}
193
194#[derive(Debug, Clone, Copy)]
195pub struct ConditionalBinaryFact<'a> {
196    expression: &'a ConditionalExpr,
197    op: ConditionalBinaryOp,
198    operator_family: ConditionalOperatorFamily,
199    left: ConditionalOperandFact<'a>,
200    right: ConditionalOperandFact<'a>,
201}
202
203impl<'a> ConditionalBinaryFact<'a> {
204    pub fn expression(&self) -> &'a ConditionalExpr {
205        self.expression
206    }
207
208    pub fn operator_span(&self) -> Span {
209        let ConditionalExpr::Binary(expression) = self.expression else {
210            unreachable!("conditional binary fact should wrap a binary expression");
211        };
212
213        expression.op_span
214    }
215
216    pub fn op(&self) -> ConditionalBinaryOp {
217        self.op
218    }
219
220    pub fn operator_family(&self) -> ConditionalOperatorFamily {
221        self.operator_family
222    }
223
224    pub fn left(&self) -> ConditionalOperandFact<'a> {
225        self.left
226    }
227
228    pub fn right(&self) -> ConditionalOperandFact<'a> {
229        self.right
230    }
231}
232
233#[derive(Debug, Clone, Copy)]
234pub enum ConditionalNodeFact<'a> {
235    BareWord(ConditionalBareWordFact<'a>),
236    Unary(ConditionalUnaryFact<'a>),
237    Binary(ConditionalBinaryFact<'a>),
238    Other(&'a ConditionalExpr),
239}
240
241impl<'a> ConditionalNodeFact<'a> {
242    pub fn expression(&self) -> &'a ConditionalExpr {
243        match self {
244            Self::BareWord(fact) => fact.expression(),
245            Self::Unary(fact) => fact.expression(),
246            Self::Binary(fact) => fact.expression(),
247            Self::Other(expression) => expression,
248        }
249    }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct ConditionalMixedLogicalOperatorFact {
254    operator_span: Span,
255    grouped_subexpression_spans: Box<[Span]>,
256}
257
258impl ConditionalMixedLogicalOperatorFact {
259    pub fn operator_span(&self) -> Span {
260        self.operator_span
261    }
262
263    pub fn grouped_subexpression_spans(&self) -> &[Span] {
264        &self.grouped_subexpression_spans
265    }
266}
267
268#[derive(Debug, Clone)]
269pub struct ConditionalFact<'a> {
270    nodes: Box<[ConditionalNodeFact<'a>]>,
271    mixed_logical_operators: Box<[ConditionalMixedLogicalOperatorFact]>,
272}
273
274impl<'a> ConditionalFact<'a> {
275    pub fn expression(&self) -> &'a ConditionalExpr {
276        self.root().expression()
277    }
278
279    pub fn root(&self) -> &ConditionalNodeFact<'a> {
280        &self.nodes[0]
281    }
282
283    pub fn nodes(&self) -> &[ConditionalNodeFact<'a>] {
284        &self.nodes
285    }
286
287    pub fn mixed_logical_operators(&self) -> &[ConditionalMixedLogicalOperatorFact] {
288        &self.mixed_logical_operators
289    }
290
291    pub fn regex_nodes(&self) -> impl Iterator<Item = &ConditionalBinaryFact<'a>> + '_ {
292        self.nodes.iter().filter_map(|node| match node {
293            ConditionalNodeFact::Binary(fact)
294                if fact.operator_family() == ConditionalOperatorFamily::Regex =>
295            {
296                Some(fact)
297            }
298            _ => None,
299        })
300    }
301}
302
303#[derive(Debug, Clone, Copy)]
304pub(crate) struct ConditionalExpressionVisit<'a> {
305    expression: &'a ConditionalExpr,
306    parent_in_same_logical_group: bool,
307}
308
309impl<'a> ConditionalExpressionVisit<'a> {
310    pub(crate) fn new(expression: &'a ConditionalExpr, parent_in_same_logical_group: bool) -> Self {
311        Self {
312            expression,
313            parent_in_same_logical_group,
314        }
315    }
316}
317
318pub(crate) fn collect_command_substitution_command_span(
319    command: &Command,
320    source: &str,
321    spans: &mut Vec<Span>,
322) {
323    if let Command::Simple(command) = command
324        && command.args.is_empty()
325        && command_name_is_plain_command_substitution(&command.name, source)
326    {
327        spans.push(command.name.span);
328    }
329}
330
331pub(crate) fn collect_c107_status_spans_in_simple_test(
332    command: &shuck_ast::SimpleCommand,
333    source: &str,
334    spans: &mut Vec<Span>,
335) {
336    if static_word_text(&command.name, source).as_deref() != Some("[") {
337        return;
338    }
339
340    let Some((closing_bracket, operands)) = command.args.split_last() else {
341        return;
342    };
343    if static_word_text(closing_bracket, source).as_deref() != Some("]") {
344        return;
345    }
346
347    let operands = operands.iter().collect::<Vec<_>>();
348    let effective_operand_offset = simple_test_effective_operand_offset(&operands, source);
349    let effective_operands = &operands[effective_operand_offset..];
350    if effective_operands.len() != 3 {
351        return;
352    }
353
354    let Some(operator) = static_word_text(effective_operands[1], source) else {
355        return;
356    };
357    if !matches!(
358        operator.as_ref(),
359        "=" | "==" | "!=" | "-eq" | "-ne" | "-lt" | "-le" | "-gt" | "-ge"
360    ) {
361        return;
362    }
363
364    let left_status = c107_status_word_span(effective_operands[0]);
365    let right_status = c107_status_word_span(effective_operands[2]);
366    let left_zero = c107_word_is_zero_literal(effective_operands[0], source);
367    let right_zero = c107_word_is_zero_literal(effective_operands[2], source);
368
369    if let Some(span) = left_status.filter(|_| right_zero) {
370        spans.push(span);
371    } else if let Some(span) = right_status.filter(|_| left_zero) {
372        spans.push(span);
373    }
374}
375
376pub(crate) fn collect_c107_status_spans_in_conditional_expr(
377    expression: &ConditionalExpr,
378    source: &str,
379    spans: &mut Vec<Span>,
380) {
381    if let Some(span) = c107_conditional_expr_status_span(expression, source) {
382        spans.push(span);
383    }
384}
385
386pub(crate) fn c107_conditional_expr_status_span(
387    expression: &ConditionalExpr,
388    source: &str,
389) -> Option<Span> {
390    match expression {
391        ConditionalExpr::Binary(expression) => {
392            if matches!(
393                expression.op,
394                ConditionalBinaryOp::And | ConditionalBinaryOp::Or
395            ) {
396                return None;
397            }
398            if !matches!(
399                expression.op,
400                ConditionalBinaryOp::ArithmeticEq
401                    | ConditionalBinaryOp::ArithmeticNe
402                    | ConditionalBinaryOp::ArithmeticLe
403                    | ConditionalBinaryOp::ArithmeticGe
404                    | ConditionalBinaryOp::ArithmeticLt
405                    | ConditionalBinaryOp::ArithmeticGt
406                    | ConditionalBinaryOp::PatternEqShort
407                    | ConditionalBinaryOp::PatternEq
408                    | ConditionalBinaryOp::PatternNe
409            ) {
410                return None;
411            }
412
413            let left_status = c107_conditional_operand_status_span(&expression.left);
414            let right_status = c107_conditional_operand_status_span(&expression.right);
415            let left_zero = c107_conditional_expr_is_zero_literal(&expression.left, source);
416            let right_zero = c107_conditional_expr_is_zero_literal(&expression.right, source);
417
418            left_status
419                .filter(|_| right_zero)
420                .or_else(|| right_status.filter(|_| left_zero))
421        }
422        ConditionalExpr::Unary(expression) => {
423            c107_conditional_expr_status_span(&expression.expr, source)
424        }
425        ConditionalExpr::Parenthesized(expression) => {
426            c107_conditional_expr_status_span(&expression.expr, source)
427        }
428        ConditionalExpr::Word(_)
429        | ConditionalExpr::Pattern(_)
430        | ConditionalExpr::Regex(_)
431        | ConditionalExpr::VarRef(_) => None,
432    }
433}
434
435pub(crate) fn c107_conditional_operand_status_span(expression: &ConditionalExpr) -> Option<Span> {
436    match expression {
437        ConditionalExpr::Word(word) | ConditionalExpr::Regex(word) => c107_status_word_span(word),
438        ConditionalExpr::Pattern(pattern) => {
439            pattern.parts.iter().find_map(|part| match &part.kind {
440                PatternPart::Word(word) => c107_status_word_span(word),
441                PatternPart::Literal(_)
442                | PatternPart::AnyString
443                | PatternPart::AnyChar
444                | PatternPart::CharClass(_)
445                | PatternPart::Group { .. } => None,
446            })
447        }
448        ConditionalExpr::VarRef(reference) => {
449            (reference.name.as_str() == "?").then_some(reference.span)
450        }
451        ConditionalExpr::Parenthesized(expression) => {
452            c107_conditional_operand_status_span(&expression.expr)
453        }
454        ConditionalExpr::Unary(expression) => {
455            c107_conditional_operand_status_span(&expression.expr)
456        }
457        ConditionalExpr::Binary(_) => None,
458    }
459}
460
461pub(crate) fn c107_conditional_expr_is_zero_literal(
462    expression: &ConditionalExpr,
463    source: &str,
464) -> bool {
465    match expression {
466        ConditionalExpr::Word(word) | ConditionalExpr::Regex(word) => {
467            c107_word_is_zero_literal(word, source)
468        }
469        ConditionalExpr::Pattern(pattern) => c107_pattern_is_zero_literal(pattern, source),
470        ConditionalExpr::Parenthesized(expression) => {
471            c107_conditional_expr_is_zero_literal(&expression.expr, source)
472        }
473        ConditionalExpr::Unary(expression) => {
474            c107_conditional_expr_is_zero_literal(&expression.expr, source)
475        }
476        ConditionalExpr::VarRef(_) | ConditionalExpr::Binary(_) => false,
477    }
478}
479
480pub(crate) fn collect_c107_status_spans_in_arithmetic_command(
481    command: &shuck_ast::ArithmeticCommand,
482    source: &str,
483    spans: &mut Vec<Span>,
484) {
485    let Some(expression) = &command.expr_ast else {
486        return;
487    };
488
489    if let Some(span) = c107_arithmetic_expr_status_span(expression, source) {
490        spans.push(span);
491    }
492}
493
494pub(crate) fn c107_arithmetic_expr_status_span(
495    expression: &shuck_ast::ArithmeticExprNode,
496    source: &str,
497) -> Option<Span> {
498    match &expression.kind {
499        shuck_ast::ArithmeticExpr::Parenthesized { expression } => {
500            c107_arithmetic_expr_status_span(expression, source)
501        }
502        shuck_ast::ArithmeticExpr::Unary { expr, .. } => {
503            c107_arithmetic_expr_status_span(expr, source)
504        }
505        shuck_ast::ArithmeticExpr::Binary { left, op, right } => {
506            if !matches!(
507                op,
508                shuck_ast::ArithmeticBinaryOp::LessThan
509                    | shuck_ast::ArithmeticBinaryOp::LessThanOrEqual
510                    | shuck_ast::ArithmeticBinaryOp::GreaterThan
511                    | shuck_ast::ArithmeticBinaryOp::GreaterThanOrEqual
512                    | shuck_ast::ArithmeticBinaryOp::Equal
513                    | shuck_ast::ArithmeticBinaryOp::NotEqual
514            ) {
515                return None;
516            }
517
518            let left_status = c107_arithmetic_operand_status_span(left);
519            let right_status = c107_arithmetic_operand_status_span(right);
520            let left_zero = c107_arithmetic_expr_is_zero_literal(left, source);
521            let right_zero = c107_arithmetic_expr_is_zero_literal(right, source);
522
523            left_status
524                .filter(|_| right_zero)
525                .or_else(|| right_status.filter(|_| left_zero))
526        }
527        _ => None,
528    }
529}
530
531pub(crate) fn c107_arithmetic_operand_status_span(
532    expression: &shuck_ast::ArithmeticExprNode,
533) -> Option<Span> {
534    match &expression.kind {
535        shuck_ast::ArithmeticExpr::ShellWord(word) => c107_status_word_span(word),
536        shuck_ast::ArithmeticExpr::Parenthesized { expression } => {
537            c107_arithmetic_operand_status_span(expression)
538        }
539        shuck_ast::ArithmeticExpr::Unary { expr, .. } => c107_arithmetic_operand_status_span(expr),
540        _ => None,
541    }
542}
543
544pub(crate) fn c107_arithmetic_expr_is_zero_literal(
545    expression: &shuck_ast::ArithmeticExprNode,
546    source: &str,
547) -> bool {
548    match &expression.kind {
549        shuck_ast::ArithmeticExpr::Number(text) => text.slice(source).trim() == "0",
550        shuck_ast::ArithmeticExpr::ShellWord(word) => c107_word_is_zero_literal(word, source),
551        shuck_ast::ArithmeticExpr::Parenthesized { expression } => {
552            c107_arithmetic_expr_is_zero_literal(expression, source)
553        }
554        shuck_ast::ArithmeticExpr::Unary { expr, .. } => {
555            c107_arithmetic_expr_is_zero_literal(expr, source)
556        }
557        _ => false,
558    }
559}
560
561pub(crate) fn c107_status_word_span(word: &Word) -> Option<Span> {
562    word_is_standalone_status_capture(word).then_some(word.span)
563}
564
565pub(crate) fn c107_word_is_zero_literal(word: &Word, source: &str) -> bool {
566    static_word_text(word, source).as_deref() == Some("0")
567}
568
569pub(crate) fn c107_pattern_is_zero_literal(pattern: &Pattern, source: &str) -> bool {
570    match pattern.parts.as_slice() {
571        [part] => match &part.kind {
572            PatternPart::Literal(text) => text.as_str(source, part.span) == "0",
573            PatternPart::Word(word) => c107_word_is_zero_literal(word, source),
574            PatternPart::AnyString
575            | PatternPart::AnyChar
576            | PatternPart::CharClass(_)
577            | PatternPart::Group { .. } => false,
578        },
579        _ => false,
580    }
581}
582
583pub(crate) fn collect_condition_status_capture_from_body(
584    condition: &StmtSeq,
585    body: &StmtSeq,
586    source: &str,
587    spans: &mut Vec<Span>,
588) {
589    if !condition_terminals_are_test_commands(condition, source) {
590        return;
591    }
592
593    let Some(first_stmt) = body.first() else {
594        return;
595    };
596
597    let mut stmt_spans = Vec::new();
598    collect_status_parameter_spans_in_stmt(first_stmt, source, &mut stmt_spans);
599    if stmt_spans.is_empty()
600        || branch_body_status_capture_is_exempt(first_stmt, body, source, &stmt_spans)
601    {
602        return;
603    }
604
605    spans.extend(stmt_spans);
606}
607
608pub(crate) fn collect_condition_status_capture_from_sequence(
609    commands: &StmtSeq,
610    source: &str,
611    spans: &mut Vec<Span>,
612) {
613    let body_shape = BodyShapeAnalyzer::new(source);
614    let mut segment_start = 0;
615    for (index, stmt) in commands.iter().enumerate() {
616        if !stmt_starts_sequence_barrier(stmt) {
617            continue;
618        }
619
620        collect_condition_status_capture_from_sequence_segment(
621            &commands.as_slice()[segment_start..index],
622            source,
623            spans,
624            body_shape.sequence_tail_contains_nested_test_command(&commands.as_slice()[index..]),
625            segment_start > 0,
626        );
627        segment_start = index + 1;
628    }
629    collect_condition_status_capture_from_sequence_segment(
630        &commands.as_slice()[segment_start..],
631        source,
632        spans,
633        false,
634        segment_start > 0,
635    );
636
637    for (index, stmt) in commands.iter().enumerate() {
638        if !body_shape.sequence_tail_contains_nested_test_command(&commands.as_slice()[index + 1..])
639        {
640            continue;
641        }
642
643        let Command::Compound(CompoundCommand::Subshell(body) | CompoundCommand::BraceGroup(body)) =
644            &stmt.command
645        else {
646            continue;
647        };
648
649        body_shape.collect_status_test_followup_chains_with_sibling_tail(body, spans);
650    }
651}
652
653pub(crate) fn collect_condition_status_capture_from_direct_body_sequences(
654    command: &Command,
655    source: &str,
656    spans: &mut Vec<Span>,
657) {
658    match command {
659        Command::Compound(command) => match command {
660            CompoundCommand::If(command) => {
661                collect_condition_status_capture_from_sequence(&command.then_branch, source, spans);
662                for (_, branch) in &command.elif_branches {
663                    collect_condition_status_capture_from_sequence(branch, source, spans);
664                }
665                if let Some(branch) = &command.else_branch {
666                    collect_condition_status_capture_from_sequence(branch, source, spans);
667                }
668            }
669            CompoundCommand::While(command) => {
670                collect_condition_status_capture_from_sequence(&command.body, source, spans);
671            }
672            CompoundCommand::Until(command) => {
673                collect_condition_status_capture_from_sequence(&command.body, source, spans);
674            }
675            CompoundCommand::For(command) => {
676                collect_condition_status_capture_from_sequence(&command.body, source, spans);
677            }
678            CompoundCommand::Select(command) => {
679                collect_condition_status_capture_from_sequence(&command.body, source, spans);
680            }
681            CompoundCommand::BraceGroup(body) | CompoundCommand::Subshell(body) => {
682                collect_condition_status_capture_from_sequence(body, source, spans);
683            }
684            CompoundCommand::Time(_) => {}
685            CompoundCommand::Always(command) => {
686                collect_condition_status_capture_from_sequence(&command.body, source, spans);
687                collect_condition_status_capture_from_sequence(&command.always_body, source, spans);
688            }
689            CompoundCommand::Case(_)
690            | CompoundCommand::Conditional(_)
691            | CompoundCommand::Repeat(_)
692            | CompoundCommand::Foreach(_)
693            | CompoundCommand::ArithmeticFor(_)
694            | CompoundCommand::Arithmetic(_)
695            | CompoundCommand::Coproc(_) => {}
696        },
697        Command::Simple(_)
698        | Command::Builtin(_)
699        | Command::Decl(_)
700        | Command::Binary(_)
701        | Command::Function(_)
702        | Command::AnonymousFunction(_) => {}
703    }
704}
705
706pub(crate) fn collect_condition_status_capture_from_sequence_segment(
707    commands: &[Stmt],
708    source: &str,
709    spans: &mut Vec<Span>,
710    trailing_tail_has_test: bool,
711    has_prior_barrier: bool,
712) {
713    let tail_contains_test = sequence_tail_test_index(commands, source);
714    let mut recent = RecentSequenceStatus::default();
715    let body_topology = BodyTopology::from_statements(commands);
716    for (index, previous, current) in body_topology.indexed_sibling_pairs() {
717        if let Some(before_previous) = body_topology.previous_sibling(index) {
718            recent.push(before_previous, source);
719        }
720        let tail_has_test = tail_contains_test.get(index + 2).copied().unwrap_or(false);
721        let effective_tail_has_test = trailing_tail_has_test || tail_has_test;
722        let in_tbegin_region = recent.seen_tbegin;
723
724        if stmt_is_assignment_only_unquoted_status_capture(current) {
725            if stmt_is_standalone_non_status_test_command(previous, source)
726                && in_tbegin_region
727                && recent.status_capture_count >= 2
728                && tail_has_test
729            {
730                collect_status_parameter_spans_in_stmt(current, source, spans);
731            }
732            continue;
733        }
734
735        if !stmt_is_plain_command_with_standalone_status_argument(current) {
736            continue;
737        }
738
739        if stmt_is_status_based_test_command(previous, source) {
740            if (!has_prior_barrier && effective_tail_has_test) || recent.contains_status_based_test
741            {
742                collect_status_parameter_spans_in_stmt(current, source, spans);
743            }
744            continue;
745        }
746
747        if !stmt_is_standalone_non_status_test_command(previous, source) {
748            continue;
749        }
750
751        if in_tbegin_region && recent.has_prior_non_status_capture_pair {
752            continue;
753        }
754
755        if effective_tail_has_test {
756            collect_status_parameter_spans_in_stmt(current, source, spans);
757        }
758    }
759}
760
761#[derive(Default)]
762pub(crate) struct RecentSequenceStatus<'a> {
763    seen_tbegin: bool,
764    status_capture_count: usize,
765    contains_status_based_test: bool,
766    has_prior_non_status_capture_pair: bool,
767    last_stmt: Option<&'a Stmt>,
768}
769
770impl<'a> RecentSequenceStatus<'a> {
771    fn push(&mut self, stmt: &'a Stmt, source: &str) {
772        if stmt_is_named_simple_command(stmt, source, "tbegin") {
773            self.seen_tbegin = true;
774            self.status_capture_count = 0;
775            self.contains_status_based_test = false;
776            self.has_prior_non_status_capture_pair = false;
777            self.last_stmt = None;
778            return;
779        }
780
781        let contains_status_capture = stmt_contains_status_capture(stmt, source);
782        if self
783            .last_stmt
784            .is_some_and(|previous| stmt_is_standalone_non_status_test_command(previous, source))
785            && contains_status_capture
786        {
787            self.has_prior_non_status_capture_pair = true;
788        }
789        if contains_status_capture {
790            self.status_capture_count += 1;
791        }
792        if stmt_is_status_based_test_command(stmt, source) {
793            self.contains_status_based_test = true;
794        }
795        self.last_stmt = Some(stmt);
796    }
797}
798
799pub(crate) fn sequence_tail_test_index(commands: &[Stmt], source: &str) -> Vec<bool> {
800    let mut suffix = vec![false; commands.len() + 1];
801    for (index, stmt) in commands.iter().enumerate().rev() {
802        suffix[index] = suffix[index + 1] || stmt_terminals_are_test_commands(stmt, source);
803    }
804    suffix
805}
806
807pub(crate) fn branch_body_status_capture_is_exempt(
808    first_stmt: &Stmt,
809    body: &StmtSeq,
810    source: &str,
811    stmt_spans: &[Span],
812) -> bool {
813    if branch_body_preserves_status_in_plain_assignment(first_stmt, body, source, stmt_spans) {
814        return true;
815    }
816
817    if branch_body_logs_status_then_immediately_exits(first_stmt, body, source, stmt_spans) {
818        return true;
819    }
820
821    false
822}
823
824pub(crate) fn branch_body_preserves_status_in_plain_assignment(
825    first_stmt: &Stmt,
826    _body: &StmtSeq,
827    source: &str,
828    stmt_spans: &[Span],
829) -> bool {
830    let Some(_) = stmt_plain_assignment_only_name(first_stmt) else {
831        return false;
832    };
833    if stmt_contains_unquoted_standalone_status_capture(first_stmt, source) || stmt_spans.is_empty()
834    {
835        return false;
836    }
837    true
838}
839
840pub(crate) fn branch_body_logs_status_then_immediately_exits(
841    first_stmt: &Stmt,
842    body: &StmtSeq,
843    source: &str,
844    stmt_spans: &[Span],
845) -> bool {
846    if stmt_spans.is_empty() || stmt_contains_unquoted_standalone_status_capture(first_stmt, source)
847    {
848        return false;
849    }
850
851    body.iter()
852        .nth(1)
853        .is_some_and(stmt_is_exit_or_return_builtin)
854}
855
856pub(crate) fn collect_precise_function_return_guard_suppressions_in_seq(
857    commands: &StmtSeq,
858    source: &str,
859    spans: &mut Vec<Span>,
860    in_function_like_body: bool,
861) {
862    let stmts = commands.as_slice();
863    let body_shape = BodyShapeAnalyzer::new(source);
864    for (index, stmt) in stmts.iter().enumerate() {
865        if in_function_like_body && stmt_is_test_return_status_guard(stmt, source) {
866            let previous_non_test_guard =
867                index > 0 && stmt_is_non_test_return_status_guard(&stmts[index - 1], source);
868            let later_unary_guard = stmts[index + 1..]
869                .iter()
870                .any(|stmt| stmt_is_unary_test_return_status_guard(stmt, source));
871            let status_accumulator_guard =
872                body_shape.stmt_starts_status_accumulator_return_guard(index, stmts);
873            if status_accumulator_guard
874                || (stmt_is_unary_test_return_status_guard(stmt, source)
875                    && (previous_non_test_guard || later_unary_guard))
876            {
877                collect_status_parameter_spans_in_return_guard_stmt(stmt, source, spans);
878            }
879        }
880    }
881}
882
883pub(crate) fn collect_precise_function_return_guard_suppressions_from_direct_body_sequences(
884    command: &Command,
885    source: &str,
886    spans: &mut Vec<Span>,
887    in_function_like_body: bool,
888) {
889    match command {
890        Command::Compound(command) => match command {
891            CompoundCommand::If(command) => {
892                collect_precise_function_return_guard_suppressions_in_seq(
893                    &command.then_branch,
894                    source,
895                    spans,
896                    in_function_like_body,
897                );
898                for (_, branch) in &command.elif_branches {
899                    collect_precise_function_return_guard_suppressions_in_seq(
900                        branch,
901                        source,
902                        spans,
903                        in_function_like_body,
904                    );
905                }
906                if let Some(branch) = &command.else_branch {
907                    collect_precise_function_return_guard_suppressions_in_seq(
908                        branch,
909                        source,
910                        spans,
911                        in_function_like_body,
912                    );
913                }
914            }
915            CompoundCommand::While(command) => {
916                collect_precise_function_return_guard_suppressions_in_seq(
917                    &command.body,
918                    source,
919                    spans,
920                    in_function_like_body,
921                );
922            }
923            CompoundCommand::Until(command) => {
924                collect_precise_function_return_guard_suppressions_in_seq(
925                    &command.body,
926                    source,
927                    spans,
928                    in_function_like_body,
929                );
930            }
931            CompoundCommand::For(command) => {
932                collect_precise_function_return_guard_suppressions_in_seq(
933                    &command.body,
934                    source,
935                    spans,
936                    in_function_like_body,
937                );
938            }
939            CompoundCommand::Select(command) => {
940                collect_precise_function_return_guard_suppressions_in_seq(
941                    &command.body,
942                    source,
943                    spans,
944                    in_function_like_body,
945                );
946            }
947            CompoundCommand::BraceGroup(body) | CompoundCommand::Subshell(body) => {
948                collect_precise_function_return_guard_suppressions_in_seq(
949                    body,
950                    source,
951                    spans,
952                    in_function_like_body,
953                );
954            }
955            CompoundCommand::Time(_) => {}
956            CompoundCommand::Always(command) => {
957                collect_precise_function_return_guard_suppressions_in_seq(
958                    &command.body,
959                    source,
960                    spans,
961                    in_function_like_body,
962                );
963                collect_precise_function_return_guard_suppressions_in_seq(
964                    &command.always_body,
965                    source,
966                    spans,
967                    in_function_like_body,
968                );
969            }
970            CompoundCommand::Case(command) => {
971                for case in &command.cases {
972                    collect_precise_function_return_guard_suppressions_in_seq(
973                        &case.body,
974                        source,
975                        spans,
976                        in_function_like_body,
977                    );
978                }
979            }
980            CompoundCommand::Conditional(_)
981            | CompoundCommand::Repeat(_)
982            | CompoundCommand::Foreach(_)
983            | CompoundCommand::ArithmeticFor(_)
984            | CompoundCommand::Arithmetic(_)
985            | CompoundCommand::Coproc(_) => {}
986        },
987        Command::Simple(_)
988        | Command::Builtin(_)
989        | Command::Decl(_)
990        | Command::Binary(_)
991        | Command::Function(_)
992        | Command::AnonymousFunction(_) => {}
993    }
994}
995
996pub(crate) fn condition_terminals_are_test_commands(condition: &StmtSeq, source: &str) -> bool {
997    condition
998        .last()
999        .is_some_and(|stmt| stmt_terminals_are_test_commands(stmt, source))
1000}
1001
1002pub(crate) fn stmt_is_standalone_non_status_test_command(stmt: &Stmt, source: &str) -> bool {
1003    stmt_terminals_are_test_commands(stmt, source) && !stmt_contains_status_capture(stmt, source)
1004}
1005
1006pub(crate) fn stmt_is_status_based_test_command(stmt: &Stmt, source: &str) -> bool {
1007    stmt_terminals_are_test_commands(stmt, source) && stmt_contains_status_capture(stmt, source)
1008}
1009
1010pub(crate) fn stmt_is_unary_test_return_status_guard(stmt: &Stmt, source: &str) -> bool {
1011    let Command::Binary(command) = &stmt.command else {
1012        return false;
1013    };
1014    matches!(command.op, BinaryOp::Or)
1015        && stmt_is_simple_unary_test_command(&command.left, source)
1016        && stmt_is_return_status_command(&command.right, source)
1017}
1018
1019pub(crate) fn stmt_is_test_return_status_guard(stmt: &Stmt, source: &str) -> bool {
1020    let Command::Binary(command) = &stmt.command else {
1021        return false;
1022    };
1023    matches!(command.op, BinaryOp::Or)
1024        && stmt_terminals_are_test_commands(&command.left, source)
1025        && stmt_is_return_status_command(&command.right, source)
1026}
1027
1028pub(crate) fn stmt_is_non_test_return_status_guard(stmt: &Stmt, source: &str) -> bool {
1029    let Command::Binary(command) = &stmt.command else {
1030        return false;
1031    };
1032    matches!(command.op, BinaryOp::Or)
1033        && !stmt_terminals_are_test_commands(&command.left, source)
1034        && stmt_is_return_status_command(&command.right, source)
1035}
1036
1037pub(crate) fn stmt_is_return_status_command(stmt: &Stmt, source: &str) -> bool {
1038    if stmt.negated || !stmt.redirects.is_empty() {
1039        return false;
1040    }
1041
1042    match &stmt.command {
1043        Command::Builtin(BuiltinCommand::Return(command)) => command
1044            .code
1045            .as_ref()
1046            .is_some_and(word_is_unquoted_standalone_status_capture),
1047        Command::Simple(command) => {
1048            static_word_text(&command.name, source).as_deref() == Some("return")
1049                && command.args.len() == 1
1050                && word_is_unquoted_standalone_status_capture(&command.args[0])
1051        }
1052        Command::Builtin(_)
1053        | Command::Decl(_)
1054        | Command::Binary(_)
1055        | Command::Compound(_)
1056        | Command::Function(_)
1057        | Command::AnonymousFunction(_) => false,
1058    }
1059}
1060
1061pub(crate) fn stmt_is_simple_unary_test_command(stmt: &Stmt, source: &str) -> bool {
1062    if stmt.negated {
1063        return false;
1064    }
1065
1066    match &stmt.command {
1067        Command::Compound(CompoundCommand::Conditional(command)) => {
1068            matches!(command.expression, ConditionalExpr::Unary(_))
1069        }
1070        Command::Simple(command) => {
1071            let Some(name) = static_word_text(&command.name, source) else {
1072                return false;
1073            };
1074            if !matches!(name.as_ref(), "[" | "test") {
1075                return false;
1076            }
1077            let Some((closing_bracket, operands)) = command.args.split_last() else {
1078                return false;
1079            };
1080            let operand_count = if name.as_ref() == "[" {
1081                if static_word_text(closing_bracket, source).as_deref() != Some("]") {
1082                    return false;
1083                }
1084                operands.len()
1085            } else {
1086                command.args.len()
1087            };
1088            operand_count == 2
1089        }
1090        Command::Builtin(_)
1091        | Command::Decl(_)
1092        | Command::Binary(_)
1093        | Command::Compound(_)
1094        | Command::Function(_)
1095        | Command::AnonymousFunction(_) => false,
1096    }
1097}
1098
1099pub(crate) fn stmt_assignment_only_scalar_literal_name<'a>(
1100    stmt: &'a Stmt,
1101    source: &str,
1102    expected: &str,
1103) -> Option<&'a Name> {
1104    let name = stmt_plain_assignment_only_name(stmt)?;
1105    let Command::Simple(command) = &stmt.command else {
1106        return None;
1107    };
1108    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
1109        return None;
1110    };
1111    (static_word_text(word, source).as_deref() == Some(expected)).then_some(name)
1112}
1113
1114pub(crate) fn word_is_name_reference(word: &Word, name: &Name) -> bool {
1115    matches!(
1116        word.parts.as_slice(),
1117        [WordPartNode {
1118            kind: WordPart::Variable(var),
1119            ..
1120        }] if var == name
1121    ) || matches!(
1122        word.parts.as_slice(),
1123        [WordPartNode {
1124            kind: WordPart::Parameter(parameter),
1125            ..
1126        }] if matches!(
1127            parameter.bourne(),
1128            Some(BourneParameterExpansion::Access { reference })
1129                if reference.name == *name && reference.subscript.is_none()
1130        )
1131    )
1132}
1133
1134pub(crate) fn stmt_contains_status_capture(stmt: &Stmt, source: &str) -> bool {
1135    let mut spans = Vec::new();
1136    collect_status_parameter_spans_in_stmt(stmt, source, &mut spans);
1137    !spans.is_empty()
1138}
1139
1140pub(crate) fn stmt_contains_unquoted_standalone_status_capture(stmt: &Stmt, source: &str) -> bool {
1141    let mut spans = Vec::new();
1142    collect_unquoted_standalone_status_parameter_spans_in_stmt(stmt, source, &mut spans);
1143    !spans.is_empty()
1144}
1145
1146pub(crate) fn stmt_starts_sequence_barrier(stmt: &Stmt) -> bool {
1147    matches!(
1148        &stmt.command,
1149        Command::Compound(
1150            CompoundCommand::If(_)
1151                | CompoundCommand::Case(_)
1152                | CompoundCommand::For(_)
1153                | CompoundCommand::Select(_)
1154                | CompoundCommand::While(_)
1155                | CompoundCommand::Until(_)
1156                | CompoundCommand::Always(_)
1157        )
1158    )
1159}
1160
1161pub(crate) fn stmt_is_named_simple_command(stmt: &Stmt, source: &str, name: &str) -> bool {
1162    matches!(
1163        &stmt.command,
1164        Command::Simple(command)
1165            if static_word_text(&command.name, source).as_deref() == Some(name)
1166    )
1167}
1168
1169pub(crate) fn stmt_plain_assignment_only_name(stmt: &Stmt) -> Option<&Name> {
1170    if stmt.negated || !stmt.redirects.is_empty() {
1171        return None;
1172    }
1173
1174    let Command::Simple(command) = &stmt.command else {
1175        return None;
1176    };
1177    if !command.args.is_empty()
1178        || command.name.span.start.offset != command.name.span.end.offset
1179        || command.assignments.len() != 1
1180    {
1181        return None;
1182    }
1183
1184    Some(&command.assignments[0].target.name)
1185}
1186
1187pub(crate) fn stmt_is_assignment_only_unquoted_status_capture(stmt: &Stmt) -> bool {
1188    if stmt.negated || !stmt.redirects.is_empty() {
1189        return false;
1190    }
1191
1192    let Command::Simple(command) = &stmt.command else {
1193        return false;
1194    };
1195
1196    command.args.is_empty()
1197        && command.name.span.start.offset == command.name.span.end.offset
1198        && !command.assignments.is_empty()
1199        && command
1200            .assignments
1201            .iter()
1202            .all(|assignment| assignment_value_is_unquoted_status_capture(&assignment.value))
1203}
1204
1205pub(crate) fn assignment_value_is_unquoted_status_capture(value: &AssignmentValue) -> bool {
1206    match value {
1207        AssignmentValue::Scalar(word) => word_is_unquoted_standalone_status_capture(word),
1208        AssignmentValue::Compound(_) => false,
1209    }
1210}
1211
1212pub(crate) fn stmt_is_exit_or_return_builtin(stmt: &Stmt) -> bool {
1213    matches!(
1214        stmt.command,
1215        Command::Builtin(BuiltinCommand::Exit(_) | BuiltinCommand::Return(_))
1216    )
1217}
1218
1219pub(crate) fn stmt_is_plain_command_with_standalone_status_argument(stmt: &Stmt) -> bool {
1220    if stmt.negated || !stmt.redirects.is_empty() {
1221        return false;
1222    }
1223
1224    match &stmt.command {
1225        Command::Simple(command) => {
1226            command.name.span.start.offset != command.name.span.end.offset
1227                && (word_is_unquoted_standalone_status_capture(&command.name)
1228                    || command
1229                        .args
1230                        .iter()
1231                        .any(word_is_unquoted_standalone_status_capture))
1232        }
1233        Command::Builtin(command) => match command {
1234            BuiltinCommand::Return(command) => command
1235                .code
1236                .as_ref()
1237                .is_some_and(word_is_unquoted_standalone_status_capture),
1238            BuiltinCommand::Exit(command) => command
1239                .code
1240                .as_ref()
1241                .is_some_and(word_is_unquoted_standalone_status_capture),
1242            BuiltinCommand::Break(_) | BuiltinCommand::Continue(_) => false,
1243        },
1244        Command::Decl(_)
1245        | Command::Binary(_)
1246        | Command::Compound(_)
1247        | Command::Function(_)
1248        | Command::AnonymousFunction(_) => false,
1249    }
1250}
1251
1252pub(crate) fn word_is_unquoted_standalone_status_capture(word: &Word) -> bool {
1253    matches!(
1254        word.parts.as_slice(),
1255        [WordPartNode {
1256            kind: WordPart::Variable(name),
1257            ..
1258        }] if name.as_str() == "?"
1259    ) || matches!(
1260        word.parts.as_slice(),
1261        [WordPartNode {
1262            kind: WordPart::Parameter(parameter),
1263            ..
1264        }] if matches!(
1265            parameter.bourne(),
1266            Some(BourneParameterExpansion::Access { reference })
1267                if reference.name.as_str() == "?" && reference.subscript.is_none()
1268        )
1269    )
1270}
1271
1272pub(crate) fn stmt_terminals_are_test_commands(stmt: &Stmt, source: &str) -> bool {
1273    if stmt.negated {
1274        return false;
1275    }
1276
1277    command_terminals_are_test_commands(&stmt.command, source)
1278}
1279
1280pub(crate) fn command_terminals_are_test_commands(command: &Command, source: &str) -> bool {
1281    match command {
1282        Command::Simple(command) => matches!(
1283            static_word_text(&command.name, source).as_deref(),
1284            Some("[") | Some("test")
1285        ),
1286        Command::Compound(CompoundCommand::Conditional(_)) => true,
1287        Command::Binary(command) => logical_list_segments_are_test_commands(command, source),
1288        Command::Builtin(_)
1289        | Command::Decl(_)
1290        | Command::Compound(_)
1291        | Command::Function(_)
1292        | Command::AnonymousFunction(_) => false,
1293    }
1294}
1295
1296pub(crate) fn logical_list_segments_are_test_commands(
1297    command: &BinaryCommand,
1298    source: &str,
1299) -> bool {
1300    matches!(command.op, BinaryOp::And | BinaryOp::Or)
1301        && stmt_terminals_are_test_commands(&command.left, source)
1302        && stmt_terminals_are_test_commands(&command.right, source)
1303}
1304
1305pub(crate) fn collect_status_parameter_spans_in_stmt(
1306    stmt: &Stmt,
1307    source: &str,
1308    spans: &mut Vec<Span>,
1309) {
1310    collect_status_parameter_spans_in_command(&stmt.command, source, spans);
1311    for redirect in &stmt.redirects {
1312        if let Some(word) = redirect.word_target() {
1313            collect_status_parameter_spans_in_word(word, source, spans);
1314        }
1315    }
1316}
1317
1318pub(crate) fn collect_status_parameter_spans_in_return_guard_stmt(
1319    stmt: &Stmt,
1320    source: &str,
1321    spans: &mut Vec<Span>,
1322) {
1323    if let Command::Binary(command) = &stmt.command {
1324        collect_status_parameter_spans_in_stmt(&command.right, source, spans);
1325        return;
1326    }
1327
1328    collect_status_parameter_spans_in_stmt(stmt, source, spans);
1329}
1330
1331pub(crate) fn collect_unquoted_standalone_status_parameter_spans_in_stmt(
1332    stmt: &Stmt,
1333    source: &str,
1334    spans: &mut Vec<Span>,
1335) {
1336    collect_unquoted_standalone_status_parameter_spans_in_command(&stmt.command, source, spans);
1337    for redirect in &stmt.redirects {
1338        if let Some(word) = redirect.word_target() {
1339            collect_unquoted_standalone_status_parameter_spans_in_word(word, spans);
1340        }
1341    }
1342}
1343
1344pub(crate) fn collect_unquoted_standalone_status_parameter_spans_in_command(
1345    command: &Command,
1346    source: &str,
1347    spans: &mut Vec<Span>,
1348) {
1349    match command {
1350        Command::Simple(command) => {
1351            for assignment in &command.assignments {
1352                if let AssignmentValue::Scalar(word) = &assignment.value {
1353                    collect_unquoted_standalone_status_parameter_spans_in_word(word, spans);
1354                }
1355            }
1356            collect_unquoted_standalone_status_parameter_spans_in_word(&command.name, spans);
1357            for word in &command.args {
1358                collect_unquoted_standalone_status_parameter_spans_in_word(word, spans);
1359            }
1360        }
1361        Command::Builtin(command) => match command {
1362            BuiltinCommand::Return(command) => {
1363                if let Some(word) = &command.code {
1364                    collect_unquoted_standalone_status_parameter_spans_in_word(word, spans);
1365                }
1366            }
1367            BuiltinCommand::Exit(command) => {
1368                if let Some(word) = &command.code {
1369                    collect_unquoted_standalone_status_parameter_spans_in_word(word, spans);
1370                }
1371            }
1372            BuiltinCommand::Break(_) | BuiltinCommand::Continue(_) => {}
1373        },
1374        Command::Decl(command) => {
1375            collect_unquoted_standalone_status_parameter_spans_in_assignments(
1376                &command.assignments,
1377                spans,
1378            );
1379            for operand in &command.operands {
1380                if let DeclOperand::Assignment(assignment) = operand {
1381                    collect_unquoted_standalone_status_parameter_spans_in_assignment(
1382                        assignment, spans,
1383                    );
1384                }
1385            }
1386        }
1387        Command::Compound(command) => match command {
1388            CompoundCommand::Case(command) => {
1389                if let Some(first_stmt) = command.cases.iter().find_map(|case| case.body.first()) {
1390                    collect_unquoted_standalone_status_parameter_spans_in_stmt(
1391                        first_stmt, source, spans,
1392                    );
1393                }
1394            }
1395            CompoundCommand::BraceGroup(body) | CompoundCommand::Subshell(body) => {
1396                if let Some(first_stmt) = body.first() {
1397                    collect_unquoted_standalone_status_parameter_spans_in_stmt(
1398                        first_stmt, source, spans,
1399                    );
1400                }
1401            }
1402            CompoundCommand::Time(command) => {
1403                if let Some(inner) = &command.command {
1404                    collect_unquoted_standalone_status_parameter_spans_in_stmt(
1405                        inner, source, spans,
1406                    );
1407                }
1408            }
1409            CompoundCommand::If(_)
1410            | CompoundCommand::While(_)
1411            | CompoundCommand::Until(_)
1412            | CompoundCommand::For(_)
1413            | CompoundCommand::Select(_)
1414            | CompoundCommand::Conditional(_)
1415            | CompoundCommand::Repeat(_)
1416            | CompoundCommand::Foreach(_)
1417            | CompoundCommand::ArithmeticFor(_)
1418            | CompoundCommand::Arithmetic(_)
1419            | CompoundCommand::Coproc(_)
1420            | CompoundCommand::Always(_) => {}
1421        },
1422        Command::Binary(command) => {
1423            collect_unquoted_standalone_status_parameter_spans_in_stmt(
1424                &command.left,
1425                source,
1426                spans,
1427            );
1428        }
1429        Command::Function(_) | Command::AnonymousFunction(_) => {}
1430    }
1431}
1432
1433pub(crate) fn collect_unquoted_standalone_status_parameter_spans_in_assignments(
1434    assignments: &[Assignment],
1435    spans: &mut Vec<Span>,
1436) {
1437    for assignment in assignments {
1438        collect_unquoted_standalone_status_parameter_spans_in_assignment(assignment, spans);
1439    }
1440}
1441
1442pub(crate) fn collect_unquoted_standalone_status_parameter_spans_in_assignment(
1443    assignment: &Assignment,
1444    spans: &mut Vec<Span>,
1445) {
1446    match &assignment.value {
1447        AssignmentValue::Scalar(word) => {
1448            collect_unquoted_standalone_status_parameter_spans_in_word(word, spans);
1449        }
1450        AssignmentValue::Compound(_) => {}
1451    }
1452}
1453
1454pub(crate) fn collect_unquoted_standalone_status_parameter_spans_in_word(
1455    word: &Word,
1456    spans: &mut Vec<Span>,
1457) {
1458    if word_is_unquoted_standalone_status_capture(word) {
1459        spans.push(word.span);
1460    }
1461}
1462
1463pub(crate) fn collect_status_parameter_spans_in_command(
1464    command: &Command,
1465    source: &str,
1466    spans: &mut Vec<Span>,
1467) {
1468    match command {
1469        Command::Simple(command) => {
1470            collect_status_parameter_spans_in_assignments(&command.assignments, source, spans);
1471            collect_status_parameter_spans_in_word(&command.name, source, spans);
1472            for word in &command.args {
1473                collect_status_parameter_spans_in_word(word, source, spans);
1474            }
1475        }
1476        Command::Builtin(command) => match command {
1477            BuiltinCommand::Break(command) => {
1478                collect_status_parameter_spans_in_assignments(&command.assignments, source, spans);
1479                if let Some(word) = &command.depth {
1480                    collect_status_parameter_spans_in_word(word, source, spans);
1481                }
1482                for word in &command.extra_args {
1483                    collect_status_parameter_spans_in_word(word, source, spans);
1484                }
1485            }
1486            BuiltinCommand::Continue(command) => {
1487                collect_status_parameter_spans_in_assignments(&command.assignments, source, spans);
1488                if let Some(word) = &command.depth {
1489                    collect_status_parameter_spans_in_word(word, source, spans);
1490                }
1491                for word in &command.extra_args {
1492                    collect_status_parameter_spans_in_word(word, source, spans);
1493                }
1494            }
1495            BuiltinCommand::Return(command) => {
1496                collect_status_parameter_spans_in_assignments(&command.assignments, source, spans);
1497                if let Some(word) = &command.code {
1498                    collect_status_parameter_spans_in_word(word, source, spans);
1499                }
1500                for word in &command.extra_args {
1501                    collect_status_parameter_spans_in_word(word, source, spans);
1502                }
1503            }
1504            BuiltinCommand::Exit(command) => {
1505                collect_status_parameter_spans_in_assignments(&command.assignments, source, spans);
1506                if let Some(word) = &command.code {
1507                    collect_status_parameter_spans_in_word(word, source, spans);
1508                }
1509                for word in &command.extra_args {
1510                    collect_status_parameter_spans_in_word(word, source, spans);
1511                }
1512            }
1513        },
1514        Command::Decl(command) => {
1515            collect_status_parameter_spans_in_assignments(&command.assignments, source, spans);
1516            for operand in &command.operands {
1517                match operand {
1518                    DeclOperand::Flag(word) | DeclOperand::Dynamic(word) => {
1519                        collect_status_parameter_spans_in_word(word, source, spans);
1520                    }
1521                    DeclOperand::Name(reference) => {
1522                        collect_status_parameter_spans_in_var_ref(reference, source, spans);
1523                    }
1524                    DeclOperand::Assignment(assignment) => {
1525                        collect_status_parameter_spans_in_assignment(assignment, source, spans);
1526                    }
1527                }
1528            }
1529        }
1530        Command::Binary(command) => {
1531            collect_status_parameter_spans_in_stmt(&command.left, source, spans);
1532        }
1533        Command::Compound(command) => match command {
1534            CompoundCommand::If(command) => {
1535                if let Some(first_stmt) = command.condition.first() {
1536                    collect_status_parameter_spans_in_stmt(first_stmt, source, spans);
1537                }
1538            }
1539            CompoundCommand::While(command) => {
1540                if let Some(first_stmt) = command.condition.first() {
1541                    collect_status_parameter_spans_in_stmt(first_stmt, source, spans);
1542                }
1543            }
1544            CompoundCommand::Until(command) => {
1545                if let Some(first_stmt) = command.condition.first() {
1546                    collect_status_parameter_spans_in_stmt(first_stmt, source, spans);
1547                }
1548            }
1549            CompoundCommand::Case(command) => {
1550                collect_status_parameter_spans_in_word(&command.word, source, spans);
1551                for case in &command.cases {
1552                    if let Some(first_stmt) = case.body.first() {
1553                        collect_status_parameter_spans_in_stmt(first_stmt, source, spans);
1554                    }
1555                }
1556            }
1557            CompoundCommand::Subshell(body) | CompoundCommand::BraceGroup(body) => {
1558                if let Some(first_stmt) = body.first() {
1559                    collect_status_parameter_spans_in_stmt(first_stmt, source, spans);
1560                }
1561            }
1562            CompoundCommand::Time(command) => {
1563                if let Some(command) = &command.command {
1564                    collect_status_parameter_spans_in_stmt(command, source, spans);
1565                }
1566            }
1567            CompoundCommand::Conditional(command) => {
1568                collect_status_parameter_spans_in_conditional_expr(
1569                    &command.expression,
1570                    source,
1571                    spans,
1572                );
1573            }
1574            CompoundCommand::Coproc(command) => {
1575                collect_status_parameter_spans_in_stmt(&command.body, source, spans);
1576            }
1577            CompoundCommand::Always(command) => {
1578                if let Some(first_stmt) = command.body.first() {
1579                    collect_status_parameter_spans_in_stmt(first_stmt, source, spans);
1580                }
1581            }
1582            CompoundCommand::For(_)
1583            | CompoundCommand::Repeat(_)
1584            | CompoundCommand::Foreach(_)
1585            | CompoundCommand::ArithmeticFor(_)
1586            | CompoundCommand::Select(_)
1587            | CompoundCommand::Arithmetic(_) => {}
1588        },
1589        Command::Function(_) => {}
1590        Command::AnonymousFunction(command) => {
1591            collect_status_parameter_spans_in_stmt(&command.body, source, spans);
1592            for word in &command.args {
1593                collect_status_parameter_spans_in_word(word, source, spans);
1594            }
1595        }
1596    }
1597}
1598
1599pub(crate) fn collect_status_parameter_spans_in_assignments(
1600    assignments: &[Assignment],
1601    source: &str,
1602    spans: &mut Vec<Span>,
1603) {
1604    for assignment in assignments {
1605        collect_status_parameter_spans_in_assignment(assignment, source, spans);
1606    }
1607}
1608
1609pub(crate) fn collect_status_parameter_spans_in_assignment(
1610    assignment: &Assignment,
1611    source: &str,
1612    spans: &mut Vec<Span>,
1613) {
1614    collect_status_parameter_spans_in_var_ref(&assignment.target, source, spans);
1615    match &assignment.value {
1616        AssignmentValue::Scalar(word) => {
1617            collect_status_parameter_spans_in_word(word, source, spans)
1618        }
1619        AssignmentValue::Compound(array) => {
1620            for element in &array.elements {
1621                match element {
1622                    ArrayElem::Sequential(word) => {
1623                        collect_status_parameter_spans_in_word(word, source, spans);
1624                    }
1625                    ArrayElem::Keyed { key, value } | ArrayElem::KeyedAppend { key, value } => {
1626                        visit_subscript_words(Some(key), source, &mut |word| {
1627                            collect_status_parameter_spans_in_word(word, source, spans);
1628                        });
1629                        collect_status_parameter_spans_in_word(value, source, spans);
1630                    }
1631                }
1632            }
1633        }
1634    }
1635}
1636
1637pub(crate) fn collect_status_parameter_spans_in_var_ref(
1638    reference: &VarRef,
1639    source: &str,
1640    spans: &mut Vec<Span>,
1641) {
1642    if reference.name.as_str() == "?" {
1643        spans.push(reference.span);
1644    }
1645
1646    visit_var_ref_subscript_words_with_source(reference, source, &mut |word| {
1647        collect_status_parameter_spans_in_word(word, source, spans);
1648    });
1649}
1650
1651pub(crate) fn collect_status_parameter_spans_in_word(
1652    word: &Word,
1653    source: &str,
1654    spans: &mut Vec<Span>,
1655) {
1656    for part in &word.parts {
1657        collect_status_parameter_spans_in_word_part(part, source, spans);
1658    }
1659}
1660
1661pub(crate) fn collect_status_parameter_spans_in_word_part(
1662    part: &WordPartNode,
1663    source: &str,
1664    spans: &mut Vec<Span>,
1665) {
1666    match &part.kind {
1667        WordPart::Literal(_) | WordPart::SingleQuoted { .. } | WordPart::ZshQualifiedGlob(_) => {}
1668        WordPart::DoubleQuoted { parts, .. } => {
1669            for nested_part in parts {
1670                collect_status_parameter_spans_in_word_part(nested_part, source, spans);
1671            }
1672        }
1673        WordPart::Variable(name) => {
1674            if name.as_str() == "?" {
1675                spans.push(part.span);
1676            }
1677        }
1678        WordPart::CommandSubstitution { body, .. } | WordPart::ProcessSubstitution { body, .. } => {
1679            if let Some(first_stmt) = body.first() {
1680                collect_status_parameter_spans_in_stmt(first_stmt, source, spans);
1681            }
1682        }
1683        WordPart::ArithmeticExpansion {
1684            expression_ast,
1685            expression_word_ast,
1686            ..
1687        } => {
1688            if let Some(expression) = expression_ast {
1689                visit_arithmetic_words(expression, &mut |word| {
1690                    collect_status_parameter_spans_in_word(word, source, spans);
1691                });
1692            } else {
1693                collect_status_parameter_spans_in_word(expression_word_ast, source, spans);
1694            }
1695        }
1696        WordPart::Parameter(parameter) => {
1697            collect_status_parameter_spans_in_parameter_expansion(parameter, source, spans);
1698        }
1699        WordPart::ParameterExpansion {
1700            reference,
1701            operand,
1702            operand_word_ast,
1703            ..
1704        }
1705        | WordPart::IndirectExpansion {
1706            reference,
1707            operand,
1708            operand_word_ast,
1709            ..
1710        } => {
1711            if reference.name.as_str() == "?" {
1712                spans.push(part.span);
1713            }
1714            collect_status_parameter_spans_in_var_ref(reference, source, spans);
1715            collect_status_parameter_spans_in_fragment(
1716                operand_word_ast.as_deref(),
1717                operand.as_ref(),
1718                source,
1719                spans,
1720            );
1721        }
1722        WordPart::Length(reference)
1723        | WordPart::ArrayAccess(reference)
1724        | WordPart::ArrayLength(reference)
1725        | WordPart::ArrayIndices(reference)
1726        | WordPart::Transformation { reference, .. } => {
1727            if reference.name.as_str() == "?" {
1728                spans.push(part.span);
1729            }
1730            collect_status_parameter_spans_in_var_ref(reference, source, spans);
1731        }
1732        WordPart::Substring {
1733            reference,
1734            offset_ast,
1735            offset_word_ast,
1736            length_ast,
1737            length_word_ast,
1738            ..
1739        }
1740        | WordPart::ArraySlice {
1741            reference,
1742            offset_ast,
1743            offset_word_ast,
1744            length_ast,
1745            length_word_ast,
1746            ..
1747        } => {
1748            if reference.name.as_str() == "?" {
1749                spans.push(part.span);
1750            }
1751            collect_status_parameter_spans_in_var_ref(reference, source, spans);
1752            if let Some(offset_ast) = offset_ast {
1753                visit_arithmetic_words(offset_ast, &mut |word| {
1754                    collect_status_parameter_spans_in_word(word, source, spans);
1755                });
1756            } else {
1757                collect_status_parameter_spans_in_word(offset_word_ast, source, spans);
1758            }
1759            match (length_ast.as_ref(), length_word_ast.as_ref()) {
1760                (Some(length_ast), _) => {
1761                    visit_arithmetic_words(length_ast, &mut |word| {
1762                        collect_status_parameter_spans_in_word(word, source, spans);
1763                    });
1764                }
1765                (None, Some(length_word_ast)) => {
1766                    collect_status_parameter_spans_in_word(length_word_ast, source, spans);
1767                }
1768                (None, None) => {}
1769            }
1770        }
1771        WordPart::PrefixMatch { .. } => {}
1772    }
1773}
1774
1775pub(crate) fn collect_status_parameter_spans_in_parameter_expansion(
1776    parameter: &shuck_ast::ParameterExpansion,
1777    source: &str,
1778    spans: &mut Vec<Span>,
1779) {
1780    match &parameter.syntax {
1781        ParameterExpansionSyntax::Bourne(syntax) => match syntax {
1782            BourneParameterExpansion::Access { reference }
1783            | BourneParameterExpansion::Length { reference }
1784            | BourneParameterExpansion::Indices { reference }
1785            | BourneParameterExpansion::Transformation { reference, .. } => {
1786                collect_status_parameter_spans_in_var_ref(reference, source, spans);
1787            }
1788            BourneParameterExpansion::Indirect {
1789                reference,
1790                operand,
1791                operand_word_ast,
1792                ..
1793            }
1794            | BourneParameterExpansion::Operation {
1795                reference,
1796                operand,
1797                operand_word_ast,
1798                ..
1799            } => {
1800                collect_status_parameter_spans_in_var_ref(reference, source, spans);
1801                collect_status_parameter_spans_in_fragment(
1802                    operand_word_ast.as_deref(),
1803                    operand.as_ref(),
1804                    source,
1805                    spans,
1806                );
1807            }
1808            BourneParameterExpansion::Slice {
1809                reference,
1810                offset_ast,
1811                offset_word_ast,
1812                length_ast,
1813                length_word_ast,
1814                ..
1815            } => {
1816                collect_status_parameter_spans_in_var_ref(reference, source, spans);
1817                if let Some(offset_ast) = offset_ast {
1818                    visit_arithmetic_words(offset_ast, &mut |word| {
1819                        collect_status_parameter_spans_in_word(word, source, spans);
1820                    });
1821                } else {
1822                    collect_status_parameter_spans_in_word(offset_word_ast, source, spans);
1823                }
1824
1825                match (length_ast.as_ref(), length_word_ast.as_ref()) {
1826                    (Some(length_ast), _) => {
1827                        visit_arithmetic_words(length_ast, &mut |word| {
1828                            collect_status_parameter_spans_in_word(word, source, spans);
1829                        });
1830                    }
1831                    (None, Some(length_word_ast)) => {
1832                        collect_status_parameter_spans_in_word(length_word_ast, source, spans);
1833                    }
1834                    (None, None) => {}
1835                }
1836            }
1837            BourneParameterExpansion::PrefixMatch { .. } => {}
1838        },
1839        ParameterExpansionSyntax::Zsh(syntax) => {
1840            collect_status_parameter_spans_in_zsh_target(&syntax.target, source, spans);
1841
1842            if let Some(operation) = &syntax.operation {
1843                match operation {
1844                    shuck_ast::ZshExpansionOperation::PatternOperation { operand, .. }
1845                    | shuck_ast::ZshExpansionOperation::Defaulting { operand, .. }
1846                    | shuck_ast::ZshExpansionOperation::TrimOperation { operand, .. } => {
1847                        collect_status_parameter_spans_in_fragment(
1848                            operation.operand_word_ast(),
1849                            Some(operand),
1850                            source,
1851                            spans,
1852                        );
1853                    }
1854                    shuck_ast::ZshExpansionOperation::ReplacementOperation {
1855                        pattern,
1856                        replacement,
1857                        ..
1858                    } => {
1859                        collect_status_parameter_spans_in_fragment(
1860                            operation.pattern_word_ast(),
1861                            Some(pattern),
1862                            source,
1863                            spans,
1864                        );
1865                        collect_status_parameter_spans_in_fragment(
1866                            operation.replacement_word_ast(),
1867                            replacement.as_ref(),
1868                            source,
1869                            spans,
1870                        );
1871                    }
1872                    shuck_ast::ZshExpansionOperation::Slice { offset, length, .. } => {
1873                        collect_status_parameter_spans_in_fragment(
1874                            operation.offset_word_ast(),
1875                            Some(offset),
1876                            source,
1877                            spans,
1878                        );
1879                        collect_status_parameter_spans_in_fragment(
1880                            operation.length_word_ast(),
1881                            length.as_ref(),
1882                            source,
1883                            spans,
1884                        );
1885                    }
1886                    shuck_ast::ZshExpansionOperation::Unknown { text, .. } => {
1887                        collect_status_parameter_spans_in_fragment(
1888                            operation.operand_word_ast(),
1889                            Some(text),
1890                            source,
1891                            spans,
1892                        );
1893                    }
1894                }
1895            }
1896        }
1897    }
1898}
1899
1900pub(crate) fn collect_status_parameter_spans_in_zsh_target(
1901    target: &ZshExpansionTarget,
1902    source: &str,
1903    spans: &mut Vec<Span>,
1904) {
1905    match target {
1906        ZshExpansionTarget::Reference(reference) => {
1907            collect_status_parameter_spans_in_var_ref(reference, source, spans);
1908        }
1909        ZshExpansionTarget::Nested(parameter) => {
1910            collect_status_parameter_spans_in_parameter_expansion(parameter, source, spans);
1911        }
1912        ZshExpansionTarget::Word(word) => {
1913            collect_status_parameter_spans_in_word(word, source, spans);
1914        }
1915        ZshExpansionTarget::Empty => {}
1916    }
1917}
1918
1919pub(crate) fn collect_status_parameter_spans_in_conditional_expr(
1920    expression: &ConditionalExpr,
1921    source: &str,
1922    spans: &mut Vec<Span>,
1923) {
1924    match expression {
1925        ConditionalExpr::Binary(expression) => {
1926            collect_status_parameter_spans_in_conditional_expr(&expression.left, source, spans);
1927            collect_status_parameter_spans_in_conditional_expr(&expression.right, source, spans);
1928        }
1929        ConditionalExpr::Unary(expression) => {
1930            collect_status_parameter_spans_in_conditional_expr(&expression.expr, source, spans);
1931        }
1932        ConditionalExpr::Parenthesized(expression) => {
1933            collect_status_parameter_spans_in_conditional_expr(&expression.expr, source, spans);
1934        }
1935        ConditionalExpr::Word(word) | ConditionalExpr::Regex(word) => {
1936            collect_status_parameter_spans_in_word(word, source, spans);
1937        }
1938        ConditionalExpr::Pattern(pattern) => {
1939            for part in &pattern.parts {
1940                if let PatternPart::Word(word) = &part.kind {
1941                    collect_status_parameter_spans_in_word(word, source, spans);
1942                }
1943            }
1944        }
1945        ConditionalExpr::VarRef(reference) => {
1946            collect_status_parameter_spans_in_var_ref(reference, source, spans);
1947        }
1948    }
1949}
1950
1951pub(crate) fn collect_status_parameter_spans_in_fragment(
1952    word: Option<&Word>,
1953    text: Option<&SourceText>,
1954    source: &str,
1955    spans: &mut Vec<Span>,
1956) {
1957    let Some(text) = text else {
1958        return;
1959    };
1960    let snippet = text.slice(source);
1961    if !snippet.contains("$?") {
1962        return;
1963    }
1964    debug_assert!(
1965        word.is_some(),
1966        "parser-backed fragment text should always carry a word AST"
1967    );
1968    let Some(word) = word else {
1969        return;
1970    };
1971    collect_status_parameter_spans_in_word(word, source, spans);
1972}
1973
1974pub(crate) fn build_conditional_fact<'a>(
1975    expression_visits: &[ConditionalExpressionVisit<'a>],
1976    source: &str,
1977) -> Option<ConditionalFact<'a>> {
1978    if expression_visits.is_empty() {
1979        return None;
1980    }
1981
1982    let mut nodes = Vec::with_capacity(expression_visits.len());
1983    let mut mixed_logical_operators = Vec::new();
1984    for visit in expression_visits {
1985        nodes.push(build_conditional_node(visit.expression, source));
1986        collect_mixed_logical_operator(
1987            visit.expression,
1988            visit.parent_in_same_logical_group,
1989            &mut mixed_logical_operators,
1990        );
1991    }
1992
1993    (!nodes.is_empty()).then_some(ConditionalFact {
1994        nodes: nodes.into_boxed_slice(),
1995        mixed_logical_operators: mixed_logical_operators.into_boxed_slice(),
1996    })
1997}
1998
1999pub(crate) fn command_name_is_plain_command_substitution(word: &Word, source: &str) -> bool {
2000    let analysis = analyze_word(word, source, None);
2001    analysis.substitution_shape == WordSubstitutionShape::Plain
2002        && analysis.quote == WordQuote::Unquoted
2003        && matches!(
2004            word.parts.as_slice(),
2005            [WordPartNode {
2006                kind: WordPart::CommandSubstitution {
2007                    syntax: CommandSubstitutionSyntax::DollarParen,
2008                    ..
2009                },
2010                ..
2011            }]
2012        )
2013}
2014
2015pub(crate) fn collect_mixed_logical_operator(
2016    expression: &ConditionalExpr,
2017    parent_in_same_logical_group: bool,
2018    operators: &mut Vec<ConditionalMixedLogicalOperatorFact>,
2019) {
2020    let ConditionalExpr::Binary(binary) = expression else {
2021        return;
2022    };
2023
2024    if conditional_binary_op_is_logical(binary.op)
2025        && !parent_in_same_logical_group
2026        && logical_operator_mask(expression) == (LOGICAL_AND_MASK | LOGICAL_OR_MASK)
2027    {
2028        let grouped_subexpression_spans =
2029            mixed_logical_grouped_subexpression_spans(expression, binary.op);
2030        debug_assert!(
2031            !grouped_subexpression_spans.is_empty(),
2032            "mixed logical operators should expose at least one grouping span"
2033        );
2034        operators.push(ConditionalMixedLogicalOperatorFact {
2035            operator_span: binary.op_span,
2036            grouped_subexpression_spans: grouped_subexpression_spans.into_boxed_slice(),
2037        });
2038    }
2039}
2040
2041pub(crate) const LOGICAL_AND_MASK: u8 = 0b01;
2042pub(crate) const LOGICAL_OR_MASK: u8 = 0b10;
2043
2044pub(crate) fn mixed_logical_grouped_subexpression_spans(
2045    expression: &ConditionalExpr,
2046    group_op: ConditionalBinaryOp,
2047) -> Vec<Span> {
2048    let mut spans = Vec::new();
2049    collect_mixed_logical_grouped_subexpression_spans(expression, group_op, &mut spans);
2050    spans
2051}
2052
2053pub(crate) fn collect_mixed_logical_grouped_subexpression_spans(
2054    expression: &ConditionalExpr,
2055    group_op: ConditionalBinaryOp,
2056    spans: &mut Vec<Span>,
2057) {
2058    let ConditionalExpr::Binary(binary) = expression else {
2059        return;
2060    };
2061    if !conditional_binary_op_is_logical(binary.op) {
2062        return;
2063    }
2064
2065    if binary.op != group_op {
2066        spans.push(expression.span());
2067        return;
2068    }
2069
2070    collect_mixed_logical_grouped_subexpression_spans(&binary.left, group_op, spans);
2071    collect_mixed_logical_grouped_subexpression_spans(&binary.right, group_op, spans);
2072}
2073
2074pub(crate) fn logical_operator_mask(expression: &ConditionalExpr) -> u8 {
2075    match expression {
2076        ConditionalExpr::Parenthesized(_) => 0,
2077        ConditionalExpr::Unary(unary) => logical_operator_mask(&unary.expr),
2078        ConditionalExpr::Binary(binary) => {
2079            let own = match binary.op {
2080                ConditionalBinaryOp::And => LOGICAL_AND_MASK,
2081                ConditionalBinaryOp::Or => LOGICAL_OR_MASK,
2082                _ => 0,
2083            };
2084
2085            own | logical_operator_mask(&binary.left) | logical_operator_mask(&binary.right)
2086        }
2087        ConditionalExpr::Word(_)
2088        | ConditionalExpr::Pattern(_)
2089        | ConditionalExpr::Regex(_)
2090        | ConditionalExpr::VarRef(_) => 0,
2091    }
2092}
2093
2094pub(crate) fn conditional_binary_op_is_logical(operator: ConditionalBinaryOp) -> bool {
2095    matches!(operator, ConditionalBinaryOp::And | ConditionalBinaryOp::Or)
2096}
2097
2098pub(crate) fn build_conditional_node<'a>(
2099    expression: &'a ConditionalExpr,
2100    source: &str,
2101) -> ConditionalNodeFact<'a> {
2102    match expression {
2103        ConditionalExpr::Word(_) => ConditionalNodeFact::BareWord(ConditionalBareWordFact {
2104            expression,
2105            operand: build_conditional_operand_fact(expression, source),
2106        }),
2107        ConditionalExpr::Unary(unary) => ConditionalNodeFact::Unary(ConditionalUnaryFact {
2108            expression,
2109            op: unary.op,
2110            operator_family: conditional_unary_operator_family(unary.op),
2111            operand: build_conditional_operand_fact(&unary.expr, source),
2112        }),
2113        ConditionalExpr::Binary(binary) => ConditionalNodeFact::Binary(ConditionalBinaryFact {
2114            expression,
2115            op: binary.op,
2116            operator_family: conditional_binary_operator_family(binary.op),
2117            left: build_conditional_operand_fact(&binary.left, source),
2118            right: build_conditional_operand_fact(&binary.right, source),
2119        }),
2120        ConditionalExpr::Parenthesized(_)
2121        | ConditionalExpr::Pattern(_)
2122        | ConditionalExpr::Regex(_)
2123        | ConditionalExpr::VarRef(_) => ConditionalNodeFact::Other(expression),
2124    }
2125}
2126
2127pub(crate) fn build_conditional_operand_fact<'a>(
2128    expression: &'a ConditionalExpr,
2129    source: &str,
2130) -> ConditionalOperandFact<'a> {
2131    let expression = strip_parenthesized_conditionals(expression);
2132    let word = match expression {
2133        ConditionalExpr::Word(word) | ConditionalExpr::Regex(word) => Some(word),
2134        ConditionalExpr::Pattern(pattern) => conditional_pattern_single_word(pattern),
2135        ConditionalExpr::Binary(_)
2136        | ConditionalExpr::Unary(_)
2137        | ConditionalExpr::Parenthesized(_)
2138        | ConditionalExpr::VarRef(_) => None,
2139    };
2140
2141    ConditionalOperandFact {
2142        expression,
2143        class: classify_conditional_operand(expression, source),
2144        word,
2145        word_classification: word.map(|word| classify_word(word, source)),
2146    }
2147}
2148
2149pub(crate) fn conditional_pattern_single_word(pattern: &Pattern) -> Option<&Word> {
2150    match pattern.parts.as_slice() {
2151        [part] => match &part.kind {
2152            PatternPart::Word(word) => Some(word),
2153            PatternPart::Literal(_)
2154            | PatternPart::AnyString
2155            | PatternPart::AnyChar
2156            | PatternPart::CharClass(_)
2157            | PatternPart::Group { .. } => None,
2158        },
2159        _ => None,
2160    }
2161}
2162
2163pub(crate) fn strip_parenthesized_conditionals(
2164    mut expression: &ConditionalExpr,
2165) -> &ConditionalExpr {
2166    while let ConditionalExpr::Parenthesized(parenthesized) = expression {
2167        expression = &parenthesized.expr;
2168    }
2169
2170    expression
2171}
2172
2173pub(crate) fn conditional_unary_operator_family(
2174    operator: ConditionalUnaryOp,
2175) -> ConditionalOperatorFamily {
2176    if matches!(
2177        operator,
2178        ConditionalUnaryOp::EmptyString | ConditionalUnaryOp::NonEmptyString
2179    ) {
2180        ConditionalOperatorFamily::StringUnary
2181    } else {
2182        ConditionalOperatorFamily::Other
2183    }
2184}
2185
2186pub(crate) fn conditional_binary_operator_family(
2187    operator: ConditionalBinaryOp,
2188) -> ConditionalOperatorFamily {
2189    match operator {
2190        ConditionalBinaryOp::RegexMatch => ConditionalOperatorFamily::Regex,
2191        ConditionalBinaryOp::And | ConditionalBinaryOp::Or => ConditionalOperatorFamily::Logical,
2192        ConditionalBinaryOp::PatternEqShort
2193        | ConditionalBinaryOp::PatternEq
2194        | ConditionalBinaryOp::PatternNe
2195        | ConditionalBinaryOp::LexicalBefore
2196        | ConditionalBinaryOp::LexicalAfter => ConditionalOperatorFamily::StringBinary,
2197        ConditionalBinaryOp::NewerThan
2198        | ConditionalBinaryOp::OlderThan
2199        | ConditionalBinaryOp::SameFile
2200        | ConditionalBinaryOp::ArithmeticEq
2201        | ConditionalBinaryOp::ArithmeticNe
2202        | ConditionalBinaryOp::ArithmeticLe
2203        | ConditionalBinaryOp::ArithmeticGe
2204        | ConditionalBinaryOp::ArithmeticLt
2205        | ConditionalBinaryOp::ArithmeticGt => ConditionalOperatorFamily::Other,
2206    }
2207}