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