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();
let items = card.frontmatter().items();
let mut i = 0;
if let Some(FrontmatterItem::Comment { text, inline: true }) = items.first() {
attach_inline_to_last_line(out, text);
i = 1;
}
while i < items.len() {
match &items[i] {
FrontmatterItem::Field { key, value, fill } => {
let trailer = items.get(i + 1).and_then(|next| match next {
FrontmatterItem::Comment { text, inline: true } => Some(text.as_str()),
_ => None,
});
let path = vec![CommentPathSegment::Key(key.clone())];
emit_field(out, key, value.as_json(), 0, *fill, &path, nested, trailer);
i += if trailer.is_some() { 2 } else { 1 };
}
FrontmatterItem::Comment { text, .. } => {
out.push_str("# ");
out.push_str(text);
out.push('\n');
i += 1;
}
}
}
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 attach_inline_to_last_line(out: &mut String, text: &str) {
if !out.ends_with('\n') {
out.push_str(" # ");
out.push_str(text);
out.push('\n');
return;
}
out.pop();
out.push_str(" # ");
out.push_str(text);
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);
}
}
fn emit_field(
out: &mut String,
key: &str,
value: &JsonValue,
indent: usize,
fill: bool,
path: &[CommentPathSegment],
nested: &[NestedComment],
inline_trailer: Option<&str>,
) {
if fill {
push_indent(out, indent);
out.push_str(key);
match value {
JsonValue::Null => {
out.push_str(": !fill");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
out.push_str(": !fill ");
emit_scalar(out, value);
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(items) if items.is_empty() => {
out.push_str(": !fill []");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(items) => {
out.push_str(": !fill");
push_trailer(out, inline_trailer);
out.push('\n');
emit_sequence_children(out, items, indent + 2, path, nested);
}
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);
out.push_str(key);
out.push(':');
push_trailer(out, inline_trailer);
out.push('\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(": []");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(items) => {
push_indent(out, indent);
out.push_str(key);
out.push(':');
push_trailer(out, inline_trailer);
out.push('\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);
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],
) {
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()));
emit_field(out, k, v, child_indent, false, &child_path, nested, 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],
) {
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, trailer);
}
emit_own_line_pending(out, path, items.len(), base_indent, nested);
emit_orphan_inlines(out, path, items.len(), base_indent, nested);
}
fn emit_sequence_item(
out: &mut String,
value: &JsonValue,
base_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
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,
&child_path,
nested,
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,
false,
&child_path,
nested,
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);
}
_ => {
push_indent(out, base_indent);
out.push_str("- ");
emit_scalar(out, value);
push_trailer(out, inline_trailer);
out.push('\n');
}
}
}
fn emit_field_inline(
out: &mut String,
key: &str,
value: &JsonValue,
child_indent: usize,
path: &[CommentPathSegment],
nested: &[NestedComment],
inline_trailer: Option<&str>,
) {
match value {
JsonValue::Object(map) if map.is_empty() => {
out.push_str(key);
out.push_str(": {}");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Object(map) => {
out.push_str(key);
out.push(':');
push_trailer(out, inline_trailer);
out.push('\n');
emit_mapping_children(out, map, child_indent, path, nested);
}
JsonValue::Array(items) if items.is_empty() => {
out.push_str(key);
out.push_str(": []");
push_trailer(out, inline_trailer);
out.push('\n');
}
JsonValue::Array(items) => {
out.push_str(key);
out.push(':');
push_trailer(out, inline_trailer);
out.push('\n');
emit_sequence_children(out, items, child_indent + 2, path, nested);
}
_ => {
out.push_str(key);
out.push_str(": ");
emit_scalar(out, value);
push_trailer(out, inline_trailer);
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()),
}
}
pub(crate) 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)) => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
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"),
&[],
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: !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: !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: !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: !fill 42\n");
}
}