shuck-linter 0.0.41

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

pub struct UnsetAssociativeArrayElement;

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

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

    fn message(&self) -> String {
        "quote `unset` array-subscript operands so bracket text stays literal".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("quote the unset operand".to_owned())
    }
}

pub fn unset_associative_array_element(checker: &mut Checker) {
    let source = checker.source();
    let mut diagnostics = Vec::new();

    for fact in checker
        .facts()
        .command_facts()
        .structural_commands()
        .filter(|fact| fact.effective_name_is("unset"))
    {
        let Some(unset) = fact.options().unset() else {
            continue;
        };

        for operand in unset.operand_facts() {
            if operand.array_subscript().is_some() {
                diagnostics.push((
                    operand.word().span,
                    rewrite_word_as_single_double_quoted_string(operand.word(), source, None),
                ));
            }
        }
    }

    for (span, replacement) in diagnostics {
        checker.report_diagnostic_dedup(
            Diagnostic::new(UnsetAssociativeArrayElement, span)
                .with_fix(Fix::unsafe_edit(Edit::replacement(replacement, span))),
        );
    }
}

#[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, assert_diagnostics_diff};

    #[test]
    fn reports_array_subscript_unset_operands() {
        let source = "\
#!/bin/bash
declare -A parts
parts[one]=1
parts[two]=2
foo=1
unset parts[\"one\"]
unset parts['two']
key=three
declare -a nums
unset foo[1]
unset nums[1]
unset nums[\"1\"]
unset nums[$key]
unset parts[\"$key\"]
unset parts[\\\"four\\\"]
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnsetAssociativeArrayElement),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "parts[\"one\"]",
                "parts['two']",
                "foo[1]",
                "nums[1]",
                "nums[\"1\"]",
                "nums[$key]",
                "parts[\"$key\"]",
                "parts[\\\"four\\\"]"
            ]
        );
    }

    #[test]
    fn ignores_non_array_and_literal_unset_operands() {
        let source = "\
#!/bin/bash
declare -A parts
declare value=one
unset plain
unset value
unset 'parts[key]'
unset \"parts[key]\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::UnsetAssociativeArrayElement),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_to_array_subscript_unset_operands() {
        let source = "\
#!/bin/bash
unset parts[\"one\"]
unset parts['two']
unset parts[$key]
unset parts[\"$key\"]
unset parts[\\\"four\\\"]
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::UnsetAssociativeArrayElement),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 5);
        assert_eq!(
            result.fixed_source,
            "\
#!/bin/bash
unset \"parts[one]\"
unset \"parts[two]\"
unset \"parts[${key}]\"
unset \"parts[${key}]\"
unset \"parts[\\\"four\\\"]\"
"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn safe_fix_mode_leaves_unset_operands_unchanged() {
        let source = "#!/bin/bash\nunset parts[key]\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::UnsetAssociativeArrayElement),
            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("C108.sh").as_path(),
            &LinterSettings::for_rule(Rule::UnsetAssociativeArrayElement),
            Applicability::Unsafe,
        )?;

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