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

pub struct IfDollarCommand;

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

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

    fn message(&self) -> String {
        "use the command's exit status directly instead of executing the output from `$(...)`"
            .to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("remove the command substitution wrapper".to_owned())
    }
}

pub fn if_dollar_command(checker: &mut Checker) {
    let source = checker.source();
    let diagnostics = checker
        .facts()
        .command_facts()
        .command_substitution_command_spans()
        .iter()
        .copied()
        .filter_map(|span| if_dollar_command_fix(span, source))
        .map(|(span, fix)| Diagnostic::new(IfDollarCommand, span).with_fix(fix))
        .collect::<Vec<_>>();

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

fn if_dollar_command_fix(span: Span, source: &str) -> Option<(Span, Fix)> {
    let text = span.slice(source);
    let body = text.strip_prefix("$(")?.strip_suffix(')')?;
    let close_cleanup = command_substitution_close_cleanup(span, source);
    Some((
        span,
        Fix::unsafe_edit(Edit::replacement_at(
            span.start.offset,
            close_cleanup.end,
            close_cleanup.replacement_body(body),
        )),
    ))
}

struct CloseCleanup<'a> {
    end: usize,
    list_operator: Option<&'a str>,
}

impl CloseCleanup<'_> {
    fn replacement_body(&self, body: &str) -> String {
        match self.list_operator {
            Some(operator) => body_with_trailing_list_operator(body, operator),
            None => body.to_owned(),
        }
    }
}

fn command_substitution_close_cleanup<'a>(span: Span, source: &'a str) -> CloseCleanup<'a> {
    let close_start = span.end.offset.saturating_sub(1);
    if !offset_is_indented_line_start(source, close_start) {
        return CloseCleanup {
            end: span.end.offset,
            list_operator: None,
        };
    }

    let mut end = span.end.offset;
    while source
        .as_bytes()
        .get(end)
        .is_some_and(|byte| matches!(byte, b' ' | b'\t'))
    {
        end += 1;
    }
    let Some((operator, operator_len)) = close_cleanup_operator(source, end) else {
        return CloseCleanup {
            end: span.end.offset,
            list_operator: None,
        };
    };

    end += operator_len;
    while source
        .as_bytes()
        .get(end)
        .is_some_and(|byte| matches!(byte, b' ' | b'\t'))
    {
        end += 1;
    }

    CloseCleanup {
        end,
        list_operator: (operator != ";").then_some(operator),
    }
}

fn close_cleanup_operator(source: &str, offset: usize) -> Option<(&str, usize)> {
    let rest = source.get(offset..)?;
    if rest.starts_with(';') {
        Some((";", 1))
    } else if rest.starts_with("&&") {
        Some(("&&", 2))
    } else if rest.starts_with("||") {
        Some(("||", 2))
    } else {
        None
    }
}

fn body_with_trailing_list_operator(body: &str, operator: &str) -> String {
    let trimmed = body.trim_end_matches([' ', '\t']);
    if let Some(without_newline) = trimmed.strip_suffix('\n') {
        format!("{without_newline} {operator} ")
    } else {
        format!("{trimmed} {operator} ")
    }
}

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 reports_command_substitution_condition_commands() {
        let source = "\
#!/bin/bash
$(false) && echo x
! $(false)
$(false)
if $(python3 -c 'import sys' 2>/dev/null); then echo ok; fi
while $(false); do break; done
until $(false); do break; done
if ! $(false); then echo no; fi
if foo && $(false); then :; fi
if $(false); echo ok; then :; fi
if $(false) | cat; then :; fi
if cat | $(false); then :; fi
if { $(false); }; then :; fi
if ( $(false) ); then :; fi
if time $(false); then :; fi
echo \"$(if $(false); then :; fi)\"
cat <(if $(false); then :; fi)
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::IfDollarCommand));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "$(false)",
                "$(false)",
                "$(false)",
                "$(python3 -c 'import sys' 2>/dev/null)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
                "$(false)",
            ]
        );
    }

    #[test]
    fn ignores_non_condition_and_wrapper_argument_substitutions() {
        let source = "\
#!/bin/bash
$(false) --arg
if foo; then :; fi
if \"$(printf '%s' foo)\"; then :; fi
if [[ \"$pm\" == apt ]] && \"$(printf '%s' missing)\" != installed; then :; fi
if $(command -v rvm) -v > /dev/null 2>&1; then :; fi
if $(tc-getSTRIP) --enable-deterministic-archives |& grep -q aarch; then :; fi
if command $(false); then :; fi
if env FOO=1 $(false); then :; fi
if `printf '%s' ok`; then :; fi
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::IfDollarCommand));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_by_unwrapping_command_substitution_condition() {
        let source = "#!/bin/bash\nif $(false); then echo ok; fi\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::IfDollarCommand),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            "#!/bin/bash\nif false; then echo ok; fi\n"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_to_multiline_command_substitution_condition() {
        let source = "\
#!/bin/bash
if $(
  false
); then
  :
fi
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::IfDollarCommand),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            concat!(
                "#!/bin/bash\n",
                "if \n",
                "  false\n",
                "then\n",
                "  :\n",
                "fi\n",
            )
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_to_multiline_command_substitution_condition_with_list_operator() {
        let source = "\
#!/bin/bash
if $(
  false
)&& echo x
then
  :
fi
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::IfDollarCommand),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            concat!(
                "#!/bin/bash\n",
                "if \n",
                "  false && echo x\n",
                "then\n",
                "  :\n",
                "fi\n",
            )
        );
        assert!(result.fixed_diagnostics.is_empty());
    }
}