use std::collections::BTreeSet;
use std::ops::Range;
use std::sync::OnceLock;
use regex::{Captures, Regex};
use crate::provider::{ItemKind, ItemRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Citation {
pub item_ref: ItemRef,
pub raw: String,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CitationMention {
pub citation: Citation,
pub range: Range<usize>,
}
fn citation_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
#[allow(clippy::expect_used)]
RE.get_or_init(|| {
Regex::new(
r"(?ix)
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]+)
| (?P<other_url>https?://\S+)
| (?P<or_owner>[A-Za-z0-9_.-]+)/(?P<or_repo>[A-Za-z0-9_.-]+)\#(?P<or_num>[0-9]+)
| (?:pr|pull\ request)\s*\#(?P<pr_num>[0-9]+)
| \#(?P<issue_num>[0-9]+)
",
)
.expect("citation regex must compile")
})
}
fn number_end(caps: &Captures<'_>) -> Option<usize> {
["url_num", "or_num", "pr_num", "issue_num"]
.iter()
.find_map(|name| caps.name(name))
.map(|m| m.end())
}
fn ends_at_boundary(body: &str, end: usize) -> bool {
body[end..]
.chars()
.next()
.is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
}
fn citation_from_captures(
caps: &Captures<'_>,
default_project: &str,
) -> Option<(String, ItemKind, u64)> {
if let Some(n) = caps.name("url_num") {
let kind = if &caps["url_kind"] == "pull" {
ItemKind::ChangeRequest
} else {
ItemKind::Issue
};
n.as_str().parse::<u64>().ok().map(|number| {
(
format!("{}/{}", &caps["url_owner"], &caps["url_repo"]),
kind,
number,
)
})
} else if let Some(n) = caps.name("or_num") {
n.as_str().parse::<u64>().ok().map(|number| {
(
format!("{}/{}", &caps["or_owner"], &caps["or_repo"]),
ItemKind::Issue,
number,
)
})
} else if let Some(n) = caps.name("pr_num") {
n.as_str()
.parse::<u64>()
.ok()
.map(|number| (default_project.to_string(), ItemKind::ChangeRequest, number))
} else if let Some(n) = caps.name("issue_num") {
n.as_str()
.parse::<u64>()
.ok()
.map(|number| (default_project.to_string(), ItemKind::Issue, number))
} else {
None
}
}
pub(crate) fn first_citation(text: &str, default_project: &str) -> Option<(String, ItemKind, u64)> {
citation_regex().captures_iter(text).find_map(|caps| {
let end = number_end(&caps)?;
if !ends_at_boundary(text, end) {
return None;
}
citation_from_captures(&caps, default_project)
})
}
#[must_use]
pub fn find_citations(body: &str, default_project: &str, judged: &ItemRef) -> Vec<Citation> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for mention in find_citation_mentions(body, default_project, judged) {
let citation = mention.citation;
if seen.insert((citation.item_ref.project.clone(), citation.item_ref.number)) {
out.push(citation);
}
}
out
}
#[must_use]
pub fn find_citation_mentions(
body: &str,
default_project: &str,
judged: &ItemRef,
) -> Vec<CitationMention> {
let mut out = Vec::new();
for caps in citation_regex().captures_iter(body) {
let Some(end) = number_end(&caps) else {
continue; };
if !ends_at_boundary(body, end) {
continue;
}
let Some((project, kind, number)) = citation_from_captures(&caps, default_project) else {
continue;
};
if project == judged.project && number == judged.number {
continue;
}
if let Some(matched) = caps.get(0) {
out.push(CitationMention {
range: matched.range(),
citation: Citation {
item_ref: ItemRef {
provider: judged.provider,
project,
kind,
number,
},
raw: caps[0].to_string(),
url: None,
},
});
}
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::jev::markdown_code::mask_code;
use crate::provider::GitProvider;
fn judged(number: u64) -> ItemRef {
ItemRef {
provider: GitProvider::GitHub,
project: "rust-works/omni-dev".to_string(),
kind: ItemKind::Issue,
number,
}
}
#[test]
fn finds_bare_hash_and_owner_repo_and_url_forms() {
let body = "See #1614, other/repo#5, and https://github.com/rust-works/omni-dev/pull/1629.";
let cites = find_citations(body, "rust-works/omni-dev", &judged(1779));
assert_eq!(cites.len(), 3);
assert_eq!(cites[0].item_ref.number, 1614);
assert_eq!(cites[0].item_ref.kind, ItemKind::Issue);
assert_eq!(cites[1].item_ref.project, "other/repo");
assert_eq!(cites[2].item_ref.number, 1629);
assert_eq!(cites[2].item_ref.kind, ItemKind::ChangeRequest);
}
#[test]
fn a_github_issue_url_resolves_to_issue_kind() {
let cites = find_citations(
"see https://github.com/rust-works/omni-dev/issues/1614",
"rust-works/omni-dev",
&judged(1779),
);
assert_eq!(cites.len(), 1);
assert_eq!(cites[0].item_ref.kind, ItemKind::Issue);
assert_eq!(cites[0].item_ref.number, 1614);
}
#[test]
fn an_overflowing_citation_number_is_skipped() {
let cites = find_citations(
"see #99999999999999999999 and #1614",
"rust-works/omni-dev",
&judged(1779),
);
assert_eq!(cites.len(), 1);
assert_eq!(cites[0].item_ref.number, 1614);
}
#[test]
fn pr_prefix_wins_over_the_bare_hash_alternative() {
let cites = find_citations("Fixed by PR #1629.", "rust-works/omni-dev", &judged(1));
assert_eq!(cites.len(), 1);
assert_eq!(cites[0].item_ref.kind, ItemKind::ChangeRequest);
assert_eq!(cites[0].raw, "PR #1629");
}
#[test]
fn pull_request_prefix_is_case_insensitive() {
let cites = find_citations("see pull request #42 for context", "o/r", &judged(1));
assert_eq!(cites[0].item_ref.number, 42);
assert_eq!(cites[0].item_ref.kind, ItemKind::ChangeRequest);
}
#[test]
fn drops_self_citations_and_dedupes() {
let cites = find_citations(
"#1779 depends on #1614, see also #1614 again",
"rust-works/omni-dev",
&judged(1779),
);
assert_eq!(cites.len(), 1);
assert_eq!(cites[0].item_ref.number, 1614);
}
#[test]
fn mentions_keep_repeated_references_and_byte_ranges() {
let body = "See #1614. Later: blocked on #1614.";
let mentions = find_citation_mentions(body, "rust-works/omni-dev", &judged(1779));
assert_eq!(mentions.len(), 2);
assert_eq!(&body[mentions[0].range.clone()], "#1614");
assert_eq!(&body[mentions[1].range.clone()], "#1614");
assert_eq!(
find_citations(body, "rust-works/omni-dev", &judged(1779)).len(),
1
);
}
#[test]
fn returns_nothing_for_a_comment_with_no_citations() {
assert!(find_citations("Looks good to me!", "o/r", &judged(1)).is_empty());
}
#[test]
fn a_number_running_into_a_word_character_is_not_a_citation() {
for body in [
"the colour #1f77b4 is used",
"see the anchor #1-overview below",
] {
assert!(
find_citations(body, "rust-works/omni-dev", &judged(1779)).is_empty(),
"{body}"
);
}
}
#[test]
fn a_doc_anchor_link_does_not_invent_a_repository() {
let cites = find_citations(
"see [state input](docs/jev.md#4-state-input) and #1614",
"rust-works/omni-dev",
&judged(1779),
);
assert_eq!(cites.len(), 1);
assert_eq!(cites[0].item_ref.project, "rust-works/omni-dev");
assert_eq!(cites[0].item_ref.number, 1614);
}
#[test]
fn a_non_issue_url_yields_no_citation() {
for body in [
"see https://github.com/rust-works/omni-dev/blob/main/README.md#12",
"see https://example.com/browse/X#1614",
] {
assert!(
find_citations(body, "rust-works/omni-dev", &judged(1779)).is_empty(),
"{body}"
);
}
}
#[test]
fn a_citation_followed_by_punctuation_is_still_found() {
for body in ["fixed by #1614.", "fixed by #1614", "fixed by (#1614)"] {
let cites = find_citations(body, "rust-works/omni-dev", &judged(1779));
assert_eq!(cites.len(), 1, "{body}");
assert_eq!(cites[0].raw, "#1614", "{body}");
}
}
const CODE_BODY: &str = "\
Real one: #1614.
```
error: could not find owner/repo#123
```
Inline `o/r#1` and ``o/r#2`` examples, then PR #1629.
";
#[test]
fn find_citations_reads_code() {
let cites = find_citations(CODE_BODY, "rust-works/omni-dev", &judged(1779));
let numbers: Vec<u64> = cites.iter().map(|c| c.item_ref.number).collect();
assert_eq!(numbers, [1614, 123, 1, 2, 1629]);
}
#[test]
fn a_backticked_reference_is_a_citation() {
let cites = find_citations("settled by `#1614`", "rust-works/omni-dev", &judged(1779));
assert_eq!(cites.len(), 1);
assert_eq!(cites[0].raw, "#1614");
}
#[test]
fn masked_text_keeps_only_the_citations_outside_code() {
let masked = mask_code(CODE_BODY);
let cites = find_citations(&masked, "rust-works/omni-dev", &judged(1779));
let raws: Vec<&str> = cites.iter().map(|c| c.raw.as_str()).collect();
assert_eq!(raws, ["#1614", "PR #1629"]);
let abutting = mask_code("fixed by #1614`x`");
assert_eq!(
find_citations(&abutting, "rust-works/omni-dev", &judged(1779))[0].raw,
"#1614"
);
}
}