gatewayd: images, and links in untrusted replies, are posted as code
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
//! One paragraph of prose: code spans, escapes, images, links and URLs, left to right.
|
||||
|
||||
use super::{BREAKS, Out, runs};
|
||||
|
||||
/// What marked lets a backslash escape. Other characters after a backslash are plain text.
|
||||
const ESCAPABLE: &str = "\\`*{}[]()#+-.!_>|~";
|
||||
|
||||
/// What a URL loses at its end: marked leaves these out of a link when they end it.
|
||||
const TRAILING: [char; 11] = ['.', ',', ':', ';', '!', '?', '*', '_', '~', '\'', '"'];
|
||||
|
||||
/// How many characters a search for the end of a construct looks at, so that a reply full of
|
||||
/// brackets costs time in proportion to its length. An image longer than this still has its `![`
|
||||
/// put in a code span; a link longer than this keeps only its URL in one.
|
||||
const WINDOW: usize = 2048;
|
||||
|
||||
/// What a paragraph has shown so far that changes how its later code spans may pair.
|
||||
#[derive(Default)]
|
||||
struct Seen {
|
||||
/// Backtick run lengths that may pair differently in the two parsers from here on.
|
||||
hazards: Vec<usize>,
|
||||
/// What would close what a plain character opened: marked lets emphasis (`*`, `_`, `~`), a
|
||||
/// tag (`<`) and inline LaTeX (`$`) take a backtick before a code span starts.
|
||||
closers: Vec<char>,
|
||||
}
|
||||
|
||||
impl Seen {
|
||||
/// Add to `hazards`, once each, every length from 1 to that of each backtick run in `text`:
|
||||
/// marked may read part of a run as part of a URL, and the rest as a run of its own.
|
||||
fn runs(&mut self, text: &str) {
|
||||
for (_, len) in runs(text) {
|
||||
for part in 1..=len {
|
||||
if !self.hazards.contains(&part) {
|
||||
self.hazards.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain character: `*`, `_`, `~` and `$` are their own closers, and `<` is closed by `>`;
|
||||
/// push the closer onto `closers` if it is not there yet. Any other character: nothing.
|
||||
fn plain(&mut self, c: char) {
|
||||
let closer = match c {
|
||||
'*' | '_' | '~' | '$' => c,
|
||||
'<' => '>',
|
||||
_ => return,
|
||||
};
|
||||
if !self.closers.contains(&closer) {
|
||||
self.closers.push(closer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Out {
|
||||
/// Write `para` with its images, and when `untrusted` its links and URLs, in code spans.
|
||||
pub(super) fn paragraph(&mut self, para: &str, untrusted: bool) {
|
||||
let mut seen = Seen::default();
|
||||
let mut prev = None;
|
||||
let mut at = 0;
|
||||
while let Some(rest) = para.get(at..).filter(|rest| !rest.is_empty()) {
|
||||
let line_start = at == 0 || para.get(..at).is_some_and(|done| done.ends_with(BREAKS));
|
||||
match self.token(rest, prev, line_start, untrusted, &mut seen) {
|
||||
Some(used) if used > 0 => {
|
||||
at += used;
|
||||
prev = None;
|
||||
}
|
||||
_ => {
|
||||
let c = rest.chars().next().unwrap_or_default();
|
||||
self.text.push(c);
|
||||
seen.plain(c);
|
||||
at += c.len_utf8();
|
||||
prev = Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the token `rest` starts with and give its length in bytes, or `None` when `rest`
|
||||
/// starts with a plain character, which the caller writes. `prev` is the plain character
|
||||
/// before, `None` after a token or at the start.
|
||||
fn token(
|
||||
&mut self,
|
||||
rest: &str,
|
||||
prev: Option<char>,
|
||||
line_start: bool,
|
||||
untrusted: bool,
|
||||
seen: &mut Seen,
|
||||
) -> Option<usize> {
|
||||
if untrusted && line_start && definition(rest) {
|
||||
let end = rest.find(BREAKS).unwrap_or(rest.len());
|
||||
return Some(self.link(rest, end, seen));
|
||||
}
|
||||
match rest.chars().next()? {
|
||||
'\\' => escape_len(rest).map(|len| self.copy(rest, len)),
|
||||
'`' => Some(self.backticks(rest, seen)),
|
||||
'!' if rest.starts_with("![") => {
|
||||
// No `]` in reach: the `` or `[text][…]`: see `construct`. Any other `[` is
|
||||
/// written escaped if `](` follows within `WINDOW`: marked may still read a link from it, with
|
||||
/// a label that takes in new code spans.
|
||||
fn bracket(&mut self, rest: &str, untrusted: bool, seen: &mut Seen) -> Option<usize> {
|
||||
let Some(len) = construct_len(rest, false) else {
|
||||
if !window(rest).contains("](") {
|
||||
return None;
|
||||
}
|
||||
self.text.push_str("\\[");
|
||||
return Some(1);
|
||||
};
|
||||
self.construct(rest, len, untrusted, seen)
|
||||
}
|
||||
|
||||
/// A `<` that starts an autolink: see `construct`.
|
||||
fn autolink(&mut self, rest: &str, untrusted: bool, seen: &mut Seen) -> Option<usize> {
|
||||
let len = autolink_len(rest)?;
|
||||
self.construct(rest, len, untrusted, seen)
|
||||
}
|
||||
|
||||
/// A URL: see `construct`. In a clean reply it is the most marked may read as the URL
|
||||
/// (`url_len` with `whole`), backticks included, since marked reads them as part of it.
|
||||
fn url(&mut self, rest: &str, untrusted: bool, seen: &mut Seen) -> Option<usize> {
|
||||
let len = url_len(rest, !untrusted);
|
||||
self.construct(rest, len, untrusted, seen)
|
||||
}
|
||||
|
||||
/// The first `len` bytes of `rest`, a link, go in code spans when `untrusted`, and also in a
|
||||
/// clean reply when they hold `![` (CommonMark may read the image where marked reads a link).
|
||||
/// Otherwise they are written as they are. Marked may end the link sooner than this, so its
|
||||
/// backtick runs become hazards, and each of its characters counts as `Seen::plain`.
|
||||
fn construct(
|
||||
&mut self,
|
||||
rest: &str,
|
||||
len: usize,
|
||||
untrusted: bool,
|
||||
seen: &mut Seen,
|
||||
) -> Option<usize> {
|
||||
let construct = rest.get(..len)?;
|
||||
if untrusted {
|
||||
return Some(self.link(rest, len, seen));
|
||||
}
|
||||
if construct.contains("![") {
|
||||
return Some(self.image(rest, len, seen));
|
||||
}
|
||||
seen.runs(construct);
|
||||
construct.chars().for_each(|c| seen.plain(c));
|
||||
Some(self.copy(rest, len))
|
||||
}
|
||||
|
||||
fn copy(&mut self, rest: &str, len: usize) -> usize {
|
||||
self.text.push_str(rest.get(..len).unwrap_or_default());
|
||||
len
|
||||
}
|
||||
|
||||
fn image(&mut self, rest: &str, len: usize, seen: &Seen) -> usize {
|
||||
self.wrap(
|
||||
rest.get(..len).unwrap_or_default(),
|
||||
rest.get(len..).unwrap_or_default(),
|
||||
&seen.closers,
|
||||
);
|
||||
len
|
||||
}
|
||||
|
||||
fn link(&mut self, rest: &str, len: usize, seen: &Seen) -> usize {
|
||||
self.links = true;
|
||||
self.image(rest, len, seen)
|
||||
}
|
||||
}
|
||||
|
||||
/// `rest` starts with a backslash: `Some(2)` if the next character is one of `ESCAPABLE`, else
|
||||
/// `None` (the backslash is plain).
|
||||
fn escape_len(rest: &str) -> Option<usize> {
|
||||
let mut chars = rest.chars();
|
||||
chars.next()?;
|
||||
matches!(chars.next(), Some(c) if ESCAPABLE.contains(c)).then_some(2)
|
||||
}
|
||||
|
||||
/// `rest` starts with a run of `run` backticks. The span's length, `run + close + run`, if both
|
||||
/// parsers read it as code: `run` is not in `seen.hazards`; `closing(after, run)` gives `close`
|
||||
/// in the text `after` the run; and the content between holds no line end (`BREAKS`), no `|`,
|
||||
/// none of `seen.closers`, and no run (`runs`) whose length is in `seen.hazards`.
|
||||
fn span_len(rest: &str, seen: &Seen) -> Option<usize> {
|
||||
let run = rest.chars().take_while(|&c| c == '`').count();
|
||||
if seen.hazards.contains(&run) {
|
||||
return None;
|
||||
}
|
||||
let after = rest.get(run..)?;
|
||||
let close = closing(after, run)?;
|
||||
let content = after.get(..close).unwrap_or_default();
|
||||
if content.contains(BREAKS)
|
||||
|| content.contains('|')
|
||||
|| content.chars().any(|c| seen.closers.contains(&c))
|
||||
|| runs(content)
|
||||
.iter()
|
||||
.any(|(_, len)| seen.hazards.contains(len))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(run + close + run)
|
||||
}
|
||||
|
||||
/// The byte offset in `text` of the first run of exactly `run` backticks, if there is one.
|
||||
fn closing(text: &str, run: usize) -> Option<usize> {
|
||||
let mut start = 0;
|
||||
let mut len = 0;
|
||||
for (i, c) in text.char_indices() {
|
||||
if c == '`' {
|
||||
if len == 0 {
|
||||
start = i;
|
||||
}
|
||||
len += 1;
|
||||
} else if len > 0 {
|
||||
if len == run {
|
||||
return Some(start);
|
||||
}
|
||||
len = 0;
|
||||
}
|
||||
}
|
||||
(len == run).then_some(start)
|
||||
}
|
||||
|
||||
/// At most the first `WINDOW` characters of `text`.
|
||||
fn window(text: &str) -> &str {
|
||||
match text.char_indices().nth(WINDOW) {
|
||||
Some((end, _)) => text.get(..end).unwrap_or(text),
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` starts with `[`; only `window(text)` is looked at. The label ends at
|
||||
/// `bracket_end(text, true)`, or if that is `None`, `bracket_end(text, false)`. Right after it: a
|
||||
/// `(` closed by `paren_end` gives the length through that `)`; or else, after white space
|
||||
/// (`trim_start`), a `[` closed by `reference_end` gives it through that `]`; or else, only when
|
||||
/// `bare` (an image may be `![alt]`), through the label's `]`. `None` if nothing fits.
|
||||
fn construct_len(text: &str, bare: bool) -> Option<usize> {
|
||||
let text = window(text);
|
||||
let label_end = bracket_end(text, true).or_else(|| bracket_end(text, false))?;
|
||||
let after = text.get(label_end + 1..).unwrap_or("");
|
||||
if after.starts_with('(')
|
||||
&& let Some(close) = paren_end(after)
|
||||
{
|
||||
return Some(label_end + 2 + close);
|
||||
}
|
||||
let trimmed = after.trim_start();
|
||||
if let Some(close) = reference_end(trimmed) {
|
||||
let before = after.len() - trimmed.len();
|
||||
return Some(label_end + 2 + before + close);
|
||||
}
|
||||
if bare {
|
||||
return Some(label_end + 1);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `text` starts with `[`: the byte offset of the `]` that closes it. Walk it counting `[` up and
|
||||
/// `]` down (to 0: found), skipping the character after each backslash. When `code`, a backtick
|
||||
/// run jumps past the next run of the same length (`closing`), as a CommonMark code span; a run
|
||||
/// with none is skipped as plain. `None` if no `]` closes it.
|
||||
fn bracket_end(text: &str, code: bool) -> Option<usize> {
|
||||
let mut depth = 0;
|
||||
let mut i = 0;
|
||||
while let Some(c) = text.get(i..).and_then(|s| s.chars().next()) {
|
||||
let len = c.len_utf8();
|
||||
if c == '\\' {
|
||||
let next = text.get(i + 1..).and_then(|s| s.chars().next())?;
|
||||
i += 1 + next.len_utf8();
|
||||
} else if c == '[' {
|
||||
depth += 1;
|
||||
i += len;
|
||||
} else if c == ']' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
i += len;
|
||||
} else if code && c == '`' {
|
||||
let run = text
|
||||
.get(i..)
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take_while(|&x| x == '`')
|
||||
.count();
|
||||
i += run + closing(text.get(i + run..).unwrap_or(""), run).map_or(0, |j| j + run);
|
||||
} else {
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `text` starts with `(`: the byte offset of the `)` that closes it, counting `(` up and `)` down
|
||||
/// (to 0: found). A backslash escapes nothing here, as in marked's link destinations. `None` if
|
||||
/// no `)` closes it.
|
||||
fn paren_end(text: &str) -> Option<usize> {
|
||||
let mut depth = 0;
|
||||
for (i, c) in text.char_indices() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
} else if c == ')' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `text` starts with `[`: the byte index of the next `]`, skipping the character after each
|
||||
/// backslash, if no `[` comes first (a reference label does not nest). `None` otherwise, and when
|
||||
/// `text` does not start with `[`.
|
||||
fn reference_end(text: &str) -> Option<usize> {
|
||||
let mut chars = text.strip_prefix('[')?.char_indices();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
match c {
|
||||
'\\' => {
|
||||
chars.next();
|
||||
}
|
||||
'[' => return None,
|
||||
']' => return Some(i + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `rest` starts with `<`: the length of the autolink it starts, `<` through the first `>` in
|
||||
/// `window(rest)`, if the text between is not empty, holds no space, tab or no-break space (a line
|
||||
/// end is allowed, as in marked), and holds `:` or `@` or starts with `www` in any case.
|
||||
fn autolink_len(rest: &str) -> Option<usize> {
|
||||
let end = window(rest).find('>')?;
|
||||
let inside = rest.get(1..end)?;
|
||||
if inside.is_empty()
|
||||
|| inside
|
||||
.chars()
|
||||
.any(|c| c == ' ' || c == '\t' || c == '\u{a0}')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let www = inside
|
||||
.get(..3)
|
||||
.is_some_and(|w| w.eq_ignore_ascii_case("www"));
|
||||
if !inside.contains(':') && !inside.contains('@') && !www {
|
||||
return None;
|
||||
}
|
||||
Some(end + 1)
|
||||
}
|
||||
|
||||
/// Whether the line `rest` starts (up to the first of `BREAKS`) is a link reference definition:
|
||||
/// after white space (`trim_start`), `[`, then a label of at least one character with no `]` in
|
||||
/// it, then `]:`.
|
||||
fn definition(rest: &str) -> bool {
|
||||
let line = rest.find(BREAKS).unwrap_or(rest.len());
|
||||
let trimmed = rest.get(..line).unwrap_or(rest).trim_start();
|
||||
let label_end = match reference_end(trimmed) {
|
||||
Some(i) => i,
|
||||
None => return false,
|
||||
};
|
||||
if label_end < 2 {
|
||||
return false;
|
||||
}
|
||||
trimmed
|
||||
.get(label_end + 1..)
|
||||
.is_some_and(|after| after.starts_with(':'))
|
||||
}
|
||||
|
||||
/// Whether a URL starts at `rest`, after the plain character `prev`: a `scheme` or `www` where
|
||||
/// `prev` is not an ASCII letter or digit, or a `scheme_less` domain where `prev` is not a
|
||||
/// letter, digit, `.` or `-`. `prev` is `None` at the start and after a token.
|
||||
fn url_start(rest: &str, prev: Option<char>) -> bool {
|
||||
let word = prev.is_none_or(|p| !p.is_ascii_alphanumeric());
|
||||
let run = prev.is_none_or(|p| !(p.is_alphanumeric() || p == '.' || p == '-'));
|
||||
(word && (scheme(rest) || www(rest))) || (run && scheme_less(rest))
|
||||
}
|
||||
|
||||
/// Whether `rest` starts with a scheme: an ASCII letter, then ASCII letters, digits, `+`, `.`
|
||||
/// or `-`, then `:` and a `/`, `%`, or letter or digit of any script.
|
||||
fn scheme(rest: &str) -> bool {
|
||||
rest.starts_with(|c: char| c.is_ascii_alphabetic())
|
||||
&& rest
|
||||
.trim_start_matches(|c: char| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
|
||||
.strip_prefix(':')
|
||||
.and_then(|after| after.chars().next())
|
||||
.is_some_and(|c| c == '/' || c == '%' || c.is_alphanumeric())
|
||||
}
|
||||
|
||||
/// Whether `rest` starts with `www` in any case, then ASCII digits (none or more), then `.` and a
|
||||
/// character that is not white space.
|
||||
fn www(rest: &str) -> bool {
|
||||
rest.get(..3).is_some_and(|w| w.eq_ignore_ascii_case("www"))
|
||||
&& rest
|
||||
.get(3..)
|
||||
.unwrap_or_default()
|
||||
.trim_start_matches(|c: char| c.is_ascii_digit())
|
||||
.strip_prefix('.')
|
||||
.and_then(|after| after.chars().next())
|
||||
.is_some_and(|c| !c.is_whitespace())
|
||||
}
|
||||
|
||||
/// Whether `rest` starts with a domain and a `/` (marked links `example.com/x` bare): a run of
|
||||
/// letters, digits, `.` and `-`, whose part after its last `.` is 2 to 4 letters with something
|
||||
/// before it, then `/`.
|
||||
fn scheme_less(rest: &str) -> bool {
|
||||
let end = rest
|
||||
.find(|c: char| !(c.is_alphanumeric() || c == '.' || c == '-'))
|
||||
.unwrap_or(rest.len());
|
||||
let (Some(host), Some(after)) = (rest.get(..end), rest.get(end..)) else {
|
||||
return false;
|
||||
};
|
||||
let Some((name, tld)) = host.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
after.starts_with('/')
|
||||
&& !name.is_empty()
|
||||
&& (2..=4).contains(&tld.chars().count())
|
||||
&& tld.chars().all(char::is_alphabetic)
|
||||
}
|
||||
|
||||
/// The length of the URL `rest` starts with: up to white space, a `)` that closes no `(` of the
|
||||
/// URL (count them), or a `<` before white space, `(`, `)`, `<`, `>` or the end (marked leaves it
|
||||
/// out: it may start an autolink); unless `whole`, up to any `<`, `>`, `|` or backtick too. Then
|
||||
/// less the characters of `TRAILING` at its end. `whole` is the most marked may read as the URL.
|
||||
fn url_len(rest: &str, whole: bool) -> usize {
|
||||
let mut depth = 0;
|
||||
let mut gt_seen = false;
|
||||
let mut end = rest.len();
|
||||
for (i, c) in rest.char_indices() {
|
||||
if c.is_whitespace() {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
if !whole && (c == '<' || c == '>' || c == '|' || c == '`') {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' if depth == 0 => {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
')' => depth -= 1,
|
||||
'<' if depth == 0 && !gt_seen => {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
'>' => gt_seen = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
while let Some(last) = rest
|
||||
.get(..end)
|
||||
.and_then(|prefix| prefix.chars().next_back())
|
||||
{
|
||||
if !TRAILING.contains(&last) {
|
||||
break;
|
||||
}
|
||||
end -= last.len_utf8();
|
||||
}
|
||||
end
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 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]) {
|
||||
let mut start = 0;
|
||||
let mut chars = construct.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
let len = if BREAKS.contains(&c) {
|
||||
match c {
|
||||
'\r' if chars.peek().map(|&(_, n)| n == '\n').unwrap_or(false) => {
|
||||
// Consume the `\n`: leaving it in the iterator would write it again.
|
||||
chars.next();
|
||||
2
|
||||
}
|
||||
_ => c.len_utf8(),
|
||||
}
|
||||
} else if c == '|' || c == '`' || closers.contains(&c) {
|
||||
c.len_utf8()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
self.code(construct.get(start..i).unwrap_or_default());
|
||||
if c == '`' || (closers.contains(&c) && c != '$') {
|
||||
self.text.push('\\');
|
||||
}
|
||||
self.text
|
||||
.push_str(construct.get(i..i + len).unwrap_or_default());
|
||||
start = i + len;
|
||||
}
|
||||
self.code(construct.get(start..).unwrap_or_default());
|
||||
if after.starts_with('`') {
|
||||
self.text.push(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if piece.trim().is_empty() {
|
||||
self.text.push_str(piece);
|
||||
return;
|
||||
}
|
||||
if !self.text.is_empty() && !self.text.ends_with(|c: char| c.is_whitespace()) {
|
||||
self.text.push(' ');
|
||||
}
|
||||
self.text.push_str(&"`".repeat(self.ticks));
|
||||
self.text.push_str(piece);
|
||||
self.text.push_str(&"`".repeat(self.ticks));
|
||||
}
|
||||
}
|
||||
|
||||
/// `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 {
|
||||
let trimmed = line.trim_start_matches(|c: char| c.is_whitespace() || ">-*+.)".contains(c));
|
||||
let mut chars = trimmed.chars();
|
||||
matches!(chars.next(), Some('<'))
|
||||
&& matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '/')
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let first = content(line).trim_start().chars().next();
|
||||
matches!(first, Some('>' | '-' | '*' | '+' | '0'..='9'))
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
let (indent, run) = opener(lines[at])?;
|
||||
let mut i = at + 1;
|
||||
while let Some(line) = lines.get(i) {
|
||||
if closes(line, run) {
|
||||
return Some(i);
|
||||
}
|
||||
if !content(line).is_empty()
|
||||
&& content(line).chars().take_while(|&c| c == ' ').count() < indent
|
||||
{
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let text = content(line);
|
||||
let indent = text.chars().take_while(|&c| c == ' ').count();
|
||||
if indent > 3 {
|
||||
return None;
|
||||
}
|
||||
let after_indent = text.get(indent..)?;
|
||||
let run_char = after_indent.chars().next()?;
|
||||
if run_char != '`' && run_char != '~' {
|
||||
return None;
|
||||
}
|
||||
let run_len = after_indent.chars().take_while(|&c| c == run_char).count();
|
||||
if run_len < 3 {
|
||||
return None;
|
||||
}
|
||||
let rest = after_indent.get(run_len..)?;
|
||||
if rest.contains(['`', '~', '|']) {
|
||||
return None;
|
||||
}
|
||||
Some((indent, after_indent.get(..run_len)?))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let text = content(line);
|
||||
let leading = text
|
||||
.chars()
|
||||
.take_while(|&c| c == ' ' || c == '\u{a0}')
|
||||
.count();
|
||||
if leading > 3 {
|
||||
return false;
|
||||
}
|
||||
let Some(after) = text.get(leading..) else {
|
||||
return false;
|
||||
};
|
||||
if !after.starts_with(run) {
|
||||
return false;
|
||||
}
|
||||
let rest = after.get(run.len()..).unwrap_or("");
|
||||
let after_ticks = rest.trim_start_matches(['`', '~']);
|
||||
after_ticks
|
||||
.chars()
|
||||
.all(|c| c == ' ' || c == '\t' || c == '\u{a0}')
|
||||
}
|
||||
Reference in New Issue
Block a user