//! The Markdown subset the operations accept text in. Deliberately //! small: this is how an agent or a script says "bold this word", not a //! document format. //! //! - Paragraphs split on blank lines; the lines of one paragraph join //! with a space. //! - A line starting `- ` or `1. ` (any digits) is its own paragraph with //! the marker stripped — a `list` block is one paragraph per item. //! - Inline: `**bold**`, `*italic*`, `` `code` ``, `==highlight==`, //! `[text](url)`. A delimiter with no closing partner is literal text. //! Inside a code span nothing else is parsed. A link's text may carry //! marks; a link inside a link is literal. //! - The only escape is `\*`, which yields a literal `*`. //! //! `parse_lines` is the plain variant for `code` blocks: one paragraph //! per line, nothing interpreted, blank lines kept as empty paragraphs. use crate::schema::{Paragraph, Run}; /// The Markdown subset as paragraphs of runs. pub fn parse(input: &str) -> Vec { let mut sources: Vec = Vec::new(); let mut current: Option = None; for line in input.lines() { let line = line.trim(); if line.is_empty() { if let Some(c) = current.take() { sources.push(c); } continue; } if let Some(item) = list_item(line) { if let Some(c) = current.take() { sources.push(c); } current = Some(item.to_string()); continue; } match current.as_mut() { Some(c) => { c.push(' '); c.push_str(line); } None => current = Some(line.to_string()), } } if let Some(c) = current { sources.push(c); } sources.iter().map(|s| parse_inline(s)).collect() } /// One plain paragraph per line, for `code` blocks. pub fn parse_lines(input: &str) -> Vec { input .lines() .map(|line| { if line.is_empty() { Vec::new() } else { vec![Run { t: line.to_string(), ..Run::default() }] } }) .collect() } /// The text after a list marker, if `line` starts with one. fn list_item(line: &str) -> Option<&str> { if let Some(rest) = line.strip_prefix("- ") { return Some(rest.trim_start()); } let digits = line.bytes().take_while(u8::is_ascii_digit).count(); if digits > 0 { if let Some(rest) = line[digits..].strip_prefix(". ") { return Some(rest.trim_start()); } } None } #[derive(Default, Clone)] struct Marks { b: bool, i: bool, c: bool, h: bool, href: Option, } struct Parser { runs: Paragraph, buf: String, marks: Marks, } impl Parser { fn flush(&mut self) { if self.buf.is_empty() { return; } let run = Run { t: std::mem::take(&mut self.buf), b: self.marks.b, i: self.marks.i, c: self.marks.c, h: self.marks.h, href: self.marks.href.clone(), }; match self.runs.last_mut() { Some(last) if last.same_marks(&run) => last.t.push_str(&run.t), _ => self.runs.push(run), } } /// Toggles a mark on `\*\*`-style delimiters: closes it when open, /// opens it when a closing partner exists later in `rest`, and /// otherwise leaves the delimiter as text. Returns whether the /// delimiter was consumed. fn toggle( &mut self, delim: &str, rest: &str, get: fn(&Marks) -> bool, set: fn(&mut Marks, bool), ) -> bool { if get(&self.marks) { self.flush(); set(&mut self.marks, false); return true; } if has_partner(rest, delim) { self.flush(); set(&mut self.marks, true); return true; } false } fn parse(&mut self, src: &str) { let mut i = 0; while i < src.len() { let rest = &src[i..]; if rest.starts_with("\\*") { self.buf.push('*'); i += 2; continue; } if let Some(after) = rest.strip_prefix("**") { if self.toggle("**", after, |m| m.b, |m, v| m.b = v) { i += 2; continue; } } else if let Some(after) = rest.strip_prefix("==") { if self.toggle("==", after, |m| m.h, |m, v| m.h = v) { i += 2; continue; } } else if let Some(after) = rest.strip_prefix('*') { if self.toggle("*", after, |m| m.i, |m, v| m.i = v) { i += 1; continue; } } else if let Some(after) = rest.strip_prefix('`') { if let Some(end) = after.find('`') { self.flush(); let was = self.marks.c; self.marks.c = true; self.buf.push_str(&after[..end]); self.flush(); self.marks.c = was; i += 1 + end + 1; continue; } } else if rest.starts_with('[') && self.marks.href.is_none() { if let Some((text, url, len)) = link(rest) { self.flush(); let outer = self.marks.clone(); self.marks.href = Some(url.to_string()); self.parse(text); self.flush(); self.marks = outer; i += len; continue; } } let ch = rest.chars().next().unwrap_or('\0'); self.buf.push(ch); i += ch.len_utf8(); } } } /// Whether `delim` occurs in `rest` as itself — for a single `*`, one /// that is not half of a `**`, so `lone* **bold**` does not open italic /// on the bold marker. fn has_partner(rest: &str, delim: &str) -> bool { if delim != "*" { return rest.contains(delim); } let bytes = rest.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'*' { if i + 1 < bytes.len() && bytes[i + 1] == b'*' { i += 2; continue; } return true; } i += 1; } false } /// `[text](url)` at the start of `s`: the text, the url, and the bytes /// the whole link spans. fn link(s: &str) -> Option<(&str, &str, usize)> { let close = s.find("](")?; let text = &s[1..close]; let url_start = close + 2; let url_end = url_start + s[url_start..].find(')')?; let url = &s[url_start..url_end]; if text.is_empty() || url.is_empty() { return None; } Some((text, url, url_end + 1)) } fn parse_inline(src: &str) -> Paragraph { let mut p = Parser { runs: Vec::new(), buf: String::new(), marks: Marks::default(), }; p.parse(src); p.flush(); p.runs } #[cfg(test)] mod tests { use super::*; fn plain(t: &str) -> Run { Run { t: t.to_string(), ..Run::default() } } #[test] fn paragraphs_split_on_blank_lines_and_list_markers() { let got = parse("a\nb\n\nc\n- d\n1. e\n\n\n"); assert_eq!(got.len(), 4); assert_eq!(got[0], vec![plain("a b")]); assert_eq!(got[1], vec![plain("c")]); assert_eq!(got[2], vec![plain("d")]); assert_eq!(got[3], vec![plain("e")]); } #[test] fn inline_marks() { let got = parse("**b** *i* `c *x*` ==h== [t](u) \\* lone* **open"); assert_eq!( got[0], vec![ Run { t: "b".into(), b: true, ..Run::default() }, plain(" "), Run { t: "i".into(), i: true, ..Run::default() }, plain(" "), Run { t: "c *x*".into(), c: true, ..Run::default() }, plain(" "), Run { t: "h".into(), h: true, ..Run::default() }, plain(" "), Run { t: "t".into(), href: Some("u".into()), ..Run::default() }, plain(" * lone* **open"), ] ); } #[test] fn marks_nest_and_links_carry_marks() { let got = parse("**a *b* c** [**x**](y)"); assert_eq!( got[0], vec![ Run { t: "a ".into(), b: true, ..Run::default() }, Run { t: "b".into(), b: true, i: true, ..Run::default() }, Run { t: " c".into(), b: true, ..Run::default() }, plain(" "), Run { t: "x".into(), b: true, href: Some("y".into()), ..Run::default() }, ] ); } #[test] fn empty_input_is_no_paragraphs_and_lines_keep_blanks() { assert!(parse("").is_empty()); assert!(parse(" \n\n ").is_empty()); let got = parse_lines("x\n\ny"); assert_eq!(got, vec![vec![plain("x")], vec![], vec![plain("y")]]); } }