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(text) => {
152 let prefix = format!("{pad}> ");
153 write_lines(out, &prefix, &prefix, &inline(text, marks));
154 }
155 BlockKind::Code { language, code } => {
156 let fence = "`".repeat(fence_width(&code.text));
157 out.push_str(&pad);
158 out.push_str(&fence);
159 out.push_str(language.as_deref().unwrap_or(""));
160 for line in code.text.split('\n') {
161 out.push('\n');
162 out.push_str(&pad);
163 out.push_str(line);
164 }
165 out.push('\n');
166 out.push_str(&pad);
167 out.push_str(&fence);
168 }
169 BlockKind::Image { url, alt, width } => {
170 out.push_str(&pad);
171 out.push_str(";
181 write_destination(out, url);
182 out.push(')');
183 }
184 BlockKind::Bookmark { url, form } => {
188 out.push_str(&pad);
189 match form.title() {
190 None => {
191 out.push('<');
192 out.push_str(url);
193 out.push('>');
194 }
195 Some(title) => {
196 out.push('[');
197 out.push_str(url);
198 out.push_str("](");
199 write_destination(out, url);
200 out.push_str(&format!(" \"{title}\")"));
201 }
202 }
203 }
204 BlockKind::Table {
205 align,
206 header,
207 rows,
208 } => write_table(out, &pad, align, header, rows, marks),
209 BlockKind::Rule => {
210 out.push_str(&pad);
211 out.push_str("---");
212 }
213 }
214}
215
216fn write_marked(out: &mut String, pad: &str, marker: &str, text: &Text, marks: &Marks) {
218 let opener = if text.is_empty() {
221 marker.trim_end()
222 } else {
223 marker
224 };
225 let first = format!("{pad}{opener}");
226 let rest = format!("{pad}{}", " ".repeat(marker.chars().count()));
227 write_lines(out, &first, &rest, &inline(text, marks));
228}
229
230fn write_lines(out: &mut String, first: &str, rest: &str, body: &str) {
231 for (ix, line) in body.split('\n').enumerate() {
232 if ix > 0 {
233 out.push('\n');
234 }
235 out.push_str(if ix == 0 { first } else { rest });
236 out.push_str(line);
237 }
238}
239
240fn fence_width(code: &str) -> usize {
242 let mut longest = 0;
243 let mut run = 0;
244 for c in code.chars() {
245 run = if c == '`' { run + 1 } else { 0 };
246 longest = longest.max(run);
247 }
248 (longest + 1).max(3)
249}
250
251fn write_table(
252 out: &mut String,
253 pad: &str,
254 align: &[Align],
255 header: &[Text],
256 rows: &[Vec<Text>],
257 marks: &Marks,
258) {
259 let columns = align.len().max(header.len());
260 let row_of = |cells: &[Text]| {
261 let mut line = String::from("|");
262 for ix in 0..columns {
263 line.push(' ');
264 if let Some(cell) = cells.get(ix) {
265 line.push_str(&inline(cell, marks));
267 }
268 line.push_str(" |");
269 }
270 line
271 };
272
273 out.push_str(pad);
274 out.push_str(&row_of(header));
275 out.push('\n');
276 out.push_str(pad);
277 out.push('|');
278 for ix in 0..columns {
279 out.push_str(match align.get(ix).copied().unwrap_or_default() {
280 Align::Left => " --- |",
281 Align::Center => " :-: |",
282 Align::Right => " ---: |",
283 });
284 }
285 for row in rows {
286 out.push('\n');
287 out.push_str(pad);
288 out.push_str(&row_of(row));
289 }
290}
291
292fn inline(text: &Text, marks: &Marks) -> String {
296 let mut out = String::new();
297 let mut open: Vec<usize> = Vec::new();
298 let mut started = vec![false; text.marks.len()];
299 let mut delimiters = vec!['_'; text.marks.len()];
301 let mut cursor = 0usize;
302
303 let mut boundaries: Vec<usize> = text
304 .marks
305 .iter()
306 .flat_map(|m| [m.range.start, m.range.end])
307 .chain([0, text.text.len()])
308 .collect();
309 boundaries.sort_unstable();
310 boundaries.dedup();
311
312 for point in boundaries {
313 if point < cursor {
314 continue;
315 }
316 escape_inline(&mut out, &text.text[cursor..point], marks);
317 cursor = point;
318
319 while let Some(&top) = open.last() {
320 if text.marks[top].range.end <= point {
321 close_mark(&mut out, &text.marks[top].mark, delimiters[top], marks);
322 open.pop();
323 } else {
324 break;
325 }
326 }
327
328 for (ix, span) in text.marks.iter().enumerate() {
329 if started[ix] || span.range.start != point {
330 continue;
331 }
332 started[ix] = true;
333 if span.mark == Mark::Code {
336 let body = &text.text[span.range.clone()];
337 let ticks = "`".repeat(fence_width_inline(body));
338 out.push_str(&ticks);
339 out.push_str(body);
340 out.push_str(&ticks);
341 cursor = cursor.max(span.range.end);
342 continue;
343 }
344 if let Mark::Mention { url, .. } = &span.mark
350 && crate::parse::is_shorthand(text, ix)
351 {
352 out.push('<');
353 out.push_str(url);
354 out.push('>');
355 cursor = cursor.max(span.range.end);
356 continue;
357 }
358 if let Mark::Link(url) = &span.mark
365 && text.text.get(span.range.clone()) == Some(url.as_str())
366 && crate::parse::is_url(url)
367 && text.alone(ix)
368 {
369 out.push_str(url);
370 cursor = cursor.max(span.range.end);
371 continue;
372 }
373 let italic = italic_delimiter(&out, text, &span.range);
374 delimiters[ix] = italic;
375 open_mark(&mut out, &span.mark, italic, marks);
376 if span.range.is_empty() {
379 close_mark(&mut out, &span.mark, italic, marks);
380 } else {
381 open.push(ix);
382 }
383 }
384 }
385
386 escape_inline(&mut out, &text.text[cursor.min(text.text.len())..], marks);
387 while let Some(ix) = open.pop() {
388 close_mark(&mut out, &text.marks[ix].mark, delimiters[ix], marks);
389 }
390 out
391}
392
393fn fence_width_inline(body: &str) -> usize {
394 let mut longest = 0;
395 let mut run = 0;
396 for c in body.chars() {
397 run = if c == '`' { run + 1 } else { 0 };
398 longest = longest.max(run);
399 }
400 longest + 1
401}
402
403fn italic_delimiter(written: &str, text: &Text, range: &std::ops::Range<usize>) -> char {
416 let intraword = written
417 .chars()
418 .next_back()
419 .is_some_and(char::is_alphanumeric)
420 || text.text[range.end..]
421 .chars()
422 .next()
423 .is_some_and(char::is_alphanumeric);
424 if intraword { '*' } else { '_' }
425}
426
427fn open_mark(out: &mut String, mark: &Mark, italic: char, marks: &Marks) {
428 match mark {
429 Mark::Bold => out.push_str("**"),
430 Mark::Italic => out.push(italic),
431 Mark::Strike => out.push_str("~~"),
432 Mark::Link(_) | Mark::Mention { .. } => out.push('['),
433 Mark::Image(_) => out.push_str(";
448 write_destination(out, url);
449 out.push(')');
450 }
451 Mark::Mention { url, form } => {
454 out.push_str("](");
455 write_destination(out, url);
456 out.push_str(" \"");
457 out.push_str(form.title().unwrap_or("chip"));
458 out.push_str("\")");
459 }
460 Mark::Custom(name) => out.push_str(marks.delimiter(name).unwrap_or("")),
461 Mark::Code => {}
462 }
463}
464
465fn write_destination(out: &mut String, url: &str) {
471 if bare_destination(url) {
472 return out.push_str(url);
473 }
474 out.push('<');
475 for c in url.chars() {
476 match c {
477 '<' | '>' | '\\' => {
478 out.push('\\');
479 out.push(c);
480 }
481 c if c.is_ascii_control() => out.push_str(&format!("%{:02X}", c as u8)),
485 c => out.push(c),
486 }
487 }
488 out.push('>');
489}
490
491fn bare_destination(url: &str) -> bool {
495 if url.starts_with('<') {
496 return false;
497 }
498 let mut depth = 0i32;
499 for c in url.chars() {
500 match c {
501 '(' => depth += 1,
502 ')' if depth == 0 => return false,
503 ')' => depth -= 1,
504 '\\' => return false,
505 c if c.is_whitespace() || c.is_ascii_control() => return false,
506 _ => {}
507 }
508 }
509 depth == 0
510}
511
512fn escape_inline(out: &mut String, s: &str, marks: &Marks) {
517 let mut line_start = out.is_empty() || out.ends_with('\n');
518 for (ix, line) in s.split('\n').enumerate() {
519 if ix > 0 {
520 out.push('\n');
521 line_start = true;
522 }
523 let body = if line_start {
524 escape_block_marker(out, line)
525 } else {
526 line
527 };
528 escape_span(out, body, marks);
529 line_start = false;
530 }
531}
532
533fn escape_block_marker<'a>(out: &mut String, line: &'a str) -> &'a str {
537 let after_space = |rest: &str| rest.starts_with([' ', '\t']) || rest.is_empty();
538
539 let hashes = line.len() - line.trim_start_matches('#').len();
540 if hashes > 0 && after_space(&line[hashes..]) {
541 out.push('\\');
542 out.push_str(&line[..hashes]);
543 return &line[hashes..];
544 }
545
546 if let Some(rest) = line.strip_prefix('>') {
547 out.push_str("\\>");
548 return rest;
549 }
550
551 if (line.starts_with('-') || line.starts_with('+')) && after_space(&line[1..]) {
554 out.push('\\');
555 out.push_str(&line[..1]);
556 return &line[1..];
557 }
558
559 let digits = line.len() - line.trim_start_matches(|c: char| c.is_ascii_digit()).len();
560 if digits > 0 {
561 let after = &line[digits..];
562 if (after.starts_with('.') || after.starts_with(')')) && after_space(&after[1..]) {
563 out.push_str(&line[..digits]);
564 out.push('\\');
565 out.push_str(&after[..1]);
566 return &after[1..];
567 }
568 }
569
570 let trimmed = line.trim_end();
572 if !trimmed.is_empty() && trimmed.chars().all(|c| c == '=' || c == '-') {
573 out.push('\\');
574 out.push_str(&line[..1]);
575 return &line[1..];
576 }
577
578 line
579}
580
581fn escape_span(out: &mut String, s: &str, marks: &Marks) {
583 let mut skip = 0usize;
584 for (ix, c) in s.char_indices() {
585 if ix < skip {
586 continue;
587 }
588 let rest = &s[ix + c.len_utf8()..];
589 if let Some(entry) = marks
594 .sorted()
595 .into_iter()
596 .find(|entry| s[ix..].starts_with(entry.delimiter.as_ref()))
597 {
598 for c in entry.delimiter.chars() {
599 out.push('\\');
600 out.push(c);
601 }
602 skip = ix + entry.delimiter.len();
603 continue;
604 }
605 match c {
606 '\\' | '*' | '`' | '[' | ']' | '~' | '|' => {
612 out.push('\\');
613 out.push(c);
614 }
615 '_' => {
618 let before = s[..ix].chars().next_back();
619 let inside_word = before.is_some_and(char::is_alphanumeric)
620 && rest.chars().next().is_some_and(char::is_alphanumeric);
621 if !inside_word {
622 out.push('\\');
623 }
624 out.push('_');
625 }
626 '<' if rest
628 .chars()
629 .next()
630 .is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | '!' | '?')) =>
631 {
632 out.push_str("\\<")
633 }
634 '&' if rest
635 .chars()
636 .next()
637 .is_some_and(|c| c.is_alphanumeric() || c == '#') =>
638 {
639 out.push_str("\\&")
640 }
641 _ => out.push(c),
642 }
643 }
644}
645
646pub(crate) const SENTINEL: char = '\u{E000}';
649
650pub fn serialize_at(doc: &Doc, at: Cursor, marks: &Marks) -> (String, usize) {
659 let mut doc = doc.clone();
660 let placed = doc
661 .blocks
662 .get_mut(at.block)
663 .and_then(|block| block.text_at_mut(at.part))
664 .filter(|text| !text.text.contains(SENTINEL))
665 .map(|text| {
666 text.insert(
667 at.offset.min(text.text.len()),
668 SENTINEL.encode_utf8(&mut [0; 4]),
669 )
670 })
671 .is_some();
672 doc.normalize_with(marks);
676 let mut source = serialize_with(&doc, marks);
677 let Some(offset) = placed.then(|| source.find(SENTINEL)).flatten() else {
678 source = source.replace(SENTINEL, "");
679 let end = source.len();
680 return (source, end);
681 };
682 source.remove(offset);
683 (source, offset)
684}