Skip to main content

panache_parser/parser/inlines/
inline_ir.rs

1//! Inline IR for both CommonMark and Pandoc dialects.
2//!
3//! The inline parsing pipeline runs in three passes over an intermediate
4//! representation (IR):
5//!
6//! 1. **Scan** ([`build_ir`]): walk the source bytes once, producing a flat
7//!    [`Vec<IrEvent>`]. Opaque higher-precedence constructs (escapes, code
8//!    spans, autolinks, raw HTML, plus Pandoc math / native spans / inline
9//!    footnotes / footnote references / citations / bracketed spans) are
10//!    skipped past as a single [`IrEvent::Construct`] event whose source
11//!    range is preserved for losslessness. Delimiter runs (`*`/`_`),
12//!    bracket markers (`[`, `![`, `]`), soft line breaks, and plain text
13//!    spans become distinct events.
14//!
15//! 2. **Process brackets** ([`process_brackets`]) — CommonMark §6.3: the
16//!    bracket-stack algorithm walks `]` markers left-to-right. For each
17//!    `]`, the algorithm finds the nearest active opener and tries to
18//!    resolve the pair as a link or image: inline `[text](dest)`, full
19//!    reference `[text][label]`, collapsed `[text][]`, or shortcut
20//!    `[text]`. Under CommonMark, reference forms are validated against
21//!    the document refdef map and a successful match deactivates all
22//!    earlier active openers (§6.3 "links may not contain other links").
23//!    Under Pandoc, reference forms resolve shape-only (any non-empty
24//!    label) and the deactivation pass is skipped; outer-wins nested-link
25//!    semantics are enforced by the emission walk's `suppress_inner_links`
26//!    flag instead.
27//!
28//! 3. **Process emphasis** ([`process_emphasis_in_range`]): the classic
29//!    delimiter-stack algorithm runs over the [`IrEvent::DelimRun`]
30//!    events, pairing openers with closers and recording matches on the
31//!    runs. Runs first scoped per resolved bracket pair (innermost
32//!    first), then a top-level pass over the residual events. Each match
33//!    consumes 1 or 2 inner-edge bytes from each side; leftover bytes
34//!    fall through to literal text. Dialect gates (Pandoc flanking rules,
35//!    mod-3 rejection, asymmetric (1,2)/(2,1) rejection, opener-count >= 4
36//!    rejection, triple-emph nesting flip, cascade-then-rerun) branch on
37//!    the `dialect` parameter.
38//!
39//! The emission walk in [`super::core::parse_inline_range_impl`] consumes
40//! three byte-keyed plans built by [`build_full_plans`]: an
41//! [`EmphasisPlan`] for delim-run dispositions, a [`BracketPlan`] for
42//! resolved link/image bracket pairs, and a [`ConstructPlan`] for
43//! standalone Pandoc constructs (inline footnotes, native spans, footnote
44//! references, citations, bracketed spans). Matched delim runs become
45//! `EMPHASIS` / `STRONG` nodes; matched bracket pairs become `LINK` /
46//! `IMAGE` nodes via the dispatcher's `try_parse_*` recognizers (called
47//! to *parse* a matched range, not to *resolve* it). Unmatched delims and
48//! brackets fall through to plain text.
49
50use crate::options::ParserOptions;
51use crate::parser::inlines::refdef_map::{RefdefMap, normalize_label};
52use std::collections::{BTreeMap, HashSet};
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum EmphasisKind {
56    Emph,
57    Strong,
58}
59
60/// Disposition of a single delimiter byte after emphasis resolution.
61#[derive(Debug, Clone, Copy)]
62pub enum DelimChar {
63    /// Start of an opening marker. The marker spans `len` bytes from this
64    /// position; the matching closer starts at `partner` and spans
65    /// `partner_len` bytes.
66    Open {
67        len: u8,
68        partner: usize,
69        partner_len: u8,
70        kind: EmphasisKind,
71    },
72    /// Start of a closing marker. The matching opener starts at `partner`.
73    /// Emission jumps past close markers via the matching `Open` entry, so
74    /// this variant is only consulted defensively.
75    Close,
76    /// Unmatched delimiter byte; emit as literal text.
77    Literal,
78}
79
80/// Byte-keyed disposition map for `*` / `_` delimiter chars produced by
81/// the IR's emphasis pass and consumed by the inline emission walk.
82#[derive(Debug, Default, Clone)]
83pub struct EmphasisPlan {
84    by_pos: BTreeMap<usize, DelimChar>,
85}
86
87impl EmphasisPlan {
88    pub fn lookup(&self, pos: usize) -> Option<DelimChar> {
89        self.by_pos.get(&pos).copied()
90    }
91
92    pub fn is_empty(&self) -> bool {
93        self.by_pos.is_empty()
94    }
95
96    /// Construct an `EmphasisPlan` from a byte-keyed disposition map.
97    pub fn from_dispositions(by_pos: BTreeMap<usize, DelimChar>) -> Self {
98        Self { by_pos }
99    }
100}
101
102use super::bracketed_spans::try_parse_bracketed_span;
103use super::citations::{try_parse_bare_citation, try_parse_bracketed_citation};
104use super::code_spans::try_parse_code_span;
105use super::escapes::{EscapeType, try_parse_escape};
106use super::inline_footnotes::{try_parse_footnote_reference, try_parse_inline_footnote};
107use super::inline_html::try_parse_inline_html;
108use super::links::{
109    LinkScanContext, try_parse_autolink, try_parse_inline_image, try_parse_inline_link,
110    try_parse_reference_image, try_parse_reference_link,
111};
112use super::math::{
113    try_parse_display_math, try_parse_double_backslash_display_math,
114    try_parse_double_backslash_inline_math, try_parse_gfm_inline_math, try_parse_inline_math,
115    try_parse_single_backslash_display_math, try_parse_single_backslash_inline_math,
116};
117use super::native_spans::try_parse_native_span;
118
119/// One event in the inline IR.
120///
121/// Events partition the source byte range covered by the IR exactly: their
122/// `range()` values are contiguous and non-overlapping, so concatenating
123/// them reproduces the original input. This is the losslessness invariant
124/// the emission pass relies on.
125#[derive(Debug, Clone)]
126pub enum IrEvent {
127    /// Plain text byte span. Emitted as a single `TEXT` token, possibly
128    /// merged with adjacent literal-disposition delim/bracket bytes.
129    Text { start: usize, end: usize },
130
131    /// An opaque higher-precedence construct (escape, code span, autolink,
132    /// raw HTML). The emission pass re-parses these from the source byte
133    /// range using the existing per-construct emitters; we don't store a
134    /// pre-built `GreenNode` because `rowan::GreenNodeBuilder` doesn't
135    /// support inserting subtrees directly. The byte range is what makes
136    /// emission well-defined — the construct kind is recovered by the
137    /// emitter dispatching on the leading byte.
138    Construct {
139        start: usize,
140        end: usize,
141        kind: ConstructKind,
142    },
143
144    /// A `*` or `_` delimiter run. The `matches` vec is filled in by
145    /// [`process_emphasis`]; before that pass it is empty.
146    DelimRun {
147        ch: u8,
148        start: usize,
149        end: usize,
150        can_open: bool,
151        can_close: bool,
152        /// Matched fragments produced by `process_emphasis`. Each entry
153        /// is one `(byte_offset_within_run, len, partner_event_idx,
154        /// partner_byte_offset, kind, is_opener)` tuple. Empty until the
155        /// pass runs; possibly multiple entries when a single run matches
156        /// at multiple positions (e.g. a 4-run that closes 2+2 pairs).
157        matches: Vec<DelimMatch>,
158    },
159
160    /// `[` or `![` bracket marker. Resolved by [`process_brackets`].
161    OpenBracket {
162        start: usize,
163        /// `start + 1` for `[`, `start + 2` for `![`.
164        end: usize,
165        is_image: bool,
166        /// True until a later resolution rule deactivates this opener.
167        active: bool,
168        /// Filled in when the matching `CloseBracket` resolves the pair
169        /// to a link / image.
170        resolution: Option<BracketResolution>,
171        /// Pandoc-only: extents of an unresolved bracket-shape pattern
172        /// (full reference / collapsed / shortcut whose label doesn't
173        /// match a refdef). Mutually exclusive with `resolution:
174        /// Some(...)`. When `Some`, emission wraps `[start, end)` in
175        /// an `UNRESOLVED_REFERENCE` node so downstream tools can
176        /// attach behavior to the bracket-shape pattern. Always
177        /// `None` under `Dialect::CommonMark`.
178        unresolved_ref: Option<UnresolvedRefShape>,
179    },
180
181    /// `]` bracket marker. Resolved by [`process_brackets`].
182    CloseBracket {
183        pos: usize,
184        /// True if this `]` was paired with an opener and the pair was
185        /// turned into a link / image.
186        matched: bool,
187    },
188
189    /// A soft line break (a `\n` or `\r\n` ending a paragraph-internal
190    /// line). Includes the line-ending bytes verbatim.
191    SoftBreak { start: usize, end: usize },
192
193    /// A hard line break (`  \n` / `\\\n` / `   \n` etc.). Includes any
194    /// trailing-space bytes plus the line ending.
195    HardBreak { start: usize, end: usize },
196}
197
198impl IrEvent {
199    /// The source byte range this event covers.
200    pub fn range(&self) -> (usize, usize) {
201        match self {
202            IrEvent::Text { start, end }
203            | IrEvent::Construct { start, end, .. }
204            | IrEvent::DelimRun { start, end, .. }
205            | IrEvent::OpenBracket { start, end, .. }
206            | IrEvent::SoftBreak { start, end }
207            | IrEvent::HardBreak { start, end } => (*start, *end),
208            IrEvent::CloseBracket { pos, .. } => (*pos, *pos + 1),
209        }
210    }
211}
212
213/// Categorical tag for a [`IrEvent::Construct`] event so emission knows
214/// which parser to call to rebuild the CST subtree.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum ConstructKind {
217    /// `\X` literal-character escape (CommonMark §2.4).
218    Escape,
219    /// `` `code` `` span (§6.1).
220    CodeSpan,
221    /// `<scheme://...>` or `<email@host>` (§6.5).
222    Autolink,
223    /// `<tag ...>` and friends (§6.6).
224    InlineHtml,
225    /// Pandoc opaque construct that doesn't have a dedicated kind yet
226    /// (currently: math spans). Pre-recognised in `build_ir` under
227    /// `Dialect::Pandoc` solely so the emphasis pass treats the entire
228    /// construct as opaque and delim runs inside don't cross its
229    /// boundary. Emission re-parses the construct via the dispatcher's
230    /// existing `try_parse_*` chain.
231    PandocOpaque,
232    /// Pandoc inline footnote `^[note text]`. Recognised in `build_ir`
233    /// under `Dialect::Pandoc` and consumed by the emission walk via
234    /// the IR's `ConstructPlan`. The dispatcher's legacy `^[` branch
235    /// is gated to CommonMark dialect only.
236    InlineFootnote,
237    /// Pandoc native span `<span ...>...</span>`. Recognised in
238    /// `build_ir` under `Dialect::Pandoc` and consumed by the emission
239    /// walk via the IR's `ConstructPlan`. The dispatcher's legacy
240    /// `<span>` branch is gated to CommonMark dialect only.
241    NativeSpan,
242    /// Pandoc footnote reference `[^id]`. Recognised in `build_ir`
243    /// under `Dialect::Pandoc` and consumed by the emission walk via
244    /// the IR's `ConstructPlan`. The dispatcher's legacy `[^id]`
245    /// branch is gated to CommonMark dialect only.
246    FootnoteReference,
247    /// Pandoc bracketed citation `[@key]`, `[see @key, p. 1]`,
248    /// `[@a; @b]`. Recognised in `build_ir` under `Dialect::Pandoc`
249    /// and consumed by the emission walk via the IR's `ConstructPlan`.
250    /// The dispatcher's legacy `[@cite]` branch is gated to CommonMark
251    /// dialect only.
252    BracketedCitation,
253    /// Pandoc bare citation `@key` or `-@key` (author-in-text /
254    /// suppress-author). Recognised in `build_ir` under
255    /// `Dialect::Pandoc` and consumed by the emission walk via the
256    /// IR's `ConstructPlan`. The dispatcher's legacy `@` and `-@`
257    /// branches are gated to CommonMark dialect only.
258    BareCitation,
259    /// Pandoc bracketed span `[content]{attrs}`. Recognised in
260    /// `build_ir` under `Dialect::Pandoc` and consumed by the emission
261    /// walk via the IR's `ConstructPlan`. The dispatcher's legacy
262    /// `[text]{attrs}` branch is gated to CommonMark dialect only.
263    BracketedSpan,
264    /// Pandoc wikilink `[[url]]` / `[[url|title]]` / `![[url]]` /
265    /// `![[url|title]]`. Recognised in `build_ir` when either
266    /// `wikilinks_title_after_pipe` or `wikilinks_title_before_pipe` is
267    /// enabled. Dialect-agnostic (pandoc accepts the extension on both
268    /// `markdown+` and `commonmark+`). The emission walk dispatches via
269    /// the IR's `ConstructPlan`; the `is_image` variant is recovered by
270    /// peeking the leading byte of the source range.
271    WikiLink,
272}
273
274/// One matched fragment within a [`IrEvent::DelimRun`].
275#[derive(Debug, Clone, Copy)]
276pub struct DelimMatch {
277    /// Byte offset of this fragment relative to the run's `start`.
278    pub offset_in_run: u8,
279    /// Number of bytes in this fragment (1 or 2).
280    pub len: u8,
281    /// Whether this fragment is the opener (`true`) or closer of the pair.
282    pub is_opener: bool,
283    /// IR event index of the partner run.
284    pub partner_event: u32,
285    /// Byte offset within the partner run of the partner fragment.
286    pub partner_offset: u8,
287    /// Emphasis kind (Emph for `len == 1`, Strong for `len == 2`).
288    pub kind: EmphasisKind,
289}
290
291/// Pandoc-only: extents of an unresolved bracket-shape reference
292/// pattern. Recorded on `IrEvent::OpenBracket.unresolved_ref` when the
293/// no-resolution fall-through fires under `Dialect::Pandoc`.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub struct UnresolvedRefShape {
296    /// IR event index of the matching `CloseBracket`. Used by the
297    /// scoped-emphasis pass to treat the wrapper as a tree boundary.
298    pub close_event: u32,
299    /// One past the end of the inner text (the byte position of the
300    /// outer `]`). Combined with the opener's `end` field, this is the
301    /// inner text range that goes through normal inline parsing.
302    pub text_end: usize,
303    /// One past the end of the full bracket-shape pattern. For
304    /// shortcut form `[text]`: `close_pos + 1`. For collapsed
305    /// `[text][]`: `close_pos + 3`. For full `[text][label]`: the byte
306    /// after the closing `]` of `[label]`.
307    pub end: usize,
308}
309
310/// Successful bracket resolution: the `[`...`]` pair is a link or image.
311#[derive(Debug, Clone)]
312pub struct BracketResolution {
313    /// IR event index of the matching `CloseBracket`.
314    pub close_event: u32,
315    /// Source range of the link text (between `[`/`![` and `]`).
316    pub text_start: usize,
317    pub text_end: usize,
318    /// Source range of the link suffix (`(...)`, `[label]`, `[]`, or
319    /// empty for shortcut). When `kind == ShortcutReference`,
320    /// `suffix_start == suffix_end == close_pos + 1`.
321    pub suffix_start: usize,
322    pub suffix_end: usize,
323    pub kind: LinkKind,
324}
325
326/// What kind of link/image we resolved a bracket pair to.
327#[derive(Debug, Clone)]
328pub enum LinkKind {
329    /// `[text](dest)` or `[text](dest "title")`.
330    Inline { dest: String, title: Option<String> },
331    /// `[text][label]` — explicit reference.
332    FullReference { label: String },
333    /// `[text][]` — collapsed reference. Label is the link text.
334    CollapsedReference,
335    /// `[text]` — shortcut reference. Label is the link text.
336    ShortcutReference,
337}
338
339// ============================================================================
340// Pass 1: Scan
341// ============================================================================
342
343/// Scan `text[start..end]` once, producing a flat IR of events.
344///
345/// The scan is forward-only and never backtracks: each iteration either
346/// consumes a known construct (escape, code span, autolink, raw HTML),
347/// records a delim run / bracket marker / line break, or steps past a
348/// single UTF-8 boundary as plain text. Adjacent text bytes are coalesced
349/// into a single [`IrEvent::Text`] event by the run-flush step.
350pub fn build_ir(text: &str, start: usize, end: usize, config: &ParserOptions) -> Vec<IrEvent> {
351    let mut events = Vec::new();
352    build_ir_into(text, start, end, config, &mut events);
353    events
354}
355
356/// Like [`build_ir`] but writes into a caller-provided `Vec<IrEvent>`,
357/// clearing it first. Used by [`build_full_plans`] to amortise the
358/// per-call allocation through a thread-local scratch pool.
359pub(super) fn build_ir_into(
360    text: &str,
361    start: usize,
362    end: usize,
363    config: &ParserOptions,
364    events: &mut Vec<IrEvent>,
365) {
366    events.clear();
367    let bytes = text.as_bytes();
368    let exts = &config.extensions;
369    let is_commonmark = config.dialect == crate::options::Dialect::CommonMark;
370
371    let mut pos = start;
372    let mut text_run_start = start;
373    // Pandoc-only: extent of the current bracket-shape link/image's
374    // opaque range. While `pos < pandoc_bracket_extent`, autolinks /
375    // raw HTML / native spans are NOT recognised — pandoc-native
376    // treats `[link text]` as opaque to those constructs (CommonMark
377    // spec example #526 / #538). The lookahead at `[`/`![` sets this
378    // when a bracket-shape forms a valid link/image; once `pos`
379    // passes the extent, normal scanning resumes. CommonMark
380    // dialect's link-text-vs-autolink ordering is handled by the
381    // dispatcher's `try_parse_inline_link` rejecting outer matches
382    // when the link text contains a valid autolink (a different
383    // mechanism, see `LinkScanContext.skip_autolinks`).
384    let mut pandoc_bracket_extent: usize = 0;
385
386    // Pre-computed byte mask: `mask[b]` is `true` iff byte `b` could
387    // start any IR-recognised construct under the current dialect /
388    // extensions. Used to bulk-skip plain bytes between structural
389    // bytes — the per-byte branch chain below only runs at positions
390    // where a construct is actually possible. Non-ASCII bytes
391    // (>= 0x80) are never structural and are skipped together with
392    // ASCII plain text.
393    let mask = build_ir_byte_mask(config);
394
395    macro_rules! flush_text {
396        () => {
397            if pos > text_run_start {
398                events.push(IrEvent::Text {
399                    start: text_run_start,
400                    end: pos,
401                });
402            }
403        };
404    }
405
406    while pos < end {
407        // Fast-skip plain bytes. `text_run_start` is preserved across
408        // the skip so the next structural-event flush picks them up.
409        while pos < end && !mask[bytes[pos] as usize] {
410            pos += 1;
411        }
412        if pos >= end {
413            break;
414        }
415        let b = bytes[pos];
416
417        // Pandoc-only: at `[` or `![`, look ahead to see if this
418        // bracket-shape forms a valid link/image. If so, suppress
419        // autolink / raw HTML / native span recognition until `pos`
420        // passes the bracket-shape's end. Skipped if we're already
421        // inside an enclosing bracket-shape's opaque range.
422        if !is_commonmark
423            && pos >= pandoc_bracket_extent
424            && (b == b'[' || (b == b'!' && pos + 1 < end && bytes[pos + 1] == b'['))
425            && let Some(len) = try_pandoc_bracket_link_extent(text, pos, end, config)
426        {
427            pandoc_bracket_extent = pos + len;
428        }
429        let in_pandoc_bracket = !is_commonmark && pos < pandoc_bracket_extent;
430
431        // Backslash escape (§2.4) — including `\\\n` hard line break.
432        if b == b'\\'
433            && let Some((len, _ch, escape_type)) = try_parse_escape(&text[pos..])
434            && pos + len <= end
435        {
436            let enabled = match escape_type {
437                EscapeType::Literal => is_commonmark || exts.all_symbols_escapable,
438                EscapeType::HardLineBreak => exts.escaped_line_breaks,
439                EscapeType::NonbreakingSpace => exts.all_symbols_escapable,
440            };
441            if enabled {
442                flush_text!();
443                let kind = match escape_type {
444                    EscapeType::HardLineBreak => {
445                        events.push(IrEvent::HardBreak {
446                            start: pos,
447                            end: pos + len,
448                        });
449                        pos += len;
450                        text_run_start = pos;
451                        continue;
452                    }
453                    EscapeType::Literal | EscapeType::NonbreakingSpace => ConstructKind::Escape,
454                };
455                events.push(IrEvent::Construct {
456                    start: pos,
457                    end: pos + len,
458                    kind,
459                });
460                pos += len;
461                text_run_start = pos;
462                continue;
463            }
464        }
465
466        // Code span (§6.1) — opaque to emphasis and brackets.
467        if b == b'`'
468            && let Some((len, _, _, _)) = try_parse_code_span(&text[pos..])
469            && pos + len <= end
470        {
471            flush_text!();
472            events.push(IrEvent::Construct {
473                start: pos,
474                end: pos + len,
475                kind: ConstructKind::CodeSpan,
476            });
477            pos += len;
478            text_run_start = pos;
479            continue;
480        }
481
482        // Pandoc-only: math spans are opaque to emphasis. The legacy
483        // `parse_until_closer_with_nested_*` skip-list includes inline
484        // math; without recognising it here, delim runs inside `$math$`
485        // would be picked up by the emphasis pass and break losslessness
486        // (the dispatcher's math parser would later re-claim the bytes,
487        // duplicating content).
488        if !is_commonmark && let Some(len) = try_pandoc_math_opaque(text, pos, end, config) {
489            flush_text!();
490            events.push(IrEvent::Construct {
491                start: pos,
492                end: pos + len,
493                kind: ConstructKind::PandocOpaque,
494            });
495            pos += len;
496            text_run_start = pos;
497            continue;
498        }
499
500        // Pandoc-only: native span `<span ...>...</span>`. Must come
501        // before the generic autolink/raw-html branches so the open tag
502        // doesn't get claimed as inline HTML. Span content is opaque to
503        // the emphasis pass; emission consumes the event via the IR's
504        // `ConstructPlan`. Suppressed inside Pandoc bracket-shape
505        // link/image text.
506        if !is_commonmark
507            && !in_pandoc_bracket
508            && b == b'<'
509            && exts.native_spans
510            && let Some((len, _, _)) = try_parse_native_span(&text[pos..])
511            && pos + len <= end
512        {
513            flush_text!();
514            events.push(IrEvent::Construct {
515                start: pos,
516                end: pos + len,
517                kind: ConstructKind::NativeSpan,
518            });
519            pos += len;
520            text_run_start = pos;
521            continue;
522        }
523
524        // Autolink (§6.5) before raw HTML — autolinks are the more
525        // specific shape inside `<...>`. Both are suppressed inside
526        // Pandoc bracket-shape link/image text (pandoc-native treats
527        // link text as opaque to autolinks and raw HTML).
528        if b == b'<' && !in_pandoc_bracket {
529            if exts.autolinks
530                && let Some((len, _)) = try_parse_autolink(&text[pos..], is_commonmark)
531                && pos + len <= end
532            {
533                flush_text!();
534                events.push(IrEvent::Construct {
535                    start: pos,
536                    end: pos + len,
537                    kind: ConstructKind::Autolink,
538                });
539                pos += len;
540                text_run_start = pos;
541                continue;
542            }
543            if exts.raw_html
544                && let Some(len) = try_parse_inline_html(&text[pos..], config.dialect)
545                && pos + len <= end
546            {
547                flush_text!();
548                events.push(IrEvent::Construct {
549                    start: pos,
550                    end: pos + len,
551                    kind: ConstructKind::InlineHtml,
552                });
553                pos += len;
554                text_run_start = pos;
555                continue;
556            }
557        }
558
559        // Pandoc-only: inline footnote `^[note]`. Recognized at scan
560        // time so the emphasis pass treats it as opaque (delim runs
561        // inside the footnote can't pair with delim runs outside).
562        if !is_commonmark
563            && b == b'^'
564            && exts.inline_footnotes
565            && let Some((len, _)) = try_parse_inline_footnote(&text[pos..])
566            && pos + len <= end
567        {
568            flush_text!();
569            events.push(IrEvent::Construct {
570                start: pos,
571                end: pos + len,
572                kind: ConstructKind::InlineFootnote,
573            });
574            pos += len;
575            text_run_start = pos;
576            continue;
577        }
578
579        // Pandoc-only: footnote reference `[^id]`. Recognised at scan
580        // time so the emphasis pass treats it as opaque (delim runs
581        // inside the label can't pair with delim runs outside) and the
582        // emission walk dispatches it directly via the IR's
583        // `ConstructPlan`. Must come before the generic bracket-opaque
584        // scan so the dedicated kind wins.
585        if !is_commonmark
586            && b == b'['
587            && pos + 1 < end
588            && bytes[pos + 1] == b'^'
589            && exts.footnotes
590            && let Some((len, _)) = try_parse_footnote_reference(&text[pos..])
591            && pos + len <= end
592        {
593            flush_text!();
594            events.push(IrEvent::Construct {
595                start: pos,
596                end: pos + len,
597                kind: ConstructKind::FootnoteReference,
598            });
599            pos += len;
600            text_run_start = pos;
601            continue;
602        }
603
604        // Pandoc-only: bracketed citation `[@cite]`. Recognised at
605        // scan time so the emphasis pass treats it as opaque (delim
606        // runs inside the citation can't pair with delim runs outside)
607        // and the emission walk dispatches it directly via the IR's
608        // `ConstructPlan`. Must come before the generic bracket-opaque
609        // scan so the dedicated kind wins.
610        if !is_commonmark
611            && b == b'['
612            && exts.citations
613            && let Some((len, _)) = try_parse_bracketed_citation(&text[pos..])
614            && pos + len <= end
615        {
616            flush_text!();
617            events.push(IrEvent::Construct {
618                start: pos,
619                end: pos + len,
620                kind: ConstructKind::BracketedCitation,
621            });
622            pos += len;
623            text_run_start = pos;
624            continue;
625        }
626
627        // Pandoc-only: bare citation `@key` or `-@key`. Recognised at
628        // scan time so the emission walk dispatches it directly via
629        // the IR's `ConstructPlan`. Bare citations don't contain
630        // emphasis-eligible content, so opacity is moot here — IR
631        // participation is only for dispatch consolidation.
632        if !is_commonmark
633            && (b == b'@' || (b == b'-' && pos + 1 < end && bytes[pos + 1] == b'@'))
634            && (exts.citations || exts.quarto_crossrefs)
635            && let Some((len, _, _)) = try_parse_bare_citation(&text[pos..])
636            && pos + len <= end
637        {
638            flush_text!();
639            events.push(IrEvent::Construct {
640                start: pos,
641                end: pos + len,
642                kind: ConstructKind::BareCitation,
643            });
644            pos += len;
645            text_run_start = pos;
646            continue;
647        }
648
649        // Pandoc-only: bracketed span `[content]{attrs}`. Recognised
650        // at scan time so the emphasis pass treats it as opaque (delim
651        // runs inside the span content can't pair with delim runs
652        // outside) and the emission walk dispatches it directly via
653        // the IR's `ConstructPlan`. Must come before the generic
654        // bracket-opaque scan so the dedicated kind wins.
655        // `try_parse_bracketed_span` requires `]` to be immediately
656        // followed by `{`, so this never shadows inline links
657        // (`[text](url)`) or reference links (`[label][refdef]`) —
658        // those don't have the `{attrs}` suffix.
659        if !is_commonmark
660            && b == b'['
661            && exts.bracketed_spans
662            && let Some((len, _, _)) = try_parse_bracketed_span(&text[pos..])
663            && pos + len <= end
664        {
665            flush_text!();
666            events.push(IrEvent::Construct {
667                start: pos,
668                end: pos + len,
669                kind: ConstructKind::BracketedSpan,
670            });
671            pos += len;
672            text_run_start = pos;
673            continue;
674        }
675
676        // Wikilinks `[[url]]`, `[[url|title]]`, `![[url]]`,
677        // `![[url|title]]`. Recognised on either pipe-order extension
678        // and on both dialects (pandoc accepts the extension under both
679        // `markdown+` and `commonmark+`). Must precede the `![` and `[`
680        // bracket scans below so the wikilink shape wins over an
681        // image-bracket or link-bracket open.
682        if (b == b'[' || b == b'!')
683            && super::wikilinks::any_enabled(config)
684            && let Some(span) = super::wikilinks::try_parse_wikilink(text, pos, config)
685            && span.end <= end
686        {
687            flush_text!();
688            events.push(IrEvent::Construct {
689                start: span.start,
690                end: span.end,
691                kind: ConstructKind::WikiLink,
692            });
693            pos = span.end;
694            text_run_start = pos;
695            continue;
696        }
697
698        // `![` opens an image bracket. Recognised whenever any
699        // image-producing extension is on — `inline_images` for the
700        // `![alt](url)` form, or `reference_links` for the
701        // `![alt][label]` reference-image form (e.g. MultiMarkdown
702        // disables `inline_images` but uses reference images).
703        if b == b'!'
704            && pos + 1 < end
705            && bytes[pos + 1] == b'['
706            && (exts.inline_images || exts.reference_links)
707        {
708            flush_text!();
709            events.push(IrEvent::OpenBracket {
710                start: pos,
711                end: pos + 2,
712                is_image: true,
713                active: true,
714                resolution: None,
715                unresolved_ref: None,
716            });
717            pos += 2;
718            text_run_start = pos;
719            continue;
720        }
721
722        // `[` opens a link bracket. Recognised whenever any
723        // link-producing extension is on — `inline_links` for
724        // `[text](url)`, or `reference_links` for `[text][label]` /
725        // `[text]` shortcut form.
726        if b == b'[' && (exts.inline_links || exts.reference_links) {
727            flush_text!();
728            events.push(IrEvent::OpenBracket {
729                start: pos,
730                end: pos + 1,
731                is_image: false,
732                active: true,
733                resolution: None,
734                unresolved_ref: None,
735            });
736            pos += 1;
737            text_run_start = pos;
738            continue;
739        }
740
741        // `]` closes a link/image bracket.
742        if b == b']' {
743            flush_text!();
744            events.push(IrEvent::CloseBracket {
745                pos,
746                matched: false,
747            });
748            pos += 1;
749            text_run_start = pos;
750            continue;
751        }
752
753        // `*` or `_` delimiter run.
754        if b == b'*' || b == b'_' {
755            flush_text!();
756            let mut run_end = pos;
757            while run_end < end && bytes[run_end] == b {
758                run_end += 1;
759            }
760            let count = run_end - pos;
761            let (can_open, can_close) = compute_flanking(text, pos, count, b, config.dialect);
762            events.push(IrEvent::DelimRun {
763                ch: b,
764                start: pos,
765                end: run_end,
766                can_open,
767                can_close,
768                matches: Vec::new(),
769            });
770            pos = run_end;
771            text_run_start = pos;
772            continue;
773        }
774
775        // Hard line break: 2+ trailing spaces before newline. We detect
776        // this when we're sitting on a `\n` (or `\r\n`) and the preceding
777        // bytes within the current text run are spaces.
778        if b == b'\n' || (b == b'\r' && pos + 1 < end && bytes[pos + 1] == b'\n') {
779            // Count trailing spaces in the text accumulated so far.
780            let nl_len = if b == b'\r' { 2 } else { 1 };
781            let mut trailing_spaces = 0;
782            let mut s = pos;
783            while s > text_run_start && bytes[s - 1] == b' ' {
784                trailing_spaces += 1;
785                s -= 1;
786            }
787            if trailing_spaces >= 2 {
788                // Flush text *before* the trailing spaces.
789                if s > text_run_start {
790                    events.push(IrEvent::Text {
791                        start: text_run_start,
792                        end: s,
793                    });
794                }
795                events.push(IrEvent::HardBreak {
796                    start: s,
797                    end: pos + nl_len,
798                });
799                pos += nl_len;
800                text_run_start = pos;
801                continue;
802            }
803
804            // Soft line break: flush preceding text, emit the line ending
805            // as its own event so the emitter can render `NEWLINE` tokens
806            // verbatim.
807            flush_text!();
808            events.push(IrEvent::SoftBreak {
809                start: pos,
810                end: pos + nl_len,
811            });
812            pos += nl_len;
813            text_run_start = pos;
814            continue;
815        }
816
817        // Plain byte — advance one UTF-8 char.
818        let ch_len = text[pos..]
819            .chars()
820            .next()
821            .map_or(1, std::primitive::char::len_utf8);
822        pos += ch_len.max(1);
823    }
824
825    flush_text!();
826}
827
828/// Build a 256-entry mask: `mask[b]` is `true` iff byte `b` could start
829/// any IR-recognised construct under the current dialect / extensions.
830///
831/// This is the build-IR-specific superset of "is this byte interesting".
832/// Plain bytes between structural bytes are bulk-skipped via this mask
833/// in the [`build_ir`] hot loop; missing a byte here is a correctness
834/// bug (we'd skip past a real construct), but having extras only costs
835/// us a wasted branch round-trip.
836fn build_ir_byte_mask(config: &ParserOptions) -> [bool; 256] {
837    let mut mask = [false; 256];
838    let exts = &config.extensions;
839    let is_commonmark = config.dialect == crate::options::Dialect::CommonMark;
840
841    // Always structural for IR scanning:
842    //   `\n` / `\r` — soft / hard breaks
843    //   `\\`        — escape, hard line break, backslash math
844    //   `` ` ``     — code span (IR construct)
845    //   `*` / `_`   — emphasis delim runs (IR core)
846    mask[b'\n' as usize] = true;
847    mask[b'\r' as usize] = true;
848    mask[b'\\' as usize] = true;
849    mask[b'`' as usize] = true;
850    mask[b'*' as usize] = true;
851    mask[b'_' as usize] = true;
852
853    // Brackets: scanned whenever any bracket-shaped construct is
854    // reachable. `]` is structural unconditionally if `[` is — the IR
855    // emits a CloseBracket event regardless of which opener variant
856    // matches. `!` is gated on image-producing extensions; the leading
857    // `!` of `![alt]` is the only image entry point.
858    if exts.inline_links
859        || exts.reference_links
860        || exts.inline_images
861        || exts.bracketed_spans
862        || exts.footnotes
863        || exts.citations
864    {
865        mask[b'[' as usize] = true;
866        mask[b']' as usize] = true;
867    }
868    if exts.inline_images || exts.reference_links {
869        mask[b'!' as usize] = true;
870    }
871
872    // `<` covers autolinks, raw HTML, and Pandoc native spans.
873    if exts.autolinks || exts.raw_html || (!is_commonmark && exts.native_spans) {
874        mask[b'<' as usize] = true;
875    }
876
877    // `^` covers Pandoc inline footnotes (`^[...]` recognised in IR
878    // under Pandoc dialect). CM dialect inline footnotes go through
879    // the dispatcher, not the IR.
880    if !is_commonmark && exts.inline_footnotes {
881        mask[b'^' as usize] = true;
882    }
883
884    // `@` covers Pandoc bare citation `@key` and `[@cite]`. The leading
885    // `[` of `[@cite]` is already in the mask via the bracket gate;
886    // gating `@` here also covers the bare-citation form.
887    if !is_commonmark && (exts.citations || exts.quarto_crossrefs) {
888        mask[b'@' as usize] = true;
889        // `-` only matters as the first byte of `-@cite`. Tracking it
890        // here avoids missing the suppress-author bare citation form.
891        mask[b'-' as usize] = true;
892    }
893
894    // `$` covers Pandoc dollar / GFM math. CM doesn't recognise math
895    // in `build_ir`.
896    if !is_commonmark
897        && (exts.tex_math_dollars
898            || exts.tex_math_gfm
899            || exts.tex_math_single_backslash
900            || exts.tex_math_double_backslash)
901    {
902        mask[b'$' as usize] = true;
903    }
904
905    mask
906}
907
908// ============================================================================
909// Flanking (CommonMark §6.2)
910// ============================================================================
911
912fn compute_flanking(
913    text: &str,
914    pos: usize,
915    count: usize,
916    ch: u8,
917    dialect: crate::options::Dialect,
918) -> (bool, bool) {
919    if dialect == crate::options::Dialect::Pandoc {
920        // Pandoc-markdown's recursive-descent emphasis parser does NOT
921        // apply CommonMark §6.2 flanking rules. Instead it gates on:
922        //   - opener: must not be followed by whitespace (Pandoc
923        //     `try_parse_emphasis` line 247 in legacy core.rs).
924        //   - closer: no flanking gate at all (Pandoc-markdown's
925        //     `ender` parser only counts characters; see Markdown.hs
926        //     in pandoc/src/Text/Pandoc/Readers/Markdown.hs).
927        //   - underscore intraword hard rule: `_` adjacent to an
928        //     alphanumeric on either side cannot open / close
929        //     (Pandoc's `intraword_underscores` extension default).
930        let prev_char = (pos > 0).then(|| text[..pos].chars().last()).flatten();
931        let next_char = text.get(pos + count..).and_then(|s| s.chars().next());
932        let followed_by_ws = next_char.is_none_or(|c| c.is_whitespace());
933
934        let mut can_open = !followed_by_ws;
935        // Pandoc-markdown's `ender` (in pandoc/Readers/Markdown.hs)
936        // has no flanking restriction on closers — just a count match.
937        // Set can_close unconditionally; the per-pair match logic in
938        // `process_emphasis_in_range_filtered` constrains pairing via
939        // the equal-count rule.
940        let mut can_close = true;
941
942        if ch == b'_' {
943            let prev_is_alnum = prev_char.is_some_and(|c| c.is_alphanumeric());
944            let next_is_alnum = next_char.is_some_and(|c| c.is_alphanumeric());
945            if prev_is_alnum {
946                can_open = false;
947            }
948            if next_is_alnum {
949                can_close = false;
950            }
951        }
952
953        return (can_open, can_close);
954    }
955
956    // CommonMark §6.2 flanking.
957    let lf = is_left_flanking(text, pos, count);
958    let rf = is_right_flanking(text, pos, count);
959    if ch == b'*' {
960        (lf, rf)
961    } else {
962        let prev_char = (pos > 0).then(|| text[..pos].chars().last()).flatten();
963        let next_char = text.get(pos + count..).and_then(|s| s.chars().next());
964        let preceded_by_punct = prev_char.is_some_and(is_unicode_punct_or_symbol);
965        let followed_by_punct = next_char.is_some_and(is_unicode_punct_or_symbol);
966        let can_open = lf && (!rf || preceded_by_punct);
967        let can_close = rf && (!lf || followed_by_punct);
968        (can_open, can_close)
969    }
970}
971
972/// Pandoc-only: identify a math span starting at `pos` and return its
973/// byte length. Tries `$math$` and `$$display$$` (gated on
974/// `tex_math_dollars`), GFM `$math$` (gated on `tex_math_gfm`), and the
975/// `\(math\)` / `\[math\]` / `\\(math\\)` / `\\[math\\]` backslash
976/// forms (gated on `tex_math_single_backslash` / `_double_backslash`).
977/// Math content is opaque to emphasis: `$a * b$` must not produce an
978/// emphasis closer at the inner `*`.
979fn try_pandoc_math_opaque(
980    text: &str,
981    pos: usize,
982    end: usize,
983    config: &ParserOptions,
984) -> Option<usize> {
985    let bytes = text.as_bytes();
986    let exts = &config.extensions;
987    let b = bytes[pos];
988    // Inline math folds a single newline only under the Pandoc dialect.
989    let multiline = config.dialect == crate::options::Dialect::Pandoc;
990
991    if exts.tex_math_dollars && b == b'$' {
992        if let Some((len, _)) = try_parse_display_math(&text[pos..])
993            && pos + len <= end
994        {
995            return Some(len);
996        }
997        if let Some((len, _)) = try_parse_inline_math(&text[pos..], multiline)
998            && pos + len <= end
999        {
1000            return Some(len);
1001        }
1002    }
1003    if exts.tex_math_gfm
1004        && b == b'$'
1005        && let Some((len, _)) = try_parse_gfm_inline_math(&text[pos..], multiline)
1006        && pos + len <= end
1007    {
1008        return Some(len);
1009    }
1010    if exts.tex_math_double_backslash && b == b'\\' {
1011        if let Some((len, _)) = try_parse_double_backslash_display_math(&text[pos..])
1012            && pos + len <= end
1013        {
1014            return Some(len);
1015        }
1016        if let Some((len, _)) = try_parse_double_backslash_inline_math(&text[pos..], multiline)
1017            && pos + len <= end
1018        {
1019            return Some(len);
1020        }
1021    }
1022    if exts.tex_math_single_backslash && b == b'\\' {
1023        if let Some((len, _)) = try_parse_single_backslash_display_math(&text[pos..])
1024            && pos + len <= end
1025        {
1026            return Some(len);
1027        }
1028        if let Some((len, _)) = try_parse_single_backslash_inline_math(&text[pos..], multiline)
1029            && pos + len <= end
1030        {
1031            return Some(len);
1032        }
1033    }
1034    None
1035}
1036
1037/// Pandoc-only: identify a bracket-shaped opaque construct starting at
1038/// `pos` and return its byte length. Tries the dispatcher's precedence
1039/// order:
1040///   1. `![alt](dest)` inline image
1041///   2. `![alt][ref]` / `![alt]` reference image (shape-only opacity)
1042///   3. `[^id]` footnote reference
1043///   4. `[text](dest)` inline link
1044///   5. `[text][ref]` / `[text]` reference link (shape-only opacity)
1045///   6. `[@cite]` bracketed citation
1046///   7. `[text]{attrs}` bracketed span
1047///
1048/// Returns `None` if the bytes at `pos` don't open any recognised Pandoc
1049/// bracket-shaped construct. In that case the scanner falls through to
1050/// the generic `OpenBracket`/`CloseBracket` emission and the dispatcher
1051/// emits the bracket bytes as literal text (or as plain emphasis if the
1052/// pattern matches an opener).
1053/// Lookahead helper: at a `[` or `![` byte under Pandoc dialect, return
1054/// the total byte length of the bracket-shape link/image if it forms a
1055/// valid one, else `None`. Used by `build_ir` to suppress autolink /
1056/// raw HTML / native span recognition inside Pandoc link text —
1057/// pandoc-native treats link text as opaque to those constructs
1058/// (CommonMark spec example #526 / #538 differs). Mirrors the
1059/// dispatcher's `try_parse_*` precedence so the lookahead, the IR's
1060/// `process_brackets` resolution, and the dispatcher's emission agree
1061/// on the bracket-shape's byte boundaries.
1062fn try_pandoc_bracket_link_extent(
1063    text: &str,
1064    pos: usize,
1065    end: usize,
1066    config: &ParserOptions,
1067) -> Option<usize> {
1068    let bytes = text.as_bytes();
1069    let exts = &config.extensions;
1070    let ctx = LinkScanContext::from_options(config);
1071    let allow_shortcut = exts.shortcut_reference_links;
1072
1073    // `![...]` images.
1074    if bytes[pos] == b'!' {
1075        if pos + 1 >= end || bytes[pos + 1] != b'[' {
1076            return None;
1077        }
1078        if exts.inline_images
1079            && let Some((len, _, _, _)) = try_parse_inline_image(&text[pos..], ctx)
1080            && pos + len <= end
1081        {
1082            return Some(len);
1083        }
1084        if exts.reference_links
1085            && let Some((len, _, _, _, _)) =
1086                try_parse_reference_image(&text[pos..], allow_shortcut, exts.spaced_reference_links)
1087            && pos + len <= end
1088        {
1089            return Some(len);
1090        }
1091        return None;
1092    }
1093
1094    // `[...]` openers — try in dispatcher order. Footnote refs
1095    // (`[^id]`), bracketed citations (`[@cite]`), and bracketed spans
1096    // (`[text]{attrs}`) are recognised by their own dedicated branches
1097    // in `build_ir` and don't need this lookahead.
1098    if exts.inline_links
1099        && let Some((len, _, _, _)) = try_parse_inline_link(&text[pos..], false, ctx)
1100        && pos + len <= end
1101    {
1102        return Some(len);
1103    }
1104    if exts.reference_links
1105        && let Some((len, _, _, _, _)) = try_parse_reference_link(
1106            &text[pos..],
1107            allow_shortcut,
1108            exts.inline_links,
1109            exts.spaced_reference_links,
1110            ctx,
1111        )
1112        && pos + len <= end
1113    {
1114        return Some(len);
1115    }
1116
1117    None
1118}
1119
1120fn is_unicode_punct_or_symbol(c: char) -> bool {
1121    if c.is_ascii() {
1122        c.is_ascii_punctuation()
1123    } else {
1124        !c.is_alphanumeric() && !c.is_whitespace()
1125    }
1126}
1127
1128fn is_left_flanking(text: &str, run_start: usize, run_len: usize) -> bool {
1129    let after = run_start + run_len;
1130    let next_char = text.get(after..).and_then(|s| s.chars().next());
1131    let prev_char = (run_start > 0)
1132        .then(|| text[..run_start].chars().last())
1133        .flatten();
1134
1135    let followed_by_ws = next_char.is_none_or(|c| c.is_whitespace());
1136    if followed_by_ws {
1137        return false;
1138    }
1139    let followed_by_punct = next_char.is_some_and(is_unicode_punct_or_symbol);
1140    if !followed_by_punct {
1141        return true;
1142    }
1143    prev_char.is_none_or(|c| c.is_whitespace() || is_unicode_punct_or_symbol(c))
1144}
1145
1146fn is_right_flanking(text: &str, run_start: usize, run_len: usize) -> bool {
1147    let after = run_start + run_len;
1148    let next_char = text.get(after..).and_then(|s| s.chars().next());
1149    let prev_char = (run_start > 0)
1150        .then(|| text[..run_start].chars().last())
1151        .flatten();
1152
1153    let preceded_by_ws = prev_char.is_none_or(|c| c.is_whitespace());
1154    if preceded_by_ws {
1155        return false;
1156    }
1157    let preceded_by_punct = prev_char.is_some_and(is_unicode_punct_or_symbol);
1158    if !preceded_by_punct {
1159        return true;
1160    }
1161    next_char.is_none_or(|c| c.is_whitespace() || is_unicode_punct_or_symbol(c))
1162}
1163
1164// ============================================================================
1165// Pass 2: Process emphasis (CommonMark §6.2)
1166// ============================================================================
1167
1168/// Run the CommonMark §6.3 `process_emphasis` algorithm over the IR's
1169/// delim runs. Mutates the IR in place: matched runs gain entries in their
1170/// `matches` vec, unmatched bytes stay implicit (the emission pass treats
1171/// any byte not covered by a match as literal text).
1172///
1173/// The algorithm tracks a per-bucket `openers_bottom` exclusive lower
1174/// bound to keep walk-back bounded; consume rules and the §6.2 mod-3
1175/// rejection match the reference implementation.
1176pub fn process_emphasis(events: &mut [IrEvent], dialect: crate::options::Dialect) {
1177    process_emphasis_in_range(events, 0, events.len(), dialect);
1178}
1179
1180/// Range-scoped variant of [`process_emphasis`].
1181///
1182/// Only delim runs whose IR event index lies in `[lo, hi)` are considered.
1183/// Used by [`build_full_plans`] to run emphasis pairing inside each
1184/// resolved bracket pair *before* the global top-level pass, so emphasis
1185/// can never form across a link's bracket boundary (CommonMark §6.3
1186/// requires bracket resolution to happen first when at a `]`, with
1187/// emphasis processed on the link's inner range).
1188///
1189/// The function additionally skips delim runs that already carry a
1190/// recorded match in their `matches` vec — this lets the second
1191/// (top-level) pass reuse the same algorithm without re-pairing bytes
1192/// already consumed by inner-range passes.
1193pub fn process_emphasis_in_range(
1194    events: &mut [IrEvent],
1195    lo: usize,
1196    hi: usize,
1197    dialect: crate::options::Dialect,
1198) {
1199    process_emphasis_in_range_filtered(events, lo, hi, None, dialect);
1200}
1201
1202/// Internal variant of [`process_emphasis_in_range`] with an optional
1203/// exclusion bitmap. Event indices for which `excluded[i] == true` are
1204/// treated as if their delim run were already fully consumed — used by
1205/// [`build_full_plans`] to keep the top-level emphasis pass from pairing
1206/// across a resolved bracket pair's boundary (the inner delim runs of
1207/// such a pair belong to the link's inner range and were already paired
1208/// by the scoped pass).
1209fn process_emphasis_in_range_filtered(
1210    events: &mut [IrEvent],
1211    lo: usize,
1212    hi: usize,
1213    excluded: Option<&[bool]>,
1214    dialect: crate::options::Dialect,
1215) {
1216    let is_commonmark = dialect == crate::options::Dialect::CommonMark;
1217    if is_commonmark {
1218        run_emphasis_pass(events, lo, hi, excluded, dialect, &[], false);
1219        return;
1220    }
1221    // Pandoc dialect: cascade-then-rerun. Run the standard pass, then
1222    // invalidate Emph/Strong pairs whose inner range contains an
1223    // unmatched same-char run with both can_open && can_close (Pandoc's
1224    // recursive descent would have failed those outer pairs because the
1225    // inner content has a stray, ambiguous delimiter the recursive
1226    // parser cannot pair). The invalidated pairs go into a "rejected
1227    // list" that the next iteration of the standard pass consults to
1228    // pick a different opener for the same closer (or reject the
1229    // closer altogether). Iterate to a fixed point.
1230    //
1231    // The rerun (iter 2+) runs in `strict` mode: a candidate pair is
1232    // rejected if its inner range contains an unmatched same-char run
1233    // with count > pair.count. This mirrors pandoc-markdown's
1234    // recursive-descent semantics where, e.g. inside a failed outer
1235    // `**...**` Strong, the inner `one c` parser's `option2`
1236    // (`string [c,c] >> two c mempty`) greedily consumes a stray `**`
1237    // and prevents subsequent `*` runs from pairing as Emph. Without
1238    // this gate, `**foo *bar** baz*` would produce Emph[bar** baz]
1239    // after the outer Strong invalidation, but pandoc treats it as
1240    // all-literal because the inner `**` blocks the Emph match.
1241    let mut rejected: Vec<(usize, usize)> = Vec::new();
1242    let max_iters = events.len().saturating_add(2);
1243    let mut iter = 0;
1244    loop {
1245        let strict = iter > 0;
1246        run_emphasis_pass(events, lo, hi, excluded, dialect, &rejected, strict);
1247        let invalidations = pandoc_cascade_invalidate(events, excluded);
1248        if invalidations.is_empty() {
1249            break;
1250        }
1251        rejected.extend(invalidations);
1252        iter += 1;
1253        if iter >= max_iters {
1254            break;
1255        }
1256    }
1257    // Recovery for `***A **B** C***` patterns: synthesise the inner
1258    // Strong match the standard delim-stack algorithm can't reach.
1259    pandoc_inner_strong_recovery(events);
1260}
1261
1262/// One pass of the CommonMark §6.2 emphasis pairing algorithm over the
1263/// IR's [`DelimRun`](IrEvent::DelimRun) events in `[lo, hi)`. Pandoc
1264/// dialect gates apply when `dialect == Dialect::Pandoc`. The
1265/// `rejected_pairs` list (Pandoc only) excludes specific
1266/// (opener_event_idx, closer_event_idx) pairs from matching — used by
1267/// the cascade-then-rerun loop to prevent invalidated pairs from
1268/// re-forming on the next iteration.
1269fn run_emphasis_pass(
1270    events: &mut [IrEvent],
1271    lo: usize,
1272    hi: usize,
1273    excluded: Option<&[bool]>,
1274    dialect: crate::options::Dialect,
1275    rejected_pairs: &[(usize, usize)],
1276    strict_pandoc: bool,
1277) {
1278    let is_commonmark = dialect == crate::options::Dialect::CommonMark;
1279    let hi = hi.min(events.len());
1280    if lo >= hi {
1281        return;
1282    }
1283    // Indices of DelimRun events within [lo, hi), in order, that have
1284    // not already been fully consumed by an earlier scoped pass and that
1285    // are not in the optional exclusion bitmap.
1286    let mut delim_idxs: Vec<usize> = events[lo..hi]
1287        .iter()
1288        .enumerate()
1289        .filter_map(|(i, e)| {
1290            let abs = lo + i;
1291            match e {
1292                IrEvent::DelimRun { matches, .. }
1293                    if matches.is_empty()
1294                        && excluded.is_none_or(|ex| ex.get(abs).copied() != Some(true)) =>
1295                {
1296                    Some(abs)
1297                }
1298                _ => None,
1299            }
1300        })
1301        .collect();
1302    if delim_idxs.is_empty() {
1303        return;
1304    }
1305
1306    // Working state: count (remaining unmatched chars) and source_start
1307    // (first remaining char) per delim run. Indexed by position in
1308    // `delim_idxs`.
1309    let mut count: Vec<usize> = Vec::with_capacity(delim_idxs.len());
1310    let mut source_start: Vec<usize> = Vec::with_capacity(delim_idxs.len());
1311    let mut removed: Vec<bool> = vec![false; delim_idxs.len()];
1312
1313    for &ev_idx in &delim_idxs {
1314        if let IrEvent::DelimRun { start, end, .. } = &events[ev_idx] {
1315            count.push(end - start);
1316            source_start.push(*start);
1317        }
1318    }
1319
1320    // openers_bottom[ch_idx][len%3][can_open] → exclusive lower bound
1321    // (an index into `delim_idxs`, or None meaning "no bottom yet").
1322    let mut openers_bottom: [[[Option<usize>; 2]; 3]; 2] = [[[None; 2]; 3]; 2];
1323
1324    // First active index, scanning forward.
1325    let first_active =
1326        |removed: &[bool]| -> Option<usize> { (0..removed.len()).find(|&i| !removed[i]) };
1327    let next_active = |removed: &[bool], from: usize| -> Option<usize> {
1328        (from + 1..removed.len()).find(|&i| !removed[i])
1329    };
1330    let prev_active =
1331        |removed: &[bool], from: usize| -> Option<usize> { (0..from).rev().find(|&i| !removed[i]) };
1332
1333    let min_closer_count = 1usize;
1334    let mut closer_local = first_active(&removed);
1335    while let Some(c) = closer_local {
1336        let ev_c_idx = delim_idxs[c];
1337        let (ch_c, can_open_c, can_close_c) = match &events[ev_c_idx] {
1338            IrEvent::DelimRun {
1339                ch,
1340                can_open,
1341                can_close,
1342                ..
1343            } => (*ch, *can_open, *can_close),
1344            _ => unreachable!(),
1345        };
1346        if !can_close_c || removed[c] || count[c] < min_closer_count {
1347            closer_local = next_active(&removed, c);
1348            continue;
1349        }
1350
1351        let ch_idx = if ch_c == b'*' { 0 } else { 1 };
1352        let closer_mod = count[c] % 3;
1353        let closer_open_bucket = can_open_c as usize;
1354        let bottom = openers_bottom[ch_idx][closer_mod][closer_open_bucket];
1355
1356        // Walk back to find a compatible opener.
1357        let mut found_opener: Option<usize> = None;
1358        let mut walk = prev_active(&removed, c);
1359        while let Some(o) = walk {
1360            if Some(o) == bottom {
1361                break;
1362            }
1363            let ev_o_idx = delim_idxs[o];
1364            let (ch_o, can_open_o, can_close_o) = match &events[ev_o_idx] {
1365                IrEvent::DelimRun {
1366                    ch,
1367                    can_open,
1368                    can_close,
1369                    ..
1370                } => (*ch, *can_open, *can_close),
1371                _ => unreachable!(),
1372            };
1373            if !removed[o] && ch_o == ch_c && can_open_o {
1374                let oc_sum = count[o] + count[c];
1375                let opener_both = can_open_o && can_close_o;
1376                let closer_both = can_open_c && can_close_c;
1377                let mod3_reject = is_commonmark
1378                    && (opener_both || closer_both)
1379                    && oc_sum.is_multiple_of(3)
1380                    && !(count[o].is_multiple_of(3) && count[c].is_multiple_of(3));
1381                // Pandoc-markdown rejects emph/strong pairs whose counts
1382                // disagree in the exactly-(1,2) / (2,1) shape:
1383                //   - `**foo*` (2,1): `try_parse_two` looks only for a
1384                //     `**` closer; the lone `*` doesn't satisfy that.
1385                //   - `*foo**` (1,2): `try_parse_one` encountering `**`
1386                //     tries `try_parse_two`; absence of an inner `**`
1387                //     closer cascades the outer parse to fail.
1388                // Other count combinations DO match (verified against
1389                // `pandoc -f markdown`):
1390                //   - (1,3) / (3,1) → emph match, opposite-side
1391                //     leftover `**` literal.
1392                //   - (2,3) / (3,2) → strong match, single `*` literal.
1393                //   - (3,3) → STRONG(EM(...)) nested.
1394                //   - (1..3, 4+) → match (Pandoc's ender walks the
1395                //     closer run for a valid position; algorithm
1396                //     consumes leftmost via leftover-as-literal).
1397                // Opener count >= 4 is rejected (Pandoc's
1398                // `try_parse_emphasis` has no count-4+ dispatch).
1399                let pandoc_reject = !is_commonmark
1400                    && ((count[o] == 1 && count[c] == 2)
1401                        || (count[o] == 2 && count[c] == 1)
1402                        || count[o] >= 4);
1403                let pair_rejected = !is_commonmark && {
1404                    let oe = delim_idxs[o];
1405                    let ce = delim_idxs[c];
1406                    rejected_pairs.iter().any(|&(ro, rc)| ro == oe && rc == ce)
1407                };
1408                // Pandoc strict-rerun gate (iter 2+ only): block a
1409                // candidate pair if any unmatched same-char run between
1410                // its opener and closer has remaining count strictly
1411                // greater than the consume rule for this pair.
1412                // Mirrors pandoc-markdown's recursive descent where
1413                // `one c`'s `option2` (`string [c,c] >> two c`) would
1414                // greedily consume a stray higher-count run, blocking
1415                // the outer `one c` from finding its `ender c 1` —
1416                // e.g. `**foo *bar** baz*` after the outer Strong
1417                // invalidates: a naïve rerun pairs ev1 (`*`) ↔ ev3
1418                // (`*`) as Emph (consume=1), but pandoc treats the
1419                // `**` between as having "consumed" any further
1420                // matching, leaving everything literal.
1421                let strict_block = strict_pandoc && {
1422                    let tentative_consume = if !is_commonmark && count[o] >= 3 && count[c] >= 3 {
1423                        1
1424                    } else if count[o] >= 2 && count[c] >= 2 {
1425                        2
1426                    } else {
1427                        1
1428                    };
1429                    let lo_evt = delim_idxs[o] + 1;
1430                    let hi_evt = delim_idxs[c];
1431                    (lo_evt..hi_evt).any(|k| match &events[k] {
1432                        IrEvent::DelimRun {
1433                            ch: ch_k,
1434                            start,
1435                            end,
1436                            matches,
1437                            ..
1438                        } => {
1439                            *ch_k == ch_c && {
1440                                let total = end - start;
1441                                let consumed: usize = matches.iter().map(|m| m.len as usize).sum();
1442                                total.saturating_sub(consumed) > tentative_consume
1443                            }
1444                        }
1445                        _ => false,
1446                    })
1447                };
1448                if !mod3_reject && !pandoc_reject && !pair_rejected && !strict_block {
1449                    found_opener = Some(o);
1450                    break;
1451                }
1452            }
1453            if o == 0 {
1454                break;
1455            }
1456            walk = prev_active(&removed, o);
1457        }
1458
1459        if let Some(o) = found_opener {
1460            // Consume rule:
1461            //   CommonMark — consume 2 (Strong) when both sides have
1462            //     >= 2 chars, else 1 (Emph). For `***x***` (3,3) this
1463            //     produces EM(STRONG(...)) because the first match
1464            //     consumes 2 from each side (Strong outermost).
1465            //   Pandoc — when both sides have >= 3, consume 1 first
1466            //     (Emph innermost) leaving 2 + 2 to pair as Strong on
1467            //     the second pass. This produces STRONG(EM(...)) for
1468            //     `***x***`, matching Pandoc-markdown's recursive
1469            //     `try_parse_three` algorithm.
1470            let consume = if !is_commonmark && count[o] >= 3 && count[c] >= 3 {
1471                1
1472            } else if count[o] >= 2 && count[c] >= 2 {
1473                2
1474            } else {
1475                1
1476            };
1477            let kind = if consume == 2 {
1478                EmphasisKind::Strong
1479            } else {
1480                EmphasisKind::Emph
1481            };
1482
1483            // Opener consumes inner-edge (rightmost) chars.
1484            let opener_match_offset =
1485                source_start[o] + count[o] - consume - source_start_event(&events[delim_idxs[o]]);
1486            // Closer consumes inner-edge (leftmost) chars.
1487            let closer_match_offset = source_start[c] - source_start_event(&events[delim_idxs[c]]);
1488
1489            // Record match on opener.
1490            if let IrEvent::DelimRun { matches, .. } = &mut events[delim_idxs[o]] {
1491                matches.push(DelimMatch {
1492                    offset_in_run: opener_match_offset as u8,
1493                    len: consume as u8,
1494                    is_opener: true,
1495                    partner_event: delim_idxs[c] as u32,
1496                    partner_offset: closer_match_offset as u8,
1497                    kind,
1498                });
1499            }
1500            // Record match on closer.
1501            if let IrEvent::DelimRun { matches, .. } = &mut events[delim_idxs[c]] {
1502                matches.push(DelimMatch {
1503                    offset_in_run: closer_match_offset as u8,
1504                    len: consume as u8,
1505                    is_opener: false,
1506                    partner_event: delim_idxs[o] as u32,
1507                    partner_offset: opener_match_offset as u8,
1508                    kind,
1509                });
1510            }
1511
1512            count[o] -= consume;
1513            source_start[c] += consume;
1514            count[c] -= consume;
1515
1516            // Remove all openers strictly between o and c.
1517            let mut between = next_active(&removed, o);
1518            while let Some(idx) = between {
1519                if idx == c {
1520                    break;
1521                }
1522                removed[idx] = true;
1523                between = next_active(&removed, idx);
1524            }
1525
1526            if count[o] == 0 {
1527                removed[o] = true;
1528            }
1529            if count[c] == 0 {
1530                removed[c] = true;
1531                closer_local = next_active(&removed, c);
1532            }
1533            // Else re-process the same closer with reduced count.
1534        } else {
1535            openers_bottom[ch_idx][closer_mod][closer_open_bucket] = prev_active(&removed, c);
1536            if !can_open_c {
1537                removed[c] = true;
1538            }
1539            closer_local = next_active(&removed, c);
1540        }
1541    }
1542
1543    // No further mutation needed: matches are recorded; remaining bytes
1544    // stay implicit literal. Pandoc cascade is invoked by the caller
1545    // (`process_emphasis_in_range_filtered`) once per pass so it can
1546    // accumulate invalidations into a rejected-pairs list and re-run.
1547    let _ = (&mut delim_idxs, &mut openers_bottom, min_closer_count);
1548}
1549
1550/// Pandoc-only post-processing pass over [`process_emphasis_in_range_filtered`]
1551/// matches: invalidate any matched delim pair that contains an unmatched
1552/// same-character run between its opener and closer. Returns the list
1553/// of (opener_event_idx, closer_event_idx) pairs that were invalidated
1554/// in this call, so the caller can seed a rejected-pairs list and
1555/// re-run the standard pass — this lets Pandoc re-pair the inner runs
1556/// that the invalidated outer match would have stolen via
1557/// between-removal (e.g. `*foo **bar* baz**` → after the outer
1558/// `ev0..ev2` Emph is invalidated, `ev1..ev3` matches as Strong on the
1559/// next iteration).
1560fn pandoc_cascade_invalidate(
1561    events: &mut [IrEvent],
1562    excluded: Option<&[bool]>,
1563) -> Vec<(usize, usize)> {
1564    let mut invalidated_pairs: Vec<(usize, usize)> = Vec::new();
1565    // Early-exit: if there are no `DelimRun` events at all, the cascade
1566    // pass is a no-op. Avoids allocating the two scratch vecs below for
1567    // every range with no `*`/`_` runs (which is the common case for
1568    // ranges that contain only standalone constructs / brackets).
1569    if !events.iter().any(|e| matches!(e, IrEvent::DelimRun { .. })) {
1570        return invalidated_pairs;
1571    }
1572    let is_excluded = |k: usize| excluded.is_some_and(|ex| ex.get(k).copied() == Some(true));
1573    // Reuse two scratch vecs across the inner loop iterations instead
1574    // of `.collect()` each time. These are tiny per-paragraph
1575    // allocations but the function is called for every Pandoc inline
1576    // emphasis pass and shows up in malloc traffic.
1577    let mut total: Vec<usize> = Vec::with_capacity(events.len());
1578    let mut consumed: Vec<usize> = Vec::with_capacity(events.len());
1579    loop {
1580        total.clear();
1581        consumed.clear();
1582        // Compute total bytes (run length) and consumed bytes (sum of
1583        // match lens) per DelimRun event index.
1584        total.extend(events.iter().map(|e| match e {
1585            IrEvent::DelimRun { start, end, .. } => end - start,
1586            _ => 0,
1587        }));
1588        consumed.extend(events.iter().map(|e| match e {
1589            IrEvent::DelimRun { matches, .. } => matches.iter().map(|m| m.len as usize).sum(),
1590            _ => 0,
1591        }));
1592
1593        // Find a pair to invalidate. We invalidate one and restart so
1594        // the cascade can re-evaluate dependent pairs.
1595        let mut to_invalidate: Option<(usize, u8)> = None;
1596        'outer: for opener_idx in 0..events.len() {
1597            let IrEvent::DelimRun {
1598                ch: ch_o, matches, ..
1599            } = &events[opener_idx]
1600            else {
1601                continue;
1602            };
1603            for (mi, m) in matches.iter().enumerate() {
1604                if !m.is_opener {
1605                    continue;
1606                }
1607                let closer_idx = m.partner_event as usize;
1608                if closer_idx <= opener_idx || closer_idx >= events.len() {
1609                    continue;
1610                }
1611                // Scan events strictly between opener and closer for any
1612                // DelimRun with the same `ch`, unmatched bytes, AND
1613                // both `can_open` and `can_close` (i.e., the run could
1614                // have participated in pairing on both sides). A
1615                // can_open-only or can_close-only run is a one-sided
1616                // fragment (e.g. an isolated `*` after a backslash
1617                // escape) that the Pandoc recursive-descent path would
1618                // never have tried as a nested-strong opener — those
1619                // shouldn't cascade-invalidate the surrounding pair.
1620                for k in (opener_idx + 1)..closer_idx {
1621                    if is_excluded(k) {
1622                        continue;
1623                    }
1624                    if let IrEvent::DelimRun {
1625                        ch: ch_k,
1626                        can_open: co_k,
1627                        can_close: cc_k,
1628                        ..
1629                    } = &events[k]
1630                        && *ch_k == *ch_o
1631                        && consumed[k] < total[k]
1632                        && *co_k
1633                        && *cc_k
1634                    {
1635                        to_invalidate = Some((opener_idx, mi as u8));
1636                        break 'outer;
1637                    }
1638                }
1639            }
1640        }
1641
1642        let Some((opener_idx, mi)) = to_invalidate else {
1643            break;
1644        };
1645
1646        // Look up the partner event/offset before mutating.
1647        let (closer_idx, opener_offset) = match &events[opener_idx] {
1648            IrEvent::DelimRun { matches, .. } => {
1649                let m = matches[mi as usize];
1650                (m.partner_event as usize, m.offset_in_run)
1651            }
1652            _ => break,
1653        };
1654
1655        // Remove the opener match.
1656        if let IrEvent::DelimRun { matches, .. } = &mut events[opener_idx] {
1657            matches.remove(mi as usize);
1658        }
1659        // Remove the corresponding closer match (closer's match has
1660        // is_opener=false and partner_offset == opener's offset_in_run).
1661        if let IrEvent::DelimRun { matches, .. } = &mut events[closer_idx] {
1662            matches.retain(|m| m.is_opener || m.partner_offset != opener_offset);
1663        }
1664        invalidated_pairs.push((opener_idx, closer_idx));
1665    }
1666    invalidated_pairs
1667}
1668
1669/// Pandoc-only post-pass: recover the inner Strong match in
1670/// `***A **B** C***` patterns where the IR's standard pass produced
1671/// `Emph[Strong[A], "B**...** C"]` (matching the outer triple as
1672/// Strong+Emph but losing the inner `**...**`-as-Strong-of-`C` pair).
1673///
1674/// Pandoc's recursive descent here goes
1675/// `three c → ender c 2 → one c → option2 → two c`, producing
1676/// `Emph[Strong[A], "B", Strong[C]]` — two Strong nodes inside an outer
1677/// Emph. The standard delim-stack algorithm can't reach this pairing
1678/// because between-removal during the outer Emph match removes the
1679/// inner closer-side `**` (e.g. `bar**`) from the candidate pool.
1680///
1681/// This recovery scans Emph matches whose opener and closer originally
1682/// had count >= 3, and whose closer has unmatched bytes >= 2 after the
1683/// standard pass; for each, we look for an unmatched same-char
1684/// between-run with count >= 2 and `can_close = true` (the would-be
1685/// inner-Strong opener) and synthesise a Strong match that consumes
1686/// the leftmost 2 bytes of the closer (where the existing Emph match
1687/// shifts to the rightmost 1 byte). The byte-position rewrite lets
1688/// the CST emission produce well-nested `Emph[..., Strong[...]]` —
1689/// outer Emph close at the rightmost outer-triple byte, inner Strong
1690/// close at the leftmost two.
1691fn pandoc_inner_strong_recovery(events: &mut [IrEvent]) {
1692    let n = events.len();
1693    // (between_idx, opener_idx, closer_idx, len)
1694    let mut to_apply: Vec<(usize, usize, usize, u8)> = Vec::new();
1695
1696    for opener_idx in 0..n {
1697        let (open_total, open_matches_clone, ch_o) = match &events[opener_idx] {
1698            IrEvent::DelimRun {
1699                start,
1700                end,
1701                matches,
1702                ch,
1703                ..
1704            } => (*end - *start, matches.clone(), *ch),
1705            _ => continue,
1706        };
1707        if open_total < 3 {
1708            continue;
1709        }
1710
1711        for m in open_matches_clone.iter() {
1712            if !m.is_opener || m.kind != EmphasisKind::Emph {
1713                continue;
1714            }
1715            let closer_idx = m.partner_event as usize;
1716            if closer_idx <= opener_idx || closer_idx >= n {
1717                continue;
1718            }
1719
1720            let (close_total, close_consumed) = match &events[closer_idx] {
1721                IrEvent::DelimRun {
1722                    start,
1723                    end,
1724                    matches,
1725                    ..
1726                } => {
1727                    let total = end - start;
1728                    let consumed: usize = matches.iter().map(|m| m.len as usize).sum();
1729                    (total, consumed)
1730                }
1731                _ => continue,
1732            };
1733            if close_total < 3 {
1734                continue;
1735            }
1736            let leftover = close_total.saturating_sub(close_consumed);
1737            if leftover < 2 {
1738                continue;
1739            }
1740
1741            // Walk backward from closer-1 looking for the rightmost
1742            // unmatched same-char run with count >= 2 and
1743            // can_close=true.
1744            for k in ((opener_idx + 1)..closer_idx).rev() {
1745                if let IrEvent::DelimRun {
1746                    ch,
1747                    start,
1748                    end,
1749                    matches,
1750                    can_close,
1751                    ..
1752                } = &events[k]
1753                {
1754                    if *ch != ch_o || !*can_close {
1755                        continue;
1756                    }
1757                    let total = end - start;
1758                    let consumed: usize = matches.iter().map(|m| m.len as usize).sum();
1759                    let remaining = total.saturating_sub(consumed);
1760                    if remaining < 2 {
1761                        continue;
1762                    }
1763                    to_apply.push((k, opener_idx, closer_idx, 2));
1764                    break;
1765                }
1766            }
1767        }
1768    }
1769
1770    for (between_idx, opener_idx, closer_idx, len) in to_apply {
1771        // Find the existing Emph match on the closer side.
1772        let (closer_emph_match_idx, closer_emph_offset) = {
1773            let mut found: Option<(usize, u8)> = None;
1774            if let IrEvent::DelimRun { matches, .. } = &events[closer_idx] {
1775                for (mi, m) in matches.iter().enumerate() {
1776                    if !m.is_opener
1777                        && m.partner_event as usize == opener_idx
1778                        && m.kind == EmphasisKind::Emph
1779                    {
1780                        found = Some((mi, m.offset_in_run));
1781                        break;
1782                    }
1783                }
1784            }
1785            match found {
1786                Some(x) => x,
1787                None => continue,
1788            }
1789        };
1790
1791        // Find the corresponding Emph match on the opener side.
1792        let opener_emph_match_idx = {
1793            let mut found: Option<usize> = None;
1794            if let IrEvent::DelimRun { matches, .. } = &events[opener_idx] {
1795                for (mi, m) in matches.iter().enumerate() {
1796                    if m.is_opener
1797                        && m.partner_event as usize == closer_idx
1798                        && m.kind == EmphasisKind::Emph
1799                    {
1800                        found = Some(mi);
1801                        break;
1802                    }
1803                }
1804            }
1805            match found {
1806                Some(x) => x,
1807                None => continue,
1808            }
1809        };
1810
1811        // Shift the Emph closer's offset to the right of the new
1812        // Strong closer's bytes (Strong takes leftmost `len` bytes,
1813        // Emph takes the next byte).
1814        let new_closer_emph_offset = closer_emph_offset + len;
1815
1816        // Update closer's Emph offset_in_run.
1817        if let IrEvent::DelimRun { matches, .. } = &mut events[closer_idx] {
1818            matches[closer_emph_match_idx].offset_in_run = new_closer_emph_offset;
1819        }
1820        // Update opener's Emph partner_offset to point at the shifted
1821        // Emph closer position.
1822        if let IrEvent::DelimRun { matches, .. } = &mut events[opener_idx] {
1823            matches[opener_emph_match_idx].partner_offset = new_closer_emph_offset;
1824        }
1825
1826        // Add Strong opener match on the between-run.
1827        if let IrEvent::DelimRun { matches, .. } = &mut events[between_idx] {
1828            matches.push(DelimMatch {
1829                offset_in_run: 0,
1830                len,
1831                is_opener: true,
1832                partner_event: closer_idx as u32,
1833                partner_offset: closer_emph_offset,
1834                kind: EmphasisKind::Strong,
1835            });
1836        }
1837        // Add Strong closer match on the closer (at the original
1838        // pre-shift Emph-closer position; the bytes that were the
1839        // single Emph closer now become the leftmost 2 bytes of the
1840        // Strong closer).
1841        if let IrEvent::DelimRun { matches, .. } = &mut events[closer_idx] {
1842            matches.push(DelimMatch {
1843                offset_in_run: closer_emph_offset,
1844                len,
1845                is_opener: false,
1846                partner_event: between_idx as u32,
1847                partner_offset: 0,
1848                kind: EmphasisKind::Strong,
1849            });
1850        }
1851    }
1852}
1853
1854fn source_start_event(event: &IrEvent) -> usize {
1855    match event {
1856        IrEvent::DelimRun { start, .. } => *start,
1857        _ => unreachable!("source_start_event called on non-DelimRun"),
1858    }
1859}
1860
1861// ============================================================================
1862// Pass 3: Process brackets (CommonMark §6.3)
1863// ============================================================================
1864
1865/// Resolve `[`/`![`/`]` markers into link/image nodes per CommonMark §6.3
1866/// (with Pandoc-aware variations under `Dialect::Pandoc`).
1867///
1868/// Walks the IR forward looking for `]` markers. For each one, finds the
1869/// nearest active matching `[`/`![` and tries to resolve the bracket pair
1870/// as a link or image. Resolution is tried in spec order:
1871///
1872/// 1. Inline link / image: `[text](dest)` or `[text](dest "title")`.
1873/// 2. Full reference: `[text][label]`, where `label` is in `refdefs`.
1874/// 3. Collapsed reference: `[text][]`, where `text` (normalised) is in
1875///    `refdefs`.
1876/// 4. Shortcut reference: `[text]` not followed by `(` or `[`, where
1877///    `text` (normalised) is in `refdefs`.
1878///
1879/// On a match, the opener gets a `BracketResolution` and the closer is
1880/// flagged `matched`. Under `Dialect::CommonMark`, all earlier active link
1881/// openers are deactivated to implement the §6.3 "links may not contain
1882/// other links" rule (image brackets do not deactivate earlier link
1883/// openers — only links do). Under `Dialect::Pandoc`, the deactivate-pass
1884/// is skipped: pandoc-native is outer-wins for nested links (the inner
1885/// `[inner](u2)` of `[link [inner](u2)](u1)` is literal text inside the
1886/// outer link), and the dispatcher enforces this via a `suppress_inner_links`
1887/// flag during LINK-text recursion. So under Pandoc the IR can leave both
1888/// outer and inner resolved and trust the dispatcher to suppress inner
1889/// LINK emission.
1890///
1891/// On a miss the bracket pair stays opaque-as-literal and the closer is
1892/// dropped from the bracket stack so the next `]` can re-pair.
1893///
1894/// Reference-form resolution consults the refdef map under both
1895/// dialects (CommonMark §6.3 and Pandoc-markdown agree on the
1896/// document-scoped lookup rule). Under Pandoc, when a bracket-shape
1897/// pattern (`[text][label]`, `[text][]`, `[text]`) doesn't resolve to
1898/// a refdef, the opener is tagged with `unresolved_ref = Some(...)`
1899/// and the closer's `matched` is set to `true` so that
1900/// [`build_bracket_plan`] emits a [`BracketDispo::UnresolvedReference`]
1901/// keyed at the opener. Emission then wraps `[start, end)` in an
1902/// `UNRESOLVED_REFERENCE` node — distinct from `LINK` — so downstream
1903/// tools (linter, LSP) can attach behavior to the bracket-shape
1904/// pattern without the parser having to lie about resolution.
1905///
1906/// Under CommonMark, no `unresolved_ref` is recorded; the
1907/// no-resolution fall-through behaves as today (opener deactivated,
1908/// brackets emit as literal text).
1909pub fn process_brackets(
1910    events: &mut [IrEvent],
1911    text: &str,
1912    refdefs: Option<&RefdefMap>,
1913    dialect: crate::options::Dialect,
1914    allow_spaced: bool,
1915) {
1916    let empty: HashSet<String> = HashSet::new();
1917    let labels: &HashSet<String> = match refdefs {
1918        Some(map) => map.as_ref(),
1919        None => &empty,
1920    };
1921    let is_commonmark = dialect == crate::options::Dialect::CommonMark;
1922    // Refdef-aware label resolution under both dialects.
1923    let label_resolves =
1924        |key_norm: &str| -> bool { !key_norm.is_empty() && labels.contains(key_norm) };
1925
1926    // Walk forward through events, treating it as a linear scan for `]`.
1927    let mut i = 0;
1928    while i < events.len() {
1929        let close_pos = match &events[i] {
1930            IrEvent::CloseBracket { pos, .. } => *pos,
1931            _ => {
1932                i += 1;
1933                continue;
1934            }
1935        };
1936
1937        // Find the nearest active OpenBracket before `i`.
1938        let mut o = match find_active_opener(events, i) {
1939            Some(o) => o,
1940            None => {
1941                i += 1;
1942                continue;
1943            }
1944        };
1945
1946        let (open_end, is_image) = match &events[o] {
1947            IrEvent::OpenBracket { end, is_image, .. } => (*end, *is_image),
1948            _ => unreachable!(),
1949        };
1950        let text_start = open_end;
1951        let text_end = close_pos;
1952        let after_close = close_pos + 1;
1953
1954        // 1. Inline link / image.
1955        if let Some((suffix_end, dest, title)) = try_inline_suffix(text, after_close) {
1956            // §6.3 link-in-link rule (CommonMark): if this is a *link*
1957            // (not an image), and any earlier active link opener exists,
1958            // deactivate them. We also deactivate openers strictly before
1959            // `o` here because matching means the inner link wins; the
1960            // spec applies this *after* matching. Pandoc skips this —
1961            // outer-wins is enforced by the dispatcher's
1962            // `suppress_inner_links` flag during LINK-text recursion.
1963            if !is_image && is_commonmark {
1964                deactivate_earlier_link_openers(events, o);
1965            }
1966            commit_resolution(
1967                events,
1968                o,
1969                i,
1970                text_start,
1971                text_end,
1972                after_close,
1973                suffix_end,
1974                LinkKind::Inline { dest, title },
1975            );
1976            // Remove the opener from the bracket stack: it has been
1977            // matched (active=false will fall out automatically since
1978            // resolution is Some).
1979            mark_opener_resolved(events, o);
1980            i += 1;
1981            continue;
1982        }
1983
1984        // 2. Full reference link: `[text][label]`.
1985        let full_ref_suffix = try_full_reference_suffix(text, after_close, allow_spaced);
1986        if let Some((suffix_end, label_raw)) = &full_ref_suffix {
1987            let label_norm = normalize_label(label_raw);
1988            if label_resolves(&label_norm) {
1989                if !is_image && is_commonmark {
1990                    deactivate_earlier_link_openers(events, o);
1991                }
1992                commit_resolution(
1993                    events,
1994                    o,
1995                    i,
1996                    text_start,
1997                    text_end,
1998                    after_close,
1999                    *suffix_end,
2000                    LinkKind::FullReference {
2001                        label: label_raw.clone(),
2002                    },
2003                );
2004                mark_opener_resolved(events, o);
2005                i += 1;
2006                continue;
2007            }
2008            // Bracketed but unresolved label: §6.3 says we still treat
2009            // `[text][label]` as not-a-link, but the brackets get
2010            // consumed as literal text AND the shortcut form is
2011            // suppressed (since the `]` is followed by a link label).
2012        }
2013
2014        // 3. Collapsed `[]`.
2015        let link_text = &text[text_start..text_end];
2016        let link_text_norm = normalize_label(link_text);
2017        let (is_collapsed, collapsed_suffix_end) =
2018            collapsed_marker_span(text, after_close, allow_spaced)
2019                .map_or((false, after_close + 2), |end| (true, end));
2020
2021        if is_collapsed && label_resolves(&link_text_norm) {
2022            if !is_image && is_commonmark {
2023                deactivate_earlier_link_openers(events, o);
2024            }
2025            commit_resolution(
2026                events,
2027                o,
2028                i,
2029                text_start,
2030                text_end,
2031                after_close,
2032                collapsed_suffix_end,
2033                LinkKind::CollapsedReference,
2034            );
2035            mark_opener_resolved(events, o);
2036            i += 1;
2037            continue;
2038        }
2039        // `[text][]` with text not in refdefs — falls through to
2040        // literal text; shortcut is suppressed (followed by `[]`).
2041
2042        // 4. Shortcut form: `[text]` not followed by `[]` or `[label]`.
2043        // Per CommonMark §6.3: "A shortcut reference link consists of a
2044        // link label that matches a link reference definition elsewhere
2045        // in the document and is not followed by [] or a link label."
2046        // The full-ref / collapsed shape attempts above suppress the
2047        // shortcut even when their labels don't resolve — the bracket
2048        // bytes still get consumed as literal text.
2049        let shortcut_suppressed = full_ref_suffix.is_some() || is_collapsed;
2050        if !shortcut_suppressed && label_resolves(&link_text_norm) {
2051            if !is_image && is_commonmark {
2052                deactivate_earlier_link_openers(events, o);
2053            }
2054            commit_resolution(
2055                events,
2056                o,
2057                i,
2058                text_start,
2059                text_end,
2060                after_close,
2061                after_close,
2062                LinkKind::ShortcutReference,
2063            );
2064            mark_opener_resolved(events, o);
2065            i += 1;
2066            continue;
2067        }
2068
2069        // No resolution. Under Pandoc, the bracket pair is still a
2070        // recognisable reference shape (full / collapsed / shortcut) —
2071        // tag the opener with `unresolved_ref` so emission wraps it
2072        // in an `UNRESOLVED_REFERENCE` node, and mark the closer
2073        // matched so it doesn't fall through to a literal `]` token.
2074        // Under CommonMark, behavior unchanged: deactivate the opener,
2075        // brackets emit as literal text.
2076        //
2077        // Empty-component shapes (`[]`, `[][]`) aren't reference
2078        // patterns even in spirit — pandoc-native treats them as
2079        // literal text — so skip wrapping.
2080        let unresolved_shape = if !is_commonmark {
2081            let (end, has_substantive_label) =
2082                if let Some((suffix_end, label_raw)) = &full_ref_suffix {
2083                    (*suffix_end, !normalize_label(label_raw).is_empty())
2084                } else if is_collapsed {
2085                    (collapsed_suffix_end, !link_text_norm.is_empty())
2086                } else {
2087                    (after_close, !link_text_norm.is_empty())
2088                };
2089            if has_substantive_label {
2090                Some(UnresolvedRefShape {
2091                    close_event: i as u32,
2092                    text_end,
2093                    end,
2094                })
2095            } else {
2096                None
2097            }
2098        } else {
2099            None
2100        };
2101        if let IrEvent::OpenBracket {
2102            active,
2103            unresolved_ref,
2104            ..
2105        } = &mut events[o]
2106        {
2107            *active = false;
2108            *unresolved_ref = unresolved_shape;
2109        }
2110        if unresolved_shape.is_some()
2111            && let IrEvent::CloseBracket { matched, .. } = &mut events[i]
2112        {
2113            *matched = true;
2114        }
2115        let _ = &mut o;
2116        i += 1;
2117    }
2118}
2119
2120fn find_active_opener(events: &[IrEvent], close_idx: usize) -> Option<usize> {
2121    (0..close_idx).rev().find(|&i| {
2122        matches!(
2123            &events[i],
2124            IrEvent::OpenBracket {
2125                active: true,
2126                resolution: None,
2127                ..
2128            }
2129        )
2130    })
2131}
2132
2133fn deactivate_earlier_link_openers(events: &mut [IrEvent], open_idx: usize) {
2134    for ev in &mut events[..open_idx] {
2135        if let IrEvent::OpenBracket {
2136            is_image: false,
2137            active,
2138            resolution: None,
2139            ..
2140        } = ev
2141        {
2142            *active = false;
2143        }
2144    }
2145}
2146
2147fn mark_opener_resolved(events: &mut [IrEvent], open_idx: usize) {
2148    if let IrEvent::OpenBracket { active, .. } = &mut events[open_idx] {
2149        *active = false;
2150    }
2151}
2152
2153#[allow(clippy::too_many_arguments)]
2154fn commit_resolution(
2155    events: &mut [IrEvent],
2156    open_idx: usize,
2157    close_idx: usize,
2158    text_start: usize,
2159    text_end: usize,
2160    suffix_start: usize,
2161    suffix_end: usize,
2162    kind: LinkKind,
2163) {
2164    if let IrEvent::OpenBracket { resolution, .. } = &mut events[open_idx] {
2165        *resolution = Some(BracketResolution {
2166            close_event: close_idx as u32,
2167            text_start,
2168            text_end,
2169            suffix_start,
2170            suffix_end,
2171            kind,
2172        });
2173    }
2174    if let IrEvent::CloseBracket { matched, .. } = &mut events[close_idx] {
2175        *matched = true;
2176    }
2177}
2178
2179/// Try to parse `(dest)` or `(dest "title")` inline link suffix starting
2180/// at `text[pos]`. Returns `(end_pos_exclusive, dest, title)`.
2181fn try_inline_suffix(text: &str, pos: usize) -> Option<(usize, String, Option<String>)> {
2182    let bytes = text.as_bytes();
2183    if pos >= bytes.len() || bytes[pos] != b'(' {
2184        return None;
2185    }
2186    let mut p = pos + 1;
2187    // Skip leading whitespace.
2188    while p < bytes.len() && matches!(bytes[p], b' ' | b'\t' | b'\n') {
2189        p += 1;
2190    }
2191    // Empty `()` — link with empty destination.
2192    if p < bytes.len() && bytes[p] == b')' {
2193        return Some((p + 1, String::new(), None));
2194    }
2195
2196    // Parse destination.
2197    let (dest, dest_end) = parse_link_destination(text, p)?;
2198    p = dest_end;
2199
2200    // Skip whitespace.
2201    while p < bytes.len() && matches!(bytes[p], b' ' | b'\t' | b'\n') {
2202        p += 1;
2203    }
2204
2205    // Optional title.
2206    let mut title = None;
2207    if p < bytes.len() && matches!(bytes[p], b'"' | b'\'' | b'(') {
2208        let (t, t_end) = parse_link_title(text, p)?;
2209        title = Some(t);
2210        p = t_end;
2211        while p < bytes.len() && matches!(bytes[p], b' ' | b'\t' | b'\n') {
2212            p += 1;
2213        }
2214    }
2215
2216    if p >= bytes.len() || bytes[p] != b')' {
2217        return None;
2218    }
2219    Some((p + 1, dest, title))
2220}
2221
2222fn parse_link_destination(text: &str, start: usize) -> Option<(String, usize)> {
2223    let bytes = text.as_bytes();
2224    if start >= bytes.len() {
2225        return None;
2226    }
2227    if bytes[start] == b'<' {
2228        // <bracketed>
2229        let mut p = start + 1;
2230        let begin = p;
2231        while p < bytes.len() && bytes[p] != b'>' && bytes[p] != b'\n' && bytes[p] != b'<' {
2232            if bytes[p] == b'\\' && p + 1 < bytes.len() {
2233                p += 2;
2234            } else {
2235                p += 1;
2236            }
2237        }
2238        if p >= bytes.len() || bytes[p] != b'>' {
2239            return None;
2240        }
2241        let dest = text[begin..p].to_string();
2242        Some((dest, p + 1))
2243    } else {
2244        // unbracketed: balanced parens, no spaces, no controls
2245        let mut p = start;
2246        let mut paren_depth: i32 = 0;
2247        while p < bytes.len() {
2248            let b = bytes[p];
2249            if b == b'\\' && p + 1 < bytes.len() {
2250                p += 2;
2251                continue;
2252            }
2253            if b == b'(' {
2254                paren_depth += 1;
2255                p += 1;
2256                continue;
2257            }
2258            if b == b')' {
2259                if paren_depth == 0 {
2260                    break;
2261                }
2262                paren_depth -= 1;
2263                p += 1;
2264                continue;
2265            }
2266            if b == b' ' || b == b'\t' || b == b'\n' || b < 0x20 || b == 0x7f {
2267                break;
2268            }
2269            p += 1;
2270        }
2271        if p == start || paren_depth != 0 {
2272            return None;
2273        }
2274        Some((text[start..p].to_string(), p))
2275    }
2276}
2277
2278fn parse_link_title(text: &str, start: usize) -> Option<(String, usize)> {
2279    let bytes = text.as_bytes();
2280    let q = bytes[start];
2281    let close = match q {
2282        b'"' => b'"',
2283        b'\'' => b'\'',
2284        b'(' => b')',
2285        _ => return None,
2286    };
2287    let mut p = start + 1;
2288    let begin = p;
2289    while p < bytes.len() {
2290        let b = bytes[p];
2291        if b == b'\\' && p + 1 < bytes.len() {
2292            p += 2;
2293            continue;
2294        }
2295        if b == close {
2296            let title = text[begin..p].to_string();
2297            return Some((title, p + 1));
2298        }
2299        p += 1;
2300    }
2301    None
2302}
2303
2304/// Try to parse `[label]` after a `]`. Returns `(suffix_end, label_raw)`.
2305/// For the collapsed form `[]`, returns `None` here (handled separately
2306/// by `collapsed_marker_span`).
2307fn try_full_reference_suffix(
2308    text: &str,
2309    pos: usize,
2310    allow_spaced: bool,
2311) -> Option<(usize, String)> {
2312    let bytes = text.as_bytes();
2313    let bracket_pos = if allow_spaced {
2314        skip_spaced_ref_gap(bytes, pos)
2315    } else {
2316        pos
2317    };
2318    if bracket_pos >= bytes.len() || bytes[bracket_pos] != b'[' {
2319        return None;
2320    }
2321    let label_start = bracket_pos + 1;
2322    let mut p = label_start;
2323    let mut escape_next = false;
2324    while p < bytes.len() {
2325        if escape_next {
2326            escape_next = false;
2327            p += 1;
2328            continue;
2329        }
2330        match bytes[p] {
2331            b'\\' => {
2332                escape_next = true;
2333                p += 1;
2334            }
2335            b']' => break,
2336            b'[' => return None,
2337            b'\n' => {
2338                p += 1;
2339            }
2340            _ => p += 1,
2341        }
2342    }
2343    if p >= bytes.len() || bytes[p] != b']' {
2344        return None;
2345    }
2346    let label = text[label_start..p].to_string();
2347    if label.is_empty() {
2348        return None;
2349    }
2350    Some((p + 1, label))
2351}
2352
2353/// True when `text[pos..]` opens with the collapsed `[]` marker. Under
2354/// `spaced_reference_links`, whitespace before the `[]` is permitted; the
2355/// returned `Some(end)` reports the byte position past the closing `]`.
2356fn collapsed_marker_span(text: &str, pos: usize, allow_spaced: bool) -> Option<usize> {
2357    let bytes = text.as_bytes();
2358    let bracket_pos = if allow_spaced {
2359        skip_spaced_ref_gap(bytes, pos)
2360    } else {
2361        pos
2362    };
2363    if bytes.get(bracket_pos) == Some(&b'[') && bytes.get(bracket_pos + 1) == Some(&b']') {
2364        Some(bracket_pos + 2)
2365    } else {
2366        None
2367    }
2368}
2369
2370/// Skip the whitespace gap permitted by `spaced_reference_links` between a
2371/// closing `]` and the next opening `[`/`[]`: spaces, tabs, and at most one LF.
2372/// Block parsing already guarantees a blank line cannot appear inside a single
2373/// inline-parse range, so a single newline is the upper bound.
2374fn skip_spaced_ref_gap(bytes: &[u8], pos: usize) -> usize {
2375    let mut p = pos;
2376    let mut saw_newline = false;
2377    while p < bytes.len() {
2378        match bytes[p] {
2379            b' ' | b'\t' => p += 1,
2380            b'\n' if !saw_newline => {
2381                saw_newline = true;
2382                p += 1;
2383            }
2384            _ => break,
2385        }
2386    }
2387    p
2388}
2389
2390// ============================================================================
2391// Bracket plan — byte-position-keyed view of resolved brackets, consumed by
2392// the existing emission walk in `core::parse_inline_range_impl`.
2393// ============================================================================
2394
2395/// Disposition of a single bracket byte after [`process_brackets`].
2396#[derive(Debug, Clone)]
2397pub enum BracketDispo {
2398    /// `[` or `![` of a resolved link/image. Emission emits the LINK/IMAGE
2399    /// node and skips past `suffix_end`.
2400    Open {
2401        is_image: bool,
2402        text_start: usize,
2403        text_end: usize,
2404        suffix_start: usize,
2405        suffix_end: usize,
2406        kind: LinkKind,
2407    },
2408    /// Pandoc-only: `[` or `![` of a bracket-shape reference pattern
2409    /// whose label didn't resolve. Emission wraps `[start, end)` in an
2410    /// `UNRESOLVED_REFERENCE` node so downstream tools can attach
2411    /// behavior to the bracket-shape pattern. `text_start..text_end` is
2412    /// the inner text range (between the outer `[`/`![` and `]`).
2413    UnresolvedReference {
2414        is_image: bool,
2415        text_start: usize,
2416        text_end: usize,
2417        end: usize,
2418    },
2419    /// Bracket byte (one of `[`, `]`, or `!`) that fell through to literal
2420    /// text. Emission accumulates into the surrounding text run.
2421    Literal,
2422}
2423
2424/// A byte-keyed view of the IR's bracket resolutions.
2425#[derive(Debug, Default, Clone)]
2426pub struct BracketPlan {
2427    by_pos: BTreeMap<usize, BracketDispo>,
2428}
2429
2430impl BracketPlan {
2431    pub fn lookup(&self, pos: usize) -> Option<&BracketDispo> {
2432        self.by_pos.get(&pos)
2433    }
2434
2435    pub fn is_empty(&self) -> bool {
2436        self.by_pos.is_empty()
2437    }
2438}
2439
2440/// A standalone Pandoc inline construct recognised by `build_ir` and
2441/// dispatched directly from the emission walk. Carries the construct's
2442/// full source range so the emission walk can slice the content for the
2443/// existing `emit_*` helpers without re-running the recognition.
2444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2445pub enum ConstructDispo {
2446    /// `^[note text]` — emit via `emit_inline_footnote` after slicing
2447    /// the inner content.
2448    InlineFootnote { end: usize },
2449    /// `<span ...>...</span>` — emit via `emit_native_span` after
2450    /// re-parsing the open-tag attributes from the source range.
2451    NativeSpan { end: usize },
2452    /// `[^id]` — emit via `emit_footnote_reference` after extracting
2453    /// the label id from the source range.
2454    FootnoteReference { end: usize },
2455    /// `[@cite]` — emit via `emit_bracketed_citation` after slicing
2456    /// the inner content.
2457    BracketedCitation { end: usize },
2458    /// `@key` or `-@key` — emit via `emit_bare_citation` (or
2459    /// `emit_crossref` when `is_quarto_crossref_key` matches and
2460    /// `extensions.quarto_crossrefs` is enabled).
2461    BareCitation { end: usize },
2462    /// `[content]{attrs}` — emit via `emit_bracketed_span` after
2463    /// slicing the inner content and attribute string.
2464    BracketedSpan { end: usize },
2465    /// `[[url]]` / `[[url|title]]` (or image variant `![[...]]`) —
2466    /// emit via `emit_wikilink` after re-locating the pipe within the
2467    /// source range.
2468    WikiLink { end: usize },
2469}
2470
2471/// A byte-keyed view of the IR's standalone Pandoc constructs that the
2472/// emission walk consumes directly: inline footnotes, native spans,
2473/// footnote references, bracketed citations, bare citations, and
2474/// bracketed spans. Recognition is authoritative in `build_ir` under
2475/// `Dialect::Pandoc`; the dispatcher's legacy branches for these
2476/// constructs (`^[`, `<span>`, `[^id]`, `[@cite]`, `@cite` / `-@cite`,
2477/// `[text]{attrs}`) are gated to `Dialect::CommonMark` only and only
2478/// fire when the relevant extension is explicitly enabled.
2479#[derive(Debug, Default, Clone)]
2480pub struct ConstructPlan {
2481    by_pos: BTreeMap<usize, ConstructDispo>,
2482}
2483
2484impl ConstructPlan {
2485    pub fn lookup(&self, pos: usize) -> Option<&ConstructDispo> {
2486        self.by_pos.get(&pos)
2487    }
2488
2489    pub fn is_empty(&self) -> bool {
2490        self.by_pos.is_empty()
2491    }
2492}
2493
2494/// Build a [`ConstructPlan`] from the resolved IR. Each
2495/// `Construct { kind: InlineFootnote | NativeSpan, .. }` becomes one
2496/// entry keyed at its start byte.
2497pub fn build_construct_plan(events: &[IrEvent]) -> ConstructPlan {
2498    let mut by_pos: BTreeMap<usize, ConstructDispo> = BTreeMap::new();
2499    for ev in events {
2500        if let IrEvent::Construct { start, end, kind } = ev {
2501            match kind {
2502                ConstructKind::InlineFootnote => {
2503                    by_pos.insert(*start, ConstructDispo::InlineFootnote { end: *end });
2504                }
2505                ConstructKind::NativeSpan => {
2506                    by_pos.insert(*start, ConstructDispo::NativeSpan { end: *end });
2507                }
2508                ConstructKind::FootnoteReference => {
2509                    by_pos.insert(*start, ConstructDispo::FootnoteReference { end: *end });
2510                }
2511                ConstructKind::BracketedCitation => {
2512                    by_pos.insert(*start, ConstructDispo::BracketedCitation { end: *end });
2513                }
2514                ConstructKind::BareCitation => {
2515                    by_pos.insert(*start, ConstructDispo::BareCitation { end: *end });
2516                }
2517                ConstructKind::BracketedSpan => {
2518                    by_pos.insert(*start, ConstructDispo::BracketedSpan { end: *end });
2519                }
2520                ConstructKind::WikiLink => {
2521                    by_pos.insert(*start, ConstructDispo::WikiLink { end: *end });
2522                }
2523                _ => {}
2524            }
2525        }
2526    }
2527    ConstructPlan { by_pos }
2528}
2529
2530/// Build a [`BracketPlan`] from the resolved IR. Each `OpenBracket`
2531/// resolution becomes an [`BracketDispo::Open`] keyed at the opener's
2532/// start byte. Unresolved openers and unmatched closers become
2533/// `BracketDispo::Literal` so the emission path can recognise them
2534/// without re-parsing.
2535pub fn build_bracket_plan(events: &[IrEvent]) -> BracketPlan {
2536    let mut by_pos: BTreeMap<usize, BracketDispo> = BTreeMap::new();
2537    for ev in events {
2538        match ev {
2539            IrEvent::OpenBracket {
2540                start,
2541                is_image,
2542                resolution: Some(res),
2543                ..
2544            } => {
2545                by_pos.insert(
2546                    *start,
2547                    BracketDispo::Open {
2548                        is_image: *is_image,
2549                        text_start: res.text_start,
2550                        text_end: res.text_end,
2551                        suffix_start: res.suffix_start,
2552                        suffix_end: res.suffix_end,
2553                        kind: res.kind.clone(),
2554                    },
2555                );
2556            }
2557            IrEvent::OpenBracket {
2558                start,
2559                end,
2560                is_image,
2561                resolution: None,
2562                unresolved_ref: Some(shape),
2563                ..
2564            } => {
2565                by_pos.insert(
2566                    *start,
2567                    BracketDispo::UnresolvedReference {
2568                        is_image: *is_image,
2569                        text_start: *end,
2570                        text_end: shape.text_end,
2571                        end: shape.end,
2572                    },
2573                );
2574            }
2575            IrEvent::OpenBracket {
2576                start,
2577                is_image,
2578                resolution: None,
2579                unresolved_ref: None,
2580                ..
2581            } => {
2582                let len = if *is_image { 2 } else { 1 };
2583                for off in 0..len {
2584                    by_pos.insert(*start + off, BracketDispo::Literal);
2585                }
2586            }
2587            IrEvent::CloseBracket {
2588                pos,
2589                matched: false,
2590            } => {
2591                by_pos.insert(*pos, BracketDispo::Literal);
2592            }
2593            _ => {}
2594        }
2595    }
2596    BracketPlan { by_pos }
2597}
2598
2599/// One-shot helper: build the IR, run all passes, and return the
2600/// bundled [`InlinePlans`] (emphasis dispositions, bracket resolutions,
2601/// and standalone Pandoc constructs) — packaged together so the inline
2602/// emission path can consume them in one go for either dialect.
2603///
2604/// Pass ordering follows the CommonMark §6.3 reference impl: bracket
2605/// resolution runs first, then emphasis is processed *scoped per resolved
2606/// bracket pair's inner event range*, then once more on the residual
2607/// top-level events. This prevents emphasis pairs from forming across a
2608/// link's bracket boundary, which the previous "all-emphasis-then-all-
2609/// brackets" order got wrong (e.g. spec example #473).
2610pub fn build_full_plans(
2611    text: &str,
2612    start: usize,
2613    end: usize,
2614    config: &ParserOptions,
2615) -> InlinePlans {
2616    let mut scratch = ScratchEvents::checkout();
2617    let bundle = scratch.inner.as_mut().unwrap();
2618    bundle.events.clear();
2619    bundle.bracket_pairs.clear();
2620    bundle.excluded.clear();
2621
2622    build_ir_into(text, start, end, config, &mut bundle.events);
2623    // §6.3 bracket resolution runs for both dialects. Under CommonMark
2624    // it enforces refdef-aware shortcut/collapsed/full-ref resolution
2625    // and the §6.3 link-in-link deactivation rule. Under Pandoc it
2626    // performs shape-only resolution (any non-empty label resolves) and
2627    // skips the deactivation pass — pandoc-native is outer-wins for
2628    // nested links and the dispatcher's `suppress_inner_links` flag
2629    // suppresses inner LINK emission during LINK-text recursion.
2630    process_brackets(
2631        &mut bundle.events,
2632        text,
2633        config.refdef_labels.as_ref(),
2634        config.dialect,
2635        config.extensions.spaced_reference_links,
2636    );
2637
2638    // Scoped emphasis pass per resolved bracket pair, innermost first.
2639    // We collect (open_idx, close_idx) pairs of resolved brackets and run
2640    // emphasis only over the events strictly between them. Innermost-first
2641    // ordering matters: an outer link wraps emphasis that wraps an inner
2642    // link, and the inner link's inner range must be paired before the
2643    // outer's inner range so the top-level pass sees consistent state.
2644    // Include both resolved-link bracket pairs and Pandoc unresolved-
2645    // reference bracket pairs in the scoping set. The latter wrap into
2646    // an `UNRESOLVED_REFERENCE` CST node, which is just as much a tree
2647    // boundary for emphasis as a resolved `LINK` — emphasis must not
2648    // pair across the wrapper's brackets, otherwise the emission walk
2649    // produces a non-tree-shaped CST.
2650    bundle.bracket_pairs.extend(
2651        bundle
2652            .events
2653            .iter()
2654            .enumerate()
2655            .filter_map(|(i, ev)| match ev {
2656                IrEvent::OpenBracket {
2657                    resolution: Some(res),
2658                    ..
2659                } => Some((i, res.close_event as usize)),
2660                IrEvent::OpenBracket {
2661                    resolution: None,
2662                    unresolved_ref: Some(shape),
2663                    ..
2664                } => Some((i, shape.close_event as usize)),
2665                _ => None,
2666            }),
2667    );
2668    // Innermost-first: sort by close_idx ascending, then open_idx descending.
2669    bundle
2670        .bracket_pairs
2671        .sort_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0)));
2672    // Iterate pairs by index so we can hold &mut bundle.events while
2673    // reading bundle.bracket_pairs (split borrow on disjoint fields).
2674    for i in 0..bundle.bracket_pairs.len() {
2675        let (open_idx, close_idx) = bundle.bracket_pairs[i];
2676        process_emphasis_in_range(&mut bundle.events, open_idx + 1, close_idx, config.dialect);
2677    }
2678
2679    // Pandoc-only degrade pass for unresolved bracket-shape patterns
2680    // whose interior left any delim-run byte unmatched after the scoped
2681    // emphasis pass. Pandoc-native degrades such brackets to literal `[`
2682    // / `]` text — the user's intent was clearly not a reference. The
2683    // bracket_pairs entry stays so the inner delims remain in the
2684    // top-level exclusion mask (otherwise they'd re-enter pairing and
2685    // could form Emph spans with delims outside, which pandoc never
2686    // does — see the bug_2_emphasis_crosses_brackets_pandoc fixture).
2687    // Flipping `unresolved_ref` to `None` makes `build_bracket_plan`
2688    // emit `BracketDispo::Literal` for the bracket bytes; flipping
2689    // `CloseBracket.matched` to `false` does the same for the `]`.
2690    for i in 0..bundle.bracket_pairs.len() {
2691        let (open_idx, close_idx) = bundle.bracket_pairs[i];
2692        let is_unresolved = matches!(
2693            &bundle.events[open_idx],
2694            IrEvent::OpenBracket {
2695                resolution: None,
2696                unresolved_ref: Some(_),
2697                ..
2698            }
2699        );
2700        if !is_unresolved {
2701            continue;
2702        }
2703        if !range_has_unmatched_delim_bytes(&bundle.events, open_idx + 1, close_idx) {
2704            continue;
2705        }
2706        if let IrEvent::OpenBracket { unresolved_ref, .. } = &mut bundle.events[open_idx] {
2707            *unresolved_ref = None;
2708        }
2709        if let IrEvent::CloseBracket { matched, .. } = &mut bundle.events[close_idx] {
2710            *matched = false;
2711        }
2712    }
2713
2714    // Top-level emphasis pass: handles delim runs that fall outside any
2715    // resolved bracket pair.
2716    let len = bundle.events.len();
2717    if bundle.bracket_pairs.is_empty() {
2718        // Fast path: no resolved brackets means no exclusion mask needed —
2719        // skip the resize-and-fill pass entirely. Common for prose
2720        // paragraphs without inline links.
2721        process_emphasis_in_range_filtered(&mut bundle.events, 0, len, None, config.dialect);
2722    } else {
2723        // Build exclusion bitmap: any delim run whose event index lies
2724        // inside a resolved bracket pair is excluded from the top-level
2725        // pass. Implements the §6.3 boundary rule: emphasis at the top
2726        // level must not pair across a link's brackets.
2727        bundle.excluded.resize(len, false);
2728        for &(open_idx, close_idx) in &bundle.bracket_pairs {
2729            for slot in bundle
2730                .excluded
2731                .iter_mut()
2732                .take(close_idx)
2733                .skip(open_idx + 1)
2734            {
2735                *slot = true;
2736            }
2737        }
2738        process_emphasis_in_range_filtered(
2739            &mut bundle.events,
2740            0,
2741            len,
2742            Some(&bundle.excluded),
2743            config.dialect,
2744        );
2745    }
2746
2747    InlinePlans {
2748        emphasis: build_emphasis_plan(&bundle.events),
2749        brackets: build_bracket_plan(&bundle.events),
2750        constructs: build_construct_plan(&bundle.events),
2751    }
2752}
2753
2754/// Returns true if any [`IrEvent::DelimRun`] in the event range
2755/// `[lo, hi)` has byte coverage from its `matches` vec that is less
2756/// than the run length — i.e. at least one byte of the run failed to
2757/// pair as emphasis. Used by the Pandoc unresolved-reference degrade
2758/// pass in [`build_full_plans`].
2759///
2760/// Delim runs whose flanking rules forbid both opening *and* closing
2761/// (e.g. intraword `_` inside `foo_bar`) are skipped: those bytes were
2762/// never a pairing candidate, so an "unmatched" count for them isn't
2763/// evidence of a failed emphasis attempt. Without this exclusion every
2764/// URL or identifier with an underscore inside an unresolved bracket
2765/// pair would spuriously degrade the bracket-shape to literal text.
2766fn range_has_unmatched_delim_bytes(events: &[IrEvent], lo: usize, hi: usize) -> bool {
2767    let hi = hi.min(events.len());
2768    for ev in &events[lo..hi] {
2769        if let IrEvent::DelimRun {
2770            start,
2771            end,
2772            matches,
2773            can_open,
2774            can_close,
2775            ..
2776        } = ev
2777        {
2778            if !can_open && !can_close {
2779                continue;
2780            }
2781            let total = end - start;
2782            let matched: usize = matches.iter().map(|m| m.len as usize).sum();
2783            if matched < total {
2784                return true;
2785            }
2786        }
2787    }
2788    false
2789}
2790
2791/// Thread-local pool of scratch buffers used by [`build_full_plans`].
2792///
2793/// `build_full_plans` checks out one bundle for the duration of the call
2794/// and returns it on drop so the next call (or a recursive nested call
2795/// from an inline emitter) reuses the allocations. The pool is
2796/// per-thread — the parser is single-threaded — and bounded so a
2797/// long-running editor session can't accumulate stale capacity.
2798struct ScratchEvents {
2799    inner: Option<ScratchBundle>,
2800}
2801
2802#[derive(Default)]
2803struct ScratchBundle {
2804    events: Vec<IrEvent>,
2805    bracket_pairs: Vec<(usize, usize)>,
2806    excluded: Vec<bool>,
2807}
2808
2809thread_local! {
2810    static IR_EVENT_POOL: std::cell::RefCell<Vec<ScratchBundle>> =
2811        const { std::cell::RefCell::new(Vec::new()) };
2812}
2813
2814impl ScratchEvents {
2815    fn checkout() -> Self {
2816        let bundle = IR_EVENT_POOL
2817            .with(|p| p.borrow_mut().pop())
2818            .unwrap_or_default();
2819        Self {
2820            inner: Some(bundle),
2821        }
2822    }
2823}
2824
2825impl Drop for ScratchEvents {
2826    fn drop(&mut self) {
2827        if let Some(mut bundle) = self.inner.take() {
2828            bundle.events.clear();
2829            bundle.bracket_pairs.clear();
2830            bundle.excluded.clear();
2831            // Cap pool depth at 8 (deepest realistic nested-link recursion)
2832            // and drop any bundle whose `events` grew past 8K (a single
2833            // pathological paragraph shouldn't pin a huge allocation
2834            // forever).
2835            if bundle.events.capacity() <= 8192 {
2836                IR_EVENT_POOL.with(|p| {
2837                    let mut pool = p.borrow_mut();
2838                    if pool.len() < 8 {
2839                        pool.push(bundle);
2840                    }
2841                });
2842            }
2843        }
2844    }
2845}
2846
2847/// Bundle of plans produced by [`build_full_plans`] and consumed by the
2848/// inline emission walk.
2849#[derive(Debug, Default, Clone)]
2850pub struct InlinePlans {
2851    pub emphasis: EmphasisPlan,
2852    pub brackets: BracketPlan,
2853    pub constructs: ConstructPlan,
2854}
2855
2856/// Convert the IR's delim-run match decisions into an [`EmphasisPlan`],
2857/// preserving the byte-keyed disposition shape the existing emission walk
2858/// consumes.
2859///
2860/// Each match on a [`DelimRun`](IrEvent::DelimRun) produces one entry in
2861/// the plan: the opener side records `Open` with the partner's source
2862/// byte and length; the closer side records `Close`. Bytes within a run
2863/// that are *not* covered by any match get a `Literal` entry, which the
2864/// emission walk uses to coalesce unmatched delimiter bytes with
2865/// surrounding plain text.
2866pub fn build_emphasis_plan(events: &[IrEvent]) -> EmphasisPlan {
2867    let mut by_pos: BTreeMap<usize, DelimChar> = BTreeMap::new();
2868    for ev in events {
2869        if let IrEvent::DelimRun {
2870            start,
2871            end,
2872            matches,
2873            ..
2874        } = ev
2875        {
2876            for m in matches {
2877                let pos = *start + m.offset_in_run as usize;
2878                let partner_run_start = match &events[m.partner_event as usize] {
2879                    IrEvent::DelimRun { start: ps, .. } => *ps,
2880                    _ => continue,
2881                };
2882                let partner_pos = partner_run_start + m.partner_offset as usize;
2883                if m.is_opener {
2884                    by_pos.insert(
2885                        pos,
2886                        DelimChar::Open {
2887                            len: m.len,
2888                            partner: partner_pos,
2889                            partner_len: m.len,
2890                            kind: m.kind,
2891                        },
2892                    );
2893                } else {
2894                    by_pos.insert(pos, DelimChar::Close);
2895                }
2896            }
2897            // Any remaining bytes (not covered by a match) are literal.
2898            for pos in *start..*end {
2899                by_pos.entry(pos).or_insert(DelimChar::Literal);
2900            }
2901        }
2902    }
2903    EmphasisPlan::from_dispositions(by_pos)
2904}
2905
2906#[cfg(test)]
2907mod tests {
2908    use super::*;
2909    use crate::options::Flavor;
2910    use crate::parser::inlines::inline_ir::DelimChar;
2911    use std::sync::Arc;
2912
2913    fn cm_opts() -> ParserOptions {
2914        let flavor = Flavor::CommonMark;
2915        ParserOptions {
2916            flavor,
2917            dialect: crate::options::Dialect::for_flavor(flavor),
2918            extensions: crate::options::Extensions::for_flavor(flavor),
2919            pandoc_compat: crate::options::PandocCompat::default(),
2920            crossref_prefixes: Vec::new(),
2921            refdef_labels: None,
2922        }
2923    }
2924
2925    fn refdefs<I: IntoIterator<Item = &'static str>>(labels: I) -> RefdefMap {
2926        Arc::new(labels.into_iter().map(|s| s.to_string()).collect())
2927    }
2928
2929    #[test]
2930    fn ir_event_range_covers_all_variants() {
2931        let txt = IrEvent::Text { start: 0, end: 5 };
2932        assert_eq!(txt.range(), (0, 5));
2933
2934        let close = IrEvent::CloseBracket {
2935            pos: 7,
2936            matched: false,
2937        };
2938        assert_eq!(close.range(), (7, 8));
2939
2940        let open = IrEvent::OpenBracket {
2941            start: 1,
2942            end: 3,
2943            is_image: true,
2944            active: true,
2945            resolution: None,
2946            unresolved_ref: None,
2947        };
2948        assert_eq!(open.range(), (1, 3));
2949    }
2950
2951    #[test]
2952    fn scan_records_text_and_delim_run() {
2953        let opts = cm_opts();
2954        let ir = build_ir("foo *bar*", 0, 9, &opts);
2955        // Expect: Text "foo ", DelimRun "*", Text "bar", DelimRun "*"
2956        assert!(matches!(ir[0], IrEvent::Text { start: 0, end: 4 }));
2957        assert!(matches!(
2958            ir[1],
2959            IrEvent::DelimRun {
2960                ch: b'*',
2961                start: 4,
2962                end: 5,
2963                ..
2964            }
2965        ));
2966        assert!(matches!(ir[2], IrEvent::Text { start: 5, end: 8 }));
2967        assert!(matches!(
2968            ir[3],
2969            IrEvent::DelimRun {
2970                ch: b'*',
2971                start: 8,
2972                end: 9,
2973                ..
2974            }
2975        ));
2976    }
2977
2978    #[test]
2979    fn scan_records_brackets() {
2980        let opts = cm_opts();
2981        let ir = build_ir("[foo]", 0, 5, &opts);
2982        assert!(matches!(
2983            ir[0],
2984            IrEvent::OpenBracket {
2985                start: 0,
2986                end: 1,
2987                is_image: false,
2988                ..
2989            }
2990        ));
2991        assert!(matches!(ir[1], IrEvent::Text { start: 1, end: 4 }));
2992        assert!(matches!(
2993            ir[2],
2994            IrEvent::CloseBracket {
2995                pos: 4,
2996                matched: false
2997            }
2998        ));
2999    }
3000
3001    #[test]
3002    fn scan_records_image_bracket() {
3003        let opts = cm_opts();
3004        let ir = build_ir("![alt]", 0, 6, &opts);
3005        assert!(matches!(
3006            ir[0],
3007            IrEvent::OpenBracket {
3008                start: 0,
3009                end: 2,
3010                is_image: true,
3011                ..
3012            }
3013        ));
3014    }
3015
3016    #[test]
3017    fn scan_handles_code_span_opacity() {
3018        let opts = cm_opts();
3019        let ir = build_ir("a `*x*` b", 0, 9, &opts);
3020        // Code span `*x*` should be a Construct, NOT delim runs.
3021        let has_delim_run = ir.iter().any(|e| matches!(e, IrEvent::DelimRun { .. }));
3022        assert!(
3023            !has_delim_run,
3024            "code span content should not produce delim runs"
3025        );
3026        assert!(ir.iter().any(|e| matches!(
3027            e,
3028            IrEvent::Construct {
3029                kind: ConstructKind::CodeSpan,
3030                ..
3031            }
3032        )));
3033    }
3034
3035    #[test]
3036    fn process_emphasis_simple_pair() {
3037        let opts = cm_opts();
3038        let mut ir = build_ir("*foo*", 0, 5, &opts);
3039        process_emphasis(&mut ir, opts.dialect);
3040        // First DelimRun (open) gets a match.
3041        let opener = ir
3042            .iter()
3043            .find(|e| matches!(e, IrEvent::DelimRun { start: 0, .. }))
3044            .unwrap();
3045        if let IrEvent::DelimRun { matches, .. } = opener {
3046            assert_eq!(matches.len(), 1);
3047            assert!(matches[0].is_opener);
3048            assert_eq!(matches[0].kind, EmphasisKind::Emph);
3049        }
3050    }
3051
3052    #[test]
3053    fn brackets_resolve_inline_link() {
3054        let opts = cm_opts();
3055        let mut ir = build_ir("[foo](/url)", 0, 11, &opts);
3056        process_brackets(&mut ir, "[foo](/url)", None, opts.dialect, false);
3057        let open = ir
3058            .iter()
3059            .find(|e| matches!(e, IrEvent::OpenBracket { start: 0, .. }))
3060            .unwrap();
3061        if let IrEvent::OpenBracket { resolution, .. } = open {
3062            let r = resolution.as_ref().expect("inline link resolved");
3063            assert!(matches!(r.kind, LinkKind::Inline { .. }));
3064            if let LinkKind::Inline { dest, .. } = &r.kind {
3065                assert_eq!(dest, "/url");
3066            }
3067        }
3068    }
3069
3070    #[test]
3071    fn brackets_shortcut_resolves_only_with_refdef() {
3072        let opts = cm_opts();
3073        let text = "[foo]";
3074        let map = refdefs(["foo"]);
3075        let mut ir = build_ir(text, 0, text.len(), &opts);
3076        process_brackets(&mut ir, text, Some(&map), opts.dialect, false);
3077        let open = ir
3078            .iter()
3079            .find(|e| matches!(e, IrEvent::OpenBracket { start: 0, .. }))
3080            .unwrap();
3081        if let IrEvent::OpenBracket { resolution, .. } = open {
3082            assert!(matches!(
3083                resolution.as_ref().unwrap().kind,
3084                LinkKind::ShortcutReference
3085            ));
3086        }
3087    }
3088
3089    #[test]
3090    fn brackets_shortcut_falls_through_without_refdef() {
3091        // CMark example #523 mechanic: `[bar* baz]` is not a refdef, so
3092        // it must NOT resolve as a link — the brackets stay literal so
3093        // the inner `*` becomes available to the outer emphasis scanner.
3094        let opts = cm_opts();
3095        let text = "[bar* baz]";
3096        let mut ir = build_ir(text, 0, text.len(), &opts);
3097        process_brackets(&mut ir, text, None, opts.dialect, false);
3098        let open = ir
3099            .iter()
3100            .find(|e| matches!(e, IrEvent::OpenBracket { start: 0, .. }))
3101            .unwrap();
3102        if let IrEvent::OpenBracket { resolution, .. } = open {
3103            assert!(resolution.is_none(), "no refdef → bracket stays literal");
3104        }
3105    }
3106
3107    /// Spec #473: `*[bar*](/url)`. The link `[bar*](/url)` resolves; the
3108    /// outer `*...*` MUST NOT pair across the link's bracket boundary,
3109    /// because the inner `*` belongs to the link text.
3110    #[test]
3111    fn full_plans_emphasis_does_not_cross_resolved_link_boundary() {
3112        let opts = cm_opts();
3113        let text = "*[bar*](/url)";
3114        let plans = build_full_plans(text, 0, text.len(), &opts);
3115        // The leading `*` (at byte 0) must NOT be matched as an emphasis
3116        // opener — there's no closer outside the link, and the inner `*`
3117        // (at byte 5) is inside the resolved link's text range so it must
3118        // not be paired with byte 0.
3119        assert!(
3120            matches!(plans.emphasis.lookup(0), Some(DelimChar::Literal) | None),
3121            "outer `*` at byte 0 must not pair across link boundary, got {:?}",
3122            plans.emphasis.lookup(0)
3123        );
3124        // The link `[bar*](/url)` must resolve (opener at byte 1).
3125        assert!(
3126            matches!(plans.brackets.lookup(1), Some(BracketDispo::Open { .. })),
3127            "link [bar*](/url) must resolve at byte 1"
3128        );
3129    }
3130
3131    fn pandoc_opts() -> ParserOptions {
3132        let flavor = Flavor::Pandoc;
3133        ParserOptions {
3134            flavor,
3135            dialect: crate::options::Dialect::for_flavor(flavor),
3136            extensions: crate::options::Extensions::for_flavor(flavor),
3137            pandoc_compat: crate::options::PandocCompat::default(),
3138            crossref_prefixes: Vec::new(),
3139            refdef_labels: None,
3140        }
3141    }
3142
3143    /// Bug #2 (a): unresolved Pandoc bracket-shape with unmatched delim
3144    /// inside its text degrades to literal `[`/`]`. Outer emphasis pair
3145    /// across the (now-literal) brackets must form.
3146    #[test]
3147    fn full_plans_unresolved_bracket_degrades_when_inner_delim_unmatched() {
3148        let opts = pandoc_opts();
3149        let text = "*foo [bar*] baz*";
3150        let plans = build_full_plans(text, 0, text.len(), &opts);
3151        assert!(
3152            matches!(plans.brackets.lookup(5), Some(BracketDispo::Literal) | None),
3153            "degraded `[` at byte 5 must be Literal/None, got {:?}",
3154            plans.brackets.lookup(5)
3155        );
3156        assert!(
3157            matches!(plans.emphasis.lookup(0), Some(DelimChar::Open { .. })),
3158            "outer `*` at byte 0 must open Emph after degrade, got {:?}",
3159            plans.emphasis.lookup(0)
3160        );
3161    }
3162
3163    /// Intraword `_` (e.g. inside a URL like
3164    /// `hyperparameter_optimization`) is not flanking — `can_open` and
3165    /// `can_close` are both false — so it can never pair as emphasis.
3166    /// The degrade pass must not treat such delim runs as "failed
3167    /// emphasis attempts" and demote the surrounding bracket-shape to
3168    /// literal text, otherwise every URL/identifier inside an
3169    /// unresolved reference round-trips through `\[` / `\]` escapes
3170    /// under `tex_math_single_backslash` and reparses as display math.
3171    #[test]
3172    fn full_plans_unresolved_bracket_keeps_wrapper_with_intraword_underscore() {
3173        let opts = pandoc_opts();
3174        let text = "[foo_bar more]";
3175        let plans = build_full_plans(text, 0, text.len(), &opts);
3176        assert!(
3177            matches!(
3178                plans.brackets.lookup(0),
3179                Some(BracketDispo::UnresolvedReference { .. })
3180            ),
3181            "wrapper must be preserved across intraword `_`, got {:?}",
3182            plans.brackets.lookup(0)
3183        );
3184    }
3185
3186    /// Bug #2 (b): unresolved Pandoc bracket whose interior emphasis
3187    /// pairs cleanly keeps the wrapper (linter/LSP hook).
3188    #[test]
3189    fn full_plans_unresolved_bracket_keeps_wrapper_when_inner_paired() {
3190        let opts = pandoc_opts();
3191        let text = "[foo *bar*]";
3192        let plans = build_full_plans(text, 0, text.len(), &opts);
3193        assert!(
3194            matches!(
3195                plans.brackets.lookup(0),
3196                Some(BracketDispo::UnresolvedReference { .. })
3197            ),
3198            "wrapper must be preserved when inner emph pairs, got {:?}",
3199            plans.brackets.lookup(0)
3200        );
3201    }
3202
3203    /// Spec #533: `[foo *bar [baz][ref]*][ref]` with `[ref]: /uri`.
3204    /// Inner `[baz][ref]` resolves as a link; §6.3 link-in-link rule
3205    /// deactivates the outer `[foo ...][ref]` so it falls through to
3206    /// literal brackets. Emphasis `*bar [baz][ref]*` wraps the inner link.
3207    #[test]
3208    fn full_plans_link_in_link_suppression_for_reference_links() {
3209        let opts = cm_opts();
3210        let text = "[foo *bar [baz][ref]*][ref]";
3211        let mut opts_with_refs = opts.clone();
3212        let labels: HashSet<String> = ["ref".to_string()].into_iter().collect();
3213        opts_with_refs.refdef_labels = Some(std::sync::Arc::new(labels));
3214        let plans = build_full_plans(text, 0, text.len(), &opts_with_refs);
3215
3216        // Inner `[baz][ref]` opener is at byte 10 — must resolve.
3217        assert!(
3218            matches!(plans.brackets.lookup(10), Some(BracketDispo::Open { .. })),
3219            "inner [baz][ref] must resolve at byte 10, got {:?}",
3220            plans.brackets.lookup(10)
3221        );
3222        // Outer `[foo ...][ref]` opener is at byte 0 — must NOT resolve
3223        // (link-in-link suppression).
3224        assert!(
3225            matches!(plans.brackets.lookup(0), Some(BracketDispo::Literal) | None),
3226            "outer [foo ...][ref] must fall through to literal at byte 0, got {:?}",
3227            plans.brackets.lookup(0)
3228        );
3229        // Trailing `[ref]` after the outer `]` is at byte 22 — it's a
3230        // standalone shortcut reference and must resolve.
3231        assert!(
3232            matches!(plans.brackets.lookup(22), Some(BracketDispo::Open { .. })),
3233            "trailing [ref] must resolve at byte 22, got {:?}",
3234            plans.brackets.lookup(22)
3235        );
3236        // Emphasis `*...*` at bytes 5 and 20 must pair — the scoped
3237        // emphasis pass over the (deactivated) outer bracket's inner
3238        // event range pairs these.
3239        assert!(
3240            matches!(plans.emphasis.lookup(5), Some(DelimChar::Open { .. })),
3241            "emphasis opener at byte 5 must pair, got {:?}",
3242            plans.emphasis.lookup(5)
3243        );
3244    }
3245}