Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Cross-references (spec §8).
2//!
3//! > Autolink `#123` to issues, `@handle` to users, and bare change-ID prefixes
4//! > to changes.
5//!
6//! This runs **after** rendering and sanitising, on the finished HTML. That
7//! ordering is deliberate:
8//!
9//! * Running before markdown would let a reference inside a fenced code block
10//! turn into a link, which is exactly where people write `#123` meaning a
11//! literal.
12//! * Running after the sanitiser means the sanitiser never sees these links and
13//! cannot be the thing that decides they are safe — so every URL emitted here
14//! is built by [`href`] from a percent-encoded, validated component and never
15//! from user text.
16//!
17//! The scanner is a small state machine over the HTML rather than a regex:
18//! text inside `<code>`, `<pre>` and existing `<a>` elements is skipped, and
19//! nothing inside a tag is ever rewritten. `references_inside_code_are_left_alone`
20//! and `an_existing_link_is_not_relinked` are what pin that.
21
22use std::fmt::Write as _;
23
24/// Where references resolve to.
25pub struct LinkContext<'a> {
26 /// `/{owner}/{repo}` — the prefix issue, change and stack links hang off.
27 pub repo_base: &'a str,
28}
29
30/// The shortest change-id prefix that is worth linking.
31///
32/// jj's own display form is 8 characters and users routinely paste that. Below
33/// that, ordinary lowercase words in the reverse-hex alphabet — and there are
34/// many, since it is `k`–`z` — would start becoming links.
35const MIN_CHANGE_PREFIX: usize = 8;
36const MAX_CHANGE_PREFIX: usize = 32;
37
38/// Rewrite `#123`, `@handle`, and change-id prefixes into links.
39pub fn autolink(html: &str, ctx: &LinkContext<'_>) -> String {
40 let bytes = html.as_bytes();
41 let mut out = String::with_capacity(html.len() + 64);
42
43 // Nesting depth inside elements whose text must be left alone.
44 let mut literal_depth = 0usize;
45 let mut i = 0usize;
46
47 while i < bytes.len() {
48 if bytes[i] == b'<' {
49 let Some(end) = html[i..].find('>').map(|e| i + e + 1) else {
50 // An unterminated `<` cannot be a tag. Copy the rest verbatim
51 // rather than guessing at where it ends.
52 out.push_str(&html[i..]);
53 break;
54 };
55 let tag = &html[i..end];
56 if let Some(name) = tag_name(tag) {
57 if matches!(name.as_str(), "code" | "pre" | "a") {
58 if tag.starts_with("</") {
59 literal_depth = literal_depth.saturating_sub(1);
60 } else if !tag.ends_with("/>") {
61 literal_depth += 1;
62 }
63 }
64 }
65 out.push_str(tag);
66 i = end;
67 continue;
68 }
69
70 // Text run up to the next tag.
71 let text_end = html[i..].find('<').map(|e| i + e).unwrap_or(html.len());
72 let text = &html[i..text_end];
73
74 if literal_depth > 0 {
75 out.push_str(text);
76 } else {
77 rewrite_text(text, ctx, &mut out);
78 }
79 i = text_end;
80 }
81
82 out
83}
84
85/// The lowercased element name of a tag, if it is one.
86fn tag_name(tag: &str) -> Option<String> {
87 let inner = tag
88 .trim_start_matches('<')
89 .trim_start_matches('/')
90 .trim_end_matches('>')
91 .trim_end_matches('/');
92 let name: String = inner
93 .chars()
94 .take_while(|c| c.is_ascii_alphanumeric())
95 .collect();
96 (!name.is_empty()).then(|| name.to_ascii_lowercase())
97}
98
99/// Rewrite references in one run of plain text.
100fn rewrite_text(text: &str, ctx: &LinkContext<'_>, out: &mut String) {
101 let b = text.as_bytes();
102 let mut i = 0usize;
103
104 while i < b.len() {
105 match b[i] {
106 b'#' if boundary_before(b, i) => {
107 let digits = run(b, i + 1, |c| c.is_ascii_digit());
108 // A very long run of digits is not an issue number; it is
109 // somebody's hash or a colour code.
110 if digits > 0 && digits <= 9 {
111 let number = &text[i + 1..i + 1 + digits];
112 href(
113 out,
114 &format!("{}/issues/{number}", ctx.repo_base),
115 &format!("#{number}"),
116 );
117 i += 1 + digits;
118 continue;
119 }
120 }
121 b'@' if boundary_before(b, i) => {
122 let len = run(b, i + 1, |c| c.is_ascii_alphanumeric() || c == b'-');
123 let handle = &text[i + 1..i + 1 + len];
124 // Mirrors the `handle_format` constraint on `users`.
125 if (1..=39).contains(&len)
126 && handle.starts_with(|c: char| c.is_ascii_alphanumeric())
127 && !handle.ends_with('-')
128 {
129 href(out, &format!("/{handle}"), &format!("@{handle}"));
130 i += 1 + len;
131 continue;
132 }
133 }
134 c if is_change_char(c) && boundary_before(b, i) => {
135 let len = run(b, i, is_change_char);
136 // Must be the whole word: `klxq` inside `klxqing` is a word, not
137 // a change id.
138 let whole_word = i + len >= b.len() || !is_word_char(b[i + len]);
139 if whole_word && (MIN_CHANGE_PREFIX..=MAX_CHANGE_PREFIX).contains(&len) {
140 let id = &text[i..i + len];
141 href(out, &format!("{}/changes/{id}", ctx.repo_base), id);
142 i += len;
143 continue;
144 }
145 // Not a reference: copy the whole word so its remaining
146 // characters are not re-examined and half-linked.
147 out.push_str(&text[i..i + len]);
148 i += len;
149 continue;
150 }
151 _ => {}
152 }
153
154 // Not the start of a reference. Copy one character — by char, not byte,
155 // so multi-byte text is not split.
156 let ch_len = utf8_len(b[i]);
157 out.push_str(&text[i..(i + ch_len).min(text.len())]);
158 i += ch_len;
159 }
160}
161
162/// jj's change-id alphabet is the reverse-hex letters `k`–`z`.
163fn is_change_char(c: u8) -> bool {
164 (b'k'..=b'z').contains(&c)
165}
166
167fn is_word_char(c: u8) -> bool {
168 c.is_ascii_alphanumeric() || c == b'_' || c == b'-'
169}
170
171/// Whether position `i` starts a new word.
172///
173/// Without this, the `#` in `abc#123` and the `@` in an email address both
174/// become links.
175fn boundary_before(b: &[u8], i: usize) -> bool {
176 i == 0 || !is_word_char(b[i - 1]) && b[i - 1] != b'#' && b[i - 1] != b'@' && b[i - 1] != b'.'
177}
178
179fn run(b: &[u8], from: usize, pred: impl Fn(u8) -> bool) -> usize {
180 let mut n = 0;
181 while from + n < b.len() && pred(b[from + n]) {
182 n += 1;
183 }
184 n
185}
186
187fn utf8_len(first: u8) -> usize {
188 match first {
189 0x00..=0x7f => 1,
190 0xc0..=0xdf => 2,
191 0xe0..=0xef => 3,
192 _ => 4,
193 }
194}
195
196/// Emit a link.
197///
198/// The href is percent-encoded and the label is HTML-escaped. Both inputs here
199/// come from character classes this module validated — digits, the handle
200/// alphabet, the change-id alphabet — so neither can contain a quote or an
201/// angle bracket. The encoding is belt and braces, and cheap.
202fn href(out: &mut String, url: &str, label: &str) {
203 let _ = write!(
204 out,
205 r#"<a href="{}">{}</a>"#,
206 escape_attr(url),
207 escape_text(label)
208 );
209}
210
211fn escape_attr(s: &str) -> String {
212 s.chars()
213 .map(|c| match c {
214 '"' => "&quot;".to_string(),
215 '&' => "&amp;".to_string(),
216 '<' => "&lt;".to_string(),
217 '>' => "&gt;".to_string(),
218 '\'' => "&#39;".to_string(),
219 c => c.to_string(),
220 })
221 .collect()
222}
223
224fn escape_text(s: &str) -> String {
225 escape_attr(s)
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn link(html: &str) -> String {
233 autolink(html, &LinkContext { repo_base: "/alice/dogfood" })
234 }
235
236 #[test]
237 fn issue_numbers_become_links() {
238 assert_eq!(
239 link("<p>see #123 for details</p>"),
240 r#"<p>see <a href="/alice/dogfood/issues/123">#123</a> for details</p>"#
241 );
242 }
243
244 #[test]
245 fn handles_become_profile_links() {
246 assert_eq!(
247 link("<p>ask @mira-h about it</p>"),
248 r#"<p>ask <a href="/mira-h">@mira-h</a> about it</p>"#
249 );
250 }
251
252 #[test]
253 fn change_id_prefixes_become_change_links() {
254 assert_eq!(
255 link("<p>fixed by klxqnvpq now</p>"),
256 r#"<p>fixed by <a href="/alice/dogfood/changes/klxqnvpq">klxqnvpq</a> now</p>"#
257 );
258 }
259
260 /// The reason this runs after markdown rather than before: a `#123` inside
261 /// a code fence is a literal, and turning it into a link is wrong.
262 #[test]
263 fn references_inside_code_are_left_alone() {
264 let input = "<pre><code>grep #123 @mira klxqnvpq</code></pre>";
265 assert_eq!(link(input), input);
266 let inline = "<p>use <code>#123</code> here</p>";
267 assert_eq!(link(inline), inline);
268 }
269
270 #[test]
271 fn an_existing_link_is_not_relinked() {
272 let input = r#"<p><a href="https://example.com">#123</a></p>"#;
273 assert_eq!(link(input), input);
274 }
275
276 #[test]
277 fn attributes_are_never_rewritten() {
278 // A `#` in a fragment or an `@` in a title must survive untouched.
279 let input = r#"<p title="ask @mira"><img src="/a.png#123"></p>"#;
280 assert_eq!(link(input), input);
281 }
282
283 #[test]
284 fn mid_word_references_are_not_links() {
285 for input in [
286 "<p>abc#123</p>",
287 "<p>mail@example</p>",
288 "<p>v1.2#3</p>",
289 ] {
290 assert_eq!(link(input), input, "{input}");
291 }
292 }
293
294 #[test]
295 fn ordinary_words_are_not_change_ids() {
296 // Short words, and words outside the k–z alphabet, must survive.
297 for input in [
298 "<p>you must not link this</p>",
299 "<p>tests</p>",
300 "<p>the quick brown fox</p>",
301 ] {
302 assert_eq!(link(input), input, "{input}");
303 }
304 }
305
306 /// A word that *is* in the alphabet but is a real word would be a false
307 /// positive. Requiring the full 8 characters plus a word boundary is what
308 /// keeps those rare.
309 #[test]
310 fn a_long_alphabet_word_inside_a_larger_word_is_not_linked() {
311 assert_eq!(link("<p>ttvvwwxxing</p>"), "<p>ttvvwwxxing</p>");
312 }
313
314 #[test]
315 fn a_thirty_two_character_change_id_links_in_full() {
316 let id = "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv";
317 let out = link(&format!("<p>{id}</p>"));
318 assert!(out.contains(&format!("/changes/{id}")), "{out}");
319 }
320
321 #[test]
322 fn absurd_numbers_are_not_issue_links() {
323 let input = "<p>#12345678901234</p>";
324 assert_eq!(link(input), input);
325 }
326
327 #[test]
328 fn multibyte_text_survives_the_scan() {
329 let input = "<p>héllo wörld — ünïcode</p>";
330 assert_eq!(link(input), input);
331 // …and a reference next to multi-byte text still resolves.
332 let out = link("<p>héllo #7</p>");
333 assert!(out.contains(r#"<a href="/alice/dogfood/issues/7">#7</a>"#), "{out}");
334 }
335
336 #[test]
337 fn unterminated_markup_does_not_lose_content() {
338 let input = "<p>trailing <notatag";
339 assert_eq!(link(input), input);
340 }
341
342 #[test]
343 fn self_closing_tags_do_not_unbalance_the_literal_depth() {
344 // A stray `<br/>` inside a paragraph must not leave the scanner
345 // thinking it is inside a code block for the rest of the document.
346 let out = link("<p>a<br/>b #5</p>");
347 assert!(out.contains("issues/5"), "{out}");
348 }
349}

349 lines · Rust