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