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
29pub fn serialize_with(doc: &Doc, marks: &Marks) -> String {
31 let mut out = String::new();
32 let mut previous: Option<(&BlockKind, u8)> = None;
33
34 for block in &doc.blocks {
35 let indent = match previous {
36 Some((_, prev)) => block.indent.min(prev + 1),
37 None => 0,
38 };
39
40 if let Some((prev_kind, prev_indent)) = previous {
41 out.push('\n');
42 if !tight_after(prev_kind, &block.kind, indent > prev_indent) {
43 out.push('\n');
44 }
45 }
46
47 write_block(&mut out, &block.kind, indent, marks);
48 previous = Some((&block.kind, indent));
49 }
50
51 if doc
55 .blocks
56 .last()
57 .is_some_and(|block| matches!(&block.kind, BlockKind::Task { text, .. } if text.is_empty()))
58 {
59 out.push(' ');
60 }
61
62 out
63}
64
65fn marker_kind(kind: &BlockKind) -> Option<u8> {
68 match kind {
69 BlockKind::Bullet(_) => Some(0),
70 BlockKind::Ordered { .. } => Some(1),
71 BlockKind::Task { .. } => Some(2),
72 _ => None,
73 }
74}
75
76fn is_marker(kind: &BlockKind) -> bool {
77 marker_kind(kind).is_some()
78}
79
80fn tight_after(previous: &BlockKind, next: &BlockKind, nested: bool) -> bool {
106 if is_empty_marker(previous) {
107 return true;
108 }
109 if is_empty_marker(next) {
110 return false;
111 }
112 if nested {
113 return is_marker(previous)
114 && is_marker(next)
115 && !matches!(next, BlockKind::Ordered { number, .. } if *number != 1);
116 }
117 marker_kind(previous).is_some() && marker_kind(previous) == marker_kind(next)
118}
119
120fn is_empty_marker(kind: &BlockKind) -> bool {
121 is_marker(kind)
122 && Block::new(kind.clone())
123 .text_at(Part::Body)
124 .is_some_and(Text::is_empty)
125}
126
127fn write_block(out: &mut String, kind: &BlockKind, indent: u8, marks: &Marks) {
128 let pad = INDENT.repeat(indent as usize);
129
130 match kind {
131 BlockKind::Paragraph(text) => write_lines(out, &pad, &pad, &inline(text, marks)),
132 BlockKind::Heading { level, text } => {
133 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
134 write_lines(out, &format!("{pad}{hashes} "), &pad, &inline(text, marks));
135 }
136 BlockKind::Bullet(text) => {
141 let marker = if text.is_empty() { "+ " } else { "- " };
142 write_marked(out, &pad, marker, text, marks)
143 }
144 BlockKind::Ordered { number, text } => {
145 write_marked(out, &pad, &format!("{number}. "), text, marks)
146 }
147 BlockKind::Task { checked, text } => {
148 let marker = if *checked { "- [x] " } else { "- [ ] " };
149 write_marked(out, &pad, marker, text, marks);
150 }
151 BlockKind::Quote { kind, text } => {
152 let prefix = format!("{pad}> ");
153 let body = inline(text, marks);
157 if let Some(kind) = kind {
158 out.push_str(&prefix);
159 out.push_str(kind.marker());
160 if body.is_empty() {
161 return;
162 }
163 out.push('\n');
164 }
165 write_lines(out, &prefix, &prefix, &body);
166 }
167 BlockKind::Code { language, code } => {
168 let fence = "`".repeat(fence_width(&code.text));
169 out.push_str(&pad);
170 out.push_str(&fence);
171 out.push_str(language.as_deref().unwrap_or(""));
172 for line in code.text.split('\n') {
173 out.push('\n');
174 out.push_str(&pad);
175 out.push_str(line);
176 }
177 out.push('\n');
178 out.push_str(&pad);
179 out.push_str(&fence);
180 }
181 BlockKind::Image { url, alt, width } => {
182 out.push_str(&pad);
183 out.push_str(";
193 write_destination(out, url);
194 out.push(')');
195 }
196 BlockKind::Bookmark { url, form } => {
200 out.push_str(&pad);
201 match form.title() {
202 None => {
203 out.push('<');
204 out.push_str(url);
205 out.push('>');
206 }
207 Some(title) => {
208 out.push('[');
209 out.push_str(url);
210 out.push_str("](");
211 write_destination(out, url);
212 out.push_str(&format!(" \"{title}\")"));
213 }
214 }
215 }
216 BlockKind::Table {
217 align,
218 header,
219 rows,
220 } => write_table(out, &pad, align, header, rows, marks),
221 BlockKind::Rule => {
222 out.push_str(&pad);
223 out.push_str("---");
224 }
225 }
226}
227
228fn write_marked(out: &mut String, pad: &str, marker: &str, text: &Text, marks: &Marks) {
230 let opener = if text.is_empty() {
233 marker.trim_end()
234 } else {
235 marker
236 };
237 let first = format!("{pad}{opener}");
238 let rest = format!("{pad}{}", " ".repeat(marker.chars().count()));
239 write_lines(out, &first, &rest, &inline(text, marks));
240}
241
242fn write_lines(out: &mut String, first: &str, rest: &str, body: &str) {
243 for (ix, line) in body.split('\n').enumerate() {
244 if ix > 0 {
245 out.push('\n');
246 }
247 out.push_str(if ix == 0 { first } else { rest });
248 out.push_str(line);
249 }
250}
251
252fn fence_width(code: &str) -> usize {
254 let mut longest = 0;
255 let mut run = 0;
256 for c in code.chars() {
257 run = if c == '`' { run + 1 } else { 0 };
258 longest = longest.max(run);
259 }
260 (longest + 1).max(3)
261}
262
263fn write_table(
264 out: &mut String,
265 pad: &str,
266 align: &[Align],
267 header: &[Text],
268 rows: &[Vec<Text>],
269 marks: &Marks,
270) {
271 let columns = align.len().max(header.len());
272 let row_of = |cells: &[Text]| {
273 let mut line = String::from("|");
274 for ix in 0..columns {
275 line.push(' ');
276 if let Some(cell) = cells.get(ix) {
277 line.push_str(&inline(cell, marks));
279 }
280 line.push_str(" |");
281 }
282 line
283 };
284
285 out.push_str(pad);
286 out.push_str(&row_of(header));
287 out.push('\n');
288 out.push_str(pad);
289 out.push('|');
290 for ix in 0..columns {
291 out.push_str(match align.get(ix).copied().unwrap_or_default() {
292 Align::Left => " --- |",
293 Align::Center => " :-: |",
294 Align::Right => " ---: |",
295 });
296 }
297 for row in rows {
298 out.push('\n');
299 out.push_str(pad);
300 out.push_str(&row_of(row));
301 }
302}
303
304fn inline(text: &Text, marks: &Marks) -> String {
308 let mut out = String::new();
309 let mut open: Vec<usize> = Vec::new();
310 let mut started = vec![false; text.marks.len()];
311 let mut delimiters = vec!['_'; text.marks.len()];
313 let mut cursor = 0usize;
314
315 let mut boundaries: Vec<usize> = text
316 .marks
317 .iter()
318 .flat_map(|m| [m.range.start, m.range.end])
319 .chain([0, text.text.len()])
320 .collect();
321 boundaries.sort_unstable();
322 boundaries.dedup();
323
324 for point in boundaries {
325 if point < cursor {
326 continue;
327 }
328 escape_inline(&mut out, &text.text[cursor..point], marks);
329 cursor = point;
330
331 while let Some(&top) = open.last() {
332 if text.marks[top].range.end <= point {
333 close_mark(&mut out, &text.marks[top].mark, delimiters[top], marks);
334 open.pop();
335 } else {
336 break;
337 }
338 }
339
340 for (ix, span) in text.marks.iter().enumerate() {
341 if started[ix] || span.range.start != point {
342 continue;
343 }
344 started[ix] = true;
345 if span.mark == Mark::Code {
348 let body = &text.text[span.range.clone()];
349 let ticks = "`".repeat(fence_width_inline(body));
350 out.push_str(&ticks);
351 out.push_str(body);
352 out.push_str(&ticks);
353 cursor = cursor.max(span.range.end);
354 continue;
355 }
356 if let Mark::Mention { url, .. } = &span.mark
362 && crate::parse::is_shorthand(text, ix)
363 {
364 out.push('<');
365 out.push_str(url);
366 out.push('>');
367 cursor = cursor.max(span.range.end);
368 continue;
369 }
370 if let Mark::Link(url) = &span.mark
377 && text.text.get(span.range.clone()) == Some(url.as_str())
378 && crate::parse::is_url(url)
379 && text.alone(ix)
380 {
381 out.push_str(url);
382 cursor = cursor.max(span.range.end);
383 continue;
384 }
385 let italic = italic_delimiter(&out, text, &span.range);
386 delimiters[ix] = italic;
387 open_mark(&mut out, &span.mark, italic, marks);
388 if span.range.is_empty() {
391 close_mark(&mut out, &span.mark, italic, marks);
392 } else {
393 open.push(ix);
394 }
395 }
396 }
397
398 escape_inline(&mut out, &text.text[cursor.min(text.text.len())..], marks);
399 while let Some(ix) = open.pop() {
400 close_mark(&mut out, &text.marks[ix].mark, delimiters[ix], marks);
401 }
402 out
403}
404
405fn fence_width_inline(body: &str) -> usize {
406 let mut longest = 0;
407 let mut run = 0;
408 for c in body.chars() {
409 run = if c == '`' { run + 1 } else { 0 };
410 longest = longest.max(run);
411 }
412 longest + 1
413}
414
415fn italic_delimiter(written: &str, text: &Text, range: &std::ops::Range<usize>) -> char {
428 let intraword = written
429 .chars()
430 .next_back()
431 .is_some_and(char::is_alphanumeric)
432 || text.text[range.end..]
433 .chars()
434 .next()
435 .is_some_and(char::is_alphanumeric);
436 if intraword { '*' } else { '_' }
437}
438
439fn open_mark(out: &mut String, mark: &Mark, italic: char, marks: &Marks) {
440 match mark {
441 Mark::Bold => out.push_str("**"),
442 Mark::Italic => out.push(italic),
443 Mark::Strike => out.push_str("~~"),
444 Mark::Link(_) | Mark::Mention { .. } => out.push('['),
445 Mark::Image(_) => out.push_str(";
460 write_destination(out, url);
461 out.push(')');
462 }
463 Mark::Mention { url, form } => {
466 out.push_str("](");
467 write_destination(out, url);
468 out.push_str(" \"");
469 out.push_str(form.title().unwrap_or("chip"));
470 out.push_str("\")");
471 }
472 Mark::Custom(name) => out.push_str(marks.delimiter(name).unwrap_or("")),
473 Mark::Code => {}
474 }
475}
476
477fn write_destination(out: &mut String, url: &str) {
483 if bare_destination(url) {
484 return out.push_str(url);
485 }
486 out.push('<');
487 for c in url.chars() {
488 match c {
489 '<' | '>' | '\\' => {
490 out.push('\\');
491 out.push(c);
492 }
493 c if c.is_ascii_control() => out.push_str(&format!("%{:02X}", c as u8)),
497 c => out.push(c),
498 }
499 }
500 out.push('>');
501}
502
503fn bare_destination(url: &str) -> bool {
507 if url.starts_with('<') {
508 return false;
509 }
510 let mut depth = 0i32;
511 for c in url.chars() {
512 match c {
513 '(' => depth += 1,
514 ')' if depth == 0 => return false,
515 ')' => depth -= 1,
516 '\\' => return false,
517 c if c.is_whitespace() || c.is_ascii_control() => return false,
518 _ => {}
519 }
520 }
521 depth == 0
522}
523
524fn escape_inline(out: &mut String, s: &str, marks: &Marks) {
529 let mut line_start = out.is_empty() || out.ends_with('\n');
530 for (ix, line) in s.split('\n').enumerate() {
531 if ix > 0 {
532 out.push('\n');
533 line_start = true;
534 }
535 let body = if line_start {
536 escape_block_marker(out, line)
537 } else {
538 line
539 };
540 escape_span(out, body, marks);
541 line_start = false;
542 }
543}
544
545fn escape_block_marker<'a>(out: &mut String, line: &'a str) -> &'a str {
549 let after_space = |rest: &str| rest.starts_with([' ', '\t']) || rest.is_empty();
550
551 let hashes = line.len() - line.trim_start_matches('#').len();
552 if hashes > 0 && after_space(&line[hashes..]) {
553 out.push('\\');
554 out.push_str(&line[..hashes]);
555 return &line[hashes..];
556 }
557
558 if let Some(rest) = line.strip_prefix('>') {
559 out.push_str("\\>");
560 return rest;
561 }
562
563 if (line.starts_with('-') || line.starts_with('+')) && after_space(&line[1..]) {
566 out.push('\\');
567 out.push_str(&line[..1]);
568 return &line[1..];
569 }
570
571 let digits = line.len() - line.trim_start_matches(|c: char| c.is_ascii_digit()).len();
572 if digits > 0 {
573 let after = &line[digits..];
574 if (after.starts_with('.') || after.starts_with(')')) && after_space(&after[1..]) {
575 out.push_str(&line[..digits]);
576 out.push('\\');
577 out.push_str(&after[..1]);
578 return &after[1..];
579 }
580 }
581
582 let trimmed = line.trim_end();
584 if !trimmed.is_empty() && trimmed.chars().all(|c| c == '=' || c == '-') {
585 out.push('\\');
586 out.push_str(&line[..1]);
587 return &line[1..];
588 }
589
590 line
591}
592
593fn escape_span(out: &mut String, s: &str, marks: &Marks) {
595 let mut skip = 0usize;
596 for (ix, c) in s.char_indices() {
597 if ix < skip {
598 continue;
599 }
600 let rest = &s[ix + c.len_utf8()..];
601 if let Some(entry) = marks
606 .sorted()
607 .into_iter()
608 .find(|entry| s[ix..].starts_with(entry.delimiter.as_ref()))
609 {
610 for c in entry.delimiter.chars() {
611 out.push('\\');
612 out.push(c);
613 }
614 skip = ix + entry.delimiter.len();
615 continue;
616 }
617 match c {
618 '\\' | '*' | '`' | '[' | ']' | '~' | '|' => {
624 out.push('\\');
625 out.push(c);
626 }
627 '_' => {
630 let before = s[..ix].chars().next_back();
631 let inside_word = before.is_some_and(char::is_alphanumeric)
632 && rest.chars().next().is_some_and(char::is_alphanumeric);
633 if !inside_word {
634 out.push('\\');
635 }
636 out.push('_');
637 }
638 '<' if rest
640 .chars()
641 .next()
642 .is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | '!' | '?')) =>
643 {
644 out.push_str("\\<")
645 }
646 '&' if rest
647 .chars()
648 .next()
649 .is_some_and(|c| c.is_alphanumeric() || c == '#') =>
650 {
651 out.push_str("\\&")
652 }
653 _ => out.push(c),
654 }
655 }
656}
657
658pub(crate) const SENTINEL: char = '\u{E000}';
661
662pub fn serialize_at(doc: &Doc, at: Cursor, marks: &Marks) -> (String, usize) {
671 let mut doc = doc.clone();
672 let placed = doc
673 .blocks
674 .get_mut(at.block)
675 .and_then(|block| block.text_at_mut(at.part))
676 .filter(|text| !text.text.contains(SENTINEL))
677 .map(|text| {
678 text.insert(
679 at.offset.min(text.text.len()),
680 SENTINEL.encode_utf8(&mut [0; 4]),
681 )
682 })
683 .is_some();
684 doc.normalize_with(marks);
688 let mut source = serialize_with(&doc, marks);
689 let Some(offset) = placed.then(|| source.find(SENTINEL)).flatten() else {
690 source = source.replace(SENTINEL, "");
691 let end = source.len();
692 return (source, end);
693 };
694 source.remove(offset);
695 (source, offset)
696}