use super::*;
use shuck_ast::{
HeredocBody, HeredocBodyPart, HeredocBodyPartNode, PatternGroupKind, PatternPartNode, Position,
};
#[derive(Debug)]
pub struct SingleQuotedFragmentFact {
span: Span,
diagnostic_span: Span,
dollar_quoted: bool,
command_name: Option<Box<str>>,
assignment_target: Option<Box<str>>,
shell_dialect: shuck_parser::ShellDialect,
variable_set_operand: bool,
literal_expansion_exempt: bool,
literal_backslash_in_single_quotes_span: Option<Span>,
}
impl SingleQuotedFragmentFact {
pub fn span(&self) -> Span {
self.span
}
pub fn diagnostic_span(&self) -> Span {
self.diagnostic_span
}
pub fn dollar_quoted(&self) -> bool {
self.dollar_quoted
}
pub fn command_name(&self) -> Option<&str> {
self.command_name.as_deref()
}
pub fn assignment_target(&self) -> Option<&str> {
self.assignment_target.as_deref()
}
pub fn shell_dialect(&self) -> shuck_parser::ShellDialect {
self.shell_dialect
}
pub fn variable_set_operand(&self) -> bool {
self.variable_set_operand
}
pub fn literal_expansion_exempt(&self) -> bool {
self.literal_expansion_exempt
}
pub fn literal_backslash_in_single_quotes_span(&self) -> Option<Span> {
self.literal_backslash_in_single_quotes_span
}
}
#[derive(Debug, Clone, Copy)]
pub struct DollarDoubleQuotedFragmentFact {
span: Span,
}
impl DollarDoubleQuotedFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone)]
pub struct OpenDoubleQuoteFragmentFact {
span: Span,
replacement_span: Span,
replacement: Box<str>,
}
impl OpenDoubleQuoteFragmentFact {
pub fn span(&self) -> Span {
self.span
}
pub fn replacement_span(&self) -> Span {
self.replacement_span
}
pub fn replacement(&self) -> &str {
&self.replacement
}
}
#[derive(Debug, Clone)]
pub struct CasePatternExpansionFact {
span: Span,
replacement: Box<str>,
}
impl CasePatternExpansionFact {
pub(super) fn new(span: Span, replacement: Box<str>) -> Self {
Self { span, replacement }
}
pub fn span(&self) -> Span {
self.span
}
pub fn replacement(&self) -> &str {
&self.replacement
}
}
#[derive(Debug, Clone, Copy)]
pub struct SuspectClosingQuoteFragmentFact {
span: Span,
}
impl SuspectClosingQuoteFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone, Copy)]
pub struct BacktickFragmentFact {
span: Span,
empty: bool,
}
impl BacktickFragmentFact {
pub fn span(&self) -> Span {
self.span
}
pub fn is_empty(&self) -> bool {
self.empty
}
}
#[derive(Debug, Clone, Copy)]
pub struct LegacyArithmeticFragmentFact {
span: Span,
}
impl LegacyArithmeticFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ArithmeticLiteralKind {
ExplicitBasePrefix,
LeadingZeroInteger,
}
#[derive(Debug, Clone, Copy)]
pub struct ArithmeticLiteralFact {
span: Span,
kind: ArithmeticLiteralKind,
behavior: ArithmeticLiteralBehavior,
}
impl ArithmeticLiteralFact {
pub(super) fn new(
span: Span,
kind: ArithmeticLiteralKind,
behavior: ArithmeticLiteralBehavior,
) -> Self {
Self {
span,
kind,
behavior,
}
}
pub fn span(&self) -> Span {
self.span
}
pub fn kind(&self) -> ArithmeticLiteralKind {
self.kind
}
pub fn behavior(&self) -> ArithmeticLiteralBehavior {
self.behavior
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PositionalParameterFragmentKind {
AboveNine,
General,
}
#[derive(Debug, Clone, Copy)]
pub struct PositionalParameterFragmentFact {
span: Span,
kind: PositionalParameterFragmentKind,
guarded: bool,
}
impl PositionalParameterFragmentFact {
pub fn span(&self) -> Span {
self.span
}
pub fn kind(&self) -> PositionalParameterFragmentKind {
self.kind
}
pub fn is_above_nine(&self) -> bool {
self.kind == PositionalParameterFragmentKind::AboveNine
}
pub fn is_guarded(&self) -> bool {
self.guarded
}
}
#[derive(Debug, Clone, Copy)]
pub struct NestedParameterExpansionFragmentFact {
span: Span,
}
impl NestedParameterExpansionFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone, Copy)]
pub struct IndirectExpansionFragmentFact {
span: Span,
array_keys: bool,
}
impl IndirectExpansionFragmentFact {
pub fn span(&self) -> Span {
self.span
}
pub fn array_keys(&self) -> bool {
self.array_keys
}
}
#[derive(Debug, Clone, Copy)]
pub enum IndexedArrayReferenceFragmentFact {
OneBased(IndexedArrayReferenceFragment),
ZeroBased(IndexedArrayReferenceFragment),
OneBasedWithZeroAlias(IndexedArrayReferenceFragment),
Ambiguous(IndexedArrayReferenceFragment),
}
impl IndexedArrayReferenceFragmentFact {
pub(super) fn new(span: Span, plain: bool) -> Self {
Self::Ambiguous(IndexedArrayReferenceFragment { span, plain })
}
pub(super) fn with_plain(mut self, plain: bool) -> Self {
match &mut self {
Self::OneBased(fragment)
| Self::ZeroBased(fragment)
| Self::OneBasedWithZeroAlias(fragment)
| Self::Ambiguous(fragment) => fragment.plain |= plain,
}
self
}
pub(super) fn with_subscript_index_behavior(self, behavior: SubscriptIndexBehavior) -> Self {
let fragment = match self {
Self::OneBased(fragment)
| Self::ZeroBased(fragment)
| Self::OneBasedWithZeroAlias(fragment)
| Self::Ambiguous(fragment) => fragment,
};
match behavior {
SubscriptIndexBehavior::OneBased => Self::OneBased(fragment),
SubscriptIndexBehavior::ZeroBased => Self::ZeroBased(fragment),
SubscriptIndexBehavior::OneBasedWithZeroAlias => Self::OneBasedWithZeroAlias(fragment),
SubscriptIndexBehavior::Ambiguous => Self::Ambiguous(fragment),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct IndexedArrayReferenceFragment {
span: Span,
plain: bool,
}
impl IndexedArrayReferenceFragment {
pub fn span(&self) -> Span {
self.span
}
pub fn is_plain(&self) -> bool {
self.plain
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlainUnindexedArrayReferenceFact {
SelectorRequired(SelectorRequiredArrayReference),
NativeZshScalar(NativeZshScalarArrayReference),
Ambiguous(AmbiguousArrayReference),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectorRequiredArrayReference {
reference_id: ReferenceId,
diagnostic_span: Span,
}
impl SelectorRequiredArrayReference {
pub(crate) fn new(reference_id: ReferenceId, diagnostic_span: Span) -> Self {
Self {
reference_id,
diagnostic_span,
}
}
pub fn reference_id(&self) -> ReferenceId {
self.reference_id
}
pub fn diagnostic_span(&self) -> Span {
self.diagnostic_span
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NativeZshScalarArrayReference {
reference_id: ReferenceId,
expansion_span: Span,
}
impl NativeZshScalarArrayReference {
pub(crate) fn new(reference_id: ReferenceId, expansion_span: Span) -> Self {
Self {
reference_id,
expansion_span,
}
}
pub fn reference_id(&self) -> ReferenceId {
self.reference_id
}
pub fn expansion_span(&self) -> Span {
self.expansion_span
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AmbiguousArrayReference {
reference_id: ReferenceId,
diagnostic_span: Span,
}
impl AmbiguousArrayReference {
pub(crate) fn new(reference_id: ReferenceId, diagnostic_span: Span) -> Self {
Self {
reference_id,
diagnostic_span,
}
}
pub fn reference_id(&self) -> ReferenceId {
self.reference_id
}
pub fn diagnostic_span(&self) -> Span {
self.diagnostic_span
}
}
#[derive(Debug, Clone, Copy)]
pub struct ParameterPatternSpecialTargetFragmentFact {
span: Span,
}
impl ParameterPatternSpecialTargetFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone, Copy)]
pub struct ZshParameterIndexFlagFragmentFact {
span: Span,
}
impl ZshParameterIndexFlagFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone, Copy)]
pub struct SubstringExpansionFragmentFact {
span: Span,
}
impl SubstringExpansionFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone, Copy)]
pub struct CaseModificationFragmentFact {
span: Span,
}
impl CaseModificationFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone, Copy)]
pub struct ReplacementExpansionFragmentFact {
span: Span,
}
impl ReplacementExpansionFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Clone, Copy)]
pub struct PositionalParameterTrimFragmentFact {
span: Span,
}
impl PositionalParameterTrimFragmentFact {
pub fn span(&self) -> Span {
self.span
}
}
#[derive(Debug, Default)]
pub(super) struct SurfaceFragmentFacts {
pub(super) single_quoted: Vec<SingleQuotedFragmentFact>,
pub(super) dollar_double_quoted: Vec<DollarDoubleQuotedFragmentFact>,
pub(super) open_double_quotes: Vec<OpenDoubleQuoteFragmentFact>,
pub(super) suspect_closing_quotes: Vec<SuspectClosingQuoteFragmentFact>,
pub(super) backticks: Vec<BacktickFragmentFact>,
pub(super) legacy_arithmetic: Vec<LegacyArithmeticFragmentFact>,
pub(super) positional_parameters: Vec<PositionalParameterFragmentFact>,
pub(super) positional_parameter_operator_spans: Vec<Span>,
pub(super) unicode_smart_quote_spans: Vec<Span>,
pub(super) pattern_exactly_one_extglob_spans: Vec<Span>,
pub(super) pattern_charclass_spans: Vec<Span>,
pub(super) parameter_pattern_spans: Vec<Span>,
pub(super) nested_pattern_charclass_spans: Vec<Span>,
pub(super) nested_parameter_expansions: Vec<NestedParameterExpansionFragmentFact>,
pub(super) indirect_expansions: Vec<IndirectExpansionFragmentFact>,
pub(super) indexed_array_references: Vec<IndexedArrayReferenceFragmentFact>,
pub(super) plain_unindexed_references: Vec<Span>,
pub(super) parameter_pattern_special_targets: Vec<ParameterPatternSpecialTargetFragmentFact>,
pub(super) zsh_parameter_index_flags: Vec<ZshParameterIndexFlagFragmentFact>,
pub(super) substring_expansions: Vec<SubstringExpansionFragmentFact>,
pub(super) case_modifications: Vec<CaseModificationFragmentFact>,
pub(super) replacement_expansions: Vec<ReplacementExpansionFragmentFact>,
pub(super) positional_parameter_trims: Vec<PositionalParameterTrimFragmentFact>,
pub(super) suppressed_subscript_spans: Vec<Span>,
pub(super) subscript_later_suppression_spans: Vec<Span>,
pub(super) arithmetic_only_suppressed_subscript_spans: Vec<Span>,
}
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct SurfaceScanContext<'a> {
command_name: Option<&'a str>,
assignment_target: Option<&'a str>,
shell_dialect: shuck_parser::ShellDialect,
nested_word_command: bool,
variable_set_operand: bool,
literal_expansion_exempt: bool,
guarded_parameter_operand: bool,
subscript_suppresses_later_references: bool,
collect_open_double_quotes: bool,
collect_pattern_charclasses: bool,
}
impl<'a> SurfaceScanContext<'a> {
pub(super) fn new(
command_name: Option<&'a str>,
nested_word_command: bool,
shell_dialect: shuck_parser::ShellDialect,
) -> Self {
Self {
command_name,
nested_word_command,
shell_dialect,
subscript_suppresses_later_references: true,
collect_open_double_quotes: true,
collect_pattern_charclasses: false,
..Self::default()
}
}
pub(super) fn command_name(&self) -> Option<&'a str> {
self.command_name
}
pub(super) fn with_assignment_target(self, assignment_target: &'a str) -> Self {
Self {
assignment_target: Some(assignment_target),
..self
}
}
pub(super) fn variable_set_operand(self) -> Self {
Self {
variable_set_operand: true,
..self
}
}
pub(super) fn literal_expansion_exempt(self) -> Self {
Self {
literal_expansion_exempt: true,
..self
}
}
pub(super) fn guarded_parameter_operand(self) -> Self {
Self {
guarded_parameter_operand: true,
..self
}
}
pub(super) fn without_open_double_quote_scan(self) -> Self {
Self {
collect_open_double_quotes: false,
..self
}
}
pub(super) fn without_command_name(self) -> Self {
Self {
command_name: None,
..self
}
}
pub(super) fn with_pattern_charclass_scan(self) -> Self {
Self {
collect_pattern_charclasses: true,
..self
}
}
}
pub(super) struct SurfaceFragmentSink<'a> {
source: &'a str,
facts: SurfaceFragmentFacts,
zsh_parameter_index_flag_scratch: Vec<Span>,
parameter_special_target_word_scratch: Vec<Span>,
open_double_quote_scratch: Vec<(Span, Span)>,
split_suspect_closing_quote_scratch: Vec<Span>,
}
impl<'a> SurfaceFragmentSink<'a> {
pub(super) fn new(source: &'a str) -> Self {
Self {
source,
facts: SurfaceFragmentFacts::default(),
zsh_parameter_index_flag_scratch: Vec::new(),
parameter_special_target_word_scratch: Vec::new(),
open_double_quote_scratch: Vec::new(),
split_suspect_closing_quote_scratch: Vec::new(),
}
}
#[cfg_attr(shuck_profiling, inline(never))]
pub(super) fn finish(mut self) -> SurfaceFragmentFacts {
self.facts
.plain_unindexed_references
.sort_by_key(|span| (span.start.offset, span.end.offset));
self.facts
.plain_unindexed_references
.dedup_by_key(|span| (span.start.offset, span.end.offset));
self.facts
}
fn opening_backtick_is_escaped(&self, span: Span) -> bool {
let source = self.source.as_bytes();
let start = span.start.offset;
let Some(fragment) = self.source.get(start..span.end.offset) else {
return false;
};
let Some(first_backtick) = fragment.find('`') else {
return true;
};
if !fragment[..first_backtick].bytes().all(|byte| byte == b'\\') {
return false;
}
let mut backslashes = first_backtick;
let mut cursor = start;
while cursor > 0 && source[cursor - 1] == b'\\' {
backslashes += 1;
cursor -= 1;
}
backslashes % 2 == 1
}
fn looks_like_unbraced_positional_above_nine(&self, span: Span) -> bool {
let fragment = span.slice(self.source);
let fragment = fragment.strip_prefix('"').unwrap_or(fragment);
let mut chars = fragment.chars();
matches!(
(chars.next(), chars.next(), chars.next()),
(Some('$'), Some(digit), Some(next))
if matches!(digit, '1'..='9') && next.is_ascii_digit()
)
}
fn record_array_reference(&mut self, span: Span, plain: bool) {
if let Some(fragment) = self
.facts
.indexed_array_references
.iter_mut()
.find(|fragment| match fragment {
IndexedArrayReferenceFragmentFact::OneBased(fragment)
| IndexedArrayReferenceFragmentFact::ZeroBased(fragment)
| IndexedArrayReferenceFragmentFact::OneBasedWithZeroAlias(fragment)
| IndexedArrayReferenceFragmentFact::Ambiguous(fragment) => fragment.span() == span,
})
{
*fragment = fragment.with_plain(plain);
return;
}
self.facts
.indexed_array_references
.push(IndexedArrayReferenceFragmentFact::new(span, plain));
}
fn record_plain_unindexed_reference(&mut self, span: Span) {
self.facts.plain_unindexed_references.push(span);
}
fn record_parameter_pattern_special_target(&mut self, operand_span: Span) {
if self
.facts
.parameter_pattern_special_targets
.iter()
.any(|fragment| fragment.span() == operand_span)
{
return;
}
self.facts
.parameter_pattern_special_targets
.push(ParameterPatternSpecialTargetFragmentFact { span: operand_span });
}
fn record_substring_expansion(&mut self, span: Span) {
if self
.facts
.substring_expansions
.iter()
.any(|fragment| fragment.span() == span)
{
return;
}
self.facts
.substring_expansions
.push(SubstringExpansionFragmentFact { span });
}
fn record_zsh_parameter_index_flag(&mut self, span: Span) {
if self
.facts
.zsh_parameter_index_flags
.iter()
.any(|fragment| fragment.span() == span)
{
return;
}
self.facts
.zsh_parameter_index_flags
.push(ZshParameterIndexFlagFragmentFact { span });
}
fn record_case_modification(&mut self, span: Span) {
if self
.facts
.case_modifications
.iter()
.any(|fragment| fragment.span() == span)
{
return;
}
self.facts
.case_modifications
.push(CaseModificationFragmentFact { span });
}
fn record_replacement_expansion(&mut self, span: Span) {
if self
.facts
.replacement_expansions
.iter()
.any(|fragment| fragment.span() == span)
{
return;
}
self.facts
.replacement_expansions
.push(ReplacementExpansionFragmentFact { span });
}
fn record_parameter_pattern(&mut self, span: Span) {
if self.facts.parameter_pattern_spans.contains(&span) {
return;
}
self.facts.parameter_pattern_spans.push(span);
}
fn record_positional_parameter_trim(&mut self, span: Span) {
if self
.facts
.positional_parameter_trims
.iter()
.any(|fragment| fragment.span() == span)
{
return;
}
self.facts
.positional_parameter_trims
.push(PositionalParameterTrimFragmentFact { span });
}
pub(super) fn collect_word(&mut self, word: &Word, context: SurfaceScanContext<'_>) -> bool {
let open_double_quote_count = self.facts.open_double_quotes.len();
let context = if zsh_delayed_eval_word_is_exempt(word, self.source, context) {
context.literal_expansion_exempt()
} else {
context
};
self.zsh_parameter_index_flag_scratch.clear();
collect_zsh_parameter_index_flag_spans_in_word(
word.span.slice(self.source),
word.span,
&mut self.zsh_parameter_index_flag_scratch,
);
for index in 0..self.zsh_parameter_index_flag_scratch.len() {
let span = self.zsh_parameter_index_flag_scratch[index];
self.record_zsh_parameter_index_flag(span);
}
if context.collect_open_double_quotes {
self.collect_open_double_quote_fragments(
word,
context.command_name,
context.assignment_target,
);
}
self.collect_word_parts(&word.parts, context, false);
self.facts.open_double_quotes.len() > open_double_quote_count
}
pub(super) fn collect_heredoc_body(
&mut self,
body: &HeredocBody,
context: SurfaceScanContext<'_>,
) {
self.collect_heredoc_body_parts(&body.parts, context);
}
pub(super) fn record_unset_array_target_word(&mut self, word: &Word) {
if word_looks_like_unset_array_target(word, self.source) {
self.facts.suppressed_subscript_spans.push(word.span);
}
}
fn collect_open_double_quote_fragments(
&mut self,
word: &Word,
command_name: Option<&str>,
assignment_target: Option<&str>,
) {
self.open_double_quote_scratch.clear();
collect_suspect_double_quote_spans(
word,
self.source,
command_name,
assignment_target,
&mut self.open_double_quote_scratch,
);
if self.open_double_quote_scratch.is_empty() {
return;
}
let replacement =
rewrite_word_as_single_double_quoted_string(word, self.source, assignment_target);
for index in 0..self.open_double_quote_scratch.len() {
let (opening_span, closing_span) = self.open_double_quote_scratch[index];
self.facts
.open_double_quotes
.push(OpenDoubleQuoteFragmentFact {
span: opening_span,
replacement_span: word.span,
replacement: replacement.clone(),
});
self.facts
.suspect_closing_quotes
.push(SuspectClosingQuoteFragmentFact { span: closing_span });
}
}
pub(super) fn collect_split_suspect_closing_quote_fragment_in_words(&mut self, words: &[Word]) {
for (index, word) in words.iter().enumerate() {
let has_later_words = index + 1 < words.len();
self.split_suspect_closing_quote_scratch.clear();
collect_split_suspect_closing_quote_spans(
word,
self.source,
has_later_words,
&mut self.split_suspect_closing_quote_scratch,
);
for index in 0..self.split_suspect_closing_quote_scratch.len() {
let span = self.split_suspect_closing_quote_scratch[index];
if self
.facts
.suspect_closing_quotes
.iter()
.any(|fragment| fragment.span() == span)
{
continue;
}
self.facts
.suspect_closing_quotes
.push(SuspectClosingQuoteFragmentFact { span });
}
}
}
fn collect_word_parts(
&mut self,
parts: &[WordPartNode],
context: SurfaceScanContext<'_>,
in_double_quote: bool,
) {
for (index, part) in parts.iter().enumerate() {
if let WordPart::Literal(text) = &part.kind
&& !in_double_quote
{
let literal = text.as_str(self.source, part.span);
if literal.as_bytes().contains(&UNICODE_SMART_QUOTE_LEAD_BYTE) {
for (offset, char) in literal.char_indices() {
if !is_unicode_smart_quote(char) {
continue;
}
let start = part.span.start.advanced_by(&literal[..offset]);
let end = start.advanced_by(char.encode_utf8(&mut [0; 4]));
self.facts
.unicode_smart_quote_spans
.push(Span::from_positions(start, end));
}
}
}
if let WordPart::Variable(name) = &part.kind
&& matches!(
name.as_str(),
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
)
&& let Some(next_part) = parts.get(index + 1)
&& let WordPart::Literal(text) = &next_part.kind
&& text
.as_str(self.source, next_part.span)
.starts_with(|char: char| char.is_ascii_digit())
&& self.looks_like_unbraced_positional_above_nine(part.span.merge(next_part.span))
{
self.facts
.positional_parameters
.push(PositionalParameterFragmentFact {
span: part.span.merge(next_part.span),
kind: PositionalParameterFragmentKind::AboveNine,
guarded: context.guarded_parameter_operand,
});
}
match &part.kind {
WordPart::SingleQuoted { dollar, .. } => {
let literal_expansion_exempt = context.literal_expansion_exempt
|| single_quoted_fragment_is_zsh_delayed_eval_exempt(
part.span,
context,
self.source,
);
self.facts.single_quoted.push(SingleQuotedFragmentFact {
span: part.span,
diagnostic_span: self.single_quoted_fragment_diagnostic_span(part.span),
dollar_quoted: *dollar,
command_name: context
.command_name
.map(str::to_owned)
.map(String::into_boxed_str),
assignment_target: context
.assignment_target
.map(str::to_owned)
.map(String::into_boxed_str),
shell_dialect: context.shell_dialect,
variable_set_operand: context.variable_set_operand,
literal_expansion_exempt,
literal_backslash_in_single_quotes_span:
single_quoted_backslash_continuation_span(parts, index, self.source),
});
}
WordPart::DoubleQuoted { parts, dollar } => {
if *dollar {
self.facts
.dollar_double_quoted
.push(DollarDoubleQuotedFragmentFact { span: part.span });
}
self.collect_word_parts(parts, context, true);
}
WordPart::ZshQualifiedGlob(glob) => self.collect_zsh_qualified_glob(glob, context),
WordPart::ArithmeticExpansion {
expression,
syntax: ArithmeticExpansionSyntax::LegacyBracket,
expression_word_ast,
expression_ast,
..
} => {
self.facts
.legacy_arithmetic
.push(LegacyArithmeticFragmentFact { span: part.span });
collect_positional_parameter_operator_spans_in_arithmetic(
part.span,
expression_ast.as_deref(),
expression,
self.source,
&mut self.facts.positional_parameter_operator_spans,
);
if let Some(expression_ast) = expression_ast.as_deref() {
visit_arithmetic_words(expression_ast, &mut |word| {
self.collect_word(word, context);
});
} else {
self.collect_word(expression_word_ast, context);
}
}
WordPart::ArithmeticExpansion {
expression,
expression_word_ast,
expression_ast,
..
} => {
collect_positional_parameter_operator_spans_in_arithmetic(
part.span,
expression_ast.as_deref(),
expression,
self.source,
&mut self.facts.positional_parameter_operator_spans,
);
if let Some(expression_ast) = expression_ast.as_deref() {
visit_arithmetic_words(expression_ast, &mut |word| {
self.collect_word(word, context);
});
} else {
self.collect_word(expression_word_ast, context);
}
}
WordPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::Backtick,
body,
..
} => {
if self.opening_backtick_is_escaped(part.span) {
continue;
}
self.facts.backticks.push(BacktickFragmentFact {
span: part.span,
empty: body.is_empty(),
});
}
WordPart::CommandSubstitution { .. } | WordPart::ProcessSubstitution { .. } => {}
WordPart::Parameter(parameter) => {
if parameter_is_plain_unindexed_reference(parameter) {
self.record_plain_unindexed_reference(part.span);
}
self.collect_parameter_expansion(parameter, part.span, context);
}
WordPart::Variable(name) => {
if name_is_plain_reference_candidate(name) {
self.record_plain_unindexed_reference(part.span);
}
if name.as_str() == "$"
&& contains_nested_parameter_marker(part.span.slice(self.source))
{
self.facts
.nested_parameter_expansions
.push(NestedParameterExpansionFragmentFact { span: part.span });
}
}
WordPart::ParameterExpansion {
reference,
operator,
operand,
operand_word_ast,
..
} => {
if reference_has_array_subscript(reference) {
self.record_array_reference(part.span, false);
}
if parameter_pattern_target_is_special(reference, operator) {
self.parameter_special_target_word_scratch.clear();
collect_parameter_operator_special_target_word_spans(
operator,
&mut self.parameter_special_target_word_scratch,
);
for index in 0..self.parameter_special_target_word_scratch.len() {
let pattern_span = self.parameter_special_target_word_scratch[index];
self.record_parameter_pattern_special_target(pattern_span);
}
}
if matches!(
operator.as_ref(),
ParameterOp::UpperFirst
| ParameterOp::UpperAll
| ParameterOp::LowerFirst
| ParameterOp::LowerAll
) {
self.record_case_modification(part.span);
}
if matches!(
operator.as_ref(),
ParameterOp::ReplaceFirst { .. } | ParameterOp::ReplaceAll { .. }
) {
self.record_replacement_expansion(part.span);
}
if reference_is_positional_parameter_trim(reference, operator) {
self.record_positional_parameter_trim(part.span);
}
self.record_var_ref_subscript(
reference,
context.subscript_suppresses_later_references,
);
self.collect_parameter_operator_patterns(
operator,
operand.as_ref(),
operand_word_ast.as_deref(),
context,
);
}
WordPart::Length(reference)
| WordPart::ArrayLength(reference)
| WordPart::Transformation { reference, .. } => {
self.record_var_ref_subscript(
reference,
context.subscript_suppresses_later_references,
);
}
WordPart::ArrayAccess(reference) => {
if reference_has_array_subscript(reference) {
self.record_array_reference(part.span, true);
let case_modification_span = parts
.get(index + 1)
.filter(|next_part| {
matches!(&next_part.kind, WordPart::Literal(text) if {
let text = text.as_str(self.source, next_part.span);
text.starts_with('^') || text.starts_with(',')
})
})
.map_or(part.span, |next_part| part.span.merge(next_part.span));
self.record_case_modification(case_modification_span);
}
self.record_var_ref_subscript(
reference,
context.subscript_suppresses_later_references,
);
}
WordPart::ArrayIndices(reference) => {
self.record_var_ref_subscript(
reference,
context.subscript_suppresses_later_references,
);
self.facts
.indirect_expansions
.push(IndirectExpansionFragmentFact {
span: part.span,
array_keys: true,
});
}
WordPart::Substring { reference, .. } => {
self.record_substring_expansion(part.span);
self.record_var_ref_subscript(
reference,
context.subscript_suppresses_later_references,
);
}
WordPart::ArraySlice { reference, .. } => {
self.record_var_ref_subscript(
reference,
context.subscript_suppresses_later_references,
);
}
WordPart::IndirectExpansion {
reference,
operator: Some(operator),
operand,
operand_word_ast,
..
} => {
self.record_var_ref_subscript(
reference,
context.subscript_suppresses_later_references,
);
self.facts
.indirect_expansions
.push(IndirectExpansionFragmentFact {
span: part.span,
array_keys: false,
});
self.collect_parameter_operator_patterns(
operator,
operand.as_ref(),
operand_word_ast.as_deref(),
context,
);
}
WordPart::IndirectExpansion {
reference,
operator: None,
..
} => {
self.record_var_ref_subscript(
reference,
context.subscript_suppresses_later_references,
);
self.facts
.indirect_expansions
.push(IndirectExpansionFragmentFact {
span: part.span,
array_keys: false,
});
}
WordPart::PrefixMatch { .. } => {
self.facts
.indirect_expansions
.push(IndirectExpansionFragmentFact {
span: part.span,
array_keys: false,
});
}
WordPart::Literal(_) => {}
}
}
}
fn collect_heredoc_body_parts(
&mut self,
parts: &[HeredocBodyPartNode],
context: SurfaceScanContext<'_>,
) {
for (index, part) in parts.iter().enumerate() {
if let HeredocBodyPart::Variable(name) = &part.kind
&& matches!(
name.as_str(),
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
)
&& let Some(next_part) = parts.get(index + 1)
&& let HeredocBodyPart::Literal(text) = &next_part.kind
&& text
.as_str(self.source, next_part.span)
.starts_with(|char: char| char.is_ascii_digit())
&& self.looks_like_unbraced_positional_above_nine(part.span.merge(next_part.span))
{
self.facts
.positional_parameters
.push(PositionalParameterFragmentFact {
span: part.span.merge(next_part.span),
kind: PositionalParameterFragmentKind::AboveNine,
guarded: context.guarded_parameter_operand,
});
}
match &part.kind {
HeredocBodyPart::Literal(_) => {}
HeredocBodyPart::Variable(name) => {
if name_is_plain_reference_candidate(name) {
self.record_plain_unindexed_reference(part.span);
}
}
HeredocBodyPart::CommandSubstitution {
syntax: CommandSubstitutionSyntax::Backtick,
body,
..
} => {
if self.opening_backtick_is_escaped(part.span) {
continue;
}
self.facts.backticks.push(BacktickFragmentFact {
span: part.span,
empty: body.is_empty(),
});
}
HeredocBodyPart::CommandSubstitution { .. } => {}
HeredocBodyPart::ArithmeticExpansion {
expression,
syntax: ArithmeticExpansionSyntax::LegacyBracket,
expression_word_ast,
expression_ast,
..
} => {
self.facts
.legacy_arithmetic
.push(LegacyArithmeticFragmentFact { span: part.span });
collect_positional_parameter_operator_spans_in_arithmetic(
part.span,
expression_ast.as_ref(),
expression,
self.source,
&mut self.facts.positional_parameter_operator_spans,
);
if let Some(expression_ast) = expression_ast.as_ref() {
visit_arithmetic_words(expression_ast, &mut |word| {
self.collect_word(word, context);
});
} else {
self.collect_word(expression_word_ast, context);
}
}
HeredocBodyPart::ArithmeticExpansion {
expression,
expression_word_ast,
expression_ast,
..
} => {
collect_positional_parameter_operator_spans_in_arithmetic(
part.span,
expression_ast.as_ref(),
expression,
self.source,
&mut self.facts.positional_parameter_operator_spans,
);
if let Some(expression_ast) = expression_ast.as_ref() {
visit_arithmetic_words(expression_ast, &mut |word| {
self.collect_word(word, context);
});
} else {
self.collect_word(expression_word_ast, context);
}
}
HeredocBodyPart::Parameter(parameter) => {
if parameter_is_plain_unindexed_reference(parameter) {
self.record_plain_unindexed_reference(part.span);
}
self.collect_parameter_expansion(parameter, part.span, context);
}
}
}
}
pub(super) fn collect_pattern(&mut self, pattern: &Pattern, context: SurfaceScanContext<'_>) {
self.collect_pattern_impl(pattern, context, true);
}
pub(super) fn collect_pattern_structure(
&mut self,
pattern: &Pattern,
context: SurfaceScanContext<'_>,
) {
self.collect_pattern_impl(pattern, context, false);
}
fn collect_pattern_impl(
&mut self,
pattern: &Pattern,
context: SurfaceScanContext<'_>,
collect_words: bool,
) {
for (part, span) in pattern.parts_with_spans() {
match part {
PatternPart::Group { kind, patterns } => {
if *kind == PatternGroupKind::ExactlyOne {
self.facts.pattern_exactly_one_extglob_spans.push(span);
}
for pattern in patterns {
self.collect_pattern_impl(pattern, context, collect_words);
}
}
PatternPart::Word(word) if collect_words => {
self.collect_word(word, context);
}
PatternPart::Word(_) => {}
PatternPart::CharClass(_) if context.collect_pattern_charclasses => {
self.facts.pattern_charclass_spans.push(span);
if context.nested_word_command {
self.facts.nested_pattern_charclass_spans.push(span);
}
}
PatternPart::CharClass(_)
| PatternPart::Literal(_)
| PatternPart::AnyString
| PatternPart::AnyChar => {}
}
}
}
fn collect_fragment_word(
&mut self,
word: Option<&Word>,
text: Option<&SourceText>,
context: SurfaceScanContext<'_>,
) {
let Some(text) = text else {
return;
};
let snippet = text.slice(self.source);
if snippet.is_empty() {
return;
}
debug_assert!(
word.is_some(),
"parser-backed fragment text should always carry a word AST"
);
let Some(word) = word else {
return;
};
self.collect_word(word, context.without_open_double_quote_scan());
}
fn collect_zsh_qualified_glob(
&mut self,
glob: &ZshQualifiedGlob,
context: SurfaceScanContext<'_>,
) {
for segment in &glob.segments {
if let ZshGlobSegment::Pattern(pattern) = segment {
self.collect_pattern(pattern, context);
}
}
}
fn collect_parameter_expansion(
&mut self,
parameter: &shuck_ast::ParameterExpansion,
span: Span,
context: SurfaceScanContext<'_>,
) {
let (classification, deepest_subscript_ref) =
classify_parameter_expansion(parameter, self.source);
let guarded_reference =
context.guarded_parameter_operand || classification.guards_unset_reference;
self.record_special_positional_parameter(parameter, guarded_reference);
if span.slice(self.source).starts_with("${##") {
self.facts
.positional_parameters
.push(PositionalParameterFragmentFact {
span,
kind: PositionalParameterFragmentKind::General,
guarded: guarded_reference,
});
}
if classification.is_nested_bourne {
self.facts
.nested_parameter_expansions
.push(NestedParameterExpansionFragmentFact { span });
}
if classification.has_array_reference {
self.record_array_reference(span, classification.is_plain_array_reference);
}
if classification.has_substring_expansion {
self.record_substring_expansion(span);
}
if classification.has_case_modification {
self.record_case_modification(span);
}
if classification.has_replacement_expansion {
self.record_replacement_expansion(span);
}
if classification.has_positional_parameter_trim {
self.record_positional_parameter_trim(span);
}
if let Some(deepest_subscript_ref) = deepest_subscript_ref {
self.record_var_ref_subscript(deepest_subscript_ref, true);
}
if let ParameterExpansionSyntax::Bourne(syntax) = ¶meter.syntax {
if matches!(
syntax,
BourneParameterExpansion::Indirect { .. }
| BourneParameterExpansion::PrefixMatch { .. }
| BourneParameterExpansion::Indices { .. }
) {
self.facts
.indirect_expansions
.push(IndirectExpansionFragmentFact {
span,
array_keys: matches!(syntax, BourneParameterExpansion::Indices { .. }),
});
}
match syntax {
BourneParameterExpansion::Operation {
reference,
operator,
operand,
operand_word_ast,
..
}
| BourneParameterExpansion::Indirect {
reference,
operator: Some(operator),
operand,
operand_word_ast,
..
} => {
if parameter_pattern_target_is_special(reference, operator) {
self.parameter_special_target_word_scratch.clear();
collect_parameter_operator_special_target_word_spans(
operator,
&mut self.parameter_special_target_word_scratch,
);
for index in 0..self.parameter_special_target_word_scratch.len() {
let pattern_span = self.parameter_special_target_word_scratch[index];
self.record_parameter_pattern_special_target(pattern_span);
}
}
self.collect_parameter_operator_patterns(
operator,
operand.as_ref(),
operand_word_ast.as_deref(),
context,
);
}
BourneParameterExpansion::Access { .. }
| BourneParameterExpansion::Length { .. }
| BourneParameterExpansion::Indices { .. }
| BourneParameterExpansion::Indirect { operator: None, .. }
| BourneParameterExpansion::PrefixMatch { .. }
| BourneParameterExpansion::Slice { .. }
| BourneParameterExpansion::Transformation { .. } => {}
}
}
}
fn record_special_positional_parameter(
&mut self,
parameter: &shuck_ast::ParameterExpansion,
guarded: bool,
) {
let reference = match ¶meter.syntax {
ParameterExpansionSyntax::Bourne(syntax) => match syntax {
BourneParameterExpansion::Access { reference }
| BourneParameterExpansion::Length { reference }
| BourneParameterExpansion::Indices { reference }
| BourneParameterExpansion::Indirect { reference, .. }
| BourneParameterExpansion::Slice { reference, .. }
| BourneParameterExpansion::Operation { reference, .. }
| BourneParameterExpansion::Transformation { reference, .. } => Some(reference),
BourneParameterExpansion::PrefixMatch { .. } => None,
},
ParameterExpansionSyntax::Zsh(_) => None,
};
if let Some(reference) = reference
&& reference.subscript.is_none()
&& matches!(reference.name.as_str(), "@" | "*" | "#")
{
self.facts
.positional_parameters
.push(PositionalParameterFragmentFact {
span: reference.span,
kind: PositionalParameterFragmentKind::General,
guarded,
});
}
}
fn collect_parameter_operator_patterns(
&mut self,
operator: &ParameterOp,
operand: Option<&SourceText>,
operand_word_ast: Option<&Word>,
context: SurfaceScanContext<'_>,
) {
match operator {
ParameterOp::RemovePrefixShort { pattern }
| ParameterOp::RemovePrefixLong { pattern }
| ParameterOp::RemoveSuffixShort { pattern }
| ParameterOp::RemoveSuffixLong { pattern } => {
self.record_parameter_pattern(pattern.span);
self.collect_pattern(pattern, context.with_pattern_charclass_scan())
}
ParameterOp::ReplaceFirst {
pattern,
replacement,
..
}
| ParameterOp::ReplaceAll {
pattern,
replacement,
..
} => {
self.record_parameter_pattern(pattern.span);
self.collect_pattern(pattern, context.with_pattern_charclass_scan());
self.collect_fragment_word(
operator.replacement_word_ast(),
Some(replacement),
context,
);
}
ParameterOp::UseDefault
| ParameterOp::AssignDefault
| ParameterOp::UseReplacement
| ParameterOp::Error => {
self.collect_fragment_word(
operand_word_ast,
operand,
context.guarded_parameter_operand(),
);
}
ParameterOp::UpperFirst
| ParameterOp::UpperAll
| ParameterOp::LowerFirst
| ParameterOp::LowerAll => {}
}
}
pub(super) fn record_var_ref_subscript(
&mut self,
reference: &VarRef,
suppresses_later_references: bool,
) {
let Some(subscript) = reference.subscript.as_deref() else {
return;
};
if subscript.selector().is_some() {
return;
}
let span = subscript.span();
self.facts.suppressed_subscript_spans.push(span);
if suppresses_later_references {
self.facts.subscript_later_suppression_spans.push(span);
}
}
pub(super) fn record_arithmetic_only_suppressed_subscript(
&mut self,
subscript: Option<&Subscript>,
) {
let Some(subscript) = subscript else {
return;
};
if subscript.selector().is_some() {
return;
}
self.facts
.arithmetic_only_suppressed_subscript_spans
.push(subscript.span());
}
fn single_quoted_fragment_diagnostic_span(&self, part_span: Span) -> Span {
if !self
.facts
.backticks
.iter()
.any(|fragment| span_contains(fragment.span, part_span))
{
return part_span;
}
let escaped_dollar_count =
backtick_display_escaped_dollar_count(part_span.slice(self.source));
let Some(chain_start) = continued_line_chain_start(part_span.start, self.source) else {
return adjust_end_column(part_span, escaped_dollar_count);
};
let start = shellcheck_collapsed_position(chain_start, part_span.start, self.source);
let end = adjust_end_column(
Span::from_positions(
start,
shellcheck_collapsed_position(chain_start, part_span.end, self.source),
),
escaped_dollar_count,
)
.end;
Span::from_positions(start, end)
}
}
fn zsh_delayed_eval_word_is_exempt(
word: &Word,
source: &str,
context: SurfaceScanContext<'_>,
) -> bool {
if context.shell_dialect != shuck_parser::ShellDialect::Zsh {
return false;
}
let assignment_context = context.assignment_target.is_some()
|| matches!(
context.command_name,
Some("local" | "typeset" | "declare" | "readonly")
);
if !assignment_context {
return false;
}
let text = word.span.slice(source);
text.contains('=')
&& (text.contains("_p9k__segment_cond_")
|| text.contains("__p9k_instant_prompt_")
|| text.contains("PATCH='for "))
}
fn single_quoted_fragment_is_zsh_delayed_eval_exempt(
span: Span,
context: SurfaceScanContext<'_>,
source: &str,
) -> bool {
if context.shell_dialect != shuck_parser::ShellDialect::Zsh {
return false;
}
let Some(body) = single_quoted_body(span.slice(source)) else {
return false;
};
if context
.assignment_target
.is_some_and(|target| zsh_delayed_eval_assignment_target(target, body))
{
return true;
}
if context
.assignment_target
.is_some_and(|target| zsh_vcs_info_patch_loop_literal(target, body))
{
return true;
}
context
.assignment_target
.is_some_and(|target| zsh_literal_map_key_target(target, body))
}
fn single_quoted_body(text: &str) -> Option<&str> {
text.strip_prefix('\'')?.strip_suffix('\'')
}
fn zsh_delayed_eval_assignment_target(target: &str, body: &str) -> bool {
if target.contains("_p9k__segment_cond_") && single_parameter_marker_literal(body) {
return true;
}
zsh_delayed_prompt_assignment_target(target) && zsh_delayed_eval_literal_body(body)
}
fn zsh_delayed_prompt_assignment_target(target: &str) -> bool {
matches!(target, "PROMPT" | "RPROMPT" | "RPS1" | "RPS2" | "SPROMPT")
|| target == "_p9k__prompt"
|| target.starts_with("_p9k__prompt_")
}
fn zsh_delayed_eval_literal_body(body: &str) -> bool {
body.contains("${(")
|| body.contains("::=")
|| body.contains("${_p9k")
|| body.contains("$_p9k")
|| body.contains("$+")
|| body.contains("${+")
}
fn zsh_literal_map_key_target(target: &str, body: &str) -> bool {
target == "___subst_map" && single_parameter_marker_literal(body)
}
fn zsh_vcs_info_patch_loop_literal(target: &str, body: &str) -> bool {
target == "PATCH"
&& body.starts_with("for ")
&& body.contains(" hook_com[")
&& single_quoted_body_contains_expansion(body)
}
fn single_parameter_marker_literal(body: &str) -> bool {
let Some(name) = body
.strip_prefix("${")
.and_then(|inner| inner.strip_suffix('}'))
.or_else(|| body.strip_prefix('$'))
else {
return false;
};
!name.is_empty()
&& name
.chars()
.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn single_quoted_body_contains_expansion(body: &str) -> bool {
body.contains('$') || (body.contains('`') && body.matches('`').count() >= 2)
}
fn continued_line_chain_start(target: Position, source: &str) -> Option<Position> {
let mut line_start_offset = source[..target.offset]
.rfind('\n')
.map_or(0, |index| index + 1);
let mut line = target.line;
let original_start = line_start_offset;
while line_start_offset > 0 {
let previous_line_end = line_start_offset - 1;
let previous_line_start = source[..previous_line_end]
.rfind('\n')
.map_or(0, |index| index + 1);
let previous_line = &source[previous_line_start..previous_line_end];
if !previous_line.trim_end_matches([' ', '\t']).ends_with('\\') {
break;
}
line_start_offset = previous_line_start;
line -= 1;
}
(line_start_offset != original_start).then_some(Position {
line,
column: 1,
offset: line_start_offset,
})
}
fn shellcheck_collapsed_position(
chain_start: Position,
target: Position,
source: &str,
) -> Position {
let mut line = chain_start.line;
let mut column = chain_start.column;
let prefix = &source[chain_start.offset..target.offset];
let mut index = 0usize;
while index < prefix.len() {
if prefix[index..].starts_with("\\\r\n") {
index += "\\\r\n".len();
continue;
}
if prefix[index..].starts_with("\\\n") {
index += "\\\n".len();
continue;
}
let ch = prefix[index..]
.chars()
.next()
.expect("prefix iteration should stay on UTF-8 boundaries");
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
index += ch.len_utf8();
}
Position {
line,
column,
offset: target.offset,
}
}
fn adjust_end_column(span: Span, display_escape_count: usize) -> Span {
if display_escape_count == 0 {
return span;
}
let mut end = span.end;
end.column = end.column.saturating_sub(display_escape_count);
Span::from_positions(span.start, end)
}
fn backtick_display_escaped_dollar_count(text: &str) -> usize {
let Some(inner) = text
.strip_prefix('\'')
.and_then(|text| text.strip_suffix('\''))
else {
return 0;
};
inner
.as_bytes()
.windows(2)
.filter(|pair| pair[0] == b'\\' && pair[1] == b'$')
.count()
}
fn quoted_parameter_target_len(text: &str) -> Option<usize> {
match text.as_bytes().first().copied() {
Some(b'\'') => single_quoted_fragment_len(text),
Some(b'"') => double_quoted_fragment_len(text),
_ => None,
}
}
fn collect_zsh_parameter_index_flag_spans_in_word(text: &str, span: Span, spans: &mut Vec<Span>) {
let mut search_from = 0usize;
while let Some(start) = next_live_parameter_expansion_start(text, search_from) {
let body = &text[start + 2..];
let Some(target_len) = quoted_parameter_target_len(body) else {
search_from = start + 2;
continue;
};
if !body[target_len..].starts_with('[') {
search_from = start + 2;
continue;
}
let target_start = span.start.advanced_by(&text[..start]);
let target_end = target_start.advanced_by(&text[start..start + 2 + target_len]);
spans.push(Span::from_positions(target_start, target_end));
search_from = start + 2 + target_len;
}
}
fn next_live_parameter_expansion_start(text: &str, search_from: usize) -> Option<usize> {
let bytes = text.as_bytes();
let mut index = search_from;
let mut in_double_quotes = false;
while index + 1 < bytes.len() {
if bytes[index] == b'\\' {
index += usize::from(index + 1 < bytes.len()) + 1;
continue;
}
if !in_double_quotes && bytes[index..].starts_with(b"$'") {
index += 1 + dollar_single_quoted_fragment_len(&text[index + 1..])?;
continue;
}
if !in_double_quotes && bytes[index] == b'\'' {
index += single_quoted_fragment_len(&text[index..])?;
continue;
}
if bytes[index] == b'"' {
in_double_quotes = !in_double_quotes;
index += 1;
continue;
}
if bytes[index..].starts_with(b"${") {
return Some(index);
}
index += 1;
}
None
}
fn single_quoted_fragment_len(text: &str) -> Option<usize> {
debug_assert!(text.starts_with('\''));
text[1..].find('\'').map(|offset| offset + 2)
}
fn dollar_single_quoted_fragment_len(text: &str) -> Option<usize> {
debug_assert!(text.starts_with('\''));
let bytes = text.as_bytes();
let mut index = 1usize;
while index < bytes.len() {
if bytes[index] == b'\\' {
index += usize::from(index + 1 < bytes.len()) + 1;
continue;
}
if bytes[index] == b'\'' {
return Some(index + 1);
}
index += 1;
}
None
}
fn single_quoted_backslash_continuation_span(
parts: &[WordPartNode],
index: usize,
source: &str,
) -> Option<Span> {
let part = parts.get(index)?;
if !single_quoted_part_contains_backslash_letter(part, source) {
return None;
}
let next_part = parts.get(index + 1)?;
let WordPart::Literal(text) = &next_part.kind else {
return None;
};
if !text
.as_str(source, next_part.span)
.starts_with(|char: char| char.is_ascii_alphabetic())
{
return None;
}
let raw = part.span.slice(source);
let closing_quote = part.span.start.advanced_by(&raw[..raw.len() - 1]);
Some(Span::from_positions(closing_quote, closing_quote))
}
fn single_quoted_part_contains_backslash_letter(part: &WordPartNode, source: &str) -> bool {
let WordPart::SingleQuoted { dollar: false, .. } = part.kind else {
return false;
};
let Some(inner) = part
.span
.slice(source)
.strip_prefix('\'')
.and_then(|text| text.strip_suffix('\''))
else {
return false;
};
inner
.as_bytes()
.windows(2)
.any(|pair| pair[0] == b'\\' && pair[1].is_ascii_alphabetic())
}
fn double_quoted_fragment_len(text: &str) -> Option<usize> {
debug_assert!(text.starts_with('"'));
let bytes = text.as_bytes();
let mut index = 1usize;
while index < bytes.len() {
if bytes[index] == b'\\' {
index += usize::from(index + 1 < bytes.len()) + 1;
continue;
}
if bytes[index..].starts_with(b"${") {
index += parameter_expansion_len(&text[index..])?;
continue;
}
if bytes[index..].starts_with(b"$(") {
index += command_substitution_len(&text[index..])?;
continue;
}
if bytes[index] == b'`' {
index += backtick_substitution_len(&text[index..])?;
continue;
}
if bytes[index] == b'"' {
return Some(index + 1);
}
index += 1;
}
None
}
fn parameter_expansion_len(text: &str) -> Option<usize> {
debug_assert!(text.starts_with("${"));
let bytes = text.as_bytes();
let mut index = 2usize;
while index < bytes.len() {
if bytes[index] == b'\\' {
index += usize::from(index + 1 < bytes.len()) + 1;
continue;
}
if bytes[index] == b'\'' {
index += single_quoted_fragment_len(&text[index..])?;
continue;
}
if bytes[index] == b'"' {
index += double_quoted_fragment_len(&text[index..])?;
continue;
}
if bytes[index] == b'`' {
index += backtick_substitution_len(&text[index..])?;
continue;
}
if bytes[index..].starts_with(b"${") {
index += parameter_expansion_len(&text[index..])?;
continue;
}
if bytes[index..].starts_with(b"$(") {
index += command_substitution_len(&text[index..])?;
continue;
}
if bytes[index] == b'}' {
return Some(index + 1);
}
index += 1;
}
None
}
fn command_substitution_len(text: &str) -> Option<usize> {
debug_assert!(text.starts_with("$("));
let bytes = text.as_bytes();
let mut index = 2usize;
let mut paren_depth = 0usize;
while index < bytes.len() {
if bytes[index] == b'\\' {
index += usize::from(index + 1 < bytes.len()) + 1;
continue;
}
if bytes[index] == b'\'' {
index += single_quoted_fragment_len(&text[index..])?;
continue;
}
if bytes[index] == b'"' {
index += double_quoted_fragment_len(&text[index..])?;
continue;
}
if bytes[index] == b'`' {
index += backtick_substitution_len(&text[index..])?;
continue;
}
if bytes[index..].starts_with(b"${") {
index += parameter_expansion_len(&text[index..])?;
continue;
}
if bytes[index..].starts_with(b"$(") {
index += command_substitution_len(&text[index..])?;
continue;
}
if bytes[index] == b'(' {
paren_depth += 1;
index += 1;
continue;
}
if bytes[index] == b')' {
if paren_depth == 0 {
return Some(index + 1);
}
paren_depth -= 1;
index += 1;
continue;
}
index += 1;
}
None
}
fn backtick_substitution_len(text: &str) -> Option<usize> {
debug_assert!(text.starts_with('`'));
let bytes = text.as_bytes();
let mut index = 1usize;
while index < bytes.len() {
if bytes[index] == b'\\' {
index += usize::from(index + 1 < bytes.len()) + 1;
continue;
}
if bytes[index] == b'\'' {
index += single_quoted_fragment_len(&text[index..])?;
continue;
}
if bytes[index] == b'"' {
index += double_quoted_fragment_len(&text[index..])?;
continue;
}
if bytes[index..].starts_with(b"${") {
index += parameter_expansion_len(&text[index..])?;
continue;
}
if bytes[index..].starts_with(b"$(") {
index += command_substitution_len(&text[index..])?;
continue;
}
if bytes[index] == b'`' {
return Some(index + 1);
}
index += 1;
}
None
}
#[derive(Default, Clone, Copy)]
struct ParameterClassification {
has_array_reference: bool,
is_plain_array_reference: bool,
has_substring_expansion: bool,
has_case_modification: bool,
has_replacement_expansion: bool,
has_positional_parameter_trim: bool,
guards_unset_reference: bool,
is_nested_bourne: bool,
}
fn classify_parameter_expansion<'a>(
parameter: &'a shuck_ast::ParameterExpansion,
source: &str,
) -> (ParameterClassification, Option<&'a VarRef>) {
let mut classification = ParameterClassification::default();
if matches!(¶meter.syntax, ParameterExpansionSyntax::Bourne(_)) {
classification.is_nested_bourne =
contains_nested_parameter_marker(parameter.raw_body.slice(source).trim_start());
}
let deepest = classify_parameter_expansion_walk(parameter, &mut classification, true, true);
(classification, deepest)
}
fn classify_parameter_expansion_walk<'a>(
parameter: &'a shuck_ast::ParameterExpansion,
classification: &mut ParameterClassification,
is_outermost: bool,
plain_chain: bool,
) -> Option<&'a VarRef> {
match ¶meter.syntax {
ParameterExpansionSyntax::Bourne(syntax) => {
if is_outermost
&& let BourneParameterExpansion::Operation { operator, .. }
| BourneParameterExpansion::Indirect {
operator: Some(operator),
..
} = syntax
{
classification.guards_unset_reference =
parameter_operator_guards_unset_reference(operator);
}
match syntax {
BourneParameterExpansion::Access { reference } => {
if reference.subscript.is_some() {
classification.has_array_reference = true;
if plain_chain {
classification.is_plain_array_reference = true;
}
}
Some(reference)
}
BourneParameterExpansion::Length { reference }
| BourneParameterExpansion::Indices { reference }
| BourneParameterExpansion::Indirect { reference, .. }
| BourneParameterExpansion::Transformation { reference, .. } => {
if reference.subscript.is_some() {
classification.has_array_reference = true;
}
Some(reference)
}
BourneParameterExpansion::Slice { reference, .. } => {
if reference.subscript.is_some() {
classification.has_array_reference = true;
} else {
classification.has_substring_expansion = true;
}
Some(reference)
}
BourneParameterExpansion::Operation {
reference,
operator,
..
} => {
if reference.subscript.is_some() {
classification.has_array_reference = true;
}
if matches!(
operator.as_ref(),
ParameterOp::UpperFirst
| ParameterOp::UpperAll
| ParameterOp::LowerFirst
| ParameterOp::LowerAll
) {
classification.has_case_modification = true;
}
if matches!(
operator.as_ref(),
ParameterOp::ReplaceFirst { .. } | ParameterOp::ReplaceAll { .. }
) {
classification.has_replacement_expansion = true;
}
if is_outermost && reference_is_positional_parameter_trim(reference, operator) {
classification.has_positional_parameter_trim = true;
}
Some(reference)
}
BourneParameterExpansion::PrefixMatch { .. } => None,
}
}
ParameterExpansionSyntax::Zsh(syntax) => {
let zsh_plain = syntax.length_prefix.is_none()
&& syntax.operation.is_none()
&& syntax.modifiers.is_empty();
let inner_plain_chain = plain_chain && zsh_plain;
match &syntax.target {
ZshExpansionTarget::Reference(reference) => {
if reference.subscript.is_some() {
classification.has_array_reference = true;
if inner_plain_chain {
classification.is_plain_array_reference = true;
}
}
Some(reference)
}
ZshExpansionTarget::Nested(inner) => classify_parameter_expansion_walk(
inner,
classification,
false,
inner_plain_chain,
),
ZshExpansionTarget::Word(_) | ZshExpansionTarget::Empty => None,
}
}
}
}
fn parameter_operator_guards_unset_reference(operator: &ParameterOp) -> bool {
matches!(
operator,
ParameterOp::UseDefault
| ParameterOp::AssignDefault
| ParameterOp::UseReplacement
| ParameterOp::Error
)
}
fn parameter_is_plain_unindexed_reference(parameter: &shuck_ast::ParameterExpansion) -> bool {
match ¶meter.syntax {
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) => {
reference.subscript.is_none() && name_is_plain_reference_candidate(&reference.name)
}
ParameterExpansionSyntax::Zsh(syntax)
if syntax.length_prefix.is_none()
&& syntax.operation.is_none()
&& syntax.modifiers.is_empty() =>
{
match &syntax.target {
ZshExpansionTarget::Reference(reference) => {
reference.subscript.is_none()
&& name_is_plain_reference_candidate(&reference.name)
}
ZshExpansionTarget::Nested(parameter) => {
parameter_is_plain_unindexed_reference(parameter)
}
ZshExpansionTarget::Word(_) | ZshExpansionTarget::Empty => false,
}
}
_ => false,
}
}
fn name_is_plain_reference_candidate(name: &Name) -> bool {
!matches!(name.as_str(), "@" | "*")
}
fn parameter_operator_has_pattern(operator: &ParameterOp) -> bool {
matches!(
operator,
ParameterOp::RemovePrefixShort { .. }
| ParameterOp::RemovePrefixLong { .. }
| ParameterOp::RemoveSuffixShort { .. }
| ParameterOp::RemoveSuffixLong { .. }
| ParameterOp::ReplaceFirst { .. }
| ParameterOp::ReplaceAll { .. }
)
}
fn parameter_operator_is_trim(operator: &ParameterOp) -> bool {
matches!(
operator,
ParameterOp::RemovePrefixShort { .. }
| ParameterOp::RemovePrefixLong { .. }
| ParameterOp::RemoveSuffixShort { .. }
| ParameterOp::RemoveSuffixLong { .. }
)
}
fn collect_parameter_operator_special_target_word_spans(
operator: &ParameterOp,
spans: &mut Vec<Span>,
) {
match operator {
ParameterOp::RemovePrefixShort { pattern }
| ParameterOp::RemovePrefixLong { pattern }
| ParameterOp::RemoveSuffixShort { pattern }
| ParameterOp::RemoveSuffixLong { pattern }
| ParameterOp::ReplaceFirst { pattern, .. }
| ParameterOp::ReplaceAll { pattern, .. } => {
collect_pattern_special_target_word_spans(pattern, spans);
}
ParameterOp::UseDefault
| ParameterOp::AssignDefault
| ParameterOp::UseReplacement
| ParameterOp::Error
| ParameterOp::UpperFirst
| ParameterOp::UpperAll
| ParameterOp::LowerFirst
| ParameterOp::LowerAll => {}
}
}
fn collect_pattern_special_target_word_spans(pattern: &Pattern, spans: &mut Vec<Span>) {
for (part, span) in pattern.parts_with_spans() {
match part {
PatternPart::Group { patterns, .. } => {
for pattern in patterns {
collect_pattern_special_target_word_spans(pattern, spans);
}
}
PatternPart::Word(_) => spans.push(span),
PatternPart::Literal(_)
| PatternPart::AnyString
| PatternPart::AnyChar
| PatternPart::CharClass(_) => {}
}
}
}
fn parameter_pattern_target_is_special(reference: &VarRef, operator: &ParameterOp) -> bool {
parameter_operator_has_pattern(operator)
&& (reference_has_array_subscript(reference) || reference.name.as_str() == "0")
}
fn reference_is_positional_parameter_trim(reference: &VarRef, operator: &ParameterOp) -> bool {
parameter_operator_is_trim(operator) && matches!(reference.name.as_str(), "*" | "@")
}
fn reference_has_array_subscript(reference: &VarRef) -> bool {
reference.subscript.is_some()
}
fn collect_positional_parameter_operator_spans_in_arithmetic(
expansion_span: Span,
expression_ast: Option<&ArithmeticExprNode>,
expression: &SourceText,
source: &str,
spans: &mut Vec<Span>,
) {
if let Some(expression_ast) = expression_ast {
if arithmetic_expr_has_positional_parameter_operator(expression_ast, source) {
spans.push(Span::from_positions(
expansion_span.start,
expansion_span.start,
));
}
return;
}
let text = expression.slice(source);
let mut should_report = false;
let mut state = ArithmeticScanState::default();
let mut chars = text.char_indices();
while let Some((index, char)) = chars.next() {
match state {
ArithmeticScanState::Normal => match char {
'\'' => state = ArithmeticScanState::SingleQuoted,
'"' => state = ArithmeticScanState::DoubleQuoted,
'\\' => {
chars.next();
}
'$' => {
let Some(token_end) = positional_parameter_token_end(text, index) else {
continue;
};
let immediate_prev = text[..index].chars().next_back();
let immediate_next = text[token_end..].chars().next();
let same_word_prefix =
immediate_prev.is_some_and(|ch| !raw_arithmetic_word_boundary(ch));
let same_word_suffix =
immediate_next.is_some_and(|ch| !raw_arithmetic_word_boundary(ch));
if same_word_prefix || same_word_suffix {
if same_word_prefix {
let word_start = raw_arithmetic_word_start(text, index);
let prefix = &text[word_start..index];
if prefix_starts_with_identifier_like_text(prefix) {
should_report = true;
break;
}
}
continue;
}
let prev = text[..index].chars().rev().find(|ch| !ch.is_whitespace());
let next = text[token_end..].chars().find(|ch| !ch.is_whitespace());
if prev.is_some_and(is_left_operand_neighbor)
|| next.is_some_and(is_right_operand_neighbor)
{
should_report = true;
break;
}
}
_ => {}
},
ArithmeticScanState::SingleQuoted => {
if char == '\'' {
state = ArithmeticScanState::Normal;
}
}
ArithmeticScanState::DoubleQuoted => match char {
'"' => state = ArithmeticScanState::Normal,
'\\' => {
chars.next();
}
_ => {}
},
}
}
if should_report {
spans.push(Span::from_positions(
expansion_span.start,
expansion_span.start,
));
}
}
fn raw_arithmetic_word_start(text: &str, end: usize) -> usize {
let mut start = end;
while let Some((index, ch)) = text[..start].char_indices().next_back() {
if raw_arithmetic_word_boundary(ch) {
break;
}
start = index;
}
start
}
fn raw_arithmetic_word_boundary(ch: char) -> bool {
ch.is_whitespace()
|| matches!(
ch,
'+' | '-'
| '*'
| '/'
| '%'
| '&'
| '|'
| '^'
| '?'
| ':'
| '<'
| '>'
| '='
| '!'
| '~'
| ','
| '('
| '['
)
}
fn arithmetic_expr_has_positional_parameter_operator(
expression: &ArithmeticExprNode,
source: &str,
) -> bool {
let mut should_report = false;
visit_arithmetic_words(expression, &mut |word| {
if word_has_unquoted_positional_parameter_operator_neighbors(word, source) {
should_report = true;
}
});
should_report
}
fn word_has_unquoted_positional_parameter_operator_neighbors(word: &Word, source: &str) -> bool {
word.parts.iter().enumerate().any(|(index, part)| {
part_is_unquoted_positional_parameter(&part.kind)
&& positional_parameter_part_has_identifier_like_prefix(word, index, source)
})
}
fn part_is_unquoted_positional_parameter(part: &WordPart) -> bool {
match part {
WordPart::Variable(name) => name_is_positional_parameter(name),
WordPart::Parameter(parameter) => matches!(
¶meter.syntax,
ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
if reference.subscript.is_none()
&& name_is_positional_parameter(&reference.name)
),
WordPart::ArrayAccess(reference) => {
reference.subscript.is_none() && name_is_positional_parameter(&reference.name)
}
WordPart::Literal(_)
| WordPart::ZshQualifiedGlob(_)
| WordPart::SingleQuoted { .. }
| WordPart::DoubleQuoted { .. }
| WordPart::CommandSubstitution { .. }
| WordPart::ArithmeticExpansion { .. }
| WordPart::ParameterExpansion { .. }
| WordPart::Length(_)
| WordPart::ArrayLength(_)
| WordPart::ArrayIndices(_)
| WordPart::Substring { .. }
| WordPart::ArraySlice { .. }
| WordPart::IndirectExpansion { .. }
| WordPart::PrefixMatch { .. }
| WordPart::ProcessSubstitution { .. }
| WordPart::Transformation { .. } => false,
}
}
fn name_is_positional_parameter(name: &Name) -> bool {
!name.as_str().is_empty() && name.as_str().bytes().all(|byte| byte.is_ascii_digit())
}
fn positional_parameter_part_has_identifier_like_prefix(
word: &Word,
index: usize,
source: &str,
) -> bool {
let Some(part) = word.parts.get(index) else {
return false;
};
let prefix = &source[word.span.start.offset..part.span.start.offset];
prefix_starts_with_identifier_like_text(prefix)
}
fn prefix_starts_with_identifier_like_text(prefix: &str) -> bool {
let Some(first_non_whitespace) = prefix.chars().find(|ch| !ch.is_whitespace()) else {
return false;
};
first_non_whitespace == '_' || first_non_whitespace.is_ascii_alphabetic()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum ArithmeticScanState {
#[default]
Normal,
SingleQuoted,
DoubleQuoted,
}
fn positional_parameter_token_end(text: &str, start: usize) -> Option<usize> {
let rest = text.get(start..)?;
if !rest.starts_with('$') {
return None;
}
let bytes = rest.as_bytes();
if bytes.get(1).is_some_and(u8::is_ascii_digit) {
let mut idx = 2usize;
while bytes.get(idx).is_some_and(u8::is_ascii_digit) {
idx += 1;
}
return Some(start + idx);
}
if bytes.get(1) == Some(&b'{') {
let mut idx = 2usize;
let mut saw_digit = false;
while bytes.get(idx).is_some_and(u8::is_ascii_digit) {
saw_digit = true;
idx += 1;
}
if saw_digit && bytes.get(idx) == Some(&b'}') {
return Some(start + idx + 1);
}
}
None
}
fn is_left_operand_neighbor(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | ')' | ']' | '}' | '"' | '\'')
}
fn is_right_operand_neighbor(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '(' | '[' | '{' | '"' | '\'')
}
pub(super) fn build_suppressed_subscript_reference_spans(
semantic: &SemanticModel,
suppressed_subscript_spans: &[Span],
arithmetic_only_suppressed_subscript_spans: &[Span],
) -> FxHashSet<FactSpan> {
if suppressed_subscript_spans.is_empty()
&& arithmetic_only_suppressed_subscript_spans.is_empty()
{
return FxHashSet::default();
}
let mut spans =
build_subscript_reference_spans_with_filter(semantic, suppressed_subscript_spans, |_| true);
spans.extend(build_subscript_reference_spans_with_filter(
semantic,
arithmetic_only_suppressed_subscript_spans,
|reference| matches!(reference.kind, ReferenceKind::ArithmeticRead),
));
spans
}
pub(super) fn build_subscript_later_suppression_reference_spans(
semantic: &SemanticModel,
subscript_later_suppression_spans: &[Span],
) -> FxHashSet<FactSpan> {
build_subscript_reference_spans_with_filter(semantic, subscript_later_suppression_spans, |_| {
true
})
}
fn build_subscript_reference_spans_with_filter(
semantic: &SemanticModel,
subscript_spans: &[Span],
mut include_reference: impl FnMut(&shuck_semantic::Reference) -> bool,
) -> FxHashSet<FactSpan> {
if subscript_spans.is_empty() {
return FxHashSet::default();
}
subscript_spans
.iter()
.flat_map(|span| semantic.references_in_span(*span))
.filter(|reference| include_reference(reference))
.map(|reference| FactSpan::new(reference.span))
.collect()
}
fn span_contains(outer: Span, inner: Span) -> bool {
outer.start.offset <= inner.start.offset && inner.end.offset <= outer.end.offset
}
fn word_looks_like_unset_array_target(word: &Word, source: &str) -> bool {
let text = word.span.slice(source);
let Some((name, _)) = text.split_once('[') else {
return false;
};
text.ends_with(']') && is_shell_variable_name(name)
}
fn collect_suspect_double_quote_spans(
word: &Word,
source: &str,
_command_name: Option<&str>,
assignment_target: Option<&str>,
spans: &mut Vec<(Span, Span)>,
) {
for (index, current) in word.parts.iter().enumerate() {
if !suspicious_open_quote_fragment(
word,
source,
index,
current,
assignment_target.is_some(),
) {
continue;
}
let Some(opening_span) = opening_quote_span(current, source) else {
continue;
};
let Some(closing_span) = closing_quote_span(current, source) else {
continue;
};
spans.push((opening_span, closing_span));
}
}
pub(super) fn rewrite_word_as_single_double_quoted_string(
word: &Word,
source: &str,
_assignment_target: Option<&str>,
) -> Box<str> {
let mut rendered = String::from("\"");
for part in &word.parts {
render_word_part_inside_double_quotes(&mut rendered, part, source, false);
}
rendered.push('"');
rendered.into_boxed_str()
}
pub(super) fn rewrite_pattern_as_single_double_quoted_string(
pattern: &Pattern,
source: &str,
) -> Box<str> {
let mut rendered = String::from("\"");
for part in &pattern.parts {
render_pattern_part_inside_double_quotes(&mut rendered, part, source);
}
rendered.push('"');
rendered.into_boxed_str()
}
fn render_pattern_part_inside_double_quotes(
rendered: &mut String,
part: &PatternPartNode,
source: &str,
) {
match &part.kind {
PatternPart::Literal(text) => {
push_double_quoted_literal(rendered, text.as_str(source, part.span));
}
PatternPart::Word(word) => {
for word_part in &word.parts {
render_word_part_inside_double_quotes(rendered, word_part, source, false);
}
}
PatternPart::AnyString
| PatternPart::AnyChar
| PatternPart::CharClass(_)
| PatternPart::Group { .. } => {
let syntax = Pattern {
parts: vec![part.clone()],
span: part.span,
}
.render_syntax(source);
push_double_quoted_literal(rendered, &syntax);
}
}
}
fn render_word_part_inside_double_quotes(
rendered: &mut String,
part: &WordPartNode,
source: &str,
source_is_double_quoted: bool,
) {
match &part.kind {
WordPart::Literal(text) => {
if source_is_double_quoted {
push_double_quoted_literal(rendered, text.as_str(source, part.span));
} else {
push_cooked_unquoted_literal_inside_double_quotes(
rendered,
text.as_str(source, part.span),
);
}
}
WordPart::SingleQuoted { value, .. } => {
push_double_quoted_literal(rendered, value.slice(source));
}
WordPart::DoubleQuoted { parts, .. } => {
for nested_part in parts {
render_word_part_inside_double_quotes(rendered, nested_part, source, true);
}
}
WordPart::Variable(name) => {
rendered.push_str("${");
rendered.push_str(name.as_ref());
rendered.push('}');
}
_ => rendered.push_str(&word_part_syntax(part, source)),
}
}
fn push_double_quoted_literal(rendered: &mut String, text: &str) {
for ch in text.chars() {
match ch {
'"' | '\\' | '$' | '`' => {
rendered.push('\\');
rendered.push(ch);
}
_ => rendered.push(ch),
}
}
}
fn push_cooked_unquoted_literal_inside_double_quotes(rendered: &mut String, text: &str) {
let mut cooked = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('\n') => {}
Some(escaped) => cooked.push(escaped),
None => cooked.push('\\'),
}
continue;
}
cooked.push(ch);
}
push_double_quoted_literal(rendered, &cooked);
}
fn word_part_syntax(part: &WordPartNode, source: &str) -> String {
Word {
parts: vec![part.clone()],
span: part.span,
brace_syntax: Vec::new(),
}
.render_syntax(source)
}
pub(super) fn word_has_reopened_double_quote_window(
word: &Word,
source: &str,
command_name: Option<&str>,
) -> bool {
word_has_suspect_double_quote_span(word, source, command_name, None)
}
fn word_has_suspect_double_quote_span(
word: &Word,
source: &str,
_command_name: Option<&str>,
assignment_target: Option<&str>,
) -> bool {
word.parts.iter().enumerate().any(|(index, current)| {
suspicious_open_quote_fragment(word, source, index, current, assignment_target.is_some())
&& opening_quote_span(current, source).is_some()
&& closing_quote_span(current, source).is_some()
})
}
fn suspicious_open_quote_fragment(
word: &Word,
source: &str,
index: usize,
current: &WordPartNode,
assignment_context: bool,
) -> bool {
(!assignment_context && suspicious_multiline_double_quote_suffix(word, source, index, current))
|| suspicious_reopened_single_quote_window(word, source, index, current)
}
fn suspicious_multiline_double_quote_suffix(
word: &Word,
source: &str,
index: usize,
current: &WordPartNode,
) -> bool {
if !matches!(current.kind, WordPart::DoubleQuoted { .. })
|| !current.span.slice(source).contains('\n')
|| !quote_starts_nonempty_multiline_fragment(current, source)
{
return false;
}
let Some(next) = word.parts.get(index + 1) else {
return false;
};
if immediate_double_quote_continuation_is_suspicious(next, source) {
return true;
}
word_part_is_empty_literal(next, source)
&& !word.parts[..index].iter().any(|part| {
matches!(part.kind, WordPart::DoubleQuoted { .. })
&& part.span.slice(source).contains('\n')
})
&& word
.parts
.get(index + 2)
.is_some_and(|part| matches!(part.kind, WordPart::DoubleQuoted { .. }))
}
fn suspicious_reopened_single_quote_window(
word: &Word,
source: &str,
index: usize,
current: &WordPartNode,
) -> bool {
let WordPart::SingleQuoted { dollar: false, .. } = current.kind else {
return false;
};
current.span.slice(source).contains('\n')
&& single_quote_reopens_after_literal_run(&word.parts[index + 1..], source)
}
fn single_quote_reopens_after_literal_run(parts: &[WordPartNode], source: &str) -> bool {
let mut saw_literal = false;
let mut first_nonempty_literal: Option<&str> = None;
for part in parts {
match &part.kind {
WordPart::Literal(text) => {
let text = text.as_str(source, part.span);
if !text.is_empty() {
saw_literal = true;
if first_nonempty_literal.is_none() {
first_nonempty_literal = Some(text);
}
}
}
WordPart::SingleQuoted { dollar: false, .. } => {
return if let Some(first_literal) = first_nonempty_literal {
!first_literal.starts_with('\\')
} else {
saw_literal || !parts.is_empty()
};
}
WordPart::SingleQuoted { dollar: true, .. }
| WordPart::DoubleQuoted { .. }
| WordPart::Variable(_)
| WordPart::ArithmeticExpansion { .. }
| WordPart::CommandSubstitution { .. }
| WordPart::ProcessSubstitution { .. }
| WordPart::Parameter(_)
| WordPart::ParameterExpansion { .. }
| WordPart::Length(_)
| WordPart::ArrayAccess(_)
| WordPart::ArrayLength(_)
| WordPart::ArrayIndices(_)
| WordPart::Substring { .. }
| WordPart::ArraySlice { .. }
| WordPart::IndirectExpansion { .. }
| WordPart::PrefixMatch { .. }
| WordPart::Transformation { .. }
| WordPart::ZshQualifiedGlob(_) => return false,
}
}
false
}
fn middle_part_is_word_like_literal_gap(part: &WordPartNode, source: &str) -> bool {
let WordPart::Literal(text) = &part.kind else {
return false;
};
let text = text.as_str(source, part.span);
split_quote_tail_is_suspicious(text) || backslash_prefixed_word_like_literal_gap(text)
}
fn word_part_is_empty_literal(part: &WordPartNode, source: &str) -> bool {
matches!(&part.kind, WordPart::Literal(text) if text.as_str(source, part.span).is_empty())
}
fn quote_starts_nonempty_multiline_fragment(part: &WordPartNode, source: &str) -> bool {
let quote = match part.kind {
WordPart::DoubleQuoted { .. } => '"',
WordPart::SingleQuoted { .. } => '\'',
_ => return false,
};
let text = part.span.slice(source);
let Some(quote_offset) = text.find(quote) else {
return false;
};
let after_quote = &text[quote_offset + quote.len_utf8()..];
!after_quote.starts_with('\n') && !after_quote.starts_with("\r\n")
}
fn immediate_double_quote_continuation_is_suspicious(part: &WordPartNode, source: &str) -> bool {
matches!(part.kind, WordPart::DoubleQuoted { .. })
|| immediate_double_quote_scalar_gap(part)
|| immediate_double_quote_substitution_gap(part)
|| middle_part_is_word_like_literal_gap(part, source)
}
fn immediate_double_quote_scalar_gap(part: &WordPartNode) -> bool {
matches!(
part.kind,
WordPart::Variable(_)
| WordPart::ArithmeticExpansion { .. }
| WordPart::Parameter(_)
| WordPart::ParameterExpansion { .. }
| WordPart::Length(_)
| WordPart::ArrayAccess(_)
| WordPart::ArrayLength(_)
| WordPart::ArrayIndices(_)
| WordPart::Substring { .. }
| WordPart::ArraySlice { .. }
| WordPart::IndirectExpansion { .. }
| WordPart::PrefixMatch { .. }
| WordPart::Transformation { .. }
)
}
fn immediate_double_quote_substitution_gap(part: &WordPartNode) -> bool {
matches!(
part.kind,
WordPart::CommandSubstitution { .. } | WordPart::ProcessSubstitution { .. }
)
}
fn double_quoted_part_is_empty(part: &WordPartNode, source: &str) -> bool {
let WordPart::DoubleQuoted { parts, .. } = &part.kind else {
return false;
};
parts.iter().all(|inner| match &inner.kind {
WordPart::Literal(text) => text.as_str(source, inner.span).is_empty(),
_ => false,
})
}
fn collect_split_suspect_closing_quote_spans(
word: &Word,
source: &str,
has_later_words: bool,
spans: &mut Vec<Span>,
) {
for (index, window) in word.parts.windows(2).enumerate() {
let [current, next] = window else {
continue;
};
let WordPart::DoubleQuoted { .. } = ¤t.kind else {
continue;
};
let WordPart::Literal(text) = &next.kind else {
continue;
};
if !current.span.slice(source).contains('\n') {
continue;
}
let tail = text.as_str(source, next.span);
if !split_quote_tail_is_suspicious(tail) {
continue;
}
let Some(span) = closing_quote_span(current, source) else {
continue;
};
if span.start.column == 1
|| (index > 0
&& double_quoted_part_is_empty(&word.parts[index - 1], source)
&& has_later_words)
{
spans.push(span);
}
}
}
fn split_quote_tail_is_suspicious(text: &str) -> bool {
let mut chars = text.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_alphabetic()) && chars.all(|char| !char.is_whitespace())
}
fn opening_quote_span(part: &WordPartNode, source: &str) -> Option<Span> {
let quote = match part.kind {
WordPart::DoubleQuoted { .. } => '"',
WordPart::SingleQuoted { .. } => '\'',
_ => return None,
};
let span = part.span;
let text = span.slice(source);
let quote_offset = text.find(quote)?;
let start = span.start.advanced_by(&text[..quote_offset]);
Some(Span::from_positions(start, start))
}
fn closing_quote_span(part: &WordPartNode, source: &str) -> Option<Span> {
let quote = match part.kind {
WordPart::DoubleQuoted { .. } => '"',
WordPart::SingleQuoted { .. } => '\'',
_ => return None,
};
let span = part.span;
let text = span.slice(source);
let quote_offset = text.rfind(quote)?;
let start = span.start.advanced_by(&text[..quote_offset]);
Some(Span::from_positions(start, start))
}
fn backslash_prefixed_word_like_literal_gap(text: &str) -> bool {
let text = text.trim();
let Some(stripped) = text.strip_prefix('\\') else {
return false;
};
!escaped_dollar_literal_gap(text) && split_quote_tail_is_suspicious(stripped)
}
fn escaped_dollar_literal_gap(text: &str) -> bool {
let mut saw_escaped_dollar = false;
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
continue;
}
saw_escaped_dollar = true;
if chars.next() != Some('$') {
return false;
}
}
saw_escaped_dollar
}
fn contains_nested_parameter_marker(text: &str) -> bool {
let inner = text
.strip_prefix("${${")
.or_else(|| text.strip_prefix("${#${"))
.or_else(|| text.strip_prefix("${!${"));
inner
.and_then(|inner| inner.chars().next())
.is_some_and(is_bourne_nested_parameter_start)
}
fn is_bourne_nested_parameter_start(char: char) -> bool {
matches!(char, '_' | '@' | '*' | '#' | '?' | '$' | '!' | '-') || char.is_ascii_alphanumeric()
}
pub(super) fn simple_command_variable_set_operand<'a>(
command: &'a SimpleCommand,
source: &str,
) -> Option<&'a Word> {
let operands = simple_test_operands(command, source)?;
(operands.len() == 2 && static_word_text(&operands[0], source).as_deref() == Some("-v"))
.then(|| &operands[1])
}
fn is_unicode_smart_quote(char: char) -> bool {
matches!(char, '\u{2018}' | '\u{2019}' | '\u{201C}' | '\u{201D}')
}
const UNICODE_SMART_QUOTE_LEAD_BYTE: u8 = 0xE2;
#[cfg(test)]
mod tests {
use super::{
arithmetic_expr_has_positional_parameter_operator,
word_has_unquoted_positional_parameter_operator_neighbors,
};
use shuck_ast::{Command, WordPart};
use shuck_parser::parser::Parser;
#[test]
fn detects_identifier_led_prefixes_before_positional_parameters_in_arithmetic_words() {
for text in ["prefix$1", "a${1}", "foo${bar}$1"] {
let word = Parser::parse_word_string(text);
assert!(
word_has_unquoted_positional_parameter_operator_neighbors(&word, text),
"expected {text:?} to be flagged",
);
}
}
#[test]
fn ignores_suffixes_and_non_identifier_prefixes_around_positional_parameters() {
for text in [
"$1",
"${1}",
"$1suffix",
"${1}suffix",
"\"$1\"",
"'$1'",
"16#$1",
"0x$1",
"0x${1}${2}",
"1a$1",
"${base}$1",
] {
let word = Parser::parse_word_string(text);
assert!(
!word_has_unquoted_positional_parameter_operator_neighbors(&word, text),
"expected {text:?} to be ignored",
);
}
}
#[test]
fn detects_positional_parameter_operator_in_parsed_arithmetic_shell_word() {
let source = "#!/bin/sh\necho \"$(( value + prefix$1 ))\"\n";
let output = Parser::new(source).parse().unwrap();
let command = output.file.body.stmts.first().expect("expected command");
let Command::Simple(command) = &command.command else {
panic!("expected simple command");
};
let expression_ast = command.args[0]
.parts
.iter()
.find_map(|part| match &part.kind {
WordPart::DoubleQuoted { parts, .. } => parts.iter().find_map(|part| {
if let WordPart::ArithmeticExpansion {
expression_ast: Some(expression_ast),
..
} = &part.kind
{
Some(expression_ast)
} else {
None
}
}),
_ => None,
})
.expect("expected parsed arithmetic expression");
assert!(arithmetic_expr_has_positional_parameter_operator(
expression_ast,
source
));
}
}