1use crate::doc::{Align, Block, BlockKind, Doc, Mark, Part, Text};
15
16const INDENT: &str = " ";
20
21pub fn serialize(doc: &Doc) -> String {
22 let mut out = String::new();
23 let mut previous: Option<(&BlockKind, u8)> = None;
24
25 for block in &doc.blocks {
26 let indent = match previous {
27 Some((_, prev)) => block.indent.min(prev + 1),
28 None => 0,
29 };
30
31 if let Some((prev_kind, prev_indent)) = previous {
32 out.push('\n');
33 if !tight_after(prev_kind, &block.kind, indent > prev_indent) {
34 out.push('\n');
35 }
36 }
37
38 write_block(&mut out, &block.kind, indent);
39 previous = Some((&block.kind, indent));
40 }
41
42 if doc
46 .blocks
47 .last()
48 .is_some_and(|block| matches!(&block.kind, BlockKind::Task { text, .. } if text.is_empty()))
49 {
50 out.push(' ');
51 }
52
53 out
54}
55
56fn marker_kind(kind: &BlockKind) -> Option<u8> {
59 match kind {
60 BlockKind::Bullet(_) => Some(0),
61 BlockKind::Ordered { .. } => Some(1),
62 BlockKind::Task { .. } => Some(2),
63 _ => None,
64 }
65}
66
67fn is_marker(kind: &BlockKind) -> bool {
68 marker_kind(kind).is_some()
69}
70
71fn tight_after(previous: &BlockKind, next: &BlockKind, nested: bool) -> bool {
97 if is_empty_marker(previous) {
98 return true;
99 }
100 if is_empty_marker(next) {
101 return false;
102 }
103 if nested {
104 return is_marker(previous)
105 && is_marker(next)
106 && !matches!(next, BlockKind::Ordered { number, .. } if *number != 1);
107 }
108 marker_kind(previous).is_some() && marker_kind(previous) == marker_kind(next)
109}
110
111fn is_empty_marker(kind: &BlockKind) -> bool {
112 is_marker(kind)
113 && Block::new(kind.clone())
114 .text_at(Part::Body)
115 .is_some_and(Text::is_empty)
116}
117
118fn write_block(out: &mut String, kind: &BlockKind, indent: u8) {
119 let pad = INDENT.repeat(indent as usize);
120
121 match kind {
122 BlockKind::Paragraph(text) => write_lines(out, &pad, &pad, &inline(text)),
123 BlockKind::Heading { level, text } => {
124 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
125 write_lines(out, &format!("{pad}{hashes} "), &pad, &inline(text));
126 }
127 BlockKind::Bullet(text) => {
132 let marker = if text.is_empty() { "+ " } else { "- " };
133 write_marked(out, &pad, marker, text)
134 }
135 BlockKind::Ordered { number, text } => {
136 write_marked(out, &pad, &format!("{number}. "), text)
137 }
138 BlockKind::Task { checked, text } => {
139 let marker = if *checked { "- [x] " } else { "- [ ] " };
140 write_marked(out, &pad, marker, text);
141 }
142 BlockKind::Quote(text) => {
143 let prefix = format!("{pad}> ");
144 write_lines(out, &prefix, &prefix, &inline(text));
145 }
146 BlockKind::Code { language, code } => {
147 let fence = "`".repeat(fence_width(&code.text));
148 out.push_str(&pad);
149 out.push_str(&fence);
150 out.push_str(language.as_deref().unwrap_or(""));
151 for line in code.text.split('\n') {
152 out.push('\n');
153 out.push_str(&pad);
154 out.push_str(line);
155 }
156 out.push('\n');
157 out.push_str(&pad);
158 out.push_str(&fence);
159 }
160 BlockKind::Image { url, alt, width } => {
161 out.push_str(&pad);
162 out.push_str(";
172 out.push_str(url);
173 out.push(')');
174 }
175 BlockKind::Bookmark { url, form } => {
179 out.push_str(&pad);
180 match form.title() {
181 None => {
182 out.push('<');
183 out.push_str(url);
184 out.push('>');
185 }
186 Some(title) => {
187 out.push('[');
188 out.push_str(url);
189 out.push_str("](");
190 out.push_str(url);
191 out.push_str(&format!(" \"{title}\")"));
192 }
193 }
194 }
195 BlockKind::Table {
196 align,
197 header,
198 rows,
199 } => write_table(out, &pad, align, header, rows),
200 BlockKind::Rule => {
201 out.push_str(&pad);
202 out.push_str("---");
203 }
204 }
205}
206
207fn write_marked(out: &mut String, pad: &str, marker: &str, text: &Text) {
209 let opener = if text.is_empty() {
212 marker.trim_end()
213 } else {
214 marker
215 };
216 let first = format!("{pad}{opener}");
217 let rest = format!("{pad}{}", " ".repeat(marker.chars().count()));
218 write_lines(out, &first, &rest, &inline(text));
219}
220
221fn write_lines(out: &mut String, first: &str, rest: &str, body: &str) {
222 for (ix, line) in body.split('\n').enumerate() {
223 if ix > 0 {
224 out.push('\n');
225 }
226 out.push_str(if ix == 0 { first } else { rest });
227 out.push_str(line);
228 }
229}
230
231fn fence_width(code: &str) -> usize {
233 let mut longest = 0;
234 let mut run = 0;
235 for c in code.chars() {
236 run = if c == '`' { run + 1 } else { 0 };
237 longest = longest.max(run);
238 }
239 (longest + 1).max(3)
240}
241
242fn write_table(out: &mut String, pad: &str, align: &[Align], header: &[Text], rows: &[Vec<Text>]) {
243 let columns = align.len().max(header.len());
244 let row_of = |cells: &[Text]| {
245 let mut line = String::from("|");
246 for ix in 0..columns {
247 line.push(' ');
248 if let Some(cell) = cells.get(ix) {
249 line.push_str(&inline(cell));
251 }
252 line.push_str(" |");
253 }
254 line
255 };
256
257 out.push_str(pad);
258 out.push_str(&row_of(header));
259 out.push('\n');
260 out.push_str(pad);
261 out.push('|');
262 for ix in 0..columns {
263 out.push_str(match align.get(ix).copied().unwrap_or_default() {
264 Align::Left => " --- |",
265 Align::Center => " :-: |",
266 Align::Right => " ---: |",
267 });
268 }
269 for row in rows {
270 out.push('\n');
271 out.push_str(pad);
272 out.push_str(&row_of(row));
273 }
274}
275
276fn inline(text: &Text) -> String {
280 let mut out = String::new();
281 let mut open: Vec<usize> = Vec::new();
282 let mut started = vec![false; text.marks.len()];
283 let mut delimiters = vec!['_'; text.marks.len()];
285 let mut cursor = 0usize;
286
287 let mut boundaries: Vec<usize> = text
288 .marks
289 .iter()
290 .flat_map(|m| [m.range.start, m.range.end])
291 .chain([0, text.text.len()])
292 .collect();
293 boundaries.sort_unstable();
294 boundaries.dedup();
295
296 for point in boundaries {
297 if point < cursor {
298 continue;
299 }
300 escape_inline(&mut out, &text.text[cursor..point]);
301 cursor = point;
302
303 while let Some(&top) = open.last() {
304 if text.marks[top].range.end <= point {
305 close_mark(&mut out, &text.marks[top].mark, delimiters[top]);
306 open.pop();
307 } else {
308 break;
309 }
310 }
311
312 for (ix, span) in text.marks.iter().enumerate() {
313 if started[ix] || span.range.start != point {
314 continue;
315 }
316 started[ix] = true;
317 if span.mark == Mark::Code {
320 let body = &text.text[span.range.clone()];
321 let ticks = "`".repeat(fence_width_inline(body));
322 out.push_str(&ticks);
323 out.push_str(body);
324 out.push_str(&ticks);
325 cursor = cursor.max(span.range.end);
326 continue;
327 }
328 if let Mark::Mention { url, .. } = &span.mark
334 && crate::parse::is_shorthand(text, ix)
335 {
336 out.push('<');
337 out.push_str(url);
338 out.push('>');
339 cursor = cursor.max(span.range.end);
340 continue;
341 }
342 if let Mark::Link(url) = &span.mark
349 && text.text.get(span.range.clone()) == Some(url.as_str())
350 && crate::parse::is_url(url)
351 && text.alone(ix)
352 {
353 out.push_str(url);
354 cursor = cursor.max(span.range.end);
355 continue;
356 }
357 let italic = italic_delimiter(&out, text, &span.range);
358 delimiters[ix] = italic;
359 open_mark(&mut out, &span.mark, italic);
360 if span.range.is_empty() {
363 close_mark(&mut out, &span.mark, italic);
364 } else {
365 open.push(ix);
366 }
367 }
368 }
369
370 escape_inline(&mut out, &text.text[cursor.min(text.text.len())..]);
371 while let Some(ix) = open.pop() {
372 close_mark(&mut out, &text.marks[ix].mark, delimiters[ix]);
373 }
374 out
375}
376
377fn fence_width_inline(body: &str) -> usize {
378 let mut longest = 0;
379 let mut run = 0;
380 for c in body.chars() {
381 run = if c == '`' { run + 1 } else { 0 };
382 longest = longest.max(run);
383 }
384 longest + 1
385}
386
387fn italic_delimiter(written: &str, text: &Text, range: &std::ops::Range<usize>) -> char {
400 let intraword = written
401 .chars()
402 .next_back()
403 .is_some_and(char::is_alphanumeric)
404 || text.text[range.end..]
405 .chars()
406 .next()
407 .is_some_and(char::is_alphanumeric);
408 if intraword { '*' } else { '_' }
409}
410
411fn open_mark(out: &mut String, mark: &Mark, italic: char) {
412 match mark {
413 Mark::Bold => out.push_str("**"),
414 Mark::Italic => out.push(italic),
415 Mark::Strike => out.push_str("~~"),
416 Mark::Link(_) | Mark::Mention { .. } => out.push('['),
417 Mark::Image(_) => out.push_str(";
429 out.push_str(url);
430 out.push(')');
431 }
432 Mark::Mention { url, form } => {
435 out.push_str("](");
436 out.push_str(url);
437 out.push_str(" \"");
438 out.push_str(form.title().unwrap_or("chip"));
439 out.push_str("\")");
440 }
441 Mark::Code => {}
442 }
443}
444
445fn escape_inline(out: &mut String, s: &str) {
450 let mut line_start = out.is_empty() || out.ends_with('\n');
451 for (ix, line) in s.split('\n').enumerate() {
452 if ix > 0 {
453 out.push('\n');
454 line_start = true;
455 }
456 let body = if line_start {
457 escape_block_marker(out, line)
458 } else {
459 line
460 };
461 escape_span(out, body);
462 line_start = false;
463 }
464}
465
466fn escape_block_marker<'a>(out: &mut String, line: &'a str) -> &'a str {
470 let after_space = |rest: &str| rest.starts_with([' ', '\t']) || rest.is_empty();
471
472 let hashes = line.len() - line.trim_start_matches('#').len();
473 if hashes > 0 && after_space(&line[hashes..]) {
474 out.push('\\');
475 out.push_str(&line[..hashes]);
476 return &line[hashes..];
477 }
478
479 if let Some(rest) = line.strip_prefix('>') {
480 out.push_str("\\>");
481 return rest;
482 }
483
484 if (line.starts_with('-') || line.starts_with('+')) && after_space(&line[1..]) {
487 out.push('\\');
488 out.push_str(&line[..1]);
489 return &line[1..];
490 }
491
492 let digits = line.len() - line.trim_start_matches(|c: char| c.is_ascii_digit()).len();
493 if digits > 0 {
494 let after = &line[digits..];
495 if (after.starts_with('.') || after.starts_with(')')) && after_space(&after[1..]) {
496 out.push_str(&line[..digits]);
497 out.push('\\');
498 out.push_str(&after[..1]);
499 return &after[1..];
500 }
501 }
502
503 let trimmed = line.trim_end();
505 if !trimmed.is_empty() && trimmed.chars().all(|c| c == '=' || c == '-') {
506 out.push('\\');
507 out.push_str(&line[..1]);
508 return &line[1..];
509 }
510
511 line
512}
513
514fn escape_span(out: &mut String, s: &str) {
516 for (ix, c) in s.char_indices() {
517 let rest = &s[ix + c.len_utf8()..];
518 match c {
519 '\\' | '*' | '`' | '[' | ']' | '~' | '|' => {
525 out.push('\\');
526 out.push(c);
527 }
528 '_' => {
531 let before = s[..ix].chars().next_back();
532 let inside_word = before.is_some_and(char::is_alphanumeric)
533 && rest.chars().next().is_some_and(char::is_alphanumeric);
534 if !inside_word {
535 out.push('\\');
536 }
537 out.push('_');
538 }
539 '<' if rest
541 .chars()
542 .next()
543 .is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | '!' | '?')) =>
544 {
545 out.push_str("\\<")
546 }
547 '&' if rest
548 .chars()
549 .next()
550 .is_some_and(|c| c.is_alphanumeric() || c == '#') =>
551 {
552 out.push_str("\\&")
553 }
554 _ => out.push(c),
555 }
556 }
557}