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}