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) = ¶meter.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 ¶meter.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!(¶meter.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 ¶meter.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 ¶meter.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 ¶meter.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 { .. } = ¤t.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
3171const 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}