use serde_json::Value as JsonValue;
use serde_saphyr::{FlowMap, FlowSeq, SerializerOptions};
use super::payload::PayloadItem;
use super::prescan::{CommentPathSegment, NestedComment};
use super::{Card, Document};
impl Document {
pub fn to_markdown(&self) -> String {
let mut out = String::new();
emit_block(&mut out, self.main());
out.push_str(self.main().body());
for card in self.cards() {
ensure_blank_before_fence(&mut out);
emit_block(&mut out, card);
if !card.body().is_empty() {
out.push_str(card.body());
}
}
out
}
}
fn emit_meta_line(out: &mut String, key: &str, value: &str, trailer: Option<&str>) {
out.push('$');
out.push_str(key);
out.push_str(": ");
out.push_str(&saphyr_emit_scalar(&JsonValue::String(value.to_string())));
push_trailer(out, trailer);
out.push('\n');
}
fn emit_meta_block(
out: &mut String,
key: &str,
value: &serde_json::Map<String, JsonValue>,
trailer: Option<&str>,
nested: &[NestedComment],
) {
if value.is_empty() {
out.push_str(key);
out.push_str(": {}");
push_trailer(out, trailer);
out.push('\n');
return;
}
out.push_str(key);
out.push(':');
push_trailer(out, trailer);
out.push('\n');
let path: Vec<CommentPathSegment> = Vec::new();
emit_mapping_children(out, value, 2, &path, nested, &[]);
}
fn path_is_fill(fills: &[Vec<CommentPathSegment>], path: &[CommentPathSegment]) -> bool {
fills.iter().any(|p| p.as_slice() == path)
}
fn emit_block(out: &mut String, card: &Card) {
out.push_str("~~~\n");
emit_payload_items(out, card.payload().items());
out.push_str("~~~\n");
}
fn emit_payload_items(out: &mut String, items: &[PayloadItem]) {
let mut i = 0;
while i < items.len() {
let trailer = items.get(i + 1).and_then(|next| match next {
PayloadItem::Comment { text, inline: true } => Some(text.as_str()),
_ => None,
});
let mut consumed_trailer = trailer.is_some();
match &items[i] {
PayloadItem::Quill { reference } => {
emit_meta_line(out, "quill", &reference.to_string(), trailer);
}
PayloadItem::Kind { value } => {
emit_meta_line(out, "kind", value, trailer);
}
PayloadItem::Id { value } => {
emit_meta_line(out, "id", value, trailer);
}
PayloadItem::Meta {
key,
value,
nested_comments,
} => {
emit_meta_block(out, key.as_str(), value, trailer, nested_comments);
}
PayloadItem::Field {
key,
value,
fill,
nested_comments,
} => {
let path: Vec<CommentPathSegment> = Vec::new();
let fills = value.fill_paths();
emit_field(
out,
key,
value.as_json(),
0,
*fill,
&path,
nested_comments,
&fills,
trailer,
);
}
PayloadItem::Comment { text, .. } => {
out.push_str("# ");
out.push_str(text);
out.push('\n');
consumed_trailer = false;
}
}
i += if consumed_trailer { 2 } else { 1 };
}
}
fn ensure_blank_before_fence(out: &mut String) {
if out.is_empty() {
return;
}
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
fn emit_own_line_pending(
out: &mut String,
path: &[CommentPathSegment],
position: usize,
indent: usize,
nested: &[NestedComment],
) {
for c in nested {
if c.position == position && !c.inline && c.container_path.as_slice() == path {
push_indent(out, indent);
out.push_str("# ");
out.push_str(&c.text);
out.push('\n');
}
}
}
fn find_inline_trailer<'a>(
out: &mut String,
path: &[CommentPathSegment],
position: usize,
indent: usize,
nested: &'a [NestedComment],
) -> Option<&'a str> {
let mut chosen: Option<&str> = None;
for c in nested {
if c.position == position && c.inline && c.container_path.as_slice() == path {
if chosen.is_none() {
chosen = Some(c.text.as_str());
} else {
push_indent(out, indent);
out.push_str("# ");
out.push_str(&c.text);
out.push('\n');
}
}
}
chosen
}
fn emit_orphan_inlines(
out: &mut String,
path: &[CommentPathSegment],
container_len: usize,
indent: usize,
nested: &[NestedComment],
) {
for c in nested {
if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
push_indent(out, indent);
out.push_str("# ");
out.push_str(&c.text);
out.push('\n');
}
}
}
fn push_trailer(out: &mut String, trailer: Option<&str>) {
if let Some(t) = trailer {
out.push_str(" # ");
out.push_str(t);
}
}
#[allow(clippy::too_many_arguments)]
fn emit_field(
out: &mut String,
key: &str,
value: &JsonValue,
indent: usize,
fill: bool,
path: &[CommentPathSegment],
nested: &[NestedComment],
fills: &[Vec<CommentPathSegment>],
inline_trailer: Option<&str>,
) {
if fill {
push_indent(out, indent);
emit_key_at(out, key, indent);
match value {
JsonValue::Null => {
out.push_str(": !must_fill");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
out.push_str(": !must_fill ");
emit_scalar(out, value);
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(items) if items.is_empty() => {
out.push_str(": !must_fill []");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(items) => {
out.push_str(": !must_fill");
push_trailer(out, inline_trailer);
out.push('\n');
emit_sequence_children(out, items, indent + 2, path, nested, fills);
}
JsonValue::Object(_) => {
out.push_str(": ");
emit_scalar(out, value);
push_trailer(out, inline_trailer);
out.push('\n');
}
}
return;
}
match value {
JsonValue::Object(map) if map.is_empty() => {
if let Some(t) = inline_trailer {
push_indent(out, indent);
out.push_str("# ");
out.push_str(t);
out.push('\n');
}
}
JsonValue::Object(map) => {
push_indent(out, indent);
emit_key_at(out, key, indent);
out.push(':');
push_trailer(out, inline_trailer);
out.push('\n');
emit_mapping_children(out, map, indent + 2, path, nested, fills);
}
JsonValue::Array(items) if items.is_empty() => {
push_indent(out, indent);
emit_key_at(out, key, indent);
out.push_str(": []");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(items) => {
push_indent(out, indent);
emit_key_at(out, key, indent);
out.push(':');
push_trailer(out, inline_trailer);
out.push('\n');
emit_sequence_children(out, items, indent + 2, path, nested, fills);
}
_ => {
push_indent(out, indent);
emit_key_at(out, key, indent);
out.push_str(": ");
emit_scalar(out, value);
push_trailer(out, inline_trailer);
out.push('\n');
}
}
}
fn emit_mapping_children(
out: &mut String,
map: &serde_json::Map<String, JsonValue>,
child_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
fills: &[Vec<CommentPathSegment>],
) {
for (i, (k, v)) in map.iter().enumerate() {
emit_own_line_pending(out, path, i, child_indent, nested);
let trailer = find_inline_trailer(out, path, i, child_indent, nested);
let mut child_path = path.to_vec();
child_path.push(CommentPathSegment::Key(k.clone()));
let child_fill = path_is_fill(fills, &child_path);
emit_field(
out,
k,
v,
child_indent,
child_fill,
&child_path,
nested,
fills,
trailer,
);
}
emit_own_line_pending(out, path, map.len(), child_indent, nested);
emit_orphan_inlines(out, path, map.len(), child_indent, nested);
}
fn emit_sequence_children(
out: &mut String,
items: &[JsonValue],
base_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
fills: &[Vec<CommentPathSegment>],
) {
for (i, item) in items.iter().enumerate() {
emit_own_line_pending(out, path, i, base_indent, nested);
let trailer = find_inline_trailer(out, path, i, base_indent, nested);
let mut child_path = path.to_vec();
child_path.push(CommentPathSegment::Index(i));
emit_sequence_item(out, item, base_indent, &child_path, nested, fills, trailer);
}
emit_own_line_pending(out, path, items.len(), base_indent, nested);
emit_orphan_inlines(out, path, items.len(), base_indent, nested);
}
#[allow(clippy::too_many_arguments)]
fn emit_sequence_item(
out: &mut String,
value: &JsonValue,
base_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
fills: &[Vec<CommentPathSegment>],
inline_trailer: Option<&str>,
) {
match value {
JsonValue::Object(map) if map.is_empty() => {
push_indent(out, base_indent);
out.push_str("- {}");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Object(map) => {
emit_own_line_pending(out, path, 0, base_indent, nested);
let mut first = true;
for (i, (k, v)) in map.iter().enumerate() {
if !first {
emit_own_line_pending(out, path, i, base_indent + 2, nested);
}
let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
let mut child_path = path.to_vec();
child_path.push(CommentPathSegment::Key(k.clone()));
if first {
let line_trailer = inline_trailer.or(inner_trailer);
push_indent(out, base_indent);
out.push_str("- ");
emit_field_inline(
out,
k,
v,
base_indent + 2,
path_is_fill(fills, &child_path),
&child_path,
nested,
fills,
line_trailer,
);
if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
push_indent(out, base_indent + 2);
out.push_str("# ");
out.push_str(loser);
out.push('\n');
}
first = false;
} else {
emit_field(
out,
k,
v,
base_indent + 2,
path_is_fill(fills, &child_path),
&child_path,
nested,
fills,
inner_trailer,
);
}
}
emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
}
JsonValue::Array(inner) if inner.is_empty() => {
push_indent(out, base_indent);
out.push_str("- []");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(inner) => {
push_indent(out, base_indent);
out.push('-');
push_trailer(out, inline_trailer);
out.push('\n');
emit_sequence_children(out, inner, base_indent + 2, path, nested, fills);
}
_ => {
push_indent(out, base_indent);
out.push_str("- ");
emit_scalar(out, value);
push_trailer(out, inline_trailer);
out.push('\n');
}
}
}
#[allow(clippy::too_many_arguments)]
fn emit_field_inline(
out: &mut String,
key: &str,
value: &JsonValue,
child_indent: usize,
fill: bool,
path: &[CommentPathSegment],
nested: &[NestedComment],
fills: &[Vec<CommentPathSegment>],
inline_trailer: Option<&str>,
) {
if fill {
emit_key(out, key);
match value {
JsonValue::Null => out.push_str(": !must_fill"),
JsonValue::Array(items) if items.is_empty() => out.push_str(": !must_fill []"),
JsonValue::Array(items) => {
out.push_str(": !must_fill");
push_trailer(out, inline_trailer);
out.push('\n');
emit_sequence_children(out, items, child_indent + 2, path, nested, fills);
return;
}
JsonValue::Object(_) => {
out.push(':');
push_trailer(out, inline_trailer);
out.push('\n');
if let JsonValue::Object(map) = value {
emit_mapping_children(out, map, child_indent, path, nested, fills);
}
return;
}
_ => {
out.push_str(": !must_fill ");
emit_scalar(out, value);
}
}
push_trailer(out, inline_trailer);
out.push('\n');
return;
}
match value {
JsonValue::Object(map) if map.is_empty() => {
emit_key(out, key);
out.push_str(": {}");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Object(map) => {
emit_key(out, key);
out.push(':');
push_trailer(out, inline_trailer);
out.push('\n');
emit_mapping_children(out, map, child_indent, path, nested, fills);
}
JsonValue::Array(items) if items.is_empty() => {
emit_key(out, key);
out.push_str(": []");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(items) => {
emit_key(out, key);
out.push(':');
push_trailer(out, inline_trailer);
out.push('\n');
emit_sequence_children(out, items, child_indent + 2, path, nested, fills);
}
_ => {
emit_key(out, key);
out.push_str(": ");
emit_scalar(out, value);
push_trailer(out, inline_trailer);
out.push('\n');
}
}
}
fn emit_scalar(out: &mut String, value: &JsonValue) {
let s = saphyr_emit_scalar(value);
out.push_str(&s);
}
fn emit_key(out: &mut String, key: &str) {
out.push_str(&saphyr_emit_scalar(&JsonValue::String(key.to_string())));
}
fn emit_key_at(out: &mut String, key: &str, indent: usize) {
if indent == 0 {
out.push_str(key);
} else {
emit_key(out, key);
}
}
fn saphyr_opts() -> SerializerOptions {
SerializerOptions {
prefer_block_scalars: false,
..SerializerOptions::default()
}
}
pub(crate) fn saphyr_emit_scalar(value: &JsonValue) -> String {
let mut buf = String::new();
serde_saphyr::to_fmt_writer_with_options(&mut buf, value, saphyr_opts())
.expect("saphyr scalar emission is infallible for JsonValue scalars");
while buf.ends_with('\n') {
buf.pop();
}
if let JsonValue::String(s) = value {
let unquoted = !buf.starts_with('"')
&& !buf.starts_with('\'')
&& !buf.starts_with('|')
&& !buf.starts_with('>');
let has_edge_whitespace = !s.is_empty()
&& (s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace));
if unquoted && has_edge_whitespace {
return double_quote_string(s);
}
}
buf
}
fn double_quote_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
pub(crate) fn saphyr_emit_flow(value: &JsonValue) -> String {
let mut buf = String::new();
let opts = saphyr_opts();
match value {
JsonValue::Array(items) => {
let wrapped = FlowSeq(items.clone());
serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
.expect("saphyr flow seq emission");
}
JsonValue::Object(map) => {
let wrapped = FlowMap(map.clone());
serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
.expect("saphyr flow map emission");
}
scalar => {
let wrapped = FlowSeq(vec![scalar.clone()]);
serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
.expect("saphyr flow scalar emission");
while buf.ends_with('\n') {
buf.pop();
}
return buf
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(&buf)
.to_string();
}
}
while buf.ends_with('\n') {
buf.pop();
}
buf
}
fn push_indent(out: &mut String, spaces: usize) {
for _ in 0..spaces {
out.push(' ');
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::QuillValue;
fn assert_scalar_round_trips(value: serde_json::Value) {
let mut yaml = String::from("~~~card-yaml\n$quill: q\n$kind: main\nv: ");
yaml.push_str(&saphyr_emit_scalar(&value));
yaml.push_str("\n~~~\n");
let doc = crate::document::Document::from_markdown(&yaml).unwrap_or_else(|e| {
panic!(
"failed to parse emitted scalar {:?}: {}\n{}",
value, e, yaml
)
});
let parsed = doc.main().payload().get("v").expect("field 'v'").as_json();
assert_eq!(
parsed, &value,
"scalar round-trip mismatch for {:?}: emitted as {:?}",
value, yaml
);
}
#[test]
fn saphyr_scalar_round_trips_plain_string() {
assert_scalar_round_trips(serde_json::json!("hello"));
}
#[test]
fn saphyr_scalar_round_trips_ambiguous_strings() {
for ambiguous in &[
"on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
] {
assert_scalar_round_trips(serde_json::json!(*ambiguous));
}
}
#[test]
fn saphyr_scalar_round_trips_escapes() {
assert_scalar_round_trips(serde_json::json!("a\\b\"c\nd\te"));
}
#[test]
fn saphyr_scalar_round_trips_control_chars() {
assert_scalar_round_trips(serde_json::json!("\x01\x1F"));
}
fn p(key: &str) -> Vec<CommentPathSegment> {
vec![CommentPathSegment::Key(key.to_string())]
}
#[test]
fn empty_object_omitted() {
let value = QuillValue::from_json(serde_json::json!({}));
let mut out = String::new();
emit_field(
&mut out,
"empty_map",
value.as_json(),
0,
false,
&p("empty_map"),
&[],
&[],
None,
);
assert_eq!(out, "");
}
#[test]
fn empty_object_with_inline_trailer_degrades() {
let value = QuillValue::from_json(serde_json::json!({}));
let mut out = String::new();
emit_field(
&mut out,
"empty_map",
value.as_json(),
0,
false,
&p("empty_map"),
&[],
&[],
Some("orphan"),
);
assert_eq!(out, "# orphan\n");
}
#[test]
fn empty_array_emitted() {
let value = QuillValue::from_json(serde_json::json!([]));
let mut out = String::new();
emit_field(
&mut out,
"empty_seq",
value.as_json(),
0,
false,
&p("empty_seq"),
&[],
&[],
None,
);
assert_eq!(out, "empty_seq: []\n");
}
#[test]
fn scalar_field_with_inline_trailer() {
let value = QuillValue::from_json(serde_json::json!("Hello"));
let mut out = String::new();
emit_field(
&mut out,
"title",
value.as_json(),
0,
false,
&p("title"),
&[],
&[],
Some("greeting"),
);
assert_eq!(out, "title: Hello # greeting\n");
}
#[test]
fn container_field_with_inline_trailer_lands_on_key_line() {
let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
let mut out = String::new();
emit_field(
&mut out,
"outer",
value.as_json(),
0,
false,
&p("outer"),
&[],
&[],
Some("note"),
);
assert_eq!(out, "outer: # note\n inner: 1\n");
}
#[test]
fn fill_null_emits_bare_tag() {
let value = QuillValue::from_json(serde_json::Value::Null);
let mut out = String::new();
emit_field(
&mut out,
"recipient",
value.as_json(),
0,
true,
&p("recipient"),
&[],
&[],
None,
);
assert_eq!(out, "recipient: !must_fill\n");
}
#[test]
fn fill_string_emits_tag_with_value() {
let value = QuillValue::from_json(serde_json::json!("placeholder"));
let mut out = String::new();
emit_field(
&mut out,
"dept",
value.as_json(),
0,
true,
&p("dept"),
&[],
&[],
None,
);
assert_eq!(out, "dept: !must_fill placeholder\n");
}
#[test]
fn fill_with_inline_trailer() {
let value = QuillValue::from_json(serde_json::json!("placeholder"));
let mut out = String::new();
emit_field(
&mut out,
"dept",
value.as_json(),
0,
true,
&p("dept"),
&[],
&[],
Some("note"),
);
assert_eq!(out, "dept: !must_fill placeholder # note\n");
}
#[test]
fn fill_integer_emits_tag_with_value() {
let value = QuillValue::from_json(serde_json::json!(42));
let mut out = String::new();
emit_field(
&mut out,
"count",
value.as_json(),
0,
true,
&p("count"),
&[],
&[],
None,
);
assert_eq!(out, "count: !must_fill 42\n");
}
}