Skip to main content

markdown_stream/
block.rs

1//! The streaming block parser: turns input lines into block-level events, deferring inline parsing
2//! until a paragraph/heading closes.
3//!
4//! CommonMark block structure with a proper **container stack**: a document holds a stack of open
5//! containers (block quotes, lists, list items) each anchored to a continuation *column*, plus at
6//! most one open *leaf* (paragraph, fenced/indented code, HTML block, table). Each input line is
7//! matched against the open containers by leading indentation (CommonMark "continuation" rules):
8//! a block quote continues with `>`, a list item's content continues at the column past its marker.
9//! Containers the line no longer belongs to are closed; new containers the line opens are pushed.
10//!
11//! Lists carry a tight/loose flag that is only knowable once the list closes (a blank line between
12//! items makes the list loose). To keep `EnterBlock(List{tight})` correct we **buffer a list's
13//! events** into the [`Container::List`] frame and replay them — correctly `<p>`-wrapped or not —
14//! when the list closes. The buffer is bounded by the list's own size, preserving bounded-memory
15//! streaming, and the looseness decision is a pure function of the (chunk-independent) line sequence,
16//! so split-equivalence still holds.
17
18use crate::event::*;
19use crate::inline;
20use crate::linkref;
21use crate::parser::Parser;
22use std::collections::HashMap;
23
24/// Expanded width of a tab stop, per CommonMark (tabs count to the next multiple of 4).
25const TAB: usize = 4;
26
27#[derive(Default)]
28pub struct StreamParser {
29    buf: Vec<u8>,
30    started: bool,
31    flushed: bool,
32    /// The open container stack (block quotes / lists / list items), outermost first. The document
33    /// itself is implicit (tracked by `started`).
34    containers: Vec<Container>,
35    /// The single open leaf block, if any (a paragraph, code block, …).
36    leaf: Leaf,
37    /// Was the previous processed line blank? Used for loose-list detection (a blank line between
38    /// two items, or before a second block in an item, makes the enclosing list loose).
39    last_blank: bool,
40    /// Link reference definitions seen so far, keyed by normalised label. Populated in line order as
41    /// paragraphs are scanned; references resolve against the definitions visible at close time.
42    refs: HashMap<String, LinkDef>,
43    /// When set, GFM extensions that are *not* part of CommonMark are enabled: extended (bare)
44    /// autolinks and task-list-item markers. (Strikethrough and tables are always on.) The flag is
45    /// off by default so the plain [`StreamParser::new`] path stays CommonMark-faithful.
46    gfm: bool,
47    /// The forward-reference output gate. Finalised top-level output is staged here as [`Slot`]s
48    /// (resolved events plus deferred inline runs) so a block holding an as-yet-undefined reference —
49    /// and every event after it — can be held until the reference resolves or `flush()` is reached,
50    /// while a document with no forward references streams out eagerly. See [`Self::drain_gate`].
51    gate: Vec<Slot>,
52}
53
54/// A staged unit of top-level output. Most output is a finalised [`Event`]; a block whose inline
55/// content references an as-yet-undefined label is staged as a [`Slot::Deferred`] run to be
56/// re-parsed once all link reference definitions are known.
57enum Slot {
58    /// A finalised event, replayed verbatim on release.
59    Event(Event),
60    /// A run of inline content holding one or more **forward references**: re-parsed against the
61    /// complete `refs` map at release time (so a later `[label]: /url` resolves it).
62    Deferred(Deferred),
63}
64
65/// A held inline run carrying at least one forward reference. `text` is the raw (refdef-stripped,
66/// trailing-trimmed) inline source; `style` the base style; `labels` the normalised labels that were
67/// undefined when the run was first parsed — once every one of them is either defined or known to be
68/// undefinable (only at `flush`), the run can be re-parsed and released.
69struct Deferred {
70    text: String,
71    style: InlineStyle,
72    labels: Vec<String>,
73}
74
75/// A list item's paragraph run when assembled at list close: either fully resolved events or a
76/// deferred (forward-reference-carrying) run to re-parse at gate release.
77enum ParaRun {
78    Resolved(Vec<Event>),
79    Deferred(Deferred),
80}
81
82/// One open container in the stack.
83enum Container {
84    /// A block quote. Its `>` marker is consumed during the match phase.
85    BlockQuote,
86    /// A list. Events for the whole list are buffered here until it closes, so the `tight` flag can
87    /// be back-patched once looseness is known.
88    List(ListFrame),
89    /// A single list item. `indent` is the column at which the item's content begins (the marker's
90    /// own indent plus its width plus the spaces after it): a continuation line must be indented at
91    /// least this far to stay in the item.
92    Item { indent: usize },
93}
94
95/// A buffered list: its metadata plus the events emitted while it is open, so the final `tight`
96/// flag (only known at close) can be applied to all item content retroactively.
97struct ListFrame {
98    ordered: bool,
99    marker: char,
100    start: u64,
101    /// `true` once any blank line is found that should make the list loose.
102    loose: bool,
103    /// Buffered events for the list body (everything between `EnterBlock(List)` and
104    /// `ExitBlock(List)`, exclusive). Item boundaries are marked so `<p>` wrappers can be inserted.
105    events: Vec<BufEvent>,
106    /// A blank line has been seen since the last block was added to this list, and no block has been
107    /// added since. If another block is then added (a sibling item, or a second block in the current
108    /// item), the list is loose. A trailing blank never commits, so it does not make the list loose.
109    pending_blank: bool,
110}
111
112/// An event buffered inside a list frame. Plain events pass through; `ItemStart`/`ItemEnd` mark item
113/// boundaries and `BlockSep` records where a `<p>` wrapper is needed in loose mode.
114enum BufEvent {
115    /// A raw event to replay verbatim.
116    Raw(Event),
117    /// Start of a list item's content (after `EnterBlock(ListItem)`).
118    ItemStart,
119    /// End of a list item's content (before `ExitBlock(ListItem)`).
120    ItemEnd,
121    /// A run of paragraph inline content (the text events between `<p>`…`</p>`), buffered so that in
122    /// a tight list the wrapper is dropped and in a loose list it is kept.
123    Para(Vec<Event>),
124    /// A deferred inline run (a list-item paragraph carrying a forward reference), with the same
125    /// looseness-dependent `<p>` wrapping as [`BufEvent::Para`] but re-parsed at gate-release time.
126    /// `prefix` holds any already-materialised leading events (e.g. a GFM task-list checkbox).
127    DeferPara {
128        prefix: Vec<Event>,
129        deferred: Deferred,
130    },
131    /// A deferred inline run propagated *as-is* from a nested list whose looseness wrapping was
132    /// already applied: replayed verbatim (like [`BufEvent::Raw`]) without re-wrapping.
133    DeferRaw(Deferred),
134}
135
136#[derive(Default)]
137enum Leaf {
138    #[default]
139    None,
140    Paragraph(String),
141    /// An indented code block. Lines are accumulated (already de-indented by 4 columns) and emitted
142    /// verbatim at close, with trailing blank lines trimmed.
143    Indented(Vec<String>),
144    Fenced {
145        ch: u8,
146        len: usize,
147        /// The indentation (in columns) of the opening fence; up to this much leading whitespace is
148        /// stripped from each content line.
149        indent: usize,
150    },
151    Table {
152        aligns: Vec<Alignment>,
153    },
154    /// A raw HTML block (one of the seven CommonMark start conditions). Content is emitted verbatim,
155    /// line by line, until `end` is satisfied.
156    Html {
157        end: HtmlEnd,
158    },
159}
160
161/// The end condition for an open HTML block, per the seven CommonMark start conditions. The string
162/// variants close on the *first line containing* the marker (inclusive); `Blank` closes on the first
163/// blank line (which is not part of the block).
164#[derive(Clone, Copy)]
165enum HtmlEnd {
166    /// Conditions 1–5: close on the first line that contains this (case-insensitive) marker.
167    Marker(&'static str),
168    /// Conditions 6–7: close on the first blank line.
169    Blank,
170}
171
172/// A list marker parsed from a line: bullet/ordered, its char, start number, and the byte offset of
173/// the first content character after the marker (and the spaces following it).
174struct Marker {
175    ordered: bool,
176    marker: char,
177    start: u64,
178    /// Byte offset (within the de-indented content) just past the marker char and its separator.
179    after: usize,
180}
181
182impl StreamParser {
183    /// A CommonMark parser (GFM-only extensions off).
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// A parser with GFM-only extensions enabled (extended autolinks, task-list items). Strikethrough
189    /// and tables are always recognised regardless of this flag.
190    pub fn new_gfm() -> Self {
191        StreamParser {
192            gfm: true,
193            ..Self::default()
194        }
195    }
196}
197
198impl Parser for StreamParser {
199    fn write(&mut self, chunk: &[u8]) -> Vec<Event> {
200        let mut out = Vec::new();
201        self.buf.extend_from_slice(chunk);
202        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
203            let mut line: Vec<u8> = self.buf.drain(..=nl).collect();
204            line.pop(); // drop '\n'
205            if line.last() == Some(&b'\r') {
206                line.pop();
207            }
208            let s = String::from_utf8_lossy(&line).into_owned();
209            self.process_line(&s, &mut out);
210        }
211        out
212    }
213
214    fn flush(&mut self) -> Vec<Event> {
215        let mut out = Vec::new();
216        if self.flushed {
217            return out;
218        }
219        if !self.buf.is_empty() {
220            let line = std::mem::take(&mut self.buf);
221            let s = String::from_utf8_lossy(&line).into_owned();
222            self.process_line(&s, &mut out);
223        }
224        self.close_leaf(&mut out);
225        self.close_containers_to(0, &mut out);
226        if self.started {
227            // Stage the document close behind any still-held content, then force a final drain: all
228            // link reference definitions are now known, so every remaining deferred run resolves
229            // (or, if still undefined, falls back to literal text).
230            self.gate
231                .push(Slot::Event(Event::exit(BlockKind::Document)));
232        }
233        self.flush_gate(&mut out);
234        self.flushed = true;
235        out
236    }
237
238    fn reset(&mut self) {
239        *self = Self::default();
240    }
241}
242
243impl StreamParser {
244    fn ensure_doc(&mut self, out: &mut Vec<Event>) {
245        if !self.started {
246            self.emit(out, Event::enter(BlockKind::Document));
247            self.started = true;
248        }
249    }
250
251    /// Process one logical line. This is the CommonMark block algorithm in three phases:
252    ///   1. **Match** the line against open containers, consuming each container's continuation
253    ///      marker and tracking the surviving content offset/column.
254    ///   2. Decide whether unmatched containers close (they do, unless a lazy paragraph continuation
255    ///      keeps a paragraph alive).
256    ///   3. **Parse** new containers and the leaf from the remaining content.
257    fn process_line(&mut self, raw: &str, out: &mut Vec<Event>) {
258        // Phase 1: walk the container stack, consuming continuation markers.
259        let mut cur = Cursor::new(raw);
260        let mut matched = 0usize; // number of containers whose continuation matched
261        for c in &self.containers {
262            match c {
263                Container::BlockQuote => {
264                    let save = cur.clone();
265                    if cur.indent() <= 3 && cur.peek_nonspace() == Some(b'>') {
266                        cur.advance_to_nonspace();
267                        cur.bump(); // consume '>'
268                        if cur.peek() == Some(b' ') {
269                            cur.bump();
270                        } else if cur.peek() == Some(b'\t') {
271                            cur.consume_tab_as_space();
272                        }
273                        matched += 1;
274                    } else {
275                        cur = save;
276                        break;
277                    }
278                }
279                Container::List(_) => {
280                    // A list as such has no continuation marker; its item does.
281                    matched += 1;
282                }
283                Container::Item { indent } => {
284                    if cur.is_blank() {
285                        // A blank line "matches" any item (it may continue the item with later,
286                        // sufficiently-indented content). Stop consuming further markers.
287                        matched += 1;
288                        // Keep matching outer list frames is moot; break out.
289                        break;
290                    }
291                    if cur.indent() >= *indent {
292                        cur.consume_cols(*indent);
293                        matched += 1;
294                    } else {
295                        break;
296                    }
297                }
298            }
299        }
300
301        let all_matched = matched == self.containers.len();
302        let blank = cur.is_blank();
303
304        // Phase 2 + 3: dispatch. Fenced/HTML/table leaves swallow lines specially.
305        self.dispatch(raw, cur, matched, all_matched, blank, out);
306        self.last_blank = blank;
307    }
308
309    /// The continuation of `process_line` after the container-match phase: handle the open leaf's
310    /// special swallowing, blank lines, lazy continuation, new containers, and new leaves.
311    fn dispatch(
312        &mut self,
313        raw: &str,
314        mut cur: Cursor,
315        matched: usize,
316        all_matched: bool,
317        blank: bool,
318        out: &mut Vec<Event>,
319    ) {
320        // --- Open fenced code block: literal lines until the closing fence. ---
321        if let Leaf::Fenced { ch, len, indent } = self.leaf {
322            if all_matched {
323                let t = cur.rest_str();
324                let tt = t.trim_start();
325                if is_closing_fence(tt, ch, len) {
326                    self.close_leaf(out);
327                } else {
328                    // Strip up to `indent` columns of leading whitespace from the content line.
329                    let stripped = strip_cols(&t, indent);
330                    self.emit(out, Event::text(format!("{stripped}\n")));
331                }
332                return;
333            }
334            // The fence's container was interrupted: close the leaf and re-handle the line below.
335            self.close_leaf(out);
336            self.close_containers_to(matched, out);
337        }
338
339        // --- Open HTML block: emit verbatim until the end condition. ---
340        if let Leaf::Html { end } = self.leaf {
341            if all_matched {
342                let content = cur.rest_str();
343                match end {
344                    HtmlEnd::Marker(marker) => {
345                        self.emit(out, Event::text(format!("{content}\n")));
346                        if contains_ci(&content, marker) {
347                            self.close_leaf(out);
348                        }
349                        return;
350                    }
351                    HtmlEnd::Blank => {
352                        if content.trim().is_empty() {
353                            self.close_leaf(out);
354                            // fall through: blank line handled below
355                        } else {
356                            self.emit(out, Event::text(format!("{content}\n")));
357                            return;
358                        }
359                    }
360                }
361                if !matches!(self.leaf, Leaf::None) {
362                    return;
363                }
364            } else {
365                self.close_leaf(out);
366                self.close_containers_to(matched, out);
367            }
368        }
369
370        // --- Open table: a pipe row continues it; otherwise it closes. ---
371        if let Leaf::Table { aligns } = &self.leaf {
372            if all_matched && !blank {
373                let content = cur.rest_str();
374                if content.contains('|') {
375                    let aligns = aligns.clone();
376                    self.emit_row(split_row(&content), &aligns, out);
377                    return;
378                }
379            }
380            self.close_leaf(out);
381            if !all_matched {
382                self.close_containers_to(matched, out);
383            }
384        }
385
386        // --- Open indented code block: 4-space continuation, or blank lines (kept). ---
387        if let Leaf::Indented(_) = &self.leaf {
388            if all_matched && (blank || cur.indent() >= TAB) {
389                if blank {
390                    if let Leaf::Indented(lines) = &mut self.leaf {
391                        lines.push(String::new());
392                    }
393                    return;
394                }
395                cur.consume_cols(TAB);
396                let line = cur.rest_str_with_partial_tab();
397                if let Leaf::Indented(lines) = &mut self.leaf {
398                    lines.push(line);
399                }
400                return;
401            }
402            self.close_leaf(out);
403            if !all_matched {
404                self.close_containers_to(matched, out);
405            }
406        }
407
408        // --- Blank line handling. ---
409        if blank {
410            // A blank line closes any open paragraph.
411            if matches!(self.leaf, Leaf::Paragraph(_)) {
412                self.close_leaf(out);
413            }
414            // Record a pending blank on the innermost open list: if content later resumes in the
415            // same item or a sibling item appears (i.e. a new block is added to the list), the list
416            // becomes loose. A trailing blank with no following content never commits, so it does
417            // not make the list loose.
418            self.note_blank_in_item();
419            return;
420        }
421
422        // Lazy paragraph continuation: a paragraph survives even when outer containers didn't match,
423        // *provided* the un-matched remainder is ordinary paragraph text (not a new block start).
424        let lazy = !all_matched
425            && matches!(self.leaf, Leaf::Paragraph(_))
426            && self.can_lazily_continue(&cur);
427        if !lazy && !all_matched {
428            self.close_leaf(out);
429            self.close_containers_to(matched, out);
430        }
431
432        // Now open any *new* containers (block quotes / list items) the line introduces, looping so
433        // a line like `> - x` or `- - x` opens several at once.
434        if !lazy {
435            self.open_new_containers(&mut cur, out);
436        }
437
438        // If, after matching and opening, the innermost container is a *bare* list (its items all
439        // closed and no new item opened), a non-item block is landing at the list's level — so the
440        // list ends. E.g. `- a\n\n<!-- -->` closes the list before the HTML block.
441        if !lazy && matches!(self.containers.last(), Some(Container::List(_))) {
442            let keep = self.containers.len() - 1;
443            self.close_containers_to(keep, out);
444        }
445
446        // Finally, the remaining content forms (or continues) a leaf.
447        self.parse_leaf(&cur, raw, lazy, out);
448    }
449
450    /// Open block-quote and list-item containers introduced by the current line, advancing `cur`
451    /// past each marker. Loops to handle several markers on one line (`> - x`, `- - x`).
452    fn open_new_containers(&mut self, cur: &mut Cursor, out: &mut Vec<Event>) {
453        loop {
454            // ≥4 columns of leading space is an indented-code amount, not a container marker — it
455            // belongs to the leaf. Stop opening containers.
456            let indent = cur.indent();
457            if indent >= TAB {
458                break;
459            }
460
461            // Block quote?
462            if indent <= 3 && cur.peek_nonspace() == Some(b'>') {
463                self.ensure_doc(out);
464                self.close_leaf(out);
465                // A block quote opening directly inside a list item is a (second) block of that item,
466                // so a preceding blank line makes the enclosing list loose.
467                if matches!(self.containers.last(), Some(Container::Item { .. })) {
468                    self.commit_pending_blank();
469                }
470                self.emit(out, Event::enter(BlockKind::BlockQuote));
471                self.containers.push(Container::BlockQuote);
472                cur.advance_to_nonspace();
473                cur.bump();
474                if cur.peek() == Some(b' ') {
475                    cur.bump();
476                } else if cur.peek() == Some(b'\t') {
477                    cur.consume_tab_as_space();
478                }
479                continue;
480            }
481
482            // List item? A thematic break takes precedence over a bullet marker, so `* * *` and
483            // `- - -` are horizontal rules, not one-item lists. (The break is parsed by `parse_leaf`.)
484            if indent <= 3 && !is_thematic_break(&cur.rest_after_indent()) {
485                if let Some(m) = self.parse_marker(cur) {
486                    self.start_list_item(cur, m, out);
487                    continue;
488                }
489            }
490            break;
491        }
492    }
493
494    /// Parse a list marker at the cursor (which sits at ≤3 spaces of indent), validating that it can
495    /// start/continue a list here (an ordered marker may only interrupt a paragraph if it starts at
496    /// `1`). Returns the marker, leaving `cur` unchanged on failure.
497    fn parse_marker(&self, cur: &Cursor) -> Option<Marker> {
498        let rest = cur.rest_after_indent();
499        let m = list_marker(&rest)?;
500        // The interrupt restrictions only apply when a marker would interrupt a *running-text*
501        // paragraph (not one already inside a list): a bullet or `1.` may interrupt, but an empty
502        // marker (`-` then EOL) or an ordered marker that doesn't start at 1 may not. Inside a list,
503        // these markers freely begin sibling items (e.g. `- foo\n-\n- bar`, `2. x` after `1) y`).
504        if matches!(self.leaf, Leaf::Paragraph(_)) && !self.in_any_list() {
505            let empty = rest[m.after..].trim().is_empty();
506            if empty || (m.ordered && m.start != 1) {
507                return None;
508            }
509        }
510        Some(m)
511    }
512
513    /// Open a list (if needed) and a new item for marker `m`, advancing `cur` past the marker and the
514    /// spaces that establish the item's content column.
515    fn start_list_item(&mut self, cur: &mut Cursor, m: Marker, out: &mut Vec<Event>) {
516        self.ensure_doc(out);
517        self.close_leaf(out);
518
519        // The item's content indent is stored *relative to the parent container's content column*
520        // (the column the cursor sits at on entry, after outer block-quote/item markers were
521        // consumed), because container matching consumes that many additional columns.
522        let base_col = cur.col();
523        // Advance past the leading indent and the marker characters.
524        cur.advance_to_nonspace();
525        cur.consume_bytes(m.after);
526        let marker_width = cur.col() - base_col; // leading indent + marker chars
527
528        // Spaces after the marker determine the content indent. 1–4 spaces → that many; ≥5 spaces or
529        // a tab means only one space counts and the rest is part of an indented code block; an empty
530        // marker (then EOL) gives one space of padding.
531        let spaces = cur.count_spaces();
532        let content_indent;
533        if cur.is_blank_from_here() {
534            // Empty item: marker immediately followed by end of line.
535            content_indent = marker_width + 1;
536        } else if (1..=TAB).contains(&spaces) {
537            content_indent = marker_width + spaces;
538            cur.consume_cols_max(spaces);
539        } else {
540            // ≥5 spaces (or tab): only one space is the marker padding; the remainder is code.
541            content_indent = marker_width + 1;
542            cur.consume_cols_max(1);
543        }
544
545        // Should this marker extend the current list or start a new one? At this point the deeper
546        // items the line did not match have already been closed, so the top container is the list
547        // this marker is a sibling of (if any). It extends iff that top container is a List of the
548        // *same* kind (ordered-ness + marker char); a *different* marker char at the same level
549        // begins a new sibling list, so the old one is closed first.
550        let same_list = matches!(self.containers.last(), Some(Container::List(l))
551            if l.ordered == m.ordered && l.marker == m.marker);
552        let diff_list = matches!(self.containers.last(), Some(Container::List(_))) && !same_list;
553
554        if diff_list {
555            // Close the sibling list of the other kind (e.g. `-` items followed by a `+` item).
556            let keep = self.containers.len() - 1;
557            self.close_containers_to(keep, out);
558        }
559
560        if same_list {
561            // Continuing the same list: a blank line before this sibling item makes the list loose.
562            self.commit_pending_blank();
563        } else {
564            // Starting a fresh list. If it is nested directly inside a list item, it is a second
565            // block of that item, so a blank line preceding it makes the *enclosing* list loose
566            // (e.g. `1.  foo\n\n    - bar`).
567            if matches!(self.containers.last(), Some(Container::Item { .. })) {
568                self.commit_pending_blank();
569            }
570            let frame = ListFrame {
571                ordered: m.ordered,
572                marker: m.marker,
573                start: m.start,
574                loose: false,
575                events: Vec::new(),
576                pending_blank: false,
577            };
578            self.containers.push(Container::List(frame));
579        }
580
581        self.emit(out, Event::enter(BlockKind::ListItem));
582        self.mark_item_start();
583        self.containers.push(Container::Item {
584            indent: content_indent,
585        });
586    }
587
588    /// Build (or continue) the leaf from the cursor's remaining content. `lazy` marks a lazy
589    /// paragraph continuation (no new containers were opened).
590    fn parse_leaf(&mut self, cur: &Cursor, raw: &str, lazy: bool, out: &mut Vec<Event>) {
591        let _ = raw;
592        let content = cur.rest_str();
593        let trimmed = content.trim_start();
594        let indent = cur.indent();
595
596        if lazy {
597            // Pure paragraph continuation. Trailing spaces are kept (a two-space run signals a hard
598            // line break, resolved during inline scanning); the final line's trailing run is trimmed
599            // when the paragraph closes.
600            if let Leaf::Paragraph(p) = &mut self.leaf {
601                p.push('\n');
602                p.push_str(&content);
603            }
604            return;
605        }
606
607        // A new block is about to be added inside a list item. If no leaf is currently open, this is a
608        // *second* block within the item (the first having closed, e.g. across a blank line); commit
609        // any pending blank so the enclosing list becomes loose.
610        if matches!(self.leaf, Leaf::None)
611            && matches!(self.containers.last(), Some(Container::Item { .. }))
612        {
613            self.commit_pending_blank();
614        }
615
616        // A ≥4-column-indented line while a paragraph is open can only continue it (indented code
617        // cannot interrupt a paragraph), so append and stop — no block re-parsing.
618        if indent >= TAB {
619            if let Leaf::Paragraph(p) = &mut self.leaf {
620                p.push('\n');
621                p.push_str(&content);
622                return;
623            }
624            // Otherwise it is an indented code block.
625            self.ensure_doc(out);
626            let mut c = cur.clone();
627            c.consume_cols(TAB);
628            let line = c.rest_str_with_partial_tab();
629            self.leaf = Leaf::Indented(vec![line]);
630            return;
631        }
632
633        // From here, work with the de-indented content (≤3 spaces stripped).
634        let in_paragraph = matches!(self.leaf, Leaf::Paragraph(_));
635
636        // HTML block start.
637        if let Some(end) = html_block_start(trimmed, in_paragraph) {
638            self.ensure_doc(out);
639            self.close_leaf(out);
640            // Raw-text blocks (`<script>`/`<style>`/`<pre>`/`<textarea>`) are exempt from the GFM tag
641            // filter; mark them so the renderer can tell them from other (filtered) HTML blocks.
642            let html_raw_text = matches!(
643                end,
644                HtmlEnd::Marker("</script>" | "</style>" | "</pre>" | "</textarea>")
645            );
646            self.emit(
647                out,
648                Event::EnterBlock {
649                    block: BlockKind::HtmlBlock,
650                    data: BlockData {
651                        html_raw_text,
652                        ..Default::default()
653                    },
654                    span: Span::default(),
655                },
656            );
657            self.emit(out, Event::text(format!("{trimmed}\n")));
658            match end {
659                HtmlEnd::Marker(marker) if contains_ci(trimmed, marker) => {
660                    self.close_leaf(out);
661                }
662                _ => self.leaf = Leaf::Html { end },
663            }
664            return;
665        }
666
667        // Fenced code start.
668        if let Some((ch, len, info)) = fence_start(trimmed) {
669            self.ensure_doc(out);
670            self.close_leaf(out);
671            let data = BlockData {
672                info,
673                ..Default::default()
674            };
675            self.emit(
676                out,
677                Event::EnterBlock {
678                    block: BlockKind::FencedCode,
679                    data,
680                    span: Span::default(),
681                },
682            );
683            self.leaf = Leaf::Fenced { ch, len, indent };
684            return;
685        }
686
687        // ATX heading.
688        if let Some((level, htext)) = atx_heading(trimmed) {
689            self.ensure_doc(out);
690            self.close_leaf(out);
691            let data = BlockData {
692                level,
693                ..Default::default()
694            };
695            self.emit(
696                out,
697                Event::EnterBlock {
698                    block: BlockKind::Heading,
699                    data,
700                    span: Span::default(),
701                },
702            );
703            self.parse_inline(htext, out);
704            self.emit(out, Event::exit(BlockKind::Heading));
705            return;
706        }
707
708        // Setext heading underline: a `=`/`-` run directly under a paragraph turns it into a heading.
709        if let Leaf::Paragraph(_) = &self.leaf {
710            if let Some(level) = setext_underline(trimmed) {
711                if let Leaf::Paragraph(text) = std::mem::take(&mut self.leaf) {
712                    let body = self.consume_refdefs(&text);
713                    if body.is_empty() {
714                        // The paragraph was only refdefs; the underline becomes its own thing —
715                        // restore and fall through to thematic-break / paragraph handling.
716                        self.leaf = Leaf::None;
717                    } else {
718                        let data = BlockData {
719                            level,
720                            ..Default::default()
721                        };
722                        self.emit(
723                            out,
724                            Event::EnterBlock {
725                                block: BlockKind::Heading,
726                                data,
727                                span: Span::default(),
728                            },
729                        );
730                        self.parse_inline(body.trim_end(), out);
731                        self.emit(out, Event::exit(BlockKind::Heading));
732                        return;
733                    }
734                }
735            }
736        }
737
738        // Thematic break (checked after setext so `---` under a paragraph is a setext h2).
739        if is_thematic_break(trimmed) {
740            self.ensure_doc(out);
741            self.close_leaf(out);
742            self.emit(out, Event::enter(BlockKind::ThematicBreak));
743            self.emit(out, Event::exit(BlockKind::ThematicBreak));
744            return;
745        }
746
747        // Default: paragraph text (new or continuation).
748        match &mut self.leaf {
749            Leaf::Paragraph(p) => {
750                // A single-line paragraph containing `|` followed by a delimiter row starts a table.
751                if !p.contains('\n') && p.contains('|') {
752                    if let Some(aligns) = parse_delim_row(trimmed) {
753                        let headers = split_row(p);
754                        if headers.len() == aligns.len() {
755                            let header = std::mem::take(p);
756                            self.leaf = Leaf::None;
757                            self.start_table(&header, aligns, out);
758                            return;
759                        }
760                    }
761                }
762                p.push('\n');
763                p.push_str(&content);
764            }
765            _ => {
766                // Blank remaining content (e.g. an empty list marker line `-   `) opens no
767                // paragraph; the item simply waits for content on a following line.
768                if trimmed.is_empty() {
769                    return;
770                }
771                self.ensure_doc(out);
772                // Keep the first line's trailing spaces (a hard-break signal); leading whitespace was
773                // already stripped by `trimmed`.
774                self.leaf = Leaf::Paragraph(trimmed.to_string());
775            }
776        }
777    }
778
779    /// Can the current line lazily continue an open paragraph? It can unless its remaining content
780    /// would start a new block (list/quote/heading/fence/thematic break/html). This is a conservative
781    /// check matching the cases the spec forbids from lazy continuation.
782    fn can_lazily_continue(&self, cur: &Cursor) -> bool {
783        let rest = cur.rest_after_indent();
784        let trimmed = rest.trim_start();
785        if trimmed.is_empty() {
786            return false;
787        }
788        if cur.indent() >= TAB {
789            // Indented enough to be code — but code can't interrupt a paragraph, so it *is* lazy text.
790            return true;
791        }
792        if is_thematic_break(trimmed) {
793            return false;
794        }
795        if atx_heading(trimmed).is_some() {
796            return false;
797        }
798        if fence_start(trimmed).is_some() {
799            return false;
800        }
801        if cur.indent() <= 3 && cur.peek_nonspace() == Some(b'>') {
802            return false;
803        }
804        if html_block_start(trimmed, true).is_some() {
805            return false;
806        }
807        // A list marker interrupts a paragraph only if non-empty and (for ordered) starts at 1 —
808        // *unless* a list is already open, in which case any marker (even an empty one, or an ordered
809        // marker not starting at 1) starts a sibling item (e.g. `- foo\n-\n- bar`, `2. bar\n3) baz`).
810        if let Some(m) = list_marker(trimmed) {
811            if self.in_any_list() {
812                return false;
813            }
814            let empty = trimmed[m.after..].trim().is_empty();
815            if !(empty || (m.ordered && m.start != 1)) {
816                return false;
817            }
818        }
819        true
820    }
821
822    // --- list buffering helpers ---------------------------------------------------------------
823
824    /// Emit an event, routing it into the innermost open list's buffer if one is open, else staging
825    /// it on the forward-reference gate. `out` is drained from the gate once per write/flush.
826    fn emit(&mut self, out: &mut Vec<Event>, ev: Event) {
827        if let Some(frame) = self.innermost_list_mut() {
828            frame.events.push(BufEvent::Raw(ev));
829        } else {
830            self.gate.push(Slot::Event(ev));
831            self.drain_gate(out);
832        }
833    }
834
835    /// Emit a buffered run of paragraph inline content (so the `<p>` wrapper can be toggled by
836    /// looseness at close time).
837    fn emit_para(&mut self, out: &mut Vec<Event>, para: Vec<Event>) {
838        if let Some(frame) = self.innermost_list_mut() {
839            frame.events.push(BufEvent::Para(para));
840        } else {
841            // Not in a list: paragraphs are always wrapped.
842            self.gate
843                .push(Slot::Event(Event::enter(BlockKind::Paragraph)));
844            for ev in para {
845                self.gate.push(Slot::Event(ev));
846            }
847            self.gate
848                .push(Slot::Event(Event::exit(BlockKind::Paragraph)));
849            self.drain_gate(out);
850        }
851    }
852
853    /// Buffer a deferred list-item paragraph run (a direct child of a list item) so its `<p>` wrapper
854    /// can be toggled by looseness at list close, like [`BufEvent::Para`]. Only called while a list
855    /// is open.
856    fn emit_buf_para_defer(&mut self, prefix: Vec<Event>, deferred: Deferred) {
857        if let Some(frame) = self.innermost_list_mut() {
858            frame.events.push(BufEvent::DeferPara { prefix, deferred });
859        }
860    }
861
862    /// Buffer a deferred inline run that is already positioned between explicit `<p>` events (a
863    /// paragraph nested under another block inside a list item): replayed verbatim, no re-wrapping.
864    /// Only called while a list is open.
865    fn emit_buf_raw_defer(&mut self, deferred: Deferred) {
866        if let Some(frame) = self.innermost_list_mut() {
867            frame.events.push(BufEvent::DeferRaw(deferred));
868        }
869    }
870
871    /// Stage a deferred paragraph inline run (one carrying a forward reference) at the top level: a
872    /// `<p>` wrapper around an optional already-materialised `prefix` and the deferred run.
873    fn emit_defer_para(&mut self, out: &mut Vec<Event>, prefix: Vec<Event>, deferred: Deferred) {
874        self.gate
875            .push(Slot::Event(Event::enter(BlockKind::Paragraph)));
876        for ev in prefix {
877            self.gate.push(Slot::Event(ev));
878        }
879        self.gate.push(Slot::Deferred(deferred));
880        self.gate
881            .push(Slot::Event(Event::exit(BlockKind::Paragraph)));
882        self.drain_gate(out);
883    }
884
885    // --- forward-reference output gate --------------------------------------------------------
886
887    /// Release as many staged [`Slot`]s as is safe, preserving document order. Leading `Slot::Event`s
888    /// flow out freely; a `Slot::Deferred` flows out only once **every** label it awaits is defined
889    /// (re-parsed against the now-complete-enough `refs`). The walk stops at the first deferred slot
890    /// still awaiting a definition — holding it, and everything after it, until the definition lands
891    /// or `flush()` forces a final drain. This bounds buffering to the span from the first unresolved
892    /// reference to its resolving definition (or EOF), and emits nothing out of order.
893    fn drain_gate(&mut self, out: &mut Vec<Event>) {
894        let mut release = 0;
895        for slot in &self.gate {
896            match slot {
897                Slot::Event(_) => release += 1,
898                Slot::Deferred(d) => {
899                    if d.labels.iter().all(|l| self.refs.contains_key(l)) {
900                        release += 1;
901                    } else {
902                        break;
903                    }
904                }
905            }
906        }
907        let released: Vec<Slot> = self.gate.drain(..release).collect();
908        self.emit_slots(released, out);
909    }
910
911    /// Force-release every staged slot at end of input: all link reference definitions are now known,
912    /// so any deferred run whose labels are still undefined re-parses to literal text (CommonMark).
913    fn flush_gate(&mut self, out: &mut Vec<Event>) {
914        let slots: Vec<Slot> = std::mem::take(&mut self.gate);
915        self.emit_slots(slots, out);
916    }
917
918    /// Materialise a run of released slots into `out`, re-parsing deferred runs against `self.refs`.
919    fn emit_slots(&self, slots: Vec<Slot>, out: &mut Vec<Event>) {
920        for slot in slots {
921            match slot {
922                Slot::Event(ev) => out.push(ev),
923                Slot::Deferred(d) => {
924                    inline::parse(&d.text, &d.style, &self.refs, self.gfm, out);
925                }
926            }
927        }
928    }
929
930    fn mark_item_start(&mut self) {
931        if let Some(frame) = self.innermost_list_mut() {
932            frame.events.push(BufEvent::ItemStart);
933        }
934    }
935
936    fn mark_item_end(&mut self) {
937        if let Some(frame) = self.innermost_list_mut() {
938            frame.events.push(BufEvent::ItemEnd);
939        }
940    }
941
942    /// Has the current (innermost) list item received no content yet? True iff the last buffered
943    /// event for the open list is the `ItemStart` marker — used to recognise a *first*-block task
944    /// marker.
945    fn item_is_empty(&mut self) -> bool {
946        matches!(
947            self.innermost_list_mut().and_then(|f| f.events.last()),
948            Some(BufEvent::ItemStart)
949        )
950    }
951
952    /// Record that a blank line occurred while inside a list. Walking outward from the innermost
953    /// container, mark each open list with a pending blank — but stop at the first block quote: a
954    /// blank line inside a nested block quote belongs to that quote and must not make a list *outside*
955    /// the quote loose (the quote "absorbs" the blank). If a later block lands in one of the marked
956    /// lists, that list is loose.
957    fn note_blank_in_item(&mut self) {
958        for c in self.containers.iter_mut().rev() {
959            match c {
960                Container::List(l) => l.pending_blank = true,
961                Container::BlockQuote => break,
962                Container::Item { .. } => {}
963            }
964        }
965    }
966
967    /// Commit a pending blank: a new block is being added to the innermost open list, so if a blank
968    /// preceded it that list becomes loose. The blank is then "consumed" — its flag is cleared on
969    /// *every* open list, so a blank that separates blocks of an inner list does not also make an
970    /// enclosing list loose (the enclosing list is only loose if a blank directly precedes one of its
971    /// own added blocks).
972    fn commit_pending_blank(&mut self) {
973        let mut committed = false;
974        for c in self.containers.iter_mut().rev() {
975            if let Container::List(l) = c {
976                if !committed {
977                    if l.pending_blank {
978                        l.loose = true;
979                    }
980                    committed = true;
981                }
982                l.pending_blank = false;
983            }
984        }
985    }
986
987    /// The innermost open list frame (mutable), or `None` if no list is open.
988    fn innermost_list_mut(&mut self) -> Option<&mut ListFrame> {
989        for c in self.containers.iter_mut().rev() {
990            if let Container::List(l) = c {
991                return Some(l);
992            }
993        }
994        None
995    }
996
997    /// Whether any list is currently open (so a closing item's content gets buffered).
998    fn in_any_list(&self) -> bool {
999        self.containers
1000            .iter()
1001            .any(|c| matches!(c, Container::List(_)))
1002    }
1003
1004    /// Whether a closing paragraph is a *direct* child of a list item (the innermost open container
1005    /// is an `Item`). Only such paragraphs participate in tight/loose `<p>`-stripping; a paragraph
1006    /// nested under, say, a block quote inside the item is always wrapped.
1007    fn para_is_direct_list_child(&self) -> bool {
1008        matches!(self.containers.last(), Some(Container::Item { .. }))
1009    }
1010
1011    // --- container closing --------------------------------------------------------------------
1012
1013    /// Close (and emit) all containers above index `keep`, deepest first.
1014    fn close_containers_to(&mut self, keep: usize, out: &mut Vec<Event>) {
1015        while self.containers.len() > keep {
1016            self.close_leaf(out);
1017            match self.containers.pop().unwrap() {
1018                Container::BlockQuote => {
1019                    self.emit(out, Event::exit(BlockKind::BlockQuote));
1020                }
1021                Container::Item { .. } => {
1022                    self.mark_item_end();
1023                    self.emit(out, Event::exit(BlockKind::ListItem));
1024                }
1025                Container::List(frame) => {
1026                    self.flush_list(frame, out);
1027                }
1028            }
1029        }
1030    }
1031
1032    /// Replay a finished list's buffered events to `out` (or to the enclosing list's buffer if this
1033    /// list was nested), applying the resolved `tight`/`loose` decision to wrap (or not) each item's
1034    /// paragraph content in `<p>`.
1035    fn flush_list(&mut self, frame: ListFrame, out: &mut Vec<Event>) {
1036        let tight = !frame.loose;
1037        let data = BlockData {
1038            list: Some(ListData {
1039                ordered: frame.ordered,
1040                start: frame.start,
1041                tight,
1042                marker: frame.marker,
1043            }),
1044            ..Default::default()
1045        };
1046
1047        // Assemble the body. Each item's child events are first collected flat (wrapped paragraphs
1048        // expanded), then run through `wrap_item_children`, which applies the CommonMark `<li>`
1049        // newline rules: a `\n` precedes every top-level *block* child of the item, and a `\n`
1050        // precedes `</li>` iff the item's last child is a block. Inline children (tight, unwrapped
1051        // paragraph text) get no separators, so a tight inline-only item stays `<li>x</li>`.
1052        let mut body: Vec<Slot> = Vec::new();
1053        let mut item: Vec<Slot> = Vec::new();
1054        let mut in_item = false;
1055
1056        // Push a paragraph run (resolved events or a deferred run) into `target`, wrapping it in
1057        // `<p>` only when the list is loose. `prefix` holds any already-materialised leading events.
1058        let push_para = |target: &mut Vec<Slot>, prefix: Vec<Event>, run: ParaRun| {
1059            if !tight {
1060                target.push(Slot::Event(Event::enter(BlockKind::Paragraph)));
1061            }
1062            for ev in prefix {
1063                target.push(Slot::Event(ev));
1064            }
1065            match run {
1066                ParaRun::Resolved(inner) => target.extend(inner.into_iter().map(Slot::Event)),
1067                ParaRun::Deferred(d) => target.push(Slot::Deferred(d)),
1068            }
1069            if !tight {
1070                target.push(Slot::Event(Event::exit(BlockKind::Paragraph)));
1071            }
1072        };
1073
1074        for be in frame.events {
1075            match be {
1076                BufEvent::ItemStart => {
1077                    in_item = true;
1078                    item.clear();
1079                }
1080                BufEvent::ItemEnd => {
1081                    let wrapped = wrap_item_children(std::mem::take(&mut item));
1082                    body.extend(wrapped);
1083                    in_item = false;
1084                }
1085                BufEvent::Para(inner) => {
1086                    let target = if in_item { &mut item } else { &mut body };
1087                    push_para(target, Vec::new(), ParaRun::Resolved(inner));
1088                }
1089                BufEvent::DeferPara { prefix, deferred } => {
1090                    let target = if in_item { &mut item } else { &mut body };
1091                    push_para(target, prefix, ParaRun::Deferred(deferred));
1092                }
1093                BufEvent::DeferRaw(deferred) => {
1094                    let target = if in_item { &mut item } else { &mut body };
1095                    target.push(Slot::Deferred(deferred));
1096                }
1097                BufEvent::Raw(ev) => {
1098                    let target = if in_item { &mut item } else { &mut body };
1099                    target.push(Slot::Event(ev));
1100                }
1101            }
1102        }
1103
1104        // Route the assembled list either to the enclosing list buffer or straight to the gate.
1105        if let Some(parent) = self.innermost_list_mut() {
1106            parent.events.push(BufEvent::Raw(Event::EnterBlock {
1107                block: BlockKind::List,
1108                data,
1109                span: Span::default(),
1110            }));
1111            for slot in body {
1112                match slot {
1113                    Slot::Event(ev) => parent.events.push(BufEvent::Raw(ev)),
1114                    // Already positioned by this list's looseness: propagate as-is (no re-wrapping).
1115                    Slot::Deferred(deferred) => parent.events.push(BufEvent::DeferRaw(deferred)),
1116                }
1117            }
1118            parent
1119                .events
1120                .push(BufEvent::Raw(Event::exit(BlockKind::List)));
1121        } else {
1122            self.gate.push(Slot::Event(Event::EnterBlock {
1123                block: BlockKind::List,
1124                data,
1125                span: Span::default(),
1126            }));
1127            self.gate.extend(body);
1128            self.gate.push(Slot::Event(Event::exit(BlockKind::List)));
1129            self.drain_gate(out);
1130        }
1131    }
1132
1133    // --- leaf closing -------------------------------------------------------------------------
1134
1135    fn close_leaf(&mut self, out: &mut Vec<Event>) {
1136        match std::mem::take(&mut self.leaf) {
1137            Leaf::None => {}
1138            Leaf::Paragraph(text) => {
1139                let body = self.consume_refdefs(&text);
1140                // The final line's trailing whitespace is not significant (only *interior* line-end
1141                // whitespace can form a hard break), so trim the very end before inline parsing.
1142                let body = body.trim_end();
1143                if body.is_empty() {
1144                    return;
1145                }
1146                // GFM task-list item: a list item whose first block is a paragraph beginning with
1147                // `[ ]`, `[x]`, or `[X]` (followed by whitespace) renders a checkbox in place of the
1148                // marker. Detect it only at the item's first content. The checkbox is a pre-built
1149                // event that precedes the (possibly deferred) inline content.
1150                let mut prefix = Vec::new();
1151                let mut body = body;
1152                if self.gfm && self.para_is_direct_list_child() && self.item_is_empty() {
1153                    if let Some((checked, rest)) = task_marker(body) {
1154                        prefix.push(Event::Text {
1155                            text: format!(
1156                                "<input {}disabled=\"\" type=\"checkbox\"> ",
1157                                if checked { "checked=\"\" " } else { "" }
1158                            ),
1159                            style: InlineStyle {
1160                                raw_html: true,
1161                                ..Default::default()
1162                            },
1163                            span: Span::default(),
1164                        });
1165                        body = rest;
1166                    }
1167                }
1168                // Parse the inline content, collecting any forward references (labels not yet
1169                // defined). When some surface, hold this paragraph's content as a deferred run so it
1170                // re-parses once the definitions are known; otherwise emit it eagerly as before.
1171                let style = InlineStyle::default();
1172                let mut inner = Vec::new();
1173                let mut labels = Vec::new();
1174                inline::parse_collect_unresolved(
1175                    body,
1176                    &style,
1177                    &self.refs,
1178                    self.gfm,
1179                    &mut inner,
1180                    &mut labels,
1181                );
1182                let deferred = (!labels.is_empty()).then(|| Deferred {
1183                    text: body.to_string(),
1184                    style,
1185                    labels,
1186                });
1187
1188                if self.para_is_direct_list_child() {
1189                    // A direct child of a list item: buffer so the list's looseness can decide on the
1190                    // `<p>` wrapper later (tight → no wrapper).
1191                    match deferred {
1192                        Some(deferred) => self.emit_buf_para_defer(prefix, deferred),
1193                        None => {
1194                            let mut run = prefix;
1195                            run.extend(inner);
1196                            self.emit_para(out, run);
1197                        }
1198                    }
1199                } else if self.in_any_list() {
1200                    // Inside a list but nested under another block (e.g. a blockquote in the item):
1201                    // always wrapped, but routed through the list buffer to preserve order.
1202                    self.emit(out, Event::enter(BlockKind::Paragraph));
1203                    for ev in prefix {
1204                        self.emit(out, ev);
1205                    }
1206                    match deferred {
1207                        Some(deferred) => self.emit_buf_raw_defer(deferred),
1208                        None => {
1209                            for ev in inner {
1210                                self.emit(out, ev);
1211                            }
1212                        }
1213                    }
1214                    self.emit(out, Event::exit(BlockKind::Paragraph));
1215                } else {
1216                    // Top level: stage on the gate (held iff deferred).
1217                    match deferred {
1218                        Some(deferred) => self.emit_defer_para(out, prefix, deferred),
1219                        None => {
1220                            let mut run = prefix;
1221                            run.extend(inner);
1222                            self.emit_para(out, run);
1223                        }
1224                    }
1225                }
1226            }
1227            Leaf::Indented(mut lines) => {
1228                // Trim trailing blank lines.
1229                while lines.last().map(|l| l.trim().is_empty()) == Some(true) {
1230                    lines.pop();
1231                }
1232                if lines.is_empty() {
1233                    return;
1234                }
1235                self.emit(out, Event::enter(BlockKind::IndentedCode));
1236                let mut text = lines.join("\n");
1237                text.push('\n');
1238                self.emit(out, Event::text(text));
1239                self.emit(out, Event::exit(BlockKind::IndentedCode));
1240            }
1241            Leaf::Fenced { .. } => {
1242                self.emit(out, Event::exit(BlockKind::FencedCode));
1243            }
1244            Leaf::Table { .. } => {
1245                self.emit(out, Event::exit(BlockKind::Table));
1246            }
1247            Leaf::Html { .. } => {
1248                self.emit(out, Event::exit(BlockKind::HtmlBlock));
1249            }
1250        }
1251    }
1252
1253    /// Parse inline content (a heading or table cell, already positioned between its block's
1254    /// enter/exit events) into the innermost list buffer or onto the gate. A forward reference holds
1255    /// the run as a deferred unit so the surrounding block emits in order but the inline content is
1256    /// re-parsed once the definition is known.
1257    fn parse_inline(&mut self, text: &str, out: &mut Vec<Event>) {
1258        let style = InlineStyle::default();
1259        let mut inner = Vec::new();
1260        let mut labels = Vec::new();
1261        inline::parse_collect_unresolved(
1262            text,
1263            &style,
1264            &self.refs,
1265            self.gfm,
1266            &mut inner,
1267            &mut labels,
1268        );
1269        let deferred = (!labels.is_empty()).then(|| Deferred {
1270            text: text.to_string(),
1271            style,
1272            labels,
1273        });
1274        if self.in_any_list() {
1275            match deferred {
1276                Some(deferred) => self.emit_buf_raw_defer(deferred),
1277                None => {
1278                    for ev in inner {
1279                        self.emit(out, ev);
1280                    }
1281                }
1282            }
1283        } else {
1284            match deferred {
1285                Some(deferred) => {
1286                    self.gate.push(Slot::Deferred(deferred));
1287                    self.drain_gate(out);
1288                }
1289                None => {
1290                    for ev in inner {
1291                        self.emit(out, ev);
1292                    }
1293                }
1294            }
1295        }
1296    }
1297
1298    /// Strip leading link reference definitions from a buffered paragraph, registering each into
1299    /// `self.refs` (first definition of a label wins). Returns the remaining paragraph text (the
1300    /// lines after the last consumed definition), trimmed of the leading newline.
1301    ///
1302    /// A definition may span multiple buffered lines (the title may sit on a continuation line), so
1303    /// this works on the whole buffer rather than line-by-line. Parsing stops at the first position
1304    /// that does not begin a valid definition; everything from there on is paragraph text.
1305    fn consume_refdefs(&mut self, text: &str) -> String {
1306        let b = text.as_bytes();
1307        let mut pos = 0;
1308        loop {
1309            let line_start = pos;
1310            let mut p = pos;
1311            let mut spaces = 0;
1312            while p < b.len() && b[p] == b' ' {
1313                spaces += 1;
1314                p += 1;
1315            }
1316            if spaces > 3 {
1317                break;
1318            }
1319            match parse_refdef(b, p) {
1320                Some((label, def, next)) => {
1321                    if let Some(norm) = linkref::normalize_label(&label) {
1322                        self.refs.entry(norm).or_insert(def);
1323                        pos = next;
1324                    } else {
1325                        break;
1326                    }
1327                }
1328                None => {
1329                    pos = line_start;
1330                    break;
1331                }
1332            }
1333        }
1334        text[pos..].to_string()
1335    }
1336
1337    fn start_table(&mut self, header: &str, aligns: Vec<Alignment>, out: &mut Vec<Event>) {
1338        let data = BlockData {
1339            alignment: aligns.clone(),
1340            ..Default::default()
1341        };
1342        self.emit(
1343            out,
1344            Event::EnterBlock {
1345                block: BlockKind::Table,
1346                data,
1347                span: Span::default(),
1348            },
1349        );
1350        self.emit_row(split_row(header), &aligns, out);
1351        self.leaf = Leaf::Table { aligns };
1352    }
1353
1354    fn emit_row(&mut self, mut cells: Vec<String>, aligns: &[Alignment], out: &mut Vec<Event>) {
1355        cells.resize(aligns.len(), String::new());
1356        self.emit(out, Event::enter(BlockKind::TableRow));
1357        for cell in cells {
1358            self.emit(out, Event::enter(BlockKind::TableCell));
1359            self.parse_inline(cell.trim(), out);
1360            self.emit(out, Event::exit(BlockKind::TableCell));
1361        }
1362        self.emit(out, Event::exit(BlockKind::TableRow));
1363    }
1364}
1365
1366// ---------------------------------------------------------------------------
1367// Cursor: a position within the current line tracking byte offset and virtual column (tabs → 4).
1368// ---------------------------------------------------------------------------
1369
1370/// A scanning cursor over one input line that tracks both a byte offset and a virtual *column*
1371/// (with tabs expanded to the next multiple of [`TAB`]). Container matching is column-based, so the
1372/// cursor lets a tab be partially consumed (e.g. a 4-wide tab where only 2 columns are needed).
1373#[derive(Clone)]
1374struct Cursor {
1375    bytes: Vec<u8>,
1376    /// Current byte offset.
1377    pos: usize,
1378    /// Current virtual column.
1379    column: usize,
1380    /// Columns of an in-progress tab already "consumed" (when a tab straddles a needed boundary).
1381    partial_tab: usize,
1382}
1383
1384impl Cursor {
1385    fn new(line: &str) -> Self {
1386        Cursor {
1387            bytes: line.as_bytes().to_vec(),
1388            pos: 0,
1389            column: 0,
1390            partial_tab: 0,
1391        }
1392    }
1393
1394    fn col(&self) -> usize {
1395        self.column
1396    }
1397
1398    fn peek(&self) -> Option<u8> {
1399        self.bytes.get(self.pos).copied()
1400    }
1401
1402    /// The next non-space/tab byte (without advancing).
1403    fn peek_nonspace(&self) -> Option<u8> {
1404        let mut i = self.pos;
1405        while i < self.bytes.len() && matches!(self.bytes[i], b' ' | b'\t') {
1406            i += 1;
1407        }
1408        self.bytes.get(i).copied()
1409    }
1410
1411    /// Columns of leading whitespace from the current position to the next non-space. The current
1412    /// column already reflects any partially-consumed tab, so `TAB - (col % TAB)` yields a tab's
1413    /// *remaining* width directly.
1414    fn indent(&self) -> usize {
1415        let mut col = self.column;
1416        let mut i = self.pos;
1417        let start = col;
1418        while i < self.bytes.len() {
1419            match self.bytes[i] {
1420                b' ' => {
1421                    col += 1;
1422                    i += 1;
1423                }
1424                b'\t' => {
1425                    col += TAB - (col % TAB);
1426                    i += 1;
1427                }
1428                _ => break,
1429            }
1430        }
1431        col - start
1432    }
1433
1434    /// Is the rest of the line blank (only whitespace)?
1435    fn is_blank(&self) -> bool {
1436        self.bytes[self.pos..]
1437            .iter()
1438            .all(|&b| matches!(b, b' ' | b'\t'))
1439    }
1440
1441    fn is_blank_from_here(&self) -> bool {
1442        self.is_blank()
1443    }
1444
1445    /// Advance one byte, updating the column. A tab advances to the next tab stop; if some of its
1446    /// columns were already consumed (`partial_tab`), `self.column` already reflects them, so the
1447    /// remaining width is simply `TAB - (column % TAB)`.
1448    fn bump(&mut self) {
1449        if let Some(b) = self.peek() {
1450            match b {
1451                b'\t' => {
1452                    self.column += TAB - (self.column % TAB);
1453                    self.partial_tab = 0;
1454                }
1455                _ => self.column += 1,
1456            }
1457            self.pos += 1;
1458        }
1459    }
1460
1461    /// Advance past `n` raw bytes (used for ASCII marker characters).
1462    fn consume_bytes(&mut self, n: usize) {
1463        for _ in 0..n {
1464            self.bump();
1465        }
1466    }
1467
1468    /// Skip leading spaces/tabs to the first non-whitespace byte.
1469    fn advance_to_nonspace(&mut self) {
1470        while matches!(self.peek(), Some(b' ') | Some(b'\t')) {
1471            self.bump();
1472        }
1473    }
1474
1475    /// Consume exactly `cols` columns of leading whitespace (splitting a tab if necessary). When a tab
1476    /// straddles the target column it is consumed partially: the byte is left in place but `column`
1477    /// (and `partial_tab`) advance, so a later read still sees the tab's remaining columns.
1478    fn consume_cols(&mut self, cols: usize) {
1479        let target = self.column + cols;
1480        while self.column < target {
1481            match self.peek() {
1482                Some(b' ') => self.bump(),
1483                Some(b'\t') => {
1484                    // Remaining width of the (possibly already partially consumed) tab.
1485                    let width = TAB - (self.column % TAB);
1486                    if self.column + width <= target {
1487                        self.bump();
1488                    } else {
1489                        let take = target - self.column;
1490                        self.partial_tab += take;
1491                        self.column = target;
1492                    }
1493                }
1494                _ => break,
1495            }
1496        }
1497    }
1498
1499    /// Consume at most `cols` columns of leading whitespace.
1500    fn consume_cols_max(&mut self, cols: usize) {
1501        self.consume_cols(cols);
1502    }
1503
1504    /// Count columns of available leading whitespace (alias of [`indent`]).
1505    fn count_spaces(&self) -> usize {
1506        self.indent()
1507    }
1508
1509    /// A `&str` view of the rest of the line from the byte cursor.
1510    fn rest_str(&self) -> String {
1511        String::from_utf8_lossy(&self.bytes[self.pos..]).into_owned()
1512    }
1513
1514    /// The rest of the line after skipping the leading indent (≤ whatever spaces are present).
1515    fn rest_after_indent(&self) -> String {
1516        let mut i = self.pos;
1517        while i < self.bytes.len() && matches!(self.bytes[i], b' ' | b'\t') {
1518            i += 1;
1519        }
1520        String::from_utf8_lossy(&self.bytes[i..]).into_owned()
1521    }
1522
1523    /// Rest of the line, but if the cursor sits mid-tab (a tab whose leading columns were already
1524    /// consumed for column alignment), emit the tab's remaining columns as spaces before the rest.
1525    /// Used for code blocks, where the exact remaining indentation must be preserved verbatim.
1526    fn rest_str_with_partial_tab(&self) -> String {
1527        if self.partial_tab > 0 && self.peek() == Some(b'\t') {
1528            let remaining = TAB - (self.column % TAB);
1529            let mut s = " ".repeat(remaining);
1530            s.push_str(&String::from_utf8_lossy(&self.bytes[self.pos + 1..]));
1531            return s;
1532        }
1533        self.rest_str()
1534    }
1535
1536    /// Consume a single tab as if it were one space (for blockquote `> \t` padding).
1537    fn consume_tab_as_space(&mut self) {
1538        if self.peek() == Some(b'\t') {
1539            let width = TAB - (self.column % TAB);
1540            if width <= 1 {
1541                self.bump();
1542            } else {
1543                self.partial_tab += 1;
1544                self.column += 1;
1545            }
1546        }
1547    }
1548}
1549
1550// ---------------------------------------------------------------------------
1551// line classifiers
1552// ---------------------------------------------------------------------------
1553
1554/// Strip up to `cols` columns of leading whitespace from `s`, returning the remainder. Tabs expand
1555/// to [`TAB`]-column stops; a tab straddling the boundary leaves its remaining columns as spaces.
1556fn strip_cols(s: &str, cols: usize) -> String {
1557    let b = s.as_bytes();
1558    let mut col = 0;
1559    let mut i = 0;
1560    while i < b.len() && col < cols {
1561        match b[i] {
1562            b' ' => {
1563                col += 1;
1564                i += 1;
1565            }
1566            b'\t' => {
1567                let width = TAB - (col % TAB);
1568                if col + width <= cols {
1569                    col += width;
1570                    i += 1;
1571                } else {
1572                    // partial tab: keep the leftover as spaces
1573                    let leftover = (col + width) - cols;
1574                    let mut out = " ".repeat(leftover);
1575                    out.push_str(&String::from_utf8_lossy(&b[i + 1..]));
1576                    return out;
1577                }
1578            }
1579            _ => break,
1580        }
1581    }
1582    String::from_utf8_lossy(&b[i..]).into_owned()
1583}
1584
1585/// Block kinds that, as a list item's child, force the CommonMark `<li>` newline layout (a `\n`
1586/// before the child and, if it is the item's last child, before `</li>`).
1587fn is_block_enter(ev: &Event) -> bool {
1588    matches!(
1589        ev,
1590        Event::EnterBlock {
1591            block: BlockKind::List
1592                | BlockKind::BlockQuote
1593                | BlockKind::FencedCode
1594                | BlockKind::IndentedCode
1595                | BlockKind::Heading
1596                | BlockKind::ThematicBreak
1597                | BlockKind::HtmlBlock
1598                | BlockKind::Table
1599                | BlockKind::Paragraph,
1600            ..
1601        }
1602    )
1603}
1604
1605/// Insert the CommonMark `<li>` separator newlines into a list item's flat child-event stream.
1606///
1607/// A `\n` is emitted before a *top-level* block child (one whose `EnterBlock` sits at item depth 0)
1608/// **only when the previous top-level child was inline** (or this is the first child). Block children
1609/// already end their own output with a newline (the HTML renderer emits `</p>\n`, `</ul>\n`, …), so a
1610/// separator between two consecutive blocks would double it; the separator is only needed after the
1611/// `<li>` itself (leading block child) or after an inline run (`<li>a\n<ul>…`). Inline content (tight,
1612/// unwrapped paragraph text) needs no separators — a tight inline-only item stays `<li>text</li>`.
1613fn wrap_item_children(slots: Vec<Slot>) -> Vec<Slot> {
1614    let mut out = Vec::with_capacity(slots.len() + 2);
1615    let mut depth = 0i32;
1616    // `prev_block`: was the most recent top-level child a block? Starts `false` so a leading block
1617    // child gets its `\n` (the `<li>\n…` layout).
1618    let mut prev_block = false;
1619    for slot in slots {
1620        match &slot {
1621            Slot::Event(Event::EnterBlock { .. }) => {
1622                if depth == 0 {
1623                    if matches!(&slot, Slot::Event(ev) if is_block_enter(ev)) {
1624                        if !prev_block {
1625                            out.push(Slot::Event(Event::text("\n")));
1626                        }
1627                        prev_block = true;
1628                    } else {
1629                        prev_block = false;
1630                    }
1631                }
1632                depth += 1;
1633                out.push(slot);
1634            }
1635            Slot::Event(Event::ExitBlock { .. }) => {
1636                depth -= 1;
1637                out.push(slot);
1638            }
1639            // A deferred inline run, like any inline content, sits at depth 0 as a non-block child.
1640            _ => {
1641                if depth == 0 {
1642                    prev_block = false;
1643                }
1644                out.push(slot);
1645            }
1646        }
1647    }
1648    out
1649}
1650
1651/// A GFM task-list marker at the start of `body`: `[ ]`, `[x]`, or `[X]` followed by a space or tab.
1652/// Returns `(checked, rest)` where `rest` is the body after the marker and its single separator
1653/// space, or `None` if no marker is present. The bracket content must be exactly one character.
1654fn task_marker(body: &str) -> Option<(bool, &str)> {
1655    let b = body.as_bytes();
1656    if b.first() != Some(&b'[') || b.get(2) != Some(&b']') {
1657        return None;
1658    }
1659    let checked = match b.get(1) {
1660        Some(b' ') => false,
1661        Some(b'x') | Some(b'X') => true,
1662        _ => return None,
1663    };
1664    // A whitespace separator (or end of line) must follow the closing bracket.
1665    match b.get(3) {
1666        Some(b' ') | Some(b'\t') => Some((checked, &body[4..])),
1667        None => Some((checked, "")),
1668        _ => None,
1669    }
1670}
1671
1672fn atx_heading(line: &str) -> Option<(u8, &str)> {
1673    let hashes = line.bytes().take_while(|&b| b == b'#').count();
1674    if hashes == 0 || hashes > 6 {
1675        return None;
1676    }
1677    let rest = &line[hashes..];
1678    if !rest.is_empty() && !rest.starts_with([' ', '\t']) {
1679        return None;
1680    }
1681    let text = rest.trim();
1682    // An optional closing sequence: a run of `#` that is either the whole text or preceded by a space
1683    // or tab is stripped (so `# foo #` → `foo`), but a `#` run welded to the preceding word is content
1684    // (`# foo#` → `foo#`). The remaining text is inline-parsed, so any escaped `\#` survives as `#`.
1685    let trimmed = text.trim_end_matches('#');
1686    let text = if trimmed.len() == text.len() {
1687        // No trailing `#` run at all.
1688        text
1689    } else if trimmed.is_empty() || trimmed.ends_with([' ', '\t']) {
1690        // The `#` run is the whole text, or is preceded by whitespace → it is a closing sequence.
1691        trimmed.trim_end()
1692    } else {
1693        // The `#` run is attached to a word → keep it as content.
1694        text
1695    };
1696    Some((hashes as u8, text))
1697}
1698
1699/// A setext underline: a line of only `=` (level 1) or only `-` (level 2), ≤3 leading spaces.
1700fn setext_underline(line: &str) -> Option<u8> {
1701    let t = line.trim_end();
1702    if t.is_empty() {
1703        return None;
1704    }
1705    if t.bytes().all(|b| b == b'=') {
1706        Some(1)
1707    } else if t.bytes().all(|b| b == b'-') {
1708        Some(2)
1709    } else {
1710        None
1711    }
1712}
1713
1714fn is_thematic_break(line: &str) -> bool {
1715    let s: String = line.chars().filter(|c| !c.is_whitespace()).collect();
1716    s.len() >= 3
1717        && (s.bytes().all(|b| b == b'-')
1718            || s.bytes().all(|b| b == b'*')
1719            || s.bytes().all(|b| b == b'_'))
1720}
1721
1722fn fence_start(line: &str) -> Option<(u8, usize, String)> {
1723    let b = line.as_bytes();
1724    let ch = *b.first()?;
1725    if ch != b'`' && ch != b'~' {
1726        return None;
1727    }
1728    let len = line.bytes().take_while(|&c| c == ch).count();
1729    if len < 3 {
1730        return None;
1731    }
1732    let info = line[len..].trim();
1733    if ch == b'`' && info.contains('`') {
1734        return None;
1735    }
1736    // The info string resolves backslash escapes and entity references to their literal value (it is
1737    // ordinary inline text), so e.g. ``` foo\+bar ``` / ``` f&ouml;&ouml; ``` give a clean language.
1738    Some((ch, len, linkref::unescape_string(info)))
1739}
1740
1741fn is_closing_fence(line: &str, ch: u8, open_len: usize) -> bool {
1742    let len = line.bytes().take_while(|&c| c == ch).count();
1743    len >= open_len && line[len..].trim().is_empty()
1744}
1745
1746/// HTML block tag names for start condition 6.
1747const HTML_BLOCK_TAGS: &[&str] = &[
1748    "address",
1749    "article",
1750    "aside",
1751    "base",
1752    "basefont",
1753    "blockquote",
1754    "body",
1755    "caption",
1756    "center",
1757    "col",
1758    "colgroup",
1759    "dd",
1760    "details",
1761    "dialog",
1762    "dir",
1763    "div",
1764    "dl",
1765    "dt",
1766    "fieldset",
1767    "figcaption",
1768    "figure",
1769    "footer",
1770    "form",
1771    "frame",
1772    "frameset",
1773    "h1",
1774    "h2",
1775    "h3",
1776    "h4",
1777    "h5",
1778    "h6",
1779    "head",
1780    "header",
1781    "hr",
1782    "html",
1783    "iframe",
1784    "legend",
1785    "li",
1786    "link",
1787    "main",
1788    "menu",
1789    "menuitem",
1790    "nav",
1791    "noframes",
1792    "ol",
1793    "optgroup",
1794    "option",
1795    "p",
1796    "param",
1797    "search",
1798    "section",
1799    "summary",
1800    "table",
1801    "tbody",
1802    "td",
1803    "tfoot",
1804    "th",
1805    "thead",
1806    "title",
1807    "tr",
1808    "track",
1809    "ul",
1810];
1811
1812fn contains_ci(haystack: &str, needle: &str) -> bool {
1813    if needle.is_empty() {
1814        return true;
1815    }
1816    let h = haystack.as_bytes();
1817    let n = needle.as_bytes();
1818    if h.len() < n.len() {
1819        return false;
1820    }
1821    (0..=h.len() - n.len()).any(|i| {
1822        h[i..i + n.len()]
1823            .iter()
1824            .zip(n)
1825            .all(|(a, b)| a.eq_ignore_ascii_case(b))
1826    })
1827}
1828
1829fn html_block_start(line: &str, in_paragraph: bool) -> Option<HtmlEnd> {
1830    let b = line.as_bytes();
1831    if b.first() != Some(&b'<') {
1832        return None;
1833    }
1834
1835    for (tag, close) in [
1836        ("script", "</script>"),
1837        ("pre", "</pre>"),
1838        ("style", "</style>"),
1839        ("textarea", "</textarea>"),
1840    ] {
1841        if starts_tag_ci(line, tag) {
1842            let after = &line[1 + tag.len()..];
1843            if after.is_empty() || after.starts_with([' ', '\t', '>']) {
1844                return Some(HtmlEnd::Marker(close));
1845            }
1846        }
1847    }
1848
1849    if line.starts_with("<!--") {
1850        return Some(HtmlEnd::Marker("-->"));
1851    }
1852    if line.starts_with("<?") {
1853        return Some(HtmlEnd::Marker("?>"));
1854    }
1855    if line.starts_with("<![CDATA[") {
1856        return Some(HtmlEnd::Marker("]]>"));
1857    }
1858    if b.get(1) == Some(&b'!') && b.get(2).is_some_and(|c| c.is_ascii_alphabetic()) {
1859        return Some(HtmlEnd::Marker(">"));
1860    }
1861
1862    let (rest, _closing) = match b.get(1) {
1863        Some(b'/') => (&line[2..], true),
1864        _ => (&line[1..], false),
1865    };
1866    for tag in HTML_BLOCK_TAGS {
1867        if starts_word_ci(rest, tag) {
1868            let after = &rest[tag.len()..];
1869            if after.is_empty() || after.starts_with([' ', '\t', '>']) || after.starts_with("/>") {
1870                return Some(HtmlEnd::Blank);
1871            }
1872        }
1873    }
1874
1875    if !in_paragraph {
1876        if let Some(after) = complete_tag(line) {
1877            if after.trim().is_empty() {
1878                return Some(HtmlEnd::Blank);
1879            }
1880        }
1881    }
1882
1883    None
1884}
1885
1886fn starts_tag_ci(line: &str, tag: &str) -> bool {
1887    let b = line.as_bytes();
1888    b.first() == Some(&b'<') && starts_word_ci(&line[1..], tag)
1889}
1890
1891fn starts_word_ci(s: &str, word: &str) -> bool {
1892    let b = s.as_bytes();
1893    let w = word.as_bytes();
1894    b.len() >= w.len()
1895        && b[..w.len()]
1896            .iter()
1897            .zip(w)
1898            .all(|(a, c)| a.eq_ignore_ascii_case(c))
1899}
1900
1901fn complete_tag(line: &str) -> Option<&str> {
1902    let b = line.as_bytes();
1903    let end = if b.get(1) == Some(&b'/') {
1904        crate::inline::scan_closing_tag(b, 0)?
1905    } else {
1906        let (e, name) = crate::inline::scan_open_tag(b, 0)?;
1907        let lname = name.to_ascii_lowercase();
1908        if matches!(lname.as_str(), "script" | "style" | "pre" | "textarea") {
1909            return None;
1910        }
1911        e
1912    };
1913    Some(&line[end..])
1914}
1915
1916/// Parse a list marker at the start of `line` (already de-indented). Returns the marker kind, char,
1917/// start number, and the byte offset just past the marker+separator (before the spaces that follow).
1918fn list_marker(line: &str) -> Option<Marker> {
1919    let b = line.as_bytes();
1920    // Bullet: -, *, + followed by a space/tab or end of line.
1921    if let Some(&c) = b.first() {
1922        if c == b'-' || c == b'*' || c == b'+' {
1923            match b.get(1) {
1924                Some(b' ') | Some(b'\t') | None => {
1925                    return Some(Marker {
1926                        ordered: false,
1927                        marker: c as char,
1928                        start: 1,
1929                        after: 1,
1930                    });
1931                }
1932                _ => {}
1933            }
1934        }
1935    }
1936    // Ordered: 1–9 digits, then '.' or ')', then a space/tab or EOL.
1937    let digits = line.bytes().take_while(|c| c.is_ascii_digit()).count();
1938    if (1..=9).contains(&digits) {
1939        let sep = b.get(digits).copied();
1940        if sep == Some(b'.') || sep == Some(b')') {
1941            match b.get(digits + 1) {
1942                Some(b' ') | Some(b'\t') | None => {
1943                    let start: u64 = line[..digits].parse().unwrap_or(1);
1944                    return Some(Marker {
1945                        ordered: true,
1946                        marker: sep.unwrap() as char,
1947                        start,
1948                        after: digits + 1,
1949                    });
1950                }
1951                _ => {}
1952            }
1953        }
1954    }
1955    None
1956}
1957
1958fn split_row(line: &str) -> Vec<String> {
1959    let mut s = line.trim();
1960    s = s.strip_prefix('|').unwrap_or(s);
1961    s = s.strip_suffix('|').unwrap_or(s);
1962    let mut cells = Vec::new();
1963    let mut cur = String::new();
1964    let mut chars = s.chars().peekable();
1965    while let Some(c) = chars.next() {
1966        match c {
1967            '\\' => {
1968                if let Some(&n) = chars.peek() {
1969                    cur.push('\\');
1970                    cur.push(n);
1971                    chars.next();
1972                } else {
1973                    cur.push('\\');
1974                }
1975            }
1976            '|' => {
1977                cells.push(cur.trim().to_string());
1978                cur.clear();
1979            }
1980            _ => cur.push(c),
1981        }
1982    }
1983    cells.push(cur.trim().to_string());
1984    cells
1985}
1986
1987fn parse_delim_row(line: &str) -> Option<Vec<Alignment>> {
1988    if !line.contains('|') && !line.contains('-') {
1989        return None;
1990    }
1991    let cells = split_row(line);
1992    if cells.is_empty() {
1993        return None;
1994    }
1995    let mut aligns = Vec::with_capacity(cells.len());
1996    for cell in &cells {
1997        let c = cell.trim();
1998        if c.is_empty() {
1999            return None;
2000        }
2001        let left = c.starts_with(':');
2002        let right = c.ends_with(':');
2003        let mid = &c[usize::from(left)..c.len() - usize::from(right)];
2004        if mid.is_empty() || !mid.bytes().all(|b| b == b'-') {
2005            return None;
2006        }
2007        aligns.push(match (left, right) {
2008            (true, true) => Alignment::Center,
2009            (true, false) => Alignment::Left,
2010            (false, true) => Alignment::Right,
2011            (false, false) => Alignment::None,
2012        });
2013    }
2014    Some(aligns)
2015}
2016
2017// ---------------------------------------------------------------------------
2018// link reference definitions (unchanged from M2b)
2019// ---------------------------------------------------------------------------
2020
2021/// Try to parse a single link reference definition `[label]: dest "title"` starting at byte `i`.
2022fn parse_refdef(b: &[u8], i: usize) -> Option<(String, LinkDef, usize)> {
2023    if b.get(i) != Some(&b'[') {
2024        return None;
2025    }
2026    let mut j = i + 1;
2027    let mut label = String::new();
2028    loop {
2029        match b.get(j) {
2030            Some(b'\\') if b.get(j + 1).is_some_and(|c| c.is_ascii_punctuation()) => {
2031                label.push('\\');
2032                label.push(b[j + 1] as char);
2033                j += 2;
2034            }
2035            Some(b']') => break,
2036            Some(b'[') => return None,
2037            Some(&c) if c < 0x80 => {
2038                label.push(c as char);
2039                j += 1;
2040            }
2041            Some(_) => {
2042                let s = String::from_utf8_lossy(&b[j..]);
2043                let ch = s.chars().next()?;
2044                label.push(ch);
2045                j += ch.len_utf8();
2046            }
2047            None => return None,
2048        }
2049    }
2050    if b.get(j) != Some(&b']') || b.get(j + 1) != Some(&b':') {
2051        return None;
2052    }
2053    j += 2;
2054
2055    j = skip_inline_ws_to_one_newline(b, j)?;
2056
2057    let (raw_dest, after_dest) = linkref::parse_destination(b, j)?;
2058    j = after_dest;
2059
2060    let (title_ws, ws_newlines) = scan_ws(b, j);
2061    let after_ws = title_ws;
2062
2063    let dest_line_end = line_end(b, j);
2064    let mut def_title = String::new();
2065    let end;
2066
2067    if after_ws > j && ws_newlines <= 1 {
2068        if let Some((raw_title, after_title)) = linkref::parse_title(b, after_ws) {
2069            let rest = skip_spaces(b, after_title);
2070            if rest >= b.len() || b[rest] == b'\n' {
2071                def_title = linkref::normalize_title(&raw_title);
2072                end = if rest < b.len() { rest + 1 } else { rest };
2073            } else {
2074                end = dest_line_end?;
2075            }
2076        } else {
2077            end = dest_line_end?;
2078        }
2079    } else {
2080        end = dest_line_end?;
2081    }
2082
2083    Some((
2084        label,
2085        LinkDef {
2086            dest: linkref::normalize_dest(&raw_dest),
2087            title: def_title,
2088        },
2089        end,
2090    ))
2091}
2092
2093fn skip_inline_ws_to_one_newline(b: &[u8], mut i: usize) -> Option<usize> {
2094    let mut newlines = 0;
2095    while i < b.len() {
2096        match b[i] {
2097            b' ' | b'\t' | b'\r' => i += 1,
2098            b'\n' => {
2099                newlines += 1;
2100                if newlines > 1 {
2101                    return None;
2102                }
2103                i += 1;
2104            }
2105            _ => break,
2106        }
2107    }
2108    Some(i)
2109}
2110
2111fn skip_spaces(b: &[u8], mut i: usize) -> usize {
2112    while i < b.len() && matches!(b[i], b' ' | b'\t' | b'\r') {
2113        i += 1;
2114    }
2115    i
2116}
2117
2118fn scan_ws(b: &[u8], mut i: usize) -> (usize, usize) {
2119    let mut nl = 0;
2120    while i < b.len() {
2121        match b[i] {
2122            b' ' | b'\t' | b'\r' => i += 1,
2123            b'\n' => {
2124                nl += 1;
2125                i += 1;
2126            }
2127            _ => break,
2128        }
2129    }
2130    (i, nl)
2131}
2132
2133fn line_end(b: &[u8], i: usize) -> Option<usize> {
2134    let mut j = i;
2135    while j < b.len() {
2136        match b[j] {
2137            b' ' | b'\t' | b'\r' => j += 1,
2138            b'\n' => return Some(j + 1),
2139            _ => return None,
2140        }
2141    }
2142    Some(j)
2143}