shuck-linter 0.0.41

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

pub struct SuspectClosingQuote;

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

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

    fn message(&self) -> String {
        "quote is closed but the following character looks ambiguous".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("rewrite the word as one double-quoted string".to_owned())
    }
}

pub fn suspect_closing_quote(checker: &mut Checker) {
    let diagnostics = checker
        .facts()
        .words()
        .suspect_closing_quote_fragments()
        .iter()
        .map(|fragment| {
            Diagnostic::new(SuspectClosingQuote, fragment.span()).with_fix(Fix::unsafe_edit(
                Edit::replacement(fragment.replacement(), fragment.replacement_span()),
            ))
        })
        .collect::<Vec<_>>();

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

#[cfg(test)]
mod tests {
    use crate::test::{test_snippet, test_snippet_with_fix};
    use crate::{Applicability, LinterSettings, Rule};

    #[test]
    fn reports_suspicious_closing_quote() {
        let source = "#!/bin/bash\necho \"#!/bin/bash\nif [[ \"$@\" =~ x ]]; then :; fi\n\"\n";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::SuspectClosingQuote));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.start.line, 3);
        assert_eq!(diagnostics[0].span.start.column, 7);
        assert_eq!(diagnostics[0].span.start, diagnostics[0].span.end);
    }

    #[test]
    fn applies_unsafe_fix_to_rewrite_suspicious_split_word() {
        let source = "#!/bin/bash\necho \"alpha\n\"_beta\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::SuspectClosingQuote),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(result.fixed_source, "#!/bin/bash\necho \"alpha\n_beta\"\n");
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn ignores_sc2140_style_multiline_quote_joins() {
        let source = "\
#!/bin/bash
echo \"[Unit]
Description=Heimdall
ExecStart=\"/usr/bin/php\" artisan serve
WantedBy=multi-user.target\"
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::SuspectClosingQuote));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_each_split_suspicious_closing_quote_in_echo_arguments() {
        let source = "\
#!/bin/bash
echo \"alpha
\"_beta \"gamma
\"_delta
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::SuspectClosingQuote));

        assert_eq!(diagnostics.len(), 2);
        assert_eq!(diagnostics[0].span.start.line, 3);
        assert_eq!(diagnostics[0].span.start.column, 1);
        assert_eq!(diagnostics[0].span.start, diagnostics[0].span.end);
        assert_eq!(diagnostics[1].span.start.line, 4);
        assert_eq!(diagnostics[1].span.start.column, 1);
        assert_eq!(diagnostics[1].span.start, diagnostics[1].span.end);
    }

    #[test]
    fn ignores_literal_double_quotes_inside_multiline_single_quoted_words() {
        let source = "\
#!/bin/bash
echo 'alpha
\"_beta'
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::SuspectClosingQuote));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_each_reopened_quote_window_in_a_single_word() {
        let source = "\
#!/bin/bash
echo \"alpha
\"$foo\"beta
\"$bar\"gamma\"
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::SuspectClosingQuote));

        assert_eq!(diagnostics.len(), 2);
        assert_eq!(diagnostics[0].span.start.line, 3);
        assert_eq!(diagnostics[0].span.start.column, 1);
        assert_eq!(diagnostics[0].span.start, diagnostics[0].span.end);
        assert_eq!(diagnostics[1].span.start.line, 4);
        assert_eq!(diagnostics[1].span.start.column, 1);
        assert_eq!(diagnostics[1].span.start, diagnostics[1].span.end);
    }

    #[test]
    fn reports_only_the_leading_triple_quote_script_builder_closing_quote() {
        let source = "\
#!/bin/bash
echo \"\"\"#!/usr/bin/env bash
echo \"GEM_HOME FIRST: \\$GEM_HOME\"
echo \"GEM_PATH: \\$GEM_PATH\"
\"\"\"
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::SuspectClosingQuote));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.start.line, 3);
        assert_eq!(diagnostics[0].span.start.column, 6);
        assert_eq!(diagnostics[0].span.start, diagnostics[0].span.end);
    }

    #[test]
    fn reports_split_closing_quote_shapes_outside_echo_and_printf() {
        let source = "\
#!/bin/bash
cat \"alpha
\"_beta
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::SuspectClosingQuote));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.start.line, 3);
        assert_eq!(diagnostics[0].span.start.column, 1);
        assert_eq!(diagnostics[0].span.start, diagnostics[0].span.end);
    }
}