use crate::{
doc::{Align, Block, BlockKind, Doc, Mark, Part, Text, quote_runs},
marks::Marks,
select::Cursor,
};
const INDENT: &str = " ";
pub fn serialize(doc: &Doc) -> String {
serialize_with(doc, &Marks::default())
}
impl From<&Doc> for String {
fn from(doc: &Doc) -> Self {
serialize(doc)
}
}
pub fn serialize_with(doc: &Doc, marks: &Marks) -> String {
let mut out = String::new();
let mut indents = Vec::with_capacity(doc.blocks.len());
for block in &doc.blocks {
let indent = indents
.last()
.map_or(0, |prev: &u8| block.indent.min(prev + 1));
indents.push(indent);
}
let runs = quote_runs(doc.blocks.iter().zip(&indents).map(|(b, i)| (b.quote, *i)));
let mut previous: Option<(&BlockKind, u8, bool)> = None;
for ((block, &indent), run) in doc.blocks.iter().zip(&indents).zip(runs) {
let (prefix, inner) = match run {
Some(run) => (
format!("{}> ", INDENT.repeat(run.level as usize)),
indent - run.level,
),
None => (String::new(), indent),
};
let within = run.is_some_and(|run| !run.first);
if let Some((prev_kind, prev_indent, prev_quoted)) = previous {
out.push('\n');
let edge = run.is_some_and(|run| run.first) || (run.is_none() && prev_quoted);
if !tight_after(prev_kind, &block.kind, indent > prev_indent)
|| (edge && !is_empty_marker(prev_kind))
{
if within {
out.push_str(prefix.trim_end());
}
out.push('\n');
}
}
let mut written = String::new();
write_block(&mut written, &block.kind, inner, marks);
if let Some(alert) = block.quote.and_then(|quote| quote.alert)
&& run.is_some_and(|run| run.first)
{
let body = std::mem::take(&mut written);
written.push_str(alert.marker());
if !body.is_empty() {
written.push('\n');
written.push_str(&body);
}
}
for (ix, line) in written.split('\n').enumerate() {
if ix > 0 {
out.push('\n');
}
if line.is_empty() {
out.push_str(prefix.trim_end());
} else {
out.push_str(&prefix);
out.push_str(line);
}
}
previous = Some((&block.kind, indent, run.is_some()));
}
if doc
.blocks
.last()
.is_some_and(|block| matches!(&block.kind, BlockKind::Task { text, .. } if text.is_empty()))
{
out.push(' ');
}
out
}
fn marker_kind(kind: &BlockKind) -> Option<u8> {
match kind {
BlockKind::Bullet(_) => Some(0),
BlockKind::Ordered { .. } => Some(1),
BlockKind::Task { .. } => Some(2),
_ => None,
}
}
fn is_marker(kind: &BlockKind) -> bool {
marker_kind(kind).is_some()
}
fn tight_after(previous: &BlockKind, next: &BlockKind, nested: bool) -> bool {
if is_empty_marker(previous) {
return true;
}
if is_empty_marker(next) {
return false;
}
if nested {
return is_marker(previous)
&& is_marker(next)
&& !matches!(next, BlockKind::Ordered { number, .. } if *number != 1);
}
marker_kind(previous).is_some() && marker_kind(previous) == marker_kind(next)
}
fn is_empty_marker(kind: &BlockKind) -> bool {
is_marker(kind)
&& Block::new(kind.clone())
.text_at(Part::Body)
.is_some_and(Text::is_empty)
}
fn write_block(out: &mut String, kind: &BlockKind, indent: u8, marks: &Marks) {
let pad = INDENT.repeat(indent as usize);
match kind {
BlockKind::Paragraph(text) => write_lines(out, &pad, &pad, &inline(text, marks)),
BlockKind::Heading { level, text } => {
let hashes = "#".repeat((*level).clamp(1, 6) as usize);
let body = inline_in(text, marks, Lead::Heading);
write_lines(out, &format!("{pad}{hashes} "), &pad, &body);
}
BlockKind::Bullet(text) => {
let marker = if text.is_empty() { "+ " } else { "- " };
write_marked(out, &pad, marker, text, marks)
}
BlockKind::Ordered { number, text } => {
write_marked(out, &pad, &format!("{number}. "), text, marks)
}
BlockKind::Task { checked, text } => {
let marker = if *checked { "- [x] " } else { "- [ ] " };
write_marked(out, &pad, marker, text, marks);
}
BlockKind::Code {
language,
code,
height,
} => {
let fence = "`".repeat(fence_width(&code.text));
out.push_str(&pad);
out.push_str(&fence);
out.push_str(language.as_deref().unwrap_or(""));
if let Some(height) = height {
out.push(' ');
out.push_str(&height.to_string());
}
for line in code.text.split('\n') {
out.push('\n');
out.push_str(&pad);
out.push_str(line);
}
out.push('\n');
out.push_str(&pad);
out.push_str(&fence);
}
BlockKind::Image { url, alt, width } => {
out.push_str(&pad);
out.push_str(";
write_destination(out, url);
out.push(')');
}
BlockKind::Bookmark { url, form } => {
out.push_str(&pad);
match form.title() {
None => {
out.push('<');
out.push_str(url);
out.push('>');
}
Some(title) => {
out.push('[');
out.push_str(url);
out.push_str("](");
write_destination(out, url);
out.push_str(&format!(" \"{title}\")"));
}
}
}
BlockKind::Table {
align,
header,
rows,
} => write_table(out, &pad, align, header, rows, marks),
BlockKind::Rule => {
out.push_str(&pad);
out.push_str("---");
}
}
}
fn write_marked(out: &mut String, pad: &str, marker: &str, text: &Text, marks: &Marks) {
let opener = if text.is_empty() {
marker.trim_end()
} else {
marker
};
let first = format!("{pad}{opener}");
let rest = format!("{pad}{}", " ".repeat(marker.chars().count()));
write_lines(out, &first, &rest, &inline(text, marks));
}
fn write_lines(out: &mut String, first: &str, rest: &str, body: &str) {
for (ix, line) in body.split('\n').enumerate() {
if ix > 0 {
out.push('\n');
}
out.push_str(if ix == 0 { first } else { rest });
out.push_str(line);
}
}
fn fence_width(code: &str) -> usize {
let mut longest = 0;
let mut run = 0;
for c in code.chars() {
run = if c == '`' { run + 1 } else { 0 };
longest = longest.max(run);
}
(longest + 1).max(3)
}
fn write_table(
out: &mut String,
pad: &str,
align: &[Align],
header: &[Text],
rows: &[Vec<Text>],
marks: &Marks,
) {
let columns = align.len().max(header.len());
let row_of = |cells: &[Text]| {
let mut line = String::from("|");
for ix in 0..columns {
line.push(' ');
if let Some(cell) = cells.get(ix) {
line.push_str(&inline(cell, marks));
}
line.push_str(" |");
}
line
};
out.push_str(pad);
out.push_str(&row_of(header));
out.push('\n');
out.push_str(pad);
out.push('|');
for ix in 0..columns {
out.push_str(match align.get(ix).copied().unwrap_or_default() {
Align::Left => " --- |",
Align::Center => " :-: |",
Align::Right => " ---: |",
});
}
for row in rows {
out.push('\n');
out.push_str(pad);
out.push_str(&row_of(row));
}
}
fn inline(text: &Text, marks: &Marks) -> String {
inline_in(text, marks, Lead::Line)
}
#[derive(Clone, Copy)]
enum Lead {
Line,
Heading,
}
fn inline_in(text: &Text, marks: &Marks, lead: Lead) -> String {
let mut out = String::new();
let mut open: Vec<usize> = Vec::new();
let mut started = vec![false; text.marks.len()];
let mut delimiters = vec!['_'; text.marks.len()];
let mut cursor = 0usize;
let mut boundaries: Vec<usize> = text
.marks
.iter()
.flat_map(|m| [m.range.start, m.range.end])
.chain([0, text.text.len()])
.collect();
boundaries.sort_unstable();
boundaries.dedup();
for point in boundaries {
if point < cursor {
continue;
}
escape_inline(&mut out, &text.text[cursor..point], marks, lead);
cursor = point;
while let Some(&top) = open.last() {
if text.marks[top].range.end <= point {
close_mark(&mut out, &text.marks[top].mark, delimiters[top], marks);
open.pop();
} else {
break;
}
}
for (ix, span) in text.marks.iter().enumerate() {
if started[ix] || span.range.start != point {
continue;
}
started[ix] = true;
if span.mark == Mark::Code {
let body = &text.text[span.range.clone()];
let ticks = "`".repeat(fence_width_inline(body));
out.push_str(&ticks);
out.push_str(body);
out.push_str(&ticks);
cursor = cursor.max(span.range.end);
continue;
}
if let Mark::Mention { url, .. } = &span.mark
&& crate::parse::is_shorthand(text, ix)
{
out.push('<');
out.push_str(url);
out.push('>');
cursor = cursor.max(span.range.end);
continue;
}
if let Mark::Link(url) = &span.mark
&& text.text.get(span.range.clone()) == Some(url.as_str())
&& crate::parse::is_url(url)
&& text.alone(ix)
{
out.push_str(url);
cursor = cursor.max(span.range.end);
continue;
}
let italic = italic_delimiter(&out, text, &span.range);
delimiters[ix] = italic;
open_mark(&mut out, &span.mark, italic, marks);
if span.range.is_empty() {
close_mark(&mut out, &span.mark, italic, marks);
} else {
open.push(ix);
}
}
}
escape_inline(
&mut out,
&text.text[cursor.min(text.text.len())..],
marks,
lead,
);
while let Some(ix) = open.pop() {
close_mark(&mut out, &text.marks[ix].mark, delimiters[ix], marks);
}
out
}
fn fence_width_inline(body: &str) -> usize {
let mut longest = 0;
let mut run = 0;
for c in body.chars() {
run = if c == '`' { run + 1 } else { 0 };
longest = longest.max(run);
}
longest + 1
}
fn italic_delimiter(written: &str, text: &Text, range: &std::ops::Range<usize>) -> char {
let intraword = written
.chars()
.next_back()
.is_some_and(char::is_alphanumeric)
|| text.text[range.end..]
.chars()
.next()
.is_some_and(char::is_alphanumeric);
if intraword { '*' } else { '_' }
}
fn open_mark(out: &mut String, mark: &Mark, italic: char, marks: &Marks) {
match mark {
Mark::Bold => out.push_str("**"),
Mark::Italic => out.push(italic),
Mark::Strike => out.push_str("~~"),
Mark::Link(_) | Mark::Mention { .. } => out.push('['),
Mark::Image(_) => out.push_str(";
write_destination(out, url);
out.push(')');
}
Mark::Mention { url, form } => {
out.push_str("](");
write_destination(out, url);
out.push_str(" \"");
out.push_str(&form.title().unwrap_or_else(|| "chip".to_owned()));
out.push_str("\")");
}
Mark::Custom(name) => out.push_str(marks.delimiter(name).unwrap_or("")),
Mark::Code => {}
}
}
fn write_destination(out: &mut String, url: &str) {
if bare_destination(url) {
return out.push_str(url);
}
out.push('<');
for c in url.chars() {
match c {
'<' | '>' | '\\' => {
out.push('\\');
out.push(c);
}
c if c.is_ascii_control() => out.push_str(&format!("%{:02X}", c as u8)),
c => out.push(c),
}
}
out.push('>');
}
fn bare_destination(url: &str) -> bool {
if url.starts_with('<') {
return false;
}
let mut depth = 0i32;
for c in url.chars() {
match c {
'(' => depth += 1,
')' if depth == 0 => return false,
')' => depth -= 1,
'\\' => return false,
c if c.is_whitespace() || c.is_ascii_control() => return false,
_ => {}
}
}
depth == 0
}
fn escape_inline(out: &mut String, s: &str, marks: &Marks, lead: Lead) {
let mut line_start = out.is_empty() || out.ends_with('\n');
let mut lead = if out.is_empty() { lead } else { Lead::Line };
for (ix, line) in s.split('\n').enumerate() {
if ix > 0 {
out.push('\n');
line_start = true;
lead = Lead::Line;
}
let body = if line_start {
escape_block_marker(out, line, lead)
} else {
line
};
escape_span(out, body, marks);
line_start = false;
}
}
fn escape_block_marker<'a>(out: &mut String, line: &'a str, lead: Lead) -> &'a str {
let after_space = |rest: &str| rest.starts_with([' ', '\t']) || rest.is_empty();
let hashes = line.len() - line.trim_start_matches('#').len();
if hashes > 0 && after_space(&line[hashes..]) {
out.push('\\');
out.push_str(&line[..hashes]);
return &line[hashes..];
}
if let Lead::Heading = lead {
return line;
}
if let Some(rest) = line.strip_prefix('>') {
out.push_str("\\>");
return rest;
}
if (line.starts_with('-') || line.starts_with('+')) && after_space(&line[1..]) {
out.push('\\');
out.push_str(&line[..1]);
return &line[1..];
}
let digits = line.len() - line.trim_start_matches(|c: char| c.is_ascii_digit()).len();
if digits > 0 {
let after = &line[digits..];
if (after.starts_with('.') || after.starts_with(')')) && after_space(&after[1..]) {
out.push_str(&line[..digits]);
out.push('\\');
out.push_str(&after[..1]);
return &after[1..];
}
}
let trimmed = line.trim_end();
if !trimmed.is_empty() && trimmed.chars().all(|c| c == '=' || c == '-') {
out.push('\\');
out.push_str(&line[..1]);
return &line[1..];
}
line
}
fn escape_span(out: &mut String, s: &str, marks: &Marks) {
let mut skip = 0usize;
for (ix, c) in s.char_indices() {
if ix < skip {
continue;
}
let rest = &s[ix + c.len_utf8()..];
if let Some(entry) = marks
.sorted()
.into_iter()
.find(|entry| s[ix..].starts_with(entry.delimiter.as_ref()))
{
for c in entry.delimiter.chars() {
out.push('\\');
out.push(c);
}
skip = ix + entry.delimiter.len();
continue;
}
match c {
'\\' | '*' | '`' | '[' | ']' | '~' | '|' => {
out.push('\\');
out.push(c);
}
'_' => {
let before = s[..ix].chars().next_back();
let inside_word = before.is_some_and(char::is_alphanumeric)
&& rest.chars().next().is_some_and(char::is_alphanumeric);
if !inside_word {
out.push('\\');
}
out.push('_');
}
'<' if rest
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | '!' | '?')) =>
{
out.push_str("\\<")
}
'&' if rest
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '#') =>
{
out.push_str("\\&")
}
_ => out.push(c),
}
}
}
const SENTINELS: std::ops::RangeInclusive<u32> = 0xF0000..=0xFFFFD;
pub(crate) fn sentinel(ix: usize) -> Option<char> {
let code = SENTINELS.start().checked_add(u32::try_from(ix).ok()?)?;
SENTINELS
.contains(&code)
.then(|| char::from_u32(code))
.flatten()
}
pub(crate) fn sentinel_index(c: char) -> Option<usize> {
SENTINELS
.contains(&(c as u32))
.then(|| (c as u32 - SENTINELS.start()) as usize)
}
fn carries_sentinel(doc: &Doc) -> bool {
doc.blocks.iter().any(|block| {
block.parts().into_iter().any(|part| {
block
.text_at(part)
.is_some_and(|text| text.text.chars().any(|c| sentinel_index(c).is_some()))
})
})
}
pub fn serialize_at(doc: &Doc, at: Cursor, marks: &Marks) -> (String, usize) {
let (source, offsets) = serialize_at_many(doc, &[at], marks);
let end = source.len();
(source, offsets[0].unwrap_or(end))
}
pub fn serialize_at_many(doc: &Doc, at: &[Cursor], marks: &Marks) -> (String, Vec<Option<usize>>) {
let mut doc = doc.clone();
let mut placed = vec![false; at.len()];
if !carries_sentinel(&doc) {
let mut order: Vec<usize> = (0..at.len()).collect();
order.sort_by_key(|&ix| std::cmp::Reverse(at[ix]));
for ix in order {
let Some(mark) = sentinel(ix) else { continue };
let Some(text) = doc
.blocks
.get_mut(at[ix].block)
.and_then(|block| block.text_at_mut(at[ix].part))
else {
continue;
};
let offset = at[ix].offset.min(text.text.len());
if text.text.is_char_boundary(offset) {
text.insert(offset, mark.encode_utf8(&mut [0; 4]));
placed[ix] = true;
}
}
}
doc.normalize_with(marks);
let written = serialize_with(&doc, marks);
if !placed.contains(&true) {
return (written, vec![None; at.len()]);
}
let mut source = String::with_capacity(written.len());
let mut offsets = vec![None; at.len()];
for c in written.chars() {
match sentinel_index(c) {
Some(ix) => {
if placed.get(ix) == Some(&true) {
offsets[ix] = Some(source.len());
}
}
None => source.push(c),
}
}
(source, offsets)
}