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