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::from_markdown(&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    /// 2. **Emit-idempotent.** `to_markdown` is a pure function of `doc`; two
45    ///    calls on the same `doc` return byte-equal strings.
46    ///
47    /// Byte-equality with the *original source* is **not** guaranteed.
48    ///
49    /// # Emission rules (§5.2)
50    ///
51    /// - Line endings: `\n` only.  CRLF normalization happens on import.
52    /// - Every block is emitted as a `~~~` card-yaml fence: a bare `~~~`
53    ///   opener, the `$`-prefixed system-metadata lines (`$quill: <ref>` for
54    ///   the root block, `$kind: <kind>` for composable cards) leading the
55    ///   YAML payload, the user-defined data fields, then a closing `~~~`.
56    /// - Cards: one blank line before each, then the block, then the card body.
57    /// - Body: emitted verbatim after the root block (and after each card).
58    /// - Mappings and sequences: **block style** at every nesting level.
59    /// - Scalars (booleans, null, numbers, strings): delegated to
60    ///   `serde-saphyr`, which emits the type-canonical form (`true`/
61    ///   `false`, `null`, bare numeric literal) and quotes strings only
62    ///   when the unquoted form would be misread (`on`/`yes`/`off`,
63    ///   `null`/`~`, numeric-looking strings, leading flow indicators,
64    ///   `: ` runs, …).  Quoting form is not stable — what matters is
65    ///   that the emitted scalar round-trips to the same `QuillValue`
66    ///   variant. This is the type-fidelity guarantee.
67    /// - Multi-line strings: emitted as inline double-quoted scalars with
68    ///   `\n` escapes; no `|` / `>` block forms.
69    ///
70    /// # Open decisions (resolved)
71    ///
72    /// - **Nested-map order.** `QuillValue` is backed by `serde_json::Value`
73    ///   whose object type (`serde_json::Map`) preserves insertion order when the
74    ///   `serde_json/preserve_order` feature is enabled (it is in this workspace).
75    ///   Insertion order is therefore preserved for nested maps at emit time.
76    ///
77    /// - **Empty containers.**
78    ///   - Empty object (`{}`) → the key is **omitted** from emit entirely.
79    ///   - Empty array (`[]`) → emitted as `key: []\n`.
80    ///
81    /// # What is preserved
82    ///
83    /// - **YAML comments**: own-line and inline trailing comments round-trip
84    ///   at their source position. Comments whose host disappears at emit time
85    ///   (empty-mapping omission, programmatic field removal) degrade to
86    ///   own-line comments at the same indent so the comment text is preserved
87    ///   even when its position shifts.
88    /// - **`!must_fill` tags**: round-trip via the `fill` flag on `PayloadItem::Field`.
89    ///
90    /// # What is lost
91    ///
92    /// - **Other custom tags** (`!include`, `!env`, …): the tag is dropped;
93    ///   the scalar value is preserved.
94    /// - **Original quoting style**: strings are re-emitted in saphyr's
95    ///   canonical form (plain when safe, quoted when ambiguous). The
96    ///   form chosen for emit may not match the form in the source.
97    pub fn to_markdown(&self) -> String {
98        let mut out = String::new();
99
100        // ── Root block (card-yaml fence + global body) ────────────────────────
101        emit_block(&mut out, self.main());
102        out.push_str(self.main().body());
103
104        // ── Composable cards ──────────────────────────────────────────────────
105        // `ensure_blank_before_fence` normalises the separator before each
106        // block, so edited bodies (which may lack a trailing blank line) still
107        // round-trip.
108        for card in self.cards() {
109            ensure_blank_before_fence(&mut out);
110            emit_block(&mut out, card);
111            if !card.body().is_empty() {
112                out.push_str(card.body());
113            }
114        }
115
116        out
117    }
118}
119
120// ── Block emission ────────────────────────────────────────────────────────────
121
122fn emit_meta_line(out: &mut String, key: &str, value: &str, trailer: Option<&str>) {
123    out.push('$');
124    out.push_str(key);
125    out.push_str(": ");
126    out.push_str(&saphyr_emit_scalar(&JsonValue::String(value.to_string())));
127    push_trailer(out, trailer);
128    out.push('\n');
129}
130
131/// Emit an out-of-band meta block (`$ext` / `$seed`). An empty map emits inline
132/// as `<key>: {}` so the declaration survives the round-trip; a non-empty map
133/// emits as a `<key>:` header followed by indented block-style children.
134/// `nested` carries comments with paths relative to the value tree (the meta
135/// key itself is not in the path) — the child mapping walker re-injects them at
136/// the matching positions. Meta maps are out-of-band data and never carry
137/// `!must_fill`.
138fn emit_meta_block(
139    out: &mut String,
140    key: &str,
141    value: &serde_json::Map<String, JsonValue>,
142    trailer: Option<&str>,
143    nested: &[NestedComment],
144) {
145    if value.is_empty() {
146        out.push_str(key);
147        out.push_str(": {}");
148        push_trailer(out, trailer);
149        out.push('\n');
150        return;
151    }
152    out.push_str(key);
153    out.push(':');
154    push_trailer(out, trailer);
155    out.push('\n');
156    let path: Vec<CommentPathSegment> = Vec::new();
157    emit_mapping_children(out, value, 2, &path, nested, &[]);
158}
159
160/// `true` when `path` (relative to a field value) carries a `!must_fill`
161/// marker. Fill sets are small (one entry per placeholder), so a linear
162/// scan is cheaper than building a hash set per field.
163fn path_is_fill(fills: &[Vec<CommentPathSegment>], path: &[CommentPathSegment]) -> bool {
164    fills.iter().any(|p| p.as_slice() == path)
165}
166
167fn emit_block(out: &mut String, card: &Card) {
168    out.push_str("~~~\n");
169    emit_payload_items(out, card.payload().items());
170    out.push_str("~~~\n");
171}
172
173/// Walk the unified item list and emit each entry. An `inline: true` comment
174/// immediately following a non-comment item is consumed as that item's trailer.
175///
176/// Each `Field` / `Ext` item carries its own `nested_comments` slice with
177/// paths relative to the field's value tree, so emission of nested
178/// structures starts with an empty container path.
179fn emit_payload_items(out: &mut String, items: &[PayloadItem]) {
180    let mut i = 0;
181    while i < items.len() {
182        // Peek for a trailing inline comment to use as the line trailer.
183        let trailer = items.get(i + 1).and_then(|next| match next {
184            PayloadItem::Comment { text, inline: true } => Some(text.as_str()),
185            _ => None,
186        });
187        let mut consumed_trailer = trailer.is_some();
188
189        match &items[i] {
190            PayloadItem::Quill { reference } => {
191                emit_meta_line(out, "quill", &reference.to_string(), trailer);
192            }
193            PayloadItem::Kind { value } => {
194                emit_meta_line(out, "kind", value, trailer);
195            }
196            PayloadItem::Id { value } => {
197                emit_meta_line(out, "id", value, trailer);
198            }
199            PayloadItem::Meta {
200                key,
201                value,
202                nested_comments,
203            } => {
204                emit_meta_block(out, key.as_str(), value, trailer, nested_comments);
205            }
206            PayloadItem::Field {
207                key,
208                value,
209                fill,
210                nested_comments,
211            } => {
212                // Paths in `nested_comments` are relative to this field's
213                // value, so the container path starts empty.
214                let path: Vec<CommentPathSegment> = Vec::new();
215                // `!must_fill` markers on nested nodes, as paths relative to
216                // this field's value; the top-level marker rides on `*fill`.
217                let fills = value.fill_paths();
218                emit_field(
219                    out,
220                    key,
221                    value.as_json(),
222                    0,
223                    *fill,
224                    &path,
225                    nested_comments,
226                    &fills,
227                    trailer,
228                );
229            }
230            PayloadItem::Comment { text, .. } => {
231                out.push_str("# ");
232                out.push_str(text);
233                out.push('\n');
234                consumed_trailer = false;
235            }
236        }
237        i += if consumed_trailer { 2 } else { 1 };
238    }
239}
240
241/// Ensure `out` ends with `\n\n` so the next fence has a blank line above it.
242/// Appends a line terminator first if `out` doesn't already end with `\n`.
243/// No-op on empty `out` (block at line 1 needs no separator).
244fn ensure_blank_before_fence(out: &mut String) {
245    if out.is_empty() {
246        return;
247    }
248    if !out.ends_with('\n') {
249        out.push('\n');
250    }
251    out.push('\n');
252}
253
254// ── YAML value emission ───────────────────────────────────────────────────────
255
256/// Emit own-line nested comments at `position` in `path` (inline comments are
257/// handled by `find_inline_trailer`).
258fn emit_own_line_pending(
259    out: &mut String,
260    path: &[CommentPathSegment],
261    position: usize,
262    indent: usize,
263    nested: &[NestedComment],
264) {
265    for c in nested {
266        if c.position == position && !c.inline && c.container_path.as_slice() == path {
267            push_indent(out, indent);
268            out.push_str("# ");
269            out.push_str(&c.text);
270            out.push('\n');
271        }
272    }
273}
274
275/// Return the inline trailer for `position` in `path`. If multiple inline
276/// comments share the slot, returns the first and emits the rest as own-line.
277fn find_inline_trailer<'a>(
278    out: &mut String,
279    path: &[CommentPathSegment],
280    position: usize,
281    indent: usize,
282    nested: &'a [NestedComment],
283) -> Option<&'a str> {
284    let mut chosen: Option<&str> = None;
285    for c in nested {
286        if c.position == position && c.inline && c.container_path.as_slice() == path {
287            if chosen.is_none() {
288                chosen = Some(c.text.as_str());
289            } else {
290                push_indent(out, indent);
291                out.push_str("# ");
292                out.push_str(&c.text);
293                out.push('\n');
294            }
295        }
296    }
297    chosen
298}
299
300/// Emit orphan inline comments (`position >= container_len`) as own-line.
301fn emit_orphan_inlines(
302    out: &mut String,
303    path: &[CommentPathSegment],
304    container_len: usize,
305    indent: usize,
306    nested: &[NestedComment],
307) {
308    for c in nested {
309        if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
310            push_indent(out, indent);
311            out.push_str("# ");
312            out.push_str(&c.text);
313            out.push('\n');
314        }
315    }
316}
317
318fn push_trailer(out: &mut String, trailer: Option<&str>) {
319    if let Some(t) = trailer {
320        out.push_str(" # ");
321        out.push_str(t);
322    }
323}
324
325/// Emit a `key: <value>\n` pair at `indent` spaces.
326///
327/// `path` is the container path for nested-comment interleaving. Empty objects
328/// are omitted; their inline trailer degrades to an own-line comment to
329/// preserve the text. Empty arrays emit `key: []\n`. When `fill` is `true`:
330/// scalars → `key: !must_fill <value>`, empty seqs → `key: !must_fill []`, null →
331/// `key: !must_fill`, non-empty seqs → `key: !must_fill\n  - …`. Mappings with `fill`
332/// are rejected at parse and never reach this path.
333#[allow(clippy::too_many_arguments)]
334fn emit_field(
335    out: &mut String,
336    key: &str,
337    value: &JsonValue,
338    indent: usize,
339    fill: bool,
340    path: &[CommentPathSegment],
341    nested: &[NestedComment],
342    fills: &[Vec<CommentPathSegment>],
343    inline_trailer: Option<&str>,
344) {
345    if fill {
346        push_indent(out, indent);
347        emit_key_at(out, key, indent);
348        match value {
349            JsonValue::Null => {
350                out.push_str(": !must_fill");
351                push_trailer(out, inline_trailer);
352                out.push('\n');
353            }
354            JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
355                out.push_str(": !must_fill ");
356                emit_scalar(out, value);
357                push_trailer(out, inline_trailer);
358                out.push('\n');
359            }
360            JsonValue::Array(items) if items.is_empty() => {
361                out.push_str(": !must_fill []");
362                push_trailer(out, inline_trailer);
363                out.push('\n');
364            }
365            JsonValue::Array(items) => {
366                out.push_str(": !must_fill");
367                push_trailer(out, inline_trailer);
368                out.push('\n');
369                emit_sequence_children(out, items, indent + 2, path, nested, fills);
370            }
371            JsonValue::Object(_) => {
372                out.push_str(": ");
373                emit_scalar(out, value);
374                push_trailer(out, inline_trailer);
375                out.push('\n');
376            }
377        }
378        return;
379    }
380    match value {
381        JsonValue::Object(map) if map.is_empty() => {
382            if let Some(t) = inline_trailer {
383                push_indent(out, indent);
384                out.push_str("# ");
385                out.push_str(t);
386                out.push('\n');
387            }
388        }
389        JsonValue::Object(map) => {
390            push_indent(out, indent);
391            emit_key_at(out, key, indent);
392            out.push(':');
393            push_trailer(out, inline_trailer);
394            out.push('\n');
395            emit_mapping_children(out, map, indent + 2, path, nested, fills);
396        }
397        JsonValue::Array(items) if items.is_empty() => {
398            push_indent(out, indent);
399            emit_key_at(out, key, indent);
400            out.push_str(": []");
401            push_trailer(out, inline_trailer);
402            out.push('\n');
403        }
404        JsonValue::Array(items) => {
405            push_indent(out, indent);
406            emit_key_at(out, key, indent);
407            out.push(':');
408            push_trailer(out, inline_trailer);
409            out.push('\n');
410            emit_sequence_children(out, items, indent + 2, path, nested, fills);
411        }
412        _ => {
413            push_indent(out, indent);
414            emit_key_at(out, key, indent);
415            out.push_str(": ");
416            emit_scalar(out, value);
417            push_trailer(out, inline_trailer);
418            out.push('\n');
419        }
420    }
421}
422
423fn emit_mapping_children(
424    out: &mut String,
425    map: &serde_json::Map<String, JsonValue>,
426    child_indent: usize,
427    path: &[CommentPathSegment],
428    nested: &[NestedComment],
429    fills: &[Vec<CommentPathSegment>],
430) {
431    for (i, (k, v)) in map.iter().enumerate() {
432        emit_own_line_pending(out, path, i, child_indent, nested);
433        let trailer = find_inline_trailer(out, path, i, child_indent, nested);
434        let mut child_path = path.to_vec();
435        child_path.push(CommentPathSegment::Key(k.clone()));
436        let child_fill = path_is_fill(fills, &child_path);
437        emit_field(
438            out,
439            k,
440            v,
441            child_indent,
442            child_fill,
443            &child_path,
444            nested,
445            fills,
446            trailer,
447        );
448    }
449    emit_own_line_pending(out, path, map.len(), child_indent, nested);
450    emit_orphan_inlines(out, path, map.len(), child_indent, nested);
451}
452
453fn emit_sequence_children(
454    out: &mut String,
455    items: &[JsonValue],
456    base_indent: usize,
457    path: &[CommentPathSegment],
458    nested: &[NestedComment],
459    fills: &[Vec<CommentPathSegment>],
460) {
461    for (i, item) in items.iter().enumerate() {
462        emit_own_line_pending(out, path, i, base_indent, nested);
463        let trailer = find_inline_trailer(out, path, i, base_indent, nested);
464        let mut child_path = path.to_vec();
465        child_path.push(CommentPathSegment::Index(i));
466        emit_sequence_item(out, item, base_indent, &child_path, nested, fills, trailer);
467    }
468    emit_own_line_pending(out, path, items.len(), base_indent, nested);
469    emit_orphan_inlines(out, path, items.len(), base_indent, nested);
470}
471
472/// Emit a single `- <value>\n` sequence item. When the item is a mapping,
473/// if both the seq-item trailer and the first key's trailer are present,
474/// the inner one degrades to an own-line comment.
475#[allow(clippy::too_many_arguments)]
476fn emit_sequence_item(
477    out: &mut String,
478    value: &JsonValue,
479    base_indent: usize,
480    path: &[CommentPathSegment],
481    nested: &[NestedComment],
482    fills: &[Vec<CommentPathSegment>],
483    inline_trailer: Option<&str>,
484) {
485    match value {
486        JsonValue::Object(map) if map.is_empty() => {
487            push_indent(out, base_indent);
488            out.push_str("- {}");
489            push_trailer(out, inline_trailer);
490            out.push('\n');
491        }
492        JsonValue::Object(map) => {
493            emit_own_line_pending(out, path, 0, base_indent, nested);
494
495            let mut first = true;
496            for (i, (k, v)) in map.iter().enumerate() {
497                if !first {
498                    emit_own_line_pending(out, path, i, base_indent + 2, nested);
499                }
500                let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
501                let mut child_path = path.to_vec();
502                child_path.push(CommentPathSegment::Key(k.clone()));
503                if first {
504                    let line_trailer = inline_trailer.or(inner_trailer);
505                    push_indent(out, base_indent);
506                    out.push_str("- ");
507                    emit_field_inline(
508                        out,
509                        k,
510                        v,
511                        base_indent + 2,
512                        path_is_fill(fills, &child_path),
513                        &child_path,
514                        nested,
515                        fills,
516                        line_trailer,
517                    );
518                    if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
519                        push_indent(out, base_indent + 2);
520                        out.push_str("# ");
521                        out.push_str(loser);
522                        out.push('\n');
523                    }
524                    first = false;
525                } else {
526                    emit_field(
527                        out,
528                        k,
529                        v,
530                        base_indent + 2,
531                        path_is_fill(fills, &child_path),
532                        &child_path,
533                        nested,
534                        fills,
535                        inner_trailer,
536                    );
537                }
538            }
539            emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
540            emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
541        }
542        JsonValue::Array(inner) if inner.is_empty() => {
543            push_indent(out, base_indent);
544            out.push_str("- []");
545            push_trailer(out, inline_trailer);
546            out.push('\n');
547        }
548        JsonValue::Array(inner) => {
549            push_indent(out, base_indent);
550            out.push('-');
551            push_trailer(out, inline_trailer);
552            out.push('\n');
553            emit_sequence_children(out, inner, base_indent + 2, path, nested, fills);
554        }
555        _ => {
556            push_indent(out, base_indent);
557            out.push_str("- ");
558            emit_scalar(out, value);
559            push_trailer(out, inline_trailer);
560            out.push('\n');
561        }
562    }
563}
564
565/// Emit `key: <value>\n` where the caller already wrote `- ` on the current line.
566#[allow(clippy::too_many_arguments)]
567fn emit_field_inline(
568    out: &mut String,
569    key: &str,
570    value: &JsonValue,
571    child_indent: usize,
572    fill: bool,
573    path: &[CommentPathSegment],
574    nested: &[NestedComment],
575    fills: &[Vec<CommentPathSegment>],
576    inline_trailer: Option<&str>,
577) {
578    if fill {
579        emit_key(out, key);
580        match value {
581            JsonValue::Null => out.push_str(": !must_fill"),
582            JsonValue::Array(items) if items.is_empty() => out.push_str(": !must_fill []"),
583            JsonValue::Array(items) => {
584                out.push_str(": !must_fill");
585                push_trailer(out, inline_trailer);
586                out.push('\n');
587                emit_sequence_children(out, items, child_indent + 2, path, nested, fills);
588                return;
589            }
590            JsonValue::Object(_) => {
591                // `!must_fill` on a mapping is rejected at parse; emit plainly.
592                out.push(':');
593                push_trailer(out, inline_trailer);
594                out.push('\n');
595                if let JsonValue::Object(map) = value {
596                    emit_mapping_children(out, map, child_indent, path, nested, fills);
597                }
598                return;
599            }
600            _ => {
601                out.push_str(": !must_fill ");
602                emit_scalar(out, value);
603            }
604        }
605        push_trailer(out, inline_trailer);
606        out.push('\n');
607        return;
608    }
609    match value {
610        JsonValue::Object(map) if map.is_empty() => {
611            emit_key(out, key);
612            out.push_str(": {}");
613            push_trailer(out, inline_trailer);
614            out.push('\n');
615        }
616        JsonValue::Object(map) => {
617            emit_key(out, key);
618            out.push(':');
619            push_trailer(out, inline_trailer);
620            out.push('\n');
621            emit_mapping_children(out, map, child_indent, path, nested, fills);
622        }
623        JsonValue::Array(items) if items.is_empty() => {
624            emit_key(out, key);
625            out.push_str(": []");
626            push_trailer(out, inline_trailer);
627            out.push('\n');
628        }
629        JsonValue::Array(items) => {
630            emit_key(out, key);
631            out.push(':');
632            push_trailer(out, inline_trailer);
633            out.push('\n');
634            emit_sequence_children(out, items, child_indent + 2, path, nested, fills);
635        }
636        _ => {
637            emit_key(out, key);
638            out.push_str(": ");
639            emit_scalar(out, value);
640            push_trailer(out, inline_trailer);
641            out.push('\n');
642        }
643    }
644}
645
646fn emit_scalar(out: &mut String, value: &JsonValue) {
647    let s = saphyr_emit_scalar(value);
648    out.push_str(&s);
649}
650
651/// Emit a *nested* mapping key, quoting it through the same scalar path as
652/// values. Nested keys are arbitrary user data (never name-validated) and are
653/// re-parsed by serde_saphyr, so a key containing `:`/`#`, a leading YAML
654/// indicator (`*`, `&`, `?`, `-`, …), edge whitespace, or a type-ambiguous form
655/// (`n`, `true`, `123`) must be quoted or the emitted document re-parses to a
656/// different key — breaking the round-trip/idempotence contract.
657fn emit_key(out: &mut String, key: &str) {
658    out.push_str(&saphyr_emit_scalar(&JsonValue::String(key.to_string())));
659}
660
661/// Emit a mapping key at `indent`. Top-level field names (indent 0) are emitted
662/// verbatim: the line-oriented prescan accepts only bare `[A-Za-z_][A-Za-z0-9_]*`
663/// field names there, so quoting one would make it unparseable. Nested keys
664/// (indent > 0) route through [`emit_key`] for correct YAML quoting.
665fn emit_key_at(out: &mut String, key: &str, indent: usize) {
666    if indent == 0 {
667        out.push_str(key);
668    } else {
669        emit_key(out, key);
670    }
671}
672
673/// `prefer_block_scalars: false` forces multi-line strings to double-quoted
674/// inline scalars (no `|` / `>` block forms in v1).
675fn saphyr_opts() -> SerializerOptions {
676    SerializerOptions {
677        prefer_block_scalars: false,
678        ..SerializerOptions::default()
679    }
680}
681
682pub(crate) fn saphyr_emit_scalar(value: &JsonValue) -> String {
683    let mut buf = String::new();
684    serde_saphyr::to_fmt_writer_with_options(&mut buf, value, saphyr_opts())
685        .expect("saphyr scalar emission is infallible for JsonValue scalars");
686    while buf.ends_with('\n') {
687        buf.pop();
688    }
689
690    // Saphyr 0.0.23's plain-safety check inspects only the leading byte
691    // as ASCII whitespace, missing strings whose first or last char is
692    // Unicode whitespace (U+2000…) or whose last char is an ASCII space.
693    // YAML strips such whitespace from plain scalars on parse, losing
694    // the data. Detect and emit double-quoted ourselves so the round-
695    // trip is preserved.
696    if let JsonValue::String(s) = value {
697        let unquoted = !buf.starts_with('"')
698            && !buf.starts_with('\'')
699            && !buf.starts_with('|')
700            && !buf.starts_with('>');
701        let has_edge_whitespace = !s.is_empty()
702            && (s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace));
703        if unquoted && has_edge_whitespace {
704            return double_quote_string(s);
705        }
706    }
707    buf
708}
709
710/// JSON-style double-quoted fallback for strings saphyr would emit in a form
711/// that loses bytes on parse (e.g. trailing-whitespace plain scalars).
712fn double_quote_string(s: &str) -> String {
713    let mut out = String::with_capacity(s.len() + 2);
714    out.push('"');
715    for ch in s.chars() {
716        match ch {
717            '\\' => out.push_str("\\\\"),
718            '"' => out.push_str("\\\""),
719            '\n' => out.push_str("\\n"),
720            '\r' => out.push_str("\\r"),
721            '\t' => out.push_str("\\t"),
722            c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
723                out.push_str(&format!("\\u{:04X}", c as u32));
724            }
725            c => out.push(c),
726        }
727    }
728    out.push('"');
729    out
730}
731
732/// Render a `JsonValue` as a one-line YAML flow form (`[a, b]` / `{k: v}` /
733/// flow-quoted scalar). Used for `# e.g.` hint lines in blueprint output.
734pub(crate) fn saphyr_emit_flow(value: &JsonValue) -> String {
735    let mut buf = String::new();
736    let opts = saphyr_opts();
737    match value {
738        JsonValue::Array(items) => {
739            let wrapped = FlowSeq(items.clone());
740            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
741                .expect("saphyr flow seq emission");
742        }
743        JsonValue::Object(map) => {
744            let wrapped = FlowMap(map.clone());
745            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
746                .expect("saphyr flow map emission");
747        }
748        scalar => {
749            // Wrap in FlowSeq so saphyr applies flow-context quoting, then strip `[`/`]`.
750            let wrapped = FlowSeq(vec![scalar.clone()]);
751            serde_saphyr::to_fmt_writer_with_options(&mut buf, &wrapped, opts)
752                .expect("saphyr flow scalar emission");
753            while buf.ends_with('\n') {
754                buf.pop();
755            }
756            return buf
757                .strip_prefix('[')
758                .and_then(|s| s.strip_suffix(']'))
759                .unwrap_or(&buf)
760                .to_string();
761        }
762    }
763    while buf.ends_with('\n') {
764        buf.pop();
765    }
766    buf
767}
768
769// ── Utilities ─────────────────────────────────────────────────────────────────
770
771fn push_indent(out: &mut String, spaces: usize) {
772    for _ in 0..spaces {
773        out.push(' ');
774    }
775}
776
777// ── Unit tests ────────────────────────────────────────────────────────────────
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use crate::value::QuillValue;
783
784    fn assert_scalar_round_trips(value: serde_json::Value) {
785        let mut yaml = String::from("~~~card-yaml\n$quill: q\n$kind: main\nv: ");
786        yaml.push_str(&saphyr_emit_scalar(&value));
787        yaml.push_str("\n~~~\n");
788        let doc = crate::document::Document::from_markdown(&yaml).unwrap_or_else(|e| {
789            panic!(
790                "failed to parse emitted scalar {:?}: {}\n{}",
791                value, e, yaml
792            )
793        });
794        let parsed = doc.main().payload().get("v").expect("field 'v'").as_json();
795        assert_eq!(
796            parsed, &value,
797            "scalar round-trip mismatch for {:?}: emitted as {:?}",
798            value, yaml
799        );
800    }
801
802    #[test]
803    fn saphyr_scalar_round_trips_plain_string() {
804        assert_scalar_round_trips(serde_json::json!("hello"));
805    }
806
807    #[test]
808    fn saphyr_scalar_round_trips_ambiguous_strings() {
809        for ambiguous in &[
810            "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
811        ] {
812            assert_scalar_round_trips(serde_json::json!(*ambiguous));
813        }
814    }
815
816    #[test]
817    fn saphyr_scalar_round_trips_escapes() {
818        assert_scalar_round_trips(serde_json::json!("a\\b\"c\nd\te"));
819    }
820
821    #[test]
822    fn saphyr_scalar_round_trips_control_chars() {
823        assert_scalar_round_trips(serde_json::json!("\x01\x1F"));
824    }
825
826    fn p(key: &str) -> Vec<CommentPathSegment> {
827        vec![CommentPathSegment::Key(key.to_string())]
828    }
829
830    #[test]
831    fn empty_object_omitted() {
832        let value = QuillValue::from_json(serde_json::json!({}));
833        let mut out = String::new();
834        emit_field(
835            &mut out,
836            "empty_map",
837            value.as_json(),
838            0,
839            false,
840            &p("empty_map"),
841            &[],
842            &[],
843            None,
844        );
845        assert_eq!(out, "");
846    }
847
848    #[test]
849    fn empty_object_with_inline_trailer_degrades() {
850        let value = QuillValue::from_json(serde_json::json!({}));
851        let mut out = String::new();
852        emit_field(
853            &mut out,
854            "empty_map",
855            value.as_json(),
856            0,
857            false,
858            &p("empty_map"),
859            &[],
860            &[],
861            Some("orphan"),
862        );
863        assert_eq!(out, "# orphan\n");
864    }
865
866    #[test]
867    fn empty_array_emitted() {
868        let value = QuillValue::from_json(serde_json::json!([]));
869        let mut out = String::new();
870        emit_field(
871            &mut out,
872            "empty_seq",
873            value.as_json(),
874            0,
875            false,
876            &p("empty_seq"),
877            &[],
878            &[],
879            None,
880        );
881        assert_eq!(out, "empty_seq: []\n");
882    }
883
884    #[test]
885    fn scalar_field_with_inline_trailer() {
886        let value = QuillValue::from_json(serde_json::json!("Hello"));
887        let mut out = String::new();
888        emit_field(
889            &mut out,
890            "title",
891            value.as_json(),
892            0,
893            false,
894            &p("title"),
895            &[],
896            &[],
897            Some("greeting"),
898        );
899        assert_eq!(out, "title: Hello # greeting\n");
900    }
901
902    #[test]
903    fn container_field_with_inline_trailer_lands_on_key_line() {
904        let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
905        let mut out = String::new();
906        emit_field(
907            &mut out,
908            "outer",
909            value.as_json(),
910            0,
911            false,
912            &p("outer"),
913            &[],
914            &[],
915            Some("note"),
916        );
917        assert_eq!(out, "outer: # note\n  inner: 1\n");
918    }
919
920    #[test]
921    fn fill_null_emits_bare_tag() {
922        let value = QuillValue::from_json(serde_json::Value::Null);
923        let mut out = String::new();
924        emit_field(
925            &mut out,
926            "recipient",
927            value.as_json(),
928            0,
929            true,
930            &p("recipient"),
931            &[],
932            &[],
933            None,
934        );
935        assert_eq!(out, "recipient: !must_fill\n");
936    }
937
938    #[test]
939    fn fill_string_emits_tag_with_value() {
940        let value = QuillValue::from_json(serde_json::json!("placeholder"));
941        let mut out = String::new();
942        emit_field(
943            &mut out,
944            "dept",
945            value.as_json(),
946            0,
947            true,
948            &p("dept"),
949            &[],
950            &[],
951            None,
952        );
953        assert_eq!(out, "dept: !must_fill placeholder\n");
954    }
955
956    #[test]
957    fn fill_with_inline_trailer() {
958        let value = QuillValue::from_json(serde_json::json!("placeholder"));
959        let mut out = String::new();
960        emit_field(
961            &mut out,
962            "dept",
963            value.as_json(),
964            0,
965            true,
966            &p("dept"),
967            &[],
968            &[],
969            Some("note"),
970        );
971        assert_eq!(out, "dept: !must_fill placeholder # note\n");
972    }
973
974    #[test]
975    fn fill_integer_emits_tag_with_value() {
976        let value = QuillValue::from_json(serde_json::json!(42));
977        let mut out = String::new();
978        emit_field(
979            &mut out,
980            "count",
981            value.as_json(),
982            0,
983            true,
984            &p("count"),
985            &[],
986            &[],
987            None,
988        );
989        assert_eq!(out, "count: !must_fill 42\n");
990    }
991}