shuck-linter 0.1.1

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

pub struct CommandSubstitutionInAlias;

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

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

    fn message(&self) -> String {
        "avoid expansions in alias definitions".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("single-quote the alias definition".to_owned())
    }
}

pub fn command_substitution_in_alias(checker: &mut Checker) {
    checker.report_fact_diagnostics_dedup(|facts, report| {
        for fact in facts.command_facts().alias_definition_expansion_facts() {
            let diagnostic = Diagnostic::new(CommandSubstitutionInAlias, fact.span());
            let diagnostic = match fact.replacement() {
                Some((span, replacement)) => {
                    diagnostic.with_fix(Fix::unsafe_edit(Edit::replacement(replacement, span)))
                }
                None => diagnostic,
            };
            report(diagnostic);
        }
    });
}

#[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_active_expansions_inside_alias_definitions() {
        let source = "\
#!/bin/bash
alias home=$HOME
alias icloud=\"cd '$HOME'\"
alias printf=$(command -v printf)
alias math=\"$((1+2))\"
alias list=${arr[@]}
alias proc=<(printf hi)
alias brace={a,b}
alias plain=printf
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandSubstitutionInAlias),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "$HOME",
                "$HOME",
                "$(command -v printf)",
                "$((1+2))",
                "${arr[@]}",
                "<(printf hi)",
                "{a,b}",
            ]
        );
    }

    #[test]
    fn ignores_aliases_without_active_expansions() {
        let source = "\
#!/bin/bash
alias printf=printf
alias plain='$(command -v printf)'
alias param='${HOME}'
alias brace='{a,b}'
alias ansi=$'\\n'
alias tilde=~
\\alias \"${1-}\" >/dev/null 2>&1
alias -p
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandSubstitutionInAlias),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_command_substitutions_outside_alias_operands() {
        let source = "\
#!/bin/sh
X=$(date) alias ll='ls -l'
FOO=$(date) BAR=$(uname) alias ll='ls -l'
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandSubstitutionInAlias),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_alias_lookups_with_equals_only_inside_expansions() {
        let source = "\
#!/bin/bash
alias \"${cur%=}\" 2>/dev/null
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandSubstitutionInAlias),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_only_the_first_active_expansion_per_alias_definition() {
        let source = "\
#!/bin/bash
alias \"$a=$b\"
alias \"${method}\"=\"lwp-request -m '${method}'\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandSubstitutionInAlias),
        );

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

    #[test]
    fn applies_unsafe_fix_to_single_quote_literal_alias_values() {
        let source = "#!/bin/bash\nalias home=$HOME\nalias icloud=\"cd '$HOME'\"\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::CommandSubstitutionInAlias),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 2);
        assert_eq!(
            result.fixed_source,
            "#!/bin/bash\nalias home='$HOME'\nalias icloud='cd '\\''$HOME'\\'''\n"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn leaves_dynamic_alias_names_without_fixes() {
        let source = "#!/bin/bash\nalias \"$name=$value\"\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandSubstitutionInAlias),
        );

        assert_eq!(diagnostics.len(), 1);
        assert!(diagnostics[0].fix.is_none());
    }

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

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