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