Task 10 (spec 9, the owner's option C): a new module gatewayd::links with
disarm(text, untrusted) -> Disarmed { text, links }, given as a skeleton
with 16 small todo!() helpers, and deliver posting each answer through
answer_posts: disarm, split_answer, disarm each part again, NOTICE once
at the end of the last post when a link was put in a code span.
Checks, against a reference kept off master:
- Replayed from ref 09 in a scratch worktree with its own target/: the
task's edits (applied from the task text) plus the given files plus the
reference bodies are byte-identical to the reference. make gate on
rustc/clippy 1.98.1: gate: ok, 1081 tests (1058 + 7 + 7 + 9). /tmp
holds nothing new after the gate.
- Step 2 (E0432 only) and step 3 (6/1, 7, 9 failing, all "not yet
implemented"; warnings as the task lists) seen in the replay.
- 71 one-line breaks of the reference, each caught by the given tests.
- Differential fuzz of generated Markdown through Mattermost's marked
fork (mattermost/marked c211e4c, the webapp's options) and CommonMark:
on the final code, 600k replies in each mode, half with inline LaTeX
on, render no image and (untrusted) no link.
- disarm(disarm(x)) == disarm(x) on 200k generated replies, both modes.
- Given files pass rustfmt; skeleton comments read beside their
signatures; no chat-template tag in the plan or given files;
deliver_links 40 runs under CPU load, no failure.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
338 lines
14 KiB
Rust
338 lines
14 KiB
Rust
//! 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) {
|
|
todo!()
|
|
}
|
|
}
|
|
|
|
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> {
|
|
todo!()
|
|
}
|
|
|
|
/// `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> {
|
|
todo!()
|
|
}
|
|
|
|
/// 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> {
|
|
todo!()
|
|
}
|
|
|
|
/// `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> {
|
|
todo!()
|
|
}
|
|
|
|
/// `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> {
|
|
todo!()
|
|
}
|
|
|
|
/// `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> {
|
|
todo!()
|
|
}
|
|
|
|
/// 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 {
|
|
todo!()
|
|
}
|
|
|
|
/// 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 {
|
|
todo!()
|
|
}
|