//! Parsing of issue references (`#12`) and closing keywords (`fixes #12`) in free text. //! Hand-written scanner, no regex. Pure, no IO. use std::ops::Range; /// Keywords that close an issue when followed by a `#N` reference. Compared case-insensitively. const CLOSING_KEYWORDS: [&str; 9] = [ "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved", ]; /// Maximum number of digits in an issue number. const MAX_DIGITS: usize = 9; /// Byte range (including the `#`) and value of every valid `#N` reference in `text`, in order. /// Duplicates are kept. Used to link references in rendered markdown with the same rules. pub fn issue_ref_spans(text: &str) -> Vec<(Range, i64)> { scan_refs(text) } /// Every `#N` reference in `text`, deduplicated, in order of first appearance. /// /// A reference is `#` followed by 1 to 9 digits. It is not recognised when the `#` is preceded by /// an alphanumeric char, `&` or `/`, or when the digits are followed by an alphanumeric char. /// `#0` is not an issue number and is ignored. pub fn find_issue_refs(text: &str) -> Vec { let mut out: Vec = Vec::new(); for (_, n) in scan_refs(text) { if !out.contains(&n) { out.push(n); } } out } /// Issue numbers closed by closing keywords in `text`, deduplicated, in order. /// /// The keyword (case-insensitive, whole word) must directly precede the reference, optionally /// separated by a `:`, with at least one whitespace char between the keyword (or colon) and `#`. /// Example: `"Fixes: #3, closes #4"` gives `[3, 4]`; `"unfixes #2"` gives nothing. pub fn find_closing_refs(text: &str) -> Vec { let mut out: Vec = Vec::new(); for (range, n) in scan_refs(text) { if !preceded_by_closing_keyword(&text[..range.start]) { continue; } if !out.contains(&n) { out.push(n); } } out } /// Byte range of each valid `#N` reference and its value, in order of appearance. fn scan_refs(text: &str) -> Vec<(Range, i64)> { let bytes = text.as_bytes(); let mut refs = Vec::new(); let mut i = 0; while i < bytes.len() { if bytes[i] != b'#' { i += 1; continue; } let hash = i; i += 1; let digits_start = i; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } let digits = &bytes[digits_start..i]; if digits.is_empty() || digits.len() > MAX_DIGITS { continue; } if let Some(next) = text[i..].chars().next() && next.is_alphanumeric() { continue; } if let Some(prev) = text[..hash].chars().next_back() && (prev.is_alphanumeric() || prev == '&' || prev == '/') { continue; } let n = digits .iter() .fold(0i64, |acc, d| acc * 10 + i64::from(d - b'0')); if n == 0 { continue; } refs.push((hash..i, n)); } refs } /// True when `before` (the text preceding a `#N` reference) ends with a closing keyword, /// an optional `:`, and at least one whitespace char. fn preceded_by_closing_keyword(before: &str) -> bool { let trimmed = before.trim_end(); if trimmed.len() == before.len() { // No whitespace between the keyword and the reference. return false; } let word_part = trimmed.strip_suffix(':').unwrap_or(trimmed); let lower = word_part.to_ascii_lowercase(); CLOSING_KEYWORDS.iter().any(|kw| { lower.ends_with(kw) && word_part[..word_part.len() - kw.len()] .chars() .next_back() .is_none_or(|c| !c.is_alphanumeric()) }) } #[cfg(test)] mod tests { use super::*; #[test] fn plain_reference() { assert_eq!(find_issue_refs("fixes #12"), vec![12]); } #[test] fn multiple_references_in_order() { assert_eq!(find_issue_refs("Fixes: #3, closes #4"), vec![3, 4]); } #[test] fn prefix_before_hash_is_not_a_reference() { assert!(find_issue_refs("prefix#5").is_empty()); } #[test] fn html_entity_is_not_a_reference() { assert!(find_issue_refs("'").is_empty()); } #[test] fn slash_before_hash_is_not_a_reference() { assert!(find_issue_refs("a/#7").is_empty()); } #[test] fn leading_zeros_are_parsed() { assert_eq!(find_issue_refs("#0012"), vec![12]); } #[test] fn ten_digits_is_not_a_reference() { assert!(find_issue_refs("#1234567890").is_empty()); } #[test] fn nine_digits_is_a_reference() { assert_eq!(find_issue_refs("#123456789"), vec![123_456_789]); } #[test] fn zero_is_not_a_reference() { assert!(find_issue_refs("#0").is_empty()); } #[test] fn parentheses_are_allowed_around_reference() { assert_eq!(find_issue_refs("(#8)"), vec![8]); } #[test] fn trailing_punctuation_is_allowed() { assert_eq!(find_issue_refs("#9."), vec![9]); } #[test] fn alphanumeric_suffix_is_not_a_reference() { assert!(find_issue_refs("#12abc").is_empty()); } #[test] fn duplicates_are_removed() { assert_eq!(find_issue_refs("#2 and #1 and #2"), vec![2, 1]); assert_eq!(find_closing_refs("fixes #2, closes #2"), vec![2]); } #[test] fn unfixes_is_not_closing() { assert!(find_closing_refs("unfixes #2").is_empty()); assert_eq!(find_issue_refs("unfixes #2"), vec![2]); } #[test] fn fixed_is_closing() { assert_eq!(find_closing_refs("fixed #2"), vec![2]); } #[test] fn all_keywords_are_case_insensitive() { for kw in [ "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved", ] { let text = format!("{}: #5", kw.to_uppercase()); assert_eq!(find_closing_refs(&text), vec![5], "{kw}"); } } #[test] fn keyword_needs_whitespace_before_hash() { assert!(find_closing_refs("fixes#2").is_empty()); assert!(find_closing_refs("fixes:#2").is_empty()); } #[test] fn plain_reference_is_not_closing() { assert!(find_closing_refs("see #2").is_empty()); } #[test] fn keyword_must_be_a_whole_word() { assert!(find_closing_refs("prefixes #2").is_empty()); } }