shuck-linter 0.0.43

Lint rule engine and checker for shell scripts
Documentation
use crate::{Checker, Rule, Violation};

pub struct TodoFormat {
    missing_owner: bool,
    missing_message: bool,
}

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

    fn message(&self) -> String {
        match (self.missing_owner, self.missing_message) {
            (true, true) => "add an owner and message to this action comment".to_owned(),
            (true, false) => "add an owner to this action comment".to_owned(),
            (false, true) => "add a message to this action comment".to_owned(),
            (false, false) => "action comment is complete".to_owned(),
        }
    }
}

pub fn todo_format(checker: &mut Checker) {
    let options = checker.rule_options().s082.clone();
    let diagnostics = checker
        .facts()
        .source_facts()
        .todo_comment_facts()
        .iter()
        .filter_map(|fact| {
            todo_comment_violation(fact.content(), &options)
                .map(|(kind, violation)| crate::Diagnostic::new(violation, fact.marker_span(kind)))
        })
        .collect::<Vec<_>>();

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

fn todo_comment_violation<'a>(
    content: &'a str,
    options: &'a crate::S082RuleOptions,
) -> Option<(&'a str, TodoFormat)> {
    for kind in options.kinds.iter().filter(|kind| !kind.is_empty()) {
        let Some(rest) = marker_rest(content, kind) else {
            continue;
        };
        let owner_rest = owner_annotation_rest(rest);
        let has_owner = owner_rest.is_some();
        let missing_owner = options.require_owner && !has_owner;
        let message_rest = owner_rest.unwrap_or(rest);
        let missing_message = options.require_message && !has_non_empty_message(message_rest);
        if missing_owner || missing_message {
            return Some((
                kind.as_str(),
                TodoFormat {
                    missing_owner,
                    missing_message,
                },
            ));
        }
    }

    None
}

fn marker_rest<'a>(content: &'a str, kind: &str) -> Option<&'a str> {
    let rest = content.strip_prefix(kind)?;
    if rest
        .chars()
        .next()
        .is_some_and(|char| char.is_ascii_alphanumeric() || char == '_')
    {
        return None;
    }

    Some(rest)
}

fn owner_annotation_rest(rest: &str) -> Option<&str> {
    let after_open = rest.strip_prefix('(')?;
    let close = after_open.find(')')?;
    let owner = &after_open[..close];
    if owner.trim().is_empty() {
        return None;
    }

    Some(&after_open[close + 1..])
}

fn has_non_empty_message(rest: &str) -> bool {
    let message = rest.trim_start();
    let message = message.strip_prefix(':').unwrap_or(message).trim_start();
    !message.is_empty()
}

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

    #[test]
    fn reports_missing_owner() {
        let source = "#!/bin/bash\n# TODO finish the parser case\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TodoFormat));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(
            diagnostics[0].message,
            "add an owner to this action comment"
        );
        assert_eq!(diagnostics[0].span.slice(source), "TODO");
    }

    #[test]
    fn reports_missing_message() {
        let source = "#!/bin/bash\n# FIXME(alice):\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TodoFormat));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(
            diagnostics[0].message,
            "add a message to this action comment"
        );
        assert_eq!(diagnostics[0].span.slice(source), "FIXME");
    }

    #[test]
    fn reports_missing_owner_and_message() {
        let source = "#!/bin/bash\n# XXX\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TodoFormat));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(
            diagnostics[0].message,
            "add an owner and message to this action comment"
        );
        assert_eq!(diagnostics[0].span.slice(source), "XXX");
    }

    #[test]
    fn accepts_owner_and_message() {
        let source = "#!/bin/bash\n# TODO(alice): finish the parser case\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TodoFormat));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn configured_kind_is_case_sensitive() {
        let source = "#!/bin/bash\n# note(alice): finish the parser case\n# NOTE finish\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::TodoFormat).with_s082_kinds(["NOTE".to_owned()]),
        );

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

    #[test]
    fn can_disable_owner_requirement() {
        let source = "#!/bin/bash\n# TODO finish the parser case\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::TodoFormat).with_s082_require_owner(false),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn can_disable_message_requirement() {
        let source = "#!/bin/bash\n# TODO(alice)\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::TodoFormat).with_s082_require_message(false),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_non_comment_text_and_longer_words() {
        let source = "#!/bin/bash\necho TODO\n# TODONOT(alice): leave this alone\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TodoFormat));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn checks_inline_comments() {
        let source = "#!/bin/bash\necho hi # TODO(alice)\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::TodoFormat));

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