use shuck_ast::{ConditionalBinaryOp, Name, Span, is_shell_variable_name, static_word_text};
use shuck_parser::text_is_self_contained_arithmetic_expression;
use crate::{
Checker, ConditionalBinaryFact, ConditionalNodeFact, ConditionalOperandFact, Diagnostic, Edit,
Fix, FixAvailability, Rule, Violation, WordQuote,
};
use super::variable_reference_common::binding_defines_variable_name_at;
pub struct StringComparedWithEq;
impl Violation for StringComparedWithEq {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;
fn rule() -> Rule {
Rule::StringComparedWithEq
}
fn message(&self) -> String {
"this numeric comparison uses text instead of a number".to_owned()
}
fn fix_title(&self) -> Option<String> {
Some("use a string comparison".to_owned())
}
}
pub fn string_compared_with_eq(checker: &mut Checker) {
let source = checker.source();
let diagnostics = checker
.facts()
.commands()
.iter()
.flat_map(|fact| {
fact.conditional()
.map(|conditional| conditional_string_eq_spans(checker, conditional, source))
.unwrap_or_default()
})
.collect::<Vec<_>>();
for (span, fix) in diagnostics {
checker.report_diagnostic_dedup(Diagnostic::new(StringComparedWithEq, span).with_fix(fix));
}
}
fn conditional_string_eq_spans(
checker: &Checker<'_>,
conditional: &crate::ConditionalFact<'_>,
source: &str,
) -> Vec<(Span, Fix)> {
conditional
.nodes()
.iter()
.flat_map(|node| match node {
ConditionalNodeFact::Binary(binary)
if matches!(
binary.op(),
ConditionalBinaryOp::ArithmeticEq | ConditionalBinaryOp::ArithmeticNe
) =>
{
string_eq_diagnostics(checker, *binary, source)
}
ConditionalNodeFact::BareWord(_)
| ConditionalNodeFact::Unary(_)
| ConditionalNodeFact::Binary(_)
| ConditionalNodeFact::Other(_) => Vec::new(),
})
.collect()
}
fn string_eq_diagnostics(
checker: &Checker<'_>,
binary: ConditionalBinaryFact<'_>,
source: &str,
) -> Vec<(Span, Fix)> {
let operands = [binary.left(), binary.right()]
.into_iter()
.filter_map(|operand| conditional_operand_string_value(checker, operand, source))
.collect::<Vec<_>>();
if operands.is_empty() {
return Vec::new();
}
let Some(fix) = string_eq_fix(binary, &operands) else {
return Vec::new();
};
operands
.into_iter()
.map(|operand| (operand.span, fix.clone()))
.collect()
}
#[derive(Debug, Clone, Copy)]
struct StringOperand {
span: Span,
quote: Option<WordQuote>,
}
fn conditional_operand_string_value(
checker: &Checker<'_>,
operand: ConditionalOperandFact<'_>,
source: &str,
) -> Option<StringOperand> {
operand
.word()
.zip(operand.word_classification())
.and_then(|(word, classification)| {
classification
.is_fixed_literal()
.then_some(word)
.filter(|word| {
static_word_text(word, source).is_some_and(|text| {
operand_text_looks_like_string_value(
checker,
&text,
operand.quote(),
word.span,
)
})
})
.map(|word| StringOperand {
span: word.span,
quote: operand.quote(),
})
})
}
fn string_eq_fix(binary: ConditionalBinaryFact<'_>, operands: &[StringOperand]) -> Option<Fix> {
let operator_replacement = match binary.op() {
ConditionalBinaryOp::ArithmeticEq => "=",
ConditionalBinaryOp::ArithmeticNe => "!=",
ConditionalBinaryOp::RegexMatch
| ConditionalBinaryOp::NewerThan
| ConditionalBinaryOp::OlderThan
| ConditionalBinaryOp::SameFile
| ConditionalBinaryOp::ArithmeticLe
| ConditionalBinaryOp::ArithmeticGe
| ConditionalBinaryOp::ArithmeticLt
| ConditionalBinaryOp::ArithmeticGt
| ConditionalBinaryOp::And
| ConditionalBinaryOp::Or
| ConditionalBinaryOp::PatternEqShort
| ConditionalBinaryOp::PatternEq
| ConditionalBinaryOp::PatternNe
| ConditionalBinaryOp::LexicalBefore
| ConditionalBinaryOp::LexicalAfter => return None,
};
let mut edits = vec![Edit::replacement(
operator_replacement,
binary.operator_span(),
)];
for operand in operands
.iter()
.filter(|operand| operand.quote == Some(WordQuote::Unquoted))
{
edits.push(Edit::insertion(operand.span.start.offset, "\""));
edits.push(Edit::insertion(operand.span.end.offset, "\""));
}
Some(Fix::unsafe_edits(edits))
}
fn operand_text_looks_like_string_value(
checker: &Checker<'_>,
text: &str,
quote: Option<WordQuote>,
span: Span,
) -> bool {
if looks_like_decimal_integer(text) {
return false;
}
if looks_like_defined_variable_name(checker, text, span) {
return false;
}
if !text_is_self_contained_arithmetic_expression(text) {
return true;
}
quote != Some(WordQuote::Unquoted) && text.trim().starts_with('(') && text.trim().ends_with(')')
}
fn looks_like_defined_variable_name(checker: &Checker<'_>, text: &str, span: Span) -> bool {
if !is_shell_variable_name(text) {
return false;
}
let name = Name::from(text);
checker
.semantic()
.bindings_for(&name)
.iter()
.copied()
.any(|binding_id| {
let binding = checker.semantic().binding(binding_id);
binding.span.start.offset != span.start.offset
&& binding_defines_variable_name_at(checker, binding, span)
})
}
fn looks_like_decimal_integer(text: &str) -> bool {
let text = text
.strip_prefix('+')
.or_else(|| text.strip_prefix('-'))
.unwrap_or(text);
!text.is_empty() && text.chars().all(|ch| ch.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use crate::test::{
test_path_with_fix, test_snippet, test_snippet_at_path, test_snippet_with_fix,
};
use crate::{Applicability, LinterSettings, Rule, assert_diagnostics_diff};
use tempfile::tempdir;
#[test]
fn reports_string_like_operands_in_double_bracket_numeric_comparisons() {
let source = "\
#!/bin/bash
[[ $VER -eq \"latest\" ]]
[[ \"latest\" -eq $VER ]]
[[ foo -ne 3 ]]
[[ 3 -eq bar ]]
[[ foo -eq bar ]]
[[ \"(1+2)\" -eq 3 ]]
[[ foo+1 -eq 3 ]]
[[ \"foo+1\" -eq 3 ]]
[[ arr[1] -eq 3 ]]
[[ \"arr[1]\" -eq 3 ]]
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::StringComparedWithEq),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec![
"\"latest\"",
"\"latest\"",
"foo",
"bar",
"foo",
"bar",
"\"(1+2)\"",
"foo+1",
"\"foo+1\"",
"arr[1]",
"\"arr[1]\"",
]
);
}
#[test]
fn ignores_single_bracket_and_numeric_arithmetic_comparisons() {
let source = "\
#!/bin/bash
[ 1 -eq 2 ]
[[ 1 -eq 2 ]]
[ $VER -eq \"latest\" ]
[[ $VER = latest ]]
[[ 1+2 -eq 3 ]]
[[ (1+2) -eq 3 ]]
[[ \"1+2\" -eq 3 ]]
[[ \"1 + 2\" -eq 3 ]]
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::StringComparedWithEq),
);
assert!(diagnostics.is_empty());
}
#[test]
fn ignores_operands_that_match_defined_variable_names() {
let source = "\
#!/bin/bash
retval=$?
[[ retval -ne 0 ]]
DISABLE_MENU=1
[[ \"$LEGACY_JOY2KEY\" -eq 0 && \"DISABLE_MENU\" -ne 1 ]]
__iterator=0
while [[ __iterator -eq 0 || -n \"${__next}\" ]]; do
break
done
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::StringComparedWithEq),
);
assert!(diagnostics.is_empty());
}
#[test]
fn same_file_bindings_suppress_literal_variable_name_heuristics() {
let source = "\
#!/bin/bash
[[ foo -eq 1 ]]
foo=1
helper() {
local bar=1
}
[[ bar -eq 1 ]]
show_launch() {
[[ \"$LEGACY_JOY2KEY\" -eq 0 && \"DISABLE_MENU\" -ne 1 ]]
}
get_config() {
DISABLE_MENU=1
}
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::StringComparedWithEq),
);
assert!(diagnostics.is_empty());
}
#[test]
fn function_definitions_do_not_suppress_diagnostics() {
let source = "\
#!/bin/bash
DISABLE_MENU() {
:
}
[[ \"DISABLE_MENU\" -ne 1 ]]
";
let diagnostics = test_snippet(
source,
&LinterSettings::for_rule(Rule::StringComparedWithEq),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["\"DISABLE_MENU\""]
);
}
#[test]
fn imported_bindings_only_suppress_after_the_source_site() {
let temp = tempdir().unwrap();
let main = temp.path().join("main.sh");
let helper = temp.path().join("helper.sh");
let source = "\
#!/bin/bash
[[ flag -eq 1 ]]
source ./helper.sh
[[ flag -eq 1 ]]
";
fs::write(&main, source).unwrap();
fs::write(&helper, "flag=1\n").unwrap();
let diagnostics = test_snippet_at_path(
&main,
source,
&LinterSettings::for_rule(Rule::StringComparedWithEq)
.with_analyzed_paths([main.clone(), helper.clone()]),
);
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec!["flag"]
);
}
#[test]
fn applies_unsafe_fix_to_text_numeric_comparisons() {
let source = "\
#!/bin/bash
[[ $VER -eq \"latest\" ]]
[[ foo -ne 3 ]]
[[ foo -eq bar ]]
";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::StringComparedWithEq),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 3);
assert_eq!(
result.fixed_source,
"\
#!/bin/bash
[[ $VER = \"latest\" ]]
[[ \"foo\" != 3 ]]
[[ \"foo\" = \"bar\" ]]
"
);
assert!(result.fixed_diagnostics.is_empty());
}
#[test]
fn safe_fix_mode_leaves_text_numeric_comparisons_unchanged() {
let source = "#!/bin/bash\n[[ foo -eq 3 ]]\n";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::StringComparedWithEq),
Applicability::Safe,
);
assert_eq!(result.fixes_applied, 0);
assert_eq!(result.fixed_source, source);
assert_eq!(result.fixed_diagnostics.len(), 1);
}
#[test]
fn snapshots_unsafe_fix_output_for_fixture() -> anyhow::Result<()> {
let result = test_path_with_fix(
Path::new("correctness").join("C121.sh").as_path(),
&LinterSettings::for_rule(Rule::StringComparedWithEq),
Applicability::Unsafe,
)?;
assert_diagnostics_diff!("C121_fix_C121.sh", result);
Ok(())
}
}