use crate::{Color, Font, FontSpec, FontWeight, TextRun, Underline};
#[derive(Clone, Copy, Default, PartialEq, Eq)]
struct Styles {
bold: bool,
italic: bool,
code: bool,
strike: bool,
}
impl Styles {
fn is_plain(self, link: Option<&String>) -> bool {
self == Styles::default() && link.is_none()
}
}
pub fn parse(md: &str, base: Font) -> (String, Vec<TextRun>) {
let mut p = Parser {
src: md.as_bytes(),
at: 0,
base,
out: String::with_capacity(md.len()),
runs: Vec::new(),
link_color: LINK_COLOR,
};
p.inline(Styles::default(), None, None);
(p.out, p.runs)
}
const LINK_COLOR: Color = Color::rgb(0.0, 0.478, 1.0);
struct Parser<'a> {
src: &'a [u8],
at: usize,
base: Font,
out: String,
runs: Vec<TextRun>,
link_color: Color,
}
impl Parser<'_> {
fn inline(&mut self, styles: Styles, link: Option<&String>, close: Option<&[u8]>) -> bool {
let mut start = self.out.len();
let mut closed = false;
while self.at < self.src.len() {
if let Some(c) = close
&& self.src[self.at..].starts_with(c)
{
self.at += c.len();
closed = true;
break;
}
let b = self.src[self.at];
if b == b'\\'
&& let Some(&next) = self.src.get(self.at + 1)
&& next.is_ascii_punctuation()
{
self.out.push(next as char);
self.at += 2;
continue;
}
if styles.code {
self.push_byte();
continue;
}
let consumed = match b {
b'`' => self.code_span(styles, link, &mut start),
b'*' | b'_' => self.emphasis(b, styles, link, &mut start),
b'~' => self.strike(styles, link, &mut start),
b'[' => self.link(styles, link, &mut start),
_ => false,
};
if !consumed {
self.push_byte();
}
}
self.flush(start, styles, link);
closed
}
fn push_byte(&mut self) {
let b = self.at;
self.at += 1;
while self.at < self.src.len() && (self.src[self.at] & 0xC0) == 0x80 {
self.at += 1;
}
if let Ok(s) = std::str::from_utf8(&self.src[b..self.at]) {
self.out.push_str(s);
}
}
fn flush(&mut self, start: usize, styles: Styles, link: Option<&String>) {
let end = self.out.len();
if end == start || styles.is_plain(link) {
return;
}
self.runs.push(TextRun {
range: start..end,
font: FontSpec {
style: self.base,
weight: styles.bold.then_some(FontWeight::Bold),
italic: styles.italic,
monospace: styles.code,
..Default::default()
},
color: link.is_some().then_some(self.link_color),
underline: if link.is_some() {
Underline::Single
} else {
Underline::None
},
strikethrough: styles.strike,
link: link.cloned(),
..TextRun::default()
});
}
fn nested(
&mut self,
open: usize,
close: &[u8],
outer: Styles,
inner: Styles,
link: Option<&String>,
start: &mut usize,
) -> bool {
let (mark_at, mark_out) = (self.at, self.out.len());
self.at += open;
let before = self.runs.len();
if !self.inline(inner, link, Some(close)) {
self.runs.truncate(before);
self.out.truncate(mark_out);
self.at = mark_at;
return false;
}
let outer_start = std::mem::replace(start, self.out.len());
self.flush_range(outer_start, mark_out, outer, link);
true
}
fn flush_range(&mut self, start: usize, end: usize, styles: Styles, link: Option<&String>) {
if end <= start || styles.is_plain(link) {
return;
}
let at = self
.runs
.iter()
.position(|r| r.range.start >= end)
.unwrap_or(self.runs.len());
let run = TextRun {
range: start..end,
font: FontSpec {
style: self.base,
weight: styles.bold.then_some(FontWeight::Bold),
italic: styles.italic,
monospace: styles.code,
..Default::default()
},
color: link.is_some().then_some(self.link_color),
underline: if link.is_some() {
Underline::Single
} else {
Underline::None
},
strikethrough: styles.strike,
link: link.cloned(),
..TextRun::default()
};
self.runs.insert(at, run);
}
fn code_span(&mut self, styles: Styles, link: Option<&String>, start: &mut usize) -> bool {
let mut inner = styles;
inner.code = true;
self.nested(1, b"`", styles, inner, link, start)
}
fn emphasis(
&mut self,
marker: u8,
styles: Styles,
link: Option<&String>,
start: &mut usize,
) -> bool {
let double = self.src.get(self.at + 1) == Some(&marker);
let mut inner = styles;
if double {
inner.bold = true;
self.nested(2, &[marker, marker], styles, inner, link, start)
} else {
inner.italic = true;
self.nested(1, &[marker], styles, inner, link, start)
}
}
fn strike(&mut self, styles: Styles, link: Option<&String>, start: &mut usize) -> bool {
if self.src.get(self.at + 1) != Some(&b'~') {
return false;
}
let mut inner = styles;
inner.strike = true;
self.nested(2, b"~~", styles, inner, link, start)
}
fn link(&mut self, styles: Styles, outer: Option<&String>, start: &mut usize) -> bool {
let Some(close) = find(self.src, self.at + 1, b']') else {
return false;
};
if self.src.get(close + 1) != Some(&b'(') {
return false;
}
let Some(paren) = find(self.src, close + 2, b')') else {
return false;
};
let Ok(url) = std::str::from_utf8(&self.src[close + 2..paren]) else {
return false;
};
let target = outer.cloned().unwrap_or_else(|| url.trim().to_string());
let mark_out = self.out.len();
let saved_at = self.at;
self.at += 1;
let before = self.runs.len();
if !self.inline(styles, Some(&target), Some(b"]")) {
self.runs.truncate(before);
self.out.truncate(mark_out);
self.at = saved_at;
return false;
}
self.at = paren + 1;
let outer_end = std::mem::replace(start, self.out.len());
self.flush_range(outer_end, mark_out, styles, outer);
true
}
}
fn find(src: &[u8], from: usize, b: u8) -> Option<usize> {
(from..src.len()).find(|&i| src[i] == b)
}
#[cfg(test)]
mod tests {
use super::*;
fn render(md: &str) -> (String, Vec<TextRun>) {
parse(md, Font::Body)
}
#[test]
fn plain_text_produces_no_runs() {
let (t, r) = render("nothing to see");
assert_eq!(t, "nothing to see");
assert!(r.is_empty());
}
#[test]
fn the_markers_are_stripped_from_the_text() {
let (t, _) = render("**a** *b* `c` ~~d~~ [e](u)");
assert_eq!(t, "a b c d e");
}
#[test]
fn each_style_lands_on_its_own_word() {
let (t, r) = render("**bold** and *italic*");
assert_eq!(t, "bold and italic");
assert_eq!(r.len(), 2);
assert_eq!(&t[r[0].range.clone()], "bold");
assert_eq!(r[0].font.weight, Some(FontWeight::Bold));
assert_eq!(&t[r[1].range.clone()], "italic");
assert!(r[1].font.italic);
}
#[test]
fn code_spans_are_literal_inside() {
let (t, r) = render("`**not bold**`");
assert_eq!(t, "**not bold**");
assert_eq!(r.len(), 1);
assert!(r[0].font.monospace);
assert_eq!(r[0].font.weight, None);
}
#[test]
fn a_link_carries_its_target_and_a_color() {
let (t, r) = render("see [the docs](https://day.dev/x) now");
assert_eq!(t, "see the docs now");
assert_eq!(r.len(), 1);
assert_eq!(&t[r[0].range.clone()], "the docs");
assert_eq!(r[0].link.as_deref(), Some("https://day.dev/x"));
assert!(r[0].color.is_some());
}
#[test]
fn styles_nest() {
let (t, r) = render("**bold with *italic* inside**");
assert_eq!(t, "bold with italic inside");
assert_eq!(r.len(), 3);
assert!(r.iter().all(|x| x.font.weight == Some(FontWeight::Bold)));
assert_eq!(r.iter().filter(|x| x.font.italic).count(), 1);
assert_eq!(&t[r[1].range.clone()], "italic");
assert!(crate::runs_are_valid(&t, &r).is_ok());
}
#[test]
fn a_link_can_hold_styles() {
let (t, r) = render("[**bold** link](u)");
assert_eq!(t, "bold link");
assert!(r.iter().all(|x| x.link.as_deref() == Some("u")));
assert_eq!(r.iter().filter(|x| x.font.weight.is_some()).count(), 1);
assert!(crate::runs_are_valid(&t, &r).is_ok());
}
#[test]
fn an_unclosed_marker_stays_literal() {
for md in ["**half bold", "a * b", "`open", "~~nope", "[text](no-close"] {
let (t, r) = render(md);
assert_eq!(t, md, "unclosed marker should be text: {md}");
assert!(r.is_empty(), "unclosed marker should not style: {md}");
}
}
#[test]
fn backslash_escapes_a_marker() {
let (t, r) = render(r"\*not italic\*");
assert_eq!(t, "*not italic*");
assert!(r.is_empty());
}
#[test]
fn multibyte_text_keeps_its_ranges_on_boundaries() {
let (t, r) = render("héllo **wörld** 🌍 ~~ok~~");
assert_eq!(t, "héllo wörld 🌍 ok");
assert_eq!(&t[r[0].range.clone()], "wörld");
assert_eq!(&t[r[1].range.clone()], "ok");
assert!(crate::runs_are_valid(&t, &r).is_ok());
}
#[test]
fn every_parse_produces_valid_runs() {
for md in [
"",
"plain",
"**a**b*c*`d`~~e~~[f](g)",
"**a *b* c** d",
"[a **b** c](u) d",
"~~**both**~~",
"`a` `b` `c`",
"**",
"*_*_*",
r"\[not a link\](x)",
] {
let (t, r) = render(md);
assert!(
crate::runs_are_valid(&t, &r).is_ok(),
"invalid runs for {md:?}: {r:?}"
);
}
}
}