shuck-linter 0.0.41

Lint rule engine and checker for shell scripts
Documentation
use shuck_ast::Span;

use crate::{
    Checker, ConditionalNodeFact, ConditionalOperatorFamily, Diagnostic, Edit, ExpansionContext,
    Fix, FixAvailability, Rule, Violation,
};

pub struct ArraySliceInComparison;

impl Violation for ArraySliceInComparison {
    const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;

    fn rule() -> Rule {
        Rule::ArraySliceInComparison
    }

    fn message(&self) -> String {
        "all-elements array expansions collapse inside `[[ ... ]]` tests".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("rewrite the expansion as an intentional join".to_owned())
    }
}

pub fn array_slice_in_comparison(checker: &mut Checker) {
    let locator = checker.locator();
    let source = checker.source();
    let direct_operand_diagnostics = [
        ExpansionContext::StringTestOperand,
        ExpansionContext::RegexOperand,
    ]
    .into_iter()
    .flat_map(|context| checker.facts().words().expansion_word_facts(context))
    .filter(|fact| !fact.is_nested_word_command())
    .filter(|fact| fact.has_direct_all_elements_array_expansion_in_source(locator))
    .map(|fact| {
        (
            fact.span(),
            intentional_join_fix(fact.direct_all_elements_array_expansion_spans(), source),
        )
    })
    .collect::<Vec<_>>();

    let risky_pattern_words = checker
        .facts()
        .words()
        .expansion_word_facts(ExpansionContext::ConditionalPattern)
        .filter(|fact| !fact.is_nested_word_command())
        .filter(|fact| fact.command_substitution_spans().is_empty())
        .filter(|fact| !fact.is_pure_positional_at_splat())
        .filter(|fact| fact.has_direct_all_elements_array_expansion_in_source(locator))
        .map(|fact| RiskyPatternWord {
            span: fact.span(),
            expansion_spans: fact.direct_all_elements_array_expansion_spans(),
        })
        .collect::<Vec<_>>();

    let diagnostics = checker
        .facts()
        .commands()
        .iter()
        .filter_map(|fact| fact.conditional())
        .flat_map(|conditional| conditional.nodes().iter())
        .flat_map(|node| conditional_pattern_spans(node, &risky_pattern_words, source))
        .chain(direct_operand_diagnostics)
        .collect::<Vec<_>>();

    for (span, fix) in diagnostics {
        let diagnostic = Diagnostic::new(ArraySliceInComparison, span);
        if let Some(fix) = fix {
            checker.report_diagnostic_dedup(diagnostic.with_fix(fix));
        } else {
            checker.report_diagnostic_dedup(diagnostic);
        }
    }
}

#[derive(Clone, Copy)]
struct RiskyPatternWord<'a> {
    span: Span,
    expansion_spans: &'a [Span],
}

