1use std::collections::BTreeSet;
12use std::ops::Range;
13use std::sync::OnceLock;
14
15use regex::{Captures, Regex};
16
17use crate::provider::{ItemKind, ItemRef};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Citation {
22 pub item_ref: ItemRef,
24 pub raw: String,
28 pub url: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CitationMention {
38 pub citation: Citation,
40 pub range: Range<usize>,
42}
43
44fn citation_regex() -> &'static Regex {
45 static RE: OnceLock<Regex> = OnceLock::new();
46 #[allow(clippy::expect_used)]
47 RE.get_or_init(|| {
48 Regex::new(
49 r"(?ix)
50 https://github\.com/(?P<url_owner>[A-Za-z0-9_.-]+)/(?P<url_repo>[A-Za-z0-9_.-]+)/(?P<url_kind>issues|pull)/(?P<url_num>[0-9]+)
51 | (?P<other_url>https?://\S+)
52 | (?P<or_owner>[A-Za-z0-9_.-]+)/(?P<or_repo>[A-Za-z0-9_.-]+)\#(?P<or_num>[0-9]+)
53 | (?:pr|pull\ request)\s*\#(?P<pr_num>[0-9]+)
54 | \#(?P<issue_num>[0-9]+)
55 ",
56 )
57 .expect("citation regex must compile")
58 })
59}
60
61fn number_end(caps: &Captures<'_>) -> Option<usize> {
65 ["url_num", "or_num", "pr_num", "issue_num"]
66 .iter()
67 .find_map(|name| caps.name(name))
68 .map(|m| m.end())
69}
70
71fn ends_at_boundary(body: &str, end: usize) -> bool {
81 body[end..]
82 .chars()
83 .next()
84 .is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
85}
86
87fn citation_from_captures(
92 caps: &Captures<'_>,
93 default_project: &str,
94) -> Option<(String, ItemKind, u64)> {
95 if let Some(n) = caps.name("url_num") {
96 let kind = if &caps["url_kind"] == "pull" {
97 ItemKind::ChangeRequest
98 } else {
99 ItemKind::Issue
100 };
101 n.as_str().parse::<u64>().ok().map(|number| {
102 (
103 format!("{}/{}", &caps["url_owner"], &caps["url_repo"]),
104 kind,
105 number,
106 )
107 })
108 } else if let Some(n) = caps.name("or_num") {
109 n.as_str().parse::<u64>().ok().map(|number| {
110 (
111 format!("{}/{}", &caps["or_owner"], &caps["or_repo"]),
112 ItemKind::Issue,
113 number,
114 )
115 })
116 } else if let Some(n) = caps.name("pr_num") {
117 n.as_str()
118 .parse::<u64>()
119 .ok()
120 .map(|number| (default_project.to_string(), ItemKind::ChangeRequest, number))
121 } else if let Some(n) = caps.name("issue_num") {
122 n.as_str()
123 .parse::<u64>()
124 .ok()
125 .map(|number| (default_project.to_string(), ItemKind::Issue, number))
126 } else {
127 None
129 }
130}
131
132pub(crate) fn first_citation(text: &str, default_project: &str) -> Option<(String, ItemKind, u64)> {
135 citation_regex().captures_iter(text).find_map(|caps| {
136 let end = number_end(&caps)?;
137 if !ends_at_boundary(text, end) {
138 return None;
139 }
140 citation_from_captures(&caps, default_project)
141 })
142}
143
144#[must_use]
158pub fn find_citations(body: &str, default_project: &str, judged: &ItemRef) -> Vec<Citation> {
159 let mut seen = BTreeSet::new();
160 let mut out = Vec::new();
161 for mention in find_citation_mentions(body, default_project, judged) {
162 let citation = mention.citation;
163 if seen.insert((citation.item_ref.project.clone(), citation.item_ref.number)) {
164 out.push(citation);
165 }
166 }
167 out
168}
169
170#[must_use]
174pub fn find_citation_mentions(
175 body: &str,
176 default_project: &str,
177 judged: &ItemRef,
178) -> Vec<CitationMention> {
179 let mut out = Vec::new();
180 for caps in citation_regex().captures_iter(body) {
181 let Some(end) = number_end(&caps) else {
182 continue; };
184 if !ends_at_boundary(body, end) {
185 continue;
186 }
187 let Some((project, kind, number)) = citation_from_captures(&caps, default_project) else {
188 continue;
189 };
190 if project == judged.project && number == judged.number {
191 continue;
192 }
193 if let Some(matched) = caps.get(0) {
194 out.push(CitationMention {
195 range: matched.range(),
196 citation: Citation {
197 item_ref: ItemRef {
198 provider: judged.provider,
199 project,
200 kind,
201 number,
202 },
203 raw: caps[0].to_string(),
204 url: None,
205 },
206 });
207 }
208 }
209 out
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used, clippy::expect_used)]
214mod tests {
215 use super::*;
216 use crate::jev::markdown_code::mask_code;
217 use crate::provider::GitProvider;
218
219 fn judged(number: u64) -> ItemRef {
220 ItemRef {
221 provider: GitProvider::GitHub,
222 project: "rust-works/omni-dev".to_string(),
223 kind: ItemKind::Issue,
224 number,
225 }
226 }
227
228 #[test]
229 fn finds_bare_hash_and_owner_repo_and_url_forms() {
230 let body = "See #1614, other/repo#5, and https://github.com/rust-works/omni-dev/pull/1629.";
231 let cites = find_citations(body, "rust-works/omni-dev", &judged(1779));
232 assert_eq!(cites.len(), 3);
233 assert_eq!(cites[0].item_ref.number, 1614);
234 assert_eq!(cites[0].item_ref.kind, ItemKind::Issue);
235 assert_eq!(cites[1].item_ref.project, "other/repo");
236 assert_eq!(cites[2].item_ref.number, 1629);
237 assert_eq!(cites[2].item_ref.kind, ItemKind::ChangeRequest);
238 }
239
240 #[test]
241 fn a_github_issue_url_resolves_to_issue_kind() {
242 let cites = find_citations(
243 "see https://github.com/rust-works/omni-dev/issues/1614",
244 "rust-works/omni-dev",
245 &judged(1779),
246 );
247 assert_eq!(cites.len(), 1);
248 assert_eq!(cites[0].item_ref.kind, ItemKind::Issue);
249 assert_eq!(cites[0].item_ref.number, 1614);
250 }
251
252 #[test]
255 fn an_overflowing_citation_number_is_skipped() {
256 let cites = find_citations(
257 "see #99999999999999999999 and #1614",
258 "rust-works/omni-dev",
259 &judged(1779),
260 );
261 assert_eq!(cites.len(), 1);
262 assert_eq!(cites[0].item_ref.number, 1614);
263 }
264
265 #[test]
266 fn pr_prefix_wins_over_the_bare_hash_alternative() {
267 let cites = find_citations("Fixed by PR #1629.", "rust-works/omni-dev", &judged(1));
268 assert_eq!(cites.len(), 1);
269 assert_eq!(cites[0].item_ref.kind, ItemKind::ChangeRequest);
270 assert_eq!(cites[0].raw, "PR #1629");
271 }
272
273 #[test]
274 fn pull_request_prefix_is_case_insensitive() {
275 let cites = find_citations("see pull request #42 for context", "o/r", &judged(1));
276 assert_eq!(cites[0].item_ref.number, 42);
277 assert_eq!(cites[0].item_ref.kind, ItemKind::ChangeRequest);
278 }
279
280 #[test]
281 fn drops_self_citations_and_dedupes() {
282 let cites = find_citations(
283 "#1779 depends on #1614, see also #1614 again",
284 "rust-works/omni-dev",
285 &judged(1779),
286 );
287 assert_eq!(cites.len(), 1);
288 assert_eq!(cites[0].item_ref.number, 1614);
289 }
290
291 #[test]
292 fn mentions_keep_repeated_references_and_byte_ranges() {
293 let body = "See #1614. Later: blocked on #1614.";
294 let mentions = find_citation_mentions(body, "rust-works/omni-dev", &judged(1779));
295 assert_eq!(mentions.len(), 2);
296 assert_eq!(&body[mentions[0].range.clone()], "#1614");
297 assert_eq!(&body[mentions[1].range.clone()], "#1614");
298 assert_eq!(
299 find_citations(body, "rust-works/omni-dev", &judged(1779)).len(),
300 1
301 );
302 }
303
304 #[test]
305 fn returns_nothing_for_a_comment_with_no_citations() {
306 assert!(find_citations("Looks good to me!", "o/r", &judged(1)).is_empty());
307 }
308
309 #[test]
313 fn a_number_running_into_a_word_character_is_not_a_citation() {
314 for body in [
315 "the colour #1f77b4 is used",
316 "see the anchor #1-overview below",
317 ] {
318 assert!(
319 find_citations(body, "rust-works/omni-dev", &judged(1779)).is_empty(),
320 "{body}"
321 );
322 }
323 }
324
325 #[test]
329 fn a_doc_anchor_link_does_not_invent_a_repository() {
330 let cites = find_citations(
331 "see [state input](docs/jev.md#4-state-input) and #1614",
332 "rust-works/omni-dev",
333 &judged(1779),
334 );
335 assert_eq!(cites.len(), 1);
336 assert_eq!(cites[0].item_ref.project, "rust-works/omni-dev");
337 assert_eq!(cites[0].item_ref.number, 1614);
338 }
339
340 #[test]
343 fn a_non_issue_url_yields_no_citation() {
344 for body in [
345 "see https://github.com/rust-works/omni-dev/blob/main/README.md#12",
346 "see https://example.com/browse/X#1614",
347 ] {
348 assert!(
349 find_citations(body, "rust-works/omni-dev", &judged(1779)).is_empty(),
350 "{body}"
351 );
352 }
353 }
354
355 #[test]
356 fn a_citation_followed_by_punctuation_is_still_found() {
357 for body in ["fixed by #1614.", "fixed by #1614", "fixed by (#1614)"] {
358 let cites = find_citations(body, "rust-works/omni-dev", &judged(1779));
359 assert_eq!(cites.len(), 1, "{body}");
360 assert_eq!(cites[0].raw, "#1614", "{body}");
361 }
362 }
363
364 const CODE_BODY: &str = "\
365Real one: #1614.
366
367```
368error: could not find owner/repo#123
369```
370
371Inline `o/r#1` and ``o/r#2`` examples, then PR #1629.
372";
373
374 #[test]
378 fn find_citations_reads_code() {
379 let cites = find_citations(CODE_BODY, "rust-works/omni-dev", &judged(1779));
380 let numbers: Vec<u64> = cites.iter().map(|c| c.item_ref.number).collect();
381 assert_eq!(numbers, [1614, 123, 1, 2, 1629]);
382 }
383
384 #[test]
385 fn a_backticked_reference_is_a_citation() {
386 let cites = find_citations("settled by `#1614`", "rust-works/omni-dev", &judged(1779));
387 assert_eq!(cites.len(), 1);
388 assert_eq!(cites[0].raw, "#1614");
389 }
390
391 #[test]
394 fn masked_text_keeps_only_the_citations_outside_code() {
395 let masked = mask_code(CODE_BODY);
396 let cites = find_citations(&masked, "rust-works/omni-dev", &judged(1779));
397 let raws: Vec<&str> = cites.iter().map(|c| c.raw.as_str()).collect();
398 assert_eq!(raws, ["#1614", "PR #1629"]);
399 let abutting = mask_code("fixed by #1614`x`");
400 assert_eq!(
401 find_citations(&abutting, "rust-works/omni-dev", &judged(1779))[0].raw,
402 "#1614"
403 );
404 }
405}