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