Skip to main content

brief/
convert.rs

1//! Markdown-to-Brief converter.
2//!
3//! Walks a `pulldown-cmark` event stream and emits Brief source text. Every
4//! Markdown construct that has no clean Brief equivalent is reported as a
5//! `Diag` carrying a `Hole` code; nothing is silently dropped.
6
7#[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, // 1-indexed
17    pub col: usize,  // 1-indexed
18    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, // **x**, __x__, ~~x~~
27    EscapedSigil,
28    AsteriskEmphasis, // *em* (Markdown italic)
29    AltBullet,        // * or + bullet
30    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    /// Stable kebab-case slug used in TODO comments and stderr report lines.
51    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    /// Short human-readable label for stderr.
80    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
117/// Convert a Markdown document to Brief source.
118///
119/// `source_path` is used only for diagnostic location strings.
120pub 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    // Pass 1: render each footnote definition's body to Brief using a fresh
138    // sub-Walker so inline formatting (emphasis, links, code, ...) inside the
139    // body is preserved. Markdown footnote definitions live elsewhere in the
140    // source; Brief's `@footnote[body]` is inline at the reference site, so
141    // we must have the rendered body ready before pass 2 visits the
142    // FootnoteReference event.
143    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    // Pass 2: render the main document, skipping footnote definitions —
176    // their bodies are now inlined at the FootnoteReference site.
177    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    /// True while inside a paragraph (between Start(Paragraph) and End).
206    in_paragraph: bool,
207    /// Set while we are inside a fenced/indented code block. Suppresses
208    /// inline parsing of `Event::Text` (text inside a code block is verbatim).
209    in_code_block: bool,
210    /// Stack of active lists.
211    list_stack: Vec<ListFrame>,
212    /// Stack of pending output buffers. The top of the stack is where output
213    /// is actually written. On End(BlockQuote/Alert), pop and post-process
214    /// (prefix lines or wrap in callout) before appending to the parent.
215    out_stack: Vec<String>,
216    /// Mirrors `out_stack`; top tells us how to wrap when the buffer is popped.
217    container_stack: Vec<Container>,
218    /// Active table accumulator (we only ever have one in flight).
219    table: Option<TableState>,
220    /// Active definition-list accumulator.
221    dl: Option<DefinitionListState>,
222    /// Stack of (dest_url, optional diagnostic to emit on End) for active links/images.
223    link_stack: Vec<(String, Option<(Hole, String)>)>,
224    /// Footnote labels → body text (rendered as a flat string).
225    footnote_defs: std::collections::BTreeMap<String, String>,
226    /// Hole TODO comment lines pending insertion before the next block.
227    pending_hole_comments: Vec<String>,
228    in_metadata: bool,
229    metadata_buf: String,
230    metadata_kind: Option<pulldown_cmark::MetadataBlockKind>,
231    /// Stack of currently-open inline HTML rewrites (`<sub>`, `<sup>`, `<kbd>`).
232    /// On a matching close tag we pop and emit `]`.
233    html_replace_stack: Vec<HtmlInlineKind>,
234    /// `Some(anchor)` between Start(Heading) and End(Heading) when the
235    /// markdown source carried a `{#anchor}` attribute.
236    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    /// Finalized (term_brief, definition_brief) pairs.
249    items: Vec<(String, String)>,
250    /// In-progress term content (writes are redirected here while `in_term`).
251    current_term: String,
252    /// In-progress definition content.
253    current_def: String,
254    /// Number of `DefinitionListDefinition` events seen for the *current*
255    /// term. Used to detect "multiple definitions per term" (Task 8).
256    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    /// Column (1-indexed) where the *content* of items at this level starts.
266    /// Used to detect non-2-space nesting in child items.
267    item_content_col: usize,
268}
269
270enum Container {
271    Quote,
272    Alert(#[allow(dead_code)] Hole, &'static str),
273    LinkPending,
274    ImagePending,
275    /// Paragraph buffer; on pop we flush pending HTML comments then this content.
276    Paragraph,
277    /// Buffer for an HtmlBlock; on End we inspect the buffered content and
278    /// either rewrite `<details>` to `@details(...)/@end`, hand off to a
279    /// `Details` container if the block is an opening fragment, or fall
280    /// back to the existing TODO + `/* */` form.
281    HtmlBlock,
282    /// Active `<details>` block whose body spans multiple events (because
283    /// blank lines split markdown content out of the surrounding HtmlBlock).
284    /// The `summary` is captured when we open; on the closing `</details>`
285    /// HtmlBlock we pop and emit `@details(summary: "..")\n[body]\n@end`.
286    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                // Nested list opens inside a parent Item whose text hasn't
416                // closed with a newline yet. Ensure one is in place.
417                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, // always renumber from 1 in Brief
423                    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                // Check for non-2-space nesting against parent frame.
435                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                // For unordered lists, detect alt-bullet first (uses src/diags).
463                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                // Defer paragraph content into a buffer so we can prepend any
510                // hole-comment lines collected from inline events before
511                // emitting the paragraph itself.
512                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                    // Inside a list item — no trailing blank line.
525                    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                // Detect setext: the source span at `range` does not start with `#`.
539                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                    // Heading body may have ended with a trailing space from a
568                    // soft break; trim before joining the anchor block.
569                    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                // *x* (asterisk italic) is a hole; _x_ is clean.
587                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                        // Detect tilde fence by inspecting the source span.
640                        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                            // Brief takes only the first whitespace-separated token.
651                            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                        // Any sigil that needed escaping is a hole — flag it so the
684                        // user can spot-check whether Brief renders the literal
685                        // intent.
686                        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                // v0.4 §4.3: Brief now natively supports `[x]` / `[ ]`
700                // as a list-item modifier. The conversion is lossless, so
701                // no diagnostic is emitted.
702                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                        // Accumulate groups of consecutive non-empty lines. A blank line
763                        // ends the current group; multiple blanks collapse to one
764                        // separator. Brief's paragraph break inside a blockquote does not
765                        // exist as a feature — it's modeled by ending the `>`-block with a
766                        // blank line and starting a new one on the next non-empty line.
767                        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                                // Consecutive blanks: drop, no-op (only one separator
775                                // matters between two non-empty groups).
776                                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                                // Empty source line — terminates the previous Brief
787                                // blockquote and starts the next one. This is the line
788                                // whose absence produced the original bug.
789                                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                            // `range` is the End(TagEnd::BlockQuote(_)) event's range — the
799                            // surrounding match arm parameter. It points at the close of the
800                            // Markdown blockquote, which is the best signal we have for
801                            // where the break originated.
802                            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                        // Should not arrive here — those containers are
825                        // popped by their own End arms. Defensive no-op.
826                        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                // Capture a non-empty title to emit as `title:` kwarg.
851                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                // Store (url, optional_title, optional_diag) via a tuple.
879                // We encode the title into the url string using a sentinel separator
880                // so we can reuse the existing link_stack without changing its type.
881                // Instead, push title into a separate parallel stack field by
882                // storing both in a combined tuple stored in out_stack label.
883                // Simplest approach: store title in a new wrapper. Use an existing
884                // field trick: push url\x00title so End can split on \x00.
885                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                // Split url and optional title.
899                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                // Ensure preceding blank line, same convention as paragraphs
1007                // and lists.
1008                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                // Malformed Markdown can nest definition lists; `self.dl` is
1025                // a single slot, so the inner `End` already consumed the
1026                // state and the outer `End` arrives with none. The converter
1027                // is lossy by design — drop the unmatched terminator instead
1028                // of panicking.
1029                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                        // TOML markdown frontmatter has a clean Brief equivalent;
1097                        // emit it directly with no hole diagnostic.
1098                        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                        // Flush the pending hole comment (just pushed by push_diag)
1112                        // so it appears immediately at the frontmatter site.
1113                        self.flush_pending_hole_comments();
1114                    }
1115                }
1116            }
1117            Event::Start(Tag::HtmlBlock) => {
1118                // Buffer the block's content; on End we either rewrite a
1119                // recognized `<details>` shape or fall back to the
1120                // existing TODO + `/* */` comment form.
1121                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                        // The `</details>` will arrive in a later HtmlBlock.
1141                        // Stage a Details container that captures any
1142                        // blocks rendered in between.
1143                        self.out_stack.push(String::new());
1144                        self.container_stack.push(Container::Details { summary });
1145                    }
1146                    DetailsShape::Close => {
1147                        // Pop the matching Details container, if any.
1148                        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                            // Stray `</details>` with no opener on the
1167                            // stack — fall back to the comment form.
1168                            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                // Block-level HTML content (between Start/End of HtmlBlock).
1178                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                    // Mismatched close — fall through to TODO so we don't
1196                    // emit a stray `]` that would corrupt the output.
1197                }
1198                if is_inline_br(trimmed) {
1199                    // Brief hard break: backslash at end of line.
1200                    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                // push_diag already queued a pending_hole_comments entry;
1211                // it will be flushed before the next block-level event.
1212            }
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                // Other events handled in subsequent tasks.
1244            }
1245        }
1246    }
1247
1248    fn finish(mut self) -> ConvertResult {
1249        // Drain any TODO comments queued by the last block — there's no
1250        // subsequent Start event to flush them.
1251        self.flush_pending_hole_comments();
1252        // Trim trailing blank lines down to a single newline.
1253        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    /// Convert a byte offset into 1-indexed (line, column).
1266    #[allow(dead_code)] // used by later tasks
1267    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)] // used by later tasks
1278    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        // Surface every hole as a `// TODO[B-hole:slug]:` line so a reviewer
1295        // can grep the converted corpus. Flushed before the next block.
1296        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            // Write to top buffer (or `out`).
1303            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    /// Emit a buffered HTML block as the existing TODO + `/* */` comment
1314    /// fallback. Used when the block isn't a recognizable `<details>`.
1315    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        // Flush the pending hole comment (just pushed by push_diag) so it
1322        // appears immediately before the block comment, not deferred.
1323        self.flush_pending_hole_comments();
1324        self.write("/*\n");
1325        // Brief block comments don't nest; sanitize any embedded `*/` so
1326        // the comment terminates only where we want it to.
1327        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
1394/// Coerce an arbitrary heading id string into Brief's `[a-z0-9-]+` form.
1395/// Lowercases ASCII, replaces every other char with `-`, collapses runs,
1396/// strips leading/trailing `-`. Falls back to `"section"` when the input
1397/// has no usable characters.
1398fn 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
1420/// Classify an `Event::InlineHtml` payload as an opening tag we know how
1421/// to rewrite. Returns `None` for everything else (closing tags,
1422/// self-closing tags, unrecognized fragments) — the caller handles those.
1423fn 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
1448/// Escape unescaped `|` characters in cell content so Brief's row
1449/// splitter sees one cell. Returns `(escaped_string, did_any_escape)`.
1450///
1451/// We deliberately do NOT track whether we are inside a backtick code
1452/// span here. After Phase B (parser backtick-aware split), `|` inside a
1453/// backtick span is already opaque to the row splitter, but `\|` outside
1454/// a code span is still the canonical Brief escape for a literal `|`.
1455/// Always escaping is simpler and never wrong.
1456fn 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
1474/// Escape a summary string for use inside `@details(summary: "...")`.
1475fn 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
1488/// Try to recognize a `<details>` HTML fragment.
1489///
1490/// Returns `DetailsShape::Closed` when the buffer fully wraps a
1491/// `<details>...</details>` block; `DetailsShape::Open` when the buffer
1492/// is the *opening* fragment of a multi-event `<details>` block (the
1493/// closing `</details>` will arrive in a later HtmlBlock); `Unknown`
1494/// otherwise.
1495fn 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        // Bare close fragment; caller will pop a Details container.
1504        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    // Strip a single optional leading newline.
1515    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    // Look for matching `</details>` at the end of the buffer (case-insensitive).
1523    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        // Opener without close — defer body to a Details container.
1533        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
1547/// Return the byte index immediately after the opening tag for `name`
1548/// (case-insensitive), e.g. the index after `<details>` or `<details ...>`.
1549fn 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/// Pull a `<summary>...</summary>` out of `s`. Returns `((text, byte_len),
1559/// end_byte_offset_of_close_tag)` when found.
1560#[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
1575/// Rough HTML-to-text: drops anything that looks like a tag.
1576fn 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
1590/// Walk `s` and prepend `\` before every `*`/`_`/`+`/`~` for which
1591/// Brief's `is_open_marker_at` predicate fires — those would otherwise
1592/// open an emphasis span in the converted Brief output. Markdown's
1593/// parser already paired any *real* emphasis as `Start/End(Emphasis)`,
1594/// so any sigil reaching us inside `Event::Text` was a literal in the
1595/// source.
1596///
1597/// Pulldown-cmark may split text around strikethrough/emphasis candidates
1598/// (e.g. `~10` becomes two events: `"~"` and `"10..."`). A sigil at the
1599/// *end* of the text string has no visible next-char in this fragment, but
1600/// when the Brief output is assembled the next event's first char follows
1601/// immediately — making the sigil a valid opener in Brief. We therefore
1602/// also escape sigils that have valid left-context and sit at the end of
1603/// the string (conservatively treating the boundary as "next char unknown").
1604fn 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            // Check the shared predicate first (handles mid-string case).
1613            let should_escape = is_open_marker_at(bytes, i) || {
1614                // Also escape a sigil at the end of the text fragment if it
1615                // has valid left-context: the next text event may start with
1616                // a non-space char, making this a valid emphasis opener in
1617                // the concatenated Brief output.
1618                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                    // prev must not be the same marker (doubled-marker rule)
1629                    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
1650/// Make a URL safe for Brief's `@link[text](url)` sugar. The compiler scans
1651/// the `(url)` with balanced-paren counting, so balanced parens pass through
1652/// verbatim; unbalanced ones would swallow or truncate the URL and must be
1653/// percent-encoded.
1654fn 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        // Production report pattern #1: `*…patch exceeds ~10 ops, any value
1740        // exceeds ~50 lines…*` opens a strikethrough on `~10` that never
1741        // closes, producing B0204 + B0207 in Brief.
1742        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        // Production report pattern #2: `### 8.6 Set an attribute (href, aria-*, …)`.
1762        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        // Production report pattern #4.
1823        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        // Production report pattern #5.
1848        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}