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>"`, followed by any scoped
326/// subject ids as positional strings.
327fn write_diagnostics_block(policy: &DiagnosticPolicy, out: &mut String, depth: usize) {
328 if policy.entries.is_empty() {
329 return;
330 }
331 indent(out, depth);
332 out.push_str("diagnostics {\n");
333 for entry in &policy.entries {
334 indent(out, depth + 1);
335 let verb = match entry.verb {
336 PolicyVerb::Allow => "allow",
337 PolicyVerb::Deny => "deny",
338 PolicyVerb::Warn => "warn",
339 };
340 out.push_str(verb);
341 out.push_str(" \"");
342 out.push_str(&escape_kdl_string(&entry.code));
343 out.push('"');
344 for subject in &entry.subjects {
345 out.push_str(" \"");
346 out.push_str(&escape_kdl_string(subject));
347 out.push('"');
348 }
349 out.push('\n');
350 }
351 indent(out, depth);
352 out.push_str("}\n");
353}
354
355// ---------------------------------------------------------------------------
356// Brand contract
357// ---------------------------------------------------------------------------
358
359/// Emit the `brand { … }` block.
360///
361/// Stable position: right after `diagnostics`, before `project`. Emitted ONLY
362/// when the contract has at least one constrained category (`!is_empty()`), so
363/// documents without a brand contract keep their existing canonical form and
364/// round-trip byte-identically. Canonical child order: `colors`, `fonts`,
365/// `weights` (matching the declaration order in the KDL syntax docs).
366///
367/// Each value is emitted as a positional argument on its child node:
368/// `colors "#0b1f33" "#ffffff"`, `fonts "Noto Sans"`, `weights 400 700`.
369/// Absent categories are not emitted (a `None` field → no line).
370fn write_brand_block(contract: &BrandContract, out: &mut String, depth: usize) {
371 if contract.is_empty() {
372 return;
373 }
374 indent(out, depth);
375 out.push_str("brand {\n");
376
377 if let Some(colors) = &contract.allowed_colors {
378 indent(out, depth + 1);
379 out.push_str("colors");
380 for color in colors {
381 out.push_str(" \"");
382 out.push_str(color);
383 out.push('"');
384 }
385 out.push('\n');
386 }
387
388 if let Some(fonts) = &contract.allowed_fonts {
389 indent(out, depth + 1);
390 out.push_str("fonts");
391 for font in fonts {
392 out.push_str(" \"");
393 out.push_str(&escape_kdl_string(font));
394 out.push('"');
395 }
396 out.push('\n');
397 }
398
399 if let Some(weights) = &contract.allowed_weights {
400 indent(out, depth + 1);
401 out.push_str("weights");
402 for weight in weights {
403 out.push(' ');
404 let _ = write!(out, "{weight}");
405 }
406 out.push('\n');
407 }
408
409 indent(out, depth);
410 out.push_str("}\n");
411}
412
413// ---------------------------------------------------------------------------
414// Masters
415// ---------------------------------------------------------------------------
416
417/// Emit the `masters { … }` block.
418///
419/// Stable position: after `components`, before `document`. Emitted ONLY when at
420/// least one master is declared, so documents without masters keep their
421/// existing canonical form (and round-trip) unchanged. Each master emits
422/// `master id="…" { <child nodes> }`. Mirrors [`write_component_block`].
423fn write_master_block(masters: &[MasterDef], out: &mut String, depth: usize) {
424 if masters.is_empty() {
425 return;
426 }
427 indent(out, depth);
428 out.push_str("masters {\n");
429 for def in masters {
430 indent(out, depth + 1);
431 out.push_str("master id=\"");
432 out.push_str(&def.id);
433 out.push_str("\" {\n");
434 write_component_children(&def.children, out, depth + 1);
435 indent(out, depth + 1);
436 out.push_str("}\n");
437 }
438 indent(out, depth);
439 out.push_str("}\n");
440}
441
442// ---------------------------------------------------------------------------
443// Sections
444// ---------------------------------------------------------------------------
445
446/// Emit the `sections { … }` block.
447///
448/// Stable position: after `masters`, before `document`. Emitted ONLY when at
449/// least one section is declared, so documents without sections keep their
450/// existing canonical form (and round-trip) unchanged. Each section emits a
451/// single leaf line: `section id="…" name="…" folio-start=N folio-style="…"
452/// start-page="…"`. Optional attributes are omitted when `None`. Mirrors
453/// [`write_master_block`].
454fn write_section_block(sections: &[SectionDef], out: &mut String, depth: usize) {
455 if sections.is_empty() {
456 return;
457 }
458 indent(out, depth);
459 out.push_str("sections {\n");
460 for def in sections {
461 indent(out, depth + 1);
462 out.push_str("section id=\"");
463 out.push_str(&def.id);
464 out.push_str("\" name=\"");
465 out.push_str(&escape_kdl_string(&def.name));
466 out.push('"');
467 if let Some(fs) = def.folio_start {
468 out.push_str(" folio-start=");
469 // Writing to a String via fmt::Write is infallible; the Err variant
470 // is unreachable but we must handle it.
471 let _ = write!(out, "{fs}");
472 }
473 write_opt_str(out, "folio-style", &def.folio_style);
474 out.push_str(" start-page=\"");
475 out.push_str(&def.start_page);
476 out.push_str("\"\n");
477 }
478 indent(out, depth);
479 out.push_str("}\n");
480}
481
482// ---------------------------------------------------------------------------
483// Components
484// ---------------------------------------------------------------------------
485
486/// Emit the `components { … }` block.
487///
488/// Stable position: after `styles`, before `document`. The block is emitted ONLY
489/// when at least one component is declared, so documents without components keep
490/// their existing canonical form (and round-trip) unchanged. Each component emits
491/// `component id="…" { <child nodes> }`.
492fn write_component_block(components: &[ComponentDef], out: &mut String, depth: usize) {
493 if components.is_empty() {
494 return;
495 }
496 indent(out, depth);
497 out.push_str("components {\n");
498 for def in components {
499 indent(out, depth + 1);
500 out.push_str("component id=\"");
501 out.push_str(&def.id);
502 out.push_str("\" {\n");
503 write_component_children(&def.children, out, depth + 1);
504 indent(out, depth + 1);
505 out.push_str("}\n");
506 }
507 indent(out, depth);
508 out.push_str("}\n");
509}
510
511// ---------------------------------------------------------------------------
512// Project
513// ---------------------------------------------------------------------------
514
515fn write_project(proj: &Project, out: &mut String, depth: usize) {
516 indent(out, depth);
517 out.push_str("project");
518 // Canonical order: id, name
519 out.push_str(" id=\"");
520 out.push_str(&proj.id);
521 out.push('"');
522 out.push_str(" name=\"");
523 out.push_str(&proj.name);
524 out.push('"');
525 // author: if present, emit as a block child
526 if let Some(author) = &proj.author {
527 out.push_str(" {\n");
528 indent(out, depth + 1);
529 out.push_str("author \"");
530 out.push_str(author);
531 out.push_str("\"\n");
532 indent(out, depth);
533 out.push_str("}\n");
534 } else {
535 out.push('\n');
536 }
537}
538
539// ---------------------------------------------------------------------------
540// Assets
541// ---------------------------------------------------------------------------
542
543/// Emit the `assets { … }` block.
544///
545/// Mirrors `write_token_block`: always emits the block (even when empty),
546/// consistent with how `tokens` and `styles` always emit their brace blocks.
547fn write_asset_block(block: &AssetBlock, out: &mut String, depth: usize) {
548 indent(out, depth);
549 out.push_str("assets {\n");
550
551 for decl in &block.assets {
552 write_asset_decl(decl, out, depth + 1);
553 }
554
555 indent(out, depth);
556 out.push_str("}\n");
557}
558
559fn write_asset_decl(decl: &AssetDecl, out: &mut String, depth: usize) {
560 indent(out, depth);
561 out.push_str("asset");
562
563 // Canonical property order: id, kind, src, sha256, ai-* provenance fields
564 // (in the order below), then unknown_props (sorted).
565 out.push_str(" id=\"");
566 out.push_str(&decl.id);
567 out.push('"');
568
569 out.push_str(" kind=\"");
570 out.push_str(decl.kind.kind_str());
571 out.push('"');
572
573 out.push_str(" src=\"");
574 out.push_str(&decl.src);
575 out.push('"');
576
577 if let Some(sha256) = &decl.sha256 {
578 out.push_str(" sha256=\"");
579 out.push_str(sha256);
580 out.push('"');
581 }
582
583 // AI-generation provenance fields — all optional, emitted only when Some.
584 // Free-form string fields pass through escape_kdl_string so quotes and
585 // newlines (common in prompts) survive as single-line KDL strings.
586 write_opt_str_escaped(out, "ai-prompt", &decl.ai_prompt);
587 write_opt_str_escaped(out, "ai-model", &decl.ai_model);
588 write_opt_str_escaped(out, "ai-provider", &decl.ai_provider);
589 if let Some(seed) = decl.ai_seed {
590 out.push_str(" ai-seed=");
591 let _ = write!(out, "{seed}");
592 }
593 write_opt_str_escaped(out, "ai-generation-date", &decl.ai_generation_date);
594 write_opt_str_escaped(out, "ai-license", &decl.ai_license);
595 write_opt_str_escaped(out, "ai-source-rights", &decl.ai_source_rights);
596 write_opt_str_escaped(out, "ai-safety-status", &decl.ai_safety_status);
597 write_opt_str_escaped(out, "ai-reuse-policy", &decl.ai_reuse_policy);
598
599 // Unknown properties in sorted key order (BTreeMap iteration is sorted).
600 for (key, prop) in &decl.unknown_props {
601 out.push(' ');
602 out.push_str(key);
603 out.push('=');
604 out.push_str(&fmt_unknown_property(prop));
605 }
606
607 out.push('\n');
608}
609
610// ---------------------------------------------------------------------------
611// Libraries
612// ---------------------------------------------------------------------------
613
614/// Emit the `libraries { … }` block.
615///
616/// Stable position: after `assets`, before `tokens`. Emitted ONLY when at least
617/// one library is declared, so documents without imported packages keep their
618/// existing canonical form (and round-trip) unchanged. Each library emits a
619/// single leaf line: `library id="…" version="…" hash="…"`, with optional
620/// attributes omitted when `None`, then any unknown props in BTreeMap key order.
621/// Mirrors [`write_section_block`].
622fn write_library_block(libraries: &[LibraryDef], out: &mut String, depth: usize) {
623 if libraries.is_empty() {
624 return;
625 }
626 indent(out, depth);
627 out.push_str("libraries {\n");
628 for def in libraries {
629 indent(out, depth + 1);
630 out.push_str("library id=\"");
631 out.push_str(&def.id);
632 out.push('"');
633 if let Some(version) = &def.version {
634 out.push_str(" version=\"");
635 out.push_str(version);
636 out.push('"');
637 }
638 if let Some(hash) = &def.hash {
639 out.push_str(" hash=\"");
640 out.push_str(hash);
641 out.push('"');
642 }
643 // Unknown properties in sorted key order (BTreeMap iteration is sorted).
644 for (key, prop) in &def.unknown_props {
645 out.push(' ');
646 out.push_str(key);
647 out.push('=');
648 out.push_str(&fmt_unknown_property(prop));
649 }
650 out.push('\n');
651 }
652 indent(out, depth);
653 out.push_str("}\n");
654}
655
656// ---------------------------------------------------------------------------
657// Provenance
658// ---------------------------------------------------------------------------
659
660/// Emit the `provenance { … }` block.
661///
662/// Stable position: after `sections`, before `document`. Emitted ONLY when at
663/// least one origin record is declared, so documents without provenance keep
664/// their existing canonical form (and round-trip) unchanged. Each record emits a
665/// single leaf line: `origin id="…" node="…" library="…"`, then optional
666/// `item="…"` and `linked=#true`/`#false` when set, then any unknown props in
667/// BTreeMap key order. Mirrors [`write_library_block`].
668fn write_provenance_block(provenance: &[ProvenanceDef], out: &mut String, depth: usize) {
669 if provenance.is_empty() {
670 return;
671 }
672 indent(out, depth);
673 out.push_str("provenance {\n");
674 for def in provenance {
675 indent(out, depth + 1);
676 out.push_str("origin id=\"");
677 out.push_str(&def.id);
678 out.push_str("\" node=\"");
679 out.push_str(&def.node);
680 out.push_str("\" library=\"");
681 out.push_str(&def.library);
682 out.push('"');
683 if let Some(item) = &def.item {
684 out.push_str(" item=\"");
685 out.push_str(item);
686 out.push('"');
687 }
688 write_opt_bool(out, "linked", &def.linked);
689 // Unknown properties in sorted key order (BTreeMap iteration is sorted).
690 for (key, prop) in &def.unknown_props {
691 out.push(' ');
692 out.push_str(key);
693 out.push('=');
694 out.push_str(&fmt_unknown_property(prop));
695 }
696 out.push('\n');
697 }
698 indent(out, depth);
699 out.push_str("}\n");
700}
701
702// ---------------------------------------------------------------------------
703// Variants
704// ---------------------------------------------------------------------------
705
706/// Emit the `variants { … }` block.
707///
708/// Stable position: after `provenance`, before `actions`. Emitted ONLY when at
709/// least one variant is declared, so documents without variants keep their
710/// existing canonical form (and round-trip) unchanged. Each variant emits:
711///
712/// ```text
713/// variant id="…" source="…" w=(px)N h=(px)N {
714/// override node="…" visible=#false x=(px)N y=(px)N w=(px)N h=(px)N fill=… text="…"
715/// }
716/// ```
717///
718/// Optional override props (`visible`, `x`, `y`, `w`, `h`, `fill`, `text`) are omitted when `None`.
719/// Unknown props follow known ones in BTreeMap key order. Variants with no
720/// overrides still emit a brace block (consistent with other block nodes).
721/// Mirrors [`write_provenance_block`].
722fn write_variants_block(variants: &[VariantDef], out: &mut String, depth: usize) {
723 if variants.is_empty() {
724 return;
725 }
726 indent(out, depth);
727 out.push_str("variants {\n");
728 for def in variants {
729 indent(out, depth + 1);
730 out.push_str("variant id=\"");
731 out.push_str(&def.id);
732 out.push_str("\" source=\"");
733 out.push_str(&def.source);
734 out.push_str("\" w=");
735 out.push_str(&fmt_dimension(&def.w));
736 out.push_str(" h=");
737 out.push_str(&fmt_dimension(&def.h));
738 // Unknown props on the variant node itself, in sorted key order.
739 for (key, prop) in &def.unknown_props {
740 out.push(' ');
741 out.push_str(key);
742 out.push('=');
743 out.push_str(&fmt_unknown_property(prop));
744 }
745 out.push_str(" {\n");
746 for ov in &def.overrides {
747 indent(out, depth + 2);
748 out.push_str("override node=\"");
749 out.push_str(&ov.node);
750 out.push('"');
751 write_opt_bool(out, "visible", &ov.visible);
752 write_opt_dimension(out, "x", &ov.x);
753 write_opt_dimension(out, "y", &ov.y);
754 write_opt_dimension(out, "w", &ov.w);
755 write_opt_dimension(out, "h", &ov.h);
756 write_opt_property_value(out, "fill", &ov.fill);
757 write_opt_str_escaped(out, "text", &ov.text);
758 // Unknown props on the override node, in sorted key order.
759 for (key, prop) in &ov.unknown_props {
760 out.push(' ');
761 out.push_str(key);
762 out.push('=');
763 out.push_str(&fmt_unknown_property(prop));
764 }
765 out.push('\n');
766 }
767 indent(out, depth + 1);
768 out.push_str("}\n");
769 }
770 indent(out, depth);
771 out.push_str("}\n");
772}
773
774// ---------------------------------------------------------------------------
775// Recipes
776// ---------------------------------------------------------------------------
777
778/// Emit the `recipes { … }` block.
779///
780/// Stable position: after `variants`, before `actions`. Emitted ONLY when at
781/// least one recipe is declared, so documents without recipes keep their
782/// existing canonical form (and round-trip) unchanged. Each recipe emits:
783///
784/// ```text
785/// recipe id="…" kind="…" seed=N generator="…" bounds="…" detached=#false {
786/// param name="…" value=…
787/// palette token="…"
788/// expanded node="…"
789/// }
790/// ```
791///
792/// Optional props (`seed`, `generator`, `bounds`, `detached`) are omitted when
793/// `None`. Unknown props follow known ones in BTreeMap key order. Free-form
794/// string fields (`generator`, `bounds`) pass through the same `escape_kdl_string`
795/// guard as `variants` uses for `text`. Mirrors [`write_variants_block`].
796fn write_recipes_block(recipes: &[RecipeDef], out: &mut String, depth: usize) {
797 if recipes.is_empty() {
798 return;
799 }
800 indent(out, depth);
801 out.push_str("recipes {\n");
802 for def in recipes {
803 indent(out, depth + 1);
804 out.push_str("recipe id=\"");
805 out.push_str(&def.id);
806 out.push_str("\" kind=\"");
807 out.push_str(&escape_kdl_string(&def.kind));
808 out.push('"');
809 if let Some(seed) = def.seed {
810 out.push_str(" seed=");
811 let _ = write!(out, "{seed}");
812 }
813 if let Some(generator) = &def.generator {
814 out.push_str(" generator=\"");
815 out.push_str(&escape_kdl_string(generator));
816 out.push('"');
817 }
818 if let Some(bounds) = &def.bounds {
819 out.push_str(" bounds=\"");
820 out.push_str(&escape_kdl_string(bounds));
821 out.push('"');
822 }
823 write_opt_bool(out, "detached", &def.detached);
824 // Unknown props on the recipe node itself, in sorted key order.
825 for (key, prop) in &def.unknown_props {
826 out.push(' ');
827 out.push_str(key);
828 out.push('=');
829 out.push_str(&fmt_unknown_property(prop));
830 }
831 out.push_str(" {\n");
832 for param in &def.params {
833 write_recipe_param(param, out, depth + 2);
834 }
835 for token_id in &def.palette {
836 indent(out, depth + 2);
837 out.push_str("palette token=\"");
838 out.push_str(token_id);
839 out.push_str("\"\n");
840 }
841 for node_id in &def.expanded {
842 indent(out, depth + 2);
843 out.push_str("expanded node=\"");
844 out.push_str(node_id);
845 out.push_str("\"\n");
846 }
847 indent(out, depth + 1);
848 out.push_str("}\n");
849 }
850 indent(out, depth);
851 out.push_str("}\n");
852}
853
854fn write_recipe_param(param: &RecipeParam, out: &mut String, depth: usize) {
855 indent(out, depth);
856 out.push_str("param name=\"");
857 out.push_str(¶m.name);
858 out.push_str("\" value=");
859 out.push_str(&fmt_property_value(¶m.value));
860 // Unknown props on the param node, in sorted key order.
861 for (key, prop) in ¶m.unknown_props {
862 out.push(' ');
863 out.push_str(key);
864 out.push('=');
865 out.push_str(&fmt_unknown_property(prop));
866 }
867 out.push('\n');
868}
869
870// ---------------------------------------------------------------------------
871// Actions
872// ---------------------------------------------------------------------------
873
874/// Emit the `actions { … }` block.
875///
876/// Stable position: after `provenance`, before `document`. Emitted ONLY when at
877/// least one action is declared, so documents without actions keep their
878/// existing canonical form (and round-trip) unchanged. Each action emits:
879///
880/// ```text
881/// action id="…" label="…" version="…" {
882/// tx "…"
883/// }
884/// ```
885///
886/// Optional attributes are omitted when `None`. Unknown props follow known
887/// ones in BTreeMap key order. The `tx` payload is emitted as a single escaped
888/// string child node (same encoding as `content` in a `code` node), so
889/// characters that require escaping (`"`, `\`, `\n`, etc.) survive
890/// round-trips. Mirrors [`write_provenance_block`].
891fn write_action_block(actions: &[ActionDef], out: &mut String, depth: usize) {
892 if actions.is_empty() {
893 return;
894 }
895 indent(out, depth);
896 out.push_str("actions {\n");
897 for def in actions {
898 indent(out, depth + 1);
899 out.push_str("action id=\"");
900 out.push_str(&def.id);
901 out.push('"');
902 if let Some(label) = &def.label {
903 out.push_str(" label=\"");
904 out.push_str(&escape_kdl_string(label));
905 out.push('"');
906 }
907 if let Some(version) = &def.version {
908 out.push_str(" version=\"");
909 out.push_str(version);
910 out.push('"');
911 }
912 // Unknown properties in sorted key order (BTreeMap iteration is sorted).
913 for (key, prop) in &def.unknown_props {
914 out.push(' ');
915 out.push_str(key);
916 out.push('=');
917 out.push_str(&fmt_unknown_property(prop));
918 }
919 out.push_str(" {\n");
920 // Emit the tx payload as a single escaped-string child node. This
921 // mirrors how `code` nodes emit their `content` child: the JSON is
922 // stored decoded and re-encoded here so quotes and backslashes survive
923 // round-trips.
924 indent(out, depth + 2);
925 out.push_str("tx \"");
926 out.push_str(&escape_kdl_string(&def.tx_json));
927 out.push_str("\"\n");
928 indent(out, depth + 1);
929 out.push_str("}\n");
930 }
931 indent(out, depth);
932 out.push_str("}\n");
933}