1use super::*;
2
3#[derive(Debug, Clone, Copy)]
4pub struct ListOperatorFact {
5 pub(crate) op: BinaryOp,
6 pub(crate) span: Span,
7}
8
9impl ListOperatorFact {
10 pub fn op(&self) -> BinaryOp {
11 self.op
12 }
13
14 pub fn span(&self) -> Span {
15 self.span
16 }
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ListSegmentKind {
21 Condition,
22 AssignmentOnly,
23 Other,
24}
25
26#[derive(Debug, Clone)]
27pub struct ListSegmentFact<'a> {
28 command_id: CommandId,
29 span: Span,
30 kind: ListSegmentKind,
31 assignment_target: Option<&'a str>,
32 assignment_span: Option<Span>,
33 assignment_is_declaration: bool,
34}
35
36impl<'a> ListSegmentFact<'a> {
37 pub fn command_id(&self) -> CommandId {
38 self.command_id
39 }
40
41 pub fn span(&self) -> Span {
42 self.span
43 }
44
45 pub fn kind(&self) -> ListSegmentKind {
46 self.kind
47 }
48
49 pub fn assignment_target(&self) -> Option<&'a str> {
50 self.assignment_target
51 }
52
53 pub fn assignment_span(&self) -> Option<Span> {
54 self.assignment_span
55 }
56
57 pub fn assignment_is_declaration(&self) -> bool {
58 self.assignment_is_declaration
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum MixedShortCircuitKind {
64 TestChain,
65 AssignmentTernary,
66 Fallthrough,
67}
68
69#[derive(Debug, Clone)]
70pub struct ListFact<'a> {
71 key: FactSpan,
72 command: &'a BinaryCommand,
73 operators: Box<[ListOperatorFact]>,
74 segments: Box<[ListSegmentFact<'a>]>,
75 mixed_short_circuit_span: Option<Span>,
76 mixed_short_circuit_kind: Option<MixedShortCircuitKind>,
77}
78
79impl<'a> ListFact<'a> {
80 pub fn key(&self) -> FactSpan {
81 self.key
82 }
83
84 pub fn command(&self) -> &'a BinaryCommand {
85 self.command
86 }
87
88 pub fn span(&self) -> Span {
89 self.command.span
90 }
91
92 pub fn operators(&self) -> &[ListOperatorFact] {
93 &self.operators
94 }
95
96 pub fn segments(&self) -> &[ListSegmentFact<'a>] {
97 &self.segments
98 }
99
100 pub fn mixed_short_circuit_span(&self) -> Option<Span> {
101 self.mixed_short_circuit_span
102 }
103
104 pub fn mixed_short_circuit_kind(&self) -> Option<MixedShortCircuitKind> {
105 self.mixed_short_circuit_kind
106 }
107}
108
109#[cfg_attr(shuck_profiling, inline(never))]
110pub(crate) fn build_list_facts<'a>(
111 commands: &[CommandFact<'a>],
112 command_fact_indices_by_id: &[Option<usize>],
113 command_ids_by_span: &CommandLookupIndex,
114 command_child_index: &CommandChildIndex,
115 source: &str,
116) -> Vec<ListFact<'a>> {
117 let command_relationships = CommandRelationshipContext::new(
118 commands,
119 command_fact_indices_by_id,
120 command_ids_by_span,
121 command_child_index,
122 );
123 let mut nested_list_commands = FxHashSet::default();
124
125 for fact in commands {
126 let Command::Binary(command) = fact.command() else {
127 continue;
128 };
129 if BinaryCommandChain::logical_list(command).is_none() {
130 continue;
131 }
132
133 record_nested_list_command(
134 &command.left,
135 fact.id(),
136 command_relationships,
137 &mut nested_list_commands,
138 );
139 record_nested_list_command(
140 &command.right,
141 fact.id(),
142 command_relationships,
143 &mut nested_list_commands,
144 );
145 }
146
147 commands
148 .iter()
149 .filter_map(|fact| {
150 let Command::Binary(command) = fact.command() else {
151 return None;
152 };
153 if BinaryCommandChain::logical_list(command).is_none()
154 || nested_list_commands.contains(&fact.id())
155 {
156 return None;
157 }
158
159 let mut operators = Vec::new();
160 collect_short_circuit_operators(command, &mut operators);
161 let segments =
162 build_list_segment_facts(command, command_relationships, fact.id(), source)?;
163 let mixed_short_circuit_span = mixed_short_circuit_operator_span(&operators);
164 let mixed_short_circuit_kind = mixed_short_circuit_span
165 .map(|_| classify_mixed_short_circuit_kind(&segments, &operators));
166
167 Some(ListFact {
168 key: fact.key(),
169 command,
170 operators: operators.into_boxed_slice(),
171 segments,
172 mixed_short_circuit_span,
173 mixed_short_circuit_kind,
174 })
175 })
176 .collect()
177}
178
179pub(crate) fn record_nested_list_command(
180 stmt: &Stmt,
181 parent_id: CommandId,
182 command_relationships: CommandRelationshipContext<'_, '_>,
183 nested_list_commands: &mut FxHashSet<CommandId>,
184) {
185 let Command::Binary(child) = &stmt.command else {
186 return;
187 };
188 if BinaryCommandChain::logical_list(child).is_some()
189 && let Some(child) = command_relationships.child_or_lookup_fact(parent_id, stmt)
190 {
191 nested_list_commands.insert(child.id());
192 }
193}
194
195pub(crate) fn build_list_segment_facts<'a>(
196 command: &BinaryCommand,
197 command_relationships: CommandRelationshipContext<'_, 'a>,
198 parent_id: CommandId,
199 source: &str,
200) -> Option<Box<[ListSegmentFact<'a>]>> {
201 let mut segments = Vec::new();
202 collect_list_segment_facts(
203 command,
204 command_relationships,
205 parent_id,
206 source,
207 &mut segments,
208 )?;
209 Some(segments.into_boxed_slice())
210}
211
212pub(crate) fn collect_list_segment_facts<'a>(
213 command: &BinaryCommand,
214 command_relationships: CommandRelationshipContext<'_, 'a>,
215 parent_id: CommandId,
216 source: &str,
217 segments: &mut Vec<ListSegmentFact<'a>>,
218) -> Option<()> {
219 let chain = BinaryCommandChain::logical_list(command)?;
220 let mut ok = true;
221 chain.visit_segments(|stmt| {
222 if ok {
223 ok = push_list_stmt_segment_fact(
224 stmt,
225 command_relationships,
226 parent_id,
227 source,
228 segments,
229 )
230 .is_some();
231 }
232 });
233 ok.then_some(())
234}
235
236pub(crate) fn push_list_stmt_segment_fact<'a>(
237 stmt: &Stmt,
238 command_relationships: CommandRelationshipContext<'_, 'a>,
239 parent_id: CommandId,
240 source: &str,
241 segments: &mut Vec<ListSegmentFact<'a>>,
242) -> Option<()> {
243 let fact = command_relationships.child_or_lookup_fact(parent_id, stmt)?;
244 let id = fact.id();
245 let assignment_info = list_segment_assignment_info(fact);
246 let assignment_target = assignment_info.as_ref().map(|info| info.target);
247 let assignment_is_declaration = assignment_info
248 .as_ref()
249 .is_some_and(|info| info.is_declaration);
250
251 segments.push(ListSegmentFact {
252 command_id: id,
253 span: fact.span_in_source(source),
254 kind: list_segment_kind(fact),
255 assignment_target,
256 assignment_span: assignment_info.map(|info| info.span),
257 assignment_is_declaration,
258 });
259 Some(())
260}
261
262pub(crate) fn list_segment_kind(fact: &CommandFact<'_>) -> ListSegmentKind {
263 if list_segment_is_condition(fact) {
264 ListSegmentKind::Condition
265 } else if list_segment_assignment_target(fact).is_some() {
266 ListSegmentKind::AssignmentOnly
267 } else {
268 ListSegmentKind::Other
269 }
270}
271
272pub(crate) fn list_segment_is_condition(fact: &CommandFact<'_>) -> bool {
273 fact.simple_test().is_some()
274 || fact.conditional().is_some()
275 || matches!(fact.effective_or_literal_name(), Some("true" | "false"))
276}
277
278pub(crate) fn list_segment_assignment_target<'a>(fact: &CommandFact<'a>) -> Option<&'a str> {
279 list_segment_assignment_info(fact).map(|info| info.target)
280}
281
282#[derive(Clone, Copy)]
283pub(crate) struct ListSegmentAssignmentInfo<'a> {
284 target: &'a str,
285 span: Span,
286 is_declaration: bool,
287}
288
289pub(crate) fn list_segment_assignment_info<'a>(
290 fact: &CommandFact<'a>,
291) -> Option<ListSegmentAssignmentInfo<'a>> {
292 match fact.command() {
293 Command::Simple(command)
294 if command.args.is_empty()
295 && !command.assignments.is_empty()
296 && fact.literal_name() == Some("") =>
297 {
298 single_assignment_info(&command.assignments)
299 }
300 Command::Decl(command) => declaration_assignment_info(command),
301 _ => None,
302 }
303}
304
305pub(crate) fn single_assignment_info<'a>(
306 assignments: &'a [Assignment],
307) -> Option<ListSegmentAssignmentInfo<'a>> {
308 (assignments.len() == 1).then(|| ListSegmentAssignmentInfo {
309 target: assignments[0].target.name.as_str(),
310 span: assignments[0].span,
311 is_declaration: false,
312 })
313}
314
315pub(crate) fn declaration_assignment_info<'a>(
316 command: &'a DeclClause,
317) -> Option<ListSegmentAssignmentInfo<'a>> {
318 if !command.assignments.is_empty() {
319 return None;
320 }
321
322 let mut assignment = None;
323
324 for operand in &command.operands {
325 match operand {
326 DeclOperand::Flag(_) => {}
327 DeclOperand::Assignment(candidate) => {
328 if assignment.replace(candidate).is_some() {
329 return None;
330 }
331 }
332 DeclOperand::Name(_) | DeclOperand::Dynamic(_) => return None,
333 }
334 }
335
336 assignment.map(|assignment| ListSegmentAssignmentInfo {
337 target: assignment.target.name.as_str(),
338 span: assignment.span,
339 is_declaration: true,
340 })
341}
342
343pub(crate) fn classify_mixed_short_circuit_kind(
344 segments: &[ListSegmentFact<'_>],
345 operators: &[ListOperatorFact],
346) -> MixedShortCircuitKind {
347 if segments
348 .iter()
349 .all(|segment| segment.kind() == ListSegmentKind::Condition)
350 {
351 MixedShortCircuitKind::TestChain
352 } else if matches_assignment_ternary(segments, operators) {
353 MixedShortCircuitKind::AssignmentTernary
354 } else {
355 MixedShortCircuitKind::Fallthrough
356 }
357}
358
359pub(crate) fn matches_assignment_ternary(
360 segments: &[ListSegmentFact<'_>],
361 operators: &[ListOperatorFact],
362) -> bool {
363 let [condition, then_branch, else_branch] = segments else {
364 return false;
365 };
366 let [first_operator, second_operator] = operators else {
367 return false;
368 };
369
370 condition.kind() == ListSegmentKind::Condition
371 && first_operator.op() == BinaryOp::And
372 && second_operator.op() == BinaryOp::Or
373 && then_branch.kind() == ListSegmentKind::AssignmentOnly
374 && else_branch.kind() == ListSegmentKind::AssignmentOnly
375 && then_branch.assignment_target().is_some()
376 && then_branch.assignment_target() == else_branch.assignment_target()
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
380struct NegativeComparison {
381 runtime_operand: String,
382 literal_operand: String,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386enum NumericLiteralNormalization {
387 None,
388 Decimal,
389 ShellArithmetic,
390}
391
392#[cfg_attr(shuck_profiling, inline(never))]
393pub(super) fn build_tautology_chain_operator_spans<'a>(
394 commands: &[CommandFact<'a>],
395 command_fact_indices_by_id: &[Option<usize>],
396 lists: &[ListFact<'a>],
397 source: &str,
398) -> Vec<Span> {
399 let mut spans = Vec::new();
400
401 for list in lists {
402 if !list
403 .operators()
404 .iter()
405 .all(|operator| operator.op() == BinaryOp::Or)
406 {
407 continue;
408 }
409
410 let mut prior_comparisons: Vec<NegativeComparison> = Vec::new();
411 for (segment_index, segment) in list.segments().iter().enumerate() {
412 let command = command_fact(commands, command_fact_indices_by_id, segment.command_id());
413 let Some(comparison) = negative_comparison_for_tautology(command, source) else {
414 continue;
415 };
416
417 if prior_comparisons.iter().any(|prior| {
418 prior.runtime_operand == comparison.runtime_operand
419 && prior.literal_operand != comparison.literal_operand
420 }) && let Some(operator) = segment_index
421 .checked_sub(1)
422 .and_then(|index| list.operators().get(index))
423 {
424 spans.push(operator.span());
425 }
426
427 prior_comparisons.push(comparison);
428 }
429 }
430
431 sort_and_dedup_spans(&mut spans);
432 spans
433}
434
435fn negative_comparison_for_tautology(
436 command: &CommandFact<'_>,
437 source: &str,
438) -> Option<NegativeComparison> {
439 command
440 .simple_test()
441 .and_then(|simple_test| simple_test_negative_comparison(simple_test, source))
442 .or_else(|| {
443 command
444 .conditional()
445 .and_then(|conditional| conditional_negative_comparison(conditional, source))
446 })
447}
448
449fn simple_test_negative_comparison(
450 simple_test: &SimpleTestFact<'_>,
451 source: &str,
452) -> Option<NegativeComparison> {
453 if simple_test.syntax() != SimpleTestSyntax::Bracket
454 || simple_test.effective_operands().len() != 3
455 {
456 return None;
457 }
458
459 let operator = simple_test_effective_operand_text(simple_test, 1, source)?;
460 if !matches!(operator.as_str(), "!=" | "-ne") {
461 return None;
462 }
463 let numeric_normalization = if operator == "-ne" {
464 NumericLiteralNormalization::Decimal
465 } else {
466 NumericLiteralNormalization::None
467 };
468
469 negative_comparison_from_words(
470 simple_test.effective_operands()[0],
471 simple_test.effective_operand_class(0)?,
472 simple_test.effective_operands()[2],
473 simple_test.effective_operand_class(2)?,
474 source,
475 false,
476 numeric_normalization,
477 )
478}
479
480fn conditional_negative_comparison(
481 conditional: &ConditionalFact<'_>,
482 source: &str,
483) -> Option<NegativeComparison> {
484 let ConditionalNodeFact::Binary(binary) = conditional.root() else {
485 return None;
486 };
487
488 let (skip_pattern_literals, numeric_normalization) = match binary.op() {
489 ConditionalBinaryOp::PatternNe => (true, NumericLiteralNormalization::None),
490 ConditionalBinaryOp::ArithmeticNe => (false, NumericLiteralNormalization::ShellArithmetic),
491 _ => return None,
492 };
493
494 negative_comparison_from_operands(
495 binary.left(),
496 binary.right(),
497 source,
498 skip_pattern_literals,
499 numeric_normalization,
500 )
501}
502
503fn negative_comparison_from_operands(
504 left: ConditionalOperandFact<'_>,
505 right: ConditionalOperandFact<'_>,
506 source: &str,
507 skip_pattern_literals: bool,
508 numeric_normalization: NumericLiteralNormalization,
509) -> Option<NegativeComparison> {
510 match (left.class(), right.class()) {
511 (TestOperandClass::FixedLiteral, TestOperandClass::RuntimeSensitive) => {
512 let literal = conditional_operand_literal_text(left, source, skip_pattern_literals)?;
513 Some(NegativeComparison {
514 runtime_operand: conditional_operand_source_text(right, source).to_owned(),
515 literal_operand: normalize_tautology_literal(literal, numeric_normalization)?,
516 })
517 }
518 (TestOperandClass::RuntimeSensitive, TestOperandClass::FixedLiteral) => {
519 let literal = conditional_operand_literal_text(right, source, skip_pattern_literals)?;
520 Some(NegativeComparison {
521 runtime_operand: conditional_operand_source_text(left, source).to_owned(),
522 literal_operand: normalize_tautology_literal(literal, numeric_normalization)?,
523 })
524 }
525 _ => None,
526 }
527}
528
529fn negative_comparison_from_words(
530 left: &Word,
531 left_class: TestOperandClass,
532 right: &Word,
533 right_class: TestOperandClass,
534 source: &str,
535 skip_pattern_literals: bool,
536 numeric_normalization: NumericLiteralNormalization,
537) -> Option<NegativeComparison> {
538 match (left_class, right_class) {
539 (TestOperandClass::FixedLiteral, TestOperandClass::RuntimeSensitive) => {
540 let literal = word_literal_text(left, source, skip_pattern_literals)?;
541 Some(NegativeComparison {
542 runtime_operand: right.span.slice(source).to_owned(),
543 literal_operand: normalize_tautology_literal(literal, numeric_normalization)?,
544 })
545 }
546 (TestOperandClass::RuntimeSensitive, TestOperandClass::FixedLiteral) => {
547 let literal = word_literal_text(right, source, skip_pattern_literals)?;
548 Some(NegativeComparison {
549 runtime_operand: left.span.slice(source).to_owned(),
550 literal_operand: normalize_tautology_literal(literal, numeric_normalization)?,
551 })
552 }
553 _ => None,
554 }
555}
556
557fn conditional_operand_literal_text(
558 operand: ConditionalOperandFact<'_>,
559 source: &str,
560 skip_pattern_literals: bool,
561) -> Option<String> {
562 let text = if let Some(word) = operand.word() {
563 word_literal_text(word, source, skip_pattern_literals)?
564 } else {
565 let text = operand.expression().span().slice(source).to_owned();
566 if skip_pattern_literals && looks_like_conditional_pattern_literal(&text) {
567 return None;
568 }
569 text
570 };
571
572 Some(text)
573}
574
575fn conditional_operand_source_text<'a>(
576 operand: ConditionalOperandFact<'_>,
577 source: &'a str,
578) -> &'a str {
579 operand.expression().span().slice(source)
580}
581
582fn word_literal_text(word: &Word, source: &str, skip_pattern_literals: bool) -> Option<String> {
583 let text = static_word_text(word, source)?;
584 if skip_pattern_literals && word_has_unquoted_conditional_pattern_literal(word, source) {
585 return None;
586 }
587
588 Some(text.into_owned())
589}
590
591fn normalize_tautology_literal(
592 literal: String,
593 numeric_normalization: NumericLiteralNormalization,
594) -> Option<String> {
595 match numeric_normalization {
596 NumericLiteralNormalization::None => Some(literal),
597 NumericLiteralNormalization::Decimal => normalize_decimal_integer_literal(&literal),
598 NumericLiteralNormalization::ShellArithmetic => {
599 normalize_shell_arithmetic_integer_literal(&literal)
600 }
601 }
602}
603
604fn normalize_decimal_integer_literal(text: &str) -> Option<String> {
605 let (negative, unsigned) = if let Some(digits) = text.strip_prefix('-') {
606 (true, digits)
607 } else if let Some(digits) = text.strip_prefix('+') {
608 (false, digits)
609 } else {
610 (false, text)
611 };
612
613 if unsigned.is_empty() || !unsigned.bytes().all(|byte| byte.is_ascii_digit()) {
614 return None;
615 }
616
617 let value = parse_unsigned_digits(unsigned, 10)?;
618 if negative {
619 value.checked_neg().map(|value| value.to_string())
620 } else {
621 Some(value.to_string())
622 }
623}
624
625fn normalize_shell_arithmetic_integer_literal(text: &str) -> Option<String> {
626 parse_shell_arithmetic_integer_value(text).map(|value| value.to_string())
627}
628
629fn parse_shell_arithmetic_integer_value(text: &str) -> Option<i128> {
630 let (negative, unsigned) = if let Some(digits) = text.strip_prefix('-') {
631 (true, digits)
632 } else if let Some(digits) = text.strip_prefix('+') {
633 (false, digits)
634 } else {
635 (false, text)
636 };
637 if unsigned.is_empty() {
638 return None;
639 }
640
641 let value = if let Some((base_text, digits)) = unsigned.split_once('#') {
642 let base = parse_unsigned_digits(base_text, 10)?;
643 if !(2..=64).contains(&base) || digits.is_empty() {
644 return None;
645 }
646 parse_arithmetic_digits(digits, base as u32)?
647 } else if let Some(digits) = unsigned
648 .strip_prefix("0x")
649 .or_else(|| unsigned.strip_prefix("0X"))
650 {
651 if digits.is_empty() {
652 return None;
653 }
654 parse_arithmetic_digits(digits, 16)?
655 } else if unsigned.len() > 1 && unsigned.starts_with('0') {
656 parse_arithmetic_digits(unsigned, 8)?
657 } else {
658 parse_unsigned_digits(unsigned, 10)?
659 };
660
661 if negative {
662 value.checked_neg()
663 } else {
664 Some(value)
665 }
666}
667
668fn parse_unsigned_digits(digits: &str, radix: u32) -> Option<i128> {
669 if digits.is_empty() {
670 return None;
671 }
672 parse_arithmetic_digits(digits, radix)
673}
674
675fn parse_arithmetic_digits(digits: &str, radix: u32) -> Option<i128> {
676 let mut value = 0i128;
677 let radix_value = i128::from(radix);
678 for byte in digits.bytes() {
679 let digit = i128::from(arithmetic_digit_value(byte, radix)?);
680 if digit >= radix_value {
681 return None;
682 }
683 value = value.checked_mul(radix_value)?.checked_add(digit)?;
684 }
685 Some(value)
686}
687
688fn arithmetic_digit_value(byte: u8, radix: u32) -> Option<u32> {
689 match byte {
690 b'0'..=b'9' => Some(u32::from(byte - b'0')),
691 b'a'..=b'z' => Some(u32::from(byte - b'a') + 10),
692 b'A'..=b'Z' if radix <= 36 => Some(u32::from(byte - b'A') + 10),
693 b'A'..=b'Z' => Some(u32::from(byte - b'A') + 36),
694 b'@' => Some(62),
695 b'_' => Some(63),
696 _ => None,
697 }
698}
699
700fn word_has_unquoted_conditional_pattern_literal(word: &Word, source: &str) -> bool {
701 !word_spans::word_unquoted_glob_pattern_spans(word, source).is_empty()
702 || word_has_unquoted_conditional_extglob_operator(&word.parts, source, false)
703}
704
705fn word_has_unquoted_conditional_extglob_operator(
706 parts: &[WordPartNode],
707 source: &str,
708 in_double_quotes: bool,
709) -> bool {
710 let mut literal_run_start = None::<usize>;
711 let mut literal_run_end = None::<usize>;
712
713 let flush_literal_run = |literal_run_start: &mut Option<usize>,
714 literal_run_end: &mut Option<usize>| {
715 let Some(start_index) = literal_run_start.take() else {
716 return false;
717 };
718 let Some(end_index) = literal_run_end.take() else {
719 return false;
720 };
721 let start = parts[start_index].span.start;
722 let end = parts[end_index].span.end;
723 unquoted_text_has_conditional_extglob_operator(
724 Span::from_positions(start, end).slice(source),
725 )
726 };
727
728 for (index, part) in parts.iter().enumerate() {
729 match &part.kind {
730 WordPart::DoubleQuoted { parts, .. } => {
731 if flush_literal_run(&mut literal_run_start, &mut literal_run_end) {
732 return true;
733 }
734 if word_has_unquoted_conditional_extglob_operator(parts, source, true) {
735 return true;
736 }
737 }
738 WordPart::Literal(_) if !in_double_quotes => {
739 literal_run_start.get_or_insert(index);
740 literal_run_end = Some(index);
741 }
742 WordPart::Literal(_)
743 | WordPart::CommandSubstitution { .. }
744 | WordPart::ProcessSubstitution { .. }
745 | WordPart::SingleQuoted { .. }
746 | WordPart::Variable(_)
747 | WordPart::ArithmeticExpansion { .. }
748 | WordPart::Parameter(_)
749 | WordPart::ParameterExpansion { .. }
750 | WordPart::Length(_)
751 | WordPart::ArrayAccess(_)
752 | WordPart::ArrayLength(_)
753 | WordPart::ArrayIndices(_)
754 | WordPart::Substring { .. }
755 | WordPart::ArraySlice { .. }
756 | WordPart::IndirectExpansion { .. }
757 | WordPart::PrefixMatch { .. }
758 | WordPart::Transformation { .. }
759 | WordPart::ZshQualifiedGlob(_) => {
760 if flush_literal_run(&mut literal_run_start, &mut literal_run_end) {
761 return true;
762 }
763 }
764 }
765 }
766
767 flush_literal_run(&mut literal_run_start, &mut literal_run_end)
768}
769
770fn unquoted_text_has_conditional_extglob_operator(text: &str) -> bool {
771 let bytes = text.as_bytes();
772 let mut index = 0usize;
773 while index + 1 < bytes.len() {
774 if matches!(bytes[index], b'@' | b'!' | b'+')
775 && bytes[index + 1] == b'('
776 && !byte_is_escaped(bytes, index)
777 {
778 return true;
779 }
780 index += 1;
781 }
782 false
783}
784
785fn byte_is_escaped(bytes: &[u8], index: usize) -> bool {
786 let mut slash_count = 0usize;
787 let mut cursor = index;
788 while cursor > 0 && bytes[cursor - 1] == b'\\' {
789 slash_count += 1;
790 cursor -= 1;
791 }
792 slash_count % 2 == 1
793}
794
795fn looks_like_conditional_pattern_literal(text: &str) -> bool {
796 let mut chars = text.chars().peekable();
797 while let Some(ch) = chars.next() {
798 if matches!(ch, '*' | '?' | '[' | ']') {
799 return true;
800 }
801
802 if matches!(ch, '@' | '!' | '+') && matches!(chars.peek(), Some('(')) {
803 return true;
804 }
805 }
806
807 false
808}