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