Skip to main content

shuck_linter/facts/
simple_tests.rs

1use super::*;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum SimpleTestSyntax {
5    Test,
6    Bracket,
7}
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SimpleTestShape {
11    Empty,
12    Truthy,
13    Unary,
14    Binary,
15    Other,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SimpleTestOperatorFamily {
20    StringUnary,
21    StringBinary,
22    Other,
23}
24
25#[derive(Debug, Clone)]
26pub struct SimpleTestFact<'a> {
27    syntax: SimpleTestSyntax,
28    operands: Box<[&'a Word]>,
29    shape: SimpleTestShape,
30    operator_family: SimpleTestOperatorFamily,
31    effective_operand_offset: usize,
32    effective_shape: SimpleTestShape,
33    effective_operator_family: SimpleTestOperatorFamily,
34    operand_classes: Box<[TestOperandClass]>,
35    empty_test_suppressed: bool,
36}
37
38impl<'a> SimpleTestFact<'a> {
39    pub fn syntax(&self) -> SimpleTestSyntax {
40        self.syntax
41    }
42
43    pub fn operands(&self) -> &[&'a Word] {
44        &self.operands
45    }
46
47    pub fn shape(&self) -> SimpleTestShape {
48        self.shape
49    }
50
51    pub fn operator_family(&self) -> SimpleTestOperatorFamily {
52        self.operator_family
53    }
54
55    pub fn is_effectively_negated(&self) -> bool {
56        self.effective_operand_offset != 0
57    }
58
59    pub fn effective_operands(&self) -> &[&'a Word] {
60        &self.operands[self.effective_operand_offset..]
61    }
62
63    pub fn effective_shape(&self) -> SimpleTestShape {
64        self.effective_shape
65    }
66
67    pub fn effective_operator_family(&self) -> SimpleTestOperatorFamily {
68        self.effective_operator_family
69    }
70
71    pub fn operand_classes(&self) -> &[TestOperandClass] {
72        &self.operand_classes
73    }
74
75    pub fn operand_class(&self, index: usize) -> Option<TestOperandClass> {
76        self.operand_classes.get(index).copied()
77    }
78
79    pub fn effective_operand_class(&self, index: usize) -> Option<TestOperandClass> {
80        self.operand_classes
81            .get(self.effective_operand_offset + index)
82            .copied()
83    }
84
85    pub fn empty_test_suppressed(&self) -> bool {
86        self.empty_test_suppressed
87    }
88
89    pub fn truthy_operand_class(&self) -> Option<TestOperandClass> {
90        (self.shape == SimpleTestShape::Truthy)
91            .then(|| self.operand_class(0))
92            .flatten()
93    }
94
95    pub fn unary_operand_class(&self) -> Option<TestOperandClass> {
96        (self.shape == SimpleTestShape::Unary)
97            .then(|| self.operand_class(1))
98            .flatten()
99    }
100
101    pub fn effective_operator_word(&self) -> Option<&'a Word> {
102        match self.effective_shape {
103            SimpleTestShape::Unary => self.effective_operands().first().copied(),
104            SimpleTestShape::Binary => self.effective_operands().get(1).copied(),
105            SimpleTestShape::Empty | SimpleTestShape::Truthy | SimpleTestShape::Other => None,
106        }
107    }
108
109    pub fn escaped_negation_spans(&self, source: &str) -> Option<(Span, Span)> {
110        let leading = self.operands.first().copied()?;
111        if leading.span.slice(source) != "\\!" {
112            return None;
113        }
114
115        escaped_negation_is_operator(self, source).then(|| {
116            let diagnostic_span = if self.syntax == SimpleTestSyntax::Bracket
117                && self.shape == SimpleTestShape::Binary
118            {
119                self.operands
120                    .get(1)
121                    .copied()
122                    .map_or(leading.span, |word| word.span)
123            } else {
124                leading.span
125            };
126            let fix_start = leading.span.start;
127            let fix_end = fix_start.advanced_by("\\");
128            (diagnostic_span, Span::from_positions(fix_start, fix_end))
129        })
130    }
131
132    pub fn compound_operator_spans(&self, source: &str) -> Vec<Span> {
133        let Some((end, spans)) = simple_test_parse_logical_expression(self, 0, source) else {
134            return Vec::new();
135        };
136
137        if end == self.effective_operands().len() {
138            spans
139        } else {
140            Vec::new()
141        }
142    }
143
144    pub fn truthy_expression_words(&'a self, source: &str) -> Vec<&'a Word> {
145        simple_test_expressions(self, source)
146            .into_iter()
147            .filter_map(|expression| match expression {
148                SimpleTestExpression::Truthy(word) => Some(word),
149                SimpleTestExpression::StringUnary { .. }
150                | SimpleTestExpression::StringBinary { .. } => None,
151            })
152            .collect()
153    }
154
155    pub fn operator_expression_operand_words(&self, source: &str) -> Vec<&'a Word> {
156        simple_test_operator_expression_operand_words(self, source)
157    }
158
159    pub fn numeric_binary_expression_operand_words(&self, source: &str) -> Vec<&'a Word> {
160        simple_test_numeric_binary_expression_operand_words(self, source)
161    }
162
163    pub fn string_unary_expression_words(&'a self, source: &str) -> Vec<(&'a Word, &'a Word)> {
164        simple_test_expressions(self, source)
165            .into_iter()
166            .filter_map(|expression| match expression {
167                SimpleTestExpression::StringUnary { operator, operand } => {
168                    Some((operator, operand))
169                }
170                SimpleTestExpression::Truthy(_) | SimpleTestExpression::StringBinary { .. } => None,
171            })
172            .collect()
173    }
174
175    pub fn string_binary_expression_words(
176        &'a self,
177        source: &str,
178    ) -> Vec<(&'a Word, &'a Word, &'a Word)> {
179        simple_test_expressions(self, source)
180            .into_iter()
181            .filter_map(|expression| match expression {
182                SimpleTestExpression::StringBinary {
183                    left,
184                    operator,
185                    right,
186                } => Some((left, operator, right)),
187                SimpleTestExpression::Truthy(_) | SimpleTestExpression::StringUnary { .. } => None,
188            })
189            .collect()
190    }
191
192    pub fn is_abort_like_bracket_test(&self, source: &str) -> bool {
193        if self.syntax != SimpleTestSyntax::Bracket
194            || self.effective_shape != SimpleTestShape::Other
195        {
196            return false;
197        }
198
199        self.effective_operands()
200            .iter()
201            .enumerate()
202            .any(|(index, word)| {
203                self.effective_operand_class(index)
204                    .is_some_and(|class| class.is_fixed_literal())
205                    && matches!(
206                        static_word_text(word, source).as_deref(),
207                        Some("(") | Some(")")
208                    )
209            })
210    }
211
212    pub fn binary_operand_classes(&self) -> Option<(TestOperandClass, TestOperandClass)> {
213        (self.shape == SimpleTestShape::Binary)
214            .then(|| Some((self.operand_class(0)?, self.operand_class(2)?)))
215            .flatten()
216    }
217}
218
219pub(crate) fn escaped_negation_is_operator(simple_test: &SimpleTestFact<'_>, source: &str) -> bool {
220    match simple_test.shape() {
221        SimpleTestShape::Unary => true,
222        SimpleTestShape::Binary => simple_test
223            .operands()
224            .get(1)
225            .and_then(|word| static_word_text(word, source))
226            .as_deref()
227            .is_some_and(|operator| !simple_test_is_binary_operator(operator)),
228        SimpleTestShape::Other => simple_test
229            .operands()
230            .get(2)
231            .and_then(|word| static_word_text(word, source))
232            .as_deref()
233            .is_some_and(simple_test_is_binary_operator),
234        SimpleTestShape::Empty | SimpleTestShape::Truthy => false,
235    }
236}
237
238pub(crate) enum SimpleTestExpression<'a> {
239    Truthy(&'a Word),
240    StringUnary {
241        operator: &'a Word,
242        operand: &'a Word,
243    },
244    StringBinary {
245        left: &'a Word,
246        operator: &'a Word,
247        right: &'a Word,
248    },
249}
250
251pub(crate) fn simple_test_operands<'a>(
252    command: &'a SimpleCommand,
253    source: &str,
254) -> Option<&'a [Word]> {
255    match static_word_text(&command.name, source).as_deref()? {
256        "[" => {
257            let (closing_bracket, operands) = command.args.split_last()?;
258            (static_word_text(closing_bracket, source).as_deref() == Some("]")).then_some(operands)
259        }
260        "test" => Some(&command.args),
261        _ => None,
262    }
263}
264
265pub(crate) fn build_simple_test_fact<'a>(
266    command: &'a Command,
267    source: &str,
268) -> Option<SimpleTestFact<'a>> {
269    let Command::Simple(command) = command else {
270        return None;
271    };
272    let syntax = match static_word_text(&command.name, source).as_deref()? {
273        "test" => SimpleTestSyntax::Test,
274        "[" => SimpleTestSyntax::Bracket,
275        _ => return None,
276    };
277    let operands = match syntax {
278        SimpleTestSyntax::Test => command.args.iter().collect::<Vec<_>>(),
279        SimpleTestSyntax::Bracket => {
280            let (closing_bracket, operands) = command.args.split_last()?;
281            if static_word_text(closing_bracket, source).as_deref() != Some("]") {
282                return None;
283            }
284            operands.iter().collect::<Vec<_>>()
285        }
286    };
287    let shape = simple_test_shape(operands.len());
288    let operator_family = simple_test_operator_family(&operands, shape, source);
289    let effective_operand_offset = simple_test_effective_operand_offset(&operands, source);
290    let effective_shape =
291        simple_test_shape(operands.len().saturating_sub(effective_operand_offset));
292    let effective_operator_family = simple_test_operator_family(
293        &operands[effective_operand_offset..],
294        effective_shape,
295        source,
296    );
297    let operand_classes = operands
298        .iter()
299        .map(|word| classify_contextual_operand(word, source, ExpansionContext::CommandArgument))
300        .collect::<Vec<_>>()
301        .into_boxed_slice();
302
303    Some(SimpleTestFact {
304        syntax,
305        operands: operands.into_boxed_slice(),
306        shape,
307        operator_family,
308        effective_operand_offset,
309        effective_shape,
310        effective_operator_family,
311        operand_classes,
312        empty_test_suppressed: false,
313    })
314}
315
316pub(crate) fn build_glued_closing_bracket_operand_span(
317    command: &Command,
318    source: &str,
319) -> Option<Span> {
320    build_glued_closing_bracket_operand_word(command, source)
321        .map(|operand| Span::from_positions(operand.span.start, operand.span.start))
322}
323
324pub(crate) fn build_glued_closing_bracket_insert_offset(
325    command: &Command,
326    source: &str,
327) -> Option<usize> {
328    build_glued_closing_bracket_operand_word(command, source)
329        .map(|operand| operand.span.end.offset - 1)
330}
331
332pub(crate) fn build_missing_space_before_bracket_close_fact(
333    command: &Command,
334    source: &str,
335    locator: Locator<'_>,
336) -> Option<(Span, usize)> {
337    let (diagnostic_offset, insert_offset) =
338        missing_space_before_bracket_close_offsets(command, source)?;
339    let position = locator.position_at_offset(diagnostic_offset)?;
340    Some((Span::from_positions(position, position), insert_offset))
341}
342
343fn missing_space_before_bracket_close_offsets(
344    command: &Command,
345    source: &str,
346) -> Option<(usize, usize)> {
347    match command {
348        Command::Simple(command) => {
349            missing_space_before_simple_bracket_close_offsets(command, source)
350        }
351        Command::Compound(CompoundCommand::Conditional(command)) => {
352            missing_space_before_conditional_bracket_close_offsets(command, source)
353        }
354        Command::Builtin(_)
355        | Command::Decl(_)
356        | Command::Binary(_)
357        | Command::Compound(_)
358        | Command::Function(_)
359        | Command::AnonymousFunction(_) => None,
360    }
361}
362
363fn missing_space_before_simple_bracket_close_offsets(
364    command: &SimpleCommand,
365    source: &str,
366) -> Option<(usize, usize)> {
367    let name_text = command.name.span.slice(source);
368    if !name_text.starts_with('[') {
369        return None;
370    }
371
372    if let Some(offsets) = missing_space_before_bracket_close_word_offsets(&command.name, source) {
373        return Some(offsets);
374    }
375
376    let (last_arg, leading_args) = command.args.split_last()?;
377    if matches!(last_arg.span.slice(source), "]" | "]]") {
378        return None;
379    }
380    if leading_args
381        .iter()
382        .any(|arg| matches!(arg.span.slice(source), "]" | "]]"))
383    {
384        return None;
385    }
386
387    if name_text == "[" && simple_command_args_form_glued_unary_test(&command.args, source) {
388        return None;
389    }
390
391    for arg in &command.args {
392        if let Some(offsets) = missing_space_before_bracket_close_word_offsets(arg, source) {
393            return Some(offsets);
394        }
395    }
396
397    None
398}
399
400fn missing_space_before_conditional_bracket_close_offsets(
401    command: &ConditionalCommand,
402    source: &str,
403) -> Option<(usize, usize)> {
404    let insert_offset = command.right_bracket_span.start.offset;
405    let previous = source[..insert_offset].chars().next_back()?;
406    if previous.is_whitespace()
407        || (previous == ')'
408            && conditional_expression_ends_with_parenthesized_group(
409                &command.expression,
410                insert_offset,
411            ))
412    {
413        return None;
414    }
415
416    Some((command.right_bracket_span.end.offset, insert_offset))
417}
418
419fn conditional_expression_ends_with_parenthesized_group(
420    mut expression: &ConditionalExpr,
421    end_offset: usize,
422) -> bool {
423    loop {
424        match expression {
425            ConditionalExpr::Parenthesized(parenthesized) => {
426                return parenthesized.right_paren_span.end.offset == end_offset;
427            }
428            ConditionalExpr::Binary(binary) => {
429                expression = &binary.right;
430            }
431            ConditionalExpr::Unary(unary) => {
432                expression = &unary.expr;
433            }
434            ConditionalExpr::Word(_)
435            | ConditionalExpr::Pattern(_)
436            | ConditionalExpr::Regex(_)
437            | ConditionalExpr::VarRef(_) => return false,
438        }
439    }
440}
441
442fn missing_space_before_bracket_close_word_offsets(
443    word: &Word,
444    source: &str,
445) -> Option<(usize, usize)> {
446    let text = word.span.slice(source);
447    if matches!(text, "[" | "[[" | "]" | "]]") || !text.ends_with(']') {
448        return None;
449    }
450
451    let close_run_len = unescaped_final_closing_bracket_len(text)?;
452    let insert_offset = word.span.end.offset.checked_sub(close_run_len)?;
453    Some((word.span.end.offset, insert_offset))
454}
455
456fn unescaped_final_closing_bracket_len(text: &str) -> Option<usize> {
457    let bytes = text.as_bytes();
458    if bytes.last() != Some(&b']') {
459        return None;
460    }
461
462    let mut slash_start = bytes.len() - 1;
463    while slash_start > 0 && bytes[slash_start - 1] == b'\\' {
464        slash_start -= 1;
465    }
466    (bytes.len() - 1 - slash_start)
467        .is_multiple_of(2)
468        .then_some(1)
469}
470
471pub(crate) fn build_jammed_test_bracket_fact(
472    command: &Command,
473    source: &str,
474) -> Option<(Span, usize)> {
475    let Command::Simple(command) = command else {
476        return None;
477    };
478    let name_text = command.name.span.slice(source);
479    let bracket_len = if name_text.starts_with("[[") {
480        "[[".len()
481    } else if name_text.starts_with('[') {
482        "[".len()
483    } else {
484        return None;
485    };
486    if name_text.len() == bracket_len {
487        return None;
488    }
489
490    let start = command.name.span.start;
491    let end = Position {
492        offset: start.offset + bracket_len,
493        line: start.line,
494        column: start.column + bracket_len,
495    };
496    Some((
497        Span::from_positions(start, end),
498        command.name.span.start.offset + bracket_len,
499    ))
500}
501
502pub(crate) fn build_glued_closing_bracket_operand_word<'a>(
503    command: &'a Command,
504    source: &str,
505) -> Option<&'a Word> {
506    let Command::Simple(command) = command else {
507        return None;
508    };
509    if static_word_text(&command.name, source).as_deref() != Some("[") {
510        return None;
511    }
512
513    let args = command.args.iter().collect::<Vec<_>>();
514    let last = args.last()?;
515    let text = last.span.slice(source);
516    if text == "]" || !text.ends_with(']') || text.ends_with("\\]") {
517        return None;
518    }
519
520    glued_closing_bracket_unary_operand(&args, source)
521}
522
523pub(crate) fn glued_closing_bracket_unary_operand<'a>(
524    args: &[&'a Word],
525    source: &str,
526) -> Option<&'a Word> {
527    let [first, second] = args else {
528        let [bang, operator, operand] = args else {
529            return None;
530        };
531        return (bang.span.slice(source) == "!"
532            && simple_test_is_unary_operator(operator.span.slice(source))
533            && word_has_glued_closing_bracket_operand(operand, source))
534        .then_some(*operand);
535    };
536
537    (simple_test_is_unary_operator(first.span.slice(source))
538        && word_has_glued_closing_bracket_operand(second, source))
539    .then_some(*second)
540}
541
542fn simple_command_args_form_glued_unary_test(args: &[Word], source: &str) -> bool {
543    match args {
544        [first, second] => {
545            simple_test_is_unary_operator(first.span.slice(source))
546                && word_has_glued_closing_bracket_operand(second, source)
547        }
548        [bang, operator, operand] => {
549            bang.span.slice(source) == "!"
550                && simple_test_is_unary_operator(operator.span.slice(source))
551                && word_has_glued_closing_bracket_operand(operand, source)
552        }
553        _ => false,
554    }
555}
556
557fn word_has_glued_closing_bracket_operand(word: &Word, source: &str) -> bool {
558    word.span
559        .slice(source)
560        .strip_suffix(']')
561        .is_some_and(|prefix| !prefix.is_empty())
562}
563
564pub(crate) fn simple_test_shape(operand_count: usize) -> SimpleTestShape {
565    match operand_count {
566        0 => SimpleTestShape::Empty,
567        1 => SimpleTestShape::Truthy,
568        2 => SimpleTestShape::Unary,
569        3 => SimpleTestShape::Binary,
570        _ => SimpleTestShape::Other,
571    }
572}
573
574pub(crate) fn simple_test_effective_operand_offset(operands: &[&Word], source: &str) -> usize {
575    if operands
576        .first()
577        .and_then(|word| static_word_text(word, source))
578        .as_deref()
579        != Some("!")
580    {
581        return 0;
582    }
583
584    match operands.len() {
585        0 | 1 => 0,
586        3 if operands
587            .get(1)
588            .and_then(|word| static_word_text(word, source))
589            .as_deref()
590            .is_some_and(simple_test_is_binary_operator) =>
591        {
592            0
593        }
594        _ => 1,
595    }
596}
597
598pub(crate) fn simple_test_is_binary_operator(operator: &str) -> bool {
599    matches!(
600        operator,
601        "=" | "=="
602            | "!="
603            | "<"
604            | ">"
605            | "-eq"
606            | "-ne"
607            | "-gt"
608            | "-ge"
609            | "-lt"
610            | "-le"
611            | "-nt"
612            | "-ot"
613            | "-ef"
614            | "-a"
615            | "-o"
616    )
617}
618
619pub(crate) fn simple_test_is_unary_operator(operator: &str) -> bool {
620    matches!(
621        operator,
622        "-e" | "-a"
623            | "-f"
624            | "-d"
625            | "-c"
626            | "-b"
627            | "-p"
628            | "-S"
629            | "-L"
630            | "-h"
631            | "-k"
632            | "-g"
633            | "-u"
634            | "-G"
635            | "-O"
636            | "-N"
637            | "-r"
638            | "-w"
639            | "-x"
640            | "-s"
641            | "-t"
642            | "-z"
643            | "-n"
644            | "-o"
645            | "-v"
646            | "-R"
647    )
648}
649
650pub(crate) fn simple_test_operator_family(
651    operands: &[&Word],
652    shape: SimpleTestShape,
653    source: &str,
654) -> SimpleTestOperatorFamily {
655    match shape {
656        SimpleTestShape::Unary => operands
657            .first()
658            .and_then(|word| static_word_text(word, source))
659            .as_deref()
660            .map_or(
661                SimpleTestOperatorFamily::Other,
662                simple_test_unary_operator_family,
663            ),
664        SimpleTestShape::Binary => operands
665            .get(1)
666            .and_then(|word| static_word_text(word, source))
667            .as_deref()
668            .map_or(
669                SimpleTestOperatorFamily::Other,
670                simple_test_binary_operator_family,
671            ),
672        _ => SimpleTestOperatorFamily::Other,
673    }
674}
675
676pub(crate) fn simple_test_unary_operator_family(operator: &str) -> SimpleTestOperatorFamily {
677    if matches!(operator, "-n" | "-z") {
678        SimpleTestOperatorFamily::StringUnary
679    } else {
680        SimpleTestOperatorFamily::Other
681    }
682}
683
684pub(crate) fn simple_test_binary_operator_family(operator: &str) -> SimpleTestOperatorFamily {
685    if matches!(operator, "=" | "==" | "!=" | "<" | ">") {
686        SimpleTestOperatorFamily::StringBinary
687    } else {
688        SimpleTestOperatorFamily::Other
689    }
690}
691
692pub(crate) fn simple_test_expressions<'a>(
693    simple_test: &'a SimpleTestFact<'a>,
694    source: &str,
695) -> Vec<SimpleTestExpression<'a>> {
696    let operands = simple_test.effective_operands();
697    let mut expressions = Vec::new();
698    let mut segment_start = 0;
699
700    for index in 0..=operands.len() {
701        let is_connector = index < operands.len()
702            && simple_test_effective_operand_text(simple_test, index, source)
703                .as_deref()
704                .is_some_and(simple_test_is_logical_connector);
705        let splits_segment = is_connector
706            && simple_test_segment_is_expression(simple_test, segment_start, index, source);
707        if !splits_segment && index != operands.len() {
708            continue;
709        }
710
711        if let Some(expression) =
712            parse_simple_test_expression_segment(simple_test, segment_start, index, source)
713        {
714            expressions.push(expression);
715        }
716
717        segment_start = index + 1;
718    }
719
720    expressions
721}
722
723pub(crate) fn simple_test_operator_expression_operand_words<'a>(
724    simple_test: &SimpleTestFact<'a>,
725    source: &str,
726) -> Vec<&'a Word> {
727    let operands = simple_test.effective_operands();
728    let mut expression_operands = Vec::new();
729    let mut segment_start = 0;
730
731    for index in 0..=operands.len() {
732        let is_connector = index < operands.len()
733            && simple_test_effective_operand_text(simple_test, index, source)
734                .as_deref()
735                .is_some_and(simple_test_is_logical_connector);
736        let splits_segment = is_connector
737            && simple_test_segment_is_expression(simple_test, segment_start, index, source);
738        if !splits_segment && index != operands.len() {
739            continue;
740        }
741
742        collect_simple_test_operator_expression_operand_words(
743            simple_test,
744            segment_start,
745            index,
746            source,
747            &mut expression_operands,
748        );
749
750        segment_start = index + 1;
751    }
752
753    expression_operands
754}
755
756pub(crate) fn simple_test_numeric_binary_expression_operand_words<'a>(
757    simple_test: &SimpleTestFact<'a>,
758    source: &str,
759) -> Vec<&'a Word> {
760    let operands = simple_test.effective_operands();
761    let mut expression_operands = Vec::new();
762    let mut segment_start = 0;
763
764    for index in 0..=operands.len() {
765        let is_connector = index < operands.len()
766            && simple_test_effective_operand_text(simple_test, index, source)
767                .as_deref()
768                .is_some_and(simple_test_is_logical_connector);
769        let splits_segment = is_connector
770            && simple_test_segment_is_expression(simple_test, segment_start, index, source);
771        if !splits_segment && index != operands.len() {
772            continue;
773        }
774
775        collect_simple_test_numeric_binary_expression_operand_words(
776            simple_test,
777            segment_start,
778            index,
779            source,
780            &mut expression_operands,
781        );
782
783        segment_start = index + 1;
784    }
785
786    expression_operands
787}
788
789pub(crate) fn collect_simple_test_operator_expression_operand_words<'a>(
790    simple_test: &SimpleTestFact<'a>,
791    start: usize,
792    end: usize,
793    source: &str,
794    expression_operands: &mut Vec<&'a Word>,
795) {
796    if start >= end {
797        return;
798    }
799
800    let segment = &simple_test.effective_operands()[start..end];
801    let mut expression_start = 0;
802    while expression_start + 1 < segment.len()
803        && simple_test_effective_operand_text(simple_test, start + expression_start, source)
804            .as_deref()
805            == Some("!")
806    {
807        expression_start += 1;
808    }
809
810    let expression = &segment[expression_start..];
811    match expression {
812        [operator, operand]
813            if simple_test_effective_operand_text(
814                simple_test,
815                start + expression_start,
816                source,
817            )
818            .as_deref()
819            .is_some_and(simple_test_is_unary_operator) =>
820        {
821            expression_operands.push(operand);
822        }
823        [left, operator, right]
824            if simple_test_effective_operand_text(
825                simple_test,
826                start + expression_start + 1,
827                source,
828            )
829            .as_deref()
830            .is_some_and(simple_test_is_nonlogical_binary_operator) =>
831        {
832            expression_operands.push(left);
833            expression_operands.push(right);
834        }
835        [..] => {}
836    }
837}
838
839pub(crate) fn collect_simple_test_numeric_binary_expression_operand_words<'a>(
840    simple_test: &SimpleTestFact<'a>,
841    start: usize,
842    end: usize,
843    source: &str,
844    expression_operands: &mut Vec<&'a Word>,
845) {
846    if start >= end {
847        return;
848    }
849
850    let segment = &simple_test.effective_operands()[start..end];
851    let mut expression_start = 0;
852    while expression_start + 1 < segment.len()
853        && simple_test_effective_operand_text(simple_test, start + expression_start, source)
854            .as_deref()
855            == Some("!")
856    {
857        expression_start += 1;
858    }
859
860    let expression = &segment[expression_start..];
861    match expression {
862        [left, operator, right]
863            if simple_test_effective_operand_text(
864                simple_test,
865                start + expression_start + 1,
866                source,
867            )
868            .as_deref()
869            .is_some_and(simple_test_is_numeric_binary_operator) =>
870        {
871            expression_operands.push(left);
872            expression_operands.push(right);
873        }
874        [..] => {}
875    }
876}
877
878pub(crate) fn simple_test_segment_is_expression(
879    simple_test: &SimpleTestFact<'_>,
880    start: usize,
881    end: usize,
882    source: &str,
883) -> bool {
884    if start >= end {
885        return false;
886    }
887
888    let segment = &simple_test.effective_operands()[start..end];
889    let mut expression_start = 0;
890    while expression_start + 1 < segment.len()
891        && simple_test_effective_operand_text(simple_test, start + expression_start, source)
892            .as_deref()
893            == Some("!")
894    {
895        expression_start += 1;
896    }
897
898    let expression_len = segment.len() - expression_start;
899    match expression_len {
900        1 => {
901            let word = segment[expression_start];
902            !(simple_test_effective_operand_text(simple_test, start + expression_start, source)
903                .as_deref()
904                == Some("!")
905                && classify_word(word, source).quote == WordQuote::Unquoted)
906        }
907        2 => simple_test_effective_operand_text(simple_test, start + expression_start, source)
908            .as_deref()
909            .is_some_and(simple_test_is_unary_operator),
910        3 => simple_test_effective_operand_text(simple_test, start + expression_start + 1, source)
911            .as_deref()
912            .is_some_and(simple_test_is_binary_operator),
913        _ => false,
914    }
915}
916
917pub(crate) fn parse_simple_test_expression_segment<'a>(
918    simple_test: &'a SimpleTestFact<'a>,
919    start: usize,
920    end: usize,
921    source: &str,
922) -> Option<SimpleTestExpression<'a>> {
923    if start >= end {
924        return None;
925    }
926
927    let segment = &simple_test.effective_operands()[start..end];
928    let mut expression_start = 0;
929    while expression_start + 1 < segment.len()
930        && simple_test_effective_operand_text(simple_test, start + expression_start, source)
931            .as_deref()
932            == Some("!")
933    {
934        expression_start += 1;
935    }
936
937    let expression = &segment[expression_start..];
938    match expression {
939        [word] => Some(SimpleTestExpression::Truthy(word)),
940        [operator, operand]
941            if simple_test_effective_operand_text(
942                simple_test,
943                start + expression_start,
944                source,
945            )
946            .as_deref()
947            .is_some_and(simple_test_is_string_unary_operator) =>
948        {
949            Some(SimpleTestExpression::StringUnary { operator, operand })
950        }
951        [left, operator, right]
952            if simple_test_effective_operand_text(
953                simple_test,
954                start + expression_start + 1,
955                source,
956            )
957            .as_deref()
958            .is_some_and(simple_test_is_string_binary_operator) =>
959        {
960            Some(SimpleTestExpression::StringBinary {
961                left,
962                operator,
963                right,
964            })
965        }
966        [] | [_, _, ..] => None,
967    }
968}
969
970pub(crate) fn simple_test_effective_operand_text(
971    simple_test: &SimpleTestFact<'_>,
972    index: usize,
973    source: &str,
974) -> Option<String> {
975    let word = simple_test.effective_operands().get(index).copied()?;
976    let class = simple_test.effective_operand_class(index)?;
977    if !class.is_fixed_literal() {
978        return None;
979    }
980
981    let mut text = static_word_text(word, source)?;
982    if classify_word(word, source).quote == WordQuote::Unquoted {
983        match text.as_ref() {
984            r"\(" => text = "(".into(),
985            r"\)" => text = ")".into(),
986            r"\!" => text = "!".into(),
987            _ => {}
988        }
989    }
990
991    Some(text.into_owned())
992}
993
994pub(crate) fn simple_test_effective_unquoted_operand_text(
995    simple_test: &SimpleTestFact<'_>,
996    index: usize,
997    source: &str,
998) -> Option<String> {
999    let word = simple_test.effective_operands().get(index).copied()?;
1000    (classify_word(word, source).quote == WordQuote::Unquoted)
1001        .then(|| simple_test_effective_operand_text(simple_test, index, source))
1002        .flatten()
1003}
1004
1005pub(crate) fn simple_test_is_logical_connector(text: &str) -> bool {
1006    matches!(text, "-a" | "-o")
1007}
1008
1009pub(crate) fn simple_test_is_logical_negation(
1010    simple_test: &SimpleTestFact<'_>,
1011    index: usize,
1012    source: &str,
1013) -> bool {
1014    if simple_test_effective_unquoted_operand_text(simple_test, index, source).as_deref()
1015        == Some("!")
1016    {
1017        return true;
1018    }
1019
1020    simple_test_effective_operand_text(simple_test, index, source).as_deref() == Some("!")
1021        && simple_test_effective_operand_text(simple_test, index + 1, source).as_deref()
1022            != Some("(")
1023}
1024
1025pub(crate) fn simple_test_is_string_unary_operator(text: &str) -> bool {
1026    matches!(text, "-n" | "-z")
1027}
1028
1029pub(crate) fn simple_test_is_string_binary_operator(text: &str) -> bool {
1030    matches!(text, "=" | "==" | "!=" | "<" | ">")
1031}
1032
1033pub(crate) fn simple_test_parse_logical_expression(
1034    simple_test: &SimpleTestFact<'_>,
1035    start: usize,
1036    source: &str,
1037) -> Option<(usize, Vec<Span>)> {
1038    let (mut index, mut spans) = simple_test_parse_logical_term(simple_test, start, source)?;
1039
1040    loop {
1041        let Some(connector_span) = simple_test_logical_connector_span(simple_test, index, source)
1042        else {
1043            break;
1044        };
1045        let (next_index, next_spans) =
1046            simple_test_parse_logical_term(simple_test, index + 1, source)?;
1047        spans.push(connector_span);
1048        spans.extend(next_spans);
1049        index = next_index;
1050    }
1051
1052    Some((index, spans))
1053}
1054
1055pub(crate) fn simple_test_parse_logical_term(
1056    simple_test: &SimpleTestFact<'_>,
1057    start: usize,
1058    source: &str,
1059) -> Option<(usize, Vec<Span>)> {
1060    let mut index = start;
1061
1062    while index + 1 < simple_test.effective_operands().len()
1063        && simple_test_is_logical_negation(simple_test, index, source)
1064    {
1065        index += 1;
1066    }
1067
1068    simple_test_parse_logical_primary(simple_test, index, source)
1069}
1070
1071pub(crate) fn simple_test_parse_logical_primary(
1072    simple_test: &SimpleTestFact<'_>,
1073    index: usize,
1074    source: &str,
1075) -> Option<(usize, Vec<Span>)> {
1076    if simple_test_effective_operand_text(simple_test, index, source).as_deref() == Some("(")
1077        && let Some((end, spans)) =
1078            simple_test_parse_logical_expression(simple_test, index + 1, source)
1079        && simple_test_effective_operand_text(simple_test, end, source).as_deref() == Some(")")
1080    {
1081        return Some((end + 1, spans));
1082    }
1083
1084    if simple_test_effective_operand_text(simple_test, index, source)
1085        .as_deref()
1086        .is_some_and(simple_test_is_unary_operator)
1087        && simple_test.effective_operands().get(index + 1).is_some()
1088    {
1089        return Some((index + 2, Vec::new()));
1090    }
1091
1092    if simple_test_effective_operand_text(simple_test, index + 1, source)
1093        .as_deref()
1094        .is_some_and(simple_test_is_nonlogical_binary_operator)
1095        && simple_test.effective_operands().get(index + 2).is_some()
1096    {
1097        return Some((index + 3, Vec::new()));
1098    }
1099
1100    simple_test
1101        .effective_operands()
1102        .get(index)
1103        .map(|_| (index + 1, Vec::new()))
1104}
1105
1106pub(crate) fn simple_test_logical_connector_span(
1107    simple_test: &SimpleTestFact<'_>,
1108    index: usize,
1109    source: &str,
1110) -> Option<Span> {
1111    let word = simple_test.effective_operands().get(index).copied()?;
1112    simple_test_effective_unquoted_operand_text(simple_test, index, source)
1113        .as_deref()
1114        .is_some_and(simple_test_is_logical_connector)
1115        .then_some(word.span)
1116}
1117
1118pub(crate) fn simple_test_is_nonlogical_binary_operator(text: &str) -> bool {
1119    simple_test_is_binary_operator(text) && !simple_test_is_logical_connector(text)
1120}
1121
1122pub(crate) fn simple_test_is_numeric_binary_operator(text: &str) -> bool {
1123    matches!(text, "-eq" | "-ne" | "-gt" | "-ge" | "-lt" | "-le")
1124}
1125
1126pub(crate) fn build_single_test_subshell_spans<'a>(
1127    commands: &[CommandFact<'a>],
1128    command_fact_indices_by_id: &[Option<usize>],
1129    command_ids_by_span: &CommandLookupIndex,
1130    command_child_index: &CommandChildIndex,
1131    locator: Locator<'_>,
1132) -> Vec<Span> {
1133    let command_relationships = CommandRelationshipContext::new(
1134        commands,
1135        command_fact_indices_by_id,
1136        command_ids_by_span,
1137        command_child_index,
1138    );
1139    commands
1140        .iter()
1141        .filter_map(|fact| single_test_subshell_span(fact, command_relationships, locator))
1142        .collect()
1143}
1144
1145pub(crate) fn build_subshell_test_group_spans<'a>(
1146    commands: &[CommandFact<'a>],
1147    command_fact_indices_by_id: &[Option<usize>],
1148    command_ids_by_span: &CommandLookupIndex,
1149    command_child_index: &CommandChildIndex,
1150    locator: Locator<'_>,
1151) -> Vec<Span> {
1152    let command_relationships = CommandRelationshipContext::new(
1153        commands,
1154        command_fact_indices_by_id,
1155        command_ids_by_span,
1156        command_child_index,
1157    );
1158    commands
1159        .iter()
1160        .filter_map(|fact| subshell_test_group_span(fact, command_relationships, locator))
1161        .collect()
1162}
1163
1164pub(crate) fn single_test_subshell_span<'a>(
1165    fact: &CommandFact<'a>,
1166    command_relationships: CommandRelationshipContext<'_, 'a>,
1167    locator: Locator<'_>,
1168) -> Option<Span> {
1169    let condition = match fact.command() {
1170        Command::Compound(CompoundCommand::If(command)) => &command.condition,
1171        Command::Compound(CompoundCommand::While(command)) => &command.condition,
1172        Command::Compound(CompoundCommand::Until(command)) => &command.condition,
1173        _ => return None,
1174    };
1175
1176    let [stmt] = condition.as_slice() else {
1177        return None;
1178    };
1179
1180    let condition_fact = command_relationships.child_or_lookup_fact(fact.id(), stmt)?;
1181    let Command::Compound(CompoundCommand::Subshell(body)) = condition_fact.command() else {
1182        return None;
1183    };
1184
1185    let [body_stmt] = body.as_slice() else {
1186        return None;
1187    };
1188
1189    let body_fact = command_relationships.child_or_lookup_fact(condition_fact.id(), body_stmt)?;
1190    let simple_test = is_test_like_command(body_fact);
1191    if stmt.negated && !simple_test {
1192        return None;
1193    }
1194
1195    if !simple_test && !is_test_condition_fact(body_fact, command_relationships) {
1196        return None;
1197    }
1198
1199    Some(subshell_anchor_span(
1200        condition_fact.span_in_source(locator.source()),
1201        locator,
1202    ))
1203}
1204
1205pub(crate) fn is_test_like_command(fact: &CommandFact<'_>) -> bool {
1206    fact.wrappers()
1207        .iter()
1208        .all(|wrapper| matches!(wrapper, WrapperKind::Command | WrapperKind::Builtin))
1209        && (fact.effective_name_is("test")
1210            || fact.effective_name_is("[")
1211            || matches!(
1212                fact.command(),
1213                Command::Compound(CompoundCommand::Conditional(_))
1214            ))
1215}
1216
1217pub(crate) fn is_test_condition_fact<'a>(
1218    fact: &CommandFact<'a>,
1219    command_relationships: CommandRelationshipContext<'_, 'a>,
1220) -> bool {
1221    match fact.command() {
1222        Command::Binary(binary) => {
1223            let Some(chain) = BinaryCommandChain::logical_list(binary) else {
1224                return false;
1225            };
1226            let mut all_segments_are_tests = true;
1227            chain.visit_segments(|stmt| {
1228                all_segments_are_tests &= command_relationships
1229                    .child_or_lookup_fact(fact.id(), stmt)
1230                    .is_some_and(|fact| is_test_condition_fact(fact, command_relationships));
1231            });
1232            all_segments_are_tests
1233        }
1234        _ => is_test_like_command(fact),
1235    }
1236}
1237
1238pub(crate) fn subshell_test_group_span<'a>(
1239    fact: &CommandFact<'a>,
1240    command_relationships: CommandRelationshipContext<'_, 'a>,
1241    locator: Locator<'_>,
1242) -> Option<Span> {
1243    let Command::Compound(CompoundCommand::Subshell(body)) = fact.command() else {
1244        return None;
1245    };
1246
1247    if !subshell_body_contains_grouped_tests(body, fact.id(), command_relationships) {
1248        return None;
1249    }
1250
1251    Some(subshell_anchor_span(fact.span(), locator))
1252}
1253
1254pub(crate) fn subshell_body_contains_grouped_tests<'a>(
1255    body: &StmtSeq,
1256    parent_id: CommandId,
1257    command_relationships: CommandRelationshipContext<'_, 'a>,
1258) -> bool {
1259    subshell_body_analysis(body, parent_id, command_relationships)
1260        .is_some_and(|analysis| analysis.has_grouping && analysis.test_count > 0)
1261}
1262
1263#[derive(Debug, Default, Clone, Copy)]
1264pub(crate) struct GroupedTestAnalysis {
1265    test_count: usize,
1266    has_grouping: bool,
1267}
1268
1269pub(crate) fn subshell_stmt_analysis<'a>(
1270    stmt: &Stmt,
1271    parent_id: CommandId,
1272    command_relationships: CommandRelationshipContext<'_, 'a>,
1273) -> Option<GroupedTestAnalysis> {
1274    let fact = command_relationships.child_or_lookup_fact(parent_id, stmt)?;
1275    subshell_command_analysis(fact, command_relationships)
1276}
1277
1278pub(crate) fn subshell_command_analysis<'a>(
1279    fact: &CommandFact<'a>,
1280    command_relationships: CommandRelationshipContext<'_, 'a>,
1281) -> Option<GroupedTestAnalysis> {
1282    match fact.command() {
1283        Command::Simple(_)
1284        | Command::Builtin(_)
1285        | Command::Compound(CompoundCommand::Conditional(_)) => {
1286            if is_test_like_command(fact) {
1287                return Some(GroupedTestAnalysis {
1288                    test_count: 1,
1289                    has_grouping: false,
1290                });
1291            }
1292            None
1293        }
1294        Command::Compound(CompoundCommand::BraceGroup(body)) => {
1295            let inner = subshell_body_analysis(body, fact.id(), command_relationships)?;
1296            Some(GroupedTestAnalysis {
1297                test_count: inner.test_count,
1298                has_grouping: true,
1299            })
1300        }
1301        Command::Compound(CompoundCommand::Subshell(body)) => {
1302            let inner = subshell_body_analysis(body, fact.id(), command_relationships)?;
1303            Some(GroupedTestAnalysis {
1304                test_count: inner.test_count,
1305                has_grouping: inner.has_grouping,
1306            })
1307        }
1308        Command::Binary(binary) => {
1309            let chain = BinaryCommandChain::logical_list(binary)?;
1310            let mut analysis = Some(GroupedTestAnalysis::default());
1311            chain.visit_segments(|stmt| {
1312                if analysis.is_none() {
1313                    return;
1314                }
1315                let Some(segment) = subshell_stmt_analysis(stmt, fact.id(), command_relationships)
1316                else {
1317                    analysis = None;
1318                    return;
1319                };
1320                if let Some(current) = analysis.as_mut() {
1321                    current.test_count += segment.test_count;
1322                    current.has_grouping = true;
1323                }
1324            });
1325            analysis
1326        }
1327        _ => None,
1328    }
1329}
1330
1331pub(crate) fn subshell_body_analysis<'a>(
1332    body: &StmtSeq,
1333    parent_id: CommandId,
1334    command_relationships: CommandRelationshipContext<'_, 'a>,
1335) -> Option<GroupedTestAnalysis> {
1336    let mut analysis = GroupedTestAnalysis::default();
1337
1338    if body.stmts.len() > 1 {
1339        analysis.has_grouping = true;
1340    }
1341
1342    for stmt in &body.stmts {
1343        let stmt_analysis = subshell_stmt_analysis(stmt, parent_id, command_relationships)?;
1344        analysis.test_count += stmt_analysis.test_count;
1345        analysis.has_grouping |= stmt_analysis.has_grouping;
1346    }
1347
1348    Some(analysis)
1349}
1350
1351pub(crate) fn subshell_anchor_span(span: Span, locator: Locator<'_>) -> Span {
1352    let source = locator.source();
1353    let Some(open_paren_offset) = leading_open_paren_offset(source, span.start.offset) else {
1354        return span;
1355    };
1356
1357    let end_offset = trim_trailing_whitespace_offset(source, span.end.offset);
1358    Span::from_positions(
1359        locator.position_at_offset_unchecked(open_paren_offset),
1360        locator.position_at_offset_unchecked(end_offset),
1361    )
1362}
1363
1364pub(crate) fn leading_open_paren_offset(source: &str, start_offset: usize) -> Option<usize> {
1365    for (offset, ch) in source[..start_offset].char_indices().rev() {
1366        if ch.is_whitespace() {
1367            continue;
1368        }
1369
1370        if ch == '(' {
1371            return Some(offset);
1372        }
1373
1374        return None;
1375    }
1376
1377    None
1378}
1379
1380pub(crate) fn trim_trailing_whitespace_offset(source: &str, end_offset: usize) -> usize {
1381    for (offset, ch) in source[..end_offset].char_indices().rev() {
1382        if ch.is_whitespace() {
1383            continue;
1384        }
1385
1386        return offset + ch.len_utf8();
1387    }
1388
1389    end_offset
1390}
1391
1392pub(crate) fn collect_short_circuit_operators(
1393    command: &BinaryCommand,
1394    operators: &mut Vec<ListOperatorFact>,
1395) {
1396    let Some(chain) = BinaryCommandChain::logical_list(command) else {
1397        return;
1398    };
1399    chain.visit_nodes(|command| {
1400        operators.push(ListOperatorFact {
1401            op: command.op,
1402            span: command.op_span,
1403        });
1404    });
1405}
1406
1407pub(crate) fn mixed_short_circuit_operator_span(operators: &[ListOperatorFact]) -> Option<Span> {
1408    let mut previous = operators.first()?;
1409
1410    for operator in operators.iter().skip(1) {
1411        if previous.op() != operator.op() {
1412            return Some(previous.span());
1413        }
1414
1415        previous = operator;
1416    }
1417
1418    None
1419}
1420
1421pub(crate) fn word_contains_find_substitution<'a>(
1422    word: &'a Word,
1423    commands: &[CommandFact<'a>],
1424    command_fact_indices_by_id: &[Option<usize>],
1425    command_ids_by_span: &CommandLookupIndex,
1426) -> bool {
1427    word.parts.iter().any(|part| {
1428        part_contains_find_substitution(
1429            &part.kind,
1430            commands,
1431            command_fact_indices_by_id,
1432            command_ids_by_span,
1433        )
1434    })
1435}
1436
1437pub(crate) fn word_contains_line_oriented_substitution<'a>(
1438    word: &'a Word,
1439    commands: &[CommandFact<'a>],
1440    command_fact_indices_by_id: &[Option<usize>],
1441    command_ids_by_span: &CommandLookupIndex,
1442) -> bool {
1443    word.parts.iter().any(|part| {
1444        part_contains_line_oriented_substitution(
1445            &part.kind,
1446            commands,
1447            command_fact_indices_by_id,
1448            command_ids_by_span,
1449        )
1450    })
1451}
1452
1453pub(crate) fn word_contains_command_substitution_named<'a>(
1454    word: &'a Word,
1455    name: &str,
1456    commands: &[CommandFact<'a>],
1457    command_fact_indices_by_id: &[Option<usize>],
1458    command_ids_by_span: &CommandLookupIndex,
1459) -> bool {
1460    word.parts.iter().any(|part| {
1461        part_contains_command_substitution_named(
1462            &part.kind,
1463            name,
1464            commands,
1465            command_fact_indices_by_id,
1466            command_ids_by_span,
1467        )
1468    })
1469}
1470
1471pub(crate) fn part_contains_command_substitution_named<'a>(
1472    part: &WordPart,
1473    name: &str,
1474    commands: &[CommandFact<'a>],
1475    command_fact_indices_by_id: &[Option<usize>],
1476    command_ids_by_span: &CommandLookupIndex,
1477) -> bool {
1478    match part {
1479        WordPart::DoubleQuoted { parts, .. } => parts.iter().any(|part| {
1480            part_contains_command_substitution_named(
1481                &part.kind,
1482                name,
1483                commands,
1484                command_fact_indices_by_id,
1485                command_ids_by_span,
1486            )
1487        }),
1488        WordPart::CommandSubstitution { body, .. } | WordPart::ProcessSubstitution { body, .. } => {
1489            substitution_body_is_simple_command_named(
1490                body,
1491                name,
1492                commands,
1493                command_fact_indices_by_id,
1494                command_ids_by_span,
1495            )
1496        }
1497        _ => false,
1498    }
1499}
1500
1501pub(crate) fn part_contains_line_oriented_substitution<'a>(
1502    part: &WordPart,
1503    commands: &[CommandFact<'a>],
1504    command_fact_indices_by_id: &[Option<usize>],
1505    command_ids_by_span: &CommandLookupIndex,
1506) -> bool {
1507    match part {
1508        WordPart::DoubleQuoted { parts, .. } => parts.iter().any(|part| {
1509            part_contains_line_oriented_substitution(
1510                &part.kind,
1511                commands,
1512                command_fact_indices_by_id,
1513                command_ids_by_span,
1514            )
1515        }),
1516        WordPart::CommandSubstitution { body, .. } => substitution_body_is_line_oriented(
1517            body,
1518            commands,
1519            command_fact_indices_by_id,
1520            command_ids_by_span,
1521        ),
1522        _ => false,
1523    }
1524}
1525
1526pub(crate) fn part_contains_find_substitution<'a>(
1527    part: &WordPart,
1528    commands: &[CommandFact<'a>],
1529    command_fact_indices_by_id: &[Option<usize>],
1530    command_ids_by_span: &CommandLookupIndex,
1531) -> bool {
1532    match part {
1533        WordPart::DoubleQuoted { parts, .. } => parts.iter().any(|part| {
1534            part_contains_find_substitution(
1535                &part.kind,
1536                commands,
1537                command_fact_indices_by_id,
1538                command_ids_by_span,
1539            )
1540        }),
1541        WordPart::CommandSubstitution { body, .. } | WordPart::ProcessSubstitution { body, .. } => {
1542            substitution_body_is_find(
1543                body,
1544                commands,
1545                command_fact_indices_by_id,
1546                command_ids_by_span,
1547            )
1548        }
1549        _ => false,
1550    }
1551}
1552
1553pub(crate) fn substitution_body_is_find<'a>(
1554    body: &'a StmtSeq,
1555    commands: &[CommandFact<'a>],
1556    command_fact_indices_by_id: &[Option<usize>],
1557    command_ids_by_span: &CommandLookupIndex,
1558) -> bool {
1559    matches!(
1560        body.as_slice(),
1561        [stmt] if stmt_invokes_find(stmt, commands, command_fact_indices_by_id, command_ids_by_span)
1562    )
1563}
1564
1565pub(crate) fn substitution_body_is_line_oriented<'a>(
1566    body: &'a StmtSeq,
1567    commands: &[CommandFact<'a>],
1568    command_fact_indices_by_id: &[Option<usize>],
1569    command_ids_by_span: &CommandLookupIndex,
1570) -> bool {
1571    matches!(
1572        body.as_slice(),
1573        [stmt] if command_is_line_oriented_substitution_body(
1574            &stmt.command,
1575            commands,
1576            command_fact_indices_by_id,
1577            command_ids_by_span,
1578        )
1579    )
1580}
1581
1582pub(crate) fn substitution_body_is_pgrep_lookup<'a>(
1583    body: &'a StmtSeq,
1584    commands: CommandFacts<'_, 'a>,
1585    command_ids_by_span: &CommandLookupIndex,
1586) -> bool {
1587    matches!(
1588        body.as_slice(),
1589        [stmt]
1590            if stmt_effective_or_literal_basename_is_ref(stmt, "pgrep", commands, command_ids_by_span)
1591    )
1592}
1593
1594pub(crate) fn substitution_body_is_seq_utility<'a>(
1595    body: &'a StmtSeq,
1596    commands: CommandFacts<'_, 'a>,
1597    command_ids_by_span: &CommandLookupIndex,
1598) -> bool {
1599    matches!(
1600        body.as_slice(),
1601        [stmt] if stmt_effective_or_literal_basename_is_ref(stmt, "seq", commands, command_ids_by_span)
1602    )
1603}
1604
1605pub(crate) fn substitution_body_is_simple_command_named<'a>(
1606    body: &'a StmtSeq,
1607    name: &str,
1608    commands: &[CommandFact<'a>],
1609    command_fact_indices_by_id: &[Option<usize>],
1610    command_ids_by_span: &CommandLookupIndex,
1611) -> bool {
1612    matches!(
1613        body.as_slice(),
1614        [stmt] if stmt_literal_name_is(stmt, name, commands, command_fact_indices_by_id, command_ids_by_span)
1615    )
1616}
1617
1618pub(crate) fn command_is_line_oriented_substitution_body<'a>(
1619    command: &'a Command,
1620    commands: &[CommandFact<'a>],
1621    command_fact_indices_by_id: &[Option<usize>],
1622    command_ids_by_span: &CommandLookupIndex,
1623) -> bool {
1624    match command {
1625        Command::Simple(_) | Command::Builtin(_) | Command::Decl(_) => command_fact_for_command(
1626            command,
1627            commands,
1628            command_fact_indices_by_id,
1629            command_ids_by_span,
1630        )
1631        .is_some_and(command_fact_is_line_oriented_utility),
1632        Command::Binary(binary) => match binary.op {
1633            BinaryOp::Pipe | BinaryOp::PipeAll => {
1634                command_is_line_oriented_substitution_body(
1635                    &binary.left.command,
1636                    commands,
1637                    command_fact_indices_by_id,
1638                    command_ids_by_span,
1639                ) && command_is_line_oriented_substitution_body(
1640                    &binary.right.command,
1641                    commands,
1642                    command_fact_indices_by_id,
1643                    command_ids_by_span,
1644                )
1645            }
1646            BinaryOp::And | BinaryOp::Or => false,
1647        },
1648        Command::Compound(CompoundCommand::Time(command)) => {
1649            command.command.as_deref().is_some_and(|stmt| {
1650                command_is_line_oriented_substitution_body(
1651                    &stmt.command,
1652                    commands,
1653                    command_fact_indices_by_id,
1654                    command_ids_by_span,
1655                )
1656            })
1657        }
1658        Command::Compound(
1659            CompoundCommand::If(_)
1660            | CompoundCommand::For(_)
1661            | CompoundCommand::Repeat(_)
1662            | CompoundCommand::Foreach(_)
1663            | CompoundCommand::ArithmeticFor(_)
1664            | CompoundCommand::While(_)
1665            | CompoundCommand::Until(_)
1666            | CompoundCommand::Case(_)
1667            | CompoundCommand::Select(_)
1668            | CompoundCommand::Arithmetic(_)
1669            | CompoundCommand::Conditional(_)
1670            | CompoundCommand::Subshell(_)
1671            | CompoundCommand::BraceGroup(_)
1672            | CompoundCommand::Coproc(_)
1673            | CompoundCommand::Always(_),
1674        ) => false,
1675        Command::Function(_) | Command::AnonymousFunction(_) => false,
1676    }
1677}
1678
1679pub(crate) fn command_fact_is_line_oriented_utility(fact: &CommandFact<'_>) -> bool {
1680    if command_fact_invokes_find(fact) {
1681        return false;
1682    }
1683
1684    fact.effective_or_literal_name().is_some_and(|name| {
1685        matches!(
1686            name.rsplit('/').next().unwrap_or(name),
1687            "cat" | "grep" | "egrep" | "fgrep" | "awk" | "sed" | "cut" | "sort"
1688        )
1689    })
1690}
1691
1692pub(crate) fn stmt_invokes_find<'a>(
1693    stmt: &'a Stmt,
1694    commands: &[CommandFact<'a>],
1695    command_fact_indices_by_id: &[Option<usize>],
1696    command_ids_by_span: &CommandLookupIndex,
1697) -> bool {
1698    command_fact_for_stmt(
1699        stmt,
1700        commands,
1701        command_fact_indices_by_id,
1702        command_ids_by_span,
1703    )
1704    .is_some_and(command_fact_invokes_find)
1705}
1706
1707pub(crate) fn command_fact_invokes_find(fact: &CommandFact<'_>) -> bool {
1708    command_name_matches_basename(fact.literal_name(), "find")
1709        || command_name_matches_basename(fact.effective_name(), "find")
1710        || fact.has_wrapper(WrapperKind::FindExec)
1711        || fact.has_wrapper(WrapperKind::FindExecDir)
1712}
1713
1714pub(crate) fn command_name_matches_basename(name: Option<&str>, expected: &str) -> bool {
1715    name.is_some_and(|name| name == expected || name.rsplit('/').next() == Some(expected))
1716}
1717
1718pub(crate) fn stmt_literal_name_is<'a>(
1719    stmt: &'a Stmt,
1720    name: &str,
1721    commands: &[CommandFact<'a>],
1722    command_fact_indices_by_id: &[Option<usize>],
1723    command_ids_by_span: &CommandLookupIndex,
1724) -> bool {
1725    command_fact_for_stmt(
1726        stmt,
1727        commands,
1728        command_fact_indices_by_id,
1729        command_ids_by_span,
1730    )
1731    .and_then(CommandFact::literal_name)
1732        == Some(name)
1733}
1734
1735pub(crate) fn stmt_effective_or_literal_basename_is_ref<'a>(
1736    stmt: &'a Stmt,
1737    name: &str,
1738    commands: CommandFacts<'_, 'a>,
1739    command_ids_by_span: &CommandLookupIndex,
1740) -> bool {
1741    command_fact_ref_for_stmt(stmt, commands, command_ids_by_span)
1742        .and_then(CommandFactRef::effective_or_literal_name)
1743        .is_some_and(|command_name| {
1744            command_name == name || command_name.rsplit('/').next() == Some(name)
1745        })
1746}