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}