shuck-linter 0.0.41

Lint rule engine and checker for shell scripts
Documentation
use crate::{
    Checker, Diagnostic, Edit, ExpansionContext, Fix, FixAvailability, Rule, Violation,
    WordFactHostKind,
};

pub struct QuotedArraySlice;

impl Violation for QuotedArraySlice {
    const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;

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

    fn message(&self) -> String {
        "all-elements array expansions collapse in scalar assignment values".to_owned()
    }

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

pub fn quoted_array_slice(checker: &mut Checker) {
    let facts = checker.facts();
    let locator = checker.locator();
    let source = checker.source();
    let diagnostics = [
        ExpansionContext::AssignmentValue,
        ExpansionContext::DeclarationAssignmentValue,
    ]
    .into_iter()
    .flat_map(|context| facts.words().expansion_word_facts(context))
    .filter(|fact| fact.host_kind() == WordFactHostKind::Direct)
    .filter(|fact| !facts.words().is_compound_assignment_value_word(*fact))
    .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<_>>();

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

fn intentional_join_fix(expansion_spans: &[shuck_ast::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_scalar_bindings() {
        let source = "\
#!/bin/bash
x=\"$@\"
y=\"${@}\"
z=${@:5}
p=\"${arr[@]}\"
q=\"${arr[@]:-fallback}\"
r=\"${arr[@]@Q}\"
flags+=\" ${add_flags[@]}\"
targets[$key]=\"${items[@]}\"
CFLAGS+=\" ${add_flags[@]}\" make
escaped=\"\\\\$@\"
escaped_slice=\"\\\\${@:2}\"
declare declared=\"$@\"
readonly packed=${arr[@]}
f() { local nested=\"${@:3}\"; }
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::QuotedArraySlice));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "\"$@\"",
                "\"${@}\"",
                "${@:5}",
                "\"${arr[@]}\"",
                "\"${arr[@]:-fallback}\"",
                "\"${arr[@]@Q}\"",
                "\" ${add_flags[@]}\"",
                "\"${items[@]}\"",
                "\" ${add_flags[@]}\"",
                "\"\\\\$@\"",
                "\"\\\\${@:2}\"",
                "\"$@\"",
                "${arr[@]}",
                "\"${@:3}\"",
            ]
        );
    }

    #[test]
    fn reports_quoted_array_slice_assignments_into_scalar_bindings() {
        let source = "\
#!/bin/bash
x=\"${@:5}\"
y=\"prefix${@:2}suffix\"
declare z=\"${arr[@]:1}\"
readonly q=\"${arr[@]:1:2}\"
f() { local nested=\"${@:3}\"; }
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::QuotedArraySlice));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "\"${@:5}\"",
                "\"prefix${@:2}suffix\"",
                "\"${arr[@]:1}\"",
                "\"${arr[@]:1:2}\"",
                "\"${@:3}\"",
            ]
        );
    }

    #[test]
    fn ignores_replacement_star_and_non_scalar_contexts() {
        let source = "\
#!/bin/bash
x=\"${@:+fallback}\"
x=\"${arr[@]:+fallback}\"
x=\"${arr[*]:1}\"
x=\"\\$@\"
x=\"\\${@:2}\"
arr=(\"${@:2}\")
declare -a packed=(\"${arr[@]:1}\")
printf '%s\\n' \"${@:2}\"
if [ \"${arr[@]:1}\" = foo ]; then :; fi
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::QuotedArraySlice));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_zsh_indexed_and_sliced_positional_parameter_assignments() {
        let source = "\
#!/bin/zsh
selected=\"${@[_i]}\"
selected_short=\"$@[_i]\"
scope=\"${@[_i]:-}\"
initial_query=\"${@[2,-1]:-}\"
fallback=\"${@[5,-1]:-fallback}\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::QuotedArraySlice).with_shell(ShellDialect::Zsh),
        );

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

    #[test]
    fn reports_zsh_positional_star_selector_assignments() {
        let source = "\
#!/bin/zsh
selected=\"${@[*]}\"
selected_short=\"$@[*]\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::QuotedArraySlice).with_shell(ShellDialect::Zsh),
        );

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

    #[test]
    fn applies_unsafe_fix_to_intentional_join_selectors() {
        let source = "\
#!/bin/bash
x=\"$@\"
y=\"${@}\"
z=${@:5}
p=\"${arr[@]}\"
q=\"${arr[@]:-fallback}\"
flags+=\" ${add_flags[@]}\"
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::QuotedArraySlice),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 6);
        assert_eq!(
            result.fixed_source,
            "\
#!/bin/bash
x=\"$*\"
y=\"${*}\"
z=${*:5}
p=\"${arr[*]}\"
q=\"${arr[*]:-fallback}\"
flags+=\" ${add_flags[*]}\"
"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_to_zsh_star_selector_forms() {
        let source = "\
#!/bin/zsh
selected=\"${@[*]}\"
selected_short=\"$@[*]\"
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::QuotedArraySlice).with_shell(ShellDialect::Zsh),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 2);
        assert_eq!(
            result.fixed_source,
            "\
#!/bin/zsh
selected=\"${*[*]}\"
selected_short=\"$*[*]\"
"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn leaves_replacement_and_array_assignment_forms_unchanged_when_fixing() {
        let source = "\
#!/bin/bash
x=\"${@:+fallback}\"
arr=(\"${@:2}\")
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::QuotedArraySlice),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 0);
        assert_eq!(result.fixed_source, source);
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn snapshots_unsafe_fix_output_for_fixture() -> anyhow::Result<()> {
        let result = test_path_with_fix(
            Path::new("correctness").join("C099.sh").as_path(),
            &LinterSettings::for_rule(Rule::QuotedArraySlice),
            Applicability::Unsafe,
        )?;

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