use serde_json::Value as JsonValue;
use super::frontmatter::FrontmatterItem;
use super::prescan::{CommentPathSegment, NestedComment};
use super::{Card, Document, Sentinel};
impl Document {
pub fn to_markdown(&self) -> String {
let mut out = String::new();
emit_card_fence(&mut out, self.main());
out.push_str(self.main().body());
for card in self.cards() {
ensure_f2_before_fence(&mut out);
emit_card_fence(&mut out, card);
if !card.body().is_empty() {
out.push_str(card.body());
}
}
out
}
}
fn emit_card_fence(out: &mut String, card: &Card) {
out.push_str("---\n");
match card.sentinel() {
Sentinel::Main(r) => {
out.push_str("QUILL: ");
out.push_str(&r.to_string());
out.push('\n');
}
Sentinel::Card(tag) => {
out.push_str("CARD: ");
out.push_str(tag);
out.push('\n');
}
}
let nested = card.frontmatter().nested_comments();
for item in card.frontmatter().items() {
match item {
FrontmatterItem::Field { key, value, fill } => {
let path = vec![CommentPathSegment::Key(key.clone())];
emit_field(out, key, value.as_json(), 0, *fill, &path, nested);
}
FrontmatterItem::Comment { text } => {
out.push_str("# ");
out.push_str(text);
out.push('\n');
}
}
}
out.push_str("---\n");
}
fn ensure_f2_before_fence(out: &mut String) {
if out.is_empty() {
return;
}
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
fn emit_pending_comments(
out: &mut String,
path: &[CommentPathSegment],
position: usize,
indent: usize,
nested: &[NestedComment],
) {
for c in nested {
if c.position == position && c.container_path.as_slice() == path {
push_indent(out, indent);
out.push_str("# ");
out.push_str(&c.text);
out.push('\n');
}
}
}
fn emit_field(
out: &mut String,
key: &str,
value: &JsonValue,
indent: usize,
fill: bool,
path: &[CommentPathSegment],
nested: &[NestedComment],
) {
if fill {
push_indent(out, indent);
out.push_str(key);
match value {
JsonValue::Null => out.push_str(": !fill\n"),
JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
out.push_str(": !fill ");
emit_scalar(out, value);
out.push('\n');
}
JsonValue::Array(items) if items.is_empty() => {
out.push_str(": !fill []\n");
}
JsonValue::Array(items) => {
out.push_str(": !fill\n");
emit_sequence_children(out, items, indent + 2, path, nested);
}
JsonValue::Object(_) => {
out.push_str(": ");
emit_scalar(out, value);
out.push('\n');
}
}
return;
}
match value {
JsonValue::Object(map) if map.is_empty() => {
return;
}
JsonValue::Object(map) => {
push_indent(out, indent);
out.push_str(key);
out.push_str(":\n");
emit_mapping_children(out, map, indent + 2, path, nested);
}
JsonValue::Array(items) if items.is_empty() => {
push_indent(out, indent);
out.push_str(key);
out.push_str(": []\n");
}
JsonValue::Array(items) => {
push_indent(out, indent);
out.push_str(key);
out.push_str(":\n");
emit_sequence_children(out, items, indent + 2, path, nested);
}
_ => {
push_indent(out, indent);
out.push_str(key);
out.push_str(": ");
emit_scalar(out, value);
out.push('\n');
}
}
}
fn emit_mapping_children(
out: &mut String,
map: &serde_json::Map<String, JsonValue>,
child_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
) {
for (i, (k, v)) in map.iter().enumerate() {
emit_pending_comments(out, path, i, child_indent, nested);
let mut child_path = path.to_vec();
child_path.push(CommentPathSegment::Key(k.clone()));
emit_field(out, k, v, child_indent, false, &child_path, nested);
}
emit_pending_comments(out, path, map.len(), child_indent, nested);
}
fn emit_sequence_children(
out: &mut String,
items: &[JsonValue],
base_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
) {
for (i, item) in items.iter().enumerate() {
emit_pending_comments(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);
}
emit_pending_comments(out, path, items.len(), base_indent, nested);
}
fn emit_sequence_item(
out: &mut String,
value: &JsonValue,
base_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
) {
match value {
JsonValue::Object(map) if map.is_empty() => {
push_indent(out, base_indent);
out.push_str("- {}\n");
}
JsonValue::Object(map) => {
emit_pending_comments(out, path, 0, base_indent, nested);
let mut first = true;
for (i, (k, v)) in map.iter().enumerate() {
if !first {
emit_pending_comments(out, path, i, base_indent + 2, nested);
}
let mut child_path = path.to_vec();
child_path.push(CommentPathSegment::Key(k.clone()));
if first {
push_indent(out, base_indent);
out.push_str("- ");
emit_field_inline(out, k, v, base_indent + 2, &child_path, nested);
first = false;
} else {
emit_field(out, k, v, base_indent + 2, false, &child_path, nested);
}
}
emit_pending_comments(out, path, map.len(), base_indent + 2, nested);
}
JsonValue::Array(inner) if inner.is_empty() => {
push_indent(out, base_indent);
out.push_str("- []\n");
}
JsonValue::Array(inner) => {
push_indent(out, base_indent);
out.push_str("-\n");
emit_sequence_children(out, inner, base_indent + 2, path, nested);
}
_ => {
push_indent(out, base_indent);
out.push_str("- ");
emit_scalar(out, value);
out.push('\n');
}
}
}
fn emit_field_inline(
out: &mut String,
key: &str,
value: &JsonValue,
child_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
) {
match value {
JsonValue::Object(map) if map.is_empty() => {
out.push_str(key);
out.push_str(": {}\n");
}
JsonValue::Object(map) => {
out.push_str(key);
out.push_str(":\n");
emit_mapping_children(out, map, child_indent, path, nested);
}
JsonValue::Array(items) if items.is_empty() => {
out.push_str(key);
out.push_str(": []\n");
}
JsonValue::Array(items) => {
out.push_str(key);
out.push_str(":\n");
emit_sequence_children(out, items, child_indent + 2, path, nested);
}
_ => {
out.push_str(key);
out.push_str(": ");
emit_scalar(out, value);
out.push('\n');
}
}
}
fn emit_scalar(out: &mut String, value: &JsonValue) {
match value {
JsonValue::Null => out.push_str("null"),
JsonValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
JsonValue::Number(n) => out.push_str(&n.to_string()),
JsonValue::String(s) => emit_double_quoted(out, s),
other => out.push_str(&other.to_string()),
}
}
fn emit_double_quoted(out: &mut String, s: &str) {
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)) => {
let n = c as u32;
if n <= 0xFF {
out.push_str(&format!("\\u{:04X}", n));
} else {
out.push_str(&format!("\\u{:04X}", n));
}
}
c => out.push(c),
}
}
out.push('"');
}
fn push_indent(out: &mut String, spaces: usize) {
for _ in 0..spaces {
out.push(' ');
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::QuillValue;
#[test]
fn double_quoted_basic() {
let mut s = String::new();
emit_double_quoted(&mut s, "hello");
assert_eq!(s, r#""hello""#);
}
#[test]
fn double_quoted_ambiguous_strings() {
for ambiguous in &[
"on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
] {
let mut s = String::new();
emit_double_quoted(&mut s, ambiguous);
assert!(
s.starts_with('"') && s.ends_with('"'),
"should be double-quoted: {}",
s
);
assert_eq!(&s[1..s.len() - 1], *ambiguous);
}
}
#[test]
fn double_quoted_escapes() {
let mut s = String::new();
emit_double_quoted(&mut s, "a\\b\"c\nd\te");
assert_eq!(s, r#""a\\b\"c\nd\te""#);
}
#[test]
fn double_quoted_control_chars() {
let mut s = String::new();
emit_double_quoted(&mut s, "\x01\x1F");
assert_eq!(s, "\"\\u0001\\u001F\"");
}
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"),
&[],
);
assert_eq!(out, ""); }
#[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"),
&[],
);
assert_eq!(out, "empty_seq: []\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"),
&[],
);
assert_eq!(out, "recipient: !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"), &[]);
assert_eq!(out, "dept: !fill \"placeholder\"\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"),
&[],
);
assert_eq!(out, "count: !fill 42\n");
}
}