Skip to main content

panache_parser/parser/inlines/
core.rs

1//! Inline emission walk.
2//!
3//! Consumes the IR plans built by [`super::inline_ir::build_full_plans`]
4//! (emphasis pairings, bracket resolutions, standalone Pandoc constructs)
5//! and emits the inline CST tokens / nodes in source order. Resolution
6//! decisions for emphasis, brackets, and standalone Pandoc constructs
7//! are entirely IR-driven for both dialects; the dispatcher's
8//! `try_parse_*` recognizers are still called to *parse* a matched byte
9//! range into a CST subtree, but "what is this byte range?" is answered
10//! exclusively by the IR.
11
12use super::sink::InlineSink;
13use crate::options::{Dialect, ParserOptions};
14use crate::syntax::SyntaxKind;
15#[cfg(test)]
16use rowan::GreenNodeBuilder;
17
18use super::inline_ir::{
19    BracketPlan, ConstructDispo, ConstructPlan, DelimChar, EmphasisKind, EmphasisPlan,
20};
21
22// Import inline element parsers from sibling modules
23use super::bookdown::{
24    try_parse_bookdown_definition, try_parse_bookdown_reference, try_parse_bookdown_text_reference,
25};
26use super::bracketed_spans::{emit_bracketed_span, try_parse_bracketed_span};
27use super::citations::{
28    emit_bare_citation, emit_bracketed_citation, try_parse_bare_citation,
29    try_parse_bracketed_citation,
30};
31use super::code_spans::{emit_code_span, try_parse_code_span};
32use super::emoji::{emit_emoji, try_parse_emoji};
33use super::escapes::{EscapeType, emit_escape, try_parse_escape};
34use super::inline_executable::{emit_inline_executable, try_parse_inline_executable};
35use super::inline_footnotes::{
36    emit_footnote_reference, emit_inline_footnote, try_parse_footnote_reference,
37    try_parse_inline_footnote,
38};
39use super::inline_html::{emit_inline_html, try_parse_inline_html};
40use super::latex::{parse_latex_command, try_parse_latex_command};
41use super::links::{
42    LinkScanContext, emit_autolink, emit_bare_uri_link, emit_inline_image, emit_inline_link,
43    emit_reference_image, emit_reference_link, emit_unresolved_reference, try_parse_autolink,
44    try_parse_bare_uri, try_parse_inline_image, try_parse_inline_link, try_parse_reference_image,
45    try_parse_reference_link,
46};
47use super::mark::{emit_mark, try_parse_mark};
48use super::math::{
49    emit_display_math, emit_display_math_environment, emit_double_backslash_display_math,
50    emit_double_backslash_inline_math, emit_gfm_inline_math, emit_inline_math,
51    emit_single_backslash_display_math, emit_single_backslash_inline_math, math_opts,
52    try_parse_display_math, try_parse_double_backslash_display_math,
53    try_parse_double_backslash_inline_math, try_parse_gfm_inline_math, try_parse_inline_math,
54    try_parse_math_environment, try_parse_single_backslash_display_math,
55    try_parse_single_backslash_inline_math,
56};
57use super::myst_roles::{emit_role, try_parse_role};
58use super::myst_substitutions::{emit_substitution, try_parse_substitution};
59use super::native_spans::{emit_native_span, try_parse_native_span};
60use super::raw_inline::is_raw_inline;
61use super::shortcodes::{emit_shortcode, try_parse_shortcode};
62use super::strikeout::{emit_strikeout, try_parse_strikeout};
63use super::subscript::{emit_subscript, try_parse_subscript};
64use super::superscript::{emit_superscript, try_parse_superscript};
65use super::svelte::{emit_svelte_template, try_parse_svelte_template};
66
67/// Parse inline text into the CST builder.
68///
69/// Top-level entry point for inline parsing. Builds the IR plans
70/// (emphasis pairings, bracket resolutions, standalone Pandoc constructs)
71/// once via [`super::inline_ir::build_full_plans`], then walks the byte
72/// range left-to-right consulting those plans plus the dispatcher's
73/// ordered-try chain for non-IR-resolved constructs (autolinks, code
74/// spans, escapes, math, etc.). Dialect-specific behavior is selected
75/// inside `build_full_plans`.
76///
77/// # Arguments
78/// * `text` - The inline text to parse
79/// * `config` - Configuration for extensions and formatting
80/// * `builder` - The CST builder to emit nodes to
81/// * `suppress_footnote_refs` - When `true`, `[^id]` bytes are emitted as
82///   literal TEXT instead of `FOOTNOTE_REFERENCE`. Set by block parsers when
83///   the inline content lives inside a reference-style footnote definition
84///   body, where pandoc silently drops nested footnote references.
85pub fn parse_inline_text_recursive(
86    builder: &mut impl InlineSink,
87    text: &str,
88    config: &ParserOptions,
89    suppress_footnote_refs: bool,
90) {
91    log::trace!(
92        "Recursive inline parsing: {:?} ({} bytes)",
93        &text[..text.len().min(40)],
94        text.len()
95    );
96
97    let mask = structural_byte_mask(config);
98    if try_emit_plain_text_fast_path_with_mask(builder, text, &mask) {
99        log::trace!("Recursive inline parsing complete (plain-text fast path)");
100        return;
101    }
102
103    let plans = super::inline_ir::build_full_plans(text, 0, text.len(), config);
104    parse_inline_range_impl(
105        text,
106        0,
107        text.len(),
108        config,
109        builder,
110        false,
111        &plans.emphasis,
112        &plans.brackets,
113        &plans.constructs,
114        false,
115        suppress_footnote_refs,
116        &mask,
117    );
118
119    log::trace!("Recursive inline parsing complete");
120}
121
122/// Parse inline elements from text content nested inside a link/image/span.
123///
124/// Used for recursive inline parsing of link text, image alt, span content, etc.
125/// Suppresses constructs that would create nested links (CommonMark §6.3 forbids
126/// links inside links), notably extended bare-URI autolinks under GFM.
127///
128/// `suppress_inner_links` should be `true` when the recursion is for a
129/// LINK or REFERENCE-LINK's text, where inner link / reference-link
130/// brackets must emit as literal text (pandoc-native:
131/// `[link [inner](u2)](u1)` → outer `Link` with `Str "[inner](u2)"`).
132/// Image alt text and all non-link contexts pass `false`:
133/// pandoc-native verifies `![alt with [inner](u)](u2)` keeps the inner
134/// `Link`, and bracketed spans / native spans / inline footnotes /
135/// emphasis all allow nested links.
136pub fn parse_inline_text(
137    builder: &mut impl InlineSink,
138    text: &str,
139    config: &ParserOptions,
140    suppress_inner_links: bool,
141    suppress_footnote_refs: bool,
142) {
143    log::trace!(
144        "Parsing inline text (nested in link): {:?} ({} bytes)",
145        &text[..text.len().min(40)],
146        text.len()
147    );
148
149    let mask = structural_byte_mask(config);
150    if try_emit_plain_text_fast_path_with_mask(builder, text, &mask) {
151        return;
152    }
153
154    let plans = super::inline_ir::build_full_plans(text, 0, text.len(), config);
155    parse_inline_range_impl(
156        text,
157        0,
158        text.len(),
159        config,
160        builder,
161        true,
162        &plans.emphasis,
163        &plans.brackets,
164        &plans.constructs,
165        suppress_inner_links,
166        suppress_footnote_refs,
167        &mask,
168    );
169}
170
171/// Plain-text fast path for inline ranges with no structural bytes.
172///
173/// Returns `true` if the range was emitted as a single `TEXT` token and
174/// the caller should skip the IR + dispatcher pipeline. Returns `false`
175/// if any structural byte appears (or the range is empty), letting the
176/// caller proceed normally. Empty input returns `false` so the caller's
177/// existing "no events → no output" path is preserved exactly.
178///
179/// The structural byte set is computed from `config.dialect` and
180/// `config.extensions` so prose containing dialect-irrelevant punctuation
181/// (e.g. `-` outside a citation flavor) doesn't unnecessarily disable the
182/// fast path. `\n` and `\r` are always structural — multi-line inline
183/// content must still split into TEXT + NEWLINE tokens like the slow path.
184fn try_emit_plain_text_fast_path_with_mask(
185    builder: &mut impl InlineSink,
186    text: &str,
187    mask: &[bool; 256],
188) -> bool {
189    if text.is_empty() {
190        return false;
191    }
192    for &b in text.as_bytes() {
193        if mask[b as usize] {
194            return false;
195        }
196    }
197    builder.token(SyntaxKind::TEXT.into(), text);
198    true
199}
200
201/// Build a 256-entry byte mask: `mask[b]` is `true` iff byte `b` could
202/// trigger any IR-recognised construct or dispatcher branch under the
203/// current dialect/extensions. Used by the plain-text fast path to scan
204/// inline ranges in a single pass.
205fn structural_byte_mask(config: &ParserOptions) -> [bool; 256] {
206    let mut mask = [false; 256];
207    let exts = &config.extensions;
208    let pandoc = config.dialect == Dialect::Pandoc;
209
210    // Always structural: line breaks (CST splits TEXT/NEWLINE), backslash
211    // (escape / hard break / backslash-math / latex / bookdown ref),
212    // backtick (code span / inline executable), `*`/`_` (emphasis is a
213    // core CommonMark construct, not extension-gated), and `[`/`]` if
214    // any bracket-shaped construct is reachable.
215    mask[b'\n' as usize] = true;
216    mask[b'\r' as usize] = true;
217    mask[b'\\' as usize] = true;
218    mask[b'`' as usize] = true;
219    mask[b'*' as usize] = true;
220    mask[b'_' as usize] = true;
221
222    // Brackets: the IR/dispatcher only acts on `[`/`]` if some
223    // bracket-shaped feature is reachable. `!` is the leading byte of
224    // `![alt]` image brackets — the IR's `BracketPlan` keys image
225    // openers at the `!` position, so the dispatcher must stop here
226    // to consult the plan.
227    if exts.inline_links
228        || exts.reference_links
229        || exts.inline_images
230        || exts.bracketed_spans
231        || exts.footnotes
232        || exts.citations
233    {
234        mask[b'[' as usize] = true;
235        mask[b']' as usize] = true;
236    }
237    if exts.inline_images || exts.reference_links {
238        mask[b'!' as usize] = true;
239    }
240
241    // `<` covers autolinks, raw HTML, and Pandoc native spans.
242    if exts.autolinks || exts.raw_html || exts.native_spans {
243        mask[b'<' as usize] = true;
244    }
245
246    // `^` covers Pandoc inline footnotes (`^[...]`), CM inline footnotes
247    // (when explicitly enabled), and superscript (`^text^`).
248    if exts.inline_footnotes || exts.superscript {
249        mask[b'^' as usize] = true;
250    }
251
252    // `@` and `-` cover Pandoc citation forms (`@cite`, `-@cite`,
253    // `[@cite]`). Under Pandoc dialect, the IR's `ConstructPlan` keys
254    // bare citations at the `@` or `-` position, so the dispatcher
255    // must stop at either to consult the plan. Including `-` is
256    // pessimistic — most prose hyphens won't form `-@` — but missing
257    // it would skip past valid suppress-author citations.
258    if exts.citations || exts.quarto_crossrefs {
259        mask[b'@' as usize] = true;
260        if pandoc {
261            mask[b'-' as usize] = true;
262        }
263    }
264
265    // `$` covers dollar-math and GFM math.
266    if exts.tex_math_dollars || exts.tex_math_gfm {
267        mask[b'$' as usize] = true;
268    }
269
270    // `~` covers subscript and strikeout (both `~text~` and `~~text~~`).
271    if exts.subscript || exts.strikeout {
272        mask[b'~' as usize] = true;
273    }
274
275    if exts.mark {
276        mask[b'=' as usize] = true;
277    }
278    if exts.emoji {
279        mask[b':' as usize] = true;
280    }
281    if exts.bookdown_references {
282        mask[b'(' as usize] = true;
283    }
284    // `{{< ... >}}` shortcodes: the dispatcher tries them on any
285    // `{` regardless of the `quarto_shortcodes` extension flag, so
286    // `{` must always be flagged here.
287    mask[b'{' as usize] = true;
288
289    // Bare-URI autolinks (`http://...` without `<>`) have no
290    // leading-byte gate in the dispatcher — `try_parse_bare_uri`
291    // probes for a URI scheme starting at every byte. Flag all
292    // ASCII alphabetic bytes so the bulk-skip stops on every
293    // potential scheme starter. This effectively disables the
294    // bulk-skip benefit for prose under GFM-style flavors but
295    // preserves correctness; ASCII digits / punctuation / non-ASCII
296    // bytes still skip cleanly.
297    if exts.autolink_bare_uris {
298        for b in b'a'..=b'z' {
299            mask[b as usize] = true;
300        }
301        for b in b'A'..=b'Z' {
302            mask[b as usize] = true;
303        }
304    }
305
306    mask
307}
308
309/// Whether a bare-URI autolink may start at `pos`.
310///
311/// Pandoc only recognizes a bare URI (`autolink_bare_uris`) at a left word
312/// boundary: a scheme immediately preceded by an alphanumeric (`squa` in
313/// `squares:`) or an intraword `.` (`x.res:`, `e.g.foo:`) stays literal. The
314/// dispatcher probes every alphabetic byte for a scheme, so without this guard
315/// a scheme embedded mid-word matches and can swallow following markers (e.g.
316/// the `**` closing a strong span in `**…squares:**`).
317fn bare_uri_has_left_boundary(text: &str, pos: usize) -> bool {
318    if pos == 0 {
319        return true;
320    }
321    match text[..pos].chars().next_back() {
322        Some(prev) => !(prev.is_alphanumeric() || prev == '.'),
323        None => true,
324    }
325}
326
327fn is_emoji_boundary(text: &str, pos: usize) -> bool {
328    if pos > 0 {
329        let prev = text.as_bytes()[pos - 1] as char;
330        if prev.is_ascii_alphanumeric() || prev == '_' {
331            return false;
332        }
333    }
334    true
335}
336
337#[inline]
338fn advance_char_boundary(text: &str, pos: usize, end: usize) -> usize {
339    if pos >= end || pos >= text.len() {
340        return pos;
341    }
342    let ch_len = text[pos..]
343        .chars()
344        .next()
345        .map_or(1, std::primitive::char::len_utf8);
346    (pos + ch_len).min(end)
347}
348
349#[allow(clippy::too_many_arguments)]
350fn parse_inline_range_impl(
351    text: &str,
352    start: usize,
353    end: usize,
354    config: &ParserOptions,
355    builder: &mut impl InlineSink,
356    nested_in_link: bool,
357    plan: &EmphasisPlan,
358    bracket_plan: &BracketPlan,
359    construct_plan: &ConstructPlan,
360    suppress_inner_links: bool,
361    suppress_footnote_refs: bool,
362    mask: &[bool; 256],
363) {
364    log::trace!(
365        "parse_inline_range: start={}, end={}, text={:?}",
366        start,
367        end,
368        &text[start..end]
369    );
370    let mut pos = start;
371    let mut text_start = start;
372    let bytes = text.as_bytes();
373
374    while pos < end {
375        // Bulk-skip plain bytes between structural bytes. Plans
376        // (`construct_plan`, `bracket_plan`, emphasis `plan`) only
377        // resolve at structural byte positions, so skipping here
378        // never elides a real match. `text_start` is preserved
379        // across the skip; the next emitted construct flushes the
380        // accumulated TEXT span.
381        if !mask[bytes[pos] as usize] {
382            let mut next = pos + 1;
383            while next < end && !mask[bytes[next] as usize] {
384                next += 1;
385            }
386            pos = next;
387            if pos >= end {
388                break;
389            }
390        }
391        // IR-driven dispatch: if the IR identified a Pandoc standalone
392        // construct starting here, emit it directly. Bypasses the
393        // dispatcher's ordered-try chain for inline footnotes, native
394        // spans, footnote references, citations, and bracketed spans
395        // under `Dialect::Pandoc`. The IR scan gates these on
396        // `!is_commonmark` and the relevant extension flag, so this
397        // branch is empty under CommonMark dialect (where the legacy
398        // dispatcher branches still run when the extension is enabled).
399        if let Some(dispo) = construct_plan.lookup(pos) {
400            match *dispo {
401                ConstructDispo::InlineFootnote { end: dispo_end } => {
402                    if dispo_end <= end
403                        && let Some((len, content)) = try_parse_inline_footnote(&text[pos..])
404                        && pos + len == dispo_end
405                    {
406                        if pos > text_start {
407                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
408                        }
409                        log::trace!("IR: matched inline footnote at pos {}", pos);
410                        emit_inline_footnote(builder, content, config, suppress_footnote_refs);
411                        pos += len;
412                        text_start = pos;
413                        continue;
414                    }
415                }
416                ConstructDispo::NativeSpan { end: dispo_end } => {
417                    if dispo_end <= end
418                        && let Some((len, content, _attributes)) =
419                            try_parse_native_span(&text[pos..])
420                        && pos + len == dispo_end
421                    {
422                        if pos > text_start {
423                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
424                        }
425                        log::trace!("IR: matched native span at pos {}", pos);
426                        emit_native_span(
427                            builder,
428                            &text[pos..pos + len],
429                            content,
430                            config,
431                            suppress_footnote_refs,
432                        );
433                        pos += len;
434                        text_start = pos;
435                        continue;
436                    }
437                }
438                ConstructDispo::FootnoteReference { end: dispo_end } => {
439                    if !suppress_footnote_refs
440                        && dispo_end <= end
441                        && let Some((len, id)) = try_parse_footnote_reference(&text[pos..])
442                        && pos + len == dispo_end
443                    {
444                        if pos > text_start {
445                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
446                        }
447                        log::trace!("IR: matched footnote reference at pos {}", pos);
448                        emit_footnote_reference(builder, &id);
449                        pos += len;
450                        text_start = pos;
451                        continue;
452                    }
453                }
454                ConstructDispo::BracketedCitation { end: dispo_end } => {
455                    if dispo_end <= end
456                        && let Some((len, content)) = try_parse_bracketed_citation(&text[pos..])
457                        && pos + len == dispo_end
458                    {
459                        if pos > text_start {
460                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
461                        }
462                        log::trace!("IR: matched bracketed citation at pos {}", pos);
463                        emit_bracketed_citation(builder, content);
464                        pos += len;
465                        text_start = pos;
466                        continue;
467                    }
468                }
469                ConstructDispo::BareCitation { end: dispo_end } => {
470                    if dispo_end <= end
471                        && let Some((len, key, has_suppress)) =
472                            try_parse_bare_citation(&text[pos..])
473                        && pos + len == dispo_end
474                    {
475                        let is_crossref = config.extensions.quarto_crossrefs
476                            && super::citations::is_crossref_key(key, &config.crossref_prefixes);
477                        if is_crossref || config.extensions.citations {
478                            if pos > text_start {
479                                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
480                            }
481                            if is_crossref {
482                                log::trace!("IR: matched Quarto crossref at pos {}: {}", pos, key);
483                                super::citations::emit_crossref(builder, key, has_suppress);
484                            } else {
485                                log::trace!("IR: matched bare citation at pos {}: {}", pos, key);
486                                emit_bare_citation(builder, key, has_suppress);
487                            }
488                            pos += len;
489                            text_start = pos;
490                            continue;
491                        }
492                    }
493                }
494                ConstructDispo::BracketedSpan { end: dispo_end } => {
495                    if dispo_end <= end
496                        && let Some((len, content, attrs)) = try_parse_bracketed_span(&text[pos..])
497                        && pos + len == dispo_end
498                    {
499                        if pos > text_start {
500                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
501                        }
502                        log::trace!("IR: matched bracketed span at pos {}", pos);
503                        emit_bracketed_span(
504                            builder,
505                            &content,
506                            &attrs,
507                            config,
508                            suppress_footnote_refs,
509                        );
510                        pos += len;
511                        text_start = pos;
512                        continue;
513                    }
514                }
515                ConstructDispo::WikiLink { end: dispo_end } => {
516                    if dispo_end <= end
517                        && let Some(span) = super::wikilinks::try_parse_wikilink(text, pos, config)
518                        && span.end == dispo_end
519                    {
520                        if pos > text_start {
521                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
522                        }
523                        log::trace!("IR: matched wikilink at pos {}", pos);
524                        super::wikilinks::emit_wikilink(builder, text, span, config);
525                        pos = span.end;
526                        text_start = pos;
527                        continue;
528                    }
529                }
530            }
531        }
532
533        // IR-driven bracket dispatch: if the IR's `process_brackets`
534        // resolved a bracket pair starting at this position, emit it
535        // directly via the appropriate helper. The
536        // dispatcher's `try_parse_*` recognizers compute the actual
537        // byte length and extract content / attributes; the IR's
538        // `suffix_end` is used to constrain the dispatcher's match
539        // shape so the two pipelines agree on which link variant
540        // resolved (e.g. `[foo][bar]` with `bar` undefined and `foo`
541        // defined: IR resolves `[foo]` as shortcut, but the
542        // dispatcher's `try_parse_reference_link` would otherwise
543        // greedily return the full-ref shape). Suppression of inner
544        // LINK / REFERENCE LINK during LINK-text recursion is applied
545        // here (pandoc-native: outer-wins for nested links).
546        //
547        // Pandoc-extended `{.attrs}` after a link can extend the
548        // dispatcher's match length past the IR's `suffix_end`. The
549        // dispatcher's len is therefore constrained to
550        // `[suffix_end, end]` rather than required to equal
551        // `suffix_end` exactly.
552        // IR-driven dispatch: Pandoc unresolved bracket-shape pattern.
553        // Before emitting the `UNRESOLVED_REFERENCE` wrapper, give the
554        // dispatcher's lenient inline-link / inline-image parsers a
555        // chance to override. The IR's `try_inline_suffix` is stricter
556        // than pandoc-markdown for some destination shapes (URLs with
557        // spaces, titles with embedded quotes, shortcode-style braces);
558        // the dispatcher accepts those and produces a real LINK / IMAGE
559        // node — pandoc-native agrees. Without this override, valid
560        // pandoc links would degrade to `UNRESOLVED_REFERENCE` here.
561        if let Some(super::inline_ir::BracketDispo::UnresolvedReference {
562            is_image,
563            text_start: ref_text_start,
564            text_end: ref_text_end,
565            end: ref_end,
566        }) = bracket_plan.lookup(pos)
567        {
568            let is_image = *is_image;
569            let dispo_suffix_end = *ref_end;
570            let suppress = suppress_inner_links && !is_image;
571            if !suppress {
572                let ctx = LinkScanContext::from_options(config);
573                let is_commonmark = config.dialect == Dialect::CommonMark;
574                if is_image {
575                    if config.extensions.inline_images
576                        && let Some((len, alt_text, dest, attributes)) =
577                            try_parse_inline_image(&text[pos..], ctx)
578                        && pos + len >= dispo_suffix_end
579                        && pos + len <= end
580                    {
581                        if pos > text_start {
582                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
583                        }
584                        log::trace!(
585                            "IR: dispatcher overrode UnresolvedReference with inline image at pos {}",
586                            pos
587                        );
588                        emit_inline_image(
589                            builder,
590                            &text[pos..pos + len],
591                            alt_text,
592                            dest,
593                            attributes,
594                            config,
595                            suppress_footnote_refs,
596                        );
597                        pos += len;
598                        text_start = pos;
599                        continue;
600                    }
601                } else if config.extensions.inline_links
602                    && let Some((len, link_text, dest, attributes)) =
603                        try_parse_inline_link(&text[pos..], is_commonmark, ctx)
604                    && pos + len >= dispo_suffix_end
605                    && pos + len <= end
606                {
607                    if pos > text_start {
608                        builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
609                    }
610                    log::trace!(
611                        "IR: dispatcher overrode UnresolvedReference with inline link at pos {}",
612                        pos
613                    );
614                    emit_inline_link(
615                        builder,
616                        &text[pos..pos + len],
617                        link_text,
618                        dest,
619                        attributes,
620                        config,
621                        suppress_footnote_refs,
622                    );
623                    pos += len;
624                    text_start = pos;
625                    continue;
626                }
627            }
628
629            // Dispatcher didn't override; emit the wrapper.
630            let inner_text = &text[*ref_text_start..*ref_text_end];
631            let suffix_start = *ref_text_end + 1;
632            let label_suffix = if suffix_start < *ref_end {
633                Some(&text[suffix_start..*ref_end])
634            } else {
635                None
636            };
637            if pos > text_start {
638                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
639            }
640            log::trace!(
641                "IR: unresolved Pandoc reference shape at pos {}..{}",
642                pos,
643                ref_end
644            );
645            emit_unresolved_reference(
646                builder,
647                is_image,
648                inner_text,
649                label_suffix,
650                config,
651                suppress_footnote_refs,
652            );
653            pos = *ref_end;
654            text_start = pos;
655            continue;
656        }
657
658        if let Some(super::inline_ir::BracketDispo::Open {
659            is_image,
660            suffix_end,
661            ..
662        }) = bracket_plan.lookup(pos)
663        {
664            let is_image = *is_image;
665            let dispo_suffix_end = *suffix_end;
666            let suppress = suppress_inner_links && !is_image;
667            if !suppress {
668                let ctx = LinkScanContext::from_options(config);
669                let allow_shortcut = config.extensions.shortcut_reference_links;
670                let is_commonmark = config.dialect == Dialect::CommonMark;
671                if is_image {
672                    if config.extensions.inline_images
673                        && let Some((len, alt_text, dest, attributes)) =
674                            try_parse_inline_image(&text[pos..], ctx)
675                        && pos + len >= dispo_suffix_end
676                        && pos + len <= end
677                    {
678                        if pos > text_start {
679                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
680                        }
681                        log::trace!("IR: matched inline image at pos {}", pos);
682                        emit_inline_image(
683                            builder,
684                            &text[pos..pos + len],
685                            alt_text,
686                            dest,
687                            attributes,
688                            config,
689                            suppress_footnote_refs,
690                        );
691                        pos += len;
692                        text_start = pos;
693                        continue;
694                    }
695                    if config.extensions.reference_links
696                        && let Some((len, alt_text, reference, gap, is_shortcut)) =
697                            try_parse_reference_image(
698                                &text[pos..],
699                                allow_shortcut,
700                                config.extensions.spaced_reference_links,
701                            )
702                        && pos + len == dispo_suffix_end
703                        && pos + len <= end
704                    {
705                        if pos > text_start {
706                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
707                        }
708                        log::trace!("IR: matched reference image at pos {}", pos);
709                        emit_reference_image(
710                            builder,
711                            alt_text,
712                            &reference,
713                            gap,
714                            is_shortcut,
715                            config,
716                            suppress_footnote_refs,
717                        );
718                        pos += len;
719                        text_start = pos;
720                        continue;
721                    }
722                } else {
723                    if config.extensions.inline_links
724                        && let Some((len, link_text, dest, attributes)) =
725                            try_parse_inline_link(&text[pos..], is_commonmark, ctx)
726                        && pos + len >= dispo_suffix_end
727                        && pos + len <= end
728                    {
729                        if pos > text_start {
730                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
731                        }
732                        log::trace!("IR: matched inline link at pos {}", pos);
733                        emit_inline_link(
734                            builder,
735                            &text[pos..pos + len],
736                            link_text,
737                            dest,
738                            attributes,
739                            config,
740                            suppress_footnote_refs,
741                        );
742                        pos += len;
743                        text_start = pos;
744                        continue;
745                    }
746                    if config.extensions.reference_links
747                        && let Some((len, link_text, reference, gap, is_shortcut)) =
748                            try_parse_reference_link(
749                                &text[pos..],
750                                allow_shortcut,
751                                config.extensions.inline_links,
752                                config.extensions.spaced_reference_links,
753                                ctx,
754                            )
755                        && pos + len == dispo_suffix_end
756                        && pos + len <= end
757                    {
758                        if pos > text_start {
759                            builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
760                        }
761                        log::trace!("IR: matched reference link at pos {}", pos);
762                        emit_reference_link(
763                            builder,
764                            link_text,
765                            &reference,
766                            gap,
767                            is_shortcut,
768                            config,
769                            suppress_footnote_refs,
770                        );
771                        pos += len;
772                        text_start = pos;
773                        continue;
774                    }
775                }
776            }
777        }
778
779        let byte = text.as_bytes()[pos];
780
781        // Backslash math (highest priority if enabled)
782        if byte == b'\\' {
783            // Try double backslash display math first: \\[...\\]
784            if config.extensions.tex_math_double_backslash {
785                if let Some((len, content)) = try_parse_double_backslash_display_math(&text[pos..])
786                {
787                    if pos > text_start {
788                        builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
789                    }
790                    log::trace!("Matched double backslash display math at pos {}", pos);
791                    emit_double_backslash_display_math(builder, content, math_opts(config));
792                    pos += len;
793                    text_start = pos;
794                    continue;
795                }
796
797                // Try double backslash inline math: \\(...\\)
798                if let Some((len, content)) = try_parse_double_backslash_inline_math(
799                    &text[pos..],
800                    config.dialect == Dialect::Pandoc,
801                ) {
802                    if pos > text_start {
803                        builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
804                    }
805                    log::trace!("Matched double backslash inline math at pos {}", pos);
806                    emit_double_backslash_inline_math(builder, content, math_opts(config));
807                    pos += len;
808                    text_start = pos;
809                    continue;
810                }
811            }
812
813            // Try single backslash display math: \[...\]
814            if config.extensions.tex_math_single_backslash {
815                if let Some((len, content)) = try_parse_single_backslash_display_math(&text[pos..])
816                {
817                    if pos > text_start {
818                        builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
819                    }
820                    log::trace!("Matched single backslash display math at pos {}", pos);
821                    emit_single_backslash_display_math(builder, content, math_opts(config));
822                    pos += len;
823                    text_start = pos;
824                    continue;
825                }
826
827                // Try single backslash inline math: \(...\)
828                if let Some((len, content)) = try_parse_single_backslash_inline_math(
829                    &text[pos..],
830                    config.dialect == Dialect::Pandoc,
831                ) {
832                    if pos > text_start {
833                        builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
834                    }
835                    log::trace!("Matched single backslash inline math at pos {}", pos);
836                    emit_single_backslash_inline_math(builder, content, math_opts(config));
837                    pos += len;
838                    text_start = pos;
839                    continue;
840                }
841            }
842
843            // Try math environments \begin{equation}...\end{equation}
844            if config.extensions.raw_tex
845                && let Some((len, begin_marker, content, end_marker)) =
846                    try_parse_math_environment(&text[pos..])
847            {
848                if pos > text_start {
849                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
850                }
851                log::trace!("Matched math environment at pos {}", pos);
852                emit_display_math_environment(
853                    builder,
854                    begin_marker,
855                    content,
856                    end_marker,
857                    math_opts(config),
858                );
859                pos += len;
860                text_start = pos;
861                continue;
862            }
863
864            // Try bookdown reference: \@ref(label)
865            if config.extensions.bookdown_references
866                && let Some((len, label)) = try_parse_bookdown_reference(&text[pos..])
867            {
868                if pos > text_start {
869                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
870                }
871                log::trace!("Matched bookdown reference at pos {}: {}", pos, label);
872                super::citations::emit_bookdown_crossref(builder, label);
873                pos += len;
874                text_start = pos;
875                continue;
876            }
877
878            // Try escapes (after bookdown refs and backslash math)
879            if let Some((len, ch, escape_type)) = try_parse_escape(&text[pos..]) {
880                let escape_enabled = match escape_type {
881                    EscapeType::HardLineBreak => config.extensions.escaped_line_breaks,
882                    EscapeType::NonbreakingSpace => config.extensions.all_symbols_escapable,
883                    EscapeType::Literal => {
884                        // BASE_ESCAPABLE matches Pandoc's markdown_strict /
885                        // original Markdown set, plus `|` and `~` which the
886                        // formatter emits as escapes for pipe-table separators
887                        // and strikethrough delimiters. Recognising those here
888                        // keeps round-trips idempotent in flavors that don't
889                        // enable all_symbols_escapable.
890                        //
891                        // Under CommonMark dialect, the spec (§2.4) explicitly
892                        // allows ANY ASCII punctuation to be backslash-escaped,
893                        // independent of the all_symbols_escapable extension
894                        // (which also widens to whitespace, a Pandoc-only
895                        // construct).
896                        const BASE_ESCAPABLE: &str = "\\`*_{}[]()>#+-.!|~";
897                        BASE_ESCAPABLE.contains(ch)
898                            || config.extensions.all_symbols_escapable
899                            || (config.dialect == crate::Dialect::CommonMark
900                                && ch.is_ascii_punctuation())
901                    }
902                };
903                if !escape_enabled {
904                    // Don't treat as hard line break - skip the escape and continue
905                    // The backslash will be included in the next TEXT token
906                    pos = advance_char_boundary(text, pos, end);
907                    continue;
908                }
909
910                // Emit accumulated text
911                if pos > text_start {
912                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
913                }
914
915                log::trace!("Matched escape at pos {}: \\{}", pos, ch);
916                emit_escape(builder, ch, escape_type);
917                pos += len;
918                text_start = pos;
919                continue;
920            }
921
922            // Try LaTeX commands (after escapes, before shortcodes)
923            if config.extensions.raw_tex
924                && let Some(len) = try_parse_latex_command(&text[pos..])
925            {
926                if pos > text_start {
927                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
928                }
929                log::trace!("Matched LaTeX command at pos {}", pos);
930                parse_latex_command(builder, &text[pos..], len);
931                pos += len;
932                text_start = pos;
933                continue;
934            }
935        }
936
937        // Try Svelte template spans (mdsvex): {#if}, {@html}, {expr}, ...
938        // Opaque, lossless. Gated on the extension, so `{` keeps its normal
939        // meaning in every other flavor. Tried before the shortcode probe;
940        // the parser leaves `{{<` to the shortcode path.
941        if byte == b'{'
942            && config.extensions.svelte_template
943            && let Some((len, kind, content)) = try_parse_svelte_template(&text[pos..])
944        {
945            if pos > text_start {
946                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
947            }
948            log::trace!("Matched Svelte template span at pos {}: {:?}", pos, kind);
949            emit_svelte_template(builder, kind, &content);
950            pos += len;
951            text_start = pos;
952            continue;
953        }
954
955        // Try MyST inline roles: {name}`content`. The leading `{` is followed
956        // by a name char (not `{`), so this never shadows the `{{<` shortcode
957        // probe below. Gated on the extension, so `{` keeps its normal meaning
958        // elsewhere.
959        if byte == b'{'
960            && config.extensions.myst_roles
961            && let Some(role) = try_parse_role(&text[pos..])
962        {
963            if pos > text_start {
964                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
965            }
966            emit_role(builder, &text[pos..pos + role.total_len], role);
967            pos += role.total_len;
968            text_start = pos;
969            continue;
970        }
971
972        // Try MyST substitutions: {{ name }}. Excludes the `{{<` shortcode
973        // opener (handled inside try_parse_substitution). Gated on the extension.
974        if byte == b'{'
975            && config.extensions.myst_substitutions
976            && let Some((len, inner_len)) = try_parse_substitution(&text[pos..])
977        {
978            if pos > text_start {
979                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
980            }
981            emit_substitution(builder, &text[pos..pos + len], inner_len);
982            pos += len;
983            text_start = pos;
984            continue;
985        }
986
987        // Try Quarto shortcodes: {{< shortcode >}}
988        if byte == b'{'
989            && pos + 1 < text.len()
990            && text.as_bytes()[pos + 1] == b'{'
991            && let Some((len, name, attrs)) = try_parse_shortcode(&text[pos..])
992        {
993            if pos > text_start {
994                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
995            }
996            log::trace!("Matched shortcode at pos {}: {}", pos, &name);
997            emit_shortcode(builder, &name, attrs);
998            pos += len;
999            text_start = pos;
1000            continue;
1001        }
1002
1003        // Try inline executable code spans (`... `r expr`` and `... `{r} expr``)
1004        if byte == b'`'
1005            && let Some(m) = try_parse_inline_executable(
1006                &text[pos..],
1007                config.extensions.rmarkdown_inline_code,
1008                config.extensions.quarto_inline_code,
1009            )
1010        {
1011            if pos > text_start {
1012                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1013            }
1014            log::trace!("Matched inline executable code at pos {}", pos);
1015            emit_inline_executable(builder, &m);
1016            pos += m.total_len;
1017            text_start = pos;
1018            continue;
1019        }
1020
1021        // Try code spans
1022        if byte == b'`' {
1023            if let Some((len, content, backtick_count, attributes)) =
1024                try_parse_code_span(&text[pos..])
1025            {
1026                // Emit accumulated text
1027                if pos > text_start {
1028                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1029                }
1030
1031                log::trace!(
1032                    "Matched code span at pos {}: {} backticks",
1033                    pos,
1034                    backtick_count
1035                );
1036
1037                // Check for raw inline
1038                if let Some((ref attrs, raw_attr)) = attributes
1039                    && config.extensions.raw_attribute
1040                    && let Some(format) = is_raw_inline(attrs)
1041                {
1042                    use super::raw_inline::emit_raw_inline;
1043                    log::trace!("Matched raw inline span at pos {}: format={}", pos, format);
1044                    emit_raw_inline(builder, content, backtick_count, raw_attr);
1045                } else if !config.extensions.inline_code_attributes && attributes.is_some() {
1046                    let code_span_len = backtick_count * 2 + content.len();
1047                    emit_code_span(builder, content, backtick_count, None);
1048                    pos += code_span_len;
1049                    text_start = pos;
1050                    continue;
1051                } else {
1052                    emit_code_span(
1053                        builder,
1054                        content,
1055                        backtick_count,
1056                        attributes.as_ref().map(|(_, raw)| *raw),
1057                    );
1058                }
1059
1060                pos += len;
1061                text_start = pos;
1062                continue;
1063            }
1064
1065            // Unmatched backtick run.
1066            //
1067            // CommonMark (and GFM) treat the whole run as literal text — the
1068            // run cannot be re-entered as a shorter opener. Pandoc-markdown
1069            // instead lets a longer run shadow a shorter one (e.g.
1070            // `` ```foo`` `` parses as `` ` `` + ``<code>foo</code>``), so
1071            // for the Pandoc dialect we fall through and advance one byte at
1072            // a time, allowing the inner run to be tried on a later iteration.
1073            if config.dialect == Dialect::CommonMark {
1074                let run_len = text[pos..].bytes().take_while(|&b| b == b'`').count();
1075                pos += run_len;
1076                continue;
1077            }
1078        }
1079
1080        // Try textual emoji aliases: :smile:
1081        if byte == b':'
1082            && config.extensions.emoji
1083            && is_emoji_boundary(text, pos)
1084            && let Some((len, _alias)) = try_parse_emoji(&text[pos..])
1085        {
1086            if pos > text_start {
1087                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1088            }
1089            log::trace!("Matched emoji at pos {}", pos);
1090            emit_emoji(builder, &text[pos..pos + len]);
1091            pos += len;
1092            text_start = pos;
1093            continue;
1094        }
1095
1096        // Try inline footnotes: ^[note]. Under Pandoc dialect this is
1097        // consumed via the IR's `ConstructPlan` at the top of the loop;
1098        // this dispatcher branch only fires for CommonMark dialect with
1099        // the extension explicitly enabled.
1100        if byte == b'^'
1101            && pos + 1 < text.len()
1102            && text.as_bytes()[pos + 1] == b'['
1103            && config.dialect == Dialect::CommonMark
1104            && config.extensions.inline_footnotes
1105            && let Some((len, content)) = try_parse_inline_footnote(&text[pos..])
1106        {
1107            if pos > text_start {
1108                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1109            }
1110            log::trace!("Matched inline footnote at pos {}", pos);
1111            emit_inline_footnote(builder, content, config, suppress_footnote_refs);
1112            pos += len;
1113            text_start = pos;
1114            continue;
1115        }
1116
1117        // Try superscript: ^text^
1118        if byte == b'^'
1119            && config.extensions.superscript
1120            && let Some((len, content)) = try_parse_superscript(&text[pos..])
1121        {
1122            if pos > text_start {
1123                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1124            }
1125            log::trace!("Matched superscript at pos {}", pos);
1126            emit_superscript(builder, content, config, suppress_footnote_refs);
1127            pos += len;
1128            text_start = pos;
1129            continue;
1130        }
1131
1132        // Try bookdown definition: (\#label) or (ref:label)
1133        if byte == b'(' && config.extensions.bookdown_references {
1134            if let Some((len, label)) = try_parse_bookdown_definition(&text[pos..]) {
1135                if pos > text_start {
1136                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1137                }
1138                log::trace!("Matched bookdown definition at pos {}: {}", pos, label);
1139                builder.token(SyntaxKind::TEXT.into(), &text[pos..pos + len]);
1140                pos += len;
1141                text_start = pos;
1142                continue;
1143            }
1144            if let Some((len, label)) = try_parse_bookdown_text_reference(&text[pos..]) {
1145                if pos > text_start {
1146                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1147                }
1148                log::trace!("Matched bookdown text reference at pos {}: {}", pos, label);
1149                builder.token(SyntaxKind::TEXT.into(), &text[pos..pos + len]);
1150                pos += len;
1151                text_start = pos;
1152                continue;
1153            }
1154        }
1155
1156        // Try strikeout: ~~text~~
1157        // Must run before subscript so `~~text~~` is matched as a single
1158        // Strikeout rather than two empty Subscripts. Subscript falls back
1159        // to consuming `~~` as an empty subscript only when strikeout
1160        // didn't match (e.g. `~~unclosed`).
1161        if byte == b'~'
1162            && config.extensions.strikeout
1163            && let Some((len, content)) = try_parse_strikeout(&text[pos..])
1164        {
1165            if pos > text_start {
1166                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1167            }
1168            log::trace!("Matched strikeout at pos {}", pos);
1169            emit_strikeout(builder, content, config, suppress_footnote_refs);
1170            pos += len;
1171            text_start = pos;
1172            continue;
1173        }
1174
1175        // Try subscript: ~text~ or `~~` as empty subscript when strikeout
1176        // didn't match (matches pandoc: `~~unclosed` → `Subscript [] + text`).
1177        if byte == b'~'
1178            && config.extensions.subscript
1179            && let Some((len, content)) = try_parse_subscript(&text[pos..])
1180        {
1181            if pos > text_start {
1182                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1183            }
1184            log::trace!("Matched subscript at pos {}", pos);
1185            emit_subscript(builder, content, config, suppress_footnote_refs);
1186            pos += len;
1187            text_start = pos;
1188            continue;
1189        }
1190
1191        // Try mark/highlight: ==text==
1192        if byte == b'='
1193            && config.extensions.mark
1194            && let Some((len, content)) = try_parse_mark(&text[pos..])
1195        {
1196            if pos > text_start {
1197                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1198            }
1199            log::trace!("Matched mark at pos {}", pos);
1200            emit_mark(builder, content, config, suppress_footnote_refs);
1201            pos += len;
1202            text_start = pos;
1203            continue;
1204        }
1205
1206        // Try GFM inline math: $`...`$
1207        if byte == b'$'
1208            && config.extensions.tex_math_gfm
1209            && let Some((len, content)) =
1210                try_parse_gfm_inline_math(&text[pos..], config.dialect == Dialect::Pandoc)
1211        {
1212            if pos > text_start {
1213                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1214            }
1215            log::trace!("Matched GFM inline math at pos {}", pos);
1216            emit_gfm_inline_math(builder, content, math_opts(config));
1217            pos += len;
1218            text_start = pos;
1219            continue;
1220        }
1221
1222        // Try math ($...$, $$...$$)
1223        if byte == b'$' && config.extensions.tex_math_dollars {
1224            // Try display math first ($$...$$)
1225            if let Some((len, content)) = try_parse_display_math(&text[pos..]) {
1226                // Emit accumulated text
1227                if pos > text_start {
1228                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1229                }
1230
1231                let dollar_count = text[pos..].chars().take_while(|&c| c == '$').count();
1232                log::trace!(
1233                    "Matched display math at pos {}: {} dollars",
1234                    pos,
1235                    dollar_count
1236                );
1237
1238                // Check for trailing attributes (Quarto cross-reference support).
1239                // The Quarto attribute block sits on the same line as the closing
1240                // `$$`, so scope the lookup to the current line — otherwise
1241                // anything on later lines (e.g. a following `@eq-id` reference)
1242                // makes the segment not end with `}` and the lift no-ops.
1243                let after_math = &text[pos + len..];
1244                let line_end = after_math.find('\n').unwrap_or(after_math.len());
1245                let line_segment = &after_math[..line_end];
1246                let attr_len = if config.extensions.quarto_crossrefs {
1247                    use crate::parser::utils::attributes::try_parse_trailing_attributes;
1248                    if let Some((_attr_block, _)) = try_parse_trailing_attributes(line_segment) {
1249                        let trimmed_after = line_segment.trim_start();
1250                        if let Some(open_brace_pos) = trimmed_after.find('{') {
1251                            let ws_before_brace = line_segment.len() - trimmed_after.len();
1252                            let attr_text_len = trimmed_after[open_brace_pos..]
1253                                .find('}')
1254                                .map(|close| close + 1)
1255                                .unwrap_or(0);
1256                            ws_before_brace + open_brace_pos + attr_text_len
1257                        } else {
1258                            0
1259                        }
1260                    } else {
1261                        0
1262                    }
1263                } else {
1264                    0
1265                };
1266
1267                let total_len = len + attr_len;
1268                emit_display_math(builder, content, dollar_count, math_opts(config));
1269
1270                // Emit attributes if present, structured over the raw source
1271                // bytes (leading whitespace split out as its own token).
1272                if attr_len > 0 {
1273                    use crate::parser::utils::attributes::emit_attribute_node;
1274                    let attr_text = &text[pos + len..pos + total_len];
1275                    let trimmed_after = attr_text.trim_start();
1276                    let ws_len = attr_text.len() - trimmed_after.len();
1277                    if ws_len > 0 {
1278                        builder.token(SyntaxKind::WHITESPACE.into(), &attr_text[..ws_len]);
1279                    }
1280                    emit_attribute_node(builder, trimmed_after);
1281                }
1282
1283                pos += total_len;
1284                text_start = pos;
1285                continue;
1286            }
1287
1288            // Try inline math ($...$)
1289            if let Some((len, content)) =
1290                try_parse_inline_math(&text[pos..], config.dialect == Dialect::Pandoc)
1291            {
1292                // Emit accumulated text
1293                if pos > text_start {
1294                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1295                }
1296
1297                log::trace!("Matched inline math at pos {}", pos);
1298                emit_inline_math(builder, content, math_opts(config));
1299                pos += len;
1300                text_start = pos;
1301                continue;
1302            }
1303
1304            // Neither display nor inline math matched - emit the $ as literal text
1305            // This ensures each $ gets its own TEXT token for CST compatibility
1306            if pos > text_start {
1307                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1308            }
1309            builder.token(SyntaxKind::TEXT.into(), "$");
1310            pos = advance_char_boundary(text, pos, end);
1311            text_start = pos;
1312            continue;
1313        }
1314
1315        // Try autolinks: <url> or <email>
1316        if byte == b'<'
1317            && config.extensions.autolinks
1318            && let Some((len, url)) = try_parse_autolink(
1319                &text[pos..],
1320                config.dialect == crate::options::Dialect::CommonMark,
1321            )
1322        {
1323            if pos > text_start {
1324                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1325            }
1326            log::trace!("Matched autolink at pos {}", pos);
1327            emit_autolink(builder, &text[pos..pos + len], url);
1328            pos += len;
1329            text_start = pos;
1330            continue;
1331        }
1332
1333        if !nested_in_link
1334            && config.extensions.autolink_bare_uris
1335            && bare_uri_has_left_boundary(text, pos)
1336            && let Some((len, url)) = try_parse_bare_uri(&text[pos..])
1337        {
1338            if pos > text_start {
1339                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1340            }
1341            log::trace!("Matched bare URI at pos {}", pos);
1342            emit_bare_uri_link(builder, url, config);
1343            pos += len;
1344            text_start = pos;
1345            continue;
1346        }
1347
1348        // Try native spans: <span>text</span> (after autolink since both
1349        // start with <). Under Pandoc dialect this is consumed via the
1350        // IR's `ConstructPlan` at the top of the loop; this dispatcher
1351        // branch only fires for CommonMark dialect with the extension
1352        // explicitly enabled.
1353        if byte == b'<'
1354            && config.dialect == Dialect::CommonMark
1355            && config.extensions.native_spans
1356            && let Some((len, content, _attributes)) = try_parse_native_span(&text[pos..])
1357        {
1358            if pos > text_start {
1359                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1360            }
1361            log::trace!("Matched native span at pos {}", pos);
1362            emit_native_span(
1363                builder,
1364                &text[pos..pos + len],
1365                content,
1366                config,
1367                suppress_footnote_refs,
1368            );
1369            pos += len;
1370            text_start = pos;
1371            continue;
1372        }
1373
1374        // Try inline raw HTML (CommonMark §6.6 / Pandoc raw_html). Must run
1375        // after autolinks (more specific) and native spans (Pandoc
1376        // <span>…</span> wrapper) since all three start with `<`.
1377        if byte == b'<'
1378            && config.extensions.raw_html
1379            && let Some(len) = try_parse_inline_html(&text[pos..], config.dialect)
1380        {
1381            if pos > text_start {
1382                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1383            }
1384            log::trace!("Matched inline raw HTML at pos {}", pos);
1385            emit_inline_html(builder, &text[pos..pos + len]);
1386            pos += len;
1387            text_start = pos;
1388            continue;
1389        }
1390
1391        // Bracket-starting elements: inline / reference links and
1392        // images are dispatched via the IR-driven arm at the top of
1393        // the loop, gated by the IR's `BracketPlan`. Only dialect-CM-
1394        // specific Pandoc-extension constructs that share the `[...]`
1395        // shape (footnote refs, bracketed citations) need a CM-gated
1396        // dispatcher branch — under Pandoc dialect they're consumed
1397        // via the IR's `ConstructPlan` instead.
1398        if byte == b'['
1399            && config.dialect == Dialect::CommonMark
1400            && config.extensions.footnotes
1401            && !suppress_footnote_refs
1402            && let Some((len, id)) = try_parse_footnote_reference(&text[pos..])
1403        {
1404            if pos > text_start {
1405                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1406            }
1407            log::trace!("Matched footnote reference at pos {}", pos);
1408            emit_footnote_reference(builder, &id);
1409            pos += len;
1410            text_start = pos;
1411            continue;
1412        }
1413        if byte == b'['
1414            && config.dialect == Dialect::CommonMark
1415            && config.extensions.citations
1416            && let Some((len, content)) = try_parse_bracketed_citation(&text[pos..])
1417        {
1418            if pos > text_start {
1419                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1420            }
1421            log::trace!("Matched bracketed citation at pos {}", pos);
1422            emit_bracketed_citation(builder, content);
1423            pos += len;
1424            text_start = pos;
1425            continue;
1426        }
1427
1428        // Try bracketed spans: [text]{.class}. Must come after
1429        // links/citations. Under Pandoc dialect this is consumed via
1430        // the IR's `ConstructPlan` at the top of the loop; this
1431        // dispatcher branch only fires for CommonMark dialect with the
1432        // extension explicitly enabled.
1433        if config.dialect == Dialect::CommonMark
1434            && byte == b'['
1435            && config.extensions.bracketed_spans
1436            && let Some((len, text_content, attrs)) = try_parse_bracketed_span(&text[pos..])
1437        {
1438            if pos > text_start {
1439                builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1440            }
1441            log::trace!("Matched bracketed span at pos {}", pos);
1442            emit_bracketed_span(
1443                builder,
1444                &text_content,
1445                &attrs,
1446                config,
1447                suppress_footnote_refs,
1448            );
1449            pos += len;
1450            text_start = pos;
1451            continue;
1452        }
1453
1454        // Try bare citation: @cite (must come after bracketed elements).
1455        // Under Pandoc dialect this is consumed via the IR's
1456        // `ConstructPlan` at the top of the loop; this dispatcher branch
1457        // only fires for CommonMark dialect with the extension
1458        // explicitly enabled.
1459        if config.dialect == Dialect::CommonMark
1460            && byte == b'@'
1461            && (config.extensions.citations || config.extensions.quarto_crossrefs)
1462            && let Some((len, key, has_suppress)) = try_parse_bare_citation(&text[pos..])
1463        {
1464            let is_crossref = config.extensions.quarto_crossrefs
1465                && super::citations::is_crossref_key(key, &config.crossref_prefixes);
1466            if is_crossref || config.extensions.citations {
1467                if pos > text_start {
1468                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1469                }
1470                if is_crossref {
1471                    log::trace!("Matched Quarto crossref at pos {}: {}", pos, &key);
1472                    super::citations::emit_crossref(builder, key, has_suppress);
1473                } else {
1474                    log::trace!("Matched bare citation at pos {}: {}", pos, &key);
1475                    emit_bare_citation(builder, key, has_suppress);
1476                }
1477                pos += len;
1478                text_start = pos;
1479                continue;
1480            }
1481        }
1482
1483        // Try suppress-author citation: -@cite. Under Pandoc dialect
1484        // this is consumed via the IR's `ConstructPlan` at the top of
1485        // the loop; this dispatcher branch only fires for CommonMark
1486        // dialect with the extension explicitly enabled.
1487        if config.dialect == Dialect::CommonMark
1488            && byte == b'-'
1489            && pos + 1 < text.len()
1490            && text.as_bytes()[pos + 1] == b'@'
1491            && (config.extensions.citations || config.extensions.quarto_crossrefs)
1492            && let Some((len, key, has_suppress)) = try_parse_bare_citation(&text[pos..])
1493        {
1494            let is_crossref = config.extensions.quarto_crossrefs
1495                && super::citations::is_crossref_key(key, &config.crossref_prefixes);
1496            if is_crossref || config.extensions.citations {
1497                if pos > text_start {
1498                    builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1499                }
1500                if is_crossref {
1501                    log::trace!("Matched Quarto crossref at pos {}: {}", pos, &key);
1502                    super::citations::emit_crossref(builder, key, has_suppress);
1503                } else {
1504                    log::trace!("Matched suppress-author citation at pos {}: {}", pos, &key);
1505                    emit_bare_citation(builder, key, has_suppress);
1506                }
1507                pos += len;
1508                text_start = pos;
1509                continue;
1510            }
1511        }
1512
1513        // Emphasis emission, plan-driven. The IR's emphasis pass has
1514        // already decided every delimiter byte's disposition (open
1515        // marker, close marker, or unmatched literal); consult the
1516        // plan here instead of re-scanning.
1517        if byte == b'*' || byte == b'_' {
1518            match plan.lookup(pos) {
1519                Some(DelimChar::Open {
1520                    len,
1521                    partner,
1522                    partner_len,
1523                    kind,
1524                }) => {
1525                    if pos > text_start {
1526                        builder.token(SyntaxKind::TEXT.into(), &text[text_start..pos]);
1527                    }
1528                    let len = len as usize;
1529                    let partner_len = partner_len as usize;
1530                    let (wrapper_kind, marker_kind) = match kind {
1531                        EmphasisKind::Strong => (SyntaxKind::STRONG, SyntaxKind::STRONG_MARKER),
1532                        EmphasisKind::Emph => (SyntaxKind::EMPHASIS, SyntaxKind::EMPHASIS_MARKER),
1533                    };
1534                    builder.start_node(wrapper_kind.into());
1535                    builder.token(marker_kind.into(), &text[pos..pos + len]);
1536                    parse_inline_range_impl(
1537                        text,
1538                        pos + len,
1539                        partner,
1540                        config,
1541                        builder,
1542                        nested_in_link,
1543                        plan,
1544                        bracket_plan,
1545                        construct_plan,
1546                        suppress_inner_links,
1547                        suppress_footnote_refs,
1548                        mask,
1549                    );
1550                    builder.token(marker_kind.into(), &text[partner..partner + partner_len]);
1551                    builder.finish_node();
1552                    pos = partner + partner_len;
1553                    text_start = pos;
1554                    continue;
1555                }
1556                Some(DelimChar::Close) => {
1557                    // Defensive: a close should be jumped past by its
1558                    // matching open. If we hit one anyway (e.g. when the
1559                    // outer caller's range starts mid-pair), let it be
1560                    // emitted as part of the surrounding text by simply
1561                    // advancing. text_start stays put so the byte folds
1562                    // into the next TEXT flush.
1563                    pos += 1;
1564                    continue;
1565                }
1566                Some(DelimChar::Literal) | None => {
1567                    // Unmatched delim chars at this position behave as
1568                    // literal text. Don't emit yet — let them coalesce
1569                    // with surrounding plain bytes via the existing
1570                    // text_start flushing so the CST keeps the same TEXT
1571                    // token granularity Pandoc fixtures expect.
1572                    let bytes = text.as_bytes();
1573                    let mut end_pos = pos + 1;
1574                    while end_pos < end && bytes[end_pos] == byte {
1575                        match plan.lookup(end_pos) {
1576                            Some(DelimChar::Literal) | None => end_pos += 1,
1577                            _ => break,
1578                        }
1579                    }
1580                    pos = end_pos;
1581                    continue;
1582                }
1583            }
1584        }
1585
1586        // Check for newlines - may need to emit as hard line break
1587        if byte == b'\r' && pos + 1 < end && text.as_bytes()[pos + 1] == b'\n' {
1588            let text_before = &text[text_start..pos];
1589
1590            // Check for trailing spaces hard line break (always enabled in Pandoc)
1591            let trailing_spaces = text_before.chars().rev().take_while(|&c| c == ' ').count();
1592            if trailing_spaces >= 2 {
1593                // Emit text before the trailing spaces
1594                let text_content = &text_before[..text_before.len() - trailing_spaces];
1595                if !text_content.is_empty() {
1596                    builder.token(SyntaxKind::TEXT.into(), text_content);
1597                }
1598                let spaces = " ".repeat(trailing_spaces);
1599                builder.token(
1600                    SyntaxKind::HARD_LINE_BREAK.into(),
1601                    &format!("{}\r\n", spaces),
1602                );
1603                pos += 2;
1604                text_start = pos;
1605                continue;
1606            }
1607
1608            // hard_line_breaks: treat all single newlines as hard line breaks
1609            if config.extensions.hard_line_breaks {
1610                if !text_before.is_empty() {
1611                    builder.token(SyntaxKind::TEXT.into(), text_before);
1612                }
1613                builder.token(SyntaxKind::HARD_LINE_BREAK.into(), "\r\n");
1614                pos += 2;
1615                text_start = pos;
1616                continue;
1617            }
1618
1619            // Regular newline
1620            if !text_before.is_empty() {
1621                builder.token(SyntaxKind::TEXT.into(), text_before);
1622            }
1623            builder.token(SyntaxKind::NEWLINE.into(), "\r\n");
1624            pos += 2;
1625            text_start = pos;
1626            continue;
1627        }
1628
1629        if byte == b'\n' {
1630            let text_before = &text[text_start..pos];
1631
1632            // Check for trailing spaces hard line break (always enabled in Pandoc)
1633            let trailing_spaces = text_before.chars().rev().take_while(|&c| c == ' ').count();
1634            if trailing_spaces >= 2 {
1635                // Emit text before the trailing spaces
1636                let text_content = &text_before[..text_before.len() - trailing_spaces];
1637                if !text_content.is_empty() {
1638                    builder.token(SyntaxKind::TEXT.into(), text_content);
1639                }
1640                let spaces = " ".repeat(trailing_spaces);
1641                builder.token(SyntaxKind::HARD_LINE_BREAK.into(), &format!("{}\n", spaces));
1642                pos += 1;
1643                text_start = pos;
1644                continue;
1645            }
1646
1647            // hard_line_breaks: treat all single newlines as hard line breaks
1648            if config.extensions.hard_line_breaks {
1649                if !text_before.is_empty() {
1650                    builder.token(SyntaxKind::TEXT.into(), text_before);
1651                }
1652                builder.token(SyntaxKind::HARD_LINE_BREAK.into(), "\n");
1653                pos += 1;
1654                text_start = pos;
1655                continue;
1656            }
1657
1658            // Regular newline
1659            if !text_before.is_empty() {
1660                builder.token(SyntaxKind::TEXT.into(), text_before);
1661            }
1662            builder.token(SyntaxKind::NEWLINE.into(), "\n");
1663            pos += 1;
1664            text_start = pos;
1665            continue;
1666        }
1667
1668        // Regular character, keep accumulating
1669        pos = advance_char_boundary(text, pos, end);
1670    }
1671
1672    // Emit any remaining text
1673    if pos > text_start && text_start < end {
1674        log::trace!("Emitting remaining TEXT: {:?}", &text[text_start..end]);
1675        builder.token(SyntaxKind::TEXT.into(), &text[text_start..end]);
1676    }
1677
1678    log::trace!("parse_inline_range complete: start={}, end={}", start, end);
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683    use super::*;
1684    use crate::syntax::{SyntaxKind, SyntaxNode};
1685    use rowan::GreenNode;
1686
1687    #[test]
1688    fn test_recursive_simple_emphasis() {
1689        let text = "*test*";
1690        let config = ParserOptions::default();
1691        let mut builder = GreenNodeBuilder::new();
1692
1693        parse_inline_text_recursive(&mut builder, text, &config, false);
1694
1695        let green: GreenNode = builder.finish();
1696        let node = SyntaxNode::new_root(green);
1697
1698        // Should be lossless
1699        assert_eq!(node.text().to_string(), text);
1700
1701        // Should have EMPHASIS node
1702        let has_emph = node.descendants().any(|n| n.kind() == SyntaxKind::EMPHASIS);
1703        assert!(has_emph, "Should have EMPHASIS node");
1704    }
1705
1706    #[test]
1707    fn test_recursive_nested() {
1708        let text = "*foo **bar** baz*";
1709        let config = ParserOptions::default();
1710        let mut builder = GreenNodeBuilder::new();
1711
1712        // Wrap in a PARAGRAPH node (inline content needs a parent)
1713        builder.start_node(SyntaxKind::PARAGRAPH.into());
1714        parse_inline_text_recursive(&mut builder, text, &config, false);
1715        builder.finish_node();
1716
1717        let green: GreenNode = builder.finish();
1718        let node = SyntaxNode::new_root(green);
1719
1720        // Should be lossless
1721        assert_eq!(node.text().to_string(), text);
1722
1723        // Should have both EMPHASIS and STRONG
1724        let has_emph = node.descendants().any(|n| n.kind() == SyntaxKind::EMPHASIS);
1725        let has_strong = node.descendants().any(|n| n.kind() == SyntaxKind::STRONG);
1726
1727        assert!(has_emph, "Should have EMPHASIS node");
1728        assert!(has_strong, "Should have STRONG node");
1729    }
1730
1731    /// Test Pandoc's "three" algorithm: ***foo* bar**
1732    /// Expected: Strong[Emph[foo], bar]
1733    #[test]
1734    fn test_triple_emphasis_star_then_double_star() {
1735        use crate::options::ParserOptions;
1736        use crate::syntax::SyntaxNode;
1737        use rowan::GreenNode;
1738
1739        let text = "***foo* bar**";
1740        let config = ParserOptions::default();
1741        let mut builder = GreenNodeBuilder::new();
1742
1743        builder.start_node(SyntaxKind::DOCUMENT.into());
1744        parse_inline_text_recursive(&mut builder, text, &config, false);
1745        builder.finish_node();
1746
1747        let green: GreenNode = builder.finish();
1748        let node = SyntaxNode::new_root(green);
1749
1750        // Verify losslessness
1751        assert_eq!(node.text().to_string(), text);
1752
1753        // Expected structure: STRONG > EMPH > "foo"
1754        // The STRONG should contain EMPH, not the other way around
1755        let structure = format!("{:#?}", node);
1756
1757        // Should have both STRONG and EMPH
1758        assert!(structure.contains("STRONG"), "Should have STRONG node");
1759        assert!(structure.contains("EMPHASIS"), "Should have EMPHASIS node");
1760
1761        // STRONG should be outer, EMPH should be inner
1762        // Check that STRONG comes before EMPH in tree traversal
1763        let mut found_strong = false;
1764        let mut found_emph_after_strong = false;
1765        for descendant in node.descendants() {
1766            if descendant.kind() == SyntaxKind::STRONG {
1767                found_strong = true;
1768            }
1769            if found_strong && descendant.kind() == SyntaxKind::EMPHASIS {
1770                found_emph_after_strong = true;
1771                break;
1772            }
1773        }
1774
1775        assert!(
1776            found_emph_after_strong,
1777            "EMPH should be inside STRONG, not before it. Current structure:\n{}",
1778            structure
1779        );
1780    }
1781
1782    /// Test Pandoc's "three" algorithm: ***foo** bar*
1783    /// Expected: Emph[Strong[foo], bar]
1784    #[test]
1785    fn test_triple_emphasis_double_star_then_star() {
1786        use crate::options::ParserOptions;
1787        use crate::syntax::SyntaxNode;
1788        use rowan::GreenNode;
1789
1790        let text = "***foo** bar*";
1791        let config = ParserOptions::default();
1792        let mut builder = GreenNodeBuilder::new();
1793
1794        builder.start_node(SyntaxKind::DOCUMENT.into());
1795        parse_inline_text_recursive(&mut builder, text, &config, false);
1796        builder.finish_node();
1797
1798        let green: GreenNode = builder.finish();
1799        let node = SyntaxNode::new_root(green);
1800
1801        // Verify losslessness
1802        assert_eq!(node.text().to_string(), text);
1803
1804        // Expected structure: EMPH > STRONG > "foo"
1805        let structure = format!("{:#?}", node);
1806
1807        // Should have both EMPH and STRONG
1808        assert!(structure.contains("EMPHASIS"), "Should have EMPHASIS node");
1809        assert!(structure.contains("STRONG"), "Should have STRONG node");
1810
1811        // EMPH should be outer, STRONG should be inner
1812        let mut found_emph = false;
1813        let mut found_strong_after_emph = false;
1814        for descendant in node.descendants() {
1815            if descendant.kind() == SyntaxKind::EMPHASIS {
1816                found_emph = true;
1817            }
1818            if found_emph && descendant.kind() == SyntaxKind::STRONG {
1819                found_strong_after_emph = true;
1820                break;
1821            }
1822        }
1823
1824        assert!(
1825            found_strong_after_emph,
1826            "STRONG should be inside EMPH. Current structure:\n{}",
1827            structure
1828        );
1829    }
1830
1831    /// Test that display math with attributes parses correctly
1832    /// Regression test for equation_attributes_single_line golden test
1833    #[test]
1834    fn test_display_math_with_attributes() {
1835        use crate::options::ParserOptions;
1836        use crate::syntax::SyntaxNode;
1837        use rowan::GreenNode;
1838
1839        let text = "$$ E = mc^2 $$ {#eq-einstein}";
1840        let mut config = ParserOptions::default();
1841        config.extensions.quarto_crossrefs = true; // Enable Quarto cross-references
1842
1843        let mut builder = GreenNodeBuilder::new();
1844        builder.start_node(SyntaxKind::DOCUMENT.into()); // Need a root node
1845
1846        // Parse the whole text
1847        parse_inline_text_recursive(&mut builder, text, &config, false);
1848
1849        builder.finish_node(); // Finish ROOT
1850        let green: GreenNode = builder.finish();
1851        let node = SyntaxNode::new_root(green);
1852
1853        // Verify losslessness
1854        assert_eq!(node.text().to_string(), text);
1855
1856        // Should have DISPLAY_MATH node
1857        let has_display_math = node
1858            .descendants()
1859            .any(|n| n.kind() == SyntaxKind::DISPLAY_MATH);
1860        assert!(has_display_math, "Should have DISPLAY_MATH node");
1861
1862        // Should have ATTRIBUTE node
1863        let has_attributes = node
1864            .descendants()
1865            .any(|n| n.kind() == SyntaxKind::ATTRIBUTE);
1866        assert!(
1867            has_attributes,
1868            "Should have ATTRIBUTE node for {{#eq-einstein}}"
1869        );
1870
1871        // Attributes should not be TEXT
1872        let math_followed_by_text = node.descendants().any(|n| {
1873            n.kind() == SyntaxKind::DISPLAY_MATH
1874                && n.next_sibling()
1875                    .map(|s| {
1876                        s.kind() == SyntaxKind::TEXT
1877                            && s.text().to_string().contains("{#eq-einstein}")
1878                    })
1879                    .unwrap_or(false)
1880        });
1881        assert!(
1882            !math_followed_by_text,
1883            "Attributes should not be parsed as TEXT"
1884        );
1885    }
1886
1887    #[test]
1888    fn test_parse_inline_text_gfm_inline_link_destination_not_autolinked() {
1889        use crate::options::{Dialect, Extensions, Flavor};
1890
1891        let config = ParserOptions {
1892            flavor: Flavor::Gfm,
1893            dialect: Dialect::for_flavor(Flavor::Gfm),
1894            extensions: Extensions::for_flavor(Flavor::Gfm),
1895            ..ParserOptions::default()
1896        };
1897
1898        let mut builder = GreenNodeBuilder::new();
1899        builder.start_node(SyntaxKind::PARAGRAPH.into());
1900        parse_inline_text_recursive(
1901            &mut builder,
1902            "Second Link [link_text](https://link.com)",
1903            &config,
1904            false,
1905        );
1906        builder.finish_node();
1907        let green = builder.finish();
1908        let root = SyntaxNode::new_root(green);
1909
1910        let links: Vec<_> = root
1911            .descendants()
1912            .filter(|n| n.kind() == SyntaxKind::LINK)
1913            .collect();
1914        assert_eq!(
1915            links.len(),
1916            1,
1917            "Expected exactly one LINK node for inline link, not nested bare URI autolink"
1918        );
1919
1920        let link = links[0].clone();
1921        let mut link_text = None::<String>;
1922        let mut link_dest = None::<String>;
1923
1924        for child in link.children() {
1925            match child.kind() {
1926                SyntaxKind::LINK_TEXT => link_text = Some(child.text().to_string()),
1927                SyntaxKind::LINK_DEST => link_dest = Some(child.text().to_string()),
1928                _ => {}
1929            }
1930        }
1931
1932        assert_eq!(link_text.as_deref(), Some("link_text"));
1933        assert_eq!(link_dest.as_deref(), Some("https://link.com"));
1934    }
1935
1936    #[test]
1937    fn test_autolink_bare_uri_utf8_boundary_safe() {
1938        let text = "§";
1939        let mut config = ParserOptions::default();
1940        config.extensions.autolink_bare_uris = true;
1941        let mut builder = GreenNodeBuilder::new();
1942
1943        builder.start_node(SyntaxKind::DOCUMENT.into());
1944        parse_inline_text_recursive(&mut builder, text, &config, false);
1945        builder.finish_node();
1946
1947        let green: GreenNode = builder.finish();
1948        let node = SyntaxNode::new_root(green);
1949        assert_eq!(node.text().to_string(), text);
1950    }
1951
1952    #[test]
1953    fn test_autolink_bare_uri_is_lossless_without_synthetic_brackets() {
1954        // Regression: bare URIs were emitted as a `LINK` node spelling
1955        // `[url](url)`, duplicating the URL and fabricating brackets absent from
1956        // the source. That broke losslessness and desynced every downstream
1957        // byte offset (e.g. linter diagnostic spans landed on the wrong lines).
1958        let text = "https://example.com/path";
1959        let mut config = ParserOptions::default();
1960        config.extensions.autolink_bare_uris = true;
1961        let mut builder = GreenNodeBuilder::new();
1962
1963        builder.start_node(SyntaxKind::PARAGRAPH.into());
1964        parse_inline_text_recursive(&mut builder, text, &config, false);
1965        builder.finish_node();
1966
1967        let green: GreenNode = builder.finish();
1968        let node = SyntaxNode::new_root(green);
1969
1970        // Lossless: reconstructed bytes equal the source exactly.
1971        assert_eq!(node.text().to_string(), text);
1972        // Represented as a marker-less AUTO_LINK, not a bracketed LINK.
1973        assert!(
1974            node.descendants()
1975                .any(|n| n.kind() == SyntaxKind::AUTO_LINK),
1976            "bare URI should be an AUTO_LINK node"
1977        );
1978        assert!(
1979            !node.descendants().any(|n| n.kind() == SyntaxKind::LINK),
1980            "bare URI must not become a bracketed LINK node"
1981        );
1982    }
1983
1984    #[test]
1985    fn test_bare_uri_requires_left_word_boundary() {
1986        // Regression: a bare URI scheme embedded mid-word (e.g. `res:` inside
1987        // `squares:`) was matched and swallowed the trailing `**` strong
1988        // closing delimiter, corrupting `**...squares:**` into a stray
1989        // `AUTO_LINK` and breaking idempotency. Pandoc only recognizes a bare
1990        // URI at a left word boundary, so a scheme preceded by an alphanumeric
1991        // (or an intraword `.`) must stay literal.
1992        let text = "**Nonlinear least squares:** Gauss-Newton";
1993        let mut config = ParserOptions::default();
1994        config.extensions.autolink_bare_uris = true;
1995        let mut builder = GreenNodeBuilder::new();
1996
1997        builder.start_node(SyntaxKind::PARAGRAPH.into());
1998        parse_inline_text_recursive(&mut builder, text, &config, false);
1999        builder.finish_node();
2000
2001        let green: GreenNode = builder.finish();
2002        let node = SyntaxNode::new_root(green);
2003
2004        assert_eq!(node.text().to_string(), text);
2005        assert!(
2006            !node
2007                .descendants()
2008                .any(|n| n.kind() == SyntaxKind::AUTO_LINK),
2009            "mid-word `res:` must not become an AUTO_LINK"
2010        );
2011    }
2012
2013    #[test]
2014    fn test_bare_uri_still_matches_at_word_boundary() {
2015        // The left-boundary guard must not regress a legitimate standalone
2016        // bare URI: preceded by whitespace, `res:foo` is still an AUTO_LINK.
2017        let text = "see res:foo here";
2018        let mut config = ParserOptions::default();
2019        config.extensions.autolink_bare_uris = true;
2020        let mut builder = GreenNodeBuilder::new();
2021
2022        builder.start_node(SyntaxKind::PARAGRAPH.into());
2023        parse_inline_text_recursive(&mut builder, text, &config, false);
2024        builder.finish_node();
2025
2026        let green: GreenNode = builder.finish();
2027        let node = SyntaxNode::new_root(green);
2028
2029        assert_eq!(node.text().to_string(), text);
2030        assert!(
2031            node.descendants()
2032                .any(|n| n.kind() == SyntaxKind::AUTO_LINK),
2033            "standalone bare URI should still be an AUTO_LINK"
2034        );
2035    }
2036
2037    #[test]
2038    fn test_parse_emphasis_unicode_content_no_panic() {
2039        let text = "*§*";
2040        let config = ParserOptions::default();
2041        let mut builder = GreenNodeBuilder::new();
2042
2043        builder.start_node(SyntaxKind::PARAGRAPH.into());
2044        parse_inline_text_recursive(&mut builder, text, &config, false);
2045        builder.finish_node();
2046
2047        let green: GreenNode = builder.finish();
2048        let node = SyntaxNode::new_root(green);
2049        let has_emph = node.descendants().any(|n| n.kind() == SyntaxKind::EMPHASIS);
2050        assert!(has_emph, "Should have EMPHASIS node");
2051        assert_eq!(node.text().to_string(), text);
2052    }
2053}
2054
2055#[test]
2056fn test_two_with_nested_one_and_triple_closer() {
2057    // **bold with *italic***
2058    // Should parse as: Strong["bold with ", Emph["italic"]]
2059    // The *** at end is parsed as * (closes Emph) + ** (closes Strong)
2060
2061    use crate::options::ParserOptions;
2062    use crate::syntax::SyntaxNode;
2063    use rowan::GreenNode;
2064
2065    let text = "**bold with *italic***";
2066    let config = ParserOptions::default();
2067    let mut builder = GreenNodeBuilder::new();
2068
2069    builder.start_node(SyntaxKind::PARAGRAPH.into());
2070    parse_inline_text_recursive(&mut builder, text, &config, false);
2071    builder.finish_node();
2072
2073    let green: GreenNode = builder.finish();
2074    let node = SyntaxNode::new_root(green);
2075
2076    assert_eq!(node.text().to_string(), text, "Should be lossless");
2077
2078    let strong_nodes: Vec<_> = node
2079        .descendants()
2080        .filter(|n| n.kind() == SyntaxKind::STRONG)
2081        .collect();
2082    assert_eq!(strong_nodes.len(), 1, "Should have exactly one STRONG node");
2083    let has_emphasis_in_strong = strong_nodes[0]
2084        .descendants()
2085        .any(|n| n.kind() == SyntaxKind::EMPHASIS);
2086    assert!(
2087        has_emphasis_in_strong,
2088        "STRONG should contain EMPHASIS node"
2089    );
2090}
2091
2092#[test]
2093fn test_emphasis_with_trailing_space_before_closer() {
2094    // *foo * should parse as emphasis (Pandoc behavior)
2095    // For asterisks, Pandoc doesn't require right-flanking for closers
2096
2097    use crate::options::ParserOptions;
2098    use crate::syntax::SyntaxNode;
2099    use rowan::GreenNode;
2100
2101    let text = "*foo *";
2102    let config = ParserOptions::default();
2103    let mut builder = GreenNodeBuilder::new();
2104
2105    builder.start_node(SyntaxKind::PARAGRAPH.into());
2106    parse_inline_text_recursive(&mut builder, text, &config, false);
2107    builder.finish_node();
2108
2109    let green: GreenNode = builder.finish();
2110    let node = SyntaxNode::new_root(green);
2111
2112    let has_emph = node.descendants().any(|n| n.kind() == SyntaxKind::EMPHASIS);
2113    assert!(has_emph, "Should have EMPHASIS node");
2114    assert_eq!(node.text().to_string(), text);
2115}
2116
2117#[test]
2118fn test_triple_emphasis_all_strong_nested() {
2119    // ***foo** bar **baz*** should parse as Emph[Strong[foo], " bar ", Strong[baz]]
2120    // Pandoc output confirms this
2121
2122    use crate::options::ParserOptions;
2123    use crate::syntax::SyntaxNode;
2124    use rowan::GreenNode;
2125
2126    let text = "***foo** bar **baz***";
2127    let config = ParserOptions::default();
2128    let mut builder = GreenNodeBuilder::new();
2129
2130    builder.start_node(SyntaxKind::DOCUMENT.into());
2131    parse_inline_text_recursive(&mut builder, text, &config, false);
2132    builder.finish_node();
2133
2134    let green: GreenNode = builder.finish();
2135    let node = SyntaxNode::new_root(green);
2136
2137    // Should have one EMPHASIS node at root
2138    let emphasis_nodes: Vec<_> = node
2139        .descendants()
2140        .filter(|n| n.kind() == SyntaxKind::EMPHASIS)
2141        .collect();
2142    assert_eq!(
2143        emphasis_nodes.len(),
2144        1,
2145        "Should have exactly one EMPHASIS node, found: {}",
2146        emphasis_nodes.len()
2147    );
2148
2149    // EMPHASIS should contain two STRONG nodes
2150    let emphasis_node = emphasis_nodes[0].clone();
2151    let strong_in_emphasis: Vec<_> = emphasis_node
2152        .children()
2153        .filter(|n| n.kind() == SyntaxKind::STRONG)
2154        .collect();
2155    assert_eq!(
2156        strong_in_emphasis.len(),
2157        2,
2158        "EMPHASIS should contain two STRONG nodes, found: {}",
2159        strong_in_emphasis.len()
2160    );
2161
2162    // Verify losslessness
2163    assert_eq!(node.text().to_string(), text);
2164}
2165
2166#[test]
2167fn test_triple_emphasis_all_emph_nested() {
2168    // ***foo* bar *baz*** should parse as Strong[Emph[foo], " bar ", Emph[baz]]
2169    // Pandoc output confirms this
2170
2171    use crate::options::ParserOptions;
2172    use crate::syntax::SyntaxNode;
2173    use rowan::GreenNode;
2174
2175    let text = "***foo* bar *baz***";
2176    let config = ParserOptions::default();
2177    let mut builder = GreenNodeBuilder::new();
2178
2179    builder.start_node(SyntaxKind::DOCUMENT.into());
2180    parse_inline_text_recursive(&mut builder, text, &config, false);
2181    builder.finish_node();
2182
2183    let green: GreenNode = builder.finish();
2184    let node = SyntaxNode::new_root(green);
2185
2186    // Should have one STRONG node at root
2187    let strong_nodes: Vec<_> = node
2188        .descendants()
2189        .filter(|n| n.kind() == SyntaxKind::STRONG)
2190        .collect();
2191    assert_eq!(
2192        strong_nodes.len(),
2193        1,
2194        "Should have exactly one STRONG node, found: {}",
2195        strong_nodes.len()
2196    );
2197
2198    // STRONG should contain two EMPHASIS nodes
2199    let strong_node = strong_nodes[0].clone();
2200    let emph_in_strong: Vec<_> = strong_node
2201        .children()
2202        .filter(|n| n.kind() == SyntaxKind::EMPHASIS)
2203        .collect();
2204    assert_eq!(
2205        emph_in_strong.len(),
2206        2,
2207        "STRONG should contain two EMPHASIS nodes, found: {}",
2208        emph_in_strong.len()
2209    );
2210
2211    // Verify losslessness
2212    assert_eq!(node.text().to_string(), text);
2213}
2214
2215// Multiline emphasis tests
2216#[test]
2217fn test_parse_emphasis_multiline() {
2218    // Per Pandoc spec, emphasis CAN contain newlines (soft breaks)
2219    use crate::options::ParserOptions;
2220    use crate::syntax::SyntaxNode;
2221    use rowan::GreenNode;
2222
2223    let text = "*text on\nline two*";
2224    let config = ParserOptions::default();
2225    let mut builder = GreenNodeBuilder::new();
2226
2227    builder.start_node(SyntaxKind::PARAGRAPH.into());
2228    parse_inline_text_recursive(&mut builder, text, &config, false);
2229    builder.finish_node();
2230
2231    let green: GreenNode = builder.finish();
2232    let node = SyntaxNode::new_root(green);
2233
2234    let has_emph = node.descendants().any(|n| n.kind() == SyntaxKind::EMPHASIS);
2235    assert!(has_emph, "Should have EMPHASIS node");
2236
2237    assert_eq!(node.text().to_string(), text);
2238    assert!(
2239        node.text().to_string().contains('\n'),
2240        "Should preserve newline in emphasis content"
2241    );
2242}
2243
2244#[test]
2245fn test_parse_strong_multiline() {
2246    // Per Pandoc spec, strong emphasis CAN contain newlines
2247    use crate::options::ParserOptions;
2248    use crate::syntax::SyntaxNode;
2249    use rowan::GreenNode;
2250
2251    let text = "**strong on\nline two**";
2252    let config = ParserOptions::default();
2253    let mut builder = GreenNodeBuilder::new();
2254
2255    builder.start_node(SyntaxKind::PARAGRAPH.into());
2256    parse_inline_text_recursive(&mut builder, text, &config, false);
2257    builder.finish_node();
2258
2259    let green: GreenNode = builder.finish();
2260    let node = SyntaxNode::new_root(green);
2261
2262    let has_strong = node.descendants().any(|n| n.kind() == SyntaxKind::STRONG);
2263    assert!(has_strong, "Should have STRONG node");
2264
2265    assert_eq!(node.text().to_string(), text);
2266    assert!(
2267        node.text().to_string().contains('\n'),
2268        "Should preserve newline in strong content"
2269    );
2270}
2271
2272#[test]
2273fn test_parse_triple_emphasis_multiline() {
2274    // Triple emphasis with newlines
2275    use crate::options::ParserOptions;
2276    use crate::syntax::SyntaxNode;
2277    use rowan::GreenNode;
2278
2279    let text = "***both on\nline two***";
2280    let config = ParserOptions::default();
2281    let mut builder = GreenNodeBuilder::new();
2282
2283    builder.start_node(SyntaxKind::PARAGRAPH.into());
2284    parse_inline_text_recursive(&mut builder, text, &config, false);
2285    builder.finish_node();
2286
2287    let green: GreenNode = builder.finish();
2288    let node = SyntaxNode::new_root(green);
2289
2290    // Should have STRONG node (triple = strong + emph)
2291    let has_strong = node.descendants().any(|n| n.kind() == SyntaxKind::STRONG);
2292    assert!(has_strong, "Should have STRONG node");
2293
2294    assert_eq!(node.text().to_string(), text);
2295    assert!(
2296        node.text().to_string().contains('\n'),
2297        "Should preserve newline in triple emphasis content"
2298    );
2299}