OXYGENHUB
# 3f02e5ea4f2d oxygenhub / crates / hub / src / render / refs.rs
4.2 KiB · Rust · last changed Milestone 0.4: issues and notifications 1 month ago source blame history raw
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
    fn url_for(&self, n: i64) -> Option<String> {
14
        if self.proposals.contains(&n) {
15
            Some(format!("{}/proposal/{n}", self.repo_url))
16
        } else if self.issues.contains(&n) {
17
            Some(format!("{}/issue/{n}", self.repo_url))
18
        } else {
19
            None
20
        }
21
    }
22
}
23
24
/// Link references in `text`. Cross-repository references link to the
25
/// other repository's issue page, which resolves to a proposal itself.
26
pub fn link(text: &str, numbers: &Numbers<'_>) -> String {
27
    let mut out = String::with_capacity(text.len() + 64);
28
    let mut in_fence = false;
29
    for line in text.split_inclusive('\n') {
30
        if line.trim_start().starts_with("```") {
31
            in_fence = !in_fence;
32
            out.push_str(line);
33
            continue;
34
        }
35
        if in_fence {
36
            out.push_str(line);
37
            continue;
38
        }
39
        // Split on inline code spans; only even segments are prose.
40
        for (i, seg) in line.split('`').enumerate() {
41
            if i > 0 {
42
                out.push('`');
43
            }
44
            if i % 2 == 0 {
45
                out.push_str(&link_prose(seg, numbers));
46
            } else {
47
                out.push_str(seg);
48
            }
49
        }
50
    }
51
    out
52
}
53
54
fn link_prose(seg: &str, numbers: &Numbers<'_>) -> String {
55
    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();
56
    re.replace_all(seg, |c: &regex::Captures| {
57
        let pre = &c["pre"];
58
        if let Some(slug) = c.name("slug") {
59
            let n = &c["xn"];
60
            return format!("{pre}[{}#{n}](/{}/issue/{n})", slug.as_str(), slug.as_str());
61
        }
62
        if let Some(n) = c.name("n") {
63
            let num: i64 = n.as_str().parse().unwrap_or(0);
64
            return match numbers.url_for(num) {
65
                Some(url) => format!("{pre}[#{num}]({url})"),
66
                None => c[0].to_string(),
67
            };
68
        }
69
        if let Some(u) = c.name("user") {
70
            let name = u.as_str().trim_end_matches('.');
71
            return format!("{pre}[@{name}](/{name})");
72
        }
73
        if let Some(h) = c.name("hash") {
74
            let hex = h.as_str();
75
            return format!("{pre}[{}]({}/change/{hex})", &hex[..12.min(hex.len())], numbers.repo_url);
76
        }
77
        c[0].to_string()
78
    })
79
    .into_owned()
80
}
81
82
/// Issue numbers a change message closes: `closes #12`, `fixes #3`,
83
/// `resolved #7`.
84
pub fn closes(message: &str) -> Vec<i64> {
85
    let re = regex::Regex::new(r"(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)").unwrap();
86
    let mut out: Vec<i64> = re.captures_iter(message).filter_map(|c| c[1].parse().ok()).collect();
87
    out.sort();
88
    out.dedup();
89
    out
90
}
91
92
#[cfg(test)]
93
mod tests {
94
    use super::*;
95
96
    #[test]
97
    fn links_references_outside_code() {
98
        let n = Numbers { repo_url: "/a/b", issues: &[3], proposals: &[7] };
99
        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);
100
        assert!(out.contains("[#3](/a/b/issue/3)"), "{out}");
101
        assert!(out.contains("[#7](/a/b/proposal/7)"), "{out}");
102
        assert!(out.contains(" #9,"), "unknown numbers stay: {out}");
103
        assert!(out.contains("`#3`"), "code spans untouched: {out}");
104
        assert!(out.contains("[@andy](/andy)"), "{out}");
105
        assert!(out.contains("[other/repo#5](/other/repo/issue/5)"), "{out}");
106
        assert!(out.contains("[deadbeefcafe](/a/b/change/deadbeefcafe1234)"), "{out}");
107
        assert!(out.contains("```\n#3 @x\n```"), "fences untouched: {out}");
108
    }
109
110
    #[test]
111
    fn closing_keywords() {
112
        assert_eq!(closes("Fixes #12 and closes #3, resolved #12"), vec![3, 12]);
113
        assert!(closes("mentions #4 only").is_empty());
114
    }
115
}