| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
pub struct Numbers<'a> { |
| 7 |
pub repo_url: &'a str, |
| 8 |
pub issues: &'a [i64], |
| 9 |
pub proposals: &'a [i64], |
| 10 |
} |
| 11 |
|
| 12 |
impl Numbers<'_> { |
| 13 |
|
| 14 |
|
| 15 |
fn url_for(&self, n: i64) -> Option<(String, &'static str)> { |
| 16 |
if self.proposals.contains(&n) { |
| 17 |
Some((format!("{}/proposal/{n}", self.repo_url), "proposal")) |
| 18 |
} else if self.issues.contains(&n) { |
| 19 |
Some((format!("{}/issue/{n}", self.repo_url), "issue")) |
| 20 |
} else { |
| 21 |
None |
| 22 |
} |
| 23 |
} |
| 24 |
} |
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
pub fn link(text: &str, numbers: &Numbers<'_>) -> String { |
| 29 |
let mut out = String::with_capacity(text.len() + 64); |
| 30 |
let mut in_fence = false; |
| 31 |
for line in text.split_inclusive('\n') { |
| 32 |
if line.trim_start().starts_with("```") { |
| 33 |
in_fence = !in_fence; |
| 34 |
out.push_str(line); |
| 35 |
continue; |
| 36 |
} |
| 37 |
if in_fence { |
| 38 |
out.push_str(line); |
| 39 |
continue; |
| 40 |
} |
| 41 |
|
| 42 |
for (i, seg) in line.split('`').enumerate() { |
| 43 |
if i > 0 { |
| 44 |
out.push('`'); |
| 45 |
} |
| 46 |
if i % 2 == 0 { |
| 47 |
out.push_str(&link_prose(seg, numbers)); |
| 48 |
} else { |
| 49 |
out.push_str(seg); |
| 50 |
} |
| 51 |
} |
| 52 |
} |
| 53 |
out |
| 54 |
} |
| 55 |
|
| 56 |
fn link_prose(seg: &str, numbers: &Numbers<'_>) -> String { |
| 57 |
let re = regex::Regex::new(r"(?P<pre>^|[^\w/\[])(?:(?P<slug>[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*)#(?P<xn>\d+)|#(?P<n>\d+)|@(?P<user>[A-Za-z0-9][A-Za-z0-9._-]{0,63})|(?P<hash>\b[0-9a-f]{12,64}\b))").unwrap(); |
| 58 |
re.replace_all(seg, |c: ®ex::Captures| { |
| 59 |
let pre = &c["pre"]; |
| 60 |
if let Some(slug) = c.name("slug") { |
| 61 |
let n = &c["xn"]; |
| 62 |
return format!("{pre}[{}#{n}](/{}/issue/{n})", slug.as_str(), slug.as_str()); |
| 63 |
} |
| 64 |
if let Some(n) = c.name("n") { |
| 65 |
let num: i64 = n.as_str().parse().unwrap_or(0); |
| 66 |
return match numbers.url_for(num) { |
| 67 |
Some((url, kind)) => format!("{pre}[{kind} #{num}]({url})"), |
| 68 |
None => c[0].to_string(), |
| 69 |
}; |
| 70 |
} |
| 71 |
if let Some(u) = c.name("user") { |
| 72 |
let name = u.as_str().trim_end_matches('.'); |
| 73 |
return format!("{pre}[@{name}](/{name})"); |
| 74 |
} |
| 75 |
if let Some(h) = c.name("hash") { |
| 76 |
let hex = h.as_str(); |
| 77 |
return format!("{pre}[{}]({}/change/{hex})", &hex[..12.min(hex.len())], numbers.repo_url); |
| 78 |
} |
| 79 |
c[0].to_string() |
| 80 |
}) |
| 81 |
.into_owned() |
| 82 |
} |
| 83 |
|
| 84 |
|
| 85 |
|
| 86 |
|
| 87 |
|
| 88 |
pub fn references(message: &str) -> Vec<i64> { |
| 89 |
let re = regex::Regex::new(r"(?:^|[^\w/\[&])#(\d+)\b").unwrap(); |
| 90 |
let closing = closes(message); |
| 91 |
let mut out: Vec<i64> = re.captures_iter(message).filter_map(|c| c[1].parse().ok()).filter(|n| !closing.contains(n)).collect(); |
| 92 |
out.sort(); |
| 93 |
out.dedup(); |
| 94 |
out |
| 95 |
} |
| 96 |
|
| 97 |
pub fn closes(message: &str) -> Vec<i64> { |
| 98 |
let re = regex::Regex::new(r"(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)").unwrap(); |
| 99 |
let mut out: Vec<i64> = re.captures_iter(message).filter_map(|c| c[1].parse().ok()).collect(); |
| 100 |
out.sort(); |
| 101 |
out.dedup(); |
| 102 |
out |
| 103 |
} |
| 104 |
|
| 105 |
#[cfg(test)] |
| 106 |
mod tests { |
| 107 |
use super::*; |
| 108 |
|
| 109 |
#[test] |
| 110 |
fn finds_references_but_not_closings_or_other_repositories() { |
| 111 |
assert_eq!(references("Faster blame (refs #14, #3)\n\nSee #3 again; closes #9; other/repo#5; a#2"), vec![3, 14]); |
| 112 |
assert!(references("Nothing to see; colour #").is_empty()); |
| 113 |
assert_eq!(references("#7 at the start"), vec![7]); |
| 114 |
} |
| 115 |
|
| 116 |
#[test] |
| 117 |
fn links_references_outside_code() { |
| 118 |
let n = Numbers { repo_url: "/a/b", issues: &[3], proposals: &[7] }; |
| 119 |
let out = link("see #3 and #7 and #9, `#3` too, by @andy; a/b#3 and other/repo#5; hash deadbeefcafe1234\n```\n#3 @x\n```\n", &n); |
| 120 |
assert!(out.contains("[issue #3](/a/b/issue/3)"), "{out}"); |
| 121 |
assert!(out.contains("[proposal #7](/a/b/proposal/7)"), "{out}"); |
| 122 |
assert!(out.contains(" #9,"), "unknown numbers stay: {out}"); |
| 123 |
assert!(out.contains("`#3`"), "code spans untouched: {out}"); |
| 124 |
assert!(out.contains("[@andy](/andy)"), "{out}"); |
| 125 |
assert!(out.contains("[other/repo#5](/other/repo/issue/5)"), "{out}"); |
| 126 |
assert!(out.contains("[deadbeefcafe](/a/b/change/deadbeefcafe1234)"), "{out}"); |
| 127 |
assert!(out.contains("```\n#3 @x\n```"), "fences untouched: {out}"); |
| 128 |
} |
| 129 |
|
| 130 |
#[test] |
| 131 |
fn closing_keywords() { |
| 132 |
assert_eq!(closes("Fixes #12 and closes #3, resolved #12"), vec![3, 12]); |
| 133 |
assert!(closes("mentions #4 only").is_empty()); |
| 134 |
} |
| 135 |
} |