quillmark_core/document/emit.rs
1//! Canonical Markdown emission for [`Document`].
2//!
3//! This module implements [`Document::to_markdown`], which converts a typed
4//! in-memory `Document` back into canonical Quillmark Markdown.
5//!
6//! ## YAML emission strategy
7//!
8//! Scalar emission (quoting, escaping, multi-line handling) is delegated to
9//! `serde-saphyr` — the same library used for parsing. This makes the emit
10//! and parse sides of the wire symmetric by construction: anything saphyr
11//! decides to quote on emit, saphyr will read back as a string on parse.
12//! Delegation also covers the YAML 1.1 edge cases that ad-hoc quoting
13//! heuristics miss (`on`/`yes`/`off`, leading-zero integers, `1.0`-style
14//! numerics) — saphyr handles them all.
15//!
16//! `prefer_block_scalars: false` keeps multi-line strings inline as
17//! double-quoted scalars with `\n` escapes, so the emitter never produces
18//! `|` or `>` block forms in v1.
19//!
20//! This module owns the surrounding structure — `~~~` card-yaml fences,
21//! `$`-prefixed system-metadata lines, field ordering, indentation, comment
22//! interleaving — and calls saphyr only for the scalar leaves.
23
24use 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
31// ── Public entry point ────────────────────────────────────────────────────────
32
33impl Document {
34 /// Emit canonical Quillmark Markdown from this document.
35 ///
36 /// # Contract
37 ///
38 /// 1. **Type-fidelity round-trip.** `Document::parse(&doc.to_markdown())`
39 /// returns a `Document` equal to `doc` by value *and* by type variant.
40 /// `QuillValue::String("on")` round-trips as a string, never as a bool.
41 /// `QuillValue::String("01234")` round-trips as a string, never as an
42 /// integer. This guarantee is the whole point of owning emission.
43 ///
44 /// **Content-field carve-out.** A richtext field committed as a canonical
45 /// content object (and the card `$body`) is *intentionally* markdown-lossy
46 /// on markdown emit: it projects to its markdown form (`project_content_field`),
47 /// so identity marks (anchors, island ids) and content-only marks
48 /// (`underline`) do not survive a `to_markdown`→`from_markdown` round-trip.
49 /// On-disk identity is markdown-lossy by design; the storage DTO is the
50 /// lossless carrier. The value-equality guarantee above holds for every
51 /// field the writer did not commit as canonical content.
52 ///
53 /// 2. **Emit-idempotent.** `to_markdown` is a pure function of `doc`; two
54 /// calls on the same `doc` return byte-equal strings.
55 ///
56 /// Byte-equality with the *original source* is **not** guaranteed.
57 ///
58 /// # Emission rules (§9)
59 ///
60 /// - Line endings: `\n` only. CRLF normalization happens on import.
61 /// - Every block is emitted as a `~~~` card-yaml fence: a bare `~~~`
62 /// opener, the `$`-prefixed system-metadata lines (`$quill: <ref>` for
63 /// the root block, `$kind: <kind>` for composable cards) leading the
64 /// YAML payload, the user-defined data fields, then a closing `~~~`.
65 /// - Cards: one blank line before each, then the block, then the card body.
66 /// - Body: emitted verbatim after the root block (and after each card).
67 /// - Mappings and sequences: **block style** at every nesting level.
68 /// - Scalars (booleans, null, numbers, strings): delegated to
69 /// `serde-saphyr`, which emits the type-canonical form (`true`/
70 /// `false`, `null`, bare numeric literal) and quotes strings only
71 /// when the unquoted form would be misread (`on`/`yes`/`off`,
72 /// `null`/`~`, numeric-looking strings, leading flow indicators,
73 /// `: ` runs, …). Quoting form is not stable — what matters is
74 /// that the emitted scalar round-trips to the same `QuillValue`
75 /// variant. This is the type-fidelity guarantee.
76 /// - Multi-line strings: emitted as inline double-quoted scalars with
77 /// `\n` escapes; no `|` / `>` block forms.
78 ///
79 /// # Design notes
80 ///
81 /// - **Nested-map order.** `QuillValue` is backed by `serde_json::Value`
82 /// whose object type (`serde_json::Map`) preserves insertion order when the
83 /// `serde_json/preserve_order` feature is enabled (it is in this workspace).
84 /// Insertion order is therefore preserved for nested maps at emit time.
85 ///
86 /// - **Empty containers.**
87 /// - Empty object (`{}`) → the key is **omitted** from emit entirely.
88 /// - Empty array (`[]`) → emitted as `key: []\n`.
89 ///
90 /// # What is preserved
91 ///
92 /// - **YAML comments**: own-line and inline trailing comments round-trip
93 /// at their source position. Comments whose host disappears at emit time
94 /// (empty-mapping omission, programmatic field removal) degrade to
95 /// own-line comments at the same indent so the comment text is preserved
96 /// even when its position shifts.
97 /// - **`!must_fill` tags**: round-trip via the `fill` flag on `PayloadItem::Field`.
98 ///
99 /// # What is lost
100 ///
101 /// - **Other custom tags** (`!include`, `!env`, …): the tag is dropped;
102 /// the scalar value is preserved.
103 /// - **Original quoting style**: strings are re-emitted in saphyr's
104 /// canonical form (plain when safe, quoted when ambiguous). The
105 /// form chosen for emit may not match the form in the source.
106 pub fn to_markdown(&self) -> String {
107 let mut out = String::new();
108
109 // ── Root block (card-yaml fence + global body) ────────────────────────
110 // Bodies are content values; the markdown surface is their export projection,
111 // so a `Document` → markdown → `Document` round-trip canonicalizes the
112 // body markdown (leading and trailing blank lines dropped — the
113 // projection is a value, not a file). A blank line separates the closing
114 // fence from a non-empty body, the conventional card-yaml shape; the
115 // file-final newline is added at the end of this method.
116 emit_block(&mut out, self.main());
117 append_body(&mut out, &self.main().body_markdown());
118
119 // ── Composable cards ──────────────────────────────────────────────────
120 // `ensure_blank_before_fence` normalises the separator before each
121 // block, so edited bodies (which may lack a trailing blank line) still
122 // round-trip.
123 for card in self.cards() {
124 ensure_blank_before_fence(&mut out);
125 emit_block(&mut out, card);
126 append_body(&mut out, &card.body_markdown());
127 }
128
129 // The body projection (`body_markdown`) emits no trailing newline — it is
130 // a value, not a file (issue #965) — so a document ending in a body ends
131 // without one. `.qmd` is a file; own its final newline here. A
132 // fence-terminated document already ends in `\n`, so this is then a no-op.
133 if !out.ends_with('\n') {
134 out.push('\n');
135 }
136
137 out
138 }
139}
140
141/// Append a card's markdown body after its closing fence, separated by one
142/// blank line (the conventional card-yaml shape). Empty bodies append nothing —
143/// the fence closes and the next block (or EOF) follows.
144fn append_body(out: &mut String, body: &str) {
145 if !body.is_empty() {
146 out.push('\n');
147 out.push_str(body);
148 }
149}
150
151// ── Block emission ────────────────────────────────────────────────────────────
152
153fn emit_meta_line(out: &mut String, key: &str, value: &str, trailer: Option<&str>) {
154 out.push('$');
155 out.push_str(key);
156 out.push_str(": ");
157 out.push_str(&saphyr_emit_scalar(&JsonValue::String(value.to_string())));
158 push_trailer(out, trailer);
159 out.push('\n');
160}
161
162/// Emit an out-of-band meta block (`$ext` / `$seed`). An empty map emits inline
163/// as `<key>: {}` so the declaration survives the round-trip; a non-empty map
164/// emits as a `<key>:` header followed by indented block-style children.
165/// `nested` carries comments with paths relative to the value tree (the meta
166/// key itself is not in the path) — the child mapping walker re-injects them at
167/// the matching positions. Meta maps are out-of-band data and never carry
168/// `!must_fill`.
169fn emit_meta_block(
170 out: &mut String,
171 key: &str,
172 value: &serde_json::Map<String, JsonValue>,
173 trailer: Option<&str>,
174 nested: &[NestedComment],
175) {
176 if value.is_empty() {
177 out.push_str(key);
178 out.push_str(": {}");
179 push_trailer(out, trailer);
180 out.push('\n');
181 return;
182 }
183 out.push_str(key);
184 out.push(':');
185 push_trailer(out, trailer);
186 out.push('\n');
187 let path: Vec<CommentPathSegment> = Vec::new();
188 emit_mapping_children(out, value, 2, &path, nested, &[]);
189}
190
191/// `true` when `path` (relative to a field value) carries a `!must_fill`
192/// marker. Fill sets are small (one entry per placeholder), so a linear
193/// scan is cheaper than building a hash set per field.
194fn path_is_fill(fills: &[Vec<CommentPathSegment>], path: &[CommentPathSegment]) -> bool {
195 fills.iter().any(|p| p.as_slice() == path)
196}
197
198fn emit_block(out: &mut String, card: &Card) {
199 out.push_str("~~~\n");
200 emit_payload_items(out, card.payload().items());
201 out.push_str("~~~\n");
202}
203
204/// Walk the unified item list and emit each entry. An `inline: true` comment
205/// immediately following a non-comment item is consumed as that item's trailer.
206///
207/// Each `Field` / `Ext` item carries its own `nested_comments` slice with
208/// paths relative to the field's value tree, so emission of nested
209/// structures starts with an empty container path.
210fn emit_payload_items(out: &mut String, items: &[PayloadItem]) {
211 let mut i = 0;
212 while i < items.len() {
213 // Peek for a trailing inline comment to use as the line trailer.
214 let trailer = items.get(i + 1).and_then(|next| match next {
215 PayloadItem::Comment { text, inline: true } => Some(text.as_str()),
216 _ => None,
217 });
218 let mut consumed_trailer = trailer.is_some();
219
220 match &items[i] {
221 PayloadItem::Quill { reference } => {
222 emit_meta_line(out, "quill", &reference.to_string(), trailer);
223 }
224 PayloadItem::Kind { value } => {
225 emit_meta_line(out, "kind", value, trailer);
226 }
227 PayloadItem::Id { value } => {
228 emit_meta_line(out, "id", value, trailer);
229 }
230 PayloadItem::Meta {
231 key,
232 value,
233 nested_comments,
234 } => {
235 emit_meta_block(out, key.as_str(), value, trailer, nested_comments);
236 }
237 PayloadItem::Field {
238 key,
239 value,
240 fill,
241 nested_comments,
242 } => {
243 // A richtext field stores its value as a canonical content
244 // object (via `commit_field`); card-yaml is the
245 // human-authored surface, so it projects back to a markdown
246 // string here — the field-level twin of the `$body` projection,
247 // lossy per the content's island loss class (the DTO stays the
248 // lossless carrier). A content field is never `!must_fill` and
249 // its content carries no user nested-comments/fills, so the
250 // projected scalar routes through the plain string path.
251 if !*fill {
252 if let Some(markdown) = project_content_field(value.as_json()) {
253 emit_field(
254 out,
255 key,
256 &JsonValue::String(markdown),
257 0,
258 false,
259 &[],
260 &[],
261 &[],
262 trailer,
263 );
264 i += if consumed_trailer { 2 } else { 1 };
265 continue;
266 }
267 }
268 // Paths in `nested_comments` are relative to this field's
269 // value, so the container path starts empty.
270 let path: Vec<CommentPathSegment> = Vec::new();
271 // `!must_fill` markers on nested nodes, as paths relative to
272 // this field's value; the top-level marker rides on `*fill`.
273 let fills = value.fill_paths();
274 emit_field(
275 out,
276 key,
277 value.as_json(),
278 0,
279 *fill,
280 &path,
281 nested_comments,
282 &fills,
283 trailer,
284 );
285 }
286 PayloadItem::Comment { text, .. } => {
287 out.push_str("# ");
288 out.push_str(text);
289 out.push('\n');
290 consumed_trailer = false;
291 }
292 }
293 i += if consumed_trailer { 2 } else { 1 };
294 }
295}
296
297/// The markdown projection of a richtext-valued field, or `None` when `value` is
298/// not a canonical content object.
299///
300/// A richtext field written via [`Card::commit_field`](super::Card::commit_field)
301/// stores the canonical content object; emit projects it to a markdown string so
302/// card-yaml — the human-authored surface — stays markdown-clean rather than
303/// carrying a nested `{text, lines, marks, islands}` tree. Projection is lossy
304/// per the content's island loss class (the same tradeoff `$body` makes): island
305/// ids and content-only marks do not survive a markdown round-trip, so on-disk
306/// identity is markdown-lossy by design; the storage DTO is the lossless carrier.
307///
308/// The guard requires the object to serialize back to a **byte-identical**
309/// canonical content, so a user object field that merely resembles one (extra
310/// keys, non-canonical shape, or non-canonical key order) stays structural. The
311/// comparison is on the serialized *strings*, not the `serde_json::Value`s: with
312/// `serde_json/preserve_order` on (it is in this workspace), `Value`'s `PartialEq`
313/// is an order-independent `IndexMap` compare, so a `Value != Value` guard would
314/// also accept a content-canonical object whose keys are in non-canonical order —
315/// projecting (and thus markdown-flattening) it. String equality pins key order.
316///
317/// A content object normally only arises from the programmatic content writer
318/// (`from_markdown` is schema-less and stores a markdown-authored richtext field
319/// as a plain string), so on a parse-originated document this projects only the
320/// fields the writer deliberately committed as content.
321fn project_content_field(value: &JsonValue) -> Option<String> {
322 if !value.is_object() {
323 return None;
324 }
325 let rt = quillmark_content::serial::from_canonical_value(value).ok()?;
326 // Byte-exact: canonical-string equality, not `Value` equality (which is
327 // order-independent under `preserve_order`). Only a content in canonical key
328 // order projects; anything else stays a structural field.
329 let canonical = quillmark_content::serial::to_canonical_value(&rt);
330 if serde_json::to_string(&canonical).ok()? != serde_json::to_string(value).ok()? {
331 return None;
332 }
333 Some(quillmark_content::export::to_markdown(&rt))
334}
335
336/// Ensure `out` ends with `\n\n` so the next fence has a blank line above it.
337/// Appends a line terminator first if `out` doesn't already end with `\n`.
338/// No-op on empty `out` (block at line 1 needs no separator).
339fn ensure_blank_before_fence(out: &mut String) {
340 if out.is_empty() {
341 return;
342 }
343 if !out.ends_with('\n') {
344 out.push('\n');
345 }
346 out.push('\n');
347}
348
349// ── YAML value emission ───────────────────────────────────────────────────────
350
351/// Emit own-line nested comments at `position` in `path` (inline comments are
352/// handled by `find_inline_trailer`).
353fn emit_own_line_pending(
354 out: &mut String,
355 path: &[CommentPathSegment],
356 position: usize,
357 indent: usize,
358 nested: &[NestedComment],
359) {
360 for c in nested {
361 if c.position == position && !c.inline && c.container_path.as_slice() == path {
362 push_indent(out, indent);
363 out.push_str("# ");
364 out.push_str(&c.text);
365 out.push('\n');
366 }
367 }
368}
369
370/// Return the inline trailer for `position` in `path`. If multiple inline
371/// comments share the slot, returns the first and emits the rest as own-line.
372fn find_inline_trailer<'a>(
373 out: &mut String,
374 path: &[CommentPathSegment],
375 position: usize,
376 indent: usize,
377 nested: &'a [NestedComment],
378) -> Option<&'a str> {
379 let mut chosen: Option<&str> = None;
380 for c in nested {
381 if c.position == position && c.inline && c.container_path.as_slice() == path {
382 if chosen.is_none() {
383 chosen = Some(c.text.as_str());
384 } else {
385 push_indent(out, indent);
386 out.push_str("# ");
387 out.push_str(&c.text);
388 out.push('\n');
389 }
390 }
391 }
392 chosen
393}
394
395/// Emit orphan inline comments (`position >= container_len`) as own-line.
396fn emit_orphan_inlines(
397 out: &mut String,
398 path: &[CommentPathSegment],
399 container_len: usize,
400 indent: usize,
401 nested: &[NestedComment],
402) {
403 for c in nested {
404 if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
405 push_indent(out, indent);
406 out.push_str("# ");
407 out.push_str(&c.text);
408 out.push('\n');
409 }
410 }
411}
412
413fn push_trailer(out: &mut String, trailer: Option<&str>) {
414 if let Some(t) = trailer {
415 out.push_str(" # ");
416 out.push_str(t);
417 }
418}
419
420/// Emit a `key: <value>\n` pair at `indent` spaces.
421///
422/// `path` is the container path for nested-comment interleaving. Empty objects
423/// are omitted; their inline trailer degrades to an own-line comment to
424/// preserve the text. Empty arrays emit `key: []\n`. When `fill` is `true`:
425/// scalars → `key: !must_fill <value>`, empty seqs → `key: !must_fill []`, null →
426/// `key: !must_fill`, non-empty seqs → `key: !must_fill\n - …`. Mappings with `fill`
427/// are rejected at parse and never reach this path.
428#[allow(clippy::too_many_arguments)]
429fn emit_field(
430 out: &mut String,
431 key: &str,
432 value: &JsonValue,
433 indent: usize,
434 fill: bool,
435 path: &[CommentPathSegment],
436 nested: &[NestedComment],
437 fills: &[Vec<CommentPathSegment>],
438 inline_trailer: Option<&str>,
439) {
440 if fill {
441 push_indent(out, indent);
442 emit_key_at(out, key, indent);
443 match value {
444 JsonValue::Null => {
445 out.push_str(": !must_fill");
446 push_trailer(out, inline_trailer);
447 out.push('\n');
448 }
449 JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
450 out.push_str(": !must_fill ");
451 emit_scalar(out, value);
452 push_trailer(out, inline_trailer);
453 out.push('\n');
454 }
455 JsonValue::Array(items) if items.is_empty() => {
456 out.push_str(": !must_fill []");
457 push_trailer(out, inline_trailer);
458 out.push('\n');
459 }
460 JsonValue::Array(items) => {
461 out.push_str(": !must_fill");
462 push_trailer(out, inline_trailer);
463 out.push('\n');
464 emit_sequence_children(out, items, indent + 2, path, nested, fills);
465 }
466 JsonValue::Object(_) => {
467 out.push_str(": ");
468 emit_scalar(out, value);
469 push_trailer(out, inline_trailer);
470 out.push('\n');
471 }
472 }
473 return;
474 }
475 match value {
476 JsonValue::Object(map) if map.is_empty() => {
477 if let Some(t) = inline_trailer {
478 push_indent(out, indent);
479 out.push_str("# ");
480 out.push_str(t);
481 out.push('\n');
482 }
483 }
484 JsonValue::Object(map) => {
485 push_indent(out, indent);
486 emit_key_at(out, key, indent);
487 out.push(':');
488 push_trailer(out, inline_trailer);
489 out.push('\n');
490 emit_mapping_children(out, map, indent + 2, path, nested, fills);
491 }
492 JsonValue::Array(items) if items.is_empty() => {
493 push_indent(out, indent);
494 emit_key_at(out, key, indent);
495 out.push_str(": []");
496 push_trailer(out, inline_trailer);
497 out.push('\n');
498 }
499 JsonValue::Array(items) => {
500 push_indent(out, indent);
501 emit_key_at(out, key, indent);
502 out.push(':');
503 push_trailer(out, inline_trailer);
504 out.push('\n');
505 emit_sequence_children(out, items, indent + 2, path, nested, fills);
506 }
507 _ => {
508 push_indent(out, indent);
509 emit_key_at(out, key, indent);
510 out.push_str(": ");
511 emit_scalar(out, value);
512 push_trailer(out, inline_trailer);
513 out.push('\n');
514 }
515 }
516}
517
518fn emit_mapping_children(
519 out: &mut String,
520 map: &serde_json::Map<String, JsonValue>,
521 child_indent: usize,
522 path: &[CommentPathSegment],
523 nested: &[NestedComment],
524 fills: &[Vec<CommentPathSegment>],
525) {
526 for (i, (k, v)) in map.iter().enumerate() {
527 emit_own_line_pending(out, path, i, child_indent, nested);
528 let trailer = find_inline_trailer(out, path, i, child_indent, nested);
529 let mut child_path = path.to_vec();
530 child_path.push(CommentPathSegment::Key(k.clone()));
531 let child_fill = path_is_fill(fills, &child_path);
532 emit_field(
533 out,
534 k,
535 v,
536 child_indent,
537 child_fill,
538 &child_path,
539 nested,
540 fills,
541 trailer,
542 );
543 }
544 emit_own_line_pending(out, path, map.len(), child_indent, nested);
545 emit_orphan_inlines(out, path, map.len(), child_indent, nested);
546}
547
548fn emit_sequence_children(
549 out: &mut String,
550 items: &[JsonValue],
551 base_indent: usize,
552 path: &[CommentPathSegment],
553 nested: &[NestedComment],
554 fills: &[Vec<CommentPathSegment>],
555) {
556 for (i, item) in items.iter().enumerate() {
557 emit_own_line_pending(out, path, i, base_indent, nested);
558 let trailer = find_inline_trailer(out, path, i, base_indent, nested);
559 let mut child_path = path.to_vec();
560 child_path.push(CommentPathSegment::Index(i));
561 emit_sequence_item(out, item, base_indent, &child_path, nested, fills, trailer);
562 }
563 emit_own_line_pending(out, path, items.len(), base_indent, nested);
564 emit_orphan_inlines(out, path, items.len(), base_indent, nested);
565}
566
567/// Emit a single `- <value>\n` sequence item. When the item is a mapping,
568/// if both the seq-item trailer and the first key's trailer are present,
569/// the inner one degrades to an own-line comment.
570#[allow(clippy::too_many_arguments)]
571fn emit_sequence_item(
572 out: &mut String,
573 value: &JsonValue,
574 base_indent: usize,
575 path: &[CommentPathSegment],
576 nested: &[NestedComment],
577 fills: &[Vec<CommentPathSegment>],
578 inline_trailer: Option<&str>,
579) {
580 match value {
581 JsonValue::Object(map) if map.is_empty() => {
582 push_indent(out, base_indent);
583 out.push_str("- {}");
584 push_trailer(out, inline_trailer);
585 out.push('\n');
586 }
587 JsonValue::Object(map) => {
588 emit_own_line_pending(out, path, 0, base_indent, nested);
589
590 let mut first = true;
591 for (i, (k, v)) in map.iter().enumerate() {
592 if !first {
593 emit_own_line_pending(out, path, i, base_indent + 2, nested);
594 }
595 let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
596 let mut child_path = path.to_vec();
597 child_path.push(CommentPathSegment::Key(k.clone()));
598 if first {
599 let line_trailer = inline_trailer.or(inner_trailer);
600 push_indent(out, base_indent);
601 out.push_str("- ");
602 emit_field_inline(
603 out,
604 k,
605 v,
606 base_indent + 2,
607 path_is_fill(fills, &child_path),
608 &child_path,
609 nested,
610 fills,
611 line_trailer,
612 );
613 if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
614 push_indent(out, base_indent + 2);
615 out.push_str("# ");
616 out.push_str(loser);
617 out.push('\n');
618 }
619 first = false;
620 } else {
621 emit_field(
622 out,
623 k,
624 v,
625 base_indent + 2,
626 path_is_fill(fills, &child_path),
627 &child_path,
628 nested,
629 fills,
630 inner_trailer,
631 );
632 }
633 }
634 emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
635 emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
636 }
637 JsonValue::Array(inner) if inner.is_empty() => {
638 push_indent(out, base_indent);
639 out.push_str("- []");
640 push_trailer(out, inline_trailer);
641 out.push('\n');
642 }
643 JsonValue::Array(inner) => {
644 push_indent(out, base_indent);
645 out.push('-');
646 push_trailer(out, inline_trailer);
647 out.push('\n');
648 emit_sequence_children(out, inner, base_indent + 2, path, nested, fills);
649 }
650 _ => {
651 push_indent(out, base_indent);
652 out.push_str("- ");
653 emit_scalar(out, value);
654 push_trailer(out, inline_trailer);
655 out.push('\n');
656 }
657 }
658}
659
660/// Emit `key: <value>\n` where the caller already wrote `- ` on the current line.
661#[allow(clippy::too_many_arguments)]
662fn emit_field_inline(
663 out: &mut String,
664 key: &str,
665 value: &JsonValue,
666 child_indent: usize,
667 fill: bool,
668 path: &[CommentPathSegment],
669 nested: &[NestedComment],
670 fills: &[Vec<CommentPathSegment>],
671 inline_trailer: Option<&str>,
672) {
673 if fill {
674 emit_key(out, key);
675 match value {
676 JsonValue::Null => out.push_str(": !must_fill"),
677 JsonValue::Array(items) if items.is_empty() => out.push_str(": !must_fill []"),
678 JsonValue::Array(items) => {
679 out.push_str(": !must_fill");
680 push_trailer(out, inline_trailer);
681 out.push('\n');
682 emit_sequence_children(out, items, child_indent + 2, path, nested, fills);
683 return;
684 }
685 JsonValue::Object(_) => {
686 // `!must_fill` on a mapping is rejected at parse; emit plainly.
687 out.push(':');
688 push_trailer(out, inline_trailer);
689 out.push('\n');
690 if let JsonValue::Object(map) = value {
691 emit_mapping_children(out, map, child_indent, path, nested, fills);
692 }
693 return;
694 }
695 _ => {
696 out.push_str(": !must_fill ");
697 emit_scalar(out, value);
698 }
699 }
700 push_trailer(out, inline_trailer);
701 out.push('\n');
702 return;
703 }
704 match value {
705 JsonValue::Object(map) if map.is_empty() => {
706 emit_key(out, key);
707 out.push_str(": {}");
708 push_trailer(out, inline_trailer);
709 out.push('\n');
710 }
711 JsonValue::Object(map) => {
712 emit_key(out, key);
713 out.push(':');
714 push_trailer(out, inline_trailer);
715 out.push('\n');
716 emit_mapping_children(out, map, child_indent, path, nested, fills);
717 }
718 JsonValue::Array(items) if items.is_empty() => {
719 emit_key(out, key);
720 out.push_str(": []");
721 push_trailer(out, inline_trailer);
722 out.push('\n');
723 }
724 JsonValue::Array(items) => {
725 emit_key(out, key);
726 out.push(':');
727 push_trailer(out, inline_trailer);
728 out.push('\n');
729 emit_sequence_children(out, items, child_indent + 2, path, nested, fills);
730 }
731 _ => {
732 emit_key(out, key);
733 out.push_str(": ");
734 emit_scalar(out, value);
735 push_trailer(out, inline_trailer);
736 out.push('\n');
737 }
738 }
739}
740
741fn emit_scalar(out: &mut String, value: &JsonValue) {
742 let s = saphyr_emit_scalar(value);
743 out.push_str(&s);
744}
745
746/// Emit a *nested* mapping key, quoting it through the same scalar path as
747/// values. Nested keys are arbitrary user data (never name-validated) and are
748/// re-parsed by serde_saphyr, so a key containing `:`/`#`, a leading YAML
749/// indicator (`*`, `&`, `?`, `-`, …), edge whitespace, or a type-ambiguous form
750/// (`n`, `true`, `123`) must be quoted or the emitted document re-parses to a
751/// different key — breaking the round-trip/idempotence contract.
752fn emit_key(out: &mut String, key: &str) {
753 out.push_str(&saphyr_emit_scalar(&JsonValue::String(key.to_string())));
754}
755
756/// Emit a mapping key at `indent`. Top-level field names (indent 0) are emitted
757/// verbatim: the line-oriented prescan accepts only bare `[A-Za-z_][A-Za-z0-9_]*`
758/// field names there, so quoting one would make it unparseable. Nested keys
759/// (indent > 0) route through [`emit_key`] for correct YAML quoting.
760fn emit_key_at(out: &mut String, key: &str, indent: usize) {
761 if indent == 0 {
762 out.push_str(key);
763 } else {
764 emit_key(out, key);
765 }
766}
767
768/// `prefer_block_scalars: false` forces multi-line strings to double-quoted
769/// inline scalars (no `|` / `>` block forms in v1).
770fn saphyr_opts() -> SerializerOptions {
771 SerializerOptions {
772 prefer_block_scalars: false,
773 ..SerializerOptions::default()
774 }
775}
776
777pub(crate) fn saphyr_emit_scalar(value: &JsonValue) -> String {
778 let mut buf = String::new();
779 serde_saphyr::to_fmt_writer_with_options(&mut buf, value, saphyr_opts())
780 .expect("saphyr scalar emission is infallible for JsonValue scalars");
781 while buf.ends_with('\n') {
782 buf.pop();
783 }
784
785 // Saphyr 0.0.23's emitter and parser disagree about which plain scalars
786 // are string-safe: it emits some `String`s unquoted that its own parser
787 // reads back as a non-string (`_0` → integer 0) or as a different string.
788 // Edge-whitespace strings are one class — the plain-safety check inspects
789 // only the leading ASCII byte, missing a leading/trailing Unicode-
790 // whitespace char (U+2000…) or a trailing ASCII space, and YAML strips
791 // such whitespace from plain scalars on parse. `_0`-style numeric-looking
792 // strings are another. Both lose the original string on round-trip. When
793 // saphyr emits a `String` unquoted, re-parse the emitted plain scalar with
794 // the same library the parser uses and, unless it round-trips to the exact
795 // same string, emit double-quoted ourselves. Edge whitespace stays an
796 // explicit guard: a trailing Unicode-whitespace char survives the isolated
797 // re-parse yet is still stripped in the real block context.
798 if let JsonValue::String(s) = value {
799 let unquoted = !buf.starts_with('"')
800 && !buf.starts_with('\'')
801 && !buf.starts_with('|')
802 && !buf.starts_with('>');
803 if unquoted {
804 let has_edge_whitespace = !s.is_empty()
805 && (s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace));
806 // A parse error counts as "must quote", conservatively.
807 let reparses_same = matches!(
808 serde_saphyr::from_str::<JsonValue>(&buf),
809 Ok(JsonValue::String(ref s2)) if s2 == s
810 );
811 if has_edge_whitespace || !reparses_same {
812 return double_quote_string(s);
813 }
814 }
815 }
816 buf
817}
818
819/// JSON-style double-quoted fallback for strings saphyr would emit in a form
820/// that loses bytes on parse (e.g. trailing-whitespace plain scalars).
821fn double_quote_string(s: &str) -> String {
822 let mut out = String::with_capacity(s.len() + 2);
823 out.push('"');
824 for ch in s.chars() {
825 match ch {
826 '\\' => out.push_str("\\\\"),
827 '"' => out.push_str("\\\""),
828 '\n' => out.push_str("\\n"),
829 '\r' => out.push_str("\\r"),
830 '\t' => out.push_str("\\t"),
831 c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
832 out.push_str(&format!("\\u{:04X}", c as u32));
833 }
834 c => out.push(c),
835 }
836 }
837 out.push('"');
838 out
839}
840
841/// Render a `JsonValue` as a one-line YAML flow form (`[a, b]` / `{k: v}` /
842/// flow-quoted scalar). Used for `# e.g.` hint lines in blueprint output.
843pub(crate) fn saphyr_emit_flow(value: &JsonValue) -> String {
844 let mut buf = String::new();
845 let opts = saphyr_opts();
846 match value {
847 JsonValue::Array(items) => {
848 let wrapped = FlowSeq(items.clone());
849 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
850 .expect("saphyr flow seq emission");
851 }
852 JsonValue::Object(map) => {
853 let wrapped = FlowMap(map.clone());
854 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
855 .expect("saphyr flow map emission");
856 }
857 scalar => {
858 // Wrap in FlowSeq so saphyr applies flow-context quoting, then strip `[`/`]`.
859 let wrapped = FlowSeq(vec![scalar.clone()]);
860 serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
861 .expect("saphyr flow scalar emission");
862 while buf.ends_with('\n') {
863 buf.pop();
864 }
865 return buf
866 .strip_prefix('[')
867 .and_then(|s| s.strip_suffix(']'))
868 .unwrap_or(&buf)
869 .to_string();
870 }
871 }
872 while buf.ends_with('\n') {
873 buf.pop();
874 }
875 buf
876}
877
878// ── Utilities ─────────────────────────────────────────────────────────────────
879
880fn push_indent(out: &mut String, spaces: usize) {
881 for _ in 0..spaces {
882 out.push(' ');
883 }
884}
885
886// ── Unit tests ────────────────────────────────────────────────────────────────
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use crate::value::QuillValue;
892
893 fn assert_scalar_round_trips(value: serde_json::Value) {
894 let mut yaml = String::from("~~~card-yaml\n$quill: q\n$kind: main\nv: ");
895 yaml.push_str(&saphyr_emit_scalar(&value));
896 yaml.push_str("\n~~~\n");
897 let doc = crate::document::Document::parse(&yaml).unwrap_or_else(|e| {
898 panic!(
899 "failed to parse emitted scalar {:?}: {}\n{}",
900 value, e, yaml
901 )
902 })
903 .document;
904 let parsed = doc.main().payload().get("v").expect("field 'v'").as_json();
905 assert_eq!(
906 parsed, &value,
907 "scalar round-trip mismatch for {:?}: emitted as {:?}",
908 value, yaml
909 );
910 }
911
912 #[test]
913 fn saphyr_scalar_round_trips_ambiguous_strings() {
914 for ambiguous in &[
915 "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
916 ] {
917 assert_scalar_round_trips(serde_json::json!(*ambiguous));
918 }
919 }
920
921 #[test]
922 fn saphyr_scalar_round_trips_numeric_looking_strings() {
923 // Saphyr's emitter treats a leading-underscore-then-digits scalar as
924 // plain-safe, but its parser reads the plain form back as an integer
925 // (underscores are digit separators, leading ones ignored): `_0` → 0,
926 // `-_0` → 0, `__0` → 0. `_0` is the minimal fuzz-shrunk case. Each is
927 // emitted unquoted and re-parsed as `Number` before the fix; the
928 // general round-trip check must quote every one.
929 for numericish in &["_0", "_1", "-_0", "__0"] {
930 assert_scalar_round_trips(serde_json::json!(*numericish));
931 }
932 }
933
934 #[test]
935 fn string_underscore_zero_round_trips_via_document() {
936 // Full `to_markdown` → `from_markdown` path for the reported bug:
937 // `String("_0")` must return as a `String`, still equal to `"_0"`.
938 let src = "~~~card-yaml\n$quill: q\n$kind: main\na: \"_0\"\n~~~\n\nBody.\n";
939 let doc = crate::document::Document::parse(src).expect("parse src").document;
940 let emitted = doc.to_markdown();
941 let reparsed =
942 crate::document::Document::parse(&emitted).expect("re-parse emitted markdown").document;
943 let value = reparsed
944 .main()
945 .payload()
946 .get("a")
947 .expect("field 'a'")
948 .as_json();
949 assert_eq!(
950 value,
951 &serde_json::Value::String("_0".to_string()),
952 "String(\"_0\") must round-trip as a string; emitted:\n{}",
953 emitted
954 );
955 }
956
957 #[test]
958 fn saphyr_scalar_round_trips_escapes() {
959 assert_scalar_round_trips(serde_json::json!("a\\b\"c\nd\te"));
960 }
961
962 #[test]
963 fn saphyr_scalar_round_trips_control_chars() {
964 assert_scalar_round_trips(serde_json::json!("\x01\x1F"));
965 }
966
967 fn p(key: &str) -> Vec<CommentPathSegment> {
968 vec![CommentPathSegment::Key(key.to_string())]
969 }
970
971 #[test]
972 fn empty_object_omitted() {
973 let value = QuillValue::from_json(serde_json::json!({}));
974 let mut out = String::new();
975 emit_field(
976 &mut out,
977 "empty_map",
978 value.as_json(),
979 0,
980 false,
981 &p("empty_map"),
982 &[],
983 &[],
984 None,
985 );
986 assert_eq!(out, "");
987 }
988
989 #[test]
990 fn empty_object_with_inline_trailer_degrades() {
991 let value = QuillValue::from_json(serde_json::json!({}));
992 let mut out = String::new();
993 emit_field(
994 &mut out,
995 "empty_map",
996 value.as_json(),
997 0,
998 false,
999 &p("empty_map"),
1000 &[],
1001 &[],
1002 Some("orphan"),
1003 );
1004 assert_eq!(out, "# orphan\n");
1005 }
1006
1007 #[test]
1008 fn empty_array_emitted() {
1009 let value = QuillValue::from_json(serde_json::json!([]));
1010 let mut out = String::new();
1011 emit_field(
1012 &mut out,
1013 "empty_seq",
1014 value.as_json(),
1015 0,
1016 false,
1017 &p("empty_seq"),
1018 &[],
1019 &[],
1020 None,
1021 );
1022 assert_eq!(out, "empty_seq: []\n");
1023 }
1024
1025 #[test]
1026 fn scalar_field_with_inline_trailer() {
1027 let value = QuillValue::from_json(serde_json::json!("Hello"));
1028 let mut out = String::new();
1029 emit_field(
1030 &mut out,
1031 "title",
1032 value.as_json(),
1033 0,
1034 false,
1035 &p("title"),
1036 &[],
1037 &[],
1038 Some("greeting"),
1039 );
1040 assert_eq!(out, "title: Hello # greeting\n");
1041 }
1042
1043 #[test]
1044 fn container_field_with_inline_trailer_lands_on_key_line() {
1045 let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
1046 let mut out = String::new();
1047 emit_field(
1048 &mut out,
1049 "outer",
1050 value.as_json(),
1051 0,
1052 false,
1053 &p("outer"),
1054 &[],
1055 &[],
1056 Some("note"),
1057 );
1058 assert_eq!(out, "outer: # note\n inner: 1\n");
1059 }
1060
1061 #[test]
1062 fn fill_null_emits_bare_tag() {
1063 let value = QuillValue::from_json(serde_json::Value::Null);
1064 let mut out = String::new();
1065 emit_field(
1066 &mut out,
1067 "recipient",
1068 value.as_json(),
1069 0,
1070 true,
1071 &p("recipient"),
1072 &[],
1073 &[],
1074 None,
1075 );
1076 assert_eq!(out, "recipient: !must_fill\n");
1077 }
1078
1079 #[test]
1080 fn fill_string_emits_tag_with_value() {
1081 let value = QuillValue::from_json(serde_json::json!("placeholder"));
1082 let mut out = String::new();
1083 emit_field(
1084 &mut out,
1085 "dept",
1086 value.as_json(),
1087 0,
1088 true,
1089 &p("dept"),
1090 &[],
1091 &[],
1092 None,
1093 );
1094 assert_eq!(out, "dept: !must_fill placeholder\n");
1095 }
1096
1097 #[test]
1098 fn fill_with_inline_trailer() {
1099 let value = QuillValue::from_json(serde_json::json!("placeholder"));
1100 let mut out = String::new();
1101 emit_field(
1102 &mut out,
1103 "dept",
1104 value.as_json(),
1105 0,
1106 true,
1107 &p("dept"),
1108 &[],
1109 &[],
1110 Some("note"),
1111 );
1112 assert_eq!(out, "dept: !must_fill placeholder # note\n");
1113 }
1114}