//! Images and links in a reply (M5b spec, section 9). Mattermost fetches a post's images when it //! is posted (the server) and when it is shown (each client: the owner's server has no image //! proxy), and the server previews the first plain URL. Either fetch can carry data out in its //! URL, with no click. A code span stops both. So `disarm` puts every image in a code span, //! always, and in a reply from a session that has read untrusted content, every link and URL too. //! //! Two parsers read a post: the server's, which follows CommonMark, and the clients' fork of //! marked (`mattermost/marked`), which has rules of its own. Text is left as it is only where both //! read it as code. Where they may differ, it is read as prose, and an image or URL in it goes in //! a code span: the reply may show a few backticks, but nothing in it is fetched. The rules are //! in the functions below, each with the case it covers. //! //! Not covered, both in marked alone and both only a link, which needs a click: a link label may //! pair backtick runs of different lengths across new code spans, and an autolink may run across //! the lines of a quote or list item. mod inline; /// The line that ends a reply whose links were put in code spans. pub const NOTICE: &str = "Links in this reply are shown as code because this conversation has read untrusted content."; /// The characters that end a line: marked reads `\r` and U+2424 as line feeds, and `\r\n` is one. const BREAKS: [char; 3] = ['\n', '\r', '\u{2424}']; /// A reply after `disarm`: its text, and whether a link or URL was put in a code span. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Disarmed { pub text: String, pub links: bool, } /// The text being written, and the length of the backtick runs its new code spans use: longer /// than any run in the reply, so a new span pairs with nothing the reply already holds. struct Out { text: String, ticks: usize, links: bool, } /// `text` with every image in a code span, and when `untrusted`, every link and URL as well. /// Code the reply already holds is left as it is, and so is everything else. pub fn disarm(text: &str, untrusted: bool) -> Disarmed { let longest = runs(text).into_iter().map(|(_, len)| len).max(); let mut out = Out { text: String::with_capacity(text.len()), ticks: longest.unwrap_or(0) + 1, links: false, }; let lines = lines(text); // `html`: a line so far may start an HTML block, which can swallow a fence (marked). // `nested`: the lines since the last empty one hold a list item or a quote, which a fence // may continue; marked may then end the item inside the fence (at an empty line, a rule, a // definition or the next item), and CommonMark may end it at the fence. let (mut html, mut nested) = (false, false); let (mut prose, mut at) = (0, 0); while let Some(line) = lines.get(at) { let closer = if html || nested { None } else { fence_end(&lines, at) }; if let Some(end) = closer { out.prose(lines.get(prose..at).unwrap_or_default(), untrusted); for line in lines.get(at..=end).unwrap_or_default() { out.text.push_str(line); } (at, prose, nested) = (end + 1, end + 1, false); } else { html = html || html_start(line); nested = !content(line).is_empty() && (nested || nested_start(line)); at += 1; } } out.prose(lines.get(prose..).unwrap_or_default(), untrusted); Disarmed { text: out.text, links: out.links, } } impl Out { /// Lines outside fenced blocks, one paragraph at a time: an empty line ends one. A line of /// spaces does not, since marked reads it as part of the paragraph. fn prose(&mut self, lines: &[&str], untrusted: bool) { let mut paragraph = String::new(); for line in lines { if content(line).is_empty() { self.paragraph(¶graph, untrusted); paragraph.clear(); self.text.push_str(line); } else { paragraph.push_str(line); } } self.paragraph(¶graph, untrusted); } /// Write `construct` as code spans (`code`), one per piece between split characters, each /// split character written after its piece: a line end (`BREAKS`) or `|` (a table cell ends /// there, even in a code span) as it is; a backtick or one of `closers` after a backslash, /// except `$` (marked does not escape it), so that nothing before can pair with it. Last, a /// space if `after` starts with a backtick: runs that touch would merge. fn wrap(&mut self, construct: &str, after: &str, closers: &[char]) { todo!() } /// One piece as a code span: `self.ticks` backticks, the piece, as many again. An empty piece, /// or one of only white space (`trim().is_empty()`), is written as it is. Before the span, a /// space, unless the text so far is empty or ends with white space: marked reads backticks as /// part of a URL they touch, a backtick would merge with the run, a backslash escape it. fn code(&mut self, piece: &str) { todo!() } } /// `text` cut after each line end (`\r\n`, `\n`, `\r` or U+2424), each line keeping its end; /// the last line may have none. An empty `text` gives no lines. fn lines(text: &str) -> Vec<&str> { let mut lines = Vec::new(); let mut start = 0; let mut chars = text.char_indices().peekable(); while let Some((i, c)) = chars.next() { let end = match c { '\r' if chars.next_if(|&(_, next)| next == '\n').is_some() => i + 2, '\n' | '\r' | '\u{2424}' => i + c.len_utf8(), _ => continue, }; lines.push(text.get(start..end).unwrap_or_default()); start = end; } if start < text.len() { lines.push(text.get(start..).unwrap_or_default()); } lines } /// A line without its line end. fn content(line: &str) -> &str { line.trim_end_matches(BREAKS) } /// Each run of backticks in `text` (a run is as long as it goes), as (byte offset, length). fn runs(text: &str) -> Vec<(usize, usize)> { let mut found: Vec<(usize, usize)> = Vec::new(); let mut last_end = None; for (i, c) in text.char_indices() { if c != '`' { continue; } match found.last_mut() { Some(run) if last_end == Some(i) => run.1 += 1, _ => found.push((i, 1)), } last_end = Some(i + 1); } found } /// Whether the line may start an HTML block: after white space, ASCII digits and the characters /// `>-*+.)` of list and quote markers at its start (`trim_start_matches`), it starts with `<` and /// an ASCII letter or `/`. fn html_start(line: &str) -> bool { todo!() } /// Whether the line starts like a list item or a quote: the first character of /// `content(line).trim_start()` is `>`, `-`, `*`, `+` or an ASCII digit. fn nested_start(line: &str) -> bool { todo!() } /// The index of the line that closes the fenced block `lines[at]` opens: `opener` gives /// `(indent, run)`, else `None`. Then for each later line `i`, in order: if `closes(line, run)`, /// `Some(i)`; else if its `content` is not empty and starts with fewer than `indent` spaces, /// `None` (it leaves a list item in CommonMark, and the block with it). No closer: `None`, since /// marked reads an unclosed fence as code only at times. fn fence_end(lines: &[&str], at: usize) -> Option { todo!() } /// If the line opens a fenced block, its indent and run. In its `content`: up to 3 spaces (the /// indent), then the run, 3 or more of one character, a backtick or `~`; the rest of the line (the /// info) holds no backtick, `~` or `|` (a `|` can make it a table row in marked). Else `None`. fn opener(line: &str) -> Option<(usize, &str)> { todo!() } /// Whether the line closes a block opened with `run`, as marked decides: its `content` starts /// with at most 3 characters that are spaces or no-break spaces (U+00A0), then `run`; after that /// come only backticks and `~`, then only spaces, tabs or no-break spaces. fn closes(line: &str, run: &str) -> bool { todo!() }