use std::{error::Error, fmt};
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use crate::model::{Block, Document, Paragraph};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseErrorCode {
UnsupportedBlock,
UnsupportedInline,
InvalidStructure,
}
impl ParseErrorCode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::UnsupportedBlock => "codexa::unsupported-block",
Self::UnsupportedInline => "codexa::unsupported-inline",
Self::InvalidStructure => "codexa::invalid-markdown-structure",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
code: ParseErrorCode,
message: String,
}
impl ParseError {
fn new(code: ParseErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
#[must_use]
pub const fn code(&self) -> ParseErrorCode {
self.code
}
}
impl fmt::Display for ParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.code.as_str(), self.message)
}
}
impl Error for ParseError {}
pub fn parse_markdown(source: &str) -> Result<Document, ParseError> {
let mut blocks = Vec::new();
let mut paragraph_text: Option<String> = None;
for event in Parser::new(source) {
match event {
Event::Start(Tag::Paragraph) => {
if paragraph_text.replace(String::new()).is_some() {
return Err(ParseError::new(
ParseErrorCode::InvalidStructure,
"encountered a nested paragraph",
));
}
}
Event::Text(text) => {
let paragraph = paragraph_text.as_mut().ok_or_else(|| {
ParseError::new(
ParseErrorCode::InvalidStructure,
"encountered text outside a paragraph",
)
})?;
paragraph.push_str(&text);
}
Event::SoftBreak => {
let paragraph = paragraph_text.as_mut().ok_or_else(|| {
ParseError::new(
ParseErrorCode::InvalidStructure,
"encountered a soft break outside a paragraph",
)
})?;
paragraph.push('\n');
}
Event::End(TagEnd::Paragraph) => {
let text = paragraph_text.take().ok_or_else(|| {
ParseError::new(
ParseErrorCode::InvalidStructure,
"encountered a paragraph end without a start",
)
})?;
let id = format!("paragraph-{}", blocks.len() + 1);
blocks.push(Block::Paragraph(Paragraph { id, text }));
}
Event::Start(tag) => {
return Err(ParseError::new(
ParseErrorCode::UnsupportedBlock,
format!("unsupported Markdown block `{}`", tag_name(&tag)),
));
}
Event::End(tag) => {
return Err(ParseError::new(
ParseErrorCode::InvalidStructure,
format!("unexpected Markdown block end `{tag:?}`"),
));
}
Event::Code(_)
| Event::Html(_)
| Event::InlineHtml(_)
| Event::FootnoteReference(_)
| Event::TaskListMarker(_)
| Event::InlineMath(_)
| Event::DisplayMath(_)
| Event::HardBreak
| Event::Rule => {
return Err(ParseError::new(
ParseErrorCode::UnsupportedInline,
"unsupported inline Markdown syntax",
));
}
}
}
if paragraph_text.is_some() {
return Err(ParseError::new(
ParseErrorCode::InvalidStructure,
"unterminated paragraph",
));
}
Ok(Document { blocks })
}
fn tag_name(tag: &Tag<'_>) -> &'static str {
match tag {
Tag::Paragraph => "paragraph",
Tag::Heading { .. } => "heading",
Tag::BlockQuote(_) => "block_quote",
Tag::CodeBlock(_) => "code_block",
Tag::HtmlBlock => "html_block",
Tag::List(_) => "list",
Tag::Item => "list_item",
Tag::FootnoteDefinition(_) => "footnote_definition",
Tag::DefinitionList => "definition_list",
Tag::DefinitionListTitle => "definition_list_title",
Tag::DefinitionListDefinition => "definition_list_definition",
Tag::Table(_) => "table",
Tag::TableHead => "table_head",
Tag::TableRow => "table_row",
Tag::TableCell => "table_cell",
Tag::Emphasis => "emphasis",
Tag::Strong => "strong",
Tag::Strikethrough => "strikethrough",
Tag::Superscript => "superscript",
Tag::Subscript => "subscript",
Tag::Link { .. } => "link",
Tag::Image { .. } => "image",
Tag::MetadataBlock(_) => "metadata_block",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_one_paragraph() {
let document = parse_markdown("Hello from Codexa.").expect("paragraph should parse");
assert_eq!(
document,
Document {
blocks: vec![Block::Paragraph(Paragraph {
id: "paragraph-1".into(),
text: "Hello from Codexa.".into(),
})],
}
);
}
#[test]
fn preserves_paragraph_order() {
let document = parse_markdown("First paragraph.\n\nSecond paragraph.")
.expect("paragraphs should parse");
assert_eq!(document.blocks.len(), 2);
assert_eq!(
document.blocks[0],
Block::Paragraph(Paragraph {
id: "paragraph-1".into(),
text: "First paragraph.".into(),
})
);
assert_eq!(
document.blocks[1],
Block::Paragraph(Paragraph {
id: "paragraph-2".into(),
text: "Second paragraph.".into(),
})
);
}
#[test]
fn rejects_headings_with_stable_code() {
let error = parse_markdown("# Unsupported").expect_err("heading should fail");
assert_eq!(error.code(), ParseErrorCode::UnsupportedBlock);
assert!(error.to_string().contains("heading"));
}
}