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_ext_block(
138 out: &mut String,
139 value: &serde_json::Map<String, JsonValue>,
140 trailer: Option<&str>,
141 nested: &[NestedComment],
142) {
143 if value.is_empty() {
144 out.push_str("$ext: {}");
145 push_trailer(out, trailer);
146 out.push('\n');
147 return;
148 }
149 out.push_str("$ext:");
150 push_trailer(out, trailer);
151 out.push('\n');
152 let path: Vec<CommentPathSegment> = Vec::new();
153 emit_mapping_children(out, value, 2, &path, nested);
154}
155
156fn emit_block(out: &mut String, card: &Card) {
157 out.push_str("~~~\n");
158 emit_payload_items(out, card.payload().items());
159 out.push_str("~~~\n");
160}
161
162fn emit_payload_items(out: &mut String, items: &[PayloadItem]) {
169 let mut i = 0;
170 while i < items.len() {
171 let trailer = items.get(i + 1).and_then(|next| match next {
173 PayloadItem::Comment { text, inline: true } => Some(text.as_str()),
174 _ => None,
175 });
176 let mut consumed_trailer = trailer.is_some();
177
178 match &items[i] {
179 PayloadItem::Quill { reference } => {
180 emit_meta_line(out, "quill", &reference.to_string(), trailer);
181 }
182 PayloadItem::Kind { value } => {
183 emit_meta_line(out, "kind", value, trailer);
184 }
185 PayloadItem::Id { value } => {
186 emit_meta_line(out, "id", value, trailer);
187 }
188 PayloadItem::Ext {
189 value,
190 nested_comments,
191 } => {
192 emit_ext_block(out, value, trailer, nested_comments);
193 }
194 PayloadItem::Field {
195 key,
196 value,
197 fill,
198 nested_comments,
199 } => {
200 let path: Vec<CommentPathSegment> = Vec::new();
203 emit_field(
204 out,
205 key,
206 value.as_json(),
207 0,
208 *fill,
209 &path,
210 nested_comments,
211 trailer,
212 );
213 }
214 PayloadItem::Comment { text, .. } => {
215 out.push_str("# ");
216 out.push_str(text);
217 out.push('\n');
218 consumed_trailer = false;
219 }
220 }
221 i += if consumed_trailer { 2 } else { 1 };
222 }
223}
224
225fn ensure_blank_before_fence(out: &mut String) {
229 if out.is_empty() {
230 return;
231 }
232 if !out.ends_with('\n') {
233 out.push('\n');
234 }
235 out.push('\n');
236}
237
238fn emit_own_line_pending(
243 out: &mut String,
244 path: &[CommentPathSegment],
245 position: usize,
246 indent: usize,
247 nested: &[NestedComment],
248) {
249 for c in nested {
250 if c.position == position && !c.inline && c.container_path.as_slice() == path {
251 push_indent(out, indent);
252 out.push_str("# ");
253 out.push_str(&c.text);
254 out.push('\n');
255 }
256 }
257}
258
259fn find_inline_trailer<'a>(
262 out: &mut String,
263 path: &[CommentPathSegment],
264 position: usize,
265 indent: usize,
266 nested: &'a [NestedComment],
267) -> Option<&'a str> {
268 let mut chosen: Option<&str> = None;
269 for c in nested {
270 if c.position == position && c.inline && c.container_path.as_slice() == path {
271 if chosen.is_none() {
272 chosen = Some(c.text.as_str());
273 } else {
274 push_indent(out, indent);
275 out.push_str("# ");
276 out.push_str(&c.text);
277 out.push('\n');
278 }
279 }
280 }
281 chosen
282}
283
284fn emit_orphan_inlines(
286 out: &mut String,
287 path: &[CommentPathSegment],
288 container_len: usize,
289 indent: usize,
290 nested: &[NestedComment],
291) {
292 for c in nested {
293 if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
294 push_indent(out, indent);
295 out.push_str("# ");
296 out.push_str(&c.text);
297 out.push('\n');
298 }
299 }
300}
301
302fn push_trailer(out: &mut String, trailer: Option<&str>) {
303 if let Some(t) = trailer {
304 out.push_str(" # ");
305 out.push_str(t);
306 }
307}
308
309#[allow(clippy::too_many_arguments)]
318fn emit_field(
319 out: &mut String,
320 key: &str,
321 value: &JsonValue,
322 indent: usize,
323 fill: bool,
324 path: &[CommentPathSegment],
325 nested: &[NestedComment],
326 inline_trailer: Option<&str>,
327) {
328 if fill {
329 push_indent(out, indent);
330 emit_key_at(out, key, indent);
331 match value {
332 JsonValue::Null => {
333 out.push_str(": !fill");
334 push_trailer(out, inline_trailer);
335 out.push('\n');
336 }
337 JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
338 out.push_str(": !fill ");
339 emit_scalar(out, value);
340 push_trailer(out, inline_trailer);
341 out.push('\n');
342 }
343 JsonValue::Array(items) if items.is_empty() => {
344 out.push_str(": !fill []");
345 push_trailer(out, inline_trailer);
346 out.push('\n');
347 }
348 JsonValue::Array(items) => {
349 out.push_str(": !fill");
350 push_trailer(out, inline_trailer);
351 out.push('\n');
352 emit_sequence_children(out, items, indent + 2, path, nested);
353 }
354 JsonValue::Object(_) => {
355 out.push_str(": ");
356 emit_scalar(out, value);
357 push_trailer(out, inline_trailer);
358 out.push('\n');
359 }
360 }
361 return;
362 }
363 match value {
364 JsonValue::Object(map) if map.is_empty() => {
365 if let Some(t) = inline_trailer {
366 push_indent(out, indent);
367 out.push_str("# ");
368 out.push_str(t);
369 out.push('\n');
370 }
371 }
372 JsonValue::Object(map) => {
373 push_indent(out, indent);
374 emit_key_at(out, key, indent);
375 out.push(':');
376 push_trailer(out, inline_trailer);
377 out.push('\n');
378 emit_mapping_children(out, map, indent + 2, path, nested);
379 }
380 JsonValue::Array(items) if items.is_empty() => {
381 push_indent(out, indent);
382 emit_key_at(out, key, indent);
383 out.push_str(": []");
384 push_trailer(out, inline_trailer);
385 out.push('\n');
386 }
387 JsonValue::Array(items) => {
388 push_indent(out, indent);
389 emit_key_at(out, key, indent);
390 out.push(':');
391 push_trailer(out, inline_trailer);
392 out.push('\n');
393 emit_sequence_children(out, items, indent + 2, path, nested);
394 }
395 _ => {
396 push_indent(out, indent);
397 emit_key_at(out, key, indent);
398 out.push_str(": ");
399 emit_scalar(out, value);
400 push_trailer(out, inline_trailer);
401 out.push('\n');
402 }
403 }
404}
405
406fn emit_mapping_children(
407 out: &mut String,
408 map: &serde_json::Map<String, JsonValue>,
409 child_indent: usize,
410 path: &[CommentPathSegment],
411 nested: &[NestedComment],
412) {
413 for (i, (k, v)) in map.iter().enumerate() {
414 emit_own_line_pending(out, path, i, child_indent, nested);
415 let trailer = find_inline_trailer(out, path, i, child_indent, nested);
416 let mut child_path = path.to_vec();
417 child_path.push(CommentPathSegment::Key(k.clone()));
418 emit_field(out, k, v, child_indent, false, &child_path, nested, trailer);
419 }
420 emit_own_line_pending(out, path, map.len(), child_indent, nested);
421 emit_orphan_inlines(out, path, map.len(), child_indent, nested);
422}
423
424fn emit_sequence_children(
425 out: &mut String,
426 items: &[JsonValue],
427 base_indent: usize,
428 path: &[CommentPathSegment],
429 nested: &[NestedComment],
430) {
431 for (i, item) in items.iter().enumerate() {
432 emit_own_line_pending(out, path, i, base_indent, nested);
433 let trailer = find_inline_trailer(out, path, i, base_indent, nested);
434 let mut child_path = path.to_vec();
435 child_path.push(CommentPathSegment::Index(i));
436 emit_sequence_item(out, item, base_indent, &child_path, nested, trailer);
437 }
438 emit_own_line_pending(out, path, items.len(), base_indent, nested);
439 emit_orphan_inlines(out, path, items.len(), base_indent, nested);
440}
441
442fn emit_sequence_item(
446 out: &mut String,
447 value: &JsonValue,
448 base_indent: usize,
449 path: &[CommentPathSegment],
450 nested: &[NestedComment],
451 inline_trailer: Option<&str>,
452) {
453 match value {
454 JsonValue::Object(map) if map.is_empty() => {
455 push_indent(out, base_indent);
456 out.push_str("- {}");
457 push_trailer(out, inline_trailer);
458 out.push('\n');
459 }
460 JsonValue::Object(map) => {
461 emit_own_line_pending(out, path, 0, base_indent, nested);
462
463 let mut first = true;
464 for (i, (k, v)) in map.iter().enumerate() {
465 if !first {
466 emit_own_line_pending(out, path, i, base_indent + 2, nested);
467 }
468 let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
469 let mut child_path = path.to_vec();
470 child_path.push(CommentPathSegment::Key(k.clone()));
471 if first {
472 let line_trailer = inline_trailer.or(inner_trailer);
473 push_indent(out, base_indent);
474 out.push_str("- ");
475 emit_field_inline(
476 out,
477 k,
478 v,
479 base_indent + 2,
480 &child_path,
481 nested,
482 line_trailer,
483 );
484 if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
485 push_indent(out, base_indent + 2);
486 out.push_str("# ");
487 out.push_str(loser);
488 out.push('\n');
489 }
490 first = false;
491 } else {
492 emit_field(
493 out,
494 k,
495 v,
496 base_indent + 2,
497 false,
498 &child_path,
499 nested,
500 inner_trailer,
501 );
502 }
503 }
504 emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
505 emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
506 }
507 JsonValue::Array(inner) if inner.is_empty() => {
508 push_indent(out, base_indent);
509 out.push_str("- []");
510 push_trailer(out, inline_trailer);
511 out.push('\n');
512 }
513 JsonValue::Array(inner) => {
514 push_indent(out, base_indent);
515 out.push('-');
516 push_trailer(out, inline_trailer);
517 out.push('\n');
518 emit_sequence_children(out, inner, base_indent + 2, path, nested);
519 }
520 _ => {
521 push_indent(out, base_indent);
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_field_inline(
532 out: &mut String,
533 key: &str,
534 value: &JsonValue,
535 child_indent: usize,
536 path: &[CommentPathSegment],
537 nested: &[NestedComment],
538 inline_trailer: Option<&str>,
539) {
540 match value {
541 JsonValue::Object(map) if map.is_empty() => {
542 emit_key(out, key);
543 out.push_str(": {}");
544 push_trailer(out, inline_trailer);
545 out.push('\n');
546 }
547 JsonValue::Object(map) => {
548 emit_key(out, key);
549 out.push(':');
550 push_trailer(out, inline_trailer);
551 out.push('\n');
552 emit_mapping_children(out, map, child_indent, path, nested);
553 }
554 JsonValue::Array(items) if items.is_empty() => {
555 emit_key(out, key);
556 out.push_str(": []");
557 push_trailer(out, inline_trailer);
558 out.push('\n');
559 }
560 JsonValue::Array(items) => {
561 emit_key(out, key);
562 out.push(':');
563 push_trailer(out, inline_trailer);
564 out.push('\n');
565 emit_sequence_children(out, items, child_indent + 2, path, nested);
566 }
567 _ => {
568 emit_key(out, key);
569 out.push_str(": ");
570 emit_scalar(out, value);
571 push_trailer(out, inline_trailer);
572 out.push('\n');
573 }
574 }
575}
576
577fn emit_scalar(out: &mut String, value: &JsonValue) {
578 let s = saphyr_emit_scalar(value);
579 out.push_str(&s);
580}
581
582fn emit_key(out: &mut String, key: &str) {
589 out.push_str(&saphyr_emit_scalar(&JsonValue::String(key.to_string())));
590}
591
592fn emit_key_at(out: &mut String, key: &str, indent: usize) {
597 if indent == 0 {
598 out.push_str(key);
599 } else {
600 emit_key(out, key);
601 }
602}
603
604fn saphyr_opts() -> SerializerOptions {
607 SerializerOptions {
608 prefer_block_scalars: false,
609 ..SerializerOptions::default()
610 }
611}
612
613pub(crate) fn saphyr_emit_scalar(value: &JsonValue) -> String {
614 let mut buf = String::new();
615 serde_saphyr::to_fmt_writer_with_options(&mut buf, value, saphyr_opts())
616 .expect("saphyr scalar emission is infallible for JsonValue scalars");
617 while buf.ends_with('\n') {
618 buf.pop();
619 }
620
621 if let JsonValue::String(s) = value {
628 let unquoted = !buf.starts_with('"')
629 && !buf.starts_with('\'')
630 && !buf.starts_with('|')
631 && !buf.starts_with('>');
632 let has_edge_whitespace = !s.is_empty()
633 && (s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace));
634 if unquoted && has_edge_whitespace {
635 return double_quote_string(s);
636 }
637 }
638 buf
639}
640
641fn double_quote_string(s: &str) -> String {
644 let mut out = String::with_capacity(s.len() + 2);
645 out.push('"');
646 for ch in s.chars() {
647 match ch {
648 '\\' => out.push_str("\\\\"),
649 '"' => out.push_str("\\\""),
650 '\n' => out.push_str("\\n"),
651 '\r' => out.push_str("\\r"),
652 '\t' => out.push_str("\\t"),
653 c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
654 out.push_str(&format!("\\u{:04X}", c as u32));
655 }
656 c => out.push(c),
657 }
658 }
659 out.push('"');
660 out
661}
662
663pub(crate) fn saphyr_emit_flow(value: &JsonValue) -> String {
666 let mut buf = String::new();
667 let opts = saphyr_opts();
668 match value {
669 JsonValue::Array(items) => {
670 let wrapped = FlowSeq(items.clone());
671 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
672 .expect("saphyr flow seq emission");
673 }
674 JsonValue::Object(map) => {
675 let wrapped = FlowMap(map.clone());
676 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
677 .expect("saphyr flow map emission");
678 }
679 scalar => {
680 let wrapped = FlowSeq(vec![scalar.clone()]);
682 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
683 .expect("saphyr flow scalar emission");
684 while buf.ends_with('\n') {
685 buf.pop();
686 }
687 return buf
688 .strip_prefix('[')
689 .and_then(|s| s.strip_suffix(']'))
690 .unwrap_or(&buf)
691 .to_string();
692 }
693 }
694 while buf.ends_with('\n') {
695 buf.pop();
696 }
697 buf
698}
699
700fn push_indent(out: &mut String, spaces: usize) {
703 for _ in 0..spaces {
704 out.push(' ');
705 }
706}
707
708#[cfg(test)]
711mod tests {
712 use super::*;
713 use crate::value::QuillValue;
714
715 fn assert_scalar_round_trips(value: serde_json::Value) {
716 let mut yaml = String::from("~~~card-yaml\n$quill: q\n$kind: main\nv: ");
717 yaml.push_str(&saphyr_emit_scalar(&value));
718 yaml.push_str("\n~~~\n");
719 let doc = crate::document::Document::from_markdown(&yaml).unwrap_or_else(|e| {
720 panic!(
721 "failed to parse emitted scalar {:?}: {}\n{}",
722 value, e, yaml
723 )
724 });
725 let parsed = doc.main().payload().get("v").expect("field 'v'").as_json();
726 assert_eq!(
727 parsed, &value,
728 "scalar round-trip mismatch for {:?}: emitted as {:?}",
729 value, yaml
730 );
731 }
732
733 #[test]
734 fn saphyr_scalar_round_trips_plain_string() {
735 assert_scalar_round_trips(serde_json::json!("hello"));
736 }
737
738 #[test]
739 fn saphyr_scalar_round_trips_ambiguous_strings() {
740 for ambiguous in &[
741 "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
742 ] {
743 assert_scalar_round_trips(serde_json::json!(*ambiguous));
744 }
745 }
746
747 #[test]
748 fn saphyr_scalar_round_trips_escapes() {
749 assert_scalar_round_trips(serde_json::json!("a\\b\"c\nd\te"));
750 }
751
752 #[test]
753 fn saphyr_scalar_round_trips_control_chars() {
754 assert_scalar_round_trips(serde_json::json!("\x01\x1F"));
755 }
756
757 fn p(key: &str) -> Vec<CommentPathSegment> {
758 vec![CommentPathSegment::Key(key.to_string())]
759 }
760
761 #[test]
762 fn empty_object_omitted() {
763 let value = QuillValue::from_json(serde_json::json!({}));
764 let mut out = String::new();
765 emit_field(
766 &mut out,
767 "empty_map",
768 value.as_json(),
769 0,
770 false,
771 &p("empty_map"),
772 &[],
773 None,
774 );
775 assert_eq!(out, "");
776 }
777
778 #[test]
779 fn empty_object_with_inline_trailer_degrades() {
780 let value = QuillValue::from_json(serde_json::json!({}));
781 let mut out = String::new();
782 emit_field(
783 &mut out,
784 "empty_map",
785 value.as_json(),
786 0,
787 false,
788 &p("empty_map"),
789 &[],
790 Some("orphan"),
791 );
792 assert_eq!(out, "# orphan\n");
793 }
794
795 #[test]
796 fn empty_array_emitted() {
797 let value = QuillValue::from_json(serde_json::json!([]));
798 let mut out = String::new();
799 emit_field(
800 &mut out,
801 "empty_seq",
802 value.as_json(),
803 0,
804 false,
805 &p("empty_seq"),
806 &[],
807 None,
808 );
809 assert_eq!(out, "empty_seq: []\n");
810 }
811
812 #[test]
813 fn scalar_field_with_inline_trailer() {
814 let value = QuillValue::from_json(serde_json::json!("Hello"));
815 let mut out = String::new();
816 emit_field(
817 &mut out,
818 "title",
819 value.as_json(),
820 0,
821 false,
822 &p("title"),
823 &[],
824 Some("greeting"),
825 );
826 assert_eq!(out, "title: Hello # greeting\n");
827 }
828
829 #[test]
830 fn container_field_with_inline_trailer_lands_on_key_line() {
831 let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
832 let mut out = String::new();
833 emit_field(
834 &mut out,
835 "outer",
836 value.as_json(),
837 0,
838 false,
839 &p("outer"),
840 &[],
841 Some("note"),
842 );
843 assert_eq!(out, "outer: # note\n inner: 1\n");
844 }
845
846 #[test]
847 fn fill_null_emits_bare_tag() {
848 let value = QuillValue::from_json(serde_json::Value::Null);
849 let mut out = String::new();
850 emit_field(
851 &mut out,
852 "recipient",
853 value.as_json(),
854 0,
855 true,
856 &p("recipient"),
857 &[],
858 None,
859 );
860 assert_eq!(out, "recipient: !fill\n");
861 }
862
863 #[test]
864 fn fill_string_emits_tag_with_value() {
865 let value = QuillValue::from_json(serde_json::json!("placeholder"));
866 let mut out = String::new();
867 emit_field(
868 &mut out,
869 "dept",
870 value.as_json(),
871 0,
872 true,
873 &p("dept"),
874 &[],
875 None,
876 );
877 assert_eq!(out, "dept: !fill placeholder\n");
878 }
879
880 #[test]
881 fn fill_with_inline_trailer() {
882 let value = QuillValue::from_json(serde_json::json!("placeholder"));
883 let mut out = String::new();
884 emit_field(
885 &mut out,
886 "dept",
887 value.as_json(),
888 0,
889 true,
890 &p("dept"),
891 &[],
892 Some("note"),
893 );
894 assert_eq!(out, "dept: !fill placeholder # note\n");
895 }
896
897 #[test]
898 fn fill_integer_emits_tag_with_value() {
899 let value = QuillValue::from_json(serde_json::json!(42));
900 let mut out = String::new();
901 emit_field(
902 &mut out,
903 "count",
904 value.as_json(),
905 0,
906 true,
907 &p("count"),
908 &[],
909 None,
910 );
911 assert_eq!(out, "count: !fill 42\n");
912 }
913}