Skip to main content

markdown_syntax/
parse.rs

1//! Markdown source to AST. The entry point is the free [`parse`] function,
2//! which reads the crate's one syntax. Parsing is tolerant: problems are
3//! collected as [`Diagnostic`]s rather than aborting.
4
5use alloc::{borrow::Cow, collections::BTreeMap, string::String, vec, vec::Vec};
6
7use crate::{
8    ast::*,
9    decode::{
10        decode_escapes_and_references, decode_selected_escapes_and_references,
11        parse_character_reference,
12    },
13    diagnostic::{Diagnostic, DiagnosticCode, DiagnosticSeverity},
14    memo::{
15        bracket_walk, path_walk, pattern_starts, BracketMemo, BracketStep, PathMemo, Positions,
16        Step,
17    },
18    span::Span,
19    validate::is_directive_name,
20};
21
22mod blocks;
23mod nul_replacement;
24#[cfg(test)]
25mod scan_tests;
26mod source_map;
27
28pub(crate) use blocks::interrupts_paragraph;
29use source_map::{DerivedText, Segment, SourceMap};
30
31/// The largest ordered-list item number: a marker holds at most 9 digits.
32pub(crate) const MAX_ORDERED_NUMBER: u64 = 999_999_999;
33
34/// The result of a tolerant parse: the document plus any diagnostics gathered
35/// along the way (empty on a clean parse).
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct ParseOutput {
38    /// The parsed document tree.
39    pub document: Document,
40    /// Diagnostics collected during parsing.
41    pub diagnostics: Vec<Diagnostic>,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45struct ParsedLinkResource {
46    destination: String,
47    destination_kind: LinkDestinationKind,
48    title: Option<Title>,
49}
50
51const REFERENCE_LABEL_MAX_CHARS: usize = 999;
52const WIKILINK_MAX_BYTES: usize = 999;
53
54/// One line of the text a block parser reads. `start`, `end`, and
55/// `end_with_eol` are positions in the original input; offsets into `text`
56/// translate to the input through `source_start` / `source_end`, since a
57/// container's lines are derived from its source lines with prefixes removed.
58#[derive(Clone, Copy, Debug)]
59struct Line<'a> {
60    text: &'a str,
61    eol: &'a str,
62    start: usize,
63    end: usize,
64    end_with_eol: usize,
65    /// The source-map segments covering `text` and `eol`, in the coordinates of
66    /// the string the line was split from, where `text` starts at `text_offset`.
67    segments: &'a [Segment],
68    text_offset: usize,
69    /// The source column `text` starts at, from which its tabs reach their
70    /// tab stops (every four columns).
71    column: usize,
72}
73
74impl<'a> Line<'a> {
75    /// A line checked for its shape alone, whose positions are not used.
76    fn detached(text: &'a str) -> Self {
77        Line {
78            text,
79            eol: "",
80            start: 0,
81            end: text.len(),
82            end_with_eol: text.len(),
83            segments: &[],
84            text_offset: 0,
85            column: 0,
86        }
87    }
88
89    /// The source column after `self.text[..offset]`.
90    fn column_at(&self, offset: usize) -> usize {
91        advance_columns(self.column, &self.text[..offset])
92    }
93
94    /// The input position where a node starting at byte `offset` of `text`
95    /// starts.
96    fn source_start(&self, offset: usize) -> usize {
97        source_map::start_of(self.segments, self.text_offset + offset)
98    }
99
100    /// The input position where a node ending at byte `offset` of `text` ends.
101    fn source_end(&self, offset: usize) -> usize {
102        source_map::end_of(self.segments, self.text_offset + offset)
103    }
104
105    /// The input range of this line's ending.
106    fn eol_source(&self) -> (usize, usize) {
107        (self.end, self.end_with_eol)
108    }
109
110    /// Records in `map`, from text position `at`, what `text[from..to]` was
111    /// read from.
112    fn copy_into(&self, map: &mut SourceMap, at: usize, from: usize, to: usize) {
113        map.copy(
114            at,
115            self.segments,
116            self.text_offset + from,
117            self.text_offset + to,
118        );
119    }
120}
121
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123enum HtmlBlockKind {
124    RawTag,
125    BlockTag,
126    Until(&'static str),
127    UntilBlank,
128}
129
130/// Parse `input`. Infallible and tolerant: problems are reported as
131/// diagnostics in the returned [`ParseOutput`].
132pub fn parse(input: &str) -> ParseOutput {
133    let mut diagnostics = Vec::new();
134    // A leading byte order mark is not content; parsing starts after it while
135    // spans keep counting from the start of `input`.
136    let start = if input.starts_with('\u{feff}') {
137        '\u{feff}'.len_utf8()
138    } else {
139        0
140    };
141    let source = &input[start..];
142    let map = SourceMap::verbatim(source.len(), start);
143    let lines = collect_lines(source, &map);
144    let children = blocks::parse_document(&lines, &mut diagnostics);
145    let mut document = Document {
146        meta: NodeMeta::new(Some(Span::new(0, input.len()))),
147        children,
148    };
149    if source.contains('\0') {
150        nul_replacement::replace_in_document(&mut document);
151    }
152
153    ParseOutput {
154        document,
155        diagnostics,
156    }
157}
158
159/// CommonMark reads U+0000 as U+FFFD. Character classifications that differ
160/// between the two call this; node values are rewritten after parsing by
161/// `nul_replacement::replace_in_document`, so spans stay in source coordinates.
162fn source_char(char: char) -> char {
163    if char == '\0' {
164        '\u{FFFD}'
165    } else {
166        char
167    }
168}
169
170/// The deepest block-container nesting (block quotes, list items, container
171/// directives, footnote definitions, HTML containers) the parser opens. Deeper container markers stay leaf-block text, so recursion
172/// and the native stack it uses stay bounded.
173pub(crate) const MAX_BLOCK_NESTING: usize = 32;
174
175/// Parses the blocks of a container's derived content.
176/// The column `text` reaches from column `column`, with tab stops every four.
177fn advance_columns(column: usize, text: &str) -> usize {
178    text.chars().fold(column, |column, char| {
179        if char == '\t' {
180            column + 4 - column % 4
181        } else {
182            column + 1
183        }
184    })
185}
186
187/// Splits `input` into lines, with positions translated through `map`, the
188/// source map of `input`.
189fn collect_lines<'a>(input: &'a str, map: &'a SourceMap) -> Vec<Line<'a>> {
190    let bytes = input.as_bytes();
191    let mut lines = Vec::new();
192    let mut segments = LineSegments {
193        segments: map.segments(),
194        first: 0,
195    };
196    let mut start = 0;
197    let mut index = 0;
198
199    while index < bytes.len() {
200        let eol_end = match bytes[index] {
201            b'\n' => index + 1,
202            b'\r' if bytes.get(index + 1) == Some(&b'\n') => index + 2,
203            b'\r' => index + 1,
204            _ => {
205                index += 1;
206                continue;
207            }
208        };
209        lines.push(segments.line(input, start, index, eol_end));
210        index = eol_end;
211        start = index;
212    }
213
214    if start < bytes.len() || input.is_empty() {
215        lines.push(segments.line(input, start, bytes.len(), bytes.len()));
216    }
217
218    lines
219}
220
221/// Hands each line of a split string the segments that cover it, moving
222/// forward through the map once.
223struct LineSegments<'a> {
224    segments: &'a [Segment],
225    first: usize,
226}
227
228impl<'a> LineSegments<'a> {
229    fn line(&mut self, input: &'a str, start: usize, end: usize, eol_end: usize) -> Line<'a> {
230        let segments = self.segments;
231        while self.first + 1 < segments.len() && segments[self.first].text_end() <= start {
232            self.first += 1;
233        }
234        let mut last = self.first;
235        while last + 1 < segments.len() && segments[last + 1].text_start() < eol_end {
236            last += 1;
237        }
238        let covering = if segments.is_empty() {
239            segments
240        } else {
241            &segments[self.first..=last]
242        };
243        let source_start = source_map::start_of(covering, start);
244        let source_end = if end == start {
245            source_start
246        } else {
247            source_map::end_of(covering, end)
248        };
249        let source_end_with_eol = if eol_end == end {
250            source_end
251        } else {
252            source_map::end_of(covering, eol_end)
253        };
254        Line {
255            text: &input[start..end],
256            eol: &input[end..eol_end],
257            start: source_start,
258            end: source_end,
259            end_with_eol: source_end_with_eol,
260            segments: covering,
261            text_offset: start,
262            column: 0,
263        }
264    }
265}
266
267pub(crate) fn frontmatter_fence_kind(line: &str) -> Option<FrontmatterKind> {
268    match line.trim_end_matches([' ', '\t']) {
269        "---" => Some(FrontmatterKind::Yaml),
270        "+++" => Some(FrontmatterKind::Toml),
271        _ => None,
272    }
273}
274
275fn directive_container_opener_prefix(input: &str) -> Option<(usize, &str)> {
276    let fence_len = input
277        .as_bytes()
278        .iter()
279        .take_while(|byte| **byte == b':')
280        .count();
281    if fence_len >= 3 {
282        Some((fence_len, &input[fence_len..]))
283    } else {
284        None
285    }
286}
287
288fn directive_container_closing_fence(input: &str, min_len: usize) -> Option<usize> {
289    let fence_len = input
290        .as_bytes()
291        .iter()
292        .take_while(|byte| **byte == b':')
293        .count();
294    if fence_len >= min_len && is_blank(&input[fence_len..]) {
295        Some(fence_len)
296    } else {
297        None
298    }
299}
300
301/// Length of the leading `$` run if `input` (already indent-stripped) is a valid
302/// math-flow opener: `>=2` dollars, then an info string with no further `$`.
303fn math_block_fence_length(input: &str) -> Option<usize> {
304    let length = input
305        .as_bytes()
306        .iter()
307        .take_while(|byte| **byte == b'$')
308        .count();
309    if length < 2 || input[length..].contains('$') {
310        return None;
311    }
312    Some(length)
313}
314
315/// A math-flow closing line (already indent-stripped) is a run of `>=length`
316/// dollars and nothing else (trailing whitespace aside).
317fn math_block_fence_closes(input: &str, length: usize) -> bool {
318    let count = input
319        .as_bytes()
320        .iter()
321        .take_while(|byte| **byte == b'$')
322        .count();
323    count >= length && is_blank(&input[count..])
324}
325
326fn parse_alert_marker(line: &str) -> Option<(AlertKind, Option<String>)> {
327    let close = line.find(']')?;
328    let marker = line.get(0..close + 1)?;
329    if !marker.starts_with("[!") {
330        return None;
331    }
332    let kind = match &marker[2..close].to_ascii_lowercase()[..] {
333        "note" => AlertKind::Note,
334        "tip" => AlertKind::Tip,
335        "important" => AlertKind::Important,
336        "warning" => AlertKind::Warning,
337        "caution" => AlertKind::Caution,
338        _ => return None,
339    };
340    Some((kind, alert_title(&line[close + 1..])))
341}
342
343/// The title an alert marker line gives with `text` after its `]`: the text
344/// without the spaces and tabs around it, or none when nothing else is left.
345pub(crate) fn alert_title(text: &str) -> Option<String> {
346    let title = text.trim_matches([' ', '\t']);
347    (!title.is_empty()).then(|| title.into())
348}
349
350fn parse_thematic_break(line: Line<'_>) -> Option<Block> {
351    let text = trim_up_to_three_spaces(line.text)?;
352    let mut marker = None;
353    let mut count = 0;
354    for char in text.chars() {
355        if char == ' ' || char == '\t' {
356            continue;
357        }
358        let current = match char {
359            '-' => ThematicBreakMarker::Dash,
360            '*' => ThematicBreakMarker::Asterisk,
361            '_' => ThematicBreakMarker::Underscore,
362            _ => return None,
363        };
364        if marker.is_some_and(|marker| marker != current) {
365            return None;
366        }
367        marker = Some(current);
368        count += 1;
369    }
370    if count >= 3 {
371        Some(Block::ThematicBreak(ThematicBreak {
372            meta: NodeMeta::new(Some(Span::new(line.start, line.end))),
373            marker: marker?,
374        }))
375    } else {
376        None
377    }
378}
379
380/// The label of a definition starting paragraph line `index`, followed by
381/// `:`: the text of the lines up to the one the label closes on, joined by
382/// `\n`, the offset of the label's `]` in it, and that line.
383///
384/// A label may span lines (CommonMark §4.7), up to its length limit. The
385/// walk to its `]` (`find_reference_label_end`) goes on from where the lines
386/// before left it, so that a long label is walked once.
387fn definition_label(lines: &[Line<'_>], index: usize) -> Option<(String, usize, usize)> {
388    let text = trim_ascii_start(lines[index].text);
389    if !text.starts_with('[') {
390        return None;
391    }
392    let mut accumulated = String::from(text);
393    let mut label_end_line = index;
394    let mut cursor = 1;
395    loop {
396        let bytes = accumulated.as_bytes();
397        // The walk has run out of text: it goes on at the `\n` the next line
398        // is joined with. A `\` that ended the text escapes that `\n`, and
399        // both step to the next line's start.
400        if cursor >= bytes.len() {
401            let next = label_end_line + 1;
402            if next >= lines.len() || accumulated.len() > 4 * REFERENCE_LABEL_MAX_CHARS + 2 {
403                return None;
404            }
405            accumulated.push('\n');
406            accumulated.push_str(lines[next].text);
407            label_end_line = next;
408            continue;
409        }
410        match reference_label_step(&accumulated, cursor) {
411            Step::Next(next) => cursor = next,
412            Step::Done(Some(close)) if reference_label_is_within_limit(&accumulated[1..close]) => {
413                // A closed label not followed by `:` is not a definition.
414                if bytes.get(close + 1) != Some(&b':') {
415                    return None;
416                }
417                return Some((accumulated, close, label_end_line));
418            }
419            // An unescaped `[`, or a label over the length limit: no line
420            // after it makes a definition.
421            Step::Done(_) => return None,
422        }
423    }
424}
425
426/// The link reference definition that starts paragraph line `index`, and
427/// the line after it. The lines are a paragraph's, each from where the
428/// containers around it leave it; the definition's span is left to the
429/// caller.
430fn parse_definition(lines: &[Line<'_>], index: usize) -> Option<(Definition, usize)> {
431    let (accumulated, close, label_end_line) = definition_label(lines, index)?;
432    let label = String::from(&accumulated[1..close]);
433    if normalize_label(&label).is_empty() {
434        return None;
435    }
436    let mut source = String::from(&accumulated[close + 2..]);
437    let mut cursor = label_end_line;
438    let mut best_without_title = None;
439    // The char that closes a title the source leaves open: a line without
440    // it cannot change the parse, so the source is parsed again only once a
441    // line holds it, which keeps a long open title linear.
442    let mut open_title: Option<char> = None;
443
444    loop {
445        let parses =
446            open_title.is_none_or(|closer| line_may_close_title(lines[cursor].text, closer));
447        if parses {
448            match parse_definition_destination_title(&source) {
449                Some(resource) => {
450                    if resource.title.is_some() {
451                        return Some((definition_node(&label, resource), cursor + 1));
452                    }
453                    best_without_title = Some((resource, cursor + 1));
454                    let next = cursor + 1;
455                    if next >= lines.len() || !line_can_start_definition_title(lines[next].text) {
456                        break;
457                    }
458                }
459                // A destination, or a title once it has closed, that does
460                // not parse cannot parse with more lines.
461                None if !is_blank(&source) => match open_definition_title(&source) {
462                    Some(closer) if open_title.is_none() => open_title = Some(closer),
463                    _ => break,
464                },
465                None => {}
466            }
467        }
468        let next = cursor + 1;
469        if next >= lines.len() {
470            break;
471        }
472        source.push('\n');
473        source.push_str(lines[next].text);
474        cursor = next;
475    }
476
477    let (resource, next) = best_without_title?;
478    Some((definition_node(&label, resource), next))
479}
480
481fn definition_node(label: &str, resource: ParsedLinkResource) -> Definition {
482    Definition {
483        meta: NodeMeta::default(),
484        label: label.into(),
485        identifier: normalize_label(label),
486        destination: resource.destination,
487        destination_kind: resource.destination_kind,
488        title: resource.title,
489    }
490}
491
492#[derive(Clone, Copy, Eq, PartialEq)]
493enum HtmlContainerTag {
494    Opening,
495    Closing,
496}
497
498#[derive(Clone, Copy)]
499struct HtmlContainerFence {
500    marker: FenceMarker,
501    length: usize,
502}
503
504/// One step of the line walk to an HTML container's closing tag line: a code
505/// fence is stepped over whole, and same-name opening and closing tag lines
506/// nest.
507fn html_container_close_step(lines: &[Line<'_>], cursor: usize, tag: &str) -> BracketStep {
508    let Some(line) = lines.get(cursor) else {
509        return BracketStep::End;
510    };
511    if let Some(fence) = html_container_fence_opens(line) {
512        let after_fence = (cursor + 1..lines.len())
513            .find(|close| html_container_fence_closes(&lines[*close], fence))
514            .map_or(lines.len(), |close| close + 1);
515        return BracketStep::Pass(after_fence);
516    }
517    if parse_html_container_tag_line(*line, tag, HtmlContainerTag::Closing).is_some() {
518        return BracketStep::Close(cursor + 1);
519    }
520    if parse_html_container_opening_line(*line, tag).is_some() {
521        return BracketStep::Open(cursor + 1);
522    }
523    BracketStep::Pass(cursor + 1)
524}
525
526fn parse_html_container_tag_line(
527    line: Line<'_>,
528    tag: &str,
529    kind: HtmlContainerTag,
530) -> Option<HtmlTag> {
531    let (trimmed, indent_bytes) = trim_html_container_line(&line)?;
532    let (end, name) = parse_html_tag(trimmed, 0)?;
533    if !name.eq_ignore_ascii_case(tag) || !is_blank(&trimmed[end..]) {
534        return None;
535    }
536
537    let closing = html_tag_is_closing(trimmed, 0);
538    if (kind == HtmlContainerTag::Opening && closing)
539        || (kind == HtmlContainerTag::Closing && !closing)
540        || html_tag_is_self_closing(&trimmed[..end])
541    {
542        return None;
543    }
544
545    Some(HtmlTag {
546        meta: NodeMeta::new(Some(Span::new(
547            line.source_start(indent_bytes),
548            line.source_end(indent_bytes + end),
549        ))),
550        name: tag.into(),
551        raw: trimmed[..end].into(),
552    })
553}
554
555fn parse_html_container_opening_line(line: Line<'_>, tag: &str) -> Option<HtmlTag> {
556    let (trimmed, indent_bytes) = trim_html_container_line(&line)?;
557    let (end, name) = parse_html_tag(trimmed, 0)?;
558    if !name.eq_ignore_ascii_case(tag)
559        || html_tag_is_closing(trimmed, 0)
560        || html_tag_is_self_closing(&trimmed[..end])
561    {
562        return None;
563    }
564
565    let rest_start = end + leading_ascii_whitespace_len(&trimmed[end..]);
566    if !trimmed[rest_start..].is_empty() && !html_container_line_has_summary(&trimmed[rest_start..])
567    {
568        return None;
569    }
570
571    Some(HtmlTag {
572        meta: NodeMeta::new(Some(Span::new(
573            line.source_start(indent_bytes),
574            line.source_end(indent_bytes + end),
575        ))),
576        name: tag.into(),
577        raw: trimmed[..end].into(),
578    })
579}
580
581fn html_container_line_has_summary(input: &str) -> bool {
582    let Some((open_end, name)) = parse_html_tag(input, 0) else {
583        return false;
584    };
585    if !name.eq_ignore_ascii_case("summary")
586        || html_tag_is_closing(input, 0)
587        || html_tag_is_self_closing(&input[..open_end])
588    {
589        return false;
590    }
591    let Some(close_start) = input[open_end..].find("</").map(|offset| open_end + offset) else {
592        return false;
593    };
594    let Some((close_end, close_name)) = parse_html_tag(input, close_start) else {
595        return false;
596    };
597    close_name.eq_ignore_ascii_case("summary")
598        && html_tag_is_closing(input, close_start)
599        && is_blank(&input[close_end..])
600}
601
602/// `line` from its first char other than a space or tab, when its
603/// indentation, from the column it starts at, is at most three columns.
604fn trim_html_container_line<'a>(line: &Line<'a>) -> Option<(&'a str, usize)> {
605    let (columns, bytes) = leading_indent_at(line.text, line.column);
606    (columns <= 3).then(|| (&line.text[bytes..], bytes))
607}
608
609fn leading_ascii_whitespace_len(input: &str) -> usize {
610    input
611        .as_bytes()
612        .iter()
613        .take_while(|byte| byte.is_ascii_whitespace())
614        .count()
615}
616
617fn html_tag_is_closing(input: &str, index: usize) -> bool {
618    input.as_bytes().get(index + 1) == Some(&b'/')
619}
620
621fn html_tag_is_self_closing(input: &str) -> bool {
622    input.trim_end_matches([' ', '\t']).ends_with("/>")
623}
624
625fn html_container_fence_opens(line: &Line<'_>) -> Option<HtmlContainerFence> {
626    let (trimmed, _) = trim_html_container_line(line)?;
627    let (marker, length) = fence_start(trimmed)?;
628    Some(HtmlContainerFence { marker, length })
629}
630
631fn html_container_fence_closes(line: &Line<'_>, fence: HtmlContainerFence) -> bool {
632    trim_html_container_line(line)
633        .is_some_and(|(trimmed, _)| fence_close(trimmed, fence.marker, fence.length))
634}
635
636fn html_block_start(input: &str) -> Option<HtmlBlockKind> {
637    let trimmed = input.trim_end_matches([' ', '\t']);
638    if !trimmed.starts_with('<') {
639        return None;
640    }
641
642    if raw_html_tag_start(trimmed) {
643        return Some(HtmlBlockKind::RawTag);
644    }
645    if trimmed.starts_with("<!--") {
646        return Some(HtmlBlockKind::Until("-->"));
647    }
648    if trimmed.starts_with("<?") {
649        return Some(HtmlBlockKind::Until("?>"));
650    }
651    if is_declaration_start(trimmed) {
652        return Some(HtmlBlockKind::Until(">"));
653    }
654    if trimmed.starts_with("<![CDATA[") {
655        return Some(HtmlBlockKind::Until("]]>"));
656    }
657
658    if html_block_tag_start(trimmed) {
659        return Some(HtmlBlockKind::BlockTag);
660    }
661
662    let Some((end, _tag_name)) = parse_html_tag(trimmed, 0) else {
663        return None;
664    };
665    if is_blank(&trimmed[end..]) {
666        Some(HtmlBlockKind::UntilBlank)
667    } else {
668        None
669    }
670}
671
672fn raw_html_tag_start(input: &str) -> bool {
673    for tag in ["script", "pre", "style", "textarea"] {
674        if html_raw_open_tag_prefix(input, tag) {
675            return true;
676        }
677    }
678    false
679}
680
681fn html_raw_open_tag_prefix(input: &str, tag: &str) -> bool {
682    let Some(rest) = input.strip_prefix('<') else {
683        return false;
684    };
685    if rest.starts_with('/') || rest.len() < tag.len() {
686        return false;
687    }
688    let rest_bytes = rest.as_bytes();
689    let tag_bytes = tag.as_bytes();
690    if !rest_bytes
691        .get(..tag_bytes.len())
692        .is_some_and(|name| name.eq_ignore_ascii_case(tag_bytes))
693    {
694        return false;
695    }
696    match rest_bytes.get(tag.len()) {
697        None => true,
698        Some(b' ' | b'\t' | b'\n' | b'\r' | b'>') => true,
699        Some(b'/') => {
700            rest_bytes.get(tag.len() + 1) == Some(&b'>') && rest_bytes.get(tag.len() + 2).is_none()
701        }
702        _ => false,
703    }
704}
705
706fn line_contains_raw_closing_tag(input: &str, tag: &str) -> bool {
707    let bytes = input.as_bytes();
708    let tag_bytes = tag.as_bytes();
709    let mut cursor = 0;
710
711    while cursor + 2 + tag_bytes.len() <= bytes.len() {
712        let tag_start = cursor + 2;
713        let tag_end = tag_start + tag_bytes.len();
714        if bytes.get(cursor) == Some(&b'<')
715            && bytes.get(cursor + 1) == Some(&b'/')
716            && bytes
717                .get(tag_start..tag_end)
718                .is_some_and(|name| name.eq_ignore_ascii_case(tag_bytes))
719        {
720            match bytes.get(tag_end) {
721                Some(b'>') => return true,
722                Some(byte) if byte.is_ascii_whitespace() => {
723                    let mut after_space = tag_end;
724                    while bytes
725                        .get(after_space)
726                        .is_some_and(|byte| byte.is_ascii_whitespace())
727                    {
728                        after_space += 1;
729                    }
730                    if bytes.get(after_space) == Some(&b'>') {
731                        return true;
732                    }
733                }
734                _ => {}
735            }
736        }
737        cursor += 1;
738    }
739
740    false
741}
742
743fn html_block_tag_start(input: &str) -> bool {
744    let bytes = input.as_bytes();
745    if bytes.first() != Some(&b'<') {
746        return false;
747    }
748
749    let mut cursor = 1;
750    if bytes.get(cursor) == Some(&b'/') {
751        cursor += 1;
752    }
753
754    let name_start = cursor;
755    if !bytes
756        .get(cursor)
757        .is_some_and(|byte| byte.is_ascii_alphabetic())
758    {
759        return false;
760    }
761    cursor += 1;
762    while bytes.get(cursor).is_some_and(|byte| html_name_byte(*byte)) {
763        cursor += 1;
764    }
765
766    let name = &input[name_start..cursor];
767    if !html_block_tag(name) {
768        return false;
769    }
770
771    match bytes.get(cursor) {
772        None | Some(b' ' | b'\t' | b'\n' | b'\r' | b'>') => true,
773        Some(b'/') if bytes.get(cursor + 1) == Some(&b'>') => true,
774        _ => false,
775    }
776}
777
778fn html_block_tag(tag: &str) -> bool {
779    matches!(
780        tag.to_ascii_lowercase().as_str(),
781        "address"
782            | "article"
783            | "aside"
784            | "base"
785            | "basefont"
786            | "blockquote"
787            | "body"
788            | "caption"
789            | "center"
790            | "col"
791            | "colgroup"
792            | "dd"
793            | "details"
794            | "dialog"
795            | "dir"
796            | "div"
797            | "dl"
798            | "dt"
799            | "fieldset"
800            | "figcaption"
801            | "figure"
802            | "footer"
803            | "form"
804            | "frame"
805            | "frameset"
806            | "h1"
807            | "h2"
808            | "h3"
809            | "h4"
810            | "h5"
811            | "h6"
812            | "head"
813            | "header"
814            | "hr"
815            | "html"
816            | "iframe"
817            | "legend"
818            | "li"
819            | "link"
820            | "main"
821            | "menu"
822            | "menuitem"
823            | "nav"
824            | "noframes"
825            | "ol"
826            | "optgroup"
827            | "option"
828            | "p"
829            | "param"
830            | "search"
831            | "section"
832            | "summary"
833            | "table"
834            | "tbody"
835            | "td"
836            | "tfoot"
837            | "th"
838            | "thead"
839            | "title"
840            | "tr"
841            | "track"
842            | "ul"
843    )
844}
845
846fn is_declaration_start(input: &str) -> bool {
847    input
848        .as_bytes()
849        .get(2)
850        .is_some_and(|byte| input.starts_with("<!") && byte.is_ascii_alphabetic())
851}
852
853fn setext_underline_depth(input: &str) -> Option<u8> {
854    let underline = trim_up_to_three_spaces(input)?.trim_matches([' ', '\t']);
855    match underline {
856        text if !text.is_empty() && text.chars().all(|char| char == '=') => Some(1),
857        text if !text.is_empty() && text.chars().all(|char| char == '-') => Some(2),
858        _ => None,
859    }
860}
861
862/// A delimiter run recorded during the inline scan for later resolution by the
863/// delimiter-stack algorithm (`process_emphasis`). A closer pairs with the
864/// nearest compatible opener before it, so same-mark spans nest: `*` and `_`
865/// (CommonMark), `~~` strikethrough, and `==` highlight.
866#[derive(Clone, Copy)]
867struct DelimMarker {
868    /// Index of the placeholder text node in the flat node list. The text node
869    /// holds the as-yet-unmatched delimiter characters; matching trims it from
870    /// the appropriate side and matched characters are removed entirely.
871    node_index: usize,
872    marker: u8,
873    /// Remaining unmatched delimiter characters in this run.
874    length: usize,
875    /// The run's length as scanned. CommonMark's rule of three, and the
876    /// `openers_bottom` key that caches its verdicts, read this rather than
877    /// what is left after earlier pairings.
878    run_length: usize,
879    /// Whether the run can open and close; settled by `assign_emphasis_roles`.
880    can_open: bool,
881    can_close: bool,
882    /// How many more times a long `==` run closes, two characters at a time,
883    /// after closing with its first two.
884    recloses: usize,
885    /// The opener of the latest-starting `==` span enclosing this run (see
886    /// `assign_emphasis_roles`), or `NIL`.
887    enclosed_from: usize,
888    /// Offset of the run in the inline input, for adjacency checks.
889    position: usize,
890}
891
892/// End-of-list marker for the index-linked lists used by `process_emphasis`.
893const NIL: usize = usize::MAX;
894
895/// The deepest `Emphasis`/`Strong`/`Delete` nesting one inline pass builds. A
896/// delimiter pair that would nest deeper stays literal text, which bounds the
897/// tree depth that recursive consumers (text merging, serialization, `Drop`)
898/// walk.
899const MAX_EMPHASIS_NESTING: usize = 16;
900
901/// How deeply the nodes between two delimiter runs nest: `emphasis` counts
902/// `Emphasis`/`Strong`/`Delete` levels since the nearest enclosed mark span,
903/// bracket, or directive,
904/// and `total` counts every inline container level (emphasis, marks, and
905/// formed brackets and directives). `total` counts toward `MAX_INLINE_NESTING`
906/// together with the inline passes and open brackets enclosing these nodes;
907/// emphasis counts toward `MAX_EMPHASIS_NESTING` within one mark.
908#[derive(Clone, Copy, Default)]
909struct Nesting {
910    emphasis: usize,
911    total: usize,
912}
913
914impl Nesting {
915    fn max(self, other: Self) -> Self {
916        Self {
917            emphasis: self.emphasis.max(other.emphasis),
918            total: self.total.max(other.total),
919        }
920    }
921}
922
923/// The delimiter stack `process_emphasis` works on: live runs form a doubly
924/// linked list in source order, and each live run records the deepest nesting
925/// among the nodes between it and the next live run.
926struct DelimiterLinks {
927    prev: Vec<usize>,
928    next: Vec<usize>,
929    live: Vec<bool>,
930    nesting_after: Vec<Nesting>,
931}
932
933impl DelimiterLinks {
934    fn new(count: usize) -> Self {
935        Self {
936            prev: (0..count).map(|index| index.wrapping_sub(1)).collect(),
937            next: (1..=count)
938                .map(|index| if index < count { index } else { NIL })
939                .collect(),
940            live: alloc::vec![true; count],
941            nesting_after: alloc::vec![Nesting::default(); count],
942        }
943    }
944
945    /// Unlinks `index`; the nodes after it now follow the previous live run.
946    fn unlink(&mut self, index: usize) {
947        let (before, after) = (self.prev[index], self.next[index]);
948        self.live[index] = false;
949        if before != NIL {
950            self.next[before] = after;
951            self.nesting_after[before] = self.nesting_after[before].max(self.nesting_after[index]);
952        }
953        if after != NIL {
954            self.prev[after] = before;
955        }
956    }
957
958    /// Unlinks every run strictly between `opener` and `closer` and returns the
959    /// deepest nesting among the nodes between them.
960    fn close_span(&mut self, opener: usize, closer: usize) -> Nesting {
961        let mut nesting = self.nesting_after[opener];
962        let mut inner = self.next[opener];
963        while inner != closer {
964            nesting = nesting.max(self.nesting_after[inner]);
965            self.live[inner] = false;
966            inner = self.next[inner];
967        }
968        self.next[opener] = closer;
969        self.prev[closer] = opener;
970        nesting
971    }
972}
973
974/// The flat inline list `process_emphasis` rewrites, held as a doubly linked
975/// list over stable slots. Wrapping a matched span and dropping a consumed
976/// delimiter run cost time proportional to the wrapped nodes rather than to the
977/// whole list, and delimiter `node_index` values never need re-indexing.
978struct InlineList {
979    slots: Vec<Option<Inline>>,
980    prev: Vec<usize>,
981    next: Vec<usize>,
982    head: usize,
983}
984
985impl InlineList {
986    fn new(nodes: Vec<Inline>) -> Self {
987        let count = nodes.len();
988        Self {
989            slots: nodes.into_iter().map(Some).collect(),
990            prev: (0..count).map(|slot| slot.wrapping_sub(1)).collect(),
991            next: (1..=count)
992                .map(|slot| if slot < count { slot } else { NIL })
993                .collect(),
994            head: if count == 0 { NIL } else { 0 },
995        }
996    }
997
998    fn node_mut(&mut self, slot: usize) -> &mut Inline {
999        self.slots[slot]
1000            .as_mut()
1001            .expect("delimiter placeholder slot is live")
1002    }
1003
1004    /// Replaces every node strictly between `before` and `after` with the node
1005    /// `wrap` builds from them (in order).
1006    fn wrap_between(
1007        &mut self,
1008        before: usize,
1009        after: usize,
1010        wrap: impl FnOnce(Vec<Inline>) -> Inline,
1011    ) {
1012        let mut children = Vec::new();
1013        let mut cursor = self.next[before];
1014        while cursor != after {
1015            children.push(self.slots[cursor].take().expect("linked slot is live"));
1016            cursor = self.next[cursor];
1017        }
1018        let slot = self.slots.len();
1019        self.slots.push(Some(wrap(children)));
1020        self.prev.push(before);
1021        self.next.push(after);
1022        self.next[before] = slot;
1023        self.prev[after] = slot;
1024    }
1025
1026    fn remove(&mut self, slot: usize) {
1027        let (prev, next) = (self.prev[slot], self.next[slot]);
1028        if prev == NIL {
1029            self.head = next;
1030        } else {
1031            self.next[prev] = next;
1032        }
1033        if next != NIL {
1034            self.prev[next] = prev;
1035        }
1036        self.slots[slot] = None;
1037    }
1038
1039    fn into_vec(mut self) -> Vec<Inline> {
1040        let mut nodes = Vec::new();
1041        let mut cursor = self.head;
1042        while cursor != NIL {
1043            nodes.push(self.slots[cursor].take().expect("linked slot is live"));
1044            cursor = self.next[cursor];
1045        }
1046        nodes
1047    }
1048}
1049
1050/// Pushes a literal text node for `value` starting at `start` of the block-level
1051/// inline input.
1052fn push_text(nodes: &mut Vec<Inline>, start: usize, value: &str) {
1053    nodes.push(Inline::Text(Text {
1054        meta: NodeMeta::new(Some(Span::new(start, start + value.len()))),
1055        value: value.into(),
1056    }));
1057}
1058
1059/// Resolves the `]` at `close` against the innermost open bracket. Returns the
1060/// end of the construct it forms, or `None` when the `]` stays literal text
1061/// (the opener's characters then stay literal too).
1062#[allow(clippy::too_many_arguments)]
1063fn close_bracket(
1064    input: &str,
1065    base_offset: usize,
1066    close: usize,
1067    definitions: Option<Definitions<'_>>,
1068    nodes: &mut Vec<Inline>,
1069    delimiters: &mut Vec<DelimMarker>,
1070    brackets: &mut Brackets,
1071    pass: &mut InlinePass,
1072) -> Option<usize> {
1073    let mut opener = brackets.openers.pop()?;
1074    let depth = pass.state.depth + brackets.openers.len();
1075    // The opener's own stack slot decides whether it may form a link; later
1076    // openers pushed into that slot start out able to.
1077    let link_active = brackets.openers.len() >= brackets.link_floor;
1078    brackets.link_floor = brackets.link_floor.min(brackets.openers.len());
1079
1080    if opener.kind == BracketKind::InlineFootnote {
1081        if !input[opener.position + 2..close].trim().is_empty() {
1082            let (children, marks) = take_label(input, nodes, delimiters, brackets, &opener, depth);
1083            nodes.truncate(opener.node_index - 1);
1084            nodes.push(Inline::InlineFootnote(InlineFootnote {
1085                meta: NodeMeta::new(Some(Span::new(
1086                    base_offset + opener.position,
1087                    base_offset + close + 1,
1088                ))),
1089                children,
1090            }));
1091            brackets.formed.push((nodes.len() - 1, marks + 1));
1092            return Some(close + 1);
1093        }
1094        // An empty inline footnote is a literal `^`, then a plain `[`.
1095        opener.kind = BracketKind::Link;
1096        opener.position += 1;
1097    }
1098
1099    if opener.kind == BracketKind::Image {
1100        let target = match_link_target(
1101            &mut pass.scan,
1102            input,
1103            opener.position + 2,
1104            close,
1105            definitions,
1106        );
1107        if let Some((end, target)) = target {
1108            let (children, marks) = take_label(input, nodes, delimiters, brackets, &opener, depth);
1109            nodes.truncate(opener.node_index - 1);
1110            let span = Span::new(base_offset + opener.position, base_offset + end);
1111            nodes.push(link_node(target, true, span, children));
1112            brackets.formed.push((nodes.len() - 1, marks + 1));
1113            return Some(end);
1114        }
1115        // Not an image: the `!` stays text and the `[` may still open a link.
1116        opener.kind = BracketKind::Link;
1117        opener.position += 1;
1118    }
1119
1120    if link_active {
1121        let target = match_link_target(
1122            &mut pass.scan,
1123            input,
1124            opener.position + 1,
1125            close,
1126            definitions,
1127        );
1128        if let Some((end, target)) = target {
1129            let (children, marks) = take_label(input, nodes, delimiters, brackets, &opener, depth);
1130            nodes.truncate(opener.node_index);
1131            let span = Span::new(base_offset + opener.position, base_offset + end);
1132            nodes.push(link_node(target, false, span, children));
1133            brackets.formed.push((nodes.len() - 1, marks + 1));
1134            // Links do not nest: no earlier `[` forms a link around this one.
1135            brackets.close_links();
1136            return Some(end);
1137        }
1138    }
1139
1140    // `[^label]` is a footnote reference when the label is plain: no link or
1141    // image formed inside it.
1142    let holds_formed = brackets
1143        .formed
1144        .last()
1145        .is_some_and(|&(node, _)| node > opener.node_index);
1146    if !holds_formed && input[opener.position + 1..].starts_with('^') {
1147        let label = &input[opener.position + 2..close];
1148        if is_footnote_label(label) {
1149            drop_label(nodes, delimiters, brackets, &opener);
1150            nodes.push(Inline::FootnoteReference(FootnoteReference {
1151                meta: NodeMeta::new(Some(Span::new(
1152                    base_offset + opener.position,
1153                    base_offset + close + 1,
1154                ))),
1155                label: label.into(),
1156                identifier: normalize_label(label),
1157            }));
1158            return Some(close + 1);
1159        }
1160    }
1161    None
1162}
1163
1164/// Drops `opener`'s `[` and everything recorded after it.
1165fn drop_label(
1166    nodes: &mut Vec<Inline>,
1167    delimiters: &mut Vec<DelimMarker>,
1168    brackets: &mut Brackets,
1169    opener: &BracketOpener,
1170) {
1171    nodes.truncate(opener.node_index);
1172    delimiters.truncate(opener.delimiter_bottom);
1173    let kept = brackets
1174        .formed
1175        .partition_point(|&(node, _)| node < opener.node_index);
1176    brackets.formed.truncate(kept);
1177}
1178
1179/// Pushes the `length`-byte run of `marker` at `index` as a literal text node
1180/// plus a delimiter entry, whose roles `assign_emphasis_roles` settles.
1181fn push_delimiter(
1182    nodes: &mut Vec<Inline>,
1183    delimiters: &mut Vec<DelimMarker>,
1184    index: usize,
1185    base_offset: usize,
1186    marker: u8,
1187    length: usize,
1188) {
1189    let node_index = nodes.len();
1190    nodes.push(Inline::Text(Text {
1191        meta: NodeMeta::new(Some(Span::new(
1192            base_offset + index,
1193            base_offset + index + length,
1194        ))),
1195        value: String::from(marker as char).repeat(length),
1196    }));
1197    delimiters.push(DelimMarker {
1198        node_index,
1199        marker,
1200        length,
1201        run_length: length,
1202        can_open: false,
1203        can_close: false,
1204        recloses: 0,
1205        enclosed_from: NIL,
1206        position: index,
1207    });
1208}
1209
1210/// The CommonMark roles of a `*`/`_`/`~~` run.
1211///
1212/// Flanking is computed on the whole run (CommonMark treats left/right-flanking
1213/// as a property of the run, not of an individual delimiter), including the
1214/// `_` intraword punctuation rules.
1215///
1216/// GFM's cross-marker bonus: a `*` run immediately adjacent to a `~` counts as
1217/// openable/closeable even though `~` is a punctuation character (this is what
1218/// makes `a*~b~*c` emphasize). A `_` run or a `~` run gets plain CommonMark
1219/// flanking beside a `~`.
1220///
1221/// A side that touches the boundary of an enclosing mark span (`bounded_before`
1222/// / `bounded_after`) flanks as the end of the span's content.
1223fn emphasis_roles(
1224    input: &str,
1225    index: usize,
1226    length: usize,
1227    marker: u8,
1228    bounded_before: bool,
1229    bounded_after: bool,
1230) -> (bool, bool) {
1231    let flanking = delimiter_flanking_within(input, index, length, bounded_before, bounded_after);
1232    let (mut can_open, mut can_close) = if marker == b'_' {
1233        (
1234            flanking.left
1235                && (!flanking.right || flanking.previous.is_some_and(is_flanking_punctuation)),
1236            flanking.right
1237                && (!flanking.left || flanking.next.is_some_and(is_flanking_punctuation)),
1238        )
1239    } else {
1240        (flanking.left, flanking.right)
1241    };
1242
1243    // GFM: a `*` run touching a `~` strikethrough marker may open/close even
1244    // when ordinary flanking refuses it (the `~` would otherwise be a blocking
1245    // punctuation neighbour).
1246    if marker == b'*' {
1247        if flanking.next == Some('~') {
1248            can_open = true;
1249        }
1250        if flanking.previous == Some('~') {
1251            can_close = true;
1252        }
1253    }
1254    (can_open, can_close)
1255}
1256
1257/// Settles the roles of every recorded run. The `==` runs take theirs first
1258/// and are paired among themselves; each `*`, `_`, and `~~` run then takes its
1259/// roles once the `==` spans are known, flanking as the edge of a span's
1260/// content where it touches the inner side of the span's delimiter.
1261fn assign_emphasis_roles(input: &str, delimiters: &mut [DelimMarker]) {
1262    let mut opens_span = alloc::vec![false; delimiters.len()];
1263    let mut closes_span = alloc::vec![false; delimiters.len()];
1264    let mut open_marks: Vec<usize> = Vec::new();
1265    // The run each span opened by a run closes at (a run opens at most one).
1266    let mut span_close = alloc::vec![NIL; delimiters.len()];
1267    for (index, run) in delimiters.iter_mut().enumerate() {
1268        if run.marker != b'=' {
1269            continue;
1270        }
1271        assign_double_mark_roles(input, run);
1272        let mut closed = 0;
1273        if run.can_close {
1274            while closed <= run.recloses {
1275                let Some(opener) = open_marks.pop() else {
1276                    break;
1277                };
1278                opens_span[opener] = true;
1279                closes_span[index] = true;
1280                span_close[opener] = index;
1281                closed += 1;
1282            }
1283        }
1284        // What is left of the run after closing may still open.
1285        if run.can_open && run.length >= 2 * closed + 2 {
1286            open_marks.push(index);
1287        }
1288    }
1289
1290    for index in 0..delimiters.len() {
1291        let run = delimiters[index];
1292        if run.marker == b'=' {
1293            continue;
1294        }
1295        let bounded_before = index > 0 && {
1296            let before = &delimiters[index - 1];
1297            before.position + before.length == run.position && opens_span[index - 1]
1298        };
1299        let bounded_after = index + 1 < delimiters.len() && {
1300            let after = &delimiters[index + 1];
1301            after.position == run.position + run.length && closes_span[index + 1]
1302        };
1303        let (can_open, can_close) = emphasis_roles(
1304            input,
1305            run.position,
1306            run.length,
1307            run.marker,
1308            bounded_before,
1309            bounded_after,
1310        );
1311        delimiters[index].can_open = can_open;
1312        delimiters[index].can_close = can_close;
1313    }
1314
1315    // Each run's latest-starting enclosing mark span. Spans are pushed in
1316    // opener order, so the top of `open_spans` is the latest-starting one;
1317    // spans that have ended are dropped when they reach the top.
1318    let mut open_spans: Vec<usize> = Vec::new();
1319    for (index, run) in delimiters.iter_mut().enumerate() {
1320        while open_spans
1321            .last()
1322            .is_some_and(|&opener| span_close[opener] <= index)
1323        {
1324            open_spans.pop();
1325        }
1326        if let Some(&opener) = open_spans.last() {
1327            run.enclosed_from = opener;
1328        }
1329        if span_close[index] != NIL {
1330            open_spans.push(index);
1331        }
1332    }
1333}
1334
1335/// Sets the roles of a `==` run: it opens with its last two characters and
1336/// closes with its first two (and then the next two), each subject to
1337/// CommonMark flanking of that two-character delimiter. A run right after an
1338/// escaped character of the same mark never closes.
1339fn assign_double_mark_roles(input: &str, run: &mut DelimMarker) {
1340    let (index, length) = (run.position, run.length);
1341    let after_escaped_mark =
1342        index > 0 && input.as_bytes()[index - 1] == run.marker && is_escaped_at(input, index - 1);
1343    run.can_close = !after_escaped_mark && can_close_delimited(input, index, 2);
1344    run.recloses = if run.can_close {
1345        (1..length / 2)
1346            .take_while(|pair| can_close_delimited(input, index + 2 * pair, 2))
1347            .count()
1348    } else {
1349        0
1350    };
1351    run.can_open = can_open_delimited(input, index + length - 2, 2);
1352}
1353
1354/// Resolves recorded delimiter runs into emphasis and mark nodes using the
1355/// delimiter-stack algorithm, leaving unmatched runs as text. Closers are taken
1356/// in source order; when a pair closes, runs strictly between its opener and
1357/// closer can no longer pair across it.
1358///
1359/// `formed` lists the link, image, footnote, and directive nodes already built
1360/// among `nodes`, with their nesting depth, so spans around them count it.
1361/// Returns the nodes and the deepest nesting among them.
1362///
1363/// `depth` counts the nesting levels enclosing these runs: the inline passes
1364/// around this one, plus the open brackets around a bracket label.
1365fn process_emphasis(
1366    nodes: Vec<Inline>,
1367    mut delimiters: Vec<DelimMarker>,
1368    depth: usize,
1369    formed: &[(usize, usize)],
1370) -> (Vec<Inline>, usize) {
1371    let mut deepest = formed.iter().map(|&(_, depth)| depth).max().unwrap_or(0);
1372    if delimiters.is_empty() {
1373        let mut nodes = nodes;
1374        merge_adjacent_text(&mut nodes);
1375        return (nodes, deepest);
1376    }
1377    let mut nodes = InlineList::new(nodes);
1378
1379    // A run that is consumed, demoted to plain text, or enclosed by a newly
1380    // closed span is unlinked, so the opener walk below only ever visits live
1381    // runs.
1382    let mut links = DelimiterLinks::new(delimiters.len());
1383    for &(node, depth) in formed {
1384        let before = delimiters.partition_point(|run| run.node_index < node);
1385        if before > 0 {
1386            let gap = &mut links.nesting_after[before - 1].total;
1387            *gap = (*gap).max(depth);
1388        }
1389    }
1390
1391    // `openers_bottom` records, per (marker, opener-can-also-close, length % 3),
1392    // the lowest opener index a nested closer is allowed to reach. Closers below
1393    // this bound for their key have already been proven to have no compatible
1394    // opener.
1395    let mut openers_bottom: [Option<usize>; NESTED_MARKERS * 6] = [None; NESTED_MARKERS * 6];
1396    let mut span_bottom: Vec<Vec<(usize, usize)>> = Vec::new();
1397    let mut closer_idx = 0;
1398
1399    // Every run at or after `closer_idx` is still linked: runs are only ever
1400    // unlinked at or before the current closer.
1401    while closer_idx < delimiters.len() {
1402        let closer = delimiters[closer_idx];
1403
1404        let opener = if closer.can_close {
1405            nested_opener(
1406                &delimiters,
1407                &links,
1408                &openers_bottom,
1409                &span_bottom,
1410                closer_idx,
1411            )
1412        } else {
1413            None
1414        };
1415
1416        let Some(opener_idx) = opener else {
1417            if closer.can_close {
1418                // No opener found: remember how far we searched so future
1419                // closers of the same key skip the same dead range.
1420                let key = openers_bottom_key(&closer);
1421                match span_floor(&closer) {
1422                    Some(span) => {
1423                        if span_bottom.len() <= span {
1424                            span_bottom.resize_with(span + 1, Vec::new);
1425                        }
1426                        match span_bottom[span].iter_mut().find(|(k, _)| *k == key) {
1427                            Some(bound) => bound.1 = closer_idx,
1428                            None => span_bottom[span].push((key, closer_idx)),
1429                        }
1430                    }
1431                    None => openers_bottom[key] = Some(closer_idx),
1432                }
1433            }
1434            if !closer.can_open {
1435                links.unlink(closer_idx);
1436            }
1437            closer_idx += 1;
1438            continue;
1439        };
1440
1441        let (used, wrap) = pair_shape(&delimiters[opener_idx], &delimiters[closer_idx]);
1442
1443        // Drop delimiters strictly between the opener and closer: they could not
1444        // match outward across this newly closed span.
1445        let inner = links.close_span(opener_idx, closer_idx);
1446        let fits = depth + inner.total < MAX_INLINE_NESTING
1447            && (wrap.is_mark() || inner.emphasis < MAX_EMPHASIS_NESTING);
1448        if fits {
1449            apply_emphasis(
1450                &mut nodes,
1451                &mut delimiters,
1452                opener_idx,
1453                closer_idx,
1454                used,
1455                wrap,
1456            );
1457            links.nesting_after[opener_idx] = Nesting {
1458                emphasis: if wrap.is_mark() {
1459                    0
1460                } else {
1461                    inner.emphasis + 1
1462                },
1463                total: inner.total + 1,
1464            };
1465            deepest = deepest.max(inner.total + 1);
1466        } else {
1467            // Too deep to wrap: both runs stay whole in their text nodes as
1468            // literal text and pair no further. Any span enclosing this one is
1469            // at least as deep, so it stays literal too.
1470            delimiters[opener_idx].length = 0;
1471            delimiters[closer_idx].length = 0;
1472            links.nesting_after[opener_idx] = inner;
1473        }
1474        if closer.marker == b'=' {
1475            // `==` runs open only with their last two characters, and a long
1476            // run closes a second time only with its next two characters.
1477            delimiters[opener_idx].can_open = false;
1478            let run = &mut delimiters[closer_idx];
1479            run.can_close = run.recloses > 0;
1480            run.recloses = run.recloses.saturating_sub(1);
1481        }
1482
1483        if delimiters[opener_idx].length == 0 {
1484            links.unlink(opener_idx);
1485        }
1486        if delimiters[closer_idx].length == 0 {
1487            links.unlink(closer_idx);
1488            closer_idx += 1;
1489        }
1490        // When a closer still has delimiters left it stays the active closer
1491        // so the leftover can match an earlier opener (e.g. `***foo*` keeps
1492        // `**`).
1493    }
1494
1495    // Adjacent text nodes can appear where unmatched delimiter runs ended up
1496    // beside literal text (`**foo*bar*` -> `**foo` + emphasis). CommonMark
1497    // coalesces them as the final step; do the same for the spans we created.
1498    let mut nodes = nodes.into_vec();
1499    merge_adjacent_text(&mut nodes);
1500    (nodes, deepest)
1501}
1502
1503/// The markers that pair through nested roles, in `openers_bottom` order.
1504const NESTED_MARKERS: usize = 4;
1505
1506/// The nearest live opener before `closer_idx` that the nested closer there can
1507/// pair with, searching no lower than its `openers_bottom` bound.
1508///
1509/// A closer that can also open does not close across a `==` span enclosing it:
1510/// it searches no lower than that span's opener, bounded per span by
1511/// `span_bottom` the way `openers_bottom` bounds the whole list.
1512fn nested_opener(
1513    delimiters: &[DelimMarker],
1514    links: &DelimiterLinks,
1515    openers_bottom: &[Option<usize>],
1516    span_bottom: &[Vec<(usize, usize)>],
1517    closer_idx: usize,
1518) -> Option<usize> {
1519    let closer = &delimiters[closer_idx];
1520    let key = openers_bottom_key(closer);
1521    let mut bottom = openers_bottom[key];
1522    if let Some(span) = span_floor(closer) {
1523        let span_floor = span_bottom
1524            .get(span)
1525            .and_then(|bounds| bounds.iter().find(|(k, _)| *k == key))
1526            .map_or(span + 1, |&(_, bound)| bound);
1527        bottom = bottom.max(Some(span_floor));
1528    }
1529    let mut search = links.prev[closer_idx];
1530    while search != NIL {
1531        if bottom.is_some_and(|bottom| search < bottom) {
1532            return None;
1533        }
1534        let candidate = &delimiters[search];
1535        if candidate.marker == closer.marker
1536            && candidate.can_open
1537            && emphasis_delimiters_match(candidate, closer)
1538        {
1539            return Some(search);
1540        }
1541        search = links.prev[search];
1542    }
1543    None
1544}
1545
1546/// The enclosing mark span a nested closer may not close across: only a run
1547/// that can also open is held inside it.
1548fn span_floor(closer: &DelimMarker) -> Option<usize> {
1549    (closer.can_open && closer.enclosed_from != NIL).then_some(closer.enclosed_from)
1550}
1551
1552/// How many characters a pair consumes from each run, and the node it forms.
1553fn pair_shape(opener: &DelimMarker, closer: &DelimMarker) -> (usize, EmphasisWrap) {
1554    match closer.marker {
1555        // Strikethrough consumes the whole `~~` run on each side.
1556        b'~' => (2, EmphasisWrap::Delete),
1557        b'=' => (2, EmphasisWrap::Mark),
1558        marker => {
1559            let delimiter = if marker == b'_' {
1560                EmphasisDelimiter::Underscore
1561            } else {
1562                EmphasisDelimiter::Asterisk
1563            };
1564            if opener.length >= 2 && closer.length >= 2 {
1565                (2, EmphasisWrap::Strong(delimiter))
1566            } else {
1567                (1, EmphasisWrap::Emphasis(delimiter))
1568            }
1569        }
1570    }
1571}
1572
1573/// Merges consecutive `Text` nodes in a list, recursing into the emphasis and
1574/// mark nodes produced at this level. Other containers were already finalized
1575/// by their own `parse_inlines` pass and are left untouched.
1576fn merge_adjacent_text(nodes: &mut Vec<Inline>) {
1577    let mut write = 0;
1578    for read in 0..nodes.len() {
1579        if read != write {
1580            nodes.swap(read, write);
1581        }
1582        if write > 0 {
1583            let (head, tail) = nodes.split_at_mut(write);
1584            if let (Inline::Text(previous), Inline::Text(current)) =
1585                (&mut head[write - 1], &tail[0])
1586            {
1587                previous.value.push_str(&current.value);
1588                if let (Some(previous_span), Some(current_span)) =
1589                    (previous.meta.span.as_mut(), current.meta.span)
1590                {
1591                    previous_span.end = current_span.end;
1592                }
1593                continue;
1594            }
1595        }
1596        write += 1;
1597    }
1598    nodes.truncate(write);
1599
1600    for node in nodes.iter_mut() {
1601        match node {
1602            Inline::Emphasis(node) => merge_adjacent_text(&mut node.children),
1603            Inline::Strong(node) => merge_adjacent_text(&mut node.children),
1604            Inline::Delete(node) => merge_adjacent_text(&mut node.children),
1605            Inline::Mark(node) => merge_adjacent_text(&mut node.children),
1606            _ => {}
1607        }
1608    }
1609}
1610
1611/// Index into `openers_bottom` for a nested closer's (marker, both-flags,
1612/// length % 3) key.
1613fn openers_bottom_key(closer: &DelimMarker) -> usize {
1614    let marker = match closer.marker {
1615        b'_' => 1,
1616        b'~' => 2,
1617        b'=' => 3,
1618        _ => 0,
1619    };
1620    let both = usize::from(closer.can_open && closer.can_close);
1621    // The key holds what `emphasis_delimiters_match` reads of the closer: the
1622    // whole run's length for the rule of three on `*` / `_`, and what is left
1623    // of the run for the other marks.
1624    let length = match closer.marker {
1625        b'*' | b'_' => closer.run_length,
1626        _ => closer.length,
1627    };
1628    let modulo = length % 3;
1629    ((marker * 2) + both) * 3 + modulo
1630}
1631
1632/// Nested opener/closer compatibility, including CommonMark's rule of three.
1633fn emphasis_delimiters_match(opener: &DelimMarker, closer: &DelimMarker) -> bool {
1634    match opener.marker {
1635        // GFM strikethrough: only runs of exactly two `~` are recorded, and a
1636        // pair consumes both whole, so any two live runs match. The rule of
1637        // three does not apply to `~`.
1638        b'~' => true,
1639        // `==` pairs two characters from each run.
1640        b'=' => opener.length >= 2 && closer.length >= 2,
1641        _ => {
1642            // Rule of three: if either delimiter can both open and close, the
1643            // sum of the lengths of the runs containing them must not be a
1644            // multiple of three, unless both are themselves multiples of three.
1645            // CommonMark counts whole runs, not what earlier pairings left.
1646            let opener_both = opener.can_open && opener.can_close;
1647            let closer_both = closer.can_open && closer.can_close;
1648            if opener_both || closer_both {
1649                let (opener_run, closer_run) = (opener.run_length, closer.run_length);
1650                let sum = opener_run + closer_run;
1651                if sum % 3 == 0 && !(opener_run % 3 == 0 && closer_run % 3 == 0) {
1652                    return false;
1653                }
1654            }
1655            true
1656        }
1657    }
1658}
1659
1660/// The node a matched delimiter pair collapses into.
1661#[derive(Clone, Copy)]
1662enum EmphasisWrap {
1663    Emphasis(EmphasisDelimiter),
1664    Strong(EmphasisDelimiter),
1665    Delete,
1666    Mark,
1667}
1668
1669impl EmphasisWrap {
1670    /// Whether the node counts toward `MAX_INLINE_NESTING` rather than
1671    /// `MAX_EMPHASIS_NESTING`.
1672    fn is_mark(self) -> bool {
1673        matches!(self, Self::Mark)
1674    }
1675}
1676
1677/// Wraps the nodes between two delimiter runs into the node `wrap` names and
1678/// consumes `used` characters from each side.
1679fn apply_emphasis(
1680    nodes: &mut InlineList,
1681    delimiters: &mut [DelimMarker],
1682    opener_idx: usize,
1683    closer_idx: usize,
1684    used: usize,
1685    wrap: EmphasisWrap,
1686) {
1687    let opener_node = delimiters[opener_idx].node_index;
1688    let closer_node = delimiters[closer_idx].node_index;
1689
1690    // A span covers exactly its consumed delimiters and what they enclose: the
1691    // last `used` characters left in the opener's text node through the first
1692    // `used` left in the closer's.
1693    let span_start = nodes
1694        .node_mut(opener_node)
1695        .span()
1696        .map(|span| span.end - used);
1697    let span_end = nodes
1698        .node_mut(closer_node)
1699        .span()
1700        .map(|span| span.start + used);
1701    // Trim the consumed characters from the opener's text node (right side) and
1702    // the closer's text node (left side), updating their recorded lengths/spans.
1703    trim_delimiter_text_tail(nodes.node_mut(opener_node), used);
1704    trim_delimiter_text_head(nodes.node_mut(closer_node), used);
1705    delimiters[opener_idx].length -= used;
1706    let closer = &mut delimiters[closer_idx];
1707    closer.length -= used;
1708    closer.position += used;
1709
1710    // The wrapped children are the nodes strictly between the opener and closer
1711    // text nodes.
1712    let meta = NodeMeta::new(
1713        span_start
1714            .zip(span_end)
1715            .map(|(start, end)| Span::new(start, end)),
1716    );
1717    nodes.wrap_between(opener_node, closer_node, |children| match wrap {
1718        EmphasisWrap::Strong(delimiter) => Inline::Strong(Strong {
1719            meta,
1720            delimiter,
1721            children,
1722        }),
1723        EmphasisWrap::Emphasis(delimiter) => Inline::Emphasis(Emphasis {
1724            meta,
1725            delimiter,
1726            children,
1727        }),
1728        EmphasisWrap::Delete => Inline::Delete(Delete { meta, children }),
1729        EmphasisWrap::Mark => Inline::Mark(Mark { meta, children }),
1730    });
1731
1732    // Drop any placeholder text node that has been fully consumed so leftover
1733    // delimiters never survive as literal text.
1734    if delimiters[closer_idx].length == 0 {
1735        nodes.remove(closer_node);
1736    }
1737    if delimiters[opener_idx].length == 0 {
1738        nodes.remove(opener_node);
1739    }
1740}
1741
1742/// What a `[`-family opener can become when its `]` arrives.
1743#[derive(Clone, Copy, PartialEq, Eq)]
1744enum BracketKind {
1745    Link,
1746    Image,
1747    InlineFootnote,
1748}
1749
1750/// A `[`, `![`, or `^[` waiting for its `]`. Its `[` is a placeholder text
1751/// node; an image's `!` and an inline footnote's `^` sit in the node before it.
1752struct BracketOpener {
1753    kind: BracketKind,
1754    /// The placeholder text node holding the `[`.
1755    node_index: usize,
1756    /// Offset of the opener's first character (`!`, `^`, or `[`) in the input.
1757    position: usize,
1758    /// `delimiters.len()` when the opener was pushed: later runs are in its
1759    /// label.
1760    delimiter_bottom: usize,
1761}
1762
1763/// What a `]` resolves a link-kind opener to.
1764enum LinkTarget {
1765    Resource(ParsedLinkResource),
1766    Reference {
1767        identifier: String,
1768        kind: ReferenceKind,
1769    },
1770}
1771
1772/// Matches what follows the `]` at `close` of a link or image whose label is
1773/// `input[label_start..close]`: an inline `(…)` resource, a full or collapsed
1774/// reference, or a shortcut reference to a defined label. A `(…)` that is not a
1775/// valid resource leaves the reference forms to try, for images and links
1776/// alike. Returns the end of the construct and its target.
1777fn match_link_target(
1778    scan: &mut InlineScan,
1779    input: &str,
1780    label_start: usize,
1781    close: usize,
1782    definitions: Option<Definitions<'_>>,
1783) -> Option<(usize, LinkTarget)> {
1784    let label = &input[label_start..close];
1785    let after = close + 1;
1786    if input.as_bytes().get(after) == Some(&b'(') {
1787        match parse_link_resource(&mut scan.lookups, input, after) {
1788            Some((end, resource)) => return Some((end, LinkTarget::Resource(resource))),
1789            // A present-but-invalid `(...)` resource is not an inline link or
1790            // image, but CommonMark still resolves `[label]` as a shortcut
1791            // reference and leaves the invalid `(...)` as literal text (links
1792            // 568).
1793            None => {}
1794        }
1795    }
1796    // A `[` that opens no reference label leaves `[label]` a shortcut
1797    // reference followed by literal text.
1798    let reference_close = if input.as_bytes().get(after) == Some(&b'[') {
1799        scan.reference_label_end(after)
1800    } else {
1801        None
1802    };
1803    if let Some(reference_close) = reference_close {
1804        let reference = &input[after + 1..reference_close];
1805        let identifier = if reference.is_empty() {
1806            label
1807        } else {
1808            reference
1809        };
1810        // A present `[...]` second label that resolves to no definition is
1811        // not a link, and CommonMark does not fall back to treating the first
1812        // label as a shortcut (`[x][ ]`, `[x][undef]` stay literal).
1813        return definition_exists(definitions, identifier).then(|| {
1814            let kind = if reference.is_empty() {
1815                ReferenceKind::Collapsed
1816            } else {
1817                ReferenceKind::Full
1818            };
1819            (
1820                reference_close + 1,
1821                LinkTarget::Reference {
1822                    identifier: identifier.into(),
1823                    kind,
1824                },
1825            )
1826        });
1827    }
1828    definition_exists(definitions, label).then(|| {
1829        (
1830            after,
1831            LinkTarget::Reference {
1832                identifier: label.into(),
1833                kind: ReferenceKind::Shortcut,
1834            },
1835        )
1836    })
1837}
1838
1839/// Builds the link or image node for a matched target over `children`.
1840fn link_node(target: LinkTarget, image: bool, span: Span, mut children: Vec<Inline>) -> Inline {
1841    let meta = NodeMeta::new(Some(span));
1842    if !image {
1843        demote_links(&mut children);
1844    }
1845    match (target, image) {
1846        (LinkTarget::Resource(resource), false) => Inline::Link(Link {
1847            meta,
1848            destination: resource.destination,
1849            destination_kind: resource.destination_kind,
1850            title: resource.title,
1851            children,
1852        }),
1853        (LinkTarget::Resource(resource), true) => Inline::Image(Image {
1854            meta,
1855            destination: resource.destination,
1856            destination_kind: resource.destination_kind,
1857            title: resource.title,
1858            alt: children,
1859        }),
1860        (LinkTarget::Reference { identifier, kind }, false) => {
1861            Inline::LinkReference(LinkReference {
1862                meta,
1863                identifier: normalize_label(&identifier),
1864                label: identifier,
1865                kind,
1866                children,
1867            })
1868        }
1869        (LinkTarget::Reference { identifier, kind }, true) => {
1870            Inline::ImageReference(ImageReference {
1871                meta,
1872                identifier: normalize_label(&identifier),
1873                label: identifier,
1874                kind,
1875                alt: children,
1876            })
1877        }
1878    }
1879}
1880
1881/// An autolink written in `form` as `text`, spanning `span`.
1882fn autolink_node(form: AutolinkForm, span: Span, text: &str) -> Inline {
1883    Inline::Autolink(Autolink {
1884        meta: NodeMeta::new(Some(span)),
1885        form,
1886        text: text.into(),
1887    })
1888}
1889
1890/// Link text holds no links: autolinks inside it, the only links a formed
1891/// link's text can hold, read as their text, an angle-bracket autolink
1892/// without its brackets. Image alt text inside it keeps its own links.
1893fn demote_links(nodes: &mut Vec<Inline>) {
1894    for node in nodes.iter_mut() {
1895        match node {
1896            Inline::Autolink(autolink) => {
1897                *node = Inline::Text(Text {
1898                    meta: autolink.meta.clone(),
1899                    value: core::mem::take(&mut autolink.text),
1900                });
1901            }
1902            Inline::Emphasis(node) => demote_links(&mut node.children),
1903            Inline::Strong(node) => demote_links(&mut node.children),
1904            Inline::Delete(node) => demote_links(&mut node.children),
1905            Inline::Mark(node) => demote_links(&mut node.children),
1906            Inline::InlineFootnote(node) => demote_links(&mut node.children),
1907            Inline::TextDirective(node) => demote_links(&mut node.label),
1908            Inline::Image(node) => demote_links(&mut node.alt),
1909            Inline::ImageReference(node) => demote_links(&mut node.alt),
1910            _ => {}
1911        }
1912    }
1913    merge_adjacent_text(nodes);
1914}
1915
1916/// The open brackets of one inline pass and the runs and nodes they enclose.
1917struct Brackets {
1918    openers: Vec<BracketOpener>,
1919    /// Openers below this stack index can no longer form links: a link formed
1920    /// after them.
1921    link_floor: usize,
1922    /// How many `[` were left as text for want of nesting room and are still
1923    /// open: the next that many `]` close them, as text.
1924    overflow: usize,
1925    /// Formed link, image, footnote, and directive nodes with their nesting
1926    /// depth, by node index, so spans around them count it.
1927    formed: Vec<(usize, usize)>,
1928}
1929
1930impl Brackets {
1931    fn new() -> Self {
1932        Self {
1933            openers: Vec::new(),
1934            link_floor: 0,
1935            overflow: 0,
1936            formed: Vec::new(),
1937        }
1938    }
1939
1940    /// Whether a `[` is open: pushed and not yet closed by a `]`, as a link or
1941    /// image opener or as text left for want of nesting room. An inline
1942    /// footnote's `^[` opens no link text. The stack is at most the nesting
1943    /// limit deep.
1944    fn open(&self) -> bool {
1945        self.overflow > 0
1946            || self
1947                .openers
1948                .iter()
1949                .any(|opener| !matches!(opener.kind, BracketKind::InlineFootnote))
1950    }
1951
1952    /// Keeps every open `[` from forming a link around what follows.
1953    fn close_links(&mut self) {
1954        self.link_floor = self.openers.len();
1955    }
1956}
1957
1958/// Takes the nodes and runs after `opener` out of the pass, pairs the runs
1959/// among themselves, and returns the resolved label content with its nesting
1960/// depth.
1961fn take_label(
1962    input: &str,
1963    nodes: &mut Vec<Inline>,
1964    delimiters: &mut Vec<DelimMarker>,
1965    brackets: &mut Brackets,
1966    opener: &BracketOpener,
1967    depth: usize,
1968) -> (Vec<Inline>, usize) {
1969    let first = opener.node_index + 1;
1970    let children: Vec<Inline> = nodes.drain(first..).collect();
1971    let mut runs = delimiters.split_off(opener.delimiter_bottom);
1972    for run in &mut runs {
1973        run.node_index -= first;
1974    }
1975    let split = brackets.formed.partition_point(|&(node, _)| node < first);
1976    let formed: Vec<(usize, usize)> = brackets
1977        .formed
1978        .split_off(split)
1979        .into_iter()
1980        .map(|(node, marks)| (node - first, marks))
1981        .collect();
1982    assign_emphasis_roles(input, &mut runs);
1983    process_emphasis(children, runs, depth, &formed)
1984}
1985
1986/// Removes `count` trailing delimiter characters from a placeholder text node.
1987fn trim_delimiter_text_tail(node: &mut Inline, count: usize) {
1988    if let Inline::Text(text) = node {
1989        let new_len = text.value.len().saturating_sub(count);
1990        text.value.truncate(new_len);
1991        if let Some(span) = text.meta.span.as_mut() {
1992            span.end = span.end.saturating_sub(count);
1993        }
1994    }
1995}
1996
1997/// Removes `count` leading delimiter characters from a placeholder text node.
1998/// The node holds one repeated marker byte, so dropping from the front leaves
1999/// the same text as truncating the back.
2000fn trim_delimiter_text_head(node: &mut Inline, count: usize) {
2001    if let Inline::Text(text) = node {
2002        let count = count.min(text.value.len());
2003        text.value.truncate(text.value.len() - count);
2004        if let Some(span) = text.meta.span.as_mut() {
2005            span.start += count;
2006        }
2007    }
2008}
2009
2010/// Forward lookups the inline scanners ask of one input. `DirectLookups`
2011/// answers each with a fresh scan, for callers that ask once; `InlineLookups`
2012/// memoizes the answers for the many questions one inline pass asks. Both
2013/// answer identically.
2014trait Lookups {
2015    /// The first occurrence of `pattern` that starts at or after `from`.
2016    fn find(&mut self, pattern: &'static str, from: usize) -> Option<usize>;
2017    /// The first run of exactly `len` backticks at or after `start`, which is
2018    /// not inside a backtick run.
2019    fn code_span_close(&mut self, start: usize, len: usize) -> Option<usize>;
2020    /// The first unescaped `<` or `>`, or line break, at or after `from`.
2021    fn angle_destination_stop(&mut self, from: usize) -> Option<usize>;
2022    /// The first `$` not right after a `\` at or after `from`.
2023    fn unescaped_dollar(&mut self, from: usize) -> Option<usize>;
2024}
2025
2026struct DirectLookups<'a> {
2027    input: &'a str,
2028}
2029
2030impl Lookups for DirectLookups<'_> {
2031    fn find(&mut self, pattern: &'static str, from: usize) -> Option<usize> {
2032        self.input[from..].find(pattern).map(|offset| from + offset)
2033    }
2034
2035    fn code_span_close(&mut self, start: usize, len: usize) -> Option<usize> {
2036        find_code_span_close(self.input, start, len)
2037    }
2038
2039    fn angle_destination_stop(&mut self, from: usize) -> Option<usize> {
2040        (from..self.input.len()).find(|index| is_angle_destination_stop(self.input, *index))
2041    }
2042
2043    fn unescaped_dollar(&mut self, from: usize) -> Option<usize> {
2044        (from..self.input.len()).find(|index| is_unescaped_dollar(self.input, *index))
2045    }
2046}
2047
2048/// The patterns `InlineLookups::find` keeps position tables for.
2049const CACHED_PATTERNS: [&str; 8] = [">", "-->", "?>", "]]>", "\"", "'", "`$", "$$"];
2050
2051/// `Lookups` over one inline input, each answered from a table built on first
2052/// use.
2053struct InlineLookups<'a> {
2054    input: &'a str,
2055    patterns: [Positions; CACHED_PATTERNS.len()],
2056    backtick_runs: Option<BTreeMap<usize, Vec<usize>>>,
2057    angle_destination_stops: Positions,
2058    unescaped_dollars: Positions,
2059}
2060
2061impl<'a> InlineLookups<'a> {
2062    fn new(input: &'a str) -> Self {
2063        Self {
2064            input,
2065            patterns: Default::default(),
2066            backtick_runs: None,
2067            angle_destination_stops: Positions::default(),
2068            unescaped_dollars: Positions::default(),
2069        }
2070    }
2071}
2072
2073impl Lookups for InlineLookups<'_> {
2074    fn find(&mut self, pattern: &'static str, from: usize) -> Option<usize> {
2075        let input = self.input;
2076        let Some(slot) = CACHED_PATTERNS.iter().position(|cached| *cached == pattern) else {
2077            return DirectLookups { input }.find(pattern, from);
2078        };
2079        self.patterns[slot].first_at_or_after(from, || pattern_starts(input, pattern))
2080    }
2081
2082    fn code_span_close(&mut self, start: usize, len: usize) -> Option<usize> {
2083        let input = self.input;
2084        let runs = self
2085            .backtick_runs
2086            .get_or_insert_with(|| backtick_runs_by_length(input));
2087        let starts = runs.get(&len)?;
2088        starts
2089            .get(starts.partition_point(|run| *run < start))
2090            .copied()
2091    }
2092
2093    fn angle_destination_stop(&mut self, from: usize) -> Option<usize> {
2094        let input = self.input;
2095        self.angle_destination_stops.first_at_or_after(from, || {
2096            (0..input.len())
2097                .filter(|index| is_angle_destination_stop(input, *index))
2098                .collect()
2099        })
2100    }
2101
2102    fn unescaped_dollar(&mut self, from: usize) -> Option<usize> {
2103        let input = self.input;
2104        self.unescaped_dollars.first_at_or_after(from, || {
2105            (0..input.len())
2106                .filter(|index| is_unescaped_dollar(input, *index))
2107                .collect()
2108        })
2109    }
2110}
2111
2112/// Start offsets of every maximal backtick run, grouped by run length.
2113fn backtick_runs_by_length(input: &str) -> BTreeMap<usize, Vec<usize>> {
2114    let mut runs: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
2115    let mut index = 0;
2116    while index < input.len() {
2117        if input.as_bytes()[index] == b'`' {
2118            let len = backtick_run_len(input, index);
2119            runs.entry(len).or_default().push(index);
2120            index += len;
2121        } else {
2122            index += 1;
2123        }
2124    }
2125    runs
2126}
2127
2128fn backtick_run_len(input: &str, index: usize) -> usize {
2129    input.as_bytes()[index..]
2130        .iter()
2131        .take_while(|byte| **byte == b'`')
2132        .count()
2133}
2134
2135/// A `$` that single-dollar math reads as a delimiter: one right after a `\`
2136/// is content, whether or not that `\` is itself escaped.
2137fn is_unescaped_dollar(input: &str, index: usize) -> bool {
2138    let bytes = input.as_bytes();
2139    bytes[index] == b'$' && (index == 0 || bytes[index - 1] != b'\\')
2140}
2141
2142fn is_angle_destination_stop(input: &str, index: usize) -> bool {
2143    match input.as_bytes()[index] {
2144        b'<' | b'>' => !is_escaped_at(input, index),
2145        b'\n' | b'\r' => true,
2146        _ => false,
2147    }
2148}
2149
2150/// Everything one `parse_inline_content` pass memoizes about its input, so its
2151/// questions about closing delimiters cost amortized linear time in total.
2152struct InlineScan<'a> {
2153    input: &'a str,
2154    lookups: InlineLookups<'a>,
2155    label_ends: BracketMemo,
2156    reference_label_ends: PathMemo,
2157    wikilink_closes: PathMemo,
2158    directive_attribute_closes: PathMemo,
2159    literal_autolinks: LiteralAutolinkScan,
2160}
2161
2162impl<'a> InlineScan<'a> {
2163    fn new(input: &'a str) -> Self {
2164        Self {
2165            input,
2166            lookups: InlineLookups::new(input),
2167            label_ends: BracketMemo::default(),
2168            reference_label_ends: PathMemo::default(),
2169            wikilink_closes: PathMemo::default(),
2170            directive_attribute_closes: PathMemo::default(),
2171            literal_autolinks: LiteralAutolinkScan::default(),
2172        }
2173    }
2174
2175    /// `find_link_label_end`, memoized.
2176    fn link_label_end(&mut self, open: usize) -> Option<usize> {
2177        let input = self.input;
2178        if input.as_bytes().get(open) != Some(&b'[') {
2179            return None;
2180        }
2181        let lookups = &mut self.lookups;
2182        self.label_ends
2183            .resolve(input.len() + 1, open + 1, |cursor| {
2184                link_label_step(lookups, input, cursor)
2185            })
2186    }
2187
2188    /// `find_reference_label_end`, memoized.
2189    fn reference_label_end(&mut self, open: usize) -> Option<usize> {
2190        let input = self.input;
2191        if input.as_bytes().get(open) != Some(&b'[') {
2192            return None;
2193        }
2194        let close = self
2195            .reference_label_ends
2196            .resolve(input.len() + 1, open + 1, |cursor| {
2197                reference_label_step(input, cursor)
2198            })?;
2199        reference_label_is_within_limit(&input[open + 1..close]).then_some(close)
2200    }
2201
2202    /// The `]]` closing the wikilink whose content starts at `start`; it must
2203    /// be on the same line.
2204    fn wikilink_close(&mut self, start: usize) -> Option<usize> {
2205        let input = self.input;
2206        self.wikilink_closes
2207            .resolve(input.len() + 1, start, |cursor| {
2208                wikilink_close_step(input, cursor)
2209            })
2210    }
2211
2212    /// `find_directive_attributes_close`, memoized.
2213    fn directive_attributes_close(&mut self, open: usize) -> Option<usize> {
2214        let input = self.input;
2215        if input.as_bytes().get(open) != Some(&b'{') {
2216            return None;
2217        }
2218        self.directive_attribute_closes.resolve(
2219            (input.len() + 1) * DIRECTIVE_QUOTE_STATES,
2220            (open + 1) * DIRECTIVE_QUOTE_STATES,
2221            |node| directive_attributes_step(input, node),
2222        )
2223    }
2224}
2225
2226/// Parses the inline content `input`, whose source map is `map`. The inline
2227/// parser works in `input`'s coordinates; the spans it produces, on nodes and
2228/// on the diagnostics it pushes, are translated to the input afterwards.
2229fn parse_inlines(
2230    input: &str,
2231    map: &SourceMap,
2232    definitions: Option<Definitions<'_>>,
2233    diagnostics: &mut Vec<Diagnostic>,
2234) -> Vec<Inline> {
2235    parse_inlines_in(input, map, InlineState::default(), definitions, diagnostics)
2236}
2237
2238/// The inline content of a table cell, whose input reads each `\|` as `|`:
2239/// `escaped_pipes` holds the offset of each such `|`, which is an `Escape`
2240/// where it stands in text.
2241fn parse_cell_inlines(
2242    input: &str,
2243    map: &SourceMap,
2244    escaped_pipes: Vec<usize>,
2245    definitions: Option<Definitions<'_>>,
2246    diagnostics: &mut Vec<Diagnostic>,
2247) -> Vec<Inline> {
2248    let state = InlineState {
2249        escaped_pipes,
2250        ..InlineState::default()
2251    };
2252    parse_inlines_in(input, map, state, definitions, diagnostics)
2253}
2254
2255fn parse_inlines_in(
2256    input: &str,
2257    map: &SourceMap,
2258    mut state: InlineState,
2259    definitions: Option<Definitions<'_>>,
2260    diagnostics: &mut Vec<Diagnostic>,
2261) -> Vec<Inline> {
2262    // Definitions are collected from the block structure alone.
2263    if definitions.is_none() {
2264        return Vec::new();
2265    }
2266    let first_diagnostic = diagnostics.len();
2267    let mut nodes = parse_inlines_with_context(input, 0, definitions, diagnostics, &mut state);
2268    source_map::translate_inlines(map, &mut nodes, &mut diagnostics[first_diagnostic..]);
2269    nodes
2270}
2271
2272/// The deepest inline nesting the parser builds. Directive labels parse their
2273/// content one inline pass deeper; within a pass, open brackets and every
2274/// emphasis or mark span each add a level. Content past this depth stays
2275/// literal text, so recursion and the native stack it uses stay bounded.
2276const MAX_INLINE_NESTING: usize = 32;
2277
2278/// State shared by every inline parse nested under one block-level inline
2279/// parse. All nested inputs are slices of that one input, addressed by their
2280/// start offset in it; `parse_inlines` maps the result to the original input.
2281#[derive(Default)]
2282struct InlineState {
2283    /// How many inline parses enclose the current one.
2284    depth: usize,
2285    /// In a table cell, the sorted offsets of the `|`s the cell's input reads
2286    /// from `\|`.
2287    escaped_pipes: Vec<usize>,
2288}
2289
2290impl InlineState {
2291    fn is_escaped_pipe(&self, offset: usize) -> bool {
2292        !self.escaped_pipes.is_empty() && self.escaped_pipes.binary_search(&offset).is_ok()
2293    }
2294}
2295
2296/// What one inline pass threads through its construct parsers: the state it
2297/// shares with enclosing passes and the memoized scans of its own input.
2298struct InlinePass<'p, 'a> {
2299    state: &'p mut InlineState,
2300    scan: InlineScan<'a>,
2301}
2302
2303fn parse_inlines_with_context(
2304    input: &str,
2305    base_offset: usize,
2306    definitions: Option<Definitions<'_>>,
2307    diagnostics: &mut Vec<Diagnostic>,
2308    state: &mut InlineState,
2309) -> Vec<Inline> {
2310    if state.depth >= MAX_INLINE_NESTING {
2311        if input.is_empty() {
2312            return Vec::new();
2313        }
2314        return vec![Inline::Text(Text {
2315            meta: NodeMeta::new(Some(Span::new(base_offset, base_offset + input.len()))),
2316            value: input.into(),
2317        })];
2318    }
2319    state.depth += 1;
2320    let nodes = parse_inline_content(input, base_offset, definitions, diagnostics, state);
2321    state.depth -= 1;
2322    nodes
2323}
2324
2325fn parse_inline_content(
2326    input: &str,
2327    base_offset: usize,
2328    definitions: Option<Definitions<'_>>,
2329    diagnostics: &mut Vec<Diagnostic>,
2330    state: &mut InlineState,
2331) -> Vec<Inline> {
2332    let bytes = input.as_bytes();
2333    let mut nodes = Vec::new();
2334    let mut text_start = 0;
2335    let mut text = String::new();
2336    let mut index = 0;
2337    // Emphasis and mark delimiters are resolved with a delimiter stack after the
2338    // scan completes. During the scan we emit each candidate delimiter run as a
2339    // literal text node and record it here so `process_emphasis` can rewrite the
2340    // flat node list into emphasis and mark nodes (or leave it as text).
2341    let mut delimiters: Vec<DelimMarker> = Vec::new();
2342    let mut brackets = Brackets::new();
2343    let mut pass = InlinePass {
2344        state,
2345        scan: InlineScan::new(input),
2346    };
2347    // A literal autolink holds `://`, `www.`, or an email's `@`; content
2348    // without any of them is not scanned for one at each position.
2349    let literal_autolinks = bytes.contains(&b'@')
2350        || input.contains("://")
2351        || bytes
2352            .windows(4)
2353            .any(|window| window.eq_ignore_ascii_case(b"www."));
2354
2355    while index < bytes.len() {
2356        if bytes[index] == b'\\' {
2357            if let Some((next_index, char)) = next_char(input, index + 1) {
2358                if char.is_ascii_punctuation() {
2359                    flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2360                    nodes.push(Inline::Escape(Escape {
2361                        meta: NodeMeta::new(Some(Span::new(
2362                            base_offset + index,
2363                            base_offset + next_index,
2364                        ))),
2365                        value: char,
2366                    }));
2367                    index = next_index;
2368                    text_start = index;
2369                    continue;
2370                }
2371            }
2372        }
2373
2374        if bytes[index] == b'&' {
2375            if let Some((end, _)) = parse_character_reference(input, index) {
2376                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2377                nodes.push(Inline::CharacterReference(CharacterReference {
2378                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
2379                    reference: input[index..end].into(),
2380                }));
2381                index = end;
2382                text_start = index;
2383                continue;
2384            }
2385        }
2386
2387        // A cell's `|` read from `\|` is an escape.
2388        if bytes[index] == b'|' && pass.state.is_escaped_pipe(base_offset + index) {
2389            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2390            nodes.push(Inline::Escape(Escape {
2391                meta: NodeMeta::new(Some(Span::new(
2392                    base_offset + index,
2393                    base_offset + index + 1,
2394                ))),
2395                value: '|',
2396            }));
2397            index += 1;
2398            text_start = index;
2399            continue;
2400        }
2401
2402        if bytes[index] == b'\n' {
2403            // Only a literal backslash, not one an escape or a reference wrote,
2404            // makes the line ending a hard break.
2405            if index > 0
2406                && bytes[index - 1] == b'\\'
2407                && !is_escaped_at(input, index - 1)
2408                && text.ends_with('\\')
2409            {
2410                text.pop();
2411                flush_text(
2412                    &mut nodes,
2413                    &mut text,
2414                    text_start,
2415                    base_offset + index.saturating_sub(1),
2416                );
2417                nodes.push(Inline::LineBreak(LineBreak {
2418                    meta: NodeMeta::new(Some(Span::new(
2419                        base_offset + index.saturating_sub(1),
2420                        base_offset + index + 1,
2421                    ))),
2422                    kind: LineBreakKind::Backslash,
2423                }));
2424                index += 1;
2425                text_start = index;
2426                continue;
2427            }
2428            // The spaces and tabs ending the line: a hard break or stripped.
2429            let trailing_spaces = trailing_space_count(&text);
2430            if is_hard_break_suffix(&text, trailing_spaces) {
2431                text.truncate(text.len() - trailing_spaces);
2432                flush_text(
2433                    &mut nodes,
2434                    &mut text,
2435                    text_start,
2436                    base_offset + index.saturating_sub(trailing_spaces),
2437                );
2438                nodes.push(Inline::LineBreak(LineBreak {
2439                    meta: NodeMeta::new(Some(Span::new(
2440                        base_offset + index.saturating_sub(trailing_spaces),
2441                        base_offset + index + 1,
2442                    ))),
2443                    kind: LineBreakKind::Spaces,
2444                }));
2445                index += 1;
2446                text_start = index;
2447                continue;
2448            }
2449            if trailing_spaces > 0 {
2450                text.truncate(text.len() - trailing_spaces);
2451            }
2452            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2453            nodes.push(Inline::SoftBreak(SoftBreak {
2454                meta: NodeMeta::new(Some(Span::new(
2455                    base_offset + index,
2456                    base_offset + index + 1,
2457                ))),
2458            }));
2459            index += 1;
2460            text_start = index;
2461            continue;
2462        }
2463
2464        if bytes[index] == b'`' {
2465            if let Some((end, value)) = parse_code_span_with(&mut pass.scan.lookups, input, index) {
2466                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2467                nodes.push(Inline::Code(CodeInline {
2468                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
2469                    value,
2470                }));
2471                index = end;
2472                text_start = index;
2473                continue;
2474            } else {
2475                // No matching-length close for this opening backtick run:
2476                // CommonMark renders the whole run as literal text. Consume the
2477                // entire run here so the loop does not advance one byte and retry
2478                // a shorter sub-run that could spuriously match a shorter close
2479                // (```foo`` stayed a phantom 2-backtick code span).
2480                let run = bytes[index..]
2481                    .iter()
2482                    .take_while(|byte| **byte == b'`')
2483                    .count();
2484                if text.is_empty() {
2485                    text_start = base_offset + index;
2486                }
2487                for _ in 0..run {
2488                    text.push('`');
2489                }
2490                index += run;
2491                continue;
2492            }
2493        }
2494
2495        if bytes[index] == b'*' && starts_delimiter_run(input, index, b'*') {
2496            let run_len = delimiter_byte_run_len(input, index, b'*');
2497            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2498            push_delimiter(
2499                &mut nodes,
2500                &mut delimiters,
2501                index,
2502                base_offset,
2503                b'*',
2504                run_len,
2505            );
2506            index += run_len;
2507            text_start = index;
2508            continue;
2509        }
2510
2511        // Core `_` emphasis/strong is resolved by the delimiter stack, just like
2512        // `*`.
2513        if bytes[index] == b'_' && starts_delimiter_run(input, index, b'_') {
2514            // A leading `_` can begin a GFM email local part (`_a@b.c`); try the
2515            // literal autolink before recording the `_` as an emphasis
2516            // delimiter, otherwise the `_` would be consumed and the email would
2517            // wrongly start one char later (where its left boundary fails).
2518            if literal_autolinks {
2519                if let Some((end, _)) = parse_literal_autolink(
2520                    input,
2521                    index,
2522                    &mut pass.scan.literal_autolinks,
2523                    !brackets.open(),
2524                ) {
2525                    flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2526                    nodes.push(autolink_node(
2527                        AutolinkForm::Literal,
2528                        Span::new(base_offset + index, base_offset + end),
2529                        &input[index..end],
2530                    ));
2531                    index = end;
2532                    text_start = index;
2533                    continue;
2534                }
2535            }
2536            let run_len = delimiter_byte_run_len(input, index, b'_');
2537            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2538            push_delimiter(
2539                &mut nodes,
2540                &mut delimiters,
2541                index,
2542                base_offset,
2543                b'_',
2544                run_len,
2545            );
2546            index += run_len;
2547            text_start = index;
2548            continue;
2549        }
2550
2551        if bytes[index] == b'=' {
2552            let run_len = delimiter_byte_run_len(input, index, b'=');
2553            if run_len >= 2 {
2554                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2555                push_delimiter(
2556                    &mut nodes,
2557                    &mut delimiters,
2558                    index,
2559                    base_offset,
2560                    b'=',
2561                    run_len,
2562                );
2563                index += run_len;
2564                text_start = index;
2565                continue;
2566            }
2567        }
2568
2569        let bracket_room = pass.state.depth - 1 + brackets.openers.len() < MAX_INLINE_NESTING;
2570
2571        // `^[` opens an inline footnote.
2572        if bracket_room && bytes[index] == b'^' && bytes.get(index + 1) == Some(&b'[') {
2573            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2574            push_text(&mut nodes, base_offset + index, "^");
2575            push_text(&mut nodes, base_offset + index + 1, "[");
2576            brackets.openers.push(BracketOpener {
2577                kind: BracketKind::InlineFootnote,
2578                node_index: nodes.len() - 1,
2579                position: index,
2580                delimiter_bottom: delimiters.len(),
2581            });
2582            index += 2;
2583            text_start = index;
2584            continue;
2585        }
2586
2587        // A run of exactly two `~` may open or close GFM strikethrough.
2588        // The run is measured only from its start, so the bytes of a longer
2589        // run, which go on as text one at a time, are not measured again.
2590        if bytes[index] == b'~' && starts_delimiter_run(input, index, b'~') {
2591            let run_len = delimiter_byte_run_len(input, index, b'~');
2592            if run_len == 2 {
2593                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2594                push_delimiter(
2595                    &mut nodes,
2596                    &mut delimiters,
2597                    index,
2598                    base_offset,
2599                    b'~',
2600                    run_len,
2601                );
2602                index += run_len;
2603                text_start = index;
2604                continue;
2605            }
2606        }
2607
2608        if bytes[index] == b'!' && bytes.get(index + 1) == Some(&b'[') {
2609            // A wikilink at the `[` wins over the image, as it wins over a
2610            // link at a lone `[`, and the `!` makes it an embed; it takes no
2611            // bracket nesting, so it forms past the limit too.
2612            if let Some((end, wikilink)) =
2613                parse_wikilink(input, index + 1, base_offset, &mut pass.scan)
2614            {
2615                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2616                nodes.push(embed(wikilink, base_offset + index));
2617                // A wikilink is a link: no open bracket forms a link around it.
2618                brackets.close_links();
2619                index = end;
2620                text_start = index;
2621                continue;
2622            }
2623        }
2624
2625        if bytes[index] == b'!' && bytes.get(index + 1) == Some(&b'[') && bracket_room {
2626            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2627            push_text(&mut nodes, base_offset + index, "!");
2628            push_text(&mut nodes, base_offset + index + 1, "[");
2629            brackets.openers.push(BracketOpener {
2630                kind: BracketKind::Image,
2631                node_index: nodes.len() - 1,
2632                position: index,
2633                delimiter_bottom: delimiters.len(),
2634            });
2635            index += 2;
2636            text_start = index;
2637            continue;
2638        }
2639
2640        if bytes[index] == b'[' {
2641            if let Some((end, wikilink)) = parse_wikilink(input, index, base_offset, &mut pass.scan)
2642            {
2643                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2644                nodes.push(wikilink);
2645                brackets.close_links();
2646                index = end;
2647                text_start = index;
2648                continue;
2649            }
2650            if bracket_room {
2651                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2652                push_text(&mut nodes, base_offset + index, "[");
2653                brackets.openers.push(BracketOpener {
2654                    kind: BracketKind::Link,
2655                    node_index: nodes.len() - 1,
2656                    position: index,
2657                    delimiter_bottom: delimiters.len(),
2658                });
2659                index += 1;
2660                text_start = index;
2661                continue;
2662            }
2663            brackets.overflow += 1;
2664        }
2665
2666        if bytes[index] == b']' && brackets.overflow > 0 {
2667            // Closes a `[` left as text past the nesting limit.
2668            brackets.overflow -= 1;
2669        } else if bytes[index] == b']' && !brackets.openers.is_empty() {
2670            flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2671            if let Some(end) = close_bracket(
2672                input,
2673                base_offset,
2674                index,
2675                definitions,
2676                &mut nodes,
2677                &mut delimiters,
2678                &mut brackets,
2679                &mut pass,
2680            ) {
2681                index = end;
2682                text_start = index;
2683                continue;
2684            }
2685        }
2686
2687        if bytes[index] == b'$' {
2688            if let Some((end, value, kind)) =
2689                parse_math_inline(&mut pass.scan.lookups, input, index)
2690            {
2691                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2692                nodes.push(Inline::Math(MathInline {
2693                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
2694                    value,
2695                    kind,
2696                }));
2697                index = end;
2698                text_start = index;
2699                continue;
2700            }
2701            // A dollar run that opens but finds no exact-length close is emitted
2702            // as literal text in one piece (like a code-span). Skipping the
2703            // whole run prevents re-opening with a shorter marker inside it, so
2704            // `$$$foo$$` stays literal rather than matching `$$foo$$`. A lone
2705            // `$` before a backtick (the code-math form) is a run of 1, so this
2706            // still advances correctly when that form fails.
2707            let run = bytes[index..]
2708                .iter()
2709                .take_while(|byte| **byte == b'$')
2710                .count();
2711            if run > 1 {
2712                if text.is_empty() {
2713                    text_start = base_offset + index;
2714                }
2715                text.push_str(&input[index..index + run]);
2716                index += run;
2717                continue;
2718            }
2719        }
2720
2721        // Inside an open `[` only an email address links; if the bracket
2722        // forms a link, `demote_links` turns it back into text.
2723        if literal_autolinks {
2724            if let Some((end, _)) = parse_literal_autolink(
2725                input,
2726                index,
2727                &mut pass.scan.literal_autolinks,
2728                !brackets.open(),
2729            ) {
2730                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2731                nodes.push(autolink_node(
2732                    AutolinkForm::Literal,
2733                    Span::new(base_offset + index, base_offset + end),
2734                    &input[index..end],
2735                ));
2736                index = end;
2737                text_start = index;
2738                continue;
2739            }
2740        }
2741
2742        if bytes[index] == b'<' {
2743            if let Some(end) = pass.scan.lookups.find(">", index).map(|close| close + 1) {
2744                let uri = &input[index + 1..end - 1];
2745                if is_angle_autolink(uri) {
2746                    flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2747                    nodes.push(autolink_node(
2748                        AutolinkForm::Angle,
2749                        Span::new(base_offset + index, base_offset + end),
2750                        uri,
2751                    ));
2752                    index = end;
2753                    text_start = index;
2754                    continue;
2755                }
2756            }
2757            if let Some(end) = html_inline_end(&mut pass.scan.lookups, input, index) {
2758                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2759                nodes.push(Inline::Html(HtmlInline {
2760                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
2761                    value: input[index..end].into(),
2762                }));
2763                index = end;
2764                text_start = index;
2765                continue;
2766            }
2767        }
2768
2769        if bytes[index] == b':' {
2770            if let Some((end, name)) = parse_shortcode(input, index) {
2771                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2772                nodes.push(Inline::Shortcode(Shortcode {
2773                    meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
2774                    name,
2775                }));
2776                index = end;
2777                text_start = index;
2778                continue;
2779            }
2780        }
2781
2782        // A directive counts one nesting level for itself, on top of the
2783        // enclosing passes and open brackets.
2784        if bytes[index] == b':' && pass.state.depth + brackets.openers.len() <= MAX_INLINE_NESTING {
2785            // The label parses one pass deeper than the open brackets around
2786            // it.
2787            pass.state.depth += brackets.openers.len();
2788            let directive = parse_text_directive(
2789                input,
2790                index,
2791                base_offset,
2792                definitions,
2793                diagnostics,
2794                &mut pass,
2795            );
2796            pass.state.depth -= brackets.openers.len();
2797            if let Some((end, directive)) = directive {
2798                flush_text(&mut nodes, &mut text, text_start, base_offset + index);
2799                if let Inline::TextDirective(node) = &directive {
2800                    // A link inside the directive label keeps open brackets
2801                    // from forming links around it.
2802                    if contains_link_inline(&node.label) {
2803                        brackets.close_links();
2804                    }
2805                    let depth = inline_nesting(&node.label) + 1;
2806                    brackets.formed.push((nodes.len(), depth));
2807                }
2808                nodes.push(directive);
2809                index = end;
2810                text_start = index;
2811                continue;
2812            }
2813        }
2814
2815        let (next_index, char) = next_char(input, index).expect("valid UTF-8 byte index");
2816        if text.is_empty() {
2817            text_start = base_offset + index;
2818        }
2819        text.push(char);
2820        index = next_index;
2821    }
2822
2823    flush_text(&mut nodes, &mut text, text_start, base_offset + input.len());
2824    assign_emphasis_roles(input, &mut delimiters);
2825    process_emphasis(nodes, delimiters, pass.state.depth - 1, &brackets.formed).0
2826}
2827
2828/// A `:name:` shortcode at `index`: `name` is in the gemoji table, and the
2829/// source chars directly outside the colons are no Unicode letter or digit,
2830/// so clock times and words joined by a colon stay text.
2831fn parse_shortcode(input: &str, index: usize) -> Option<(usize, String)> {
2832    if input[index..].starts_with("::") {
2833        return None;
2834    }
2835    if input[..index]
2836        .chars()
2837        .next_back()
2838        .is_some_and(char::is_alphanumeric)
2839    {
2840        return None;
2841    }
2842
2843    let mut cursor = index + 1;
2844    while let Some((next, char)) = next_char(input, cursor) {
2845        if char == ':' {
2846            let name = &input[index + 1..cursor];
2847            if crate::gemoji::glyph(name).is_none()
2848                || input[next..]
2849                    .chars()
2850                    .next()
2851                    .is_some_and(char::is_alphanumeric)
2852            {
2853                return None;
2854            }
2855            return Some((next, name.into()));
2856        }
2857        if !(char.is_ascii_alphanumeric() || matches!(char, '_' | '-' | '+')) {
2858            return None;
2859        }
2860        cursor = next;
2861    }
2862    None
2863}
2864
2865fn parse_wikilink(
2866    input: &str,
2867    index: usize,
2868    base_offset: usize,
2869    scan: &mut InlineScan,
2870) -> Option<(usize, Inline)> {
2871    if input.as_bytes().get(index) != Some(&b'[') || input.as_bytes().get(index + 1) != Some(&b'[')
2872    {
2873        return None;
2874    }
2875
2876    let close = scan.wikilink_close(index + 2)?;
2877    let source = &input[index + 2..close];
2878    if source.is_empty() || source.len() > WIKILINK_MAX_BYTES {
2879        return None;
2880    }
2881
2882    let (target_source, label_source) = match find_wikilink_separator(source) {
2883        Some(separator) => (&source[..separator], &source[separator + 1..]),
2884        None => (source, source),
2885    };
2886
2887    // Target and label keep their escapes and character references as
2888    // written.
2889    if target_source.is_empty() {
2890        return None;
2891    }
2892    let target = String::from(target_source);
2893    let label = String::from(label_source);
2894    let end = close + 2;
2895    Some((
2896        end,
2897        Inline::WikiLink(WikiLink {
2898            meta: NodeMeta::new(Some(Span::new(base_offset + index, base_offset + end))),
2899            target,
2900            label,
2901            embed: false,
2902        }),
2903    ))
2904}
2905
2906/// `wikilink` marked as an embed by the `!` at `bang`, where its span starts.
2907fn embed(mut wikilink: Inline, bang: usize) -> Inline {
2908    if let Inline::WikiLink(node) = &mut wikilink {
2909        node.embed = true;
2910        node.meta.span = node.meta.span.map(|span| Span::new(bang, span.end));
2911    }
2912    wikilink
2913}
2914
2915/// One step of the walk to a wikilink's closing `]]`, which must be on the
2916/// opener's line. The content holds no unescaped `[` or `]`.
2917fn wikilink_close_step(input: &str, cursor: usize) -> Step {
2918    let bytes = input.as_bytes();
2919    match bytes.get(cursor) {
2920        None | Some(b'\n' | b'\r' | b'[') => Step::Done(None),
2921        Some(b'\\') => {
2922            let escaped = cursor + 1;
2923            Step::Next(next_char(input, escaped).map_or(escaped, |(after_escape, _)| after_escape))
2924        }
2925        Some(b']') if bytes.get(cursor + 1) == Some(&b']') => Step::Done(Some(cursor)),
2926        Some(b']') => Step::Done(None),
2927        Some(_) => Step::Next(next_char(input, cursor).map_or(input.len(), |(next, _)| next)),
2928    }
2929}
2930
2931fn find_wikilink_separator(input: &str) -> Option<usize> {
2932    let bytes = input.as_bytes();
2933    let mut cursor = 0;
2934    while cursor < input.len() {
2935        match bytes[cursor] {
2936            b'\\' => {
2937                cursor += 1;
2938                if cursor < input.len() {
2939                    cursor = next_char(input, cursor)?.0;
2940                }
2941            }
2942            b'|' => return Some(cursor),
2943            _ => cursor = next_char(input, cursor)?.0,
2944        }
2945    }
2946    None
2947}
2948
2949fn trailing_space_count(input: &str) -> usize {
2950    input
2951        .as_bytes()
2952        .iter()
2953        .rev()
2954        .take_while(|byte| matches!(**byte, b' ' | b'\t'))
2955        .count()
2956}
2957
2958fn is_hard_break_suffix(input: &str, trailing: usize) -> bool {
2959    // A hard line break is two or more spaces immediately before the newline
2960    // with no intervening tab; a tab anywhere in the trailing whitespace run
2961    // demotes it to a soft break.
2962    let bytes = input.as_bytes();
2963    trailing >= 2
2964        && bytes[bytes.len() - trailing..]
2965            .iter()
2966            .all(|byte| *byte == b' ')
2967}
2968
2969/// The walk `InlineScan::reference_label_end` and `definition_label` take in
2970/// steps, run once from the start; the scan tests hold them to it.
2971#[cfg(test)]
2972fn find_reference_label_end(input: &str, open: usize) -> Option<usize> {
2973    // A reference/definition link label does not nest: it ends at the first
2974    // unescaped `]`, and an unescaped interior `[` disqualifies it.
2975    if input.as_bytes().get(open) != Some(&b'[') {
2976        return None;
2977    }
2978    let close = path_walk(open + 1, |cursor| reference_label_step(input, cursor))?;
2979    reference_label_is_within_limit(&input[open + 1..close]).then_some(close)
2980}
2981
2982/// One step of the walk to a reference label's closing `]`.
2983fn reference_label_step(input: &str, cursor: usize) -> Step {
2984    let Some((next, char)) = next_char(input, cursor) else {
2985        return Step::Done(None);
2986    };
2987    match char {
2988        '\\' => Step::Next(next_char(input, next).map_or(next, |(after_escape, _)| after_escape)),
2989        '[' => Step::Done(None),
2990        ']' => Step::Done(Some(cursor)),
2991        _ => Step::Next(next),
2992    }
2993}
2994
2995/// How many container levels `inlines` nest, counting empty containers.
2996fn inline_nesting(inlines: &[Inline]) -> usize {
2997    let mut deepest = 0;
2998    let mut pending = vec![(inlines, 0)];
2999    while let Some((nodes, depth)) = pending.pop() {
3000        for node in nodes {
3001            if is_inline_container(node) {
3002                deepest = deepest.max(depth + 1);
3003                pending.push((node.children(), depth + 1));
3004            }
3005        }
3006    }
3007    deepest
3008}
3009
3010fn is_inline_container(inline: &Inline) -> bool {
3011    matches!(
3012        inline,
3013        Inline::Emphasis(_)
3014            | Inline::Strong(_)
3015            | Inline::Delete(_)
3016            | Inline::Mark(_)
3017            | Inline::Link(_)
3018            | Inline::Image(_)
3019            | Inline::LinkReference(_)
3020            | Inline::ImageReference(_)
3021            | Inline::InlineFootnote(_)
3022            | Inline::TextDirective(_)
3023    )
3024}
3025
3026/// Whether `inlines` hold a link formed from brackets, at any depth: an
3027/// autolink, which keeps open brackets from nothing, does not count.
3028fn contains_link_inline(inlines: &[Inline]) -> bool {
3029    inlines.iter().any(|inline| {
3030        matches!(
3031            inline,
3032            Inline::Link(_) | Inline::LinkReference(_) | Inline::WikiLink(_)
3033        ) || contains_link_inline(inline.children())
3034    })
3035}
3036
3037fn find_link_label_end(input: &str, open: usize) -> Option<usize> {
3038    if input.as_bytes().get(open) != Some(&b'[') {
3039        return None;
3040    }
3041    let mut lookups = DirectLookups { input };
3042    bracket_walk(open + 1, |cursor| {
3043        link_label_step(&mut lookups, input, cursor)
3044    })
3045}
3046
3047/// One step of the walk to a link label's closing `]`: escapes, code spans,
3048/// autolinks, and inline HTML are stepped over whole, so brackets inside them
3049/// do not count; other brackets nest.
3050fn link_label_step(lookups: &mut impl Lookups, input: &str, cursor: usize) -> BracketStep {
3051    let Some((next, char)) = next_char(input, cursor) else {
3052        return BracketStep::End;
3053    };
3054    match char {
3055        '\\' => {
3056            BracketStep::Pass(next_char(input, next).map_or(next, |(after_escape, _)| after_escape))
3057        }
3058        '`' => BracketStep::Pass(code_span_end(lookups, input, cursor).unwrap_or(next)),
3059        '<' => BracketStep::Pass(
3060            autolink_end(lookups, input, cursor)
3061                .or_else(|| html_inline_end(lookups, input, cursor))
3062                .unwrap_or(next),
3063        ),
3064        '[' => BracketStep::Open(next),
3065        ']' => BracketStep::Close(next),
3066        _ => BracketStep::Pass(next),
3067    }
3068}
3069
3070fn parse_text_directive(
3071    input: &str,
3072    index: usize,
3073    base_offset: usize,
3074    definitions: Option<Definitions<'_>>,
3075    diagnostics: &mut Vec<Diagnostic>,
3076    pass: &mut InlinePass,
3077) -> Option<(usize, Inline)> {
3078    if input[index..].starts_with("::") {
3079        return None;
3080    }
3081    if index > 0 {
3082        let previous = input[..index].chars().next_back()?;
3083        if !previous.is_whitespace() && !matches!(previous, '(' | '[' | '{') {
3084            return None;
3085        }
3086    }
3087    let opener_offset = index + 1;
3088    let opener = parse_directive_opener_with(
3089        &input[opener_offset..],
3090        |close, open| {
3091            let found = match close {
3092                DirectiveClose::Label => pass.scan.link_label_end(opener_offset + open),
3093                DirectiveClose::Attributes => {
3094                    pass.scan.directive_attributes_close(opener_offset + open)
3095                }
3096            };
3097            found.map(|position| position - opener_offset)
3098        },
3099        |label, attributes, rest| text_directive_ends(label, attributes, rest.bytes().next()),
3100    );
3101    let opener = match opener {
3102        Ok(opener) => opener,
3103        Err(refused) => {
3104            // A `[` or `{` left open after a valid name is malformed; a bad
3105            // name, or an opener refused for what follows it, is plain text.
3106            if refused == Refused::Unclosed {
3107                diagnostics.push(Diagnostic::new(
3108                    DiagnosticSeverity::Error,
3109                    DiagnosticCode::InvalidDirectiveName,
3110                    Span::new(base_offset + index, base_offset + input.len()),
3111                    "text directive opener is malformed",
3112                ));
3113            }
3114            return None;
3115        }
3116    };
3117    let label = opener
3118        .label
3119        .map(|source| {
3120            parse_inlines_with_context(
3121                source,
3122                base_offset + index + 1 + opener.name.len() + 1,
3123                definitions,
3124                diagnostics,
3125                pass.state,
3126            )
3127        })
3128        .unwrap_or_default();
3129    let at = base_offset + opener_offset;
3130    opener.report_dropped(diagnostics, |start, end| Span::new(at + start, at + end));
3131    let DirectiveOpener {
3132        name,
3133        attributes,
3134        consumed,
3135        ..
3136    } = opener;
3137    Some((
3138        index + 1 + consumed,
3139        Inline::TextDirective(TextDirective {
3140            meta: NodeMeta::new(Some(Span::new(
3141                base_offset + index,
3142                base_offset + index + 1 + consumed,
3143            ))),
3144            name,
3145            label,
3146            attributes,
3147        }),
3148    ))
3149}
3150
3151/// Whether a text directive with this label and attribute source ends before
3152/// `next`: whitespace or the end of the content, or ASCII punctuation when the
3153/// label is not empty or the attributes hold a non-space char. So `:e[a].` is
3154/// a directive, while `:word:`, `:a@b.c`, `(:note)`, `:e{}x`, `:e{}.`, and
3155/// `:e[a]b` stay text. Deciding from the sources keeps a refused opener from
3156/// parsing its attributes.
3157fn text_directive_ends(label: Option<&str>, attributes: Option<&str>, next: Option<u8>) -> bool {
3158    match next {
3159        None | Some(b' ' | b'\t' | b'\n' | b'\r') => true,
3160        Some(byte) if byte.is_ascii_punctuation() => {
3161            label.is_some_and(|label| !label.is_empty())
3162                || attributes
3163                    .is_some_and(|source| source.bytes().any(|byte| !byte.is_ascii_whitespace()))
3164        }
3165        Some(_) => false,
3166    }
3167}
3168
3169/// A directive's name, label, and attributes, read from the text after its
3170/// colons.
3171struct DirectiveOpener<'a> {
3172    name: String,
3173    label: Option<&'a str>,
3174    attributes: Vec<DirectiveAttribute>,
3175    /// The ranges of the attributes dropped for having no valid name.
3176    dropped: Vec<(usize, usize)>,
3177    /// The bytes the opener takes.
3178    consumed: usize,
3179}
3180
3181impl DirectiveOpener<'_> {
3182    /// A warning for each dropped attribute, spanning `span(start, end)`.
3183    fn report_dropped(
3184        &self,
3185        diagnostics: &mut Vec<Diagnostic>,
3186        span: impl Fn(usize, usize) -> Span,
3187    ) {
3188        for &(start, end) in &self.dropped {
3189            diagnostics.push(Diagnostic::new(
3190                DiagnosticSeverity::Warning,
3191                DiagnosticCode::InvalidDirectiveAttribute,
3192                span(start, end),
3193                "directive attribute without a valid name is dropped",
3194            ));
3195        }
3196    }
3197}
3198
3199/// Why `parse_directive_opener_with` read no opener.
3200#[derive(Clone, Copy, PartialEq, Eq)]
3201enum Refused {
3202    /// The text after the colons does not start with a valid directive name.
3203    Name,
3204    /// The `[` or `{` after the name has no closing `]` or `}`.
3205    Unclosed,
3206    /// `accept` refused the opener for its label, attributes, or what follows.
3207    Follow,
3208}
3209
3210/// `parse_directive_opener_with` for a block directive line, finding closing
3211/// brackets in `input` alone.
3212fn parse_directive_opener<'a>(
3213    input: &'a str,
3214    accept: impl FnOnce(Option<&str>, Option<&str>, &str) -> bool,
3215) -> Result<DirectiveOpener<'a>, Refused> {
3216    parse_directive_opener_with(
3217        input,
3218        |close, open| match close {
3219            DirectiveClose::Label => find_link_label_end(input, open),
3220            DirectiveClose::Attributes => find_directive_attributes_close(input, open),
3221        },
3222        accept,
3223    )
3224}
3225
3226/// Which closing position `parse_directive_opener_with` asks its finder for.
3227#[derive(Clone, Copy)]
3228enum DirectiveClose {
3229    /// The `]` closing the `[label]` opening at the given position.
3230    Label,
3231    /// The `}` closing the `{attributes}` opening at the given position.
3232    Attributes,
3233}
3234
3235/// Reads the directive opener at the start of `input`, the text after the
3236/// colons: a name, then an optional `[label]`, then optional `{attributes}`.
3237/// `find_close` gives the `]` or `}` closing the bracket at a position.
3238///
3239/// `accept` sees the label source, the attribute source, and the text after
3240/// the opener, and runs before the attributes are parsed. That order keeps
3241/// refused openers linear-time: `:a{` repeated, then `}x`, refuses every
3242/// opener without parsing the attribute source each one spans to the end.
3243/// The cost is that acceptance reads the attribute source, not the parsed
3244/// attributes: `:e{!}.` is a text directive, since its attribute source is not
3245/// blank, whose invalid attribute is dropped, so it is written `:e.`, which
3246/// reads back as text.
3247fn parse_directive_opener_with<'a>(
3248    input: &'a str,
3249    mut find_close: impl FnMut(DirectiveClose, usize) -> Option<usize>,
3250    accept: impl FnOnce(Option<&str>, Option<&str>, &str) -> bool,
3251) -> Result<DirectiveOpener<'a>, Refused> {
3252    let mut index = 0;
3253    while let Some((next, char)) = next_char(input, index) {
3254        if char.is_ascii_alphanumeric() || char == '_' || char == '-' {
3255            index = next;
3256        } else {
3257            break;
3258        }
3259    }
3260    let name = &input[..index];
3261    if !is_directive_name(name) {
3262        return Err(Refused::Name);
3263    }
3264
3265    let mut label = None;
3266    let mut attribute_source = None;
3267    let mut consumed = index;
3268    if input.as_bytes().get(consumed) == Some(&b'[') {
3269        let close = find_close(DirectiveClose::Label, consumed).ok_or(Refused::Unclosed)?;
3270        label = Some(&input[consumed + 1..close]);
3271        consumed = close + 1;
3272    }
3273    if input.as_bytes().get(consumed) == Some(&b'{') {
3274        let close = find_close(DirectiveClose::Attributes, consumed).ok_or(Refused::Unclosed)?;
3275        attribute_source = Some((consumed + 1, &input[consumed + 1..close]));
3276        consumed = close + 1;
3277    }
3278    if !accept(
3279        label,
3280        attribute_source.map(|(_, source)| source),
3281        &input[consumed..],
3282    ) {
3283        return Err(Refused::Follow);
3284    }
3285    let (attributes, dropped) = match attribute_source {
3286        Some((start, source)) => {
3287            let (attributes, mut dropped) = parse_attributes(source);
3288            for range in &mut dropped {
3289                *range = (range.0 + start, range.1 + start);
3290            }
3291            (attributes, dropped)
3292        }
3293        None => (Vec::new(), Vec::new()),
3294    };
3295
3296    Ok(DirectiveOpener {
3297        name: name.into(),
3298        label,
3299        attributes,
3300        dropped,
3301        consumed,
3302    })
3303}
3304
3305/// Quote states of the walk to a directive's closing `}`: outside quotes,
3306/// inside `"…"`, inside `'…'`.
3307const DIRECTIVE_QUOTE_STATES: usize = 3;
3308
3309fn find_directive_attributes_close(input: &str, open: usize) -> Option<usize> {
3310    if input.as_bytes().get(open) != Some(&b'{') {
3311        return None;
3312    }
3313    path_walk((open + 1) * DIRECTIVE_QUOTE_STATES, |node| {
3314        directive_attributes_step(input, node)
3315    })
3316}
3317
3318/// One step of the walk to a directive's closing `}`, over nodes
3319/// `byte position * DIRECTIVE_QUOTE_STATES + quote state`: a `\` escapes the
3320/// next byte, and a `}` inside quotes does not close.
3321fn directive_attributes_step(input: &str, node: usize) -> Step {
3322    let bytes = input.as_bytes();
3323    let (cursor, quote) = (node / DIRECTIVE_QUOTE_STATES, node % DIRECTIVE_QUOTE_STATES);
3324    let at = |position: usize, quote: usize| Step::Next(position * DIRECTIVE_QUOTE_STATES + quote);
3325    let Some(&byte) = bytes.get(cursor) else {
3326        return Step::Done(None);
3327    };
3328    if byte == b'\\' {
3329        return at((cursor + 2).min(bytes.len()), quote);
3330    }
3331    match (quote, byte) {
3332        (0, b'"') => at(cursor + 1, 1),
3333        (0, b'\'') => at(cursor + 1, 2),
3334        (0, b'}') => Step::Done(Some(cursor)),
3335        (1, b'"') | (2, b'\'') => at(cursor + 1, 0),
3336        _ => at(cursor + 1, quote),
3337    }
3338}
3339
3340/// The attributes in `input`, the text between a directive's braces, and the
3341/// ranges of `input` holding attributes dropped for having no valid name.
3342fn parse_attributes(input: &str) -> (Vec<DirectiveAttribute>, Vec<(usize, usize)>) {
3343    let mut attributes = Vec::new();
3344    let mut dropped = Vec::new();
3345    let mut cursor = 0;
3346    while cursor < input.len() {
3347        cursor = skip_spaces(input, cursor);
3348        if cursor >= input.len() {
3349            break;
3350        }
3351
3352        if input.as_bytes().get(cursor) == Some(&b'#') {
3353            let (id, next) = parse_attribute_token(input, cursor + 1);
3354            if !id.is_empty() {
3355                attributes.push(DirectiveAttribute {
3356                    name: "id".into(),
3357                    value: Some(id.into()),
3358                });
3359            }
3360            cursor = next;
3361            continue;
3362        }
3363
3364        if input.as_bytes().get(cursor) == Some(&b'.') {
3365            let (class, next) = parse_attribute_token(input, cursor + 1);
3366            if !class.is_empty() {
3367                attributes.push(DirectiveAttribute {
3368                    name: "class".into(),
3369                    value: Some(class.into()),
3370                });
3371            }
3372            cursor = next;
3373            continue;
3374        }
3375
3376        let token_start = cursor;
3377        let (name, next) = parse_attribute_name(input, cursor);
3378        if name.is_empty() {
3379            dropped.push((token_start, input.trim_end().len().max(token_start)));
3380            break;
3381        }
3382        let mut token_end = next;
3383        cursor = skip_spaces(input, next);
3384        let value = if input.as_bytes().get(cursor) == Some(&b'=') {
3385            cursor = skip_spaces(input, cursor + 1);
3386            token_end = cursor;
3387            if let Some((value, next)) = parse_attribute_value(input, cursor) {
3388                cursor = next;
3389                token_end = next;
3390                Some(value)
3391            } else {
3392                Some(String::new())
3393            }
3394        } else {
3395            None
3396        };
3397        // A token that is no attribute name is dropped, as a malformed value
3398        // is read leniently: the document keeps only attributes it can write.
3399        if crate::validate::is_attribute_name(name) {
3400            attributes.push(DirectiveAttribute {
3401                name: name.into(),
3402                value,
3403            });
3404        } else {
3405            dropped.push((token_start, token_end));
3406        }
3407    }
3408    (attributes, dropped)
3409}
3410
3411fn parse_attribute_token(input: &str, index: usize) -> (&str, usize) {
3412    let mut cursor = index;
3413    while let Some((next, char)) = next_char(input, cursor) {
3414        if char.is_whitespace() {
3415            break;
3416        }
3417        cursor = next;
3418    }
3419    (&input[index..cursor], cursor)
3420}
3421
3422fn parse_attribute_name(input: &str, index: usize) -> (&str, usize) {
3423    let mut cursor = index;
3424    while let Some((next, char)) = next_char(input, cursor) {
3425        if char.is_whitespace() || char == '=' {
3426            break;
3427        }
3428        cursor = next;
3429    }
3430    (&input[index..cursor], cursor)
3431}
3432
3433fn parse_attribute_value(input: &str, index: usize) -> Option<(String, usize)> {
3434    let quote = input.as_bytes().get(index).copied();
3435    if matches!(quote, Some(b'"' | b'\'')) {
3436        let quote = quote?;
3437        let mut cursor = index + 1;
3438        while cursor < input.len() {
3439            let (next, char) = next_char(input, cursor)?;
3440            if char as u8 == quote && !is_escaped_at(input, cursor) {
3441                return Some((
3442                    decode_escapes_and_references(&input[index + 1..cursor]),
3443                    next,
3444                ));
3445            }
3446            cursor = next;
3447        }
3448        return None;
3449    }
3450
3451    let (value, next) = parse_attribute_token(input, index);
3452    Some((
3453        decode_selected_escapes_and_references(value, |char| matches!(char, '\\' | '&')),
3454        next,
3455    ))
3456}
3457
3458/// The end and the normalized value of the code span opening at `index`.
3459fn parse_code_span_with(
3460    lookups: &mut impl Lookups,
3461    input: &str,
3462    index: usize,
3463) -> Option<(usize, String)> {
3464    let len = backtick_run_len(input, index);
3465    let search_start = index + len;
3466    let close = lookups.code_span_close(search_start, len)?;
3467    Some((
3468        close + len,
3469        normalize_code_span(&input[search_start..close]),
3470    ))
3471}
3472
3473/// The end of the code span opening at `index`, as `parse_code_span` finds it.
3474fn code_span_end(lookups: &mut impl Lookups, input: &str, index: usize) -> Option<usize> {
3475    let len = backtick_run_len(input, index);
3476    Some(lookups.code_span_close(index + len, len)? + len)
3477}
3478
3479fn find_code_span_close(input: &str, start: usize, marker_len: usize) -> Option<usize> {
3480    let bytes = input.as_bytes();
3481    let mut cursor = start;
3482    while cursor < bytes.len() {
3483        if bytes[cursor] != b'`' {
3484            cursor = next_char(input, cursor)
3485                .map(|(next, _)| next)
3486                .unwrap_or(bytes.len());
3487            continue;
3488        }
3489        let run_len = bytes[cursor..]
3490            .iter()
3491            .take_while(|byte| **byte == b'`')
3492            .count();
3493        if run_len == marker_len {
3494            return Some(cursor);
3495        }
3496        cursor += run_len;
3497    }
3498    None
3499}
3500
3501fn normalize_code_span(input: &str) -> String {
3502    let mut normalized = String::new();
3503    let mut cursor = 0;
3504    while cursor < input.len() {
3505        let (next, char) = next_char(input, cursor).expect("valid UTF-8 byte index");
3506        if char == '\r' {
3507            if input.as_bytes().get(next) == Some(&b'\n') {
3508                cursor = next + 1;
3509            } else {
3510                cursor = next;
3511            }
3512            normalized.push(' ');
3513            continue;
3514        }
3515        if char == '\n' {
3516            normalized.push(' ');
3517            cursor = next;
3518            continue;
3519        }
3520        normalized.push(char);
3521        cursor = next;
3522    }
3523
3524    if normalized.starts_with(' ')
3525        && normalized.ends_with(' ')
3526        && normalized.chars().any(|char| char != ' ')
3527    {
3528        normalized[1..normalized.len() - 1].into()
3529    } else {
3530        normalized
3531    }
3532}
3533
3534fn can_open_delimited(input: &str, index: usize, marker_len: usize) -> bool {
3535    delimiter_flanking(input, index, marker_len).left
3536}
3537
3538fn can_close_delimited(input: &str, index: usize, marker_len: usize) -> bool {
3539    delimiter_flanking(input, index, marker_len).right
3540}
3541
3542/// Whether a run of `marker` bytes starts at `index`: the byte before it is
3543/// not an unescaped `marker`. Only the one byte is looked at, so asking at
3544/// every byte of a long run stays linear.
3545fn starts_delimiter_run(input: &str, index: usize, marker: u8) -> bool {
3546    index == 0 || input.as_bytes()[index - 1] != marker || is_escaped_at(input, index - 1)
3547}
3548
3549fn delimiter_byte_run_len(input: &str, index: usize, marker: u8) -> usize {
3550    let bytes = input.as_bytes();
3551    let mut cursor = index;
3552    while bytes.get(cursor) == Some(&marker) {
3553        cursor += 1;
3554    }
3555    cursor - index
3556}
3557
3558#[derive(Clone, Copy)]
3559struct DelimiterFlanking {
3560    left: bool,
3561    right: bool,
3562    previous: Option<char>,
3563    next: Option<char>,
3564}
3565
3566fn delimiter_flanking(input: &str, index: usize, marker_len: usize) -> DelimiterFlanking {
3567    delimiter_flanking_within(input, index, marker_len, false, false)
3568}
3569
3570/// `delimiter_flanking` for a run that may sit right inside a mark span: a
3571/// bounded side flanks as if the input ended there, the way the span's content
3572/// reads on its own.
3573fn delimiter_flanking_within(
3574    input: &str,
3575    index: usize,
3576    marker_len: usize,
3577    bounded_before: bool,
3578    bounded_after: bool,
3579) -> DelimiterFlanking {
3580    let previous = if bounded_before {
3581        None
3582    } else {
3583        input[..index].chars().next_back().map(source_char)
3584    };
3585    let next = if bounded_after {
3586        None
3587    } else {
3588        input[index + marker_len..].chars().next().map(source_char)
3589    };
3590
3591    let previous_whitespace = previous.is_none_or(char::is_whitespace);
3592    let next_whitespace = next.is_none_or(char::is_whitespace);
3593    let previous_punctuation = previous.is_some_and(is_flanking_punctuation);
3594    let next_punctuation = next.is_some_and(is_flanking_punctuation);
3595
3596    let left = next.is_some()
3597        && !next_whitespace
3598        && !(next_punctuation && !previous_whitespace && !previous_punctuation);
3599    let right = previous.is_some()
3600        && !previous_whitespace
3601        && !(previous_punctuation && !next_whitespace && !next_punctuation);
3602
3603    DelimiterFlanking {
3604        left,
3605        right,
3606        previous,
3607        next,
3608    }
3609}
3610
3611/// Dollar-fenced inline math, GitHub Flavored Markdown dialect.
3612///
3613/// A `$` is a flanking delimiter resolved at scan time (math is not pushed onto
3614/// the emphasis delimiter stack). An opening run of one or two `$` (runs of
3615/// three or more never form math) scans forward for a matching closing run:
3616///
3617/// * single `$`: cannot open if the next char is ASCII whitespace; the closing
3618///   `$` cannot be preceded by ASCII whitespace nor followed by an ASCII digit;
3619///   a `\$` inside is skipped (the backslash is kept verbatim, never a
3620///   delimiter); the close must be a run of exactly one `$`.
3621/// * double `$$`: no flanking and no digit guard; closes on the next run of two
3622///   `$`; content is kept verbatim and may span newlines (this is still an
3623///   inline display span — `$$` flow blocks are handled by `parse_math_block`).
3624///
3625/// The closing run is matched greedily (the nearest valid close wins), which is
3626/// equivalent to emphasis-style "nearest preceding open" because a failed open
3627/// emits a literal `$`/`$$` and the scan resumes after it. Content for the
3628/// single-`$` form is normalized like a code span (line endings → spaces, one
3629/// edge-space strip); the `$$` display form is verbatim. The `` $`…`$ `` code
3630/// form takes precedence.
3631fn parse_math_inline(
3632    lookups: &mut impl Lookups,
3633    input: &str,
3634    index: usize,
3635) -> Option<(usize, String, MathInlineKind)> {
3636    if let Some((end, value)) = parse_math_code_inline(lookups, input, index) {
3637        return Some((end, value, MathInlineKind::Code));
3638    }
3639
3640    let bytes = input.as_bytes();
3641    let open_dollars = bytes[index..]
3642        .iter()
3643        .take_while(|byte| **byte == b'$')
3644        .count();
3645    // The maximum math fence length is 2 dollars: a run of three or more never
3646    // opens math.
3647    if open_dollars == 0 || open_dollars > 2 {
3648        return None;
3649    }
3650
3651    let content_start = index + open_dollars;
3652    let close = scan_to_closing_dollar(lookups, input, content_start, open_dollars)?;
3653    let content_end = close - open_dollars;
3654    // The span requires `endpos - startpos >= fence_length * 2 + 1`, i.e. at
3655    // least one content byte between the open and close fences.
3656    if content_end <= content_start {
3657        return None;
3658    }
3659
3660    let raw = &input[content_start..content_end];
3661    let value = if open_dollars == 1 {
3662        normalize_math_text(raw)
3663    } else {
3664        raw.into()
3665    };
3666    let dollars = u8::try_from(open_dollars).unwrap_or(u8::MAX);
3667    Some((close, value, MathInlineKind::Dollar { dollars }))
3668}
3669
3670/// Whether `written`, read alone, is exactly one inline math holding `value`
3671/// as `kind`: validation's check that dollar math reads back as written.
3672pub(crate) fn reads_as_inline_math(written: &str, value: &str, kind: MathInlineKind) -> bool {
3673    parse_math_inline(&mut DirectLookups { input: written }, written, 0).is_some_and(
3674        |(end, read, read_kind)| end == written.len() && read == value && read_kind == kind,
3675    )
3676}
3677
3678/// Scans for the closing dollar run. `start` is the first content byte
3679/// (just past the opening run); returns the byte offset just past a matching
3680/// closing run of exactly `open_dollars` `$`.
3681fn scan_to_closing_dollar(
3682    lookups: &mut impl Lookups,
3683    input: &str,
3684    start: usize,
3685    open_dollars: usize,
3686) -> Option<usize> {
3687    let bytes = input.as_bytes();
3688    if open_dollars == 1 {
3689        // A space immediately after a single opening `$` forbids the open.
3690        if bytes.get(start).is_some_and(|byte| is_math_space(*byte)) {
3691            return None;
3692        }
3693        // An escaped `\$` is content, not a delimiter (the backslash stays in
3694        // the content verbatim); the first other `$` closes, unless a space
3695        // comes before it or an ASCII digit after it. The lookup keeps a run
3696        // of `\$` from being walked again from each `$` that opens before it.
3697        let close = lookups.unescaped_dollar(start)?;
3698        if is_math_space(bytes[close - 1]) || bytes.get(close + 1).is_some_and(u8::is_ascii_digit) {
3699            return None;
3700        }
3701        return Some(close + 1);
3702    }
3703    // The display form closes at the first `$$`, even the first two `$` of a
3704    // longer run.
3705    lookups.find("$$", start).map(|close| close + 2)
3706}
3707
3708/// Math whitespace: ASCII tab, line feed, carriage return, and space.
3709fn is_math_space(byte: u8) -> bool {
3710    matches!(byte, b'\t' | b'\n' | b'\r' | b' ')
3711}
3712
3713/// Applies the code-span content rules to dollar-fenced math: line endings
3714/// become single spaces, then if the content begins AND ends with U+0020 and is
3715/// not entirely spaces, one space is stripped from each edge.
3716fn normalize_math_text(input: &str) -> String {
3717    let mut normalized = String::new();
3718    let mut cursor = 0;
3719    while cursor < input.len() {
3720        let (next, char) = next_char(input, cursor).expect("valid UTF-8 byte index");
3721        if char == '\r' {
3722            if input.as_bytes().get(next) == Some(&b'\n') {
3723                cursor = next + 1;
3724            } else {
3725                cursor = next;
3726            }
3727            normalized.push(' ');
3728            continue;
3729        }
3730        if char == '\n' {
3731            normalized.push(' ');
3732            cursor = next;
3733            continue;
3734        }
3735        normalized.push(char);
3736        cursor = next;
3737    }
3738
3739    if normalized.starts_with(' ')
3740        && normalized.ends_with(' ')
3741        && normalized.chars().any(|char| char != ' ')
3742    {
3743        normalized[1..normalized.len() - 1].into()
3744    } else {
3745        normalized
3746    }
3747}
3748
3749fn parse_math_code_inline(
3750    lookups: &mut impl Lookups,
3751    input: &str,
3752    index: usize,
3753) -> Option<(usize, String)> {
3754    if !input[index..].starts_with("$`") {
3755        return None;
3756    }
3757
3758    let search_start = index + 2;
3759    let close = lookups.find("`$", search_start)?;
3760    if close == search_start {
3761        return None;
3762    }
3763
3764    Some((close + 2, input[search_start..close].into()))
3765}
3766
3767fn parse_link_resource(
3768    lookups: &mut impl Lookups,
3769    input: &str,
3770    open: usize,
3771) -> Option<(usize, ParsedLinkResource)> {
3772    let bytes = input.as_bytes();
3773    if bytes.get(open) != Some(&b'(') {
3774        return None;
3775    }
3776    let (mut cursor, initial_space) = skip_link_resource_space_with_info(input, open + 1)?;
3777    if bytes.get(cursor) == Some(&b')') {
3778        return Some((
3779            cursor + 1,
3780            ParsedLinkResource {
3781                destination: String::new(),
3782                destination_kind: LinkDestinationKind::Omitted,
3783                title: None,
3784            },
3785        ));
3786    }
3787    if initial_space && matches!(bytes.get(cursor), Some(b'"' | b'\'' | b'(')) {
3788        let (title, next) = parse_link_title(input, cursor)?;
3789        cursor = skip_link_resource_space(input, next)?;
3790        if bytes.get(cursor) == Some(&b')') {
3791            return Some((
3792                cursor + 1,
3793                ParsedLinkResource {
3794                    destination: String::new(),
3795                    destination_kind: LinkDestinationKind::Omitted,
3796                    title: Some(title),
3797                },
3798            ));
3799        }
3800        return None;
3801    }
3802    let (destination, destination_kind, next) = parse_link_destination(lookups, input, cursor)?;
3803    let (after_destination, had_space) = skip_link_resource_space_with_info(input, next)?;
3804    cursor = after_destination;
3805    if bytes.get(cursor) == Some(&b')') {
3806        return Some((
3807            cursor + 1,
3808            ParsedLinkResource {
3809                destination,
3810                destination_kind,
3811                title: None,
3812            },
3813        ));
3814    }
3815    if !had_space {
3816        return None;
3817    }
3818
3819    let (title, next) = parse_link_title(input, cursor)?;
3820    cursor = skip_link_resource_space(input, next)?;
3821    if bytes.get(cursor) == Some(&b')') {
3822        Some((
3823            cursor + 1,
3824            ParsedLinkResource {
3825                destination,
3826                destination_kind,
3827                title: Some(title),
3828            },
3829        ))
3830    } else {
3831        None
3832    }
3833}
3834
3835fn parse_link_destination(
3836    lookups: &mut impl Lookups,
3837    input: &str,
3838    index: usize,
3839) -> Option<(String, LinkDestinationKind, usize)> {
3840    if input.as_bytes().get(index) == Some(&b'<') {
3841        let stop = lookups.angle_destination_stop(index + 1)?;
3842        if input.as_bytes()[stop] != b'>' {
3843            return None;
3844        }
3845        return Some((
3846            decode_escapes_and_references(&input[index + 1..stop]),
3847            LinkDestinationKind::Angle,
3848            stop + 1,
3849        ));
3850    }
3851
3852    let mut cursor = index;
3853    let mut depth = 0usize;
3854    while cursor < input.len() {
3855        let (next, char) = next_char(input, cursor)?;
3856        // A bare destination terminates on ASCII space or an ASCII control
3857        // character; Unicode whitespace (e.g. U+00A0) is ordinary. A backslash
3858        // before a space is NOT an escape (only ASCII punctuation is escapable),
3859        // so `\ ` still terminates the destination → `[a](\ b)` is not a link.
3860        if char == ' ' || source_char(char).is_ascii_control() {
3861            break;
3862        }
3863        if char == '(' && !is_escaped_at(input, cursor) {
3864            depth += 1;
3865            // CommonMark caps balanced parens in a bare destination at depth 32.
3866            if depth > 32 {
3867                return None;
3868            }
3869        } else if char == ')' && !is_escaped_at(input, cursor) {
3870            if depth == 0 {
3871                break;
3872            }
3873            depth -= 1;
3874        }
3875        cursor = next;
3876    }
3877
3878    if cursor == index || depth > 0 {
3879        None
3880    } else {
3881        Some((
3882            decode_escapes_and_references(&input[index..cursor]),
3883            LinkDestinationKind::Bare,
3884            cursor,
3885        ))
3886    }
3887}
3888
3889fn parse_link_title(input: &str, index: usize) -> Option<(Title, usize)> {
3890    let opener = input.as_bytes().get(index).copied()?;
3891    let (closer, title_kind) = match opener {
3892        b'"' => ('"', LinkTitleKind::DoubleQuote),
3893        b'\'' => ('\'', LinkTitleKind::SingleQuote),
3894        b'(' => (')', LinkTitleKind::Paren),
3895        _ => return None,
3896    };
3897    let mut cursor = index + 1;
3898    while cursor < input.len() {
3899        let (next, char) = next_char(input, cursor)?;
3900        if char == closer && !is_escaped_at(input, cursor) {
3901            if contains_blank_line(&input[index + 1..cursor]) {
3902                return None;
3903            }
3904            let value = decode_escapes_and_references(&input[index + 1..cursor]);
3905            return Some((Title::new(value, title_kind), next));
3906        }
3907        if opener == b'(' && char == '(' && !is_escaped_at(input, cursor) {
3908            return None;
3909        }
3910        cursor = next;
3911    }
3912    None
3913}
3914
3915fn contains_blank_line(input: &str) -> bool {
3916    if !input.bytes().any(|byte| matches!(byte, b'\n' | b'\r')) {
3917        return false;
3918    }
3919    // A title that merely begins or ends with an EOL is allowed; only an INTERIOR
3920    // blank line (a blank line bounded by content on both sides) is rejected. The
3921    // empty first/last line entries that a leading/trailing newline produces are
3922    // boundary artifacts, not blank lines in the title content.
3923    let map = SourceMap::verbatim(input.len(), 0);
3924    let lines = collect_lines(input, &map);
3925    let interior = lines.len().saturating_sub(1);
3926    lines
3927        .iter()
3928        .take(interior)
3929        .skip(1)
3930        .any(|line| is_blank(line.text))
3931}
3932
3933fn skip_link_resource_space(input: &str, index: usize) -> Option<usize> {
3934    skip_link_resource_space_with_info(input, index).map(|(index, _)| index)
3935}
3936
3937fn skip_link_resource_space_with_info(input: &str, mut index: usize) -> Option<(usize, bool)> {
3938    let mut line_breaks = 0usize;
3939    let mut had_space = false;
3940    while input
3941        .as_bytes()
3942        .get(index)
3943        .is_some_and(|byte| matches!(*byte, b' ' | b'\t' | b'\n' | b'\r'))
3944    {
3945        had_space = true;
3946        match input.as_bytes()[index] {
3947            b'\n' => {
3948                line_breaks += 1;
3949                if line_breaks > 1 {
3950                    return None;
3951                }
3952                index += 1;
3953            }
3954            b'\r' => {
3955                line_breaks += 1;
3956                if line_breaks > 1 {
3957                    return None;
3958                }
3959                if input.as_bytes().get(index + 1) == Some(&b'\n') {
3960                    index += 2;
3961                } else {
3962                    index += 1;
3963                }
3964            }
3965            _ => index += 1,
3966        }
3967    }
3968    Some((index, had_space))
3969}
3970
3971pub(crate) fn is_escaped_at(input: &str, index: usize) -> bool {
3972    let bytes = input.as_bytes();
3973    let mut cursor = index;
3974    let mut count = 0;
3975    while cursor > 0 && bytes[cursor - 1] == b'\\' {
3976        count += 1;
3977        cursor -= 1;
3978    }
3979    count % 2 == 1
3980}
3981
3982fn parse_definition_destination_title(input: &str) -> Option<ParsedLinkResource> {
3983    let (mut cursor, _) = skip_link_resource_space_with_info(input, 0)?;
3984    let (destination, destination_kind, next) =
3985        parse_link_destination(&mut DirectLookups { input }, input, cursor)?;
3986    cursor = next;
3987
3988    let (next, had_space) = skip_link_resource_space_with_info(input, cursor)?;
3989    cursor = next;
3990    if cursor >= input.len() {
3991        return Some(ParsedLinkResource {
3992            destination,
3993            destination_kind,
3994            title: None,
3995        });
3996    }
3997    if !had_space {
3998        return None;
3999    }
4000
4001    let (title, next) = parse_link_title(input, cursor)?;
4002    let after_title = skip_link_resource_space(input, next)?;
4003    (after_title == input.len()).then_some(ParsedLinkResource {
4004        destination,
4005        destination_kind,
4006        title: Some(title),
4007    })
4008}
4009
4010/// The char that closes the title `source` opens after its destination, when
4011/// no unescaped one follows the opener yet.
4012fn open_definition_title(source: &str) -> Option<char> {
4013    let (cursor, _) = skip_link_resource_space_with_info(source, 0)?;
4014    let (_, _, next) =
4015        parse_link_destination(&mut DirectLookups { input: source }, source, cursor)?;
4016    let (cursor, had_space) = skip_link_resource_space_with_info(source, next)?;
4017    if !had_space {
4018        return None;
4019    }
4020    let closer = match source.as_bytes().get(cursor)? {
4021        b'"' => '"',
4022        b'\'' => '\'',
4023        b'(' => ')',
4024        _ => return None,
4025    };
4026    (!line_may_close_title(&source[cursor + 1..], closer)).then_some(closer)
4027}
4028
4029/// Whether `line` holds an unescaped `closer`, or an unescaped `(` inside a
4030/// parenthesized title, either of which settles the title. A backslash
4031/// escape never spans a line ending, so each line is read alone.
4032fn line_may_close_title(line: &str, closer: char) -> bool {
4033    line.char_indices().any(|(index, char)| {
4034        (char == closer || (closer == ')' && char == '(')) && !is_escaped_at(line, index)
4035    })
4036}
4037
4038fn line_can_start_definition_title(input: &str) -> bool {
4039    let trimmed = trim_ascii_start(input);
4040    matches!(trimmed.as_bytes().first(), Some(b'"' | b'\'' | b'('))
4041}
4042
4043fn push_line(output: &mut String, line: &str) {
4044    if !output.is_empty() {
4045        output.push('\n');
4046    }
4047    output.push_str(line);
4048}
4049
4050/// Ends a code or math block's last line when the input ended without a line
4051/// ending: every line of such a block's value ends with one. The added ending
4052/// repeats the value's first line ending, `\n` when it has none.
4053fn end_last_line(value: &mut String) {
4054    if value.is_empty() || ends_with_line_ending(value) {
4055        return;
4056    }
4057    value.push_str(value_line_ending(value));
4058}
4059
4060/// The line ending a code or math value's lines take: its first one, `\n`
4061/// when it has none.
4062fn value_line_ending(value: &str) -> &'static str {
4063    match value.find(['\r', '\n']) {
4064        Some(index) if value[index..].starts_with("\r\n") => "\r\n",
4065        Some(index) if value[index..].starts_with('\r') => "\r",
4066        _ => "\n",
4067    }
4068}
4069
4070fn ends_with_line_ending(input: &str) -> bool {
4071    input.ends_with('\n') || input.ends_with('\r')
4072}
4073
4074fn flush_text(nodes: &mut Vec<Inline>, text: &mut String, text_start: usize, end: usize) {
4075    if !text.is_empty() {
4076        nodes.push(Inline::Text(Text {
4077            meta: NodeMeta::new(Some(Span::new(text_start, end))),
4078            value: core::mem::take(text),
4079        }));
4080    }
4081}
4082
4083fn next_char(input: &str, index: usize) -> Option<(usize, char)> {
4084    let char = input[index..].chars().next()?;
4085    Some((index + char.len_utf8(), char))
4086}
4087
4088/// A CommonMark "Unicode punctuation character" for emphasis/strong flanking:
4089/// ASCII punctuation plus the non-ASCII Unicode `P*`/`S*` categories. Only the
4090/// flanking classification needs the Unicode set; escape/label logic stays
4091/// ASCII-only via `char::is_ascii_punctuation`.
4092fn is_flanking_punctuation(value: char) -> bool {
4093    value.is_ascii_punctuation() || crate::unicode_punctuation::is_unicode_punctuation(value)
4094}
4095
4096/// Fold a reference label to its matching identifier. Per CommonMark, two
4097/// labels match when their RAW source (no backslash unescape, no entity decode)
4098/// agrees after collapsing internal spaces, tabs, and line endings to a single
4099/// space, trimming, and Unicode case-folding (`to_uppercase()` then
4100/// `to_lowercase()`). So `[foo\!]`
4101/// does NOT match `[foo!]`, and `[&copy;]` does NOT match `[©]`.
4102///
4103/// The serializer's `normalize_reference_label` delegates here so the
4104/// Shortcut/Collapsed omission oracle stays in lockstep with this matcher.
4105pub(crate) fn normalize_label(label: &str) -> String {
4106    label
4107        // Unicode full casefold maps capital sharp S (ẞ, U+1E9E) to "ss"; Rust's
4108        // `to_uppercase` leaves it unchanged (it is already uppercase), so without
4109        // this `[ẞ]` would not match a `[SS]: …` definition (links 540). This is
4110        // the only char where `to_uppercase().to_lowercase()` diverges from the
4111        // full casefold that matters for label matching.
4112        .replace('ẞ', "ss")
4113        .replace('\0', "\u{FFFD}")
4114        .split([' ', '\t', '\n', '\r'])
4115        .filter(|word| !word.is_empty())
4116        .collect::<Vec<_>>()
4117        .join(" ")
4118        .to_uppercase()
4119        .to_lowercase()
4120}
4121
4122/// The identifiers a document's references can resolve to: those of its own
4123/// definitions, sorted and deduplicated so that a lookup is a binary search.
4124#[derive(Clone, Copy)]
4125pub(crate) struct Definitions<'a> {
4126    pub(crate) own: &'a [String],
4127}
4128
4129fn definition_exists(definitions: Option<Definitions<'_>>, label: &str) -> bool {
4130    if label.is_empty() || !reference_label_is_within_limit(label) {
4131        return false;
4132    }
4133    let Some(definitions) = definitions else {
4134        return false;
4135    };
4136    let identifier = normalize_label(label);
4137    definitions.own.binary_search(&identifier).is_ok()
4138}
4139
4140fn reference_label_is_within_limit(label: &str) -> bool {
4141    label.chars().take(REFERENCE_LABEL_MAX_CHARS + 1).count() <= REFERENCE_LABEL_MAX_CHARS
4142}
4143
4144/// Whether `[label]` reads back as a link reference label holding `label`:
4145/// the walk to its `]` ends at its own `]`, so the label holds no unescaped
4146/// bracket and does not escape its close; it is within the length limit, is
4147/// not blank, and holds no blank line, which would end its paragraph.
4148pub(crate) fn is_reference_label(label: &str) -> bool {
4149    let written = alloc::format!("[{label}]");
4150    path_walk(1, |cursor| reference_label_step(&written, cursor)) == Some(written.len() - 1)
4151        && reference_label_is_within_limit(label)
4152        && !normalize_label(label).is_empty()
4153        && !holds_blank_line(label)
4154}
4155
4156/// Whether `[^label]` reads back as a footnote label holding `label`: a
4157/// footnote label whose `]` it does not escape.
4158pub(crate) fn is_written_footnote_label(label: &str) -> bool {
4159    is_footnote_label(label) && !is_escaped_at(&alloc::format!("{label}]"), label.len())
4160}
4161
4162/// Whether a line of `text` other than its first and its last, which share
4163/// their lines with what is written around `text`, is blank.
4164fn holds_blank_line(text: &str) -> bool {
4165    let text = text.replace("\r\n", "\n");
4166    let lines: Vec<&str> = text.split(['\n', '\r']).collect();
4167    lines.len() > 2 && lines[1..lines.len() - 1].iter().any(|line| is_blank(line))
4168}
4169
4170fn trim_up_to_three_spaces(input: &str) -> Option<&str> {
4171    let (columns, bytes) = leading_indent(input);
4172    if columns <= 3 {
4173        Some(&input[bytes..])
4174    } else {
4175        None
4176    }
4177}
4178
4179/// The marker and length of the code fence that `input` opens: three or more
4180/// backticks or tildes, where a backtick fence's info string holds no backtick.
4181fn fence_start(input: &str) -> Option<(FenceMarker, usize)> {
4182    let marker = match input.as_bytes().first()? {
4183        b'`' => FenceMarker::Backtick,
4184        b'~' => FenceMarker::Tilde,
4185        _ => return None,
4186    };
4187    let byte = match marker {
4188        FenceMarker::Backtick => b'`',
4189        FenceMarker::Tilde => b'~',
4190    };
4191    let length = input
4192        .as_bytes()
4193        .iter()
4194        .take_while(|item| **item == byte)
4195        .count();
4196    let opens = length >= 3 && (byte == b'~' || !input[length..].contains('`'));
4197    opens.then_some((marker, length))
4198}
4199
4200fn fence_close(input: &str, marker: FenceMarker, length: usize) -> bool {
4201    let byte = match marker {
4202        FenceMarker::Backtick => b'`',
4203        FenceMarker::Tilde => b'~',
4204    };
4205    let count = input
4206        .as_bytes()
4207        .iter()
4208        .take_while(|item| **item == byte)
4209        .count();
4210    count >= length && is_blank(&input[count..])
4211}
4212
4213fn trim_closing_hashes(input: &str) -> &str {
4214    let input = input.trim_end_matches([' ', '\t']);
4215    let hash_start = input.trim_end_matches('#').len();
4216    if hash_start == input.len() {
4217        return input;
4218    }
4219    if hash_start == 0 {
4220        return "";
4221    }
4222
4223    let before = &input[..hash_start];
4224    if before.ends_with(' ') || before.ends_with('\t') {
4225        before.trim_end_matches([' ', '\t'])
4226    } else {
4227        input
4228    }
4229}
4230
4231/// Whether `input` opens with a list item marker, up to three spaces in.
4232fn opens_list_item(input: &str) -> bool {
4233    trim_up_to_three_spaces(input).is_some_and(|rest| blocks::list_marker_head(rest).is_some())
4234}
4235
4236/// The offset of `slice` inside `text` when it is a borrowed sub-slice of it.
4237fn slice_offset_in(text: &str, slice: &str) -> Option<usize> {
4238    let offset = (slice.as_ptr() as usize).checked_sub(text.as_ptr() as usize)?;
4239    (offset + slice.len() <= text.len()).then_some(offset)
4240}
4241
4242fn leading_indent(input: &str) -> (usize, usize) {
4243    leading_indent_at(input, 0)
4244}
4245
4246/// The columns and bytes of `input`'s leading spaces and tabs, its first
4247/// char at source column `start`, from which tabs reach their stops.
4248fn leading_indent_at(input: &str, start: usize) -> (usize, usize) {
4249    let mut column = start;
4250    let mut bytes = 0usize;
4251    for byte in input.as_bytes() {
4252        match *byte {
4253            b' ' => column += 1,
4254            b'\t' => column += 4 - (column % 4),
4255            _ => break,
4256        }
4257        bytes += 1;
4258    }
4259    (column - start, bytes)
4260}
4261
4262fn task_marker_checked(input: &str) -> Option<bool> {
4263    if input.starts_with("[ ]") {
4264        Some(false)
4265    } else if input.starts_with("[x]") || input.starts_with("[X]") {
4266        Some(true)
4267    } else {
4268        None
4269    }
4270}
4271
4272/// Whether `text` holds only spaces and tabs, as a blank line does.
4273pub(crate) fn is_blank(text: &str) -> bool {
4274    text.bytes().all(|byte| matches!(byte, b' ' | b'\t'))
4275}
4276
4277fn trim_ascii_start(input: &str) -> &str {
4278    input.trim_start_matches(|char| matches!(char, ' ' | '\t'))
4279}
4280
4281fn leading_trim_bytes(input: &str) -> usize {
4282    input.len() - trim_ascii_start(input).len()
4283}
4284
4285fn parse_table_delimiter(input: &str) -> Option<Vec<TableAlignment>> {
4286    // Every cell trims to colons around dashes, so a row with any other char
4287    // is no delimiter row, whatever its cells.
4288    if !input.contains('-')
4289        || !input
4290            .chars()
4291            .all(|char| matches!(char, '|' | '-' | ':' | ' ' | '\t'))
4292    {
4293        return None;
4294    }
4295    let cells = table_row_cell_ranges(input);
4296    if cells.is_empty() {
4297        return None;
4298    }
4299    let mut alignments = Vec::new();
4300    for (start, end) in cells {
4301        alignments.push(table_delimiter_alignment(
4302            input[start..end].trim_matches([' ', '\t']),
4303        )?);
4304    }
4305    Some(alignments)
4306}
4307
4308// A delimiter cell is `:?` `-`+ `:?` once trimmed: colons only at the
4309// boundaries, the dashes contiguous, no interior space or colon.
4310fn table_delimiter_alignment(cell: &str) -> Option<TableAlignment> {
4311    let bytes = cell.as_bytes();
4312    let mut cursor = 0;
4313    let left = bytes.first() == Some(&b':');
4314    if left {
4315        cursor += 1;
4316    }
4317    let dash_start = cursor;
4318    while bytes.get(cursor) == Some(&b'-') {
4319        cursor += 1;
4320    }
4321    if cursor == dash_start {
4322        return None;
4323    }
4324    let right = bytes.get(cursor) == Some(&b':');
4325    if right {
4326        cursor += 1;
4327    }
4328    if cursor != bytes.len() {
4329        return None;
4330    }
4331    Some(match (left, right) {
4332        (true, true) => TableAlignment::Center,
4333        (true, false) => TableAlignment::Left,
4334        (false, true) => TableAlignment::Right,
4335        (false, false) => TableAlignment::None,
4336    })
4337}
4338
4339/// The cell-delimiter pipes of a table row, in order: every `|` that is not
4340/// escaped by an odd backslash run. An escaped pipe is an escape in the cell's
4341/// inline content.
4342fn table_row_delimiters(row: &str) -> Vec<usize> {
4343    let bytes = row.as_bytes();
4344    let mut delimiters = Vec::new();
4345    let mut cursor = 0;
4346    while cursor < bytes.len() {
4347        match bytes[cursor] {
4348            b'\\' => {
4349                let backslashes = delimiter_byte_run_len(row, cursor, b'\\');
4350                let pipe = cursor + backslashes;
4351                cursor = if bytes.get(pipe) == Some(&b'|') && backslashes % 2 == 1 {
4352                    pipe + 1
4353                } else {
4354                    pipe
4355                };
4356            }
4357            b'|' => {
4358                delimiters.push(cursor);
4359                cursor += 1;
4360            }
4361            _ => cursor += 1,
4362        }
4363    }
4364    delimiters
4365}
4366
4367/// The byte ranges of `input`'s cells, between the pipes that delimit them.
4368fn table_row_cell_ranges(input: &str) -> Vec<(usize, usize)> {
4369    let trimmed = input.trim_matches([' ', '\t']);
4370    let offset = leading_trim_bytes(input);
4371    let delimiters = table_row_delimiters(trimmed);
4372    let mut cells = Vec::new();
4373    let mut start = 0;
4374    for &pipe in &delimiters {
4375        cells.push((offset + start, offset + pipe));
4376        start = pipe + 1;
4377    }
4378    cells.push((offset + start, offset + trimmed.len()));
4379
4380    // A delimiter at the very start or end (only whitespace after it) is a
4381    // border, not the edge of an empty cell.
4382    if delimiters.first() == Some(&0) {
4383        cells.remove(0);
4384    }
4385    if delimiters
4386        .last()
4387        .is_some_and(|&pipe| is_blank(&trimmed[pipe + 1..]))
4388    {
4389        cells.pop();
4390    }
4391    cells
4392}
4393
4394/// One table cell's inline input (whitespace trimmed, escaped pipes read as
4395/// `|`), its source map, and its span.
4396struct TableCellSource {
4397    text: DerivedText,
4398    /// The offsets in `text` of the `|`s read from `\|`.
4399    escaped_pipes: Vec<usize>,
4400    span: Span,
4401}
4402
4403/// The cells of `row`, a slice of `line.text`, as `table_row_cell_ranges`
4404/// splits it.
4405fn table_row_cells(line: &Line<'_>, row: &str) -> Vec<TableCellSource> {
4406    let row_offset = slice_offset_in(line.text, row).unwrap_or(0);
4407    table_row_cell_ranges(row)
4408        .into_iter()
4409        .map(|(start, end)| {
4410            let raw = &row[start..end];
4411            let leading = leading_trim_bytes(raw);
4412            let content = raw.trim_matches([' ', '\t']);
4413            let offset = row_offset + start + leading;
4414            let span = Span::new(
4415                line.source_start(offset),
4416                line.source_end(offset + content.len()),
4417            );
4418            let mut text = DerivedText::default();
4419            let mut escaped_pipes = Vec::new();
4420            // GitHub/cmark-gfm reads an odd backslash run before `|` as a
4421            // literal pipe; the run keeps its other backslashes, which the
4422            // inline parser resolves as written. The `|` read from `\|` maps
4423            // to both of its bytes.
4424            let bytes = content.as_bytes();
4425            let mut copied = 0;
4426            let mut cursor = 0;
4427            while cursor < bytes.len() {
4428                if bytes[cursor] == b'\\' {
4429                    let pipe = cursor + delimiter_byte_run_len(content, cursor, b'\\');
4430                    if bytes.get(pipe) == Some(&b'|') && (pipe - cursor) % 2 == 1 {
4431                        text.append(line, &content[copied..pipe - 1]);
4432                        escaped_pipes.push(text.text.len());
4433                        text.append_replacing(
4434                            "|",
4435                            line.source_start(offset + pipe - 1),
4436                            line.source_end(offset + pipe + 1),
4437                        );
4438                        copied = pipe + 1;
4439                    }
4440                    cursor = pipe;
4441                } else {
4442                    cursor += 1;
4443                }
4444            }
4445            text.append(line, &content[copied..]);
4446            TableCellSource {
4447                text,
4448                escaped_pipes,
4449                span,
4450            }
4451        })
4452        .collect()
4453}
4454
4455fn table_has_separator(header: &str, delimiter: &str) -> bool {
4456    // GFM makes leading/trailing pipes optional, so `parse_table_delimiter` plus
4457    // the header/alignment column-count check usually suffice. The one exception
4458    // is a single resolved column with no disambiguating syntax: `a\n-\nb` has
4459    // matching one-column shapes yet no pipe and no alignment colon, so it is a
4460    // loose paragraph/setext, not a table. A single column still forms a table
4461    // when a pipe appears in the header/delimiter or the delimiter carries an
4462    // explicit alignment colon (`a\n-:`, `a\n:-:`, …).
4463    let Some(alignments) = parse_table_delimiter(delimiter) else {
4464        return true;
4465    };
4466    if alignments.len() == 1 {
4467        return contains_unescaped_pipe(header)
4468            || contains_unescaped_pipe(delimiter)
4469            || delimiter.contains(':');
4470    }
4471    true
4472}
4473
4474// Still used by `block_quote_table_body_row` to detect a table row appearing as
4475// a block-quote continuation line (which DOES require a pipe).
4476fn contains_unescaped_pipe(input: &str) -> bool {
4477    !table_row_delimiters(input).is_empty()
4478}
4479
4480// A GFM footnote definition `[^label]:` is a block boundary: it interrupts a
4481// paragraph and ends a prior footnote's lazy continuation.
4482/// The end of the `<…>` autolink opening at `index`.
4483fn autolink_end(lookups: &mut impl Lookups, input: &str, index: usize) -> Option<usize> {
4484    let end = lookups.find(">", index)? + 1;
4485    is_angle_autolink(&input[index + 1..end - 1]).then_some(end)
4486}
4487
4488/// The end of the inline HTML (comment, processing instruction, CDATA,
4489/// declaration, or tag) opening at `index`.
4490fn html_inline_end(lookups: &mut impl Lookups, input: &str, index: usize) -> Option<usize> {
4491    let rest = &input[index..];
4492    if rest.starts_with("<!--") {
4493        return Some(lookups.find("-->", index)? + 3);
4494    }
4495    // The `?>` closing a processing instruction follows its `<?`.
4496    if rest.starts_with("<?") {
4497        return Some(lookups.find("?>", index + 2)? + 2);
4498    }
4499    if rest.starts_with("<![CDATA[") {
4500        return Some(lookups.find("]]>", index)? + 3);
4501    }
4502    if is_declaration_start(rest) {
4503        return Some(lookups.find(">", index)? + 1);
4504    }
4505    parse_html_tag_with(lookups, input, index).map(|(end, _)| end)
4506}
4507
4508fn parse_html_tag(input: &str, index: usize) -> Option<(usize, &str)> {
4509    parse_html_tag_with(&mut DirectLookups { input }, input, index)
4510}
4511
4512fn parse_html_tag_with<'a>(
4513    lookups: &mut impl Lookups,
4514    input: &'a str,
4515    index: usize,
4516) -> Option<(usize, &'a str)> {
4517    let bytes = input.as_bytes();
4518    if bytes.get(index) != Some(&b'<') {
4519        return None;
4520    }
4521
4522    let closing = bytes.get(index + 1) == Some(&b'/');
4523    let name_start = index + if closing { 2 } else { 1 };
4524    let first = *bytes.get(name_start)?;
4525    if !first.is_ascii_alphabetic() {
4526        return None;
4527    }
4528
4529    let mut cursor = name_start + 1;
4530    while bytes.get(cursor).is_some_and(|byte| html_name_byte(*byte)) {
4531        cursor += 1;
4532    }
4533    let name = &input[name_start..cursor];
4534
4535    if closing {
4536        cursor = skip_spaces(input, cursor);
4537        if bytes.get(cursor) == Some(&b'>') {
4538            return Some((cursor + 1, name));
4539        }
4540        return None;
4541    }
4542
4543    let mut needs_space = false;
4544    loop {
4545        let before_spaces = cursor;
4546        cursor = skip_spaces(input, cursor);
4547        let had_space = cursor > before_spaces;
4548        match bytes.get(cursor) {
4549            Some(b'>') => return Some((cursor + 1, name)),
4550            Some(b'/') if bytes.get(cursor + 1) == Some(&b'>') => return Some((cursor + 2, name)),
4551            Some(byte) if had_space && html_attribute_name_start(*byte) => {
4552                cursor += 1;
4553                while bytes
4554                    .get(cursor)
4555                    .is_some_and(|byte| html_attribute_name_byte(*byte))
4556                {
4557                    cursor += 1;
4558                }
4559                let after_name = cursor;
4560                let after_spaces = skip_spaces(input, cursor);
4561                if bytes.get(after_spaces) == Some(&b'=') {
4562                    cursor = skip_spaces(input, after_spaces + 1);
4563                    cursor = parse_html_attribute_value(lookups, input, cursor)?;
4564                } else {
4565                    cursor = after_name;
4566                }
4567                needs_space = true;
4568            }
4569            Some(_) if needs_space => return None,
4570            _ => return None,
4571        }
4572    }
4573}
4574
4575fn parse_html_attribute_value(
4576    lookups: &mut impl Lookups,
4577    input: &str,
4578    index: usize,
4579) -> Option<usize> {
4580    let bytes = input.as_bytes();
4581    match bytes.get(index)? {
4582        b'"' => Some(lookups.find("\"", index + 1)? + 1),
4583        b'\'' => Some(lookups.find("'", index + 1)? + 1),
4584        b'=' | b'<' | b'>' | b'`' => None,
4585        _ => {
4586            let mut cursor = index;
4587            while bytes.get(cursor).is_some_and(|byte| {
4588                !byte.is_ascii_whitespace()
4589                    && !matches!(*byte, b'"' | b'\'' | b'=' | b'<' | b'>' | b'`')
4590            }) {
4591                cursor += 1;
4592            }
4593            if cursor == index {
4594                None
4595            } else {
4596                Some(cursor)
4597            }
4598        }
4599    }
4600}
4601
4602fn html_name_byte(byte: u8) -> bool {
4603    byte.is_ascii_alphanumeric() || byte == b'-'
4604}
4605
4606fn html_attribute_name_start(byte: u8) -> bool {
4607    byte.is_ascii_alphabetic() || byte == b'_' || byte == b':'
4608}
4609
4610fn html_attribute_name_byte(byte: u8) -> bool {
4611    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b':' | b'.' | b'-')
4612}
4613
4614fn skip_spaces(input: &str, mut index: usize) -> usize {
4615    while input
4616        .as_bytes()
4617        .get(index)
4618        .is_some_and(|byte| matches!(*byte, b' ' | b'\t' | b'\n' | b'\r'))
4619    {
4620        index += 1;
4621    }
4622    index
4623}
4624
4625/// The destination of the angle-bracket autolink `<uri>`, when `uri` writes
4626/// one: the URI itself, or `mailto:` and an email address.
4627pub(crate) fn angle_autolink_destination(uri: &str) -> Option<String> {
4628    if is_uri_autolink(uri) {
4629        Some(String::from(uri))
4630    } else if is_email_autolink(uri) {
4631        Some(alloc::format!("mailto:{uri}"))
4632    } else {
4633        None
4634    }
4635}
4636
4637/// Whether `<uri>` is an angle-bracket autolink.
4638fn is_angle_autolink(uri: &str) -> bool {
4639    is_uri_autolink(uri) || is_email_autolink(uri)
4640}
4641
4642fn is_uri_autolink(input: &str) -> bool {
4643    // A scheme is at most 32 bytes, so the `:` ending it is within the first 33.
4644    let Some(colon) = input
4645        .as_bytes()
4646        .iter()
4647        .take(33)
4648        .position(|byte| *byte == b':')
4649    else {
4650        return false;
4651    };
4652    let scheme = &input[..colon];
4653    if scheme.len() < 2 || scheme.len() > 32 {
4654        return false;
4655    }
4656    let mut bytes = scheme.bytes();
4657    if !bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic()) {
4658        return false;
4659    }
4660    if !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'.' | b'-')) {
4661        return false;
4662    }
4663    input[colon + 1..]
4664        .chars()
4665        .map(source_char)
4666        .all(|char| !matches!(char, '<' | '>' | ' ') && !char.is_ascii_control())
4667}
4668
4669fn is_email_autolink(input: &str) -> bool {
4670    // No email address contains `<`, and stopping at one keeps a run of `<`
4671    // openers that share one later `>` linear.
4672    if input
4673        .chars()
4674        .any(|char| char.is_whitespace() || char == '<')
4675    {
4676        return false;
4677    }
4678    let Some(at) = input.find('@') else {
4679        return false;
4680    };
4681    if at == 0 || at + 1 >= input.len() {
4682        return false;
4683    }
4684    // Angle-bracket `<email>` autolinks use the strict CommonMark domain
4685    // grammar but, unlike the GFM bare form, allow a single (dotless) label.
4686    is_email_local_part(&input[..at]) && is_email_domain(&input[at + 1..], 1)
4687}
4688
4689/// The destination of a literal autolink written as `text`, when `text` is
4690/// exactly one literal autolink the inline parser can read: the text after the
4691/// prefix `parse_literal_autolink` gives it.
4692///
4693/// Whether a literal autolink forms depends in part on what follows it:
4694/// cmark-gfm's domain check counts the `.` and `_` in the punctuation trimmed
4695/// off its end, and `www.` at a line start links `www`. So `text` is read at a
4696/// line start and followed by what lets the most through, `..` and a space:
4697/// every literal autolink the parser reads has a destination, and a text the
4698/// parser would trim never does.
4699pub(crate) fn literal_autolink_destination(text: &str) -> Option<String> {
4700    let mut input = String::from(text);
4701    input.push_str(".. ");
4702    let (end, prefix) =
4703        parse_literal_autolink(&input, 0, &mut LiteralAutolinkScan::default(), true)?;
4704    (end == text.len()).then(|| {
4705        let mut destination = String::from(prefix);
4706        destination.push_str(text);
4707        destination
4708    })
4709}
4710
4711/// A literal autolink starting at `index`, by cmark-gfm's dispatch: it tries,
4712/// in order, `http(s)://` URLs, `www.` URLs, extended-protocol
4713/// (`mailto:`/`xmpp:`) emails, and bare emails. Each branch enforces
4714/// cmark-gfm's per-scheme preceding-character guard and its domain/host
4715/// rules; the trailing trim is shared (`autolink_delim`). Returns the end and
4716/// the prefix the destination puts before `input[index..end]`, the visible
4717/// original: `http://` for a `www.` URL, `mailto:` for a bare email, and
4718/// nothing otherwise.
4719///
4720/// `urls` is false inside an open `[`: as in cmark-gfm, a `http://`,
4721/// `https://`, or `www.` URL after a `[` no `]` has closed yet stays text, and
4722/// only an email address links.
4723fn parse_literal_autolink(
4724    input: &str,
4725    index: usize,
4726    scan: &mut LiteralAutolinkScan,
4727    urls: bool,
4728) -> Option<(usize, &'static str)> {
4729    let rest = &input[index..];
4730    let www = rest
4731        .as_bytes()
4732        .get(..4)
4733        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"www."));
4734    if !urls {
4735        // A literal starting `www.` is a URL, linked or not, as outside an
4736        // open `[`: no email starts there.
4737        if www {
4738            return None;
4739        }
4740        return parse_literal_email(input, index, scan);
4741    }
4742
4743    // `http://` / `https://` URLs. cmark requires the char before the scheme
4744    // to be non-alphanumeric (so `mmmhttp://…` does not link from `mmmh`).
4745    if let Some(scheme_len) = rest
4746        .starts_with("http://")
4747        .then_some(7)
4748        .or_else(|| rest.starts_with("https://").then_some(8))
4749    {
4750        if !literal_scheme_prefix_ok(input, index) {
4751            return None;
4752        }
4753        // A non-empty domain is additionally required, so `http://`,
4754        // `http://#`, `http://$`, and a bracketed IPv6 host are not links.
4755        if !http_literal_host_ok(&input[index + scheme_len..]) {
4756            return None;
4757        }
4758        // The URL extent is scanned from the very start (after `://`) and the
4759        // trailing trim runs over the whole URL.
4760        let end = autolink_url_end(input, index + scheme_len);
4761        if end <= index + scheme_len {
4762            return None;
4763        }
4764        return Some((end, ""));
4765    }
4766
4767    // `www.` URLs (synthesize a `http://` href). cmark allows the preceding
4768    // char to be one of `*_~(` or whitespace (or start of input).
4769    if www {
4770        if !literal_www_prefix_ok(input, index) {
4771            return None;
4772        }
4773        if !check_domain(rest, false) {
4774            return None;
4775        }
4776        let end = autolink_url_end(input, index);
4777        if end <= index || (end <= index + 3 && !literal_starts_line(input, index)) {
4778            return None;
4779        }
4780        return Some((end, "http://"));
4781    }
4782
4783    parse_literal_email(input, index, scan)
4784}
4785
4786// The char immediately before a `http(s)://` literal must be non-alphabetic.
4787// An escaped `<` (`\<http://…`) is just literal text before the URL, so the
4788// literal still forms (the `<` is not treated as an angle-autolink opener).
4789fn literal_scheme_prefix_ok(input: &str, index: usize) -> bool {
4790    if index == 0 {
4791        return true;
4792    }
4793    let Some(previous) = input[..index].chars().next_back() else {
4794        return true;
4795    };
4796    !previous.is_ascii_alphabetic()
4797}
4798
4799// The char before a `www.` literal must be one of cmark-gfm's accepted ASCII
4800// delimiters or ordinary Markdown layout whitespace. As on GitHub, other
4801// Unicode whitespace is not a start delimiter for this branch.
4802fn literal_www_prefix_ok(input: &str, index: usize) -> bool {
4803    if index == 0 {
4804        return true;
4805    }
4806    let Some(previous) = input[..index].chars().next_back() else {
4807        return true;
4808    };
4809    if matches!(previous, '*' | '_' | '~' | '(' | '[' | ']') {
4810        return true;
4811    }
4812    matches!(previous, ' ' | '\t' | '\n' | '\r')
4813}
4814
4815fn literal_starts_line(input: &str, index: usize) -> bool {
4816    index == 0
4817        || input
4818            .as_bytes()
4819            .get(index - 1)
4820            .is_some_and(|byte| matches!(byte, b'\n' | b'\r'))
4821}
4822
4823fn http_literal_host_ok(host: &str) -> bool {
4824    match host.chars().next() {
4825        Some(char) if char.is_ascii() && char.is_ascii_alphanumeric() => check_domain(host, true),
4826        Some(char) if !char.is_ascii() && is_valid_hostchar(source_char(char)) => {
4827            check_domain(host, true)
4828        }
4829        _ => false,
4830    }
4831}
4832
4833// Port of cmark-gfm `is_valid_hostchar`: a host char is valid when it is not a
4834// Unicode space and not a Unicode punctuation character.
4835fn is_valid_hostchar(char: char) -> bool {
4836    !char.is_whitespace() && !crate::unicode_punctuation::is_unicode_punctuation(char)
4837}
4838
4839// Port of cmark-gfm `check_domain`: whether `data` starts with a valid host.
4840// Rejects a `_` in either of the last two `.`-separated host segments (unless
4841// the host has >10 segments — a DoS guard). When `allow_short` is false a dot
4842// is required (the `www.` rule). The URL extent past the host is determined by
4843// `autolink_url_end`, so only the verdict matters here.
4844//
4845// cmark walks bytes with `is_valid_hostchar` decoding each char; this walks
4846// chars directly (UTF-8 safe) over the host prefix, which yields the same
4847// dot/underscore-segment verdict. A `\` escapes the following char, so a host
4848// can run on over escaped spaces through later `www.` candidates; the walk
4849// stops at the eleventh dot, which settles the verdict, so that the walks
4850// from all the candidates of a pass stay linear in total.
4851fn check_domain(data: &str, allow_short: bool) -> bool {
4852    let mut np = 0usize;
4853    let mut uscore1 = 0usize;
4854    let mut uscore2 = 0usize;
4855
4856    let mut chars = data.char_indices().peekable();
4857    while let Some((offset, char)) = chars.next() {
4858        // cmark's accounting loop runs `for (i = 1; i < size - 1; i++)`: it
4859        // never inspects the first char (offset 0) nor the final char of the
4860        // chunk. We replicate that — a trailing `_` (e.g. `http://a_`) is not
4861        // counted, so the link still forms.
4862        let account = offset != 0 && chars.peek().is_some();
4863        match char {
4864            '\\' => {
4865                // Escape: consume the next char as a literal host char.
4866                chars.next();
4867            }
4868            '_' if account => uscore2 += 1,
4869            '.' if account => {
4870                uscore1 = uscore2;
4871                uscore2 = 0;
4872                np += 1;
4873                if np > 10 {
4874                    return true;
4875                }
4876            }
4877            '_' | '.' | '-' => {}
4878            _ => {
4879                if !is_valid_hostchar(source_char(char)) {
4880                    break;
4881                }
4882            }
4883        }
4884    }
4885
4886    if (uscore1 > 0 || uscore2 > 0) && np <= 10 {
4887        return false;
4888    }
4889
4890    allow_short || np > 0
4891}
4892
4893// Forward scan from `start` for the URL extent: Unicode whitespace, `<`, a
4894// non-ASCII char in CommonMark's Unicode punctuation set (full-width `,` or
4895// `。`) other than the replacement char, `[[`, or `]` ends the URL. CommonMark allows
4896// `>` and `[` inside (the renderer percent-encodes them); a `]` is
4897// additionally treated as a hard URL boundary (autolink-3), so a `]` ends the
4898// scan and is never part of the link. The trailing trim may reach `start`.
4899fn autolink_url_end(input: &str, start: usize) -> usize {
4900    let bytes = input.as_bytes();
4901    let mut end = start;
4902    // The extent stops at the first `]` outside backticks unless a `[` came
4903    // before it in the URL (no balancing).
4904    let mut strict_has_open_bracket = false;
4905    let mut strict_inside_backticks = false;
4906    for (offset, char) in input[start..].char_indices() {
4907        if char.is_whitespace()
4908            || char == '<'
4909            || is_autolink_terminating_control(char)
4910            || (!char.is_ascii()
4911                && char != '\u{FFFD}'
4912                && crate::unicode_punctuation::is_unicode_punctuation(char))
4913            || input[start + offset..].starts_with("[[")
4914        {
4915            break;
4916        }
4917        match char {
4918            '[' => strict_has_open_bracket = true,
4919            '`' => strict_inside_backticks = !strict_inside_backticks,
4920            ']' if !strict_has_open_bracket && !strict_inside_backticks => break,
4921            _ => {}
4922        }
4923        // A `\` before ASCII punctuation other than `.` ends the URL. The
4924        // serializer writes text after a literal autolink with backslash
4925        // escapes (`\*`, `\_`, `\[`, …); stopping here keeps such a tree
4926        // writable. cmark-gfm keeps the `\*x` in `www.a.com\*x` inside the
4927        // URL; this crate deliberately diverges. A `\` before `.` or a
4928        // non-punctuation char stays part of the URL (`www.x.com/a\.`).
4929        if char == '\\' {
4930            if let Some(&next) = bytes.get(start + offset + 1) {
4931                if next.is_ascii_punctuation() && next != b'.' {
4932                    break;
4933                }
4934            }
4935        }
4936        end = start + offset + char.len_utf8();
4937    }
4938    autolink_delim(input, start, end)
4939}
4940
4941fn is_autolink_terminating_control(char: char) -> bool {
4942    matches!(char, '\u{2066}'..='\u{2069}')
4943}
4944
4945// Port of cmark-gfm `autolink_delim`: trim trailing delimiters from the end of
4946// the URL. A trailing `) ? ! . , : * _ ~ ' "` is trimmed; `)` only when there
4947// are more `)` than `(` in the link; a trailing `&` and alphanumerics ending
4948// in `;` is excluded whole; any other trailing `;` is trimmed alone.
4949fn autolink_delim(input: &str, start: usize, mut end: usize) -> usize {
4950    let bytes = input.as_bytes();
4951    let mut opening = 0usize;
4952    let mut closing = 0usize;
4953    for &byte in &bytes[start..end] {
4954        match byte {
4955            b'(' => opening += 1,
4956            b')' => closing += 1,
4957            _ => {}
4958        }
4959    }
4960
4961    while end > start {
4962        match bytes[end - 1] {
4963            b')' => {
4964                if closing <= opening {
4965                    break;
4966                }
4967                closing -= 1;
4968                end -= 1;
4969            }
4970            b'?' | b'!' | b'.' | b',' | b':' | b'*' | b'_' | b'~' | b'\'' | b'"' => {
4971                end -= 1;
4972            }
4973            b';' => {
4974                // Walk back over alphanumerics; if they reach a `&`, exclude
4975                // the whole `&…;` entity run, otherwise trim just the `;`.
4976                let mut new_end = end - 1;
4977                while new_end > start && bytes[new_end - 1].is_ascii_alphanumeric() {
4978                    new_end -= 1;
4979                }
4980                if new_end > start && new_end < end - 1 && bytes[new_end - 1] == b'&' {
4981                    end = new_end - 1;
4982                } else {
4983                    end -= 1;
4984                }
4985            }
4986            _ => break,
4987        }
4988    }
4989    end
4990}
4991
4992/// Scan state shared by the `parse_literal_autolink` calls of one inline pass.
4993/// The pass only moves forward, so each piece lets a later start position reuse
4994/// the bytes already walked for an earlier one instead of rescanning them.
4995#[derive(Default)]
4996struct LiteralAutolinkScan {
4997    /// Runs of email local-part bytes (plus `:` for `mailto:`/`xmpp:`).
4998    email_local: ByteRun,
4999    /// What every start before one `@` shares: its last `:` and its domain.
5000    email_at: EmailAt,
5001    /// The text before a start, for the `+` boundary check.
5002    email_prefix: EmailPrefix,
5003}
5004
5005/// The facts of one `@` that every email start before it reads: the last `:`
5006/// in its local-part run at or after `from`, and the domain end for the plain
5007/// and the `xmpp:` form. A query starting before `from` rescans the run.
5008#[derive(Default)]
5009struct EmailAt {
5010    at: Option<usize>,
5011    from: usize,
5012    last_colon: Option<usize>,
5013    domain_end: [Option<Option<usize>>; 2],
5014}
5015
5016impl EmailAt {
5017    fn update(&mut self, input: &str, index: usize, at: usize) {
5018        if self.at != Some(at) {
5019            *self = Self {
5020                at: Some(at),
5021                from: usize::MAX,
5022                ..Self::default()
5023            };
5024        }
5025        if index < self.from {
5026            self.last_colon = input[index..at].rfind(':').map(|offset| index + offset);
5027            self.from = index;
5028        }
5029    }
5030
5031    /// The end of the domain after the `@`, trimmed and validated, or `None`.
5032    fn domain_end(&mut self, input: &str, is_xmpp: bool) -> Option<usize> {
5033        let at = self.at.expect("updated before use");
5034        *self.domain_end[usize::from(is_xmpp)].get_or_insert_with(|| {
5035            let start = at + 1;
5036            let end = literal_email_domain_end(input, start, is_xmpp)?;
5037            let trimmed = autolink_delim(input, start, end);
5038            (trimmed > start && is_gfm_email_domain(&input[start..trimmed], is_xmpp))
5039                .then_some(trimmed)
5040        })
5041    }
5042}
5043
5044/// Whether the text before a position ends in a bare email, kept as a pass
5045/// walks forward: the start of the text after the last whitespace, the first
5046/// byte there that no local part holds, the last `@`, and after it the first
5047/// byte no domain holds, the first `.`, and the first `.` opening an empty
5048/// label. A query before the walked position walks again from the start.
5049#[derive(Default)]
5050struct EmailPrefix {
5051    cursor: usize,
5052    segment: usize,
5053    non_local: Option<usize>,
5054    at: Option<usize>,
5055    domain_bad: Option<usize>,
5056    domain_dot: Option<usize>,
5057    domain_empty_label: Option<usize>,
5058}
5059
5060impl EmailPrefix {
5061    fn advance(&mut self, input: &str, end: usize) {
5062        if end < self.cursor {
5063            *self = Self::default();
5064        }
5065        let bytes = input.as_bytes();
5066        let from = self.cursor;
5067        for (offset, char) in input[from..end].char_indices() {
5068            let position = from + offset;
5069            if char.is_whitespace() {
5070                *self = Self {
5071                    segment: position + char.len_utf8(),
5072                    ..Self::default()
5073                };
5074                continue;
5075            }
5076            let byte = char.is_ascii().then_some(char as u8);
5077            if self.non_local.is_none() && !byte.is_some_and(is_gfm_email_local_byte) {
5078                self.non_local = Some(position);
5079            }
5080            if char == '@' {
5081                self.at = Some(position);
5082                self.domain_bad = None;
5083                self.domain_dot = None;
5084                self.domain_empty_label = None;
5085                continue;
5086            }
5087            let Some(at) = self.at else {
5088                continue;
5089            };
5090            match byte {
5091                Some(b'.') => {
5092                    self.domain_dot.get_or_insert(position);
5093                    if position == at + 1 || bytes[position - 1] == b'.' {
5094                        self.domain_empty_label.get_or_insert(position);
5095                    }
5096                }
5097                Some(byte) if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') => {}
5098                _ => {
5099                    self.domain_bad.get_or_insert(position);
5100                }
5101            }
5102        }
5103        self.cursor = end;
5104    }
5105
5106    /// Whether the text from the last whitespace before `end` to `end` ends
5107    /// in a bare email: `prefix_ends_with_gfm_email`, walked once per pass.
5108    fn ends_with_email(&mut self, input: &str, end: usize) -> bool {
5109        self.advance(input, end);
5110        let Some(at) = self.at else {
5111            return false;
5112        };
5113        let before = |position: Option<usize>| position.is_some_and(|position| position < end);
5114        at > self.segment
5115            && !self.non_local.is_some_and(|position| position < at)
5116            && end > at + 1
5117            && !before(self.domain_bad)
5118            && before(self.domain_dot)
5119            && !before(self.domain_empty_label)
5120            && !matches!(input.as_bytes()[end - 1], b'.' | b'-' | b'_')
5121    }
5122}
5123
5124/// The extent of one run of bytes accepted by a fixed predicate: every start
5125/// position in `start..=end` stops at `end`. Each `ByteRun` is always queried
5126/// with the same predicate.
5127#[derive(Default)]
5128struct ByteRun {
5129    start: usize,
5130    end: Option<usize>,
5131}
5132
5133impl ByteRun {
5134    /// Returns the first byte position at or after `index` that `in_run`
5135    /// rejects, or the input length.
5136    fn end(&mut self, input: &str, index: usize, in_run: fn(u8) -> bool) -> usize {
5137        if let Some(end) = self.end {
5138            if self.start <= index && index <= end {
5139                return end;
5140            }
5141        }
5142        let end = input.as_bytes()[index..]
5143            .iter()
5144            .position(|byte| !in_run(*byte))
5145            .map_or(input.len(), |offset| index + offset);
5146        self.start = index;
5147        self.end = Some(end);
5148        end
5149    }
5150}
5151
5152fn is_email_local_or_scheme_byte(byte: u8) -> bool {
5153    is_gfm_email_local_byte(byte) || byte == b':'
5154}
5155
5156/// A GFM bare-email literal (and the extended `mailto:`/`xmpp:` protocol
5157/// forms) starting at `index`, which must be the link start: cmark anchors
5158/// the email at the left edge found by rewinding from `@` over
5159/// `[A-Za-z0-9._+-]` (or a `mailto:`/`xmpp:` scheme), so this only succeeds
5160/// when the char before `index` is not part of that left extent.
5161fn parse_literal_email(
5162    input: &str,
5163    index: usize,
5164    scan: &mut LiteralAutolinkScan,
5165) -> Option<(usize, &'static str)> {
5166    // A valid local part holds only local-part bytes behind an optional
5167    // `mailto:`/`xmpp:` scheme, so the `@` must be the byte that ends that run.
5168    let at_index = scan
5169        .email_local
5170        .end(input, index, is_email_local_or_scheme_byte);
5171    if input.as_bytes().get(at_index) != Some(&b'@') || at_index == index {
5172        return None;
5173    }
5174
5175    // Determine whether this `@` is preceded by an extended protocol scheme
5176    // (`mailto:` / `xmpp:`), which both relaxes the href synthesis and (xmpp)
5177    // allows `/` in the domain.
5178    let (auto_mailto, is_xmpp) = classify_email_local(&input[index..at_index]);
5179
5180    // Left-boundary guard (autolink-1): the char before `index` must not be a
5181    // local-part continuation char, otherwise the true link starts earlier and
5182    // this position is interior. After a recognized scheme, the scheme's own
5183    // preceding-char rule is what matters.
5184    if !email_left_boundary_ok(input, index, auto_mailto, |end| {
5185        scan.email_prefix.ends_with_email(input, end)
5186    }) {
5187        return None;
5188    }
5189
5190    // The run holds only local-part bytes and `:`, so the local part after
5191    // its scheme is valid when it is not empty and holds no `:`. The facts of
5192    // the `@` are shared by every start before it, so a run of starts reads
5193    // them once.
5194    let body_start = if auto_mailto {
5195        index
5196    } else if is_xmpp {
5197        index + "xmpp:".len()
5198    } else {
5199        index + "mailto:".len()
5200    };
5201    scan.email_at.update(input, index, at_index);
5202    if body_start >= at_index
5203        || scan
5204            .email_at
5205            .last_colon
5206            .is_some_and(|colon| colon >= body_start)
5207    {
5208        return None;
5209    }
5210    let end = scan.email_at.domain_end(input, is_xmpp)?;
5211    Some((end, if auto_mailto { "mailto:" } else { "" }))
5212}
5213
5214// Classify the local part for the extended-protocol forms. Returns
5215// `(auto_mailto, is_xmpp)`: `mailto:user` → (false, false); `xmpp:user` →
5216// (false, true); a bare local part → (true, false). The scheme match is
5217// case-insensitive.
5218fn classify_email_local(local: &str) -> (bool, bool) {
5219    if let Some(rest) = strip_ci_prefix(local, "mailto:") {
5220        if !rest.is_empty() {
5221            return (false, false);
5222        }
5223    }
5224    if let Some(rest) = strip_ci_prefix(local, "xmpp:") {
5225        if !rest.is_empty() {
5226            return (false, true);
5227        }
5228    }
5229    (true, false)
5230}
5231
5232fn strip_ci_prefix<'a>(input: &'a str, prefix: &str) -> Option<&'a str> {
5233    let bytes = input.as_bytes();
5234    let plen = prefix.len();
5235    if bytes.len() >= plen && bytes[..plen].eq_ignore_ascii_case(prefix.as_bytes()) {
5236        Some(&input[plen..])
5237    } else {
5238        None
5239    }
5240}
5241
5242// The left-boundary check for an email literal. The link is anchored at its
5243// true left edge: the preceding char must not be an ASCII alphanumeric (which
5244// would extend the local part leftward). For the bare form, a preceding `/` is
5245// also rejected (`/a@b.c` is not linked), while the extended
5246// `mailto:`/`xmpp:` form permits `/` before the scheme (so
5247// `…/mailto:beedrill@…` links).
5248fn email_left_boundary_ok(
5249    input: &str,
5250    index: usize,
5251    auto_mailto: bool,
5252    prefix_ends_with_email: impl FnOnce(usize) -> bool,
5253) -> bool {
5254    if index == 0 {
5255        return true;
5256    }
5257    let Some(previous) = input[..index].chars().next_back() else {
5258        return true;
5259    };
5260    if previous.is_ascii_alphanumeric() {
5261        if auto_mailto && input[index..].starts_with('+') && prefix_ends_with_email(index) {
5262            return true;
5263        }
5264        return false;
5265    }
5266    if auto_mailto && previous == '/' {
5267        return false;
5268    }
5269    true
5270}
5271
5272#[cfg(test)]
5273fn prefix_ends_with_gfm_email(input: &str, end: usize) -> bool {
5274    let start = input[..end]
5275        .char_indices()
5276        .rev()
5277        .find(|(_, char)| char.is_whitespace())
5278        .map_or(0, |(offset, char)| offset + char.len_utf8());
5279    let candidate = &input[start..end];
5280    let Some(at) = candidate.rfind('@') else {
5281        return false;
5282    };
5283    email_local_is_valid(&candidate[..at], true) && is_gfm_email_domain(&candidate[at + 1..], false)
5284}
5285
5286// Validate the email local part. For the bare form, every char must be a GFM
5287// email atext byte (`[A-Za-z0-9.+_-]` plus the dot-separated structure). For
5288// the extended-protocol forms, the part after the scheme is validated.
5289#[cfg(test)]
5290fn email_local_is_valid(local: &str, auto_mailto: bool) -> bool {
5291    let body = if auto_mailto {
5292        local
5293    } else if let Some(rest) = strip_ci_prefix(local, "mailto:") {
5294        rest
5295    } else if let Some(rest) = strip_ci_prefix(local, "xmpp:") {
5296        rest
5297    } else {
5298        local
5299    };
5300    !body.is_empty() && body.bytes().all(is_gfm_email_local_byte)
5301}
5302
5303// GFM email local-part charset (autolink-1): a narrower set than RFC atext,
5304// matching cmark's rewind class `[A-Za-z0-9.+_-]`.
5305fn is_gfm_email_local_byte(byte: u8) -> bool {
5306    byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'_' | b'-')
5307}
5308
5309fn is_email_local_part(input: &str) -> bool {
5310    !input.is_empty()
5311        && input
5312            .split('.')
5313            .all(|segment| !segment.is_empty() && segment.bytes().all(is_email_atext))
5314}
5315
5316fn is_email_atext(byte: u8) -> bool {
5317    byte.is_ascii_alphanumeric()
5318        || matches!(
5319            byte,
5320            b'!' | b'#'
5321                | b'$'
5322                | b'%'
5323                | b'&'
5324                | b'\''
5325                | b'*'
5326                | b'+'
5327                | b'/'
5328                | b'='
5329                | b'?'
5330                | b'^'
5331                | b'_'
5332                | b'`'
5333                | b'{'
5334                | b'|'
5335                | b'}'
5336                | b'~'
5337                | b'-'
5338        )
5339}
5340
5341// Port of cmark-gfm's email-domain scan (`postprocess_text`). Scans forward
5342// from `index` over the email domain, accepting alphanumerics, `-`, `_`, and
5343// `.`; for the `xmpp:` form a `/` is also accepted (path). A dot only counts
5344// toward the "at least one dot" requirement when it is followed by an
5345// alphanumeric. The scanned span must be >= 1 byte, contain at least one such
5346// dot, and end in an alphabetic char or a dot. Returns the domain end offset
5347// (before trailing trim), or `None` when invalid.
5348fn literal_email_domain_end(input: &str, index: usize, is_xmpp: bool) -> Option<usize> {
5349    let bytes = input.as_bytes();
5350    let mut end = index;
5351    let mut np = 0usize;
5352    while end < bytes.len() {
5353        let byte = bytes[end];
5354        if byte.is_ascii_alphanumeric() {
5355            end += 1;
5356        } else if byte == b'.' && end + 1 < bytes.len() && bytes[end + 1].is_ascii_alphanumeric() {
5357            np += 1;
5358            end += 1;
5359        } else if byte == b'-' || byte == b'_' || (byte == b'/' && is_xmpp) {
5360            // `-`/`_` always continue the domain; `/` continues only the xmpp
5361            // path form.
5362            end += 1;
5363        } else {
5364            break;
5365        }
5366    }
5367    if end <= index {
5368        return None;
5369    }
5370    let len = end - index;
5371    let last = bytes[end - 1];
5372    if len < 1 || np == 0 || !(last.is_ascii_alphabetic() || last == b'.') {
5373        return None;
5374    }
5375    Some(end)
5376}
5377
5378// Final structural validation of the trimmed email domain. The cmark scan
5379// already enforced the dot/last-char rules; this re-checks them after the
5380// shared trailing trim removed any delimiters, and rejects a domain ending in
5381// `-`/`_` (autolink-7: a hyphen in the final label disqualifies the link).
5382fn is_gfm_email_domain(input: &str, is_xmpp: bool) -> bool {
5383    if input.is_empty() {
5384        return false;
5385    }
5386    // A `/` path is only legal in the `xmpp:` form; split it off for the host
5387    // structural checks.
5388    let host = if is_xmpp {
5389        input.split('/').next().unwrap_or(input)
5390    } else {
5391        input
5392    };
5393    if !host.contains('.') {
5394        return false;
5395    }
5396    let last = host.as_bytes()[host.len() - 1];
5397    // The final label must not end in `-` or `_`, and the trailing label may
5398    // not be all ASCII digits.
5399    if matches!(last, b'-' | b'_') {
5400        return false;
5401    }
5402    host.split('.').all(|label| {
5403        !label.is_empty()
5404            && label
5405                .bytes()
5406                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
5407    })
5408}
5409
5410fn is_email_domain(input: &str, min_labels: usize) -> bool {
5411    let mut label_count = 0usize;
5412    for label in input.split('.') {
5413        label_count += 1;
5414        let bytes = label.as_bytes();
5415        if bytes.is_empty()
5416            || bytes.len() > 63
5417            || !bytes
5418                .first()
5419                .is_some_and(|byte| byte.is_ascii_alphanumeric())
5420            || !bytes
5421                .last()
5422                .is_some_and(|byte| byte.is_ascii_alphanumeric())
5423            || !bytes
5424                .iter()
5425                .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
5426        {
5427            return false;
5428        }
5429    }
5430    label_count >= min_labels
5431}
5432
5433/// A footnote label: no space, tab, or line ending and, as in a link label,
5434/// no unescaped bracket.
5435pub(crate) fn is_footnote_label(label: &str) -> bool {
5436    !label.is_empty()
5437        && reference_label_is_within_limit(label)
5438        && !label.contains([' ', '\t', '\n', '\r'])
5439        && !label
5440            .match_indices(['[', ']'])
5441            .any(|(index, _)| !is_escaped_at(label, index))
5442}
5443
5444fn find_footnote_definition_label_end(input: &str) -> Option<usize> {
5445    let close = find_footnote_reference_label_end(input, 2)?;
5446    if input.as_bytes().get(close + 1) == Some(&b':') {
5447        Some(close)
5448    } else {
5449        None
5450    }
5451}
5452
5453fn find_footnote_reference_label_end(input: &str, mut cursor: usize) -> Option<usize> {
5454    while cursor < input.len() {
5455        let (next, char) = next_char(input, cursor)?;
5456        if char == ']' && !is_escaped_at(input, cursor) {
5457            return Some(cursor);
5458        }
5459        cursor = next;
5460    }
5461    None
5462}