1use crate::model::{
43 Container, Island, Line, LineKind, Loss, Mark, MarkKind, Content, ISLAND_SLOT,
44};
45use crate::island::KnownIslandType;
46use crate::normalize::normalize_markdown;
47use crate::MAX_NESTING_DEPTH;
48use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
49use serde_json::json;
50use std::cell::RefCell;
51use std::collections::HashSet;
52use std::ops::Range;
53use std::rc::Rc;
54
55type UnderlineOpens = Rc<RefCell<HashSet<usize>>>;
60
61#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ImportError {
65 NestingTooDeep { depth: usize, max: usize },
67}
68
69impl std::fmt::Display for ImportError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 ImportError::NestingTooDeep { depth, max } => {
73 write!(f, "nesting too deep: {depth} (max {max})")
74 }
75 }
76 }
77}
78impl std::error::Error for ImportError {}
79
80pub fn from_markdown(markdown: &str) -> Result<Content, ImportError> {
82 let normalized = normalize_markdown(markdown);
83 let mut options = Options::empty();
84 options.insert(Options::ENABLE_STRIKETHROUGH);
85 options.insert(Options::ENABLE_TABLES);
86 let parser = Parser::new_ext(&normalized, options);
87 let underline_opens: UnderlineOpens = Rc::new(RefCell::new(HashSet::new()));
88 let fixer = MarkdownFixer::new(
89 parser.into_offset_iter(),
90 &normalized,
91 Rc::clone(&underline_opens),
92 );
93
94 let mut b = Builder::new(underline_opens);
95 b.run(fixer)?;
96 let mut rt = b.finish();
97 rt.normalize();
98 Ok(rt)
99}
100
101pub fn from_plaintext(s: &str) -> Content {
117 let text: String = s
121 .chars()
122 .filter(|&c| c != '\r' && c != ISLAND_SLOT && !crate::normalize::is_bidi_char(c))
123 .collect();
124 let mut prev_nonempty = false;
130 let lines = text
131 .split('\n')
132 .map(|seg| {
133 let continues = prev_nonempty && !seg.is_empty();
134 prev_nonempty = !seg.is_empty();
135 Line {
136 kind: LineKind::Para,
137 containers: Vec::new(),
138 continues,
139 }
140 })
141 .collect();
142 Content {
143 text,
144 lines,
145 marks: Vec::new(),
146 islands: Vec::new(),
147 }
148}
149
150#[derive(Default)]
160struct Inline {
161 text: String,
164 pos: usize,
166 marks: Vec<Mark>,
167 open: Vec<(MarkKind, usize)>,
169}
170
171impl Inline {
172 fn push_text(&mut self, s: &str) {
182 for c in s.chars() {
183 let c = match c {
184 '\r' => continue,
185 ISLAND_SLOT => continue,
186 '\n' => ' ',
187 other => other,
188 };
189 self.text.push(c);
190 self.pos += 1;
191 }
192 }
193
194 fn push_raw(&mut self, c: char) {
197 self.text.push(c);
198 self.pos += 1;
199 }
200
201 fn open_mark(&mut self, kind: MarkKind) {
203 self.open.push((kind, self.pos));
204 }
205
206 fn close_mark(&mut self) {
208 if let Some((kind, start)) = self.open.pop() {
209 self.marks.push(Mark {
210 start,
211 end: self.pos,
212 kind,
213 });
214 }
215 }
216
217 fn push_code(&mut self, s: &str) {
219 let start = self.pos;
220 self.push_text(s);
221 self.marks.push(Mark {
222 start,
223 end: self.pos,
224 kind: MarkKind::Code,
225 });
226 }
227}
228
229struct Builder {
230 underline_opens: UnderlineOpens,
235 inline: Inline,
239 lines: Vec<Line>,
240 cur: Option<Line>, pending: Option<(LineKind, bool)>,
247 islands: Vec<Island>,
248 island_seq: usize,
249 containers: Vec<Container>,
250 container_marks: Vec<usize>,
254 list_stack: Vec<ListInfo>,
255 code_lang: Option<String>,
257 in_code: bool,
258 code_opened: bool, image_depth: usize,
261 image_url: String,
262 image_alt: String,
263 table: Option<TableAcc>,
265}
266
267#[derive(Clone)]
268struct ListInfo {
269 ordered: bool,
270 start: u64,
271 count: u64,
273}
274
275struct TableAcc {
276 aligns: Vec<&'static str>,
277 header: Vec<serde_json::Value>,
280 rows: Vec<Vec<serde_json::Value>>,
281 cur_row: Vec<serde_json::Value>,
282 in_head: bool,
283 cell: Option<Inline>,
286 img_depth: usize,
291 degraded: bool,
295}
296
297fn align_str(a: &pulldown_cmark::Alignment) -> &'static str {
298 match a {
299 pulldown_cmark::Alignment::None => "none",
300 pulldown_cmark::Alignment::Left => "left",
301 pulldown_cmark::Alignment::Center => "center",
302 pulldown_cmark::Alignment::Right => "right",
303 }
304}
305
306impl Builder {
307 fn new(underline_opens: UnderlineOpens) -> Self {
308 Builder {
309 underline_opens,
310 inline: Inline::default(),
311 lines: Vec::new(),
312 cur: None,
313 pending: None,
314 islands: Vec::new(),
315 island_seq: 0,
316 containers: Vec::new(),
317 container_marks: Vec::new(),
318 list_stack: Vec::new(),
319 code_lang: None,
320 in_code: false,
321 code_opened: false,
322 image_depth: 0,
323 image_url: String::new(),
324 image_alt: String::new(),
325 table: None,
326 }
327 }
328
329 fn open_line(&mut self, kind: LineKind, continues: bool) {
334 let continues = continues && self.cur.is_some();
336 if let Some(prev) = self.cur.take() {
337 self.inline.push_raw('\n');
338 self.lines.push(prev);
339 }
340 self.cur = Some(Line {
341 kind,
342 containers: self.containers.clone(),
343 continues,
344 });
345 }
346
347 fn ensure_open(&mut self, default: LineKind) {
352 if let Some((k, cont)) = self.pending.take() {
353 self.open_line(k, cont);
354 } else if self.cur.is_none() {
355 self.open_line(default, false);
356 }
357 }
358
359 fn push_inline(&mut self, s: &str) {
363 self.ensure_open(LineKind::Para);
364 self.inline.push_text(s);
365 }
366
367 fn emitted(&self) -> usize {
370 self.lines.len() + usize::from(self.cur.is_some())
371 }
372
373 fn flush_empty_block(&mut self) {
377 if let Some((k, cont)) = self.pending.take() {
378 self.open_line(k, cont);
379 }
380 }
381
382 fn close_container(&mut self, mark: usize) {
386 if self.emitted() == mark {
387 self.pending = None;
388 self.open_line(LineKind::Para, false);
389 }
390 self.containers.pop();
391 }
392
393 fn open_mark(&mut self, kind: MarkKind) {
394 self.ensure_open(LineKind::Para);
399 self.inline.open_mark(kind);
400 }
401
402 fn close_mark(&mut self) {
403 self.inline.close_mark();
405 }
406
407 fn mint_island(&mut self, kind: KnownIslandType, props: serde_json::Value, loss: Loss) {
413 let id = format!("isl-{}", self.island_seq);
414 self.island_seq += 1;
415 self.islands.push(Island {
416 id,
417 island_type: kind.as_str().to_string(),
418 props,
419 loss,
420 });
421 }
422
423 fn check_depth(&self) -> Result<(), ImportError> {
424 let depth = self.containers.len() + self.inline.open.len();
427 if depth > MAX_NESTING_DEPTH {
428 return Err(ImportError::NestingTooDeep {
429 depth,
430 max: MAX_NESTING_DEPTH,
431 });
432 }
433 Ok(())
434 }
435
436 fn strong_kind(&self, start: usize) -> MarkKind {
440 if self.underline_opens.borrow().contains(&start) {
441 MarkKind::Underline
442 } else {
443 MarkKind::Strong
444 }
445 }
446
447 fn run<'a, I>(&mut self, iter: I) -> Result<(), ImportError>
448 where
449 I: Iterator<Item = (Event<'a>, Range<usize>)>,
450 {
451 for (event, range) in iter {
452 if self.image_depth > 0 {
454 match &event {
455 Event::Start(Tag::Image { .. }) => self.image_depth += 1,
456 Event::End(TagEnd::Image) => {
457 self.image_depth -= 1;
458 if self.image_depth == 0 {
459 self.emit_image();
460 }
461 }
462 Event::Text(t) | Event::Code(t) => self.image_alt.push_str(t),
463 Event::SoftBreak | Event::HardBreak => self.image_alt.push(' '),
464 _ => {}
465 }
466 continue;
467 }
468
469 if self.table.is_some() {
474 self.table_event(&event, &range);
475 if matches!(event, Event::End(TagEnd::Table)) {
476 self.emit_table();
477 }
478 continue;
479 }
480
481 match event {
482 Event::Start(tag) => self.start_tag(tag, range)?,
483 Event::End(tag) => self.end_tag(tag),
484 Event::Text(t) => {
485 if self.in_code {
486 self.push_code_content(&t);
487 } else {
488 self.push_inline(&t);
489 }
490 }
491 Event::Code(t) => {
492 self.ensure_open(LineKind::Para);
493 self.inline.push_code(&t);
494 }
495 Event::Rule => self.open_line(LineKind::Rule, false),
496 Event::SoftBreak => self.push_inline(" "),
497 Event::HardBreak => {
498 match self.cur.as_ref().map(|l| &l.kind) {
499 Some(LineKind::Heading { .. }) => self.push_inline(" "),
503 _ => {
508 let kind = self
509 .cur
510 .as_ref()
511 .map(|l| l.kind.clone())
512 .unwrap_or(LineKind::Para);
513 self.pending = Some((kind, true));
514 }
515 }
516 }
517 _ => {}
520 }
521 }
522 Ok(())
523 }
524
525 fn start_tag<'a>(&mut self, tag: Tag<'a>, range: Range<usize>) -> Result<(), ImportError> {
526 match tag {
527 Tag::Paragraph => self.pending = Some((LineKind::Para, false)),
530 Tag::Heading { level, .. } => {
531 self.pending = Some((
532 LineKind::Heading {
533 level: heading_level(level),
534 },
535 false,
536 ))
537 }
538 Tag::CodeBlock(kind) => {
539 self.pending = None; self.in_code = true;
541 self.code_lang = match kind {
542 pulldown_cmark::CodeBlockKind::Fenced(lang) => {
543 let l = sanitize_lang(&lang);
544 if l.is_empty() {
545 None
546 } else {
547 Some(l)
548 }
549 }
550 pulldown_cmark::CodeBlockKind::Indented => None,
551 };
552 self.code_opened = false;
556 }
557 Tag::List(start) => {
558 self.pending = None; self.list_stack.push(ListInfo {
560 ordered: start.is_some(),
561 start: start.unwrap_or(1),
562 count: 0,
563 });
564 }
565 Tag::Item => {
566 self.pending = Some((LineKind::Para, false));
569 self.container_marks.push(self.emitted());
570 let container = match self.list_stack.last_mut() {
571 Some(info) => {
572 let ordinal = info.count;
573 info.count += 1;
574 Container::ListItem {
575 ordered: info.ordered,
576 start: info.start,
577 ordinal,
578 }
579 }
580 None => Container::ListItem {
581 ordered: false,
582 start: 1,
583 ordinal: 0,
584 },
585 };
586 self.containers.push(container);
587 self.check_depth()?;
588 }
589 Tag::BlockQuote(_) => {
590 self.pending = None; self.container_marks.push(self.emitted());
592 self.containers.push(Container::Quote);
593 self.check_depth()?;
594 }
595 Tag::Table(aligns) => {
596 self.pending = None;
597 self.open_line(LineKind::Island, false);
598 self.inline.push_raw(ISLAND_SLOT);
599 self.table = Some(TableAcc {
600 aligns: aligns.iter().map(align_str).collect(),
601 header: Vec::new(),
602 rows: Vec::new(),
603 cur_row: Vec::new(),
604 in_head: false,
605 cell: None,
606 img_depth: 0,
607 degraded: false,
608 });
609 }
610 Tag::Emphasis => {
611 self.open_mark(MarkKind::Emph);
612 self.check_depth()?;
613 }
614 Tag::Strong => {
615 let kind = self.strong_kind(range.start);
616 self.open_mark(kind);
617 self.check_depth()?;
618 }
619 Tag::Strikethrough => {
620 self.open_mark(MarkKind::Strike);
621 self.check_depth()?;
622 }
623 Tag::Link { dest_url, .. } => {
624 self.open_mark(MarkKind::Link {
625 url: dest_url.to_string(),
626 });
627 self.check_depth()?;
628 }
629 Tag::Image { dest_url, .. } => {
630 self.image_url = dest_url.to_string();
631 self.image_alt.clear();
632 self.image_depth = 1;
633 }
634 _ => {}
635 }
636 Ok(())
637 }
638
639 fn end_tag(&mut self, tag: TagEnd) {
640 match tag {
641 TagEnd::CodeBlock => {
642 if !self.code_opened {
643 let lang = self.code_lang.take();
645 self.open_line(LineKind::Code { lang }, false);
646 }
647 self.in_code = false;
648 self.code_lang = None;
649 }
650 TagEnd::List(_) => {
651 self.list_stack.pop();
652 }
653 TagEnd::Item => {
654 let mark = self.container_marks.pop().unwrap_or(0);
655 self.close_container(mark);
656 }
657 TagEnd::BlockQuote(_) => {
658 let mark = self.container_marks.pop().unwrap_or(0);
659 self.close_container(mark);
660 }
661 TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => {
662 self.close_mark()
663 }
664 TagEnd::Heading(_) | TagEnd::Paragraph => self.flush_empty_block(),
666 _ => {}
667 }
668 }
669
670 fn push_code_content(&mut self, content: &str) {
671 let content = content.strip_suffix('\n').unwrap_or(content);
674 for seg in content.split('\n') {
675 let continues = self.code_opened;
678 self.open_line(
679 LineKind::Code {
680 lang: self.code_lang.clone(),
681 },
682 continues,
683 );
684 self.code_opened = true;
685 self.push_code_line(seg);
687 }
688 }
689
690 fn push_code_line(&mut self, seg: &str) {
691 for c in seg.chars() {
692 match c {
693 '\r' | '\n' => continue,
694 ISLAND_SLOT => continue,
695 other => self.inline.push_raw(other),
696 }
697 }
698 }
699
700 fn cell_mut(&mut self) -> Option<&mut Inline> {
704 self.table.as_mut()?.cell.as_mut()
705 }
706
707 fn table_event(&mut self, event: &Event, range: &Range<usize>) {
712 if self.table.as_ref().is_some_and(|a| a.img_depth > 0) {
717 match event {
718 Event::Start(Tag::Image { .. }) => {
719 if let Some(a) = self.table.as_mut() {
720 a.img_depth += 1;
721 }
722 }
723 Event::End(TagEnd::Image) => {
724 if let Some(a) = self.table.as_mut() {
725 a.img_depth -= 1;
726 }
727 }
728 Event::Text(t) | Event::Code(t) => {
729 if let Some(c) = self.cell_mut() {
730 c.push_text(t);
731 }
732 }
733 Event::SoftBreak | Event::HardBreak => {
734 if let Some(c) = self.cell_mut() {
735 c.push_text(" ");
736 }
737 }
738 _ => {}
739 }
740 return;
741 }
742 match event {
743 Event::Start(Tag::Image { .. }) => {
744 if let Some(a) = self.table.as_mut() {
745 a.img_depth += 1;
746 a.degraded = true;
747 }
748 }
749 Event::Start(Tag::TableHead) => {
750 if let Some(a) = self.table.as_mut() {
751 a.in_head = true;
752 }
753 }
754 Event::End(TagEnd::TableHead) => {
755 if let Some(a) = self.table.as_mut() {
756 a.header = std::mem::take(&mut a.cur_row);
757 a.in_head = false;
758 }
759 }
760 Event::Start(Tag::TableRow) => {
761 if let Some(a) = self.table.as_mut() {
762 a.cur_row.clear();
763 }
764 }
765 Event::End(TagEnd::TableRow) => {
766 if let Some(a) = self.table.as_mut() {
767 if !a.in_head {
768 let row = std::mem::take(&mut a.cur_row);
769 a.rows.push(row);
770 }
771 }
772 }
773 Event::Start(Tag::TableCell) => {
774 if let Some(a) = self.table.as_mut() {
775 a.cell = Some(Inline::default());
776 }
777 }
778 Event::End(TagEnd::TableCell) => {
779 if let Some(a) = self.table.as_mut() {
780 if let Some(mut cell) = a.cell.take() {
781 while !cell.open.is_empty() {
783 cell.close_mark();
784 }
785 a.cur_row
786 .push(crate::serial::cell_to_value(&cell.text, &cell.marks));
787 }
788 }
789 }
790 Event::Text(t) => {
794 if let Some(c) = self.cell_mut() {
795 c.push_text(t);
796 }
797 }
798 Event::Code(t) => {
799 if let Some(c) = self.cell_mut() {
800 c.push_code(t);
801 }
802 }
803 Event::SoftBreak | Event::HardBreak => {
804 if let Some(c) = self.cell_mut() {
805 c.push_text(" ");
806 }
807 }
808 Event::Start(Tag::Emphasis) => {
809 if let Some(c) = self.cell_mut() {
810 c.open_mark(MarkKind::Emph);
811 }
812 }
813 Event::Start(Tag::Strong) => {
814 let kind = self.strong_kind(range.start);
815 if let Some(c) = self.cell_mut() {
816 c.open_mark(kind);
817 }
818 }
819 Event::Start(Tag::Strikethrough) => {
820 if let Some(c) = self.cell_mut() {
821 c.open_mark(MarkKind::Strike);
822 }
823 }
824 Event::Start(Tag::Link { dest_url, .. }) => {
825 let url = dest_url.to_string();
826 if let Some(c) = self.cell_mut() {
827 c.open_mark(MarkKind::Link { url });
828 }
829 }
830 Event::End(TagEnd::Emphasis)
831 | Event::End(TagEnd::Strong)
832 | Event::End(TagEnd::Strikethrough)
833 | Event::End(TagEnd::Link) => {
834 if let Some(c) = self.cell_mut() {
835 c.close_mark();
836 }
837 }
838 _ => {}
839 }
840 }
841
842 fn emit_table(&mut self) {
843 if let Some(acc) = self.table.take() {
844 let props = json!({
845 "aligns": acc.aligns,
846 "header": acc.header,
847 "rows": acc.rows,
848 });
849 let loss = if acc.degraded {
853 Loss::Degraded
854 } else {
855 KnownIslandType::Table.default_loss()
856 };
857 self.mint_island(KnownIslandType::Table, props, loss);
858 }
859 }
860
861 fn emit_image(&mut self) {
862 self.ensure_open(LineKind::Para);
863 self.inline.push_raw(ISLAND_SLOT);
864 let props = json!({
865 "url": self.image_url,
866 "alt": self.image_alt.trim(),
867 });
868 self.mint_island(KnownIslandType::Image, props, KnownIslandType::Image.default_loss());
869 }
870
871 fn finish(mut self) -> Content {
872 if let Some(last) = self.cur.take() {
873 self.lines.push(last);
874 }
875 if self.lines.is_empty() {
876 self.lines.push(Line {
878 kind: LineKind::Para,
879 containers: Vec::new(),
880 continues: false,
881 });
882 }
883 while !self.inline.open.is_empty() {
885 self.close_mark();
886 }
887 Content {
888 text: self.inline.text,
889 lines: self.lines,
890 marks: self.inline.marks,
891 islands: self.islands,
892 }
893 }
894}
895
896fn heading_level(level: pulldown_cmark::HeadingLevel) -> u8 {
897 use pulldown_cmark::HeadingLevel::*;
898 match level {
899 H1 => 1,
900 H2 => 2,
901 H3 => 3,
902 H4 => 4,
903 H5 => 5,
904 H6 => 6,
905 }
906}
907
908fn sanitize_lang(lang: &str) -> String {
911 lang.chars()
912 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
913 .collect()
914}
915
916fn is_u_open_tag(html: &str) -> bool {
925 let s = html.trim();
926 if s.starts_with('<') && s.ends_with('>') {
927 s[1..s.len() - 1].trim().eq_ignore_ascii_case("u")
928 } else {
929 false
930 }
931}
932
933fn is_u_close_tag(html: &str) -> bool {
934 let s = html.trim();
935 if s.starts_with("</") && s.ends_with('>') {
936 s[2..s.len() - 1].trim().eq_ignore_ascii_case("u")
937 } else {
938 false
939 }
940}
941
942struct MarkdownFixer<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>> {
943 inner: std::iter::Peekable<I>,
944 source: &'a str,
945 underline_opens: UnderlineOpens,
949 buffer: Vec<(Event<'a>, Range<usize>)>,
950 emph_depth: usize,
951 strong_depth: usize,
952}
953
954impl<'a, I> MarkdownFixer<'a, I>
955where
956 I: Iterator<Item = (Event<'a>, Range<usize>)>,
957{
958 fn new(inner: I, source: &'a str, underline_opens: UnderlineOpens) -> Self {
959 Self {
960 inner: inner.peekable(),
961 source,
962 underline_opens,
963 buffer: Vec::new(),
964 emph_depth: 0,
965 strong_depth: 0,
966 }
967 }
968
969 fn events_for_stars(
970 star_count: usize,
971 is_start: bool,
972 start_idx: usize,
973 ) -> Vec<(Event<'a>, Range<usize>)> {
974 let mut events = Vec::new();
975 let mut offset = 0;
976 let mut remaining = star_count;
977
978 if remaining >= 2 {
979 let len = 2;
980 let range = start_idx + offset..start_idx + offset + len;
981 let event = if is_start {
982 Event::Start(Tag::Strong)
983 } else {
984 Event::End(TagEnd::Strong)
985 };
986 events.push((event, range));
987 remaining -= 2;
988 offset += 2;
989 }
990 if remaining >= 1 {
991 let len = 1;
992 let range = start_idx + offset..start_idx + offset + len;
993 let event = if is_start {
994 Event::Start(Tag::Emphasis)
995 } else {
996 Event::End(TagEnd::Emphasis)
997 };
998 events.push((event, range));
999 }
1000 if !is_start {
1001 events.reverse();
1002 }
1003 events
1004 }
1005
1006 fn coalesce_text_range(&mut self, initial_range: Range<usize>) -> Range<usize> {
1007 let mut merged_range = initial_range;
1008 while let Some((next_event, next_range)) = self.inner.peek() {
1009 if matches!(next_event, Event::Text(_)) && next_range.start == merged_range.end {
1010 merged_range.end = next_range.end;
1011 self.inner.next();
1012 } else {
1013 break;
1014 }
1015 }
1016 merged_range
1017 }
1018
1019 fn closable_star_count(&self, star_count: usize) -> usize {
1020 let mut remaining = star_count;
1021 let mut consumed = 0;
1022 if remaining >= 2 && self.strong_depth > 0 {
1023 remaining -= 2;
1024 consumed += 2;
1025 }
1026 if remaining >= 1 && self.emph_depth > 0 {
1027 consumed += 1;
1028 }
1029 consumed
1030 }
1031
1032 fn handle_candidate(
1033 &mut self,
1034 candidate: (Event<'a>, Range<usize>),
1035 ) -> Option<(Event<'a>, Range<usize>)> {
1036 let (event, range) = candidate;
1037
1038 match &event {
1039 Event::Start(Tag::Emphasis) => self.emph_depth += 1,
1040 Event::Start(Tag::Strong) => self.strong_depth += 1,
1041 Event::End(TagEnd::Emphasis) => self.emph_depth = self.emph_depth.saturating_sub(1),
1042 Event::End(TagEnd::Strong) => self.strong_depth = self.strong_depth.saturating_sub(1),
1043 _ => {}
1044 }
1045
1046 match &event {
1047 Event::Text(cow_str) => {
1048 let s = cow_str.as_ref();
1049 if s.ends_with('*') {
1050 let is_strong_start = if let Some(next) = self.buffer.last() {
1051 matches!(next.0, Event::Start(Tag::Strong))
1052 } else {
1053 matches!(self.inner.peek(), Some((Event::Start(Tag::Strong), _)))
1054 };
1055 if is_strong_start {
1056 let star_count = s.chars().rev().take_while(|c| *c == '*').count();
1057 if star_count > 0 && star_count <= 3 {
1058 let text_len = s.len() - star_count;
1059 let text_content = &s[..text_len];
1060 let star_events =
1061 Self::events_for_stars(star_count, true, range.start + text_len);
1062 let next_event = if !self.buffer.is_empty() {
1063 self.buffer.pop().unwrap()
1064 } else {
1065 self.inner.next().unwrap()
1066 };
1067 self.buffer.push(next_event);
1068 for ev in star_events.into_iter().rev() {
1069 self.buffer.push(ev);
1070 }
1071 if !text_content.is_empty() {
1072 return Some((
1073 Event::Text(text_content.to_string().into()),
1074 range.start..range.start + text_len,
1075 ));
1076 } else {
1077 return None;
1078 }
1079 }
1080 }
1081 }
1082 }
1083 Event::End(TagEnd::Strong) | Event::End(TagEnd::Emphasis) => {
1084 let has_open_tags = self.emph_depth > 0 || self.strong_depth > 0;
1085 if !has_open_tags {
1086 return Some((event, range));
1087 }
1088 let next_is_star_text = if let Some((Event::Text(cow_str), _)) = self.buffer.last()
1089 {
1090 cow_str.starts_with('*')
1091 } else if let Some((Event::Text(cow_str), _)) = self.inner.peek() {
1092 cow_str.starts_with('*')
1093 } else {
1094 false
1095 };
1096 if next_is_star_text {
1097 let (text_event, text_range) = if !self.buffer.is_empty() {
1098 self.buffer.pop().unwrap()
1099 } else {
1100 let (_ev, rng) = self.inner.next().unwrap();
1101 let merged_range = self.coalesce_text_range(rng);
1102 let text = self.source[merged_range.clone()].into();
1103 (Event::Text(text), merged_range)
1104 };
1105 if let Event::Text(cow_str) = text_event {
1106 let s = cow_str.as_ref();
1107 let star_count = s.chars().take_while(|c| *c == '*').count();
1108 let consumable = self.closable_star_count(star_count);
1109 if consumable > 0 {
1110 let star_events =
1111 Self::events_for_stars(consumable, false, text_range.start);
1112 let text_after = &s[consumable..];
1113 if !text_after.is_empty() {
1114 self.buffer.push((
1115 Event::Text(text_after.to_string().into()),
1116 text_range.start + consumable..text_range.end,
1117 ));
1118 }
1119 for ev in star_events.into_iter().rev() {
1120 self.buffer.push(ev);
1121 }
1122 return Some((event, range));
1123 } else {
1124 self.buffer.push((Event::Text(cow_str), text_range));
1125 }
1126 }
1127 }
1128 }
1129 _ => {}
1130 }
1131 Some((event, range))
1132 }
1133}
1134
1135impl<'a, I> Iterator for MarkdownFixer<'a, I>
1136where
1137 I: Iterator<Item = (Event<'a>, Range<usize>)>,
1138{
1139 type Item = (Event<'a>, Range<usize>);
1140
1141 fn next(&mut self) -> Option<Self::Item> {
1142 loop {
1143 if let Some(event) = self.buffer.pop() {
1144 if let Some(result) = self.handle_candidate(event) {
1145 return Some(result);
1146 } else {
1147 continue;
1148 }
1149 }
1150 let (event, range) = self.inner.next()?;
1151 let (event, range) = match event {
1152 Event::InlineHtml(ref html) | Event::Html(ref html) if is_u_open_tag(html) => {
1153 self.underline_opens.borrow_mut().insert(range.start);
1156 (Event::Start(Tag::Strong), range)
1157 }
1158 Event::InlineHtml(ref html) | Event::Html(ref html) if is_u_close_tag(html) => {
1159 (Event::End(TagEnd::Strong), range)
1160 }
1161 Event::Html(_) | Event::InlineHtml(_) => continue,
1162 other => (other, range),
1163 };
1164 if let Some(result) = self.handle_candidate((event, range)) {
1165 return Some(result);
1166 } else {
1167 continue;
1168 }
1169 }
1170 }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176 use crate::model::LineKind;
1177
1178 fn imp(md: &str) -> Content {
1179 let rt = from_markdown(md).unwrap();
1180 assert_eq!(rt.validate(), Ok(()), "invariants for {md:?}");
1181 rt
1182 }
1183
1184 fn imp_plain(s: &str) -> Content {
1185 let rt = from_plaintext(s);
1186 assert_eq!(rt.validate(), Ok(()), "invariants for {s:?}");
1187 rt
1188 }
1189
1190 #[test]
1193 fn plaintext_is_literal_and_plain() {
1194 let rt = imp_plain("a *star* and _under_ #hash");
1195 assert_eq!(rt.text, "a *star* and _under_ #hash");
1196 assert!(rt.marks.is_empty());
1197 assert!(rt.islands.is_empty());
1198 assert!(rt.is_plain());
1199 assert!(rt.is_inline(), "one line with no formatting is also inline");
1200 }
1201
1202 #[test]
1205 fn plaintext_round_trip_is_verbatim_and_idempotent() {
1206 for s in ["", "one line", "a\nb", "a\n\nb", "trailing\n", "*not bold*"] {
1207 let rt = imp_plain(s);
1208 assert_eq!(crate::export::to_plaintext(&rt), s, "verbatim for {s:?}");
1209 let rt2 = from_plaintext(&crate::export::to_plaintext(&rt));
1210 assert_eq!(rt2.text, rt.text, "idempotent for {s:?}");
1211 assert_eq!(rt2.lines, rt.lines, "idempotent structure for {s:?}");
1212 }
1213 }
1214
1215 #[test]
1218 fn plaintext_derives_continues_from_line_structure() {
1219 let rt = imp_plain("a\nb");
1220 assert_eq!(rt.lines.len(), 2);
1221 assert!(!rt.lines[0].continues);
1222 assert!(rt.lines[1].continues, "lone \\n is a within-paragraph break");
1223
1224 let rt = imp_plain("a\n\nb");
1225 assert_eq!(rt.lines.len(), 3);
1226 assert!(!rt.lines[0].continues);
1227 assert!(!rt.lines[1].continues, "the blank line is a paragraph boundary");
1228 assert!(!rt.lines[2].continues, "text after a blank line starts a new block");
1229 }
1230
1231 #[test]
1235 fn plaintext_strips_invariant_breakers() {
1236 let rt = imp_plain("a\r\nb");
1237 assert_eq!(rt.text, "a\nb", "CRLF collapses to LF");
1238 let rt = imp_plain(&format!("a{ISLAND_SLOT}b"));
1239 assert_eq!(rt.text, "ab", "the reserved island slot is dropped");
1240 assert_eq!(rt.islands.len(), 0);
1241 }
1242
1243 #[test]
1244 fn plain_paragraph() {
1245 let rt = imp("Hello world");
1246 assert_eq!(rt.text, "Hello world");
1247 assert_eq!(rt.lines.len(), 1);
1248 assert_eq!(rt.lines[0].kind, LineKind::Para);
1249 assert!(rt.marks.is_empty());
1250 }
1251
1252 #[test]
1253 fn bold_and_italic_marks() {
1254 let rt = imp("a **b** _c_");
1255 assert_eq!(rt.text, "a b c");
1256 assert!(rt.marks.contains(&Mark {
1258 start: 2,
1259 end: 3,
1260 kind: MarkKind::Strong
1261 }));
1262 assert!(rt.marks.contains(&Mark {
1263 start: 4,
1264 end: 5,
1265 kind: MarkKind::Emph
1266 }));
1267 }
1268
1269 #[test]
1270 fn underline_from_u_tag() {
1271 let rt = imp("x <u>y</u> z");
1272 assert_eq!(rt.text, "x y z");
1273 assert!(rt
1274 .marks
1275 .iter()
1276 .any(|m| m.kind == MarkKind::Underline && m.start == 2 && m.end == 3));
1277 }
1278
1279 #[test]
1280 fn other_html_stripped() {
1281 let rt = imp("a <span>b</span> c");
1282 assert_eq!(rt.text, "a b c");
1283 }
1284
1285 #[test]
1291 fn ul_lookalike_is_not_underline() {
1292 let rt = imp("x <ul>y</ul> z");
1293 assert_eq!(rt.text, "x y z");
1294 assert!(rt
1295 .marks
1296 .iter()
1297 .all(|m| m.kind != MarkKind::Underline && m.kind != MarkKind::Strong));
1298 }
1299
1300 #[test]
1301 fn two_paragraphs_two_lines() {
1302 let rt = imp("one\n\ntwo");
1303 assert_eq!(rt.text, "one\ntwo");
1304 assert_eq!(rt.lines.len(), 2);
1305 assert!(rt.lines.iter().all(|l| l.kind == LineKind::Para));
1306 }
1307
1308 #[test]
1309 fn heading_line_kind() {
1310 let rt = imp("## Title");
1311 assert_eq!(rt.text, "Title");
1312 assert_eq!(rt.lines[0].kind, LineKind::Heading { level: 2 });
1313 }
1314
1315 #[test]
1316 fn inline_code_mark() {
1317 let rt = imp("run `cargo test` now");
1318 assert_eq!(rt.text, "run cargo test now");
1319 assert!(rt
1320 .marks
1321 .iter()
1322 .any(|m| m.kind == MarkKind::Code && m.start == 4 && m.end == 14));
1323 }
1324
1325 #[test]
1326 fn code_block_lines() {
1327 let rt = imp("```rust\nfn a() {}\nfn b() {}\n```");
1328 assert_eq!(rt.text, "fn a() {}\nfn b() {}");
1329 assert_eq!(rt.lines.len(), 2);
1330 assert!(rt.lines.iter().all(|l| l.kind
1331 == LineKind::Code {
1332 lang: Some("rust".into())
1333 }));
1334 }
1335
1336 #[test]
1337 fn bullet_list_containers() {
1338 let rt = imp("- a\n- b");
1339 assert_eq!(rt.text, "a\nb");
1340 assert_eq!(rt.lines.len(), 2);
1341 assert_eq!(
1343 rt.lines[0].containers,
1344 vec![Container::ListItem {
1345 ordered: false,
1346 start: 1,
1347 ordinal: 0
1348 }]
1349 );
1350 assert_eq!(
1351 rt.lines[1].containers,
1352 vec![Container::ListItem {
1353 ordered: false,
1354 start: 1,
1355 ordinal: 1
1356 }]
1357 );
1358 }
1359
1360 #[test]
1361 fn ordered_list_custom_start() {
1362 let rt = imp("3. a\n4. b");
1363 assert_eq!(
1364 rt.lines[0].containers,
1365 vec![Container::ListItem {
1366 ordered: true,
1367 start: 3,
1368 ordinal: 0
1369 }]
1370 );
1371 assert_eq!(
1372 rt.lines[1].containers,
1373 vec![Container::ListItem {
1374 ordered: true,
1375 start: 3,
1376 ordinal: 1
1377 }]
1378 );
1379 }
1380
1381 #[test]
1382 fn multi_paragraph_list_item_shares_container() {
1383 let rt = imp("- first\n\n second");
1385 assert_eq!(rt.lines.len(), 2);
1386 assert_eq!(rt.lines[0].containers, rt.lines[1].containers);
1387 assert_eq!(
1388 rt.lines[0].containers,
1389 vec![Container::ListItem {
1390 ordered: false,
1391 start: 1,
1392 ordinal: 0
1393 }]
1394 );
1395 }
1396
1397 #[test]
1398 fn blockquote_container() {
1399 let rt = imp("> quoted");
1400 assert_eq!(rt.text, "quoted");
1401 assert_eq!(rt.lines[0].containers, vec![Container::Quote]);
1402 }
1403
1404 #[test]
1405 fn thematic_break_is_rule_line() {
1406 for src in ["---", "***", "___"] {
1407 let md = format!("one\n\n{src}\n\ntwo");
1408 let rt = imp(&md);
1409 assert_eq!(rt.lines.len(), 3, "source: {src}");
1410 assert_eq!(rt.lines[0].kind, LineKind::Para);
1411 assert_eq!(rt.lines[1].kind, LineKind::Rule, "source: {src}");
1412 assert_eq!(rt.lines[2].kind, LineKind::Para);
1413 assert_eq!(rt.text, "one\n\ntwo");
1415 }
1416 }
1417
1418 #[test]
1419 fn table_is_block_island() {
1420 let rt = imp("| a | b |\n|---|---|\n| 1 | 2 |");
1421 assert_eq!(rt.text, "\u{FFFC}");
1422 assert_eq!(rt.lines[0].kind, LineKind::Island);
1423 assert_eq!(rt.islands.len(), 1);
1424 assert_eq!(rt.islands[0].island_type, "table");
1425 assert_eq!(rt.islands[0].loss, Loss::Lossless);
1426 }
1427
1428 #[test]
1429 fn island_ids_are_deterministic_and_positional() {
1430 let md = "\n\n| h |\n|---|\n| c |";
1436 let a = imp(md);
1437 let b = imp(md);
1438 assert_eq!(a.to_canonical_json(), b.to_canonical_json());
1439 let ids: Vec<&str> = a.islands.iter().map(|i| i.id.as_str()).collect();
1441 assert_eq!(ids, ["isl-0", "isl-1"]);
1442 }
1443
1444 #[test]
1445 fn table_with_cell_image_degrades() {
1446 let rt = imp("| a | b |\n|---|---|\n|  | 2 |");
1450 assert_eq!(rt.islands.len(), 1);
1451 assert_eq!(rt.islands[0].island_type, "table");
1452 assert_eq!(rt.islands[0].loss, Loss::Degraded);
1453 assert_eq!(rt.islands[0].props["rows"][0][0]["text"], "a cat");
1455 let plain = imp("| a | b |\n|---|---|\n| 1 | 2 |");
1457 assert_eq!(plain.islands[0].loss, Loss::Lossless);
1458 }
1459
1460 #[test]
1461 fn image_is_inline_island() {
1462 let rt = imp("see  here");
1463 assert_eq!(rt.text, "see \u{FFFC} here");
1464 assert_eq!(rt.islands.len(), 1);
1465 assert_eq!(rt.islands[0].island_type, "image");
1466 assert_eq!(rt.islands[0].props["url"], "cat.png");
1467 assert_eq!(rt.islands[0].props["alt"], "a cat");
1468 }
1469
1470 #[test]
1471 fn empty_list_item_keeps_its_line() {
1472 let rt = imp("- a\n-\n- b");
1475 assert_eq!(rt.lines.len(), 3, "empty middle item preserved");
1476 }
1477
1478 #[test]
1479 fn empty_blockquote_keeps_its_line() {
1480 let rt = imp("> ");
1481 assert_eq!(rt.lines.len(), 1);
1482 assert_eq!(rt.lines[0].containers, vec![Container::Quote]);
1483 }
1484
1485 #[test]
1486 fn adjacent_sibling_lists_merge_is_stable() {
1487 let rt = imp("* a\n\n+ b");
1490 let rt2 = from_markdown(&crate::export::to_markdown(&rt)).unwrap();
1491 assert_eq!(rt, rt2, "merged sibling lists still round-trip");
1492 }
1493
1494 #[test]
1495 fn empty_input_one_empty_line() {
1496 let rt = imp("");
1497 assert_eq!(rt.text, "");
1498 assert_eq!(rt.lines.len(), 1);
1499 }
1500
1501 #[test]
1502 fn mark_does_not_swallow_leading_newline() {
1503 let rt = imp("a\n\n**b**");
1506 assert_eq!(rt.text, "a\nb");
1507 let m = &rt.marks[0];
1508 assert_eq!((m.start, m.end), (2, 3));
1509 assert_eq!(rt.text.chars().nth(m.start), Some('b'));
1510 }
1511
1512 #[test]
1513 fn import_and_editor_content_same_canonical_bytes() {
1514 let imported = imp("a\n\n**b**");
1518 let editor = Content {
1519 text: "a\nb".into(),
1520 lines: vec![
1521 Line {
1522 kind: LineKind::Para,
1523 containers: vec![],
1524 continues: false,
1525 },
1526 Line {
1527 kind: LineKind::Para,
1528 containers: vec![],
1529 continues: false,
1530 },
1531 ],
1532 marks: vec![Mark {
1533 start: 2,
1534 end: 3,
1535 kind: MarkKind::Strong,
1536 }],
1537 islands: vec![],
1538 };
1539 assert_eq!(imported.to_canonical_json(), editor.to_canonical_json());
1540 }
1541
1542 #[test]
1543 fn hard_break_is_a_continuation_line() {
1544 let rt = imp("line one\\\nline two");
1545 assert_eq!(rt.text, "line one\nline two");
1546 assert_eq!(rt.lines.len(), 2);
1547 assert!(!rt.lines[0].continues);
1548 assert!(rt.lines[1].continues, "hard break -> continuation line");
1549 }
1550
1551 #[test]
1552 fn heading_cannot_carry_hard_break() {
1553 let rt = imp("## a \nb");
1558 assert_eq!(rt.text, "a\nb");
1559 assert_eq!(rt.lines.len(), 2);
1560 assert_eq!(rt.lines[0].kind, LineKind::Heading { level: 2 });
1561 assert_eq!(rt.lines[1].kind, LineKind::Para);
1562 assert!(!rt.lines[1].continues, "separate block, not a continuation");
1563 }
1564
1565 #[test]
1566 fn astral_positions_are_usv() {
1567 let rt = imp("a😀**b**");
1568 assert_eq!(rt.text, "a😀b");
1570 assert!(rt
1571 .marks
1572 .iter()
1573 .any(|m| m.start == 2 && m.end == 3 && m.kind == MarkKind::Strong));
1574 }
1575}