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