OXYGENHUB

Coverage / crates/hub/src/render/refs.rs 98.2%

112 of 114 lines run, 24 of 24 functions · run #500 on main · run missed

1
//! Cross references in Markdown (hub spec §8): `#12`, `owner/name#12`,
2
//! change hashes and `@user` become links before rendering. Text inside
3
//! code spans and fences is left alone.
4
5
/// Where a repository's numbers live: issues and proposals.
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
    /// Where `#n` points, and what it is: issues and proposals share one
14
    /// number space, so the link says which.
1531×
    fn url_for(&self, n: i64) -> Option<(String, &'static str)> {
1631×
        if self.proposals.contains(&n) {
173×
            Some((format!("{}/proposal/{n}", self.repo_url), "proposal"))
1828×
        } else if self.issues.contains(&n) {
1911×
            Some((format!("{}/issue/{n}", self.repo_url), "issue"))
20
        } else {
2117×
            None
22
        }
2331×
    }
24
}
25
26
/// Link references in `text`. Cross-repository references link to the
27
/// other repository's issue page, which resolves to a proposal itself.
28141×
pub fn link(text: &str, numbers: &Numbers<'_>) -> String {
29141×
    let mut out = String::with_capacity(text.len() + 64);
30141×
    let mut in_fence = false;
31141×
    for line in text.split_inclusive('\n') {
3290×
        if line.trim_start().starts_with("```") {
332×
            in_fence = !in_fence;
342×
            out.push_str(line);
352×
            continue;
3688×
        }
3788×
        if in_fence {
381×
            out.push_str(line);
391×
            continue;
4087×
        }
41
        // Split on inline code spans; only even segments are prose.
4289×
        for (i, seg) in line.split('`').enumerate() {
4389×
            if i > 0 {
442×
                out.push('`');
4587×
            }
4689×
            if i % 2 == 0 {
4788×
                out.push_str(&link_prose(seg, numbers));
4888×
            } else {
491×
                out.push_str(seg);
501×
            }
51
        }
52
    }
53141×
    out
54141×
}
55
5688×
fn link_prose(seg: &str, numbers: &Numbers<'_>) -> String {
5788×
    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();
5888×
    re.replace_all(seg, |c: &regex::Captures| {
5968×
        let pre = &c["pre"];
6068×
        if let Some(slug) = c.name("slug") {
612×
            let n = &c["xn"];
622×
            return format!("{pre}[{}#{n}](/{}/issue/{n})", slug.as_str(), slug.as_str());
6366×
        }
6466×
        if let Some(n) = c.name("n") {
6531×
            let num: i64 = n.as_str().parse().unwrap_or(0);
6631×
            return match numbers.url_for(num) {
6714×
                Some((url, kind)) => format!("{pre}[{kind} #{num}]({url})"),
6817×
                None => c[0].to_string(),
69
            };
7035×
        }
7135×
        if let Some(u) = c.name("user") {
7217×
            let name = u.as_str().trim_end_matches('.');
7317×
            return format!("{pre}[@{name}](/{name})");
7418×
        }
7518×
        if let Some(h) = c.name("hash") {
7618×
            let hex = h.as_str();
7718×
            return format!("{pre}[{}]({}/change/{hex})", &hex[..12.min(hex.len())], numbers.repo_url);
780×
        }
790×
        c[0].to_string()
8068×
    })
8188×
    .into_owned()
8288×
}
83
84
/// Issue numbers a change message closes: `closes #12`, `fixes #3`,
85
/// `resolved #7`.
86
/// Every bare `#N` a change message names, except those it closes
87
/// (`closes #N` records its own event) and other repositories' `a/b#N`.
88240×
pub fn references(message: &str) -> Vec<i64> {
89240×
    let re = regex::Regex::new(r"(?:^|[^\w/\[&])#(\d+)\b").unwrap();
90240×
    let closing = closes(message);
91240×
    let mut out: Vec<i64> = re.captures_iter(message).filter_map(|c| c[1].parse().ok()).filter(|n| !closing.contains(n)).collect();
92240×
    out.sort();
93240×
    out.dedup();
94240×
    out
95240×
}
96
97695×
pub fn closes(message: &str) -> Vec<i64> {
98
    // A keyword, then a list of numbers (#226): "Closes #1, #2 and #3",
99
    // "Fixes: #4 #5", "resolves #6 & #7". Another repository's a/b#N in
100
    // the list is not this repository's to close.
101695×
    let re = regex::Regex::new(
102695×
        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
    )
104695×
    .unwrap();
105695×
    let item = regex::Regex::new(r"(?:^|[^\w/])#(\d+)").unwrap();
106695×
    let mut out: Vec<i64> =
107695×
        re.captures_iter(message).flat_map(|c| item.captures_iter(&c[1]).filter_map(|i| i[1].parse().ok()).collect::<Vec<i64>>()).collect();
108695×
    out.sort();
109695×
    out.dedup();
110695×
    out
111695×
}
112
113
#[cfg(test)]
114
mod tests {
115
    use super::*;
116
117
    #[test]
1181×
    fn a_closing_keyword_closes_every_number_it_lists() {
119
        // #226: commit 7a31f384's message closed only its first issue.
1201×
        assert_eq!(closes("Polish\n\nCloses #187, #188, #189, #190, #191, #192, #193, #194, #195, #196"), (187..=196).collect::<Vec<_>>());
1211×
        assert_eq!(closes("Closes #1, #2 and #3"), vec![1, 2, 3]);
1221×
        assert_eq!(closes("Closes #1, and #2"), vec![1, 2]);
1231×
        assert_eq!(closes("Fixes: #4 #5"), vec![4, 5]);
1241×
        assert_eq!(closes("resolves #6 & #7"), vec![6, 7]);
1251×
        assert_eq!(closes("Closes #1, closes #2\nFixed #8."), vec![1, 2, 8]);
1261×
        assert_eq!(closes("Closes #9, other/repo#10, #11"), vec![9, 11], "another repository's number is not ours");
1271×
        assert_eq!(closes("Refs #12, closes #13. See #14."), vec![13], "a list ends where the prose starts");
1281×
        assert!(closes("closest #1; disclosed #2").is_empty());
1291×
        assert_eq!(references("Closes #1, #2 and #3; refs #4"), vec![4], "listed closings are not plain references");
1301×
    }
131
132
    #[test]
1331×
    fn finds_references_but_not_closings_or_other_repositories() {
1341×
        assert_eq!(references("Faster blame (refs #14, #3)\n\nSee #3 again; closes #9; other/repo#5; a#2"), vec![3, 14]);
1351×
        assert!(references("Nothing to see; colour &#35;").is_empty());
1361×
        assert_eq!(references("#7 at the start"), vec![7]);
1371×
    }
138
139
    #[test]
1401×
    fn links_references_outside_code() {
1411×
        let n = Numbers { repo_url: "/a/b", issues: &[3], proposals: &[7] };
1421×
        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);
1431×
        assert!(out.contains("[issue #3](/a/b/issue/3)"), "{out}");
1441×
        assert!(out.contains("[proposal #7](/a/b/proposal/7)"), "{out}");
1451×
        assert!(out.contains(" #9,"), "unknown numbers stay: {out}");
1461×
        assert!(out.contains("`#3`"), "code spans untouched: {out}");
1471×
        assert!(out.contains("[@andy](/andy)"), "{out}");
1481×
        assert!(out.contains("[other/repo#5](/other/repo/issue/5)"), "{out}");
1491×
        assert!(out.contains("[deadbeefcafe](/a/b/change/deadbeefcafe1234)"), "{out}");
1501×
        assert!(out.contains("```\n#3 @x\n```"), "fences untouched: {out}");
1511×
    }
152
153
    #[test]
1541×
    fn closing_keywords() {
1551×
        assert_eq!(closes("Fixes #12 and closes #3, resolved #12"), vec![3, 12]);
1561×
        assert!(closes("mentions #4 only").is_empty());
1571×
    }
158
}