zenith_core/format/writer/mod.rs
1//! Hand-written deterministic serializer for the Zenith AST.
2//!
3//! Produces canonical `.zen` text from a [`Document`]. The output is
4//! idempotent: `format(format(doc)) == format(doc)` for all valid documents.
5//!
6//! Rules:
7//! - Two-space indentation per nesting level.
8//! - Root `zenith` node at column 0.
9//! - Child order under `zenith`: project, assets, libraries, tokens, styles, components, masters, sections, provenance, variants, recipes, actions, document.
10//! - Structural containers (`tokens`, `styles`, `document`, `page`) always emit
11//! a brace block, even when empty.
12//! - Leaf nodes (`project`, a `rect` with no children) emit a single line.
13//! - `text` emits a brace block containing `span` children.
14//! - Numbers: integral `f64` values emit without a decimal point (`640`, not
15//! `640.0`); non-integral emit the shortest representation.
16//! - Booleans: `#true` / `#false` (KDL v2 form).
17//! - Token refs: `fill=(token)"color.bg"`. String values: `name="One"`.
18//! - Dimensions: `x=(px)0`.
19//! - Unknown properties emit after known ones, in BTreeMap (sorted) key order.
20//! - File ends with a single trailing newline.
21//!
22//! The implementation is split across focused submodules:
23//! - this module root holds the public entry point, the `zenith`/`project`/
24//! `assets`/`libraries`/`components`/`masters`/`sections`/`provenance`/
25//! `variants`/`recipes`/`actions` orchestration, and the shared low-level
26//! primitives;
27//! - [`tokens`] writes the `tokens` block;
28//! - [`styles`] writes the `styles` block;
29//! - [`nodes`] writes the `document` body, pages, and every node kind.
30
31use std::fmt::Write as _;
32
33use crate::ast::{
34 ActionDef, AssetBlock, AssetDecl, BrandContract, ComponentDef, DiagnosticPolicy, Dimension,
35 Document, LibraryDef, MasterDef, ObjectPosition, PolicyVerb, Project, PropertyValue,
36 ProvenanceDef, RecipeDef, RecipeParam, SectionDef, UnknownProperty, UnknownValue, VariantDef,
37};
38use crate::error::FormatError;
39
40mod nodes;
41mod styles;
42mod tokens;
43
44#[cfg(test)]
45mod tests;
46
47use nodes::{write_component_children, write_document_body};
48use styles::write_style_block;
49use tokens::write_token_block;
50
51// ---------------------------------------------------------------------------
52// Unknown property value formatting
53// ---------------------------------------------------------------------------
54
55/// Produce a KDL-valid serialization for an `UnknownValue`, preserving the
56/// original KDL type so that parse→format→parse is a perfect round-trip:
57///
58/// - `String(s)` → a double-quoted, escaped KDL string (`"hello"`)
59/// - `Integer(n)` → a bare decimal integer (`42`)
60/// - `Float(f)` → a bare number via the canonical f64 formatter (integral
61/// floats emit without `.0`: `1` not `1.0`)
62/// - `Bool(b)` → KDL v2 boolean keyword (`#true` / `#false`)
63/// - `Null` → KDL v2 null keyword (`#null`)
64fn fmt_unknown_value(v: &UnknownValue) -> String {
65 match v {
66 UnknownValue::String(s) => {
67 let mut out = String::with_capacity(s.len() + 2);
68 out.push('"');
69 out.push_str(&escape_kdl_string(s));
70 out.push('"');
71 out
72 }
73 UnknownValue::Integer(n) => n.to_string(),
74 UnknownValue::Float(f) => fmt_f64(*f),
75 UnknownValue::Bool(b) => (if *b { "#true" } else { "#false" }).to_owned(),
76 UnknownValue::Null => "#null".to_owned(),
77 }
78}
79
80/// Serialize an [`UnknownProperty`]'s value, including its KDL type annotation
81/// when present, so that an annotated value round-trips byte-identically.
82///
83/// The annotation is emitted as a `(ty)` prefix in the value position, matching
84/// KDL v2 syntax `name=(type)value`:
85///
86/// - annotated → `(px)10`, `(token)"color.navy"`
87/// - unannotated → identical to [`fmt_unknown_value`]
88pub(super) fn fmt_unknown_property(p: &UnknownProperty) -> String {
89 match &p.ty {
90 Some(ty) => format!("({}){}", ty, fmt_unknown_value(&p.value)),
91 None => fmt_unknown_value(&p.value),
92 }
93}
94
95// ---------------------------------------------------------------------------
96// Public entry point
97// ---------------------------------------------------------------------------
98
99/// Serialize `doc` to canonical `.zen` UTF-8 bytes.
100pub fn format_document(doc: &Document) -> Result<Vec<u8>, FormatError> {
101 let mut out = String::new();
102 write_document(doc, &mut out);
103 out.push('\n');
104 Ok(out.into_bytes())
105}
106
107// ---------------------------------------------------------------------------
108// Internal helpers
109// ---------------------------------------------------------------------------
110
111/// Append `count * 2` spaces of indentation.
112pub(super) fn indent(out: &mut String, depth: usize) {
113 for _ in 0..depth * 2 {
114 out.push(' ');
115 }
116}
117
118/// Format a `f64` canonically: no trailing `.0` for integral values.
119pub(super) fn fmt_f64(v: f64) -> String {
120 if v.fract() == 0.0 && v.is_finite() {
121 format!("{}", v as i64)
122 } else {
123 format!("{v}")
124 }
125}
126
127/// Format a dimension annotation + value, e.g. `(px)640` or `(pt)10.5`.
128pub(super) fn fmt_dimension(d: &Dimension) -> String {
129 d.to_kdl_string()
130}
131
132/// Format a `PropertyValue` as a KDL value.
133///
134/// - `TokenRef("color.bg")` → `(token)"color.bg"`
135/// - `Literal("center")` → `"center"`
136/// - `Dimension((px)24)` → `(px)24`
137pub(super) fn fmt_property_value(pv: &PropertyValue) -> String {
138 match pv {
139 PropertyValue::TokenRef(id) => format!("(token)\"{id}\""),
140 PropertyValue::Literal(s) => format!("\"{s}\""),
141 PropertyValue::Dimension(d) => fmt_dimension(d),
142 PropertyValue::DataRef(path) => format!("(data)\"{path}\""),
143 }
144}
145
146/// Emit `key=value` for a `PropertyValue` property (if present).
147pub(super) fn write_opt_property_value(out: &mut String, key: &str, opt: &Option<PropertyValue>) {
148 if let Some(pv) = opt {
149 out.push(' ');
150 out.push_str(key);
151 out.push('=');
152 out.push_str(&fmt_property_value(pv));
153 }
154}
155
156/// Emit `key=(unit)N` for an optional `Dimension`.
157pub(super) fn write_opt_dimension(out: &mut String, key: &str, opt: &Option<Dimension>) {
158 if let Some(d) = opt {
159 out.push(' ');
160 out.push_str(key);
161 out.push('=');
162 out.push_str(&fmt_dimension(d));
163 }
164}
165
166/// Emit `key="string"` for an optional string (quoted, no escaping).
167pub(super) fn write_opt_str(out: &mut String, key: &str, opt: &Option<String>) {
168 if let Some(s) = opt {
169 out.push(' ');
170 out.push_str(key);
171 out.push_str("=\"");
172 out.push_str(s);
173 out.push('"');
174 }
175}
176
177/// Emit `key="string"` for an optional string, running the value through
178/// [`escape_kdl_string`] so that backslashes, quotes, and whitespace control
179/// characters survive as a single-line KDL string.
180pub(super) fn write_opt_str_escaped(out: &mut String, key: &str, opt: &Option<String>) {
181 if let Some(s) = opt {
182 out.push(' ');
183 out.push_str(key);
184 out.push_str("=\"");
185 out.push_str(&escape_kdl_string(s));
186 out.push('"');
187 }
188}
189
190/// Emit `key=#true` or `key=#false` for an optional bool.
191pub(super) fn write_opt_bool(out: &mut String, key: &str, opt: &Option<bool>) {
192 if let Some(b) = opt {
193 out.push(' ');
194 out.push_str(key);
195 out.push('=');
196 out.push_str(if *b { "#true" } else { "#false" });
197 }
198}
199
200/// Emit `key="anchor"` (string) or `key=(pct)N` (annotated number) for an
201/// optional [`ObjectPosition`].
202pub(super) fn write_opt_object_position(out: &mut String, key: &str, opt: &Option<ObjectPosition>) {
203 if let Some(pos) = opt {
204 out.push(' ');
205 out.push_str(key);
206 out.push('=');
207 match pos {
208 ObjectPosition::Start => out.push_str("\"start\""),
209 ObjectPosition::Center => out.push_str("\"center\""),
210 ObjectPosition::End => out.push_str("\"end\""),
211 ObjectPosition::Pct(n) => {
212 out.push_str("(pct)");
213 out.push_str(&fmt_f64(*n));
214 }
215 }
216 }
217}
218
219/// Emit `key=N` for an optional `f64` (bare number, no unit).
220pub(super) fn write_opt_f64(out: &mut String, key: &str, opt: &Option<f64>) {
221 if let Some(v) = opt {
222 out.push(' ');
223 out.push_str(key);
224 out.push('=');
225 out.push_str(&fmt_f64(*v));
226 }
227}
228
229/// Escape a string for emission as a single-line KDL v2 quoted string.
230///
231/// Unlike the inline span/unknown-prop escapers (which only handle `\` and `"`),
232/// this also encodes the whitespace control characters `\n`, `\r`, and `\t` as
233/// backslash escapes so that a multi-line `code` blob survives as ONE physical
234/// line. All other characters pass through verbatim. This is the inverse of the
235/// `kdl` crate's decode on parse, guaranteeing a byte-exact content round-trip.
236pub(super) fn escape_kdl_string(s: &str) -> String {
237 let mut out = String::with_capacity(s.len() + 2);
238 for ch in s.chars() {
239 match ch {
240 '\\' => out.push_str("\\\\"),
241 '"' => out.push_str("\\\""),
242 '\n' => out.push_str("\\n"),
243 '\r' => out.push_str("\\r"),
244 '\t' => out.push_str("\\t"),
245 other => out.push(other),
246 }
247 }
248 out
249}
250
251// ---------------------------------------------------------------------------
252// Document
253// ---------------------------------------------------------------------------
254
255fn write_document(doc: &Document, out: &mut String) {
256 // `zenith version=1 {`
257 out.push_str("zenith version=");
258 // Writing to a String via fmt::Write is infallible; the Err variant is
259 // unreachable but we must handle it — discard rather than unwrap.
260 let _ = write!(out, "{}", doc.version);
261 // Optional export color space attribute, emitted right after version so the
262 // canonical form round-trips (parse → format → parse is byte-stable).
263 write_opt_str(out, "colorspace", &doc.colorspace);
264 // Optional stable document identity (ULID, Crockford base-32). Value is
265 // always safe to emit without escaping (no special characters). Emitted
266 // right after colorspace — grouped with version/colorspace as identity metadata.
267 write_opt_str(out, "doc-id", &doc.doc_id);
268 write_opt_bool(out, "mirror-margins", &doc.mirror_margins);
269 // Facing-pages and spread-gutter are emitted right after mirror-margins (the
270 // spread-layout metadata group). Both are omitted when None so a document
271 // without these attrs round-trips byte-identically.
272 write_opt_bool(out, "facing-pages", &doc.facing_pages);
273 write_opt_dimension(out, "spread-gutter", &doc.spread_gutter);
274 // Document-level default margins, grouped right after `mirror-margins` (the
275 // other margin doc attr). Canonical order: inner, outer, top, bottom — same
276 // order and spelling as on a page. Emitted only when set, so a document with
277 // no defaults round-trips byte-identically.
278 write_opt_dimension(out, "margin-inner", &doc.margin_inner);
279 write_opt_dimension(out, "margin-outer", &doc.margin_outer);
280 write_opt_dimension(out, "margin-top", &doc.margin_top);
281 write_opt_dimension(out, "margin-bottom", &doc.margin_bottom);
282 write_opt_str(out, "page-progression", &doc.page_progression);
283 write_opt_str(out, "page-parity-start", &doc.page_parity_start);
284 out.push_str(" {\n");
285
286 // Child order: diagnostics, brand, project, assets, libraries, tokens, styles, components, masters, sections, provenance, variants, recipes, actions, document.
287 // `diagnostics` is emitted first — right after the document-level attributes
288 // and before `tokens` — so the lint policy reads at the top of the file. It
289 // is omitted entirely when the policy is empty, so a document with no
290 // `diagnostics` block round-trips byte-identically.
291 write_diagnostics_block(&doc.diagnostic_policy, out, 1);
292 // `brand` is emitted right after `diagnostics` (both are structural metadata
293 // that live outside the token/style pipeline). Omitted when the contract is
294 // empty so a document with no `brand` block round-trips byte-identically.
295 write_brand_block(&doc.brand_contract, out, 1);
296 if let Some(proj) = &doc.project {
297 write_project(proj, out, 1);
298 }
299 write_asset_block(&doc.assets, out, 1);
300 write_library_block(&doc.libraries, out, 1);
301 write_token_block(&doc.tokens, out, 1);
302 write_style_block(&doc.styles, out, 1);
303 write_component_block(&doc.components, out, 1);
304 write_master_block(&doc.masters, out, 1);
305 write_section_block(&doc.sections, out, 1);
306 write_provenance_block(&doc.provenance, out, 1);
307 write_variants_block(&doc.variants, out, 1);
308 write_recipes_block(&doc.recipes, out, 1);
309 write_action_block(&doc.actions, out, 1);
310 write_document_body(&doc.body, out, 1);
311
312 out.push('}');
313}
314
315// ---------------------------------------------------------------------------
316// Diagnostics policy
317// ---------------------------------------------------------------------------
318
319/// Emit the `diagnostics { … }` block.
320///
321/// Stable position: first child of `zenith`, before `tokens`. Emitted ONLY when
322/// the policy has at least one entry, so documents without a policy keep their
323/// existing canonical form (and round-trip) byte-identically. Entry order is
324/// preserved (last-wins resolution is applied at consult time, not here). Each
325/// entry emits a single leaf line: `<verb> "<code>"`.
326fn write_diagnostics_block(policy: &DiagnosticPolicy, out: &mut String, depth: usize) {
327 if policy.entries.is_empty() {
328 return;
329 }
330 indent(out, depth);
331 out.push_str("diagnostics {\n");
332 for entry in &policy.entries {
333 indent(out, depth + 1);
334 let verb = match entry.verb {
335 PolicyVerb::Allow => "allow",
336 PolicyVerb::Deny => "deny",
337 PolicyVerb::Warn => "warn",
338 };
339 out.push_str(verb);
340 out.push_str(" \"");
341 out.push_str(&escape_kdl_string(&entry.code));
342 out.push_str("\"\n");
343 }
344 indent(out, depth);
345 out.push_str("}\n");
346}
347
348// ---------------------------------------------------------------------------
349// Brand contract
350// ---------------------------------------------------------------------------
351
352/// Emit the `brand { … }` block.
353///
354/// Stable position: right after `diagnostics`, before `project`. Emitted ONLY
355/// when the contract has at least one constrained category (`!is_empty()`), so
356/// documents without a brand contract keep their existing canonical form and
357/// round-trip byte-identically. Canonical child order: `colors`, `fonts`,
358/// `weights` (matching the declaration order in the KDL syntax docs).
359///
360/// Each value is emitted as a positional argument on its child node:
361/// `colors "#0b1f33" "#ffffff"`, `fonts "Noto Sans"`, `weights 400 700`.
362/// Absent categories are not emitted (a `None` field → no line).
363fn write_brand_block(contract: &BrandContract, out: &mut String, depth: usize) {
364 if contract.is_empty() {
365 return;
366 }
367 indent(out, depth);
368 out.push_str("brand {\n");
369
370 if let Some(colors) = &contract.allowed_colors {
371 indent(out, depth + 1);
372 out.push_str("colors");
373 for color in colors {
374 out.push_str(" \"");
375 out.push_str(color);
376 out.push('"');
377 }
378 out.push('\n');
379 }
380
381 if let Some(fonts) = &contract.allowed_fonts {
382 indent(out, depth + 1);
383 out.push_str("fonts");
384 for font in fonts {
385 out.push_str(" \"");
386 out.push_str(&escape_kdl_string(font));
387 out.push('"');
388 }
389 out.push('\n');
390 }
391
392 if let Some(weights) = &contract.allowed_weights {
393 indent(out, depth + 1);
394 out.push_str("weights");
395 for weight in weights {
396 out.push(' ');
397 let _ = write!(out, "{weight}");
398 }
399 out.push('\n');
400 }
401
402 indent(out, depth);
403 out.push_str("}\n");
404}
405
406// ---------------------------------------------------------------------------
407// Masters
408// ---------------------------------------------------------------------------
409
410/// Emit the `masters { … }` block.
411///
412/// Stable position: after `components`, before `document`. Emitted ONLY when at
413/// least one master is declared, so documents without masters keep their
414/// existing canonical form (and round-trip) unchanged. Each master emits
415/// `master id="…" { <child nodes> }`. Mirrors [`write_component_block`].
416fn write_master_block(masters: &[MasterDef], out: &mut String, depth: usize) {
417 if masters.is_empty() {
418 return;
419 }
420 indent(out, depth);
421 out.push_str("masters {\n");
422 for def in masters {
423 indent(out, depth + 1);
424 out.push_str("master id=\"");
425 out.push_str(&def.id);
426 out.push_str("\" {\n");
427 write_component_children(&def.children, out, depth + 1);
428 indent(out, depth + 1);
429 out.push_str("}\n");
430 }
431 indent(out, depth);
432 out.push_str("}\n");
433}
434
435// ---------------------------------------------------------------------------
436// Sections
437// ---------------------------------------------------------------------------
438
439/// Emit the `sections { … }` block.
440///
441/// Stable position: after `masters`, before `document`. Emitted ONLY when at
442/// least one section is declared, so documents without sections keep their
443/// existing canonical form (and round-trip) unchanged. Each section emits a
444/// single leaf line: `section id="…" name="…" folio-start=N folio-style="…"
445/// start-page="…"`. Optional attributes are omitted when `None`. Mirrors
446/// [`write_master_block`].
447fn write_section_block(sections: &[SectionDef], out: &mut String, depth: usize) {
448 if sections.is_empty() {
449 return;
450 }
451 indent(out, depth);
452 out.push_str("sections {\n");
453 for def in sections {
454 indent(out, depth + 1);
455 out.push_str("section id=\"");
456 out.push_str(&def.id);
457 out.push_str("\" name=\"");
458 out.push_str(&escape_kdl_string(&def.name));
459 out.push('"');
460 if let Some(fs) = def.folio_start {
461 out.push_str(" folio-start=");
462 // Writing to a String via fmt::Write is infallible; the Err variant
463 // is unreachable but we must handle it.
464 let _ = write!(out, "{fs}");
465 }
466 write_opt_str(out, "folio-style", &def.folio_style);
467 out.push_str(" start-page=\"");
468 out.push_str(&def.start_page);
469 out.push_str("\"\n");
470 }
471 indent(out, depth);
472 out.push_str("}\n");
473}
474
475// ---------------------------------------------------------------------------
476// Components
477// ---------------------------------------------------------------------------
478
479/// Emit the `components { … }` block.
480///
481/// Stable position: after `styles`, before `document`. The block is emitted ONLY
482/// when at least one component is declared, so documents without components keep
483/// their existing canonical form (and round-trip) unchanged. Each component emits
484/// `component id="…" { <child nodes> }`.
485fn write_component_block(components: &[ComponentDef], out: &mut String, depth: usize) {
486 if components.is_empty() {
487 return;
488 }
489 indent(out, depth);
490 out.push_str("components {\n");
491 for def in components {
492 indent(out, depth + 1);
493 out.push_str("component id=\"");
494 out.push_str(&def.id);
495 out.push_str("\" {\n");
496 write_component_children(&def.children, out, depth + 1);
497 indent(out, depth + 1);
498 out.push_str("}\n");
499 }
500 indent(out, depth);
501 out.push_str("}\n");
502}
503
504// ---------------------------------------------------------------------------
505// Project
506// ---------------------------------------------------------------------------
507
508fn write_project(proj: &Project, out: &mut String, depth: usize) {
509 indent(out, depth);
510 out.push_str("project");
511 // Canonical order: id, name
512 out.push_str(" id=\"");
513 out.push_str(&proj.id);
514 out.push('"');
515 out.push_str(" name=\"");
516 out.push_str(&proj.name);
517 out.push('"');
518 // author: if present, emit as a block child
519 if let Some(author) = &proj.author {
520 out.push_str(" {\n");
521 indent(out, depth + 1);
522 out.push_str("author \"");
523 out.push_str(author);
524 out.push_str("\"\n");
525 indent(out, depth);
526 out.push_str("}\n");
527 } else {
528 out.push('\n');
529 }
530}
531
532// ---------------------------------------------------------------------------
533// Assets
534// ---------------------------------------------------------------------------
535
536/// Emit the `assets { … }` block.
537///
538/// Mirrors `write_token_block`: always emits the block (even when empty),
539/// consistent with how `tokens` and `styles` always emit their brace blocks.
540fn write_asset_block(block: &AssetBlock, out: &mut String, depth: usize) {
541 indent(out, depth);
542 out.push_str("assets {\n");
543
544 for decl in &block.assets {
545 write_asset_decl(decl, out, depth + 1);
546 }
547
548 indent(out, depth);
549 out.push_str("}\n");
550}
551
552fn write_asset_decl(decl: &AssetDecl, out: &mut String, depth: usize) {
553 indent(out, depth);
554 out.push_str("asset");
555
556 // Canonical property order: id, kind, src, sha256, ai-* provenance fields
557 // (in the order below), then unknown_props (sorted).
558 out.push_str(" id=\"");
559 out.push_str(&decl.id);
560 out.push('"');
561
562 out.push_str(" kind=\"");
563 out.push_str(decl.kind.kind_str());
564 out.push('"');
565
566 out.push_str(" src=\"");
567 out.push_str(&decl.src);
568 out.push('"');
569
570 if let Some(sha256) = &decl.sha256 {
571 out.push_str(" sha256=\"");
572 out.push_str(sha256);
573 out.push('"');
574 }
575
576 // AI-generation provenance fields — all optional, emitted only when Some.
577 // Free-form string fields pass through escape_kdl_string so quotes and
578 // newlines (common in prompts) survive as single-line KDL strings.
579 write_opt_str_escaped(out, "ai-prompt", &decl.ai_prompt);
580 write_opt_str_escaped(out, "ai-model", &decl.ai_model);
581 write_opt_str_escaped(out, "ai-provider", &decl.ai_provider);
582 if let Some(seed) = decl.ai_seed {
583 out.push_str(" ai-seed=");
584 let _ = write!(out, "{seed}");
585 }
586 write_opt_str_escaped(out, "ai-generation-date", &decl.ai_generation_date);
587 write_opt_str_escaped(out, "ai-license", &decl.ai_license);
588 write_opt_str_escaped(out, "ai-source-rights", &decl.ai_source_rights);
589 write_opt_str_escaped(out, "ai-safety-status", &decl.ai_safety_status);
590 write_opt_str_escaped(out, "ai-reuse-policy", &decl.ai_reuse_policy);
591
592 // Unknown properties in sorted key order (BTreeMap iteration is sorted).
593 for (key, prop) in &decl.unknown_props {
594 out.push(' ');
595 out.push_str(key);
596 out.push('=');
597 out.push_str(&fmt_unknown_property(prop));
598 }
599
600 out.push('\n');
601}
602
603// ---------------------------------------------------------------------------
604// Libraries
605// ---------------------------------------------------------------------------
606
607/// Emit the `libraries { … }` block.
608///
609/// Stable position: after `assets`, before `tokens`. Emitted ONLY when at least
610/// one library is declared, so documents without imported packages keep their
611/// existing canonical form (and round-trip) unchanged. Each library emits a
612/// single leaf line: `library id="…" version="…" hash="…"`, with optional
613/// attributes omitted when `None`, then any unknown props in BTreeMap key order.
614/// Mirrors [`write_section_block`].
615fn write_library_block(libraries: &[LibraryDef], out: &mut String, depth: usize) {
616 if libraries.is_empty() {
617 return;
618 }
619 indent(out, depth);
620 out.push_str("libraries {\n");
621 for def in libraries {
622 indent(out, depth + 1);
623 out.push_str("library id=\"");
624 out.push_str(&def.id);
625 out.push('"');
626 if let Some(version) = &def.version {
627 out.push_str(" version=\"");
628 out.push_str(version);
629 out.push('"');
630 }
631 if let Some(hash) = &def.hash {
632 out.push_str(" hash=\"");
633 out.push_str(hash);
634 out.push('"');
635 }
636 // Unknown properties in sorted key order (BTreeMap iteration is sorted).
637 for (key, prop) in &def.unknown_props {
638 out.push(' ');
639 out.push_str(key);
640 out.push('=');
641 out.push_str(&fmt_unknown_property(prop));
642 }
643 out.push('\n');
644 }
645 indent(out, depth);
646 out.push_str("}\n");
647}
648
649// ---------------------------------------------------------------------------
650// Provenance
651// ---------------------------------------------------------------------------
652
653/// Emit the `provenance { … }` block.
654///
655/// Stable position: after `sections`, before `document`. Emitted ONLY when at
656/// least one origin record is declared, so documents without provenance keep
657/// their existing canonical form (and round-trip) unchanged. Each record emits a
658/// single leaf line: `origin id="…" node="…" library="…"`, then optional
659/// `item="…"` and `linked=#true`/`#false` when set, then any unknown props in
660/// BTreeMap key order. Mirrors [`write_library_block`].
661fn write_provenance_block(provenance: &[ProvenanceDef], out: &mut String, depth: usize) {
662 if provenance.is_empty() {
663 return;
664 }
665 indent(out, depth);
666 out.push_str("provenance {\n");
667 for def in provenance {
668 indent(out, depth + 1);
669 out.push_str("origin id=\"");
670 out.push_str(&def.id);
671 out.push_str("\" node=\"");
672 out.push_str(&def.node);
673 out.push_str("\" library=\"");
674 out.push_str(&def.library);
675 out.push('"');
676 if let Some(item) = &def.item {
677 out.push_str(" item=\"");
678 out.push_str(item);
679 out.push('"');
680 }
681 write_opt_bool(out, "linked", &def.linked);
682 // Unknown properties in sorted key order (BTreeMap iteration is sorted).
683 for (key, prop) in &def.unknown_props {
684 out.push(' ');
685 out.push_str(key);
686 out.push('=');
687 out.push_str(&fmt_unknown_property(prop));
688 }
689 out.push('\n');
690 }
691 indent(out, depth);
692 out.push_str("}\n");
693}
694
695// ---------------------------------------------------------------------------
696// Variants
697// ---------------------------------------------------------------------------
698
699/// Emit the `variants { … }` block.
700///
701/// Stable position: after `provenance`, before `actions`. Emitted ONLY when at
702/// least one variant is declared, so documents without variants keep their
703/// existing canonical form (and round-trip) unchanged. Each variant emits:
704///
705/// ```text
706/// variant id="…" source="…" w=(px)N h=(px)N {
707/// override node="…" visible=#false x=(px)N y=(px)N w=(px)N h=(px)N fill=… text="…"
708/// }
709/// ```
710///
711/// Optional override props (`visible`, `x`, `y`, `w`, `h`, `fill`, `text`) are omitted when `None`.
712/// Unknown props follow known ones in BTreeMap key order. Variants with no
713/// overrides still emit a brace block (consistent with other block nodes).
714/// Mirrors [`write_provenance_block`].
715fn write_variants_block(variants: &[VariantDef], out: &mut String, depth: usize) {
716 if variants.is_empty() {
717 return;
718 }
719 indent(out, depth);
720 out.push_str("variants {\n");
721 for def in variants {
722 indent(out, depth + 1);
723 out.push_str("variant id=\"");
724 out.push_str(&def.id);
725 out.push_str("\" source=\"");
726 out.push_str(&def.source);
727 out.push_str("\" w=");
728 out.push_str(&fmt_dimension(&def.w));
729 out.push_str(" h=");
730 out.push_str(&fmt_dimension(&def.h));
731 // Unknown props on the variant node itself, in sorted key order.
732 for (key, prop) in &def.unknown_props {
733 out.push(' ');
734 out.push_str(key);
735 out.push('=');
736 out.push_str(&fmt_unknown_property(prop));
737 }
738 out.push_str(" {\n");
739 for ov in &def.overrides {
740 indent(out, depth + 2);
741 out.push_str("override node=\"");
742 out.push_str(&ov.node);
743 out.push('"');
744 write_opt_bool(out, "visible", &ov.visible);
745 write_opt_dimension(out, "x", &ov.x);
746 write_opt_dimension(out, "y", &ov.y);
747 write_opt_dimension(out, "w", &ov.w);
748 write_opt_dimension(out, "h", &ov.h);
749 write_opt_property_value(out, "fill", &ov.fill);
750 write_opt_str_escaped(out, "text", &ov.text);
751 // Unknown props on the override node, in sorted key order.
752 for (key, prop) in &ov.unknown_props {
753 out.push(' ');
754 out.push_str(key);
755 out.push('=');
756 out.push_str(&fmt_unknown_property(prop));
757 }
758 out.push('\n');
759 }
760 indent(out, depth + 1);
761 out.push_str("}\n");
762 }
763 indent(out, depth);
764 out.push_str("}\n");
765}
766
767// ---------------------------------------------------------------------------
768// Recipes
769// ---------------------------------------------------------------------------
770
771/// Emit the `recipes { … }` block.
772///
773/// Stable position: after `variants`, before `actions`. Emitted ONLY when at
774/// least one recipe is declared, so documents without recipes keep their
775/// existing canonical form (and round-trip) unchanged. Each recipe emits:
776///
777/// ```text
778/// recipe id="…" kind="…" seed=N generator="…" bounds="…" detached=#false {
779/// param name="…" value=…
780/// palette token="…"
781/// expanded node="…"
782/// }
783/// ```
784///
785/// Optional props (`seed`, `generator`, `bounds`, `detached`) are omitted when
786/// `None`. Unknown props follow known ones in BTreeMap key order. Free-form
787/// string fields (`generator`, `bounds`) pass through the same `escape_kdl_string`
788/// guard as `variants` uses for `text`. Mirrors [`write_variants_block`].
789fn write_recipes_block(recipes: &[RecipeDef], out: &mut String, depth: usize) {
790 if recipes.is_empty() {
791 return;
792 }
793 indent(out, depth);
794 out.push_str("recipes {\n");
795 for def in recipes {
796 indent(out, depth + 1);
797 out.push_str("recipe id=\"");
798 out.push_str(&def.id);
799 out.push_str("\" kind=\"");
800 out.push_str(&escape_kdl_string(&def.kind));
801 out.push('"');
802 if let Some(seed) = def.seed {
803 out.push_str(" seed=");
804 let _ = write!(out, "{seed}");
805 }
806 if let Some(generator) = &def.generator {
807 out.push_str(" generator=\"");
808 out.push_str(&escape_kdl_string(generator));
809 out.push('"');
810 }
811 if let Some(bounds) = &def.bounds {
812 out.push_str(" bounds=\"");
813 out.push_str(&escape_kdl_string(bounds));
814 out.push('"');
815 }
816 write_opt_bool(out, "detached", &def.detached);
817 // Unknown props on the recipe node itself, in sorted key order.
818 for (key, prop) in &def.unknown_props {
819 out.push(' ');
820 out.push_str(key);
821 out.push('=');
822 out.push_str(&fmt_unknown_property(prop));
823 }
824 out.push_str(" {\n");
825 for param in &def.params {
826 write_recipe_param(param, out, depth + 2);
827 }
828 for token_id in &def.palette {
829 indent(out, depth + 2);
830 out.push_str("palette token=\"");
831 out.push_str(token_id);
832 out.push_str("\"\n");
833 }
834 for node_id in &def.expanded {
835 indent(out, depth + 2);
836 out.push_str("expanded node=\"");
837 out.push_str(node_id);
838 out.push_str("\"\n");
839 }
840 indent(out, depth + 1);
841 out.push_str("}\n");
842 }
843 indent(out, depth);
844 out.push_str("}\n");
845}
846
847fn write_recipe_param(param: &RecipeParam, out: &mut String, depth: usize) {
848 indent(out, depth);
849 out.push_str("param name=\"");
850 out.push_str(¶m.name);
851 out.push_str("\" value=");
852 out.push_str(&fmt_property_value(¶m.value));
853 // Unknown props on the param node, in sorted key order.
854 for (key, prop) in ¶m.unknown_props {
855 out.push(' ');
856 out.push_str(key);
857 out.push('=');
858 out.push_str(&fmt_unknown_property(prop));
859 }
860 out.push('\n');
861}
862
863// ---------------------------------------------------------------------------
864// Actions
865// ---------------------------------------------------------------------------
866
867/// Emit the `actions { … }` block.
868///
869/// Stable position: after `provenance`, before `document`. Emitted ONLY when at
870/// least one action is declared, so documents without actions keep their
871/// existing canonical form (and round-trip) unchanged. Each action emits:
872///
873/// ```text
874/// action id="…" label="…" version="…" {
875/// tx "…"
876/// }
877/// ```
878///
879/// Optional attributes are omitted when `None`. Unknown props follow known
880/// ones in BTreeMap key order. The `tx` payload is emitted as a single escaped
881/// string child node (same encoding as `content` in a `code` node), so
882/// characters that require escaping (`"`, `\`, `\n`, etc.) survive
883/// round-trips. Mirrors [`write_provenance_block`].
884fn write_action_block(actions: &[ActionDef], out: &mut String, depth: usize) {
885 if actions.is_empty() {
886 return;
887 }
888 indent(out, depth);
889 out.push_str("actions {\n");
890 for def in actions {
891 indent(out, depth + 1);
892 out.push_str("action id=\"");
893 out.push_str(&def.id);
894 out.push('"');
895 if let Some(label) = &def.label {
896 out.push_str(" label=\"");
897 out.push_str(&escape_kdl_string(label));
898 out.push('"');
899 }
900 if let Some(version) = &def.version {
901 out.push_str(" version=\"");
902 out.push_str(version);
903 out.push('"');
904 }
905 // Unknown properties in sorted key order (BTreeMap iteration is sorted).
906 for (key, prop) in &def.unknown_props {
907 out.push(' ');
908 out.push_str(key);
909 out.push('=');
910 out.push_str(&fmt_unknown_property(prop));
911 }
912 out.push_str(" {\n");
913 // Emit the tx payload as a single escaped-string child node. This
914 // mirrors how `code` nodes emit their `content` child: the JSON is
915 // stored decoded and re-encoded here so quotes and backslashes survive
916 // round-trips.
917 indent(out, depth + 2);
918 out.push_str("tx \"");
919 out.push_str(&escape_kdl_string(&def.tx_json));
920 out.push_str("\"\n");
921 indent(out, depth + 1);
922 out.push_str("}\n");
923 }
924 indent(out, depth);
925 out.push_str("}\n");
926}