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 out
43}
44
45fn marker_kind(kind: &BlockKind) -> Option<u8> {
48 match kind {
49 BlockKind::Bullet(_) => Some(0),
50 BlockKind::Ordered { .. } => Some(1),
51 BlockKind::Task { .. } => Some(2),
52 _ => None,
53 }
54}
55
56fn is_marker(kind: &BlockKind) -> bool {
57 marker_kind(kind).is_some()
58}
59
60fn tight_after(previous: &BlockKind, next: &BlockKind, nested: bool) -> bool {
84 if is_empty_marker(previous) {
85 return true;
86 }
87 if is_empty_marker(next) {
88 return false;
89 }
90 if nested && matches!(next, BlockKind::Ordered { number, .. } if *number != 1) {
91 return false;
92 }
93 marker_kind(previous).is_some() && marker_kind(previous) == marker_kind(next)
94}
95
96fn is_empty_marker(kind: &BlockKind) -> bool {
97 is_marker(kind)
98 && Block::new(kind.clone())
99 .text_at(Part::Body)
100 .is_some_and(Text::is_empty)
101}
102
103fn write_block(out: &mut String, kind: &BlockKind, indent: u8) {
104 let pad = INDENT.repeat(indent as usize);
105
106 match kind {
107 BlockKind::Paragraph(text) => write_lines(out, &pad, &pad, &inline(text)),
108 BlockKind::Heading { level, text } => {
109 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
110 write_lines(out, &format!("{pad}{hashes} "), &pad, &inline(text));
111 }
112 BlockKind::Bullet(text) => {
117 let marker = if text.is_empty() { "+ " } else { "- " };
118 write_marked(out, &pad, marker, text)
119 }
120 BlockKind::Ordered { number, text } => {
121 write_marked(out, &pad, &format!("{number}. "), text)
122 }
123 BlockKind::Task { checked, text } => {
124 let marker = if *checked { "- [x] " } else { "- [ ] " };
125 write_marked(out, &pad, marker, text);
126 }
127 BlockKind::Quote(text) => {
128 let prefix = format!("{pad}> ");
129 write_lines(out, &prefix, &prefix, &inline(text));
130 }
131 BlockKind::Code { language, code } => {
132 let fence = "`".repeat(fence_width(&code.text));
133 out.push_str(&pad);
134 out.push_str(&fence);
135 out.push_str(language.as_deref().unwrap_or(""));
136 for line in code.text.split('\n') {
137 out.push('\n');
138 out.push_str(&pad);
139 out.push_str(line);
140 }
141 out.push('\n');
142 out.push_str(&pad);
143 out.push_str(&fence);
144 }
145 BlockKind::Image { url, alt, width } => {
146 out.push_str(&pad);
147 out.push_str(";
157 out.push_str(url);
158 out.push(')');
159 }
160 BlockKind::Bookmark { url, form } => {
164 out.push_str(&pad);
165 match form.title() {
166 None => {
167 out.push('<');
168 out.push_str(url);
169 out.push('>');
170 }
171 Some(title) => {
172 out.push('[');
173 out.push_str(url);
174 out.push_str("](");
175 out.push_str(url);
176 out.push_str(&format!(" \"{title}\")"));
177 }
178 }
179 }
180 BlockKind::Table {
181 align,
182 header,
183 rows,
184 } => write_table(out, &pad, align, header, rows),
185 BlockKind::Rule => {
186 out.push_str(&pad);
187 out.push_str("---");
188 }
189 }
190}
191
192fn write_marked(out: &mut String, pad: &str, marker: &str, text: &Text) {
194 let first = format!("{pad}{marker}");
195 let rest = format!("{pad}{}", " ".repeat(marker.chars().count()));
196 write_lines(out, &first, &rest, &inline(text));
197}
198
199fn write_lines(out: &mut String, first: &str, rest: &str, body: &str) {
200 for (ix, line) in body.split('\n').enumerate() {
201 if ix > 0 {
202 out.push('\n');
203 }
204 out.push_str(if ix == 0 { first } else { rest });
205 out.push_str(line);
206 }
207}
208
209fn fence_width(code: &str) -> usize {
211 let mut longest = 0;
212 let mut run = 0;
213 for c in code.chars() {
214 run = if c == '`' { run + 1 } else { 0 };
215 longest = longest.max(run);
216 }
217 (longest + 1).max(3)
218}
219
220fn write_table(out: &mut String, pad: &str, align: &[Align], header: &[Text], rows: &[Vec<Text>]) {
221 let columns = align.len().max(header.len());
222 let row_of = |cells: &[Text]| {
223 let mut line = String::from("|");
224 for ix in 0..columns {
225 line.push(' ');
226 if let Some(cell) = cells.get(ix) {
227 line.push_str(&inline(cell));
229 }
230 line.push_str(" |");
231 }
232 line
233 };
234
235 out.push_str(pad);
236 out.push_str(&row_of(header));
237 out.push('\n');
238 out.push_str(pad);
239 out.push('|');
240 for ix in 0..columns {
241 out.push_str(match align.get(ix).copied().unwrap_or_default() {
242 Align::Left => " --- |",
243 Align::Center => " :-: |",
244 Align::Right => " ---: |",
245 });
246 }
247 for row in rows {
248 out.push('\n');
249 out.push_str(pad);
250 out.push_str(&row_of(row));
251 }
252}
253
254fn inline(text: &Text) -> String {
258 let mut out = String::new();
259 let mut open: Vec<usize> = Vec::new();
260 let mut started = vec![false; text.marks.len()];
261 let mut delimiters = vec!['_'; text.marks.len()];
263 let mut cursor = 0usize;
264
265 let mut boundaries: Vec<usize> = text
266 .marks
267 .iter()
268 .flat_map(|m| [m.range.start, m.range.end])
269 .chain([0, text.text.len()])
270 .collect();
271 boundaries.sort_unstable();
272 boundaries.dedup();
273
274 for point in boundaries {
275 if point < cursor {
276 continue;
277 }
278 escape_inline(&mut out, &text.text[cursor..point]);
279 cursor = point;
280
281 while let Some(&top) = open.last() {
282 if text.marks[top].range.end <= point {
283 close_mark(&mut out, &text.marks[top].mark, delimiters[top]);
284 open.pop();
285 } else {
286 break;
287 }
288 }
289
290 for (ix, span) in text.marks.iter().enumerate() {
291 if started[ix] || span.range.start != point {
292 continue;
293 }
294 started[ix] = true;
295 if span.mark == Mark::Code {
298 let body = &text.text[span.range.clone()];
299 let ticks = "`".repeat(fence_width_inline(body));
300 out.push_str(&ticks);
301 out.push_str(body);
302 out.push_str(&ticks);
303 cursor = cursor.max(span.range.end);
304 continue;
305 }
306 if let Mark::Mention { url, .. } = &span.mark
312 && crate::parse::is_shorthand(text, ix)
313 {
314 out.push('<');
315 out.push_str(url);
316 out.push('>');
317 cursor = cursor.max(span.range.end);
318 continue;
319 }
320 if let Mark::Link(url) = &span.mark
327 && text.text.get(span.range.clone()) == Some(url.as_str())
328 && crate::parse::is_url(url)
329 && text.alone(ix)
330 {
331 out.push_str(url);
332 cursor = cursor.max(span.range.end);
333 continue;
334 }
335 let italic = italic_delimiter(&out, text, &span.range);
336 delimiters[ix] = italic;
337 open_mark(&mut out, &span.mark, italic);
338 if span.range.is_empty() {
341 close_mark(&mut out, &span.mark, italic);
342 } else {
343 open.push(ix);
344 }
345 }
346 }
347
348 escape_inline(&mut out, &text.text[cursor.min(text.text.len())..]);
349 while let Some(ix) = open.pop() {
350 close_mark(&mut out, &text.marks[ix].mark, delimiters[ix]);
351 }
352 out
353}
354
355fn fence_width_inline(body: &str) -> usize {
356 let mut longest = 0;
357 let mut run = 0;
358 for c in body.chars() {
359 run = if c == '`' { run + 1 } else { 0 };
360 longest = longest.max(run);
361 }
362 longest + 1
363}
364
365fn italic_delimiter(written: &str, text: &Text, range: &std::ops::Range<usize>) -> char {
378 let intraword = written
379 .chars()
380 .next_back()
381 .is_some_and(char::is_alphanumeric)
382 || text.text[range.end..]
383 .chars()
384 .next()
385 .is_some_and(char::is_alphanumeric);
386 if intraword { '*' } else { '_' }
387}
388
389fn open_mark(out: &mut String, mark: &Mark, italic: char) {
390 match mark {
391 Mark::Bold => out.push_str("**"),
392 Mark::Italic => out.push(italic),
393 Mark::Strike => out.push_str("~~"),
394 Mark::Link(_) | Mark::Mention { .. } => out.push('['),
395 Mark::Image(_) => out.push_str(";
407 out.push_str(url);
408 out.push(')');
409 }
410 Mark::Mention { url, form } => {
413 out.push_str("](");
414 out.push_str(url);
415 out.push_str(" \"");
416 out.push_str(form.title().unwrap_or("chip"));
417 out.push_str("\")");
418 }
419 Mark::Code => {}
420 }
421}
422
423fn escape_inline(out: &mut String, s: &str) {
428 let mut line_start = out.is_empty() || out.ends_with('\n');
429 for (ix, line) in s.split('\n').enumerate() {
430 if ix > 0 {
431 out.push('\n');
432 line_start = true;
433 }
434 let body = if line_start {
435 escape_block_marker(out, line)
436 } else {
437 line
438 };
439 escape_span(out, body);
440 line_start = false;
441 }
442}
443
444fn escape_block_marker<'a>(out: &mut String, line: &'a str) -> &'a str {
448 let after_space = |rest: &str| rest.starts_with([' ', '\t']) || rest.is_empty();
449
450 let hashes = line.len() - line.trim_start_matches('#').len();
451 if hashes > 0 && after_space(&line[hashes..]) {
452 out.push('\\');
453 out.push_str(&line[..hashes]);
454 return &line[hashes..];
455 }
456
457 if let Some(rest) = line.strip_prefix('>') {
458 out.push_str("\\>");
459 return rest;
460 }
461
462 if (line.starts_with('-') || line.starts_with('+')) && after_space(&line[1..]) {
465 out.push('\\');
466 out.push_str(&line[..1]);
467 return &line[1..];
468 }
469
470 let digits = line.len() - line.trim_start_matches(|c: char| c.is_ascii_digit()).len();
471 if digits > 0 {
472 let after = &line[digits..];
473 if (after.starts_with('.') || after.starts_with(')')) && after_space(&after[1..]) {
474 out.push_str(&line[..digits]);
475 out.push('\\');
476 out.push_str(&after[..1]);
477 return &after[1..];
478 }
479 }
480
481 let trimmed = line.trim_end();
483 if !trimmed.is_empty() && trimmed.chars().all(|c| c == '=' || c == '-') {
484 out.push('\\');
485 out.push_str(&line[..1]);
486 return &line[1..];
487 }
488
489 line
490}
491
492fn escape_span(out: &mut String, s: &str) {
494 for (ix, c) in s.char_indices() {
495 let rest = &s[ix + c.len_utf8()..];
496 match c {
497 '\\' | '*' | '`' | '[' | ']' | '~' | '|' => {
503 out.push('\\');
504 out.push(c);
505 }
506 '_' => {
509 let before = s[..ix].chars().next_back();
510 let inside_word = before.is_some_and(char::is_alphanumeric)
511 && rest.chars().next().is_some_and(char::is_alphanumeric);
512 if !inside_word {
513 out.push('\\');
514 }
515 out.push('_');
516 }
517 '<' if rest
519 .chars()
520 .next()
521 .is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | '!' | '?')) =>
522 {
523 out.push_str("\\<")
524 }
525 '&' if rest
526 .chars()
527 .next()
528 .is_some_and(|c| c.is_alphanumeric() || c == '#') =>
529 {
530 out.push_str("\\&")
531 }
532 _ => out.push(c),
533 }
534 }
535}