1use crate::{Color, Font, FontSpec, FontWeight, TextRun, Underline};
14
15#[derive(Clone, Copy, Default, PartialEq, Eq)]
17struct Styles {
18 bold: bool,
19 italic: bool,
20 code: bool,
21 strike: bool,
22}
23
24impl Styles {
25 fn is_plain(self, link: Option<&String>) -> bool {
26 self == Styles::default() && link.is_none()
27 }
28}
29
30pub fn parse(md: &str, base: Font) -> (String, Vec<TextRun>) {
50 let mut p = Parser {
51 src: md.as_bytes(),
52 at: 0,
53 base,
54 out: String::with_capacity(md.len()),
55 runs: Vec::new(),
56 link_color: LINK_COLOR,
57 };
58 p.inline(Styles::default(), None, None);
59 (p.out, p.runs)
60}
61
62const LINK_COLOR: Color = Color::rgb(0.0, 0.478, 1.0);
65
66struct Parser<'a> {
67 src: &'a [u8],
68 at: usize,
69 base: Font,
70 out: String,
71 runs: Vec<TextRun>,
72 link_color: Color,
73}
74
75impl Parser<'_> {
76 fn inline(&mut self, styles: Styles, link: Option<&String>, close: Option<&[u8]>) -> bool {
79 let mut start = self.out.len();
81 let mut closed = false;
82 while self.at < self.src.len() {
83 if let Some(c) = close
84 && self.src[self.at..].starts_with(c)
85 {
86 self.at += c.len();
87 closed = true;
88 break;
89 }
90 let b = self.src[self.at];
91 if b == b'\\'
94 && let Some(&next) = self.src.get(self.at + 1)
95 && next.is_ascii_punctuation()
96 {
97 self.out.push(next as char);
98 self.at += 2;
99 continue;
100 }
101 if styles.code {
103 self.push_byte();
104 continue;
105 }
106 let consumed = match b {
107 b'`' => self.code_span(styles, link, &mut start),
108 b'*' | b'_' => self.emphasis(b, styles, link, &mut start),
109 b'~' => self.strike(styles, link, &mut start),
110 b'[' => self.link(styles, link, &mut start),
111 _ => false,
112 };
113 if !consumed {
114 self.push_byte();
115 }
116 }
117 self.flush(start, styles, link);
118 closed
119 }
120
121 fn push_byte(&mut self) {
124 let b = self.at;
127 self.at += 1;
128 while self.at < self.src.len() && (self.src[self.at] & 0xC0) == 0x80 {
129 self.at += 1;
130 }
131 if let Ok(s) = std::str::from_utf8(&self.src[b..self.at]) {
132 self.out.push_str(s);
133 }
134 }
135
136 fn flush(&mut self, start: usize, styles: Styles, link: Option<&String>) {
138 let end = self.out.len();
139 if end == start || styles.is_plain(link) {
140 return;
141 }
142 self.runs.push(TextRun {
143 range: start..end,
144 font: FontSpec {
145 style: self.base,
146 weight: styles.bold.then_some(FontWeight::Bold),
147 italic: styles.italic,
148 monospace: styles.code,
149 ..Default::default()
150 },
151 color: link.is_some().then_some(self.link_color),
152 underline: if link.is_some() {
155 Underline::Single
156 } else {
157 Underline::None
158 },
159 strikethrough: styles.strike,
160 link: link.cloned(),
161 ..TextRun::default()
162 });
163 }
164
165 fn nested(
168 &mut self,
169 open: usize,
170 close: &[u8],
171 outer: Styles,
172 inner: Styles,
173 link: Option<&String>,
174 start: &mut usize,
175 ) -> bool {
176 let (mark_at, mark_out) = (self.at, self.out.len());
177 self.at += open;
178 let before = self.runs.len();
179 if !self.inline(inner, link, Some(close)) {
180 self.runs.truncate(before);
182 self.out.truncate(mark_out);
183 self.at = mark_at;
184 return false;
185 }
186 let outer_start = std::mem::replace(start, self.out.len());
189 self.flush_range(outer_start, mark_out, outer, link);
190 true
191 }
192
193 fn flush_range(&mut self, start: usize, end: usize, styles: Styles, link: Option<&String>) {
195 if end <= start || styles.is_plain(link) {
196 return;
197 }
198 let at = self
200 .runs
201 .iter()
202 .position(|r| r.range.start >= end)
203 .unwrap_or(self.runs.len());
204 let run = TextRun {
205 range: start..end,
206 font: FontSpec {
207 style: self.base,
208 weight: styles.bold.then_some(FontWeight::Bold),
209 italic: styles.italic,
210 monospace: styles.code,
211 ..Default::default()
212 },
213 color: link.is_some().then_some(self.link_color),
214 underline: if link.is_some() {
215 Underline::Single
216 } else {
217 Underline::None
218 },
219 strikethrough: styles.strike,
220 link: link.cloned(),
221 ..TextRun::default()
222 };
223 self.runs.insert(at, run);
224 }
225
226 fn code_span(&mut self, styles: Styles, link: Option<&String>, start: &mut usize) -> bool {
227 let mut inner = styles;
228 inner.code = true;
229 self.nested(1, b"`", styles, inner, link, start)
230 }
231
232 fn emphasis(
233 &mut self,
234 marker: u8,
235 styles: Styles,
236 link: Option<&String>,
237 start: &mut usize,
238 ) -> bool {
239 let double = self.src.get(self.at + 1) == Some(&marker);
240 let mut inner = styles;
241 if double {
242 inner.bold = true;
243 self.nested(2, &[marker, marker], styles, inner, link, start)
244 } else {
245 inner.italic = true;
246 self.nested(1, &[marker], styles, inner, link, start)
247 }
248 }
249
250 fn strike(&mut self, styles: Styles, link: Option<&String>, start: &mut usize) -> bool {
251 if self.src.get(self.at + 1) != Some(&b'~') {
252 return false;
253 }
254 let mut inner = styles;
255 inner.strike = true;
256 self.nested(2, b"~~", styles, inner, link, start)
257 }
258
259 fn link(&mut self, styles: Styles, outer: Option<&String>, start: &mut usize) -> bool {
262 let Some(close) = find(self.src, self.at + 1, b']') else {
263 return false;
264 };
265 if self.src.get(close + 1) != Some(&b'(') {
266 return false;
267 }
268 let Some(paren) = find(self.src, close + 2, b')') else {
269 return false;
270 };
271 let Ok(url) = std::str::from_utf8(&self.src[close + 2..paren]) else {
272 return false;
273 };
274 let target = outer.cloned().unwrap_or_else(|| url.trim().to_string());
276 let mark_out = self.out.len();
277 let saved_at = self.at;
278 self.at += 1;
279 let before = self.runs.len();
280 if !self.inline(styles, Some(&target), Some(b"]")) {
281 self.runs.truncate(before);
282 self.out.truncate(mark_out);
283 self.at = saved_at;
284 return false;
285 }
286 self.at = paren + 1;
287 let outer_end = std::mem::replace(start, self.out.len());
288 self.flush_range(outer_end, mark_out, styles, outer);
289 true
290 }
291}
292
293fn find(src: &[u8], from: usize, b: u8) -> Option<usize> {
294 (from..src.len()).find(|&i| src[i] == b)
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 fn render(md: &str) -> (String, Vec<TextRun>) {
302 parse(md, Font::Body)
303 }
304
305 #[test]
306 fn plain_text_produces_no_runs() {
307 let (t, r) = render("nothing to see");
308 assert_eq!(t, "nothing to see");
309 assert!(r.is_empty());
310 }
311
312 #[test]
313 fn the_markers_are_stripped_from_the_text() {
314 let (t, _) = render("**a** *b* `c` ~~d~~ [e](u)");
315 assert_eq!(t, "a b c d e");
316 }
317
318 #[test]
319 fn each_style_lands_on_its_own_word() {
320 let (t, r) = render("**bold** and *italic*");
321 assert_eq!(t, "bold and italic");
322 assert_eq!(r.len(), 2);
323 assert_eq!(&t[r[0].range.clone()], "bold");
324 assert_eq!(r[0].font.weight, Some(FontWeight::Bold));
325 assert_eq!(&t[r[1].range.clone()], "italic");
326 assert!(r[1].font.italic);
327 }
328
329 #[test]
330 fn code_spans_are_literal_inside() {
331 let (t, r) = render("`**not bold**`");
332 assert_eq!(t, "**not bold**");
333 assert_eq!(r.len(), 1);
334 assert!(r[0].font.monospace);
335 assert_eq!(r[0].font.weight, None);
336 }
337
338 #[test]
339 fn a_link_carries_its_target_and_a_color() {
340 let (t, r) = render("see [the docs](https://day.dev/x) now");
341 assert_eq!(t, "see the docs now");
342 assert_eq!(r.len(), 1);
343 assert_eq!(&t[r[0].range.clone()], "the docs");
344 assert_eq!(r[0].link.as_deref(), Some("https://day.dev/x"));
345 assert!(r[0].color.is_some());
346 }
347
348 #[test]
349 fn styles_nest() {
350 let (t, r) = render("**bold with *italic* inside**");
351 assert_eq!(t, "bold with italic inside");
352 assert_eq!(r.len(), 3);
354 assert!(r.iter().all(|x| x.font.weight == Some(FontWeight::Bold)));
355 assert_eq!(r.iter().filter(|x| x.font.italic).count(), 1);
356 assert_eq!(&t[r[1].range.clone()], "italic");
357 assert!(crate::runs_are_valid(&t, &r).is_ok());
358 }
359
360 #[test]
361 fn a_link_can_hold_styles() {
362 let (t, r) = render("[**bold** link](u)");
363 assert_eq!(t, "bold link");
364 assert!(r.iter().all(|x| x.link.as_deref() == Some("u")));
365 assert_eq!(r.iter().filter(|x| x.font.weight.is_some()).count(), 1);
366 assert!(crate::runs_are_valid(&t, &r).is_ok());
367 }
368
369 #[test]
370 fn an_unclosed_marker_stays_literal() {
371 for md in ["**half bold", "a * b", "`open", "~~nope", "[text](no-close"] {
372 let (t, r) = render(md);
373 assert_eq!(t, md, "unclosed marker should be text: {md}");
374 assert!(r.is_empty(), "unclosed marker should not style: {md}");
375 }
376 }
377
378 #[test]
379 fn backslash_escapes_a_marker() {
380 let (t, r) = render(r"\*not italic\*");
381 assert_eq!(t, "*not italic*");
382 assert!(r.is_empty());
383 }
384
385 #[test]
386 fn multibyte_text_keeps_its_ranges_on_boundaries() {
387 let (t, r) = render("héllo **wörld** 🌍 ~~ok~~");
388 assert_eq!(t, "héllo wörld 🌍 ok");
389 assert_eq!(&t[r[0].range.clone()], "wörld");
390 assert_eq!(&t[r[1].range.clone()], "ok");
391 assert!(crate::runs_are_valid(&t, &r).is_ok());
392 }
393
394 #[test]
395 fn every_parse_produces_valid_runs() {
396 for md in [
399 "",
400 "plain",
401 "**a**b*c*`d`~~e~~[f](g)",
402 "**a *b* c** d",
403 "[a **b** c](u) d",
404 "~~**both**~~",
405 "`a` `b` `c`",
406 "**",
407 "*_*_*",
408 r"\[not a link\](x)",
409 ] {
410 let (t, r) = render(md);
411 assert!(
412 crate::runs_are_valid(&t, &r).is_ok(),
413 "invalid runs for {md:?}: {r:?}"
414 );
415 }
416 }
417}