1#[derive(Clone, Debug)]
8pub struct ConvertResult {
9 pub brief_source: String,
10 pub diagnostics: Vec<Diag>,
11}
12
13#[derive(Clone, Debug)]
14pub struct Diag {
15 pub hole: Hole,
16 pub line: usize, pub col: usize, pub original: String,
19 pub note: String,
20}
21
22#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
23pub enum Hole {
24 SetextHeading,
25 DefinitionListMultipleDefs,
26 DoubleEmphasis, EscapedSigil,
28 AsteriskEmphasis, AltBullet, OrderedRenumber,
31 NestIndentNormalize,
32 TildeFence,
33 IndentedCodeBlock,
34 AltHorizontalRule,
35 LinkTitleDropped,
36 AutolinkRewrap,
37 RefLinkInlined,
38 GfmAlert,
39 InlineHtml,
40 HtmlBlock,
41 Frontmatter,
42 HtmlEntity,
43 HeadingAnchorSlugged,
44 BlockquoteParagraphSplit,
45 TableCellPipeEscape,
46 EmptyTableCell,
47}
48
49impl Hole {
50 pub fn slug(self) -> &'static str {
52 match self {
53 Hole::SetextHeading => "setext-heading",
54 Hole::DefinitionListMultipleDefs => "definition-list-multiple-defs",
55 Hole::DoubleEmphasis => "double-emphasis",
56 Hole::EscapedSigil => "escaped-sigil",
57 Hole::AsteriskEmphasis => "asterisk-emphasis",
58 Hole::AltBullet => "alt-bullet",
59 Hole::OrderedRenumber => "ordered-renumber",
60 Hole::NestIndentNormalize => "nest-indent-normalize",
61 Hole::TildeFence => "tilde-fence",
62 Hole::IndentedCodeBlock => "indented-code-block",
63 Hole::AltHorizontalRule => "alt-horizontal-rule",
64 Hole::LinkTitleDropped => "link-title-dropped",
65 Hole::AutolinkRewrap => "autolink-rewrap",
66 Hole::RefLinkInlined => "ref-link-inlined",
67 Hole::GfmAlert => "gfm-alert",
68 Hole::InlineHtml => "inline-html",
69 Hole::HtmlBlock => "html-block",
70 Hole::Frontmatter => "frontmatter",
71 Hole::HtmlEntity => "html-entity",
72 Hole::HeadingAnchorSlugged => "heading-anchor-slugged",
73 Hole::BlockquoteParagraphSplit => "blockquote-paragraph-split",
74 Hole::TableCellPipeEscape => "table-cell-pipe-escape",
75 Hole::EmptyTableCell => "empty-table-cell",
76 }
77 }
78
79 pub fn message(self) -> &'static str {
81 match self {
82 Hole::SetextHeading => "setext heading rewritten to ATX",
83 Hole::DefinitionListMultipleDefs => {
84 "definition list term repeated for each of multiple Markdown definitions (Brief v0.4 limitation)"
85 }
86 Hole::DoubleEmphasis => "doubled emphasis marker rewritten to single",
87 Hole::EscapedSigil => {
88 "literal `*`/`_`/`+`/`~` in text escaped to keep Brief from opening an emphasis span"
89 }
90 Hole::AsteriskEmphasis => "Markdown `*italic*` rewritten to Brief `_italic_`",
91 Hole::AltBullet => "`*`/`+` bullet rewritten to `-`",
92 Hole::OrderedRenumber => "ordered list renumbered to sequential 1,2,3,...",
93 Hole::NestIndentNormalize => "list nesting indent normalized to 2 spaces",
94 Hole::TildeFence => "`~~~` fence rewritten to triple-backtick fence",
95 Hole::IndentedCodeBlock => "indented code block rewritten to fenced block",
96 Hole::AltHorizontalRule => "`***`/`___`/spaced rule rewritten to `---`",
97 Hole::LinkTitleDropped => "link/image title attribute dropped",
98 Hole::AutolinkRewrap => "autolink/bare URL wrapped in `@link[...](...)`",
99 Hole::RefLinkInlined => "reference-style link resolved inline",
100 Hole::GfmAlert => "GFM alert blockquote rewritten to `@callout`",
101 Hole::InlineHtml => "inline HTML preserved as TODO comment",
102 Hole::HtmlBlock => "HTML block preserved inside Brief block comment",
103 Hole::Frontmatter => "frontmatter dropped, replaced with TODO comment",
104 Hole::HtmlEntity => "HTML entity decoded to literal character",
105 Hole::HeadingAnchorSlugged => "heading anchor id rewritten to `[a-z0-9-]+` slug",
106 Hole::BlockquoteParagraphSplit => {
107 "Markdown in-quote paragraph break rewritten to two adjacent Brief blockquotes"
108 }
109 Hole::TableCellPipeEscape => "`|` inside table cell escaped to `\\|`",
110 Hole::EmptyTableCell => "empty Markdown table cell padded with em-dash",
111 }
112 }
113}
114
115use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
116
117pub fn convert(input: &str, source_path: &str) -> ConvertResult {
121 let mut opts = Options::empty();
122 opts.insert(Options::ENABLE_TABLES);
123 opts.insert(Options::ENABLE_STRIKETHROUGH);
124 opts.insert(Options::ENABLE_TASKLISTS);
125 opts.insert(Options::ENABLE_FOOTNOTES);
126 opts.insert(Options::ENABLE_GFM);
127 opts.insert(Options::ENABLE_MATH);
128 opts.insert(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
129 opts.insert(Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS);
130 opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
131 opts.insert(Options::ENABLE_DEFINITION_LIST);
132
133 let line_offsets = compute_line_offsets(input);
134 let events: Vec<(Event<'_>, std::ops::Range<usize>)> =
135 Parser::new_ext(input, opts).into_offset_iter().collect();
136
137 let mut footnote_defs: std::collections::BTreeMap<String, String> =
144 std::collections::BTreeMap::new();
145 let mut footnote_diags: Vec<Diag> = Vec::new();
146 {
147 let mut current_label: Option<String> = None;
148 let mut current_events: Vec<(Event<'_>, std::ops::Range<usize>)> = Vec::new();
149 for (event, range) in &events {
150 match event {
151 Event::Start(Tag::FootnoteDefinition(label)) => {
152 current_label = Some(label.to_string());
153 current_events.clear();
154 }
155 Event::End(TagEnd::FootnoteDefinition) => {
156 if let Some(label) = current_label.take() {
157 let mut sub = Walker::new(input, source_path, line_offsets.clone());
158 for (e, r) in current_events.drain(..) {
159 sub.visit(e, r);
160 }
161 let body = sub.out.trim().to_string();
162 footnote_diags.append(&mut sub.diags);
163 footnote_defs.insert(label, body);
164 }
165 }
166 _ => {
167 if current_label.is_some() {
168 current_events.push((event.clone(), range.clone()));
169 }
170 }
171 }
172 }
173 }
174
175 let mut walker = Walker::new(input, source_path, line_offsets);
178 walker.footnote_defs = footnote_defs;
179 walker.diags.extend(footnote_diags);
180 let mut skip_until_footnote_end = false;
181 for (event, range) in events {
182 match (&event, skip_until_footnote_end) {
183 (Event::Start(Tag::FootnoteDefinition(_)), _) => {
184 skip_until_footnote_end = true;
185 continue;
186 }
187 (Event::End(TagEnd::FootnoteDefinition), _) => {
188 skip_until_footnote_end = false;
189 continue;
190 }
191 (_, true) => continue,
192 _ => {}
193 }
194 walker.visit(event, range);
195 }
196 walker.finish()
197}
198
199struct Walker<'a> {
200 src: &'a str,
201 _source_path: String,
202 line_offsets: Vec<usize>,
203 out: String,
204 diags: Vec<Diag>,
205 in_paragraph: bool,
207 in_code_block: bool,
210 list_stack: Vec<ListFrame>,
212 out_stack: Vec<String>,
216 container_stack: Vec<Container>,
218 table: Option<TableState>,
220 dl: Option<DefinitionListState>,
222 link_stack: Vec<(String, Option<(Hole, String)>)>,
224 footnote_defs: std::collections::BTreeMap<String, String>,
226 pending_hole_comments: Vec<String>,
228 in_metadata: bool,
229 metadata_buf: String,
230 metadata_kind: Option<pulldown_cmark::MetadataBlockKind>,
231 html_replace_stack: Vec<HtmlInlineKind>,
234 pending_heading_anchor: Option<String>,
237}
238
239struct TableState {
240 aligns: Vec<pulldown_cmark::Alignment>,
241 rows: Vec<Vec<String>>,
242 current_row: Vec<String>,
243 current_cell: String,
244 in_cell: bool,
245}
246
247struct DefinitionListState {
248 items: Vec<(String, String)>,
250 current_term: String,
252 current_def: String,
254 defs_for_current_term: usize,
257 in_term: bool,
258 in_def: bool,
259}
260
261struct ListFrame {
262 ordered: bool,
263 next_index: u64,
264 saw_first_item: bool,
265 item_content_col: usize,
268}
269
270enum Container {
271 Quote,
272 Alert(#[allow(dead_code)] Hole, &'static str),
273 LinkPending,
274 ImagePending,
275 Paragraph,
277 HtmlBlock,
282 Details {
287 summary: String,
288 },
289}
290
291#[derive(Copy, Clone, Debug, PartialEq, Eq)]
292enum HtmlInlineKind {
293 Sub,
294 Sup,
295 Kbd,
296}
297
298impl HtmlInlineKind {
299 fn shortcode(self) -> &'static str {
300 match self {
301 HtmlInlineKind::Sub => "sub",
302 HtmlInlineKind::Sup => "sup",
303 HtmlInlineKind::Kbd => "kbd",
304 }
305 }
306}
307
308impl<'a> Walker<'a> {
309 fn write(&mut self, s: &str) {
310 if let Some(d) = self.dl.as_mut() {
311 if d.in_term {
312 d.current_term.push_str(s);
313 return;
314 }
315 if d.in_def {
316 d.current_def.push_str(s);
317 return;
318 }
319 }
320 if let Some(t) = self.table.as_mut() {
321 if t.in_cell {
322 t.current_cell.push_str(s);
323 return;
324 }
325 }
326 if let Some(buf) = self.out_stack.last_mut() {
327 buf.push_str(s);
328 } else {
329 self.out.push_str(s);
330 }
331 }
332 fn write_char(&mut self, c: char) {
333 if let Some(d) = self.dl.as_mut() {
334 if d.in_term {
335 d.current_term.push(c);
336 return;
337 }
338 if d.in_def {
339 d.current_def.push(c);
340 return;
341 }
342 }
343 if let Some(t) = self.table.as_mut() {
344 if t.in_cell {
345 t.current_cell.push(c);
346 return;
347 }
348 }
349 if let Some(buf) = self.out_stack.last_mut() {
350 buf.push(c);
351 } else {
352 self.out.push(c);
353 }
354 }
355 fn current_ends_with(&self, c: char) -> bool {
356 if let Some(d) = self.dl.as_ref() {
357 if d.in_term {
358 return d.current_term.ends_with(c);
359 }
360 if d.in_def {
361 return d.current_def.ends_with(c);
362 }
363 }
364 if let Some(t) = self.table.as_ref() {
365 if t.in_cell {
366 return t.current_cell.ends_with(c);
367 }
368 }
369 if let Some(buf) = self.out_stack.last() {
370 buf.ends_with(c)
371 } else {
372 self.out.ends_with(c)
373 }
374 }
375
376 fn new(src: &'a str, source_path: &str, line_offsets: Vec<usize>) -> Self {
377 Walker {
378 src,
379 _source_path: source_path.to_string(),
380 line_offsets,
381 out: String::new(),
382 diags: Vec::new(),
383 in_paragraph: false,
384 in_code_block: false,
385 list_stack: Vec::new(),
386 out_stack: Vec::new(),
387 container_stack: Vec::new(),
388 table: None,
389 dl: None,
390 link_stack: Vec::new(),
391 footnote_defs: std::collections::BTreeMap::new(),
392 pending_hole_comments: Vec::new(),
393 in_metadata: false,
394 metadata_buf: String::new(),
395 metadata_kind: None,
396 html_replace_stack: Vec::new(),
397 pending_heading_anchor: None,
398 }
399 }
400
401 fn visit(&mut self, event: Event<'_>, range: std::ops::Range<usize>) {
402 match event {
403 Event::Start(Tag::List(start)) => {
404 self.flush_pending_hole_comments();
405 let ordered = start.is_some();
406 if let Some(n) = start {
407 if n != 1 {
408 self.push_diag(
409 Hole::OrderedRenumber,
410 range.clone(),
411 format!("ordered list started at {}; renumbered from 1", n),
412 );
413 }
414 }
415 if !self.list_stack.is_empty() && !self.current_ends_with('\n') {
418 self.write_char('\n');
419 }
420 self.list_stack.push(ListFrame {
421 ordered,
422 next_index: 1, saw_first_item: false,
424 item_content_col: 0,
425 });
426 }
427 Event::End(TagEnd::List(_)) => {
428 self.list_stack.pop();
429 }
430 Event::Start(Tag::Item) => {
431 let depth = self.list_stack.len().saturating_sub(1);
432 let (line, col) = self.pos(range.start);
433 let _ = line;
434 if depth > 0 {
436 let parent = &self.list_stack[depth - 1];
437 if parent.item_content_col > 0 {
438 let expected_col = parent.item_content_col;
439 if col != expected_col && col != parent.item_content_col {
440 let already = self
441 .diags
442 .iter()
443 .any(|d| d.hole == Hole::NestIndentNormalize && d.line == line);
444 if !already {
445 self.push_diag(
446 Hole::NestIndentNormalize,
447 range.clone(),
448 format!(
449 "nesting at col {} normalized to {} (2-space rule)",
450 col, expected_col
451 ),
452 );
453 }
454 }
455 }
456 }
457 let frame_ordered = self
458 .list_stack
459 .last()
460 .expect("Item without enclosing List")
461 .ordered;
462 if !frame_ordered {
464 let snippet = self.src.get(range.clone()).unwrap_or("");
465 let first = snippet
466 .as_bytes()
467 .iter()
468 .find(|&&b| b == b'-' || b == b'*' || b == b'+')
469 .copied();
470 if first == Some(b'*') || first == Some(b'+') {
471 self.push_diag(
472 Hole::AltBullet,
473 range.clone(),
474 format!(
475 "`{}` bullet rewritten to `-`",
476 first.map(|b| b as char).unwrap_or('?')
477 ),
478 );
479 }
480 }
481 let pad: String = " ".repeat(depth * 2);
482 self.write(&pad);
483 let frame = self
484 .list_stack
485 .last_mut()
486 .expect("Item without enclosing List");
487 let marker_len: usize;
488 let marker_str: String;
489 if frame.ordered {
490 marker_str = format!("{}. ", frame.next_index);
491 marker_len = marker_str.len();
492 frame.next_index += 1;
493 } else {
494 marker_str = "- ".to_string();
495 marker_len = 2;
496 }
497 frame.saw_first_item = true;
498 frame.item_content_col = depth * 2 + marker_len + 1;
499 self.write(&marker_str);
500 }
501 Event::End(TagEnd::Item) => {
502 if !self.current_ends_with('\n') {
503 self.write_char('\n');
504 }
505 }
506 Event::Start(Tag::Paragraph) => {
507 self.flush_pending_hole_comments();
508 self.in_paragraph = true;
509 self.out_stack.push(String::new());
513 self.container_stack.push(Container::Paragraph);
514 }
515 Event::End(TagEnd::Paragraph) => {
516 self.in_paragraph = false;
517 let body = self.out_stack.pop().expect("paragraph buffer");
518 let _ = self.container_stack.pop();
519 self.write(&body);
520 if self.list_stack.is_empty() {
521 self.write_char('\n');
522 self.write_char('\n');
523 } else {
524 self.write_char('\n');
526 }
527 }
528 Event::Start(Tag::Heading { level, id, .. }) => {
529 self.flush_pending_hole_comments();
530 let n = match level {
531 pulldown_cmark::HeadingLevel::H1 => 1,
532 pulldown_cmark::HeadingLevel::H2 => 2,
533 pulldown_cmark::HeadingLevel::H3 => 3,
534 pulldown_cmark::HeadingLevel::H4 => 4,
535 pulldown_cmark::HeadingLevel::H5 => 5,
536 pulldown_cmark::HeadingLevel::H6 => 6,
537 };
538 let snippet = self.src.get(range.clone()).unwrap_or("");
540 let is_setext = !snippet.trim_start().starts_with('#');
541 if is_setext {
542 self.push_diag(
543 Hole::SetextHeading,
544 range.clone(),
545 format!("rewritten to `{} ...`", "#".repeat(n)),
546 );
547 }
548 if let Some(raw) = id {
549 let raw = raw.to_string();
550 let safe = sluggify_anchor(&raw);
551 if safe != raw {
552 self.push_diag(
553 Hole::HeadingAnchorSlugged,
554 range.clone(),
555 format!("anchor `{}` rewritten to `{}`", raw, safe),
556 );
557 }
558 self.pending_heading_anchor = Some(safe);
559 }
560 for _ in 0..n {
561 self.write_char('#');
562 }
563 self.write_char(' ');
564 }
565 Event::End(TagEnd::Heading(_)) => {
566 if let Some(anchor) = self.pending_heading_anchor.take() {
567 while self.current_ends_with(' ') {
570 match self.out_stack.last_mut() {
571 Some(buf) => {
572 buf.pop();
573 }
574 None => {
575 self.out.pop();
576 }
577 }
578 }
579 self.write(" {#");
580 self.write(&anchor);
581 self.write_char('}');
582 }
583 self.write_char('\n');
584 }
585 Event::Start(Tag::Emphasis) => {
586 let first = self.src.as_bytes().get(range.start).copied();
588 if first == Some(b'*') {
589 self.push_diag(
590 Hole::AsteriskEmphasis,
591 range.clone(),
592 "Markdown `*italic*` rewritten to Brief `_italic_`".into(),
593 );
594 }
595 self.write_char('_');
596 }
597 Event::End(TagEnd::Emphasis) => {
598 self.write_char('_');
599 }
600 Event::Start(Tag::Strong) => {
601 self.push_diag(
602 Hole::DoubleEmphasis,
603 range.clone(),
604 "doubled emphasis marker rewritten to single `*`".into(),
605 );
606 self.write_char('*');
607 }
608 Event::End(TagEnd::Strong) => {
609 self.write_char('*');
610 }
611 Event::Start(Tag::Strikethrough) => {
612 self.push_diag(
613 Hole::DoubleEmphasis,
614 range.clone(),
615 "doubled strikethrough rewritten to single `~`".into(),
616 );
617 self.write_char('~');
618 }
619 Event::End(TagEnd::Strikethrough) => {
620 self.write_char('~');
621 }
622 Event::Code(s) => {
623 if s.contains('`') {
624 self.write("``");
625 self.write(&s);
626 self.write("``");
627 } else {
628 self.write_char('`');
629 self.write(&s);
630 self.write_char('`');
631 }
632 }
633 Event::Start(Tag::CodeBlock(kind)) => {
634 self.flush_pending_hole_comments();
635 use pulldown_cmark::CodeBlockKind;
636 self.in_code_block = true;
637 match kind {
638 CodeBlockKind::Fenced(lang) => {
639 let snippet = self.src.get(range.clone()).unwrap_or("");
641 if snippet.trim_start().starts_with('~') {
642 self.push_diag(
643 Hole::TildeFence,
644 range.clone(),
645 "`~~~` fence rewritten to ```` ``` ```` fence".into(),
646 );
647 }
648 self.write("```");
649 if !lang.is_empty() {
650 let lang_token = lang.split_whitespace().next().unwrap_or("");
652 self.write(lang_token);
653 }
654 self.write_char('\n');
655 }
656 CodeBlockKind::Indented => {
657 self.push_diag(
658 Hole::IndentedCodeBlock,
659 range.clone(),
660 "indented code block rewritten to fenced block".into(),
661 );
662 self.write("```\n");
663 }
664 }
665 }
666 Event::End(TagEnd::CodeBlock) => {
667 self.in_code_block = false;
668 if !self.current_ends_with('\n') {
669 self.write_char('\n');
670 }
671 self.write("```\n");
672 }
673 Event::Text(t) => {
674 if self.in_metadata {
675 self.metadata_buf.push_str(&t);
676 return;
677 }
678 if self.in_code_block {
679 self.write(&t);
680 } else {
681 let escaped = escape_brief_inline_text(&t);
682 if escaped != *t {
683 self.push_diag(
687 Hole::EscapedSigil,
688 range.clone(),
689 format!(
690 "escaped emphasis sigil(s) in literal text: {:?}",
691 t.chars().take(40).collect::<String>()
692 ),
693 );
694 }
695 self.write(&escaped);
696 }
697 }
698 Event::TaskListMarker(checked) => {
699 self.write(if checked { "[x] " } else { "[ ] " });
703 }
704 Event::Start(Tag::BlockQuote(kind)) => {
705 self.flush_pending_hole_comments();
706 use pulldown_cmark::BlockQuoteKind;
707 let container = match kind {
708 None => Container::Quote,
709 Some(BlockQuoteKind::Note) => {
710 self.push_diag(
711 Hole::GfmAlert,
712 range.clone(),
713 "GFM alert mapped to `@callout(kind: note)`".into(),
714 );
715 Container::Alert(Hole::GfmAlert, "note")
716 }
717 Some(BlockQuoteKind::Tip) => {
718 self.push_diag(
719 Hole::GfmAlert,
720 range.clone(),
721 "GFM alert mapped to `@callout(kind: tip)`".into(),
722 );
723 Container::Alert(Hole::GfmAlert, "tip")
724 }
725 Some(BlockQuoteKind::Important) => {
726 self.push_diag(
727 Hole::GfmAlert,
728 range.clone(),
729 "GFM alert mapped to `@callout(kind: important)`".into(),
730 );
731 Container::Alert(Hole::GfmAlert, "important")
732 }
733 Some(BlockQuoteKind::Warning) => {
734 self.push_diag(
735 Hole::GfmAlert,
736 range.clone(),
737 "GFM alert mapped to `@callout(kind: warning)`".into(),
738 );
739 Container::Alert(Hole::GfmAlert, "warning")
740 }
741 Some(BlockQuoteKind::Caution) => {
742 self.push_diag(
743 Hole::GfmAlert,
744 range.clone(),
745 "GFM alert mapped to `@callout(kind: caution)`".into(),
746 );
747 Container::Alert(Hole::GfmAlert, "caution")
748 }
749 };
750 self.out_stack.push(String::new());
751 self.container_stack.push(container);
752 }
753 Event::End(TagEnd::BlockQuote(_)) => {
754 let inner = self.out_stack.pop().expect("unbalanced quote stack");
755 let container = self
756 .container_stack
757 .pop()
758 .expect("unbalanced container stack");
759 let trimmed = inner.trim_end_matches('\n');
760 match container {
761 Container::Quote => {
762 let mut groups: Vec<Vec<&str>> = Vec::new();
768 let mut cur: Vec<&str> = Vec::new();
769 for line in trimmed.split('\n') {
770 if line.is_empty() {
771 if !cur.is_empty() {
772 groups.push(std::mem::take(&mut cur));
773 }
774 continue;
777 }
778 cur.push(line);
779 }
780 if !cur.is_empty() {
781 groups.push(cur);
782 }
783 let saw_blank = groups.len() > 1;
784 for (gi, group) in groups.iter().enumerate() {
785 if gi > 0 {
786 self.write_char('\n');
790 }
791 for line in group {
792 self.write("> ");
793 self.write(line);
794 self.write_char('\n');
795 }
796 }
797 if saw_blank {
798 self.push_diag(
803 Hole::BlockquoteParagraphSplit,
804 range.clone(),
805 "in-quote paragraph break rewritten to adjacent blockquotes".into(),
806 );
807 }
808 }
809 Container::Alert(_, kind) => {
810 self.write("@callout(kind: ");
811 self.write(kind);
812 self.write(")\n");
813 self.write(trimmed);
814 if !trimmed.ends_with('\n') {
815 self.write_char('\n');
816 }
817 self.write("@end\n");
818 }
819 Container::LinkPending
820 | Container::ImagePending
821 | Container::Paragraph
822 | Container::HtmlBlock
823 | Container::Details { .. } => {
824 self.write(&inner);
827 }
828 }
829 }
830 Event::Rule => {
831 self.flush_pending_hole_comments();
832 let snippet = self.src.get(range.clone()).unwrap_or("").trim();
833 let is_clean_dashes = snippet == "---";
834 if !is_clean_dashes {
835 self.push_diag(
836 Hole::AltHorizontalRule,
837 range.clone(),
838 format!("`{}` rewritten to `---`", snippet),
839 );
840 }
841 self.write("---\n");
842 }
843 Event::Start(Tag::Link {
844 link_type,
845 dest_url,
846 title,
847 ..
848 }) => {
849 use pulldown_cmark::LinkType;
850 let link_title = if title.is_empty() {
852 None
853 } else {
854 Some(title.to_string())
855 };
856 let mut diag: Option<(Hole, String)> = None;
857 match link_type {
858 LinkType::Autolink | LinkType::Email => {
859 diag = Some((
860 Hole::AutolinkRewrap,
861 format!("autolink `<{}>` wrapped in `@link[..](..)`", dest_url),
862 ));
863 }
864 LinkType::Reference
865 | LinkType::ReferenceUnknown
866 | LinkType::Collapsed
867 | LinkType::CollapsedUnknown
868 | LinkType::Shortcut
869 | LinkType::ShortcutUnknown => {
870 diag = Some((
871 Hole::RefLinkInlined,
872 "reference-style link resolved inline".into(),
873 ));
874 }
875 LinkType::Inline => {}
876 _ => {}
877 }
878 let url_with_title = if let Some(ref t) = link_title {
886 format!("{}\x00{}", dest_url, t)
887 } else {
888 dest_url.to_string()
889 };
890 self.link_stack.push((url_with_title, diag));
891 self.out_stack.push(String::new());
892 self.container_stack.push(Container::LinkPending);
893 }
894 Event::End(TagEnd::Link) => {
895 let text = self.out_stack.pop().expect("link buffer");
896 let _ = self.container_stack.pop();
897 let (url_with_title, diag) = self.link_stack.pop().expect("link stack");
898 let (url, opt_title) = if let Some(idx) = url_with_title.find('\x00') {
900 let (u, t) = url_with_title.split_at(idx);
901 (u.to_string(), Some(t[1..].to_string()))
902 } else {
903 (url_with_title, None)
904 };
905 if let Some((hole, note)) = diag {
906 self.diags.push(Diag {
907 hole,
908 line: 0,
909 col: 0,
910 original: format!("[{}]({})", text, url),
911 note,
912 });
913 }
914 let url = brief_safe_url(&url);
915 if let Some(t) = opt_title {
916 self.write("@link(title: \"");
917 self.write(&t);
918 self.write("\")[");
919 self.write(&text);
920 self.write("](");
921 self.write(&url);
922 self.write(")");
923 } else {
924 self.write("@link[");
925 self.write(&text);
926 self.write("](");
927 self.write(&url);
928 self.write(")");
929 }
930 }
931 Event::Start(Tag::Image {
932 dest_url, title, ..
933 }) => {
934 let diag = if title.is_empty() {
935 None
936 } else {
937 Some((
938 Hole::LinkTitleDropped,
939 format!("image title `{}` dropped", title),
940 ))
941 };
942 self.link_stack.push((dest_url.to_string(), diag));
943 self.out_stack.push(String::new());
944 self.container_stack.push(Container::ImagePending);
945 }
946 Event::End(TagEnd::Image) => {
947 let alt = self.out_stack.pop().expect("image buffer");
948 let _ = self.container_stack.pop();
949 let (src, diag) = self.link_stack.pop().expect("link stack");
950 if let Some((hole, note)) = diag {
951 self.diags.push(Diag {
952 hole,
953 line: 0,
954 col: 0,
955 original: format!("", alt, src),
956 note,
957 });
958 }
959 self.write("@image(src: \"");
960 self.write(&src);
961 self.write("\", alt: \"");
962 self.write(&alt);
963 self.write("\")[]");
964 }
965 Event::Start(Tag::Table(aligns)) => {
966 self.flush_pending_hole_comments();
967 self.table = Some(TableState {
968 aligns,
969 rows: Vec::new(),
970 current_row: Vec::new(),
971 current_cell: String::new(),
972 in_cell: false,
973 });
974 }
975 Event::End(TagEnd::Table) => {
976 if let Some(state) = self.table.take() {
977 self.emit_table(state);
978 }
979 }
980 Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => {
981 if let Some(t) = self.table.as_mut() {
982 t.current_row = Vec::new();
983 }
984 }
985 Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
986 if let Some(t) = self.table.as_mut() {
987 let row = std::mem::take(&mut t.current_row);
988 t.rows.push(row);
989 }
990 }
991 Event::Start(Tag::TableCell) => {
992 if let Some(t) = self.table.as_mut() {
993 t.current_cell = String::new();
994 t.in_cell = true;
995 }
996 }
997 Event::End(TagEnd::TableCell) => {
998 if let Some(t) = self.table.as_mut() {
999 let cell = std::mem::take(&mut t.current_cell);
1000 t.current_row.push(cell);
1001 t.in_cell = false;
1002 }
1003 }
1004 Event::Start(Tag::DefinitionList) => {
1005 self.flush_pending_hole_comments();
1006 if !self.out.is_empty() && !self.out.ends_with("\n\n") {
1009 if !self.out.ends_with('\n') {
1010 self.out.push('\n');
1011 }
1012 self.out.push('\n');
1013 }
1014 self.dl = Some(DefinitionListState {
1015 items: Vec::new(),
1016 current_term: String::new(),
1017 current_def: String::new(),
1018 defs_for_current_term: 0,
1019 in_term: false,
1020 in_def: false,
1021 });
1022 }
1023 Event::End(TagEnd::DefinitionList) => {
1024 let Some(state) = self.dl.take() else {
1030 return;
1031 };
1032 if !state.items.is_empty() {
1033 self.out.push_str("@dl\n");
1034 for (term, def) in &state.items {
1035 self.out.push_str(term.trim_end());
1036 self.out.push('\n');
1037 self.out.push_str(": ");
1038 self.out.push_str(def.trim_end());
1039 self.out.push('\n');
1040 }
1041 self.out.push_str("@end\n\n");
1042 }
1043 }
1044 Event::Start(Tag::DefinitionListTitle) => {
1045 if let Some(d) = self.dl.as_mut() {
1046 d.in_term = true;
1047 d.current_term.clear();
1048 d.defs_for_current_term = 0;
1049 }
1050 }
1051 Event::End(TagEnd::DefinitionListTitle) => {
1052 if let Some(d) = self.dl.as_mut() {
1053 d.in_term = false;
1054 }
1055 }
1056 Event::Start(Tag::DefinitionListDefinition) => {
1057 if let Some(d) = self.dl.as_mut() {
1058 d.in_def = true;
1059 d.current_def.clear();
1060 d.defs_for_current_term += 1;
1061 }
1062 let crossed_to_two = self
1063 .dl
1064 .as_ref()
1065 .map(|d| d.defs_for_current_term == 2)
1066 .unwrap_or(false);
1067 if crossed_to_two {
1068 self.push_diag(
1069 Hole::DefinitionListMultipleDefs,
1070 range.clone(),
1071 "definition list term repeated for each definition (Brief v0.4 limitation)"
1072 .into(),
1073 );
1074 }
1075 }
1076 Event::End(TagEnd::DefinitionListDefinition) => {
1077 if let Some(d) = self.dl.as_mut() {
1078 d.in_def = false;
1079 let term = d.current_term.clone();
1080 let def = std::mem::take(&mut d.current_def);
1081 d.items.push((term, def));
1082 }
1083 }
1084 Event::Start(Tag::MetadataBlock(kind)) => {
1085 self.in_metadata = true;
1086 self.metadata_buf.clear();
1087 self.metadata_kind = Some(kind);
1088 }
1089 Event::End(TagEnd::MetadataBlock(_)) => {
1090 use pulldown_cmark::MetadataBlockKind;
1091 self.in_metadata = false;
1092 let kind = self.metadata_kind.take();
1093 let body = std::mem::take(&mut self.metadata_buf);
1094 match kind {
1095 Some(MetadataBlockKind::PlusesStyle) => {
1096 self.write("+++\n");
1099 self.write(&body);
1100 if !body.ends_with('\n') {
1101 self.write_char('\n');
1102 }
1103 self.write("+++\n\n");
1104 }
1105 _ => {
1106 self.push_diag(
1107 Hole::Frontmatter,
1108 range.clone(),
1109 "frontmatter dropped, replaced with TODO comment".into(),
1110 );
1111 self.flush_pending_hole_comments();
1114 }
1115 }
1116 }
1117 Event::Start(Tag::HtmlBlock) => {
1118 self.out_stack.push(String::new());
1122 self.container_stack.push(Container::HtmlBlock);
1123 }
1124 Event::End(TagEnd::HtmlBlock) => {
1125 let buf = self.out_stack.pop().expect("html block buffer");
1126 let _ = self.container_stack.pop();
1127 match classify_details_block(&buf) {
1128 DetailsShape::Closed { summary, body } => {
1129 self.write("@details(summary: \"");
1130 self.write(&escape_brief_string(&summary));
1131 self.write("\")\n");
1132 let body = body.trim_matches('\n');
1133 if !body.is_empty() {
1134 self.write(body);
1135 self.write_char('\n');
1136 }
1137 self.write("@end\n\n");
1138 }
1139 DetailsShape::Open { summary } => {
1140 self.out_stack.push(String::new());
1144 self.container_stack.push(Container::Details { summary });
1145 }
1146 DetailsShape::Close => {
1147 let mut closed = false;
1149 if matches!(self.container_stack.last(), Some(Container::Details { .. })) {
1150 let body = self.out_stack.pop().expect("details body buffer");
1151 let container = self.container_stack.pop().expect("details container");
1152 if let Container::Details { summary } = container {
1153 self.write("@details(summary: \"");
1154 self.write(&escape_brief_string(&summary));
1155 self.write("\")\n");
1156 let body = body.trim_matches('\n');
1157 if !body.is_empty() {
1158 self.write(body);
1159 self.write_char('\n');
1160 }
1161 self.write("@end\n\n");
1162 closed = true;
1163 }
1164 }
1165 if !closed {
1166 self.fallback_html_block(range.clone(), &buf);
1169 }
1170 }
1171 DetailsShape::Unknown => {
1172 self.fallback_html_block(range.clone(), &buf);
1173 }
1174 }
1175 }
1176 Event::Html(s) => {
1177 self.write(&s);
1179 }
1180 Event::InlineHtml(s) => {
1181 let trimmed = s.trim();
1182 if let Some(kind) = classify_inline_html_open(trimmed) {
1183 self.html_replace_stack.push(kind);
1184 self.write_char('@');
1185 self.write(kind.shortcode());
1186 self.write_char('[');
1187 return;
1188 }
1189 if let Some(kind) = classify_inline_html_close(trimmed) {
1190 if self.html_replace_stack.last().copied() == Some(kind) {
1191 self.html_replace_stack.pop();
1192 self.write_char(']');
1193 return;
1194 }
1195 }
1198 if is_inline_br(trimmed) {
1199 self.write_char('\\');
1201 self.write_char('\n');
1202 return;
1203 }
1204 let snippet = s.to_string();
1205 self.push_diag(
1206 Hole::InlineHtml,
1207 range.clone(),
1208 format!("`{}` preserved as TODO comment", snippet.trim()),
1209 );
1210 }
1213 Event::FootnoteReference(label) => {
1214 let body = self
1215 .footnote_defs
1216 .get(label.as_ref())
1217 .cloned()
1218 .unwrap_or_else(|| format!("??: {}", label));
1219 self.write("@footnote[");
1220 self.write(&body);
1221 self.write("]");
1222 }
1223 Event::SoftBreak => {
1224 self.write_char('\n');
1225 }
1226 Event::HardBreak => {
1227 self.write_char('\\');
1228 self.write_char('\n');
1229 }
1230 Event::InlineMath(s) => {
1231 self.write("@math[");
1232 self.write(&s);
1233 self.write_char(']');
1234 }
1235 Event::DisplayMath(s) => {
1236 let body = s.trim_matches('\n');
1237 self.write("@math\n");
1238 self.write(body);
1239 self.write_char('\n');
1240 self.write("@end");
1241 }
1242 _ => {
1243 }
1245 }
1246 }
1247
1248 fn finish(mut self) -> ConvertResult {
1249 self.flush_pending_hole_comments();
1252 while self.out.ends_with("\n\n") {
1254 self.out.pop();
1255 }
1256 if !self.out.is_empty() && !self.out.ends_with('\n') {
1257 self.out.push('\n');
1258 }
1259 ConvertResult {
1260 brief_source: self.out,
1261 diagnostics: self.diags,
1262 }
1263 }
1264
1265 #[allow(dead_code)] fn pos(&self, offset: usize) -> (usize, usize) {
1268 match self.line_offsets.binary_search(&offset) {
1269 Ok(line) => (line + 1, 1),
1270 Err(line) => {
1271 let line_start = self.line_offsets[line - 1];
1272 (line, offset - line_start + 1)
1273 }
1274 }
1275 }
1276
1277 #[allow(dead_code)] fn push_diag(&mut self, hole: Hole, range: std::ops::Range<usize>, note: String) {
1279 let (line, col) = self.pos(range.start);
1280 let original = self
1281 .src
1282 .get(range.clone())
1283 .unwrap_or("")
1284 .chars()
1285 .take(80)
1286 .collect::<String>();
1287 self.diags.push(Diag {
1288 hole,
1289 line,
1290 col,
1291 original,
1292 note: note.clone(),
1293 });
1294 self.pending_hole_comments
1297 .push(format!("// TODO[B-hole:{}]: {}", hole.slug(), note));
1298 }
1299
1300 fn flush_pending_hole_comments(&mut self) {
1301 for c in std::mem::take(&mut self.pending_hole_comments) {
1302 if let Some(buf) = self.out_stack.last_mut() {
1304 buf.push_str(&c);
1305 buf.push('\n');
1306 } else {
1307 self.out.push_str(&c);
1308 self.out.push('\n');
1309 }
1310 }
1311 }
1312
1313 fn fallback_html_block(&mut self, range: std::ops::Range<usize>, buf: &str) {
1316 self.push_diag(
1317 Hole::HtmlBlock,
1318 range,
1319 "HTML block preserved inside Brief block comment".into(),
1320 );
1321 self.flush_pending_hole_comments();
1324 self.write("/*\n");
1325 let sanitized = buf.replace("*/", "* /");
1328 self.write(&sanitized);
1329 if !sanitized.ends_with('\n') {
1330 self.write_char('\n');
1331 }
1332 self.write("*/\n");
1333 }
1334
1335 fn emit_table(&mut self, state: TableState) {
1336 use pulldown_cmark::Alignment;
1337 let needs_align = state.aligns.iter().any(|a| !matches!(a, Alignment::None));
1338 if needs_align {
1339 self.write("@t(align: [");
1340 let parts: Vec<&str> = state
1341 .aligns
1342 .iter()
1343 .map(|a| match a {
1344 Alignment::None | Alignment::Left => "left",
1345 Alignment::Center => "center",
1346 Alignment::Right => "right",
1347 })
1348 .collect();
1349 self.write(&parts.join(", "));
1350 self.write("])\n");
1351 } else {
1352 self.write("@t\n");
1353 }
1354 let mut saw_pipe_escape = false;
1355 for row in &state.rows {
1356 self.write("|");
1357 for (i, cell) in row.iter().enumerate() {
1358 self.write(" ");
1359 let trimmed = cell.trim();
1360 let (escaped, escaped_pipe) = if trimmed.is_empty() {
1361 self.diags.push(Diag {
1362 hole: Hole::EmptyTableCell,
1363 line: 0,
1364 col: 0,
1365 original: String::new(),
1366 note: "empty Markdown table cell padded with `—`".into(),
1367 });
1368 ("—".to_string(), false)
1369 } else {
1370 escape_table_cell(trimmed)
1371 };
1372 if escaped_pipe {
1373 saw_pipe_escape = true;
1374 }
1375 self.write(&escaped);
1376 if i + 1 < row.len() {
1377 self.write(" |");
1378 }
1379 }
1380 self.write("\n");
1381 }
1382 if saw_pipe_escape {
1383 self.diags.push(Diag {
1384 hole: Hole::TableCellPipeEscape,
1385 line: 0,
1386 col: 0,
1387 original: String::new(),
1388 note: "`|` inside table cell escaped to `\\|`".into(),
1389 });
1390 }
1391 }
1392}
1393
1394fn sluggify_anchor(raw: &str) -> String {
1399 let mut out = String::with_capacity(raw.len());
1400 let mut last_dash = true;
1401 for ch in raw.chars() {
1402 let lo = ch.to_ascii_lowercase();
1403 if lo.is_ascii_lowercase() || lo.is_ascii_digit() {
1404 out.push(lo);
1405 last_dash = false;
1406 } else if !last_dash {
1407 out.push('-');
1408 last_dash = true;
1409 }
1410 }
1411 while out.ends_with('-') {
1412 out.pop();
1413 }
1414 if out.is_empty() {
1415 return "section".to_string();
1416 }
1417 out
1418}
1419
1420fn classify_inline_html_open(s: &str) -> Option<HtmlInlineKind> {
1424 let t = s.trim().to_ascii_lowercase();
1425 match t.as_str() {
1426 "<sub>" => Some(HtmlInlineKind::Sub),
1427 "<sup>" => Some(HtmlInlineKind::Sup),
1428 "<kbd>" => Some(HtmlInlineKind::Kbd),
1429 _ => None,
1430 }
1431}
1432
1433fn classify_inline_html_close(s: &str) -> Option<HtmlInlineKind> {
1434 let t = s.trim().to_ascii_lowercase();
1435 match t.as_str() {
1436 "</sub>" => Some(HtmlInlineKind::Sub),
1437 "</sup>" => Some(HtmlInlineKind::Sup),
1438 "</kbd>" => Some(HtmlInlineKind::Kbd),
1439 _ => None,
1440 }
1441}
1442
1443fn is_inline_br(s: &str) -> bool {
1444 let t = s.trim().to_ascii_lowercase();
1445 matches!(t.as_str(), "<br>" | "<br/>" | "<br />")
1446}
1447
1448fn escape_table_cell(s: &str) -> (String, bool) {
1457 let mut out = String::with_capacity(s.len());
1458 let mut escaped = false;
1459 let mut prev_backslash = false;
1460 for ch in s.chars() {
1461 if ch == '|' && !prev_backslash {
1462 out.push('\\');
1463 out.push('|');
1464 escaped = true;
1465 prev_backslash = false;
1466 continue;
1467 }
1468 prev_backslash = ch == '\\' && !prev_backslash;
1469 out.push(ch);
1470 }
1471 (out, escaped)
1472}
1473
1474fn escape_brief_string(s: &str) -> String {
1476 let mut out = String::with_capacity(s.len());
1477 for ch in s.chars() {
1478 match ch {
1479 '\\' => out.push_str("\\\\"),
1480 '"' => out.push_str("\\\""),
1481 '\n' | '\r' => out.push(' '),
1482 _ => out.push(ch),
1483 }
1484 }
1485 out
1486}
1487
1488fn classify_details_block(buf: &str) -> DetailsShape {
1496 let trimmed = buf.trim();
1497 let lower = trimmed.to_ascii_lowercase();
1498 let starts_open = lower.starts_with("<details>")
1499 || lower.starts_with("<details ")
1500 || lower.starts_with("<details\n");
1501 let only_close = lower == "</details>" || lower.starts_with("</details>");
1502 if only_close && !starts_open {
1503 return DetailsShape::Close;
1505 }
1506 if !starts_open {
1507 return DetailsShape::Unknown;
1508 }
1509 let after_open = match find_after_open_tag(trimmed, "details") {
1510 Some(idx) => idx,
1511 None => return DetailsShape::Unknown,
1512 };
1513 let inner = &trimmed[after_open..];
1514 let inner = inner.strip_prefix('\n').unwrap_or(inner);
1516 let summary = extract_summary(inner);
1517 let body_start = match summary.as_ref() {
1518 Some((_, end)) => *end,
1519 None => 0,
1520 };
1521 let after_summary = &inner[body_start..];
1522 let lower_after = after_summary.to_ascii_lowercase();
1524 if let Some(close_idx) = lower_after.rfind("</details>") {
1525 let body = after_summary[..close_idx].trim_matches('\n').to_string();
1526 let summary_text = summary.map(|((s, _), _)| s).unwrap_or_default();
1527 DetailsShape::Closed {
1528 summary: summary_text,
1529 body,
1530 }
1531 } else {
1532 let summary_text = summary.map(|((s, _), _)| s).unwrap_or_default();
1534 DetailsShape::Open {
1535 summary: summary_text,
1536 }
1537 }
1538}
1539
1540enum DetailsShape {
1541 Closed { summary: String, body: String },
1542 Open { summary: String },
1543 Close,
1544 Unknown,
1545}
1546
1547fn find_after_open_tag(s: &str, name: &str) -> Option<usize> {
1550 let lower = s.to_ascii_lowercase();
1551 let needle = format!("<{}", name);
1552 let start = lower.find(&needle)?;
1553 let rest = &s[start + needle.len()..];
1554 let close = rest.find('>')?;
1555 Some(start + needle.len() + close + 1)
1556}
1557
1558#[allow(clippy::type_complexity)]
1561fn extract_summary(s: &str) -> Option<((String, usize), usize)> {
1562 let lower = s.to_ascii_lowercase();
1563 let open_idx = lower.find("<summary")?;
1564 let after_open_attrs = &s[open_idx..];
1565 let gt = after_open_attrs.find('>')?;
1566 let body_start = open_idx + gt + 1;
1567 let after_body = &s[body_start..];
1568 let lower_after = after_body.to_ascii_lowercase();
1569 let close_rel = lower_after.find("</summary>")?;
1570 let summary_text = strip_tags(&after_body[..close_rel]).trim().to_string();
1571 let close_end = body_start + close_rel + "</summary>".len();
1572 Some(((summary_text, close_end - open_idx), close_end))
1573}
1574
1575fn strip_tags(s: &str) -> String {
1577 let mut out = String::with_capacity(s.len());
1578 let mut in_tag = false;
1579 for ch in s.chars() {
1580 match ch {
1581 '<' => in_tag = true,
1582 '>' => in_tag = false,
1583 _ if !in_tag => out.push(ch),
1584 _ => {}
1585 }
1586 }
1587 out
1588}
1589
1590fn escape_brief_inline_text(s: &str) -> String {
1605 use crate::inline::{is_inline_sigil, is_open_marker_at, is_punct};
1606 let bytes = s.as_bytes();
1607 let mut out = String::with_capacity(s.len());
1608 let mut i = 0usize;
1609 while i < bytes.len() {
1610 let b = bytes[i];
1611 if matches!(b, b'*' | b'_' | b'+' | b'~') {
1612 let should_escape = is_open_marker_at(bytes, i) || {
1614 let is_last = i + 1 == bytes.len();
1619 if is_last {
1620 let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
1621 let prev_ok = match prev {
1622 None => true,
1623 Some(b' ') => true,
1624 Some(pb) if is_inline_sigil(pb) => true,
1625 Some(pb) if is_punct(pb) => true,
1626 _ => false,
1627 };
1628 let not_doubled = prev != Some(b);
1630 prev_ok && not_doubled
1631 } else {
1632 false
1633 }
1634 };
1635 if should_escape {
1636 out.push('\\');
1637 out.push(b as char);
1638 let w = s[i..].chars().next().map_or(1, |c| c.len_utf8());
1639 i += w;
1640 continue;
1641 }
1642 }
1643 let w = s[i..].chars().next().map_or(1, |c| c.len_utf8());
1644 out.push_str(&s[i..i + w]);
1645 i += w;
1646 }
1647 out
1648}
1649
1650fn brief_safe_url(url: &str) -> String {
1655 let mut depth = 0i64;
1656 let mut balanced = true;
1657 for b in url.bytes() {
1658 match b {
1659 b'(' => depth += 1,
1660 b')' => {
1661 depth -= 1;
1662 if depth < 0 {
1663 balanced = false;
1664 break;
1665 }
1666 }
1667 _ => {}
1668 }
1669 }
1670 if balanced && depth == 0 {
1671 return url.to_string();
1672 }
1673 url.replace('(', "%28").replace(')', "%29")
1674}
1675
1676fn compute_line_offsets(s: &str) -> Vec<usize> {
1677 let mut v = vec![0usize];
1678 for (i, b) in s.bytes().enumerate() {
1679 if b == b'\n' {
1680 v.push(i + 1);
1681 }
1682 }
1683 v
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688 use super::*;
1689
1690 #[test]
1691 fn converts_toml_frontmatter_cleanly() {
1692 let md = "+++\ntitle = \"hi\"\nn = 3\n+++\n\n# Doc\nbody\n";
1693 let res = convert(md, "in.md");
1694 assert!(
1695 !res.diagnostics.iter().any(|d| d.hole == Hole::Frontmatter),
1696 "{:?}",
1697 res.diagnostics
1698 );
1699 assert!(
1700 res.brief_source.starts_with("+++\n"),
1701 "got: {}",
1702 res.brief_source
1703 );
1704 assert!(res.brief_source.contains("title = \"hi\""));
1705 assert!(res.brief_source.contains("n = 3"));
1706 assert!(res.brief_source.contains("\n+++\n"));
1707 assert!(res.brief_source.contains("# Doc"));
1708 }
1709
1710 #[test]
1711 fn converts_yaml_frontmatter_as_hole() {
1712 let md = "---\ntitle: hi\n---\n\n# Doc\n";
1713 let res = convert(md, "in.md");
1714 assert!(
1715 res.diagnostics.iter().any(|d| d.hole == Hole::Frontmatter),
1716 "{:?}",
1717 res.diagnostics
1718 );
1719 assert!(
1720 res.brief_source.contains("// TODO[B-hole:frontmatter]"),
1721 "{}",
1722 res.brief_source
1723 );
1724 }
1725
1726 #[test]
1727 fn converted_toml_frontmatter_round_trips_through_compiler() {
1728 let md = "+++\ntitle = \"hi\"\n+++\n\n# Doc\n";
1729 let res = convert(md, "in.md");
1730 let src = crate::span::SourceMap::new("in.brf", res.brief_source.clone());
1731 let toks = crate::lexer::lex(&src).expect("lex ok");
1732 let (doc, diags) = crate::parser::parse(toks, &src);
1733 assert!(diags.is_empty(), "{:?}\n---\n{}", diags, res.brief_source);
1734 assert!(doc.metadata.is_some());
1735 }
1736
1737 #[test]
1738 fn tilde_before_digit_inside_emphasis_round_trips() {
1739 let md = "*patch exceeds ~10 ops, any value exceeds ~50 lines*\n";
1743 let res = convert(md, "in.md");
1744 let src = crate::span::SourceMap::new("in.brf", res.brief_source.clone());
1745 let toks = crate::lexer::lex(&src).expect("lex ok");
1746 let (_doc, diags) = crate::parser::parse(toks, &src);
1747 let errors: Vec<_> = diags
1748 .iter()
1749 .filter(|d| d.severity == crate::diag::Severity::Error)
1750 .collect();
1751 assert!(
1752 errors.is_empty(),
1753 "converter output failed to compile: {:?}\nbrief: {}",
1754 errors,
1755 res.brief_source
1756 );
1757 }
1758
1759 #[test]
1760 fn asterisk_in_heading_text_round_trips() {
1761 let md = "### 8.6 Set an attribute (href, aria-*, …)\n";
1763 let res = convert(md, "in.md");
1764 let src = crate::span::SourceMap::new("in.brf", res.brief_source.clone());
1765 let toks = crate::lexer::lex(&src).expect("lex ok");
1766 let (_doc, diags) = crate::parser::parse(toks, &src);
1767 let errors: Vec<_> = diags
1768 .iter()
1769 .filter(|d| d.severity == crate::diag::Severity::Error)
1770 .collect();
1771 assert!(
1772 errors.is_empty(),
1773 "{:?}\nbrief: {}",
1774 errors,
1775 res.brief_source
1776 );
1777 assert!(
1778 res.brief_source.contains(r"aria-\*"),
1779 "brief: {}",
1780 res.brief_source
1781 );
1782 }
1783
1784 #[test]
1785 fn empty_blockquote_line_splits_into_adjacent_quotes() {
1786 let md = "> first paragraph\n> still first\n>\n> second paragraph\n";
1787 let res = convert(md, "in.md");
1788 let src = crate::span::SourceMap::new("in.brf", res.brief_source.clone());
1789 let toks = crate::lexer::lex(&src).expect("lex ok");
1790 let (doc, diags) = crate::parser::parse(toks, &src);
1791 let errors: Vec<_> = diags
1792 .iter()
1793 .filter(|d| d.severity == crate::diag::Severity::Error)
1794 .collect();
1795 assert!(
1796 errors.is_empty(),
1797 "{:?}\nbrief: {}",
1798 errors,
1799 res.brief_source
1800 );
1801 assert!(
1802 res.diagnostics
1803 .iter()
1804 .any(|d| d.hole == Hole::BlockquoteParagraphSplit),
1805 "{:?}",
1806 res.diagnostics
1807 );
1808 let blockquote_count = doc
1809 .blocks
1810 .iter()
1811 .filter(|b| matches!(b, crate::ast::Block::Blockquote { .. }))
1812 .count();
1813 assert_eq!(
1814 blockquote_count, 2,
1815 "expected two adjacent blockquotes; got blocks {:?}\nbrief: {}",
1816 doc.blocks, res.brief_source
1817 );
1818 }
1819
1820 #[test]
1821 fn pipe_in_cell_is_escaped() {
1822 let md = "| Kind | Example |\n| --- | --- |\n| separator | \"semantic\"\\|\"utility\" |\n";
1824 let res = convert(md, "in.md");
1825 let src = crate::span::SourceMap::new("in.brf", res.brief_source.clone());
1826 let toks = crate::lexer::lex(&src).expect("lex ok");
1827 let (_doc, diags) = crate::parser::parse(toks, &src);
1828 let errors: Vec<_> = diags
1829 .iter()
1830 .filter(|d| d.severity == crate::diag::Severity::Error)
1831 .collect();
1832 assert!(
1833 errors.is_empty(),
1834 "{:?}\nbrief: {}",
1835 errors,
1836 res.brief_source
1837 );
1838 assert!(
1839 res.brief_source.contains(r#""semantic"\|"utility""#),
1840 "brief: {}",
1841 res.brief_source
1842 );
1843 }
1844
1845 #[test]
1846 fn empty_trailing_cell_is_padded() {
1847 let md = "| Value | Type | Note |\n| --- | --- | --- |\n| true/false | Boolean | |\n";
1849 let res = convert(md, "in.md");
1850 let src = crate::span::SourceMap::new("in.brf", res.brief_source.clone());
1851 let toks = crate::lexer::lex(&src).expect("lex ok");
1852 let (_doc, diags) = crate::parser::parse(toks, &src);
1853 let errors: Vec<_> = diags
1854 .iter()
1855 .filter(|d| d.severity == crate::diag::Severity::Error)
1856 .collect();
1857 assert!(
1858 errors.is_empty(),
1859 "{:?}\nbrief: {}",
1860 errors,
1861 res.brief_source
1862 );
1863 assert!(
1864 res.brief_source.contains("—"),
1865 "brief: {}",
1866 res.brief_source
1867 );
1868 assert!(
1869 res.diagnostics
1870 .iter()
1871 .any(|d| d.hole == Hole::EmptyTableCell),
1872 "{:?}",
1873 res.diagnostics
1874 );
1875 }
1876
1877 #[test]
1878 fn double_emphasis_emits_inline_todo_comment() {
1879 let md = "**bold text** in a paragraph\n";
1880 let res = convert(md, "in.md");
1881 assert!(
1882 res.brief_source.contains("// TODO[B-hole:double-emphasis]"),
1883 "brief: {}",
1884 res.brief_source
1885 );
1886 }
1887
1888 #[test]
1889 fn last_block_hole_flushes_at_eof() {
1890 let md = "trailing **bold** at the end of doc\n";
1891 let res = convert(md, "in.md");
1892 assert!(
1893 res.brief_source.contains("// TODO[B-hole:double-emphasis]"),
1894 "EOF flush dropped the trailing hole's comment\nbrief: {}",
1895 res.brief_source
1896 );
1897 }
1898}