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