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, ShellDialect, Violation};

pub struct BraceVariableBeforeBracket;

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

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

    fn message(&self) -> String {
        "brace variable expansions before adjacent `[` text".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("wrap the variable expansion in braces".to_owned())
    }
}

pub fn brace_variable_before_bracket(checker: &mut Checker) {
    if checker.shell() == ShellDialect::Zsh {
        return;
    }

    let source = checker.source();
    let diagnostics = checker
        .facts()
        .words()
        .brace_variable_before_bracket_spans()
        .iter()
        .copied()
        .map(|span| {
            let diagnostic = Diagnostic::new(BraceVariableBeforeBracket, span);
            match brace_variable_fix(span, source) {
                Some(fix) => diagnostic.with_fix(fix),
                None => diagnostic,
            }
        })
        .collect::<Vec<_>>();

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

fn brace_variable_fix(span: Span, source: &str) -> Option<Fix> {
    let offset = span.start.offset;
    let tail = source.get(offset..)?;
    let rest = tail.strip_prefix('$')?;
    let mut chars = rest.char_indices();
    let (_, first) = chars.next()?;
    if !(first == '_' || first.is_ascii_alphabetic()) {
        return None;
    }

    let mut name_len = first.len_utf8();
    for (index, ch) in chars {
        if ch == '_' || ch.is_ascii_alphanumeric() {
            name_len = index + ch.len_utf8();
        } else {
            break;
        }
    }

    Some(Fix::safe_edits([
        Edit::insertion(offset + '$'.len_utf8(), "{"),
        Edit::insertion(offset + '$'.len_utf8() + name_len, "}"),
    ]))
}

#[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, ShellDialect, assert_diagnostics_diff};

    #[test]
    fn reports_unbraced_variables_before_bracket_text() {
        let source = "\
#!/bin/sh
echo \"$foo[0]\"
echo \"$key[[:space:]]\"
echo game$game[0]
echo \"$foo[\"
$cmd[0] arg
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::BraceVariableBeforeBracket),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| (diagnostic.span.start.line, diagnostic.span.start.column))
                .collect::<Vec<_>>(),
            vec![(2, 7), (3, 7), (4, 10), (5, 7), (6, 1)]
        );
        assert!(
            diagnostics
                .iter()
                .all(|diagnostic| diagnostic.span.start == diagnostic.span.end)
        );
    }

    #[test]
    fn ignores_braced_special_and_quote_split_forms() {
        let source = "\
#!/bin/sh
echo \"${foo}[0]\"
echo \"${foo}[[:space:]]\"
echo \"$foo\"\"[0]\"
echo \"$foo\"'[0]'
echo \"$foo\\[0]\"
echo \"$1[0]\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::BraceVariableBeforeBracket),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_corpus_style_regex_suffix_forms() {
        let source = "\
#!/bin/bash
check() {
  local cmd=\"$1\"
  command git grep -E \"^[^#]*\\\\<$cmd[[:space:]]+\"
}
sed_var() {
  sed -i \"/\\\\$symon['$1']/s|=.*|='$2';|\" setup.inc
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::BraceVariableBeforeBracket),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| (diagnostic.span.start.line, diagnostic.span.start.column))
                .collect::<Vec<_>>(),
            vec![(4, 33), (7, 14)]
        );
    }

    #[test]
    fn ignores_zsh_array_subscripts() {
        let source = "\
#!/bin/zsh
echo \"$foo[1]\"
print $reply[2]
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::BraceVariableBeforeBracket)
                .with_shell(ShellDialect::Zsh),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_safe_fix_by_bracing_variable_expansions() {
        let source = "\
#!/bin/sh
echo \"$foo[0]\"
echo game$game[0]
$cmd[0] arg
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::BraceVariableBeforeBracket),
            Applicability::Safe,
        );

        assert_eq!(
            result.fixed_source,
            "\
#!/bin/sh
echo \"${foo}[0]\"
echo game${game}[0]
${cmd}[0] arg
"
        );
        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("S077.sh").as_path(),
            &LinterSettings::for_rule(Rule::BraceVariableBeforeBracket),
            Applicability::Safe,
        )?;

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