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