1extern crate alloc;
102
103#[cfg_attr(all(), allow(clippy::mem_forget))]
104mod parser_cell {
105 use self_cell::self_cell;
106
107 use super::MarkdownParser;
108
109 self_cell!(
110 pub(super) struct ParserCell {
111 owner: String,
112 #[covariant]
113 dependent: MarkdownParser,
114 }
115 );
116}
117
118mod html;
119
120use alloc::collections::VecDeque;
121use std::io::{Read, Seek};
122
123pub use docspec_core::EventSource;
124use docspec_core::{Event, ImageSource, ListStyleType, Result, TableHeaderScope, TextStyleKind};
125use parser_cell::ParserCell;
126use pulldown_cmark::{CodeBlockKind, CowStr, HeadingLevel, Options, Parser, Tag, TagEnd};
127
128struct MarkdownParser<'a>(Parser<'a>);
129
130#[derive(Clone, Copy, PartialEq, Eq)]
132enum BlockState {
133 AutoParagraph,
135 Explicit,
137 None,
139 PendingExplicit,
141}
142
143#[derive(Clone, Copy, PartialEq, Eq)]
145enum Phase {
146 Finished,
148 NotStarted,
150 Running,
152}
153
154struct ListContext {
156 item_open: bool,
158 ordered: bool,
160 pending_start: Option<u64>,
163}
164
165struct ImageBuffer {
167 alt_buf: String,
169 title: Option<String>,
171 url: String,
173}
174
175enum MarkdownPulldownEvent {
176 BlockHtml(String),
177 Code(String),
178 End(TagEnd),
179 HardBreak,
180 InlineHtml(String),
181 Ignored,
182 Rule,
183 SoftBreak,
184 Start(MarkdownStartTag),
185 Text(String),
186}
187
188enum MarkdownStartTag {
189 BlockQuote,
190 CodeBlock {
191 syntax: Option<String>,
192 },
193 Emphasis,
194 Heading {
195 level: HeadingLevel,
196 },
197 Image {
198 dest_url: String,
199 title: Option<String>,
200 },
201 Item,
202 Link {
203 dest_url: String,
204 title: Option<String>,
205 },
206 HtmlBlock,
207 List(Option<u64>),
208 Paragraph,
209 Strikethrough,
210 Strong,
211 Table,
212 TableCell,
213 TableHead,
214 TableRow,
215}
216
217struct LinkBuffer {
219 href: String,
221 started: bool,
223 title: Option<String>,
225}
226
227pub struct MarkdownReader {
245 block_state: BlockState,
247 cell: ParserCell,
249 code_block_buffer: Option<String>,
251 image: Option<ImageBuffer>,
253 html_block_heading_acc: crate::html::translator::BlockHeadingAccumulator,
255 html_block_inline_stack: docspec_core::StyleStack,
257 in_html_block: bool,
259 in_preformatted: bool,
261 in_table_head: bool,
263 link: Option<LinkBuffer>,
265 list_stack: alloc::vec::Vec<ListContext>,
268 inline_style_stack: docspec_core::StyleStack,
270 phase: Phase,
272 queue: VecDeque<Event>,
274}
275
276impl MarkdownReader {
277 fn close_current_item_if_open(&mut self) {
278 let Some(ctx) = self.list_stack.last() else {
279 return;
280 };
281 if !ctx.item_open {
282 return;
283 }
284
285 let ordered = ctx.ordered;
286 self.flush_html_styles();
287 if ordered {
288 self.queue.push_back(Event::EndOrderedListItem);
289 } else {
290 self.queue.push_back(Event::EndUnorderedListItem);
291 }
292 if let Some(current_ctx) = self.list_stack.last_mut() {
293 current_ctx.item_open = false;
294 }
295 self.block_state = BlockState::None;
296 }
297
298 fn close_style(&mut self, kind: &TextStyleKind) {
299 if self.in_preformatted {
300 return;
301 }
302
303 for event in self.inline_style_stack.close(kind) {
304 self.queue.push_back(event);
305 }
306 }
307
308 fn open_style(&mut self, kind: &TextStyleKind) {
309 if !self.in_preformatted {
310 for event in self.inline_style_stack.open(kind.clone()) {
311 self.queue.push_back(event);
312 }
313 }
314 }
315
316 fn enqueue_text(&mut self, content: String) {
317 for event in self.inline_style_stack.note_text() {
318 self.queue.push_back(event);
319 }
320 let text_event = Event::Text { content };
321 self.queue.push_back(text_event);
322 }
323
324 fn flush_html_styles(&mut self) {
325 for event in self.inline_style_stack.close_all() {
326 self.queue.push_back(event);
327 }
328 }
329
330 fn emit_pending_link_start(&mut self) {
333 self.flush_pending_paragraph_start();
334 if let Some(link) = self.link.as_mut() {
335 if !link.started {
336 self.queue.push_back(Event::StartLink {
337 href: link.href.clone(),
338 id: None,
339 title: link.title.clone(),
340 });
341 link.started = true;
342 }
343 }
344 }
345
346 fn flush_pending_paragraph_start(&mut self) {
349 if self.block_state == BlockState::PendingExplicit {
350 self.queue.push_back(Event::StartParagraph {
351 alignment: None,
352 id: None,
353 });
354 self.block_state = BlockState::Explicit;
355 }
356 }
357
358 fn from_owned_string(source: String) -> Self {
359 let options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
360 let cell = ParserCell::new(source, |s| MarkdownParser(Parser::new_ext(s, options)));
361 Self {
362 block_state: BlockState::None,
363 cell,
364 code_block_buffer: None,
365 image: None,
366 html_block_heading_acc: crate::html::translator::BlockHeadingAccumulator::default(),
367 html_block_inline_stack: docspec_core::StyleStack::default(),
368 in_html_block: false,
369 in_preformatted: false,
370 in_table_head: false,
371 link: None,
372 list_stack: Vec::new(),
373 inline_style_stack: docspec_core::StyleStack::default(),
374 phase: Phase::NotStarted,
375 queue: VecDeque::new(),
376 }
377 }
378
379 #[inline]
388 pub fn from_reader<R>(mut reader: R) -> Result<Self>
389 where
390 R: Read + Seek + Send + 'static,
391 {
392 let mut source = String::new();
393 reader.read_to_string(&mut source)?;
394 Ok(Self::from_owned_string(source))
395 }
396
397 #[inline]
409 #[must_use]
410 #[expect(
411 clippy::should_implement_trait,
412 reason = "constructor name is required for reader API consistency"
413 )]
414 pub fn from_str(input: &str) -> Self {
415 Self::from_owned_string(input.to_owned())
416 }
417
418 fn handle_code(&mut self, content: String) {
419 if let Some(img) = &mut self.image {
420 img.alt_buf.push_str(&content);
421 } else {
422 self.emit_pending_link_start();
423 if self.block_state == BlockState::None {
424 self.queue.push_back(Event::StartParagraph {
425 alignment: None,
426 id: None,
427 });
428 self.block_state = BlockState::AutoParagraph;
429 }
430 self.open_style(&TextStyleKind::Code);
431 self.enqueue_text(content);
432 self.close_style(&TextStyleKind::Code);
433 }
434 }
435
436 fn handle_end_code_block(&mut self) {
439 if let Some(buf) = self.code_block_buffer.take() {
440 let content = buf.strip_suffix('\n').unwrap_or(&buf).to_owned();
441 if !content.is_empty() {
442 self.enqueue_text(content);
443 }
444 }
445 self.in_preformatted = false;
446 self.push_event_end(Event::EndPreformatted);
447 }
448
449 fn handle_end_image(&mut self) {
453 let Some(img) = self.image.take() else { return };
454 self.flush_pending_paragraph_start();
455 let trimmed = img.alt_buf.trim();
456 let alt = if trimmed.is_empty() {
457 None
458 } else {
459 Some(trimmed.to_owned())
460 };
461 let decorative = alt.is_none();
462 self.queue.push_back(Event::Image {
463 source: ImageSource::Uri { uri: img.url },
464 alt,
465 title: img.title,
466 decorative,
467 id: None,
468 });
469 }
470
471 fn handle_end_item(&mut self) {
474 if self.block_state == BlockState::AutoParagraph {
475 self.flush_html_styles();
476 self.queue.push_back(Event::EndParagraph);
477 }
478 self.close_current_item_if_open();
479 self.block_state = BlockState::None;
480 }
481
482 fn handle_end_link(&mut self) {
484 let Some(link) = self.link.take() else { return };
485 if link.started {
486 self.queue.push_back(Event::EndLink);
487 } else {
488 self.flush_pending_paragraph_start();
489 self.queue.push_back(Event::StartLink {
490 href: link.href,
491 id: None,
492 title: link.title,
493 });
494 self.queue.push_back(Event::EndLink);
495 }
496 }
497
498 fn handle_end_list(&mut self) {
500 self.close_current_item_if_open();
501 self.list_stack.pop();
502 self.block_state = BlockState::None;
503 }
504
505 fn handle_end_table_cell(&mut self) {
508 if self.in_table_head {
509 self.push_event_end(Event::EndTableHeader);
510 } else {
511 self.push_event_end(Event::EndTableCell);
512 }
513 }
514
515 fn handle_end_table_head(&mut self) {
517 self.push_event_end(Event::EndTableRow);
518 self.in_table_head = false;
519 }
520
521 fn handle_end_tag(&mut self, tag_end: TagEnd) {
527 match tag_end {
528 TagEnd::BlockQuote(_) => self.push_event_end(Event::EndBlockQuote),
529 TagEnd::CodeBlock => self.handle_end_code_block(),
530 TagEnd::Emphasis => self.close_style(&TextStyleKind::Italic),
531 TagEnd::Heading(_) => self.push_event_end(Event::EndHeading),
532 TagEnd::HtmlBlock => {
533 self.in_html_block = false;
534 for event in self.html_block_inline_stack.close_all() {
535 self.queue.push_back(event);
536 }
537 if let Some(event) = self.html_block_heading_acc.finish_block() {
538 self.queue.push_back(event);
539 }
540 }
541 TagEnd::Image => self.handle_end_image(),
542 TagEnd::Item => self.handle_end_item(),
543 TagEnd::Link => self.handle_end_link(),
544 TagEnd::List(_) => self.handle_end_list(),
545 TagEnd::Paragraph => {
546 if self.block_state == BlockState::PendingExplicit {
547 self.flush_html_styles();
548 self.block_state = BlockState::None;
549 } else {
550 self.push_event_end(Event::EndParagraph);
551 }
552 }
553 TagEnd::Strikethrough => self.close_style(&TextStyleKind::Strikethrough),
554 TagEnd::Strong => self.close_style(&TextStyleKind::Bold),
555 TagEnd::Table => self.push_event_end(Event::EndTable),
556 TagEnd::TableCell => self.handle_end_table_cell(),
557 TagEnd::TableHead => self.handle_end_table_head(),
558 TagEnd::TableRow => self.push_event_end(Event::EndTableRow),
559 TagEnd::DefinitionList
561 | TagEnd::DefinitionListDefinition
562 | TagEnd::DefinitionListTitle
563 | TagEnd::FootnoteDefinition
564 | TagEnd::MetadataBlock(_)
565 | TagEnd::Subscript
566 | TagEnd::Superscript => {}
567 }
568 }
569
570 fn handle_item_start(&mut self) {
571 let depth = self.list_stack.len().saturating_sub(1);
572 let level = u32::try_from(depth).map_or(u32::MAX, |v| v);
573 if let Some(ctx) = self.list_stack.last_mut() {
574 if ctx.ordered {
575 self.queue.push_back(Event::StartOrderedListItem {
576 start: ctx.pending_start.take(),
577 style_type: ListStyleType::Decimal,
578 level,
579 id: None,
580 });
581 } else {
582 self.queue.push_back(Event::StartUnorderedListItem {
583 style_type: ListStyleType::Disc,
584 level,
585 id: None,
586 });
587 }
588 ctx.item_open = true;
589 self.block_state = BlockState::Explicit;
590 }
591 }
592
593 fn handle_list_start(&mut self, start_opt: Option<u64>) {
594 self.list_stack.push(ListContext {
595 item_open: false,
596 ordered: start_opt.is_some(),
597 pending_start: start_opt,
598 });
599 }
600
601 fn handle_start_code_block(&mut self, syntax: Option<String>) {
604 self.code_block_buffer = Some(String::new());
605 self.in_preformatted = true;
606 self.push_event_start(Event::StartPreformatted { id: None, syntax });
607 }
608
609 fn handle_start_heading(&mut self, level: HeadingLevel) {
611 let level_u8 = match level {
612 HeadingLevel::H1 => 1,
613 HeadingLevel::H2 => 2,
614 HeadingLevel::H3 => 3,
615 HeadingLevel::H4 => 4,
616 HeadingLevel::H5 => 5,
617 HeadingLevel::H6 => 6,
618 };
619 self.push_event_start(Event::StartHeading {
620 level: level_u8,
621 id: None,
622 });
623 }
624
625 fn handle_start_image(&mut self, dest_url: String, title: Option<String>) {
629 self.flush_pending_paragraph_start();
638 if let Some(link) = self.link.take() {
639 if link.started {
640 self.queue.push_back(Event::EndLink);
641 } else {
642 self.queue.push_back(Event::StartLink {
643 href: link.href,
644 id: None,
645 title: link.title,
646 });
647 self.queue.push_back(Event::EndLink);
648 }
649 }
650
651 self.image = Some(ImageBuffer {
652 alt_buf: String::new(),
653 title,
654 url: dest_url,
655 });
656 }
657
658 fn handle_start_link(&mut self, dest_url: String, title: Option<String>) {
663 self.link = Some(LinkBuffer {
664 href: dest_url,
665 started: false,
666 title,
667 });
668 }
669
670 fn handle_start_table_cell(&mut self) {
673 if self.in_table_head {
674 self.push_event_start(Event::StartTableHeader {
675 scope: Some(TableHeaderScope::Column),
676 abbr: None,
677 colspan: None,
678 rowspan: None,
679 id: None,
680 });
681 } else {
682 self.push_event_start(Event::StartTableCell {
683 colspan: None,
684 rowspan: None,
685 id: None,
686 });
687 }
688 }
689
690 fn handle_start_table_head(&mut self) {
692 self.in_table_head = true;
693 self.push_event_start(Event::StartTableRow { id: None });
694 }
695
696 fn handle_start_tag(&mut self, tag: MarkdownStartTag) {
702 match tag {
703 MarkdownStartTag::BlockQuote => {
704 self.push_event_start(Event::StartBlockQuote { id: None });
705 }
706 MarkdownStartTag::CodeBlock { syntax } => self.handle_start_code_block(syntax),
707 MarkdownStartTag::Emphasis => self.open_style(&TextStyleKind::Italic),
708 MarkdownStartTag::Heading { level } => self.handle_start_heading(level),
709 MarkdownStartTag::HtmlBlock => self.in_html_block = true,
710 MarkdownStartTag::Image { dest_url, title } => self.handle_start_image(dest_url, title),
711 MarkdownStartTag::Item => self.handle_item_start(),
712 MarkdownStartTag::Link { dest_url, title } => self.handle_start_link(dest_url, title),
713 MarkdownStartTag::List(start_opt) => self.handle_list_start(start_opt),
714 MarkdownStartTag::Paragraph => self.block_state = BlockState::PendingExplicit,
715 MarkdownStartTag::Strikethrough => self.open_style(&TextStyleKind::Strikethrough),
716 MarkdownStartTag::Strong => self.open_style(&TextStyleKind::Bold),
717 MarkdownStartTag::Table => self.push_event_start(Event::StartTable { id: None }),
718 MarkdownStartTag::TableCell => self.handle_start_table_cell(),
719 MarkdownStartTag::TableHead => self.handle_start_table_head(),
720 MarkdownStartTag::TableRow => self.push_event_start(Event::StartTableRow { id: None }),
721 }
722 }
723
724 fn handle_text(&mut self, content: String) {
725 if let Some(img) = &mut self.image {
726 img.alt_buf.push_str(&content);
727 } else if let Some(buf) = &mut self.code_block_buffer {
728 buf.push_str(&content);
729 } else {
730 self.emit_pending_link_start();
731 if self.block_state == BlockState::None {
732 self.queue.push_back(Event::StartParagraph {
733 alignment: None,
734 id: None,
735 });
736 self.block_state = BlockState::AutoParagraph;
737 }
738 self.enqueue_text(content);
739 }
740 }
741
742 fn next_pulldown_event(&mut self) -> Option<MarkdownPulldownEvent> {
743 self.cell.with_dependent_mut(|_, dep| {
744 dep.0.next().map(|event| match event {
745 pulldown_cmark::Event::Start(tag) => markdown_start_tag(tag)
746 .map_or(MarkdownPulldownEvent::Ignored, MarkdownPulldownEvent::Start),
747 pulldown_cmark::Event::End(tag_end) => MarkdownPulldownEvent::End(tag_end),
748 pulldown_cmark::Event::Text(text) => {
749 MarkdownPulldownEvent::Text(text.into_string())
750 }
751 pulldown_cmark::Event::Code(code) => {
752 MarkdownPulldownEvent::Code(code.into_string())
753 }
754 pulldown_cmark::Event::HardBreak => MarkdownPulldownEvent::HardBreak,
755 pulldown_cmark::Event::SoftBreak => MarkdownPulldownEvent::SoftBreak,
756 pulldown_cmark::Event::Rule => MarkdownPulldownEvent::Rule,
757 pulldown_cmark::Event::InlineHtml(tag_str) => {
758 MarkdownPulldownEvent::InlineHtml(tag_str.into_string())
759 }
760 pulldown_cmark::Event::Html(fragment) => {
761 MarkdownPulldownEvent::BlockHtml(fragment.into_string())
762 }
763 pulldown_cmark::Event::DisplayMath(_)
764 | pulldown_cmark::Event::FootnoteReference(_)
765 | pulldown_cmark::Event::InlineMath(_)
766 | pulldown_cmark::Event::TaskListMarker(_) => MarkdownPulldownEvent::Ignored,
767 })
768 })
769 }
770
771 fn process_next_pulldown_event(&mut self) {
772 let Some(pm_event) = self.next_pulldown_event() else {
773 if self.phase != Phase::Finished {
774 self.phase = Phase::Finished;
775 self.flush_html_styles();
776 self.queue.push_back(Event::EndDocument);
777 }
778 return;
779 };
780
781 match pm_event {
782 MarkdownPulldownEvent::BlockHtml(fragment) => {
783 let events = crate::html::translator::translate_block(
784 &fragment,
785 &mut self.html_block_heading_acc,
786 &mut self.html_block_inline_stack,
787 self.in_preformatted,
788 );
789 for event in events {
790 match event {
791 Event::Text { content } => self.enqueue_text(content),
792 other => self.queue.push_back(other),
793 }
794 }
795 }
796 MarkdownPulldownEvent::Start(tag) => self.handle_start_tag(tag),
797 MarkdownPulldownEvent::End(tag_end) => self.handle_end_tag(tag_end),
798 MarkdownPulldownEvent::Text(text) => self.handle_text(text),
799 MarkdownPulldownEvent::Code(code) => self.handle_code(code),
800 MarkdownPulldownEvent::InlineHtml(fragment) => {
801 let events = crate::html::translator::translate_inline(
802 &fragment,
803 &mut self.inline_style_stack,
804 self.in_preformatted,
805 );
806 for event in events {
807 match event {
808 Event::Text { content } => self.enqueue_text(content),
809 other => self.queue.push_back(other),
810 }
811 }
812 }
813 MarkdownPulldownEvent::HardBreak => {
814 if let Some(img) = &mut self.image {
815 img.alt_buf.push(' ');
816 } else if self.block_state == BlockState::PendingExplicit {
817 } else {
819 self.emit_pending_link_start();
820 self.queue.push_back(Event::LineBreak);
821 }
822 }
823 MarkdownPulldownEvent::SoftBreak => {
824 if let Some(img) = &mut self.image {
825 img.alt_buf.push(' ');
826 } else if self.block_state == BlockState::PendingExplicit {
827 } else {
829 self.emit_pending_link_start();
830 self.queue.push_back(Event::SoftBreak);
831 }
832 }
833 MarkdownPulldownEvent::Rule => {
834 self.queue.push_back(Event::ThematicBreak { id: None });
835 }
836 MarkdownPulldownEvent::Ignored => {}
837 }
838 }
839
840 fn push_event(&mut self, event: Event, state: BlockState) {
841 self.queue.push_back(event);
842 self.block_state = state;
843 }
844
845 fn push_event_end(&mut self, event: Event) {
846 self.flush_html_styles();
847 self.push_event(event, BlockState::None);
848 }
849
850 fn push_event_start(&mut self, event: Event) {
851 self.push_event(event, BlockState::Explicit);
852 }
853}
854
855impl EventSource for MarkdownReader {
856 #[inline]
857 fn next_event(&mut self) -> Result<Option<Event>> {
858 if self.phase == Phase::NotStarted {
859 self.phase = Phase::Running;
860 return Ok(Some(Event::StartDocument {
861 id: None,
862 language: None,
863 metadata: None,
864 }));
865 }
866
867 if self.phase == Phase::Finished && self.queue.is_empty() {
868 return Ok(None);
869 }
870
871 while self.queue.is_empty() && self.phase != Phase::Finished {
872 self.process_next_pulldown_event();
873 }
874
875 Ok(self.queue.pop_front())
876 }
877}
878
879fn markdown_start_tag(tag: Tag<'_>) -> Option<MarkdownStartTag> {
880 match tag {
881 Tag::BlockQuote(_) => Some(MarkdownStartTag::BlockQuote),
882 Tag::CodeBlock(kind) => Some(MarkdownStartTag::CodeBlock {
883 syntax: code_block_syntax(kind),
884 }),
885 Tag::Emphasis => Some(MarkdownStartTag::Emphasis),
886 Tag::Heading { level, .. } => Some(MarkdownStartTag::Heading { level }),
887 Tag::Image {
888 dest_url, title, ..
889 } => Some(MarkdownStartTag::Image {
890 dest_url: dest_url.into_string(),
891 title: cow_to_optional_string(title),
892 }),
893 Tag::HtmlBlock => Some(MarkdownStartTag::HtmlBlock),
894 Tag::Item => Some(MarkdownStartTag::Item),
895 Tag::Link {
896 dest_url, title, ..
897 } => Some(MarkdownStartTag::Link {
898 dest_url: dest_url.into_string(),
899 title: cow_to_optional_string(title),
900 }),
901 Tag::List(start_opt) => Some(MarkdownStartTag::List(start_opt)),
902 Tag::Paragraph => Some(MarkdownStartTag::Paragraph),
903 Tag::Strikethrough => Some(MarkdownStartTag::Strikethrough),
904 Tag::Strong => Some(MarkdownStartTag::Strong),
905 Tag::Table(_) => Some(MarkdownStartTag::Table),
906 Tag::TableCell => Some(MarkdownStartTag::TableCell),
907 Tag::TableHead => Some(MarkdownStartTag::TableHead),
908 Tag::TableRow => Some(MarkdownStartTag::TableRow),
909 Tag::DefinitionList
910 | Tag::DefinitionListDefinition
911 | Tag::DefinitionListTitle
912 | Tag::FootnoteDefinition(_)
913 | Tag::MetadataBlock(_)
914 | Tag::Subscript
915 | Tag::Superscript => None,
916 }
917}
918
919fn code_block_syntax(kind: CodeBlockKind<'_>) -> Option<String> {
920 match kind {
921 CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.into_string()),
922 CodeBlockKind::Fenced(_) | CodeBlockKind::Indented => None,
923 }
924}
925
926fn cow_to_optional_string(value: CowStr<'_>) -> Option<String> {
927 if value.is_empty() {
928 None
929 } else {
930 Some(value.into_string())
931 }
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937
938 #[test]
939 fn handle_code_without_open_block_auto_opens_paragraph() {
940 let mut reader = MarkdownReader::from_str("");
941 reader.handle_code("code".to_string());
942
943 assert_eq!(reader.queue.len(), 4);
944 assert_eq!(
945 reader.queue.front(),
946 Some(&Event::StartParagraph {
947 alignment: None,
948 id: None,
949 })
950 );
951 assert_eq!(
952 reader.queue.get(1),
953 Some(&Event::StartTextStyle {
954 kind: TextStyleKind::Code,
955 id: None,
956 })
957 );
958 assert_eq!(
959 reader.queue.get(2),
960 Some(&Event::Text {
961 content: "code".to_string(),
962 })
963 );
964 assert_eq!(reader.queue.get(3), Some(&Event::EndTextStyle));
965 }
966
967 #[test]
968 fn handle_text_without_open_block_auto_opens_paragraph() {
969 let mut reader = MarkdownReader::from_str("");
970 reader.handle_text("hello".to_string());
971
972 assert_eq!(reader.queue.len(), 2);
973 assert_eq!(
974 reader.queue.front(),
975 Some(&Event::StartParagraph {
976 alignment: None,
977 id: None,
978 })
979 );
980 assert_eq!(
981 reader.queue.get(1),
982 Some(&Event::Text {
983 content: "hello".to_string(),
984 })
985 );
986 }
987}
988
989#[cfg(test)]
990mod send_static_assertions {
991 fn assert_send_static<T>()
992 where
993 T: Send + 'static,
994 {
995 }
996
997 #[test]
998 fn markdown_reader_is_send_static() {
999 assert_send_static::<crate::MarkdownReader>();
1000 }
1001}