Skip to main content

markdown_syntax/
parse.rs

1//! Markdown source to AST. The entry points are the free [`parse`] function
2//! (maximal default dialect) and the [`SyntaxOptions::parse`] /
3//! [`SyntaxOptions::parse_strict`] methods. Parsing is tolerant: problems are
4//! collected as [`Diagnostic`]s rather than aborting.
5
6use alloc::{borrow::Cow, collections::BTreeMap, string::String, vec, vec::Vec};
7
8use crate::{
9    ast::*,
10    diagnostic::{Diagnostic, DiagnosticCode, DiagnosticSeverity},
11    entities::named_character_reference,
12    memo::{
13        bracket_walk, path_walk, pattern_starts, BracketMemo, BracketStep, PathMemo, PersistentMap,
14        Positions, Step,
15    },
16    options::{SyntaxConfigError, SyntaxOptions},
17    span::Span,
18    validate::is_directive_name,
19};
20
21mod nul;
22#[cfg(test)]
23mod scan_tests;
24
25/// The result of a tolerant parse: the document plus any diagnostics gathered
26/// along the way (empty on a clean parse).
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct ParseOutput {
29    /// The parsed document tree.
30    pub document: Document,
31    /// Diagnostics collected during parsing.
32    pub diagnostics: Vec<Diagnostic>,
33}
34
35/// The error returned by [`SyntaxOptions::parse_strict`].
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum ParseStrictError {
38    /// The options themselves were contradictory.
39    Config(SyntaxConfigError),
40    /// An error-severity diagnostic was promoted to a hard failure.
41    Diagnostic(Diagnostic),
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45struct ParsedLinkResource {
46    destination: String,
47    destination_kind: LinkDestinationKind,
48    title: Option<String>,
49    title_kind: Option<LinkTitleKind>,
50}
51
52const REFERENCE_LABEL_MAX_CHARS: usize = 999;
53const WIKILINK_MAX_BYTES: usize = 999;
54
55#[derive(Clone, Copy, Debug)]
56struct Line<'a> {
57    text: &'a str,
58    eol: &'a str,
59    start: usize,
60    end: usize,
61    end_with_eol: usize,
62    /// True when this line reached the current container as a *lazy continuation*
63    /// — a line with no container marker that nonetheless continues an open
64    /// paragraph (CommonMark §5.2 laziness). Block constructs that must not be
65    /// started by a lazy line (e.g. a setext underline) consult this flag.
66    lazy: bool,
67}
68
69#[derive(Clone, Copy, Debug)]
70struct ListMarkerInfo<'a> {
71    ordered: bool,
72    start: Option<u64>,
73    delimiter: ListDelimiter,
74    indent: usize,
75    marker_len: usize,
76    content_indent: usize,
77    content: &'a str,
78}
79
80#[derive(Clone, Copy, Debug)]
81struct DescriptionMarker<'a> {
82    content_offset: usize,
83    content: &'a str,
84}
85
86#[derive(Clone, Debug)]
87struct DescriptionTerm {
88    marker_index: usize,
89    term_end: usize,
90    blank_after_term: bool,
91    source: String,
92    source_offset: usize,
93}
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96enum HtmlBlockKind {
97    RawTag,
98    BlockTag,
99    Until(&'static str),
100    UntilBlank,
101}
102
103/// Parse `input` under the maximal default dialect ([`SyntaxOptions::default`]).
104/// Infallible and tolerant; sugar for `SyntaxOptions::default().parse(input)`.
105pub fn parse(input: &str) -> ParseOutput {
106    SyntaxOptions::default().parse(input)
107}
108
109impl SyntaxOptions {
110    /// Parse `input` under these options. Infallible and tolerant: a config
111    /// conflict (reachable only by hand-building contradictory `Constructs`) is
112    /// surfaced as an error diagnostic rather than a hard error. Call
113    /// [`SyntaxOptions::validate`] first for fail-fast config checking.
114    pub fn parse(&self, input: &str) -> ParseOutput {
115        match parse_checked(input, self) {
116            Ok(output) => output,
117            Err(error) => ParseOutput {
118                document: Document::default(),
119                diagnostics: vec![Diagnostic::new(
120                    DiagnosticSeverity::Error,
121                    DiagnosticCode::StrictParse,
122                    Span::new(0, input.len()),
123                    error.message(),
124                )],
125            },
126        }
127    }
128
129    /// Parse `input`, promoting a config conflict or any error-severity
130    /// diagnostic to a hard [`ParseStrictError`].
131    pub fn parse_strict(&self, input: &str) -> Result<ParseOutput, ParseStrictError> {
132        let output = parse_checked(input, self).map_err(ParseStrictError::Config)?;
133        if let Some(diagnostic) = output
134            .diagnostics
135            .iter()
136            .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
137        {
138            return Err(ParseStrictError::Diagnostic(diagnostic.clone()));
139        }
140        Ok(output)
141    }
142}
143
144fn parse_checked(input: &str, options: &SyntaxOptions) -> Result<ParseOutput, SyntaxConfigError> {
145    options.validate()?;
146    let mut diagnostics = Vec::new();
147    // A leading byte order mark is not content; parsing starts after it while
148    // spans keep counting from the start of `input`.
149    let start = if input.starts_with('\u{feff}') {
150        '\u{feff}'.len_utf8()
151    } else {
152        0
153    };
154    let source = &input[start..];
155    let definitions = collect_definitions(source, start, options);
156    let children = parse_blocks(
157        source,
158        start,
159        true,
160        options,
161        &definitions,
162        &mut diagnostics,
163        0,
164    );
165    let mut document = Document {
166        meta: NodeMeta::new(Some(Span::new(0, input.len()))),
167        children,
168    };
169    if source.contains('\0') {
170        nul::replace_in_document(&mut document);
171    }
172
173    Ok(ParseOutput {
174        document,
175        diagnostics,
176    })
177}
178
179/// CommonMark reads U+0000 as U+FFFD. Character classifications that differ
180/// between the two call this; node values are rewritten after parsing by
181/// `nul::replace_in_document`, so spans stay in source coordinates.
182fn source_char(char: char) -> char {
183    if char == '\0' {
184        '\u{FFFD}'
185    } else {
186        char
187    }
188}
189
190/// The deepest block-container nesting (block quotes, list items, container
191/// directives, footnote definitions, HTML containers, description details) the
192/// parser opens. Deeper container markers stay leaf-block text, so recursion
193/// and the native stack it uses stay bounded.
194const MAX_BLOCK_NESTING: usize = 32;
195
196fn parse_blocks(
197    input: &str,
198    base_offset: usize,
199    allow_frontmatter: bool,
200    options: &SyntaxOptions,
201    definitions: &[String],
202    diagnostics: &mut Vec<Diagnostic>,
203    depth: usize,
204) -> Vec<Block> {
205    let lines = collect_lines(input, base_offset);
206    parse_blocks_from_lines(
207        &lines,
208        allow_frontmatter,
209        options,
210        definitions,
211        diagnostics,
212        depth,
213    )
214}
215
216fn parse_blocks_from_lines(
217    lines: &[Line<'_>],
218    allow_frontmatter: bool,
219    options: &SyntaxOptions,
220    definitions: &[String],
221    diagnostics: &mut Vec<Diagnostic>,
222    depth: usize,
223) -> Vec<Block> {
224    let mut blocks = Vec::new();
225    let mut index = 0;
226    // Containers open one nesting level deeper; at the limit their markers are
227    // left to the leaf blocks (mostly paragraph text).
228    let nest_containers = depth < MAX_BLOCK_NESTING;
229    // `<details>` close searches over these lines, memoized so a run of
230    // unclosed openers costs linear time.
231    let mut container_closes = BracketMemo::default();
232    let mut mdx_flow = MdxFlowScan::default();
233
234    while index < lines.len() {
235        let line = lines[index];
236        if line.text.trim().is_empty() {
237            index += 1;
238            continue;
239        }
240        let after_definition_unbroken = index > 0
241            && !lines[index - 1].text.trim().is_empty()
242            && matches!(blocks.last(), Some(Block::Definition(_)));
243
244        if allow_frontmatter && index == 0 {
245            if let Some((block, next)) = parse_frontmatter(lines, index, options) {
246                blocks.push(block);
247                index = next;
248                continue;
249            }
250        }
251
252        if let Some((block, next)) = nest_containers
253            .then(|| {
254                parse_container_directive(lines, index, options, definitions, diagnostics, depth)
255            })
256            .flatten()
257        {
258            blocks.push(block);
259            index = next;
260            continue;
261        }
262
263        if let Some((block, next)) = parse_math_block(lines, index, options) {
264            blocks.push(block);
265            index = next;
266            continue;
267        }
268
269        if let Some((block, next)) = parse_fenced_code(lines, index, options) {
270            blocks.push(block);
271            index = next;
272            continue;
273        }
274
275        if let Some((block, next)) = nest_containers
276            .then(|| parse_block_quote(lines, index, options, definitions, diagnostics, depth))
277            .flatten()
278        {
279            blocks.push(block);
280            index = next;
281            continue;
282        }
283
284        if let Some(block) = parse_atx_heading(line, options, definitions) {
285            blocks.push(block);
286            index += 1;
287            continue;
288        }
289
290        if let Some(block) = parse_thematic_break(line) {
291            blocks.push(block);
292            index += 1;
293            continue;
294        }
295
296        if let Some((block, next)) = nest_containers
297            .then(|| parse_list(lines, index, options, definitions, diagnostics, depth))
298            .flatten()
299        {
300            blocks.push(block);
301            index = next;
302            continue;
303        }
304
305        if let Some((block, next)) = nest_containers
306            .then(|| {
307                parse_footnote_definition(lines, index, options, definitions, diagnostics, depth)
308            })
309            .flatten()
310        {
311            blocks.push(block);
312            index = next;
313            continue;
314        }
315
316        if let Some((block, next)) =
317            parse_definition(lines, index, options, after_definition_unbroken)
318        {
319            blocks.push(block);
320            index = next;
321            continue;
322        }
323
324        if let Some(block) = parse_leaf_directive(line, options, definitions, diagnostics) {
325            blocks.push(block);
326            index += 1;
327            continue;
328        }
329
330        if let Some((block, next)) = nest_containers
331            .then(|| {
332                parse_html_container(
333                    lines,
334                    index,
335                    options,
336                    definitions,
337                    diagnostics,
338                    depth,
339                    &mut container_closes,
340                )
341            })
342            .flatten()
343        {
344            blocks.push(block);
345            index = next;
346            continue;
347        }
348
349        if let Some((block, next)) = parse_html_block(lines, index, options) {
350            blocks.push(block);
351            index = next;
352            continue;
353        }
354
355        if let Some((block, next)) =
356            parse_mdx_flow(lines, index, options, diagnostics, &mut mdx_flow)
357        {
358            blocks.push(block);
359            index = next;
360            continue;
361        }
362
363        if !after_definition_unbroken {
364            if let Some((block, next)) = parse_indented_code(lines, index, options) {
365                blocks.push(block);
366                index = next;
367                continue;
368            }
369        }
370
371        if let Some((block, next)) = parse_table(lines, index, options, definitions, diagnostics) {
372            blocks.push(block);
373            index = next;
374            continue;
375        }
376
377        if let Some((block, next)) = parse_setext_heading(lines, index, options, definitions) {
378            blocks.push(block);
379            index = next;
380            continue;
381        }
382
383        if let Some((block, next)) = nest_containers
384            .then(|| parse_description_list(lines, index, options, definitions, diagnostics, depth))
385            .flatten()
386        {
387            blocks.push(block);
388            index = next;
389            continue;
390        }
391
392        let (block, next) = parse_paragraph(lines, index, options, definitions, diagnostics);
393        blocks.push(block);
394        index = next;
395    }
396
397    blocks
398}
399
400fn collect_lines(input: &str, base_offset: usize) -> Vec<Line<'_>> {
401    let bytes = input.as_bytes();
402    let mut lines = Vec::new();
403    let mut start = 0;
404    let mut index = 0;
405
406    while index < bytes.len() {
407        match bytes[index] {
408            b'\n' => {
409                let end = index;
410                lines.push(Line {
411                    text: &input[start..end],
412                    eol: &input[index..index + 1],
413                    start: base_offset + start,
414                    end: base_offset + end,
415                    end_with_eol: base_offset + index + 1,
416                    lazy: false,
417                });
418                index += 1;
419                start = index;
420            }
421            b'\r' => {
422                let end = index;
423                let eol_end = if index + 1 < bytes.len() && bytes[index + 1] == b'\n' {
424                    index + 2
425                } else {
426                    index + 1
427                };
428                lines.push(Line {
429                    text: &input[start..end],
430                    eol: &input[index..eol_end],
431                    start: base_offset + start,
432                    end: base_offset + end,
433                    end_with_eol: base_offset + eol_end,
434                    lazy: false,
435                });
436                index = eol_end;
437                start = index;
438            }
439            _ => index += 1,
440        }
441    }
442
443    if start < bytes.len() || input.is_empty() {
444        lines.push(Line {
445            text: &input[start..],
446            eol: "",
447            start: base_offset + start,
448            end: base_offset + bytes.len(),
449            end_with_eol: base_offset + bytes.len(),
450            lazy: false,
451        });
452    }
453
454    lines
455}
456
457fn collect_definitions(input: &str, base_offset: usize, options: &SyntaxOptions) -> Vec<String> {
458    let mut diagnostics = Vec::new();
459    let blocks = parse_blocks(input, base_offset, true, options, &[], &mut diagnostics, 0);
460    let mut definitions = Vec::new();
461    collect_definition_refs_from_blocks(&blocks, &mut definitions);
462    // Sorted and deduplicated so `definition_exists` can binary-search.
463    definitions.sort_unstable();
464    definitions.dedup();
465    definitions
466}
467
468fn collect_definition_refs_from_blocks(blocks: &[Block], definitions: &mut Vec<String>) {
469    for block in blocks {
470        match block {
471            Block::Definition(definition) => {
472                definitions.push(definition.identifier.clone());
473            }
474            Block::BlockQuote(node) => {
475                collect_definition_refs_from_blocks(&node.children, definitions);
476            }
477            Block::Alert(node) => {
478                collect_definition_refs_from_blocks(&node.children, definitions);
479            }
480            Block::List(node) => {
481                for item in &node.children {
482                    collect_definition_refs_from_blocks(&item.children, definitions);
483                }
484            }
485            Block::DescriptionList(node) => {
486                for item in &node.children {
487                    for details in &item.details {
488                        collect_definition_refs_from_blocks(&details.children, definitions);
489                    }
490                }
491            }
492            Block::FootnoteDefinition(node) => {
493                collect_definition_refs_from_blocks(&node.children, definitions);
494            }
495            Block::HtmlContainer(node) => {
496                if let HtmlContainerContent::Blocks(children) = &node.content {
497                    collect_definition_refs_from_blocks(children, definitions);
498                }
499            }
500            Block::ContainerDirective(node) => {
501                collect_definition_refs_from_blocks(&node.children, definitions);
502            }
503            _ => {}
504        }
505    }
506}
507
508fn parse_frontmatter(
509    lines: &[Line<'_>],
510    index: usize,
511    options: &SyntaxOptions,
512) -> Option<(Block, usize)> {
513    if !options.constructs.frontmatter {
514        return None;
515    }
516    let kind = frontmatter_fence_kind(lines[index].text)?;
517
518    let mut value = String::new();
519    let mut cursor = index + 1;
520    while cursor < lines.len() {
521        if frontmatter_fence_kind(lines[cursor].text) == Some(kind) {
522            let span = Span::new(lines[index].start, lines[cursor].end_with_eol);
523            return Some((
524                Block::Frontmatter(Frontmatter {
525                    meta: NodeMeta::new(Some(span)),
526                    kind,
527                    value,
528                }),
529                cursor + 1,
530            ));
531        }
532        push_line(&mut value, lines[cursor].text);
533        cursor += 1;
534    }
535
536    None
537}
538
539fn frontmatter_fence_kind(line: &str) -> Option<FrontmatterKind> {
540    match line.trim_end_matches([' ', '\t']) {
541        "---" => Some(FrontmatterKind::Yaml),
542        "+++" => Some(FrontmatterKind::Toml),
543        _ => None,
544    }
545}
546
547fn parse_container_directive(
548    lines: &[Line<'_>],
549    index: usize,
550    options: &SyntaxOptions,
551    definitions: &[String],
552    diagnostics: &mut Vec<Diagnostic>,
553    depth: usize,
554) -> Option<(Block, usize)> {
555    if !options.constructs.directive_container {
556        return None;
557    }
558    let trimmed = trim_up_to_three_spaces(lines[index].text)?;
559    let Some((fence_len, opener_rest)) = directive_container_opener_prefix(trimmed) else {
560        return None;
561    };
562    let opener_base = lines[index].start + (lines[index].text.len() - trimmed.len()) + fence_len;
563
564    let Some((name, label_source, attributes, _consumed)) = parse_directive_opener(opener_rest)
565    else {
566        diagnostics.push(Diagnostic::new(
567            DiagnosticSeverity::Error,
568            DiagnosticCode::InvalidDirectiveName,
569            Span::new(lines[index].start, lines[index].end),
570            "container directive must have a valid name",
571        ));
572        return None;
573    };
574    let label_base = opener_base + name.len() + 1;
575
576    let mut content = String::new();
577    let mut cursor = index + 1;
578    let mut nested_fences = Vec::new();
579    while cursor < lines.len() {
580        let line = lines[cursor].text;
581        let trimmed = trim_up_to_three_spaces(line);
582        if let Some(trimmed) = trimmed {
583            if let Some(nested_len) = nested_fences.last().copied() {
584                if directive_container_closing_fence(trimmed, nested_len).is_some() {
585                    nested_fences.pop();
586                    push_line(&mut content, line);
587                    cursor += 1;
588                    continue;
589                }
590            } else if directive_container_closing_fence(trimmed, fence_len).is_some() {
591                let label = label_source
592                    .map(|source| {
593                        parse_inlines(source, label_base, options, definitions, diagnostics)
594                    })
595                    .unwrap_or_default();
596                let children = parse_blocks(
597                    &content,
598                    lines[index + 1].start,
599                    false,
600                    options,
601                    definitions,
602                    diagnostics,
603                    depth + 1,
604                );
605                return Some((
606                    Block::ContainerDirective(ContainerDirective {
607                        meta: NodeMeta::new(Some(Span::new(
608                            lines[index].start,
609                            lines[cursor].end_with_eol,
610                        ))),
611                        name,
612                        label,
613                        attributes,
614                        children,
615                    }),
616                    cursor + 1,
617                ));
618            }
619
620            if let Some((nested_len, nested_rest)) = directive_container_opener_prefix(trimmed) {
621                if parse_directive_opener(nested_rest).is_some() {
622                    nested_fences.push(nested_len);
623                }
624            }
625        }
626
627        push_line(&mut content, line);
628        cursor += 1;
629    }
630
631    diagnostics.push(Diagnostic::new(
632        DiagnosticSeverity::Error,
633        DiagnosticCode::UnclosedDirectiveContainer,
634        Span::new(lines[index].start, lines[index].end),
635        "container directive is missing a closing fence",
636    ));
637    Some((
638        Block::ContainerDirective(ContainerDirective {
639            meta: NodeMeta::new(Some(Span::new(
640                lines[index].start,
641                lines.last()?.end_with_eol,
642            ))),
643            name,
644            label: label_source
645                .map(|source| parse_inlines(source, label_base, options, definitions, diagnostics))
646                .unwrap_or_default(),
647            attributes,
648            children: parse_blocks(
649                &content,
650                lines
651                    .get(index + 1)
652                    .map(|line| line.start)
653                    .unwrap_or(lines[index].end),
654                false,
655                options,
656                definitions,
657                diagnostics,
658                depth + 1,
659            ),
660        }),
661        lines.len(),
662    ))
663}
664
665fn directive_container_opener_prefix(input: &str) -> Option<(usize, &str)> {
666    let fence_len = input
667        .as_bytes()
668        .iter()
669        .take_while(|byte| **byte == b':')
670        .count();
671    if fence_len >= 3 {
672        Some((fence_len, &input[fence_len..]))
673    } else {
674        None
675    }
676}
677
678fn directive_container_closing_fence(input: &str, min_len: usize) -> Option<usize> {
679    let fence_len = input
680        .as_bytes()
681        .iter()
682        .take_while(|byte| **byte == b':')
683        .count();
684    if fence_len >= min_len && input[fence_len..].trim().is_empty() {
685        Some(fence_len)
686    } else {
687        None
688    }
689}
690
691fn parse_math_block(
692    lines: &[Line<'_>],
693    index: usize,
694    options: &SyntaxOptions,
695) -> Option<(Block, usize)> {
696    if !options.constructs.math_block {
697        return None;
698    }
699    // A math-flow opener is the fenced-code analogue: a `>=2` dollar run after
700    // 0–3 columns of indent, optionally followed by an "info"/meta string that
701    // must NOT contain another `$` (`$$ $$` is inline math, not a flow open).
702    // The opening indent is stripped (up to its own width) from each content
703    // line, exactly like a fenced code block.
704    let opener = trim_up_to_three_spaces(lines[index].text)?;
705    let fence_length = math_block_fence_length(opener)?;
706    let opening_indent = leading_indent_columns(lines[index].text);
707
708    let mut value = String::new();
709    let mut content_lines = 0usize;
710    let mut cursor = index + 1;
711    while cursor < lines.len() {
712        if let Some(close_line) = trim_up_to_three_spaces(lines[cursor].text) {
713            if math_block_fence_closes(close_line, fence_length) {
714                return Some((
715                    Block::MathBlock(MathBlock {
716                        meta: NodeMeta::new(Some(Span::new(
717                            lines[index].start,
718                            lines[cursor].end_with_eol,
719                        ))),
720                        value,
721                    }),
722                    cursor + 1,
723                ));
724            }
725        }
726        if content_lines > 0 {
727            // The previous content line's `eol` usually separates lines. This
728            // fallback only covers synthetic child input that lacks an EOL despite
729            // yielding another line.
730            ensure_line_separator(&mut value);
731        }
732        let stripped = strip_leading_indent_columns(lines[cursor].text, opening_indent);
733        value.push_str(&stripped);
734        value.push_str(lines[cursor].eol);
735        content_lines += 1;
736        cursor += 1;
737    }
738
739    // EOF closes the block (an unclosed opener runs to end of document); an
740    // immediate EOF after the opener yields an empty math block.
741    Some((
742        Block::MathBlock(MathBlock {
743            meta: NodeMeta::new(Some(Span::new(
744                lines[index].start,
745                lines.last()?.end_with_eol,
746            ))),
747            value,
748        }),
749        lines.len(),
750    ))
751}
752
753/// Length of the leading `$` run if `input` (already indent-stripped) is a valid
754/// math-flow opener: `>=2` dollars, then an info string with no further `$`.
755fn math_block_fence_length(input: &str) -> Option<usize> {
756    let length = input
757        .as_bytes()
758        .iter()
759        .take_while(|byte| **byte == b'$')
760        .count();
761    if length < 2 || input[length..].contains('$') {
762        return None;
763    }
764    Some(length)
765}
766
767/// A math-flow closing line (already indent-stripped) is a run of `>=length`
768/// dollars and nothing else (trailing whitespace aside).
769fn math_block_fence_closes(input: &str, length: usize) -> bool {
770    let count = input
771        .as_bytes()
772        .iter()
773        .take_while(|byte| **byte == b'$')
774        .count();
775    count >= length && input[count..].trim().is_empty()
776}
777
778fn parse_fenced_code(
779    lines: &[Line<'_>],
780    index: usize,
781    options: &SyntaxOptions,
782) -> Option<(Block, usize)> {
783    let line = fence_line(lines[index].text, options)?;
784    let (marker, length) = fence_start(line)?;
785    // CommonMark: up to N columns of indentation (N = the opening fence's
786    // indent, 0–3) are removed from each content line.
787    let opening_indent = leading_indent_columns(lines[index].text);
788    let info = line[length..].trim();
789    if marker == FenceMarker::Backtick && info.contains('`') {
790        return None;
791    }
792    let info = if info.is_empty() {
793        None
794    } else {
795        Some(unescape_string(info))
796    };
797
798    let mut value = String::new();
799    // Join content lines with `\n` while preserving a leading blank line: a
800    // fenced block can open with a blank content line, and `push_line`'s
801    // empty-output proxy cannot tell zero lines from one empty line, so it would
802    // drop that leading blank. Track the count explicitly (as parse_math_block).
803    let mut content_lines = 0usize;
804    let mut cursor = index + 1;
805    while cursor < lines.len() {
806        if let Some(close_line) = fence_line(lines[cursor].text, options) {
807            if fence_close(close_line, marker, length) {
808                return Some((
809                    Block::CodeBlock(CodeBlock {
810                        meta: NodeMeta::new(Some(Span::new(
811                            lines[index].start,
812                            lines[cursor].end_with_eol,
813                        ))),
814                        kind: CodeBlockKind::Fenced { marker, length },
815                        info,
816                        value,
817                    }),
818                    cursor + 1,
819                ));
820            }
821        }
822        if content_lines > 0 {
823            // The previous content line's `eol` usually separates lines. This
824            // fallback only covers synthetic child input that lacks an EOL despite
825            // yielding another line.
826            ensure_line_separator(&mut value);
827        }
828        let stripped = strip_leading_indent_columns(lines[cursor].text, opening_indent);
829        value.push_str(&stripped);
830        value.push_str(lines[cursor].eol);
831        content_lines += 1;
832        cursor += 1;
833    }
834    Some((
835        Block::CodeBlock(CodeBlock {
836            meta: NodeMeta::new(Some(Span::new(
837                lines[index].start,
838                lines.last()?.end_with_eol,
839            ))),
840            kind: CodeBlockKind::Fenced { marker, length },
841            info,
842            value,
843        }),
844        lines.len(),
845    ))
846}
847
848fn fence_line<'a>(line: &'a str, options: &SyntaxOptions) -> Option<&'a str> {
849    if options.constructs.indented_code {
850        trim_up_to_three_spaces(line)
851    } else {
852        Some(trim_ascii_start(line))
853    }
854}
855
856fn container_closed_after_unclosed_fence(
857    lines: &[Line<'_>],
858    cursor: usize,
859    last_content_index: usize,
860    content: &str,
861    options: &SyntaxOptions,
862) -> bool {
863    !lines[last_content_index].eol.is_empty()
864        && (cursor >= lines.len() || lines[cursor].text.trim().is_empty())
865        && content_has_unclosed_fenced_code(content, options)
866}
867
868fn content_has_unclosed_fenced_code(content: &str, options: &SyntaxOptions) -> bool {
869    let lines = collect_lines(content, 0);
870    let mut open_fence = None;
871    for line in lines {
872        let Some(trimmed) = fence_line(line.text, options) else {
873            continue;
874        };
875        if let Some((marker, length, has_nonblank_content)) = open_fence {
876            if fence_close(trimmed, marker, length) {
877                open_fence = None;
878            } else {
879                open_fence = Some((
880                    marker,
881                    length,
882                    has_nonblank_content || !trimmed.trim().is_empty(),
883                ));
884            }
885            continue;
886        }
887        let Some((marker, length)) = fence_start(trimmed) else {
888            continue;
889        };
890        let info = trimmed[length..].trim();
891        if marker != FenceMarker::Backtick || !info.contains('`') {
892            open_fence = Some((marker, length, false));
893        }
894    }
895    open_fence.is_some_and(|(_, _, has_nonblank_content)| !has_nonblank_content)
896}
897
898/// Recursively determines whether the innermost block reachable through this
899/// (already marker-stripped) block-quote content line is an OPEN paragraph —
900/// the only block kind that a following lazy continuation line may extend.
901///
902/// Nested quote markers are stripped one level at a time so that, e.g.,
903/// `> > a` reports that the deepest content `a` is an open paragraph (this is
904/// what lets a lazy line continue a paragraph buried inside several quotes).
905/// Indented code, blank lines, HTML blocks, and every other block start are
906/// reported as NOT-an-open-paragraph.
907fn block_quote_content_paragraph_open(content: &str, options: &SyntaxOptions) -> bool {
908    // Nested `>` and list markers are peeled one per iteration. Past
909    // `MAX_BLOCK_NESTING` of them no container opens (see
910    // `parse_blocks_from_lines`), so what remains is paragraph text.
911    let mut source = Cow::Borrowed(content);
912    let mut offset = 0;
913    for _ in 0..=MAX_BLOCK_NESTING {
914        let Some(trimmed) = trim_up_to_three_spaces(&source[offset..]) else {
915            // >= 4 columns of indentation: indented code, never a paragraph.
916            return false;
917        };
918        if trimmed.is_empty() {
919            return false;
920        }
921        let rest = if let Some(rest) = trimmed.strip_prefix('>') {
922            Cow::Borrowed(rest.strip_prefix(' ').unwrap_or(rest))
923        } else if let Some(marker) = list_marker_info(trimmed) {
924            list_marker_first_content(trimmed, marker)
925        } else {
926            return !lazy_line_starts_block(trimmed, options);
927        };
928        match rest {
929            // A borrowed rest is a suffix of `source`; continue from it in place.
930            Cow::Borrowed(rest) => offset = source.len() - rest.len(),
931            Cow::Owned(rest) => {
932                source = Cow::Owned(rest);
933                offset = 0;
934            }
935        }
936    }
937    true
938}
939
940/// Whether a line starts a block for the purpose of LAZY-continuation
941/// suppression. Identical to [`likely_block_start`] except that *every* HTML
942/// block start — including the type-7 "complete tag" form that cannot interrupt
943/// a paragraph with a marker present — blocks lazy continuation. A bare `<a>`
944/// after `> a` must close the quote, not be absorbed as paragraph text.
945fn lazy_line_starts_block(input: &str, options: &SyntaxOptions) -> bool {
946    likely_block_start(input, options)
947        || (options.constructs.html_block && line_starts_html_block(input))
948        // A lazy line that almost opens a fenced code block — any fence-char
949        // run after up to three spaces of indent — ends the paragraph instead
950        // of continuing it (GH-19): `> x\n``\n` closes the quote rather than
951        // joining `` ` `` onto the paragraph.
952        || trim_up_to_three_spaces(input).is_some_and(|t| t.starts_with('`') || t.starts_with('~'))
953}
954
955fn parse_block_quote(
956    lines: &[Line<'_>],
957    index: usize,
958    options: &SyntaxOptions,
959    definitions: &[String],
960    diagnostics: &mut Vec<Diagnostic>,
961    depth: usize,
962) -> Option<(Block, usize)> {
963    if !trim_up_to_three_spaces(lines[index].text)?.starts_with('>') {
964        return None;
965    }
966
967    let mut content = String::new();
968    // Lazy provenance per collected content line, parallel to the `\n`-joined
969    // `content`. Re-split (`collect_lines`) lines map 1:1 to these flags, so the
970    // child parser can suppress lazy-only constructs (e.g. setext underlines).
971    let mut lazy_flags: Vec<bool> = Vec::new();
972    let mut cursor = index;
973    let mut paragraph_open = false;
974    let mut in_table = false;
975    let mut last_content_line: Option<String> = None;
976    let mut content_base_offset = None;
977    while cursor < lines.len() {
978        let raw = lines[cursor].text;
979        let trimmed_opt = trim_up_to_three_spaces(raw);
980        let marked = trimmed_opt.is_some_and(|trimmed| trimmed.starts_with('>'));
981        let quote_rest_owned: String;
982        if let Some(trimmed) = trimmed_opt {
983            if trimmed.is_empty() {
984                break;
985            }
986        }
987        let (line, line_start) = if marked {
988            let trimmed = trimmed_opt.expect("marked implies a trimmed line");
989            let trimmed_start = lines[cursor].start + (raw.len() - trimmed.len());
990            let mut rest_start = 1;
991            let mut rest = &trimmed[rest_start..];
992            if rest.starts_with(' ') {
993                rest_start += 1;
994                rest = &rest[1..];
995            } else if rest.starts_with('\t') {
996                let marker_end_column = leading_indent_columns(raw) + 1;
997                match strip_leading_indent_columns_from(rest, 1, marker_end_column) {
998                    Cow::Borrowed(stripped) => rest = stripped,
999                    Cow::Owned(stripped) => {
1000                        quote_rest_owned = stripped;
1001                        rest = &quote_rest_owned;
1002                    }
1003                }
1004            }
1005            (rest, trimmed_start + rest_start)
1006        } else if in_table {
1007            // An open GFM table absorbs unmarked rows (lazy table body); a
1008            // non-row unmarked line ends the quote.
1009            break;
1010        } else if paragraph_open && !lazy_line_starts_block(raw, options) {
1011            // Lazy paragraph continuation: a marker-less line that continues an
1012            // open paragraph (possibly nested). The RAW line is used verbatim —
1013            // its indentation (even >= 4 columns) is paragraph text, not code.
1014            (raw, lines[cursor].start)
1015        } else {
1016            break;
1017        };
1018
1019        let mut escaped_lazy = String::new();
1020        let line = if !marked
1021            && last_content_line.as_deref().is_some_and(|previous| {
1022                table_can_start_source(
1023                    previous,
1024                    line,
1025                    options.constructs.indented_code,
1026                    options.constructs.spoiler,
1027                )
1028            }) {
1029            escaped_lazy.push_str(line);
1030            if let Some(offset) = escaped_lazy.find('-') {
1031                escaped_lazy.insert(offset, '\\');
1032            }
1033            &escaped_lazy
1034        } else {
1035            line
1036        };
1037
1038        let starts_table = last_content_line.as_deref().is_some_and(|previous| {
1039            table_can_start_source(
1040                previous,
1041                line,
1042                options.constructs.indented_code,
1043                options.constructs.spoiler,
1044            )
1045        });
1046        if marked && starts_table {
1047            paragraph_open = false;
1048            in_table = true;
1049        } else if marked && in_table && block_quote_table_body_row(line, options) {
1050            paragraph_open = false;
1051        } else {
1052            in_table = false;
1053            // Track the innermost open paragraph across nested quote markers so a
1054            // following lazy line can reach a paragraph buried in nested quotes.
1055            paragraph_open = block_quote_content_paragraph_open(line, options);
1056        }
1057        last_content_line = Some(line.into());
1058        if content_base_offset.is_none() {
1059            content_base_offset = Some(line_start);
1060        }
1061        push_line(&mut content, line);
1062        lazy_flags.push(!marked);
1063        cursor += 1;
1064    }
1065
1066    let span = Span::new(lines[index].start, lines[cursor - 1].end_with_eol);
1067    let child_base_offset = content_base_offset.unwrap_or(lines[index].start);
1068    if !lines[cursor - 1].eol.is_empty() && !ends_with_line_ending(&content) {
1069        content.push_str(lines[cursor - 1].eol);
1070    }
1071    if container_closed_after_unclosed_fence(lines, cursor, cursor - 1, &content, options) {
1072        content.push('\n');
1073    }
1074    if let Some(alert) = parse_alert_from_block_quote(
1075        &content,
1076        child_base_offset,
1077        span,
1078        options,
1079        definitions,
1080        diagnostics,
1081        depth,
1082    ) {
1083        return Some((alert, cursor));
1084    }
1085
1086    let mut child_lines = collect_lines(&content, child_base_offset);
1087    for (child, &lazy) in child_lines.iter_mut().zip(lazy_flags.iter()) {
1088        child.lazy = lazy;
1089    }
1090    let children = parse_blocks_from_lines(
1091        &child_lines,
1092        false,
1093        options,
1094        definitions,
1095        diagnostics,
1096        depth + 1,
1097    );
1098    Some((
1099        Block::BlockQuote(BlockQuote {
1100            meta: NodeMeta::new(Some(span)),
1101            children,
1102        }),
1103        cursor,
1104    ))
1105}
1106
1107fn parse_alert_from_block_quote(
1108    content: &str,
1109    base_offset: usize,
1110    span: Span,
1111    options: &SyntaxOptions,
1112    definitions: &[String],
1113    diagnostics: &mut Vec<Diagnostic>,
1114    depth: usize,
1115) -> Option<Block> {
1116    if !options.constructs.gfm_alert {
1117        return None;
1118    }
1119    let (first_line, rest) = content.split_once('\n').unwrap_or((content, ""));
1120    let (kind, title) = parse_alert_marker(first_line)?;
1121    let rest_base_offset = base_offset + first_line.len() + usize::from(!rest.is_empty());
1122    let children = if rest.is_empty() {
1123        Vec::new()
1124    } else {
1125        parse_blocks(
1126            rest,
1127            rest_base_offset,
1128            false,
1129            options,
1130            definitions,
1131            diagnostics,
1132            depth + 1,
1133        )
1134    };
1135    Some(Block::Alert(Alert {
1136        meta: NodeMeta::new(Some(span)),
1137        kind,
1138        title,
1139        children,
1140    }))
1141}
1142
1143fn parse_alert_marker(line: &str) -> Option<(AlertKind, Option<String>)> {
1144    let close = line.find(']')?;
1145    let marker = line.get(0..close + 1)?;
1146    if !marker.starts_with("[!") {
1147        return None;
1148    }
1149    let kind = match &marker[2..close].to_ascii_lowercase()[..] {
1150        "note" => AlertKind::Note,
1151        "tip" => AlertKind::Tip,
1152        "important" => AlertKind::Important,
1153        "warning" => AlertKind::Warning,
1154        "caution" => AlertKind::Caution,
1155        _ => return None,
1156    };
1157    let title = line[close + 1..].trim();
1158    Some((
1159        kind,
1160        if title.is_empty() {
1161            None
1162        } else {
1163            Some(title.into())
1164        },
1165    ))
1166}
1167
1168fn block_quote_table_body_row(line: &str, options: &SyntaxOptions) -> bool {
1169    table_indent_line(line, options.constructs.indented_code).is_some_and(|row| {
1170        !row.trim().is_empty() && contains_unescaped_pipe(row, options.constructs.spoiler)
1171    })
1172}
1173
1174fn parse_list(
1175    lines: &[Line<'_>],
1176    index: usize,
1177    options: &SyntaxOptions,
1178    definitions: &[String],
1179    diagnostics: &mut Vec<Diagnostic>,
1180    depth: usize,
1181) -> Option<(Block, usize)> {
1182    let first_marker = list_marker_info(lines[index].text)?;
1183    let mut items = Vec::new();
1184    let mut cursor = index;
1185    let mut tight = true;
1186
1187    while cursor < lines.len() {
1188        // A thematic break (`* * *`, `---`, …) outranks a list marker at the same
1189        // position: it ends the list rather than opening a nested item. Test it
1190        // before accepting the line as a marker (precedence belongs at the call
1191        // site, not inside `list_marker_info`).
1192        if parse_thematic_break(lines[cursor]).is_some() {
1193            break;
1194        }
1195        let Some(marker) = list_marker_info(lines[cursor].text) else {
1196            break;
1197        };
1198        if !same_list_marker(first_marker, marker) {
1199            break;
1200        }
1201
1202        let item_start = cursor;
1203        let mut item_end = cursor;
1204        let mut item_tight = true;
1205        // Byte offsets within `content` at which an item-internal blank line
1206        // sits. After the item's children are parsed, a blank loosens the item
1207        // only when it falls in the GAP between two consecutive top-level
1208        // children (a direct separator); a blank absorbed inside a nested
1209        // container's span does not (per-list tightness).
1210        let mut item_blank_offsets: Vec<usize> = Vec::new();
1211        let mut content = String::new();
1212        // Lazy provenance per collected content line (parallel to the `\n`-joined
1213        // `content`, mapped 1:1 by the re-split `collect_lines`). A line is lazy
1214        // when it reached the item only as a paragraph continuation while
1215        // dedented below the item's content start: it is paragraph text and must
1216        // not begin a new block (e.g. `- d\n    - e` keeps `- e` as the lazy tail
1217        // of `d`'s paragraph, not a sublist — CommonMark "too few spaces").
1218        let mut lazy_flags: Vec<bool> = Vec::new();
1219        let mut open_fence = None;
1220        let first_content = list_marker_first_content(lines[cursor].text, marker);
1221        let mut last_content_line: Option<String> = Some(first_content.as_ref().into());
1222        let mut paragraph_open = list_item_paragraph_stays_open(None, &first_content, options);
1223        // CommonMark §5.2: a list item can begin with at most one blank line.
1224        // When the marker has no content the item starts blank, and the first
1225        // following blank line ends it — later indented content cannot join
1226        // (`-\n\n  foo` → empty item + separate paragraph).
1227        let mut item_started_blank = first_content.trim().is_empty();
1228        push_line(&mut content, &first_content);
1229        lazy_flags.push(false);
1230        update_list_item_fence(&first_content, &mut open_fence);
1231        cursor += 1;
1232
1233        while cursor < lines.len() {
1234            if lines[cursor].text.trim().is_empty() {
1235                // Blank/whitespace lines inside an open fenced code block are
1236                // verbatim code content, not item-ending blanks: keep them.
1237                if open_fence.is_some() {
1238                    let stripped = strip_list_continuation(
1239                        lines[cursor].text,
1240                        marker.content_indent,
1241                        first_marker.indent,
1242                    );
1243                    push_line(&mut content, &stripped);
1244                    lazy_flags.push(false);
1245                    update_list_item_fence(&stripped, &mut open_fence);
1246                    item_end = cursor;
1247                    cursor += 1;
1248                    continue;
1249                }
1250                let next = next_nonblank_line(lines, cursor + 1);
1251                if item_started_blank
1252                    || next >= lines.len()
1253                    || sibling_list_marker_at_line(
1254                        lines[next].text,
1255                        first_marker,
1256                        marker.content_indent,
1257                    )
1258                    || leading_indent_columns(lines[next].text) < marker.content_indent
1259                {
1260                    if next < lines.len()
1261                        && sibling_list_marker_at_line(
1262                            lines[next].text,
1263                            first_marker,
1264                            marker.content_indent,
1265                        )
1266                    {
1267                        item_tight = false;
1268                    }
1269                    cursor = next;
1270                    break;
1271                }
1272                // A blank between item content is recorded; whether it actually
1273                // loosens THIS list is decided structurally after the item's
1274                // children are parsed (a blank buried in a nested sublist must
1275                // not loosen the outer list — CommonMark requires the item to
1276                // *directly* contain the blank-separated blocks). Track the blank
1277                // line's offset within the collected content so the structural
1278                // check can tell a direct-child separator from a nested one.
1279                item_blank_offsets.push(content.len() + usize::from(!content.is_empty()));
1280                paragraph_open = false;
1281                push_line(&mut content, "");
1282                lazy_flags.push(false);
1283                item_end = cursor;
1284                cursor += 1;
1285                continue;
1286            }
1287
1288            item_started_blank = false;
1289
1290            if sibling_list_marker_at_line(lines[cursor].text, first_marker, marker.content_indent)
1291            {
1292                break;
1293            }
1294
1295            // A list marker of a different type/delimiter is a block boundary
1296            // (CommonMark §5.3: changing the marker starts a new list). It is not
1297            // a same-list sibling, so it would otherwise be absorbed as lazy
1298            // paragraph text — break the item instead so a new list can start.
1299            if leading_indent_columns(lines[cursor].text) < marker.content_indent
1300                && !same_list_marker_line(lines[cursor].text, first_marker)
1301                && list_marker_info(lines[cursor].text).is_some()
1302            {
1303                break;
1304            }
1305
1306            if leading_indent_columns(lines[cursor].text) < marker.content_indent {
1307                if likely_block_start(lines[cursor].text, options) || !paragraph_open {
1308                    break;
1309                }
1310            }
1311
1312            // A line dedented below the item's content start only stays in the
1313            // item as a lazy paragraph continuation (it reached here because a
1314            // paragraph was open). Mark it lazy so the re-parse keeps it as
1315            // paragraph text rather than letting a stripped `- e`/`> q`/`# h`
1316            // begin a fresh block inside the item.
1317            let lazy = paragraph_open
1318                && leading_indent_columns(lines[cursor].text) < marker.content_indent;
1319            let stripped = strip_list_continuation(
1320                lines[cursor].text,
1321                marker.content_indent,
1322                first_marker.indent,
1323            );
1324            let starts_table = last_content_line.as_deref().is_some_and(|previous| {
1325                table_can_start_source(
1326                    previous,
1327                    &stripped,
1328                    options.constructs.indented_code,
1329                    options.constructs.spoiler,
1330                )
1331            });
1332            paragraph_open = if starts_table {
1333                false
1334            } else {
1335                list_item_paragraph_stays_open(Some(paragraph_open), &stripped, options)
1336            };
1337            push_line(&mut content, &stripped);
1338            lazy_flags.push(lazy);
1339            update_list_item_fence(&stripped, &mut open_fence);
1340            last_content_line = Some(stripped.into_owned());
1341            item_end = cursor;
1342            cursor += 1;
1343        }
1344
1345        let child_base = lines[item_start].start + marker.content_indent;
1346        if !lines[item_end].eol.is_empty() && !ends_with_line_ending(&content) {
1347            content.push_str(lines[item_end].eol);
1348        }
1349        if container_closed_after_unclosed_fence(lines, cursor, item_end, &content, options) {
1350            content.push('\n');
1351        }
1352        let mut child_lines = collect_lines(&content, child_base);
1353        for (child, &lazy) in child_lines.iter_mut().zip(lazy_flags.iter()) {
1354            child.lazy = lazy;
1355        }
1356        let mut children = parse_blocks_from_lines(
1357            &child_lines,
1358            false,
1359            options,
1360            definitions,
1361            diagnostics,
1362            depth + 1,
1363        );
1364        let checked = if options.constructs.gfm_task_list_item {
1365            take_task_marker_from_children(&mut children)
1366        } else {
1367            None
1368        };
1369
1370        if item_tight
1371            && blank_separates_top_level_blocks(&item_blank_offsets, &children, child_base)
1372        {
1373            item_tight = false;
1374        }
1375        tight = tight && item_tight;
1376        items.push(ListItem {
1377            meta: NodeMeta::new(Some(Span::new(
1378                lines[item_start].start,
1379                lines[item_end].end_with_eol,
1380            ))),
1381            checked,
1382            children,
1383        });
1384    }
1385
1386    Some((
1387        Block::List(List {
1388            meta: NodeMeta::new(Some(Span::new(
1389                lines[index].start,
1390                lines[cursor - 1].end_with_eol,
1391            ))),
1392            ordered: first_marker.ordered,
1393            start: first_marker.start,
1394            delimiter: first_marker.delimiter,
1395            tight,
1396            children: items,
1397        }),
1398        cursor,
1399    ))
1400}
1401
1402/// Whether an item-internal blank line directly separates two of the item's own
1403/// top-level block children — which loosens the list. A blank loosens the item
1404/// when some top-level child STARTS after the blank: that child was split off
1405/// from the preceding content by the blank. A blank with no top-level child
1406/// starting after it was either trailing or absorbed into a nested container
1407/// (e.g. a sublist), so it does not loosen the outer list — CommonMark only
1408/// counts blank lines between blocks the item *directly* contains, and per-list
1409/// tightness keeps a sublist's internal blank from propagating outward.
1410///
1411/// Blank offsets and child spans share the `child_base` content origin (both
1412/// were produced from the same stripped item content), so the comparison is in
1413/// one coordinate space.
1414fn blank_separates_top_level_blocks(
1415    blank_offsets: &[usize],
1416    children: &[Block],
1417    child_base: usize,
1418) -> bool {
1419    if blank_offsets.is_empty() || children.len() < 2 {
1420        return false;
1421    }
1422    let Some(&first_blank) = blank_offsets.iter().min() else {
1423        return false;
1424    };
1425    children.iter().any(|child| {
1426        block_span(child).is_some_and(|span| span.start.saturating_sub(child_base) > first_blank)
1427    })
1428}
1429
1430fn block_span(block: &Block) -> Option<Span> {
1431    let meta = match block {
1432        Block::Paragraph(node) => &node.meta,
1433        Block::Heading(node) => &node.meta,
1434        Block::ThematicBreak(node) => &node.meta,
1435        Block::BlockQuote(node) => &node.meta,
1436        Block::Alert(node) => &node.meta,
1437        Block::List(node) => &node.meta,
1438        Block::DescriptionList(node) => &node.meta,
1439        Block::CodeBlock(node) => &node.meta,
1440        Block::HtmlBlock(node) => &node.meta,
1441        Block::HtmlContainer(node) => &node.meta,
1442        Block::Definition(node) => &node.meta,
1443        Block::FootnoteDefinition(node) => &node.meta,
1444        Block::Table(node) => &node.meta,
1445        Block::MathBlock(node) => &node.meta,
1446        Block::Frontmatter(node) => &node.meta,
1447        Block::MdxEsm(node) => &node.meta,
1448        Block::MdxExpression(node) => &node.meta,
1449        Block::MdxJsx(node) => &node.meta,
1450        Block::LeafDirective(node) => &node.meta,
1451        Block::ContainerDirective(node) => &node.meta,
1452    };
1453    meta.span
1454}
1455
1456fn list_item_paragraph_stays_open(
1457    previous_open: Option<bool>,
1458    line: &str,
1459    options: &SyntaxOptions,
1460) -> bool {
1461    if line.trim().is_empty() {
1462        return false;
1463    }
1464    if previous_open == Some(false) {
1465        return false;
1466    }
1467    block_quote_content_paragraph_open(line, options)
1468}
1469
1470fn parse_description_list(
1471    lines: &[Line<'_>],
1472    index: usize,
1473    options: &SyntaxOptions,
1474    definitions: &[String],
1475    diagnostics: &mut Vec<Diagnostic>,
1476    depth: usize,
1477) -> Option<(Block, usize)> {
1478    if !options.constructs.description_list || !is_description_term_line(lines[index].text, options)
1479    {
1480        return None;
1481    }
1482
1483    let mut cursor = index;
1484    let mut items = Vec::new();
1485    let mut tight = true;
1486    let mut list_end = lines[index].end_with_eol;
1487
1488    while cursor < lines.len() {
1489        if !is_description_term_line(lines[cursor].text, options) {
1490            break;
1491        }
1492        let Some(term) = description_term(lines, cursor, options) else {
1493            break;
1494        };
1495        let term_line = lines[cursor];
1496        let mut details = Vec::new();
1497        let item_start = term_line.start;
1498        let mut item_end = lines[term.term_end].end_with_eol;
1499        tight = tight && !term.blank_after_term;
1500        cursor = term.marker_index;
1501
1502        loop {
1503            let Some(marker) = description_marker(lines[cursor].text) else {
1504                break;
1505            };
1506            let (detail, next, detail_tight) = parse_description_details(
1507                lines,
1508                cursor,
1509                marker,
1510                options,
1511                definitions,
1512                diagnostics,
1513                depth,
1514            )?;
1515            tight = tight && detail_tight;
1516            item_end = detail
1517                .meta
1518                .span
1519                .map(|span| span.end)
1520                .unwrap_or(lines[cursor].end_with_eol);
1521            details.push(detail);
1522            cursor = next;
1523
1524            let next_nonblank = next_nonblank_line(lines, cursor);
1525            if next_nonblank < lines.len()
1526                && description_marker(lines[next_nonblank].text).is_some()
1527            {
1528                if next_nonblank != cursor {
1529                    tight = false;
1530                }
1531                cursor = next_nonblank;
1532                continue;
1533            }
1534            break;
1535        }
1536
1537        if details.is_empty() {
1538            return None;
1539        }
1540        list_end = item_end;
1541        items.push(DescriptionItem {
1542            meta: NodeMeta::new(Some(Span::new(item_start, item_end))),
1543            term: parse_inlines(
1544                &term.source,
1545                term.source_offset,
1546                options,
1547                definitions,
1548                diagnostics,
1549            ),
1550            details,
1551        });
1552
1553        let next_item = next_nonblank_line(lines, cursor);
1554        if next_item >= lines.len() {
1555            cursor = next_item;
1556            break;
1557        }
1558        if description_term(lines, next_item, options).is_some() {
1559            if next_item != cursor {
1560                tight = false;
1561            }
1562            cursor = next_item;
1563            continue;
1564        }
1565        cursor = next_item;
1566        break;
1567    }
1568
1569    (!items.is_empty()).then_some((
1570        Block::DescriptionList(DescriptionList {
1571            meta: NodeMeta::new(Some(Span::new(lines[index].start, list_end))),
1572            tight,
1573            children: items,
1574        }),
1575        cursor,
1576    ))
1577}
1578
1579fn parse_description_details(
1580    lines: &[Line<'_>],
1581    index: usize,
1582    marker: DescriptionMarker<'_>,
1583    options: &SyntaxOptions,
1584    definitions: &[String],
1585    diagnostics: &mut Vec<Diagnostic>,
1586    depth: usize,
1587) -> Option<(DescriptionDetails, usize, bool)> {
1588    let mut content = String::new();
1589    push_line(&mut content, marker.content);
1590    let mut cursor = index + 1;
1591    let mut end = lines[index].end_with_eol;
1592    let mut tight = true;
1593    let mut paragraph_open = paragraph_stays_open(marker.content, options);
1594
1595    while cursor < lines.len() {
1596        if lines[cursor].text.trim().is_empty() {
1597            let next = next_nonblank_line(lines, cursor + 1);
1598            // A blank that merely separates this definition from a following
1599            // `:`/`~` marker (another definition of the SAME term) is
1600            // content-separating, so it loosens the list. A blank that ends the
1601            // item — because the next non-blank line begins a new TERM, or the
1602            // document ends — is just an item boundary and must NOT loosen the
1603            // list (such blank-separated term groups stay tight).
1604            if next >= lines.len() || description_term(lines, next, options).is_some() {
1605                cursor = next;
1606                break;
1607            }
1608            if description_marker(lines[next].text).is_some() {
1609                tight = false;
1610                cursor = next;
1611                break;
1612            }
1613            if strip_indent_continuation(lines[next].text).is_none() {
1614                break;
1615            }
1616            push_line(&mut content, "");
1617            paragraph_open = false;
1618            tight = false;
1619            end = lines[cursor].end_with_eol;
1620            cursor += 1;
1621            continue;
1622        }
1623
1624        if description_marker(lines[cursor].text).is_some()
1625            || description_term(lines, cursor, options).is_some()
1626        {
1627            break;
1628        }
1629
1630        let continuation = if let Some(continuation) = strip_indent_continuation(lines[cursor].text)
1631        {
1632            continuation
1633        } else if paragraph_open && !likely_block_start(lines[cursor].text, options) {
1634            trim_ascii_start(lines[cursor].text)
1635        } else {
1636            break;
1637        };
1638        paragraph_open = paragraph_stays_open(continuation, options);
1639        push_line(&mut content, continuation);
1640        end = lines[cursor].end_with_eol;
1641        cursor += 1;
1642    }
1643
1644    if content.trim().is_empty() {
1645        return None;
1646    }
1647
1648    Some((
1649        DescriptionDetails {
1650            meta: NodeMeta::new(Some(Span::new(lines[index].start, end))),
1651            children: parse_blocks(
1652                &content,
1653                lines[index].start + marker.content_offset,
1654                false,
1655                options,
1656                definitions,
1657                diagnostics,
1658                depth + 1,
1659            ),
1660        },
1661        cursor,
1662        tight,
1663    ))
1664}
1665
1666fn description_term(
1667    lines: &[Line<'_>],
1668    term_index: usize,
1669    options: &SyntaxOptions,
1670) -> Option<DescriptionTerm> {
1671    if term_index >= lines.len() || !is_description_term_line(lines[term_index].text, options) {
1672        return None;
1673    }
1674    let mut source = String::new();
1675    let mut term_end = term_index;
1676    let mut cursor = term_index;
1677    while cursor < lines.len() && is_description_term_line(lines[cursor].text, options) {
1678        if !source.is_empty() {
1679            source.push('\n');
1680        }
1681        source.push_str(trim_ascii_start(lines[cursor].text).trim_end());
1682        term_end = cursor;
1683        cursor += 1;
1684    }
1685
1686    let mut marker_index = cursor;
1687    let mut blank_after_term = false;
1688    while marker_index < lines.len() && lines[marker_index].text.trim().is_empty() {
1689        blank_after_term = true;
1690        marker_index += 1;
1691    }
1692    (marker_index < lines.len() && description_marker(lines[marker_index].text).is_some()).then(
1693        || DescriptionTerm {
1694            marker_index,
1695            term_end,
1696            blank_after_term,
1697            source,
1698            source_offset: lines[term_index].start + leading_trim_bytes(lines[term_index].text),
1699        },
1700    )
1701}
1702
1703fn is_description_term_line(line: &str, options: &SyntaxOptions) -> bool {
1704    leading_indent_columns(line) <= 3
1705        && !line.trim().is_empty()
1706        && description_marker(line).is_none()
1707        && !likely_block_start(line, options)
1708}
1709
1710fn description_marker(line: &str) -> Option<DescriptionMarker<'_>> {
1711    let (columns, bytes) = leading_indent(line);
1712    if columns > 2 || !matches!(line.as_bytes().get(bytes), Some(b':' | b'~')) {
1713        return None;
1714    }
1715    if line
1716        .as_bytes()
1717        .get(bytes + 1)
1718        .is_some_and(|byte| !matches!(*byte, b' ' | b'\t'))
1719    {
1720        return None;
1721    }
1722    let mut content_offset = bytes + 1;
1723    while line
1724        .as_bytes()
1725        .get(content_offset)
1726        .is_some_and(|byte| matches!(*byte, b' ' | b'\t'))
1727    {
1728        content_offset += 1;
1729    }
1730    Some(DescriptionMarker {
1731        content_offset,
1732        content: &line[content_offset..],
1733    })
1734}
1735
1736/// A paragraph inside an indent-continuation container (footnote/description
1737/// detail) keeps absorbing the next line as long as it is non-blank and does
1738/// not itself begin a new block.
1739fn paragraph_stays_open(line: &str, options: &SyntaxOptions) -> bool {
1740    !line.trim().is_empty() && !likely_block_start(line, options)
1741}
1742
1743/// Strips one level of indent-continuation (four spaces or a tab) from a line.
1744fn strip_indent_continuation(input: &str) -> Option<&str> {
1745    input
1746        .strip_prefix("    ")
1747        .or_else(|| input.strip_prefix('\t'))
1748}
1749
1750fn parse_atx_heading(
1751    line: Line<'_>,
1752    options: &SyntaxOptions,
1753    definitions: &[String],
1754) -> Option<Block> {
1755    let text = trim_up_to_three_spaces(line.text)?;
1756    let depth = text
1757        .as_bytes()
1758        .iter()
1759        .take_while(|byte| **byte == b'#')
1760        .count();
1761    if depth == 0 || depth > 6 {
1762        return None;
1763    }
1764    if text
1765        .as_bytes()
1766        .get(depth)
1767        .is_some_and(|byte| !matches!(*byte, b' ' | b'\t'))
1768        && text.len() != depth
1769    {
1770        return None;
1771    }
1772    let after_opening = &text[depth..];
1773    let content_start_in_text = depth + leading_trim_bytes(after_opening);
1774    let content = trim_closing_hashes(after_opening.trim_start());
1775    let content_start = line.start + (line.text.len() - text.len()) + content_start_in_text;
1776    Some(Block::Heading(Heading {
1777        meta: NodeMeta::new(Some(Span::new(line.start, line.end))),
1778        depth: depth as u8,
1779        kind: HeadingKind::Atx,
1780        children: parse_inlines(
1781            content,
1782            content_start,
1783            options,
1784            definitions,
1785            &mut Vec::new(),
1786        ),
1787    }))
1788}
1789
1790fn parse_thematic_break(line: Line<'_>) -> Option<Block> {
1791    let text = trim_up_to_three_spaces(line.text)?.trim();
1792    let mut marker = None;
1793    let mut count = 0;
1794    for char in text.chars() {
1795        if char == ' ' || char == '\t' {
1796            continue;
1797        }
1798        let current = match char {
1799            '-' => ThematicBreakMarker::Dash,
1800            '*' => ThematicBreakMarker::Asterisk,
1801            '_' => ThematicBreakMarker::Underscore,
1802            _ => return None,
1803        };
1804        if marker.is_some_and(|marker| marker != current) {
1805            return None;
1806        }
1807        marker = Some(current);
1808        count += 1;
1809    }
1810    if count >= 3 {
1811        Some(Block::ThematicBreak(ThematicBreak {
1812            meta: NodeMeta::new(Some(Span::new(line.start, line.end))),
1813            marker: marker?,
1814        }))
1815    } else {
1816        None
1817    }
1818}
1819
1820fn parse_definition(
1821    lines: &[Line<'_>],
1822    index: usize,
1823    options: &SyntaxOptions,
1824    allow_subsequent_indent: bool,
1825) -> Option<(Block, usize)> {
1826    let line = lines[index];
1827    let text = trim_definition_start(line.text, allow_subsequent_indent)?;
1828    if !text.starts_with('[') {
1829        return None;
1830    }
1831
1832    // A reference-definition label may span several lines (CommonMark §4.7): the
1833    // `]:` closing the label can appear on a later line. Accumulate continuation
1834    // lines until the label closes, stopping at a blank line or end of input (a
1835    // blank line cannot occur inside a label). The first line's <=3-space indent
1836    // is already stripped by `trim_up_to_three_spaces`; continuation lines are
1837    // appended verbatim, and `normalize_label` collapses the interior newlines and
1838    // surrounding whitespace when the label is matched.
1839    let mut accumulated = String::from(text);
1840    let mut label_end_line = index;
1841    let close = loop {
1842        if let Some(close) = find_reference_label_end(&accumulated, 0) {
1843            if accumulated.as_bytes().get(close + 1) == Some(&b':') {
1844                break close;
1845            }
1846            // A closed label not followed by `:` is not a definition.
1847            return None;
1848        }
1849        let next = label_end_line + 1;
1850        if next >= lines.len() || lines[next].text.trim().is_empty() {
1851            return None;
1852        }
1853        // The unclosed label behaves like an open paragraph: a continuation line
1854        // that itself begins a block construct (a setext underline, or a GFM table
1855        // header/delimiter pair) interrupts it, so the definition fails and the
1856        // lines are re-parsed as blocks (CommonMark/GFM prefer setext headings,
1857        // thematic breaks, fenced code, and tables over a label that has not yet
1858        // closed — e.g. `[\na\n=\n]: b` or `[\na\n:-\n]: b`).
1859        if likely_block_start(lines[next].text, options)
1860            || setext_underline_depth(lines[next].text).is_some()
1861            || table_can_start(lines, next, options)
1862        {
1863            return None;
1864        }
1865        accumulated.push('\n');
1866        accumulated.push_str(lines[next].text);
1867        label_end_line = next;
1868    };
1869    let label = String::from(&accumulated[1..close]);
1870    if normalize_label(&label).is_empty() {
1871        return None;
1872    }
1873    let label = label.as_str();
1874    let mut source = String::from(&accumulated[close + 2..]);
1875    let mut cursor = label_end_line;
1876    let mut best_without_title = None;
1877
1878    loop {
1879        if let Some(resource) = parse_definition_destination_title(&source) {
1880            if resource.title.is_some() {
1881                return Some((
1882                    Block::Definition(Definition {
1883                        meta: NodeMeta::new(Some(Span::new(
1884                            line.start,
1885                            lines[cursor].end_with_eol,
1886                        ))),
1887                        label: label.into(),
1888                        identifier: normalize_label(label),
1889                        destination: resource.destination,
1890                        destination_kind: resource.destination_kind,
1891                        title: resource.title,
1892                        title_kind: resource.title_kind,
1893                    }),
1894                    cursor + 1,
1895                ));
1896            }
1897
1898            best_without_title = Some((resource, cursor + 1));
1899            let next = cursor + 1;
1900            if next >= lines.len()
1901                || lines[next].text.trim().is_empty()
1902                || !line_can_start_definition_title(lines[next].text)
1903            {
1904                break;
1905            }
1906        }
1907
1908        let next = cursor + 1;
1909        if next >= lines.len() || lines[next].text.trim().is_empty() {
1910            break;
1911        }
1912        // A continuation line that itself begins a block-level construct (or a
1913        // setext underline) cannot be swallowed into the definition's pending,
1914        // not-yet-closed title: such a line interrupts the would-be paragraph, so
1915        // the definition fails and the lines are re-parsed as blocks (e.g.
1916        // `[a]: b '` then `***` is a paragraph + thematic break, not a title).
1917        if likely_block_start(lines[next].text, options)
1918            || setext_underline_depth(lines[next].text).is_some()
1919        {
1920            break;
1921        }
1922        source.push('\n');
1923        source.push_str(lines[next].text);
1924        cursor = next;
1925    }
1926
1927    let (resource, next) = best_without_title?;
1928    let end = lines[next - 1].end_with_eol;
1929    Some((
1930        Block::Definition(Definition {
1931            meta: NodeMeta::new(Some(Span::new(line.start, end))),
1932            label: label.into(),
1933            identifier: normalize_label(label),
1934            destination: resource.destination,
1935            destination_kind: resource.destination_kind,
1936            title: resource.title,
1937            title_kind: resource.title_kind,
1938        }),
1939        next,
1940    ))
1941}
1942
1943fn trim_definition_start(input: &str, allow_subsequent_indent: bool) -> Option<&str> {
1944    if let Some(trimmed) = trim_up_to_three_spaces(input) {
1945        return Some(trimmed);
1946    }
1947    if allow_subsequent_indent {
1948        let (columns, bytes) = leading_indent(input);
1949        if columns == 4 {
1950            return Some(&input[bytes..]);
1951        }
1952    }
1953    None
1954}
1955
1956fn parse_footnote_definition(
1957    lines: &[Line<'_>],
1958    index: usize,
1959    options: &SyntaxOptions,
1960    definitions: &[String],
1961    diagnostics: &mut Vec<Diagnostic>,
1962    depth: usize,
1963) -> Option<(Block, usize)> {
1964    if !options.constructs.footnote_definition {
1965        return None;
1966    }
1967    let line = lines[index];
1968    let text = line.text.trim();
1969    if !text.starts_with("[^") {
1970        return None;
1971    }
1972    let close = find_footnote_definition_label_end(text)?;
1973    let label = &text[2..close];
1974    if !is_footnote_label(label) {
1975        return None;
1976    }
1977    let rest = text[close + 2..].trim();
1978    let mut content = String::new();
1979    push_line(&mut content, rest);
1980    let mut cursor = index + 1;
1981    let mut end = line.end_with_eol;
1982    let mut paragraph_open = paragraph_stays_open(rest, options);
1983
1984    while cursor < lines.len() {
1985        if lines[cursor].text.trim().is_empty() {
1986            let next = next_nonblank_line(lines, cursor + 1);
1987            if next >= lines.len() || !is_footnote_continuation(lines[next].text) {
1988                break;
1989            }
1990            push_line(&mut content, "");
1991            paragraph_open = false;
1992            end = lines[cursor].end_with_eol;
1993            cursor += 1;
1994            continue;
1995        }
1996
1997        let continuation = if let Some(continuation) = strip_indent_continuation(lines[cursor].text)
1998        {
1999            continuation
2000        } else if paragraph_open && !likely_block_start(lines[cursor].text, options) {
2001            trim_ascii_start(lines[cursor].text)
2002        } else {
2003            break;
2004        };
2005        paragraph_open = paragraph_stays_open(continuation, options);
2006        push_line(&mut content, continuation);
2007        end = lines[cursor].end_with_eol;
2008        cursor += 1;
2009    }
2010
2011    Some((
2012        Block::FootnoteDefinition(FootnoteDefinition {
2013            meta: NodeMeta::new(Some(Span::new(line.start, end))),
2014            label: label.into(),
2015            identifier: normalize_label(label),
2016            children: parse_blocks(
2017                &content,
2018                line.end.saturating_sub(rest.len()),
2019                false,
2020                options,
2021                definitions,
2022                diagnostics,
2023                depth + 1,
2024            ),
2025        }),
2026        cursor,
2027    ))
2028}
2029
2030fn is_footnote_continuation(input: &str) -> bool {
2031    strip_indent_continuation(input).is_some()
2032}
2033
2034fn parse_leaf_directive(
2035    line: Line<'_>,
2036    options: &SyntaxOptions,
2037    definitions: &[String],
2038    diagnostics: &mut Vec<Diagnostic>,
2039) -> Option<Block> {
2040    if !options.constructs.directive_leaf {
2041        return None;
2042    }
2043    let trimmed = line.text.trim_start();
2044    if trimmed.starts_with(":::") || !trimmed.starts_with("::") {
2045        return None;
2046    }
2047    let opener_base = line.start + (line.text.len() - trimmed.len()) + 2;
2048    let Some((name, label_source, attributes, _)) = parse_directive_opener(&trimmed[2..]) else {
2049        diagnostics.push(Diagnostic::new(
2050            DiagnosticSeverity::Error,
2051            DiagnosticCode::InvalidDirectiveName,
2052            Span::new(line.start, line.end),
2053            "leaf directive must have a valid name",
2054        ));
2055        return None;
2056    };
2057    let label = label_source
2058        .map(|source| {
2059            parse_inlines(
2060                source,
2061                opener_base + name.len() + 1,
2062                options,
2063                definitions,
2064                diagnostics,
2065            )
2066        })
2067        .unwrap_or_default();
2068    Some(Block::LeafDirective(LeafDirective {
2069        meta: NodeMeta::new(Some(Span::new(line.start, line.end))),
2070        name,
2071        label,
2072        attributes,
2073    }))
2074}
2075
2076fn parse_html_container(
2077    lines: &[Line<'_>],
2078    index: usize,
2079    options: &SyntaxOptions,
2080    definitions: &[String],
2081    diagnostics: &mut Vec<Diagnostic>,
2082    depth: usize,
2083    container_closes: &mut BracketMemo,
2084) -> Option<(Block, usize)> {
2085    if !options.constructs.html_container {
2086        return None;
2087    }
2088
2089    let (opening, leading_summary) =
2090        parse_details_container_opening_line(lines[index], options, definitions, diagnostics)?;
2091    let close_index = container_closes.resolve(lines.len() + 1, index + 1, |cursor| {
2092        html_container_close_step(lines, cursor, "details")
2093    })?;
2094    let closing =
2095        parse_html_container_tag_line(lines[close_index], "details", HtmlContainerTag::Closing)?;
2096    let children = parse_details_container_children(
2097        &lines[index + 1..close_index],
2098        leading_summary,
2099        options,
2100        definitions,
2101        diagnostics,
2102        depth,
2103    );
2104
2105    Some((
2106        Block::HtmlContainer(HtmlContainer {
2107            meta: NodeMeta::new(Some(Span::new(
2108                opening
2109                    .meta
2110                    .span
2111                    .map(|span| span.start)
2112                    .unwrap_or(lines[index].start),
2113                lines[close_index].end_with_eol,
2114            ))),
2115            opening,
2116            content: HtmlContainerContent::Blocks(children),
2117            closing,
2118        }),
2119        close_index + 1,
2120    ))
2121}
2122
2123#[derive(Clone, Copy, Eq, PartialEq)]
2124enum HtmlContainerTag {
2125    Opening,
2126    Closing,
2127}
2128
2129#[derive(Clone, Copy)]
2130struct HtmlContainerFence {
2131    marker: FenceMarker,
2132    length: usize,
2133}
2134
2135fn parse_details_container_children(
2136    lines: &[Line<'_>],
2137    leading_summary: Option<Block>,
2138    options: &SyntaxOptions,
2139    definitions: &[String],
2140    diagnostics: &mut Vec<Diagnostic>,
2141    depth: usize,
2142) -> Vec<Block> {
2143    let mut children = Vec::new();
2144    if let Some(summary) = leading_summary {
2145        children.push(summary);
2146        children.extend(parse_blocks_from_lines(
2147            lines,
2148            false,
2149            options,
2150            definitions,
2151            diagnostics,
2152            depth + 1,
2153        ));
2154        return children;
2155    }
2156
2157    let Some(summary_index) = lines.iter().position(|line| !line.text.trim().is_empty()) else {
2158        return children;
2159    };
2160
2161    if let Some((summary, next)) =
2162        parse_summary_container(lines, summary_index, options, definitions, diagnostics)
2163    {
2164        children.push(summary);
2165        children.extend(parse_blocks_from_lines(
2166            &lines[next..],
2167            false,
2168            options,
2169            definitions,
2170            diagnostics,
2171            depth + 1,
2172        ));
2173        return children;
2174    }
2175
2176    parse_blocks_from_lines(lines, false, options, definitions, diagnostics, depth + 1)
2177}
2178
2179fn parse_summary_container(
2180    lines: &[Line<'_>],
2181    index: usize,
2182    options: &SyntaxOptions,
2183    definitions: &[String],
2184    diagnostics: &mut Vec<Diagnostic>,
2185) -> Option<(Block, usize)> {
2186    let line = lines[index];
2187    let (trimmed, indent_bytes) = trim_html_container_line(line.text)?;
2188    parse_summary_container_source(
2189        trimmed,
2190        line.start + indent_bytes,
2191        options,
2192        definitions,
2193        diagnostics,
2194    )
2195    .map(|block| (block, index + 1))
2196}
2197
2198fn parse_details_container_opening_line(
2199    line: Line<'_>,
2200    options: &SyntaxOptions,
2201    definitions: &[String],
2202    diagnostics: &mut Vec<Diagnostic>,
2203) -> Option<(HtmlTag, Option<Block>)> {
2204    let (trimmed, indent_bytes) = trim_html_container_line(line.text)?;
2205    let (open_end, name) = parse_html_tag(trimmed, 0)?;
2206    if !name.eq_ignore_ascii_case("details")
2207        || html_tag_is_closing(trimmed, 0)
2208        || html_tag_is_self_closing(&trimmed[..open_end])
2209    {
2210        return None;
2211    }
2212
2213    let tag_start = line.start + indent_bytes;
2214    let rest_start = open_end + leading_ascii_whitespace_len(&trimmed[open_end..]);
2215    let summary = if trimmed[rest_start..].is_empty() {
2216        None
2217    } else {
2218        Some(parse_summary_container_source(
2219            &trimmed[rest_start..],
2220            tag_start + rest_start,
2221            options,
2222            definitions,
2223            diagnostics,
2224        )?)
2225    };
2226
2227    Some((
2228        HtmlTag {
2229            meta: NodeMeta::new(Some(Span::new(tag_start, tag_start + open_end))),
2230            name: "details".into(),
2231            raw: trimmed[..open_end].into(),
2232        },
2233        summary,
2234    ))
2235}
2236
2237fn parse_summary_container_source(
2238    source: &str,
2239    base_offset: usize,
2240    options: &SyntaxOptions,
2241    definitions: &[String],
2242    diagnostics: &mut Vec<Diagnostic>,
2243) -> Option<Block> {
2244    let (open_end, open_name) = parse_html_tag(source, 0)?;
2245    if !open_name.eq_ignore_ascii_case("summary")
2246        || html_tag_is_closing(source, 0)
2247        || html_tag_is_self_closing(&source[..open_end])
2248    {
2249        return None;
2250    }
2251
2252    let close_start = source[open_end..]
2253        .find("</")
2254        .map(|offset| open_end + offset)?;
2255    let (close_end, close_name) = parse_html_tag(source, close_start)?;
2256    if !close_name.eq_ignore_ascii_case("summary")
2257        || !html_tag_is_closing(source, close_start)
2258        || !source[close_end..].trim().is_empty()
2259    {
2260        return None;
2261    }
2262
2263    let content = &source[open_end..close_start];
2264    let content_base = base_offset + open_end;
2265    let opening = HtmlTag {
2266        meta: NodeMeta::new(Some(Span::new(base_offset, base_offset + open_end))),
2267        name: "summary".into(),
2268        raw: source[..open_end].into(),
2269    };
2270    let closing = HtmlTag {
2271        meta: NodeMeta::new(Some(Span::new(
2272            base_offset + close_start,
2273            base_offset + close_end,
2274        ))),
2275        name: "summary".into(),
2276        raw: source[close_start..close_end].into(),
2277    };
2278
2279    Some(Block::HtmlContainer(HtmlContainer {
2280        meta: NodeMeta::new(Some(Span::new(base_offset, base_offset + source.len()))),
2281        opening,
2282        content: HtmlContainerContent::Inlines(parse_inlines(
2283            content,
2284            content_base,
2285            options,
2286            definitions,
2287            diagnostics,
2288        )),
2289        closing,
2290    }))
2291}
2292
2293/// One step of the line walk to an HTML container's closing tag line: a code
2294/// fence is stepped over whole, and same-name opening and closing tag lines
2295/// nest.
2296fn html_container_close_step(lines: &[Line<'_>], cursor: usize, tag: &str) -> BracketStep {
2297    let Some(line) = lines.get(cursor) else {
2298        return BracketStep::End;
2299    };
2300    if let Some(fence) = html_container_fence_opens(line.text) {
2301        let after_fence = (cursor + 1..lines.len())
2302            .find(|close| html_container_fence_closes(lines[*close].text, fence))
2303            .map_or(lines.len(), |close| close + 1);
2304        return BracketStep::Pass(after_fence);
2305    }
2306    if parse_html_container_tag_line(*line, tag, HtmlContainerTag::Closing).is_some() {
2307        return BracketStep::Close(cursor + 1);
2308    }
2309    if parse_html_container_opening_line(*line, tag).is_some() {
2310        return BracketStep::Open(cursor + 1);
2311    }
2312    BracketStep::Pass(cursor + 1)
2313}
2314
2315fn parse_html_container_tag_line(
2316    line: Line<'_>,
2317    tag: &str,
2318    kind: HtmlContainerTag,
2319) -> Option<HtmlTag> {
2320    let (trimmed, indent_bytes) = trim_html_container_line(line.text)?;
2321    let (end, name) = parse_html_tag(trimmed, 0)?;
2322    if !name.eq_ignore_ascii_case(tag) || !trimmed[end..].trim().is_empty() {
2323        return None;
2324    }
2325
2326    let closing = html_tag_is_closing(trimmed, 0);
2327    if (kind == HtmlContainerTag::Opening && closing)
2328        || (kind == HtmlContainerTag::Closing && !closing)
2329        || html_tag_is_self_closing(&trimmed[..end])
2330    {
2331        return None;
2332    }
2333
2334    let start = line.start + indent_bytes;
2335    Some(HtmlTag {
2336        meta: NodeMeta::new(Some(Span::new(start, start + end))),
2337        name: tag.into(),
2338        raw: trimmed[..end].into(),
2339    })
2340}
2341
2342fn parse_html_container_opening_line(line: Line<'_>, tag: &str) -> Option<HtmlTag> {
2343    let (trimmed, indent_bytes) = trim_html_container_line(line.text)?;
2344    let (end, name) = parse_html_tag(trimmed, 0)?;
2345    if !name.eq_ignore_ascii_case(tag)
2346        || html_tag_is_closing(trimmed, 0)
2347        || html_tag_is_self_closing(&trimmed[..end])
2348    {
2349        return None;
2350    }
2351
2352    let rest_start = end + leading_ascii_whitespace_len(&trimmed[end..]);
2353    if !trimmed[rest_start..].is_empty() && !html_container_line_has_summary(&trimmed[rest_start..])
2354    {
2355        return None;
2356    }
2357
2358    let start = line.start + indent_bytes;
2359    Some(HtmlTag {
2360        meta: NodeMeta::new(Some(Span::new(start, start + end))),
2361        name: tag.into(),
2362        raw: trimmed[..end].into(),
2363    })
2364}
2365
2366fn html_container_line_has_summary(input: &str) -> bool {
2367    let Some((open_end, name)) = parse_html_tag(input, 0) else {
2368        return false;
2369    };
2370    if !name.eq_ignore_ascii_case("summary")
2371        || html_tag_is_closing(input, 0)
2372        || html_tag_is_self_closing(&input[..open_end])
2373    {
2374        return false;
2375    }
2376    let Some(close_start) = input[open_end..].find("</").map(|offset| open_end + offset) else {
2377        return false;
2378    };
2379    let Some((close_end, close_name)) = parse_html_tag(input, close_start) else {
2380        return false;
2381    };
2382    close_name.eq_ignore_ascii_case("summary")
2383        && html_tag_is_closing(input, close_start)
2384        && input[close_end..].trim().is_empty()
2385}
2386
2387fn trim_html_container_line(input: &str) -> Option<(&str, usize)> {
2388    let trimmed = trim_up_to_three_spaces(input)?;
2389    Some((trimmed, input.len() - trimmed.len()))
2390}
2391
2392fn leading_ascii_whitespace_len(input: &str) -> usize {
2393    input
2394        .as_bytes()
2395        .iter()
2396        .take_while(|byte| byte.is_ascii_whitespace())
2397        .count()
2398}
2399
2400fn html_tag_is_closing(input: &str, index: usize) -> bool {
2401    input.as_bytes().get(index + 1) == Some(&b'/')
2402}
2403
2404fn html_tag_is_self_closing(input: &str) -> bool {
2405    input.trim_end().ends_with("/>")
2406}
2407
2408fn html_container_fence_opens(line: &str) -> Option<HtmlContainerFence> {
2409    let trimmed = trim_up_to_three_spaces(line)?;
2410    let (marker, length) = fence_start(trimmed)?;
2411    Some(HtmlContainerFence { marker, length })
2412}
2413
2414fn html_container_fence_closes(line: &str, fence: HtmlContainerFence) -> bool {
2415    trim_up_to_three_spaces(line)
2416        .is_some_and(|trimmed| fence_close(trimmed, fence.marker, fence.length))
2417}
2418
2419fn parse_html_block(
2420    lines: &[Line<'_>],
2421    index: usize,
2422    options: &SyntaxOptions,
2423) -> Option<(Block, usize)> {
2424    if !options.constructs.html_block {
2425        return None;
2426    }
2427
2428    let trimmed = trim_up_to_three_spaces(lines[index].text)?;
2429    let kind = html_block_start(trimmed)?;
2430    let mut value = String::new();
2431    let mut cursor = index;
2432    match kind {
2433        HtmlBlockKind::RawTag => {
2434            // CommonMark §4.6 type-1: the block ends on a line containing ANY of
2435            // `</script>`, `</pre>`, `</style>`, `</textarea>` (case-insensitive),
2436            // regardless of which opened it.
2437            while cursor < lines.len() {
2438                push_line(&mut value, lines[cursor].text);
2439                if ["script", "pre", "style", "textarea"]
2440                    .iter()
2441                    .any(|tag| line_contains_raw_closing_tag(lines[cursor].text, tag))
2442                {
2443                    cursor += 1;
2444                    break;
2445                }
2446                cursor += 1;
2447            }
2448        }
2449        HtmlBlockKind::BlockTag => {
2450            while cursor < lines.len() && !lines[cursor].text.trim().is_empty() {
2451                push_line(&mut value, lines[cursor].text);
2452                cursor += 1;
2453            }
2454        }
2455        HtmlBlockKind::Until(end) => {
2456            while cursor < lines.len() {
2457                push_line(&mut value, lines[cursor].text);
2458                if lines[cursor].text.contains(end) {
2459                    cursor += 1;
2460                    break;
2461                }
2462                cursor += 1;
2463            }
2464        }
2465        HtmlBlockKind::UntilBlank => {
2466            while cursor < lines.len() && !lines[cursor].text.trim().is_empty() {
2467                push_line(&mut value, lines[cursor].text);
2468                cursor += 1;
2469            }
2470        }
2471    }
2472    Some((
2473        Block::HtmlBlock(HtmlBlock {
2474            meta: NodeMeta::new(Some(Span::new(
2475                lines[index].start,
2476                lines[cursor - 1].end_with_eol,
2477            ))),
2478            value,
2479        }),
2480        cursor,
2481    ))
2482}
2483
2484fn html_block_start(input: &str) -> Option<HtmlBlockKind> {
2485    let trimmed = input.trim_end();
2486    if !trimmed.starts_with('<') {
2487        return None;
2488    }
2489
2490    if raw_html_tag_start(trimmed) {
2491        return Some(HtmlBlockKind::RawTag);
2492    }
2493    if trimmed.starts_with("<!--") {
2494        return Some(HtmlBlockKind::Until("-->"));
2495    }
2496    if trimmed.starts_with("<?") {
2497        return Some(HtmlBlockKind::Until("?>"));
2498    }
2499    if is_declaration_start(trimmed) {
2500        return Some(HtmlBlockKind::Until(">"));
2501    }
2502    if trimmed.starts_with("<![CDATA[") {
2503        return Some(HtmlBlockKind::Until("]]>"));
2504    }
2505
2506    if html_block_tag_start(trimmed) {
2507        return Some(HtmlBlockKind::BlockTag);
2508    }
2509
2510    let Some((end, _tag_name)) = parse_html_tag(trimmed, 0) else {
2511        return None;
2512    };
2513    let rest = trimmed[end..].trim();
2514    if rest.is_empty() {
2515        Some(HtmlBlockKind::UntilBlank)
2516    } else {
2517        None
2518    }
2519}
2520
2521pub(crate) fn line_starts_html_block(input: &str) -> bool {
2522    trim_up_to_three_spaces(input)
2523        .and_then(html_block_start)
2524        .is_some()
2525}
2526
2527fn line_starts_html_container(input: &str) -> bool {
2528    let line = Line {
2529        text: input,
2530        eol: "",
2531        start: 0,
2532        end: input.len(),
2533        end_with_eol: input.len(),
2534        lazy: false,
2535    };
2536    parse_html_container_opening_line(line, "details").is_some()
2537}
2538
2539fn raw_html_tag_start(input: &str) -> bool {
2540    for tag in ["script", "pre", "style", "textarea"] {
2541        if html_raw_open_tag_prefix(input, tag) {
2542            return true;
2543        }
2544    }
2545    false
2546}
2547
2548fn html_raw_open_tag_prefix(input: &str, tag: &str) -> bool {
2549    let Some(rest) = input.strip_prefix('<') else {
2550        return false;
2551    };
2552    if rest.starts_with('/') || rest.len() < tag.len() {
2553        return false;
2554    }
2555    let rest_bytes = rest.as_bytes();
2556    let tag_bytes = tag.as_bytes();
2557    if !rest_bytes
2558        .get(..tag_bytes.len())
2559        .is_some_and(|name| name.eq_ignore_ascii_case(tag_bytes))
2560    {
2561        return false;
2562    }
2563    match rest_bytes.get(tag.len()) {
2564        None => true,
2565        Some(b' ' | b'\t' | b'\n' | b'\r' | b'>') => true,
2566        Some(b'/') => {
2567            rest_bytes.get(tag.len() + 1) == Some(&b'>') && rest_bytes.get(tag.len() + 2).is_none()
2568        }
2569        _ => false,
2570    }
2571}
2572
2573fn line_contains_raw_closing_tag(input: &str, tag: &str) -> bool {
2574    let bytes = input.as_bytes();
2575    let tag_bytes = tag.as_bytes();
2576    let mut cursor = 0;
2577
2578    while cursor + 2 + tag_bytes.len() <= bytes.len() {
2579        let tag_start = cursor + 2;
2580        let tag_end = tag_start + tag_bytes.len();
2581        if bytes.get(cursor) == Some(&b'<')
2582            && bytes.get(cursor + 1) == Some(&b'/')
2583            && bytes
2584                .get(tag_start..tag_end)
2585                .is_some_and(|name| name.eq_ignore_ascii_case(tag_bytes))
2586        {
2587            match bytes.get(tag_end) {
2588                Some(b'>') => return true,
2589                Some(byte) if byte.is_ascii_whitespace() => {
2590                    let mut after_space = tag_end;
2591                    while bytes
2592                        .get(after_space)
2593                        .is_some_and(|byte| byte.is_ascii_whitespace())
2594                    {
2595                        after_space += 1;
2596                    }
2597                    if bytes.get(after_space) == Some(&b'>') {
2598                        return true;
2599                    }
2600                }
2601                _ => {}
2602            }
2603        }
2604        cursor += 1;
2605    }
2606
2607    false
2608}
2609
2610fn html_block_tag_start(input: &str) -> bool {
2611    let bytes = input.as_bytes();
2612    if bytes.first() != Some(&b'<') {
2613        return false;
2614    }
2615
2616    let mut cursor = 1;
2617    if bytes.get(cursor) == Some(&b'/') {
2618        cursor += 1;
2619    }
2620
2621    let name_start = cursor;
2622    if !bytes
2623        .get(cursor)
2624        .is_some_and(|byte| byte.is_ascii_alphabetic())
2625    {
2626        return false;
2627    }
2628    cursor += 1;
2629    while bytes.get(cursor).is_some_and(|byte| html_name_byte(*byte)) {
2630        cursor += 1;
2631    }
2632
2633    let name = &input[name_start..cursor];
2634    if !html_block_tag(name) {
2635        return false;
2636    }
2637
2638    match bytes.get(cursor) {
2639        None | Some(b' ' | b'\t' | b'\n' | b'\r' | b'>') => true,
2640        Some(b'/') if bytes.get(cursor + 1) == Some(&b'>') => true,
2641        _ => false,
2642    }
2643}
2644
2645fn html_block_tag(tag: &str) -> bool {
2646    matches!(
2647        tag.to_ascii_lowercase().as_str(),
2648        "address"
2649            | "article"
2650            | "aside"
2651            | "base"
2652            | "basefont"
2653            | "blockquote"
2654            | "body"
2655            | "caption"
2656            | "center"
2657            | "col"
2658            | "colgroup"
2659            | "dd"
2660            | "details"
2661            | "dialog"
2662            | "dir"
2663            | "div"
2664            | "dl"
2665            | "dt"
2666            | "fieldset"
2667            | "figcaption"
2668            | "figure"
2669            | "footer"
2670            | "form"
2671            | "frame"
2672            | "frameset"
2673            | "h1"
2674            | "h2"
2675            | "h3"
2676            | "h4"
2677            | "h5"
2678            | "h6"
2679            | "head"
2680            | "header"
2681            | "hr"
2682            | "html"
2683            | "iframe"
2684            | "legend"
2685            | "li"
2686            | "link"
2687            | "main"
2688            | "menu"
2689            | "menuitem"
2690            | "nav"
2691            | "noframes"
2692            | "ol"
2693            | "optgroup"
2694            | "option"
2695            | "p"
2696            | "param"
2697            | "search"
2698            | "section"
2699            | "summary"
2700            | "table"
2701            | "tbody"
2702            | "td"
2703            | "tfoot"
2704            | "th"
2705            | "thead"
2706            | "title"
2707            | "tr"
2708            | "track"
2709            | "ul"
2710    )
2711}
2712
2713fn is_declaration_start(input: &str) -> bool {
2714    input
2715        .as_bytes()
2716        .get(2)
2717        .is_some_and(|byte| input.starts_with("<!") && byte.is_ascii_alphabetic())
2718}
2719
2720fn parse_mdx_flow(
2721    lines: &[Line<'_>],
2722    index: usize,
2723    options: &SyntaxOptions,
2724    diagnostics: &mut Vec<Diagnostic>,
2725    flow: &mut MdxFlowScan,
2726) -> Option<(Block, usize)> {
2727    if options.constructs.mdx_esm {
2728        if let Some((block, next)) = parse_mdx_esm_flow(lines, index, diagnostics) {
2729            return Some((block, next));
2730        }
2731    }
2732
2733    let line = lines[index];
2734    let trimmed = line.text.trim_start();
2735    if options.constructs.mdx_expression_block && trimmed.starts_with('{') {
2736        let open_byte = line.text.len() - trimmed.len();
2737        if let Some((close_line, close_byte)) = flow.expression_close(lines, index, open_byte) {
2738            return Some((
2739                Block::MdxExpression(MdxExpression {
2740                    meta: NodeMeta::new(Some(Span::new(line.start, lines[close_line].end))),
2741                    value: collect_mdx_expression_value(
2742                        lines, index, open_byte, close_line, close_byte,
2743                    ),
2744                }),
2745                close_line + 1,
2746            ));
2747        }
2748        diagnostics.push(Diagnostic::new(
2749            DiagnosticSeverity::Error,
2750            DiagnosticCode::InvalidMdx,
2751            Span::new(line.start + open_byte, lines.last()?.end_with_eol),
2752            "MDX expression block is missing a closing brace",
2753        ));
2754    }
2755    if options.constructs.mdx_jsx_block && trimmed.starts_with('<') {
2756        let start_byte = line.text.len() - trimmed.len();
2757        if let Some(close_line) = flow.jsx_close_line(lines, index, start_byte) {
2758            return Some((
2759                Block::MdxJsx(MdxJsx {
2760                    meta: NodeMeta::new(Some(Span::new(line.start, lines[close_line].end))),
2761                    value: collect_line_range(lines, index, close_line),
2762                }),
2763                close_line + 1,
2764            ));
2765        }
2766        if let Some(root) = mdx_jsx_tag_start(line.text, start_byte) {
2767            if !root.closing {
2768                if let Some(self_closing) = flow.jsx_tag_self_closing(lines, index, start_byte) {
2769                    if !self_closing {
2770                        diagnostics.push(Diagnostic::new(
2771                            DiagnosticSeverity::Error,
2772                            DiagnosticCode::InvalidMdx,
2773                            Span::new(line.start + start_byte, lines.last()?.end_with_eol),
2774                            "MDX JSX block is missing a closing tag",
2775                        ));
2776                    }
2777                }
2778            }
2779        }
2780    }
2781    None
2782}
2783
2784#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2785struct MdxEsmState {
2786    brace_depth: usize,
2787    bracket_depth: usize,
2788    paren_depth: usize,
2789    block_comment: bool,
2790    quote: Option<u8>,
2791    escaped: bool,
2792}
2793
2794#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2795enum MdxJsxTag<'a> {
2796    Fragment,
2797    Named(&'a str),
2798}
2799
2800#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2801struct MdxJsxTagStart<'a> {
2802    tag: MdxJsxTag<'a>,
2803    closing: bool,
2804}
2805
2806fn parse_mdx_esm_flow(
2807    lines: &[Line<'_>],
2808    index: usize,
2809    diagnostics: &mut Vec<Diagnostic>,
2810) -> Option<(Block, usize)> {
2811    if !is_mdx_esm_start(lines[index].text) {
2812        return None;
2813    }
2814
2815    let mut value = String::new();
2816    let mut state = MdxEsmState::default();
2817    let mut cursor = index;
2818    while cursor < lines.len() {
2819        let line = lines[cursor].text;
2820        if cursor > index && !is_mdx_esm_continuation(line, &state) {
2821            break;
2822        }
2823        if cursor > index {
2824            value.push('\n');
2825        }
2826        value.push_str(line);
2827        update_mdx_esm_state(line, &mut state);
2828        cursor += 1;
2829    }
2830    if cursor >= lines.len() && state_has_open_mdx_esm_construct(&state) {
2831        diagnostics.push(Diagnostic::new(
2832            DiagnosticSeverity::Error,
2833            DiagnosticCode::InvalidMdx,
2834            Span::new(lines[index].start, lines[cursor - 1].end_with_eol),
2835            "MDX ESM block is missing a closing delimiter",
2836        ));
2837    }
2838
2839    Some((
2840        Block::MdxEsm(MdxEsm {
2841            meta: NodeMeta::new(Some(Span::new(lines[index].start, lines[cursor - 1].end))),
2842            value,
2843        }),
2844        cursor,
2845    ))
2846}
2847
2848fn is_mdx_esm_start(line: &str) -> bool {
2849    line.starts_with("import ") || line.starts_with("export ")
2850}
2851
2852fn is_mdx_esm_continuation(line: &str, state: &MdxEsmState) -> bool {
2853    if state_has_open_mdx_esm_construct(state) {
2854        return true;
2855    }
2856    let trimmed = line.trim_start();
2857    if trimmed.is_empty() {
2858        return false;
2859    }
2860    is_mdx_esm_start(line) || trimmed.starts_with("//") || trimmed.starts_with("/*")
2861}
2862
2863fn state_has_open_mdx_esm_construct(state: &MdxEsmState) -> bool {
2864    state.brace_depth > 0
2865        || state.bracket_depth > 0
2866        || state.paren_depth > 0
2867        || state.block_comment
2868        || state.quote == Some(b'`')
2869}
2870
2871fn update_mdx_esm_state(line: &str, state: &mut MdxEsmState) {
2872    let bytes = line.as_bytes();
2873    let mut index = 0;
2874    while index < bytes.len() {
2875        let byte = bytes[index];
2876        if state.block_comment {
2877            if byte == b'*' && bytes.get(index + 1) == Some(&b'/') {
2878                state.block_comment = false;
2879                index += 1;
2880            }
2881            index += 1;
2882            continue;
2883        }
2884
2885        if let Some(delimiter) = state.quote {
2886            if state.escaped {
2887                state.escaped = false;
2888            } else if byte == b'\\' {
2889                state.escaped = true;
2890            } else if byte == delimiter {
2891                state.quote = None;
2892            }
2893            index += 1;
2894            continue;
2895        }
2896
2897        match byte {
2898            b'\'' | b'"' | b'`' => state.quote = Some(byte),
2899            b'/' if bytes.get(index + 1) == Some(&b'/') => break,
2900            b'/' if bytes.get(index + 1) == Some(&b'*') => {
2901                state.block_comment = true;
2902                index += 1;
2903            }
2904            b'{' => state.brace_depth += 1,
2905            b'}' => state.brace_depth = state.brace_depth.saturating_sub(1),
2906            b'[' => state.bracket_depth += 1,
2907            b']' => state.bracket_depth = state.bracket_depth.saturating_sub(1),
2908            b'(' => state.paren_depth += 1,
2909            b')' => state.paren_depth = state.paren_depth.saturating_sub(1),
2910            _ => {}
2911        }
2912        index += 1;
2913    }
2914}
2915/// Lexical states of the walk to an inline MDX expression's closing `}`.
2916const MDX_BRACE_STATES: usize = 6;
2917const MDX_NORMAL: usize = 0;
2918const MDX_SINGLE_QUOTED: usize = 1;
2919const MDX_DOUBLE_QUOTED: usize = 2;
2920const MDX_TEMPLATE: usize = 3;
2921const MDX_LINE_COMMENT: usize = 4;
2922const MDX_BLOCK_COMMENT: usize = 5;
2923
2924/// One step of the walk to an inline MDX expression's closing `}`, over nodes
2925/// `byte position * MDX_BRACE_STATES + lexical state`: braces nest outside
2926/// strings and comments, and `\` escapes the next byte inside strings.
2927/// The position after the byte a `\` at `escape` escapes. In block lines joined
2928/// by `\n` (`lines_joined`), a `\` ending a line escapes the first byte of the
2929/// next non-empty line, as the line-by-line scan it stands for does, rather
2930/// than the line break.
2931fn after_escaped_byte(text: &str, escape: usize, lines_joined: bool) -> usize {
2932    let mut escaped = escape + 1;
2933    if lines_joined {
2934        while text.as_bytes().get(escaped) == Some(&b'\n') {
2935            escaped += 1;
2936        }
2937    }
2938    (escaped + 1).min(text.len())
2939}
2940
2941fn mdx_expression_step(input: &str, node: usize, lines_joined: bool) -> BracketStep {
2942    let bytes = input.as_bytes();
2943    let (cursor, state) = (node / MDX_BRACE_STATES, node % MDX_BRACE_STATES);
2944    let at = |position: usize, state: usize| position.min(bytes.len()) * MDX_BRACE_STATES + state;
2945    let Some(&byte) = bytes.get(cursor) else {
2946        return BracketStep::End;
2947    };
2948    let following = bytes.get(cursor + 1).copied();
2949    match state {
2950        MDX_NORMAL => match byte {
2951            b'\'' => BracketStep::Pass(at(cursor + 1, MDX_SINGLE_QUOTED)),
2952            b'"' => BracketStep::Pass(at(cursor + 1, MDX_DOUBLE_QUOTED)),
2953            b'`' => BracketStep::Pass(at(cursor + 1, MDX_TEMPLATE)),
2954            b'/' if following == Some(b'/') => BracketStep::Pass(at(cursor + 2, MDX_LINE_COMMENT)),
2955            b'/' if following == Some(b'*') => BracketStep::Pass(at(cursor + 2, MDX_BLOCK_COMMENT)),
2956            b'{' => BracketStep::Open(at(cursor + 1, MDX_NORMAL)),
2957            b'}' => BracketStep::Close(at(cursor + 1, MDX_NORMAL)),
2958            _ => BracketStep::Pass(at(cursor + 1, MDX_NORMAL)),
2959        },
2960        MDX_LINE_COMMENT => BracketStep::Pass(at(
2961            cursor + 1,
2962            if byte == b'\n' {
2963                MDX_NORMAL
2964            } else {
2965                MDX_LINE_COMMENT
2966            },
2967        )),
2968        MDX_BLOCK_COMMENT => {
2969            if byte == b'*' && following == Some(b'/') {
2970                BracketStep::Pass(at(cursor + 2, MDX_NORMAL))
2971            } else {
2972                BracketStep::Pass(at(cursor + 1, MDX_BLOCK_COMMENT))
2973            }
2974        }
2975        quoted => {
2976            let delimiter = [b'\'', b'"', b'`'][quoted - MDX_SINGLE_QUOTED];
2977            if byte == b'\\' {
2978                BracketStep::Pass(at(after_escaped_byte(input, cursor, lines_joined), quoted))
2979            } else if byte == delimiter {
2980                BracketStep::Pass(at(cursor + 1, MDX_NORMAL))
2981            } else {
2982                BracketStep::Pass(at(cursor + 1, quoted))
2983            }
2984        }
2985    }
2986}
2987
2988fn collect_mdx_expression_value(
2989    lines: &[Line<'_>],
2990    start_line: usize,
2991    open_byte: usize,
2992    close_line: usize,
2993    close_byte: usize,
2994) -> String {
2995    let mut value = String::new();
2996    let mut cursor = start_line;
2997    while cursor <= close_line {
2998        if cursor > start_line {
2999            value.push('\n');
3000        }
3001        let line = lines[cursor].text;
3002        let segment = if cursor == start_line && cursor == close_line {
3003            &line[open_byte + 1..close_byte]
3004        } else if cursor == start_line {
3005            &line[open_byte + 1..]
3006        } else if cursor == close_line {
3007            &line[..close_byte]
3008        } else {
3009            line
3010        };
3011        value.push_str(segment);
3012        cursor += 1;
3013    }
3014    value
3015}
3016
3017fn mdx_jsx_tag_start(input: &str, start: usize) -> Option<MdxJsxTagStart<'_>> {
3018    let bytes = input.as_bytes();
3019    if bytes.get(start) != Some(&b'<') {
3020        return None;
3021    }
3022
3023    match bytes.get(start + 1) {
3024        Some(b'>') => {
3025            return Some(MdxJsxTagStart {
3026                tag: MdxJsxTag::Fragment,
3027                closing: false,
3028            });
3029        }
3030        Some(b'/') if bytes.get(start + 2) == Some(&b'>') => {
3031            return Some(MdxJsxTagStart {
3032                tag: MdxJsxTag::Fragment,
3033                closing: true,
3034            });
3035        }
3036        Some(b'!' | b'?') | None => return None,
3037        _ => {}
3038    }
3039
3040    let closing = bytes.get(start + 1) == Some(&b'/');
3041    let name_start = start + if closing { 2 } else { 1 };
3042    if !bytes
3043        .get(name_start)
3044        .is_some_and(|byte| is_mdx_jsx_name_start_byte(*byte))
3045    {
3046        return None;
3047    }
3048
3049    let mut name_end = name_start + 1;
3050    while bytes
3051        .get(name_end)
3052        .is_some_and(|byte| is_mdx_jsx_name_byte(*byte))
3053    {
3054        name_end += 1;
3055    }
3056    if name_end == name_start {
3057        return None;
3058    }
3059    if bytes
3060        .get(name_end)
3061        .is_some_and(|byte| !is_mdx_jsx_name_delimiter(*byte))
3062    {
3063        return None;
3064    }
3065    Some(MdxJsxTagStart {
3066        tag: MdxJsxTag::Named(&input[name_start..name_end]),
3067        closing,
3068    })
3069}
3070
3071fn previous_nonspace_before_text(input: &str, byte_index: usize) -> Option<u8> {
3072    input.as_bytes()[..byte_index]
3073        .iter()
3074        .rev()
3075        .copied()
3076        .find(|byte| !byte.is_ascii_whitespace())
3077}
3078
3079/// Quote states of the walk to an MDX JSX tag's closing `>`: outside quotes,
3080/// inside `'…'`, inside `"…"`.
3081const JSX_TAG_QUOTE_STATES: usize = 3;
3082
3083/// One step of the walk to an MDX JSX tag's closing `>`, over nodes
3084/// `byte position * JSX_TAG_QUOTE_STATES + quote state`: quoted attribute
3085/// values and `{…}` expressions (stepped over whole via `expression_close`)
3086/// cannot close the tag.
3087fn jsx_tag_end_step(
3088    text: &str,
3089    node: usize,
3090    lines_joined: bool,
3091    expression_close: &mut impl FnMut(usize) -> Option<usize>,
3092) -> Step {
3093    let (cursor, quote) = (node / JSX_TAG_QUOTE_STATES, node % JSX_TAG_QUOTE_STATES);
3094    let at = |position: usize, quote: usize| Step::Next(position * JSX_TAG_QUOTE_STATES + quote);
3095    let Some(&byte) = text.as_bytes().get(cursor) else {
3096        return Step::Done(None);
3097    };
3098    if quote != 0 {
3099        let delimiter = if quote == 1 { b'\'' } else { b'"' };
3100        return if byte == b'\\' {
3101            at(after_escaped_byte(text, cursor, lines_joined), quote)
3102        } else if byte == delimiter {
3103            at(cursor + 1, 0)
3104        } else {
3105            at(cursor + 1, quote)
3106        };
3107    }
3108    match byte {
3109        b'\'' => at(cursor + 1, 1),
3110        b'"' => at(cursor + 1, 2),
3111        b'{' => match expression_close(cursor) {
3112            Some(close) => at(close + 1, 0),
3113            None => Step::Done(None),
3114        },
3115        b'>' => Step::Done(Some(cursor)),
3116        _ => at(cursor + 1, 0),
3117    }
3118}
3119
3120/// Closing-tag lookups for MDX JSX over one text. A tag's closing tag is found
3121/// by walking from tag to tag (each stepped over whole, to its `>`) and
3122/// counting only tags with its name. Every tag is indexed once: where it ends,
3123/// and the next tag with its name along the walk after it, read from
3124/// persistent per-name maps built from the last tag back. Matching then walks
3125/// only same-name tags, memoized, so all closing-tag questions about one text
3126/// cost `O(n log n)` together.
3127struct JsxIndex {
3128    starts: Vec<usize>,
3129    closing: Vec<bool>,
3130    ends: Vec<Option<usize>>,
3131    self_closing: Vec<bool>,
3132    /// Per tag, the index of the next tag with its name along the walk after
3133    /// it, or `starts.len()` when the walk ends (or reaches a tag without an
3134    /// end) first.
3135    next_same_name: Vec<usize>,
3136    matches: BracketMemo,
3137}
3138
3139impl JsxIndex {
3140    fn new(text: &str, lines_joined: bool, expressions: &mut BracketMemo) -> Self {
3141        let bytes = text.as_bytes();
3142        let mut starts = Vec::new();
3143        let mut closing = Vec::new();
3144        let mut names = Vec::new();
3145        for (position, byte) in bytes.iter().enumerate() {
3146            if *byte == b'<' {
3147                if let Some(tag) = mdx_jsx_tag_start(text, position) {
3148                    starts.push(position);
3149                    closing.push(tag.closing);
3150                    names.push(match tag.tag {
3151                        MdxJsxTag::Fragment => None,
3152                        MdxJsxTag::Named(name) => Some(name),
3153                    });
3154                }
3155            }
3156        }
3157
3158        let mut tag_ends = PathMemo::default();
3159        let mut self_closing_by_end = BTreeMap::new();
3160        let mut ends = Vec::with_capacity(starts.len());
3161        let mut self_closing = Vec::with_capacity(starts.len());
3162        for &start in &starts {
3163            let end = tag_ends.resolve(
3164                (text.len() + 1) * JSX_TAG_QUOTE_STATES,
3165                (start + 1) * JSX_TAG_QUOTE_STATES,
3166                |node| {
3167                    jsx_tag_end_step(text, node, lines_joined, &mut |open| {
3168                        expressions
3169                            .resolve(
3170                                (text.len() + 1) * MDX_BRACE_STATES,
3171                                (open + 1) * MDX_BRACE_STATES,
3172                                |node| mdx_expression_step(text, node, lines_joined),
3173                            )
3174                            .map(|node| node / MDX_BRACE_STATES)
3175                    })
3176                },
3177            );
3178            ends.push(end);
3179            self_closing.push(end.is_some_and(|end| {
3180                *self_closing_by_end
3181                    .entry(end)
3182                    .or_insert_with(|| previous_nonspace_before_text(text, end) == Some(b'/'))
3183            }));
3184        }
3185
3186        let mut name_ids = BTreeMap::new();
3187        for name in &names {
3188            let next_id = name_ids.len();
3189            name_ids.entry(*name).or_insert(next_id);
3190        }
3191        let count = starts.len();
3192        let mut maps = PersistentMap::new(name_ids.len());
3193        let mut versions = alloc::vec![PersistentMap::EMPTY; count];
3194        let mut next_same_name = alloc::vec![count; count];
3195        for tag in (0..count).rev() {
3196            // A tag without an end stops every walk that reaches it.
3197            let Some(end) = ends[tag] else {
3198                continue;
3199            };
3200            let after = starts.partition_point(|start| *start <= end);
3201            let rest = versions.get(after).copied().unwrap_or(PersistentMap::EMPTY);
3202            let name = name_ids[&names[tag]];
3203            next_same_name[tag] = maps.get(rest, name).unwrap_or(count);
3204            versions[tag] = maps.insert(rest, name, tag);
3205        }
3206
3207        Self {
3208            starts,
3209            closing,
3210            ends,
3211            self_closing,
3212            next_same_name,
3213            matches: BracketMemo::default(),
3214        }
3215    }
3216
3217    /// The `>` ending the element whose opening tag starts at `start`: its own
3218    /// `>` when self-closing, else the `>` of its matching closing tag.
3219    fn element_end(&mut self, start: usize) -> Option<usize> {
3220        let tag = self.starts.binary_search(&start).ok()?;
3221        if self.closing[tag] {
3222            return None;
3223        }
3224        let end = self.ends[tag]?;
3225        if self.self_closing[tag] {
3226            return Some(end);
3227        }
3228        let count = self.starts.len();
3229        let (closing, self_closing, next) =
3230            (&self.closing, &self.self_closing, &self.next_same_name);
3231        let close = self.matches.resolve(count + 1, next[tag], |tag| {
3232            if tag == count {
3233                BracketStep::End
3234            } else if closing[tag] {
3235                BracketStep::Close(next[tag])
3236            } else if self_closing[tag] {
3237                BracketStep::Pass(next[tag])
3238            } else {
3239                BracketStep::Open(next[tag])
3240            }
3241        })?;
3242        self.ends[close]
3243    }
3244
3245    /// The `>` ending the tag that starts at `start`, and whether the tag is
3246    /// self-closing.
3247    fn tag_end(&self, start: usize) -> Option<(usize, bool)> {
3248        let tag = self.starts.binary_search(&start).ok()?;
3249        Some((self.ends[tag]?, self.self_closing[tag]))
3250    }
3251}
3252
3253/// MDX flow lookups over one run of block lines, joined by `\n` into one text
3254/// on first use so JSX and expression closes are found by the same memoized
3255/// walks as inline ones.
3256#[derive(Default)]
3257struct MdxFlowScan {
3258    joined: Option<JoinedLines>,
3259}
3260
3261struct JoinedLines {
3262    text: String,
3263    line_starts: Vec<usize>,
3264    expressions: BracketMemo,
3265    jsx: Option<JsxIndex>,
3266}
3267
3268impl JoinedLines {
3269    fn new(lines: &[Line<'_>]) -> Self {
3270        let mut text = String::new();
3271        let mut line_starts = Vec::with_capacity(lines.len());
3272        for (index, line) in lines.iter().enumerate() {
3273            if index > 0 {
3274                text.push('\n');
3275            }
3276            line_starts.push(text.len());
3277            text.push_str(line.text);
3278        }
3279        Self {
3280            text,
3281            line_starts,
3282            expressions: BracketMemo::default(),
3283            jsx: None,
3284        }
3285    }
3286
3287    /// The line holding byte `position`, and the byte's offset in that line.
3288    fn locate(&self, position: usize) -> (usize, usize) {
3289        let line = self.line_starts.partition_point(|start| *start <= position) - 1;
3290        (line, position - self.line_starts[line])
3291    }
3292
3293    fn jsx(&mut self) -> &mut JsxIndex {
3294        let Self {
3295            text,
3296            expressions,
3297            jsx,
3298            ..
3299        } = self;
3300        jsx.get_or_insert_with(|| JsxIndex::new(text, true, expressions))
3301    }
3302}
3303
3304impl MdxFlowScan {
3305    fn joined(&mut self, lines: &[Line<'_>]) -> &mut JoinedLines {
3306        self.joined.get_or_insert_with(|| JoinedLines::new(lines))
3307    }
3308
3309    /// The line and byte of the `}` closing the expression block opening at
3310    /// `open_byte` of line `index`, when nothing but whitespace follows it on
3311    /// its line.
3312    fn expression_close(
3313        &mut self,
3314        lines: &[Line<'_>],
3315        index: usize,
3316        open_byte: usize,
3317    ) -> Option<(usize, usize)> {
3318        let joined = self.joined(lines);
3319        let open = joined.line_starts[index] + open_byte;
3320        let text = &joined.text;
3321        let start = (open + 1) * MDX_BRACE_STATES;
3322        let close =
3323            joined
3324                .expressions
3325                .resolve((text.len() + 1) * MDX_BRACE_STATES, start, |node| {
3326                    mdx_expression_step(text, node, true)
3327                })?
3328                / MDX_BRACE_STATES;
3329        let (line, byte) = joined.locate(close);
3330        lines[line].text[byte + 1..]
3331            .trim()
3332            .is_empty()
3333            .then_some((line, byte))
3334    }
3335
3336    /// The last line of the JSX element block opening at `start_byte` of line
3337    /// `index`.
3338    fn jsx_close_line(
3339        &mut self,
3340        lines: &[Line<'_>],
3341        index: usize,
3342        start_byte: usize,
3343    ) -> Option<usize> {
3344        let joined = self.joined(lines);
3345        let start = joined.line_starts[index] + start_byte;
3346        let end = joined.jsx().element_end(start)?;
3347        Some(joined.locate(end).0)
3348    }
3349
3350    /// Whether the JSX tag opening at `start_byte` of line `index` ends, and if
3351    /// so whether it is self-closing.
3352    fn jsx_tag_self_closing(
3353        &mut self,
3354        lines: &[Line<'_>],
3355        index: usize,
3356        start_byte: usize,
3357    ) -> Option<bool> {
3358        let joined = self.joined(lines);
3359        let start = joined.line_starts[index] + start_byte;
3360        joined
3361            .jsx()
3362            .tag_end(start)
3363            .map(|(_, self_closing)| self_closing)
3364    }
3365}
3366
3367fn is_mdx_jsx_name_start_byte(byte: u8) -> bool {
3368    byte.is_ascii_alphabetic() || matches!(byte, b'_' | b'$')
3369}
3370
3371fn is_mdx_jsx_name_byte(byte: u8) -> bool {
3372    byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-' | b'$')
3373}
3374
3375fn is_mdx_jsx_name_delimiter(byte: u8) -> bool {
3376    byte.is_ascii_whitespace() || matches!(byte, b'/' | b'>' | b'{' | b'}')
3377}
3378
3379fn collect_line_range(lines: &[Line<'_>], start: usize, end: usize) -> String {
3380    let mut value = String::new();
3381    let mut cursor = start;
3382    while cursor <= end {
3383        if cursor > start {
3384            value.push('\n');
3385        }
3386        value.push_str(lines[cursor].text);
3387        cursor += 1;
3388    }
3389    value
3390}
3391
3392fn parse_indented_code(
3393    lines: &[Line<'_>],
3394    index: usize,
3395    options: &SyntaxOptions,
3396) -> Option<(Block, usize)> {
3397    if !options.constructs.indented_code || strip_indented_code_prefix(lines[index].text).is_none()
3398    {
3399        return None;
3400    }
3401    let mut value = String::new();
3402    let mut cursor = index;
3403    // Track the last line that carried real content: leading and trailing blank
3404    // lines are not part of an indented code block, only interior ones are.
3405    let mut content_end = index;
3406    let mut content_end_len = 0usize;
3407    while cursor < lines.len() {
3408        if let Some(text) = strip_indented_code_prefix(lines[cursor].text) {
3409            ensure_line_separator(&mut value);
3410            value.push_str(text);
3411            value.push_str(lines[cursor].eol);
3412            if !text.trim().is_empty() {
3413                content_end = cursor;
3414                content_end_len = value.len();
3415            }
3416            cursor += 1;
3417            continue;
3418        }
3419
3420        if !lines[cursor].text.trim().is_empty() {
3421            break;
3422        }
3423        ensure_line_separator(&mut value);
3424        value.push_str(lines[cursor].eol);
3425        cursor += 1;
3426    }
3427    // Drop trailing blank lines accumulated past the last real content line.
3428    value.truncate(content_end_len);
3429    Some((
3430        Block::CodeBlock(CodeBlock {
3431            meta: NodeMeta::new(Some(Span::new(
3432                lines[index].start,
3433                lines[content_end].end_with_eol,
3434            ))),
3435            kind: CodeBlockKind::Indented,
3436            info: None,
3437            value,
3438        }),
3439        cursor,
3440    ))
3441}
3442
3443fn strip_indented_code_prefix(input: &str) -> Option<&str> {
3444    let mut column = 0usize;
3445    for (index, byte) in input.as_bytes().iter().enumerate() {
3446        match *byte {
3447            b' ' => {
3448                column += 1;
3449                if column == 4 {
3450                    return Some(&input[index + 1..]);
3451                }
3452            }
3453            b'\t' => {
3454                column += 4 - (column % 4);
3455                if column >= 4 {
3456                    return Some(&input[index + 1..]);
3457                }
3458            }
3459            _ => return None,
3460        }
3461    }
3462    None
3463}
3464
3465fn parse_table(
3466    lines: &[Line<'_>],
3467    index: usize,
3468    options: &SyntaxOptions,
3469    definitions: &[String],
3470    diagnostics: &mut Vec<Diagnostic>,
3471) -> Option<(Block, usize)> {
3472    if !options.constructs.gfm_table || index + 1 >= lines.len() {
3473        return None;
3474    }
3475    let delimiter = table_indent_line(lines[index + 1].text, options.constructs.indented_code)?;
3476    if list_marker_info(delimiter).is_some() {
3477        return None;
3478    }
3479    if !table_has_separator(lines[index].text, delimiter, options.constructs.spoiler) {
3480        return None;
3481    }
3482    let alignments = parse_table_delimiter(delimiter, options.constructs.spoiler)?;
3483    let headers = split_table_row(lines[index].text, options.constructs.spoiler);
3484    if headers.len() != alignments.len() {
3485        return None;
3486    }
3487
3488    let mut rows = Vec::new();
3489    rows.push(TableRow {
3490        meta: NodeMeta::new(Some(Span::new(lines[index].start, lines[index].end))),
3491        cells: headers
3492            .iter()
3493            .map(|cell| TableCell {
3494                meta: NodeMeta::default(),
3495                children: parse_inlines(
3496                    cell.trim(),
3497                    lines[index].start,
3498                    options,
3499                    definitions,
3500                    diagnostics,
3501                ),
3502            })
3503            .collect(),
3504    });
3505
3506    let mut cursor = index + 2;
3507    while cursor < lines.len() {
3508        let Some(row) = table_indent_line(lines[cursor].text, options.constructs.indented_code)
3509        else {
3510            break;
3511        };
3512        // Once a table is open, every non-blank line that isn't a real block
3513        // start is a body row (GFM); pipeless lines (incl. setext underlines)
3514        // become a single padded cell.
3515        if row.trim().is_empty() || table_body_line_ends_table(lines[cursor].text, options) {
3516            break;
3517        }
3518        let cells = split_table_row(row, options.constructs.spoiler);
3519        rows.push(TableRow {
3520            meta: NodeMeta::new(Some(Span::new(lines[cursor].start, lines[cursor].end))),
3521            cells: alignments
3522                .iter()
3523                .enumerate()
3524                .map(|(cell_index, _)| {
3525                    let value = cells.get(cell_index).map(String::as_str).unwrap_or("");
3526                    TableCell {
3527                        meta: NodeMeta::default(),
3528                        children: parse_inlines(
3529                            value.trim(),
3530                            lines[cursor].start,
3531                            options,
3532                            definitions,
3533                            diagnostics,
3534                        ),
3535                    }
3536                })
3537                .collect(),
3538        });
3539        cursor += 1;
3540    }
3541
3542    Some((
3543        Block::Table(Table {
3544            meta: NodeMeta::new(Some(Span::new(
3545                lines[index].start,
3546                lines[cursor - 1].end_with_eol,
3547            ))),
3548            alignments,
3549            rows,
3550        }),
3551        cursor,
3552    ))
3553}
3554
3555fn parse_setext_heading(
3556    lines: &[Line<'_>],
3557    index: usize,
3558    options: &SyntaxOptions,
3559    definitions: &[String],
3560) -> Option<(Block, usize)> {
3561    if index + 1 >= lines.len() || lines[index].text.trim().is_empty() {
3562        return None;
3563    }
3564
3565    // A setext heading is a (possibly multi-line) paragraph followed by an
3566    // underline. Scan over paragraph-continuation lines to find the underline,
3567    // stopping if a continuation line is itself a block start (which would
3568    // interrupt the paragraph before any underline could apply).
3569    let mut underline_index = index + 1;
3570    loop {
3571        // A setext underline that arrived as a LAZY block-quote continuation is
3572        // paragraph text, not an underline: `> a\n===` is `<p>a\n===</p>`, while
3573        // a MARKED `> a\n> ---` stays an H2 (its `---` is not lazy). The lazy
3574        // flag distinguishes the two; a lazy underline keeps scanning as
3575        // ordinary paragraph-continuation text.
3576        let underline_depth = if lines[underline_index].lazy {
3577            None
3578        } else {
3579            setext_underline_depth(lines[underline_index].text)
3580        };
3581        if let Some(depth) = underline_depth {
3582            let mut value = String::new();
3583            for line in &lines[index..underline_index] {
3584                // Trim leading indentation only: a fully `.trim()`ed content line
3585                // would discard the trailing spaces that form a hard line break.
3586                push_line(&mut value, trim_ascii_start(line.text));
3587            }
3588            return Some((
3589                Block::Heading(Heading {
3590                    meta: NodeMeta::new(Some(Span::new(
3591                        lines[index].start,
3592                        lines[underline_index].end,
3593                    ))),
3594                    depth,
3595                    kind: HeadingKind::Setext,
3596                    children: parse_inlines(
3597                        &value,
3598                        lines[index].start,
3599                        options,
3600                        definitions,
3601                        &mut Vec::new(),
3602                    ),
3603                }),
3604                underline_index + 1,
3605            ));
3606        }
3607
3608        // Not an underline: it must be a valid paragraph-continuation line for
3609        // the run to remain a setext heading.
3610        let line = lines[underline_index].text;
3611        if line.trim().is_empty()
3612            || table_can_start(lines, underline_index, options)
3613            || likely_block_start(line, options)
3614        {
3615            return None;
3616        }
3617        underline_index += 1;
3618        if underline_index >= lines.len() {
3619            return None;
3620        }
3621    }
3622}
3623
3624fn setext_underline_depth(input: &str) -> Option<u8> {
3625    let underline = trim_up_to_three_spaces(input)?.trim();
3626    match underline {
3627        text if !text.is_empty() && text.chars().all(|char| char == '=') => Some(1),
3628        text if !text.is_empty() && text.chars().all(|char| char == '-') => Some(2),
3629        _ => None,
3630    }
3631}
3632
3633fn parse_paragraph(
3634    lines: &[Line<'_>],
3635    index: usize,
3636    options: &SyntaxOptions,
3637    definitions: &[String],
3638    diagnostics: &mut Vec<Diagnostic>,
3639) -> (Block, usize) {
3640    let mut value = String::new();
3641    let start = lines[index].start;
3642    let mut cursor = index;
3643    while cursor < lines.len() {
3644        if lines[cursor].text.trim().is_empty() {
3645            break;
3646        }
3647        // A lazy continuation line is paragraph text by construction (it reached
3648        // this paragraph as the dedented tail of an enclosing container), so it
3649        // cannot itself start a new block — skip the block-boundary checks.
3650        if cursor > index && !lines[cursor].lazy {
3651            if table_can_start(lines, cursor, options) {
3652                break;
3653            }
3654            if likely_block_start(lines[cursor].text, options) {
3655                break;
3656            }
3657        }
3658        if !value.is_empty() {
3659            value.push('\n');
3660        }
3661        value.push_str(trim_ascii_start(lines[cursor].text));
3662        cursor += 1;
3663    }
3664
3665    let end = lines[cursor - 1].end;
3666    (
3667        Block::Paragraph(Paragraph {
3668            meta: NodeMeta::new(Some(Span::new(start, end))),
3669            children: parse_inlines(&value, start, options, definitions, diagnostics),
3670        }),
3671        cursor,
3672    )
3673}
3674
3675/// A delimiter run recorded during the inline scan for later resolution by the
3676/// delimiter-stack algorithm (`process_emphasis`).
3677///
3678/// Marks pair in one of two ways. *Nested* roles (`can_open` / `can_close`)
3679/// pair a closer with the nearest compatible opener before it, so same-mark
3680/// spans nest: `*` and `_` (CommonMark), `~~` strikethrough, `++` insert, and
3681/// `==` highlight. *First-closer* roles pair an opener with the first closer of
3682/// its kind on the same line and never nest: `||` spoiler (through `can_open`
3683/// / `can_close`) and the one-character `~` subscript and `^` superscript
3684/// (through `single_open` / `single_close`).
3685#[derive(Clone, Copy)]
3686struct DelimMarker {
3687    /// Index of the placeholder text node in the flat node list. The text node
3688    /// holds the as-yet-unmatched delimiter characters; matching trims it from
3689    /// the appropriate side and matched characters are removed entirely.
3690    node_index: usize,
3691    marker: u8,
3692    /// Remaining unmatched delimiter characters in this run.
3693    length: usize,
3694    can_open: bool,
3695    can_close: bool,
3696    /// The `~` subscript / `^` superscript roles of a run.
3697    single_open: bool,
3698    single_close: bool,
3699    /// A `~` run that can pair as strikethrough.
3700    strike: bool,
3701    /// How many more times a long `++` / `==` run closes, two characters at a
3702    /// time, after closing with its first two.
3703    recloses: usize,
3704    /// The opener of the latest-starting mark span enclosing this run (see
3705    /// `assign_emphasis_roles`), or `NIL`.
3706    enclosed_from: usize,
3707    /// Offset of the run in the inline input, for adjacency checks.
3708    position: usize,
3709    /// How many line breaks precede the run in the inline input; first-closer
3710    /// spans cannot cross a line break.
3711    line: usize,
3712}
3713
3714impl DelimMarker {
3715    /// Whether `can_open` / `can_close` are nested roles. A `||` spoiler uses
3716    /// them as first-closer roles instead.
3717    fn nests(&self) -> bool {
3718        self.marker != b'|'
3719    }
3720}
3721
3722/// End-of-list marker for the index-linked lists used by `process_emphasis`.
3723const NIL: usize = usize::MAX;
3724
3725/// The deepest `Emphasis`/`Strong`/`Delete` nesting one inline pass builds. A
3726/// delimiter pair that would nest deeper stays literal text, which bounds the
3727/// tree depth that recursive consumers (text merging, serialization, `Drop`)
3728/// walk.
3729const MAX_EMPHASIS_NESTING: usize = 16;
3730
3731/// How deeply the nodes between two delimiter runs nest: `emphasis` counts
3732/// `Emphasis`/`Strong`/`Delete` levels since the nearest enclosed mark span,
3733/// bracket, or directive,
3734/// and `total` counts every inline container level (emphasis, marks, and
3735/// formed brackets and directives). `total` counts toward `MAX_INLINE_NESTING`
3736/// together with the inline passes and open brackets enclosing these nodes;
3737/// emphasis counts toward `MAX_EMPHASIS_NESTING` within one mark.
3738#[derive(Clone, Copy, Default)]
3739struct Nesting {
3740    emphasis: usize,
3741    total: usize,
3742}
3743
3744impl Nesting {
3745    fn max(self, other: Self) -> Self {
3746        Self {
3747            emphasis: self.emphasis.max(other.emphasis),
3748            total: self.total.max(other.total),
3749        }
3750    }
3751}
3752
3753/// The delimiter stack `process_emphasis` works on: live runs form a doubly
3754/// linked list in source order, and each live run records the deepest nesting
3755/// among the nodes between it and the next live run.
3756struct DelimiterLinks {
3757    prev: Vec<usize>,
3758    next: Vec<usize>,
3759    live: Vec<bool>,
3760    nesting_after: Vec<Nesting>,
3761}
3762
3763impl DelimiterLinks {
3764    fn new(count: usize) -> Self {
3765        Self {
3766            prev: (0..count).map(|index| index.wrapping_sub(1)).collect(),
3767            next: (1..=count)
3768                .map(|index| if index < count { index } else { NIL })
3769                .collect(),
3770            live: alloc::vec![true; count],
3771            nesting_after: alloc::vec![Nesting::default(); count],
3772        }
3773    }
3774
3775    /// Unlinks `index`; the nodes after it now follow the previous live run.
3776    fn unlink(&mut self, index: usize) {
3777        let (before, after) = (self.prev[index], self.next[index]);
3778        self.live[index] = false;
3779        if before != NIL {
3780            self.next[before] = after;
3781            self.nesting_after[before] = self.nesting_after[before].max(self.nesting_after[index]);
3782        }
3783        if after != NIL {
3784            self.prev[after] = before;
3785        }
3786    }
3787
3788    /// Unlinks every run strictly between `opener` and `closer` and returns the
3789    /// deepest nesting among the nodes between them.
3790    fn close_span(&mut self, opener: usize, closer: usize) -> Nesting {
3791        let mut nesting = self.nesting_after[opener];
3792        let mut inner = self.next[opener];
3793        while inner != closer {
3794            nesting = nesting.max(self.nesting_after[inner]);
3795            self.live[inner] = false;
3796            inner = self.next[inner];
3797        }
3798        self.next[opener] = closer;
3799        self.prev[closer] = opener;
3800        nesting
3801    }
3802}
3803
3804/// The flat inline list `process_emphasis` rewrites, held as a doubly linked
3805/// list over stable slots. Wrapping a matched span and dropping a consumed
3806/// delimiter run cost time proportional to the wrapped nodes rather than to the
3807/// whole list, and delimiter `node_index` values never need re-indexing.
3808struct InlineList {
3809    slots: Vec<Option<Inline>>,
3810    prev: Vec<usize>,
3811    next: Vec<usize>,
3812    head: usize,
3813}
3814
3815impl InlineList {
3816    fn new(nodes: Vec<Inline>) -> Self {
3817        let count = nodes.len();
3818        Self {
3819            slots: nodes.into_iter().map(Some).collect(),
3820            prev: (0..count).map(|slot| slot.wrapping_sub(1)).collect(),
3821            next: (1..=count)
3822                .map(|slot| if slot < count { slot } else { NIL })
3823                .collect(),
3824            head: if count == 0 { NIL } else { 0 },
3825        }
3826    }
3827
3828    fn node_mut(&mut self, slot: usize) -> &mut Inline {
3829        self.slots[slot]
3830            .as_mut()
3831            .expect("delimiter placeholder slot is live")
3832    }
3833
3834    /// Replaces every node strictly between `before` and `after` with the node
3835    /// `wrap` builds from them (in order).
3836    fn wrap_between(
3837        &mut self,
3838        before: usize,
3839        after: usize,
3840        wrap: impl FnOnce(Vec<Inline>) -> Inline,
3841    ) {
3842        let mut children = Vec::new();
3843        let mut cursor = self.next[before];
3844        while cursor != after {
3845            children.push(self.slots[cursor].take().expect("linked slot is live"));
3846            cursor = self.next[cursor];
3847        }
3848        let slot = self.slots.len();
3849        self.slots.push(Some(wrap(children)));
3850        self.prev.push(before);
3851        self.next.push(after);
3852        self.next[before] = slot;
3853        self.prev[after] = slot;
3854    }
3855
3856    fn remove(&mut self, slot: usize) {
3857        let (prev, next) = (self.prev[slot], self.next[slot]);
3858        if prev == NIL {
3859            self.head = next;
3860        } else {
3861            self.next[prev] = next;
3862        }
3863        if next != NIL {
3864            self.prev[next] = prev;
3865        }
3866        self.slots[slot] = None;
3867    }
3868
3869    fn into_vec(mut self) -> Vec<Inline> {
3870        let mut nodes = Vec::new();
3871        let mut cursor = self.head;
3872        while cursor != NIL {
3873            nodes.push(self.slots[cursor].take().expect("linked slot is live"));
3874            cursor = self.next[cursor];
3875        }
3876        nodes
3877    }
3878}
3879
3880/// Counts the line breaks before each recorded run. Runs are recorded in
3881/// source order, so the count advances incrementally.
3882#[derive(Default)]
3883struct LineCounter {
3884    position: usize,
3885    line: usize,
3886}
3887
3888impl LineCounter {
3889    fn line_at(&mut self, input: &str, position: usize) -> usize {
3890        self.line += input.as_bytes()[self.position..position]
3891            .iter()
3892            .filter(|byte| matches!(byte, b'\n' | b'\r'))
3893            .count();
3894        self.position = position;
3895        self.line
3896    }
3897}
3898
3899/// The roles a recorded run can play; see `DelimMarker`.
3900#[derive(Clone, Copy, Default)]
3901struct DelimRoles {
3902    can_open: bool,
3903    can_close: bool,
3904    single_open: bool,
3905    single_close: bool,
3906    /// A `~` run that can pair as strikethrough; its roles are settled by
3907    /// `assign_emphasis_roles`.
3908    strike: bool,
3909    recloses: usize,
3910}
3911
3912/// Pushes a literal text node for `value` starting at absolute `start`.
3913fn push_text(nodes: &mut Vec<Inline>, start: usize, value: &str) {
3914    nodes.push(Inline::Text(Text {
3915        meta: NodeMeta::new(Some(Span::new(start, start + value.len()))),
3916        value: value.into(),
3917    }));
3918}
3919
3920/// Resolves the `]` at `close` against the innermost open bracket. Returns the
3921/// end of the construct it forms, or `None` when the `]` stays literal text
3922/// (the opener's characters then stay literal too).
3923#[allow(clippy::too_many_arguments)]
3924fn close_bracket(
3925    input: &str,
3926    base_offset: usize,
3927    close: usize,
3928    options: &SyntaxOptions,
3929    definitions: &[String],
3930    nodes: &mut Vec<Inline>,
3931    delimiters: &mut Vec<DelimMarker>,
3932    brackets: &mut Brackets,
3933    pass: &mut InlinePass,
3934) -> Option<usize> {
3935    let mut opener = brackets.openers.pop()?;
3936    let depth = pass.state.depth + brackets.openers.len();
3937    // The opener's own stack slot decides whether it may form a link; later
3938    // openers pushed into that slot start out able to.
3939    let link_active = brackets.openers.len() >= brackets.link_floor;
3940    brackets.link_floor = brackets.link_floor.min(brackets.openers.len());
3941
3942    if opener.kind == BracketKind::InlineFootnote {
3943        if !input[opener.position + 2..close].trim().is_empty() {
3944            let (children, marks) =
3945                take_label(input, options, nodes, delimiters, brackets, &opener, depth);
3946            nodes.truncate(opener.node_index - 1);
3947            if let Some(caret) = opener.caret {
3948                delimiters.truncate(caret);
3949            }
3950            nodes.push(Inline::InlineFootnote(InlineFootnote {
3951                meta: NodeMeta::new(Some(Span::new(
3952                    base_offset + opener.position,
3953                    base_offset + close + 1,
3954                ))),
3955                children,
3956            }));
3957            brackets.formed.push((nodes.len() - 1, marks + 1));
3958            return Some(close + 1);
3959        }
3960        // An empty inline footnote is a `^` that may close a superscript, then
3961        // a plain `[`.
3962        if let Some(caret) = opener.caret {
3963            delimiters[caret].single_close = true;
3964        }
3965        opener.kind = BracketKind::Link;
3966        opener.position += 1;
3967    }
3968
3969    if opener.kind == BracketKind::Image {
3970        let target = match_link_target(
3971            &mut pass.scan,
3972            input,
3973            opener.position + 2,
3974            close,
3975            true,
3976            definitions,
3977        );
3978        if let Some((end, target)) = target {
3979            let (children, marks) =
3980                take_label(input, options, nodes, delimiters, brackets, &opener, depth);
3981            nodes.truncate(opener.node_index - 1);
3982            let span = Span::new(base_offset + opener.position, base_offset + end);
3983            nodes.push(link_node(target, true, span, children));
3984            brackets.formed.push((nodes.len() - 1, marks + 1));
3985            return Some(end);
3986        }
3987        if let Some((end, wikilink)) = opener.wikilink.take() {
3988            if end > close {
3989                drop_label(nodes, delimiters, brackets, &opener);
3990                nodes.push(wikilink);
3991                return Some(end);
3992            }
3993        }
3994        // Not an image: the `!` stays text and the `[` may still open a link.
3995        opener.kind = BracketKind::Link;
3996        opener.position += 1;
3997    }
3998
3999    if link_active {
4000        let target = match_link_target(
4001            &mut pass.scan,
4002            input,
4003            opener.position + 1,
4004            close,
4005            false,
4006            definitions,
4007        );
4008        if let Some((end, target)) = target {
4009            let (children, marks) =
4010                take_label(input, options, nodes, delimiters, brackets, &opener, depth);
4011            nodes.truncate(opener.node_index);
4012            let span = Span::new(base_offset + opener.position, base_offset + end);
4013            nodes.push(link_node(target, false, span, children));
4014            brackets.formed.push((nodes.len() - 1, marks + 1));
4015            // Links do not nest: no earlier `[` forms a link around this one.
4016            brackets.close_links();
4017            return Some(end);
4018        }
4019    }
4020
4021    // `[^label]` is a footnote reference when the label is plain: no link or
4022    // image formed inside it.
4023    let holds_formed = brackets
4024        .formed
4025        .last()
4026        .is_some_and(|&(node, _)| node > opener.node_index);
4027    if options.constructs.footnote_reference
4028        && !holds_formed
4029        && input[opener.position + 1..].starts_with('^')
4030    {
4031        let label = &input[opener.position + 2..close];
4032        if is_footnote_label(label) {
4033            drop_label(nodes, delimiters, brackets, &opener);
4034            nodes.push(Inline::FootnoteReference(FootnoteReference {
4035                meta: NodeMeta::new(Some(Span::new(
4036                    base_offset + opener.position,
4037                    base_offset + close + 1,
4038                ))),
4039                label: label.into(),
4040                identifier: normalize_label(label),
4041            }));
4042            return Some(close + 1);
4043        }
4044    }
4045    None
4046}
4047
4048/// Drops `opener`'s `[` and everything recorded after it.
4049fn drop_label(
4050    nodes: &mut Vec<Inline>,
4051    delimiters: &mut Vec<DelimMarker>,
4052    brackets: &mut Brackets,
4053    opener: &BracketOpener,
4054) {
4055    nodes.truncate(opener.node_index);
4056    delimiters.truncate(opener.delimiter_bottom);
4057    let kept = brackets
4058        .formed
4059        .partition_point(|&(node, _)| node < opener.node_index);
4060    brackets.formed.truncate(kept);
4061}
4062
4063/// Pushes the `length`-byte run of `marker` at `index` as a literal text node
4064/// plus a delimiter entry with `roles`.
4065#[allow(clippy::too_many_arguments)]
4066fn push_delimiter(
4067    nodes: &mut Vec<Inline>,
4068    delimiters: &mut Vec<DelimMarker>,
4069    lines: &mut LineCounter,
4070    input: &str,
4071    index: usize,
4072    base_offset: usize,
4073    marker: u8,
4074    length: usize,
4075    roles: DelimRoles,
4076) {
4077    let node_index = nodes.len();
4078    nodes.push(Inline::Text(Text {
4079        meta: NodeMeta::new(Some(Span::new(
4080            base_offset + index,
4081            base_offset + index + length,
4082        ))),
4083        value: String::from(marker as char).repeat(length),
4084    }));
4085    delimiters.push(DelimMarker {
4086        node_index,
4087        marker,
4088        length,
4089        can_open: roles.can_open,
4090        can_close: roles.can_close,
4091        single_open: roles.single_open,
4092        single_close: roles.single_close,
4093        strike: roles.strike,
4094        recloses: roles.recloses,
4095        enclosed_from: NIL,
4096        position: index,
4097        line: lines.line_at(input, index),
4098    });
4099}
4100
4101/// The CommonMark roles of a `*`/`_`/`~~` run.
4102///
4103/// Flanking is computed on the whole run (CommonMark treats left/right-flanking
4104/// as a property of the run, not of an individual delimiter), including the
4105/// `_` intraword punctuation rules.
4106///
4107/// `strikethrough` enables the GFM cross-marker bonus: when strikethrough is an
4108/// active construct, a `*`/`_` run immediately adjacent to a `~` counts as
4109/// openable/closeable even though `~` is a punctuation character (this is what
4110/// makes `a*~b~*c` emphasize). The bonus is never granted to a `~` run itself —
4111/// tilde gets plain CommonMark flanking.
4112///
4113/// A side that touches the boundary of an enclosing mark span (`bounded_before`
4114/// / `bounded_after`) flanks as the end of the span's content.
4115fn emphasis_roles(
4116    input: &str,
4117    index: usize,
4118    length: usize,
4119    marker: u8,
4120    strikethrough: bool,
4121    bounded_before: bool,
4122    bounded_after: bool,
4123) -> (bool, bool) {
4124    let flanking = delimiter_flanking_within(input, index, length, bounded_before, bounded_after);
4125    let (mut can_open, mut can_close) = if marker == b'_' {
4126        (
4127            flanking.left
4128                && (!flanking.right || flanking.previous.is_some_and(|c| c.is_ascii_punctuation())),
4129            flanking.right
4130                && (!flanking.left || flanking.next.is_some_and(|c| c.is_ascii_punctuation())),
4131        )
4132    } else {
4133        (flanking.left, flanking.right)
4134    };
4135
4136    // GFM: a `*`/`_` run touching a `~` strikethrough marker may open/close even
4137    // when ordinary flanking refuses it (the `~` would otherwise be a blocking
4138    // punctuation neighbour). Tilde itself never receives this bonus.
4139    if strikethrough && marker != b'~' {
4140        if flanking.next == Some('~') {
4141            can_open = true;
4142        }
4143        if flanking.previous == Some('~') {
4144            can_close = true;
4145        }
4146    }
4147    (can_open, can_close)
4148}
4149
4150/// Settles the roles of every `*`, `_`, and `~~` run once the mark spans are
4151/// known. The mark runs (`++`, `==`, `||`, `~`, `^`, and with `underline` the
4152/// `__` runs) are paired among themselves first; an emphasis run touching the
4153/// inner side of such a span's delimiter then flanks as the edge of that span's
4154/// content.
4155fn assign_emphasis_roles(
4156    input: &str,
4157    delimiters: &mut [DelimMarker],
4158    strikethrough: bool,
4159    underline: bool,
4160) {
4161    let underline_mark = |run: &DelimMarker| underline && run.marker == b'_' && run.length == 2;
4162    let mut opens_span = alloc::vec![false; delimiters.len()];
4163    let mut closes_span = alloc::vec![false; delimiters.len()];
4164    let mut open_marks: [Vec<usize>; 3] = [Vec::new(), Vec::new(), Vec::new()];
4165    let mut pending: [Option<usize>; 3] = [None; 3];
4166    // The run each span opened by a run closes at (a run opens at most one).
4167    let mut span_close = alloc::vec![NIL; delimiters.len()];
4168    for (index, run) in delimiters.iter().enumerate() {
4169        if underline_mark(run) {
4170            let open = &mut open_marks[2];
4171            if can_close_underscore(input, run.position, 2) {
4172                if let Some(opener) = open.pop() {
4173                    opens_span[opener] = true;
4174                    closes_span[index] = true;
4175                    span_close[opener] = index;
4176                    continue;
4177                }
4178            }
4179            if can_open_underscore(input, run.position, 2) {
4180                open.push(index);
4181            }
4182            continue;
4183        }
4184        if matches!(run.marker, b'+' | b'=') {
4185            let open = &mut open_marks[usize::from(run.marker == b'=')];
4186            let mut closed = 0;
4187            if run.can_close {
4188                while closed <= run.recloses {
4189                    let Some(opener) = open.pop() else {
4190                        break;
4191                    };
4192                    opens_span[opener] = true;
4193                    closes_span[index] = true;
4194                    span_close[opener] = index;
4195                    closed += 1;
4196                }
4197            }
4198            // What is left of the run after closing may still open.
4199            if run.can_open && run.length >= 2 * closed + 2 {
4200                open.push(index);
4201            }
4202            continue;
4203        }
4204        let Some(kind) = first_closer_kind(run) else {
4205            continue;
4206        };
4207        if let Some(opener) = pending[kind] {
4208            let opener_run = &delimiters[opener];
4209            if opener_run.line != run.line {
4210                pending[kind] = None;
4211            } else if first_closer_can_close(run) {
4212                pending[kind] = None;
4213                if opener_run.position + opener_run.length != run.position {
4214                    opens_span[opener] = true;
4215                    closes_span[index] = true;
4216                    span_close[opener] = index;
4217                    // A long `||` run opens again with what it has left.
4218                    if run.marker == b'|' && run.can_open && run.length >= 4 {
4219                        pending[kind] = Some(index);
4220                    }
4221                    continue;
4222                }
4223            }
4224        }
4225        if pending[kind].is_none() && first_closer_can_open(run) {
4226            pending[kind] = Some(index);
4227        }
4228    }
4229
4230    for index in 0..delimiters.len() {
4231        let run = delimiters[index];
4232        if !matches!(run.marker, b'*' | b'_') && !run.strike {
4233            continue;
4234        }
4235        let bounded_before = index > 0 && {
4236            let before = &delimiters[index - 1];
4237            before.position + before.length == run.position && opens_span[index - 1]
4238        };
4239        let bounded_after = index + 1 < delimiters.len() && {
4240            let after = &delimiters[index + 1];
4241            after.position == run.position + run.length && closes_span[index + 1]
4242        };
4243        let (can_open, can_close) = emphasis_roles(
4244            input,
4245            run.position,
4246            run.length,
4247            run.marker,
4248            strikethrough,
4249            bounded_before,
4250            bounded_after,
4251        );
4252        delimiters[index].can_open = can_open;
4253        delimiters[index].can_close = can_close;
4254    }
4255
4256    // Each run's latest-starting enclosing mark span. Spans are pushed in
4257    // opener order, so the top of `open_spans` is the latest-starting one;
4258    // spans that have ended are dropped when they reach the top.
4259    let mut open_spans: Vec<usize> = Vec::new();
4260    for (index, run) in delimiters.iter_mut().enumerate() {
4261        while open_spans
4262            .last()
4263            .is_some_and(|&opener| span_close[opener] <= index)
4264        {
4265            open_spans.pop();
4266        }
4267        if let Some(&opener) = open_spans.last() {
4268            run.enclosed_from = opener;
4269        }
4270        if span_close[index] != NIL {
4271            open_spans.push(index);
4272        }
4273    }
4274}
4275
4276/// The roles of a `++` / `==` run of `length` bytes at `index`: it opens with
4277/// its last two characters and closes with its first two (and then the next
4278/// two), each subject to CommonMark flanking of that two-character delimiter.
4279/// A run right after an escaped character of the same mark never closes.
4280fn double_mark_roles(input: &str, index: usize, length: usize, marker: u8) -> DelimRoles {
4281    let after_escaped_mark =
4282        index > 0 && input.as_bytes()[index - 1] == marker && is_escaped_at(input, index - 1);
4283    let can_close = !after_escaped_mark && can_close_delimited(input, index, 2);
4284    let recloses = if can_close {
4285        (1..length / 2)
4286            .take_while(|pair| can_close_delimited(input, index + 2 * pair, 2))
4287            .count()
4288    } else {
4289        0
4290    };
4291    DelimRoles {
4292        can_open: can_open_delimited(input, index + length - 2, 2),
4293        can_close,
4294        recloses,
4295        ..DelimRoles::default()
4296    }
4297}
4298
4299/// What resolving one delimiter pair needs to know beyond the runs themselves.
4300#[derive(Clone, Copy)]
4301struct EmphasisContext {
4302    /// `__` pairs form `Underline` instead of `Strong`.
4303    underline: bool,
4304    /// How many nesting levels enclose these runs: the inline passes around
4305    /// this one, plus the open brackets around a bracket label.
4306    depth: usize,
4307}
4308
4309/// Resolves recorded delimiter runs into emphasis and mark nodes using the
4310/// delimiter-stack algorithm, leaving unmatched runs as text. Closers are taken
4311/// in source order; when a pair closes, runs strictly between its opener and
4312/// closer can no longer pair across it.
4313///
4314/// `formed` lists the link, image, footnote, and directive nodes already built
4315/// among `nodes`, with their nesting depth, so spans around them count it.
4316/// Returns the nodes and the deepest nesting among them.
4317fn process_emphasis(
4318    nodes: Vec<Inline>,
4319    mut delimiters: Vec<DelimMarker>,
4320    context: EmphasisContext,
4321    formed: &[(usize, usize)],
4322) -> (Vec<Inline>, usize) {
4323    let mut deepest = formed.iter().map(|&(_, depth)| depth).max().unwrap_or(0);
4324    if delimiters.is_empty() {
4325        let mut nodes = nodes;
4326        merge_adjacent_text(&mut nodes);
4327        return (nodes, deepest);
4328    }
4329    let mut nodes = InlineList::new(nodes);
4330
4331    // A run that is consumed, demoted to plain text, or enclosed by a newly
4332    // closed span is unlinked, so the opener walk below only ever visits live
4333    // runs.
4334    let mut links = DelimiterLinks::new(delimiters.len());
4335    for &(node, depth) in formed {
4336        let before = delimiters.partition_point(|run| run.node_index < node);
4337        if before > 0 {
4338            let gap = &mut links.nesting_after[before - 1].total;
4339            *gap = (*gap).max(depth);
4340        }
4341    }
4342
4343    // `openers_bottom` records, per (marker, opener-can-also-close, length % 3),
4344    // the lowest opener index a nested closer is allowed to reach. Closers below
4345    // this bound for their key have already been proven to have no compatible
4346    // opener.
4347    let mut openers_bottom: [Option<usize>; NESTED_MARKERS * 6] = [None; NESTED_MARKERS * 6];
4348    let mut span_bottom: Vec<Vec<(usize, usize)>> = Vec::new();
4349    // The opener each first-closer kind (`||`, `~`, `^`) is waiting to close.
4350    let mut pending: [Option<usize>; 3] = [None; 3];
4351    let mut closer_idx = 0;
4352    // The closer whose first-closer bookkeeping has already run, so a closer
4353    // revisited for leftover delimiters does not run it twice.
4354    let mut visited = NIL;
4355
4356    // Every run at or after `closer_idx` is still linked: runs are only ever
4357    // unlinked at or before the current closer.
4358    while closer_idx < delimiters.len() {
4359        let closer = delimiters[closer_idx];
4360
4361        let mut first = None;
4362        let kind = first_closer_kind(&closer);
4363        if let Some(kind) = kind {
4364            if visited != closer_idx {
4365                if let Some(opener) = pending[kind] {
4366                    let opener_run = delimiters[opener];
4367                    let usable = links.live[opener]
4368                        && first_closer_can_open(&opener_run)
4369                        && opener_run.line == closer.line;
4370                    if !usable {
4371                        pending[kind] = None;
4372                    } else if first_closer_can_close(&closer) {
4373                        if opener_run.position + opener_run.length == closer.position {
4374                            // An empty span: the opener fails for good.
4375                            pending[kind] = None;
4376                        } else {
4377                            first = Some(opener);
4378                        }
4379                    }
4380                }
4381            }
4382        }
4383        visited = closer_idx;
4384
4385        // A nested opener wins only when it is nearer than the first-closer
4386        // opener, so the search stops there.
4387        let mut nested = None;
4388        if closer.can_close && closer.nests() {
4389            nested = nested_opener(
4390                &delimiters,
4391                &links,
4392                &openers_bottom,
4393                &span_bottom,
4394                closer_idx,
4395                first.map(|opener| opener + 1),
4396            );
4397        }
4398
4399        let choice = match (nested, first) {
4400            (Some(nested), Some(first)) => Some((nested.max(first), nested > first)),
4401            (Some(nested), None) => Some((nested, true)),
4402            (None, Some(first)) => Some((first, false)),
4403            (None, None) => None,
4404        };
4405
4406        let Some((opener_idx, is_nested)) = choice else {
4407            if closer.can_close && closer.nests() {
4408                // No opener found: remember how far we searched so future
4409                // closers of the same key skip the same dead range.
4410                let key = openers_bottom_key(&closer);
4411                match span_floor(&closer) {
4412                    Some(span) => {
4413                        if span_bottom.len() <= span {
4414                            span_bottom.resize_with(span + 1, Vec::new);
4415                        }
4416                        match span_bottom[span].iter_mut().find(|(k, _)| *k == key) {
4417                            Some(bound) => bound.1 = closer_idx,
4418                            None => span_bottom[span].push((key, closer_idx)),
4419                        }
4420                    }
4421                    None => openers_bottom[key] = Some(closer_idx),
4422                }
4423            }
4424            if let Some(kind) = kind {
4425                if pending[kind].is_none() && first_closer_can_open(&closer) {
4426                    pending[kind] = Some(closer_idx);
4427                }
4428            }
4429            if !closer.can_open && !closer.single_open {
4430                links.unlink(closer_idx);
4431            }
4432            closer_idx += 1;
4433            continue;
4434        };
4435
4436        if !is_nested {
4437            if let Some(kind) = kind {
4438                pending[kind] = None;
4439            }
4440        }
4441        let (used, wrap) = pair_shape(
4442            &delimiters[opener_idx],
4443            &delimiters[closer_idx],
4444            is_nested,
4445            context,
4446        );
4447
4448        // Drop delimiters strictly between the opener and closer: they could not
4449        // match outward across this newly closed span.
4450        let inner = links.close_span(opener_idx, closer_idx);
4451        let fits = context.depth + inner.total < MAX_INLINE_NESTING
4452            && (wrap.is_mark() || inner.emphasis < MAX_EMPHASIS_NESTING);
4453        if fits {
4454            apply_emphasis(
4455                &mut nodes,
4456                &mut delimiters,
4457                opener_idx,
4458                closer_idx,
4459                used,
4460                wrap,
4461            );
4462            links.nesting_after[opener_idx] = Nesting {
4463                emphasis: if wrap.is_mark() {
4464                    0
4465                } else {
4466                    inner.emphasis + 1
4467                },
4468                total: inner.total + 1,
4469            };
4470            deepest = deepest.max(inner.total + 1);
4471        } else {
4472            // Too deep to wrap: both runs stay whole in their text nodes as
4473            // literal text and pair no further. Any span enclosing this one is
4474            // at least as deep, so it stays literal too.
4475            delimiters[opener_idx].length = 0;
4476            delimiters[closer_idx].length = 0;
4477            links.nesting_after[opener_idx] = inner;
4478        }
4479        if matches!(delimiters[opener_idx].marker, b'+' | b'=' | b'|') {
4480            // These runs open only with their last two characters.
4481            delimiters[opener_idx].can_open = false;
4482        }
4483        if is_nested && matches!(closer.marker, b'+' | b'=') {
4484            // A long run closes a second time only with its next two
4485            // characters.
4486            let run = &mut delimiters[closer_idx];
4487            run.can_close = run.recloses > 0;
4488            run.recloses = run.recloses.saturating_sub(1);
4489        }
4490        if !is_nested {
4491            // A run consumed as a one-character subscript/superscript closer
4492            // leaves its other characters literal.
4493            if closer.marker == b'~' {
4494                delimiters[closer_idx].can_open = false;
4495                delimiters[closer_idx].can_close = false;
4496            }
4497            delimiters[closer_idx].single_close = false;
4498        }
4499
4500        if delimiters[opener_idx].length == 0 {
4501            links.unlink(opener_idx);
4502        }
4503        let leftover = delimiters[closer_idx];
4504        if leftover.length == 0 {
4505            links.unlink(closer_idx);
4506            closer_idx += 1;
4507        } else if !is_nested {
4508            // A first-closer run whose leftover can still open waits for the
4509            // next closer of its kind.
4510            if let Some(kind) = kind {
4511                if first_closer_can_open(&leftover) {
4512                    pending[kind] = Some(closer_idx);
4513                }
4514            }
4515            if !first_closer_can_open(&leftover) {
4516                links.unlink(closer_idx);
4517            }
4518            closer_idx += 1;
4519        }
4520        // When a nested closer still has delimiters left it stays the active
4521        // closer so the leftover can match an earlier opener (e.g. `***foo*`
4522        // keeps `**`).
4523    }
4524
4525    // Adjacent text nodes can appear where unmatched delimiter runs ended up
4526    // beside literal text (`**foo*bar*` -> `**foo` + emphasis). CommonMark
4527    // coalesces them as the final step; do the same for the spans we created.
4528    let mut nodes = nodes.into_vec();
4529    merge_adjacent_text(&mut nodes);
4530    (nodes, deepest)
4531}
4532
4533/// The markers that pair through nested roles, in `openers_bottom` order.
4534const NESTED_MARKERS: usize = 5;
4535
4536/// The nearest live opener before `closer_idx` that the nested closer there can
4537/// pair with, searching no lower than its `openers_bottom` bound.
4538///
4539/// A closer that can also open does not close across a mark span enclosing it:
4540/// it searches no lower than that span's opener, bounded per span by
4541/// `span_bottom` the way `openers_bottom` bounds the whole list. `floor` stops
4542/// the search at a nearer first-closer opener.
4543fn nested_opener(
4544    delimiters: &[DelimMarker],
4545    links: &DelimiterLinks,
4546    openers_bottom: &[Option<usize>],
4547    span_bottom: &[Vec<(usize, usize)>],
4548    closer_idx: usize,
4549    floor: Option<usize>,
4550) -> Option<usize> {
4551    let closer = &delimiters[closer_idx];
4552    let key = openers_bottom_key(closer);
4553    let mut bottom = openers_bottom[key].max(floor);
4554    if let Some(span) = span_floor(closer) {
4555        let span_floor = span_bottom
4556            .get(span)
4557            .and_then(|bounds| bounds.iter().find(|(k, _)| *k == key))
4558            .map_or(span + 1, |&(_, bound)| bound);
4559        bottom = bottom.max(Some(span_floor));
4560    }
4561    let mut search = links.prev[closer_idx];
4562    while search != NIL {
4563        if bottom.is_some_and(|bottom| search < bottom) {
4564            return None;
4565        }
4566        let candidate = &delimiters[search];
4567        if candidate.marker == closer.marker
4568            && candidate.can_open
4569            && emphasis_delimiters_match(candidate, closer)
4570        {
4571            return Some(search);
4572        }
4573        search = links.prev[search];
4574    }
4575    None
4576}
4577
4578/// The enclosing mark span a nested closer may not close across: only a run
4579/// that can also open is held inside it.
4580fn span_floor(closer: &DelimMarker) -> Option<usize> {
4581    (closer.can_open && closer.enclosed_from != NIL).then_some(closer.enclosed_from)
4582}
4583
4584/// The `pending` slot of a run with first-closer roles: `||`, `~`, or `^`.
4585fn first_closer_kind(run: &DelimMarker) -> Option<usize> {
4586    match run.marker {
4587        b'|' => Some(0),
4588        b'~' if run.single_open || run.single_close => Some(1),
4589        b'^' => Some(2),
4590        _ => None,
4591    }
4592}
4593
4594fn first_closer_can_open(run: &DelimMarker) -> bool {
4595    if run.marker == b'|' {
4596        run.can_open && run.length >= 2
4597    } else {
4598        run.single_open
4599    }
4600}
4601
4602fn first_closer_can_close(run: &DelimMarker) -> bool {
4603    if run.marker == b'|' {
4604        run.can_close && run.length >= 2
4605    } else {
4606        run.single_close
4607    }
4608}
4609
4610/// How many characters a pair consumes from each run, and the node it forms.
4611fn pair_shape(
4612    opener: &DelimMarker,
4613    closer: &DelimMarker,
4614    nested: bool,
4615    context: EmphasisContext,
4616) -> (usize, EmphasisWrap) {
4617    match closer.marker {
4618        b'~' if nested => {
4619            // Strikethrough consumes the whole (equal-length) run on each side at
4620            // once; the marker width selects the `Delete` flavour.
4621            let marker = if closer.length >= 2 {
4622                DeleteMarker::DoubleTilde
4623            } else {
4624                DeleteMarker::SingleTilde
4625            };
4626            (closer.length, EmphasisWrap::Delete(marker))
4627        }
4628        b'~' => (1, EmphasisWrap::Subscript),
4629        b'^' => (1, EmphasisWrap::Superscript),
4630        b'+' => (2, EmphasisWrap::Insert),
4631        b'=' => (2, EmphasisWrap::Mark),
4632        b'|' => (2, EmphasisWrap::Spoiler),
4633        marker => {
4634            let strong = opener.length >= 2 && closer.length >= 2;
4635            if !strong {
4636                (1, EmphasisWrap::Emphasis)
4637            } else if marker == b'_' && context.underline {
4638                (2, EmphasisWrap::Underline)
4639            } else {
4640                (2, EmphasisWrap::Strong)
4641            }
4642        }
4643    }
4644}
4645
4646/// Merges consecutive `Text` nodes in a list, recursing into the emphasis and
4647/// mark nodes produced at this level. Other containers were already finalized
4648/// by their own `parse_inlines` pass and are left untouched.
4649fn merge_adjacent_text(nodes: &mut Vec<Inline>) {
4650    let mut write = 0;
4651    for read in 0..nodes.len() {
4652        if read != write {
4653            nodes.swap(read, write);
4654        }
4655        if write > 0 {
4656            let (head, tail) = nodes.split_at_mut(write);
4657            if let (Inline::Text(previous), Inline::Text(current)) =
4658                (&mut head[write - 1], &tail[0])
4659            {
4660                previous.value.push_str(&current.value);
4661                if let (Some(previous_span), Some(current_span)) =
4662                    (previous.meta.span.as_mut(), current.meta.span)
4663                {
4664                    previous_span.end = current_span.end;
4665                }
4666                continue;
4667            }
4668        }
4669        write += 1;
4670    }
4671    nodes.truncate(write);
4672
4673    for node in nodes.iter_mut() {
4674        match node {
4675            Inline::Emphasis(node) => merge_adjacent_text(&mut node.children),
4676            Inline::Strong(node) => merge_adjacent_text(&mut node.children),
4677            Inline::Delete(node) => merge_adjacent_text(&mut node.children),
4678            Inline::Underline(node) => merge_adjacent_text(&mut node.children),
4679            Inline::Insert(node) => merge_adjacent_text(&mut node.children),
4680            Inline::Mark(node) => merge_adjacent_text(&mut node.children),
4681            Inline::Spoiler(node) => merge_adjacent_text(&mut node.children),
4682            Inline::Subscript(node) => merge_adjacent_text(&mut node.children),
4683            Inline::Superscript(node) => merge_adjacent_text(&mut node.children),
4684            _ => {}
4685        }
4686    }
4687}
4688
4689/// Index into `openers_bottom` for a nested closer's (marker, both-flags,
4690/// length%3) key.
4691fn openers_bottom_key(closer: &DelimMarker) -> usize {
4692    let marker = match closer.marker {
4693        b'_' => 1,
4694        b'~' => 2,
4695        b'+' => 3,
4696        b'=' => 4,
4697        _ => 0,
4698    };
4699    let both = usize::from(closer.can_open && closer.can_close);
4700    let modulo = closer.length % 3;
4701    ((marker * 2) + both) * 3 + modulo
4702}
4703
4704/// Nested opener/closer compatibility, including CommonMark's rule of three.
4705fn emphasis_delimiters_match(opener: &DelimMarker, closer: &DelimMarker) -> bool {
4706    match opener.marker {
4707        // GFM strikethrough: opener and closer runs must be the same length (a
4708        // `~` never pairs with `~~`). The rule of three does not apply to `~`.
4709        b'~' => opener.length == closer.length,
4710        // `++` / `==` pair two characters from each run.
4711        b'+' | b'=' => opener.length >= 2 && closer.length >= 2,
4712        _ => {
4713            // Rule of three: if either delimiter can both open and close, the
4714            // sum of the two run lengths must not be a multiple of three, unless
4715            // both lengths are themselves multiples of three.
4716            let opener_both = opener.can_open && opener.can_close;
4717            let closer_both = closer.can_open && closer.can_close;
4718            if opener_both || closer_both {
4719                let sum = opener.length + closer.length;
4720                if sum % 3 == 0 && !(opener.length % 3 == 0 && closer.length % 3 == 0) {
4721                    return false;
4722                }
4723            }
4724            true
4725        }
4726    }
4727}
4728
4729/// The node a matched delimiter pair collapses into.
4730#[derive(Clone, Copy)]
4731enum EmphasisWrap {
4732    Emphasis,
4733    Strong,
4734    Delete(DeleteMarker),
4735    Underline,
4736    Insert,
4737    Mark,
4738    Spoiler,
4739    Subscript,
4740    Superscript,
4741}
4742
4743impl EmphasisWrap {
4744    /// Whether the node counts toward `MAX_INLINE_NESTING` rather than
4745    /// `MAX_EMPHASIS_NESTING`.
4746    fn is_mark(self) -> bool {
4747        !matches!(self, Self::Emphasis | Self::Strong | Self::Delete(_))
4748    }
4749}
4750
4751/// Wraps the nodes between two delimiter runs into the node `wrap` names and
4752/// consumes `used` characters from each side.
4753fn apply_emphasis(
4754    nodes: &mut InlineList,
4755    delimiters: &mut [DelimMarker],
4756    opener_idx: usize,
4757    closer_idx: usize,
4758    used: usize,
4759    wrap: EmphasisWrap,
4760) {
4761    let opener_node = delimiters[opener_idx].node_index;
4762    let closer_node = delimiters[closer_idx].node_index;
4763
4764    // A span covers exactly its consumed delimiters and what they enclose: the
4765    // last `used` characters left in the opener's text node through the first
4766    // `used` left in the closer's.
4767    let span_start = nodes
4768        .node_mut(opener_node)
4769        .span()
4770        .map(|span| span.end - used);
4771    let span_end = nodes
4772        .node_mut(closer_node)
4773        .span()
4774        .map(|span| span.start + used);
4775    // Trim the consumed characters from the opener's text node (right side) and
4776    // the closer's text node (left side), updating their recorded lengths/spans.
4777    trim_delimiter_text_tail(nodes.node_mut(opener_node), used);
4778    trim_delimiter_text_head(nodes.node_mut(closer_node), used);
4779    delimiters[opener_idx].length -= used;
4780    let closer = &mut delimiters[closer_idx];
4781    closer.length -= used;
4782    closer.position += used;
4783
4784    // The wrapped children are the nodes strictly between the opener and closer
4785    // text nodes.
4786    let meta = NodeMeta::new(
4787        span_start
4788            .zip(span_end)
4789            .map(|(start, end)| Span::new(start, end)),
4790    );
4791    nodes.wrap_between(opener_node, closer_node, |children| match wrap {
4792        EmphasisWrap::Strong => Inline::Strong(Strong { meta, children }),
4793        EmphasisWrap::Emphasis => Inline::Emphasis(Emphasis { meta, children }),
4794        EmphasisWrap::Delete(marker) => Inline::Delete(Delete {
4795            meta,
4796            marker,
4797            children,
4798        }),
4799        EmphasisWrap::Underline => Inline::Underline(Underline { meta, children }),
4800        EmphasisWrap::Insert => Inline::Insert(Insert { meta, children }),
4801        EmphasisWrap::Mark => Inline::Mark(Mark { meta, children }),
4802        EmphasisWrap::Spoiler => Inline::Spoiler(Spoiler { meta, children }),
4803        EmphasisWrap::Subscript => Inline::Subscript(Subscript { meta, children }),
4804        EmphasisWrap::Superscript => Inline::Superscript(Superscript { meta, children }),
4805    });
4806
4807    // Drop any placeholder text node that has been fully consumed so leftover
4808    // delimiters never survive as literal text.
4809    if delimiters[closer_idx].length == 0 {
4810        nodes.remove(closer_node);
4811    }
4812    if delimiters[opener_idx].length == 0 {
4813        nodes.remove(opener_node);
4814    }
4815}
4816
4817/// What a `[`-family opener can become when its `]` arrives.
4818#[derive(Clone, Copy, PartialEq, Eq)]
4819enum BracketKind {
4820    Link,
4821    Image,
4822    InlineFootnote,
4823}
4824
4825/// A `[`, `![`, or `^[` waiting for its `]`. Its `[` is a placeholder text
4826/// node; an image's `!` and an inline footnote's `^` sit in the node before it.
4827struct BracketOpener {
4828    kind: BracketKind,
4829    /// The placeholder text node holding the `[`.
4830    node_index: usize,
4831    /// Offset of the opener's first character (`!`, `^`, or `[`) in the input.
4832    position: usize,
4833    /// `delimiters.len()` when the opener was pushed: later runs are in its
4834    /// label.
4835    delimiter_bottom: usize,
4836    /// For an inline footnote, the dormant `^` superscript run recorded before
4837    /// it, woken if the footnote does not form.
4838    caret: Option<usize>,
4839    /// For an image, the wikilink that starts at its `[`, used if the image
4840    /// does not form and the wikilink runs through the image's `]`.
4841    wikilink: Option<(usize, Inline)>,
4842}
4843
4844/// What a `]` resolves a link-kind opener to.
4845enum LinkTarget {
4846    Resource(ParsedLinkResource),
4847    Reference {
4848        identifier: String,
4849        kind: ReferenceKind,
4850    },
4851}
4852
4853/// Matches what follows the `]` at `close` of a link or image whose label is
4854/// `input[label_start..close]`: an inline `(…)` resource, a full or collapsed
4855/// reference, or a shortcut reference to a defined label. An image whose `(…)`
4856/// is not a valid resource is no image; a link falls back to the reference
4857/// forms. Returns the end of the construct and its target.
4858fn match_link_target(
4859    scan: &mut InlineScan,
4860    input: &str,
4861    label_start: usize,
4862    close: usize,
4863    image: bool,
4864    definitions: &[String],
4865) -> Option<(usize, LinkTarget)> {
4866    let label = &input[label_start..close];
4867    let after = close + 1;
4868    if input.as_bytes().get(after) == Some(&b'(') {
4869        match parse_link_resource(&mut scan.lookups, input, after) {
4870            Some((end, resource)) => return Some((end, LinkTarget::Resource(resource))),
4871            // A present-but-invalid `(...)` resource is not an inline link,
4872            // but CommonMark still resolves `[label]` as a shortcut reference
4873            // and leaves the invalid `(...)` as literal text (links 568).
4874            None if image => return None,
4875            None => {}
4876        }
4877    }
4878    // A `[` that opens no reference label leaves `[label]` a shortcut
4879    // reference followed by literal text.
4880    let reference_close = if input.as_bytes().get(after) == Some(&b'[') {
4881        scan.reference_label_end(after)
4882    } else {
4883        None
4884    };
4885    if let Some(reference_close) = reference_close {
4886        let reference = &input[after + 1..reference_close];
4887        let identifier = if reference.is_empty() {
4888            label
4889        } else {
4890            reference
4891        };
4892        // A present `[...]` second label that resolves to no definition is
4893        // not a link, and CommonMark does not fall back to treating the first
4894        // label as a shortcut (`[x][ ]`, `[x][undef]` stay literal).
4895        return definition_exists(definitions, identifier).then(|| {
4896            let kind = if reference.is_empty() {
4897                ReferenceKind::Collapsed
4898            } else {
4899                ReferenceKind::Full
4900            };
4901            (
4902                reference_close + 1,
4903                LinkTarget::Reference {
4904                    identifier: identifier.into(),
4905                    kind,
4906                },
4907            )
4908        });
4909    }
4910    definition_exists(definitions, label).then(|| {
4911        (
4912            after,
4913            LinkTarget::Reference {
4914                identifier: label.into(),
4915                kind: ReferenceKind::Shortcut,
4916            },
4917        )
4918    })
4919}
4920
4921/// Builds the link or image node for a matched target over `children`.
4922fn link_node(target: LinkTarget, image: bool, span: Span, mut children: Vec<Inline>) -> Inline {
4923    let meta = NodeMeta::new(Some(span));
4924    if !image {
4925        demote_links(&mut children);
4926    }
4927    match (target, image) {
4928        (LinkTarget::Resource(resource), false) => Inline::Link(Link {
4929            meta,
4930            destination: resource.destination,
4931            destination_kind: resource.destination_kind,
4932            title: resource.title,
4933            title_kind: resource.title_kind,
4934            children,
4935        }),
4936        (LinkTarget::Resource(resource), true) => Inline::Image(Image {
4937            meta,
4938            destination: resource.destination,
4939            destination_kind: resource.destination_kind,
4940            title: resource.title,
4941            title_kind: resource.title_kind,
4942            alt: children,
4943        }),
4944        (LinkTarget::Reference { identifier, kind }, false) => {
4945            Inline::LinkReference(LinkReference {
4946                meta,
4947                identifier: normalize_label(&identifier),
4948                label: identifier,
4949                kind,
4950                children,
4951            })
4952        }
4953        (LinkTarget::Reference { identifier, kind }, true) => {
4954            Inline::ImageReference(ImageReference {
4955                meta,
4956                identifier: normalize_label(&identifier),
4957                label: identifier,
4958                kind,
4959                alt: children,
4960            })
4961        }
4962    }
4963}
4964
4965/// Link text holds no links: autolinks inside it read as their text, an
4966/// angle-bracket autolink without its brackets. Image alt text inside it keeps
4967/// its own links.
4968fn demote_links(nodes: &mut Vec<Inline>) {
4969    for node in nodes.iter_mut() {
4970        match node {
4971            Inline::Autolink(autolink) => {
4972                let value = match &autolink.kind {
4973                    AutolinkKind::Angle => autolink.destination.clone(),
4974                    AutolinkKind::GfmLiteral { original } => original.clone(),
4975                };
4976                *node = Inline::Text(Text {
4977                    meta: autolink.meta.clone(),
4978                    value,
4979                });
4980            }
4981            Inline::Emphasis(node) => demote_links(&mut node.children),
4982            Inline::Strong(node) => demote_links(&mut node.children),
4983            Inline::Underline(node) => demote_links(&mut node.children),
4984            Inline::Delete(node) => demote_links(&mut node.children),
4985            Inline::Insert(node) => demote_links(&mut node.children),
4986            Inline::Mark(node) => demote_links(&mut node.children),
4987            Inline::Spoiler(node) => demote_links(&mut node.children),
4988            Inline::Subscript(node) => demote_links(&mut node.children),
4989            Inline::Superscript(node) => demote_links(&mut node.children),
4990            Inline::InlineFootnote(node) => demote_links(&mut node.children),
4991            Inline::TextDirective(node) => demote_links(&mut node.label),
4992            _ => {}
4993        }
4994    }
4995    merge_adjacent_text(nodes);
4996}
4997
4998/// The open brackets of one inline pass and the runs and nodes they enclose.
4999struct Brackets {
5000    openers: Vec<BracketOpener>,
5001    /// Openers below this stack index can no longer form links: a link formed
5002    /// after them.
5003    link_floor: usize,
5004    /// How many `[` were left as text for want of nesting room and are still
5005    /// open: the next that many `]` close them, as text.
5006    overflow: usize,
5007    /// Formed link, image, footnote, and directive nodes with their nesting
5008    /// depth, by node index, so spans around them count it.
5009    formed: Vec<(usize, usize)>,
5010}
5011
5012impl Brackets {
5013    fn new() -> Self {
5014        Self {
5015            openers: Vec::new(),
5016            link_floor: 0,
5017            overflow: 0,
5018            formed: Vec::new(),
5019        }
5020    }
5021
5022    /// Keeps every open `[` from forming a link around what follows.
5023    fn close_links(&mut self) {
5024        self.link_floor = self.openers.len();
5025    }
5026}
5027
5028/// Takes the nodes and runs after `opener` out of the pass, pairs the runs
5029/// among themselves, and returns the resolved label content with its nesting
5030/// depth.
5031fn take_label(
5032    input: &str,
5033    options: &SyntaxOptions,
5034    nodes: &mut Vec<Inline>,
5035    delimiters: &mut Vec<DelimMarker>,
5036    brackets: &mut Brackets,
5037    opener: &BracketOpener,
5038    depth: usize,
5039) -> (Vec<Inline>, usize) {
5040    let first = opener.node_index + 1;
5041    let children: Vec<Inline> = nodes.drain(first..).collect();
5042    let mut runs = delimiters.split_off(opener.delimiter_bottom);
5043    for run in &mut runs {
5044        run.node_index -= first;
5045    }
5046    let split = brackets.formed.partition_point(|&(node, _)| node < first);
5047    let formed: Vec<(usize, usize)> = brackets
5048        .formed
5049        .split_off(split)
5050        .into_iter()
5051        .map(|(node, marks)| (node - first, marks))
5052        .collect();
5053    assign_emphasis_roles(
5054        input,
5055        &mut runs,
5056        options.constructs.gfm_strikethrough,
5057        options.constructs.underline,
5058    );
5059    process_emphasis(
5060        children,
5061        runs,
5062        EmphasisContext {
5063            underline: options.constructs.underline,
5064            depth,
5065        },
5066        &formed,
5067    )
5068}
5069
5070/// Removes `count` trailing delimiter characters from a placeholder text node.
5071fn trim_delimiter_text_tail(node: &mut Inline, count: usize) {
5072    if let Inline::Text(text) = node {
5073        let new_len = text.value.len().saturating_sub(count);
5074        text.value.truncate(new_len);
5075        if let Some(span) = text.meta.span.as_mut() {
5076            span.end = span.end.saturating_sub(count);
5077        }
5078    }
5079}
5080
5081/// Removes `count` leading delimiter characters from a placeholder text node.
5082/// The node holds one repeated marker byte, so dropping from the front leaves
5083/// the same text as truncating the back.
5084fn trim_delimiter_text_head(node: &mut Inline, count: usize) {
5085    if let Inline::Text(text) = node {
5086        let count = count.min(text.value.len());
5087        text.value.truncate(text.value.len() - count);
5088        if let Some(span) = text.meta.span.as_mut() {
5089            span.start += count;
5090        }
5091    }
5092}
5093
5094/// Forward lookups the inline scanners ask of one input. `DirectLookups`
5095/// answers each with a fresh scan, for callers that ask once; `InlineLookups`
5096/// memoizes the answers for the many questions one inline pass asks. Both
5097/// answer identically.
5098trait Lookups {
5099    /// The first occurrence of `pattern` that starts at or after `from`.
5100    fn find(&mut self, pattern: &'static str, from: usize) -> Option<usize>;
5101    /// The first run of exactly `len` backticks at or after `start`, which is
5102    /// not inside a backtick run.
5103    fn code_span_close(&mut self, start: usize, len: usize) -> Option<usize>;
5104    /// The first unescaped `<` or `>`, or line break, at or after `from`.
5105    fn angle_destination_stop(&mut self, from: usize) -> Option<usize>;
5106}
5107
5108struct DirectLookups<'a> {
5109    input: &'a str,
5110}
5111
5112impl Lookups for DirectLookups<'_> {
5113    fn find(&mut self, pattern: &'static str, from: usize) -> Option<usize> {
5114        self.input[from..].find(pattern).map(|offset| from + offset)
5115    }
5116
5117    fn code_span_close(&mut self, start: usize, len: usize) -> Option<usize> {
5118        find_code_span_close(self.input, start, len)
5119    }
5120
5121    fn angle_destination_stop(&mut self, from: usize) -> Option<usize> {
5122        (from..self.input.len()).find(|index| is_angle_destination_stop(self.input, *index))
5123    }
5124}
5125
5126/// The patterns `InlineLookups::find` keeps position tables for.
5127const CACHED_PATTERNS: [&str; 7] = [">", "-->", "?>", "]]>", "\"", "'", "`$"];
5128
5129/// `Lookups` over one inline input, each answered from a table built on first
5130/// use.
5131struct InlineLookups<'a> {
5132    input: &'a str,
5133    patterns: [Positions; CACHED_PATTERNS.len()],
5134    backtick_runs: Option<BTreeMap<usize, Vec<usize>>>,
5135    angle_destination_stops: Positions,
5136}
5137
5138impl<'a> InlineLookups<'a> {
5139    fn new(input: &'a str) -> Self {
5140        Self {
5141            input,
5142            patterns: Default::default(),
5143            backtick_runs: None,
5144            angle_destination_stops: Positions::default(),
5145        }
5146    }
5147}
5148
5149impl Lookups for InlineLookups<'_> {
5150    fn find(&mut self, pattern: &'static str, from: usize) -> Option<usize> {
5151        let input = self.input;
5152        let Some(slot) = CACHED_PATTERNS.iter().position(|cached| *cached == pattern) else {
5153            return DirectLookups { input }.find(pattern, from);
5154        };
5155        self.patterns[slot].first_at_or_after(from, || pattern_starts(input, pattern))
5156    }
5157
5158    fn code_span_close(&mut self, start: usize, len: usize) -> Option<usize> {
5159        let input = self.input;
5160        let runs = self
5161            .backtick_runs
5162            .get_or_insert_with(|| backtick_runs_by_length(input));
5163        let starts = runs.get(&len)?;
5164        starts
5165            .get(starts.partition_point(|run| *run < start))
5166            .copied()
5167    }
5168
5169    fn angle_destination_stop(&mut self, from: usize) -> Option<usize> {
5170        let input = self.input;
5171        self.angle_destination_stops.first_at_or_after(from, || {
5172            (0..input.len())
5173                .filter(|index| is_angle_destination_stop(input, *index))
5174                .collect()
5175        })
5176    }
5177}
5178
5179/// Start offsets of every maximal backtick run, grouped by run length.
5180fn backtick_runs_by_length(input: &str) -> BTreeMap<usize, Vec<usize>> {
5181    let mut runs: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
5182    let mut index = 0;
5183    while index < input.len() {
5184        if input.as_bytes()[index] == b'`' {
5185            let len = backtick_run_len(input, index);
5186            runs.entry(len).or_default().push(index);
5187            index += len;
5188        } else {
5189            index += 1;
5190        }
5191    }
5192    runs
5193}
5194
5195fn backtick_run_len(input: &str, index: usize) -> usize {
5196    input.as_bytes()[index..]
5197        .iter()
5198        .take_while(|byte| **byte == b'`')
5199        .count()
5200}
5201
5202fn is_angle_destination_stop(input: &str, index: usize) -> bool {
5203    match input.as_bytes()[index] {
5204        b'<' | b'>' => !is_escaped_at(input, index),
5205        b'\n' | b'\r' => true,
5206        _ => false,
5207    }
5208}
5209
5210/// Everything one `parse_inline_content` pass memoizes about its input, so its
5211/// questions about closing delimiters cost amortized linear time in total.
5212struct InlineScan<'a> {
5213    input: &'a str,
5214    lookups: InlineLookups<'a>,
5215    label_ends: BracketMemo,
5216    reference_label_ends: PathMemo,
5217    wikilink_closes: PathMemo,
5218    directive_attribute_closes: PathMemo,
5219    mdx_expression_closes: BracketMemo,
5220    jsx: Option<JsxIndex>,
5221    single_tilde_delete_closes: Positions,
5222    literal_autolinks: LiteralAutolinkScan,
5223}
5224
5225impl<'a> InlineScan<'a> {
5226    fn new(input: &'a str) -> Self {
5227        Self {
5228            input,
5229            lookups: InlineLookups::new(input),
5230            label_ends: BracketMemo::default(),
5231            reference_label_ends: PathMemo::default(),
5232            wikilink_closes: PathMemo::default(),
5233            directive_attribute_closes: PathMemo::default(),
5234            mdx_expression_closes: BracketMemo::default(),
5235            jsx: None,
5236            single_tilde_delete_closes: Positions::default(),
5237            literal_autolinks: LiteralAutolinkScan::default(),
5238        }
5239    }
5240
5241    /// `find_link_label_end`, memoized.
5242    fn link_label_end(&mut self, open: usize) -> Option<usize> {
5243        let input = self.input;
5244        if input.as_bytes().get(open) != Some(&b'[') {
5245            return None;
5246        }
5247        let lookups = &mut self.lookups;
5248        self.label_ends
5249            .resolve(input.len() + 1, open + 1, |cursor| {
5250                link_label_step(lookups, input, cursor)
5251            })
5252    }
5253
5254    /// `find_reference_label_end`, memoized.
5255    fn reference_label_end(&mut self, open: usize) -> Option<usize> {
5256        let input = self.input;
5257        if input.as_bytes().get(open) != Some(&b'[') {
5258            return None;
5259        }
5260        let close = self
5261            .reference_label_ends
5262            .resolve(input.len() + 1, open + 1, |cursor| {
5263                reference_label_step(input, cursor)
5264            })?;
5265        reference_label_is_within_limit(&input[open + 1..close]).then_some(close)
5266    }
5267
5268    /// The `]]` closing the wikilink whose content starts at `start`; it must
5269    /// be on the same line.
5270    fn wikilink_close(&mut self, start: usize) -> Option<usize> {
5271        let input = self.input;
5272        self.wikilink_closes
5273            .resolve(input.len() + 1, start, |cursor| {
5274                wikilink_close_step(input, cursor)
5275            })
5276    }
5277
5278    /// The `}` closing the inline MDX expression opening at `open`.
5279    fn mdx_expression_close(&mut self, open: usize) -> Option<usize> {
5280        let input = self.input;
5281        if input.as_bytes().get(open) != Some(&b'{') {
5282            return None;
5283        }
5284        self.mdx_expression_closes
5285            .resolve(
5286                (input.len() + 1) * MDX_BRACE_STATES,
5287                (open + 1) * MDX_BRACE_STATES,
5288                |node| mdx_expression_step(input, node, false),
5289            )
5290            .map(|node| node / MDX_BRACE_STATES)
5291    }
5292
5293    /// The end of the inline MDX JSX element opening at `start`.
5294    fn mdx_jsx_end(&mut self, start: usize) -> Option<usize> {
5295        let input = self.input;
5296        let expressions = &mut self.mdx_expression_closes;
5297        self.jsx
5298            .get_or_insert_with(|| JsxIndex::new(input, false, expressions))
5299            .element_end(start)
5300            .map(|end| end + 1)
5301    }
5302
5303    /// The first `~` at or after `start` that can close a single-tilde delete.
5304    fn single_tilde_delete_close(&mut self, start: usize) -> Option<usize> {
5305        let input = self.input;
5306        self.single_tilde_delete_closes
5307            .first_at_or_after(start, || {
5308                (0..input.len())
5309                    .filter(|index| is_single_tilde_delete_close(input, *index))
5310                    .collect()
5311            })
5312    }
5313
5314    /// `find_directive_attributes_close`, memoized.
5315    fn directive_attributes_close(&mut self, open: usize) -> Option<usize> {
5316        let input = self.input;
5317        if input.as_bytes().get(open) != Some(&b'{') {
5318            return None;
5319        }
5320        self.directive_attribute_closes.resolve(
5321            (input.len() + 1) * DIRECTIVE_QUOTE_STATES,
5322            (open + 1) * DIRECTIVE_QUOTE_STATES,
5323            |node| directive_attributes_step(input, node),
5324        )
5325    }
5326}
5327
5328fn parse_inlines(
5329    input: &str,
5330    base_offset: usize,
5331    options: &SyntaxOptions,
5332    definitions: &[String],
5333    diagnostics: &mut Vec<Diagnostic>,
5334) -> Vec<Inline> {
5335    parse_inlines_with_context(
5336        input,
5337        base_offset,
5338        options,
5339        definitions,
5340        diagnostics,
5341        &mut InlineState::default(),
5342    )
5343}
5344
5345/// The deepest inline nesting the parser builds. Directive labels parse their
5346/// content one inline pass deeper; within a pass, open brackets and every
5347/// emphasis or mark span each add a level. Content past this depth stays
5348/// literal text, so recursion and the native stack it uses stay bounded.
5349const MAX_INLINE_NESTING: usize = 32;
5350
5351/// State shared by every inline parse nested under one block-level inline
5352/// parse. All nested inputs are slices of that one input, addressed by their
5353/// absolute start offset.
5354#[derive(Default)]
5355struct InlineState {
5356    /// How many inline parses enclose the current one.
5357    depth: usize,
5358}
5359
5360/// What one inline pass threads through its construct parsers: the state it
5361/// shares with enclosing passes and the memoized scans of its own input.
5362struct InlinePass<'p, 'a> {
5363    state: &'p mut InlineState,
5364    scan: InlineScan<'a>,
5365}
5366
5367fn parse_inlines_with_context(
5368    input: &str,
5369    base_offset: usize,
5370    options: &SyntaxOptions,
5371    definitions: &[String],
5372    diagnostics: &mut Vec<Diagnostic>,
5373    state: &mut InlineState,
5374) -> Vec<Inline> {
5375    if state.depth >= MAX_INLINE_NESTING {
5376        if input.is_empty() {
5377            return Vec::new();
5378        }
5379        return vec![Inline::Text(Text {
5380            meta: NodeMeta::new(Some(Span::new(base_offset, base_offset + input.len()))),
5381            value: input.into(),
5382        })];
5383    }
5384    state.depth += 1;
5385    let nodes = parse_inline_content(input, base_offset, options, definitions, diagnostics, state);
5386    state.depth -= 1;
5387    nodes
5388}
5389
5390fn parse_inline_content(
5391    input: &str,
5392    base_offset: usize,
5393    options: &SyntaxOptions,
5394    definitions: &[String],
5395    diagnostics: &mut Vec<Diagnostic>,
5396    state: &mut InlineState,
5397) -> Vec<Inline> {
5398    let bytes = input.as_bytes();
5399    let mut nodes = Vec::new();
5400    let mut text_start = 0;
5401    let mut text = String::new();
5402    let mut index = 0;
5403    // Emphasis and mark delimiters are resolved with a delimiter stack after the
5404    // scan completes. During the scan we emit each candidate delimiter run as a
5405    // literal text node and record it here so `process_emphasis` can rewrite the
5406    // flat node list into emphasis and mark nodes (or leave it as text).
5407    let mut delimiters: Vec<DelimMarker> = Vec::new();
5408    let mut lines = LineCounter::default();
5409    let mut brackets = Brackets::new();
5410    // The run and offset of the `^` a superscript is waiting to close.
5411    let mut superscript_open: Option<(usize, usize)> = None;
5412    let mut pass = InlinePass {
5413        state,
5414        scan: InlineScan::new(input),
5415    };
5416
5417    while index < bytes.len() {
5418        if bytes[index] == b'\\' {
5419            if let Some((next_index, char)) = next_char(input, index + 1) {
5420                if char.is_ascii_punctuation() {
5421                    if options.parse.preserve_character_escapes {
5422                        flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5423                        nodes.push(Inline::Escape(Escape {
5424                            meta: NodeMeta::new(Some(Span::new(
5425                                base_offset + index,
5426                                base_offset + next_index,
5427                            ))),
5428                            value: char,
5429                        }));
5430                        index = next_index;
5431                        text_start = index;
5432                        continue;
5433                    }
5434                    if text.is_empty() {
5435                        text_start = base_offset + index;
5436                    }
5437                    text.push(char);
5438                    index = next_index;
5439                    continue;
5440                }
5441            }
5442        }
5443
5444        if bytes[index] == b'&' {
5445            if let Some((end, value)) = parse_character_reference(input, index) {
5446                if options.parse.preserve_character_references {
5447                    flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5448                    nodes.push(Inline::CharacterReference(CharacterReference {
5449                        meta: NodeMeta::new(Some(Span::new(
5450                            base_offset + index,
5451                            base_offset + end,
5452                        ))),
5453                        reference: input[index..end].into(),
5454                        value,
5455                    }));
5456                    index = end;
5457                    text_start = index;
5458                    continue;
5459                }
5460                if text.is_empty() {
5461                    text_start = base_offset + index;
5462                }
5463                text.push_str(&value);
5464                index = end;
5465                continue;
5466            }
5467        }
5468
5469        if bytes[index] == b'\n' {
5470            if text.ends_with('\\') {
5471                text.pop();
5472                flush_text(
5473                    &mut nodes,
5474                    &mut text,
5475                    text_start,
5476                    base_offset + index.saturating_sub(1),
5477                );
5478                nodes.push(Inline::LineBreak(LineBreak {
5479                    meta: NodeMeta::new(Some(Span::new(
5480                        base_offset + index.saturating_sub(1),
5481                        base_offset + index + 1,
5482                    ))),
5483                    kind: LineBreakKind::Backslash,
5484                }));
5485                index += 1;
5486                text_start = index;
5487                continue;
5488            }
5489            let trailing_spaces = trailing_space_count(&text);
5490            if is_hard_break_suffix(&text, trailing_spaces) {
5491                text.truncate(text.len() - trailing_spaces);
5492                flush_text(
5493                    &mut nodes,
5494                    &mut text,
5495                    text_start,
5496                    base_offset + index.saturating_sub(trailing_spaces),
5497                );
5498                nodes.push(Inline::LineBreak(LineBreak {
5499                    meta: NodeMeta::new(Some(Span::new(
5500                        base_offset + index.saturating_sub(trailing_spaces),
5501                        base_offset + index + 1,
5502                    ))),
5503                    kind: LineBreakKind::Spaces,
5504                }));
5505                index += 1;
5506                text_start = index;
5507                continue;
5508            }
5509            if trailing_spaces > 0 {
5510                text.truncate(text.len() - trailing_spaces);
5511            }
5512            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5513            nodes.push(Inline::SoftBreak(SoftBreak {
5514                meta: NodeMeta::new(Some(Span::new(
5515                    base_offset + index,
5516                    base_offset + index + 1,
5517                ))),
5518            }));
5519            index += 1;
5520            text_start = index;
5521            continue;
5522        }
5523
5524        if bytes[index] == b'`' {
5525            if let Some((end, code_span)) =
5526                parse_code_span_with(&mut pass.scan.lookups, input, index)
5527            {
5528                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5529                nodes.push(Inline::Code(CodeInline {
5530                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
5531                    value: code_span.value,
5532                    raw: code_span.raw,
5533                    fence_length: code_span.fence_length,
5534                }));
5535                index = end;
5536                text_start = index;
5537                continue;
5538            } else {
5539                // No matching-length close for this opening backtick run:
5540                // CommonMark renders the whole run as literal text. Consume the
5541                // entire run here so the loop does not advance one byte and retry
5542                // a shorter sub-run that could spuriously match a shorter close
5543                // (```foo`` stayed a phantom 2-backtick code span).
5544                let run = bytes[index..]
5545                    .iter()
5546                    .take_while(|byte| **byte == b'`')
5547                    .count();
5548                if text.is_empty() {
5549                    text_start = base_offset + index;
5550                }
5551                for _ in 0..run {
5552                    text.push('`');
5553                }
5554                index += run;
5555                continue;
5556            }
5557        }
5558
5559        if options.constructs.spoiler && bytes[index] == b'|' {
5560            let run_len = delimiter_byte_run_len(input, index, b'|');
5561            if run_len >= 2 {
5562                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5563                // A spoiler run opens with its last two bars and closes with
5564                // its first two, unless a bar precedes it.
5565                let roles = DelimRoles {
5566                    can_open: true,
5567                    can_close: index == 0 || bytes[index - 1] != b'|',
5568                    ..DelimRoles::default()
5569                };
5570                push_delimiter(
5571                    &mut nodes,
5572                    &mut delimiters,
5573                    &mut lines,
5574                    input,
5575                    index,
5576                    base_offset,
5577                    b'|',
5578                    run_len,
5579                    roles,
5580                );
5581                index += run_len;
5582                text_start = index;
5583                continue;
5584            }
5585        }
5586
5587        if bytes[index] == b'*' && delimiter_byte_run_start(input, index, b'*') == index {
5588            let run_len = delimiter_byte_run_len(input, index, b'*');
5589            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5590            // Roles are settled by `assign_emphasis_roles`.
5591            let roles = DelimRoles::default();
5592            push_delimiter(
5593                &mut nodes,
5594                &mut delimiters,
5595                &mut lines,
5596                input,
5597                index,
5598                base_offset,
5599                b'*',
5600                run_len,
5601                roles,
5602            );
5603            index += run_len;
5604            text_start = index;
5605            continue;
5606        }
5607
5608        // Core `_` emphasis/strong is resolved by the delimiter stack, just like
5609        // `*`; with underline enabled, a `__` pair forms `Underline` instead of
5610        // `Strong`.
5611        if bytes[index] == b'_' && delimiter_byte_run_start(input, index, b'_') == index {
5612            // A leading `_` can begin a GFM email local part (`_a@b.c`); try the
5613            // literal autolink before recording the `_` as an emphasis
5614            // delimiter, otherwise the `_` would be consumed and the email would
5615            // wrongly start one char later (where its left boundary fails).
5616            if options.constructs.gfm_autolink_literal || options.constructs.relaxed_autolinks {
5617                if let Some((end, destination)) = parse_literal_autolink(
5618                    input,
5619                    index,
5620                    options.constructs.gfm_autolink_literal,
5621                    options.constructs.relaxed_autolinks,
5622                    &mut pass.scan.literal_autolinks,
5623                ) {
5624                    flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5625                    nodes.push(Inline::Autolink(Autolink {
5626                        meta: NodeMeta::new(Some(Span::new(
5627                            base_offset + index,
5628                            base_offset + end,
5629                        ))),
5630                        destination,
5631                        kind: AutolinkKind::GfmLiteral {
5632                            original: input[index..end].into(),
5633                        },
5634                    }));
5635                    index = end;
5636                    text_start = index;
5637                    continue;
5638                }
5639            }
5640            let run_len = delimiter_byte_run_len(input, index, b'_');
5641            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5642            // Roles are settled by `assign_emphasis_roles`.
5643            let roles = DelimRoles::default();
5644            push_delimiter(
5645                &mut nodes,
5646                &mut delimiters,
5647                &mut lines,
5648                input,
5649                index,
5650                base_offset,
5651                b'_',
5652                run_len,
5653                roles,
5654            );
5655            index += run_len;
5656            text_start = index;
5657            continue;
5658        }
5659
5660        if (options.constructs.insert && bytes[index] == b'+')
5661            || (options.constructs.highlight && bytes[index] == b'=')
5662        {
5663            let marker = bytes[index];
5664            let run_len = delimiter_byte_run_len(input, index, marker);
5665            if run_len >= 2 {
5666                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5667                let roles = double_mark_roles(input, index, run_len, marker);
5668                push_delimiter(
5669                    &mut nodes,
5670                    &mut delimiters,
5671                    &mut lines,
5672                    input,
5673                    index,
5674                    base_offset,
5675                    marker,
5676                    run_len,
5677                    roles,
5678                );
5679                index += run_len;
5680                text_start = index;
5681                continue;
5682            }
5683        }
5684
5685        let bracket_room = pass.state.depth - 1 + brackets.openers.len() < MAX_INLINE_NESTING;
5686
5687        // A `^` that a superscript on its line is waiting for closes it, even
5688        // before a `[`.
5689        let closes_superscript = options.constructs.superscript
5690            && bytes[index] == b'^'
5691            && superscript_open.is_some_and(|(run, position)| {
5692                // A run taken into or dropped with a bracket label is gone.
5693                delimiters.get(run).is_some_and(|run| {
5694                    run.position == position && run.line == lines.line_at(input, index)
5695                }) && position + 1 != index
5696            });
5697
5698        // `^[` opens an inline footnote. Its `^` is recorded as a dormant
5699        // superscript run, woken if the footnote does not form.
5700        if options.constructs.inline_footnote
5701            && options.constructs.footnote_reference
5702            && bracket_room
5703            && !closes_superscript
5704            && bytes[index] == b'^'
5705            && bytes.get(index + 1) == Some(&b'[')
5706        {
5707            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5708            let caret = if options.constructs.superscript {
5709                push_delimiter(
5710                    &mut nodes,
5711                    &mut delimiters,
5712                    &mut lines,
5713                    input,
5714                    index,
5715                    base_offset,
5716                    b'^',
5717                    1,
5718                    DelimRoles::default(),
5719                );
5720                Some(delimiters.len() - 1)
5721            } else {
5722                push_text(&mut nodes, base_offset + index, "^");
5723                None
5724            };
5725            push_text(&mut nodes, base_offset + index + 1, "[");
5726            brackets.openers.push(BracketOpener {
5727                kind: BracketKind::InlineFootnote,
5728                node_index: nodes.len() - 1,
5729                position: index,
5730                delimiter_bottom: delimiters.len(),
5731                caret,
5732                wikilink: None,
5733            });
5734            index += 2;
5735            text_start = index;
5736            continue;
5737        }
5738
5739        // A `^` opens a superscript unless it starts an inline footnote, and
5740        // closes one at the next `^` on its line.
5741        if options.constructs.superscript && bytes[index] == b'^' {
5742            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5743            let roles = DelimRoles {
5744                single_open: !(options.constructs.inline_footnote
5745                    && bytes.get(index + 1) == Some(&b'[')),
5746                single_close: true,
5747                ..DelimRoles::default()
5748            };
5749            push_delimiter(
5750                &mut nodes,
5751                &mut delimiters,
5752                &mut lines,
5753                input,
5754                index,
5755                base_offset,
5756                b'^',
5757                1,
5758                roles,
5759            );
5760            // Track the superscript this run leaves open, as the stack will
5761            // pair it, so a later `^[` knows whether its `^` closes one.
5762            superscript_open = if !closes_superscript && roles.single_open {
5763                Some((delimiters.len() - 1, index))
5764            } else {
5765                None
5766            };
5767            index += 1;
5768            text_start = index;
5769            continue;
5770        }
5771
5772        // A `~` run is recorded once for both of its possible roles: GFM
5773        // strikethrough pairs runs of equal length (2, or 1 in single-tilde
5774        // mode) like `*`/`_`; a subscript opens with a lone `~` and closes at
5775        // the first `~` that follows on its line.
5776        if (options.constructs.gfm_strikethrough || options.constructs.subscript)
5777            && bytes[index] == b'~'
5778        {
5779            let run_len = delimiter_byte_run_len(input, index, b'~');
5780            let mut roles = DelimRoles::default();
5781            if options.constructs.gfm_strikethrough
5782                && delimiter_byte_run_start(input, index, b'~') == index
5783                && (run_len == 2 || (run_len == 1 && options.parse.single_tilde_strikethrough))
5784            {
5785                roles.strike = true;
5786            }
5787            if options.constructs.subscript {
5788                roles.single_open = starts_exact_byte_run(input, index, b'~', 1)
5789                    && !single_tilde_delete_takes_precedence(options, input, index, &mut pass.scan);
5790                roles.single_close = true;
5791            }
5792            if roles.strike || roles.single_open || roles.single_close {
5793                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5794                push_delimiter(
5795                    &mut nodes,
5796                    &mut delimiters,
5797                    &mut lines,
5798                    input,
5799                    index,
5800                    base_offset,
5801                    b'~',
5802                    run_len,
5803                    roles,
5804                );
5805                index += run_len;
5806                text_start = index;
5807                continue;
5808            }
5809        }
5810
5811        if bytes[index] == b'!' && bytes.get(index + 1) == Some(&b'[') && bracket_room {
5812            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5813            let wikilink = parse_wikilink(input, index + 1, base_offset, options, &mut pass.scan);
5814            if let Some((end, wikilink)) = wikilink.clone() {
5815                // An image label whose brackets never balance cannot close, so
5816                // the wikilink at its `[` wins outright.
5817                if pass.scan.link_label_end(index + 1).is_none() {
5818                    push_text(&mut nodes, base_offset + index, "!");
5819                    nodes.push(wikilink);
5820                    index = end;
5821                    text_start = index;
5822                    continue;
5823                }
5824            }
5825            push_text(&mut nodes, base_offset + index, "!");
5826            push_text(&mut nodes, base_offset + index + 1, "[");
5827            brackets.openers.push(BracketOpener {
5828                kind: BracketKind::Image,
5829                node_index: nodes.len() - 1,
5830                position: index,
5831                delimiter_bottom: delimiters.len(),
5832                caret: None,
5833                wikilink,
5834            });
5835            index += 2;
5836            text_start = index;
5837            continue;
5838        }
5839
5840        if bytes[index] == b'[' {
5841            if let Some((end, wikilink)) =
5842                parse_wikilink(input, index, base_offset, options, &mut pass.scan)
5843            {
5844                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5845                nodes.push(wikilink);
5846                index = end;
5847                text_start = index;
5848                continue;
5849            }
5850            if bracket_room {
5851                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5852                push_text(&mut nodes, base_offset + index, "[");
5853                brackets.openers.push(BracketOpener {
5854                    kind: BracketKind::Link,
5855                    node_index: nodes.len() - 1,
5856                    position: index,
5857                    delimiter_bottom: delimiters.len(),
5858                    caret: None,
5859                    wikilink: None,
5860                });
5861                index += 1;
5862                text_start = index;
5863                continue;
5864            }
5865            brackets.overflow += 1;
5866        }
5867
5868        if bytes[index] == b']' && brackets.overflow > 0 {
5869            // Closes a `[` left as text past the nesting limit.
5870            brackets.overflow -= 1;
5871        } else if bytes[index] == b']' && !brackets.openers.is_empty() {
5872            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5873            if let Some(end) = close_bracket(
5874                input,
5875                base_offset,
5876                index,
5877                options,
5878                definitions,
5879                &mut nodes,
5880                &mut delimiters,
5881                &mut brackets,
5882                &mut pass,
5883            ) {
5884                index = end;
5885                text_start = index;
5886                continue;
5887            }
5888        }
5889
5890        if bytes[index] == b'$' && options.constructs.math_inline {
5891            if let Some((end, value, kind)) =
5892                parse_math_inline(&mut pass.scan.lookups, input, index)
5893            {
5894                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5895                nodes.push(Inline::Math(MathInline {
5896                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
5897                    value,
5898                    kind,
5899                }));
5900                index = end;
5901                text_start = index;
5902                continue;
5903            }
5904            // A dollar run that opens but finds no exact-length close is emitted
5905            // as literal text in one piece (like a code-span). Skipping the
5906            // whole run prevents re-opening with a shorter marker inside it, so
5907            // `$$$foo$$` stays literal rather than matching `$$foo$$`. A lone
5908            // `$` before a backtick (the code-math form) is a run of 1, so this
5909            // still advances correctly when that form fails.
5910            let run = bytes[index..]
5911                .iter()
5912                .take_while(|byte| **byte == b'$')
5913                .count();
5914            if run > 1 {
5915                if text.is_empty() {
5916                    text_start = base_offset + index;
5917                }
5918                text.push_str(&input[index..index + run]);
5919                index += run;
5920                continue;
5921            }
5922        }
5923
5924        // A bare URL is an autolink even inside an open bracket; if the bracket
5925        // forms a link, `demote_links` turns it back into text.
5926        if options.constructs.gfm_autolink_literal || options.constructs.relaxed_autolinks {
5927            if let Some((end, destination)) = parse_literal_autolink(
5928                input,
5929                index,
5930                options.constructs.gfm_autolink_literal,
5931                options.constructs.relaxed_autolinks,
5932                &mut pass.scan.literal_autolinks,
5933            ) {
5934                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5935                nodes.push(Inline::Autolink(Autolink {
5936                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
5937                    destination,
5938                    kind: AutolinkKind::GfmLiteral {
5939                        original: input[index..end].into(),
5940                    },
5941                }));
5942                index = end;
5943                text_start = index;
5944                continue;
5945            }
5946        }
5947
5948        if bytes[index] == b'<' {
5949            if let Some(end) = pass.scan.lookups.find(">", index).map(|close| close + 1) {
5950                let raw = &input[index..end];
5951                if is_autolink(raw) {
5952                    flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5953                    nodes.push(Inline::Autolink(Autolink {
5954                        meta: NodeMeta::new(Some(Span::new(
5955                            base_offset + index,
5956                            base_offset + end,
5957                        ))),
5958                        destination: raw[1..raw.len() - 1].into(),
5959                        kind: AutolinkKind::Angle,
5960                    }));
5961                    index = end;
5962                    text_start = index;
5963                    continue;
5964                }
5965            }
5966            if options.constructs.mdx_jsx_inline {
5967                if let Some((end, raw)) = pass
5968                    .scan
5969                    .mdx_jsx_end(index)
5970                    .map(|end| (end, String::from(&input[index..end])))
5971                {
5972                    flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5973                    nodes.push(Inline::MdxJsx(MdxJsxInline {
5974                        meta: NodeMeta::new(Some(Span::new(
5975                            base_offset + index,
5976                            base_offset + end,
5977                        ))),
5978                        value: raw,
5979                    }));
5980                    index = end;
5981                    text_start = index;
5982                    continue;
5983                }
5984            }
5985            if options.constructs.html_inline {
5986                if let Some(end) = html_inline_end(&mut pass.scan.lookups, input, index) {
5987                    flush_text(&mut nodes, &mut text, text_start, base_offset + index);
5988                    nodes.push(Inline::Html(HtmlInline {
5989                        meta: NodeMeta::new(Some(Span::new(
5990                            base_offset + index,
5991                            base_offset + end,
5992                        ))),
5993                        value: input[index..end].into(),
5994                    }));
5995                    index = end;
5996                    text_start = index;
5997                    continue;
5998                }
5999            }
6000        }
6001
6002        if bytes[index] == b'{' && options.constructs.mdx_expression_inline {
6003            if let Some(end) = pass.scan.mdx_expression_close(index) {
6004                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
6005                nodes.push(Inline::MdxExpression(MdxExpressionInline {
6006                    meta: NodeMeta::new(Some(Span::new(
6007                        base_offset + index,
6008                        base_offset + end + 1,
6009                    ))),
6010                    value: input[index + 1..end].into(),
6011                }));
6012                index = end + 1;
6013                text_start = index;
6014                continue;
6015            } else {
6016                diagnostics.push(Diagnostic::new(
6017                    DiagnosticSeverity::Error,
6018                    DiagnosticCode::InvalidMdx,
6019                    Span::new(base_offset + index, base_offset + input.len()),
6020                    "MDX expression is missing a closing brace",
6021                ));
6022            }
6023        }
6024
6025        if bytes[index] == b':' && options.constructs.shortcode {
6026            if let Some((end, name)) = parse_shortcode(input, index) {
6027                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
6028                nodes.push(Inline::Shortcode(Shortcode {
6029                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
6030                    name,
6031                }));
6032                index = end;
6033                text_start = index;
6034                continue;
6035            }
6036        }
6037
6038        // A directive counts one nesting level for itself, on top of the
6039        // enclosing passes and open brackets.
6040        if bytes[index] == b':'
6041            && options.constructs.directive_text
6042            && pass.state.depth + brackets.openers.len() <= MAX_INLINE_NESTING
6043        {
6044            // The label parses one pass deeper than the open brackets around
6045            // it.
6046            pass.state.depth += brackets.openers.len();
6047            let directive = parse_text_directive(
6048                input,
6049                index,
6050                base_offset,
6051                options,
6052                definitions,
6053                diagnostics,
6054                &mut pass,
6055            );
6056            pass.state.depth -= brackets.openers.len();
6057            if let Some((end, directive)) = directive {
6058                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
6059                if let Inline::TextDirective(node) = &directive {
6060                    // A link inside the directive label keeps open brackets
6061                    // from forming links around it.
6062                    if contains_link_inline(&node.label) {
6063                        brackets.close_links();
6064                    }
6065                    let depth = inline_nesting(&node.label) + 1;
6066                    brackets.formed.push((nodes.len(), depth));
6067                }
6068                nodes.push(directive);
6069                index = end;
6070                text_start = index;
6071                continue;
6072            }
6073        }
6074
6075        let (next_index, char) = next_char(input, index).expect("valid UTF-8 byte index");
6076        if text.is_empty() {
6077            text_start = base_offset + index;
6078        }
6079        text.push(char);
6080        index = next_index;
6081    }
6082
6083    flush_text(&mut nodes, &mut text, text_start, base_offset + input.len());
6084    // An inline footnote that never closed leaves its `^` free to close a
6085    // superscript.
6086    for opener in &brackets.openers {
6087        if let Some(caret) = opener.caret {
6088            delimiters[caret].single_close = true;
6089        }
6090    }
6091    assign_emphasis_roles(
6092        input,
6093        &mut delimiters,
6094        options.constructs.gfm_strikethrough,
6095        options.constructs.underline,
6096    );
6097    process_emphasis(
6098        nodes,
6099        delimiters,
6100        EmphasisContext {
6101            underline: options.constructs.underline,
6102            depth: pass.state.depth - 1,
6103        },
6104        &brackets.formed,
6105    )
6106    .0
6107}
6108
6109fn parse_shortcode(input: &str, index: usize) -> Option<(usize, String)> {
6110    if input[index..].starts_with("::") {
6111        return None;
6112    }
6113
6114    let mut cursor = index + 1;
6115    while let Some((next, char)) = next_char(input, cursor) {
6116        if char == ':' {
6117            if cursor == index + 1 {
6118                return None;
6119            }
6120            return Some((next, input[index + 1..cursor].into()));
6121        }
6122        if !(char.is_ascii_alphanumeric() || matches!(char, '_' | '-' | '+')) {
6123            return None;
6124        }
6125        cursor = next;
6126    }
6127    None
6128}
6129
6130fn parse_wikilink(
6131    input: &str,
6132    index: usize,
6133    base_offset: usize,
6134    options: &SyntaxOptions,
6135    scan: &mut InlineScan,
6136) -> Option<(usize, Inline)> {
6137    let configured_order = if options.constructs.wikilink_title_after_pipe {
6138        WikiLinkLabelOrder::AfterPipe
6139    } else if options.constructs.wikilink_title_before_pipe {
6140        WikiLinkLabelOrder::BeforePipe
6141    } else {
6142        return None;
6143    };
6144    if input.as_bytes().get(index) != Some(&b'[') || input.as_bytes().get(index + 1) != Some(&b'[')
6145    {
6146        return None;
6147    }
6148
6149    let close = scan.wikilink_close(index + 2)?;
6150    let source = &input[index + 2..close];
6151    if source.is_empty() || source.len() > WIKILINK_MAX_BYTES {
6152        return None;
6153    }
6154
6155    let (target_source, label_source, label_order) =
6156        if let Some(separator) = find_wikilink_separator(source) {
6157            match configured_order {
6158                WikiLinkLabelOrder::AfterPipe => (
6159                    &source[..separator],
6160                    &source[separator + 1..],
6161                    WikiLinkLabelOrder::AfterPipe,
6162                ),
6163                WikiLinkLabelOrder::BeforePipe => (
6164                    &source[separator + 1..],
6165                    &source[..separator],
6166                    WikiLinkLabelOrder::BeforePipe,
6167                ),
6168            }
6169        } else {
6170            (source, source, configured_order)
6171        };
6172
6173    let target = unescape_string(target_source);
6174    if target.is_empty() {
6175        return None;
6176    }
6177    let label = unescape_string(label_source);
6178    let end = close + 2;
6179    Some((
6180        end,
6181        Inline::WikiLink(WikiLink {
6182            meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
6183            target,
6184            label,
6185            label_order,
6186        }),
6187    ))
6188}
6189
6190/// One step of the walk to a wikilink's closing `]]`, which must be on the
6191/// opener's line.
6192fn wikilink_close_step(input: &str, cursor: usize) -> Step {
6193    let bytes = input.as_bytes();
6194    match bytes.get(cursor) {
6195        None | Some(b'\n' | b'\r') => Step::Done(None),
6196        Some(b'\\') => {
6197            let escaped = cursor + 1;
6198            Step::Next(next_char(input, escaped).map_or(escaped, |(after_escape, _)| after_escape))
6199        }
6200        Some(b']') if bytes.get(cursor + 1) == Some(&b']') => Step::Done(Some(cursor)),
6201        Some(_) => Step::Next(next_char(input, cursor).map_or(input.len(), |(next, _)| next)),
6202    }
6203}
6204
6205fn find_wikilink_separator(input: &str) -> Option<usize> {
6206    let bytes = input.as_bytes();
6207    let mut cursor = 0;
6208    while cursor < input.len() {
6209        match bytes[cursor] {
6210            b'\\' => {
6211                cursor += 1;
6212                if cursor < input.len() {
6213                    cursor = next_char(input, cursor)?.0;
6214                }
6215            }
6216            b'|' => return Some(cursor),
6217            _ => cursor = next_char(input, cursor)?.0,
6218        }
6219    }
6220    None
6221}
6222
6223fn trailing_space_count(input: &str) -> usize {
6224    input
6225        .as_bytes()
6226        .iter()
6227        .rev()
6228        .take_while(|byte| matches!(**byte, b' ' | b'\t'))
6229        .count()
6230}
6231
6232fn is_hard_break_suffix(input: &str, trailing: usize) -> bool {
6233    // A hard line break is two or more spaces immediately before the newline
6234    // with no intervening tab; a tab anywhere in the trailing whitespace run
6235    // demotes it to a soft break.
6236    let bytes = input.as_bytes();
6237    trailing >= 2
6238        && bytes[bytes.len() - trailing..]
6239            .iter()
6240            .all(|byte| *byte == b' ')
6241}
6242
6243fn find_reference_label_end(input: &str, open: usize) -> Option<usize> {
6244    // A reference/definition link label does not nest: it ends at the first
6245    // unescaped `]`, and an unescaped interior `[` disqualifies it.
6246    if input.as_bytes().get(open) != Some(&b'[') {
6247        return None;
6248    }
6249    let close = path_walk(open + 1, |cursor| reference_label_step(input, cursor))?;
6250    reference_label_is_within_limit(&input[open + 1..close]).then_some(close)
6251}
6252
6253/// One step of the walk to a reference label's closing `]`.
6254fn reference_label_step(input: &str, cursor: usize) -> Step {
6255    let Some((next, char)) = next_char(input, cursor) else {
6256        return Step::Done(None);
6257    };
6258    match char {
6259        '\\' => Step::Next(next_char(input, next).map_or(next, |(after_escape, _)| after_escape)),
6260        '[' => Step::Done(None),
6261        ']' => Step::Done(Some(cursor)),
6262        _ => Step::Next(next),
6263    }
6264}
6265
6266/// How many container levels `inlines` nest, counting empty containers.
6267fn inline_nesting(inlines: &[Inline]) -> usize {
6268    let mut deepest = 0;
6269    let mut pending = vec![(inlines, 0)];
6270    while let Some((nodes, depth)) = pending.pop() {
6271        for node in nodes {
6272            if is_inline_container(node) {
6273                deepest = deepest.max(depth + 1);
6274                pending.push((node.children(), depth + 1));
6275            }
6276        }
6277    }
6278    deepest
6279}
6280
6281fn is_inline_container(inline: &Inline) -> bool {
6282    matches!(
6283        inline,
6284        Inline::Emphasis(_)
6285            | Inline::Strong(_)
6286            | Inline::Underline(_)
6287            | Inline::Delete(_)
6288            | Inline::Insert(_)
6289            | Inline::Mark(_)
6290            | Inline::Subscript(_)
6291            | Inline::Superscript(_)
6292            | Inline::Spoiler(_)
6293            | Inline::Link(_)
6294            | Inline::Image(_)
6295            | Inline::LinkReference(_)
6296            | Inline::ImageReference(_)
6297            | Inline::InlineFootnote(_)
6298            | Inline::TextDirective(_)
6299    )
6300}
6301
6302fn contains_link_inline(inlines: &[Inline]) -> bool {
6303    inlines.iter().any(|inline| {
6304        matches!(inline, Inline::Link(_) | Inline::LinkReference(_))
6305            || contains_link_inline(inline.children())
6306    })
6307}
6308
6309fn find_link_label_end(input: &str, open: usize) -> Option<usize> {
6310    if input.as_bytes().get(open) != Some(&b'[') {
6311        return None;
6312    }
6313    let mut lookups = DirectLookups { input };
6314    bracket_walk(open + 1, |cursor| {
6315        link_label_step(&mut lookups, input, cursor)
6316    })
6317}
6318
6319/// One step of the walk to a link label's closing `]`: escapes, code spans,
6320/// autolinks, and inline HTML are stepped over whole, so brackets inside them
6321/// do not count; other brackets nest.
6322fn link_label_step(lookups: &mut impl Lookups, input: &str, cursor: usize) -> BracketStep {
6323    let Some((next, char)) = next_char(input, cursor) else {
6324        return BracketStep::End;
6325    };
6326    match char {
6327        '\\' => {
6328            BracketStep::Pass(next_char(input, next).map_or(next, |(after_escape, _)| after_escape))
6329        }
6330        '`' => BracketStep::Pass(code_span_end(lookups, input, cursor).unwrap_or(next)),
6331        '<' => BracketStep::Pass(
6332            autolink_end(lookups, input, cursor)
6333                .or_else(|| html_inline_end(lookups, input, cursor))
6334                .unwrap_or(next),
6335        ),
6336        '[' => BracketStep::Open(next),
6337        ']' => BracketStep::Close(next),
6338        _ => BracketStep::Pass(next),
6339    }
6340}
6341
6342fn parse_text_directive(
6343    input: &str,
6344    index: usize,
6345    base_offset: usize,
6346    options: &SyntaxOptions,
6347    definitions: &[String],
6348    diagnostics: &mut Vec<Diagnostic>,
6349    pass: &mut InlinePass,
6350) -> Option<(usize, Inline)> {
6351    if input[index..].starts_with("::") {
6352        return None;
6353    }
6354    if index > 0 {
6355        let previous = input[..index].chars().next_back()?;
6356        if !previous.is_whitespace() && !matches!(previous, '(' | '[' | '{') {
6357            return None;
6358        }
6359    }
6360    let opener_source = &input[index + 1..];
6361    let opener_offset = index + 1;
6362    let opener = parse_directive_opener_with(opener_source, |close, open| {
6363        let found = match close {
6364            DirectiveClose::Label => pass.scan.link_label_end(opener_offset + open),
6365            DirectiveClose::Attributes => {
6366                pass.scan.directive_attributes_close(opener_offset + open)
6367            }
6368        };
6369        found.map(|position| position - opener_offset)
6370    });
6371    let (name, label_source, attributes, consumed) = match opener {
6372        Some(opener) => opener,
6373        None => {
6374            if directive_opener_looks_malformed(opener_source) {
6375                diagnostics.push(Diagnostic::new(
6376                    DiagnosticSeverity::Error,
6377                    DiagnosticCode::InvalidDirectiveName,
6378                    Span::new(base_offset + index, base_offset + input.len()),
6379                    "text directive opener is malformed",
6380                ));
6381            }
6382            return None;
6383        }
6384    };
6385    let label = label_source
6386        .map(|source| {
6387            parse_inlines_with_context(
6388                source,
6389                base_offset + index + 1 + name.len() + 1,
6390                options,
6391                definitions,
6392                diagnostics,
6393                pass.state,
6394            )
6395        })
6396        .unwrap_or_default();
6397    Some((
6398        index + 1 + consumed,
6399        Inline::TextDirective(TextDirective {
6400            meta: NodeMeta::new(Some(Span::new(
6401                base_offset + index,
6402                base_offset + index + 1 + consumed,
6403            ))),
6404            name,
6405            label,
6406            attributes,
6407        }),
6408    ))
6409}
6410
6411fn parse_directive_opener(
6412    input: &str,
6413) -> Option<(String, Option<&str>, Vec<DirectiveAttribute>, usize)> {
6414    parse_directive_opener_with(input, |close, open| match close {
6415        DirectiveClose::Label => find_link_label_end(input, open),
6416        DirectiveClose::Attributes => find_directive_attributes_close(input, open),
6417    })
6418}
6419
6420/// Which closing position `parse_directive_opener_with` asks its finder for.
6421#[derive(Clone, Copy)]
6422enum DirectiveClose {
6423    /// The `]` closing the `[label]` opening at the given position.
6424    Label,
6425    /// The `}` closing the `{attributes}` opening at the given position.
6426    Attributes,
6427}
6428
6429fn parse_directive_opener_with(
6430    input: &str,
6431    mut find_close: impl FnMut(DirectiveClose, usize) -> Option<usize>,
6432) -> Option<(String, Option<&str>, Vec<DirectiveAttribute>, usize)> {
6433    let mut index = 0;
6434    while let Some((next, char)) = next_char(input, index) {
6435        if char.is_ascii_alphanumeric() || char == '_' || char == '-' {
6436            index = next;
6437        } else {
6438            break;
6439        }
6440    }
6441    let name = &input[..index];
6442    if !is_directive_name(name) {
6443        return None;
6444    }
6445
6446    let mut label = None;
6447    let mut attributes = Vec::new();
6448    let mut consumed = index;
6449    if input.as_bytes().get(consumed) == Some(&b'[') {
6450        let close = find_close(DirectiveClose::Label, consumed)?;
6451        label = Some(&input[consumed + 1..close]);
6452        consumed = close + 1;
6453    }
6454    if input.as_bytes().get(consumed) == Some(&b'{') {
6455        let close = find_close(DirectiveClose::Attributes, consumed)?;
6456        attributes = parse_attributes(&input[consumed + 1..close]);
6457        consumed = close + 1;
6458    }
6459
6460    Some((name.into(), label, attributes, consumed))
6461}
6462
6463fn directive_opener_looks_malformed(input: &str) -> bool {
6464    let mut index = 0;
6465    while let Some((next, char)) = next_char(input, index) {
6466        if char.is_ascii_alphanumeric() || char == '_' || char == '-' {
6467            index = next;
6468        } else {
6469            break;
6470        }
6471    }
6472    index > 0
6473        && is_directive_name(&input[..index])
6474        && matches!(input.as_bytes().get(index), Some(b'[' | b'{'))
6475}
6476
6477/// Quote states of the walk to a directive's closing `}`: outside quotes,
6478/// inside `"…"`, inside `'…'`.
6479const DIRECTIVE_QUOTE_STATES: usize = 3;
6480
6481fn find_directive_attributes_close(input: &str, open: usize) -> Option<usize> {
6482    if input.as_bytes().get(open) != Some(&b'{') {
6483        return None;
6484    }
6485    path_walk((open + 1) * DIRECTIVE_QUOTE_STATES, |node| {
6486        directive_attributes_step(input, node)
6487    })
6488}
6489
6490/// One step of the walk to a directive's closing `}`, over nodes
6491/// `byte position * DIRECTIVE_QUOTE_STATES + quote state`: a `\` escapes the
6492/// next byte, and a `}` inside quotes does not close.
6493fn directive_attributes_step(input: &str, node: usize) -> Step {
6494    let bytes = input.as_bytes();
6495    let (cursor, quote) = (node / DIRECTIVE_QUOTE_STATES, node % DIRECTIVE_QUOTE_STATES);
6496    let at = |position: usize, quote: usize| Step::Next(position * DIRECTIVE_QUOTE_STATES + quote);
6497    let Some(&byte) = bytes.get(cursor) else {
6498        return Step::Done(None);
6499    };
6500    if byte == b'\\' {
6501        return at((cursor + 2).min(bytes.len()), quote);
6502    }
6503    match (quote, byte) {
6504        (0, b'"') => at(cursor + 1, 1),
6505        (0, b'\'') => at(cursor + 1, 2),
6506        (0, b'}') => Step::Done(Some(cursor)),
6507        (1, b'"') | (2, b'\'') => at(cursor + 1, 0),
6508        _ => at(cursor + 1, quote),
6509    }
6510}
6511
6512fn parse_attributes(input: &str) -> Vec<DirectiveAttribute> {
6513    let mut attributes = Vec::new();
6514    let mut cursor = 0;
6515    while cursor < input.len() {
6516        cursor = skip_spaces(input, cursor);
6517        if cursor >= input.len() {
6518            break;
6519        }
6520
6521        if input.as_bytes().get(cursor) == Some(&b'#') {
6522            let (id, next) = parse_attribute_token(input, cursor + 1);
6523            if !id.is_empty() {
6524                attributes.push(DirectiveAttribute {
6525                    name: "id".into(),
6526                    value: Some(id.into()),
6527                });
6528            }
6529            cursor = next;
6530            continue;
6531        }
6532
6533        if input.as_bytes().get(cursor) == Some(&b'.') {
6534            let (class, next) = parse_attribute_token(input, cursor + 1);
6535            if !class.is_empty() {
6536                attributes.push(DirectiveAttribute {
6537                    name: "class".into(),
6538                    value: Some(class.into()),
6539                });
6540            }
6541            cursor = next;
6542            continue;
6543        }
6544
6545        let (name, next) = parse_attribute_name(input, cursor);
6546        if name.is_empty() {
6547            break;
6548        }
6549        cursor = skip_spaces(input, next);
6550        if input.as_bytes().get(cursor) == Some(&b'=') {
6551            cursor = skip_spaces(input, cursor + 1);
6552            if let Some((value, next)) = parse_attribute_value(input, cursor) {
6553                attributes.push(DirectiveAttribute {
6554                    name: name.into(),
6555                    value: Some(value),
6556                });
6557                cursor = next;
6558            } else {
6559                attributes.push(DirectiveAttribute {
6560                    name: name.into(),
6561                    value: Some(String::new()),
6562                });
6563            }
6564        } else {
6565            attributes.push(DirectiveAttribute {
6566                name: name.into(),
6567                value: None,
6568            });
6569        }
6570    }
6571    attributes
6572}
6573
6574fn parse_attribute_token(input: &str, index: usize) -> (&str, usize) {
6575    let mut cursor = index;
6576    while let Some((next, char)) = next_char(input, cursor) {
6577        if char.is_whitespace() {
6578            break;
6579        }
6580        cursor = next;
6581    }
6582    (&input[index..cursor], cursor)
6583}
6584
6585fn parse_attribute_name(input: &str, index: usize) -> (&str, usize) {
6586    let mut cursor = index;
6587    while let Some((next, char)) = next_char(input, cursor) {
6588        if char.is_whitespace() || char == '=' {
6589            break;
6590        }
6591        cursor = next;
6592    }
6593    (&input[index..cursor], cursor)
6594}
6595
6596fn parse_attribute_value(input: &str, index: usize) -> Option<(String, usize)> {
6597    let quote = input.as_bytes().get(index).copied();
6598    if matches!(quote, Some(b'"' | b'\'')) {
6599        let quote = quote?;
6600        let mut cursor = index + 1;
6601        while cursor < input.len() {
6602            let (next, char) = next_char(input, cursor)?;
6603            if char as u8 == quote && !is_escaped_at(input, cursor) {
6604                return Some((unescape_ascii_punctuation(&input[index + 1..cursor]), next));
6605            }
6606            cursor = next;
6607        }
6608        return None;
6609    }
6610
6611    let (value, next) = parse_attribute_token(input, index);
6612    Some((
6613        unescape_selected(value, |char| matches!(char, '\\' | '&')),
6614        next,
6615    ))
6616}
6617
6618struct CodeSpanSource {
6619    value: String,
6620    raw: String,
6621    fence_length: usize,
6622}
6623
6624fn parse_code_span_with(
6625    lookups: &mut impl Lookups,
6626    input: &str,
6627    index: usize,
6628) -> Option<(usize, CodeSpanSource)> {
6629    let len = backtick_run_len(input, index);
6630    let search_start = index + len;
6631    let close = lookups.code_span_close(search_start, len)?;
6632    let raw = &input[search_start..close];
6633    Some((
6634        close + len,
6635        CodeSpanSource {
6636            value: normalize_code_span(raw),
6637            raw: raw.into(),
6638            fence_length: len,
6639        },
6640    ))
6641}
6642
6643/// The end of the code span opening at `index`, as `parse_code_span` finds it.
6644fn code_span_end(lookups: &mut impl Lookups, input: &str, index: usize) -> Option<usize> {
6645    let len = backtick_run_len(input, index);
6646    Some(lookups.code_span_close(index + len, len)? + len)
6647}
6648
6649fn find_code_span_close(input: &str, start: usize, marker_len: usize) -> Option<usize> {
6650    let bytes = input.as_bytes();
6651    let mut cursor = start;
6652    while cursor < bytes.len() {
6653        if bytes[cursor] != b'`' {
6654            cursor = next_char(input, cursor)
6655                .map(|(next, _)| next)
6656                .unwrap_or(bytes.len());
6657            continue;
6658        }
6659        let run_len = bytes[cursor..]
6660            .iter()
6661            .take_while(|byte| **byte == b'`')
6662            .count();
6663        if run_len == marker_len {
6664            return Some(cursor);
6665        }
6666        cursor += run_len;
6667    }
6668    None
6669}
6670
6671fn normalize_code_span(input: &str) -> String {
6672    let mut normalized = String::new();
6673    let mut cursor = 0;
6674    while cursor < input.len() {
6675        let (next, char) = next_char(input, cursor).expect("valid UTF-8 byte index");
6676        if char == '\r' {
6677            if input.as_bytes().get(next) == Some(&b'\n') {
6678                cursor = next + 1;
6679            } else {
6680                cursor = next;
6681            }
6682            normalized.push(' ');
6683            continue;
6684        }
6685        if char == '\n' {
6686            normalized.push(' ');
6687            cursor = next;
6688            continue;
6689        }
6690        normalized.push(char);
6691        cursor = next;
6692    }
6693
6694    if normalized.starts_with(' ')
6695        && normalized.ends_with(' ')
6696        && normalized.chars().any(|char| char != ' ')
6697    {
6698        normalized[1..normalized.len() - 1].into()
6699    } else {
6700        normalized
6701    }
6702}
6703
6704fn can_open_delimited(input: &str, index: usize, marker_len: usize) -> bool {
6705    delimiter_flanking(input, index, marker_len).left
6706}
6707
6708fn can_close_delimited(input: &str, index: usize, marker_len: usize) -> bool {
6709    delimiter_flanking(input, index, marker_len).right
6710}
6711
6712fn is_single_tilde_delete_close(input: &str, index: usize) -> bool {
6713    input.as_bytes()[index] == b'~'
6714        && !is_escaped_at(input, index)
6715        && single_tilde_can_close_delete(input, index)
6716}
6717
6718fn single_tilde_can_open_delete(input: &str, index: usize) -> bool {
6719    starts_exact_byte_run(input, index, b'~', 1)
6720        && can_open_delimited(input, index, 1)
6721        && !tilde_is_alphanumeric_interior(input, index)
6722}
6723
6724fn single_tilde_can_close_delete(input: &str, index: usize) -> bool {
6725    starts_exact_byte_run(input, index, b'~', 1)
6726        && can_close_delimited(input, index, 1)
6727        && !tilde_is_alphanumeric_interior(input, index)
6728}
6729
6730fn single_tilde_delete_takes_precedence(
6731    options: &SyntaxOptions,
6732    input: &str,
6733    index: usize,
6734    scan: &mut InlineScan,
6735) -> bool {
6736    options.constructs.gfm_strikethrough
6737        && options.parse.single_tilde_strikethrough
6738        && single_tilde_can_open_delete(input, index)
6739        && scan.single_tilde_delete_close(index + 1).is_some()
6740}
6741
6742fn tilde_is_alphanumeric_interior(input: &str, index: usize) -> bool {
6743    let previous = input[..index].chars().next_back();
6744    let next = input[index + 1..].chars().next();
6745    previous.is_some_and(|char| char.is_alphanumeric())
6746        && next.is_some_and(|char| char.is_alphanumeric())
6747}
6748
6749fn starts_exact_byte_run(input: &str, index: usize, marker: u8, len: usize) -> bool {
6750    input.as_bytes().get(index) == Some(&marker)
6751        && delimiter_byte_run_start(input, index, marker) == index
6752        && delimiter_byte_run_len(input, index, marker) == len
6753}
6754
6755fn delimiter_byte_run_start(input: &str, index: usize, marker: u8) -> usize {
6756    let bytes = input.as_bytes();
6757    let mut start = index;
6758    while start > 0 && bytes[start - 1] == marker && !is_escaped_at(input, start - 1) {
6759        start -= 1;
6760    }
6761    start
6762}
6763
6764fn delimiter_byte_run_len(input: &str, index: usize, marker: u8) -> usize {
6765    let bytes = input.as_bytes();
6766    let mut cursor = index;
6767    while bytes.get(cursor) == Some(&marker) {
6768        cursor += 1;
6769    }
6770    cursor - index
6771}
6772
6773fn can_open_underscore(input: &str, index: usize, marker_len: usize) -> bool {
6774    let flanking = delimiter_flanking(input, index, marker_len);
6775    flanking.left
6776        && (!flanking.right || flanking.previous.is_some_and(|c| c.is_ascii_punctuation()))
6777}
6778
6779fn can_close_underscore(input: &str, index: usize, marker_len: usize) -> bool {
6780    let flanking = delimiter_flanking(input, index, marker_len);
6781    flanking.right && (!flanking.left || flanking.next.is_some_and(|c| c.is_ascii_punctuation()))
6782}
6783
6784#[derive(Clone, Copy)]
6785struct DelimiterFlanking {
6786    left: bool,
6787    right: bool,
6788    previous: Option<char>,
6789    next: Option<char>,
6790}
6791
6792fn delimiter_flanking(input: &str, index: usize, marker_len: usize) -> DelimiterFlanking {
6793    delimiter_flanking_within(input, index, marker_len, false, false)
6794}
6795
6796/// `delimiter_flanking` for a run that may sit right inside a mark span: a
6797/// bounded side flanks as if the input ended there, the way the span's content
6798/// reads on its own.
6799fn delimiter_flanking_within(
6800    input: &str,
6801    index: usize,
6802    marker_len: usize,
6803    bounded_before: bool,
6804    bounded_after: bool,
6805) -> DelimiterFlanking {
6806    let previous = if bounded_before {
6807        None
6808    } else {
6809        input[..index].chars().next_back().map(source_char)
6810    };
6811    let next = if bounded_after {
6812        None
6813    } else {
6814        input[index + marker_len..].chars().next().map(source_char)
6815    };
6816
6817    let previous_whitespace = previous.is_none_or(char::is_whitespace);
6818    let next_whitespace = next.is_none_or(char::is_whitespace);
6819    let previous_punctuation = previous.is_some_and(is_flanking_punctuation);
6820    let next_punctuation = next.is_some_and(is_flanking_punctuation);
6821
6822    let left = next.is_some()
6823        && !next_whitespace
6824        && !(next_punctuation && !previous_whitespace && !previous_punctuation);
6825    let right = previous.is_some()
6826        && !previous_whitespace
6827        && !(previous_punctuation && !next_whitespace && !next_punctuation);
6828
6829    DelimiterFlanking {
6830        left,
6831        right,
6832        previous,
6833        next,
6834    }
6835}
6836
6837/// Dollar-fenced inline math, GitHub Flavored Markdown dialect.
6838///
6839/// A `$` is a flanking delimiter resolved at scan time (math is not pushed onto
6840/// the emphasis delimiter stack). An opening run of one or two `$` (runs of
6841/// three or more never form math) scans forward for a matching closing run:
6842///
6843/// * single `$`: cannot open if the next char is ASCII whitespace; the closing
6844///   `$` cannot be preceded by ASCII whitespace nor followed by an ASCII digit;
6845///   a `\$` inside is skipped (the backslash is kept verbatim, never a
6846///   delimiter); the close must be a run of exactly one `$`.
6847/// * double `$$`: no flanking and no digit guard; closes on the next run of two
6848///   `$`; content is kept verbatim and may span newlines (this is still an
6849///   inline display span — `$$` flow blocks are handled by `parse_math_block`).
6850///
6851/// The closing run is matched greedily (the nearest valid close wins), which is
6852/// equivalent to emphasis-style "nearest preceding open" because a failed open
6853/// emits a literal `$`/`$$` and the scan resumes after it. Content for the
6854/// single-`$` form is normalized like a code span (line endings → spaces, one
6855/// edge-space strip); the `$$` display form is verbatim. The `` $`…`$ `` code
6856/// form takes precedence.
6857fn parse_math_inline(
6858    lookups: &mut impl Lookups,
6859    input: &str,
6860    index: usize,
6861) -> Option<(usize, String, MathInlineKind)> {
6862    if let Some((end, value)) = parse_math_code_inline(lookups, input, index) {
6863        return Some((end, value, MathInlineKind::Code));
6864    }
6865
6866    let bytes = input.as_bytes();
6867    let open_dollars = bytes[index..]
6868        .iter()
6869        .take_while(|byte| **byte == b'$')
6870        .count();
6871    // The maximum math fence length is 2 dollars: a run of three or more never
6872    // opens math.
6873    if open_dollars == 0 || open_dollars > 2 {
6874        return None;
6875    }
6876
6877    let content_start = index + open_dollars;
6878    let close = scan_to_closing_dollar(input, content_start, open_dollars)?;
6879    let content_end = close - open_dollars;
6880    // The span requires `endpos - startpos >= fence_length * 2 + 1`, i.e. at
6881    // least one content byte between the open and close fences.
6882    if content_end <= content_start {
6883        return None;
6884    }
6885
6886    let raw = &input[content_start..content_end];
6887    let value = if open_dollars == 1 {
6888        normalize_math_text(raw)
6889    } else {
6890        raw.into()
6891    };
6892    let dollars = u8::try_from(open_dollars).unwrap_or(u8::MAX);
6893    Some((close, value, MathInlineKind::Dollar { dollars }))
6894}
6895
6896/// Scans for the closing dollar run. `start` is the first content byte
6897/// (just past the opening run); returns the byte offset just past a matching
6898/// closing run of exactly `open_dollars` `$`.
6899fn scan_to_closing_dollar(input: &str, start: usize, open_dollars: usize) -> Option<usize> {
6900    let bytes = input.as_bytes();
6901    // A space immediately after a single opening `$` forbids the open.
6902    if open_dollars == 1 && bytes.get(start).is_some_and(|byte| is_math_space(*byte)) {
6903        return None;
6904    }
6905
6906    let mut cursor = start;
6907    loop {
6908        while cursor < bytes.len() && bytes[cursor] != b'$' {
6909            cursor += 1;
6910        }
6911        if cursor >= bytes.len() {
6912            return None;
6913        }
6914        // `cursor` now points at the first `$` of a potential closing run; the
6915        // char just before it gates the single-`$` flanking and escape rules.
6916        let prev = bytes[cursor - 1];
6917        if open_dollars == 1 && is_math_space(prev) {
6918            return None;
6919        }
6920        if open_dollars == 1 && prev == b'\\' {
6921            // An escaped `\$` is content, not a delimiter: skip this one `$` and
6922            // keep scanning (the backslash stays in the content verbatim).
6923            cursor += 1;
6924            continue;
6925        }
6926        let run = bytes[cursor..]
6927            .iter()
6928            .take(open_dollars)
6929            .take_while(|byte| **byte == b'$')
6930            .count();
6931        // The single-`$` close cannot be followed by an ASCII digit.
6932        if open_dollars == 1 && bytes.get(cursor + run).is_some_and(u8::is_ascii_digit) {
6933            return None;
6934        }
6935        if run == open_dollars {
6936            return Some(cursor + run);
6937        }
6938        cursor += run;
6939    }
6940}
6941
6942/// Math whitespace: ASCII tab, line feed, carriage return, and space.
6943fn is_math_space(byte: u8) -> bool {
6944    matches!(byte, b'\t' | b'\n' | b'\r' | b' ')
6945}
6946
6947/// Applies the code-span content rules to dollar-fenced math: line endings
6948/// become single spaces, then if the content begins AND ends with U+0020 and is
6949/// not entirely spaces, one space is stripped from each edge.
6950fn normalize_math_text(input: &str) -> String {
6951    let mut normalized = String::new();
6952    let mut cursor = 0;
6953    while cursor < input.len() {
6954        let (next, char) = next_char(input, cursor).expect("valid UTF-8 byte index");
6955        if char == '\r' {
6956            if input.as_bytes().get(next) == Some(&b'\n') {
6957                cursor = next + 1;
6958            } else {
6959                cursor = next;
6960            }
6961            normalized.push(' ');
6962            continue;
6963        }
6964        if char == '\n' {
6965            normalized.push(' ');
6966            cursor = next;
6967            continue;
6968        }
6969        normalized.push(char);
6970        cursor = next;
6971    }
6972
6973    if normalized.starts_with(' ')
6974        && normalized.ends_with(' ')
6975        && normalized.chars().any(|char| char != ' ')
6976    {
6977        normalized[1..normalized.len() - 1].into()
6978    } else {
6979        normalized
6980    }
6981}
6982
6983fn parse_math_code_inline(
6984    lookups: &mut impl Lookups,
6985    input: &str,
6986    index: usize,
6987) -> Option<(usize, String)> {
6988    if !input[index..].starts_with("$`") {
6989        return None;
6990    }
6991
6992    let search_start = index + 2;
6993    let close = lookups.find("`$", search_start)?;
6994    if close == search_start {
6995        return None;
6996    }
6997
6998    Some((close + 2, input[search_start..close].into()))
6999}
7000
7001fn parse_link_resource(
7002    lookups: &mut impl Lookups,
7003    input: &str,
7004    open: usize,
7005) -> Option<(usize, ParsedLinkResource)> {
7006    let bytes = input.as_bytes();
7007    if bytes.get(open) != Some(&b'(') {
7008        return None;
7009    }
7010    let (mut cursor, initial_space) = skip_link_resource_space_with_info(input, open + 1)?;
7011    if bytes.get(cursor) == Some(&b')') {
7012        return Some((
7013            cursor + 1,
7014            ParsedLinkResource {
7015                destination: String::new(),
7016                destination_kind: LinkDestinationKind::Omitted,
7017                title: None,
7018                title_kind: None,
7019            },
7020        ));
7021    }
7022    if initial_space && matches!(bytes.get(cursor), Some(b'"' | b'\'' | b'(')) {
7023        let (title, title_kind, next) = parse_link_title(input, cursor)?;
7024        cursor = skip_link_resource_space(input, next)?;
7025        if bytes.get(cursor) == Some(&b')') {
7026            return Some((
7027                cursor + 1,
7028                ParsedLinkResource {
7029                    destination: String::new(),
7030                    destination_kind: LinkDestinationKind::Omitted,
7031                    title: Some(title),
7032                    title_kind: Some(title_kind),
7033                },
7034            ));
7035        }
7036        return None;
7037    }
7038    let (destination, destination_kind, next) = parse_link_destination(lookups, input, cursor)?;
7039    let (after_destination, had_space) = skip_link_resource_space_with_info(input, next)?;
7040    cursor = after_destination;
7041    if bytes.get(cursor) == Some(&b')') {
7042        return Some((
7043            cursor + 1,
7044            ParsedLinkResource {
7045                destination,
7046                destination_kind,
7047                title: None,
7048                title_kind: None,
7049            },
7050        ));
7051    }
7052    if !had_space {
7053        return None;
7054    }
7055
7056    let (title, title_kind, next) = parse_link_title(input, cursor)?;
7057    cursor = skip_link_resource_space(input, next)?;
7058    if bytes.get(cursor) == Some(&b')') {
7059        Some((
7060            cursor + 1,
7061            ParsedLinkResource {
7062                destination,
7063                destination_kind,
7064                title: Some(title),
7065                title_kind: Some(title_kind),
7066            },
7067        ))
7068    } else {
7069        None
7070    }
7071}
7072
7073fn parse_link_destination(
7074    lookups: &mut impl Lookups,
7075    input: &str,
7076    index: usize,
7077) -> Option<(String, LinkDestinationKind, usize)> {
7078    if input.as_bytes().get(index) == Some(&b'<') {
7079        let stop = lookups.angle_destination_stop(index + 1)?;
7080        if input.as_bytes()[stop] != b'>' {
7081            return None;
7082        }
7083        return Some((
7084            unescape_ascii_punctuation(&input[index + 1..stop]),
7085            LinkDestinationKind::Angle,
7086            stop + 1,
7087        ));
7088    }
7089
7090    let mut cursor = index;
7091    let mut depth = 0usize;
7092    while cursor < input.len() {
7093        let (next, char) = next_char(input, cursor)?;
7094        // A bare destination terminates on ASCII space or an ASCII control
7095        // character; Unicode whitespace (e.g. U+00A0) is ordinary. A backslash
7096        // before a space is NOT an escape (only ASCII punctuation is escapable),
7097        // so `\ ` still terminates the destination → `[a](\ b)` is not a link.
7098        if (char == ' ' || source_char(char).is_ascii_control()) && depth == 0 {
7099            break;
7100        }
7101        if char == '(' && !is_escaped_at(input, cursor) {
7102            depth += 1;
7103            // CommonMark caps balanced parens in a bare destination at depth 32.
7104            if depth > 32 {
7105                return None;
7106            }
7107        } else if char == ')' && !is_escaped_at(input, cursor) {
7108            if depth == 0 {
7109                break;
7110            }
7111            depth -= 1;
7112        }
7113        cursor = next;
7114    }
7115
7116    if cursor == index || depth > 0 {
7117        None
7118    } else {
7119        Some((
7120            unescape_ascii_punctuation(&input[index..cursor]),
7121            LinkDestinationKind::Bare,
7122            cursor,
7123        ))
7124    }
7125}
7126
7127fn parse_link_title(input: &str, index: usize) -> Option<(String, LinkTitleKind, usize)> {
7128    let opener = input.as_bytes().get(index).copied()?;
7129    let (closer, title_kind) = match opener {
7130        b'"' => ('"', LinkTitleKind::DoubleQuote),
7131        b'\'' => ('\'', LinkTitleKind::SingleQuote),
7132        b'(' => (')', LinkTitleKind::Paren),
7133        _ => return None,
7134    };
7135    let mut cursor = index + 1;
7136    while cursor < input.len() {
7137        let (next, char) = next_char(input, cursor)?;
7138        if char == closer && !is_escaped_at(input, cursor) {
7139            if contains_blank_line(&input[index + 1..cursor]) {
7140                return None;
7141            }
7142            return Some((
7143                unescape_ascii_punctuation(&input[index + 1..cursor]),
7144                title_kind,
7145                next,
7146            ));
7147        }
7148        if opener == b'(' && char == '(' && !is_escaped_at(input, cursor) {
7149            return None;
7150        }
7151        cursor = next;
7152    }
7153    None
7154}
7155
7156fn contains_blank_line(input: &str) -> bool {
7157    if !input.bytes().any(|byte| matches!(byte, b'\n' | b'\r')) {
7158        return false;
7159    }
7160    // A title that merely begins or ends with an EOL is allowed; only an INTERIOR
7161    // blank line (a blank line bounded by content on both sides) is rejected. The
7162    // empty first/last line entries that a leading/trailing newline produces are
7163    // boundary artifacts, not blank lines in the title content.
7164    let lines = collect_lines(input, 0);
7165    let interior = lines.len().saturating_sub(1);
7166    lines
7167        .iter()
7168        .take(interior)
7169        .skip(1)
7170        .any(|line| line.text.trim().is_empty())
7171}
7172
7173fn skip_link_resource_space(input: &str, index: usize) -> Option<usize> {
7174    skip_link_resource_space_with_info(input, index).map(|(index, _)| index)
7175}
7176
7177fn skip_link_resource_space_with_info(input: &str, mut index: usize) -> Option<(usize, bool)> {
7178    let mut line_breaks = 0usize;
7179    let mut had_space = false;
7180    while input
7181        .as_bytes()
7182        .get(index)
7183        .is_some_and(|byte| matches!(*byte, b' ' | b'\t' | b'\n' | b'\r'))
7184    {
7185        had_space = true;
7186        match input.as_bytes()[index] {
7187            b'\n' => {
7188                line_breaks += 1;
7189                if line_breaks > 1 {
7190                    return None;
7191                }
7192                index += 1;
7193            }
7194            b'\r' => {
7195                line_breaks += 1;
7196                if line_breaks > 1 {
7197                    return None;
7198                }
7199                if input.as_bytes().get(index + 1) == Some(&b'\n') {
7200                    index += 2;
7201                } else {
7202                    index += 1;
7203                }
7204            }
7205            _ => index += 1,
7206        }
7207    }
7208    Some((index, had_space))
7209}
7210
7211pub(crate) fn parse_character_reference(input: &str, index: usize) -> Option<(usize, String)> {
7212    let rest = input.get(index..)?;
7213    if let Some(rest) = rest
7214        .strip_prefix("&#x")
7215        .or_else(|| rest.strip_prefix("&#X"))
7216    {
7217        let digits = find_reference_terminator(rest, 6)?;
7218        if digits == 0 || !rest[..digits].bytes().all(|byte| byte.is_ascii_hexdigit()) {
7219            return None;
7220        }
7221        let value = u32::from_str_radix(&rest[..digits], 16).ok()?;
7222        return Some((
7223            index + 3 + digits + 1,
7224            character_reference_value(value).into(),
7225        ));
7226    }
7227    if let Some(rest) = rest.strip_prefix("&#") {
7228        let digits = find_reference_terminator(rest, 7)?;
7229        if digits == 0 || !rest[..digits].bytes().all(|byte| byte.is_ascii_digit()) {
7230            return None;
7231        }
7232        let value = rest[..digits].parse::<u32>().ok()?;
7233        return Some((
7234            index + 2 + digits + 1,
7235            character_reference_value(value).into(),
7236        ));
7237    }
7238
7239    let name_end = find_reference_terminator(rest, 32)?;
7240    if name_end == 0 {
7241        return None;
7242    }
7243    let name = &rest[1..name_end];
7244    named_character_reference(name).map(|value| (index + name_end + 1, value.into()))
7245}
7246
7247/// Finds the `;` that ends a character reference body, looking no further than
7248/// `max_len` bytes: a longer body is never a reference, so the scan stays
7249/// bounded instead of running to the end of the input.
7250fn find_reference_terminator(rest: &str, max_len: usize) -> Option<usize> {
7251    rest.as_bytes()
7252        .iter()
7253        .take(max_len + 1)
7254        .position(|byte| *byte == b';')
7255}
7256
7257/// Decode a numeric character reference codepoint to its scalar value.
7258///
7259/// This follows the CommonMark reference behavior: `U+0000`, the UTF-16
7260/// surrogate range, and codepoints beyond the Unicode scalar range decode to
7261/// `U+FFFD`; every other codepoint decodes to itself.
7262///
7263/// Two deliberate non-behaviors:
7264/// - We do NOT apply the HTML5 Windows-1252 remapping of C1 bytes; `&#128;`
7265///   decodes to `U+0080`, not the Euro sign. The CommonMark reference does not
7266///   perform that remapping.
7267/// - We do NOT extend replacement to the C0/C1 controls, DEL, or the Unicode
7268///   noncharacters the way some HTML-oriented decoders do. Keeping those as
7269///   their literal scalar is what makes the serializer's `&#xNN;` escaping of
7270///   control characters round-trip through a re-parse. The roundtrip corpus
7271///   only pins `{0 -> FFFD, 9 -> tab, 10 -> line feed, surrogate -> FFFD,
7272///   out-of-range -> FFFD}`, all of which this matches.
7273pub(crate) fn character_reference_value(value: u32) -> char {
7274    if value == 0 {
7275        '\u{FFFD}'
7276    } else {
7277        char::from_u32(value).unwrap_or('\u{FFFD}')
7278    }
7279}
7280
7281pub(crate) fn is_escaped_at(input: &str, index: usize) -> bool {
7282    let bytes = input.as_bytes();
7283    let mut cursor = index;
7284    let mut count = 0;
7285    while cursor > 0 && bytes[cursor - 1] == b'\\' {
7286        count += 1;
7287        cursor -= 1;
7288    }
7289    count % 2 == 1
7290}
7291
7292fn parse_definition_destination_title(input: &str) -> Option<ParsedLinkResource> {
7293    let (mut cursor, _) = skip_link_resource_space_with_info(input, 0)?;
7294    let (destination, destination_kind, next) =
7295        parse_link_destination(&mut DirectLookups { input }, input, cursor)?;
7296    cursor = next;
7297
7298    let (next, had_space) = skip_link_resource_space_with_info(input, cursor)?;
7299    cursor = next;
7300    if cursor >= input.len() {
7301        return Some(ParsedLinkResource {
7302            destination,
7303            destination_kind,
7304            title: None,
7305            title_kind: None,
7306        });
7307    }
7308    if !had_space {
7309        return None;
7310    }
7311
7312    let (title, title_kind, next) = parse_link_title(input, cursor)?;
7313    let after_title = skip_link_resource_space(input, next)?;
7314    (after_title == input.len()).then_some(ParsedLinkResource {
7315        destination,
7316        destination_kind,
7317        title: Some(title),
7318        title_kind: Some(title_kind),
7319    })
7320}
7321
7322fn line_can_start_definition_title(input: &str) -> bool {
7323    let trimmed = input.trim_start();
7324    matches!(trimmed.as_bytes().first(), Some(b'"' | b'\'' | b'('))
7325}
7326
7327fn unescape_ascii_punctuation(input: &str) -> String {
7328    // Only ASCII punctuation is escapable (`\ ` keeps its backslash).
7329    unescape_selected(input, |char| char.is_ascii_punctuation())
7330}
7331
7332fn unescape_string(input: &str) -> String {
7333    unescape_selected(input, |char| char.is_ascii_punctuation() || char == '&')
7334}
7335
7336fn unescape_selected(input: &str, should_unescape: impl Fn(char) -> bool) -> String {
7337    let mut output = String::new();
7338    let mut cursor = 0;
7339    while cursor < input.len() {
7340        if input.as_bytes().get(cursor) == Some(&b'&') {
7341            if let Some((end, value)) = parse_character_reference(input, cursor) {
7342                output.push_str(&value);
7343                cursor = end;
7344                continue;
7345            }
7346        }
7347        let (next, char) = next_char(input, cursor).expect("valid UTF-8 byte index");
7348        if char == '\\' {
7349            if let Some((after_escape, escaped)) = next_char(input, next) {
7350                if should_unescape(escaped) {
7351                    output.push(escaped);
7352                } else {
7353                    output.push(char);
7354                    output.push(escaped);
7355                }
7356                cursor = after_escape;
7357            } else {
7358                output.push(char);
7359                cursor = next;
7360            }
7361        } else {
7362            output.push(char);
7363            cursor = next;
7364        }
7365    }
7366    output
7367}
7368
7369fn push_line(output: &mut String, line: &str) {
7370    if !output.is_empty() {
7371        output.push('\n');
7372    }
7373    output.push_str(line);
7374}
7375
7376fn ensure_line_separator(output: &mut String) {
7377    if !output.is_empty() && !ends_with_line_ending(output) {
7378        output.push('\n');
7379    }
7380}
7381
7382fn ends_with_line_ending(input: &str) -> bool {
7383    input.ends_with('\n') || input.ends_with('\r')
7384}
7385
7386fn flush_text(nodes: &mut Vec<Inline>, text: &mut String, text_start: usize, end: usize) {
7387    if !text.is_empty() {
7388        nodes.push(Inline::Text(Text {
7389            meta: NodeMeta::new(Some(Span::new(text_start, end))),
7390            value: core::mem::take(text),
7391        }));
7392    }
7393}
7394
7395fn next_char(input: &str, index: usize) -> Option<(usize, char)> {
7396    let char = input[index..].chars().next()?;
7397    Some((index + char.len_utf8(), char))
7398}
7399
7400/// A CommonMark "Unicode punctuation character" for emphasis/strong flanking:
7401/// ASCII punctuation plus the non-ASCII Unicode `P*`/`S*` categories. Only the
7402/// flanking classification needs the Unicode set; escape/label logic stays
7403/// ASCII-only via `char::is_ascii_punctuation`.
7404fn is_flanking_punctuation(value: char) -> bool {
7405    value.is_ascii_punctuation() || crate::unicode_punctuation::is_unicode_punctuation(value)
7406}
7407
7408/// Fold a reference label to its matching identifier. Per CommonMark, two
7409/// labels match when their RAW source (no backslash unescape, no entity decode)
7410/// agrees after collapsing internal whitespace to a single space, trimming, and
7411/// Unicode case-folding (`to_uppercase()` then `to_lowercase()`). So `[foo\!]`
7412/// does NOT match `[foo!]`, and `[&copy;]` does NOT match `[©]`.
7413///
7414/// The serializer's `normalize_reference_label` delegates here so the
7415/// Shortcut/Collapsed omission oracle stays in lockstep with this matcher.
7416pub(crate) fn normalize_label(label: &str) -> String {
7417    label
7418        // Unicode full casefold maps capital sharp S (ẞ, U+1E9E) to "ss"; Rust's
7419        // `to_uppercase` leaves it unchanged (it is already uppercase), so without
7420        // this `[ẞ]` would not match a `[SS]: …` definition (links 540). This is
7421        // the only char where `to_uppercase().to_lowercase()` diverges from the
7422        // full casefold that matters for label matching.
7423        .replace('ẞ', "ss")
7424        .replace('\0', "\u{FFFD}")
7425        .split_whitespace()
7426        .collect::<Vec<_>>()
7427        .join(" ")
7428        .to_uppercase()
7429        .to_lowercase()
7430}
7431
7432fn definition_exists(definitions: &[String], label: &str) -> bool {
7433    if label.is_empty() || !reference_label_is_within_limit(label) {
7434        return false;
7435    }
7436
7437    definitions.binary_search(&normalize_label(label)).is_ok()
7438}
7439
7440fn reference_label_is_within_limit(label: &str) -> bool {
7441    label.chars().take(REFERENCE_LABEL_MAX_CHARS + 1).count() <= REFERENCE_LABEL_MAX_CHARS
7442}
7443
7444fn trim_up_to_three_spaces(input: &str) -> Option<&str> {
7445    let (columns, bytes) = leading_indent(input);
7446    if columns <= 3 {
7447        Some(&input[bytes..])
7448    } else {
7449        None
7450    }
7451}
7452
7453fn fence_start(input: &str) -> Option<(FenceMarker, usize)> {
7454    let marker = match input.as_bytes().first()? {
7455        b'`' => FenceMarker::Backtick,
7456        b'~' => FenceMarker::Tilde,
7457        _ => return None,
7458    };
7459    let byte = match marker {
7460        FenceMarker::Backtick => b'`',
7461        FenceMarker::Tilde => b'~',
7462    };
7463    let length = input
7464        .as_bytes()
7465        .iter()
7466        .take_while(|item| **item == byte)
7467        .count();
7468    if length >= 3 {
7469        Some((marker, length))
7470    } else {
7471        None
7472    }
7473}
7474
7475fn fence_close(input: &str, marker: FenceMarker, length: usize) -> bool {
7476    let byte = match marker {
7477        FenceMarker::Backtick => b'`',
7478        FenceMarker::Tilde => b'~',
7479    };
7480    let count = input
7481        .as_bytes()
7482        .iter()
7483        .take_while(|item| **item == byte)
7484        .count();
7485    count >= length && input[count..].trim().is_empty()
7486}
7487
7488fn trim_closing_hashes(input: &str) -> &str {
7489    let input = input.trim_end();
7490    let hash_start = input.trim_end_matches('#').len();
7491    if hash_start == input.len() {
7492        return input;
7493    }
7494    if hash_start == 0 {
7495        return "";
7496    }
7497
7498    let before = &input[..hash_start];
7499    if before.ends_with(' ') || before.ends_with('\t') {
7500        before.trim_end()
7501    } else {
7502        input
7503    }
7504}
7505
7506fn list_marker_info(input: &str) -> Option<ListMarkerInfo<'_>> {
7507    let trimmed = trim_up_to_three_spaces(input)?;
7508    let indent = input.len() - trimmed.len();
7509    let bytes = trimmed.as_bytes();
7510    match bytes.first()? {
7511        b'-' | b'*' | b'+' if is_list_padding_byte(bytes.get(1).copied()) => {
7512            let delimiter = match bytes[0] {
7513                b'-' => ListDelimiter::Dash,
7514                b'*' => ListDelimiter::Asterisk,
7515                _ => ListDelimiter::Plus,
7516            };
7517            let (content_offset, content_indent) = list_content_offset(trimmed, 1, indent);
7518            Some(ListMarkerInfo {
7519                ordered: false,
7520                start: None,
7521                delimiter,
7522                indent,
7523                marker_len: 1,
7524                content_indent,
7525                content: &trimmed[content_offset..],
7526            })
7527        }
7528        byte if byte.is_ascii_digit() => {
7529            let mut end = 0;
7530            while bytes.get(end).is_some_and(|byte| byte.is_ascii_digit()) {
7531                end += 1;
7532            }
7533            if end > 9 {
7534                return None;
7535            }
7536            let delimiter = match bytes.get(end)? {
7537                b'.' => ListDelimiter::Period,
7538                b')' => ListDelimiter::Paren,
7539                _ => return None,
7540            };
7541            if !is_list_padding_byte(bytes.get(end + 1).copied()) {
7542                return None;
7543            }
7544            let start = trimmed[..end].parse().ok()?;
7545            let marker_len = end + 1;
7546            let (content_offset, content_indent) = list_content_offset(trimmed, marker_len, indent);
7547            Some(ListMarkerInfo {
7548                ordered: true,
7549                start: Some(start),
7550                delimiter,
7551                indent,
7552                marker_len,
7553                content_indent,
7554                content: &trimmed[content_offset..],
7555            })
7556        }
7557        _ => None,
7558    }
7559}
7560
7561fn list_content_offset(input: &str, marker_len: usize, indent: usize) -> (usize, usize) {
7562    let bytes = input.as_bytes();
7563    if bytes.get(marker_len).is_none() {
7564        return (marker_len, indent + marker_len + 1);
7565    }
7566    let mut cursor = marker_len;
7567    let mut column = indent + marker_len;
7568    let marker_end_column = column;
7569    while let Some(byte) = bytes.get(cursor) {
7570        match *byte {
7571            b' ' => column += 1,
7572            b'\t' => column += 4 - (column % 4),
7573            _ => break,
7574        }
7575        cursor += 1;
7576    }
7577    // The line is the marker followed only by whitespace: an empty item whose
7578    // first line is blank. CommonMark §5.2 fixes its content indent at marker
7579    // width + 1 regardless of how many trailing spaces follow, so content on the
7580    // next line indented one column past the marker joins the item.
7581    if cursor >= bytes.len() {
7582        return (cursor, marker_end_column + 1);
7583    }
7584    let padding_columns = column.saturating_sub(marker_end_column);
7585    if padding_columns > 0 && padding_columns <= 4 {
7586        (cursor, column)
7587    } else {
7588        (marker_len + 1, marker_end_column + 1)
7589    }
7590}
7591
7592fn list_marker_first_content<'a>(input: &'a str, marker: ListMarkerInfo<'a>) -> Cow<'a, str> {
7593    let Some(trimmed) = trim_up_to_three_spaces(input) else {
7594        return Cow::Borrowed(marker.content);
7595    };
7596    let after_marker = &trimmed[marker.marker_len..];
7597    if after_marker.starts_with('\t') {
7598        strip_leading_indent_columns_from(after_marker, 1, marker.indent + marker.marker_len)
7599    } else {
7600        Cow::Borrowed(marker.content)
7601    }
7602}
7603
7604fn is_list_padding_byte(byte: Option<u8>) -> bool {
7605    matches!(byte, None | Some(b' ' | b'\t'))
7606}
7607
7608fn same_list_marker(left: ListMarkerInfo<'_>, right: ListMarkerInfo<'_>) -> bool {
7609    // CommonMark §5.3: list items belong to the same list when they share a
7610    // bullet character or ordered delimiter. Indentation does not enter into
7611    // it — `- foo\n - bar\n  - baz` is one four-item bullet list, not three.
7612    left.ordered == right.ordered && left.delimiter == right.delimiter
7613}
7614
7615/// Whether `input` begins a *sibling* item of the current list item.
7616///
7617/// A same-delimiter marker is a sibling only when it is not indented far enough
7618/// to nest inside the current item — i.e. its indent is less than the item's
7619/// `content_indent`. A marker indented at or beyond the content start belongs to
7620/// a sublist within the item and is consumed as item content instead.
7621fn sibling_list_marker_at_line(
7622    input: &str,
7623    first_marker: ListMarkerInfo<'_>,
7624    content_indent: usize,
7625) -> bool {
7626    list_marker_info(input).is_some_and(|candidate| {
7627        same_list_marker(first_marker, candidate) && candidate.indent < content_indent
7628    })
7629}
7630
7631/// Whether `input` begins a list marker belonging to the same list as
7632/// `first_marker` (same ordered/unordered kind and delimiter). Used to tell a
7633/// marker that merely continues the current list apart from one that, by
7634/// changing the marker type, starts a new list (CommonMark §5.3).
7635fn same_list_marker_line(input: &str, first_marker: ListMarkerInfo<'_>) -> bool {
7636    list_marker_info(input).is_some_and(|candidate| same_list_marker(first_marker, candidate))
7637}
7638
7639fn next_nonblank_line(lines: &[Line<'_>], mut index: usize) -> usize {
7640    while index < lines.len() && lines[index].text.trim().is_empty() {
7641        index += 1;
7642    }
7643    index
7644}
7645
7646fn leading_indent(input: &str) -> (usize, usize) {
7647    let mut column = 0usize;
7648    let mut bytes = 0usize;
7649    for byte in input.as_bytes() {
7650        match *byte {
7651            b' ' => column += 1,
7652            b'\t' => column += 4 - (column % 4),
7653            _ => break,
7654        }
7655        bytes += 1;
7656    }
7657    (column, bytes)
7658}
7659
7660fn leading_indent_columns(input: &str) -> usize {
7661    leading_indent(input).0
7662}
7663
7664/// Removes up to `max_columns` columns of leading whitespace, stopping at the
7665/// first non-space/tab byte (tabs advance to the next 4-column tab stop). A tab
7666/// that straddles the column budget is PARTIALLY consumed: the columns beyond the
7667/// budget are re-emitted as spaces (CommonMark tab-expansion of indentation), so
7668/// the result may be an owned `String`. Whitespace already at/over the budget
7669/// (and any literal tab whose start sits at the budget) is returned verbatim.
7670fn strip_leading_indent_columns(input: &str, max_columns: usize) -> Cow<'_, str> {
7671    strip_leading_indent_columns_from(input, max_columns, 0)
7672}
7673
7674fn strip_leading_indent_columns_from(
7675    input: &str,
7676    max_columns: usize,
7677    start_column: usize,
7678) -> Cow<'_, str> {
7679    let mut column = start_column;
7680    let target_column = start_column + max_columns;
7681    for (index, byte) in input.as_bytes().iter().enumerate() {
7682        let next = match *byte {
7683            b' ' => column + 1,
7684            b'\t' => column + (4 - (column % 4)),
7685            _ => return Cow::Borrowed(&input[index..]),
7686        };
7687        if next > target_column {
7688            // A tab whose expansion crosses the budget (its start still inside the
7689            // budget) is split: the over-budget columns survive as spaces.
7690            if *byte == b'\t' && column < target_column {
7691                let residual = next - target_column;
7692                let mut owned = String::with_capacity(residual + input.len() - (index + 1));
7693                for _ in 0..residual {
7694                    owned.push(' ');
7695                }
7696                let mut rest_column = next;
7697                let mut rest_index = index + 1;
7698                while let Some(rest_byte) = input.as_bytes().get(rest_index) {
7699                    match *rest_byte {
7700                        b' ' => {
7701                            owned.push(' ');
7702                            rest_column += 1;
7703                            rest_index += 1;
7704                        }
7705                        b'\t' => {
7706                            let width = 4 - (rest_column % 4);
7707                            for _ in 0..width {
7708                                owned.push(' ');
7709                            }
7710                            rest_column += width;
7711                            rest_index += 1;
7712                        }
7713                        _ => break,
7714                    }
7715                }
7716                owned.push_str(&input[rest_index..]);
7717                return Cow::Owned(owned);
7718            }
7719            return Cow::Borrowed(&input[index..]);
7720        }
7721        column = next;
7722    }
7723    Cow::Borrowed("")
7724}
7725
7726fn strip_list_continuation(input: &str, content_indent: usize, list_indent: usize) -> Cow<'_, str> {
7727    let (indent_columns, indent_bytes) = leading_indent(input);
7728    if indent_columns >= content_indent {
7729        // Remove exactly `content_indent` columns. A tab straddling that budget
7730        // is split: the columns past the budget survive as spaces (CommonMark
7731        // tab expansion of list-item indentation), so a `\t`-only line inside a
7732        // 2-column item keeps the residual two spaces instead of vanishing.
7733        strip_leading_indent_columns(input, content_indent)
7734    } else if indent_columns > list_indent {
7735        Cow::Borrowed(&input[indent_bytes..])
7736    } else {
7737        Cow::Borrowed(trim_ascii_start(input))
7738    }
7739}
7740
7741fn take_task_marker_from_children(children: &mut [Block]) -> Option<bool> {
7742    let Some(Block::Paragraph(paragraph)) = children.first_mut() else {
7743        return None;
7744    };
7745    take_task_marker_from_inlines(&mut paragraph.children)
7746}
7747
7748fn take_task_marker_from_inlines(inlines: &mut Vec<Inline>) -> Option<bool> {
7749    let Some(Inline::Text(text)) = inlines.first() else {
7750        return None;
7751    };
7752    let first = text.value.clone();
7753
7754    if let Some((checked, consumed)) = task_marker_inline_prefix(&first) {
7755        if !first[consumed..].is_empty() || inlines_have_content_after(inlines, 1) {
7756            remove_text_prefix(inlines, consumed);
7757            return Some(checked);
7758        }
7759    }
7760
7761    if let Some(checked) = task_marker_at_text_end(&first) {
7762        if inlines
7763            .get(1)
7764            .is_some_and(|inline| matches!(inline, Inline::SoftBreak(_)))
7765            && inlines_have_content_after(inlines, 2)
7766        {
7767            inlines.remove(1);
7768            inlines.remove(0);
7769            return Some(checked);
7770        }
7771    }
7772
7773    if task_marker_split_open(&first)
7774        && inlines
7775            .get(1)
7776            .is_some_and(|inline| matches!(inline, Inline::SoftBreak(_)))
7777    {
7778        let Some(Inline::Text(next)) = inlines.get(2) else {
7779            return None;
7780        };
7781        if let Some((checked, consumed)) = task_marker_split_close_prefix(&next.value) {
7782            if !next.value[consumed..].is_empty() || inlines_have_content_after(inlines, 3) {
7783                inlines.remove(1);
7784                inlines.remove(0);
7785                remove_text_prefix(inlines, consumed);
7786                return Some(checked);
7787            }
7788        }
7789    }
7790
7791    None
7792}
7793
7794fn task_marker_inline_prefix(input: &str) -> Option<(bool, usize)> {
7795    let start = leading_trim_bytes(input);
7796    let rest = &input[start..];
7797    let checked = task_marker_checked(rest)?;
7798    let after_marker = start + 3;
7799    match input.as_bytes().get(after_marker) {
7800        Some(b' ' | b'\t') => Some((checked, after_marker + 1)),
7801        _ => None,
7802    }
7803}
7804
7805fn task_marker_at_text_end(input: &str) -> Option<bool> {
7806    let start = leading_trim_bytes(input);
7807    let rest = &input[start..];
7808    let checked = task_marker_checked(rest)?;
7809    if rest.len() == 3 {
7810        Some(checked)
7811    } else {
7812        None
7813    }
7814}
7815
7816fn task_marker_split_open(input: &str) -> bool {
7817    let start = leading_trim_bytes(input);
7818    input[start..] == *"["
7819}
7820
7821fn task_marker_split_close_prefix(input: &str) -> Option<(bool, usize)> {
7822    match input.as_bytes().get(..2)? {
7823        b"] " => Some((false, 2)),
7824        b"]\t" => Some((false, 2)),
7825        b"x]" | b"X]" if matches!(input.as_bytes().get(2), Some(b' ' | b'\t')) => Some((true, 3)),
7826        _ => None,
7827    }
7828}
7829
7830fn task_marker_checked(input: &str) -> Option<bool> {
7831    if input.starts_with("[ ]") {
7832        Some(false)
7833    } else if input.starts_with("[x]") || input.starts_with("[X]") {
7834        Some(true)
7835    } else {
7836        None
7837    }
7838}
7839
7840fn remove_text_prefix(inlines: &mut Vec<Inline>, consumed: usize) {
7841    if let Some(Inline::Text(text)) = inlines.first_mut() {
7842        text.value = text.value[consumed..].into();
7843        if text.value.is_empty() {
7844            inlines.remove(0);
7845        }
7846    }
7847}
7848
7849fn inlines_have_content_after(inlines: &[Inline], start: usize) -> bool {
7850    inlines.iter().skip(start).any(|inline| match inline {
7851        Inline::Text(text) => !text.value.is_empty(),
7852        Inline::SoftBreak(_) | Inline::LineBreak(_) => false,
7853        _ => true,
7854    })
7855}
7856
7857fn update_list_item_fence(line: &str, open_fence: &mut Option<(FenceMarker, usize)>) {
7858    let Some(trimmed) = trim_up_to_three_spaces(line) else {
7859        return;
7860    };
7861    if let Some((marker, length)) = *open_fence {
7862        if fence_close(trimmed, marker, length) {
7863            *open_fence = None;
7864        }
7865        return;
7866    }
7867    if let Some((marker, length)) = fence_start(trimmed) {
7868        *open_fence = Some((marker, length));
7869    }
7870}
7871
7872fn trim_ascii_start(input: &str) -> &str {
7873    input.trim_start_matches(|char| matches!(char, ' ' | '\t'))
7874}
7875
7876fn leading_trim_bytes(input: &str) -> usize {
7877    input.len() - trim_ascii_start(input).len()
7878}
7879
7880fn parse_table_delimiter(input: &str, spoiler: bool) -> Option<Vec<TableAlignment>> {
7881    let cells = split_table_row(input, spoiler);
7882    if cells.is_empty() {
7883        return None;
7884    }
7885    let mut alignments = Vec::new();
7886    for cell in cells {
7887        alignments.push(table_delimiter_alignment(cell.trim())?);
7888    }
7889    Some(alignments)
7890}
7891
7892// A delimiter cell is `:?` `-`+ `:?` once trimmed: colons only at the
7893// boundaries, the dashes contiguous, no interior space or colon.
7894fn table_delimiter_alignment(cell: &str) -> Option<TableAlignment> {
7895    let bytes = cell.as_bytes();
7896    let mut cursor = 0;
7897    let left = bytes.first() == Some(&b':');
7898    if left {
7899        cursor += 1;
7900    }
7901    let dash_start = cursor;
7902    while bytes.get(cursor) == Some(&b'-') {
7903        cursor += 1;
7904    }
7905    if cursor == dash_start {
7906        return None;
7907    }
7908    let right = bytes.get(cursor) == Some(&b':');
7909    if right {
7910        cursor += 1;
7911    }
7912    if cursor != bytes.len() {
7913        return None;
7914    }
7915    Some(match (left, right) {
7916        (true, true) => TableAlignment::Center,
7917        (true, false) => TableAlignment::Left,
7918        (false, true) => TableAlignment::Right,
7919        (false, false) => TableAlignment::None,
7920    })
7921}
7922
7923/// Normalizes a table line's leading indentation: when indented code is enabled
7924/// a four-space indent would start a code block, so up to three leading spaces
7925/// are trimmed and four or more disqualifies the line.
7926fn table_indent_line(input: &str, indented_code: bool) -> Option<&str> {
7927    if indented_code {
7928        trim_up_to_three_spaces(input)
7929    } else {
7930        Some(input)
7931    }
7932}
7933
7934/// The cell-delimiter pipes of a table row, in order: every `|` that is not
7935/// escaped by an odd backslash run and not held inside a cell by a code span or
7936/// a spoiler.
7937///
7938/// A single unescaped pipe always delimits, even inside a code span; a code
7939/// span only keeps the bars of a `||` from delimiting. With spoilers enabled,
7940/// bar runs outside code spans pair first-closer style, as the inline parser
7941/// pairs them within one cell: a run opens with its last two bars, the next run
7942/// closes with its first two (and opens again with what it has left), and every
7943/// pipe between the two stays in the cell. The other bars of a run, and both
7944/// bars of an opener with no closer, delimit. Runs are read as the cell's text
7945/// has them, where an escaped pipe has lost its backslash and joins the bars
7946/// beside it; it never delimits, and a pair that uses one forms only when no
7947/// pipe that delimits lies between its bars, so it never joins two cells. Code
7948/// spans are matched as the inline parser matches them, and only when the span
7949/// closes before any pipe that would delimit inside it, so it lies within one
7950/// cell. The pairing does not consider other inline constructs.
7951pub(crate) fn table_row_delimiters(row: &str, spoiler: bool) -> Vec<usize> {
7952    scan_table_row(row, spoiler).0
7953}
7954
7955/// The delimiters of a table row and the spans of the spoilers its cells will
7956/// hold, from each opener's first bar to its closer's last.
7957fn scan_table_row(row: &str, spoiler: bool) -> (Vec<usize>, Vec<(usize, usize)>) {
7958    let bytes = row.as_bytes();
7959
7960    // Every pipe, grouped into the bar runs of the cell text.
7961    let mut bars: Vec<TableBar> = Vec::new();
7962    let mut runs: Vec<(usize, usize)> = Vec::new();
7963    let mut backticks = false;
7964    let mut cursor = 0;
7965    while cursor < bytes.len() {
7966        match bytes[cursor] {
7967            b'\\' => {
7968                let backslashes = delimiter_byte_run_len(row, cursor, b'\\');
7969                let pipe = cursor + backslashes;
7970                if bytes.get(pipe) == Some(&b'|') && backslashes % 2 == 1 {
7971                    // The cell text drops one backslash, so with exactly one
7972                    // the pipe follows whatever precedes the run.
7973                    let text_start = if backslashes == 1 { cursor } else { pipe };
7974                    push_table_bar(&mut bars, &mut runs, pipe, text_start, true);
7975                    cursor = pipe + 1;
7976                } else {
7977                    cursor = pipe;
7978                }
7979            }
7980            b'|' => {
7981                push_table_bar(&mut bars, &mut runs, cursor, cursor, false);
7982                cursor += 1;
7983            }
7984            byte => {
7985                backticks |= byte == b'`';
7986                cursor += 1;
7987            }
7988        }
7989    }
7990    if !spoiler || (!backticks && runs.iter().all(|&(_, length)| length == 1)) {
7991        // With no bar runs and no code spans, every unescaped pipe delimits.
7992        let delimiters = bars
7993            .iter()
7994            .filter(|bar| !bar.escaped)
7995            .map(|bar| bar.position)
7996            .collect();
7997        return (delimiters, Vec::new());
7998    }
7999
8000    // Inside a code span, two unescaped bars side by side stay in the cell and
8001    // any other unescaped bar delimits.
8002    let mut code_delimits = vec![false; bars.len()];
8003    for &(first, length) in &runs {
8004        let mut bar = first;
8005        while bar < first + length {
8006            if bar + 1 < first + length && !bars[bar].escaped && !bars[bar + 1].escaped {
8007                bar += 2;
8008            } else {
8009                code_delimits[bar] = !bars[bar].escaped;
8010                bar += 1;
8011            }
8012        }
8013    }
8014
8015    // A code span forms only when no bar that would delimit inside it lies
8016    // before its closer.
8017    let mut breaker = 0;
8018    let code_spans = if backticks {
8019        inline_code_spans(row, |open, close| {
8020            while breaker < bars.len() && (bars[breaker].position < open || !code_delimits[breaker])
8021            {
8022                breaker += 1;
8023            }
8024            bars.get(breaker).is_none_or(|bar| close < bar.position)
8025        })
8026    } else {
8027        Vec::new()
8028    };
8029
8030    // Decide which bars delimit; `held` collects the ranges a spoiler keeps in
8031    // its cell. `blocked` records a bar that would delimit since the opener.
8032    let mut delimits = vec![false; bars.len()];
8033    let mut held = Vec::new();
8034    let mut opener: Option<usize> = None;
8035    let mut blocked = false;
8036    let mut code = code_spans.iter().peekable();
8037    for (first, length) in runs {
8038        let end = first + length;
8039        let position = bars[first].position;
8040        while code.peek().is_some_and(|&&(_, close)| close < position) {
8041            code.next();
8042        }
8043        if code.peek().is_some_and(|&&(open, _)| open < position) {
8044            delimits[first..end].copy_from_slice(&code_delimits[first..end]);
8045            blocked |= code_delimits[first..end].contains(&true);
8046            continue;
8047        }
8048        if length == 1 {
8049            delimits[first] = true;
8050            blocked |= !bars[first].escaped;
8051            continue;
8052        }
8053        let mut rest = first;
8054        if let Some(opened) = opener.take() {
8055            let escaped = bars[opened..opened + 2]
8056                .iter()
8057                .chain(&bars[first..first + 2])
8058                .any(|bar| bar.escaped);
8059            if escaped && blocked {
8060                delimits[opened..opened + 2].fill(true);
8061            } else {
8062                held.push((bars[opened].position, bars[first + 1].position));
8063                rest = first + 2;
8064            }
8065        }
8066        let mut open_at = end;
8067        if end - rest >= 2 {
8068            open_at = end - 2;
8069            opener = Some(open_at);
8070            blocked = false;
8071        }
8072        delimits[rest..open_at].fill(true);
8073    }
8074    if let Some(opened) = opener {
8075        delimits[opened..opened + 2].fill(true);
8076    }
8077
8078    let mut spoilers = held.iter().copied().peekable();
8079    let mut delimiters = Vec::new();
8080    for (bar, delimit) in bars.iter().zip(delimits) {
8081        while spoilers.peek().is_some_and(|&(_, end)| end < bar.position) {
8082            spoilers.next();
8083        }
8084        let in_spoiler = spoilers
8085            .peek()
8086            .is_some_and(|&(start, end)| start <= bar.position && bar.position <= end);
8087        if delimit && !bar.escaped && !in_spoiler {
8088            delimiters.push(bar.position);
8089        }
8090    }
8091    (delimiters, held)
8092}
8093
8094/// The code spans of `text` as the inline parser reads them, each as the
8095/// offsets of its opening and closing backtick runs: a backtick run opens a
8096/// span that the next run of the same length closes, except that outside a
8097/// span an odd backslash run escapes the first backtick after it.
8098/// `may_close(open, close)` can refuse a pair, leaving the opener literal; it
8099/// is asked in increasing order of `open`.
8100fn inline_code_spans(
8101    text: &str,
8102    mut may_close: impl FnMut(usize, usize) -> bool,
8103) -> Vec<(usize, usize)> {
8104    let bytes = text.as_bytes();
8105    // Each backtick run's offset, length, and whether an odd backslash run
8106    // precedes it.
8107    let mut runs = Vec::new();
8108    let mut cursor = 0;
8109    while cursor < bytes.len() {
8110        match bytes[cursor] {
8111            b'\\' => {
8112                let backslashes = delimiter_byte_run_len(text, cursor, b'\\');
8113                cursor += backslashes;
8114                if bytes.get(cursor) == Some(&b'`') {
8115                    let length = delimiter_byte_run_len(text, cursor, b'`');
8116                    runs.push((cursor, length, backslashes % 2 == 1));
8117                    cursor += length;
8118                }
8119            }
8120            b'`' => {
8121                let length = delimiter_byte_run_len(text, cursor, b'`');
8122                runs.push((cursor, length, false));
8123                cursor += length;
8124            }
8125            _ => cursor += 1,
8126        }
8127    }
8128    let longest = runs.iter().map(|&(_, length, _)| length).max().unwrap_or(0);
8129    let mut by_length = vec![Vec::new(); longest + 1];
8130    for (index, &(_, length, _)) in runs.iter().enumerate() {
8131        by_length[length].push(index);
8132    }
8133
8134    let mut spans = Vec::new();
8135    let mut index = 0;
8136    while index < runs.len() {
8137        let (position, length, escaped) = runs[index];
8138        let (open, length) = if escaped {
8139            (position + 1, length - 1)
8140        } else {
8141            (position, length)
8142        };
8143        let close = by_length[length]
8144            .get(by_length[length].partition_point(|&later| later <= index))
8145            .copied()
8146            .filter(|_| length > 0);
8147        match close {
8148            Some(close) if may_close(open, runs[close].0) => {
8149                spans.push((open, runs[close].0));
8150                index = close + 1;
8151            }
8152            _ => index += 1,
8153        }
8154    }
8155    spans
8156}
8157
8158/// A pipe of a table row.
8159struct TableBar {
8160    position: usize,
8161    escaped: bool,
8162}
8163
8164/// Adds the pipe at `position` to the bar runs, joining the previous run when
8165/// the cell text has the two side by side.
8166fn push_table_bar(
8167    bars: &mut Vec<TableBar>,
8168    runs: &mut Vec<(usize, usize)>,
8169    position: usize,
8170    text_start: usize,
8171    escaped: bool,
8172) {
8173    let joins = bars
8174        .last()
8175        .is_some_and(|previous| previous.position + 1 == text_start);
8176    match runs.last_mut() {
8177        Some(run) if joins => run.1 += 1,
8178        _ => runs.push((bars.len(), 1)),
8179    }
8180    bars.push(TableBar { position, escaped });
8181}
8182
8183fn split_table_row(input: &str, spoiler: bool) -> Vec<String> {
8184    let trimmed = input.trim();
8185    let delimiters = table_row_delimiters(trimmed, spoiler);
8186    let mut cells = Vec::new();
8187    let mut start = 0;
8188    for &pipe in &delimiters {
8189        cells.push(table_cell_text(&trimmed[start..pipe]));
8190        start = pipe + 1;
8191    }
8192    cells.push(table_cell_text(&trimmed[start..]));
8193
8194    // A delimiter at the very start or end (only whitespace after it) is a
8195    // border, not the edge of an empty cell.
8196    if delimiters.first() == Some(&0) {
8197        cells.remove(0);
8198    }
8199    if delimiters
8200        .last()
8201        .is_some_and(|&pipe| trimmed[pipe + 1..].trim().is_empty())
8202    {
8203        cells.pop();
8204    }
8205    cells
8206}
8207
8208/// A cell's source with each escaped pipe unescaped. GitHub/cmark-gfm treats an
8209/// odd backslash run before `|` as a literal cell-content pipe; the run keeps
8210/// its other backslashes so the inline parser resolves them as written.
8211fn table_cell_text(source: &str) -> String {
8212    let bytes = source.as_bytes();
8213    let mut cell = String::with_capacity(source.len());
8214    let mut copied = 0;
8215    let mut cursor = 0;
8216    while cursor < bytes.len() {
8217        if bytes[cursor] == b'\\' {
8218            let pipe = cursor + delimiter_byte_run_len(source, cursor, b'\\');
8219            if bytes.get(pipe) == Some(&b'|') && (pipe - cursor) % 2 == 1 {
8220                cell.push_str(&source[copied..pipe - 1]);
8221                copied = pipe;
8222            }
8223            cursor = pipe;
8224        } else {
8225            cursor += 1;
8226        }
8227    }
8228    cell.push_str(&source[copied..]);
8229    cell
8230}
8231
8232fn table_can_start(lines: &[Line<'_>], index: usize, options: &SyntaxOptions) -> bool {
8233    if !options.constructs.gfm_table || index + 1 >= lines.len() {
8234        return false;
8235    }
8236    table_can_start_source(
8237        lines[index].text,
8238        lines[index + 1].text,
8239        options.constructs.indented_code,
8240        options.constructs.spoiler,
8241    )
8242}
8243
8244pub(crate) fn gfm_table_can_start_source(header: &str, delimiter: &str) -> bool {
8245    table_can_start_source(header, delimiter, true, false)
8246}
8247
8248fn table_can_start_source(
8249    header: &str,
8250    delimiter: &str,
8251    indented_code: bool,
8252    spoiler: bool,
8253) -> bool {
8254    let Some(delimiter) = table_indent_line(delimiter, indented_code) else {
8255        return false;
8256    };
8257    if list_marker_info(delimiter).is_some() {
8258        return false;
8259    }
8260    if !table_has_separator(header, delimiter, spoiler) {
8261        return false;
8262    }
8263    let Some(alignments) = parse_table_delimiter(delimiter, spoiler) else {
8264        return false;
8265    };
8266    split_table_row(header, spoiler).len() == alignments.len()
8267}
8268
8269fn table_has_separator(header: &str, delimiter: &str, spoiler: bool) -> bool {
8270    // GFM makes leading/trailing pipes optional, so `parse_table_delimiter` plus
8271    // the header/alignment column-count check usually suffice. The one exception
8272    // is a single resolved column with no disambiguating syntax: `a\n-\nb` has
8273    // matching one-column shapes yet no pipe and no alignment colon, so it is a
8274    // loose paragraph/setext, not a table. A single column still forms a table
8275    // when a pipe appears in the header/delimiter or the delimiter carries an
8276    // explicit alignment colon (`a\n-:`, `a\n:-:`, …).
8277    let Some(alignments) = parse_table_delimiter(delimiter, spoiler) else {
8278        return true;
8279    };
8280    if alignments.len() == 1 {
8281        return contains_unescaped_pipe(header, spoiler)
8282            || contains_unescaped_pipe(delimiter, spoiler)
8283            || delimiter.contains(':');
8284    }
8285    true
8286}
8287
8288// Still used by `block_quote_table_body_row` to detect a table row appearing as
8289// a block-quote continuation line (which DOES require a pipe).
8290fn contains_unescaped_pipe(input: &str, spoiler: bool) -> bool {
8291    !table_row_delimiters(input, spoiler).is_empty()
8292}
8293
8294fn likely_block_start(input: &str, options: &SyntaxOptions) -> bool {
8295    // Block-structure markers (ATX, fences, thematic breaks, list markers, math
8296    // fences, directives, …) only begin a block when indented at most 3 columns.
8297    // At >=4 columns the line is indented code, which never interrupts a
8298    // paragraph, so no marker test should fire.
8299    let Some(trimmed) = trim_up_to_three_spaces(input) else {
8300        return false;
8301    };
8302    trimmed.starts_with('#')
8303        || trimmed.starts_with('>')
8304        || trimmed.starts_with("```")
8305        || trimmed.starts_with("~~~")
8306        || list_marker_can_interrupt_paragraph(input)
8307        || parse_thematic_break(Line {
8308            text: input,
8309            eol: "",
8310            start: 0,
8311            end: input.len(),
8312            end_with_eol: input.len(),
8313            lazy: false,
8314        })
8315        .is_some()
8316        || (options.constructs.html_container && line_starts_html_container(input))
8317        || (options.constructs.html_block && line_starts_interrupting_html_block(input))
8318        || (options.constructs.math_block && math_block_fence_length(trimmed).is_some())
8319        || (options.constructs.directive_container && trimmed.starts_with(":::"))
8320        || (options.constructs.directive_leaf && trimmed.starts_with("::"))
8321        || (options.constructs.footnote_definition && line_starts_footnote_definition(trimmed))
8322}
8323
8324// A GFM footnote definition `[^label]:` is a block boundary: it interrupts a
8325// paragraph and ends a prior footnote's lazy continuation.
8326fn line_starts_footnote_definition(trimmed: &str) -> bool {
8327    trimmed.starts_with("[^")
8328        && find_footnote_definition_label_end(trimmed)
8329            .is_some_and(|close| is_footnote_label(&trimmed[2..close]))
8330}
8331
8332fn list_marker_can_interrupt_paragraph(input: &str) -> bool {
8333    list_marker_info(input).is_some_and(|marker| {
8334        // An empty list item never interrupts a paragraph (CommonMark §5.3):
8335        // `foo\n*` is a single paragraph, not a paragraph plus an empty list.
8336        !marker.content.trim().is_empty() && (!marker.ordered || marker.start == Some(1))
8337    })
8338}
8339
8340// GFM table-body termination is stricter than paragraph interruption: an open
8341// table also ends on a list marker with EMPTY content (`-`, `*`, `1.`), which
8342// `likely_block_start` deliberately ignores for paragraphs. Used only by the
8343// table body loop; `likely_block_start` itself is left untouched.
8344fn table_body_line_ends_table(line: &str, options: &SyntaxOptions) -> bool {
8345    likely_block_start(line, options)
8346        || list_marker_info(line).is_some()
8347        || (options.constructs.html_container && line_starts_html_container(line))
8348        || (options.constructs.html_block && line_starts_html_block(line))
8349}
8350
8351fn line_starts_interrupting_html_block(input: &str) -> bool {
8352    match trim_up_to_three_spaces(input).and_then(html_block_start) {
8353        Some(HtmlBlockKind::UntilBlank) | None => false,
8354        Some(_) => true,
8355    }
8356}
8357
8358/// The end of the `<…>` autolink opening at `index`.
8359fn autolink_end(lookups: &mut impl Lookups, input: &str, index: usize) -> Option<usize> {
8360    let end = lookups.find(">", index)? + 1;
8361    is_autolink(&input[index..end]).then_some(end)
8362}
8363
8364/// The end of the inline HTML (comment, processing instruction, CDATA,
8365/// declaration, or tag) opening at `index`.
8366fn html_inline_end(lookups: &mut impl Lookups, input: &str, index: usize) -> Option<usize> {
8367    let rest = &input[index..];
8368    if rest.starts_with("<!--") {
8369        return Some(lookups.find("-->", index)? + 3);
8370    }
8371    if rest.starts_with("<?") {
8372        return Some(lookups.find("?>", index)? + 2);
8373    }
8374    if rest.starts_with("<![CDATA[") {
8375        return Some(lookups.find("]]>", index)? + 3);
8376    }
8377    if is_declaration_start(rest) {
8378        return Some(lookups.find(">", index)? + 1);
8379    }
8380    parse_html_tag_with(lookups, input, index).map(|(end, _)| end)
8381}
8382
8383fn parse_html_tag(input: &str, index: usize) -> Option<(usize, &str)> {
8384    parse_html_tag_with(&mut DirectLookups { input }, input, index)
8385}
8386
8387fn parse_html_tag_with<'a>(
8388    lookups: &mut impl Lookups,
8389    input: &'a str,
8390    index: usize,
8391) -> Option<(usize, &'a str)> {
8392    let bytes = input.as_bytes();
8393    if bytes.get(index) != Some(&b'<') {
8394        return None;
8395    }
8396
8397    let closing = bytes.get(index + 1) == Some(&b'/');
8398    let name_start = index + if closing { 2 } else { 1 };
8399    let first = *bytes.get(name_start)?;
8400    if !first.is_ascii_alphabetic() {
8401        return None;
8402    }
8403
8404    let mut cursor = name_start + 1;
8405    while bytes.get(cursor).is_some_and(|byte| html_name_byte(*byte)) {
8406        cursor += 1;
8407    }
8408    let name = &input[name_start..cursor];
8409
8410    if closing {
8411        cursor = skip_spaces(input, cursor);
8412        if bytes.get(cursor) == Some(&b'>') {
8413            return Some((cursor + 1, name));
8414        }
8415        return None;
8416    }
8417
8418    let mut needs_space = false;
8419    loop {
8420        let before_spaces = cursor;
8421        cursor = skip_spaces(input, cursor);
8422        let had_space = cursor > before_spaces;
8423        match bytes.get(cursor) {
8424            Some(b'>') => return Some((cursor + 1, name)),
8425            Some(b'/') if bytes.get(cursor + 1) == Some(&b'>') => return Some((cursor + 2, name)),
8426            Some(byte) if had_space && html_attribute_name_start(*byte) => {
8427                cursor += 1;
8428                while bytes
8429                    .get(cursor)
8430                    .is_some_and(|byte| html_attribute_name_byte(*byte))
8431                {
8432                    cursor += 1;
8433                }
8434                let after_name = cursor;
8435                let after_spaces = skip_spaces(input, cursor);
8436                if bytes.get(after_spaces) == Some(&b'=') {
8437                    cursor = skip_spaces(input, after_spaces + 1);
8438                    cursor = parse_html_attribute_value(lookups, input, cursor)?;
8439                } else {
8440                    cursor = after_name;
8441                }
8442                needs_space = true;
8443            }
8444            Some(_) if needs_space => return None,
8445            _ => return None,
8446        }
8447    }
8448}
8449
8450fn parse_html_attribute_value(
8451    lookups: &mut impl Lookups,
8452    input: &str,
8453    index: usize,
8454) -> Option<usize> {
8455    let bytes = input.as_bytes();
8456    match bytes.get(index)? {
8457        b'"' => Some(lookups.find("\"", index + 1)? + 1),
8458        b'\'' => Some(lookups.find("'", index + 1)? + 1),
8459        b'=' | b'<' | b'>' | b'`' => None,
8460        _ => {
8461            let mut cursor = index;
8462            while bytes.get(cursor).is_some_and(|byte| {
8463                !byte.is_ascii_whitespace()
8464                    && !matches!(*byte, b'"' | b'\'' | b'=' | b'<' | b'>' | b'`')
8465            }) {
8466                cursor += 1;
8467            }
8468            if cursor == index {
8469                None
8470            } else {
8471                Some(cursor)
8472            }
8473        }
8474    }
8475}
8476
8477fn html_name_byte(byte: u8) -> bool {
8478    byte.is_ascii_alphanumeric() || byte == b'-'
8479}
8480
8481fn html_attribute_name_start(byte: u8) -> bool {
8482    byte.is_ascii_alphabetic() || byte == b'_' || byte == b':'
8483}
8484
8485fn html_attribute_name_byte(byte: u8) -> bool {
8486    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b':' | b'.' | b'-')
8487}
8488
8489fn skip_spaces(input: &str, mut index: usize) -> usize {
8490    while input
8491        .as_bytes()
8492        .get(index)
8493        .is_some_and(|byte| matches!(*byte, b' ' | b'\t' | b'\n' | b'\r'))
8494    {
8495        index += 1;
8496    }
8497    index
8498}
8499
8500fn is_autolink(input: &str) -> bool {
8501    let inner = &input[1..input.len() - 1];
8502    is_uri_autolink(inner) || is_email_autolink(inner)
8503}
8504
8505fn is_uri_autolink(input: &str) -> bool {
8506    // A scheme is at most 32 bytes, so the `:` ending it is within the first 33.
8507    let Some(colon) = input
8508        .as_bytes()
8509        .iter()
8510        .take(33)
8511        .position(|byte| *byte == b':')
8512    else {
8513        return false;
8514    };
8515    let scheme = &input[..colon];
8516    if scheme.len() < 2 || scheme.len() > 32 {
8517        return false;
8518    }
8519    let mut bytes = scheme.bytes();
8520    if !bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic()) {
8521        return false;
8522    }
8523    if !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'.' | b'-')) {
8524        return false;
8525    }
8526    input[colon + 1..]
8527        .chars()
8528        .map(source_char)
8529        .all(|char| !matches!(char, '<' | '>') && !char.is_control() && !char.is_whitespace())
8530}
8531
8532fn is_email_autolink(input: &str) -> bool {
8533    // No email address contains `<`, and stopping at one keeps a run of `<`
8534    // openers that share one later `>` linear.
8535    if input
8536        .chars()
8537        .any(|char| char.is_whitespace() || char == '<')
8538    {
8539        return false;
8540    }
8541    let Some(at) = input.find('@') else {
8542        return false;
8543    };
8544    if at == 0 || at + 1 >= input.len() {
8545        return false;
8546    }
8547    // Angle-bracket `<email>` autolinks use the strict CommonMark domain
8548    // grammar but, unlike the GFM bare form, allow a single (dotless) label.
8549    is_email_local_part(&input[..at]) && is_email_domain(&input[at + 1..], 1)
8550}
8551
8552// GFM literal-autolink dispatch. Tries, in order: `http(s)://` URLs, `www.`
8553// URLs, extended-protocol (`mailto:`/`xmpp:`) emails, and bare emails. Each
8554// branch enforces cmark-gfm's per-scheme preceding-character guard and its
8555// domain/host rules; the trailing trim is shared (`autolink_delim`). The
8556// returned destination is the synthesized href (a `http://`/`mailto:` prefix
8557// may be prepended); the caller keeps `input[index..end]` as the visible
8558// original.
8559fn parse_literal_autolink(
8560    input: &str,
8561    index: usize,
8562    gfm: bool,
8563    relaxed: bool,
8564    scan: &mut LiteralAutolinkScan,
8565) -> Option<(usize, String)> {
8566    let rest = &input[index..];
8567
8568    if gfm {
8569        // `http://` / `https://` URLs. cmark requires the char before the scheme
8570        // to be non-alphanumeric (so `mmmhttp://…` does not link from `mmmh`).
8571        if let Some(scheme_len) = rest
8572            .starts_with("http://")
8573            .then_some(7)
8574            .or_else(|| rest.starts_with("https://").then_some(8))
8575        {
8576            if !literal_scheme_prefix_ok(input, index) {
8577                return None;
8578            }
8579            let host = &input[index + scheme_len..];
8580            // A non-empty domain or bracketed IPv6 host is additionally
8581            // required, so `http://`, `http://#`, `http://$` are not links.
8582            if !http_literal_host_ok(host) {
8583                if relaxed {
8584                    // Let cmark-gfm's relaxed `scheme://` pass decide cases
8585                    // such as a bare `http://` followed by whitespace.
8586                } else {
8587                    return None;
8588                }
8589            } else {
8590                // The URL extent is scanned from the very start (after `://`) and the
8591                // trailing trim runs over the whole URL. Relaxed mode balances
8592                // brackets/braces so `[abc]`/`{abc}`/IPv6 hosts stay in the URL.
8593                let end = autolink_url_end(input, index + scheme_len, index + scheme_len, relaxed);
8594                if end <= index + scheme_len {
8595                    return None;
8596                }
8597                if literal_autolink_suppressed_by_link_label(
8598                    input,
8599                    index,
8600                    end,
8601                    relaxed,
8602                    gfm,
8603                    &mut scan.label_openers,
8604                ) {
8605                    return None;
8606                }
8607                return Some((end, input[index..end].into()));
8608            }
8609        }
8610
8611        // `www.` URLs (synthesize a `http://` href). cmark allows the preceding
8612        // char to be one of `*_~(` or whitespace (or start of input).
8613        if rest
8614            .as_bytes()
8615            .get(..4)
8616            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"www."))
8617        {
8618            if !literal_www_prefix_ok(input, index) {
8619                return None;
8620            }
8621            check_domain(rest, false)?;
8622            let end = autolink_url_end(input, index, index, relaxed);
8623            if end <= index || (!relaxed && end <= index + 3 && !literal_starts_line(input, index))
8624            {
8625                return None;
8626            }
8627            if literal_autolink_suppressed_by_link_label(
8628                input,
8629                index,
8630                end,
8631                relaxed,
8632                gfm,
8633                &mut scan.label_openers,
8634            ) {
8635                return None;
8636            }
8637            let mut destination = String::from("http://");
8638            destination.push_str(&input[index..end]);
8639            return Some((end, destination));
8640        }
8641
8642        if let Some(email) = parse_literal_email(input, index, &mut scan.email_local) {
8643            return Some(email);
8644        }
8645    }
8646
8647    if relaxed {
8648        // cmark-gfm "relaxed" URL autolinks: a bare `scheme://…` for any scheme
8649        // (`smb://`, `irc://`, `rdar://`, `we://`, `nex://[…]`, …) or a
8650        // scheme-less leading `://…` (`://-`). Requires the same non-alphanumeric
8651        // preceding char as the http literal and at least one non-whitespace
8652        // char after `://`; no host/domain validation (cmark-gfm is permissive
8653        // here — `smb:///path` and `://-` both linkify). The extent is balanced.
8654        if literal_scheme_prefix_ok(input, index) {
8655            if let Some(after_slashes) =
8656                relaxed_scheme_after_slashes(input, index, &mut scan.scheme)
8657            {
8658                let body_start = index + after_slashes;
8659                let next = input[body_start..].chars().next();
8660                if next.is_none_or(|char| char.is_whitespace()) && after_slashes == 3 {
8661                    return None;
8662                }
8663                let end = autolink_url_end(input, body_start, body_start, true);
8664                if end > index {
8665                    if literal_autolink_suppressed_by_link_label(
8666                        input,
8667                        index,
8668                        end,
8669                        relaxed,
8670                        gfm,
8671                        &mut scan.label_openers,
8672                    ) {
8673                        return None;
8674                    }
8675                    return Some((end, input[index..end].into()));
8676                }
8677            }
8678        }
8679    }
8680
8681    None
8682}
8683
8684// Returns the byte offset (within `rest`) just past a relaxed `scheme://` (any
8685// ASCII-alpha-then-`[alnum+. -]` scheme) or scheme-less `://` prefix, if `rest`
8686// starts with one. No scheme length cap — cmark-gfm's relaxed autolink is
8687// permissive. Returns `None` for a bare `scheme:` without `//` (that is the
8688// email/angle-autolink path's job).
8689fn relaxed_scheme_after_slashes(
8690    input: &str,
8691    index: usize,
8692    scheme_run: &mut ByteRun,
8693) -> Option<usize> {
8694    let bytes = input[index..].as_bytes();
8695    if bytes.starts_with(b"://") {
8696        return Some(3);
8697    }
8698    let first = bytes.first()?;
8699    if !first.is_ascii_alphabetic() {
8700        return None;
8701    }
8702    // The scheme is the run of scheme bytes starting here, and `://` must
8703    // follow it directly.
8704    let i = scheme_run.end(input, index, is_relaxed_scheme_byte) - index;
8705    if bytes.get(i..i + 3) == Some(b"://") {
8706        Some(i + 3)
8707    } else {
8708        None
8709    }
8710}
8711
8712// The char immediately before a `http(s)://` literal must be non-alphabetic.
8713// An escaped `<` (`\<http://…`) is just literal text before the URL, so the
8714// literal still forms (the `<` is not treated as an angle-autolink opener).
8715fn literal_scheme_prefix_ok(input: &str, index: usize) -> bool {
8716    if index == 0 {
8717        return true;
8718    }
8719    let Some(previous) = input[..index].chars().next_back() else {
8720        return true;
8721    };
8722    !previous.is_ascii_alphabetic()
8723}
8724
8725// The char before a `www.` literal must be one of cmark-gfm's accepted ASCII
8726// delimiters or ordinary Markdown layout whitespace. Unicode whitespace is not
8727// a start delimiter for this branch.
8728fn literal_www_prefix_ok(input: &str, index: usize) -> bool {
8729    if index == 0 {
8730        return true;
8731    }
8732    let Some(previous) = input[..index].chars().next_back() else {
8733        return true;
8734    };
8735    if matches!(previous, '*' | '_' | '~' | '(' | '[' | ']') {
8736        return true;
8737    }
8738    matches!(previous, ' ' | '\t' | '\n' | '\r')
8739}
8740
8741fn literal_starts_line(input: &str, index: usize) -> bool {
8742    index == 0
8743        || input
8744            .as_bytes()
8745            .get(index - 1)
8746            .is_some_and(|byte| matches!(byte, b'\n' | b'\r'))
8747}
8748
8749fn literal_autolink_suppressed_by_link_label(
8750    input: &str,
8751    index: usize,
8752    end: usize,
8753    relaxed: bool,
8754    gfm_autolink_literal: bool,
8755    label_openers: &mut LabelOpenerScan,
8756) -> bool {
8757    if !label_openers.has_unclosed_opener(input, index) {
8758        return false;
8759    }
8760    if input[end..].starts_with("](") && !link_resource_tail_has_close(input, end + 2) {
8761        return true;
8762    }
8763    !relaxed && !gfm_autolink_literal && input.as_bytes().get(end).is_some_and(|byte| *byte == b']')
8764}
8765
8766/// Whether an unclosed `[` precedes a literal autolink on its line: the
8767/// `[`/`]` depth from the line start (honouring `\` escapes) is positive. The
8768/// walk is kept across calls because one inline scan asks about increasing
8769/// positions; it drops back to depth 0 after each line break it consumes, which
8770/// is exactly the state a walk from that line's start reaches.
8771#[derive(Default)]
8772struct LabelOpenerScan {
8773    /// The last position asked about; the walk has stopped at the first step
8774    /// at or after it.
8775    queried: usize,
8776    cursor: usize,
8777    depth: usize,
8778}
8779
8780impl LabelOpenerScan {
8781    fn has_unclosed_opener(&mut self, input: &str, index: usize) -> bool {
8782        if index < self.queried {
8783            *self = Self::default();
8784        }
8785        while self.cursor < index {
8786            let Some((next, char)) = next_char(input, self.cursor) else {
8787                break;
8788            };
8789            match char {
8790                '\\' => {
8791                    // An escaped line break still ends the line, so it is left
8792                    // for the next step to consume as a break.
8793                    self.cursor = match next_char(input, next) {
8794                        Some((_, '\n' | '\r')) | None => next,
8795                        Some((after_escape, _)) => after_escape,
8796                    };
8797                    continue;
8798                }
8799                '\n' | '\r' => self.depth = 0,
8800                '[' => self.depth += 1,
8801                ']' => self.depth = self.depth.saturating_sub(1),
8802                _ => {}
8803            }
8804            self.cursor = next;
8805        }
8806        self.queried = index;
8807        self.depth > 0
8808    }
8809}
8810
8811fn link_resource_tail_has_close(input: &str, start: usize) -> bool {
8812    let mut cursor = start;
8813    while cursor < input.len() {
8814        let Some((next, char)) = next_char(input, cursor) else {
8815            break;
8816        };
8817        match char {
8818            '\\' => {
8819                cursor = next_char(input, next)
8820                    .map(|(after_escape, _)| after_escape)
8821                    .unwrap_or(next);
8822                continue;
8823            }
8824            '\n' | '\r' => return false,
8825            ')' => return true,
8826            _ => {}
8827        }
8828        cursor = next;
8829    }
8830    false
8831}
8832
8833fn http_literal_host_ok(host: &str) -> bool {
8834    if host.starts_with('[') {
8835        return bracketed_ipv6_host_end(host).is_some();
8836    }
8837    match host.chars().next() {
8838        Some(char) if char.is_ascii() && char.is_ascii_alphanumeric() => {
8839            check_domain(host, true).is_some()
8840        }
8841        Some(char) if !char.is_ascii() && is_valid_hostchar(source_char(char)) => {
8842            check_domain(host, true).is_some()
8843        }
8844        _ => false,
8845    }
8846}
8847
8848fn bracketed_ipv6_host_end(host: &str) -> Option<usize> {
8849    let close = host.find(']')?;
8850    (close > 1).then_some(close + 1)
8851}
8852
8853// Port of cmark-gfm `is_valid_hostchar`: a host char is valid when it is not a
8854// Unicode space and not a Unicode punctuation character.
8855fn is_valid_hostchar(char: char) -> bool {
8856    !char.is_whitespace() && !crate::unicode_punctuation::is_unicode_punctuation(char)
8857}
8858
8859// Port of cmark-gfm `check_domain`. Scans the leading host of `data` (up to the
8860// first non-host char) and returns its byte length, or `None` when invalid.
8861// Rejects a `_` in either of the last two `.`-separated host segments (unless
8862// the host has >10 segments — a DoS guard). When `allow_short` is false a dot
8863// is required (the `www.` rule). The URL extent past the host is determined by
8864// `autolink_url_end`, so the precise length here only gates validity.
8865//
8866// cmark walks bytes with `is_valid_hostchar` decoding each char; this walks
8867// chars directly (UTF-8 safe) over the host prefix, which yields the same
8868// dot/underscore-segment verdict. A `\` escapes the following char.
8869fn check_domain(data: &str, allow_short: bool) -> Option<usize> {
8870    let mut np = 0usize;
8871    let mut uscore1 = 0usize;
8872    let mut uscore2 = 0usize;
8873    let mut host_len = 0usize;
8874
8875    let mut chars = data.char_indices().peekable();
8876    while let Some((offset, char)) = chars.next() {
8877        // cmark's accounting loop runs `for (i = 1; i < size - 1; i++)`: it
8878        // never inspects the first char (offset 0) nor the final char of the
8879        // chunk. We replicate that — a trailing `_` (e.g. `http://a_`) is not
8880        // counted, so the link still forms.
8881        let account = offset != 0 && chars.peek().is_some();
8882        match char {
8883            '\\' => {
8884                // Escape: consume the next char as a literal host char.
8885                host_len = offset + char.len_utf8();
8886                if let Some((next_off, next)) = chars.next() {
8887                    host_len = next_off + next.len_utf8();
8888                }
8889            }
8890            '_' if account => {
8891                uscore2 += 1;
8892                host_len = offset + char.len_utf8();
8893            }
8894            '.' if account => {
8895                uscore1 = uscore2;
8896                uscore2 = 0;
8897                np += 1;
8898                host_len = offset + char.len_utf8();
8899            }
8900            '_' | '.' | '-' => {
8901                host_len = offset + char.len_utf8();
8902            }
8903            _ => {
8904                if !is_valid_hostchar(source_char(char)) {
8905                    break;
8906                }
8907                host_len = offset + char.len_utf8();
8908            }
8909        }
8910    }
8911
8912    if (uscore1 > 0 || uscore2 > 0) && np <= 10 {
8913        return None;
8914    }
8915
8916    if allow_short || np > 0 {
8917        Some(host_len)
8918    } else {
8919        None
8920    }
8921}
8922
8923// Forward scan from `start` for the URL extent: every char up to whitespace,
8924// `<`, or `]` ends the URL. CommonMark allows `>` and `[` inside (the renderer
8925// percent-encodes them); a `]` is additionally treated as a hard URL boundary
8926// (autolink-3), so a `]` ends the scan and is never part of the link.
8927// `trim_from` is where the trailing trim may reach (the URL start).
8928fn autolink_url_end(input: &str, start: usize, trim_from: usize, balanced: bool) -> usize {
8929    let bytes = input.as_bytes();
8930    let mut end = start;
8931    // Relaxed (cmark-gfm) URL extents balance `[`/`]` and `{`/`}` so an IPv6
8932    // host `nex://[fe80…]/z` and a balanced `[abc]`/`{abc}` run stay inside the
8933    // URL while an unbalanced trailing `]`/`}` ends it. Strict (GFM literal)
8934    // extents stop at the first `]` (no balancing) — the two oracle shapes
8935    // differ on purpose (`autolink_brackets_unbalanced` keeps both `]`;
8936    // `autolink_relaxed_links_brackets_balanced` keeps one).
8937    let mut bracket_depth = 0i32;
8938    let mut curly_depth = 0i32;
8939    let mut strict_has_open_bracket = false;
8940    let mut strict_inside_backticks = false;
8941    for (offset, char) in input[start..].char_indices() {
8942        if char.is_whitespace() || char == '<' || is_autolink_terminating_control(char) {
8943            break;
8944        }
8945        if balanced {
8946            match char {
8947                '[' => bracket_depth += 1,
8948                ']' => {
8949                    if bracket_depth > 0 {
8950                        bracket_depth -= 1;
8951                    } else {
8952                        break;
8953                    }
8954                }
8955                '{' => curly_depth += 1,
8956                '}' => {
8957                    if curly_depth > 0 {
8958                        curly_depth -= 1;
8959                    } else {
8960                        break;
8961                    }
8962                }
8963                _ => {}
8964            }
8965        } else {
8966            match char {
8967                '[' => strict_has_open_bracket = true,
8968                '`' => strict_inside_backticks = !strict_inside_backticks,
8969                ']' if !strict_has_open_bracket && !strict_inside_backticks => break,
8970                _ => {}
8971            }
8972        }
8973        // Round-trip guard: when a literal autolink ends (a trailing entity
8974        // run, punctuation trim, unbalanced `)`, or the `]`/`<` hard boundary),
8975        // the text that follows often begins with a char the serializer escapes
8976        // with a backslash (`\&`, `\[`, `\]`, `\<`, `\>`, `\*`, `\_`, …). The
8977        // URL scan must stop at such a `\<punct>` so the escape is not re-merged
8978        // into the destination. A `\` before `.` (or any non-punctuation) is a
8979        // genuine literal backslash inside the URL (e.g. `www.x.com/a\.`), which
8980        // the serializer never produces, so it stays part of the URL.
8981        if char == '\\' {
8982            if let Some(&next) = bytes.get(start + offset + 1) {
8983                let next_is_escapable_punct = next.is_ascii_punctuation() && next != b'.';
8984                if next_is_escapable_punct {
8985                    break;
8986                }
8987            }
8988        }
8989        end = start + offset + char.len_utf8();
8990    }
8991    autolink_delim(input, trim_from, end)
8992}
8993
8994fn is_autolink_terminating_control(char: char) -> bool {
8995    matches!(char, '\u{2066}'..='\u{2069}')
8996}
8997
8998// Port of cmark-gfm `autolink_delim`: trim trailing delimiters from the end of
8999// the URL. A trailing `) ? ! . , : * _ ~ ' "` is trimmed; `)` only when there
9000// are more `)` than `(` in the link; a trailing `&…;` entity run is excluded
9001// whole; a lone trailing `;` is trimmed.
9002fn autolink_delim(input: &str, start: usize, mut end: usize) -> usize {
9003    let bytes = input.as_bytes();
9004    let mut opening = 0usize;
9005    let mut closing = 0usize;
9006    for &byte in &bytes[start..end] {
9007        match byte {
9008            b'(' => opening += 1,
9009            b')' => closing += 1,
9010            _ => {}
9011        }
9012    }
9013
9014    while end > start {
9015        match bytes[end - 1] {
9016            b')' => {
9017                if closing <= opening {
9018                    break;
9019                }
9020                closing -= 1;
9021                end -= 1;
9022            }
9023            b'?' | b'!' | b'.' | b',' | b':' | b'*' | b'_' | b'~' | b'\'' | b'"' => {
9024                end -= 1;
9025            }
9026            b';' => {
9027                // A trailing hex numeric character reference `&#x…;` is excluded
9028                // whole. This is the round-trip dual of the serializer, which
9029                // encodes a text char that would otherwise merge into the URL as
9030                // a hex entity; no autolink-oracle URL ends in `&#x…;`, so this
9031                // is conformance-safe (decimal `&#…;` is left intact to match
9032                // the oracle, which keeps `www.a&#35` in the URL).
9033                if let Some(amp) = trailing_hex_entity_run_start(bytes, start, end) {
9034                    end = amp;
9035                } else {
9036                    // Walk back over alphanumerics; if they reach a `&`, exclude
9037                    // the whole `&…;` entity run, otherwise trim just the `;`.
9038                    let mut new_end = end - 1;
9039                    while new_end > start && bytes[new_end - 1].is_ascii_alphanumeric() {
9040                        new_end -= 1;
9041                    }
9042                    if new_end > start && new_end < end - 1 && bytes[new_end - 1] == b'&' {
9043                        end = new_end - 1;
9044                    } else {
9045                        end -= 1;
9046                    }
9047                }
9048            }
9049            _ => break,
9050        }
9051    }
9052    end
9053}
9054
9055// When the URL ends with a hex numeric character reference `&#x[hex]+;`, returns
9056// the offset of its leading `&`; otherwise `None`. Used only by `autolink_delim`
9057// to trim the serializer's round-trip boundary marker (the serializer encodes a
9058// would-merge text char as `&#xNN;`). Decimal `&#…;` is intentionally NOT
9059// matched so the oracle's `www.a&#35` URLs stay intact.
9060fn trailing_hex_entity_run_start(bytes: &[u8], start: usize, end: usize) -> Option<usize> {
9061    if end <= start || bytes[end - 1] != b';' {
9062        return None;
9063    }
9064    let mut cursor = end - 1;
9065    while cursor > start && bytes[cursor - 1].is_ascii_hexdigit() {
9066        cursor -= 1;
9067    }
9068    // Require at least one hex digit, then `&#x` (case-insensitive `x`).
9069    if cursor == end - 1 || cursor < start + 3 {
9070        return None;
9071    }
9072    let x = bytes[cursor - 1];
9073    if (x == b'x' || x == b'X') && bytes[cursor - 2] == b'#' && bytes[cursor - 3] == b'&' {
9074        Some(cursor - 3)
9075    } else {
9076        None
9077    }
9078}
9079
9080// GFM bare-email literal (and the extended `mailto:`/`xmpp:` protocol forms).
9081// `index` must be the link start: cmark anchors the email at the left edge
9082// found by rewinding from `@` over `[A-Za-z0-9._+-]` (or a `mailto:`/`xmpp:`
9083// scheme), so this only succeeds when the char before `index` is not part of
9084// that left extent.
9085/// Scan state shared by the `parse_literal_autolink` calls of one inline pass.
9086/// The pass only moves forward, so each piece lets a later start position reuse
9087/// the bytes already walked for an earlier one instead of rescanning them.
9088#[derive(Default)]
9089struct LiteralAutolinkScan {
9090    /// Runs of email local-part bytes (plus `:` for `mailto:`/`xmpp:`).
9091    email_local: ByteRun,
9092    /// Runs of relaxed-autolink scheme bytes.
9093    scheme: ByteRun,
9094    label_openers: LabelOpenerScan,
9095}
9096
9097/// The extent of one run of bytes accepted by a fixed predicate: every start
9098/// position in `start..=end` stops at `end`. Each `ByteRun` is always queried
9099/// with the same predicate.
9100#[derive(Default)]
9101struct ByteRun {
9102    start: usize,
9103    end: Option<usize>,
9104}
9105
9106impl ByteRun {
9107    /// Returns the first byte position at or after `index` that `in_run`
9108    /// rejects, or the input length.
9109    fn end(&mut self, input: &str, index: usize, in_run: fn(u8) -> bool) -> usize {
9110        if let Some(end) = self.end {
9111            if self.start <= index && index <= end {
9112                return end;
9113            }
9114        }
9115        let end = input.as_bytes()[index..]
9116            .iter()
9117            .position(|byte| !in_run(*byte))
9118            .map_or(input.len(), |offset| index + offset);
9119        self.start = index;
9120        self.end = Some(end);
9121        end
9122    }
9123}
9124
9125fn is_email_local_or_scheme_byte(byte: u8) -> bool {
9126    is_gfm_email_local_byte(byte) || byte == b':'
9127}
9128
9129fn is_relaxed_scheme_byte(byte: u8) -> bool {
9130    byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'.' | b'-')
9131}
9132
9133fn parse_literal_email(
9134    input: &str,
9135    index: usize,
9136    local_run: &mut ByteRun,
9137) -> Option<(usize, String)> {
9138    let rest = &input[index..];
9139    // A valid local part holds only local-part bytes behind an optional
9140    // `mailto:`/`xmpp:` scheme, so the `@` must be the byte that ends that run.
9141    let at_index = local_run.end(input, index, is_email_local_or_scheme_byte);
9142    if input.as_bytes().get(at_index) != Some(&b'@') {
9143        return None;
9144    }
9145    let at = at_index - index;
9146    if at == 0 {
9147        return None;
9148    }
9149    let local = &rest[..at];
9150
9151    // Determine whether this `@` is preceded by an extended protocol scheme
9152    // (`mailto:` / `xmpp:`), which both relaxes the href synthesis and (xmpp)
9153    // allows `/` in the domain.
9154    let (auto_mailto, is_xmpp) = classify_email_local(local);
9155
9156    // Left-boundary guard (autolink-1): the char before `index` must not be a
9157    // local-part continuation char, otherwise the true link starts earlier and
9158    // this position is interior. After a recognized scheme, the scheme's own
9159    // preceding-char rule is what matters.
9160    if !email_left_boundary_ok(input, index, auto_mailto) {
9161        return None;
9162    }
9163
9164    if !email_local_is_valid(local, auto_mailto) {
9165        return None;
9166    }
9167
9168    let domain_start = index + at + 1;
9169    let domain_end = literal_email_domain_end(input, domain_start, is_xmpp)?;
9170    let trimmed = autolink_delim(input, domain_start, domain_end);
9171    if trimmed <= domain_start {
9172        return None;
9173    }
9174
9175    let domain = &input[domain_start..trimmed];
9176    if !is_gfm_email_domain(domain, is_xmpp) {
9177        return None;
9178    }
9179
9180    let mut destination = String::new();
9181    if auto_mailto {
9182        destination.push_str("mailto:");
9183    }
9184    destination.push_str(&input[index..trimmed]);
9185    Some((trimmed, destination))
9186}
9187
9188// Classify the local part for the extended-protocol forms. Returns
9189// `(auto_mailto, is_xmpp)`: `mailto:user` → (false, false); `xmpp:user` →
9190// (false, true); a bare local part → (true, false). The scheme match is
9191// case-insensitive.
9192fn classify_email_local(local: &str) -> (bool, bool) {
9193    if let Some(rest) = strip_ci_prefix(local, "mailto:") {
9194        if !rest.is_empty() {
9195            return (false, false);
9196        }
9197    }
9198    if let Some(rest) = strip_ci_prefix(local, "xmpp:") {
9199        if !rest.is_empty() {
9200            return (false, true);
9201        }
9202    }
9203    (true, false)
9204}
9205
9206fn strip_ci_prefix<'a>(input: &'a str, prefix: &str) -> Option<&'a str> {
9207    let bytes = input.as_bytes();
9208    let plen = prefix.len();
9209    if bytes.len() >= plen && bytes[..plen].eq_ignore_ascii_case(prefix.as_bytes()) {
9210        Some(&input[plen..])
9211    } else {
9212        None
9213    }
9214}
9215
9216// The left-boundary check for an email literal. The link is anchored at its
9217// true left edge: the preceding char must not be an ASCII alphanumeric (which
9218// would extend the local part leftward). For the bare form, a preceding `/` is
9219// also rejected (`/a@b.c` is not linked), while the extended
9220// `mailto:`/`xmpp:` form permits `/` before the scheme (so
9221// `…/mailto:beedrill@…` links).
9222fn email_left_boundary_ok(input: &str, index: usize, auto_mailto: bool) -> bool {
9223    if index == 0 {
9224        return true;
9225    }
9226    let Some(previous) = input[..index].chars().next_back() else {
9227        return true;
9228    };
9229    if previous.is_ascii_alphanumeric() {
9230        if auto_mailto
9231            && input[index..].starts_with('+')
9232            && prefix_ends_with_gfm_email(input, index)
9233        {
9234            return true;
9235        }
9236        return false;
9237    }
9238    if auto_mailto && previous == '/' {
9239        return false;
9240    }
9241    true
9242}
9243
9244fn prefix_ends_with_gfm_email(input: &str, end: usize) -> bool {
9245    let start = input[..end]
9246        .rfind(char::is_whitespace)
9247        .map_or(0, |offset| offset + 1);
9248    let candidate = &input[start..end];
9249    let Some(at) = candidate.rfind('@') else {
9250        return false;
9251    };
9252    email_local_is_valid(&candidate[..at], true) && is_gfm_email_domain(&candidate[at + 1..], false)
9253}
9254
9255// Validate the email local part. For the bare form, every char must be a GFM
9256// email atext byte (`[A-Za-z0-9.+_-]` plus the dot-separated structure). For
9257// the extended-protocol forms, the part after the scheme is validated.
9258fn email_local_is_valid(local: &str, auto_mailto: bool) -> bool {
9259    let body = if auto_mailto {
9260        local
9261    } else if let Some(rest) = strip_ci_prefix(local, "mailto:") {
9262        rest
9263    } else if let Some(rest) = strip_ci_prefix(local, "xmpp:") {
9264        rest
9265    } else {
9266        local
9267    };
9268    !body.is_empty() && body.bytes().all(is_gfm_email_local_byte)
9269}
9270
9271// GFM email local-part charset (autolink-1): a narrower set than RFC atext,
9272// matching cmark's rewind class `[A-Za-z0-9.+_-]`.
9273fn is_gfm_email_local_byte(byte: u8) -> bool {
9274    byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'_' | b'-')
9275}
9276
9277fn is_email_local_part(input: &str) -> bool {
9278    !input.is_empty()
9279        && input
9280            .split('.')
9281            .all(|segment| !segment.is_empty() && segment.bytes().all(is_email_atext))
9282}
9283
9284fn is_email_atext(byte: u8) -> bool {
9285    byte.is_ascii_alphanumeric()
9286        || matches!(
9287            byte,
9288            b'!' | b'#'
9289                | b'$'
9290                | b'%'
9291                | b'&'
9292                | b'\''
9293                | b'*'
9294                | b'+'
9295                | b'/'
9296                | b'='
9297                | b'?'
9298                | b'^'
9299                | b'_'
9300                | b'`'
9301                | b'{'
9302                | b'|'
9303                | b'}'
9304                | b'~'
9305                | b'-'
9306        )
9307}
9308
9309// Port of cmark-gfm's email-domain scan (`postprocess_text`). Scans forward
9310// from `index` over the email domain, accepting alphanumerics, `-`, `_`, and
9311// `.`; for the `xmpp:` form a `/` is also accepted (path). A dot only counts
9312// toward the "at least one dot" requirement when it is followed by an
9313// alphanumeric. The scanned span must be >= 1 byte, contain at least one such
9314// dot, and end in an alphabetic char or a dot. Returns the domain end offset
9315// (before trailing trim), or `None` when invalid.
9316fn literal_email_domain_end(input: &str, index: usize, is_xmpp: bool) -> Option<usize> {
9317    let bytes = input.as_bytes();
9318    let mut end = index;
9319    let mut np = 0usize;
9320    while end < bytes.len() {
9321        let byte = bytes[end];
9322        if byte.is_ascii_alphanumeric() {
9323            end += 1;
9324        } else if byte == b'.' && end + 1 < bytes.len() && bytes[end + 1].is_ascii_alphanumeric() {
9325            np += 1;
9326            end += 1;
9327        } else if byte == b'-' || byte == b'_' || (byte == b'/' && is_xmpp) {
9328            // `-`/`_` always continue the domain; `/` continues only the xmpp
9329            // path form.
9330            end += 1;
9331        } else {
9332            break;
9333        }
9334    }
9335    if end <= index {
9336        return None;
9337    }
9338    let len = end - index;
9339    let last = bytes[end - 1];
9340    if len < 1 || np == 0 || !(last.is_ascii_alphabetic() || last == b'.') {
9341        return None;
9342    }
9343    Some(end)
9344}
9345
9346// Final structural validation of the trimmed email domain. The cmark scan
9347// already enforced the dot/last-char rules; this re-checks them after the
9348// shared trailing trim removed any delimiters, and rejects a domain ending in
9349// `-`/`_` (autolink-7: a hyphen in the final label disqualifies the link).
9350fn is_gfm_email_domain(input: &str, is_xmpp: bool) -> bool {
9351    if input.is_empty() {
9352        return false;
9353    }
9354    // A `/` path is only legal in the `xmpp:` form; split it off for the host
9355    // structural checks.
9356    let host = if is_xmpp {
9357        input.split('/').next().unwrap_or(input)
9358    } else {
9359        input
9360    };
9361    if !host.contains('.') {
9362        return false;
9363    }
9364    let last = host.as_bytes()[host.len() - 1];
9365    // The final label must not end in `-` or `_`, and the trailing label may
9366    // not be all ASCII digits.
9367    if matches!(last, b'-' | b'_') {
9368        return false;
9369    }
9370    host.split('.').all(|label| {
9371        !label.is_empty()
9372            && label
9373                .bytes()
9374                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
9375    })
9376}
9377
9378fn is_email_domain(input: &str, min_labels: usize) -> bool {
9379    let mut label_count = 0usize;
9380    for label in input.split('.') {
9381        label_count += 1;
9382        let bytes = label.as_bytes();
9383        if bytes.is_empty()
9384            || bytes.len() > 63
9385            || !bytes
9386                .first()
9387                .is_some_and(|byte| byte.is_ascii_alphanumeric())
9388            || !bytes
9389                .last()
9390                .is_some_and(|byte| byte.is_ascii_alphanumeric())
9391            || !bytes
9392                .iter()
9393                .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
9394        {
9395            return false;
9396        }
9397    }
9398    label_count >= min_labels
9399}
9400
9401fn is_footnote_label(label: &str) -> bool {
9402    !label.is_empty()
9403        && reference_label_is_within_limit(label)
9404        && !label.chars().any(char::is_whitespace)
9405}
9406
9407fn find_footnote_definition_label_end(input: &str) -> Option<usize> {
9408    let close = find_footnote_reference_label_end(input, 2)?;
9409    if input.as_bytes().get(close + 1) == Some(&b':') {
9410        Some(close)
9411    } else {
9412        None
9413    }
9414}
9415
9416fn find_footnote_reference_label_end(input: &str, mut cursor: usize) -> Option<usize> {
9417    while cursor < input.len() {
9418        let (next, char) = next_char(input, cursor)?;
9419        if char == ']' && !is_escaped_at(input, cursor) {
9420            return Some(cursor);
9421        }
9422        cursor = next;
9423    }
9424    None
9425}