shuck-linter 0.0.41

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

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

pub struct SubshellTestGroup;

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

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

    fn message(&self) -> String {
        "use braces to group these tests instead of a subshell".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("replace the subshell with a brace group".to_owned())
    }
}

pub fn subshell_test_group(checker: &mut Checker) {
    let source = checker.source();
    let single_test_spans = checker.facts().command_facts().single_test_subshell_spans();
    let spans = checker
        .facts()
        .command_facts()
        .subshell_test_group_spans()
        .iter()
        .copied()
        .filter(|span| !single_test_spans.contains(span))
        .collect::<Vec<_>>();
    for span in spans {
        checker.report_diagnostic_dedup(
            Diagnostic::new(SubshellTestGroup, span).with_fix(brace_group_fix(source, span)),
        );
    }
}

fn brace_group_fix(source: &str, span: Span) -> Fix {
    let close_start = span.end.offset.saturating_sub(1);
    let close_replacement = if offset_is_indented_line_start(source, close_start) {
        "}"
    } else {
        "; }"
    };

    Fix::unsafe_edits([
        Edit::replacement_at(span.start.offset, span.start.offset + 1, "{"),
        Edit::replacement_at(close_start, span.end.offset, close_replacement),
    ])
}

fn offset_is_indented_line_start(source: &str, offset: usize) -> bool {
    let line_start = source[..offset].rfind('\n').map_or(0, |offset| offset + 1);
    source[line_start..offset]
        .bytes()
        .all(|byte| matches!(byte, b' ' | b'\t'))
}

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

    #[test]
    fn anchors_on_the_grouping_subshell() {
        let source = "\
#!/bin/sh
a=1
b=jpg
if [ -n \"$a\" ] && ( [ \"$b\" = jpeg ] || [ \"$b\" = jpg ] ); then echo ok; fi
if ! ( [ \"$b\" = jpeg ] || [ \"$b\" = jpg ] ); then echo ok; fi
if ( [ \"$b\" = jpeg ] || [ \"$b\" = jpg ] ); then echo ok; fi
( { [ \"$b\" = jpeg ] || [ \"$b\" = jpg ]; } )
( [ \"$b\" = jpeg ] ; [ \"$b\" = jpg ] )
( cd /tmp || exit 1
  [ \"$b\" = jpg ]
 )
( [ \"$b\" = jpeg ] )
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::SubshellTestGroup));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "( [ \"$b\" = jpeg ] || [ \"$b\" = jpg ] )",
                "( [ \"$b\" = jpeg ] || [ \"$b\" = jpg ] )",
                "( { [ \"$b\" = jpeg ] || [ \"$b\" = jpg ]; } )",
                "( [ \"$b\" = jpeg ] ; [ \"$b\" = jpg ] )",
            ]
        );
    }

    #[test]
    fn applies_unsafe_fix_to_replace_subshell_group_with_braces() {
        let source = "#!/bin/sh\na=1\nb=jpg\nif [ -n \"$a\" ] && ( [ \"$b\" = jpeg ] || [ \"$b\" = jpg ] ); then echo ok; fi\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::SubshellTestGroup),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            "#!/bin/sh\na=1\nb=jpg\nif [ -n \"$a\" ] && { [ \"$b\" = jpeg ] || [ \"$b\" = jpg ] ; }; then echo ok; fi\n"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_to_multiline_subshell_group() {
        let source = "\
#!/bin/sh
if (
  [ -f a ]
  [ -f b ]
); then
  echo ok
fi
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::SubshellTestGroup),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            "\
#!/bin/sh
if {
  [ -f a ]
  [ -f b ]
}; then
  echo ok
fi
"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }
}