| 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 | ||
| 22 | use std::fmt::Write as _; | |
| 23 | ||
| 24 | /// Where references resolve to. | |
| 25 | pub 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. | |
| 35 | const MIN_CHANGE_PREFIX: usize = 8; | |
| 36 | const MAX_CHANGE_PREFIX: usize = 32; | |
| 37 | ||
| 38 | /// Rewrite `#123`, `@handle`, and change-id prefixes into links. | |
| 39 | pub 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. | |
| 86 | fn 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. | |
| 100 | fn 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`. | |
| 163 | fn is_change_char(c: u8) -> bool { | |
| 164 | (b'k'..=b'z').contains(&c) | |
| 165 | } | |
| 166 | ||
| 167 | fn 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. | |
| 175 | fn 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 | ||
| 179 | fn 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 | ||
| 187 | fn 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. | |
| 202 | fn 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 | ||
| 211 | fn escape_attr(s: &str) -> String { | |
| 212 | s.chars() | |
| 213 | .map(|c| match c { | |
| 214 | '"' => """.to_string(), | |
| 215 | '&' => "&".to_string(), | |
| 216 | '<' => "<".to_string(), | |
| 217 | '>' => ">".to_string(), | |
| 218 | '\'' => "'".to_string(), | |
| 219 | c => c.to_string(), | |
| 220 | }) | |
| 221 | .collect() | |
| 222 | } | |
| 223 | ||
| 224 | fn escape_text(s: &str) -> String { | |
| 225 | escape_attr(s) | |
| 226 | } | |
| 227 | ||
| 228 | #[cfg(test)] | |
| 229 | mod 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