shuck-linter 0.0.41

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

use crate::{Checker, Rule, Violation};

pub struct MissingMainEntrypoint {
    main_name: String,
}

impl Violation for MissingMainEntrypoint {
    fn rule() -> Rule {
        Rule::MissingMainEntrypoint
    }

    fn message(&self) -> String {
        format!(
            "end non-trivial scripts with a call to `{}`",
            self.main_name
        )
    }
}

pub fn missing_main_entrypoint(checker: &mut Checker) {
    let options = checker.rule_options().s085.clone();
    let facts = checker.facts();
    let source = facts.source_facts().source();
    let command_facts = facts.command_facts();

    if source.lines().count() < options.non_trivial_line_threshold
        || command_facts.function_headers().len() < options.non_trivial_function_count
    {
        return;
    }

    let Some(top_level_body_span) = command_facts
        .statement_facts()
        .iter()
        .filter_map(|statement| {
            let command = command_facts.command(statement.command_id());
            command
                .enclosing_function_scope()
                .is_none()
                .then_some(statement.body_span())
        })
        .max_by_key(|span| span.end.offset.saturating_sub(span.start.offset))
    else {
        return;
    };

    let Some(last_statement) = command_facts
        .statement_facts()
        .iter()
        .filter(|statement| statement.body_span() == top_level_body_span)
        .max_by_key(|statement| statement.stmt_span().end.offset)
    else {
        return;
    };

    let last_command = command_facts.command(last_statement.command_id());
    let ends_with_main_call = matches!(last_command.command(), Command::Simple(_))
        && last_command.effective_name_is(&options.main_name);

    if ends_with_main_call {
        return;
    }

    let span = last_command
        .body_name_word()
        .map(|word| word.span)
        .unwrap_or_else(|| last_command.span_in_source(source));
    checker.report(
        MissingMainEntrypoint {
            main_name: options.main_name,
        },
        span,
    );
}

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

    fn settings() -> LinterSettings {
        LinterSettings::for_rule(Rule::MissingMainEntrypoint)
            .with_s085_non_trivial_line_threshold(1)
            .with_s085_non_trivial_function_count(2)
    }

    #[test]
    fn reports_non_trivial_script_without_final_main_call() {
        let source = "\
#!/bin/bash
setup() { :; }
run() { :; }
setup
run
";
        let diagnostics = test_snippet(source, &settings());

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "run");
    }

    #[test]
    fn accepts_final_main_call() {
        let source = "\
#!/bin/bash
setup() { :; }
run() { :; }
main() {
  setup
  run
}
main \"$@\"
";
        let diagnostics = test_snippet(source, &settings());

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_scripts_below_line_threshold() {
        let source = "\
#!/bin/bash
setup() { :; }
run() { :; }
setup
run
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::MissingMainEntrypoint)
                .with_s085_non_trivial_line_threshold(10)
                .with_s085_non_trivial_function_count(2),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_scripts_below_function_threshold() {
        let source = "\
#!/bin/bash
setup() { :; }
run() { :; }
setup
run
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::MissingMainEntrypoint)
                .with_s085_non_trivial_line_threshold(1)
                .with_s085_non_trivial_function_count(3),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn accepts_custom_entrypoint_name() {
        let source = "\
#!/bin/bash
setup() { :; }
run() { setup; }
run \"$@\"
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::MissingMainEntrypoint)
                .with_s085_non_trivial_line_threshold(1)
                .with_s085_non_trivial_function_count(2)
                .with_s085_main_name("run"),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_statement_after_main_call() {
        let source = "\
#!/bin/bash
setup() { :; }
run() { :; }
main() { run; }
main \"$@\"
echo done
";
        let diagnostics = test_snippet(source, &settings());

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "echo");
    }

    #[test]
    fn function_definition_is_not_a_final_call() {
        let source = "\
#!/bin/bash
setup() { :; }
main() { setup; }
";
        let diagnostics = test_snippet(source, &settings());

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "main() { setup; }");
    }

    #[test]
    fn nested_main_call_does_not_satisfy_top_level_entrypoint() {
        let source = "\
#!/bin/bash
setup() { :; }
run() { :; }
if true; then
  main \"$@\"
fi
";
        let diagnostics = test_snippet(source, &settings());

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(
            diagnostics[0].span.slice(source),
            "if true; then\n  main \"$@\"\nfi"
        );
    }
}