Skip to main content

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}