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