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