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::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    serde_saphyr::ser_options! {
773        prefer_block_scalars: false,
774    }
775}
776
777pub(crate) fn saphyr_emit_scalar(value: &JsonValue) -> String {
778    let mut buf = String::new();
779    serde_saphyr::to_fmt_writer_with_options(&mut buf, value, saphyr_opts())
780        .expect("saphyr scalar emission is infallible for JsonValue scalars");
781    while buf.ends_with('\n') {
782        buf.pop();
783    }
784
785    // Saphyr 0.0.23's emitter and parser disagree about which plain scalars
786    // are string-safe: it emits some `String`s unquoted that its own parser
787    // reads back as a non-string (`_0` → integer 0) or as a different string.
788    // Edge-whitespace strings are one class: the plain-safety check inspects
789    // only the leading ASCII byte, missing a leading/trailing Unicode-
790    // whitespace char (U+2000…) or a trailing ASCII space, and YAML strips
791    // such whitespace from plain scalars on parse. `_0`-style numeric-looking
792    // strings are another. Both lose the original string on round-trip. When
793    // saphyr emits a `String` unquoted, re-parse the emitted plain scalar with
794    // the same library the parser uses and, unless it round-trips to the exact
795    // same string, emit double-quoted ourselves. Edge whitespace stays an
796    // explicit guard: a trailing Unicode-whitespace char survives the isolated
797    // re-parse yet is still stripped in the real block context.
798    if let JsonValue::String(s) = value {
799        let unquoted = !buf.starts_with('"')
800            && !buf.starts_with('\'')
801            && !buf.starts_with('|')
802            && !buf.starts_with('>');
803        if unquoted {
804            let has_edge_whitespace = !s.is_empty()
805                && (s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace));
806            // A parse error counts as "must quote", conservatively.
807            let reparses_same = matches!(
808                serde_saphyr::from_str::<JsonValue>(&buf),
809                Ok(JsonValue::String(ref s2)) if s2 == s
810            );
811            if has_edge_whitespace || !reparses_same {
812                return double_quote_string(s);
813            }
814        }
815    }
816    buf
817}
818
819/// JSON-style double-quoted fallback for strings saphyr would emit in a form
820/// that loses bytes on parse (e.g. trailing-whitespace plain scalars).
821fn double_quote_string(s: &str) -> String {
822    let mut out = String::with_capacity(s.len() + 2);
823    out.push('"');
824    for ch in s.chars() {
825        match ch {
826            '\\' => out.push_str("\\\\"),
827            '"' => out.push_str("\\\""),
828            '\n' => out.push_str("\\n"),
829            '\r' => out.push_str("\\r"),
830            '\t' => out.push_str("\\t"),
831            c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
832                out.push_str(&format!("\\u{:04X}", c as u32));
833            }
834            c => out.push(c),
835        }
836    }
837    out.push('"');
838    out
839}
840
841/// Render a `JsonValue` as a one-line YAML flow form (`[a, b]` / `{k: v}` /
842/// flow-quoted scalar). Used for `# e.g.` hint lines in blueprint output.
843pub(crate) fn saphyr_emit_flow(value: &JsonValue) -> String {
844    let mut buf = String::new();
845    let opts = saphyr_opts();
846    match value {
847        JsonValue::Array(items) => {
848            let wrapped = FlowSeq(items.clone());
849            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
850                .expect("saphyr flow seq emission");
851        }
852        JsonValue::Object(map) => {
853            let wrapped = FlowMap(map.clone());
854            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
855                .expect("saphyr flow map emission");
856        }
857        scalar => {
858            // Wrap in FlowSeq so saphyr applies flow-context quoting, then strip `[`/`]`.
859            let wrapped = FlowSeq(vec![scalar.clone()]);
860            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
861                .expect("saphyr flow scalar emission");
862            while buf.ends_with('\n') {
863                buf.pop();
864            }
865            return buf
866                .strip_prefix('[')
867                .and_then(|s| s.strip_suffix(']'))
868                .unwrap_or(&buf)
869                .to_string();
870        }
871    }
872    while buf.ends_with('\n') {
873        buf.pop();
874    }
875    buf
876}
877
878// ── Utilities ─────────────────────────────────────────────────────────────────
879
880fn push_indent(out: &mut String, spaces: usize) {
881    for _ in 0..spaces {
882        out.push(' ');
883    }
884}
885
886// ── Unit tests ────────────────────────────────────────────────────────────────
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use crate::value::QuillValue;
892
893    fn assert_scalar_round_trips(value: serde_json::Value) {
894        let mut yaml = String::from("~~~card-yaml\n$quill: q\n$kind: main\nv: ");
895        yaml.push_str(&saphyr_emit_scalar(&value));
896        yaml.push_str("\n~~~\n");
897        let doc = crate::document::Document::parse(&yaml).unwrap_or_else(|e| {
898            panic!(
899                "failed to parse emitted scalar {:?}: {}\n{}",
900                value, e, yaml
901            )
902        })
903        .document;
904        let parsed = doc.main().payload().get("v").expect("field 'v'").as_json();
905        assert_eq!(
906            parsed, &value,
907            "scalar round-trip mismatch for {:?}: emitted as {:?}",
908            value, yaml
909        );
910    }
911
912    #[test]
913    fn saphyr_scalar_round_trips_ambiguous_strings() {
914        for ambiguous in &[
915            "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
916        ] {
917            assert_scalar_round_trips(serde_json::json!(*ambiguous));
918        }
919    }
920
921    #[test]
922    fn saphyr_scalar_round_trips_numeric_looking_strings() {
923        // Saphyr's emitter treats a leading-underscore-then-digits scalar as
924        // plain-safe, but its parser reads the plain form back as an integer
925        // (underscores are digit separators, leading ones ignored): `_0` → 0,
926        // `-_0` → 0, `__0` → 0. `_0` is the minimal fuzz-shrunk case. Each is
927        // emitted unquoted and re-parsed as `Number` before the fix; the
928        // general round-trip check must quote every one.
929        for numericish in &["_0", "_1", "-_0", "__0"] {
930            assert_scalar_round_trips(serde_json::json!(*numericish));
931        }
932    }
933
934    #[test]
935    fn string_underscore_zero_round_trips_via_document() {
936        // Full `to_markdown` → `from_markdown` path for the reported bug:
937        // `String("_0")` must return as a `String`, still equal to `"_0"`.
938        let src = "~~~card-yaml\n$quill: q\n$kind: main\na: \"_0\"\n~~~\n\nBody.\n";
939        let doc = crate::document::Document::parse(src).expect("parse src").document;
940        let emitted = doc.to_markdown();
941        let reparsed =
942            crate::document::Document::parse(&emitted).expect("re-parse emitted markdown").document;
943        let value = reparsed
944            .main()
945            .payload()
946            .get("a")
947            .expect("field 'a'")
948            .as_json();
949        assert_eq!(
950            value,
951            &serde_json::Value::String("_0".to_string()),
952            "String(\"_0\") must round-trip as a string; emitted:\n{}",
953            emitted
954        );
955    }
956
957    #[test]
958    fn saphyr_scalar_round_trips_escapes() {
959        assert_scalar_round_trips(serde_json::json!("a\\b\"c\nd\te"));
960    }
961
962    #[test]
963    fn saphyr_scalar_round_trips_control_chars() {
964        assert_scalar_round_trips(serde_json::json!("\x01\x1F"));
965    }
966
967    fn p(key: &str) -> Vec<CommentPathSegment> {
968        vec![CommentPathSegment::Key(key.to_string())]
969    }
970
971    #[test]
972    fn empty_object_omitted() {
973        let value = QuillValue::from_json(serde_json::json!({}));
974        let mut out = String::new();
975        emit_field(
976            &mut out,
977            "empty_map",
978            value.as_json(),
979            0,
980            false,
981            &p("empty_map"),
982            &[],
983            &[],
984            None,
985        );
986        assert_eq!(out, "");
987    }
988
989    #[test]
990    fn empty_object_with_inline_trailer_degrades() {
991        let value = QuillValue::from_json(serde_json::json!({}));
992        let mut out = String::new();
993        emit_field(
994            &mut out,
995            "empty_map",
996            value.as_json(),
997            0,
998            false,
999            &p("empty_map"),
1000            &[],
1001            &[],
1002            Some("orphan"),
1003        );
1004        assert_eq!(out, "# orphan\n");
1005    }
1006
1007    #[test]
1008    fn empty_array_emitted() {
1009        let value = QuillValue::from_json(serde_json::json!([]));
1010        let mut out = String::new();
1011        emit_field(
1012            &mut out,
1013            "empty_seq",
1014            value.as_json(),
1015            0,
1016            false,
1017            &p("empty_seq"),
1018            &[],
1019            &[],
1020            None,
1021        );
1022        assert_eq!(out, "empty_seq: []\n");
1023    }
1024
1025    #[test]
1026    fn scalar_field_with_inline_trailer() {
1027        let value = QuillValue::from_json(serde_json::json!("Hello"));
1028        let mut out = String::new();
1029        emit_field(
1030            &mut out,
1031            "title",
1032            value.as_json(),
1033            0,
1034            false,
1035            &p("title"),
1036            &[],
1037            &[],
1038            Some("greeting"),
1039        );
1040        assert_eq!(out, "title: Hello # greeting\n");
1041    }
1042
1043    #[test]
1044    fn container_field_with_inline_trailer_lands_on_key_line() {
1045        let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
1046        let mut out = String::new();
1047        emit_field(
1048            &mut out,
1049            "outer",
1050            value.as_json(),
1051            0,
1052            false,
1053            &p("outer"),
1054            &[],
1055            &[],
1056            Some("note"),
1057        );
1058        assert_eq!(out, "outer: # note\n  inner: 1\n");
1059    }
1060
1061    #[test]
1062    fn fill_null_emits_bare_tag() {
1063        let value = QuillValue::from_json(serde_json::Value::Null);
1064        let mut out = String::new();
1065        emit_field(
1066            &mut out,
1067            "recipient",
1068            value.as_json(),
1069            0,
1070            true,
1071            &p("recipient"),
1072            &[],
1073            &[],
1074            None,
1075        );
1076        assert_eq!(out, "recipient: !must_fill\n");
1077    }
1078
1079    #[test]
1080    fn fill_string_emits_tag_with_value() {
1081        let value = QuillValue::from_json(serde_json::json!("placeholder"));
1082        let mut out = String::new();
1083        emit_field(
1084            &mut out,
1085            "dept",
1086            value.as_json(),
1087            0,
1088            true,
1089            &p("dept"),
1090            &[],
1091            &[],
1092            None,
1093        );
1094        assert_eq!(out, "dept: !must_fill placeholder\n");
1095    }
1096
1097    #[test]
1098    fn fill_with_inline_trailer() {
1099        let value = QuillValue::from_json(serde_json::json!("placeholder"));
1100        let mut out = String::new();
1101        emit_field(
1102            &mut out,
1103            "dept",
1104            value.as_json(),
1105            0,
1106            true,
1107            &p("dept"),
1108            &[],
1109            &[],
1110            Some("note"),
1111        );
1112        assert_eq!(out, "dept: !must_fill placeholder # note\n");
1113    }
1114}