Skip to main content

markdown_syntax/
serialize.rs

1//! AST to canonical Markdown. The verbs live on [`Document`]
2//! ([`to_markdown`](Document::to_markdown) /
3//! [`to_markdown_with`](Document::to_markdown_with)); [`SerializeOptions`] tunes
4//! the output style. The document is validated first, so serialization can fail
5//! with a [`SerializeError`].
6
7use alloc::{
8    format,
9    string::{String, ToString},
10    vec::Vec,
11};
12
13use crate::{
14    ast::*,
15    diagnostic::Diagnostic,
16    parse::{gfm_table_can_start_source, line_starts_html_block},
17    validate::validate_document,
18};
19
20/// The newline style emitted by the serializer.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum LineEnding {
23    /// Unix `\n`.
24    Lf,
25    /// Windows `\r\n`.
26    CrLf,
27}
28
29impl LineEnding {
30    fn as_str(self) -> &'static str {
31        match self {
32            Self::Lf => "\n",
33            Self::CrLf => "\r\n",
34        }
35    }
36}
37
38/// Output-style options for serialization. Defaults: LF, trailing newline, `-`
39/// bullets, `.` ordered markers, and backtick code fences.
40#[derive(Clone, Debug, Eq, PartialEq)]
41#[non_exhaustive]
42pub struct SerializeOptions {
43    /// Newline style to emit.
44    pub line_ending: LineEnding,
45    /// Whether to end the output with a trailing newline.
46    pub final_newline: bool,
47    /// The bullet marker for unordered lists.
48    pub bullet: ListDelimiter,
49    /// The delimiter for ordered-list markers (e.g. `.` → `1.`).
50    pub ordered_delimiter: ListDelimiter,
51    /// The fence character for fenced code blocks.
52    pub fence_marker: FenceMarker,
53}
54
55impl Default for SerializeOptions {
56    fn default() -> Self {
57        Self {
58            line_ending: LineEnding::Lf,
59            final_newline: true,
60            bullet: ListDelimiter::Dash,
61            ordered_delimiter: ListDelimiter::Period,
62            fence_marker: FenceMarker::Backtick,
63        }
64    }
65}
66
67/// Why serialization failed.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub enum SerializeError {
70    /// The AST failed validation; carries the validation diagnostics.
71    InvalidDocument(Vec<Diagnostic>),
72    /// A node kind that the serializer does not support was encountered.
73    UnsupportedNode(&'static str),
74}
75
76impl Document {
77    /// Serialize this document to canonical Markdown with default options.
78    pub fn to_markdown(&self) -> Result<String, SerializeError> {
79        self.to_markdown_with(&SerializeOptions::default())
80    }
81
82    /// Serialize this document to canonical Markdown with explicit options.
83    pub fn to_markdown_with(&self, options: &SerializeOptions) -> Result<String, SerializeError> {
84        let diagnostics = validate_document(self);
85        if !diagnostics.is_empty() {
86            return Err(SerializeError::InvalidDocument(diagnostics));
87        }
88
89        serialize_document_body(self, options)
90    }
91}
92
93fn serialize_document_body(
94    document: &Document,
95    options: &SerializeOptions,
96) -> Result<String, SerializeError> {
97    let mut output = serialize_blocks_at_start(&document.children, options, true)?;
98    if options.line_ending == LineEnding::CrLf {
99        output = output.replace('\n', "\r\n");
100    }
101    if options.final_newline
102        && !output.is_empty()
103        && !output.ends_with(options.line_ending.as_str())
104    {
105        output.push_str(options.line_ending.as_str());
106    }
107    Ok(output)
108}
109
110/// Serialize a block sequence. `document_start` is true only for the top-level
111/// document body, where the first block sits at byte 0 and a contiguous `---`
112/// would open frontmatter; that one position emits a spaced dash thematic break
113/// instead. Nested sequences (blockquotes, list items, ...) are never at byte 0.
114fn serialize_blocks_at_start(
115    blocks: &[Block],
116    options: &SerializeOptions,
117    document_start: bool,
118) -> Result<String, SerializeError> {
119    let mut output = String::new();
120    for (index, block) in blocks.iter().enumerate() {
121        if index > 0 {
122            output.push_str("\n\n");
123        }
124        let at_document_start = document_start && index == 0;
125        if let (
126            Block::List(list),
127            Some(Block::CodeBlock(CodeBlock {
128                kind: CodeBlockKind::Indented,
129                ..
130            })),
131        ) = (block, blocks.get(index + 1))
132        {
133            output.push_str(&serialize_list_with_marker_spacing(
134                list, options, " ", "    ",
135            )?);
136        } else {
137            output.push_str(&serialize_block(block, options, at_document_start)?);
138        }
139    }
140    Ok(output)
141}
142
143fn serialize_block(
144    block: &Block,
145    options: &SerializeOptions,
146    at_document_start: bool,
147) -> Result<String, SerializeError> {
148    match block {
149        Block::Paragraph(node) => serialize_paragraph(node, options),
150        Block::Heading(node) => {
151            let content = serialize_inlines(&node.children, options)?;
152            // A setext underline can only express depth 1 (`=`) or 2 (`-`); any
153            // other depth must fall back to ATX, otherwise the depth is lost.
154            // Multi-line content stays setext because ATX is single-line and
155            // would split a heading the parser legitimately produces.
156            let setext_representable = matches!(node.depth, 1 | 2);
157            Ok(match node.kind {
158                HeadingKind::Setext if setext_representable => {
159                    let marker = if node.depth == 1 { '=' } else { '-' };
160                    format!(
161                        "{}\n{}",
162                        content,
163                        marker.to_string().repeat(content.len().max(3))
164                    )
165                }
166                _ if content.is_empty() => "#".repeat(node.depth as usize),
167                _ => format!(
168                    "{} {}",
169                    "#".repeat(node.depth as usize),
170                    escape_atx_heading_content(&content)
171                ),
172            })
173        }
174        Block::ThematicBreak(node) => Ok(match node.marker {
175            // A Dash break is normally written contiguous (`---`) — the form
176            // that survives after a `-` bullet list, where the spaced `- - -`
177            // would be re-read as nested list items. The one exception is the
178            // document start, where a contiguous `---` opens frontmatter, so the
179            // spaced form (which is not a frontmatter fence) is used there.
180            ThematicBreakMarker::Dash if at_document_start => "- - -".into(),
181            ThematicBreakMarker::Dash => "---".into(),
182            ThematicBreakMarker::Asterisk => "***".into(),
183            ThematicBreakMarker::Underscore => "___".into(),
184        }),
185        Block::BlockQuote(node) => {
186            let inner = serialize_blocks_at_start(&node.children, options, false)?;
187            if inner.is_empty() {
188                Ok(">".into())
189            } else {
190                Ok(prefix_lines(&inner, "> "))
191            }
192        }
193        Block::Alert(node) => serialize_alert(node, options),
194        Block::List(node) => serialize_list(node, options),
195        Block::DescriptionList(node) => serialize_description_list(node, options),
196        Block::CodeBlock(node) => serialize_code_block(node, options),
197        Block::HtmlBlock(node) => Ok(trim_trailing_newline(&node.value).into()),
198        Block::Definition(node) => {
199            let destination = serialize_destination_kind(
200                &node.destination,
201                node.destination_kind,
202                InlineSerializeContext::default(),
203            );
204            let mut label = if node.meta.span.is_some() {
205                escape_definition_label_source(&node.label)
206            } else {
207                escape_reference_label_with_pipe(&node.label, false)
208            };
209            if node.meta.span.is_none() && label.starts_with('^') {
210                label.insert(0, '\\');
211            }
212            let mut output = format!("[{}]: {}", label, destination);
213            if let (Some(title), Some(title_kind)) = (&node.title, node.title_kind) {
214                output.push(' ');
215                output.push_str(&serialize_title_kind(
216                    title,
217                    title_kind,
218                    InlineSerializeContext::default(),
219                ));
220            }
221            Ok(output)
222        }
223        Block::FootnoteDefinition(node) => {
224            let inner = serialize_blocks_at_start(&node.children, options, false)?;
225            let label = if node.meta.span.is_some() {
226                escape_footnote_label_source(&node.label)
227            } else {
228                escape_footnote_label_semantic(&node.label)
229            };
230            Ok(format!("[^{}]: {}", label, indent_continuation(&inner)))
231        }
232        Block::Table(node) => serialize_table(node, options),
233        Block::MathBlock(node) => {
234            let fence = block_math_fence(&node.value);
235            Ok(format!(
236                "{fence}\n{}\n{fence}",
237                trim_trailing_newline(&node.value)
238            ))
239        }
240        Block::Frontmatter(node) => {
241            let fence = match node.kind {
242                FrontmatterKind::Yaml => "---",
243                FrontmatterKind::Toml => "+++",
244            };
245            Ok(format!(
246                "{fence}\n{}\n{fence}",
247                trim_trailing_newline(&node.value)
248            ))
249        }
250        Block::MdxEsm(node) => Ok(node.value.clone()),
251        Block::MdxExpression(node) => Ok(format!("{{{}}}", node.value)),
252        Block::MdxJsx(node) => Ok(node.value.clone()),
253        Block::LeafDirective(node) => Ok(format!(
254            "::{}{}{}",
255            node.name,
256            serialize_directive_label(&node.label, options)?,
257            serialize_attributes(&node.attributes)
258        )),
259        Block::ContainerDirective(node) => {
260            let inner = serialize_blocks_at_start(&node.children, options, false)?;
261            let fence = directive_fence(&inner);
262            Ok(format!(
263                "{fence}{}{}{}\n{}\n{fence}",
264                node.name,
265                serialize_directive_label(&node.label, options)?,
266                serialize_attributes(&node.attributes),
267                inner
268            ))
269        }
270    }
271}
272
273/// Escape a trailing `#`-run in ATX heading content so it is not consumed as a
274/// closing hash sequence. CommonMark treats a final run of `#` preceded by
275/// whitespace (after trailing whitespace is trimmed) as the optional closing
276/// sequence; escaping the first `#` of that run keeps it as literal text.
277fn escape_atx_heading_content(content: &str) -> String {
278    let trimmed_len = content.trim_end_matches([' ', '\t']).len();
279    let trimmed = &content[..trimmed_len];
280    let hash_start = trimmed.trim_end_matches('#').len();
281    let preceded_by_whitespace = trimmed[..hash_start]
282        .chars()
283        .next_back()
284        .is_some_and(|char| char == ' ' || char == '\t');
285    if hash_start == trimmed_len || !preceded_by_whitespace {
286        return content.into();
287    }
288    let mut output = String::with_capacity(content.len() + 1);
289    output.push_str(&content[..hash_start]);
290    output.push('\\');
291    output.push_str(&content[hash_start..]);
292    output
293}
294
295fn serialize_paragraph(
296    node: &Paragraph,
297    options: &SerializeOptions,
298) -> Result<String, SerializeError> {
299    let mut output = serialize_inlines(&node.children, options)?;
300    if let Some(offset) = paragraph_html_block_escape_offset(&output) {
301        output.insert(offset, '\\');
302    }
303    if let Some(offset) = paragraph_table_escape_offset(&output) {
304        output.insert(offset, '\\');
305    }
306    Ok(output)
307}
308
309fn paragraph_html_block_escape_offset(input: &str) -> Option<usize> {
310    let first_line = input.split('\n').next().unwrap_or(input);
311    if !line_starts_html_block(first_line) {
312        return None;
313    }
314
315    Some(
316        first_line
317            .as_bytes()
318            .iter()
319            .take_while(|byte| **byte == b' ')
320            .count(),
321    )
322}
323
324fn paragraph_table_escape_offset(input: &str) -> Option<usize> {
325    let first_line_end = input.find('\n')?;
326    let first_line = &input[..first_line_end];
327    let second_line_start = first_line_end + 1;
328    let second_line_end = input[second_line_start..]
329        .find('\n')
330        .map(|offset| second_line_start + offset)
331        .unwrap_or(input.len());
332    let second_line = &input[second_line_start..second_line_end];
333
334    if !gfm_table_can_start_source(first_line, second_line) {
335        return None;
336    }
337
338    second_line
339        .find('-')
340        .map(|offset| second_line_start + offset)
341}
342
343fn serialize_alert(node: &Alert, options: &SerializeOptions) -> Result<String, SerializeError> {
344    let mut output = String::from("> [!");
345    output.push_str(alert_kind_name(node.kind));
346    output.push(']');
347    if let Some(title) = &node.title {
348        if !title.is_empty() {
349            output.push(' ');
350            output.push_str(&escape_alert_title(title));
351        }
352    }
353    let inner = serialize_blocks_at_start(&node.children, options, false)?;
354    if !inner.is_empty() {
355        output.push('\n');
356        output.push_str(&prefix_lines(&inner, "> "));
357    }
358    Ok(output)
359}
360
361fn alert_kind_name(kind: AlertKind) -> &'static str {
362    match kind {
363        AlertKind::Note => "NOTE",
364        AlertKind::Tip => "TIP",
365        AlertKind::Important => "IMPORTANT",
366        AlertKind::Warning => "WARNING",
367        AlertKind::Caution => "CAUTION",
368    }
369}
370
371fn escape_alert_title(input: &str) -> String {
372    let mut output = String::new();
373    for char in input.chars() {
374        match char {
375            '\n' | '\r' => output.push(' '),
376            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
377            _ => output.push(char),
378        }
379    }
380    output
381}
382
383fn serialize_list(node: &List, options: &SerializeOptions) -> Result<String, SerializeError> {
384    serialize_list_with_marker_spacing(node, options, "", " ")
385}
386
387fn serialize_list_with_marker_spacing(
388    node: &List,
389    options: &SerializeOptions,
390    marker_prefix: &str,
391    marker_padding: &str,
392) -> Result<String, SerializeError> {
393    let mut output = String::new();
394    for (index, item) in node.children.iter().enumerate() {
395        if index > 0 {
396            if node.tight {
397                output.push('\n');
398            } else {
399                output.push_str("\n\n");
400            }
401        }
402        let list_delimiter = if node.ordered {
403            if options.ordered_delimiter == SerializeOptions::default().ordered_delimiter {
404                node.delimiter
405            } else {
406                options.ordered_delimiter
407            }
408        } else if options.bullet == SerializeOptions::default().bullet {
409            node.delimiter
410        } else {
411            options.bullet
412        };
413        let marker = if node.ordered {
414            let start = node.start.unwrap_or(1).saturating_add(index as u64);
415            let delimiter = ordered_list_marker(list_delimiter);
416            format!("{marker_prefix}{start}{delimiter}{marker_padding}")
417        } else {
418            format!(
419                "{marker_prefix}{}{marker_padding}",
420                unordered_list_marker(list_delimiter)
421            )
422        };
423        let mut inner = serialize_item_blocks(&item.children, options, node.tight)?;
424        if !node.ordered && unordered_list_marker(list_delimiter) == '*' {
425            inner = disambiguate_asterisk_list_item(inner);
426        }
427        if let Some(checked) = item.checked {
428            if let Some(rest) = inner.strip_prefix("- ") {
429                inner = rest.into();
430            }
431            let checkbox = if checked { "[x] " } else { "[ ] " };
432            inner = format!("{checkbox}{inner}");
433        }
434        if !node.tight
435            && node.children.len() == 1
436            && matches!(item.children.as_slice(), [Block::Paragraph(_)])
437            && !inner.is_empty()
438        {
439            output.push_str(marker.trim_end());
440            output.push_str("\n\n");
441            output.push_str(&prefix_lines(&inner, &" ".repeat(marker.len())));
442            continue;
443        }
444        output.push_str(&marker);
445        output.push_str(&indent_after_first_line(&inner, marker.len()));
446    }
447    Ok(output)
448}
449
450fn disambiguate_asterisk_list_item(inner: String) -> String {
451    let first_line_end = inner.find('\n').unwrap_or(inner.len());
452    let first_line = &inner[..first_line_end];
453    if !asterisk_bullet_first_line_is_thematic_break(first_line) {
454        return inner;
455    }
456    let mut output = String::from("---");
457    output.push_str(&inner[first_line_end..]);
458    output
459}
460
461/// Whether a `*`-bullet item's first content line, once prefixed by the `* `
462/// marker, would escape the list as an asterisk thematic break. This is the
463/// rendering of a `ThematicBreak` child: a contiguous run of asterisks (`***`,
464/// rendered with no internal whitespace). A line with interior spaces such as
465/// `* *` is a genuine nested bullet and must be left alone, since `* * *`
466/// re-parses back into the nested list it came from.
467fn asterisk_bullet_first_line_is_thematic_break(first_line: &str) -> bool {
468    first_line.len() >= 2 && first_line.bytes().all(|byte| byte == b'*')
469}
470
471fn serialize_item_blocks(
472    blocks: &[Block],
473    options: &SerializeOptions,
474    tight: bool,
475) -> Result<String, SerializeError> {
476    let mut output = String::new();
477    for (index, block) in blocks.iter().enumerate() {
478        if index > 0 {
479            if tight {
480                output.push('\n');
481            } else {
482                output.push_str("\n\n");
483            }
484        }
485        output.push_str(&serialize_block(block, options, false)?);
486    }
487    Ok(output)
488}
489
490fn serialize_description_list(
491    node: &DescriptionList,
492    options: &SerializeOptions,
493) -> Result<String, SerializeError> {
494    let mut output = String::new();
495    for (item_index, item) in node.children.iter().enumerate() {
496        if item_index > 0 {
497            output.push_str(if node.tight { "\n" } else { "\n\n" });
498        }
499        output.push_str(&serialize_inlines(&item.term, options)?);
500        for (detail_index, detail) in item.details.iter().enumerate() {
501            if node.tight && detail.children.len() == 1 {
502                if let Block::Paragraph(paragraph) = &detail.children[0] {
503                    output.push('\n');
504                    output.push_str(": ");
505                    output.push_str(&serialize_inlines(&paragraph.children, options)?);
506                    continue;
507                }
508            }
509            // A loose list is re-parsed as loose only through an intra-item blank;
510            // the parser treats blanks BETWEEN items as tight-preserving group
511            // separators. Encode the looseness with a blank line before the term's
512            // first definition marker (a `blank_after_term`), so the round trip
513            // keeps `tight=false`.
514            if !node.tight && detail_index == 0 {
515                output.push('\n');
516            }
517            output.push_str("\n:");
518            let inner = serialize_blocks_at_start(&detail.children, options, false)?;
519            if !inner.is_empty() {
520                output.push('\n');
521                output.push_str(&indent_lines(&inner, 4));
522            }
523        }
524    }
525    Ok(output)
526}
527
528fn serialize_code_block(
529    node: &CodeBlock,
530    options: &SerializeOptions,
531) -> Result<String, SerializeError> {
532    match node.kind {
533        CodeBlockKind::Indented => Ok(prefix_lines(trim_trailing_newline(&node.value), "    ")),
534        CodeBlockKind::Fenced { marker, length } => {
535            let marker = code_block_fence_marker(node, marker, options);
536            let fence = fence_for(&node.value, marker, length.max(3));
537            let mut opener = fence.clone();
538            if let Some(info) = &node.info {
539                opener.push(' ');
540                opener.push_str(&escape_code_info(info));
541            }
542            let mut output = opener;
543            output.push('\n');
544            output.push_str(&node.value);
545            if !ends_with_line_ending(&node.value) {
546                output.push('\n');
547            }
548            output.push_str(&fence);
549            Ok(output)
550        }
551    }
552}
553
554fn code_block_fence_marker(
555    node: &CodeBlock,
556    marker: FenceMarker,
557    options: &SerializeOptions,
558) -> FenceMarker {
559    if node.info.as_deref().is_some_and(|info| info.contains('`')) {
560        return FenceMarker::Tilde;
561    }
562    if options.fence_marker == SerializeOptions::default().fence_marker {
563        marker
564    } else {
565        options.fence_marker
566    }
567}
568
569fn escape_code_info(input: &str) -> String {
570    let mut output = String::new();
571    for char in input.chars() {
572        match char {
573            '\n' => output.push_str("&#xA;"),
574            '\r' => output.push_str("&#xD;"),
575            '\t' => output.push(char),
576            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
577            '\\' | '&' => {
578                output.push('\\');
579                output.push(char);
580            }
581            _ => output.push(char),
582        }
583    }
584    output
585}
586
587fn serialize_table(node: &Table, options: &SerializeOptions) -> Result<String, SerializeError> {
588    let header = &node.rows[0];
589    let mut output = serialize_table_row(header, options)?;
590    output.push('\n');
591    output.push('|');
592    output.push(' ');
593    output.push_str(
594        &node
595            .alignments
596            .iter()
597            .map(|alignment| match alignment {
598                TableAlignment::None => "---",
599                TableAlignment::Left => ":---",
600                TableAlignment::Center => ":---:",
601                TableAlignment::Right => "---:",
602            })
603            .collect::<Vec<_>>()
604            .join(" | "),
605    );
606    output.push(' ');
607    output.push('|');
608    for row in node.rows.iter().skip(1) {
609        output.push('\n');
610        output.push_str(&serialize_table_row(row, options)?);
611    }
612    Ok(output)
613}
614
615fn serialize_table_row(
616    row: &TableRow,
617    options: &SerializeOptions,
618) -> Result<String, SerializeError> {
619    let mut cells = Vec::new();
620    for cell in &row.cells {
621        let cell = serialize_inlines_with_context(
622            &cell.children,
623            options,
624            InlineSerializeContext::table_cell(),
625        )?;
626        if table_cell_has_unescaped_pipe(&cell) {
627            return Err(SerializeError::UnsupportedNode(
628                "table cell inline contains a pipe that cannot be escaped without changing source",
629            ));
630        }
631        cells.push(cell);
632    }
633    Ok(format!("| {} |", cells.join(" | ")))
634}
635
636#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
637struct InlineSerializeContext {
638    table_cell: bool,
639    avoid_star_edges: bool,
640}
641
642impl InlineSerializeContext {
643    const fn table_cell() -> Self {
644        Self {
645            table_cell: true,
646            avoid_star_edges: false,
647        }
648    }
649
650    const fn avoiding_star_edges(self) -> Self {
651        Self {
652            table_cell: self.table_cell,
653            avoid_star_edges: true,
654        }
655    }
656}
657
658fn serialize_inlines(
659    inlines: &[Inline],
660    options: &SerializeOptions,
661) -> Result<String, SerializeError> {
662    serialize_inlines_with_context(inlines, options, InlineSerializeContext::default())
663}
664
665/// Escape a trailing unescaped `!` already in `output` before emitting a
666/// following `[`-starting node (link / reference / footnote), so the pair does
667/// not reparse as an image (`![…]`). The within-text `!`-before-`[` escaper
668/// only sees a single text node, so this handles the cross-node boundary.
669fn escape_trailing_bang(output: &mut String) {
670    if output.ends_with('!') && !output.ends_with("\\!") {
671        output.pop();
672        output.push_str("\\!");
673    }
674}
675
676// A GFM literal autolink is serialized as its raw URL text. If the preceding
677// output ends with `<`, that `<` plus the URL plus a following `>` could be
678// reparsed as an angle autolink (`<http://x>`) instead of the literal. Escaping
679// the trailing `<` keeps it literal text, so the URL stays a GFM literal on the
680// round trip (`\<` before `http://…` is just text + literal).
681fn escape_trailing_less_than(output: &mut String) {
682    if output.ends_with('<') && !output.ends_with("\\<") {
683        output.pop();
684        output.push_str("\\<");
685    }
686}
687
688// A GFM bare-email literal anchors at its leftmost run of email-local chars
689// (`[A-Za-z0-9.+_-]`). If the preceding output ends with such a char, the
690// reparse would extend the email's local part leftward into that text (e.g.
691// `A` + `i@i.a` → `Ai@i.a`). Re-emit the trailing email-local char as a numeric
692// character reference (which decodes back to the same text but is not an
693// email-local char), preserving the boundary on the round trip.
694fn escape_trailing_email_local(output: &mut String) {
695    let Some(last) = output.chars().next_back() else {
696        return;
697    };
698    // Only an ASCII alphanumeric immediately before the email forces a leftward
699    // re-anchor on reparse (the local part starts at the leftmost local-char
700    // run, and the dispatch reaches that alnum first). The punctuation
701    // local-chars (`.+-_`) are handled by the text serializer's own escaping.
702    if !last.is_ascii_alphanumeric() {
703        return;
704    }
705    output.pop();
706    output.push_str(&alloc::format!("&#{};", last as u32));
707}
708
709// True when `inline` is a GFM literal autolink (its raw URL serialization can
710// re-absorb a following text char on reparse).
711fn is_gfm_literal_autolink(inline: &Inline) -> bool {
712    matches!(
713        inline,
714        Inline::Autolink(node) if matches!(node.kind, AutolinkKind::GfmLiteral { .. })
715    )
716}
717
718fn is_gfm_literal_email(inline: &Inline) -> bool {
719    matches!(
720        inline,
721        Inline::Autolink(node)
722            if matches!(&node.kind, AutolinkKind::GfmLiteral { original }
723                if node.destination.strip_prefix("mailto:") == Some(original.as_str()))
724    )
725}
726
727// A GFM literal autolink's URL scan stops at whitespace, `<`, `]`, and a
728// backslash-escaped punctuation char, and trims trailing punctuation/entities.
729// A following text node whose first char is none of those — in particular a
730// non-ASCII char such as `©` decoded from `&copy;` — would otherwise be pulled
731// into the URL on reparse. Re-emit that leading char as a hex numeric character
732// reference (`&#xNN;`), which `autolink_delim` trims back off the URL and which
733// decodes to the same text, keeping the boundary stable.
734fn encode_leading_char_after_autolink(value: &str) -> Option<(String, &str)> {
735    let first = value.chars().next()?;
736    if first.is_ascii() {
737        // ASCII merge chars are handled by the text serializer's own backslash
738        // escaping (`\[`, `\&`, …) and the parser's matching `\<punct>` stop.
739        return None;
740    }
741    let encoded = alloc::format!("&#x{:X};", first as u32);
742    Some((encoded, &value[first.len_utf8()..]))
743}
744
745fn serialize_inlines_with_context(
746    inlines: &[Inline],
747    options: &SerializeOptions,
748    context: InlineSerializeContext,
749) -> Result<String, SerializeError> {
750    let mut output = String::new();
751    for (index, inline) in inlines.iter().enumerate() {
752        match inline {
753            Inline::Text(node) => {
754                let after_literal_autolink = index
755                    .checked_sub(1)
756                    .is_some_and(|prev| is_gfm_literal_autolink(&inlines[prev]));
757                let before_literal_autolink =
758                    inlines.get(index + 1).is_some_and(is_gfm_literal_autolink);
759
760                // Leading guard: a non-ASCII char abutting the END of a literal
761                // autolink would merge into its URL on reparse — encode it.
762                let (lead, body) = match after_literal_autolink
763                    .then(|| encode_leading_char_after_autolink(&node.value))
764                    .flatten()
765                {
766                    Some((encoded, rest)) => (encoded, rest),
767                    None => (String::new(), node.value.as_str()),
768                };
769
770                // Trailing guard: when this text is immediately followed by a
771                // www/http/email literal, its trailing whitespace must survive
772                // as a real whitespace preceder. A trailing space/tab is
773                // otherwise re-encoded (`&#x20;`/`&#x9;`) at an edge or as a
774                // control char, which would break the literal's left boundary on
775                // reparse — emit the trailing space/tab run literally instead.
776                let (escape_body, trailing_ws) = if before_literal_autolink {
777                    let head = body.trim_end_matches([' ', '\t']);
778                    (head, &body[head.len()..])
779                } else {
780                    (body, "")
781                };
782
783                output.push_str(&lead);
784                output.push_str(&escape_text_with_context(
785                    escape_body,
786                    lead.is_empty()
787                        && trailing_ws.len() != body.len()
788                        && output_line_len(&output) == 0,
789                    trailing_ws.is_empty() && text_is_at_line_end(inlines, index),
790                    context,
791                ));
792                output.push_str(trailing_ws);
793            }
794            Inline::Escape(node) => {
795                output.push('\\');
796                output.push(node.value);
797            }
798            Inline::CharacterReference(node) => output.push_str(&node.reference),
799            Inline::Emphasis(node) => {
800                let children = serialize_inlines_with_context(&node.children, options, context)?;
801                let touches_underscore = children.starts_with('_')
802                    || children.ends_with('_')
803                    || children.starts_with("\\_")
804                    || children.ends_with("\\_");
805                // An emphasis abutting a `*` already in the output (e.g. a
806                // preceding `*`-emphasis) would otherwise merge into one run, so
807                // switch this run to `_` when that does not introduce a new
808                // `_`-collision with the children.
809                let abuts_star = output.ends_with('*') && !touches_underscore;
810                let prefer_underscore = (context.avoid_star_edges && !touches_underscore)
811                    || abuts_star
812                    || children.starts_with('*')
813                    || children.ends_with('*');
814                let delimiter = if prefer_underscore { '_' } else { '*' };
815                let children = if delimiter == '*' {
816                    serialize_inlines_with_context(
817                        &node.children,
818                        options,
819                        context.avoiding_star_edges(),
820                    )?
821                } else {
822                    children
823                };
824                output.push(delimiter);
825                output.push_str(&children);
826                output.push(delimiter);
827            }
828            Inline::Strong(node) => {
829                let children = serialize_inlines_with_context(
830                    &node.children,
831                    options,
832                    context.avoiding_star_edges(),
833                )?;
834                // NOTE: two abutting `Strong` nodes (`**a****b**`) reparse as a
835                // single run. The only zero-insertion separator is flipping one
836                // run to `__`, but `__` reparses as `Underline` when that
837                // construct is enabled and the serializer has no signal for it,
838                // so this hand-built-AST sub-case is left as a known limitation.
839                output.push_str("**");
840                output.push_str(&children);
841                output.push_str("**");
842            }
843            Inline::Underline(node) => {
844                output.push_str("__");
845                output.push_str(&serialize_inlines_with_context(
846                    &node.children,
847                    options,
848                    context,
849                )?);
850                output.push_str("__");
851            }
852            Inline::Delete(node) => {
853                let children = serialize_inlines_with_context(&node.children, options, context)?;
854                let marker = match node.marker {
855                    DeleteMarker::SingleTilde => "~",
856                    DeleteMarker::DoubleTilde => "~~",
857                };
858                output.push_str(marker);
859                output.push_str(&children);
860                output.push_str(marker);
861            }
862            Inline::Insert(node) => {
863                output.push_str("++");
864                output.push_str(&serialize_inlines_with_context(
865                    &node.children,
866                    options,
867                    context,
868                )?);
869                output.push_str("++");
870            }
871            Inline::Mark(node) => {
872                output.push_str("==");
873                output.push_str(&serialize_inlines_with_context(
874                    &node.children,
875                    options,
876                    context,
877                )?);
878                output.push_str("==");
879            }
880            Inline::Subscript(node) => {
881                output.push('~');
882                output.push_str(&serialize_inlines_with_context(
883                    &node.children,
884                    options,
885                    context,
886                )?);
887                output.push('~');
888            }
889            Inline::Superscript(node) => {
890                output.push('^');
891                output.push_str(&serialize_inlines_with_context(
892                    &node.children,
893                    options,
894                    context,
895                )?);
896                output.push('^');
897            }
898            Inline::Spoiler(node) => {
899                output.push_str("||");
900                output.push_str(&serialize_inlines_with_context(
901                    &node.children,
902                    options,
903                    context,
904                )?);
905                output.push_str("||");
906            }
907            Inline::Shortcode(node) => {
908                output.push(':');
909                output.push_str(&node.name);
910                output.push(':');
911            }
912            Inline::Code(node) => {
913                if node.fence_length > 0 && !node.raw.is_empty() {
914                    let fence = "`".repeat(node.fence_length);
915                    let raw = if context.table_cell {
916                        table_cell_escape_code_pipes(&node.raw)
917                    } else {
918                        node.raw.clone()
919                    };
920                    output.push_str(&fence);
921                    output.push_str(&raw);
922                    output.push_str(&fence);
923                    continue;
924                }
925                if node.value.is_empty() {
926                    output.push_str("`` ``");
927                    continue;
928                }
929                let value = if context.table_cell {
930                    table_cell_escape_code_pipes(&node.value)
931                } else {
932                    node.value.clone()
933                };
934                let fence = inline_code_fence(&value);
935                output.push_str(&fence);
936                if code_span_needs_padding(&value) {
937                    output.push(' ');
938                    output.push_str(&value);
939                    output.push(' ');
940                } else {
941                    output.push_str(&value);
942                }
943                output.push_str(&fence);
944            }
945            Inline::Link(node) => {
946                escape_trailing_bang(&mut output);
947                output.push('[');
948                output.push_str(&serialize_inlines_with_context(
949                    &node.children,
950                    options,
951                    context,
952                )?);
953                output.push_str("](");
954                output.push_str(&serialize_destination_kind(
955                    &node.destination,
956                    node.destination_kind,
957                    context,
958                ));
959                if let (Some(title), Some(title_kind)) = (&node.title, node.title_kind) {
960                    output.push(' ');
961                    output.push_str(&serialize_title_kind(title, title_kind, context));
962                }
963                output.push(')');
964            }
965            Inline::Image(node) => {
966                output.push_str("![");
967                output.push_str(&serialize_inlines_with_context(
968                    &node.alt, options, context,
969                )?);
970                output.push_str("](");
971                output.push_str(&serialize_destination_kind(
972                    &node.destination,
973                    node.destination_kind,
974                    context,
975                ));
976                if let (Some(title), Some(title_kind)) = (&node.title, node.title_kind) {
977                    output.push(' ');
978                    output.push_str(&serialize_title_kind(title, title_kind, context));
979                }
980                output.push(')');
981            }
982            Inline::LinkReference(node) => {
983                let children = serialize_inlines_with_context(&node.children, options, context)?;
984                let children_identifier = normalize_reference_label(&children);
985                escape_trailing_bang(&mut output);
986                push_reference_body(
987                    &mut output,
988                    node.kind,
989                    &children,
990                    children_identifier == node.identifier,
991                    &reference_explicit_label(node.meta.span.is_some(), &node.label, context),
992                );
993            }
994            Inline::ImageReference(node) => {
995                let alt = serialize_inlines_with_context(&node.alt, options, context)?;
996                let alt_identifier = normalize_reference_label(&alt);
997                output.push('!');
998                push_reference_body(
999                    &mut output,
1000                    node.kind,
1001                    &alt,
1002                    alt_identifier == node.identifier,
1003                    &reference_explicit_label(node.meta.span.is_some(), &node.label, context),
1004                );
1005            }
1006            Inline::Autolink(node) => match &node.kind {
1007                AutolinkKind::Angle => {
1008                    output.push('<');
1009                    output.push_str(&node.destination);
1010                    output.push('>');
1011                }
1012                // A GFM literal autolink re-emits its original source text,
1013                // which re-parses to the same literal (the synthesized
1014                // `http://`/`mailto:` destination is reconstructed on parse).
1015                AutolinkKind::GfmLiteral { original } => {
1016                    // Bare-email literals (`destination` is the original with a
1017                    // synthesized `mailto:` prefix) re-anchor leftward over
1018                    // email-local chars on reparse; guard the preceding char.
1019                    let is_bare_email = node.destination == alloc::format!("mailto:{original}");
1020                    let follows_literal_email_plus = original.starts_with('+')
1021                        && index
1022                            .checked_sub(1)
1023                            .is_some_and(|prev| is_gfm_literal_email(&inlines[prev]));
1024                    if is_bare_email && !follows_literal_email_plus {
1025                        escape_trailing_email_local(&mut output);
1026                    } else {
1027                        escape_trailing_less_than(&mut output);
1028                    }
1029                    output.push_str(original);
1030                }
1031            },
1032            Inline::Html(node) => output.push_str(&node.value),
1033            Inline::SoftBreak(_) => output.push('\n'),
1034            Inline::LineBreak(node) => match node.kind {
1035                LineBreakKind::Backslash => output.push_str("\\\n"),
1036                LineBreakKind::Spaces => output.push_str("  \n"),
1037            },
1038            Inline::Math(node) => {
1039                output.push_str(&serialize_inline_math_with_context(node, context)?);
1040            }
1041            Inline::FootnoteReference(node) => {
1042                escape_trailing_bang(&mut output);
1043                output.push_str("[^");
1044                if node.meta.span.is_some() {
1045                    output.push_str(&escape_footnote_label_source(&node.label));
1046                } else {
1047                    output.push_str(&escape_footnote_label_semantic(&node.label));
1048                }
1049                output.push(']');
1050            }
1051            Inline::InlineFootnote(node) => {
1052                output.push_str("^[");
1053                output.push_str(&serialize_inlines_with_context(
1054                    &node.children,
1055                    options,
1056                    context,
1057                )?);
1058                output.push(']');
1059            }
1060            Inline::WikiLink(node) => {
1061                output.push_str("[[");
1062                let target = escape_wikilink_part(&node.target);
1063                let label = escape_wikilink_part(&node.label);
1064                if node.target == node.label {
1065                    output.push_str(&target);
1066                } else {
1067                    match node.label_order {
1068                        WikiLinkLabelOrder::AfterPipe => {
1069                            output.push_str(&target);
1070                            output.push('|');
1071                            output.push_str(&label);
1072                        }
1073                        WikiLinkLabelOrder::BeforePipe => {
1074                            output.push_str(&label);
1075                            output.push('|');
1076                            output.push_str(&target);
1077                        }
1078                    }
1079                }
1080                output.push_str("]]");
1081            }
1082            Inline::MdxExpression(node) => {
1083                output.push('{');
1084                output.push_str(&node.value);
1085                output.push('}');
1086            }
1087            Inline::MdxJsx(node) => output.push_str(&node.value),
1088            Inline::TextDirective(node) => {
1089                output.push(':');
1090                output.push_str(&node.name);
1091                output.push_str(&serialize_directive_label_with_context(
1092                    &node.label,
1093                    options,
1094                    context,
1095                )?);
1096                output.push_str(&serialize_attributes_with_context(
1097                    &node.attributes,
1098                    context,
1099                ));
1100            }
1101        }
1102    }
1103    Ok(output)
1104}
1105
1106fn serialize_directive_label(
1107    label: &[Inline],
1108    options: &SerializeOptions,
1109) -> Result<String, SerializeError> {
1110    serialize_directive_label_with_context(label, options, InlineSerializeContext::default())
1111}
1112
1113fn serialize_directive_label_with_context(
1114    label: &[Inline],
1115    options: &SerializeOptions,
1116    context: InlineSerializeContext,
1117) -> Result<String, SerializeError> {
1118    if label.is_empty() {
1119        Ok(String::new())
1120    } else {
1121        Ok(format!(
1122            "[{}]",
1123            serialize_inlines_with_context(label, options, context)?
1124        ))
1125    }
1126}
1127
1128fn serialize_attributes(attributes: &[DirectiveAttribute]) -> String {
1129    serialize_attributes_with_context(attributes, InlineSerializeContext::default())
1130}
1131
1132fn serialize_attributes_with_context(
1133    attributes: &[DirectiveAttribute],
1134    context: InlineSerializeContext,
1135) -> String {
1136    if attributes.is_empty() {
1137        return String::new();
1138    }
1139    let mut output = String::from("{");
1140    for (index, attribute) in attributes.iter().enumerate() {
1141        if index > 0 {
1142            output.push(' ');
1143        }
1144        match (&*attribute.name, &attribute.value) {
1145            ("id", Some(value)) if is_directive_shorthand_value(value) => {
1146                output.push('#');
1147                output.push_str(value);
1148            }
1149            ("class", Some(value)) if is_directive_shorthand_value(value) => {
1150                output.push('.');
1151                output.push_str(value);
1152            }
1153            (_, Some(value)) => {
1154                output.push_str(&attribute.name);
1155                output.push('=');
1156                output.push('"');
1157                output.push_str(&escape_title_with_context(
1158                    value,
1159                    LinkTitleKind::DoubleQuote,
1160                    context,
1161                ));
1162                output.push('"');
1163            }
1164            (_, None) => output.push_str(&attribute.name),
1165        }
1166    }
1167    output.push('}');
1168    output
1169}
1170
1171fn is_directive_shorthand_value(input: &str) -> bool {
1172    !input.is_empty()
1173        && input
1174            .chars()
1175            .all(|char| char.is_ascii_alphanumeric() || matches!(char, '_' | '-'))
1176}
1177
1178fn text_is_at_line_end(inlines: &[Inline], index: usize) -> bool {
1179    matches!(
1180        inlines.get(index + 1),
1181        None | Some(Inline::SoftBreak(_)) | Some(Inline::LineBreak(_))
1182    )
1183}
1184
1185fn escape_text_with_context(
1186    input: &str,
1187    preserve_leading: bool,
1188    preserve_trailing: bool,
1189    context: InlineSerializeContext,
1190) -> String {
1191    let avoid_star_edges = context.avoid_star_edges;
1192    let mut output = String::new();
1193    let mut line_digit_prefix = 0usize;
1194    let trailing_start = if preserve_trailing {
1195        input
1196            .trim_end_matches(|char| matches!(char, ' ' | '\t'))
1197            .len()
1198    } else {
1199        input.len()
1200    };
1201    let mut chars = input.char_indices().peekable();
1202    let mut at_leading_edge = preserve_leading;
1203    while let Some((offset, char)) = chars.next() {
1204        if char == '\n' {
1205            output.push_str("&#xA;");
1206            at_leading_edge = false;
1207            continue;
1208        }
1209        if char == '\r' {
1210            output.push_str("&#xD;");
1211            at_leading_edge = false;
1212            continue;
1213        }
1214        if (at_leading_edge || offset >= trailing_start) && char == ' ' {
1215            output.push_str("&#x20;");
1216            continue;
1217        }
1218        if (at_leading_edge || offset >= trailing_start) && char == '\t' {
1219            output.push_str("&#x9;");
1220            continue;
1221        }
1222        if char.is_control() {
1223            output.push_str(&format!("&#x{:X};", char as u32));
1224            at_leading_edge = false;
1225            continue;
1226        }
1227        at_leading_edge = false;
1228        if line_digit_prefix == output_line_len(&output) && char.is_ascii_digit() {
1229            output.push(char);
1230            line_digit_prefix += 1;
1231            continue;
1232        }
1233        if char == ':'
1234            && (input[..offset].ends_with("http") || input[..offset].ends_with("https"))
1235            && input[offset + char.len_utf8()..].starts_with("//")
1236        {
1237            output.push('\\');
1238            output.push(char);
1239            line_digit_prefix = usize::MAX;
1240            continue;
1241        }
1242        if char == '.' && input[..offset].ends_with("www") {
1243            output.push('\\');
1244            output.push(char);
1245            line_digit_prefix = usize::MAX;
1246            continue;
1247        }
1248        if char == '@' {
1249            if at_sign_can_start_email_autolink(input, offset) {
1250                output.push_str("&#x40;");
1251            } else {
1252                output.push(char);
1253            }
1254            line_digit_prefix = usize::MAX;
1255            continue;
1256        }
1257        if line_digit_prefix != usize::MAX
1258            && line_digit_prefix > 0
1259            && matches!(char, '.' | ')')
1260            && chars
1261                .peek()
1262                .map(|(_, next)| next.is_whitespace())
1263                .unwrap_or(true)
1264        {
1265            output.push('\\');
1266            output.push(char);
1267            line_digit_prefix = usize::MAX;
1268            continue;
1269        }
1270        if output_line_len(&output) == 0
1271            && matches!(char, '-' | '+')
1272            && chars
1273                .peek()
1274                .map(|(_, next)| next.is_whitespace())
1275                .unwrap_or(true)
1276        {
1277            output.push('\\');
1278            output.push(char);
1279            line_digit_prefix = usize::MAX;
1280            continue;
1281        }
1282        if output_line_len(&output) == 0
1283            && ((char == '-' && chars.peek().is_some_and(|(_, next)| *next == '-')) || char == '=')
1284        {
1285            output.push('\\');
1286            output.push(char);
1287            line_digit_prefix = usize::MAX;
1288            continue;
1289        }
1290        line_digit_prefix = usize::MAX;
1291        match char {
1292            '*' if avoid_star_edges => output.push_str("&#x2A;"),
1293            '|' if context.table_cell => output.push_str("&#x7C;"),
1294            '|' if output_line_len(&output) == 0 => {
1295                output.push('\\');
1296                output.push(char);
1297            }
1298            '`' if text_code_span_can_start(input, offset) => {
1299                output.push('\\');
1300                output.push(char);
1301            }
1302            '*' if text_attention_delimiter_can_start(input, offset, "*", false) => {
1303                output.push('\\');
1304                output.push(char);
1305            }
1306            '_' if text_attention_delimiter_can_start(input, offset, "_", true) => {
1307                output.push('\\');
1308                output.push(char);
1309            }
1310            '<' if text_less_than_can_start_inline(input, offset) => {
1311                output.push('\\');
1312                output.push(char);
1313            }
1314            '>' if output_line_len(&output) == 0 => {
1315                output.push('\\');
1316                output.push(char);
1317            }
1318            '{' if input[offset + char.len_utf8()..].contains('}') => {
1319                output.push('\\');
1320                output.push(char);
1321            }
1322            '#' if text_atx_heading_can_start(input, offset, &output) => {
1323                output.push('\\');
1324                output.push(char);
1325            }
1326            '|' if text_spoiler_can_start(input, offset) => output.push_str("&#x7C;"),
1327            '$' if text_math_can_start(input, offset) => {
1328                output.push('\\');
1329                output.push(char);
1330            }
1331            '!' if input[offset + char.len_utf8()..].starts_with('[') => {
1332                output.push('\\');
1333                output.push(char);
1334            }
1335            '~' if text_tilde_can_start(input, offset) => {
1336                output.push('\\');
1337                output.push(char);
1338            }
1339            '^' if text_caret_can_start(input, offset) => {
1340                output.push('\\');
1341                output.push(char);
1342            }
1343            '+' if text_attention_delimiter_can_start(input, offset, "++", false) => {
1344                output.push('\\');
1345                output.push(char);
1346            }
1347            '=' if text_attention_delimiter_can_start(input, offset, "==", false) => {
1348                output.push('\\');
1349                output.push(char);
1350            }
1351            '&' if text_character_reference_can_start(input, offset) => {
1352                output.push('\\');
1353                output.push(char);
1354            }
1355            '\\' | '[' | ']' => {
1356                output.push('\\');
1357                output.push(char);
1358            }
1359            _ => output.push(char),
1360        }
1361    }
1362    output
1363}
1364
1365fn text_code_span_can_start(input: &str, offset: usize) -> bool {
1366    let marker_len = same_char_run_len(input, offset, '`');
1367    if marker_len == 0 || text_char_at_edge(input, offset, marker_len) {
1368        return true;
1369    }
1370    find_same_char_run(input, offset + marker_len, '`', marker_len).is_some()
1371}
1372
1373fn text_attention_delimiter_can_start(
1374    input: &str,
1375    offset: usize,
1376    marker: &str,
1377    underscore: bool,
1378) -> bool {
1379    if !input[offset..].starts_with(marker) {
1380        return false;
1381    }
1382    if input[offset + marker.len()..].starts_with(marker)
1383        || text_char_at_edge(input, offset, marker.len())
1384    {
1385        return true;
1386    }
1387    if !text_delimiter_can_open(input, offset, marker.len(), underscore) {
1388        return false;
1389    }
1390
1391    let mut cursor = offset + marker.len();
1392    while let Some(candidate) = input[cursor..].find(marker).map(|index| cursor + index) {
1393        if !input[candidate + marker.len()..].starts_with(marker)
1394            && text_delimiter_can_close(input, candidate, marker.len(), underscore)
1395        {
1396            return true;
1397        }
1398        cursor = candidate + marker.len();
1399    }
1400    false
1401}
1402
1403fn text_delimiter_can_open(
1404    input: &str,
1405    offset: usize,
1406    marker_len: usize,
1407    underscore: bool,
1408) -> bool {
1409    let flanking = text_delimiter_flanking(input, offset, marker_len);
1410    if underscore {
1411        flanking.left
1412            && (!flanking.right
1413                || flanking
1414                    .previous
1415                    .is_some_and(|char| char.is_ascii_punctuation()))
1416    } else {
1417        flanking.left
1418    }
1419}
1420
1421fn text_delimiter_can_close(
1422    input: &str,
1423    offset: usize,
1424    marker_len: usize,
1425    underscore: bool,
1426) -> bool {
1427    let flanking = text_delimiter_flanking(input, offset, marker_len);
1428    if underscore {
1429        flanking.right
1430            && (!flanking.left
1431                || flanking
1432                    .next
1433                    .is_some_and(|char| char.is_ascii_punctuation()))
1434    } else {
1435        flanking.right
1436    }
1437}
1438
1439#[derive(Clone, Copy)]
1440struct TextDelimiterFlanking {
1441    left: bool,
1442    right: bool,
1443    previous: Option<char>,
1444    next: Option<char>,
1445}
1446
1447fn text_delimiter_flanking(input: &str, offset: usize, marker_len: usize) -> TextDelimiterFlanking {
1448    let previous = input[..offset].chars().next_back();
1449    let next = input[offset + marker_len..].chars().next();
1450
1451    let previous_whitespace = previous.is_none_or(char::is_whitespace);
1452    let next_whitespace = next.is_none_or(char::is_whitespace);
1453    let previous_punctuation = previous.is_some_and(|char| char.is_ascii_punctuation());
1454    let next_punctuation = next.is_some_and(|char| char.is_ascii_punctuation());
1455
1456    let left = next.is_some()
1457        && !next_whitespace
1458        && !(next_punctuation && !previous_whitespace && !previous_punctuation);
1459    let right = previous.is_some()
1460        && !previous_whitespace
1461        && !(previous_punctuation && !next_whitespace && !next_punctuation);
1462
1463    TextDelimiterFlanking {
1464        left,
1465        right,
1466        previous,
1467        next,
1468    }
1469}
1470
1471fn text_less_than_can_start_inline(input: &str, offset: usize) -> bool {
1472    let after = &input[offset + '<'.len_utf8()..];
1473    if after.contains('>') {
1474        let next = after.chars().next();
1475        return next.is_some_and(|char| {
1476            char.is_ascii_alphabetic() || matches!(char, '/' | '!' | '?' | '_')
1477        }) || after.starts_with("http://")
1478            || after.starts_with("https://")
1479            || after.contains('@');
1480    }
1481    false
1482}
1483
1484fn text_atx_heading_can_start(input: &str, offset: usize, output: &str) -> bool {
1485    if output_line_len(output) != 0 {
1486        return false;
1487    }
1488    let hashes = same_char_run_len(input, offset, '#');
1489    (1..=6).contains(&hashes)
1490        && input[offset + hashes..]
1491            .chars()
1492            .next()
1493            .is_none_or(char::is_whitespace)
1494}
1495
1496fn text_spoiler_can_start(input: &str, offset: usize) -> bool {
1497    input[offset..].starts_with("||")
1498        && !input[offset + "||".len()..].starts_with('|')
1499        && input[offset + "||".len()..].contains("||")
1500}
1501
1502fn text_math_can_start(input: &str, offset: usize) -> bool {
1503    // Mirror the parser's dollar-math start (code-span analogue): an opening run
1504    // of N dollars starts math when an exact-length-N closing run exists ahead.
1505    // Edge whitespace no longer blocks it, so a literal `$` adjacent to such a
1506    // run must be escaped to avoid forming math on the round trip.
1507    let marker_len = same_char_run_len(input, offset, '$');
1508    if marker_len == 0 || text_char_at_edge(input, offset, marker_len) {
1509        return true;
1510    }
1511    let after_open = offset + marker_len;
1512    find_same_char_run(input, after_open, '$', marker_len).is_some()
1513}
1514
1515fn text_tilde_can_start(input: &str, offset: usize) -> bool {
1516    if input[offset..].starts_with("~~") {
1517        return text_attention_delimiter_can_start(input, offset, "~~", false)
1518            || text_simple_delimiter_can_start(input, offset, '~');
1519    }
1520    text_simple_delimiter_can_start(input, offset, '~')
1521}
1522
1523fn text_caret_can_start(input: &str, offset: usize) -> bool {
1524    input[offset + '^'.len_utf8()..].starts_with('[')
1525        || text_simple_delimiter_can_start(input, offset, '^')
1526}
1527
1528fn text_simple_delimiter_can_start(input: &str, offset: usize, marker: char) -> bool {
1529    let marker_len = marker.len_utf8();
1530    if text_char_at_edge(input, offset, marker_len)
1531        || input[offset + marker_len..].starts_with(marker)
1532        || input[..offset].ends_with(marker)
1533    {
1534        return true;
1535    }
1536    input[offset + marker_len..].contains(marker)
1537}
1538
1539fn text_character_reference_can_start(input: &str, offset: usize) -> bool {
1540    let after = &input[offset + '&'.len_utf8()..];
1541    if let Some(rest) = after.strip_prefix('#') {
1542        let (digits, rest) = if let Some(hex) = rest.strip_prefix(['x', 'X']) {
1543            (
1544                hex.chars()
1545                    .take_while(|char| char.is_ascii_hexdigit())
1546                    .count(),
1547                hex,
1548            )
1549        } else {
1550            (
1551                rest.chars()
1552                    .take_while(|char| char.is_ascii_digit())
1553                    .count(),
1554                rest,
1555            )
1556        };
1557        return digits > 0 && rest[digits..].starts_with(';');
1558    }
1559
1560    let name_len = after
1561        .chars()
1562        .take_while(|char| char.is_ascii_alphanumeric())
1563        .count();
1564    name_len > 0 && after[name_len..].starts_with(';')
1565}
1566
1567fn text_char_at_edge(input: &str, offset: usize, len: usize) -> bool {
1568    offset == 0 || offset + len >= input.len()
1569}
1570
1571fn same_char_run_len(input: &str, offset: usize, needle: char) -> usize {
1572    input[offset..]
1573        .chars()
1574        .take_while(|char| *char == needle)
1575        .map(char::len_utf8)
1576        .sum()
1577}
1578
1579fn find_same_char_run(
1580    input: &str,
1581    mut offset: usize,
1582    needle: char,
1583    run_len: usize,
1584) -> Option<usize> {
1585    while offset < input.len() {
1586        let candidate = input[offset..].find(needle).map(|index| offset + index)?;
1587        if same_char_run_len(input, candidate, needle) == run_len {
1588            return Some(candidate);
1589        }
1590        offset = candidate + needle.len_utf8();
1591    }
1592    None
1593}
1594
1595fn at_sign_can_start_email_autolink(input: &str, offset: usize) -> bool {
1596    let before = input[..offset]
1597        .chars()
1598        .next_back()
1599        .is_some_and(|char| char.is_ascii_alphanumeric());
1600    if !before {
1601        return false;
1602    }
1603
1604    let mut saw_domain_char = false;
1605    let mut saw_dot = false;
1606    let mut saw_domain_char_after_dot = false;
1607    for char in input[offset + 1..].chars() {
1608        if char.is_ascii_alphanumeric() {
1609            saw_domain_char = true;
1610            if saw_dot {
1611                saw_domain_char_after_dot = true;
1612            }
1613            continue;
1614        }
1615        if char == '.' && saw_domain_char {
1616            saw_dot = true;
1617            continue;
1618        }
1619        if matches!(char, '-' | '_') && saw_domain_char {
1620            continue;
1621        }
1622        break;
1623    }
1624    saw_domain_char_after_dot
1625}
1626
1627fn output_line_len(output: &str) -> usize {
1628    output
1629        .rsplit_once('\n')
1630        .map(|(_, line)| line.len())
1631        .unwrap_or_else(|| output.len())
1632}
1633
1634fn escape_destination_with_pipe(input: &str, escape_pipe: bool) -> String {
1635    let mut output = String::new();
1636    for char in input.chars() {
1637        match char {
1638            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
1639            '|' if escape_pipe => {
1640                output.push('\\');
1641                output.push(char);
1642            }
1643            // NB: a literal space is NOT escaped here — `\ ` is not a valid
1644            // escape, so a space-containing destination is routed to the
1645            // angle-bracket form by `serialize_destination_kind`.
1646            '(' | ')' | '\\' | '<' | '>' | '&' => {
1647                output.push('\\');
1648                output.push(char);
1649            }
1650            _ => output.push(char),
1651        }
1652    }
1653    output
1654}
1655
1656/// Normalize a serialized reference label exactly the way the parser matches
1657/// reference labels: collapse internal whitespace and Unicode case-fold the RAW
1658/// text (no backslash/entity unescape). Delegating to the parser's
1659/// `normalize_label` keeps this in lockstep so the Shortcut/Collapsed arms
1660/// decide correctly whether the rendered children already reproduce the
1661/// definition identifier.
1662fn normalize_reference_label(input: &str) -> String {
1663    crate::parse::normalize_label(input)
1664}
1665
1666/// Emit the bracketed body of a link/image reference (`[text]`, `[text][]`, or
1667/// `[text][label]`) given the already-serialized `rendered` children and the
1668/// escaped raw `label`.
1669///
1670/// A Shortcut/Collapsed reference normally re-uses the rendered children as the
1671/// matching label, so it is only whole if those children fold back to the
1672/// definition identifier. Under RAW label matching the children can re-escape
1673/// in a fold-breaking way (e.g. a leading `^` becomes `\^`), so when the
1674/// children no longer reproduce the identifier we substitute the escaped raw
1675/// label as the bracket body — keeping the Shortcut/Collapsed kind (and its
1676/// re-parse) intact instead of degrading it into a Full reference.
1677fn push_reference_body(
1678    output: &mut String,
1679    kind: ReferenceKind,
1680    rendered: &str,
1681    children_match_identifier: bool,
1682    escaped_label: &str,
1683) {
1684    // For a Shortcut/Collapsed reference the bracket body must fold back to the
1685    // identifier on its own. Substitute the escaped raw label when the rendered
1686    // children would not (keeping the reference kind), but a Full reference
1687    // always keeps its rendered text since its explicit label does the matching.
1688    let use_label_body = !children_match_identifier && !matches!(kind, ReferenceKind::Full);
1689    let body = if use_label_body {
1690        escaped_label
1691    } else {
1692        rendered
1693    };
1694
1695    output.push('[');
1696    output.push_str(body);
1697    output.push(']');
1698
1699    match kind {
1700        ReferenceKind::Shortcut => {}
1701        ReferenceKind::Collapsed => output.push_str("[]"),
1702        ReferenceKind::Full => {
1703            output.push('[');
1704            output.push_str(escaped_label);
1705            output.push(']');
1706        }
1707    }
1708}
1709
1710/// Escape the explicit label of a link/image reference. The original `label`
1711/// (not the normalized identifier) is used so case and entity spelling survive
1712/// the round-trip. A parsed label (`span.is_some()`) is already source text, so
1713/// only control characters are escaped; a hand-built label is semantic text and
1714/// is escaped like any reference label.
1715fn reference_explicit_label(
1716    from_source: bool,
1717    label: &str,
1718    context: InlineSerializeContext,
1719) -> String {
1720    if from_source {
1721        escape_reference_label_source(label, context.table_cell)
1722    } else {
1723        escape_reference_label_with_pipe(label, context.table_cell)
1724    }
1725}
1726
1727/// Escapes a parsed definition label for re-emission. A definition label may
1728/// span several physical lines (CommonMark §4.7), and the parser stores those
1729/// interior newlines verbatim in `label`. Emitting them as literal line breaks
1730/// (rather than `&#xA;`) lets the multi-line label re-parse to the same raw
1731/// label, keeping the round trip stable; other control characters are still
1732/// numeric-escaped, and tabs pass through as in `escape_reference_label_source`.
1733fn escape_definition_label_source(input: &str) -> String {
1734    escape_reference_label_source(input, false)
1735}
1736
1737fn escape_reference_label_source(input: &str, escape_pipe: bool) -> String {
1738    let mut output = String::new();
1739    for char in input.chars() {
1740        match char {
1741            // A reference label may span several physical lines, and the parser
1742            // matches the RAW label (whitespace collapsed, no entity decode).
1743            // Emitting interior newlines/tabs literally (rather than `&#xA;`)
1744            // keeps a whitespace-bearing label re-parsing as the same reference
1745            // — crucially, a `^`-prefixed label with literal whitespace stays a
1746            // link reference instead of becoming a footnote (which requires `^`
1747            // followed by non-whitespace).
1748            '\t' | '\n' | '\r' => output.push(char),
1749            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
1750            '|' if escape_pipe => {
1751                output.push('\\');
1752                output.push(char);
1753            }
1754            _ => output.push(char),
1755        }
1756    }
1757    output
1758}
1759
1760fn escape_reference_label_with_pipe(input: &str, escape_pipe: bool) -> String {
1761    escape_label_syntax(input, escape_pipe, false)
1762}
1763
1764fn escape_footnote_label_source(input: &str) -> String {
1765    let mut output = String::new();
1766    for char in input.chars() {
1767        match char {
1768            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
1769            _ => output.push(char),
1770        }
1771    }
1772    output
1773}
1774
1775fn escape_footnote_label_semantic(input: &str) -> String {
1776    escape_label_syntax(input, false, true)
1777}
1778
1779fn escape_label_syntax(input: &str, escape_pipe: bool, escape_whitespace: bool) -> String {
1780    let mut output = String::new();
1781    for char in input.chars() {
1782        match char {
1783            char if char.is_whitespace() && escape_whitespace => {
1784                output.push_str(&format!("&#x{:X};", char as u32));
1785            }
1786            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
1787            '|' if escape_pipe => {
1788                output.push('\\');
1789                output.push(char);
1790            }
1791            '\\' | '[' | ']' => {
1792                output.push('\\');
1793                output.push(char);
1794            }
1795            _ => output.push(char),
1796        }
1797    }
1798    output
1799}
1800
1801fn escape_wikilink_part(input: &str) -> String {
1802    let mut output = String::new();
1803    for char in input.chars() {
1804        match char {
1805            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
1806            '\\' | '[' | ']' | '|' => {
1807                output.push('\\');
1808                output.push(char);
1809            }
1810            _ => output.push(char),
1811        }
1812    }
1813    output
1814}
1815
1816fn serialize_destination_kind(
1817    input: &str,
1818    kind: LinkDestinationKind,
1819    context: InlineSerializeContext,
1820) -> String {
1821    match kind {
1822        LinkDestinationKind::Omitted if input.is_empty() => String::new(),
1823        LinkDestinationKind::Angle => {
1824            let mut output = String::from("<");
1825            output.push_str(&escape_angle_destination_with_context(input, context));
1826            output.push('>');
1827            output
1828        }
1829        LinkDestinationKind::Bare | LinkDestinationKind::Omitted => {
1830            if input.is_empty() {
1831                "<>".into()
1832            } else if input.contains(' ') {
1833                // A bare destination cannot contain a space (it would terminate
1834                // the destination, and `\ ` is not an escape), so emit the
1835                // angle-bracket form instead.
1836                let mut output = String::from("<");
1837                output.push_str(&escape_angle_destination_with_context(input, context));
1838                output.push('>');
1839                output
1840            } else {
1841                escape_destination_with_pipe(input, context.table_cell)
1842            }
1843        }
1844    }
1845}
1846
1847fn escape_angle_destination_with_context(input: &str, context: InlineSerializeContext) -> String {
1848    let mut output = String::new();
1849    for char in input.chars() {
1850        match char {
1851            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
1852            '|' if context.table_cell => {
1853                output.push('\\');
1854                output.push(char);
1855            }
1856            '\\' | '<' | '>' => {
1857                output.push('\\');
1858                output.push(char);
1859            }
1860            _ => output.push(char),
1861        }
1862    }
1863    output
1864}
1865
1866fn serialize_title_kind(
1867    input: &str,
1868    kind: LinkTitleKind,
1869    context: InlineSerializeContext,
1870) -> String {
1871    let (open, close) = match kind {
1872        LinkTitleKind::DoubleQuote => ('"', '"'),
1873        LinkTitleKind::SingleQuote => ('\'', '\''),
1874        LinkTitleKind::Paren => ('(', ')'),
1875    };
1876    let mut output = String::new();
1877    output.push(open);
1878    output.push_str(&escape_title_with_context(input, kind, context));
1879    output.push(close);
1880    output
1881}
1882
1883fn escape_title_with_context(
1884    input: &str,
1885    kind: LinkTitleKind,
1886    context: InlineSerializeContext,
1887) -> String {
1888    let mut output = String::new();
1889    for char in input.chars() {
1890        match char {
1891            char if char.is_control() => output.push_str(&format!("&#x{:X};", char as u32)),
1892            '|' if context.table_cell => {
1893                output.push('\\');
1894                output.push(char);
1895            }
1896            '\\' | '&' => {
1897                output.push('\\');
1898                output.push(char);
1899            }
1900            '"' if kind == LinkTitleKind::DoubleQuote => {
1901                output.push('\\');
1902                output.push(char);
1903            }
1904            '\'' if kind == LinkTitleKind::SingleQuote => {
1905                output.push('\\');
1906                output.push(char);
1907            }
1908            '(' | ')' if kind == LinkTitleKind::Paren => {
1909                output.push('\\');
1910                output.push(char);
1911            }
1912            _ => output.push(char),
1913        }
1914    }
1915    output
1916}
1917
1918fn unordered_list_marker(delimiter: ListDelimiter) -> char {
1919    match delimiter {
1920        ListDelimiter::Dash => '-',
1921        ListDelimiter::Asterisk => '*',
1922        ListDelimiter::Plus => '+',
1923        ListDelimiter::Period | ListDelimiter::Paren => '-',
1924    }
1925}
1926
1927fn ordered_list_marker(delimiter: ListDelimiter) -> char {
1928    match delimiter {
1929        ListDelimiter::Paren => ')',
1930        ListDelimiter::Dash
1931        | ListDelimiter::Asterisk
1932        | ListDelimiter::Plus
1933        | ListDelimiter::Period => '.',
1934    }
1935}
1936
1937fn prefix_lines(input: &str, prefix: &str) -> String {
1938    if input.is_empty() {
1939        return String::new();
1940    }
1941    let bytes = input.as_bytes();
1942    let mut output = String::new();
1943    let mut line_start = 0;
1944    let mut cursor = 0;
1945    while cursor < input.len() {
1946        let eol_end = match bytes[cursor] {
1947            b'\n' => Some(cursor + 1),
1948            b'\r' if bytes.get(cursor + 1) == Some(&b'\n') => Some(cursor + 2),
1949            b'\r' => Some(cursor + 1),
1950            _ => None,
1951        };
1952        if let Some(end) = eol_end {
1953            output.push_str(prefix);
1954            output.push_str(&input[line_start..end]);
1955            cursor = end;
1956            line_start = cursor;
1957        } else {
1958            cursor += 1;
1959        }
1960    }
1961    if line_start < input.len() {
1962        output.push_str(prefix);
1963        output.push_str(&input[line_start..]);
1964    }
1965    output
1966}
1967
1968fn indent_after_first_line(input: &str, width: usize) -> String {
1969    let indent = " ".repeat(width);
1970    input
1971        .lines()
1972        .enumerate()
1973        .map(|(index, line)| {
1974            if index == 0 {
1975                line.into()
1976            } else {
1977                format!("{indent}{line}")
1978            }
1979        })
1980        .collect::<Vec<String>>()
1981        .join("\n")
1982}
1983
1984fn indent_lines(input: &str, width: usize) -> String {
1985    let indent = " ".repeat(width);
1986    input
1987        .lines()
1988        .map(|line| {
1989            if line.is_empty() {
1990                String::new()
1991            } else {
1992                format!("{indent}{line}")
1993            }
1994        })
1995        .collect::<Vec<String>>()
1996        .join("\n")
1997}
1998
1999fn indent_continuation(input: &str) -> String {
2000    input
2001        .lines()
2002        .enumerate()
2003        .map(|(index, line)| {
2004            if index == 0 {
2005                line.into()
2006            } else {
2007                format!("    {line}")
2008            }
2009        })
2010        .collect::<Vec<String>>()
2011        .join("\n")
2012}
2013
2014fn trim_trailing_newline(input: &str) -> &str {
2015    input.trim_end_matches('\n').trim_end_matches('\r')
2016}
2017
2018fn ends_with_line_ending(input: &str) -> bool {
2019    input.ends_with('\n') || input.ends_with('\r')
2020}
2021
2022fn fence_for(input: &str, marker: FenceMarker, min_len: usize) -> String {
2023    let char = match marker {
2024        FenceMarker::Backtick => '`',
2025        FenceMarker::Tilde => '~',
2026    };
2027    let longest = longest_char_streak(input, char);
2028    char.to_string().repeat(min_len.max(longest + 1))
2029}
2030
2031fn inline_code_fence(input: &str) -> String {
2032    fence_for(input, FenceMarker::Backtick, 1)
2033}
2034
2035fn code_span_needs_padding(input: &str) -> bool {
2036    input.starts_with('`')
2037        || input.ends_with('`')
2038        || (input.starts_with(' ') && input.ends_with(' ') && input.chars().any(|char| char != ' '))
2039}
2040
2041fn table_cell_escape_code_pipes(input: &str) -> String {
2042    let mut output = String::with_capacity(input.len());
2043    for char in input.chars() {
2044        if char == '|' {
2045            output.push('\\');
2046        }
2047        output.push(char);
2048    }
2049    output
2050}
2051
2052fn block_math_fence(input: &str) -> String {
2053    let mut length = 2;
2054    for line in trim_trailing_newline(input).lines() {
2055        let trimmed = line.trim();
2056        if trimmed.len() >= 2 && trimmed.chars().all(|char| char == '$') {
2057            length = length.max(trimmed.len() + 1);
2058        }
2059    }
2060    "$".repeat(length)
2061}
2062
2063fn serialize_inline_math_with_context(
2064    node: &MathInline,
2065    context: InlineSerializeContext,
2066) -> Result<String, SerializeError> {
2067    let input = node.value.as_str();
2068
2069    // A table-cell pipe cannot live inside a dollar fence (it would split the
2070    // cell), so it is forced into the `$`…`$` code-math form regardless of the
2071    // node's recorded kind. That form cannot represent a value that itself
2072    // contains a `` `$ `` close.
2073    if context.table_cell && input.contains('|') {
2074        if input.contains("`$") {
2075            return Err(SerializeError::UnsupportedNode(
2076                "inline math containing a table pipe and a code-math close",
2077            ));
2078        }
2079        let input = table_cell_escape_code_pipes(input);
2080        return Ok(format!("$`{input}`$"));
2081    }
2082
2083    match node.kind {
2084        MathInlineKind::Code => {
2085            if input.contains("`$") {
2086                return Err(SerializeError::UnsupportedNode(
2087                    "inline math (code-math form) containing a `$` close",
2088                ));
2089            }
2090            Ok(format!("$`{input}`$"))
2091        }
2092        // Dollar math is emitted verbatim behind an exact-length fence: no
2093        // padding strip and no fence widening. A single-`$` value can only
2094        // contain a `$` that is backslash-escaped (`\$`), which the flanking
2095        // parser skips, so an exact `$`…`$` fence round-trips; a `$$` display
2096        // value is verbatim including any edge spaces or newlines.
2097        MathInlineKind::Dollar { dollars } => {
2098            let fence = "$".repeat(usize::from(dollars));
2099            Ok(format!("{fence}{input}{fence}"))
2100        }
2101    }
2102}
2103
2104fn table_cell_has_unescaped_pipe(input: &str) -> bool {
2105    let mut cursor = 0;
2106    let mut code_fence = None;
2107    let mut spoiler_open = false;
2108    while cursor < input.len() {
2109        let Some((next, char)) = input[cursor..]
2110            .chars()
2111            .next()
2112            .map(|char| (cursor + char.len_utf8(), char))
2113        else {
2114            break;
2115        };
2116        // Backticks are never escapable: a preceding backslash is code-span
2117        // content, so it must not suppress the code-span boundary here. Track
2118        // code spans only for extension syntax such as spoilers; a single
2119        // unescaped pipe still splits a table row, even inside code.
2120        if char == '`' {
2121            let length = input[cursor..]
2122                .as_bytes()
2123                .iter()
2124                .take_while(|byte| **byte == b'`')
2125                .count();
2126            if code_fence == Some(length) {
2127                code_fence = None;
2128            } else if code_fence.is_none() {
2129                code_fence = Some(length);
2130            }
2131            cursor += length;
2132            continue;
2133        }
2134        if char == '|' && input.as_bytes().get(cursor + 1) == Some(&b'|') && code_fence.is_some() {
2135            cursor += 2;
2136            continue;
2137        }
2138        if char == '|'
2139            && input.as_bytes().get(cursor + 1) == Some(&b'|')
2140            && code_fence.is_none()
2141            && !crate::parse::is_escaped_at(input, cursor)
2142        {
2143            let closes_spoiler =
2144                spoiler_open && input.as_bytes().get(cursor.wrapping_sub(1)) != Some(&b'|');
2145            let opens_spoiler = !spoiler_open
2146                && input.as_bytes().get(cursor + 2) != Some(&b'|')
2147                && find_table_cell_spoiler_close(input, cursor + 2).is_some();
2148            if closes_spoiler || opens_spoiler {
2149                spoiler_open = opens_spoiler;
2150                cursor += 2;
2151                continue;
2152            }
2153        }
2154        if char == '|' && !spoiler_open && !crate::parse::is_escaped_at(input, cursor) {
2155            return true;
2156        }
2157        cursor = next;
2158    }
2159    false
2160}
2161
2162fn find_table_cell_spoiler_close(input: &str, mut offset: usize) -> Option<usize> {
2163    while offset < input.len() {
2164        let candidate = input[offset..].find("||").map(|index| offset + index)?;
2165        if !crate::parse::is_escaped_at(input, candidate)
2166            && input.as_bytes().get(candidate + 2) != Some(&b'|')
2167        {
2168            return Some(candidate);
2169        }
2170        offset = candidate + 2;
2171    }
2172    None
2173}
2174
2175fn longest_char_streak(input: &str, needle: char) -> usize {
2176    let mut longest = 0;
2177    let mut current = 0;
2178    for char in input.chars() {
2179        if char == needle {
2180            current += 1;
2181            longest = longest.max(current);
2182        } else {
2183            current = 0;
2184        }
2185    }
2186    longest
2187}
2188
2189fn directive_fence(inner: &str) -> String {
2190    ":".repeat(directive_fence_len(inner))
2191}
2192
2193fn directive_fence_len(inner: &str) -> usize {
2194    let mut max = 3;
2195    for line in inner.lines() {
2196        if let Some(length) = directive_closing_fence_len(line) {
2197            max = max.max(length + 1);
2198        }
2199    }
2200    max
2201}
2202
2203fn directive_closing_fence_len(line: &str) -> Option<usize> {
2204    let trimmed = trim_up_to_three_indent_columns(line)?;
2205    let length = trimmed
2206        .as_bytes()
2207        .iter()
2208        .take_while(|byte| **byte == b':')
2209        .count();
2210    if length >= 3 && trimmed[length..].trim().is_empty() {
2211        Some(length)
2212    } else {
2213        None
2214    }
2215}
2216
2217fn trim_up_to_three_indent_columns(input: &str) -> Option<&str> {
2218    let mut columns = 0usize;
2219    let mut bytes = 0usize;
2220    for byte in input.as_bytes() {
2221        match *byte {
2222            b' ' => columns += 1,
2223            b'\t' => columns += 4 - (columns % 4),
2224            _ => break,
2225        }
2226        bytes += 1;
2227    }
2228    (columns <= 3).then_some(&input[bytes..])
2229}