fn conditional_pattern_spans(
    fact: &ConditionalNodeFact<'_>,
    risky_words: &[RiskyPatternWord<'_>],
    source: &str,
) -> Vec<(Span, Option<Fix>)> {
    match fact {
        ConditionalNodeFact::Binary(binary)
            if binary.operator_family() != ConditionalOperatorFamily::Logical =>
        {
            [
                pattern_span_if_risky(binary.left().expression().span(), risky_words, source),
                pattern_span_if_risky(binary.right().expression().span(), risky_words, source),
            ]
            .into_iter()
            .flatten()
            .collect()
        }
        ConditionalNodeFact::BareWord(_)
        | ConditionalNodeFact::Unary(_)
        | ConditionalNodeFact::Binary(_)
        | ConditionalNodeFact::Other(_) => Vec::new(),
    }
}

fn pattern_span_if_risky(
    span: Span,
    risky_words: &[RiskyPatternWord<'_>],
    source: &str,
) -> Option<(Span, Option<Fix>)> {
    let expansion_spans = risky_words
        .iter()
        .filter(|word| span_contains(span, word.span))
        .flat_map(|word| word.expansion_spans.iter().copied())
        .collect::<Vec<_>>();

    (!expansion_spans.is_empty()).then(|| (span, intentional_join_fix(&expansion_spans, source)))
}

fn span_contains(outer: Span, inner: Span) -> bool {
    outer.start.offset <= inner.start.offset && outer.end.offset >= inner.end.offset
}

fn intentional_join_fix(expansion_spans: &[Span], source: &str) -> Option<Fix> {
    expansion_spans
        .iter()
        .map(|span| {
            intentional_join_expansion(span.slice(source))
                .map(|replacement| Edit::replacement(replacement, *span))
        })
        .collect::<Option<Vec<_>>>()
        .filter(|edits| !edits.is_empty())
        .map(Fix::unsafe_edits)
}

fn intentional_join_expansion(raw: &str) -> Option<String> {
    if raw == "$@" {
        return Some("$*".to_owned());
    }

    if let Some(rest) = raw.strip_prefix("$@[*]") {
        return Some(format!("$*{rest}"));
    }

    if raw.starts_with("${@") {
        return Some(raw.replacen("${@", "${*", 1));
    }

    if raw.contains("[@]") {
        return Some(raw.replacen("[@]", "[*]", 1));
    }

    None
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use crate::test::{test_path_with_fix, test_snippet, test_snippet_with_fix};
    use crate::{Applicability, LinterSettings, Rule, ShellDialect, assert_diagnostics_diff};

    #[test]
    fn reports_all_elements_array_expansions_in_double_bracket_tests() {
        let source = "\
#!/bin/bash
set -- a b
arr=(x y)
if [[ \"${sel[@]:0:4}\" == \"HELP\" ]]; then :; fi
if [[ -n \"$@\" ]]; then :; fi
if [[ x == *${arr[@]}* ]]; then :; fi
if [[ \"${@: -1}\" == \"mM\" || \"${@:-1}\" == \"Mm\" ]]; then :; fi
if [[ \" ${arr[@]} \" =~ \" x \" ]]; then :; fi
if [[ \"${arr[@]}\" ]]; then :; fi
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ArraySliceInComparison),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "\"${sel[@]:0:4}\"",
                "\"$@\"",
                "*${arr[@]}*",
                "\"${@: -1}\"",
                "\"${@:-1}\"",
                "\" ${arr[@]} \"",
                "\"${arr[@]}\"",
            ]
        );
    }

    #[test]
    fn ignores_star_expansions_escaped_literals_and_single_bracket_tests() {
        let source = "\
#!/bin/bash
if [[ \"${sel[*]:1}\" == \"HELP\" ]]; then :; fi
if [[ \"\\${sel[@]:1}\" == \"HELP\" ]]; then :; fi
if [[ x == ${sel[*]}* ]]; then :; fi
if [[ \"\\$@\" ]]; then :; fi
if [[ -z ${packed=\"$@\"} ]]; then :; fi
if [ \"${sel[@]:1}\" = \"HELP\" ]; then :; fi
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ArraySliceInComparison),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_zsh_indexed_and_sliced_positional_parameter_tests() {
        let source = "\
#!/bin/zsh
set -- alpha --scope tail
_i=2
if [[ \"${@[_i]}\" == --scope ]]; then :; fi
if [[ \"$@[_i]\" == --scope ]]; then :; fi
if [[ \"${@[2,-1]}\" == \"--scope tail\" ]]; then :; fi
if [[ \"${@[5,-1]:-fallback}\" == fallback ]]; then :; fi
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ArraySliceInComparison).with_shell(ShellDialect::Zsh),
        );

        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
    }

    #[test]
    fn reports_zsh_positional_star_selector_tests() {
        let source = "\
#!/bin/zsh
set -- alpha --scope tail
if [[ \"${@[*]}\" == \"alpha --scope tail\" ]]; then :; fi
if [[ \"$@[*]\" == \"alpha --scope tail\" ]]; then :; fi
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ArraySliceInComparison).with_shell(ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["\"${@[*]}\"", "\"$@[*]\""]
        );
    }

    #[test]
    fn applies_unsafe_fix_to_all_elements_expansions_in_double_bracket_tests() {
        let source = "\
#!/bin/bash
if [[ \"${sel[@]:0:4}\" == \"HELP\" ]]; then :; fi
if [[ -n \"$@\" ]]; then :; fi
if [[ x == *${arr[@]}* ]]; then :; fi
if [[ \"_$@:${arr[@]}\" = x ]]; then :; fi
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::ArraySliceInComparison),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 4);
        assert_eq!(
            result.fixed_source,
            "\
#!/bin/bash
if [[ \"${sel[*]:0:4}\" == \"HELP\" ]]; then :; fi
if [[ -n \"$*\" ]]; then :; fi
if [[ x == *${arr[*]}* ]]; then :; fi
if [[ \"_$*:${arr[*]}\" = x ]]; then :; fi
"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn safe_fix_mode_leaves_all_elements_expansions_unchanged() {
        let source = "#!/bin/bash\nif [[ -n \"$@\" ]]; then :; fi\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::ArraySliceInComparison),
            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("C112.sh").as_path(),
            &LinterSettings::for_rule(Rule::ArraySliceInComparison),
            Applicability::Unsafe,
        )?;

        assert_diagnostics_diff!("C112_fix_C112.sh", result);
        Ok(())
    }
}