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 AvoidLetBuiltin;

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

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

    fn message(&self) -> String {
        "prefer arithmetic expansion instead of `let`".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("rewrite `let` as an arithmetic command".to_owned())
    }
}

pub fn avoid_let_builtin(checker: &mut Checker) {
    if !matches!(checker.shell(), ShellDialect::Bash | ShellDialect::Ksh) {
        return;
    }

    let source = checker.source();
    let reports = checker
        .facts()
        .commands()
        .iter()
        .filter(|fact| fact.wrappers().is_empty() && fact.effective_name_is("let"))
        .filter_map(|fact| {
            Some(LetCommandReport {
                diagnostic_span: let_command_span(source, fact)?,
                fix: let_command_fix(source, fact),
            })
        })
        .collect::<Vec<_>>();

    for report in reports {
        let mut diagnostic = Diagnostic::new(AvoidLetBuiltin, report.diagnostic_span);
        if let Some(fix) = report.fix {
            diagnostic = diagnostic.with_fix(fix);
        }
        checker.report_diagnostic(diagnostic);
    }
}

struct LetCommandReport {
    diagnostic_span: Span,
    fix: Option<Fix>,
}

fn let_command_fix(source: &str, fact: crate::facts::CommandFactRef<'_, '_>) -> Option<Fix> {
    let name = fact.body_name_word()?;
    let args = fact.body_args();
    let last_arg = args.last()?;
    let operands = args
        .iter()
        .map(|word| let_operand_text(source, word))
        .filter(|operand| operand != "--")
        .collect::<Vec<_>>();
    if operands.is_empty() {
        return None;
    }

    let replacement = format!("(( {} ))", operands.join(", "));
    let span = Span::from_positions(name.span.start, last_arg.span.end);
    Some(Fix::unsafe_edit(Edit::replacement(replacement, span)))
}

fn let_operand_text(source: &str, word: &shuck_ast::Word) -> String {
    word.quoted_content_span_in_source(source).map_or_else(
        || word.span.slice(source).to_owned(),
        |span| span.slice(source).to_owned(),
    )
}

fn let_command_span(source: &str, fact: crate::facts::CommandFactRef<'_, '_>) -> Option<Span> {
    let name = fact.body_name_word()?;
    let mut end = fact
        .body_args()
        .last()
        .map_or(name.span.end, |word| let_argument_end(source, word));

    let tail = source.get(end.offset..)?;
    let mut padding_end = end;
    for ch in tail.chars() {
        match ch {
            ' ' => padding_end = padding_end.advanced_by(" "),
            '\t' => padding_end = padding_end.advanced_by("\t"),
            ';' => {
                end = padding_end;
                break;
            }
            '\r' | '\n' => break,
            _ => break,
        }
    }

    Some(Span::from_positions(name.span.start, end))
}

fn let_argument_end(source: &str, word: &shuck_ast::Word) -> shuck_ast::Position {
    if word.is_fully_quoted() {
        let text = word.span.slice(source);
        if let Some(trimmed) = text.strip_suffix('"').or_else(|| text.strip_suffix('\'')) {
            return word.span.start.advanced_by(trimmed);
        }
    }

    word.span.end
}

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

    #[test]
    fn reports_let_builtin_in_bash() {
        let source = "#!/usr/bin/env bash\nlet 10\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::AvoidLetBuiltin));

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

    #[test]
    fn anchors_quoted_let_builtin_before_closing_quote() {
        let source = "#!/usr/bin/env bash\nlet \"number %= RANGE\"\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::AvoidLetBuiltin));

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

    #[test]
    fn ignores_non_bash_or_ksh_shells() {
        let source = "#!/bin/sh\nlet 10\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::AvoidLetBuiltin).with_shell(ShellDialect::Sh),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_wrapped_let_commands() {
        let source = "#!/usr/bin/env bash\ncommand let 10\nbuiltin let 10\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::AvoidLetBuiltin));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_to_rewrite_let_builtin() {
        let source = "#!/usr/bin/env bash\nlet count+=1 \"total %= RANGE\"\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::AvoidLetBuiltin),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            "#!/usr/bin/env bash\n(( count+=1, total %= RANGE ))\n"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_without_option_sentinel_operand() {
        let source = "#!/usr/bin/env bash\nlet -- i=1\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::AvoidLetBuiltin),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(result.fixed_source, "#!/usr/bin/env bash\n(( i=1 ))\n");
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn skips_unsafe_fix_for_only_option_sentinel_operand() {
        let source = "#!/usr/bin/env bash\nlet --\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::AvoidLetBuiltin),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 0);
        assert_eq!(result.fixed_source, source);
        assert_eq!(result.fixed_diagnostics.len(), 1);
    }
}