shuck-linter 0.0.43

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

pub struct GlobAssignedToVariable;

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

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

    fn message(&self) -> String {
        "quote assigned glob patterns so they stay literal".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("quote the assigned glob pattern".to_owned())
    }
}

pub fn glob_assigned_to_variable(checker: &mut Checker) {
    let source = checker.source();
    let diagnostics = checker
        .facts()
        .words()
        .expansion_word_facts(ExpansionContext::AssignmentValue)
        .chain(
            checker
                .facts()
                .words()
                .expansion_word_facts(ExpansionContext::DeclarationAssignmentValue),
        )
        .filter(|fact| fact.host_kind() == WordFactHostKind::Direct)
        .filter(|fact| {
            !checker
                .facts()
                .words()
                .is_compound_assignment_value_word(*fact)
        })
        .filter(|fact| fact.classification().quote != WordQuote::FullyQuoted)
        .filter_map(|fact| {
            let glob_spans = fact.active_literal_glob_spans(source);
            (!glob_spans.is_empty()).then(|| {
                Diagnostic::new(GlobAssignedToVariable, fact.span())
                    .with_fix(quote_glob_spans_fix(glob_spans, source))
            })
        })
        .collect::<Vec<_>>();

    for diagnostic in diagnostics {
        checker.report_diagnostic_dedup(diagnostic);
    }
}

fn quote_glob_spans_fix(spans: Vec<shuck_ast::Span>, source: &str) -> Fix {
    let edits = spans.into_iter().map(|span| {
        let text = span.slice(source);
        Edit::replacement(format!("'{text}'"), span)
    });
    Fix::safe_edits(edits)
}

#[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_assignment_values_with_unquoted_globs() {
        let source = "\
#!/bin/bash
LOCS=*.oxt
LOCS=$dir/*.oxt
LOCS=\"$dir\"/*.oxt
LOCS=${dir}/*.oxt
LOCS=$(pwd)/*.oxt
readonly LOCS=foo*bar
export LOCS=$dir/*.txt
LOCS=${disk_info[${#disk_info[@]} - 1]/*\\/}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::GlobAssignedToVariable),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "*.oxt",
                "$dir/*.oxt",
                "\"$dir\"/*.oxt",
                "${dir}/*.oxt",
                "$(pwd)/*.oxt",
                "foo*bar",
                "$dir/*.txt"
            ]
        );
    }

    #[test]
    fn ignores_fully_quoted_or_non_glob_assignment_values() {
        let source = "\
#!/bin/bash
LOCS=\"*.oxt\"
LOCS='*.oxt'
LOCS=\\*.oxt
LOCS=$dir/file.txt
readonly LOCS=\"*.txt\"
export LOCS='$dir/*.txt'
LOCS=(*.oxt)
LOCS=(\"$dir\"/*.oxt)
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::GlobAssignedToVariable),
        );

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

    #[test]
    fn applies_safe_fix_by_quoting_literal_glob_fragments() {
        let source = "\
#!/bin/bash
LOCS=*.oxt
LOCS=$dir/*.oxt
readonly LOCS=foo*bar
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::GlobAssignedToVariable),
            Applicability::Safe,
        );

        assert_eq!(
            result.fixed_source,
            "\
#!/bin/bash
LOCS='*'.oxt
LOCS=$dir/'*'.oxt
readonly LOCS=foo'*'bar
"
        );
        assert_eq!(result.fixes_applied, 3);
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn snapshots_safe_fix_output_for_fixture() -> anyhow::Result<()> {
        let result = test_path_with_fix(
            Path::new("style").join("S055.sh").as_path(),
            &LinterSettings::for_rule(Rule::GlobAssignedToVariable),
            Applicability::Safe,
        )?;

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