| 1 | //! The Markdown subset the operations accept text in. Deliberately |
| 2 | //! small: this is how an agent or a script says "bold this word", not a |
| 3 | //! document format. |
| 4 | //! |
| 5 | //! - Paragraphs split on blank lines; the lines of one paragraph join |
| 6 | //! with a space. |
| 7 | //! - A line starting `- ` or `1. ` (any digits) is its own paragraph with |
| 8 | //! the marker stripped — a `list` block is one paragraph per item. |
| 9 | //! - Inline: `**bold**`, `*italic*`, `` `code` ``, `==highlight==`, |
| 10 | //! `[text](url)`. A delimiter with no closing partner is literal text. |
| 11 | //! Inside a code span nothing else is parsed. A link's text may carry |
| 12 | //! marks; a link inside a link is literal. |
| 13 | //! - The only escape is `\*`, which yields a literal `*`. |
| 14 | //! |
| 15 | //! `parse_lines` is the plain variant for `code` blocks: one paragraph |
| 16 | //! per line, nothing interpreted, blank lines kept as empty paragraphs. |
| 17 | |
| 18 | use crate::schema::{Paragraph, Run}; |
| 19 | |
| 20 | /// The Markdown subset as paragraphs of runs. |
| 21 | pub fn parse(input: &str) -> Vec<Paragraph> { |
| 22 | let mut sources: Vec<String> = Vec::new(); |
| 23 | let mut current: Option<String> = None; |
| 24 | for line in input.lines() { |
| 25 | let line = line.trim(); |
| 26 | if line.is_empty() { |
| 27 | if let Some(c) = current.take() { |
| 28 | sources.push(c); |
| 29 | } |
| 30 | continue; |
| 31 | } |
| 32 | if let Some(item) = list_item(line) { |
| 33 | if let Some(c) = current.take() { |
| 34 | sources.push(c); |
| 35 | } |
| 36 | current = Some(item.to_string()); |
| 37 | continue; |
| 38 | } |
| 39 | match current.as_mut() { |
| 40 | Some(c) => { |
| 41 | c.push(' '); |
| 42 | c.push_str(line); |
| 43 | } |
| 44 | None => current = Some(line.to_string()), |
| 45 | } |
| 46 | } |
| 47 | if let Some(c) = current { |
| 48 | sources.push(c); |
| 49 | } |
| 50 | sources.iter().map(|s| parse_inline(s)).collect() |
| 51 | } |
| 52 | |
| 53 | /// One plain paragraph per line, for `code` blocks. |
| 54 | pub fn parse_lines(input: &str) -> Vec<Paragraph> { |
| 55 | input |
| 56 | .lines() |
| 57 | .map(|line| { |
| 58 | if line.is_empty() { |
| 59 | Vec::new() |
| 60 | } else { |
| 61 | vec![Run { |
| 62 | t: line.to_string(), |
| 63 | ..Run::default() |
| 64 | }] |
| 65 | } |
| 66 | }) |
| 67 | .collect() |
| 68 | } |
| 69 | |
| 70 | /// The text after a list marker, if `line` starts with one. |
| 71 | fn list_item(line: &str) -> Option<&str> { |
| 72 | if let Some(rest) = line.strip_prefix("- ") { |
| 73 | return Some(rest.trim_start()); |
| 74 | } |
| 75 | let digits = line.bytes().take_while(u8::is_ascii_digit).count(); |
| 76 | if digits > 0 { |
| 77 | if let Some(rest) = line[digits..].strip_prefix(". ") { |
| 78 | return Some(rest.trim_start()); |
| 79 | } |
| 80 | } |
| 81 | None |
| 82 | } |
| 83 | |
| 84 | #[derive(Default, Clone)] |
| 85 | struct Marks { |
| 86 | b: bool, |
| 87 | i: bool, |
| 88 | c: bool, |
| 89 | h: bool, |
| 90 | href: Option<String>, |
| 91 | } |
| 92 | |
| 93 | struct Parser { |
| 94 | runs: Paragraph, |
| 95 | buf: String, |
| 96 | marks: Marks, |
| 97 | } |
| 98 | |
| 99 | impl Parser { |
| 100 | fn flush(&mut self) { |
| 101 | if self.buf.is_empty() { |
| 102 | return; |
| 103 | } |
| 104 | let run = Run { |
| 105 | t: std::mem::take(&mut self.buf), |
| 106 | b: self.marks.b, |
| 107 | i: self.marks.i, |
| 108 | c: self.marks.c, |
| 109 | h: self.marks.h, |
| 110 | href: self.marks.href.clone(), |
| 111 | }; |
| 112 | match self.runs.last_mut() { |
| 113 | Some(last) if last.same_marks(&run) => last.t.push_str(&run.t), |
| 114 | _ => self.runs.push(run), |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// Toggles a mark on `\*\*`-style delimiters: closes it when open, |
| 119 | /// opens it when a closing partner exists later in `rest`, and |
| 120 | /// otherwise leaves the delimiter as text. Returns whether the |
| 121 | /// delimiter was consumed. |
| 122 | fn toggle( |
| 123 | &mut self, |
| 124 | delim: &str, |
| 125 | rest: &str, |
| 126 | get: fn(&Marks) -> bool, |
| 127 | set: fn(&mut Marks, bool), |
| 128 | ) -> bool { |
| 129 | if get(&self.marks) { |
| 130 | self.flush(); |
| 131 | set(&mut self.marks, false); |
| 132 | return true; |
| 133 | } |
| 134 | if has_partner(rest, delim) { |
| 135 | self.flush(); |
| 136 | set(&mut self.marks, true); |
| 137 | return true; |
| 138 | } |
| 139 | false |
| 140 | } |
| 141 | |
| 142 | fn parse(&mut self, src: &str) { |
| 143 | let mut i = 0; |
| 144 | while i < src.len() { |
| 145 | let rest = &src[i..]; |
| 146 | if rest.starts_with("\\*") { |
| 147 | self.buf.push('*'); |
| 148 | i += 2; |
| 149 | continue; |
| 150 | } |
| 151 | if let Some(after) = rest.strip_prefix("**") { |
| 152 | if self.toggle("**", after, |m| m.b, |m, v| m.b = v) { |
| 153 | i += 2; |
| 154 | continue; |
| 155 | } |
| 156 | } else if let Some(after) = rest.strip_prefix("==") { |
| 157 | if self.toggle("==", after, |m| m.h, |m, v| m.h = v) { |
| 158 | i += 2; |
| 159 | continue; |
| 160 | } |
| 161 | } else if let Some(after) = rest.strip_prefix('*') { |
| 162 | if self.toggle("*", after, |m| m.i, |m, v| m.i = v) { |
| 163 | i += 1; |
| 164 | continue; |
| 165 | } |
| 166 | } else if let Some(after) = rest.strip_prefix('`') { |
| 167 | if let Some(end) = after.find('`') { |
| 168 | self.flush(); |
| 169 | let was = self.marks.c; |
| 170 | self.marks.c = true; |
| 171 | self.buf.push_str(&after[..end]); |
| 172 | self.flush(); |
| 173 | self.marks.c = was; |
| 174 | i += 1 + end + 1; |
| 175 | continue; |
| 176 | } |
| 177 | } else if rest.starts_with('[') && self.marks.href.is_none() { |
| 178 | if let Some((text, url, len)) = link(rest) { |
| 179 | self.flush(); |
| 180 | let outer = self.marks.clone(); |
| 181 | self.marks.href = Some(url.to_string()); |
| 182 | self.parse(text); |
| 183 | self.flush(); |
| 184 | self.marks = outer; |
| 185 | i += len; |
| 186 | continue; |
| 187 | } |
| 188 | } |
| 189 | let ch = rest.chars().next().unwrap_or('\0'); |
| 190 | self.buf.push(ch); |
| 191 | i += ch.len_utf8(); |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | /// Whether `delim` occurs in `rest` as itself — for a single `*`, one |
| 197 | /// that is not half of a `**`, so `lone* **bold**` does not open italic |
| 198 | /// on the bold marker. |
| 199 | fn has_partner(rest: &str, delim: &str) -> bool { |
| 200 | if delim != "*" { |
| 201 | return rest.contains(delim); |
| 202 | } |
| 203 | let bytes = rest.as_bytes(); |
| 204 | let mut i = 0; |
| 205 | while i < bytes.len() { |
| 206 | if bytes[i] == b'*' { |
| 207 | if i + 1 < bytes.len() && bytes[i + 1] == b'*' { |
| 208 | i += 2; |
| 209 | continue; |
| 210 | } |
| 211 | return true; |
| 212 | } |
| 213 | i += 1; |
| 214 | } |
| 215 | false |
| 216 | } |
| 217 | |
| 218 | /// `[text](url)` at the start of `s`: the text, the url, and the bytes |
| 219 | /// the whole link spans. |
| 220 | fn link(s: &str) -> Option<(&str, &str, usize)> { |
| 221 | let close = s.find("](")?; |
| 222 | let text = &s[1..close]; |
| 223 | let url_start = close + 2; |
| 224 | let url_end = url_start + s[url_start..].find(')')?; |
| 225 | let url = &s[url_start..url_end]; |
| 226 | if text.is_empty() || url.is_empty() { |
| 227 | return None; |
| 228 | } |
| 229 | Some((text, url, url_end + 1)) |
| 230 | } |
| 231 | |
| 232 | fn parse_inline(src: &str) -> Paragraph { |
| 233 | let mut p = Parser { |
| 234 | runs: Vec::new(), |
| 235 | buf: String::new(), |
| 236 | marks: Marks::default(), |
| 237 | }; |
| 238 | p.parse(src); |
| 239 | p.flush(); |
| 240 | p.runs |
| 241 | } |
| 242 | |
| 243 | #[cfg(test)] |
| 244 | mod tests { |
| 245 | use super::*; |
| 246 | |
| 247 | fn plain(t: &str) -> Run { |
| 248 | Run { |
| 249 | t: t.to_string(), |
| 250 | ..Run::default() |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn paragraphs_split_on_blank_lines_and_list_markers() { |
| 256 | let got = parse("a\nb\n\nc\n- d\n1. e\n\n\n"); |
| 257 | assert_eq!(got.len(), 4); |
| 258 | assert_eq!(got[0], vec![plain("a b")]); |
| 259 | assert_eq!(got[1], vec![plain("c")]); |
| 260 | assert_eq!(got[2], vec![plain("d")]); |
| 261 | assert_eq!(got[3], vec![plain("e")]); |
| 262 | } |
| 263 | |
| 264 | #[test] |
| 265 | fn inline_marks() { |
| 266 | let got = parse("**b** *i* `c *x*` ==h== [t](u) \\* lone* **open"); |
| 267 | assert_eq!( |
| 268 | got[0], |
| 269 | vec![ |
| 270 | Run { |
| 271 | t: "b".into(), |
| 272 | b: true, |
| 273 | ..Run::default() |
| 274 | }, |
| 275 | plain(" "), |
| 276 | Run { |
| 277 | t: "i".into(), |
| 278 | i: true, |
| 279 | ..Run::default() |
| 280 | }, |
| 281 | plain(" "), |
| 282 | Run { |
| 283 | t: "c *x*".into(), |
| 284 | c: true, |
| 285 | ..Run::default() |
| 286 | }, |
| 287 | plain(" "), |
| 288 | Run { |
| 289 | t: "h".into(), |
| 290 | h: true, |
| 291 | ..Run::default() |
| 292 | }, |
| 293 | plain(" "), |
| 294 | Run { |
| 295 | t: "t".into(), |
| 296 | href: Some("u".into()), |
| 297 | ..Run::default() |
| 298 | }, |
| 299 | plain(" * lone* **open"), |
| 300 | ] |
| 301 | ); |
| 302 | } |
| 303 | |
| 304 | #[test] |
| 305 | fn marks_nest_and_links_carry_marks() { |
| 306 | let got = parse("**a *b* c** [**x**](y)"); |
| 307 | assert_eq!( |
| 308 | got[0], |
| 309 | vec![ |
| 310 | Run { |
| 311 | t: "a ".into(), |
| 312 | b: true, |
| 313 | ..Run::default() |
| 314 | }, |
| 315 | Run { |
| 316 | t: "b".into(), |
| 317 | b: true, |
| 318 | i: true, |
| 319 | ..Run::default() |
| 320 | }, |
| 321 | Run { |
| 322 | t: " c".into(), |
| 323 | b: true, |
| 324 | ..Run::default() |
| 325 | }, |
| 326 | plain(" "), |
| 327 | Run { |
| 328 | t: "x".into(), |
| 329 | b: true, |
| 330 | href: Some("y".into()), |
| 331 | ..Run::default() |
| 332 | }, |
| 333 | ] |
| 334 | ); |
| 335 | } |
| 336 | |
| 337 | #[test] |
| 338 | fn empty_input_is_no_paragraphs_and_lines_keep_blanks() { |
| 339 | assert!(parse("").is_empty()); |
| 340 | assert!(parse(" \n\n ").is_empty()); |
| 341 | let got = parse_lines("x\n\ny"); |
| 342 | assert_eq!(got, vec![vec![plain("x")], vec![], vec![plain("y")]]); |
| 343 | } |
| 344 | } |
| 345 | |