shuck-linter 0.0.41

Lint rule engine and checker for shell scripts
Documentation
use shuck_ast::Span;

use crate::{Checker, Rule, ShellDialect, Violation};

pub struct PlusPrefixInAssignment;

impl Violation for PlusPrefixInAssignment {
    fn rule() -> Rule {
        Rule::PlusPrefixInAssignment
    }

    fn message(&self) -> String {
        "this looks like an assignment, but the shell parses it as a command name".to_owned()
    }
}

pub fn plus_prefix_in_assignment(checker: &mut Checker) {
    let suppress_assign_special_zero_overlap =
        checker.shell() == ShellDialect::Sh && checker.is_rule_enabled(Rule::AssignSpecialZero);
    let assign_special_zero_spans = if suppress_assign_special_zero_overlap {
        checker.facts().command_facts().assign_special_zero_spans()
    } else {
        &[]
    };
    let spans = checker
        .facts()
        .command_facts()
        .assignment_like_command_name_spans()
        .iter()
        .copied()
        .filter(|span| {
            !is_assign_special_zero_overlap(
                checker,
                assign_special_zero_spans,
                *span,
                suppress_assign_special_zero_overlap,
            )
        })
        .collect::<Vec<_>>();

    checker.report_all_dedup(spans, || PlusPrefixInAssignment);
}

fn is_assign_special_zero_overlap(
    checker: &Checker<'_>,
    assign_special_zero_spans: &[Span],
    span: Span,
    suppress_assign_special_zero_overlap: bool,
) -> bool {
    suppress_assign_special_zero_overlap
        && assign_special_zero_spans.contains(&span)
        && !checker.is_suppressed_at(Rule::AssignSpecialZero, span)
}

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

    #[test]
    fn anchors_on_assignment_like_words_with_a_leading_plus() {
        let source = "\
#!/bin/bash
+YYYY=\"$( date +%Y )\"
export +MONTH=12
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["+YYYY=\"$( date +%Y )\"", "+MONTH=12"]
        );
    }

    #[test]
    fn ignores_regular_commands_and_non_identifier_targets() {
        let source = "\
#!/bin/sh
echo +YEAR=2024
+1=bad
name+=still_ok
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn anchors_on_invalid_assignment_like_command_names_without_a_leading_plus() {
        let source = r#"#!/bin/sh
network.wan.proto='dhcp'
@VAR@=$(. /etc/profile >/dev/null 2>&1; echo "${@VAR@}")
"${NINJA:=ninja}"
"#;
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "network.wan.proto='dhcp'",
                "@VAR@=$(. /etc/profile >/dev/null 2>&1; echo \"${@VAR@}\")"
            ]
        );
    }

    #[test]
    fn defers_plain_zero_assignment_to_assign_special_zero_when_enabled() {
        let source = "#!/bin/sh\n0=demo\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rules([Rule::PlusPrefixInAssignment, Rule::AssignSpecialZero]),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].rule, Rule::AssignSpecialZero);
        assert_eq!(diagnostics[0].span.slice(source), "0=demo");
    }

    #[test]
    fn keeps_declaration_zero_operands_with_assign_special_zero_enabled() {
        let source = "#!/bin/sh\nexport 0=demo\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rules([Rule::PlusPrefixInAssignment, Rule::AssignSpecialZero]),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].rule, Rule::PlusPrefixInAssignment);
        assert_eq!(diagnostics[0].span.slice(source), "0=demo");
    }

    #[test]
    fn ignores_assignment_like_text_after_literal_arrow_prefix() {
        let source = r#"#!/bin/bash
rvm_info="
  bash: \"$(command -v bash) => $(version_for bash)\"
  zsh:  \"$(command -v zsh) => $(version_for zsh)\"
"
"#;
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_zsh_numeric_parameter_assignments() {
        let source = "\
#!/bin/zsh
0=${(%):-%N}
1=value
2+=more
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PlusPrefixInAssignment).with_shell(ShellDialect::Zsh),
        );

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

    #[test]
    fn ignores_zsh_word_splitting_expansion_in_command_position() {
        let source = "\
#!/bin/zsh
local -a UNPACKCMD
UNPACKCMD=( dd if=$pkg ibs=$o skip=1 )
local COMPRESSION=\"$($=UNPACKCMD | file -)\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PlusPrefixInAssignment).with_shell(ShellDialect::Zsh),
        );

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

    #[test]
    fn ignores_zsh_declaration_brace_expanded_assignment_targets() {
        let source = "\
#!/bin/zsh
typeset -g POWERLEVEL9K_{LEFT,RIGHT}_{LEFT,RIGHT}_WHITESPACE=
typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIINS_CONTENT_EXPANSION='>'
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PlusPrefixInAssignment).with_shell(ShellDialect::Zsh),
        );

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

    #[test]
    fn still_reports_non_zsh_declaration_brace_assignment_targets() {
        let source = "\
#!/bin/bash
declare POWERLEVEL9K_{LEFT,RIGHT}_WHITESPACE=
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PlusPrefixInAssignment),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["POWERLEVEL9K_{LEFT,RIGHT}_WHITESPACE="]
        );
    }
}