shuck-linter 0.0.43

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

pub struct TrailingDirective;

impl Violation for TrailingDirective {
    const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;

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

    fn message(&self) -> String {
        "directive after code is ignored".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("move the directive before the command".to_owned())
    }
}

pub fn trailing_directive(checker: &mut Checker) {
    let locator = checker.locator();
    let spans = checker
        .facts()
        .source_facts()
        .trailing_directive_comment_spans()
        .to_vec();
    for span in spans {
        let mut diagnostic = Diagnostic::new(TrailingDirective, span);
        if let Some(fix) = trailing_directive_fix(locator, span) {
            diagnostic = diagnostic.with_fix(fix);
        }
        checker.report_diagnostic_dedup(diagnostic);
    }
}

fn trailing_directive_fix(locator: Locator<'_>, span: shuck_ast::Span) -> Option<Fix> {
    let source = locator.source();
    let line_range = locator.line_range(span.start.line)?;
    let line_start = usize::from(line_range.start());
    let raw_line_end = usize::from(line_range.end());
    let line_end = source
        .get(..raw_line_end)
        .filter(|_| raw_line_end > 0 && source.as_bytes()[raw_line_end - 1] == b'\r')
        .map_or(raw_line_end, |_| raw_line_end - 1);
    let comment_start = span.start.offset;
    if comment_start < line_start || comment_start >= line_end {
        return None;
    }

    let line = source.get(line_start..line_end)?;
    let indent_len = line
        .bytes()
        .take_while(|byte| matches!(byte, b' ' | b'\t'))
        .count();
    let indent = &line[..indent_len];
    let comment_text = source.get(comment_start..line_end)?;
    let newline = if raw_line_end > line_end {
        "\r\n"
    } else {
        "\n"
    };

    let mut delete_start = comment_start;
    while delete_start > line_start
        && matches!(source.as_bytes().get(delete_start - 1), Some(b' ' | b'\t'))
    {
        delete_start -= 1;
    }

    Some(Fix::safe_edits([
        Edit::insertion(line_start, format!("{indent}{comment_text}{newline}")),
        Edit::deletion_at(delete_start, line_end),
    ]))
}

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

    #[test]
    fn reports_inline_disable_directive() {
        let source = "#!/bin/sh\n: # shellcheck disable=2034\nfoo=1\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.start.line, 2);
        assert_eq!(diagnostics[0].span.start.column, 3);
        assert_eq!(diagnostics[0].span.slice(source), "#");
    }

    #[test]
    fn reports_inline_shuck_disable_directive() {
        let source = "#!/bin/sh\n: # shuck: disable=C003\nfoo=1\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.start.line, 2);
        assert_eq!(diagnostics[0].span.start.column, 3);
        assert_eq!(diagnostics[0].span.slice(source), "#");
    }

    #[test]
    fn ignores_own_line_directive() {
        let source = "#!/bin/sh\n# shellcheck disable=2034\nfoo=1\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_non_directive_inline_comment() {
        let source = "#!/bin/sh\n: # shellcheck reminder\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_directive_after_subshell_opener() {
        let source = "#!/bin/sh\n( # shellcheck disable=SC2164\n  cd /tmp\n)\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_directive_after_brace_group_opener() {
        let source = "#!/bin/sh\n{ # shellcheck disable=SC2164\n  cd /tmp\n}\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_directive_after_semicolon_separator() {
        let source = "#!/bin/sh\ntrue; # shellcheck disable=SC2317\nfalse\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_shuck_disable_after_semicolon_separator() {
        let source = "#!/bin/sh\ntrue; # shuck: disable=C001\nfalse\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_directive_after_case_label() {
        let source =
            "#!/bin/sh\ncase $x in\n  on) # shellcheck disable=SC2034\n    :\n    ;;\nesac\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_directive_after_control_flow_headers() {
        let sources = [
            "#!/bin/sh\nif # shellcheck disable=SC2086\n  echo $foo\nthen\n  :\nfi\n",
            "#!/bin/sh\nif true; then # shellcheck disable=SC2086\n  echo $foo\nfi\n",
            "#!/bin/sh\nif true; then { # shellcheck disable=SC2086\n  echo $foo\n}; fi\n",
            "#!/bin/sh\nif true; then ( # shellcheck disable=SC2086\n  echo $foo\n); fi\n",
            "#!/bin/sh\nif false; then\n  :\nelif true; then # shellcheck disable=SC2086\n  echo $foo\nfi\n",
            "#!/bin/sh\nif false; then\n  :\nelse # shellcheck disable=SC2086\n  echo $foo\nfi\n",
            "#!/bin/sh\nfor item in 1; do # shellcheck disable=SC2086\n  echo $foo\ndone\n",
            "#!/bin/sh\nwhile # shellcheck disable=SC2086\n  echo $foo\ndo\n  :\ndone\n",
            "#!/bin/sh\nuntil # shellcheck disable=SC2086\n  echo $foo\ndo\n  :\ndone\n",
        ];

        for source in sources {
            let diagnostics =
                test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));
            assert!(diagnostics.is_empty(), "{source}");
        }
    }

    #[test]
    fn ignores_directive_after_zsh_brace_control_flow_headers() {
        let sources = [
            "#!/bin/zsh\nif [[ -n $foo ]] { # shellcheck disable=SC2086\n  echo $foo\n}\n",
            "#!/bin/zsh\nif [[ -n $foo ]] { :\n} elif [[ -n $bar ]] { # shellcheck disable=SC2086\n  echo $foo\n}\n",
            "#!/bin/zsh\nif [[ -n $foo ]] { :\n} else { # shellcheck disable=SC2086\n  echo $foo\n}\n",
            "#!/bin/zsh\nfor item in 1; { # shellcheck disable=SC2086\n  echo $foo\n}\n",
            "#!/bin/zsh\nrepeat 2 { # shellcheck disable=SC2086\n  echo $foo\n}\n",
            "#!/bin/zsh\nforeach item (1 2) { # shellcheck disable=SC2086\n  echo $foo\n}\n",
        ];

        for source in sources {
            let diagnostics = test_snippet(
                source,
                &LinterSettings::for_rule(Rule::TrailingDirective).with_shell(ShellDialect::Zsh),
            );
            assert!(diagnostics.is_empty(), "{source}");
        }
    }

    #[test]
    fn reports_keyword_like_arguments_with_trailing_directives() {
        let source = "#!/bin/sh\necho if # shellcheck disable=SC2086\necho $foo\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

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

    #[test]
    fn reports_keyword_like_arguments_with_trailing_shuck_disable() {
        let source = "#!/bin/sh\necho if # shuck: disable=S001\necho $foo\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

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

    #[test]
    fn reports_keyword_suffixes_inside_words_with_trailing_directives() {
        let source =
            "#!/bin/sh\nfor item in to-do # shellcheck disable=SC2086\ndo\n  echo $foo\ndone\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TrailingDirective));

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

    #[test]
    fn applies_safe_fix_to_move_directive_before_command() {
        let source = "#!/bin/sh\n  : # shellcheck disable=2034\nfoo=1\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::TrailingDirective),
            Applicability::Safe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            "#!/bin/sh\n  # shellcheck disable=2034\n  :\nfoo=1\n"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }
}