1use serde_json::Value as JsonValue;
25use serde_saphyr::{FlowMap, FlowSeq, SerializerOptions};
26
27use super::payload::PayloadItem;
28use super::prescan::{CommentPathSegment, NestedComment};
29use super::{Card, Document};
30
31impl Document {
34 pub fn to_markdown(&self) -> String {
98 let mut out = String::new();
99
100 emit_block(&mut out, self.main());
102 out.push_str(self.main().body());
103
104 for card in self.cards() {
109 ensure_blank_before_fence(&mut out);
110 emit_block(&mut out, card);
111 if !card.body().is_empty() {
112 out.push_str(card.body());
113 }
114 }
115
116 out
117 }
118}
119
120fn emit_meta_line(out: &mut String, key: &str, value: &str, trailer: Option<&str>) {
123 out.push('$');
124 out.push_str(key);
125 out.push_str(": ");
126 out.push_str(&saphyr_emit_scalar(&JsonValue::String(value.to_string())));
127 push_trailer(out, trailer);
128 out.push('\n');
129}
130
131fn emit_block(out: &mut String, card: &Card) {
132 out.push_str("~~~card-yaml\n");
133 emit_payload_items(
134 out,
135 card.payload().items(),
136 card.payload().nested_comments(),
137 );
138 out.push_str("~~~\n");
139}
140
141fn emit_payload_items(out: &mut String, items: &[PayloadItem], nested: &[NestedComment]) {
144 let mut i = 0;
145 while i < items.len() {
146 let trailer = items.get(i + 1).and_then(|next| match next {
148 PayloadItem::Comment { text, inline: true } => Some(text.as_str()),
149 _ => None,
150 });
151 let mut consumed_trailer = trailer.is_some();
152
153 match &items[i] {
154 PayloadItem::Quill { reference } => {
155 emit_meta_line(out, "quill", &reference.to_string(), trailer);
156 }
157 PayloadItem::Kind { value } => {
158 emit_meta_line(out, "kind", value, trailer);
159 }
160 PayloadItem::Id { value } => {
161 emit_meta_line(out, "id", value, trailer);
162 }
163 PayloadItem::Field { key, value, fill } => {
164 let path = vec![CommentPathSegment::Key(key.clone())];
165 emit_field(out, key, value.as_json(), 0, *fill, &path, nested, trailer);
166 }
167 PayloadItem::Comment { text, .. } => {
168 out.push_str("# ");
169 out.push_str(text);
170 out.push('\n');
171 consumed_trailer = false;
172 }
173 }
174 i += if consumed_trailer { 2 } else { 1 };
175 }
176}
177
178fn ensure_blank_before_fence(out: &mut String) {
182 if out.is_empty() {
183 return;
184 }
185 if !out.ends_with('\n') {
186 out.push('\n');
187 }
188 out.push('\n');
189}
190
191fn emit_own_line_pending(
196 out: &mut String,
197 path: &[CommentPathSegment],
198 position: usize,
199 indent: usize,
200 nested: &[NestedComment],
201) {
202 for c in nested {
203 if c.position == position && !c.inline && c.container_path.as_slice() == path {
204 push_indent(out, indent);
205 out.push_str("# ");
206 out.push_str(&c.text);
207 out.push('\n');
208 }
209 }
210}
211
212fn find_inline_trailer<'a>(
215 out: &mut String,
216 path: &[CommentPathSegment],
217 position: usize,
218 indent: usize,
219 nested: &'a [NestedComment],
220) -> Option<&'a str> {
221 let mut chosen: Option<&str> = None;
222 for c in nested {
223 if c.position == position && c.inline && c.container_path.as_slice() == path {
224 if chosen.is_none() {
225 chosen = Some(c.text.as_str());
226 } else {
227 push_indent(out, indent);
228 out.push_str("# ");
229 out.push_str(&c.text);
230 out.push('\n');
231 }
232 }
233 }
234 chosen
235}
236
237fn emit_orphan_inlines(
239 out: &mut String,
240 path: &[CommentPathSegment],
241 container_len: usize,
242 indent: usize,
243 nested: &[NestedComment],
244) {
245 for c in nested {
246 if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
247 push_indent(out, indent);
248 out.push_str("# ");
249 out.push_str(&c.text);
250 out.push('\n');
251 }
252 }
253}
254
255fn push_trailer(out: &mut String, trailer: Option<&str>) {
256 if let Some(t) = trailer {
257 out.push_str(" # ");
258 out.push_str(t);
259 }
260}
261
262#[allow(clippy::too_many_arguments)]
271fn emit_field(
272 out: &mut String,
273 key: &str,
274 value: &JsonValue,
275 indent: usize,
276 fill: bool,
277 path: &[CommentPathSegment],
278 nested: &[NestedComment],
279 inline_trailer: Option<&str>,
280) {
281 if fill {
282 push_indent(out, indent);
283 out.push_str(key);
284 match value {
285 JsonValue::Null => {
286 out.push_str(": !fill");
287 push_trailer(out, inline_trailer);
288 out.push('\n');
289 }
290 JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
291 out.push_str(": !fill ");
292 emit_scalar(out, value);
293 push_trailer(out, inline_trailer);
294 out.push('\n');
295 }
296 JsonValue::Array(items) if items.is_empty() => {
297 out.push_str(": !fill []");
298 push_trailer(out, inline_trailer);
299 out.push('\n');
300 }
301 JsonValue::Array(items) => {
302 out.push_str(": !fill");
303 push_trailer(out, inline_trailer);
304 out.push('\n');
305 emit_sequence_children(out, items, indent + 2, path, nested);
306 }
307 JsonValue::Object(_) => {
308 out.push_str(": ");
309 emit_scalar(out, value);
310 push_trailer(out, inline_trailer);
311 out.push('\n');
312 }
313 }
314 return;
315 }
316 match value {
317 JsonValue::Object(map) if map.is_empty() => {
318 if let Some(t) = inline_trailer {
319 push_indent(out, indent);
320 out.push_str("# ");
321 out.push_str(t);
322 out.push('\n');
323 }
324 }
325 JsonValue::Object(map) => {
326 push_indent(out, indent);
327 out.push_str(key);
328 out.push(':');
329 push_trailer(out, inline_trailer);
330 out.push('\n');
331 emit_mapping_children(out, map, indent + 2, path, nested);
332 }
333 JsonValue::Array(items) if items.is_empty() => {
334 push_indent(out, indent);
335 out.push_str(key);
336 out.push_str(": []");
337 push_trailer(out, inline_trailer);
338 out.push('\n');
339 }
340 JsonValue::Array(items) => {
341 push_indent(out, indent);
342 out.push_str(key);
343 out.push(':');
344 push_trailer(out, inline_trailer);
345 out.push('\n');
346 emit_sequence_children(out, items, indent + 2, path, nested);
347 }
348 _ => {
349 push_indent(out, indent);
350 out.push_str(key);
351 out.push_str(": ");
352 emit_scalar(out, value);
353 push_trailer(out, inline_trailer);
354 out.push('\n');
355 }
356 }
357}
358
359fn emit_mapping_children(
360 out: &mut String,
361 map: &serde_json::Map<String, JsonValue>,
362 child_indent: usize,
363 path: &[CommentPathSegment],
364 nested: &[NestedComment],
365) {
366 for (i, (k, v)) in map.iter().enumerate() {
367 emit_own_line_pending(out, path, i, child_indent, nested);
368 let trailer = find_inline_trailer(out, path, i, child_indent, nested);
369 let mut child_path = path.to_vec();
370 child_path.push(CommentPathSegment::Key(k.clone()));
371 emit_field(out, k, v, child_indent, false, &child_path, nested, trailer);
372 }
373 emit_own_line_pending(out, path, map.len(), child_indent, nested);
374 emit_orphan_inlines(out, path, map.len(), child_indent, nested);
375}
376
377fn emit_sequence_children(
378 out: &mut String,
379 items: &[JsonValue],
380 base_indent: usize,
381 path: &[CommentPathSegment],
382 nested: &[NestedComment],
383) {
384 for (i, item) in items.iter().enumerate() {
385 emit_own_line_pending(out, path, i, base_indent, nested);
386 let trailer = find_inline_trailer(out, path, i, base_indent, nested);
387 let mut child_path = path.to_vec();
388 child_path.push(CommentPathSegment::Index(i));
389 emit_sequence_item(out, item, base_indent, &child_path, nested, trailer);
390 }
391 emit_own_line_pending(out, path, items.len(), base_indent, nested);
392 emit_orphan_inlines(out, path, items.len(), base_indent, nested);
393}
394
395fn emit_sequence_item(
399 out: &mut String,
400 value: &JsonValue,
401 base_indent: usize,
402 path: &[CommentPathSegment],
403 nested: &[NestedComment],
404 inline_trailer: Option<&str>,
405) {
406 match value {
407 JsonValue::Object(map) if map.is_empty() => {
408 push_indent(out, base_indent);
409 out.push_str("- {}");
410 push_trailer(out, inline_trailer);
411 out.push('\n');
412 }
413 JsonValue::Object(map) => {
414 emit_own_line_pending(out, path, 0, base_indent, nested);
415
416 let mut first = true;
417 for (i, (k, v)) in map.iter().enumerate() {
418 if !first {
419 emit_own_line_pending(out, path, i, base_indent + 2, nested);
420 }
421 let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
422 let mut child_path = path.to_vec();
423 child_path.push(CommentPathSegment::Key(k.clone()));
424 if first {
425 let line_trailer = inline_trailer.or(inner_trailer);
426 push_indent(out, base_indent);
427 out.push_str("- ");
428 emit_field_inline(
429 out,
430 k,
431 v,
432 base_indent + 2,
433 &child_path,
434 nested,
435 line_trailer,
436 );
437 if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
438 push_indent(out, base_indent + 2);
439 out.push_str("# ");
440 out.push_str(loser);
441 out.push('\n');
442 }
443 first = false;
444 } else {
445 emit_field(
446 out,
447 k,
448 v,
449 base_indent + 2,
450 false,
451 &child_path,
452 nested,
453 inner_trailer,
454 );
455 }
456 }
457 emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
458 emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
459 }
460 JsonValue::Array(inner) if inner.is_empty() => {
461 push_indent(out, base_indent);
462 out.push_str("- []");
463 push_trailer(out, inline_trailer);
464 out.push('\n');
465 }
466 JsonValue::Array(inner) => {
467 push_indent(out, base_indent);
468 out.push('-');
469 push_trailer(out, inline_trailer);
470 out.push('\n');
471 emit_sequence_children(out, inner, base_indent + 2, path, nested);
472 }
473 _ => {
474 push_indent(out, base_indent);
475 out.push_str("- ");
476 emit_scalar(out, value);
477 push_trailer(out, inline_trailer);
478 out.push('\n');
479 }
480 }
481}
482
483fn emit_field_inline(
485 out: &mut String,
486 key: &str,
487 value: &JsonValue,
488 child_indent: usize,
489 path: &[CommentPathSegment],
490 nested: &[NestedComment],
491 inline_trailer: Option<&str>,
492) {
493 match value {
494 JsonValue::Object(map) if map.is_empty() => {
495 out.push_str(key);
496 out.push_str(": {}");
497 push_trailer(out, inline_trailer);
498 out.push('\n');
499 }
500 JsonValue::Object(map) => {
501 out.push_str(key);
502 out.push(':');
503 push_trailer(out, inline_trailer);
504 out.push('\n');
505 emit_mapping_children(out, map, child_indent, path, nested);
506 }
507 JsonValue::Array(items) if items.is_empty() => {
508 out.push_str(key);
509 out.push_str(": []");
510 push_trailer(out, inline_trailer);
511 out.push('\n');
512 }
513 JsonValue::Array(items) => {
514 out.push_str(key);
515 out.push(':');
516 push_trailer(out, inline_trailer);
517 out.push('\n');
518 emit_sequence_children(out, items, child_indent + 2, path, nested);
519 }
520 _ => {
521 out.push_str(key);
522 out.push_str(": ");
523 emit_scalar(out, value);
524 push_trailer(out, inline_trailer);
525 out.push('\n');
526 }
527 }
528}
529
530fn emit_scalar(out: &mut String, value: &JsonValue) {
531 let s = saphyr_emit_scalar(value);
532 out.push_str(&s);
533}
534
535fn saphyr_opts() -> SerializerOptions {
538 SerializerOptions {
539 prefer_block_scalars: false,
540 ..SerializerOptions::default()
541 }
542}
543
544pub(crate) fn saphyr_emit_scalar(value: &JsonValue) -> String {
545 let mut buf = String::new();
546 serde_saphyr::to_fmt_writer_with_options(&mut buf, value, saphyr_opts())
547 .expect("saphyr scalar emission is infallible for JsonValue scalars");
548 while buf.ends_with('\n') {
549 buf.pop();
550 }
551
552 if let JsonValue::String(s) = value {
559 let unquoted = !buf.starts_with('"')
560 && !buf.starts_with('\'')
561 && !buf.starts_with('|')
562 && !buf.starts_with('>');
563 let has_edge_whitespace = !s.is_empty()
564 && (s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace));
565 if unquoted && has_edge_whitespace {
566 return double_quote_string(s);
567 }
568 }
569 buf
570}
571
572fn double_quote_string(s: &str) -> String {
575 let mut out = String::with_capacity(s.len() + 2);
576 out.push('"');
577 for ch in s.chars() {
578 match ch {
579 '\\' => out.push_str("\\\\"),
580 '"' => out.push_str("\\\""),
581 '\n' => out.push_str("\\n"),
582 '\r' => out.push_str("\\r"),
583 '\t' => out.push_str("\\t"),
584 c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
585 out.push_str(&format!("\\u{:04X}", c as u32));
586 }
587 c => out.push(c),
588 }
589 }
590 out.push('"');
591 out
592}
593
594pub(crate) fn saphyr_emit_flow(value: &JsonValue) -> String {
597 let mut buf = String::new();
598 let opts = saphyr_opts();
599 match value {
600 JsonValue::Array(items) => {
601 let wrapped = FlowSeq(items.clone());
602 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
603 .expect("saphyr flow seq emission");
604 }
605 JsonValue::Object(map) => {
606 let wrapped = FlowMap(map.clone());
607 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
608 .expect("saphyr flow map emission");
609 }
610 scalar => {
611 let wrapped = FlowSeq(vec![scalar.clone()]);
613 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
614 .expect("saphyr flow scalar emission");
615 while buf.ends_with('\n') {
616 buf.pop();
617 }
618 return buf
619 .strip_prefix('[')
620 .and_then(|s| s.strip_suffix(']'))
621 .unwrap_or(&buf)
622 .to_string();
623 }
624 }
625 while buf.ends_with('\n') {
626 buf.pop();
627 }
628 buf
629}
630
631fn push_indent(out: &mut String, spaces: usize) {
634 for _ in 0..spaces {
635 out.push(' ');
636 }
637}
638
639#[cfg(test)]
642mod tests {
643 use super::*;
644 use crate::value::QuillValue;
645
646 fn assert_scalar_round_trips(value: serde_json::Value) {
647 let mut yaml = String::from("~~~card-yaml\n$quill: q\n$kind: main\nv: ");
648 yaml.push_str(&saphyr_emit_scalar(&value));
649 yaml.push_str("\n~~~\n");
650 let doc = crate::document::Document::from_markdown(&yaml).unwrap_or_else(|e| {
651 panic!(
652 "failed to parse emitted scalar {:?}: {}\n{}",
653 value, e, yaml
654 )
655 });
656 let parsed = doc.main().payload().get("v").expect("field 'v'").as_json();
657 assert_eq!(
658 parsed, &value,
659 "scalar round-trip mismatch for {:?}: emitted as {:?}",
660 value, yaml
661 );
662 }
663
664 #[test]
665 fn saphyr_scalar_round_trips_plain_string() {
666 assert_scalar_round_trips(serde_json::json!("hello"));
667 }
668
669 #[test]
670 fn saphyr_scalar_round_trips_ambiguous_strings() {
671 for ambiguous in &[
672 "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
673 ] {
674 assert_scalar_round_trips(serde_json::json!(*ambiguous));
675 }
676 }
677
678 #[test]
679 fn saphyr_scalar_round_trips_escapes() {
680 assert_scalar_round_trips(serde_json::json!("a\\b\"c\nd\te"));
681 }
682
683 #[test]
684 fn saphyr_scalar_round_trips_control_chars() {
685 assert_scalar_round_trips(serde_json::json!("\x01\x1F"));
686 }
687
688 fn p(key: &str) -> Vec<CommentPathSegment> {
689 vec![CommentPathSegment::Key(key.to_string())]
690 }
691
692 #[test]
693 fn empty_object_omitted() {
694 let value = QuillValue::from_json(serde_json::json!({}));
695 let mut out = String::new();
696 emit_field(
697 &mut out,
698 "empty_map",
699 value.as_json(),
700 0,
701 false,
702 &p("empty_map"),
703 &[],
704 None,
705 );
706 assert_eq!(out, "");
707 }
708
709 #[test]
710 fn empty_object_with_inline_trailer_degrades() {
711 let value = QuillValue::from_json(serde_json::json!({}));
712 let mut out = String::new();
713 emit_field(
714 &mut out,
715 "empty_map",
716 value.as_json(),
717 0,
718 false,
719 &p("empty_map"),
720 &[],
721 Some("orphan"),
722 );
723 assert_eq!(out, "# orphan\n");
724 }
725
726 #[test]
727 fn empty_array_emitted() {
728 let value = QuillValue::from_json(serde_json::json!([]));
729 let mut out = String::new();
730 emit_field(
731 &mut out,
732 "empty_seq",
733 value.as_json(),
734 0,
735 false,
736 &p("empty_seq"),
737 &[],
738 None,
739 );
740 assert_eq!(out, "empty_seq: []\n");
741 }
742
743 #[test]
744 fn scalar_field_with_inline_trailer() {
745 let value = QuillValue::from_json(serde_json::json!("Hello"));
746 let mut out = String::new();
747 emit_field(
748 &mut out,
749 "title",
750 value.as_json(),
751 0,
752 false,
753 &p("title"),
754 &[],
755 Some("greeting"),
756 );
757 assert_eq!(out, "title: Hello # greeting\n");
758 }
759
760 #[test]
761 fn container_field_with_inline_trailer_lands_on_key_line() {
762 let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
763 let mut out = String::new();
764 emit_field(
765 &mut out,
766 "outer",
767 value.as_json(),
768 0,
769 false,
770 &p("outer"),
771 &[],
772 Some("note"),
773 );
774 assert_eq!(out, "outer: # note\n inner: 1\n");
775 }
776
777 #[test]
778 fn fill_null_emits_bare_tag() {
779 let value = QuillValue::from_json(serde_json::Value::Null);
780 let mut out = String::new();
781 emit_field(
782 &mut out,
783 "recipient",
784 value.as_json(),
785 0,
786 true,
787 &p("recipient"),
788 &[],
789 None,
790 );
791 assert_eq!(out, "recipient: !fill\n");
792 }
793
794 #[test]
795 fn fill_string_emits_tag_with_value() {
796 let value = QuillValue::from_json(serde_json::json!("placeholder"));
797 let mut out = String::new();
798 emit_field(
799 &mut out,
800 "dept",
801 value.as_json(),
802 0,
803 true,
804 &p("dept"),
805 &[],
806 None,
807 );
808 assert_eq!(out, "dept: !fill placeholder\n");
809 }
810
811 #[test]
812 fn fill_with_inline_trailer() {
813 let value = QuillValue::from_json(serde_json::json!("placeholder"));
814 let mut out = String::new();
815 emit_field(
816 &mut out,
817 "dept",
818 value.as_json(),
819 0,
820 true,
821 &p("dept"),
822 &[],
823 Some("note"),
824 );
825 assert_eq!(out, "dept: !fill placeholder # note\n");
826 }
827
828 #[test]
829 fn fill_integer_emits_tag_with_value() {
830 let value = QuillValue::from_json(serde_json::json!(42));
831 let mut out = String::new();
832 emit_field(
833 &mut out,
834 "count",
835 value.as_json(),
836 0,
837 true,
838 &p("count"),
839 &[],
840 None,
841 );
842 assert_eq!(out, "count: !fill 42\n");
843 }
844}