lintje 0.11.2

Lintje is an opinionated linter for Git.
use core::ops::Range;
use regex::Regex;

use crate::commit::Commit;
use crate::issue::{Context, Issue, Position};
use crate::rule::Rule;
use crate::rule::RuleValidator;
use crate::utils::line_length_stats;

lazy_static! {
    static ref URL_REGEX: Regex =
        Regex::new(r"[^:/?#\s]{2,}://[^/?#\s]{2,}([\w\./?#=%^]+)").unwrap();
    static ref CODE_BLOCK_LINE_WITH_LANGUAGE: Regex = Regex::new(r"^\s*```\s*([\w]+)?$").unwrap();
    static ref CODE_BLOCK_LINE_END: Regex = Regex::new(r"^\s*```$").unwrap();
}

#[derive(PartialEq)]
enum CodeBlockStyle {
    None,
    Fenced,
    Indenting,
}

pub struct MessageLineLength {}

impl MessageLineLength {
    pub fn new() -> Self {
        Self {}
    }
}

impl RuleValidator<Commit> for MessageLineLength {
    fn validate(&self, commit: &Commit) -> Option<Vec<Issue>> {
        let mut code_block_style = CodeBlockStyle::None;
        let mut previous_line_was_empty_line = false;
        let mut issues = vec![];
        for (index, raw_line) in commit.message.lines().enumerate() {
            let line = raw_line.trim_end();
            let (width, line_stats) = line_length_stats(line, 72);
            match code_block_style {
                CodeBlockStyle::Fenced => {
                    if CODE_BLOCK_LINE_END.is_match(line) {
                        code_block_style = CodeBlockStyle::None;
                    }
                }
                CodeBlockStyle::Indenting => {
                    if !line.starts_with("    ") {
                        code_block_style = CodeBlockStyle::None;
                    }
                }
                CodeBlockStyle::None => {
                    if CODE_BLOCK_LINE_WITH_LANGUAGE.is_match(line) {
                        code_block_style = CodeBlockStyle::Fenced;
                    } else if line.starts_with("    ") && previous_line_was_empty_line {
                        code_block_style = CodeBlockStyle::Indenting;
                    }
                }
            }
            if code_block_style != CodeBlockStyle::None {
                // When in a code block, skip line length validation
                continue;
            }
            if width > 72 {
                if URL_REGEX.is_match(line) {
                    continue;
                }
                let line_number = index + 2; // + 1 for subject + 1 for zero index
                let context = Context::message_line_error(
                    line_number,
                    line.to_string(),
                    Range {
                        start: line_stats.bytes_index,
                        end: line.len(),
                    },
                    "Shorten line to maximum 72 characters".to_string(),
                );
                issues.push(Issue::error(
                    Rule::MessageLineLength,
                    format!(
                        "Line {} in the message body is longer than 72 characters",
                        line_number
                    ),
                    Position::MessageLine {
                        line: line_number,
                        column: line_stats.char_count + 1, // + 1 because the next char is the problem
                    },
                    vec![context],
                ));
            }
            previous_line_was_empty_line = line.trim() == "";
        }

        if issues.is_empty() {
            None
        } else {
            Some(issues)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test::*;

    fn validate(commit: &Commit) -> Option<Vec<Issue>> {
        MessageLineLength::new().validate(commit)
    }

    fn assert_valid(message: &str) {
        assert_eq!(validate(&commit("Subject", message)), None);
    }

    fn assert_invalid(message: &str) {
        assert!(validate(&commit("Subject", message)).is_some());
    }

    #[test]
    fn valid_message() {
        let message = ["Hello I am a message.", "Line 2.", &"a".repeat(72)].join("\n");
        assert_valid(&message);
    }

    #[test]
    fn invalid_long_line() {
        let message = ["".to_string(), "a".repeat(72), "a".repeat(73)].join("\n");
        let issue = first_issue(validate(&commit("Subject", &message)));
        assert_eq!(
            issue.message,
            "Line 4 in the message body is longer than 72 characters"
        );
        assert_eq!(issue.position, message_position(4, 73));
        assert_contains_issue_output(
            &issue,
            "4 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
               |                                                                         ^ Shorten line to maximum 72 characters"
        );
    }

    #[test]
    fn valid_long_line_with_https_url() {
        let message = [
            "This message is accepted.".to_string(),
            "This a long line with a link https://tomdebruijn.com/posts/git-is-about-communication/".to_string()
        ].join("\n");
        assert_valid(&message);
    }

    #[test]
    fn valid_long_line_with_http_url() {
        let message = [
            "This message is accepted.".to_string(),
            "This a long line with a link http://tomdebruijn.com/posts/git-is-about-communication/"
                .to_string(),
        ]
        .join("\n");
        assert_valid(&message);
    }

    #[test]
    fn valid_long_line_with_other_uri_scheme() {
        let message = [
            "This message is accepted.".to_string(),
            "This a long line with a link xmlrpc.beep://tomdebruijn.com/posts?query=git%20is%20about%23#communication"
                .to_string(),
        ]
        .join("\n");
        assert_valid(&message);
    }

    #[test]
    fn long_line_with_url_schema_only() {
        let message =
            "This a too long line with only protocols http:// https://, not accepted!!".to_string();
        let issue = first_issue(validate(&commit("Subject", &message)));
        assert_eq!(
            issue.message,
            "Line 2 in the message body is longer than 72 characters"
        );
        assert_eq!(issue.position, message_position(2, 73));
        assert_contains_issue_output(
            &issue,
            "2 | This a too long line with only protocols http:// https://, not accepted!!\n\
               |                                                                         ^ Shorten line to maximum 72 characters"
        );
    }

    #[test]
    fn emoji_line_short_enough() {
        // This emoji display width is 2
        let message = ["".repeat(36)].join("\n");
        assert_valid(&message);
    }

    #[test]
    fn emoji_line_too_long() {
        let message = ["".repeat(37)].join("\n");
        assert_invalid(&message);
    }

    #[test]
    fn hiragana_line_short_enough() {
        // Hiragana display width is 2
        let message = ["".repeat(36)].join("\n");
        assert_valid(&message);
    }

    #[test]
    fn hiragana_line_too_long() {
        let message = ["".repeat(37)].join("\n");
        assert_invalid(&message);
    }

    #[test]
    fn valid_fenced_code_blocks() {
        let message = [
            "Beginning of message.",
            "```",
            &"a".repeat(73), // Valid, inside code block
            &"b".repeat(73),
            &"c".repeat(73),
            "```",
            "Normal line",
            "```md",
            "I am markdown",
            &"d".repeat(73), // Valid, inside code block
            "```",
            "Normal line",
            "``` yaml",
            "I am yaml",
            &"d".repeat(73), // Valid, inside code block
            "```",
            "Normal line",
            "```  elixir ",
            "I am elixir",
            &"d".repeat(73), // Valid, inside code block
            "```",
            "",
            "  ```",
            "  I am elixir",
            &"  d".repeat(73), // Valid, inside fenced indented code block
            "  ```",
            "End of message",
        ]
        .join("\n");
        assert_valid(&message);
    }

    #[test]
    fn invalid_long_line_outside_fenced_code_block() {
        let message = [
            "Beginning of message.",
            "```",
            &"a".repeat(73),
            "```",
            &"a".repeat(73), // Long line outside code block is invalid
            "End of message",
        ]
        .join("\n");
        assert_invalid(&message);
    }

    #[test]
    fn invalid_fenced_code_block_language_identifier() {
        let message = [
            "Beginning of message.",
            "``` m d", // Invald language identifier
            &"a".repeat(73),
            "```",
            "End of message",
        ]
        .join("\n");
        assert_invalid(&message);
    }

    #[test]
    fn valid_indented_code_blocks() {
        let message = [
            "Beginning of message.",
            "",
            "    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            "    bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
            "",
            "    ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
            "    ",
            "    ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
            "",
            "End of message",
        ]
        .join("\n");
        assert_valid(&message);
    }

    #[test]
    fn invalid_long_ling_outside_indended_code_block() {
        let message = [
            "Beginning of message.",
            "",
            "    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            "    bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
            "",
            "    ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
            "",
            "End of message",
            &"a".repeat(73), // Long line outside code block is invalid
        ]
        .join("\n");
        assert_invalid(&message);
    }

    fn assert_contain_url(string: &str) {
        assert!(
            super::URL_REGEX.is_match(string),
            "String is not a URL: {}",
            string
        );
    }

    fn assert_not_url(string: &str) {
        assert!(
            !super::URL_REGEX.is_match(string),
            "String is a URL: {}",
            string
        );
    }

    #[test]
    fn url() {
        let urls = [
            "http://tomdebruijn.com",
            "http://other-domain-with-dashes_and_underscores.com",
            "https://tomdebruijn.com",
            "https://tomdebruijn.com/path/!file.extension?key=value",
            "https://tomdebruijn.com:80",
            "https://tomdebruijn.com:80/path/other-path/_file.extension",
            "https://tomdebruijn.com:80/path/!file.extension?key=value",
            "ftp://tomdebruijn.com",
            "ssh://10.0.0.2:22",
            "z39.50s://tomdebruijn.com",
            "ms-settings://tomdebruijn",
            "dict://tom,pw@localhost:80",
            "facetime://+19995551234",
            "https://tom de bruijn.com", // First part until the space is an URL
            "https://tom/de/bruijn.com",
            "Start string https://tomdebruijn.com end string",
        ];
        for url in urls {
            assert_contain_url(url);
        }

        let invalid_urls = [
            "s://tomdebruijn.com",
            "https://t",
            "https ://tomdebruijn.com",
            "https:// tomdebruijn.com",
        ];
        for string in invalid_urls {
            assert_not_url(string);
        }
    }
}