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//! `serde-saphyr::SerializerOptions::quote_all` was evaluated (spike, 2026-04-21)
9//! and found to emit single-quoted strings for ordinary scalars like `"on"` and
10//! `"01234"` — switching to double quotes only when the string contains a single
11//! quote, backslash, or control character.  That behaviour is correct for
12//! round-trip type-fidelity (single-quoted YAML strings are re-parsed as strings),
13//! but the Quillmark spec (§5.2) requires **always double-quoted, JSON-style
14//! escaping**.  Because `SerializerOptions` provides no "force double-quote" knob,
15//! the YAML value block is generated by a hand-written emitter in this module.
16//!
17//! The hand-written emitter is small (< 120 lines), covers exactly the
18//! `QuillValue` type variants, and gives complete control over quoting style and
19//! indentation without pulling in additional abstractions.
20
21use serde_json::Value as JsonValue;
22
23use super::frontmatter::FrontmatterItem;
24use super::prescan::{CommentPathSegment, NestedComment};
25use super::{Card, Document, Sentinel};
26
27// ── Public entry point ────────────────────────────────────────────────────────
28
29impl Document {
30    /// Emit canonical Quillmark Markdown from this document.
31    ///
32    /// # Contract
33    ///
34    /// 1. **Type-fidelity round-trip.** `Document::from_markdown(&doc.to_markdown())`
35    ///    returns a `Document` equal to `doc` by value *and* by type variant.
36    ///    `QuillValue::String("on")` round-trips as a string, never as a bool.
37    ///    `QuillValue::String("01234")` round-trips as a string, never as an
38    ///    integer.  This guarantee is the whole point of owning emission.
39    ///
40    /// 2. **Emit-idempotent.** `to_markdown` is a pure function of `doc`; two
41    ///    calls on the same `doc` return byte-equal strings.
42    ///
43    /// Byte-equality with the *original source* is **not** guaranteed.
44    ///
45    /// # Emission rules (§5.2)
46    ///
47    /// - Line endings: `\n` only.  CRLF normalization happens on import.
48    /// - Frontmatter: `---\n`, `QUILL: <ref>` first, remaining fields in
49    ///   `IndexMap` insertion order, `---\n`, blank line.
50    /// - Cards: one blank line before each, emitted as the canonical fenced
51    ///   block — a ```` ```card <tag> ```` opener, the card's YAML, a closing
52    ///   ```` ``` ````, then the card body. The legacy `---`/`CARD:` fence is
53    ///   accepted on input but never emitted — every card round-trips to the
54    ///   ```` ```card ```` form.
55    /// - Body: emitted verbatim after frontmatter (and cards).
56    /// - Mappings and sequences: **block style** at every nesting level.
57    /// - Booleans: `true` / `false`.
58    /// - Null: `null`.
59    /// - Numbers: bare literals (integer or float as stored in `serde_json::Value`).
60    /// - **Strings: always double-quoted**, JSON-style escaping
61    ///   (`\"`, `\\`, `\n`, `\t`, `\uXXXX` for control chars).  This is the
62    ///   load-bearing rule that guarantees type fidelity.
63    /// - Multi-line strings: double-quoted with `\n` escape sequences.  No block
64    ///   scalars (`|`, `>`) in v1.
65    ///
66    /// # Open decisions (resolved)
67    ///
68    /// - **Nested-map order.** `QuillValue` is backed by `serde_json::Value`
69    ///   whose object type (`serde_json::Map`) preserves insertion order when the
70    ///   `serde_json/preserve_order` feature is enabled (it is in this workspace).
71    ///   Insertion order is therefore preserved for nested maps at emit time.
72    ///
73    /// - **Empty containers.**
74    ///   - Empty object (`{}`) → the key is **omitted** from emit entirely.
75    ///   - Empty array (`[]`) → emitted as `key: []\n`.
76    ///
77    /// # What is preserved
78    ///
79    /// - **YAML comments**: own-line and inline trailing comments round-trip
80    ///   at their source position. Inline comments on sentinel lines
81    ///   (`QUILL: r # …` / `CARD: t # …`) round-trip too. Comments whose
82    ///   host disappears at emit time (empty-mapping omission, programmatic
83    ///   field removal) degrade to own-line comments at the same indent so
84    ///   the comment text is preserved even when its position shifts.
85    /// - **`!fill` tags**: round-trip via the `fill` flag on `FrontmatterItem::Field`.
86    ///
87    /// # What is lost
88    ///
89    /// - **Other custom tags** (`!include`, `!env`, …): the tag is dropped;
90    ///   the scalar value is preserved.
91    /// - **Original quoting style**: all strings are re-emitted double-quoted
92    ///   regardless of how they were written in the source.
93    pub fn to_markdown(&self) -> String {
94        let mut out = String::new();
95
96        // ── Main card (frontmatter fence + global body) ───────────────────────
97        emit_main_fence(&mut out, self.main());
98        out.push_str(self.main().body());
99
100        // ── Composable cards ──────────────────────────────────────────────────
101        // `ensure_f2_before_fence` normalises the separator before each fence,
102        // so edited bodies (which may lack a trailing blank line) still
103        // round-trip. Every card emits as the canonical ```` ```card ```` block
104        // regardless of whether it was authored with the legacy `---` syntax.
105        for card in self.cards() {
106            ensure_f2_before_fence(&mut out);
107            emit_card_block(&mut out, card);
108            if !card.body().is_empty() {
109                out.push_str(card.body());
110            }
111        }
112
113        out
114    }
115}
116
117// ── Card emission ─────────────────────────────────────────────────────────────
118
119/// Emit the document frontmatter fence: `---\nQUILL: <ref>\n<items>\n---\n`.
120///
121/// ## Inline-comment handling
122///
123/// - **Sentinel-inline preview.** If `items[0]` is a `Comment{inline:true}`,
124///   its text is appended to the `QUILL:` line and the item is skipped. This
125///   is the only way to round-trip a source-level inline comment on the
126///   `QUILL:` line.
127/// - The remaining items are emitted by [`emit_fence_items`].
128fn emit_main_fence(out: &mut String, card: &Card) {
129    out.push_str("---\n");
130
131    match card.sentinel() {
132        Sentinel::Main(r) => {
133            out.push_str("QUILL: ");
134            out.push_str(&r.to_string());
135            out.push('\n');
136        }
137        Sentinel::Card(_) => unreachable!("main card must carry Sentinel::Main"),
138    }
139
140    let items = card.frontmatter().items();
141    let nested = card.frontmatter().nested_comments();
142
143    // Sentinel-inline preview.
144    let start = if let Some(FrontmatterItem::Comment { text, inline: true }) = items.first() {
145        attach_inline_to_last_line(out, text);
146        1
147    } else {
148        0
149    };
150
151    emit_fence_items(out, &items[start..], nested);
152
153    out.push_str("---\n");
154}
155
156/// Emit a composable card as the canonical fenced block:
157/// ```` ```card <tag>\n<items>\n``` ````.
158///
159/// The card kind lives in the info string, so — unlike the legacy `---`
160/// fence — there is no sentinel line for an inline comment to attach to. An
161/// inline comment carried over at `items[0]` (from a legacy `CARD: tag # note`
162/// source) degrades to an own-line comment as the first body line, preserving
163/// its text. Three backticks are always a safe fence length: canonically
164/// emitted YAML never produces a line that begins with a backtick (keys are
165/// identifiers, sequence items start with `-`, strings are double-quoted), so
166/// the fence can never be closed early.
167fn emit_card_block(out: &mut String, card: &Card) {
168    out.push_str("```card ");
169    out.push_str(&card.tag());
170    out.push('\n');
171
172    emit_fence_items(
173        out,
174        card.frontmatter().items(),
175        card.frontmatter().nested_comments(),
176    );
177
178    out.push_str("```\n");
179}
180
181/// Emit the ordered YAML items (fields and comments) of a fence body.
182///
183/// ## Inline-comment handling
184///
185/// - **Field + trailing inline.** A `Field` peeks at its successor: if the
186///   next item is `Comment{inline:true}`, the comment text is passed to
187///   [`emit_field`] as a trailer and consumed here. The trailer lands on the
188///   field's key/value line.
189/// - **Own-line / orphan.** A `Comment` that is not consumed by a field's
190///   lookahead — own-line, or an inline orphan — renders as an own-line
191///   `# text` comment.
192fn emit_fence_items(out: &mut String, items: &[FrontmatterItem], nested: &[NestedComment]) {
193    let mut i = 0;
194    while i < items.len() {
195        match &items[i] {
196            FrontmatterItem::Field { key, value, fill } => {
197                let trailer = items.get(i + 1).and_then(|next| match next {
198                    FrontmatterItem::Comment { text, inline: true } => Some(text.as_str()),
199                    _ => None,
200                });
201                let path = vec![CommentPathSegment::Key(key.clone())];
202                emit_field(out, key, value.as_json(), 0, *fill, &path, nested, trailer);
203                i += if trailer.is_some() { 2 } else { 1 };
204            }
205            FrontmatterItem::Comment { text, .. } => {
206                out.push_str("# ");
207                out.push_str(text);
208                out.push('\n');
209                i += 1;
210            }
211        }
212    }
213}
214
215/// Ensures `out` ends with a `\n\n` suffix suitable for the F2 precondition
216/// of the next metadata fence.
217///
218/// Under the F2-separator-never-stored invariant, stored bodies may end with
219/// their content (no newline), a content line terminator (`\n`), or an
220/// author-intended blank line (`\n\n`, `\n\n\n`, …). In every case we append
221/// exactly one `\n` to produce the F2 blank line. If the body doesn't already
222/// end in `\n`, we also append a line terminator first so content lines are
223/// terminated in the emitted markdown.
224///
225/// Empty `out` satisfies F2 via the "line 1" clause (MARKDOWN.md §3 F2) and
226/// needs no separator.
227fn ensure_f2_before_fence(out: &mut String) {
228    if out.is_empty() {
229        return;
230    }
231    if !out.ends_with('\n') {
232        out.push('\n');
233    }
234    out.push('\n');
235}
236
237// ── YAML value emission ───────────────────────────────────────────────────────
238
239/// Strip the trailing `\n` from `out`, append ` # text`, and restore `\n`.
240///
241/// The caller is responsible for ensuring the previous line is a valid host
242/// for an inline comment (a field/sequence-item line, not a fence or another
243/// comment line).
244fn attach_inline_to_last_line(out: &mut String, text: &str) {
245    if !out.ends_with('\n') {
246        // Defensive: shouldn't happen given how this is called.
247        out.push_str(" # ");
248        out.push_str(text);
249        out.push('\n');
250        return;
251    }
252    out.pop();
253    out.push_str(" # ");
254    out.push_str(text);
255    out.push('\n');
256}
257
258/// Emit own-line nested comments at `position` in `path` as `# text` lines
259/// indented by `indent` spaces. Inline comments are skipped here — they are
260/// consumed by `find_inline_trailer` at the host's emission site.
261fn emit_own_line_pending(
262    out: &mut String,
263    path: &[CommentPathSegment],
264    position: usize,
265    indent: usize,
266    nested: &[NestedComment],
267) {
268    for c in nested {
269        if c.position == position && !c.inline && c.container_path.as_slice() == path {
270            push_indent(out, indent);
271            out.push_str("# ");
272            out.push_str(&c.text);
273            out.push('\n');
274        }
275    }
276}
277
278/// Look up the inline trailer for the child at `position` in `path`. If
279/// multiple inline comments share this slot (programmatic edge case), the
280/// first one is returned and the rest are emitted as own-line comments at
281/// `indent` to preserve their text.
282fn find_inline_trailer<'a>(
283    out: &mut String,
284    path: &[CommentPathSegment],
285    position: usize,
286    indent: usize,
287    nested: &'a [NestedComment],
288) -> Option<&'a str> {
289    let mut chosen: Option<&str> = None;
290    for c in nested {
291        if c.position == position && c.inline && c.container_path.as_slice() == path {
292            if chosen.is_none() {
293                chosen = Some(c.text.as_str());
294            } else {
295                push_indent(out, indent);
296                out.push_str("# ");
297                out.push_str(&c.text);
298                out.push('\n');
299            }
300        }
301    }
302    chosen
303}
304
305/// Emit any orphan inline comments (`inline=true` with `position >=
306/// container_len`) as own-line comments at the trailing slot. These are
307/// programmatic edge cases — well-formed prescan output never produces them.
308fn emit_orphan_inlines(
309    out: &mut String,
310    path: &[CommentPathSegment],
311    container_len: usize,
312    indent: usize,
313    nested: &[NestedComment],
314) {
315    for c in nested {
316        if c.inline && c.position >= container_len && c.container_path.as_slice() == path {
317            push_indent(out, indent);
318            out.push_str("# ");
319            out.push_str(&c.text);
320            out.push('\n');
321        }
322    }
323}
324
325/// Append ` # trailer` to `out` if `trailer` is `Some`. Caller writes the
326/// terminating `\n` afterwards.
327fn push_trailer(out: &mut String, trailer: Option<&str>) {
328    if let Some(t) = trailer {
329        out.push_str(" # ");
330        out.push_str(t);
331    }
332}
333
334/// Emit a `key: <value>\n` pair at `indent` spaces.
335///
336/// `path` is the path to *this* field (parent path + this key). It's used as
337/// the *container* path when recursing into the value: nested comments
338/// captured at this path are interleaved between the value's children.
339///
340/// `inline_trailer`, when `Some`, is rendered as ` # text` on the field's
341/// key/value line. For scalars this trails the value; for containers it
342/// trails the `key:` line (before the indented children).
343///
344/// - Empty objects are **omitted** (caller skips them). An empty-object
345///   field with an inline trailer degrades the trailer to an own-line
346///   comment at `indent`, so the comment text is preserved even though its
347///   host disappears.
348/// - Empty arrays emit `key: []\n`.
349/// - All other values follow the block-style rules.
350/// - When `fill` is `true`, the emitted form is `key: !fill <value>` for
351///   scalars, `key: !fill\n  - …` for non-empty sequences,
352///   `key: !fill []` for empty sequences, and `key: !fill` for null.
353///   Mappings are rejected at parse and never reach this path.
354#[allow(clippy::too_many_arguments)]
355fn emit_field(
356    out: &mut String,
357    key: &str,
358    value: &JsonValue,
359    indent: usize,
360    fill: bool,
361    path: &[CommentPathSegment],
362    nested: &[NestedComment],
363    inline_trailer: Option<&str>,
364) {
365    if fill {
366        push_indent(out, indent);
367        out.push_str(key);
368        match value {
369            JsonValue::Null => {
370                out.push_str(": !fill");
371                push_trailer(out, inline_trailer);
372                out.push('\n');
373            }
374            JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {
375                out.push_str(": !fill ");
376                emit_scalar(out, value);
377                push_trailer(out, inline_trailer);
378                out.push('\n');
379            }
380            JsonValue::Array(items) if items.is_empty() => {
381                out.push_str(": !fill []");
382                push_trailer(out, inline_trailer);
383                out.push('\n');
384            }
385            JsonValue::Array(items) => {
386                out.push_str(": !fill");
387                push_trailer(out, inline_trailer);
388                out.push('\n');
389                emit_sequence_children(out, items, indent + 2, path, nested);
390            }
391            JsonValue::Object(_) => {
392                // Parser rejects !fill on mappings; recovery path only.
393                out.push_str(": ");
394                emit_scalar(out, value);
395                push_trailer(out, inline_trailer);
396                out.push('\n');
397            }
398        }
399        return;
400    }
401    match value {
402        JsonValue::Object(map) if map.is_empty() => {
403            // Empty object → omit the key entirely. If there's an inline
404            // trailer, degrade it to an own-line comment so its text isn't
405            // lost.
406            if let Some(t) = inline_trailer {
407                push_indent(out, indent);
408                out.push_str("# ");
409                out.push_str(t);
410                out.push('\n');
411            }
412        }
413        JsonValue::Object(map) => {
414            push_indent(out, indent);
415            out.push_str(key);
416            out.push(':');
417            push_trailer(out, inline_trailer);
418            out.push('\n');
419            emit_mapping_children(out, map, indent + 2, path, nested);
420        }
421        JsonValue::Array(items) if items.is_empty() => {
422            push_indent(out, indent);
423            out.push_str(key);
424            out.push_str(": []");
425            push_trailer(out, inline_trailer);
426            out.push('\n');
427        }
428        JsonValue::Array(items) => {
429            push_indent(out, indent);
430            out.push_str(key);
431            out.push(':');
432            push_trailer(out, inline_trailer);
433            out.push('\n');
434            emit_sequence_children(out, items, indent + 2, path, nested);
435        }
436        _ => {
437            push_indent(out, indent);
438            out.push_str(key);
439            out.push_str(": ");
440            emit_scalar(out, value);
441            push_trailer(out, inline_trailer);
442            out.push('\n');
443        }
444    }
445}
446
447/// Emit the children of a mapping value with comment interleaving.
448///
449/// `child_indent` is the indent at which each child key sits; nested
450/// comments inside this mapping are emitted at the same indent. `path` is
451/// the path to the mapping container (its key in the parent).
452fn emit_mapping_children(
453    out: &mut String,
454    map: &serde_json::Map<String, JsonValue>,
455    child_indent: usize,
456    path: &[CommentPathSegment],
457    nested: &[NestedComment],
458) {
459    for (i, (k, v)) in map.iter().enumerate() {
460        emit_own_line_pending(out, path, i, child_indent, nested);
461        let trailer = find_inline_trailer(out, path, i, child_indent, nested);
462        let mut child_path = path.to_vec();
463        child_path.push(CommentPathSegment::Key(k.clone()));
464        emit_field(out, k, v, child_indent, false, &child_path, nested, trailer);
465    }
466    emit_own_line_pending(out, path, map.len(), child_indent, nested);
467    emit_orphan_inlines(out, path, map.len(), child_indent, nested);
468}
469
470/// Emit the children of a sequence value with comment interleaving.
471///
472/// `base_indent` is the indent at which each `- ` sits; nested comments
473/// inside this sequence are emitted at the same indent.
474fn emit_sequence_children(
475    out: &mut String,
476    items: &[JsonValue],
477    base_indent: usize,
478    path: &[CommentPathSegment],
479    nested: &[NestedComment],
480) {
481    for (i, item) in items.iter().enumerate() {
482        emit_own_line_pending(out, path, i, base_indent, nested);
483        let trailer = find_inline_trailer(out, path, i, base_indent, nested);
484        let mut child_path = path.to_vec();
485        child_path.push(CommentPathSegment::Index(i));
486        emit_sequence_item(out, item, base_indent, &child_path, nested, trailer);
487    }
488    emit_own_line_pending(out, path, items.len(), base_indent, nested);
489    emit_orphan_inlines(out, path, items.len(), base_indent, nested);
490}
491
492/// Emit a single `- <value>\n` sequence item at `base_indent` spaces.
493///
494/// `path` is the path to *this* item (parent path + item index).
495///
496/// `inline_trailer`, when `Some`, is rendered as ` # text` on the `-` line.
497/// For mapping items the trailer co-exists with any inline trailer at index
498/// 0 of the inner mapping (the latter would be on the same physical line);
499/// in well-formed input only one of them is present, but if both appear
500/// the inner one degrades to an own-line comment beneath the `- ` line.
501fn emit_sequence_item(
502    out: &mut String,
503    value: &JsonValue,
504    base_indent: usize,
505    path: &[CommentPathSegment],
506    nested: &[NestedComment],
507    inline_trailer: Option<&str>,
508) {
509    match value {
510        JsonValue::Object(map) if map.is_empty() => {
511            // Empty nested object in a sequence: emit as `- {}`
512            push_indent(out, base_indent);
513            out.push_str("- {}");
514            push_trailer(out, inline_trailer);
515            out.push('\n');
516        }
517        JsonValue::Object(map) => {
518            // Block mapping inside a sequence. First key on the same line
519            // as `- `; subsequent keys indented by 2. Comments inside this
520            // mapping use this item's path as the container.
521            emit_own_line_pending(out, path, 0, base_indent, nested);
522
523            let mut first = true;
524            for (i, (k, v)) in map.iter().enumerate() {
525                if !first {
526                    emit_own_line_pending(out, path, i, base_indent + 2, nested);
527                }
528                let inner_trailer = find_inline_trailer(out, path, i, base_indent + 2, nested);
529                let mut child_path = path.to_vec();
530                child_path.push(CommentPathSegment::Key(k.clone()));
531                if first {
532                    // The seq-item's trailer and the first key's trailer
533                    // both target the `- key: ...` line. Prefer the
534                    // seq-item's; degrade the loser to own-line.
535                    let line_trailer = inline_trailer.or(inner_trailer);
536                    push_indent(out, base_indent);
537                    out.push_str("- ");
538                    emit_field_inline(
539                        out,
540                        k,
541                        v,
542                        base_indent + 2,
543                        &child_path,
544                        nested,
545                        line_trailer,
546                    );
547                    if let (Some(_), Some(loser)) = (inline_trailer, inner_trailer) {
548                        push_indent(out, base_indent + 2);
549                        out.push_str("# ");
550                        out.push_str(loser);
551                        out.push('\n');
552                    }
553                    first = false;
554                } else {
555                    emit_field(
556                        out,
557                        k,
558                        v,
559                        base_indent + 2,
560                        false,
561                        &child_path,
562                        nested,
563                        inner_trailer,
564                    );
565                }
566            }
567            emit_own_line_pending(out, path, map.len(), base_indent + 2, nested);
568            emit_orphan_inlines(out, path, map.len(), base_indent + 2, nested);
569        }
570        JsonValue::Array(inner) if inner.is_empty() => {
571            push_indent(out, base_indent);
572            out.push_str("- []");
573            push_trailer(out, inline_trailer);
574            out.push('\n');
575        }
576        JsonValue::Array(inner) => {
577            // Nested sequence: `-` line then recurse.
578            push_indent(out, base_indent);
579            out.push('-');
580            push_trailer(out, inline_trailer);
581            out.push('\n');
582            emit_sequence_children(out, inner, base_indent + 2, path, nested);
583        }
584        _ => {
585            push_indent(out, base_indent);
586            out.push_str("- ");
587            emit_scalar(out, value);
588            push_trailer(out, inline_trailer);
589            out.push('\n');
590        }
591    }
592}
593
594/// Emit a `key: <value>\n` pair where the key is already on a `- ` line.
595/// The key/value go on the same line as the `- ` prefix (caller already wrote it).
596fn emit_field_inline(
597    out: &mut String,
598    key: &str,
599    value: &JsonValue,
600    child_indent: usize,
601    path: &[CommentPathSegment],
602    nested: &[NestedComment],
603    inline_trailer: Option<&str>,
604) {
605    match value {
606        JsonValue::Object(map) if map.is_empty() => {
607            out.push_str(key);
608            out.push_str(": {}");
609            push_trailer(out, inline_trailer);
610            out.push('\n');
611        }
612        JsonValue::Object(map) => {
613            out.push_str(key);
614            out.push(':');
615            push_trailer(out, inline_trailer);
616            out.push('\n');
617            emit_mapping_children(out, map, child_indent, path, nested);
618        }
619        JsonValue::Array(items) if items.is_empty() => {
620            out.push_str(key);
621            out.push_str(": []");
622            push_trailer(out, inline_trailer);
623            out.push('\n');
624        }
625        JsonValue::Array(items) => {
626            out.push_str(key);
627            out.push(':');
628            push_trailer(out, inline_trailer);
629            out.push('\n');
630            emit_sequence_children(out, items, child_indent + 2, path, nested);
631        }
632        _ => {
633            out.push_str(key);
634            out.push_str(": ");
635            emit_scalar(out, value);
636            push_trailer(out, inline_trailer);
637            out.push('\n');
638        }
639    }
640}
641
642/// Emit a scalar value (no key, no newline) onto `out`.
643fn emit_scalar(out: &mut String, value: &JsonValue) {
644    match value {
645        JsonValue::Null => out.push_str("null"),
646        JsonValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
647        JsonValue::Number(n) => out.push_str(&n.to_string()),
648        JsonValue::String(s) => emit_double_quoted(out, s),
649        // Arrays/objects should not reach here via emit_field — handled above.
650        // As a fallback, emit JSON representation.
651        other => out.push_str(&other.to_string()),
652    }
653}
654
655/// Emit a string as a JSON-style double-quoted YAML scalar.
656///
657/// Escape rules (same as JSON string encoding):
658/// - `\` → `\\`
659/// - `"` → `\"`
660/// - `\n` → `\n`
661/// - `\r` → `\r`
662/// - `\t` → `\t`
663/// - Other control characters (U+0000–U+001F, U+007F–U+009F) → `\uXXXX`
664pub(crate) fn emit_double_quoted(out: &mut String, s: &str) {
665    out.push('"');
666    for ch in s.chars() {
667        match ch {
668            '\\' => out.push_str("\\\\"),
669            '"' => out.push_str("\\\""),
670            '\n' => out.push_str("\\n"),
671            '\r' => out.push_str("\\r"),
672            '\t' => out.push_str("\\t"),
673            c if (c as u32) < 0x20 || (0x7F..=0x9F).contains(&(c as u32)) => {
674                out.push_str(&format!("\\u{:04X}", c as u32));
675            }
676            c => out.push(c),
677        }
678    }
679    out.push('"');
680}
681
682// ── Utilities ─────────────────────────────────────────────────────────────────
683
684fn push_indent(out: &mut String, spaces: usize) {
685    for _ in 0..spaces {
686        out.push(' ');
687    }
688}
689
690// ── Unit tests ────────────────────────────────────────────────────────────────
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::value::QuillValue;
696
697    #[test]
698    fn double_quoted_basic() {
699        let mut s = String::new();
700        emit_double_quoted(&mut s, "hello");
701        assert_eq!(s, r#""hello""#);
702    }
703
704    #[test]
705    fn double_quoted_ambiguous_strings() {
706        // These must remain strings on re-parse — the double-quoting is the guarantee.
707        for ambiguous in &[
708            "on", "off", "yes", "no", "true", "false", "null", "~", "01234", "1e10",
709        ] {
710            let mut s = String::new();
711            emit_double_quoted(&mut s, ambiguous);
712            assert!(
713                s.starts_with('"') && s.ends_with('"'),
714                "should be double-quoted: {}",
715                s
716            );
717            // Verify the content is correct (no extra escaping for these).
718            assert_eq!(&s[1..s.len() - 1], *ambiguous);
719        }
720    }
721
722    #[test]
723    fn double_quoted_escapes() {
724        let mut s = String::new();
725        emit_double_quoted(&mut s, "a\\b\"c\nd\te");
726        assert_eq!(s, r#""a\\b\"c\nd\te""#);
727    }
728
729    #[test]
730    fn double_quoted_control_chars() {
731        let mut s = String::new();
732        emit_double_quoted(&mut s, "\x01\x1F");
733        assert_eq!(s, "\"\\u0001\\u001F\"");
734    }
735
736    fn p(key: &str) -> Vec<CommentPathSegment> {
737        vec![CommentPathSegment::Key(key.to_string())]
738    }
739
740    #[test]
741    fn empty_object_omitted() {
742        let value = QuillValue::from_json(serde_json::json!({}));
743        let mut out = String::new();
744        emit_field(
745            &mut out,
746            "empty_map",
747            value.as_json(),
748            0,
749            false,
750            &p("empty_map"),
751            &[],
752            None,
753        );
754        assert_eq!(out, ""); // omitted
755    }
756
757    #[test]
758    fn empty_object_with_inline_trailer_degrades() {
759        let value = QuillValue::from_json(serde_json::json!({}));
760        let mut out = String::new();
761        emit_field(
762            &mut out,
763            "empty_map",
764            value.as_json(),
765            0,
766            false,
767            &p("empty_map"),
768            &[],
769            Some("orphan"),
770        );
771        // Host omitted; trailer survives as own-line at the same indent.
772        assert_eq!(out, "# orphan\n");
773    }
774
775    #[test]
776    fn empty_array_emitted() {
777        let value = QuillValue::from_json(serde_json::json!([]));
778        let mut out = String::new();
779        emit_field(
780            &mut out,
781            "empty_seq",
782            value.as_json(),
783            0,
784            false,
785            &p("empty_seq"),
786            &[],
787            None,
788        );
789        assert_eq!(out, "empty_seq: []\n");
790    }
791
792    #[test]
793    fn scalar_field_with_inline_trailer() {
794        let value = QuillValue::from_json(serde_json::json!("Hello"));
795        let mut out = String::new();
796        emit_field(
797            &mut out,
798            "title",
799            value.as_json(),
800            0,
801            false,
802            &p("title"),
803            &[],
804            Some("greeting"),
805        );
806        assert_eq!(out, "title: \"Hello\" # greeting\n");
807    }
808
809    #[test]
810    fn container_field_with_inline_trailer_lands_on_key_line() {
811        let value = QuillValue::from_json(serde_json::json!({"inner": 1}));
812        let mut out = String::new();
813        emit_field(
814            &mut out,
815            "outer",
816            value.as_json(),
817            0,
818            false,
819            &p("outer"),
820            &[],
821            Some("note"),
822        );
823        // Trailer lands on the key line, not after the children.
824        assert_eq!(out, "outer: # note\n  inner: 1\n");
825    }
826
827    #[test]
828    fn fill_null_emits_bare_tag() {
829        let value = QuillValue::from_json(serde_json::Value::Null);
830        let mut out = String::new();
831        emit_field(
832            &mut out,
833            "recipient",
834            value.as_json(),
835            0,
836            true,
837            &p("recipient"),
838            &[],
839            None,
840        );
841        assert_eq!(out, "recipient: !fill\n");
842    }
843
844    #[test]
845    fn fill_string_emits_tag_with_value() {
846        let value = QuillValue::from_json(serde_json::json!("placeholder"));
847        let mut out = String::new();
848        emit_field(
849            &mut out,
850            "dept",
851            value.as_json(),
852            0,
853            true,
854            &p("dept"),
855            &[],
856            None,
857        );
858        assert_eq!(out, "dept: !fill \"placeholder\"\n");
859    }
860
861    #[test]
862    fn fill_with_inline_trailer() {
863        let value = QuillValue::from_json(serde_json::json!("placeholder"));
864        let mut out = String::new();
865        emit_field(
866            &mut out,
867            "dept",
868            value.as_json(),
869            0,
870            true,
871            &p("dept"),
872            &[],
873            Some("note"),
874        );
875        assert_eq!(out, "dept: !fill \"placeholder\" # note\n");
876    }
877
878    #[test]
879    fn fill_integer_emits_tag_with_value() {
880        let value = QuillValue::from_json(serde_json::json!(42));
881        let mut out = String::new();
882        emit_field(
883            &mut out,
884            "count",
885            value.as_json(),
886            0,
887            true,
888            &p("count"),
889            &[],
890            None,
891        );
892        assert_eq!(out, "count: !fill 42\n");
893    }
894}