Skip to main content

shuck_linter/facts/
lists.rs

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}