shuck-linter 0.0.43

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

use crate::{Checker, Diagnostic, Edit, Fix, FixAvailability, Rule, Violation};

pub struct BareCommandNameAssignment;

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

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

    fn message(&self) -> String {
        "bare command-like text in an assignment should be quoted or captured with `$(...)`"
            .to_owned()
    }

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

pub fn bare_command_name_assignment(checker: &mut Checker) {
    let source = checker.source();
    checker.report_fact_diagnostics_dedup(|facts, report| {
        for span in facts
            .command_facts()
            .bare_command_name_assignment_spans()
            .iter()
            .copied()
        {
            let diagnostic = Diagnostic::new(BareCommandNameAssignment, span);
            report(match quote_assignment_value_fix(span, source) {
                Some(fix) => diagnostic.with_fix(fix),
                None => diagnostic,
            });
        }
    });
}

fn quote_assignment_value_fix(span: Span, source: &str) -> Option<Fix> {
    let line = source
        .get(span.start.offset..)?
        .split_inclusive('\n')
        .next()?;
    let eq_relative = line.find('=')?;
    let value_start = span.start.offset + eq_relative + 1;
    let value_text = source.get(value_start..)?;
    let value_len = value_text
        .chars()
        .take_while(|ch| !ch.is_whitespace() && *ch != ';')
        .map(char::len_utf8)
        .sum::<usize>();
    if value_len == 0 {
        return None;
    }
    Some(Fix::safe_edits([
        Edit::insertion(value_start, "\""),
        Edit::insertion(value_start + value_len, "\""),
    ]))
}

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

    #[test]
    fn reports_plain_assignments_and_single_assignment_command_prefixes() {
        let source = "\
#!/bin/sh
tool=grep
paths[$path]=set
tool=sh printf '%s\\n' hi
pager=cat \"$1\" -u perl
f() {
  state=sh return 0
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::BareCommandNameAssignment),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "tool",
                "paths[$path]",
                "tool=sh printf '%s\\n' hi",
                "pager=cat \"$1\" -u perl",
                "state=sh return 0",
            ]
        );
    }

    #[test]
    fn ignores_quoted_dynamic_declaration_and_multi_assignment_forms() {
        let source = "\
#!/bin/bash
tool=\"grep\"
tool=$(grep pattern file)
tool=git
tool=grep other=set printf '%s\\n' hi
f() {
  local scoped=sh
  readonly pinned=sh
  export exported=sh
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::BareCommandNameAssignment),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_safe_fix_by_quoting_literal_assignment_value() {
        let source = "#!/bin/sh\ntool=grep\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::BareCommandNameAssignment),
            Applicability::Safe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(result.fixed_source, "#!/bin/sh\ntool=\"grep\"\n");
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn respects_zsh_equals_path_expansion_for_assignment_values() {
        let source = "\
#!/bin/zsh
unsetopt equals
tool==grep run
setopt magic_equal_subst
magic_literal==grep run
setopt equals
path==grep run
setopt magic_equal_subst
magic==grep run
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::BareCommandNameAssignment),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["tool==grep run", "magic_literal==grep run"]
        );
    }
}