1use crate::{
15 doc::{Align, Block, BlockKind, Doc, Mark, Part, Text},
16 marks::Marks,
17 select::Cursor,
18};
19
20const INDENT: &str = " ";
24
25pub fn serialize(doc: &Doc) -> String {
26 serialize_with(doc, &Marks::default())
27}
28
29impl From<&Doc> for String {
30 fn from(doc: &Doc) -> Self {
31 serialize(doc)
32 }
33}
34
35pub fn serialize_with(doc: &Doc, marks: &Marks) -> String {
37 let mut out = String::new();
38 let mut previous: Option<(&BlockKind, u8)> = None;
39
40 for block in &doc.blocks {
41 let indent = match previous {
42 Some((_, prev)) => block.indent.min(prev + 1),
43 None => 0,
44 };
45
46 if let Some((prev_kind, prev_indent)) = previous {
47 out.push('\n');
48 if !tight_after(prev_kind, &block.kind, indent > prev_indent) {
49 out.push('\n');
50 }
51 }
52
53 write_block(&mut out, &block.kind, indent, marks);
54 previous = Some((&block.kind, indent));
55 }
56
57 if doc
61 .blocks
62 .last()
63 .is_some_and(|block| matches!(&block.kind, BlockKind::Task { text, .. } if text.is_empty()))
64 {
65 out.push(' ');
66 }
67
68 out
69}
70
71fn marker_kind(kind: &BlockKind) -> Option<u8> {
74 match kind {
75 BlockKind::Bullet(_) => Some(0),
76 BlockKind::Ordered { .. } => Some(1),
77 BlockKind::Task { .. } => Some(2),
78 _ => None,
79 }
80}
81
82fn is_marker(kind: &BlockKind) -> bool {
83 marker_kind(kind).is_some()
84}
85
86fn tight_after(previous: &BlockKind, next: &BlockKind, nested: bool) -> bool {
112 if is_empty_marker(previous) {
113 return true;
114 }
115 if is_empty_marker(next) {
116 return false;
117 }
118 if nested {
119 return is_marker(previous)
120 && is_marker(next)
121 && !matches!(next, BlockKind::Ordered { number, .. } if *number != 1);
122 }
123 marker_kind(previous).is_some() && marker_kind(previous) == marker_kind(next)
124}
125
126fn is_empty_marker(kind: &BlockKind) -> bool {
127 is_marker(kind)
128 && Block::new(kind.clone())
129 .text_at(Part::Body)
130 .is_some_and(Text::is_empty)
131}
132
133fn write_block(out: &mut String, kind: &BlockKind, indent: u8, marks: &Marks) {
134 let pad = INDENT.repeat(indent as usize);
135
136 match kind {
137 BlockKind::Paragraph(text) => write_lines(out, &pad, &pad, &inline(text, marks)),
138 BlockKind::Heading { level, text } => {
139 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
140 let body = inline_in(text, marks, Lead::Heading);
141 write_lines(out, &format!("{pad}{hashes} "), &pad, &body);
142 }
143 BlockKind::Bullet(text) => {
148 let marker = if text.is_empty() { "+ " } else { "- " };
149 write_marked(out, &pad, marker, text, marks)
150 }
151 BlockKind::Ordered { number, text } => {
152 write_marked(out, &pad, &format!("{number}. "), text, marks)
153 }
154 BlockKind::Task { checked, text } => {
155 let marker = if *checked { "- [x] " } else { "- [ ] " };
156 write_marked(out, &pad, marker, text, marks);
157 }
158 BlockKind::Quote { kind, text } => {
159 let prefix = format!("{pad}> ");
160 let body = inline(text, marks);
164 if let Some(kind) = kind {
165 out.push_str(&prefix);
166 out.push_str(kind.marker());
167 if body.is_empty() {
168 return;
169 }
170 out.push('\n');
171 }
172 write_lines(out, &prefix, &prefix, &body);
173 }
174 BlockKind::Code { language, code } => {
175 let fence = "`".repeat(fence_width(&code.text));
176 out.push_str(&pad);
177 out.push_str(&fence);
178 out.push_str(language.as_deref().unwrap_or(""));
179 for line in code.text.split('\n') {
180 out.push('\n');
181 out.push_str(&pad);
182 out.push_str(line);
183 }
184 out.push('\n');
185 out.push_str(&pad);
186 out.push_str(&fence);
187 }
188 BlockKind::Image { url, alt, width } => {
189 out.push_str(&pad);
190 out.push_str(";
200 write_destination(out, url);
201 out.push(')');
202 }
203 BlockKind::Bookmark { url, form } => {
207 out.push_str(&pad);
208 match form.title() {
209 None => {
210 out.push('<');
211 out.push_str(url);
212 out.push('>');
213 }
214 Some(title) => {
215 out.push('[');
216 out.push_str(url);
217 out.push_str("](");
218 write_destination(out, url);
219 out.push_str(&format!(" \"{title}\")"));
220 }
221 }
222 }
223 BlockKind::Table {
224 align,
225 header,
226 rows,
227 } => write_table(out, &pad, align, header, rows, marks),
228 BlockKind::Rule => {
229 out.push_str(&pad);
230 out.push_str("---");
231 }
232 }
233}
234
235fn write_marked(out: &mut String, pad: &str, marker: &str, text: &Text, marks: &Marks) {
237 let opener = if text.is_empty() {
240 marker.trim_end()
241 } else {
242 marker
243 };
244 let first = format!("{pad}{opener}");
245 let rest = format!("{pad}{}", " ".repeat(marker.chars().count()));
246 write_lines(out, &first, &rest, &inline(text, marks));
247}
248
249fn write_lines(out: &mut String, first: &str, rest: &str, body: &str) {
250 for (ix, line) in body.split('\n').enumerate() {
251 if ix > 0 {
252 out.push('\n');
253 }
254 out.push_str(if ix == 0 { first } else { rest });
255 out.push_str(line);
256 }
257}
258
259fn fence_width(code: &str) -> usize {
261 let mut longest = 0;
262 let mut run = 0;
263 for c in code.chars() {
264 run = if c == '`' { run + 1 } else { 0 };
265 longest = longest.max(run);
266 }
267 (longest + 1).max(3)
268}
269
270fn write_table(
271 out: &mut String,
272 pad: &str,
273 align: &[Align],
274 header: &[Text],
275 rows: &[Vec<Text>],
276 marks: &Marks,
277) {
278 let columns = align.len().max(header.len());
279 let row_of = |cells: &[Text]| {
280 let mut line = String::from("|");
281 for ix in 0..columns {
282 line.push(' ');
283 if let Some(cell) = cells.get(ix) {
284 line.push_str(&inline(cell, marks));
286 }
287 line.push_str(" |");
288 }
289 line
290 };
291
292 out.push_str(pad);
293 out.push_str(&row_of(header));
294 out.push('\n');
295 out.push_str(pad);
296 out.push('|');
297 for ix in 0..columns {
298 out.push_str(match align.get(ix).copied().unwrap_or_default() {
299 Align::Left => " --- |",
300 Align::Center => " :-: |",
301 Align::Right => " ---: |",
302 });
303 }
304 for row in rows {
305 out.push('\n');
306 out.push_str(pad);
307 out.push_str(&row_of(row));
308 }
309}
310
311fn inline(text: &Text, marks: &Marks) -> String {
315 inline_in(text, marks, Lead::Line)
316}
317
318#[derive(Clone, Copy)]
320enum Lead {
321 Line,
324 Heading,
326}
327
328fn inline_in(text: &Text, marks: &Marks, lead: Lead) -> String {
329 let mut out = String::new();
330 let mut open: Vec<usize> = Vec::new();
331 let mut started = vec![false; text.marks.len()];
332 let mut delimiters = vec!['_'; text.marks.len()];
334 let mut cursor = 0usize;
335
336 let mut boundaries: Vec<usize> = text
337 .marks
338 .iter()
339 .flat_map(|m| [m.range.start, m.range.end])
340 .chain([0, text.text.len()])
341 .collect();
342 boundaries.sort_unstable();
343 boundaries.dedup();
344
345 for point in boundaries {
346 if point < cursor {
347 continue;
348 }
349 escape_inline(&mut out, &text.text[cursor..point], marks, lead);
350 cursor = point;
351
352 while let Some(&top) = open.last() {
353 if text.marks[top].range.end <= point {
354 close_mark(&mut out, &text.marks[top].mark, delimiters[top], marks);
355 open.pop();
356 } else {
357 break;
358 }
359 }
360
361 for (ix, span) in text.marks.iter().enumerate() {
362 if started[ix] || span.range.start != point {
363 continue;
364 }
365 started[ix] = true;
366 if span.mark == Mark::Code {
369 let body = &text.text[span.range.clone()];
370 let ticks = "`".repeat(fence_width_inline(body));
371 out.push_str(&ticks);
372 out.push_str(body);
373 out.push_str(&ticks);
374 cursor = cursor.max(span.range.end);
375 continue;
376 }
377 if let Mark::Mention { url, .. } = &span.mark
383 && crate::parse::is_shorthand(text, ix)
384 {
385 out.push('<');
386 out.push_str(url);
387 out.push('>');
388 cursor = cursor.max(span.range.end);
389 continue;
390 }
391 if let Mark::Link(url) = &span.mark
398 && text.text.get(span.range.clone()) == Some(url.as_str())
399 && crate::parse::is_url(url)
400 && text.alone(ix)
401 {
402 out.push_str(url);
403 cursor = cursor.max(span.range.end);
404 continue;
405 }
406 let italic = italic_delimiter(&out, text, &span.range);
407 delimiters[ix] = italic;
408 open_mark(&mut out, &span.mark, italic, marks);
409 if span.range.is_empty() {
412 close_mark(&mut out, &span.mark, italic, marks);
413 } else {
414 open.push(ix);
415 }
416 }
417 }
418
419 escape_inline(
420 &mut out,
421 &text.text[cursor.min(text.text.len())..],
422 marks,
423 lead,
424 );
425 while let Some(ix) = open.pop() {
426 close_mark(&mut out, &text.marks[ix].mark, delimiters[ix], marks);
427 }
428 out
429}
430
431fn fence_width_inline(body: &str) -> usize {
432 let mut longest = 0;
433 let mut run = 0;
434 for c in body.chars() {
435 run = if c == '`' { run + 1 } else { 0 };
436 longest = longest.max(run);
437 }
438 longest + 1
439}
440
441fn italic_delimiter(written: &str, text: &Text, range: &std::ops::Range<usize>) -> char {
454 let intraword = written
455 .chars()
456 .next_back()
457 .is_some_and(char::is_alphanumeric)
458 || text.text[range.end..]
459 .chars()
460 .next()
461 .is_some_and(char::is_alphanumeric);
462 if intraword { '*' } else { '_' }
463}
464
465fn open_mark(out: &mut String, mark: &Mark, italic: char, marks: &Marks) {
466 match mark {
467 Mark::Bold => out.push_str("**"),
468 Mark::Italic => out.push(italic),
469 Mark::Strike => out.push_str("~~"),
470 Mark::Link(_) | Mark::Mention { .. } => out.push('['),
471 Mark::Image(_) => out.push_str(";
486 write_destination(out, url);
487 out.push(')');
488 }
489 Mark::Mention { url, form } => {
492 out.push_str("](");
493 write_destination(out, url);
494 out.push_str(" \"");
495 out.push_str(form.title().unwrap_or("chip"));
496 out.push_str("\")");
497 }
498 Mark::Custom(name) => out.push_str(marks.delimiter(name).unwrap_or("")),
499 Mark::Code => {}
500 }
501}
502
503fn write_destination(out: &mut String, url: &str) {
509 if bare_destination(url) {
510 return out.push_str(url);
511 }
512 out.push('<');
513 for c in url.chars() {
514 match c {
515 '<' | '>' | '\\' => {
516 out.push('\\');
517 out.push(c);
518 }
519 c if c.is_ascii_control() => out.push_str(&format!("%{:02X}", c as u8)),
523 c => out.push(c),
524 }
525 }
526 out.push('>');
527}
528
529fn bare_destination(url: &str) -> bool {
533 if url.starts_with('<') {
534 return false;
535 }
536 let mut depth = 0i32;
537 for c in url.chars() {
538 match c {
539 '(' => depth += 1,
540 ')' if depth == 0 => return false,
541 ')' => depth -= 1,
542 '\\' => return false,
543 c if c.is_whitespace() || c.is_ascii_control() => return false,
544 _ => {}
545 }
546 }
547 depth == 0
548}
549
550fn escape_inline(out: &mut String, s: &str, marks: &Marks, lead: Lead) {
555 let mut line_start = out.is_empty() || out.ends_with('\n');
556 let mut lead = if out.is_empty() { lead } else { Lead::Line };
557 for (ix, line) in s.split('\n').enumerate() {
558 if ix > 0 {
559 out.push('\n');
560 line_start = true;
561 lead = Lead::Line;
562 }
563 let body = if line_start {
564 escape_block_marker(out, line, lead)
565 } else {
566 line
567 };
568 escape_span(out, body, marks);
569 line_start = false;
570 }
571}
572
573fn escape_block_marker<'a>(out: &mut String, line: &'a str, lead: Lead) -> &'a str {
577 let after_space = |rest: &str| rest.starts_with([' ', '\t']) || rest.is_empty();
578
579 let hashes = line.len() - line.trim_start_matches('#').len();
580 if hashes > 0 && after_space(&line[hashes..]) {
581 out.push('\\');
582 out.push_str(&line[..hashes]);
583 return &line[hashes..];
584 }
585 if let Lead::Heading = lead {
586 return line;
587 }
588
589 if let Some(rest) = line.strip_prefix('>') {
590 out.push_str("\\>");
591 return rest;
592 }
593
594 if (line.starts_with('-') || line.starts_with('+')) && after_space(&line[1..]) {
597 out.push('\\');
598 out.push_str(&line[..1]);
599 return &line[1..];
600 }
601
602 let digits = line.len() - line.trim_start_matches(|c: char| c.is_ascii_digit()).len();
603 if digits > 0 {
604 let after = &line[digits..];
605 if (after.starts_with('.') || after.starts_with(')')) && after_space(&after[1..]) {
606 out.push_str(&line[..digits]);
607 out.push('\\');
608 out.push_str(&after[..1]);
609 return &after[1..];
610 }
611 }
612
613 let trimmed = line.trim_end();
615 if !trimmed.is_empty() && trimmed.chars().all(|c| c == '=' || c == '-') {
616 out.push('\\');
617 out.push_str(&line[..1]);
618 return &line[1..];
619 }
620
621 line
622}
623
624fn escape_span(out: &mut String, s: &str, marks: &Marks) {
626 let mut skip = 0usize;
627 for (ix, c) in s.char_indices() {
628 if ix < skip {
629 continue;
630 }
631 let rest = &s[ix + c.len_utf8()..];
632 if let Some(entry) = marks
637 .sorted()
638 .into_iter()
639 .find(|entry| s[ix..].starts_with(entry.delimiter.as_ref()))
640 {
641 for c in entry.delimiter.chars() {
642 out.push('\\');
643 out.push(c);
644 }
645 skip = ix + entry.delimiter.len();
646 continue;
647 }
648 match c {
649 '\\' | '*' | '`' | '[' | ']' | '~' | '|' => {
655 out.push('\\');
656 out.push(c);
657 }
658 '_' => {
661 let before = s[..ix].chars().next_back();
662 let inside_word = before.is_some_and(char::is_alphanumeric)
663 && rest.chars().next().is_some_and(char::is_alphanumeric);
664 if !inside_word {
665 out.push('\\');
666 }
667 out.push('_');
668 }
669 '<' if rest
671 .chars()
672 .next()
673 .is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | '!' | '?')) =>
674 {
675 out.push_str("\\<")
676 }
677 '&' if rest
678 .chars()
679 .next()
680 .is_some_and(|c| c.is_alphanumeric() || c == '#') =>
681 {
682 out.push_str("\\&")
683 }
684 _ => out.push(c),
685 }
686 }
687}
688
689pub(crate) const SENTINEL: char = '\u{E000}';
692
693pub fn serialize_at(doc: &Doc, at: Cursor, marks: &Marks) -> (String, usize) {
702 let mut doc = doc.clone();
703 let placed = doc
704 .blocks
705 .get_mut(at.block)
706 .and_then(|block| block.text_at_mut(at.part))
707 .filter(|text| !text.text.contains(SENTINEL))
708 .map(|text| {
709 text.insert(
710 at.offset.min(text.text.len()),
711 SENTINEL.encode_utf8(&mut [0; 4]),
712 )
713 })
714 .is_some();
715 doc.normalize_with(marks);
719 let mut source = serialize_with(&doc, marks);
720 let Some(offset) = placed.then(|| source.find(SENTINEL)).flatten() else {
721 source = source.replace(SENTINEL, "");
722 let end = source.len();
723 return (source, end);
724 };
725 source.remove(offset);
726 (source, offset)
727}