use crate::{
doc::{Align, Block, BlockKind, Doc, Mark, Part, Text},
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 previous: Option<(&BlockKind, u8)> = None;
for block in &doc.blocks {
let indent = match previous {
Some((_, prev)) => block.indent.min(prev + 1),
None => 0,
};
if let Some((prev_kind, prev_indent)) = previous {
out.push('\n');
if !tight_after(prev_kind, &block.kind, indent > prev_indent) {
out.push('\n');
}
}
write_block(&mut out, &block.kind, indent, marks);
previous = Some((&block.kind, indent));
}
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);
write_lines(out, &format!("{pad}{hashes} "), &pad, &inline(text, marks));
}
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::Quote { kind, text } => {
let prefix = format!("{pad}> ");
let body = inline(text, marks);
if let Some(kind) = kind {
out.push_str(&prefix);
out.push_str(kind.marker());
if body.is_empty() {
return;
}
out.push('\n');
}
write_lines(out, &prefix, &prefix, &body);
}
BlockKind::Code { language, code } => {
let fence = "`".repeat(fence_width(&code.text));
out.push_str(&pad);
out.push_str(&fence);
out.push_str(language.as_deref().unwrap_or(""));
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 {
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);
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);
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("chip"));
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) {
let mut line_start = out.is_empty() || out.ends_with('\n');
for (ix, line) in s.split('\n').enumerate() {
if ix > 0 {
out.push('\n');
line_start = true;
}
let body = if line_start {
escape_block_marker(out, line)
} else {
line
};
escape_span(out, body, marks);
line_start = false;
}
}
fn escape_block_marker<'a>(out: &mut String, line: &'a str) -> &'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 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),
}
}
}
pub(crate) const SENTINEL: char = '\u{E000}';
pub fn serialize_at(doc: &Doc, at: Cursor, marks: &Marks) -> (String, usize) {
let mut doc = doc.clone();
let placed = doc
.blocks
.get_mut(at.block)
.and_then(|block| block.text_at_mut(at.part))
.filter(|text| !text.text.contains(SENTINEL))
.map(|text| {
text.insert(
at.offset.min(text.text.len()),
SENTINEL.encode_utf8(&mut [0; 4]),
)
})
.is_some();
doc.normalize_with(marks);
let mut source = serialize_with(&doc, marks);
let Some(offset) = placed.then(|| source.find(SENTINEL)).flatten() else {
source = source.replace(SENTINEL, "");
let end = source.len();
return (source, end);
};
source.remove(offset);
(source, offset)
}