Skip to main content

shuck_linter/facts/
surface.rs

1use super::*;
2use crate::facts::words::{
3    WordSubtreeVisitor, WordTraversalContext, WordTraversalState, walk_word_subtree,
4};
5use shuck_ast::{
6    HeredocBody, HeredocBodyPart, HeredocBodyPartNode, PatternGroupKind, PatternPartNode, Position,
7};
8
9#[derive(Debug)]
10pub struct SingleQuotedFragmentFact {
11    span: Span,
12    diagnostic_span: Span,
13    dollar_quoted: bool,
14    command_name: Option<Box<str>>,
15    assignment_target: Option<Box<str>>,
16    shell_dialect: shuck_parser::ShellDialect,
17    variable_set_operand: bool,
18    literal_expansion_exempt: bool,
19    literal_backslash_in_single_quotes_span: Option<Span>,
20}
21
22impl SingleQuotedFragmentFact {
23    pub fn span(&self) -> Span {
24        self.span
25    }
26
27    pub fn diagnostic_span(&self) -> Span {
28        self.diagnostic_span
29    }
30
31    pub fn dollar_quoted(&self) -> bool {
32        self.dollar_quoted
33    }
34
35    pub fn command_name(&self) -> Option<&str> {
36        self.command_name.as_deref()
37    }
38
39    pub fn assignment_target(&self) -> Option<&str> {
40        self.assignment_target.as_deref()
41    }
42
43    pub fn shell_dialect(&self) -> shuck_parser::ShellDialect {
44        self.shell_dialect
45    }
46
47    pub fn variable_set_operand(&self) -> bool {
48        self.variable_set_operand
49    }
50
51    pub fn literal_expansion_exempt(&self) -> bool {
52        self.literal_expansion_exempt
53    }
54
55    pub fn literal_backslash_in_single_quotes_span(&self) -> Option<Span> {
56        self.literal_backslash_in_single_quotes_span
57    }
58}
59
60#[derive(Debug, Clone, Copy)]
61pub struct DollarDoubleQuotedFragmentFact {
62    span: Span,
63}
64
65impl DollarDoubleQuotedFragmentFact {
66    pub fn span(&self) -> Span {
67        self.span
68    }
69}
70
71#[derive(Debug, Clone)]
72pub struct OpenDoubleQuoteFragmentFact {
73    span: Span,
74    replacement_span: Span,
75    replacement: Box<str>,
76}
77
78impl OpenDoubleQuoteFragmentFact {
79    pub fn span(&self) -> Span {
80        self.span
81    }
82
83    pub fn replacement_span(&self) -> Span {
84        self.replacement_span
85    }
86
87    pub fn replacement(&self) -> &str {
88        &self.replacement
89    }
90}
91
92#[derive(Debug, Clone)]
93pub struct CasePatternExpansionFact {
94    span: Span,
95    replacement: Box<str>,
96}
97
98impl CasePatternExpansionFact {
99    pub(crate) fn new(span: Span, replacement: Box<str>) -> Self {
100        Self { span, replacement }
101    }
102
103    pub fn span(&self) -> Span {
104        self.span
105    }
106
107    pub fn replacement(&self) -> &str {
108        &self.replacement
109    }
110}
111
112#[derive(Debug, Clone)]
113pub struct SuspectClosingQuoteFragmentFact {
114    span: Span,
115    replacement_span: Span,
116    replacement: Box<str>,
117}
118
119impl SuspectClosingQuoteFragmentFact {
120    pub fn span(&self) -> Span {
121        self.span
122    }
123
124    pub fn replacement_span(&self) -> Span {
125        self.replacement_span
126    }
127
128    pub fn replacement(&self) -> &str {
129        &self.replacement
130    }
131}
132
133#[derive(Debug, Clone, Copy)]
134pub struct BacktickFragmentFact {
135    span: Span,
136    empty: bool,
137}
138
139impl BacktickFragmentFact {
140    pub fn span(&self) -> Span {
141        self.span
142    }
143
144    pub fn is_empty(&self) -> bool {
145        self.empty
146    }
147}
148
149#[derive(Debug, Clone, Copy)]
150pub struct LegacyArithmeticFragmentFact {
151    span: Span,
152}
153
154impl LegacyArithmeticFragmentFact {
155    pub fn span(&self) -> Span {
156        self.span
157    }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
161pub enum ArithmeticLiteralKind {
162    ExplicitBasePrefix,
163    LeadingZeroInteger,
164}
165
166#[derive(Debug, Clone, Copy)]
167pub struct ArithmeticLiteralFact {
168    span: Span,
169    kind: ArithmeticLiteralKind,
170    behavior: ArithmeticLiteralBehavior,
171}
172
173impl ArithmeticLiteralFact {
174    pub(crate) fn new(
175        span: Span,
176        kind: ArithmeticLiteralKind,
177        behavior: ArithmeticLiteralBehavior,
178    ) -> Self {
179        Self {
180            span,
181            kind,
182            behavior,
183        }
184    }
185
186    pub fn span(&self) -> Span {
187        self.span
188    }
189
190    pub fn kind(&self) -> ArithmeticLiteralKind {
191        self.kind
192    }
193
194    pub fn behavior(&self) -> ArithmeticLiteralBehavior {
195        self.behavior
196    }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum PositionalParameterFragmentKind {
201    AboveNine,
202    General,
203}
204
205#[derive(Debug, Clone, Copy)]
206pub struct PositionalParameterFragmentFact {
207    span: Span,
208    kind: PositionalParameterFragmentKind,
209    guarded: bool,
210}
211
212impl PositionalParameterFragmentFact {
213    pub fn span(&self) -> Span {
214        self.span
215    }
216
217    pub fn kind(&self) -> PositionalParameterFragmentKind {
218        self.kind
219    }
220
221    pub fn is_above_nine(&self) -> bool {
222        self.kind == PositionalParameterFragmentKind::AboveNine
223    }
224
225    pub fn is_guarded(&self) -> bool {
226        self.guarded
227    }
228}
229
230#[derive(Debug, Clone, Copy)]
231pub struct NestedParameterExpansionFragmentFact {
232    span: Span,
233}
234
235impl NestedParameterExpansionFragmentFact {
236    pub fn span(&self) -> Span {
237        self.span
238    }
239}
240
241#[derive(Debug, Clone, Copy)]
242pub struct IndirectExpansionFragmentFact {
243    span: Span,
244    array_keys: bool,
245}
246
247impl IndirectExpansionFragmentFact {
248    pub fn span(&self) -> Span {
249        self.span
250    }
251
252    pub fn array_keys(&self) -> bool {
253        self.array_keys
254    }
255}
256
257#[derive(Debug, Clone, Copy)]
258pub enum IndexedArrayReferenceFragmentFact {
259    OneBased(IndexedArrayReferenceFragment),
260    ZeroBased(IndexedArrayReferenceFragment),
261    OneBasedWithZeroAlias(IndexedArrayReferenceFragment),
262    Ambiguous(IndexedArrayReferenceFragment),
263}
264
265impl IndexedArrayReferenceFragmentFact {
266    pub(crate) fn new(span: Span, plain: bool) -> Self {
267        Self::Ambiguous(IndexedArrayReferenceFragment { span, plain })
268    }
269
270    pub(crate) fn with_plain(mut self, plain: bool) -> Self {
271        match &mut self {
272            Self::OneBased(fragment)
273            | Self::ZeroBased(fragment)
274            | Self::OneBasedWithZeroAlias(fragment)
275            | Self::Ambiguous(fragment) => fragment.plain |= plain,
276        }
277        self
278    }
279
280    pub(crate) fn with_subscript_index_behavior(self, behavior: SubscriptIndexBehavior) -> Self {
281        let fragment = match self {
282            Self::OneBased(fragment)
283            | Self::ZeroBased(fragment)
284            | Self::OneBasedWithZeroAlias(fragment)
285            | Self::Ambiguous(fragment) => fragment,
286        };
287
288        match behavior {
289            SubscriptIndexBehavior::OneBased => Self::OneBased(fragment),
290            SubscriptIndexBehavior::ZeroBased => Self::ZeroBased(fragment),
291            SubscriptIndexBehavior::OneBasedWithZeroAlias => Self::OneBasedWithZeroAlias(fragment),
292            SubscriptIndexBehavior::Ambiguous => Self::Ambiguous(fragment),
293        }
294    }
295}
296
297#[derive(Debug, Clone, Copy)]
298pub struct IndexedArrayReferenceFragment {
299    span: Span,
300    plain: bool,
301}
302
303impl IndexedArrayReferenceFragment {
304    pub fn span(&self) -> Span {
305        self.span
306    }
307
308    pub fn is_plain(&self) -> bool {
309        self.plain
310    }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum PlainUnindexedArrayReferenceFact {
315    SelectorRequired(SelectorRequiredArrayReference),
316    NativeZshScalar(NativeZshScalarArrayReference),
317    Ambiguous(AmbiguousArrayReference),
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub struct SelectorRequiredArrayReference {
322    reference_id: ReferenceId,
323    diagnostic_span: Span,
324}
325
326impl SelectorRequiredArrayReference {
327    pub(crate) fn new(reference_id: ReferenceId, diagnostic_span: Span) -> Self {
328        Self {
329            reference_id,
330            diagnostic_span,
331        }
332    }
333
334    pub fn reference_id(&self) -> ReferenceId {
335        self.reference_id
336    }
337
338    pub fn diagnostic_span(&self) -> Span {
339        self.diagnostic_span
340    }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub struct NativeZshScalarArrayReference {
345    reference_id: ReferenceId,
346    expansion_span: Span,
347}
348
349impl NativeZshScalarArrayReference {
350    pub(crate) fn new(reference_id: ReferenceId, expansion_span: Span) -> Self {
351        Self {
352            reference_id,
353            expansion_span,
354        }
355    }
356
357    pub fn reference_id(&self) -> ReferenceId {
358        self.reference_id
359    }
360
361    pub fn expansion_span(&self) -> Span {
362        self.expansion_span
363    }
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub struct AmbiguousArrayReference {
368    reference_id: ReferenceId,
369    diagnostic_span: Span,
370}
371
372impl AmbiguousArrayReference {
373    pub(crate) fn new(reference_id: ReferenceId, diagnostic_span: Span) -> Self {
374        Self {
375            reference_id,
376            diagnostic_span,
377        }
378    }
379
380    pub fn reference_id(&self) -> ReferenceId {
381        self.reference_id
382    }
383
384    pub fn diagnostic_span(&self) -> Span {
385        self.diagnostic_span
386    }
387}
388
389#[derive(Debug, Clone, Copy)]
390pub struct ParameterPatternSpecialTargetFragmentFact {
391    span: Span,
392}
393
394impl ParameterPatternSpecialTargetFragmentFact {
395    pub fn span(&self) -> Span {
396        self.span
397    }
398}
399
400#[derive(Debug, Clone, Copy)]
401pub struct ZshParameterIndexFlagFragmentFact {
402    span: Span,
403}
404
405impl ZshParameterIndexFlagFragmentFact {
406    pub fn span(&self) -> Span {
407        self.span
408    }
409}
410
411#[derive(Debug, Clone, Copy)]
412pub struct SubstringExpansionFragmentFact {
413    span: Span,
414}
415
416impl SubstringExpansionFragmentFact {
417    pub fn span(&self) -> Span {
418        self.span
419    }
420}
421
422#[derive(Debug, Clone, Copy)]
423pub struct CaseModificationFragmentFact {
424    span: Span,
425}
426
427impl CaseModificationFragmentFact {
428    pub fn span(&self) -> Span {
429        self.span
430    }
431}
432
433#[derive(Debug, Clone, Copy)]
434pub struct ReplacementExpansionFragmentFact {
435    span: Span,
436}
437
438impl ReplacementExpansionFragmentFact {
439    pub fn span(&self) -> Span {
440        self.span
441    }
442}
443
444#[derive(Debug, Clone, Copy)]
445pub struct PositionalParameterTrimFragmentFact {
446    span: Span,
447}
448
449impl PositionalParameterTrimFragmentFact {
450    pub fn span(&self) -> Span {
451        self.span
452    }
453}
454
455#[derive(Debug, Default)]
456pub(crate) struct SurfaceFragmentFacts {
457    pub(crate) single_quoted: Vec<SingleQuotedFragmentFact>,
458    pub(crate) dollar_double_quoted: Vec<DollarDoubleQuotedFragmentFact>,
459    pub(crate) open_double_quotes: Vec<OpenDoubleQuoteFragmentFact>,
460    pub(crate) suspect_closing_quotes: Vec<SuspectClosingQuoteFragmentFact>,
461    pub(crate) backticks: Vec<BacktickFragmentFact>,
462    pub(crate) legacy_arithmetic: Vec<LegacyArithmeticFragmentFact>,
463    pub(crate) positional_parameters: Vec<PositionalParameterFragmentFact>,
464    pub(crate) positional_parameter_operator_spans: Vec<Span>,
465    pub(crate) unicode_smart_quote_spans: Vec<Span>,
466    pub(crate) pattern_exactly_one_extglob_spans: Vec<Span>,
467    pub(crate) pattern_charclass_spans: Vec<Span>,
468    pub(crate) parameter_pattern_spans: Vec<Span>,
469    pub(crate) nested_pattern_charclass_spans: Vec<Span>,
470    pub(crate) nested_parameter_expansions: Vec<NestedParameterExpansionFragmentFact>,
471    pub(crate) indirect_expansions: Vec<IndirectExpansionFragmentFact>,
472    pub(crate) indexed_array_references: Vec<IndexedArrayReferenceFragmentFact>,
473    pub(crate) plain_unindexed_references: Vec<Span>,
474    pub(crate) parameter_pattern_special_targets: Vec<ParameterPatternSpecialTargetFragmentFact>,
475    pub(crate) zsh_parameter_index_flags: Vec<ZshParameterIndexFlagFragmentFact>,
476    pub(crate) substring_expansions: Vec<SubstringExpansionFragmentFact>,
477    pub(crate) case_modifications: Vec<CaseModificationFragmentFact>,
478    pub(crate) replacement_expansions: Vec<ReplacementExpansionFragmentFact>,
479    pub(crate) positional_parameter_trims: Vec<PositionalParameterTrimFragmentFact>,
480    pub(crate) suppressed_subscript_spans: Vec<Span>,
481    pub(crate) subscript_later_suppression_spans: Vec<Span>,
482    pub(crate) arithmetic_only_suppressed_subscript_spans: Vec<Span>,
483}
484
485#[derive(Debug, Clone, Copy, Default)]
486pub(crate) struct SurfaceScanContext<'a> {
487    command_name: Option<&'a str>,
488    assignment_target: Option<&'a str>,
489    shell_dialect: shuck_parser::ShellDialect,
490    nested_word_command: bool,
491    variable_set_operand: bool,
492    literal_expansion_exempt: bool,
493    guarded_parameter_operand: bool,
494    subscript_suppresses_later_references: bool,
495    collect_open_double_quotes: bool,
496    collect_pattern_charclasses: bool,
497}
498
499impl<'a> SurfaceScanContext<'a> {
500    pub(crate) fn new(
501        command_name: Option<&'a str>,
502        nested_word_command: bool,
503        shell_dialect: shuck_parser::ShellDialect,
504    ) -> Self {
505        Self {
506            command_name,
507            nested_word_command,
508            shell_dialect,
509            subscript_suppresses_later_references: true,
510            collect_open_double_quotes: true,
511            collect_pattern_charclasses: false,
512            ..Self::default()
513        }
514    }
515
516    pub(crate) fn command_name(&self) -> Option<&'a str> {
517        self.command_name
518    }
519
520    pub(crate) fn with_assignment_target(self, assignment_target: &'a str) -> Self {
521        Self {
522            assignment_target: Some(assignment_target),
523            ..self
524        }
525    }
526
527    pub(crate) fn variable_set_operand(self) -> Self {
528        Self {
529            variable_set_operand: true,
530            ..self
531        }
532    }
533
534    pub(crate) fn literal_expansion_exempt(self) -> Self {
535        Self {
536            literal_expansion_exempt: true,
537            ..self
538        }
539    }
540
541    pub(crate) fn guarded_parameter_operand(self) -> Self {
542        Self {
543            guarded_parameter_operand: true,
544            ..self
545        }
546    }
547
548    pub(crate) fn without_open_double_quote_scan(self) -> Self {
549        Self {
550            collect_open_double_quotes: false,
551            ..self
552        }
553    }
554
555    pub(crate) fn without_command_name(self) -> Self {
556        Self {
557            command_name: None,
558            ..self
559        }
560    }
561
562    pub(crate) fn with_pattern_charclass_scan(self) -> Self {
563        Self {
564            collect_pattern_charclasses: true,
565            ..self
566        }
567    }
568}
569
570pub(crate) struct SurfaceFragmentSink<'a> {
571    source: &'a str,
572    facts: SurfaceFragmentFacts,
573    zsh_parameter_index_flag_scratch: Vec<Span>,
574    parameter_special_target_word_scratch: Vec<Span>,
575    open_double_quote_scratch: Vec<(Span, Span)>,
576    split_suspect_closing_quote_scratch: Vec<Span>,
577}
578
579impl<'a> SurfaceFragmentSink<'a> {
580    pub(crate) fn new(source: &'a str) -> Self {
581        Self {
582            source,
583            facts: SurfaceFragmentFacts::default(),
584            zsh_parameter_index_flag_scratch: Vec::new(),
585            parameter_special_target_word_scratch: Vec::new(),
586            open_double_quote_scratch: Vec::new(),
587            split_suspect_closing_quote_scratch: Vec::new(),
588        }
589    }
590
591    #[cfg_attr(shuck_profiling, inline(never))]
592    pub(crate) fn finish(mut self) -> SurfaceFragmentFacts {
593        self.facts
594            .plain_unindexed_references
595            .sort_by_key(|span| (span.start.offset, span.end.offset));
596        self.facts
597            .plain_unindexed_references
598            .dedup_by_key(|span| (span.start.offset, span.end.offset));
599        self.facts
600    }
601
602    fn opening_backtick_is_escaped(&self, span: Span) -> bool {
603        let source = self.source.as_bytes();
604        let start = span.start.offset;
605        let Some(fragment) = self.source.get(start..span.end.offset) else {
606            return false;
607        };
608        let Some(first_backtick) = fragment.find('`') else {
609            return true;
610        };
611        if !fragment[..first_backtick].bytes().all(|byte| byte == b'\\') {
612            return false;
613        }
614
615        let mut backslashes = first_backtick;
616        let mut cursor = start;
617        while cursor > 0 && source[cursor - 1] == b'\\' {
618            backslashes += 1;
619            cursor -= 1;
620        }
621
622        backslashes % 2 == 1
623    }
624
625    fn looks_like_unbraced_positional_above_nine(&self, span: Span) -> bool {
626        let fragment = span.slice(self.source);
627        let fragment = fragment.strip_prefix('"').unwrap_or(fragment);
628        let mut chars = fragment.chars();
629
630        matches!(
631            (chars.next(), chars.next(), chars.next()),
632            (Some('$'), Some(digit), Some(next))
633                if matches!(digit, '1'..='9') && next.is_ascii_digit()
634        )
635    }
636
637    fn record_array_reference(&mut self, span: Span, plain: bool) {
638        if let Some(fragment) = self
639            .facts
640            .indexed_array_references
641            .iter_mut()
642            .find(|fragment| match fragment {
643                IndexedArrayReferenceFragmentFact::OneBased(fragment)
644                | IndexedArrayReferenceFragmentFact::ZeroBased(fragment)
645                | IndexedArrayReferenceFragmentFact::OneBasedWithZeroAlias(fragment)
646                | IndexedArrayReferenceFragmentFact::Ambiguous(fragment) => fragment.span() == span,
647            })
648        {
649            *fragment = fragment.with_plain(plain);
650            return;
651        }
652        self.facts
653            .indexed_array_references
654            .push(IndexedArrayReferenceFragmentFact::new(span, plain));
655    }
656
657    fn record_plain_unindexed_reference(&mut self, span: Span) {
658        self.facts.plain_unindexed_references.push(span);
659    }
660
661    fn record_parameter_pattern_special_target(&mut self, operand_span: Span) {
662        if self
663            .facts
664            .parameter_pattern_special_targets
665            .iter()
666            .any(|fragment| fragment.span() == operand_span)
667        {
668            return;
669        }
670        self.facts
671            .parameter_pattern_special_targets
672            .push(ParameterPatternSpecialTargetFragmentFact { span: operand_span });
673    }
674
675    fn record_substring_expansion(&mut self, span: Span) {
676        if self
677            .facts
678            .substring_expansions
679            .iter()
680            .any(|fragment| fragment.span() == span)
681        {
682            return;
683        }
684        self.facts
685            .substring_expansions
686            .push(SubstringExpansionFragmentFact { span });
687    }
688
689    fn record_zsh_parameter_index_flag(&mut self, span: Span) {
690        if self
691            .facts
692            .zsh_parameter_index_flags
693            .iter()
694            .any(|fragment| fragment.span() == span)
695        {
696            return;
697        }
698        self.facts
699            .zsh_parameter_index_flags
700            .push(ZshParameterIndexFlagFragmentFact { span });
701    }
702
703    fn record_case_modification(&mut self, span: Span) {
704        if self
705            .facts
706            .case_modifications
707            .iter()
708            .any(|fragment| fragment.span() == span)
709        {
710            return;
711        }
712        self.facts
713            .case_modifications
714            .push(CaseModificationFragmentFact { span });
715    }
716
717    fn record_replacement_expansion(&mut self, span: Span) {
718        if self
719            .facts
720            .replacement_expansions
721            .iter()
722            .any(|fragment| fragment.span() == span)
723        {
724            return;
725        }
726        self.facts
727            .replacement_expansions
728            .push(ReplacementExpansionFragmentFact { span });
729    }
730
731    fn record_parameter_pattern(&mut self, span: Span) {
732        if self.facts.parameter_pattern_spans.contains(&span) {
733            return;
734        }
735        self.facts.parameter_pattern_spans.push(span);
736    }
737
738    fn record_positional_parameter_trim(&mut self, span: Span) {
739        if self
740            .facts
741            .positional_parameter_trims
742            .iter()
743            .any(|fragment| fragment.span() == span)
744        {
745            return;
746        }
747        self.facts
748            .positional_parameter_trims
749            .push(PositionalParameterTrimFragmentFact { span });
750    }
751
752    pub(crate) fn collect_word(&mut self, word: &Word, context: SurfaceScanContext<'_>) -> bool {
753        let open_double_quote_count = self.facts.open_double_quotes.len();
754        let context = if zsh_delayed_eval_word_is_exempt(word, self.source, context) {
755            context.literal_expansion_exempt()
756        } else {
757            context
758        };
759        self.zsh_parameter_index_flag_scratch.clear();
760        collect_zsh_parameter_index_flag_spans_in_word(
761            word.span.slice(self.source),
762            word.span,
763            &mut self.zsh_parameter_index_flag_scratch,
764        );
765        for index in 0..self.zsh_parameter_index_flag_scratch.len() {
766            let span = self.zsh_parameter_index_flag_scratch[index];
767            self.record_zsh_parameter_index_flag(span);
768        }
769        if context.collect_open_double_quotes {
770            self.collect_open_double_quote_fragments(
771                word,
772                context.command_name,
773                context.assignment_target,
774            );
775        }
776        let mut visitor = SurfaceWordVisitor {
777            sink: self,
778            context,
779        };
780        walk_word_subtree(
781            word,
782            WordTraversalContext {
783                source: visitor.sink.source,
784                locator: None,
785                shell_dialect: context.shell_dialect,
786            },
787            &mut visitor,
788        );
789        self.facts.open_double_quotes.len() > open_double_quote_count
790    }
791
792    pub(crate) fn collect_heredoc_body(
793        &mut self,
794        body: &HeredocBody,
795        context: SurfaceScanContext<'_>,
796    ) {
797        self.collect_heredoc_body_parts(&body.parts, context);
798    }
799
800    pub(crate) fn record_unset_array_target_word(&mut self, word: &Word) {
801        if word_looks_like_unset_array_target(word, self.source) {
802            self.facts.suppressed_subscript_spans.push(word.span);
803        }
804    }
805
806    fn collect_open_double_quote_fragments(
807        &mut self,
808        word: &Word,
809        command_name: Option<&str>,
810        assignment_target: Option<&str>,
811    ) {
812        self.open_double_quote_scratch.clear();
813        collect_suspect_double_quote_spans(
814            word,
815            self.source,
816            command_name,
817            assignment_target,
818            &mut self.open_double_quote_scratch,
819        );
820        if self.open_double_quote_scratch.is_empty() {
821            return;
822        }
823
824        let replacement =
825            rewrite_word_as_single_double_quoted_string(word, self.source, assignment_target);
826        for index in 0..self.open_double_quote_scratch.len() {
827            let (opening_span, closing_span) = self.open_double_quote_scratch[index];
828            self.facts
829                .open_double_quotes
830                .push(OpenDoubleQuoteFragmentFact {
831                    span: opening_span,
832                    replacement_span: word.span,
833                    replacement: replacement.clone(),
834                });
835            self.facts
836                .suspect_closing_quotes
837                .push(SuspectClosingQuoteFragmentFact {
838                    span: closing_span,
839                    replacement_span: word.span,
840                    replacement: replacement.clone(),
841                });
842        }
843    }
844
845    pub(crate) fn collect_split_suspect_closing_quote_fragment_in_words(&mut self, words: &[Word]) {
846        for (index, word) in words.iter().enumerate() {
847            let has_later_words = index + 1 < words.len();
848            self.split_suspect_closing_quote_scratch.clear();
849            collect_split_suspect_closing_quote_spans(
850                word,
851                self.source,
852                has_later_words,
853                &mut self.split_suspect_closing_quote_scratch,
854            );
855            if self.split_suspect_closing_quote_scratch.is_empty() {
856                continue;
857            }
858
859            let mut replacement = None;
860            for index in 0..self.split_suspect_closing_quote_scratch.len() {
861                let span = self.split_suspect_closing_quote_scratch[index];
862                if self
863                    .facts
864                    .suspect_closing_quotes
865                    .iter()
866                    .any(|fragment| fragment.span() == span)
867                {
868                    continue;
869                }
870                self.facts
871                    .suspect_closing_quotes
872                    .push(SuspectClosingQuoteFragmentFact {
873                        span,
874                        replacement_span: word.span,
875                        replacement: replacement
876                            .get_or_insert_with(|| {
877                                rewrite_word_as_single_double_quoted_string(word, self.source, None)
878                            })
879                            .clone(),
880                    });
881            }
882        }
883    }
884
885    fn collect_word_part_from_traversal(
886        &mut self,
887        part: &WordPartNode,
888        state: WordTraversalState<'_>,
889        context: SurfaceScanContext<'_>,
890    ) {
891        let in_double_quote = state.in_double_quote;
892        let Some(parts) = state.siblings else {
893            return;
894        };
895        let Some(index) = state.part_index else {
896            return;
897        };
898        if let WordPart::Literal(text) = &part.kind
899            && !in_double_quote
900        {
901            let literal = text.syntax_str(self.source, part.span);
902            if literal.as_bytes().contains(&UNICODE_SMART_QUOTE_LEAD_BYTE) {
903                for (offset, char) in literal.char_indices() {
904                    if !is_unicode_smart_quote(char) {
905                        continue;
906                    }
907                    let start = part.span.start.advanced_by(&literal[..offset]);
908                    let end = start.advanced_by(char.encode_utf8(&mut [0; 4]));
909                    self.facts
910                        .unicode_smart_quote_spans
911                        .push(Span::from_positions(start, end));
912                }
913            }
914        }
915
916        if let WordPart::Variable(name) = &part.kind
917            && matches!(
918                name.as_str(),
919                "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
920            )
921            && let Some(next_part) = parts.get(index + 1)
922            && let WordPart::Literal(text) = &next_part.kind
923            && text
924                .as_str(self.source, next_part.span)
925                .starts_with(|char: char| char.is_ascii_digit())
926            && self.looks_like_unbraced_positional_above_nine(part.span.merge(next_part.span))
927        {
928            self.facts
929                .positional_parameters
930                .push(PositionalParameterFragmentFact {
931                    span: part.span.merge(next_part.span),
932                    kind: PositionalParameterFragmentKind::AboveNine,
933                    guarded: context.guarded_parameter_operand,
934                });
935        }
936
937        match &part.kind {
938            WordPart::SingleQuoted { dollar, .. } => {
939                let literal_expansion_exempt = context.literal_expansion_exempt
940                    || single_quoted_fragment_is_zsh_delayed_eval_exempt(
941                        part.span,
942                        context,
943                        self.source,
944                    );
945                self.facts.single_quoted.push(SingleQuotedFragmentFact {
946                    span: part.span,
947                    diagnostic_span: self.single_quoted_fragment_diagnostic_span(part.span),
948                    dollar_quoted: *dollar,
949                    command_name: context
950                        .command_name
951                        .map(str::to_owned)
952                        .map(String::into_boxed_str),
953                    assignment_target: context
954                        .assignment_target
955                        .map(str::to_owned)
956                        .map(String::into_boxed_str),
957                    shell_dialect: context.shell_dialect,
958                    variable_set_operand: context.variable_set_operand,
959                    literal_expansion_exempt,
960                    literal_backslash_in_single_quotes_span:
961                        single_quoted_backslash_continuation_span(parts, index, self.source),
962                });
963            }
964            WordPart::DoubleQuoted { dollar, .. } => {
965                if *dollar {
966                    self.facts
967                        .dollar_double_quoted
968                        .push(DollarDoubleQuotedFragmentFact { span: part.span });
969                }
970            }
971            WordPart::ZshQualifiedGlob(glob) => self.collect_zsh_qualified_glob(glob, context),
972            WordPart::ArithmeticExpansion {
973                expression,
974                syntax: ArithmeticExpansionSyntax::LegacyBracket,
975                expression_word_ast,
976                expression_ast,
977                ..
978            } => {
979                self.facts
980                    .legacy_arithmetic
981                    .push(LegacyArithmeticFragmentFact { span: part.span });
982                collect_positional_parameter_operator_spans_in_arithmetic(
983                    part.span,
984                    expression_ast.as_deref(),
985                    expression,
986                    self.source,
987                    &mut self.facts.positional_parameter_operator_spans,
988                );
989                if let Some(expression_ast) = expression_ast.as_deref() {
990                    visit_arithmetic_words(expression_ast, &mut |word| {
991                        self.collect_word(word, context);
992                    });
993                } else {
994                    self.collect_word(expression_word_ast, context);
995                }
996            }
997            WordPart::ArithmeticExpansion {
998                expression,
999                expression_word_ast,
1000                expression_ast,
1001                ..
1002            } => {
1003                collect_positional_parameter_operator_spans_in_arithmetic(
1004                    part.span,
1005                    expression_ast.as_deref(),
1006                    expression,
1007                    self.source,
1008                    &mut self.facts.positional_parameter_operator_spans,
1009                );
1010                if let Some(expression_ast) = expression_ast.as_deref() {
1011                    visit_arithmetic_words(expression_ast, &mut |word| {
1012                        self.collect_word(word, context);
1013                    });
1014                } else {
1015                    self.collect_word(expression_word_ast, context);
1016                }
1017            }
1018            WordPart::CommandSubstitution {
1019                syntax: CommandSubstitutionSyntax::Backtick,
1020                body,
1021                ..
1022            } => {
1023                if self.opening_backtick_is_escaped(part.span) {
1024                    return;
1025                }
1026                self.facts.backticks.push(BacktickFragmentFact {
1027                    span: part.span,
1028                    empty: body.is_empty(),
1029                });
1030            }
1031            WordPart::CommandSubstitution { .. } | WordPart::ProcessSubstitution { .. } => {}
1032            WordPart::Parameter(parameter) => {
1033                if parameter_is_plain_unindexed_reference(parameter) {
1034                    self.record_plain_unindexed_reference(part.span);
1035                }
1036                self.collect_parameter_expansion(parameter, part.span, context);
1037            }
1038            WordPart::Variable(name) => {
1039                if name_is_plain_reference_candidate(name) {
1040                    self.record_plain_unindexed_reference(part.span);
1041                }
1042                if name.as_str() == "$"
1043                    && contains_nested_parameter_marker(part.span.slice(self.source))
1044                {
1045                    self.facts
1046                        .nested_parameter_expansions
1047                        .push(NestedParameterExpansionFragmentFact { span: part.span });
1048                }
1049            }
1050            WordPart::ParameterExpansion {
1051                reference,
1052                operator,
1053                operand,
1054                operand_word_ast,
1055                ..
1056            } => {
1057                if reference_has_array_subscript(reference) {
1058                    self.record_array_reference(part.span, false);
1059                }
1060                if parameter_pattern_target_is_special(reference, operator) {
1061                    self.parameter_special_target_word_scratch.clear();
1062                    collect_parameter_operator_special_target_word_spans(
1063                        operator,
1064                        &mut self.parameter_special_target_word_scratch,
1065                    );
1066                    for index in 0..self.parameter_special_target_word_scratch.len() {
1067                        let pattern_span = self.parameter_special_target_word_scratch[index];
1068                        self.record_parameter_pattern_special_target(pattern_span);
1069                    }
1070                }
1071                if matches!(
1072                    operator.as_ref(),
1073                    ParameterOp::UpperFirst
1074                        | ParameterOp::UpperAll
1075                        | ParameterOp::LowerFirst
1076                        | ParameterOp::LowerAll
1077                ) {
1078                    self.record_case_modification(part.span);
1079                }
1080                if matches!(
1081                    operator.as_ref(),
1082                    ParameterOp::ReplaceFirst { .. } | ParameterOp::ReplaceAll { .. }
1083                ) {
1084                    self.record_replacement_expansion(part.span);
1085                }
1086                if reference_is_positional_parameter_trim(reference, operator) {
1087                    self.record_positional_parameter_trim(part.span);
1088                }
1089                self.record_var_ref_subscript(
1090                    reference,
1091                    context.subscript_suppresses_later_references,
1092                );
1093                self.collect_parameter_operator_patterns(
1094                    operator,
1095                    operand.as_ref(),
1096                    operand_word_ast.as_deref(),
1097                    context,
1098                );
1099            }
1100            WordPart::Length(reference)
1101            | WordPart::ArrayLength(reference)
1102            | WordPart::Transformation { reference, .. } => {
1103                self.record_var_ref_subscript(
1104                    reference,
1105                    context.subscript_suppresses_later_references,
1106                );
1107            }
1108            WordPart::ArrayAccess(reference) => {
1109                if reference_has_array_subscript(reference) {
1110                    self.record_array_reference(part.span, true);
1111                    let case_modification_span = parts
1112                        .get(index + 1)
1113                        .filter(|next_part| {
1114                            matches!(&next_part.kind, WordPart::Literal(text) if {
1115                                let text = text.as_str(self.source, next_part.span);
1116                                text.starts_with('^') || text.starts_with(',')
1117                            })
1118                        })
1119                        .map_or(part.span, |next_part| part.span.merge(next_part.span));
1120                    self.record_case_modification(case_modification_span);
1121                }
1122                self.record_var_ref_subscript(
1123                    reference,
1124                    context.subscript_suppresses_later_references,
1125                );
1126            }
1127            WordPart::ArrayIndices(reference) => {
1128                self.record_var_ref_subscript(
1129                    reference,
1130                    context.subscript_suppresses_later_references,
1131                );
1132                self.facts
1133                    .indirect_expansions
1134                    .push(IndirectExpansionFragmentFact {
1135                        span: part.span,
1136                        array_keys: true,
1137                    });
1138            }
1139            WordPart::Substring { reference, .. } => {
1140                self.record_substring_expansion(part.span);
1141                self.record_var_ref_subscript(
1142                    reference,
1143                    context.subscript_suppresses_later_references,
1144                );
1145            }
1146            WordPart::ArraySlice { reference, .. } => {
1147                self.record_var_ref_subscript(
1148                    reference,
1149                    context.subscript_suppresses_later_references,
1150                );
1151            }
1152            WordPart::IndirectExpansion {
1153                reference,
1154                operator: Some(operator),
1155                operand,
1156                operand_word_ast,
1157                ..
1158            } => {
1159                self.record_var_ref_subscript(
1160                    reference,
1161                    context.subscript_suppresses_later_references,
1162                );
1163                self.facts
1164                    .indirect_expansions
1165                    .push(IndirectExpansionFragmentFact {
1166                        span: part.span,
1167                        array_keys: false,
1168                    });
1169                self.collect_parameter_operator_patterns(
1170                    operator,
1171                    operand.as_ref(),
1172                    operand_word_ast.as_deref(),
1173                    context,
1174                );
1175            }
1176            WordPart::IndirectExpansion {
1177                reference,
1178                operator: None,
1179                ..
1180            } => {
1181                self.record_var_ref_subscript(
1182                    reference,
1183                    context.subscript_suppresses_later_references,
1184                );
1185                self.facts
1186                    .indirect_expansions
1187                    .push(IndirectExpansionFragmentFact {
1188                        span: part.span,
1189                        array_keys: false,
1190                    });
1191            }
1192            WordPart::PrefixMatch { .. } => {
1193                self.facts
1194                    .indirect_expansions
1195                    .push(IndirectExpansionFragmentFact {
1196                        span: part.span,
1197                        array_keys: false,
1198                    });
1199            }
1200            WordPart::Literal(_) => {}
1201        }
1202    }
1203
1204    fn collect_heredoc_body_parts(
1205        &mut self,
1206        parts: &[HeredocBodyPartNode],
1207        context: SurfaceScanContext<'_>,
1208    ) {
1209        for (index, part) in parts.iter().enumerate() {
1210            if let HeredocBodyPart::Variable(name) = &part.kind
1211                && matches!(
1212                    name.as_str(),
1213                    "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
1214                )
1215                && let Some(next_part) = parts.get(index + 1)
1216                && let HeredocBodyPart::Literal(text) = &next_part.kind
1217                && text
1218                    .as_str(self.source, next_part.span)
1219                    .starts_with(|char: char| char.is_ascii_digit())
1220                && self.looks_like_unbraced_positional_above_nine(part.span.merge(next_part.span))
1221            {
1222                self.facts
1223                    .positional_parameters
1224                    .push(PositionalParameterFragmentFact {
1225                        span: part.span.merge(next_part.span),
1226                        kind: PositionalParameterFragmentKind::AboveNine,
1227                        guarded: context.guarded_parameter_operand,
1228                    });
1229            }
1230
1231            match &part.kind {
1232                HeredocBodyPart::Literal(_) => {}
1233                HeredocBodyPart::Variable(name) => {
1234                    if name_is_plain_reference_candidate(name) {
1235                        self.record_plain_unindexed_reference(part.span);
1236                    }
1237                }
1238                HeredocBodyPart::CommandSubstitution {
1239                    syntax: CommandSubstitutionSyntax::Backtick,
1240                    body,
1241                    ..
1242                } => {
1243                    if self.opening_backtick_is_escaped(part.span) {
1244                        continue;
1245                    }
1246                    self.facts.backticks.push(BacktickFragmentFact {
1247                        span: part.span,
1248                        empty: body.is_empty(),
1249                    });
1250                }
1251                HeredocBodyPart::CommandSubstitution { .. } => {}
1252                HeredocBodyPart::ArithmeticExpansion {
1253                    expression,
1254                    syntax: ArithmeticExpansionSyntax::LegacyBracket,
1255                    expression_word_ast,
1256                    expression_ast,
1257                    ..
1258                } => {
1259                    self.facts
1260                        .legacy_arithmetic
1261                        .push(LegacyArithmeticFragmentFact { span: part.span });
1262                    collect_positional_parameter_operator_spans_in_arithmetic(
1263                        part.span,
1264                        expression_ast.as_ref(),
1265                        expression,
1266                        self.source,
1267                        &mut self.facts.positional_parameter_operator_spans,
1268                    );
1269                    if let Some(expression_ast) = expression_ast.as_ref() {
1270                        visit_arithmetic_words(expression_ast, &mut |word| {
1271                            self.collect_word(word, context);
1272                        });
1273                    } else {
1274                        self.collect_word(expression_word_ast, context);
1275                    }
1276                }
1277                HeredocBodyPart::ArithmeticExpansion {
1278                    expression,
1279                    expression_word_ast,
1280                    expression_ast,
1281                    ..
1282                } => {
1283                    collect_positional_parameter_operator_spans_in_arithmetic(
1284                        part.span,
1285                        expression_ast.as_ref(),
1286                        expression,
1287                        self.source,
1288                        &mut self.facts.positional_parameter_operator_spans,
1289                    );
1290                    if let Some(expression_ast) = expression_ast.as_ref() {
1291                        visit_arithmetic_words(expression_ast, &mut |word| {
1292                            self.collect_word(word, context);
1293                        });
1294                    } else {
1295                        self.collect_word(expression_word_ast, context);
1296                    }
1297                }
1298                HeredocBodyPart::Parameter(parameter) => {
1299                    if parameter_is_plain_unindexed_reference(parameter) {
1300                        self.record_plain_unindexed_reference(part.span);
1301                    }
1302                    self.collect_parameter_expansion(parameter, part.span, context);
1303                }
1304            }
1305        }
1306    }
1307
1308    pub(crate) fn collect_pattern(&mut self, pattern: &Pattern, context: SurfaceScanContext<'_>) {
1309        self.collect_pattern_impl(pattern, context, true);
1310    }
1311
1312    pub(crate) fn collect_pattern_structure(
1313        &mut self,
1314        pattern: &Pattern,
1315        context: SurfaceScanContext<'_>,
1316    ) {
1317        self.collect_pattern_impl(pattern, context, false);
1318    }
1319
1320    fn collect_pattern_impl(
1321        &mut self,
1322        pattern: &Pattern,
1323        context: SurfaceScanContext<'_>,
1324        collect_words: bool,
1325    ) {
1326        for (part, span) in pattern.parts_with_spans() {
1327            match part {
1328                PatternPart::Group { kind, patterns } => {
1329                    if *kind == PatternGroupKind::ExactlyOne {
1330                        self.facts.pattern_exactly_one_extglob_spans.push(span);
1331                    }
1332                    for pattern in patterns {
1333                        self.collect_pattern_impl(pattern, context, collect_words);
1334                    }
1335                }
1336                PatternPart::Word(word) if collect_words => {
1337                    self.collect_word(word, context);
1338                }
1339                PatternPart::Word(_) => {}
1340                PatternPart::CharClass(_) if context.collect_pattern_charclasses => {
1341                    self.facts.pattern_charclass_spans.push(span);
1342                    if context.nested_word_command {
1343                        self.facts.nested_pattern_charclass_spans.push(span);
1344                    }
1345                }
1346                PatternPart::CharClass(_)
1347                | PatternPart::Literal(_)
1348                | PatternPart::AnyString
1349                | PatternPart::AnyChar => {}
1350            }
1351        }
1352    }
1353
1354    fn collect_fragment_word(
1355        &mut self,
1356        word: Option<&Word>,
1357        text: Option<&SourceText>,
1358        context: SurfaceScanContext<'_>,
1359    ) {
1360        let Some(text) = text else {
1361            return;
1362        };
1363        let snippet = text.slice(self.source);
1364        if snippet.is_empty() {
1365            return;
1366        }
1367
1368        debug_assert!(
1369            word.is_some(),
1370            "parser-backed fragment text should always carry a word AST"
1371        );
1372        let Some(word) = word else {
1373            return;
1374        };
1375        self.collect_word(word, context.without_open_double_quote_scan());
1376    }
1377
1378    fn collect_zsh_qualified_glob(
1379        &mut self,
1380        glob: &ZshQualifiedGlob,
1381        context: SurfaceScanContext<'_>,
1382    ) {
1383        for segment in &glob.segments {
1384            if let ZshGlobSegment::Pattern(pattern) = segment {
1385                self.collect_pattern(pattern, context);
1386            }
1387        }
1388    }
1389
1390    fn collect_parameter_expansion(
1391        &mut self,
1392        parameter: &shuck_ast::ParameterExpansion,
1393        span: Span,
1394        context: SurfaceScanContext<'_>,
1395    ) {
1396        let (classification, deepest_subscript_ref) =
1397            classify_parameter_expansion(parameter, self.source);
1398        let guarded_reference =
1399            context.guarded_parameter_operand || classification.guards_unset_reference;
1400
1401        self.record_special_positional_parameter(parameter, guarded_reference);
1402        if span.slice(self.source).starts_with("${##") {
1403            self.facts
1404                .positional_parameters
1405                .push(PositionalParameterFragmentFact {
1406                    span,
1407                    kind: PositionalParameterFragmentKind::General,
1408                    guarded: guarded_reference,
1409                });
1410        }
1411        if classification.is_nested_bourne {
1412            self.facts
1413                .nested_parameter_expansions
1414                .push(NestedParameterExpansionFragmentFact { span });
1415        }
1416        if classification.has_array_reference {
1417            self.record_array_reference(span, classification.is_plain_array_reference);
1418        }
1419        if classification.has_substring_expansion {
1420            self.record_substring_expansion(span);
1421        }
1422        if classification.has_case_modification {
1423            self.record_case_modification(span);
1424        }
1425        if classification.has_replacement_expansion {
1426            self.record_replacement_expansion(span);
1427        }
1428        if classification.has_positional_parameter_trim {
1429            self.record_positional_parameter_trim(span);
1430        }
1431        if let Some(deepest_subscript_ref) = deepest_subscript_ref {
1432            self.record_var_ref_subscript(deepest_subscript_ref, true);
1433        }
1434        if let ParameterExpansionSyntax::Bourne(syntax) = &parameter.syntax {
1435            if matches!(
1436                syntax,
1437                BourneParameterExpansion::Indirect { .. }
1438                    | BourneParameterExpansion::PrefixMatch { .. }
1439                    | BourneParameterExpansion::Indices { .. }
1440            ) {
1441                self.facts
1442                    .indirect_expansions
1443                    .push(IndirectExpansionFragmentFact {
1444                        span,
1445                        array_keys: matches!(syntax, BourneParameterExpansion::Indices { .. }),
1446                    });
1447            }
1448            match syntax {
1449                BourneParameterExpansion::Operation {
1450                    reference,
1451                    operator,
1452                    operand,
1453                    operand_word_ast,
1454                    ..
1455                }
1456                | BourneParameterExpansion::Indirect {
1457                    reference,
1458                    operator: Some(operator),
1459                    operand,
1460                    operand_word_ast,
1461                    ..
1462                } => {
1463                    if parameter_pattern_target_is_special(reference, operator) {
1464                        self.parameter_special_target_word_scratch.clear();
1465                        collect_parameter_operator_special_target_word_spans(
1466                            operator,
1467                            &mut self.parameter_special_target_word_scratch,
1468                        );
1469                        for index in 0..self.parameter_special_target_word_scratch.len() {
1470                            let pattern_span = self.parameter_special_target_word_scratch[index];
1471                            self.record_parameter_pattern_special_target(pattern_span);
1472                        }
1473                    }
1474                    self.collect_parameter_operator_patterns(
1475                        operator,
1476                        operand.as_ref(),
1477                        operand_word_ast.as_deref(),
1478                        context,
1479                    );
1480                }
1481                BourneParameterExpansion::Access { .. }
1482                | BourneParameterExpansion::Length { .. }
1483                | BourneParameterExpansion::Indices { .. }
1484                | BourneParameterExpansion::Indirect { operator: None, .. }
1485                | BourneParameterExpansion::PrefixMatch { .. }
1486                | BourneParameterExpansion::Slice { .. }
1487                | BourneParameterExpansion::Transformation { .. } => {}
1488            }
1489        }
1490    }
1491
1492    fn record_special_positional_parameter(
1493        &mut self,
1494        parameter: &shuck_ast::ParameterExpansion,
1495        guarded: bool,
1496    ) {
1497        let reference = match &parameter.syntax {
1498            ParameterExpansionSyntax::Bourne(syntax) => match syntax {
1499                BourneParameterExpansion::Access { reference }
1500                | BourneParameterExpansion::Length { reference }
1501                | BourneParameterExpansion::Indices { reference }
1502                | BourneParameterExpansion::Indirect { reference, .. }
1503                | BourneParameterExpansion::Slice { reference, .. }
1504                | BourneParameterExpansion::Operation { reference, .. }
1505                | BourneParameterExpansion::Transformation { reference, .. } => Some(reference),
1506                BourneParameterExpansion::PrefixMatch { .. } => None,
1507            },
1508            ParameterExpansionSyntax::Zsh(_) => None,
1509        };
1510
1511        if let Some(reference) = reference
1512            && reference.subscript.is_none()
1513            && matches!(reference.name.as_str(), "@" | "*" | "#")
1514        {
1515            self.facts
1516                .positional_parameters
1517                .push(PositionalParameterFragmentFact {
1518                    span: reference.span,
1519                    kind: PositionalParameterFragmentKind::General,
1520                    guarded,
1521                });
1522        }
1523    }
1524
1525    fn collect_parameter_operator_patterns(
1526        &mut self,
1527        operator: &ParameterOp,
1528        operand: Option<&SourceText>,
1529        operand_word_ast: Option<&Word>,
1530        context: SurfaceScanContext<'_>,
1531    ) {
1532        match operator {
1533            ParameterOp::RemovePrefixShort { pattern }
1534            | ParameterOp::RemovePrefixLong { pattern }
1535            | ParameterOp::RemoveSuffixShort { pattern }
1536            | ParameterOp::RemoveSuffixLong { pattern } => {
1537                self.record_parameter_pattern(pattern.span);
1538                self.collect_pattern(pattern, context.with_pattern_charclass_scan())
1539            }
1540            ParameterOp::ReplaceFirst {
1541                pattern,
1542                replacement,
1543                ..
1544            }
1545            | ParameterOp::ReplaceAll {
1546                pattern,
1547                replacement,
1548                ..
1549            } => {
1550                self.record_parameter_pattern(pattern.span);
1551                self.collect_pattern(pattern, context.with_pattern_charclass_scan());
1552                self.collect_fragment_word(
1553                    operator.replacement_word_ast(),
1554                    Some(replacement),
1555                    context,
1556                );
1557            }
1558            ParameterOp::UseDefault
1559            | ParameterOp::AssignDefault
1560            | ParameterOp::UseReplacement
1561            | ParameterOp::Error => {
1562                self.collect_fragment_word(
1563                    operand_word_ast,
1564                    operand,
1565                    context.guarded_parameter_operand(),
1566                );
1567            }
1568            ParameterOp::UpperFirst
1569            | ParameterOp::UpperAll
1570            | ParameterOp::LowerFirst
1571            | ParameterOp::LowerAll => {}
1572        }
1573    }
1574
1575    pub(crate) fn record_var_ref_subscript(
1576        &mut self,
1577        reference: &VarRef,
1578        suppresses_later_references: bool,
1579    ) {
1580        let Some(subscript) = reference.subscript.as_deref() else {
1581            return;
1582        };
1583        if subscript.selector().is_some() {
1584            return;
1585        }
1586        let span = subscript.span();
1587        self.facts.suppressed_subscript_spans.push(span);
1588        if suppresses_later_references {
1589            self.facts.subscript_later_suppression_spans.push(span);
1590        }
1591    }
1592
1593    pub(crate) fn record_arithmetic_only_suppressed_subscript(
1594        &mut self,
1595        subscript: Option<&Subscript>,
1596    ) {
1597        let Some(subscript) = subscript else {
1598            return;
1599        };
1600        if subscript.selector().is_some() {
1601            return;
1602        }
1603        self.facts
1604            .arithmetic_only_suppressed_subscript_spans
1605            .push(subscript.span());
1606    }
1607
1608    fn single_quoted_fragment_diagnostic_span(&self, part_span: Span) -> Span {
1609        if !self
1610            .facts
1611            .backticks
1612            .iter()
1613            .any(|fragment| span_contains(fragment.span, part_span))
1614        {
1615            return part_span;
1616        }
1617
1618        let escaped_dollar_count =
1619            backtick_display_escaped_dollar_count(part_span.slice(self.source));
1620        let Some(chain_start) = continued_line_chain_start(part_span.start, self.source) else {
1621            return adjust_end_column(part_span, escaped_dollar_count);
1622        };
1623
1624        let start = shellcheck_collapsed_position(chain_start, part_span.start, self.source);
1625        let end = adjust_end_column(
1626            Span::from_positions(
1627                start,
1628                shellcheck_collapsed_position(chain_start, part_span.end, self.source),
1629            ),
1630            escaped_dollar_count,
1631        )
1632        .end;
1633
1634        Span::from_positions(start, end)
1635    }
1636}
1637
1638struct SurfaceWordVisitor<'sink, 'a, 'context> {
1639    sink: &'sink mut SurfaceFragmentSink<'a>,
1640    context: SurfaceScanContext<'context>,
1641}
1642
1643impl<'word> WordSubtreeVisitor<'word> for SurfaceWordVisitor<'_, '_, '_> {
1644    fn visit_part(&mut self, part: &'word WordPartNode, state: WordTraversalState<'word>) {
1645        if state.processes_root_word() {
1646            self.sink
1647                .collect_word_part_from_traversal(part, state, self.context);
1648        }
1649    }
1650}
1651
1652fn zsh_delayed_eval_word_is_exempt(
1653    word: &Word,
1654    source: &str,
1655    context: SurfaceScanContext<'_>,
1656) -> bool {
1657    if context.shell_dialect != shuck_parser::ShellDialect::Zsh {
1658        return false;
1659    }
1660
1661    let assignment_context = context.assignment_target.is_some()
1662        || matches!(
1663            context.command_name,
1664            Some("local" | "typeset" | "declare" | "readonly")
1665        );
1666    if !assignment_context {
1667        return false;
1668    }
1669
1670    let text = word.span.slice(source);
1671    text.contains('=')
1672        && (text.contains("_p9k__segment_cond_")
1673            || text.contains("__p9k_instant_prompt_")
1674            || text.contains("PATCH='for "))
1675}
1676
1677fn single_quoted_fragment_is_zsh_delayed_eval_exempt(
1678    span: Span,
1679    context: SurfaceScanContext<'_>,
1680    source: &str,
1681) -> bool {
1682    if context.shell_dialect != shuck_parser::ShellDialect::Zsh {
1683        return false;
1684    }
1685
1686    let Some(body) = single_quoted_body(span.slice(source)) else {
1687        return false;
1688    };
1689
1690    if context
1691        .assignment_target
1692        .is_some_and(|target| zsh_delayed_eval_assignment_target(target, body))
1693    {
1694        return true;
1695    }
1696
1697    if context
1698        .assignment_target
1699        .is_some_and(|target| zsh_vcs_info_patch_loop_literal(target, body))
1700    {
1701        return true;
1702    }
1703
1704    context
1705        .assignment_target
1706        .is_some_and(|target| zsh_literal_map_key_target(target, body))
1707}
1708
1709fn single_quoted_body(text: &str) -> Option<&str> {
1710    text.strip_prefix('\'')?.strip_suffix('\'')
1711}
1712
1713fn zsh_delayed_eval_assignment_target(target: &str, body: &str) -> bool {
1714    if target.contains("_p9k__segment_cond_") && single_parameter_marker_literal(body) {
1715        return true;
1716    }
1717
1718    zsh_delayed_prompt_assignment_target(target) && zsh_delayed_eval_literal_body(body)
1719}
1720
1721fn zsh_delayed_prompt_assignment_target(target: &str) -> bool {
1722    matches!(target, "PROMPT" | "RPROMPT" | "RPS1" | "RPS2" | "SPROMPT")
1723        || target == "_p9k__prompt"
1724        || target.starts_with("_p9k__prompt_")
1725}
1726
1727fn zsh_delayed_eval_literal_body(body: &str) -> bool {
1728    body.contains("${(")
1729        || body.contains("::=")
1730        || body.contains("${_p9k")
1731        || body.contains("$_p9k")
1732        || body.contains("$+")
1733        || body.contains("${+")
1734}
1735
1736fn zsh_literal_map_key_target(target: &str, body: &str) -> bool {
1737    target == "___subst_map" && single_parameter_marker_literal(body)
1738}
1739
1740fn zsh_vcs_info_patch_loop_literal(target: &str, body: &str) -> bool {
1741    target == "PATCH"
1742        && body.starts_with("for ")
1743        && body.contains(" hook_com[")
1744        && single_quoted_body_contains_expansion(body)
1745}
1746
1747fn single_parameter_marker_literal(body: &str) -> bool {
1748    let Some(name) = body
1749        .strip_prefix("${")
1750        .and_then(|inner| inner.strip_suffix('}'))
1751        .or_else(|| body.strip_prefix('$'))
1752    else {
1753        return false;
1754    };
1755
1756    !name.is_empty()
1757        && name
1758            .chars()
1759            .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1760}
1761
1762fn single_quoted_body_contains_expansion(body: &str) -> bool {
1763    body.contains('$') || (body.contains('`') && body.matches('`').count() >= 2)
1764}
1765
1766fn continued_line_chain_start(target: Position, source: &str) -> Option<Position> {
1767    let mut line_start_offset = source[..target.offset]
1768        .rfind('\n')
1769        .map_or(0, |index| index + 1);
1770    let mut line = target.line;
1771    let original_start = line_start_offset;
1772
1773    while line_start_offset > 0 {
1774        let previous_line_end = line_start_offset - 1;
1775        let previous_line_start = source[..previous_line_end]
1776            .rfind('\n')
1777            .map_or(0, |index| index + 1);
1778        let previous_line = &source[previous_line_start..previous_line_end];
1779        if !previous_line.trim_end_matches([' ', '\t']).ends_with('\\') {
1780            break;
1781        }
1782        line_start_offset = previous_line_start;
1783        line -= 1;
1784    }
1785
1786    (line_start_offset != original_start).then_some(Position {
1787        line,
1788        column: 1,
1789        offset: line_start_offset,
1790    })
1791}
1792
1793fn shellcheck_collapsed_position(
1794    chain_start: Position,
1795    target: Position,
1796    source: &str,
1797) -> Position {
1798    let mut line = chain_start.line;
1799    let mut column = chain_start.column;
1800    let prefix = &source[chain_start.offset..target.offset];
1801    let mut index = 0usize;
1802
1803    while index < prefix.len() {
1804        if prefix[index..].starts_with("\\\r\n") {
1805            index += "\\\r\n".len();
1806            continue;
1807        }
1808
1809        if prefix[index..].starts_with("\\\n") {
1810            index += "\\\n".len();
1811            continue;
1812        }
1813
1814        let ch = prefix[index..]
1815            .chars()
1816            .next()
1817            .expect("prefix iteration should stay on UTF-8 boundaries");
1818        if ch == '\n' {
1819            line += 1;
1820            column = 1;
1821        } else {
1822            column += 1;
1823        }
1824        index += ch.len_utf8();
1825    }
1826
1827    Position {
1828        line,
1829        column,
1830        offset: target.offset,
1831    }
1832}
1833
1834fn adjust_end_column(span: Span, display_escape_count: usize) -> Span {
1835    if display_escape_count == 0 {
1836        return span;
1837    }
1838
1839    let mut end = span.end;
1840    end.column = end.column.saturating_sub(display_escape_count);
1841    Span::from_positions(span.start, end)
1842}
1843
1844fn backtick_display_escaped_dollar_count(text: &str) -> usize {
1845    let Some(inner) = text
1846        .strip_prefix('\'')
1847        .and_then(|text| text.strip_suffix('\''))
1848    else {
1849        return 0;
1850    };
1851
1852    inner
1853        .as_bytes()
1854        .windows(2)
1855        .filter(|pair| pair[0] == b'\\' && pair[1] == b'$')
1856        .count()
1857}
1858
1859fn quoted_parameter_target_len(text: &str) -> Option<usize> {
1860    match text.as_bytes().first().copied() {
1861        Some(b'\'') => single_quoted_fragment_len(text),
1862        Some(b'"') => double_quoted_fragment_len(text),
1863        _ => None,
1864    }
1865}
1866
1867fn collect_zsh_parameter_index_flag_spans_in_word(text: &str, span: Span, spans: &mut Vec<Span>) {
1868    let mut search_from = 0usize;
1869
1870    while let Some(start) = next_live_parameter_expansion_start(text, search_from) {
1871        let body = &text[start + 2..];
1872        let Some(target_len) = quoted_parameter_target_len(body) else {
1873            search_from = start + 2;
1874            continue;
1875        };
1876        if !body[target_len..].starts_with('[') {
1877            search_from = start + 2;
1878            continue;
1879        }
1880
1881        let target_start = span.start.advanced_by(&text[..start]);
1882        let target_end = target_start.advanced_by(&text[start..start + 2 + target_len]);
1883        spans.push(Span::from_positions(target_start, target_end));
1884        search_from = start + 2 + target_len;
1885    }
1886}
1887
1888fn next_live_parameter_expansion_start(text: &str, search_from: usize) -> Option<usize> {
1889    let bytes = text.as_bytes();
1890    let mut index = search_from;
1891    let mut in_double_quotes = false;
1892
1893    while index + 1 < bytes.len() {
1894        if bytes[index] == b'\\' {
1895            index += usize::from(index + 1 < bytes.len()) + 1;
1896            continue;
1897        }
1898
1899        if !in_double_quotes && bytes[index..].starts_with(b"$'") {
1900            index += 1 + dollar_single_quoted_fragment_len(&text[index + 1..])?;
1901            continue;
1902        }
1903
1904        if !in_double_quotes && bytes[index] == b'\'' {
1905            index += single_quoted_fragment_len(&text[index..])?;
1906            continue;
1907        }
1908
1909        if bytes[index] == b'"' {
1910            in_double_quotes = !in_double_quotes;
1911            index += 1;
1912            continue;
1913        }
1914
1915        if bytes[index..].starts_with(b"${") {
1916            return Some(index);
1917        }
1918
1919        index += 1;
1920    }
1921
1922    None
1923}
1924
1925fn single_quoted_fragment_len(text: &str) -> Option<usize> {
1926    debug_assert!(text.starts_with('\''));
1927    text[1..].find('\'').map(|offset| offset + 2)
1928}
1929
1930fn dollar_single_quoted_fragment_len(text: &str) -> Option<usize> {
1931    debug_assert!(text.starts_with('\''));
1932    let bytes = text.as_bytes();
1933    let mut index = 1usize;
1934
1935    while index < bytes.len() {
1936        if bytes[index] == b'\\' {
1937            index += usize::from(index + 1 < bytes.len()) + 1;
1938            continue;
1939        }
1940        if bytes[index] == b'\'' {
1941            return Some(index + 1);
1942        }
1943        index += 1;
1944    }
1945
1946    None
1947}
1948
1949fn single_quoted_backslash_continuation_span(
1950    parts: &[WordPartNode],
1951    index: usize,
1952    source: &str,
1953) -> Option<Span> {
1954    let part = parts.get(index)?;
1955    if !single_quoted_part_contains_backslash_letter(part, source) {
1956        return None;
1957    }
1958
1959    let next_part = parts.get(index + 1)?;
1960    let WordPart::Literal(text) = &next_part.kind else {
1961        return None;
1962    };
1963    if !text
1964        .as_str(source, next_part.span)
1965        .starts_with(|char: char| char.is_ascii_alphabetic())
1966    {
1967        return None;
1968    }
1969
1970    let raw = part.span.slice(source);
1971    let closing_quote = part.span.start.advanced_by(&raw[..raw.len() - 1]);
1972    Some(Span::from_positions(closing_quote, closing_quote))
1973}
1974
1975fn single_quoted_part_contains_backslash_letter(part: &WordPartNode, source: &str) -> bool {
1976    let WordPart::SingleQuoted { dollar: false, .. } = part.kind else {
1977        return false;
1978    };
1979    let Some(inner) = part
1980        .span
1981        .slice(source)
1982        .strip_prefix('\'')
1983        .and_then(|text| text.strip_suffix('\''))
1984    else {
1985        return false;
1986    };
1987
1988    inner
1989        .as_bytes()
1990        .windows(2)
1991        .any(|pair| pair[0] == b'\\' && pair[1].is_ascii_alphabetic())
1992}
1993
1994fn double_quoted_fragment_len(text: &str) -> Option<usize> {
1995    debug_assert!(text.starts_with('"'));
1996    let bytes = text.as_bytes();
1997    let mut index = 1usize;
1998
1999    while index < bytes.len() {
2000        if bytes[index] == b'\\' {
2001            index += usize::from(index + 1 < bytes.len()) + 1;
2002            continue;
2003        }
2004        if bytes[index..].starts_with(b"${") {
2005            index += parameter_expansion_len(&text[index..])?;
2006            continue;
2007        }
2008        if bytes[index..].starts_with(b"$(") {
2009            index += command_substitution_len(&text[index..])?;
2010            continue;
2011        }
2012        if bytes[index] == b'`' {
2013            index += backtick_substitution_len(&text[index..])?;
2014            continue;
2015        }
2016        if bytes[index] == b'"' {
2017            return Some(index + 1);
2018        }
2019        index += 1;
2020    }
2021
2022    None
2023}
2024
2025fn parameter_expansion_len(text: &str) -> Option<usize> {
2026    debug_assert!(text.starts_with("${"));
2027    let bytes = text.as_bytes();
2028    let mut index = 2usize;
2029
2030    while index < bytes.len() {
2031        if bytes[index] == b'\\' {
2032            index += usize::from(index + 1 < bytes.len()) + 1;
2033            continue;
2034        }
2035        if bytes[index] == b'\'' {
2036            index += single_quoted_fragment_len(&text[index..])?;
2037            continue;
2038        }
2039        if bytes[index] == b'"' {
2040            index += double_quoted_fragment_len(&text[index..])?;
2041            continue;
2042        }
2043        if bytes[index] == b'`' {
2044            index += backtick_substitution_len(&text[index..])?;
2045            continue;
2046        }
2047        if bytes[index..].starts_with(b"${") {
2048            index += parameter_expansion_len(&text[index..])?;
2049            continue;
2050        }
2051        if bytes[index..].starts_with(b"$(") {
2052            index += command_substitution_len(&text[index..])?;
2053            continue;
2054        }
2055        if bytes[index] == b'}' {
2056            return Some(index + 1);
2057        }
2058        index += 1;
2059    }
2060
2061    None
2062}
2063
2064fn command_substitution_len(text: &str) -> Option<usize> {
2065    debug_assert!(text.starts_with("$("));
2066    let bytes = text.as_bytes();
2067    let mut index = 2usize;
2068    let mut paren_depth = 0usize;
2069
2070    while index < bytes.len() {
2071        if bytes[index] == b'\\' {
2072            index += usize::from(index + 1 < bytes.len()) + 1;
2073            continue;
2074        }
2075        if bytes[index] == b'\'' {
2076            index += single_quoted_fragment_len(&text[index..])?;
2077            continue;
2078        }
2079        if bytes[index] == b'"' {
2080            index += double_quoted_fragment_len(&text[index..])?;
2081            continue;
2082        }
2083        if bytes[index] == b'`' {
2084            index += backtick_substitution_len(&text[index..])?;
2085            continue;
2086        }
2087        if bytes[index..].starts_with(b"${") {
2088            index += parameter_expansion_len(&text[index..])?;
2089            continue;
2090        }
2091        if bytes[index..].starts_with(b"$(") {
2092            index += command_substitution_len(&text[index..])?;
2093            continue;
2094        }
2095        if bytes[index] == b'(' {
2096            paren_depth += 1;
2097            index += 1;
2098            continue;
2099        }
2100        if bytes[index] == b')' {
2101            if paren_depth == 0 {
2102                return Some(index + 1);
2103            }
2104            paren_depth -= 1;
2105            index += 1;
2106            continue;
2107        }
2108        index += 1;
2109    }
2110
2111    None
2112}
2113
2114fn backtick_substitution_len(text: &str) -> Option<usize> {
2115    debug_assert!(text.starts_with('`'));
2116    let bytes = text.as_bytes();
2117    let mut index = 1usize;
2118
2119    while index < bytes.len() {
2120        if bytes[index] == b'\\' {
2121            index += usize::from(index + 1 < bytes.len()) + 1;
2122            continue;
2123        }
2124        if bytes[index] == b'\'' {
2125            index += single_quoted_fragment_len(&text[index..])?;
2126            continue;
2127        }
2128        if bytes[index] == b'"' {
2129            index += double_quoted_fragment_len(&text[index..])?;
2130            continue;
2131        }
2132        if bytes[index..].starts_with(b"${") {
2133            index += parameter_expansion_len(&text[index..])?;
2134            continue;
2135        }
2136        if bytes[index..].starts_with(b"$(") {
2137            index += command_substitution_len(&text[index..])?;
2138            continue;
2139        }
2140        if bytes[index] == b'`' {
2141            return Some(index + 1);
2142        }
2143        index += 1;
2144    }
2145
2146    None
2147}
2148
2149#[derive(Default, Clone, Copy)]
2150struct ParameterClassification {
2151    has_array_reference: bool,
2152    is_plain_array_reference: bool,
2153    has_substring_expansion: bool,
2154    has_case_modification: bool,
2155    has_replacement_expansion: bool,
2156    has_positional_parameter_trim: bool,
2157    guards_unset_reference: bool,
2158    is_nested_bourne: bool,
2159}
2160
2161fn classify_parameter_expansion<'a>(
2162    parameter: &'a shuck_ast::ParameterExpansion,
2163    source: &str,
2164) -> (ParameterClassification, Option<&'a VarRef>) {
2165    let mut classification = ParameterClassification::default();
2166    if matches!(&parameter.syntax, ParameterExpansionSyntax::Bourne(_)) {
2167        classification.is_nested_bourne =
2168            contains_nested_parameter_marker(parameter.raw_body.slice(source).trim_start());
2169    }
2170    let deepest = classify_parameter_expansion_walk(parameter, &mut classification, true, true);
2171    (classification, deepest)
2172}
2173
2174fn classify_parameter_expansion_walk<'a>(
2175    parameter: &'a shuck_ast::ParameterExpansion,
2176    classification: &mut ParameterClassification,
2177    is_outermost: bool,
2178    plain_chain: bool,
2179) -> Option<&'a VarRef> {
2180    match &parameter.syntax {
2181        ParameterExpansionSyntax::Bourne(syntax) => {
2182            if is_outermost
2183                && let BourneParameterExpansion::Operation { operator, .. }
2184                | BourneParameterExpansion::Indirect {
2185                    operator: Some(operator),
2186                    ..
2187                } = syntax
2188            {
2189                classification.guards_unset_reference =
2190                    parameter_operator_guards_unset_reference(operator);
2191            }
2192            match syntax {
2193                BourneParameterExpansion::Access { reference } => {
2194                    if reference.subscript.is_some() {
2195                        classification.has_array_reference = true;
2196                        if plain_chain {
2197                            classification.is_plain_array_reference = true;
2198                        }
2199                    }
2200                    Some(reference)
2201                }
2202                BourneParameterExpansion::Length { reference }
2203                | BourneParameterExpansion::Indices { reference }
2204                | BourneParameterExpansion::Indirect { reference, .. }
2205                | BourneParameterExpansion::Transformation { reference, .. } => {
2206                    if reference.subscript.is_some() {
2207                        classification.has_array_reference = true;
2208                    }
2209                    Some(reference)
2210                }
2211                BourneParameterExpansion::Slice { reference, .. } => {
2212                    if reference.subscript.is_some() {
2213                        classification.has_array_reference = true;
2214                    } else {
2215                        classification.has_substring_expansion = true;
2216                    }
2217                    Some(reference)
2218                }
2219                BourneParameterExpansion::Operation {
2220                    reference,
2221                    operator,
2222                    ..
2223                } => {
2224                    if reference.subscript.is_some() {
2225                        classification.has_array_reference = true;
2226                    }
2227                    if matches!(
2228                        operator.as_ref(),
2229                        ParameterOp::UpperFirst
2230                            | ParameterOp::UpperAll
2231                            | ParameterOp::LowerFirst
2232                            | ParameterOp::LowerAll
2233                    ) {
2234                        classification.has_case_modification = true;
2235                    }
2236                    if matches!(
2237                        operator.as_ref(),
2238                        ParameterOp::ReplaceFirst { .. } | ParameterOp::ReplaceAll { .. }
2239                    ) {
2240                        classification.has_replacement_expansion = true;
2241                    }
2242                    if is_outermost && reference_is_positional_parameter_trim(reference, operator) {
2243                        classification.has_positional_parameter_trim = true;
2244                    }
2245                    Some(reference)
2246                }
2247                BourneParameterExpansion::PrefixMatch { .. } => None,
2248            }
2249        }
2250        ParameterExpansionSyntax::Zsh(syntax) => {
2251            let zsh_plain = syntax.length_prefix.is_none()
2252                && syntax.operation.is_none()
2253                && syntax.modifiers.is_empty();
2254            let inner_plain_chain = plain_chain && zsh_plain;
2255            match &syntax.target {
2256                ZshExpansionTarget::Reference(reference) => {
2257                    if reference.subscript.is_some() {
2258                        classification.has_array_reference = true;
2259                        if inner_plain_chain {
2260                            classification.is_plain_array_reference = true;
2261                        }
2262                    }
2263                    Some(reference)
2264                }
2265                ZshExpansionTarget::Nested(inner) => classify_parameter_expansion_walk(
2266                    inner,
2267                    classification,
2268                    false,
2269                    inner_plain_chain,
2270                ),
2271                ZshExpansionTarget::Word(_) | ZshExpansionTarget::Empty => None,
2272            }
2273        }
2274    }
2275}
2276
2277fn parameter_operator_guards_unset_reference(operator: &ParameterOp) -> bool {
2278    matches!(
2279        operator,
2280        ParameterOp::UseDefault
2281            | ParameterOp::AssignDefault
2282            | ParameterOp::UseReplacement
2283            | ParameterOp::Error
2284    )
2285}
2286
2287fn parameter_is_plain_unindexed_reference(parameter: &shuck_ast::ParameterExpansion) -> bool {
2288    match &parameter.syntax {
2289        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) => {
2290            reference.subscript.is_none() && name_is_plain_reference_candidate(&reference.name)
2291        }
2292        ParameterExpansionSyntax::Zsh(syntax)
2293            if syntax.length_prefix.is_none()
2294                && syntax.operation.is_none()
2295                && syntax.modifiers.is_empty() =>
2296        {
2297            match &syntax.target {
2298                ZshExpansionTarget::Reference(reference) => {
2299                    reference.subscript.is_none()
2300                        && name_is_plain_reference_candidate(&reference.name)
2301                }
2302                ZshExpansionTarget::Nested(parameter) => {
2303                    parameter_is_plain_unindexed_reference(parameter)
2304                }
2305                ZshExpansionTarget::Word(_) | ZshExpansionTarget::Empty => false,
2306            }
2307        }
2308        _ => false,
2309    }
2310}
2311
2312fn name_is_plain_reference_candidate(name: &Name) -> bool {
2313    !matches!(name.as_str(), "@" | "*")
2314}
2315
2316fn parameter_operator_has_pattern(operator: &ParameterOp) -> bool {
2317    matches!(
2318        operator,
2319        ParameterOp::RemovePrefixShort { .. }
2320            | ParameterOp::RemovePrefixLong { .. }
2321            | ParameterOp::RemoveSuffixShort { .. }
2322            | ParameterOp::RemoveSuffixLong { .. }
2323            | ParameterOp::ReplaceFirst { .. }
2324            | ParameterOp::ReplaceAll { .. }
2325    )
2326}
2327
2328fn parameter_operator_is_trim(operator: &ParameterOp) -> bool {
2329    matches!(
2330        operator,
2331        ParameterOp::RemovePrefixShort { .. }
2332            | ParameterOp::RemovePrefixLong { .. }
2333            | ParameterOp::RemoveSuffixShort { .. }
2334            | ParameterOp::RemoveSuffixLong { .. }
2335    )
2336}
2337
2338fn collect_parameter_operator_special_target_word_spans(
2339    operator: &ParameterOp,
2340    spans: &mut Vec<Span>,
2341) {
2342    match operator {
2343        ParameterOp::RemovePrefixShort { pattern }
2344        | ParameterOp::RemovePrefixLong { pattern }
2345        | ParameterOp::RemoveSuffixShort { pattern }
2346        | ParameterOp::RemoveSuffixLong { pattern }
2347        | ParameterOp::ReplaceFirst { pattern, .. }
2348        | ParameterOp::ReplaceAll { pattern, .. } => {
2349            collect_pattern_special_target_word_spans(pattern, spans);
2350        }
2351        ParameterOp::UseDefault
2352        | ParameterOp::AssignDefault
2353        | ParameterOp::UseReplacement
2354        | ParameterOp::Error
2355        | ParameterOp::UpperFirst
2356        | ParameterOp::UpperAll
2357        | ParameterOp::LowerFirst
2358        | ParameterOp::LowerAll => {}
2359    }
2360}
2361
2362fn collect_pattern_special_target_word_spans(pattern: &Pattern, spans: &mut Vec<Span>) {
2363    for (part, span) in pattern.parts_with_spans() {
2364        match part {
2365            PatternPart::Group { patterns, .. } => {
2366                for pattern in patterns {
2367                    collect_pattern_special_target_word_spans(pattern, spans);
2368                }
2369            }
2370            PatternPart::Word(_) => spans.push(span),
2371            PatternPart::Literal(_)
2372            | PatternPart::AnyString
2373            | PatternPart::AnyChar
2374            | PatternPart::CharClass(_) => {}
2375        }
2376    }
2377}
2378
2379fn parameter_pattern_target_is_special(reference: &VarRef, operator: &ParameterOp) -> bool {
2380    parameter_operator_has_pattern(operator)
2381        && (reference_has_array_subscript(reference) || reference.name.as_str() == "0")
2382}
2383
2384fn reference_is_positional_parameter_trim(reference: &VarRef, operator: &ParameterOp) -> bool {
2385    parameter_operator_is_trim(operator) && matches!(reference.name.as_str(), "*" | "@")
2386}
2387
2388fn reference_has_array_subscript(reference: &VarRef) -> bool {
2389    reference.subscript.is_some()
2390}
2391
2392fn collect_positional_parameter_operator_spans_in_arithmetic(
2393    expansion_span: Span,
2394    expression_ast: Option<&ArithmeticExprNode>,
2395    expression: &SourceText,
2396    source: &str,
2397    spans: &mut Vec<Span>,
2398) {
2399    if let Some(expression_ast) = expression_ast {
2400        if arithmetic_expr_has_positional_parameter_operator(expression_ast, source) {
2401            spans.push(Span::from_positions(
2402                expansion_span.start,
2403                expansion_span.start,
2404            ));
2405        }
2406        return;
2407    }
2408
2409    let text = expression.slice(source);
2410    let mut should_report = false;
2411    let mut state = ArithmeticScanState::default();
2412    let mut chars = text.char_indices();
2413
2414    while let Some((index, char)) = chars.next() {
2415        match state {
2416            ArithmeticScanState::Normal => match char {
2417                '\'' => state = ArithmeticScanState::SingleQuoted,
2418                '"' => state = ArithmeticScanState::DoubleQuoted,
2419                '\\' => {
2420                    chars.next();
2421                }
2422                '$' => {
2423                    let Some(token_end) = positional_parameter_token_end(text, index) else {
2424                        continue;
2425                    };
2426
2427                    let immediate_prev = text[..index].chars().next_back();
2428                    let immediate_next = text[token_end..].chars().next();
2429                    let same_word_prefix =
2430                        immediate_prev.is_some_and(|ch| !raw_arithmetic_word_boundary(ch));
2431                    let same_word_suffix =
2432                        immediate_next.is_some_and(|ch| !raw_arithmetic_word_boundary(ch));
2433
2434                    if same_word_prefix || same_word_suffix {
2435                        if same_word_prefix {
2436                            let word_start = raw_arithmetic_word_start(text, index);
2437                            let prefix = &text[word_start..index];
2438                            if prefix_starts_with_identifier_like_text(prefix) {
2439                                should_report = true;
2440                                break;
2441                            }
2442                        }
2443                        continue;
2444                    }
2445
2446                    let prev = text[..index].chars().rev().find(|ch| !ch.is_whitespace());
2447                    let next = text[token_end..].chars().find(|ch| !ch.is_whitespace());
2448
2449                    if prev.is_some_and(is_left_operand_neighbor)
2450                        || next.is_some_and(is_right_operand_neighbor)
2451                    {
2452                        should_report = true;
2453                        break;
2454                    }
2455                }
2456                _ => {}
2457            },
2458            ArithmeticScanState::SingleQuoted => {
2459                if char == '\'' {
2460                    state = ArithmeticScanState::Normal;
2461                }
2462            }
2463            ArithmeticScanState::DoubleQuoted => match char {
2464                '"' => state = ArithmeticScanState::Normal,
2465                '\\' => {
2466                    chars.next();
2467                }
2468                _ => {}
2469            },
2470        }
2471    }
2472
2473    if should_report {
2474        spans.push(Span::from_positions(
2475            expansion_span.start,
2476            expansion_span.start,
2477        ));
2478    }
2479}
2480
2481fn raw_arithmetic_word_start(text: &str, end: usize) -> usize {
2482    let mut start = end;
2483
2484    while let Some((index, ch)) = text[..start].char_indices().next_back() {
2485        if raw_arithmetic_word_boundary(ch) {
2486            break;
2487        }
2488        start = index;
2489    }
2490
2491    start
2492}
2493
2494fn raw_arithmetic_word_boundary(ch: char) -> bool {
2495    ch.is_whitespace()
2496        || matches!(
2497            ch,
2498            '+' | '-'
2499                | '*'
2500                | '/'
2501                | '%'
2502                | '&'
2503                | '|'
2504                | '^'
2505                | '?'
2506                | ':'
2507                | '<'
2508                | '>'
2509                | '='
2510                | '!'
2511                | '~'
2512                | ','
2513                | '('
2514                | '['
2515        )
2516}
2517
2518fn arithmetic_expr_has_positional_parameter_operator(
2519    expression: &ArithmeticExprNode,
2520    source: &str,
2521) -> bool {
2522    let mut should_report = false;
2523    visit_arithmetic_words(expression, &mut |word| {
2524        if word_has_unquoted_positional_parameter_operator_neighbors(word, source) {
2525            should_report = true;
2526        }
2527    });
2528    should_report
2529}
2530
2531fn word_has_unquoted_positional_parameter_operator_neighbors(word: &Word, source: &str) -> bool {
2532    word.parts.iter().enumerate().any(|(index, part)| {
2533        part_is_unquoted_positional_parameter(&part.kind)
2534            && positional_parameter_part_has_identifier_like_prefix(word, index, source)
2535    })
2536}
2537
2538fn part_is_unquoted_positional_parameter(part: &WordPart) -> bool {
2539    match part {
2540        WordPart::Variable(name) => name_is_positional_parameter(name),
2541        WordPart::Parameter(parameter) => matches!(
2542            &parameter.syntax,
2543            ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
2544                if reference.subscript.is_none()
2545                    && name_is_positional_parameter(&reference.name)
2546        ),
2547        WordPart::ArrayAccess(reference) => {
2548            reference.subscript.is_none() && name_is_positional_parameter(&reference.name)
2549        }
2550        WordPart::Literal(_)
2551        | WordPart::ZshQualifiedGlob(_)
2552        | WordPart::SingleQuoted { .. }
2553        | WordPart::DoubleQuoted { .. }
2554        | WordPart::CommandSubstitution { .. }
2555        | WordPart::ArithmeticExpansion { .. }
2556        | WordPart::ParameterExpansion { .. }
2557        | WordPart::Length(_)
2558        | WordPart::ArrayLength(_)
2559        | WordPart::ArrayIndices(_)
2560        | WordPart::Substring { .. }
2561        | WordPart::ArraySlice { .. }
2562        | WordPart::IndirectExpansion { .. }
2563        | WordPart::PrefixMatch { .. }
2564        | WordPart::ProcessSubstitution { .. }
2565        | WordPart::Transformation { .. } => false,
2566    }
2567}
2568
2569fn name_is_positional_parameter(name: &Name) -> bool {
2570    !name.as_str().is_empty() && name.as_str().bytes().all(|byte| byte.is_ascii_digit())
2571}
2572
2573fn positional_parameter_part_has_identifier_like_prefix(
2574    word: &Word,
2575    index: usize,
2576    source: &str,
2577) -> bool {
2578    let Some(part) = word.parts.get(index) else {
2579        return false;
2580    };
2581
2582    let prefix = &source[word.span.start.offset..part.span.start.offset];
2583    prefix_starts_with_identifier_like_text(prefix)
2584}
2585
2586fn prefix_starts_with_identifier_like_text(prefix: &str) -> bool {
2587    let Some(first_non_whitespace) = prefix.chars().find(|ch| !ch.is_whitespace()) else {
2588        return false;
2589    };
2590
2591    first_non_whitespace == '_' || first_non_whitespace.is_ascii_alphabetic()
2592}
2593
2594#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2595enum ArithmeticScanState {
2596    #[default]
2597    Normal,
2598    SingleQuoted,
2599    DoubleQuoted,
2600}
2601
2602fn positional_parameter_token_end(text: &str, start: usize) -> Option<usize> {
2603    let rest = text.get(start..)?;
2604    if !rest.starts_with('$') {
2605        return None;
2606    }
2607
2608    let bytes = rest.as_bytes();
2609    if bytes.get(1).is_some_and(u8::is_ascii_digit) {
2610        let mut idx = 2usize;
2611        while bytes.get(idx).is_some_and(u8::is_ascii_digit) {
2612            idx += 1;
2613        }
2614        return Some(start + idx);
2615    }
2616
2617    if bytes.get(1) == Some(&b'{') {
2618        let mut idx = 2usize;
2619        let mut saw_digit = false;
2620        while bytes.get(idx).is_some_and(u8::is_ascii_digit) {
2621            saw_digit = true;
2622            idx += 1;
2623        }
2624        if saw_digit && bytes.get(idx) == Some(&b'}') {
2625            return Some(start + idx + 1);
2626        }
2627    }
2628
2629    None
2630}
2631
2632fn is_left_operand_neighbor(ch: char) -> bool {
2633    ch.is_ascii_alphanumeric() || matches!(ch, '_' | ')' | ']' | '}' | '"' | '\'')
2634}
2635
2636fn is_right_operand_neighbor(ch: char) -> bool {
2637    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '(' | '[' | '{' | '"' | '\'')
2638}
2639
2640pub(crate) fn build_suppressed_subscript_reference_spans(
2641    semantic: &SemanticModel,
2642    suppressed_subscript_spans: &[Span],
2643    arithmetic_only_suppressed_subscript_spans: &[Span],
2644) -> FxHashSet<FactSpan> {
2645    if suppressed_subscript_spans.is_empty()
2646        && arithmetic_only_suppressed_subscript_spans.is_empty()
2647    {
2648        return FxHashSet::default();
2649    }
2650
2651    let mut spans =
2652        build_subscript_reference_spans_with_filter(semantic, suppressed_subscript_spans, |_| true);
2653    spans.extend(build_subscript_reference_spans_with_filter(
2654        semantic,
2655        arithmetic_only_suppressed_subscript_spans,
2656        |reference| matches!(reference.kind, ReferenceKind::ArithmeticRead),
2657    ));
2658    spans
2659}
2660
2661#[cfg(test)]
2662pub(crate) fn build_subscript_later_suppression_reference_spans(
2663    semantic: &SemanticModel,
2664    subscript_later_suppression_spans: &[Span],
2665) -> FxHashSet<FactSpan> {
2666    build_subscript_reference_spans_with_filter(semantic, subscript_later_suppression_spans, |_| {
2667        true
2668    })
2669}
2670
2671fn build_subscript_reference_spans_with_filter(
2672    semantic: &SemanticModel,
2673    subscript_spans: &[Span],
2674    mut include_reference: impl FnMut(&shuck_semantic::Reference) -> bool,
2675) -> FxHashSet<FactSpan> {
2676    if subscript_spans.is_empty() {
2677        return FxHashSet::default();
2678    }
2679
2680    subscript_spans
2681        .iter()
2682        .flat_map(|span| semantic.references_in_span(*span))
2683        .filter(|reference| include_reference(reference))
2684        .map(|reference| FactSpan::new(reference.span))
2685        .collect()
2686}
2687
2688fn span_contains(outer: Span, inner: Span) -> bool {
2689    outer.start.offset <= inner.start.offset && inner.end.offset <= outer.end.offset
2690}
2691
2692fn word_looks_like_unset_array_target(word: &Word, source: &str) -> bool {
2693    let text = word.span.slice(source);
2694    let Some((name, _)) = text.split_once('[') else {
2695        return false;
2696    };
2697    text.ends_with(']') && is_shell_variable_name(name)
2698}
2699
2700fn collect_suspect_double_quote_spans(
2701    word: &Word,
2702    source: &str,
2703    _command_name: Option<&str>,
2704    assignment_target: Option<&str>,
2705    spans: &mut Vec<(Span, Span)>,
2706) {
2707    for (index, current) in word.parts.iter().enumerate() {
2708        if !suspicious_open_quote_fragment(
2709            word,
2710            source,
2711            index,
2712            current,
2713            assignment_target.is_some(),
2714        ) {
2715            continue;
2716        }
2717
2718        let Some(opening_span) = opening_quote_span(current, source) else {
2719            continue;
2720        };
2721        let Some(closing_span) = closing_quote_span(current, source) else {
2722            continue;
2723        };
2724        spans.push((opening_span, closing_span));
2725    }
2726}
2727
2728pub(crate) fn rewrite_word_as_single_double_quoted_string(
2729    word: &Word,
2730    source: &str,
2731    _assignment_target: Option<&str>,
2732) -> Box<str> {
2733    let mut rendered = String::from("\"");
2734    for part in &word.parts {
2735        render_word_part_inside_double_quotes(&mut rendered, part, source, false);
2736    }
2737    rendered.push('"');
2738    rendered.into_boxed_str()
2739}
2740
2741pub(crate) fn rewrite_pattern_as_single_double_quoted_string(
2742    pattern: &Pattern,
2743    source: &str,
2744) -> Box<str> {
2745    let mut rendered = String::from("\"");
2746    for part in &pattern.parts {
2747        render_pattern_part_inside_double_quotes(&mut rendered, part, source);
2748    }
2749    rendered.push('"');
2750    rendered.into_boxed_str()
2751}
2752
2753fn render_pattern_part_inside_double_quotes(
2754    rendered: &mut String,
2755    part: &PatternPartNode,
2756    source: &str,
2757) {
2758    match &part.kind {
2759        PatternPart::Literal(text) => {
2760            push_double_quoted_literal(rendered, text.as_str(source, part.span));
2761        }
2762        PatternPart::Word(word) => {
2763            for word_part in &word.parts {
2764                render_word_part_inside_double_quotes(rendered, word_part, source, false);
2765            }
2766        }
2767        PatternPart::AnyString
2768        | PatternPart::AnyChar
2769        | PatternPart::CharClass(_)
2770        | PatternPart::Group { .. } => {
2771            let syntax = Pattern {
2772                parts: vec![part.clone()],
2773                span: part.span,
2774            }
2775            .render_syntax(source);
2776            push_double_quoted_literal(rendered, &syntax);
2777        }
2778    }
2779}
2780
2781fn render_word_part_inside_double_quotes(
2782    rendered: &mut String,
2783    part: &WordPartNode,
2784    source: &str,
2785    source_is_double_quoted: bool,
2786) {
2787    match &part.kind {
2788        WordPart::Literal(text) => {
2789            if source_is_double_quoted {
2790                push_double_quoted_literal(rendered, text.as_str(source, part.span));
2791            } else {
2792                push_cooked_unquoted_literal_inside_double_quotes(
2793                    rendered,
2794                    text.as_str(source, part.span),
2795                );
2796            }
2797        }
2798        WordPart::SingleQuoted { value, .. } => {
2799            push_double_quoted_literal(rendered, value.slice(source));
2800        }
2801        WordPart::DoubleQuoted { parts, .. } => {
2802            for nested_part in parts {
2803                render_word_part_inside_double_quotes(rendered, nested_part, source, true);
2804            }
2805        }
2806        WordPart::Variable(name) => {
2807            rendered.push_str("${");
2808            rendered.push_str(name.as_ref());
2809            rendered.push('}');
2810        }
2811        _ => rendered.push_str(&word_part_syntax(part, source)),
2812    }
2813}
2814
2815fn push_double_quoted_literal(rendered: &mut String, text: &str) {
2816    for ch in text.chars() {
2817        match ch {
2818            '"' | '\\' | '$' | '`' => {
2819                rendered.push('\\');
2820                rendered.push(ch);
2821            }
2822            _ => rendered.push(ch),
2823        }
2824    }
2825}
2826
2827fn push_cooked_unquoted_literal_inside_double_quotes(rendered: &mut String, text: &str) {
2828    let mut cooked = String::with_capacity(text.len());
2829    let mut chars = text.chars();
2830
2831    while let Some(ch) = chars.next() {
2832        if ch == '\\' {
2833            match chars.next() {
2834                Some('\n') => {}
2835                Some(escaped) => cooked.push(escaped),
2836                None => cooked.push('\\'),
2837            }
2838            continue;
2839        }
2840
2841        cooked.push(ch);
2842    }
2843
2844    push_double_quoted_literal(rendered, &cooked);
2845}
2846
2847fn word_part_syntax(part: &WordPartNode, source: &str) -> String {
2848    Word {
2849        parts: vec![part.clone()],
2850        span: part.span,
2851        brace_syntax: Vec::new(),
2852    }
2853    .render_syntax(source)
2854}
2855
2856pub(crate) fn word_has_reopened_double_quote_window(
2857    word: &Word,
2858    source: &str,
2859    command_name: Option<&str>,
2860) -> bool {
2861    word_has_suspect_double_quote_span(word, source, command_name, None)
2862}
2863
2864fn word_has_suspect_double_quote_span(
2865    word: &Word,
2866    source: &str,
2867    _command_name: Option<&str>,
2868    assignment_target: Option<&str>,
2869) -> bool {
2870    word.parts.iter().enumerate().any(|(index, current)| {
2871        suspicious_open_quote_fragment(word, source, index, current, assignment_target.is_some())
2872            && opening_quote_span(current, source).is_some()
2873            && closing_quote_span(current, source).is_some()
2874    })
2875}
2876
2877fn suspicious_open_quote_fragment(
2878    word: &Word,
2879    source: &str,
2880    index: usize,
2881    current: &WordPartNode,
2882    assignment_context: bool,
2883) -> bool {
2884    (!assignment_context && suspicious_multiline_double_quote_suffix(word, source, index, current))
2885        || suspicious_reopened_single_quote_window(word, source, index, current)
2886}
2887
2888fn suspicious_multiline_double_quote_suffix(
2889    word: &Word,
2890    source: &str,
2891    index: usize,
2892    current: &WordPartNode,
2893) -> bool {
2894    if !matches!(current.kind, WordPart::DoubleQuoted { .. })
2895        || !current.span.slice(source).contains('\n')
2896        || !quote_starts_nonempty_multiline_fragment(current, source)
2897    {
2898        return false;
2899    }
2900
2901    let Some(next) = word.parts.get(index + 1) else {
2902        return false;
2903    };
2904    if immediate_double_quote_continuation_is_suspicious(next, source) {
2905        return true;
2906    }
2907
2908    word_part_is_empty_literal(next, source)
2909        && !word.parts[..index].iter().any(|part| {
2910            matches!(part.kind, WordPart::DoubleQuoted { .. })
2911                && part.span.slice(source).contains('\n')
2912        })
2913        && word
2914            .parts
2915            .get(index + 2)
2916            .is_some_and(|part| matches!(part.kind, WordPart::DoubleQuoted { .. }))
2917}
2918
2919fn suspicious_reopened_single_quote_window(
2920    word: &Word,
2921    source: &str,
2922    index: usize,
2923    current: &WordPartNode,
2924) -> bool {
2925    let WordPart::SingleQuoted { dollar: false, .. } = current.kind else {
2926        return false;
2927    };
2928    current.span.slice(source).contains('\n')
2929        && single_quote_reopens_after_literal_run(&word.parts[index + 1..], source)
2930}
2931
2932fn single_quote_reopens_after_literal_run(parts: &[WordPartNode], source: &str) -> bool {
2933    let mut saw_literal = false;
2934    let mut first_nonempty_literal: Option<&str> = None;
2935
2936    for part in parts {
2937        match &part.kind {
2938            WordPart::Literal(text) => {
2939                let text = text.as_str(source, part.span);
2940                if !text.is_empty() {
2941                    saw_literal = true;
2942                    if first_nonempty_literal.is_none() {
2943                        first_nonempty_literal = Some(text);
2944                    }
2945                }
2946            }
2947            WordPart::SingleQuoted { dollar: false, .. } => {
2948                return if let Some(first_literal) = first_nonempty_literal {
2949                    !first_literal.starts_with('\\')
2950                } else {
2951                    saw_literal || !parts.is_empty()
2952                };
2953            }
2954            WordPart::SingleQuoted { dollar: true, .. }
2955            | WordPart::DoubleQuoted { .. }
2956            | WordPart::Variable(_)
2957            | WordPart::ArithmeticExpansion { .. }
2958            | WordPart::CommandSubstitution { .. }
2959            | WordPart::ProcessSubstitution { .. }
2960            | WordPart::Parameter(_)
2961            | WordPart::ParameterExpansion { .. }
2962            | WordPart::Length(_)
2963            | WordPart::ArrayAccess(_)
2964            | WordPart::ArrayLength(_)
2965            | WordPart::ArrayIndices(_)
2966            | WordPart::Substring { .. }
2967            | WordPart::ArraySlice { .. }
2968            | WordPart::IndirectExpansion { .. }
2969            | WordPart::PrefixMatch { .. }
2970            | WordPart::Transformation { .. }
2971            | WordPart::ZshQualifiedGlob(_) => return false,
2972        }
2973    }
2974
2975    false
2976}
2977
2978fn middle_part_is_word_like_literal_gap(part: &WordPartNode, source: &str) -> bool {
2979    let WordPart::Literal(text) = &part.kind else {
2980        return false;
2981    };
2982    let text = text.as_str(source, part.span);
2983    split_quote_tail_is_suspicious(text) || backslash_prefixed_word_like_literal_gap(text)
2984}
2985
2986fn word_part_is_empty_literal(part: &WordPartNode, source: &str) -> bool {
2987    matches!(&part.kind, WordPart::Literal(text) if text.as_str(source, part.span).is_empty())
2988}
2989
2990fn quote_starts_nonempty_multiline_fragment(part: &WordPartNode, source: &str) -> bool {
2991    let quote = match part.kind {
2992        WordPart::DoubleQuoted { .. } => '"',
2993        WordPart::SingleQuoted { .. } => '\'',
2994        _ => return false,
2995    };
2996    let text = part.span.slice(source);
2997    let Some(quote_offset) = text.find(quote) else {
2998        return false;
2999    };
3000    let after_quote = &text[quote_offset + quote.len_utf8()..];
3001    !after_quote.starts_with('\n') && !after_quote.starts_with("\r\n")
3002}
3003
3004fn immediate_double_quote_continuation_is_suspicious(part: &WordPartNode, source: &str) -> bool {
3005    matches!(part.kind, WordPart::DoubleQuoted { .. })
3006        || immediate_double_quote_scalar_gap(part)
3007        || immediate_double_quote_substitution_gap(part)
3008        || middle_part_is_word_like_literal_gap(part, source)
3009}
3010
3011fn immediate_double_quote_scalar_gap(part: &WordPartNode) -> bool {
3012    matches!(
3013        part.kind,
3014        WordPart::Variable(_)
3015            | WordPart::ArithmeticExpansion { .. }
3016            | WordPart::Parameter(_)
3017            | WordPart::ParameterExpansion { .. }
3018            | WordPart::Length(_)
3019            | WordPart::ArrayAccess(_)
3020            | WordPart::ArrayLength(_)
3021            | WordPart::ArrayIndices(_)
3022            | WordPart::Substring { .. }
3023            | WordPart::ArraySlice { .. }
3024            | WordPart::IndirectExpansion { .. }
3025            | WordPart::PrefixMatch { .. }
3026            | WordPart::Transformation { .. }
3027    )
3028}
3029
3030fn immediate_double_quote_substitution_gap(part: &WordPartNode) -> bool {
3031    matches!(
3032        part.kind,
3033        WordPart::CommandSubstitution { .. } | WordPart::ProcessSubstitution { .. }
3034    )
3035}
3036
3037fn double_quoted_part_is_empty(part: &WordPartNode, source: &str) -> bool {
3038    let WordPart::DoubleQuoted { parts, .. } = &part.kind else {
3039        return false;
3040    };
3041    parts.iter().all(|inner| match &inner.kind {
3042        WordPart::Literal(text) => text.as_str(source, inner.span).is_empty(),
3043        _ => false,
3044    })
3045}
3046
3047fn collect_split_suspect_closing_quote_spans(
3048    word: &Word,
3049    source: &str,
3050    has_later_words: bool,
3051    spans: &mut Vec<Span>,
3052) {
3053    for (index, window) in word.parts.windows(2).enumerate() {
3054        let [current, next] = window else {
3055            continue;
3056        };
3057        let WordPart::DoubleQuoted { .. } = &current.kind else {
3058            continue;
3059        };
3060        let WordPart::Literal(text) = &next.kind else {
3061            continue;
3062        };
3063        if !current.span.slice(source).contains('\n') {
3064            continue;
3065        }
3066
3067        let tail = text.as_str(source, next.span);
3068        if !split_quote_tail_is_suspicious(tail) {
3069            continue;
3070        }
3071
3072        let Some(span) = closing_quote_span(current, source) else {
3073            continue;
3074        };
3075        if span.start.column == 1
3076            || (index > 0
3077                && double_quoted_part_is_empty(&word.parts[index - 1], source)
3078                && has_later_words)
3079        {
3080            spans.push(span);
3081        }
3082    }
3083}
3084
3085fn split_quote_tail_is_suspicious(text: &str) -> bool {
3086    let mut chars = text.chars();
3087    let Some(first) = chars.next() else {
3088        return false;
3089    };
3090    (first == '_' || first.is_ascii_alphabetic()) && chars.all(|char| !char.is_whitespace())
3091}
3092
3093fn opening_quote_span(part: &WordPartNode, source: &str) -> Option<Span> {
3094    let quote = match part.kind {
3095        WordPart::DoubleQuoted { .. } => '"',
3096        WordPart::SingleQuoted { .. } => '\'',
3097        _ => return None,
3098    };
3099    let span = part.span;
3100    let text = span.slice(source);
3101    let quote_offset = text.find(quote)?;
3102    let start = span.start.advanced_by(&text[..quote_offset]);
3103    Some(Span::from_positions(start, start))
3104}
3105
3106fn closing_quote_span(part: &WordPartNode, source: &str) -> Option<Span> {
3107    let quote = match part.kind {
3108        WordPart::DoubleQuoted { .. } => '"',
3109        WordPart::SingleQuoted { .. } => '\'',
3110        _ => return None,
3111    };
3112    let span = part.span;
3113    let text = span.slice(source);
3114    let quote_offset = text.rfind(quote)?;
3115    let start = span.start.advanced_by(&text[..quote_offset]);
3116    Some(Span::from_positions(start, start))
3117}
3118
3119fn backslash_prefixed_word_like_literal_gap(text: &str) -> bool {
3120    let text = text.trim();
3121    let Some(stripped) = text.strip_prefix('\\') else {
3122        return false;
3123    };
3124    !escaped_dollar_literal_gap(text) && split_quote_tail_is_suspicious(stripped)
3125}
3126
3127fn escaped_dollar_literal_gap(text: &str) -> bool {
3128    let mut saw_escaped_dollar = false;
3129    let mut chars = text.chars();
3130
3131    while let Some(ch) = chars.next() {
3132        if ch != '\\' {
3133            continue;
3134        }
3135
3136        saw_escaped_dollar = true;
3137        if chars.next() != Some('$') {
3138            return false;
3139        }
3140    }
3141
3142    saw_escaped_dollar
3143}
3144
3145fn contains_nested_parameter_marker(text: &str) -> bool {
3146    let inner = text
3147        .strip_prefix("${${")
3148        .or_else(|| text.strip_prefix("${#${"))
3149        .or_else(|| text.strip_prefix("${!${"));
3150    inner
3151        .and_then(|inner| inner.chars().next())
3152        .is_some_and(is_bourne_nested_parameter_start)
3153}
3154
3155fn is_bourne_nested_parameter_start(char: char) -> bool {
3156    matches!(char, '_' | '@' | '*' | '#' | '?' | '$' | '!' | '-') || char.is_ascii_alphanumeric()
3157}
3158pub(crate) fn simple_command_variable_set_operand<'a>(
3159    command: &'a SimpleCommand,
3160    source: &str,
3161) -> Option<&'a Word> {
3162    let operands = simple_test_operands(command, source)?;
3163    (operands.len() == 2 && static_word_text(&operands[0], source).as_deref() == Some("-v"))
3164        .then(|| &operands[1])
3165}
3166
3167fn is_unicode_smart_quote(char: char) -> bool {
3168    matches!(char, '\u{2018}' | '\u{2019}' | '\u{201C}' | '\u{201D}')
3169}
3170
3171/// First UTF-8 byte shared by every smart-quote codepoint scanned above. Used as a fast skip
3172/// over ASCII-only literals that cannot contain any smart quote.
3173const UNICODE_SMART_QUOTE_LEAD_BYTE: u8 = 0xE2;
3174
3175#[cfg(test)]
3176mod tests {
3177    use super::{
3178        arithmetic_expr_has_positional_parameter_operator,
3179        word_has_unquoted_positional_parameter_operator_neighbors,
3180    };
3181    use shuck_ast::{Command, WordPart};
3182    use shuck_parser::parser::Parser;
3183
3184    #[test]
3185    fn detects_identifier_led_prefixes_before_positional_parameters_in_arithmetic_words() {
3186        for text in ["prefix$1", "a${1}", "foo${bar}$1"] {
3187            let word = Parser::parse_word_string(text);
3188            assert!(
3189                word_has_unquoted_positional_parameter_operator_neighbors(&word, text),
3190                "expected {text:?} to be flagged",
3191            );
3192        }
3193    }
3194
3195    #[test]
3196    fn ignores_suffixes_and_non_identifier_prefixes_around_positional_parameters() {
3197        for text in [
3198            "$1",
3199            "${1}",
3200            "$1suffix",
3201            "${1}suffix",
3202            "\"$1\"",
3203            "'$1'",
3204            "16#$1",
3205            "0x$1",
3206            "0x${1}${2}",
3207            "1a$1",
3208            "${base}$1",
3209        ] {
3210            let word = Parser::parse_word_string(text);
3211            assert!(
3212                !word_has_unquoted_positional_parameter_operator_neighbors(&word, text),
3213                "expected {text:?} to be ignored",
3214            );
3215        }
3216    }
3217
3218    #[test]
3219    fn detects_positional_parameter_operator_in_parsed_arithmetic_shell_word() {
3220        let source = "#!/bin/sh\necho \"$(( value + prefix$1 ))\"\n";
3221        let output = Parser::new(source).parse().unwrap();
3222        let command = output.file.body.stmts.first().expect("expected command");
3223        let Command::Simple(command) = &command.command else {
3224            panic!("expected simple command");
3225        };
3226        let expression_ast = command.args[0]
3227            .parts
3228            .iter()
3229            .find_map(|part| match &part.kind {
3230                WordPart::DoubleQuoted { parts, .. } => parts.iter().find_map(|part| {
3231                    if let WordPart::ArithmeticExpansion {
3232                        expression_ast: Some(expression_ast),
3233                        ..
3234                    } = &part.kind
3235                    {
3236                        Some(expression_ast)
3237                    } else {
3238                        None
3239                    }
3240                }),
3241                _ => None,
3242            })
3243            .expect("expected parsed arithmetic expression");
3244
3245        assert!(arithmetic_expr_has_positional_parameter_operator(
3246            expression_ast,
3247            source
3248        ));
3249    }
3250}