1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum LineEnding {
23 Lf,
25 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#[derive(Clone, Debug, Eq, PartialEq)]
41#[non_exhaustive]
42pub struct SerializeOptions {
43 pub line_ending: LineEnding,
45 pub final_newline: bool,
47 pub bullet: ListDelimiter,
49 pub ordered_delimiter: ListDelimiter,
51 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#[derive(Clone, Debug, Eq, PartialEq)]
69pub enum SerializeError {
70 InvalidDocument(Vec<Diagnostic>),
72 UnsupportedNode(&'static str),
74}
75
76impl Document {
77 pub fn to_markdown(&self) -> Result<String, SerializeError> {
79 self.to_markdown_with(&SerializeOptions::default())
80 }
81
82 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
110fn 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 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 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
299fn 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
487fn 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(¶graph.children, options)?);
532 continue;
533 }
534 }
535 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("
"),
600 '\r' => output.push_str("
"),
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
691fn escape_trailing_bang(output: &mut String) {
696 if output.ends_with('!') && !output.ends_with("\\!") {
697 output.pop();
698 output.push_str("\\!");
699 }
700}
701
702fn 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
714fn escape_trailing_email_local(output: &mut String) {
721 let Some(last) = output.chars().next_back() else {
722 return;
723 };
724 if !last.is_ascii_alphanumeric() {
729 return;
730 }
731 output.pop();
732 output.push_str(&alloc::format!("&#{};", last as u32));
733}
734
735fn 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
753fn encode_leading_char_after_autolink(value: &str) -> Option<(String, &str)> {
761 let first = value.chars().next()?;
762 if first.is_ascii() {
763 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 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 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 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 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(";
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 AutolinkKind::GfmLiteral { original } => {
1042 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("
");
1232 at_leading_edge = false;
1233 continue;
1234 }
1235 if char == '\r' {
1236 output.push_str("
");
1237 at_leading_edge = false;
1238 continue;
1239 }
1240 if (at_leading_edge || offset >= trailing_start) && char == ' ' {
1241 output.push_str(" ");
1242 continue;
1243 }
1244 if (at_leading_edge || offset >= trailing_start) && char == '\t' {
1245 output.push_str("	");
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("@");
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("*"),
1319 '|' if context.table_cell => output.push_str("|"),
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("|"),
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 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 '(' | ')' | '\\' | '<' | '>' | '&' => {
1673 output.push('\\');
1674 output.push(char);
1675 }
1676 _ => output.push(char),
1677 }
1678 }
1679 output
1680}
1681
1682fn normalize_reference_label(input: &str) -> String {
1689 crate::parse::normalize_label(input)
1690}
1691
1692fn push_reference_body(
1704 output: &mut String,
1705 kind: ReferenceKind,
1706 rendered: &str,
1707 children_match_identifier: bool,
1708 escaped_label: &str,
1709) {
1710 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
1736fn 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
1753fn 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 '\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 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 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 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 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}