//! Cross references in Markdown (hub spec ยง8): `#12`, `owner/name#12`, //! change hashes and `@user` become links before rendering. Text inside //! code spans and fences is left alone. /// Where a repository's numbers live: issues and proposals. pub struct Numbers<'a> { pub repo_url: &'a str, pub issues: &'a [i64], pub proposals: &'a [i64], } impl Numbers<'_> { /// Where `#n` points, and what it is: issues and proposals share one /// number space, so the link says which. fn url_for(&self, n: i64) -> Option<(String, &'static str)> { if self.proposals.contains(&n) { Some((format!("{}/proposal/{n}", self.repo_url), "proposal")) } else if self.issues.contains(&n) { Some((format!("{}/issue/{n}", self.repo_url), "issue")) } else { None } } } /// Link references in `text`. Cross-repository references link to the /// other repository's issue page, which resolves to a proposal itself. pub fn link(text: &str, numbers: &Numbers<'_>) -> String { let mut out = String::with_capacity(text.len() + 64); let mut in_fence = false; for line in text.split_inclusive('\n') { if line.trim_start().starts_with("```") { in_fence = !in_fence; out.push_str(line); continue; } if in_fence { out.push_str(line); continue; } // Split on inline code spans; only even segments are prose. for (i, seg) in line.split('`').enumerate() { if i > 0 { out.push('`'); } if i % 2 == 0 { out.push_str(&link_prose(seg, numbers)); } else { out.push_str(seg); } } } out } fn link_prose(seg: &str, numbers: &Numbers<'_>) -> String { let re = regex::Regex::new(r"(?P
^|[^\w/\[])(?:(?P[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*)#(?P \d+)|#(?P \d+)|@(?P [A-Za-z0-9][A-Za-z0-9._-]{0,63})|(?P \b[0-9a-f]{12,64}\b))").unwrap(); re.replace_all(seg, |c: ®ex::Captures| { let pre = &c["pre"]; if let Some(slug) = c.name("slug") { let n = &c["xn"]; return format!("{pre}[{}#{n}](/{}/issue/{n})", slug.as_str(), slug.as_str()); } if let Some(n) = c.name("n") { let num: i64 = n.as_str().parse().unwrap_or(0); return match numbers.url_for(num) { Some((url, kind)) => format!("{pre}[{kind} #{num}]({url})"), None => c[0].to_string(), }; } if let Some(u) = c.name("user") { let name = u.as_str().trim_end_matches('.'); return format!("{pre}[@{name}](/{name})"); } if let Some(h) = c.name("hash") { let hex = h.as_str(); return format!("{pre}[{}]({}/change/{hex})", &hex[..12.min(hex.len())], numbers.repo_url); } c[0].to_string() }) .into_owned() } /// Issue numbers a change message closes: `closes #12`, `fixes #3`, /// `resolved #7`. /// Every bare `#N` a change message names, except those it closes /// (`closes #N` records its own event) and other repositories' `a/b#N`. pub fn references(message: &str) -> Vec { let re = regex::Regex::new(r"(?:^|[^\w/\[&])#(\d+)\b").unwrap(); let closing = closes(message); let mut out: Vec = re.captures_iter(message).filter_map(|c| c[1].parse().ok()).filter(|n| !closing.contains(n)).collect(); out.sort(); out.dedup(); out } pub fn closes(message: &str) -> Vec { // A keyword, then a list of numbers (#226): "Closes #1, #2 and #3", // "Fixes: #4 #5", "resolves #6 & #7". Another repository's a/b#N in // the list is not this repository's to close. let re = regex::Regex::new( 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+)*)", ) .unwrap(); let item = regex::Regex::new(r"(?:^|[^\w/])#(\d+)").unwrap(); let mut out: Vec = re.captures_iter(message).flat_map(|c| item.captures_iter(&c[1]).filter_map(|i| i[1].parse().ok()).collect:: >()).collect(); out.sort(); out.dedup(); out } #[cfg(test)] mod tests { use super::*; #[test] fn a_closing_keyword_closes_every_number_it_lists() { // #226: commit 7a31f384's message closed only its first issue. assert_eq!(closes("Polish\n\nCloses #187, #188, #189, #190, #191, #192, #193, #194, #195, #196"), (187..=196).collect:: >()); assert_eq!(closes("Closes #1, #2 and #3"), vec![1, 2, 3]); assert_eq!(closes("Closes #1, and #2"), vec![1, 2]); assert_eq!(closes("Fixes: #4 #5"), vec![4, 5]); assert_eq!(closes("resolves #6 & #7"), vec![6, 7]); assert_eq!(closes("Closes #1, closes #2\nFixed #8."), vec![1, 2, 8]); assert_eq!(closes("Closes #9, other/repo#10, #11"), vec![9, 11], "another repository's number is not ours"); assert_eq!(closes("Refs #12, closes #13. See #14."), vec![13], "a list ends where the prose starts"); assert!(closes("closest #1; disclosed #2").is_empty()); assert_eq!(references("Closes #1, #2 and #3; refs #4"), vec![4], "listed closings are not plain references"); } #[test] fn finds_references_but_not_closings_or_other_repositories() { assert_eq!(references("Faster blame (refs #14, #3)\n\nSee #3 again; closes #9; other/repo#5; a#2"), vec![3, 14]); assert!(references("Nothing to see; colour #").is_empty()); assert_eq!(references("#7 at the start"), vec![7]); } #[test] fn links_references_outside_code() { let n = Numbers { repo_url: "/a/b", issues: &[3], proposals: &[7] }; 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); assert!(out.contains("[issue #3](/a/b/issue/3)"), "{out}"); assert!(out.contains("[proposal #7](/a/b/proposal/7)"), "{out}"); assert!(out.contains(" #9,"), "unknown numbers stay: {out}"); assert!(out.contains("`#3`"), "code spans untouched: {out}"); assert!(out.contains("[@andy](/andy)"), "{out}"); assert!(out.contains("[other/repo#5](/other/repo/issue/5)"), "{out}"); assert!(out.contains("[deadbeefcafe](/a/b/change/deadbeefcafe1234)"), "{out}"); assert!(out.contains("```\n#3 @x\n```"), "fences untouched: {out}"); } #[test] fn closing_keywords() { assert_eq!(closes("Fixes #12 and closes #3, resolved #12"), vec![3, 12]); assert!(closes("mentions #4 only").is_empty()); } }