forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.4 KiB
1//! Parsing of issue references (`#12`) and closing keywords (`fixes #12`) in free text.
2//! Hand-written scanner, no regex. Pure, no IO.
3
4use std::ops::Range;
5
6/// Keywords that close an issue when followed by a `#N` reference. Compared case-insensitively.
7const CLOSING_KEYWORDS: [&str; 9] = [
8 "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
9];
10
11/// Maximum number of digits in an issue number.
12const MAX_DIGITS: usize = 9;
13
14/// Byte range (including the `#`) and value of every valid `#N` reference in `text`, in order.
15/// Duplicates are kept. Used to link references in rendered markdown with the same rules.
16pub fn issue_ref_spans(text: &str) -> Vec<(Range<usize>, i64)> {
17 scan_refs(text)
18}
19
20/// Every `#N` reference in `text`, deduplicated, in order of first appearance.
21///
22/// A reference is `#` followed by 1 to 9 digits. It is not recognised when the `#` is preceded by
23/// an alphanumeric char, `&` or `/`, or when the digits are followed by an alphanumeric char.
24/// `#0` is not an issue number and is ignored.
25pub fn find_issue_refs(text: &str) -> Vec<i64> {
26 let mut out: Vec<i64> = Vec::new();
27 for (_, n) in scan_refs(text) {
28 if !out.contains(&n) {
29 out.push(n);
30 }
31 }
32 out
33}
34
35/// Issue numbers closed by closing keywords in `text`, deduplicated, in order.
36///
37/// The keyword (case-insensitive, whole word) must directly precede the reference, optionally
38/// separated by a `:`, with at least one whitespace char between the keyword (or colon) and `#`.
39/// Example: `"Fixes: #3, closes #4"` gives `[3, 4]`; `"unfixes #2"` gives nothing.
40pub fn find_closing_refs(text: &str) -> Vec<i64> {
41 let mut out: Vec<i64> = Vec::new();
42 for (range, n) in scan_refs(text) {
43 if !preceded_by_closing_keyword(&text[..range.start]) {
44 continue;
45 }
46 if !out.contains(&n) {
47 out.push(n);
48 }
49 }
50 out
51}
52
53/// Byte range of each valid `#N` reference and its value, in order of appearance.
54fn scan_refs(text: &str) -> Vec<(Range<usize>, i64)> {
55 let bytes = text.as_bytes();
56 let mut refs = Vec::new();
57 let mut i = 0;
58 while i < bytes.len() {
59 if bytes[i] != b'#' {
60 i += 1;
61 continue;
62 }
63 let hash = i;
64 i += 1;
65 let digits_start = i;
66 while i < bytes.len() && bytes[i].is_ascii_digit() {
67 i += 1;
68 }
69 let digits = &bytes[digits_start..i];
70 if digits.is_empty() || digits.len() > MAX_DIGITS {
71 continue;
72 }
73 if let Some(next) = text[i..].chars().next()
74 && next.is_alphanumeric()
75 {
76 continue;
77 }
78 if let Some(prev) = text[..hash].chars().next_back()
79 && (prev.is_alphanumeric() || prev == '&' || prev == '/')
80 {
81 continue;
82 }
83 let n = digits
84 .iter()
85 .fold(0i64, |acc, d| acc * 10 + i64::from(d - b'0'));
86 if n == 0 {
87 continue;
88 }
89 refs.push((hash..i, n));
90 }
91 refs
92}
93
94/// True when `before` (the text preceding a `#N` reference) ends with a closing keyword,
95/// an optional `:`, and at least one whitespace char.
96fn preceded_by_closing_keyword(before: &str) -> bool {
97 let trimmed = before.trim_end();
98 if trimmed.len() == before.len() {
99 // No whitespace between the keyword and the reference.
100 return false;
101 }
102 let word_part = trimmed.strip_suffix(':').unwrap_or(trimmed);
103 let lower = word_part.to_ascii_lowercase();
104 CLOSING_KEYWORDS.iter().any(|kw| {
105 lower.ends_with(kw)
106 && word_part[..word_part.len() - kw.len()]
107 .chars()
108 .next_back()
109 .is_none_or(|c| !c.is_alphanumeric())
110 })
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn plain_reference() {
119 assert_eq!(find_issue_refs("fixes #12"), vec![12]);
120 }
121
122 #[test]
123 fn multiple_references_in_order() {
124 assert_eq!(find_issue_refs("Fixes: #3, closes #4"), vec![3, 4]);
125 }
126
127 #[test]
128 fn prefix_before_hash_is_not_a_reference() {
129 assert!(find_issue_refs("prefix#5").is_empty());
130 }
131
132 #[test]
133 fn html_entity_is_not_a_reference() {
134 assert!(find_issue_refs("&#39;").is_empty());
135 }
136
137 #[test]
138 fn slash_before_hash_is_not_a_reference() {
139 assert!(find_issue_refs("a/#7").is_empty());
140 }
141
142 #[test]
143 fn leading_zeros_are_parsed() {
144 assert_eq!(find_issue_refs("#0012"), vec![12]);
145 }
146
147 #[test]
148 fn ten_digits_is_not_a_reference() {
149 assert!(find_issue_refs("#1234567890").is_empty());
150 }
151
152 #[test]
153 fn nine_digits_is_a_reference() {
154 assert_eq!(find_issue_refs("#123456789"), vec![123_456_789]);
155 }
156
157 #[test]
158 fn zero_is_not_a_reference() {
159 assert!(find_issue_refs("#0").is_empty());
160 }
161
162 #[test]
163 fn parentheses_are_allowed_around_reference() {
164 assert_eq!(find_issue_refs("(#8)"), vec![8]);
165 }
166
167 #[test]
168 fn trailing_punctuation_is_allowed() {
169 assert_eq!(find_issue_refs("#9."), vec![9]);
170 }
171
172 #[test]
173 fn alphanumeric_suffix_is_not_a_reference() {
174 assert!(find_issue_refs("#12abc").is_empty());
175 }
176
177 #[test]
178 fn duplicates_are_removed() {
179 assert_eq!(find_issue_refs("#2 and #1 and #2"), vec![2, 1]);
180 assert_eq!(find_closing_refs("fixes #2, closes #2"), vec![2]);
181 }
182
183 #[test]
184 fn unfixes_is_not_closing() {
185 assert!(find_closing_refs("unfixes #2").is_empty());
186 assert_eq!(find_issue_refs("unfixes #2"), vec![2]);
187 }
188
189 #[test]
190 fn fixed_is_closing() {
191 assert_eq!(find_closing_refs("fixed #2"), vec![2]);
192 }
193
194 #[test]
195 fn all_keywords_are_case_insensitive() {
196 for kw in [
197 "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
198 ] {
199 let text = format!("{}: #5", kw.to_uppercase());
200 assert_eq!(find_closing_refs(&text), vec![5], "{kw}");
201 }
202 }
203
204 #[test]
205 fn keyword_needs_whitespace_before_hash() {
206 assert!(find_closing_refs("fixes#2").is_empty());
207 assert!(find_closing_refs("fixes:#2").is_empty());
208 }
209
210 #[test]
211 fn plain_reference_is_not_closing() {
212 assert!(find_closing_refs("see #2").is_empty());
213 }
214
215 #[test]
216 fn keyword_must_be_a_whole_word() {
217 assert!(find_closing_refs("prefixes #2").is_empty());
218 }
219}