use super::ast::Inline;
type StyleNodeFactory = fn(Vec<Inline>) -> Inline;
const HTML_STYLE_TAGS: &[(&str, StyleNodeFactory)] = &[
("b", Inline::Bold),
("strong", Inline::Bold),
("i", Inline::Italic),
("em", Inline::Italic),
("u", Inline::Underline),
("s", Inline::Strike),
("del", Inline::Strike),
];
pub(super) fn parse_inlines(input: &str) -> Vec<Inline> {
let mut out = Vec::new();
let mut text = String::new();
let mut i = 0;
while i < input.len() {
let rest = &input[i..];
if let Some(c) = rest.strip_prefix('`')
&& let Some(close) = c.find('`')
{
flush(&mut text, &mut out);
out.push(Inline::Code(c[..close].to_string()));
i += 1 + close + 1;
continue;
}
if !rest.starts_with("$$")
&& let Some(c) = rest.strip_prefix('$')
&& let Some(close) = c.find('$')
&& close > 0
{
flush(&mut text, &mut out);
out.push(Inline::Math(c[..close].to_string()));
i += 1 + close + 1;
continue;
}
if let Some(span) = paired(rest, "**") {
flush(&mut text, &mut out);
out.push(Inline::Bold(parse_inlines(span)));
i += span.len() + 4;
continue;
}
if let Some(span) = paired_word_bounded(input, i, rest, "__") {
flush(&mut text, &mut out);
out.push(Inline::Bold(parse_inlines(span)));
i += span.len() + 4;
continue;
}
if let Some((used, node)) = try_html_style_tag(rest) {
flush(&mut text, &mut out);
out.push(node);
i += used;
continue;
}
if let Some(span) = tag_paired(rest, "sub") {
flush(&mut text, &mut out);
out.push(Inline::Sub(parse_inlines(span)));
i += span.len() + "<sub></sub>".len();
continue;
}
if let Some(span) = paired(rest, "~~") {
flush(&mut text, &mut out);
out.push(Inline::Strike(parse_inlines(span)));
i += span.len() + 4;
continue;
}
if let Some(span) = paired(rest, "*") {
flush(&mut text, &mut out);
out.push(Inline::Italic(parse_inlines(span)));
i += span.len() + 2;
continue;
}
if let Some(span) = paired_word_bounded(input, i, rest, "_") {
flush(&mut text, &mut out);
out.push(Inline::Italic(parse_inlines(span)));
i += span.len() + 2;
continue;
}
if rest.starts_with('[')
&& let Some(link) = parse_link(rest)
{
let (content, url, consumed) = link;
flush(&mut text, &mut out);
out.push(Inline::Link {
content: parse_inlines(content),
url: url.to_string(),
});
i += consumed;
continue;
}
let ch = rest.chars().next().unwrap();
text.push(ch);
i += ch.len_utf8();
}
flush(&mut text, &mut out);
out
}
fn flush(text: &mut String, out: &mut Vec<Inline>) {
if !text.is_empty() {
out.push(Inline::Text(std::mem::take(text)));
}
}
fn paired_word_bounded<'a>(input: &str, i: usize, rest: &'a str, delim: &str) -> Option<&'a str> {
if input[..i]
.chars()
.next_back()
.is_some_and(char::is_alphanumeric)
{
return None;
}
let span = paired(rest, delim)?;
let after_close = &rest[delim.len() + span.len() + delim.len()..];
if after_close
.chars()
.next()
.is_some_and(char::is_alphanumeric)
{
return None;
}
Some(span)
}
fn paired<'a>(rest: &'a str, delim: &str) -> Option<&'a str> {
let after = rest.strip_prefix(delim)?;
let close = after.find(delim)?;
if close == 0 {
return None;
}
Some(&after[..close])
}
fn tag_paired<'a>(rest: &'a str, tag: &str) -> Option<&'a str> {
let after = rest.strip_prefix(&format!("<{tag}>"))?;
let close = after.find(&format!("</{tag}>"))?;
if close == 0 {
return None;
}
Some(&after[..close])
}
fn try_html_style_tag(rest: &str) -> Option<(usize, Inline)> {
for (tag, make) in HTML_STYLE_TAGS {
if let Some(span) = tag_paired(rest, tag) {
let used = span.len() + tag.len() * 2 + 5; return Some((used, make(parse_inlines(span))));
}
}
None
}
fn parse_link(rest: &str) -> Option<(&str, &str, usize)> {
let mid = rest.find("](")?;
let end = rest[mid + 2..].find(')')?;
let text = &rest[1..mid];
let url = &rest[mid + 2..mid + 2 + end];
if url.is_empty() {
return None;
}
Some((text, url, mid + 2 + end + 1))
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(input: &str) -> Vec<String> {
parse_inlines(input)
.iter()
.map(|i| match i {
Inline::Text(_) => "text".into(),
Inline::Bold(_) => "bold".into(),
Inline::Italic(_) => "italic".into(),
Inline::Underline(_) => "underline".into(),
Inline::Strike(_) => "strike".into(),
Inline::Sub(_) => "sub".into(),
Inline::Code(_) => "code".into(),
Inline::Math(_) => "math".into(),
Inline::Link { .. } => "link".into(),
})
.collect()
}
fn text_of(input: &str) -> String {
fn flatten(inlines: &[Inline], out: &mut String) {
for i in inlines {
match i {
Inline::Text(t) | Inline::Code(t) | Inline::Math(t) => out.push_str(t),
Inline::Bold(c)
| Inline::Italic(c)
| Inline::Underline(c)
| Inline::Strike(c)
| Inline::Sub(c) => flatten(c, out),
Inline::Link { content, .. } => flatten(content, out),
}
}
}
let mut out = String::new();
flatten(&parse_inlines(input), &mut out);
out
}
#[test]
fn html_style_tags_map_to_inline_variants() {
assert_eq!(kinds("<b>hi</b>"), vec!["bold"]);
assert_eq!(kinds("<strong>hi</strong>"), vec!["bold"]);
assert_eq!(kinds("<i>hi</i>"), vec!["italic"]);
assert_eq!(kinds("<em>hi</em>"), vec!["italic"]);
assert_eq!(kinds("<u>hi</u>"), vec!["underline"]);
assert_eq!(kinds("<s>hi</s>"), vec!["strike"]);
assert_eq!(kinds("<del>hi</del>"), vec!["strike"]);
}
#[test]
fn html_tags_recurse_and_mix_with_markdown() {
assert_eq!(kinds("<b>a *b* c</b>"), vec!["bold"]);
assert_eq!(kinds("x<b>y</b>z"), vec!["text", "bold", "text"]);
assert_eq!(kinds("**a** <i>b</i>"), vec!["bold", "text", "italic"]);
}
#[test]
fn unmatched_openers_stay_literal() {
assert_eq!(kinds("<b>unclosed"), vec!["text"]);
assert_eq!(text_of("<b>unclosed"), "<b>unclosed");
assert_eq!(kinds("<b></b>"), vec!["text"]);
}
#[test]
fn summary_b_renders_as_bold_not_escape() {
assert_eq!(kinds("<b>Summary text</b>"), vec!["bold"]);
assert_eq!(text_of("<b>Summary text</b>"), "Summary text");
}
}