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

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

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

    fn message(&self) -> String {
        "replace deprecated `xargs -i` with `xargs -I{}`".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("rewrite `xargs -i` as `xargs -I`".to_owned())
    }
}

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

    let source = checker.source();
    let diagnostics = checker
        .facts()
        .commands()
        .iter()
        .filter_map(|fact| fact.options().xargs())
        .flat_map(|xargs| {
            xargs
                .inline_replace_options()
                .iter()
                .copied()
                .filter(move |option| {
                    !option.uses_default_replacement()
                        || !xargs.has_sc2267_default_replace_silent_shape()
                })
                .filter_map(|option| xargs_inline_replace_fix(option.span(), source))
        })
        .map(|(span, fix)| Diagnostic::new(XargsWithInlineReplace, span).with_fix(fix))
        .collect::<Vec<_>>();

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

fn xargs_inline_replace_fix(span: Span, source: &str) -> Option<(Span, Fix)> {
    let text = span.slice(source);
    let index = text.find('i')?;
    let mut replacement = String::with_capacity(text.len() + 2);
    replacement.push_str(&text[..index]);
    replacement.push('I');
    replacement.push_str(&text[index + 1..]);
    if index + 1 == text.len() {
        replacement.push_str("{}");
    }
    Some((span, Fix::safe_edit(Edit::replacement(replacement, span))))
}

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

    #[test]
    fn reports_inline_replace_xargs_flags() {
        let source = "\
#!/bin/sh
find . -type d -name CVS | xargs -iX rm -rf X
find . -type d -name CVS | xargs -0iX rm -rf X
find . -type d -name CVS | xargs -i{} rm -rf '{}'
command xargs -i echo {}
sudo xargs -i echo {}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::XargsWithInlineReplace),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["-iX", "-0iX", "-i{}", "-i", "-i"]
        );
    }

    #[test]
    fn follows_shellcheck_silent_wrapper_cases() {
        let source = "\
#!/bin/sh
find . -type f | xargs -i bash -c 'echo {}'
find . -type f | xargs -0i sh -c 'echo {}'
find . -type f | xargs -i /bin/sh -c 'echo {}'
find . -type f | xargs -i command sh -c 'echo {}'
xargs -i echo '-----> Configuring {}'
xargs -0i echo '-----> Configuring {}'
xargs -i echo \"-----> Configuring {} with $template\"
xargs -i /bin/echo '-----> Configuring {}'
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::XargsWithInlineReplace),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_default_replace_outside_silent_sc2267_shapes() {
        let source = "\
#!/bin/sh
xargs -i env sh -c 'echo {}'
xargs -i sudo sh -c 'echo {}'
xargs -i sh -ec 'echo {}'
xargs -i echo '{}'
xargs -i echo -n '-x {}'
xargs -i echo -- '-x {}'
xargs -i command echo '-----> Configuring {}'
xargs -i printf -- '-x %s\n' '{}'
xargs -i{} sh -c 'echo {}'
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::XargsWithInlineReplace),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["-i", "-i", "-i", "-i", "-i", "-i", "-i", "-i", "-i{}"]
        );
    }

    #[test]
    fn ignores_modern_xargs_replace_flags() {
        let source = "\
#!/bin/sh
find . -type d -name CVS | xargs -I{} rm -rf {}
find . -type d -name CVS | xargs --replace rm -rf {}
find . -type d -name CVS | xargs --replace={} rm -rf '{}'
find . -type d -name CVS | xargs -0 rm -rf
find . -type d -name CVS | xargs --null rm -rf
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::XargsWithInlineReplace),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_safe_fix_to_inline_replace_options() {
        let source = "#!/bin/sh\nxargs -i echo {}\nxargs -0iX rm -rf X\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::XargsWithInlineReplace),
            Applicability::Safe,
        );

        assert_eq!(result.fixes_applied, 2);
        assert_eq!(
            result.fixed_source,
            "#!/bin/sh\nxargs -I{} echo {}\nxargs -0IX rm -rf X\n"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }
}