Skip to main content

day_spec/
markdown.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Inline markdown → styled runs, parsed at RUNTIME (docs/markdown.md).
5//!
6//! Runtime rather than a compile-time macro because the string a label shows is usually chosen at
7//! run time: a translation picked from the locale bundle, a value from the network, text the user
8//! typed. A macro can only see literals, which is the least interesting case.
9//!
10//! The grammar is the inline subset: what fits in one label. Block constructs (headings, lists,
11//! quotes, tables) are layout, and layout is `column`/`form`/`list`, not a text attribute.
12
13use crate::{Color, Font, FontSpec, FontWeight, TextRun, Underline};
14
15/// The inline styles the parser recognizes, as a bitmask carried down the nesting stack.
16#[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
30/// Parse inline markdown into the plain text a label shows plus the runs that style it.
31///
32/// Recognized:
33///
34/// | Markdown | Run |
35/// | --- | --- |
36/// | `**bold**`, `__bold__` | bold |
37/// | `*italic*`, `_italic_` | italic |
38/// | `` `code` `` | the monospaced face |
39/// | `~~strike~~` | struck through |
40/// | `[text](url)` | a link run |
41/// | `\*` | the literal character |
42///
43/// `base` is the font the styles vary from. Pass the label's font, so a bold run inside a
44/// `Footnote` paragraph stays footnote-sized.
45///
46/// Anything unrecognized is text: an unclosed `**`, a stray `_` inside a word, a `[` with no
47/// `](…)` after it. Markdown's own rule, and the one that keeps a half-typed string in a live
48/// editor from flickering between readings.
49pub 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
62/// The color a link run takes when the markdown does not say otherwise: the platform-ish tint
63/// blue, matching the `link()` piece so the two read as the same affordance.
64const 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    /// Parse until `close` is found (or the end of input when it is `None`), emitting runs for
77    /// everything under the current style set. Returns whether `close` was actually reached.
78    fn inline(&mut self, styles: Styles, link: Option<&String>, close: Option<&[u8]>) -> bool {
79        // Text accumulated under this style set, flushed as one run whenever a marker interrupts.
80        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            // A backslash escapes the next byte, which is always ASCII punctuation in this
92            // grammar, so pushing it raw cannot split a character.
93            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            // Inside a code span nothing else is a marker: `**` is two asterisks.
102            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    /// Copy one byte of source to the output. Bytes rather than chars: every marker in this
122    /// grammar is ASCII, so a multi-byte character arrives one byte at a time and lands intact.
123    fn push_byte(&mut self) {
124        // SAFETY-free equivalent: the byte is part of a valid UTF-8 string and is copied in
125        // order, so `out` stays valid UTF-8. `push_str` on a subslice keeps that explicit.
126        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    /// Emit the text collected since `start` as a run, unless it is plain or empty.
137    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            // A link run is underlined too now that a run can say so: every platform's own
153            // link rendering draws one, and Day's did not because `TextRun` had no way to.
154            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    /// A nested span: flush what came before, parse the inside under `inner`, and resume. Rolls
166    /// the whole attempt back if the closing marker never arrives, so the opener stays literal.
167    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            // Unclosed: undo everything the attempt produced and let the marker be text.
181            self.runs.truncate(before);
182            self.out.truncate(mark_out);
183            self.at = mark_at;
184            return false;
185        }
186        // The nested text landed after `mark_out`, so the text still pending under the outer
187        // styles is everything from `start` up to there. Flush it, then resume after the span.
188        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    /// Flush an explicit range under the outer styles (the ones in force before this span).
194    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        // Runs must stay ascending: the outer text precedes everything the nested parse pushed.
199        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    /// `[text](url)`. The label is parsed for nested styles; the target is taken literally, since
260    /// a URL's own punctuation is not markup.
261    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        // A link inside a link is not a thing; the outer target wins.
275        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        // Three runs: the bold head, the bold-italic middle, the bold tail.
353        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        // The parser's output feeds `label().runs()`, which rejects overlapping or misordered
397        // runs, so validity is the parser's contract, checked over a spread of shapes.
398        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}