use shuck_ast::Span;
use crate::{
Checker, ConditionalNodeFact, ConditionalOperatorFamily, Diagnostic, Edit, Fix,
FixAvailability, Rule, SimpleTestSyntax, Violation, leading_literal_word_prefix,
};
pub struct XPrefixInTest;
impl Violation for XPrefixInTest {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;
fn rule() -> Rule {
Rule::XPrefixInTest
}
fn message(&self) -> String {
"this comparison uses the legacy x-prefix idiom".to_owned()
}
fn fix_title(&self) -> Option<String> {
Some("remove the x-prefix comparison padding".to_owned())
}
}
pub fn x_prefix_in_test(checker: &mut Checker) {
let source = checker.source();
let reports = checker
.facts()
.commands()
.iter()
.flat_map(|fact| {
let mut reports = Vec::new();
if let Some(simple_test) = fact.simple_test() {
reports.extend(simple_test_reports(simple_test, source));
}
if let Some(conditional) = fact.conditional() {
reports.extend(conditional_reports(conditional, source));
}
reports
})
.collect::<Vec<_>>();
for report in reports {
let diagnostic = Diagnostic::new(XPrefixInTest, report.diagnostic_span);
checker.report_diagnostic_dedup(match x_prefix_fix(source, &report) {
Some(fix) => diagnostic.with_fix(fix),
None => diagnostic,
});
}
}
struct XPrefixComparisonReport {
diagnostic_span: Span,
left_span: Span,
right_span: Span,
}
fn simple_test_reports(
simple_test: &crate::SimpleTestFact<'_>,
source: &str,
) -> Vec<XPrefixComparisonReport> {
if simple_test.syntax() != SimpleTestSyntax::Test
&& simple_test.syntax() != SimpleTestSyntax::Bracket
{
return Vec::new();
}
simple_test
.string_binary_expression_words(source)
.into_iter()
.filter_map(|(left, _operator, right)| {
(word_has_x_prefix(left, source) && word_has_x_prefix(right, source)).then_some(
XPrefixComparisonReport {
diagnostic_span: left.span,
left_span: left.span,
right_span: right.span,
},
)
})
.collect()
}
fn conditional_reports(
conditional: &crate::ConditionalFact<'_>,
source: &str,
) -> Vec<XPrefixComparisonReport> {
conditional
.nodes()
.iter()
.filter_map(|node| match node {
ConditionalNodeFact::Binary(binary)
if binary.operator_family() == ConditionalOperatorFamily::StringBinary =>
{
if conditional_operand_has_x_prefix(binary.left(), source)
&& conditional_operand_has_x_prefix(binary.right(), source)
{
let left_span = conditional_operand_span(binary.left());
let right_span = conditional_operand_span(binary.right());
Some(XPrefixComparisonReport {
diagnostic_span: left_span,
left_span,
right_span,
})
} else {
None
}
}
ConditionalNodeFact::BareWord(_)
| ConditionalNodeFact::Unary(_)
| ConditionalNodeFact::Binary(_)
| ConditionalNodeFact::Other(_) => None,
})
.collect()
}
fn conditional_operand_span(operand: crate::ConditionalOperandFact<'_>) -> Span {
operand
.word()
.map(|word| word.span)
.unwrap_or_else(|| operand.expression().span())
}
fn conditional_operand_has_x_prefix(
operand: crate::ConditionalOperandFact<'_>,
source: &str,
) -> bool {
operand
.word()
.map(|word| word_has_x_prefix(word, source))
.unwrap_or_else(|| has_legacy_x_prefix(operand.expression().span().slice(source)))
}
fn word_has_x_prefix(word: &shuck_ast::Word, source: &str) -> bool {
has_legacy_x_prefix(&leading_literal_word_prefix(word, source))
}
fn has_legacy_x_prefix(text: &str) -> bool {
matches!(text.as_bytes().first(), Some(b'x' | b'X'))
}
fn x_prefix_fix(source: &str, report: &XPrefixComparisonReport) -> Option<Fix> {
Some(Fix::unsafe_edits([
Edit::replacement(
x_prefix_operand_replacement(source, report.left_span)?,
report.left_span,
),
Edit::replacement(
x_prefix_operand_replacement(source, report.right_span)?,
report.right_span,
),
]))
}
fn x_prefix_operand_replacement(source: &str, span: Span) -> Option<String> {
let text = span.slice(source);
let (body, quote_style) = strip_simple_quotes(text).unwrap_or((text, QuoteStyle::Unquoted));
if quote_style == QuoteStyle::Unquoted && text.bytes().any(|byte| matches!(byte, b'\'' | b'"'))
{
return None;
}
let without_prefix = body
.char_indices()
.nth(1)
.map_or("", |(offset, _)| &body[offset..]);
Some(match quote_style {
QuoteStyle::Single => format!("'{without_prefix}'"),
QuoteStyle::Double | QuoteStyle::Unquoted => {
format!("\"{}\"", without_prefix.replace('"', "\\\""))
}
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum QuoteStyle {
Single,
Double,
Unquoted,
}
fn strip_simple_quotes(text: &str) -> Option<(&str, QuoteStyle)> {
text.strip_prefix('"')
.and_then(|inner| inner.strip_suffix('"'))
.map(|inner| (inner, QuoteStyle::Double))
.or_else(|| {
text.strip_prefix('\'')
.and_then(|inner| inner.strip_suffix('\''))
.map(|inner| (inner, QuoteStyle::Single))
})
}
#[cfg(test)]
mod tests {
use crate::test::{test_snippet, test_snippet_with_fix};
use crate::{Applicability, LinterSettings, Rule};
#[test]
fn reports_x_prefix_comparisons_in_simple_tests_and_conditionals() {
let source = "\
#!/bin/bash
[ x = x ]
[ X = Xbar ]
[ \"Xfoo\" = \"X$browser\" ]
[ X = \"X$browser\" ]
test \"x$browser\" != \"x\"
[ \"X`id -u`\" = \"X0\" -a -z \"$RUN_AS_USER\" ]
[ \"pkg-config --exists libffmpegthumbnailer\" -a \"x${VIDEO_THUMBNAILS}\" != \"xno\" ]
[ X = X ]
[[ X = Xbar ]]
[[ \"X$browser\" != \"X\" ]]
[ \"x$browser\" = \"x$other\" ]
[ x = \"x$browser\" ]
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::XPrefixInTest));
assert_eq!(
diagnostics
.iter()
.map(|diagnostic| diagnostic.span.slice(source))
.collect::<Vec<_>>(),
vec![
"x",
"X",
"\"Xfoo\"",
"X",
"\"x$browser\"",
"\"X`id -u`\"",
"\"x${VIDEO_THUMBNAILS}\"",
"X",
"X",
"\"X$browser\"",
"\"x$browser\"",
"x"
]
);
}
#[test]
fn ignores_non_x_prefix_or_single_sided_comparisons() {
let source = "\
#!/bin/bash
[ \"x$browser\" = \"$other\" ]
[ x = \"$browser\" ]
[ \"X$browser\" = \"$other\" ]
[ X = \"$browser\" ]
[ Xfoo = y ]
[ \"X$browser\" = \"Y\" ]
[[ prefix$browser = prefix ]]
[[ Prefix$browser = Prefix ]]
[[ x = y ]]
[[ X = Y ]]
";
let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::XPrefixInTest));
assert!(diagnostics.is_empty());
}
#[test]
fn applies_unsafe_fix_to_remove_x_prefixes_from_both_operands() {
let source = "#!/bin/bash\n[ \"x$browser\" = \"x\" ]\n[[ X = Xbar ]]\n";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::XPrefixInTest),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 2);
assert_eq!(
result.fixed_source,
"#!/bin/bash\n[ \"$browser\" = \"\" ]\n[[ \"\" = \"bar\" ]]\n"
);
assert!(result.fixed_diagnostics.is_empty());
}
#[test]
fn applies_unsafe_fix_without_expanding_single_quoted_operands() {
let source = "#!/bin/bash\n[ 'x$HOME' = 'x' ]\n[ 'x$(cmd)' = 'x' ]\n";
let result = test_snippet_with_fix(
source,
&LinterSettings::for_rule(Rule::XPrefixInTest),
Applicability::Unsafe,
);
assert_eq!(result.fixes_applied, 2);
assert_eq!(
result.fixed_source,
"#!/bin/bash\n[ '$HOME' = '' ]\n[ '$(cmd)' = '' ]\n"
);
assert!(result.fixed_diagnostics.is_empty());
}
}