use super::dialect;
use pulldown_cmark::{Event, Tag, TagEnd};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Emphasis {
#[default]
Plain,
Strong,
Emphasis,
Code,
Heading(u8),
Struck,
Quote,
Marker,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Span {
pub text: String,
pub emphasis: Emphasis,
}
impl Span {
#[must_use]
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: Self::displayable(text),
emphasis: Emphasis::Plain,
}
}
#[must_use]
pub fn styled(text: impl Into<String>, emphasis: Emphasis) -> Self {
Self {
text: Self::displayable(text),
emphasis,
}
}
fn displayable(text: impl Into<String>) -> String {
match crate::notes_scan::neutralize_for_display(&text.into()) {
std::borrow::Cow::Borrowed(clean) => clean.to_string(),
std::borrow::Cow::Owned(fixed) => fixed,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SpanLine {
pub spans: Vec<Span>,
}
impl SpanLine {
#[must_use]
pub fn text(&self) -> String {
self.spans.iter().map(|s| s.text.as_str()).collect()
}
#[must_use]
pub fn is_blank(&self) -> bool {
self.spans.iter().all(|s| s.text.trim().is_empty())
}
}
#[must_use]
pub fn project(markdown: &str) -> Vec<SpanLine> {
let markdown = &*crate::notes_scan::neutralize_for_display(markdown);
let mut out: Vec<SpanLine> = Vec::new();
let mut line = SpanLine::default();
let mut stack: Vec<Emphasis> = Vec::new();
let mut in_code_block = false;
let mut list_depth: usize = 0;
let current = |stack: &[Emphasis]| stack.last().copied().unwrap_or_default();
let push = |line: &mut SpanLine, text: &str, emphasis: Emphasis| {
if text.is_empty() {
return;
}
match line.spans.last_mut() {
Some(prev) if prev.emphasis == emphasis => prev.text.push_str(text),
_ => line.spans.push(Span::styled(text, emphasis)),
}
};
let break_line = |line: &mut SpanLine, out: &mut Vec<SpanLine>| {
out.push(std::mem::take(line));
};
for event in dialect::parse(markdown) {
match event {
Event::Start(Tag::Strong) => stack.push(Emphasis::Strong),
Event::Start(Tag::Emphasis) => stack.push(Emphasis::Emphasis),
Event::Start(Tag::Strikethrough) => stack.push(Emphasis::Struck),
Event::Start(Tag::BlockQuote(_)) => stack.push(Emphasis::Quote),
Event::Start(Tag::Heading { level, .. }) => {
stack.push(Emphasis::Heading(level as u8));
}
Event::Start(Tag::CodeBlock(_)) => {
in_code_block = true;
stack.push(Emphasis::Code);
}
Event::Start(Tag::List(_)) => list_depth += 1,
Event::Start(Tag::Item) => {
let indent = " ".repeat(list_depth.saturating_sub(1));
push(&mut line, &format!("{indent}• "), Emphasis::Marker);
}
Event::End(
TagEnd::Strong
| TagEnd::Emphasis
| TagEnd::Strikethrough
| TagEnd::BlockQuote(_)
| TagEnd::Heading(_),
) => {
stack.pop();
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
stack.pop();
break_line(&mut line, &mut out);
}
Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1),
Event::End(TagEnd::Item) => break_line(&mut line, &mut out),
Event::End(TagEnd::Paragraph) => break_line(&mut line, &mut out),
Event::Text(text) => {
let emphasis = current(&stack);
if in_code_block {
let mut parts = text.split('\n').peekable();
while let Some(part) = parts.next() {
push(&mut line, part, emphasis);
if parts.peek().is_some() {
break_line(&mut line, &mut out);
}
}
} else {
push(&mut line, &text, emphasis);
}
}
Event::Code(code) => push(&mut line, &code, Emphasis::Code),
Event::SoftBreak | Event::HardBreak => break_line(&mut line, &mut out),
Event::Html(html) | Event::InlineHtml(html) => {
push(&mut line, &html, Emphasis::Plain);
}
Event::Rule => {
break_line(&mut line, &mut out);
push(&mut line, "───", Emphasis::Marker);
break_line(&mut line, &mut out);
}
Event::TaskListMarker(done) => {
push(
&mut line,
if done { "[x] " } else { "[ ] " },
Emphasis::Marker,
);
}
_ => {}
}
}
if !line.spans.is_empty() {
out.push(line);
}
while out.last().is_some_and(SpanLine::is_blank) {
out.pop();
}
out
}
#[cfg(test)]
mod c2 {
use super::*;
fn flat(markdown: &str) -> Vec<String> {
project(markdown).iter().map(SpanLine::text).collect()
}
fn roles(markdown: &str) -> Vec<Vec<(String, Emphasis)>> {
project(markdown)
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| (s.text.clone(), s.emphasis))
.collect()
})
.collect()
}
#[test]
fn plain_text_projects_to_one_plain_line() {
assert_eq!(
roles("hello world"),
vec![vec![("hello world".to_string(), Emphasis::Plain)]]
);
}
#[test]
fn the_inline_vocabulary_carries_meaning_not_style() {
assert_eq!(
roles("**bold** and *soft* and `code` and ~~gone~~"),
vec![vec![
("bold".to_string(), Emphasis::Strong),
(" and ".to_string(), Emphasis::Plain),
("soft".to_string(), Emphasis::Emphasis),
(" and ".to_string(), Emphasis::Plain),
("code".to_string(), Emphasis::Code),
(" and ".to_string(), Emphasis::Plain),
("gone".to_string(), Emphasis::Struck),
]]
);
}
#[test]
fn a_heading_carries_its_level() {
assert_eq!(
roles("### three"),
vec![vec![("three".to_string(), Emphasis::Heading(3))]]
);
}
#[test]
fn the_innermost_meaning_wins() {
let r = roles("**bold `code`**");
assert_eq!(r[0][0], ("bold ".to_string(), Emphasis::Strong));
assert_eq!(r[0][1], ("code".to_string(), Emphasis::Code));
}
#[test]
fn adjacent_runs_of_one_meaning_merge() {
let line = &project("plain *a* more")[0];
assert_eq!(line.spans.len(), 3, "{:?}", line.spans);
}
#[test]
fn a_code_block_keeps_its_line_breaks() {
assert_eq!(
flat("```\nfn a() {}\nfn b() {}\n```"),
vec!["fn a() {}".to_string(), "fn b() {}".to_string()]
);
let r = roles("```\nfn a() {}\n```");
assert_eq!(r[0][0].1, Emphasis::Code);
}
#[test]
fn soft_and_hard_breaks_both_start_a_new_line() {
let lines = vec!["one".to_string(), "two".to_string()];
assert_eq!(flat("one\ntwo"), lines);
assert_eq!(flat("one \ntwo"), lines);
}
#[test]
fn list_items_get_a_marker_and_their_own_lines() {
let r = roles("- first\n- second");
assert_eq!(r.len(), 2);
assert_eq!(r[0][0], ("• ".to_string(), Emphasis::Marker));
assert_eq!(r[0][1], ("first".to_string(), Emphasis::Plain));
}
#[test]
fn a_task_list_marker_is_visible() {
let f = flat("- [x] done\n- [ ] todo");
assert_eq!(f[0], "• [x] done");
assert_eq!(f[1], "• [ ] todo");
}
#[test]
fn raw_html_is_literal_text_never_interpreted() {
let f = flat("a <b>tag</b> here");
assert!(f[0].contains("<b>"), "{f:?}");
assert!(f[0].contains("</b>"), "{f:?}");
}
#[test]
fn flattening_a_projection_loses_styling_and_nothing_else() {
for markdown in [
"plain words",
"**bold** and `code`",
"### heading",
"- a\n- b",
"> quoted",
] {
let flattened = flat(markdown).join("\n");
for word in markdown
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|w| !w.is_empty())
{
assert!(
flattened.contains(word),
"{word:?} vanished from {markdown:?} -> {flattened:?}"
);
}
}
}
#[test]
fn the_projection_would_notice_a_role_that_stopped_being_carried() {
let styled = project("**bold**");
assert_eq!(styled[0].spans[0].emphasis, Emphasis::Strong);
let unstyled = project("bold");
assert_eq!(unstyled[0].spans[0].emphasis, Emphasis::Plain);
assert_ne!(styled[0].spans[0].emphasis, unstyled[0].spans[0].emphasis);
}
#[test]
fn the_projection_names_no_renderer_and_leaks_no_parser_type() {
let source = include_str!("spans.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("the production half");
let code: String = production
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
assert!(
!code.contains("ratatui"),
"a renderer type reached the core projection"
);
for line in code.lines().filter(|l| l.trim_start().starts_with("pub ")) {
assert!(
!line.contains("Event") && !line.contains("Parser") && !line.contains("Tag"),
"a pulldown type escaped through a public signature: {line}"
);
}
assert!(code.contains("dialect::parse"), "the scan read nothing");
}
}