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