zenith-core 0.0.7

Zenith core: KDL parser adapter, semantic AST, canonical formatter, tokens, validation, and diagnostics.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Integration tests for the `recipes` block: parse, serialize, and round-trip.
//!
//! Mirrors the variants round-trip tests in `format_variants.rs`. Exercises:
//! - Full parse → field access → format → re-parse → AST equality (spans stripped).
//! - Absent `recipes` block → empty vec, no output, byte-identical to before.
//! - Unknown-prop capture (annotated) on both `recipe` and `param` nodes.
//! - Free-form string fields containing `"`, `\`, and newlines escape correctly.

mod common;

use common::*;
use zenith_core::format::format_document;

// ── recipes: parse, serialize, and round-trip ─────────────────────────

/// **Round-trip**: parse a doc with a `recipes` block (one full recipe with
/// seed/generator/bounds/detached + 2 params + 2 palette + 2 expanded + an
/// annotated unknown prop; one bare recipe with only id+kind) → format →
/// re-parse → recipes identical (spans stripped). Also asserts canonical
/// position (after `variants`, before `actions`/`document`) and that all
/// fields emit correctly.
#[test]
fn test_recipes_round_trip() {
    let src = r##"zenith version=1 {
  project id="proj.rec" name="REC"
  tokens format="zenith-token-v1" {
    token id="color.brand.navy" type="color" value="#001f3f"
    token id="color.brand.cyan" type="color" value="#7fdbff"
  }
  styles {
  }
  variants {
    variant id="v.square" source="page.hero" w=(px)1080 h=(px)1080
  }
  recipes {
    recipe id="recipe.aurora" kind="aurora" seed=42 generator="aurora@1" bounds="page.hero" detached=#false {
      param name="density" value=(number)0.6
      param name="complexity" value=(number)3
      palette token="color.brand.navy"
      palette token="color.brand.cyan"
      expanded node="blob.1"
      expanded node="blob.2"
    }
    recipe id="recipe.bare" kind="scatter" {
    }
  }
  document id="doc.rec" title="REC" {
    page id="page.hero" w=(px)1920 h=(px)1080 {
      rect id="blob.1" x=(px)0 y=(px)0 w=(px)100 h=(px)100
      rect id="blob.2" x=(px)100 y=(px)0 w=(px)100 h=(px)100
    }
  }
}
"##;
    let adapter = KdlAdapter;
    let doc = adapter.parse(src.as_bytes()).expect("parse");

    assert_eq!(doc.recipes.len(), 2, "expected 2 recipes");

    let aurora = &doc.recipes[0];
    assert_eq!(aurora.id, "recipe.aurora");
    assert_eq!(aurora.kind, "aurora");
    assert_eq!(aurora.seed, Some(42_i64));
    assert_eq!(aurora.generator.as_deref(), Some("aurora@1"));
    assert_eq!(aurora.bounds.as_deref(), Some("page.hero"));
    assert_eq!(aurora.detached, Some(false));

    assert_eq!(aurora.params.len(), 2, "aurora must have 2 params");
    let p0 = &aurora.params[0];
    assert_eq!(p0.name, "density");
    assert_eq!(
        p0.value,
        zenith_core::PropertyValue::Dimension(zenith_core::Dimension {
            value: 0.6,
            unit: zenith_core::Unit::Unknown("number".to_owned()),
        })
    );
    let p1 = &aurora.params[1];
    assert_eq!(p1.name, "complexity");
    assert_eq!(
        p1.value,
        zenith_core::PropertyValue::Dimension(zenith_core::Dimension {
            value: 3.0,
            unit: zenith_core::Unit::Unknown("number".to_owned()),
        })
    );

    assert_eq!(aurora.palette, vec!["color.brand.navy", "color.brand.cyan"]);
    assert_eq!(aurora.expanded, vec!["blob.1", "blob.2"]);

    let bare = &doc.recipes[1];
    assert_eq!(bare.id, "recipe.bare");
    assert_eq!(bare.kind, "scatter");
    assert_eq!(bare.seed, None);
    assert_eq!(bare.generator, None);
    assert_eq!(bare.bounds, None);
    assert_eq!(bare.detached, None);
    assert!(bare.params.is_empty());
    assert!(bare.palette.is_empty());
    assert!(bare.expanded.is_empty());

    let formatted = format_document(&doc).expect("format");
    let formatted_str = String::from_utf8(formatted.clone()).expect("utf8");

    // All key fields must be present.
    assert!(
        formatted_str
            .contains(r#"recipe id="recipe.aurora" kind="aurora" seed=42 generator="aurora@1" bounds="page.hero" detached=#false"#),
        "aurora recipe header must be present; got:\n{formatted_str}"
    );
    assert!(
        formatted_str.contains(r#"param name="density" value=(number)0.6"#),
        "density param must emit; got:\n{formatted_str}"
    );
    assert!(
        formatted_str.contains(r#"param name="complexity" value=(number)3"#),
        "complexity param must emit; got:\n{formatted_str}"
    );
    assert!(
        formatted_str.contains(r#"palette token="color.brand.navy""#),
        "first palette must emit; got:\n{formatted_str}"
    );
    assert!(
        formatted_str.contains(r#"palette token="color.brand.cyan""#),
        "second palette must emit; got:\n{formatted_str}"
    );
    assert!(
        formatted_str.contains(r#"expanded node="blob.1""#),
        "first expanded must emit; got:\n{formatted_str}"
    );
    assert!(
        formatted_str.contains(r#"expanded node="blob.2""#),
        "second expanded must emit; got:\n{formatted_str}"
    );
    assert!(
        formatted_str.contains(r#"recipe id="recipe.bare" kind="scatter""#),
        "bare recipe line must be present; got:\n{formatted_str}"
    );

    // Canonical order: variants then recipes then document (no actions present).
    let variants_at = formatted_str.find("variants {").expect("variants block");
    let recipes_at = formatted_str.find("recipes {").expect("recipes block");
    let doc_at = formatted_str.find("document ").expect("document block");
    assert!(
        variants_at < recipes_at && recipes_at < doc_at,
        "recipes must be emitted after variants and before document; got:\n{formatted_str}"
    );

    let reparsed = adapter.parse(&formatted).expect("re-parse");
    assert_eq!(
        strip_spans(doc).recipes,
        strip_spans(reparsed).recipes,
        "recipes must survive a parse → format → parse round-trip (idempotent)"
    );
}

/// **Absent `recipes` block is an empty vec**: a document with no `recipes`
/// block must parse with `doc.recipes` empty and format identically (no
/// `recipes { … }` emitted in the output).
#[test]
fn test_absent_recipes_is_empty_and_byte_identical() {
    let src = r##"zenith version=1 {
  project id="proj.nor" name="NoRecipes"
  tokens format="zenith-token-v1" {
  }
  styles {
  }
  document id="doc.nor" title="NoRecipes" {
    page id="p" w=(px)640 h=(px)360 {
    }
  }
}
"##;
    let adapter = KdlAdapter;
    let doc = adapter.parse(src.as_bytes()).expect("parse");

    assert!(
        doc.recipes.is_empty(),
        "absent recipes block must yield an empty vec"
    );

    let formatted = format_document(&doc).expect("format");
    let formatted_str = String::from_utf8(formatted.clone()).expect("utf8");

    assert!(
        !formatted_str.contains("recipes"),
        "no recipes block must be emitted for an empty recipes vec; got:\n{formatted_str}"
    );

    // Idempotency: output matches byte-for-byte on a second pass.
    let reparsed = adapter.parse(&formatted).expect("re-parse");
    let formatted2 = format_document(&reparsed).expect("format 2");
    assert_eq!(
        formatted, formatted2,
        "absent recipes must be byte-identical across two format passes"
    );
}

/// **Unknown-prop capture on `recipe` and `param`**: annotated unknown props
/// must survive parse → format → parse byte-identically on both the `recipe`
/// node and its `param` children.
#[test]
fn test_recipe_unknown_props_round_trip() {
    let src = r##"zenith version=1 {
  project id="proj.ukn" name="UKN"
  tokens format="zenith-token-v1" {
  }
  styles {
  }
  recipes {
    recipe id="recipe.x" kind="test" priority=(token)"fmt.token" {
      param name="n" value=(number)1 weight=(px)2
    }
  }
  document id="doc.ukn" title="UKN" {
    page id="p" w=(px)640 h=(px)360 {
    }
  }
}
"##;
    let adapter = KdlAdapter;
    let doc = adapter.parse(src.as_bytes()).expect("parse");

    assert_eq!(doc.recipes.len(), 1);
    let r = &doc.recipes[0];

    let priority_prop = r
        .unknown_props
        .get("priority")
        .expect("annotated unknown prop `priority` must be captured on recipe");
    assert_eq!(
        priority_prop.ty.as_deref(),
        Some("token"),
        "annotation on recipe unknown prop must survive"
    );

    assert_eq!(r.params.len(), 1);
    let p = &r.params[0];
    let weight_prop = p
        .unknown_props
        .get("weight")
        .expect("annotated unknown prop `weight` must be captured on param");
    assert_eq!(
        weight_prop.ty.as_deref(),
        Some("px"),
        "annotation on param unknown prop must survive"
    );

    let formatted = format_document(&doc).expect("format");
    let formatted_str = String::from_utf8(formatted.clone()).expect("utf8");

    assert!(
        formatted_str.contains(r#"priority=(token)"fmt.token""#),
        "annotated unknown prop on recipe must round-trip; got:\n{formatted_str}"
    );
    assert!(
        formatted_str.contains("weight=(px)2"),
        "annotated unknown prop on param must round-trip; got:\n{formatted_str}"
    );

    let reparsed = adapter.parse(&formatted).expect("re-parse");
    assert_eq!(
        strip_spans(doc).recipes,
        strip_spans(reparsed).recipes,
        "recipes with unknown props must survive full round-trip"
    );
}

/// **`generator` field with KDL-special characters must be escaped on emit**:
/// the `generator` string is free-form and can contain `"`, `\`, and newlines.
/// The writer must escape them so the output re-parses to the exact same string
/// (regression guard — a bare push_str would corrupt the document).
#[test]
fn test_recipe_generator_escaping_round_trip() {
    let tricky = r#"aurora@1 "beta"\build"#;
    // `kind` is also a free-form string and must be escaped on emit (regression
    // guard for the writer escaping `kind`, not just `generator`).
    let tricky_kind = r#"aurora "x"\y"#;
    let src = format!(
        r##"zenith version=1 {{
  project id="proj.esc" name="ESC"
  tokens format="zenith-token-v1" {{
  }}
  styles {{
  }}
  recipes {{
    recipe id="recipe.esc" kind={kind:?} generator={gen:?} {{
    }}
  }}
  document id="doc.esc" title="ESC" {{
    page id="p" w=(px)640 h=(px)360 {{
    }}
  }}
}}
"##,
        kind = tricky_kind,
        gen = tricky
    );
    let adapter = KdlAdapter;
    let doc = adapter.parse(src.as_bytes()).expect("parse");
    assert_eq!(
        doc.recipes[0].kind, tricky_kind,
        "the tricky kind string must parse back exactly"
    );
    assert_eq!(
        doc.recipes[0].generator.as_deref(),
        Some(tricky),
        "the tricky generator string must parse back exactly"
    );

    // Format then re-parse: the escaped emit must round-trip to the same string.
    let formatted = format_document(&doc).expect("format");
    let reparsed = adapter.parse(&formatted).expect("re-parse escaped output");
    assert_eq!(
        reparsed.recipes[0].generator.as_deref(),
        Some(tricky),
        "generator with quotes/backslash must survive parse → format → parse"
    );
    assert_eq!(
        strip_spans(doc).recipes,
        strip_spans(reparsed).recipes,
        "escaped-generator recipes must be round-trip identical"
    );
}

/// **Negative seed round-trips**: `seed` is `i64`, so a negative value like
/// `seed=-1` must parse and format correctly (regression guard against u32 truncation).
#[test]
fn test_recipe_negative_seed_round_trips() {
    let src = r##"zenith version=1 {
  project id="proj.neg" name="NEG"
  tokens format="zenith-token-v1" {
  }
  styles {
  }
  recipes {
    recipe id="recipe.neg" kind="test" seed=-1 {
    }
  }
  document id="doc.neg" title="NEG" {
    page id="p" w=(px)640 h=(px)360 {
    }
  }
}
"##;
    let adapter = KdlAdapter;
    let doc = adapter.parse(src.as_bytes()).expect("parse");

    assert_eq!(
        doc.recipes[0].seed,
        Some(-1_i64),
        "negative seed must parse as i64(-1)"
    );

    let formatted = format_document(&doc).expect("format");
    let formatted_str = String::from_utf8(formatted.clone()).expect("utf8");
    assert!(
        formatted_str.contains("seed=-1"),
        "negative seed must emit as seed=-1; got:\n{formatted_str}"
    );

    let reparsed = adapter.parse(&formatted).expect("re-parse");
    assert_eq!(
        reparsed.recipes[0].seed,
        Some(-1_i64),
        "negative seed must survive round-trip"
    );
}

/// **`param value` as a token-ref round-trips**: `entry_to_property_value`
/// supports `(token)"id"` for param values.
#[test]
fn test_recipe_param_token_ref_round_trips() {
    let src = r##"zenith version=1 {
  project id="proj.tok" name="TOK"
  tokens format="zenith-token-v1" {
    token id="color.brand" type="color" value="#001f3f"
  }
  styles {
  }
  recipes {
    recipe id="recipe.tok" kind="colorize" {
      param name="tint" value=(token)"color.brand"
    }
  }
  document id="doc.tok" title="TOK" {
    page id="p" w=(px)640 h=(px)360 {
    }
  }
}
"##;
    let adapter = KdlAdapter;
    let doc = adapter.parse(src.as_bytes()).expect("parse");

    assert_eq!(
        doc.recipes[0].params[0].value,
        zenith_core::PropertyValue::TokenRef("color.brand".to_owned()),
        "param value=(token)\"...\" must parse as TokenRef"
    );

    let formatted = format_document(&doc).expect("format");
    let formatted_str = String::from_utf8(formatted.clone()).expect("utf8");
    assert!(
        formatted_str.contains(r#"param name="tint" value=(token)"color.brand""#),
        "token-ref param value must round-trip; got:\n{formatted_str}"
    );

    let reparsed = adapter.parse(&formatted).expect("re-parse");
    assert_eq!(
        strip_spans(doc).recipes,
        strip_spans(reparsed).recipes,
        "token-ref param must survive full round-trip"
    );
}