Skip to main content

zenith_core/parse/
kdl_adapter.rs

1//! `KdlAdapter` — the concrete implementation of `KdlSource` backed by the
2//! `kdl` 6.x crate.
3
4use crate::ast::Document;
5use crate::error::{FormatError, ParseError, ParseErrorCode};
6use crate::format::format_document;
7use crate::parse::transform;
8
9use super::KdlSource;
10
11/// Parses `.zen` source bytes into a `Document` AST using the KDL v2 parser.
12///
13/// This is the only struct in zenith-core that directly touches the `kdl` crate.
14/// All other code works with the Zenith AST types.
15#[derive(Debug, Clone, Default)]
16pub struct KdlAdapter;
17
18/// Converts a byte offset within `text` into a 1-based (line, column) pair.
19///
20/// Both line and column are counted in bytes, which matches the convention used
21/// by the `kdl` crate's `SourceSpan`. The offset is clamped to `text.len()` so
22/// no unchecked indexing can occur.
23fn line_col(text: &str, offset: usize) -> (usize, usize) {
24    let safe_offset = offset.min(text.len());
25    // Iterate over bytes up to safe_offset.  We only need to count '\n' bytes;
26    // the source is valid UTF-8 (guaranteed by Step 1) so byte-by-byte is safe.
27    let prefix = match text.get(..safe_offset) {
28        Some(s) => s,
29        // `safe_offset` is already clamped, so this branch is unreachable in
30        // practice, but we handle it gracefully rather than panicking.
31        None => text,
32    };
33    let mut line = 1usize;
34    let mut last_newline_byte = 0usize;
35    for (i, b) in prefix.bytes().enumerate() {
36        if b == b'\n' {
37            line += 1;
38            last_newline_byte = i + 1;
39        }
40    }
41    let col = safe_offset - last_newline_byte + 1;
42    (line, col)
43}
44
45impl KdlSource for KdlAdapter {
46    fn parse(&self, source: &[u8]) -> Result<Document, ParseError> {
47        // Step 1: validate UTF-8.
48        let text = std::str::from_utf8(source).map_err(|e| {
49            ParseError::spanless(
50                ParseErrorCode::NotUtf8,
51                format!("source is not valid UTF-8: {e}"),
52            )
53        })?;
54
55        // Step 2: parse KDL.
56        let kdl_doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| {
57            // Extract the first diagnostic span and rich message if available.
58            match e.diagnostics.first() {
59                Some(d) => {
60                    let offset = d.span.offset();
61                    let (line, col) = line_col(text, offset);
62                    let mut msg = format!("KDL parse error at line {line}, column {col}");
63                    match (&d.message, &d.help) {
64                        (Some(m), Some(h)) => {
65                            msg.push_str(": ");
66                            msg.push_str(m);
67                            msg.push_str(" (help: ");
68                            msg.push_str(h);
69                            msg.push(')');
70                        }
71                        (Some(m), None) => {
72                            msg.push_str(": ");
73                            msg.push_str(m);
74                        }
75                        (None, Some(h)) => {
76                            msg.push_str(" (help: ");
77                            msg.push_str(h);
78                            msg.push(')');
79                        }
80                        (None, None) => {
81                            // No per-diagnostic detail; fall back to the top-level
82                            // error so no information is lost.
83                            msg.push_str(": ");
84                            msg.push_str(&e.to_string());
85                        }
86                    }
87                    // KDL terminates a node at a bare newline, so attributes
88                    // split across lines without a `\` continuation are misparsed
89                    // and surface as an unclosed child block — pointing at the
90                    // `{`, not the real cause. The kdl crate exposes no error
91                    // kind, so key off its message and append a hint covering
92                    // both causes (the hint is correct either way).
93                    if let Some(m) = &d.message
94                        && m.contains("No closing")
95                        && m.contains("child block")
96                    {
97                        // Put the hint on its own line so it is not buried after
98                        // the raw kdl message — this is the most common real cause.
99                        msg.push_str(
100                            "\n  hint: a node and all its arguments must be on ONE line. If you \
101                             split a node's attributes across lines, end each line with `\\` to \
102                             continue it — otherwise a `{` is genuinely unclosed.",
103                        );
104                    }
105                    // KDL v2 booleans require a leading `#` (`#true` / `#false`).
106                    // A bare `true` or `false` is rejected as "Expected identifier
107                    // string" because those words are reserved and not valid
108                    // identifier strings. Detect this precisely by combining the
109                    // error message with the actual source text at the error span.
110                    if let Some(m) = &d.message
111                        && m.contains("Expected identifier string")
112                    {
113                        let span_start = d.span.offset();
114                        let span_end = (span_start + d.span.len()).min(text.len());
115                        let token_text = text.get(span_start..span_end).unwrap_or("");
116                        if token_text == "true" || token_text == "false" {
117                            msg.push_str(
118                                "\n  hint: KDL booleans are `#true` / `#false` (with a leading \
119                                 `#`). Did you write a bare `true`/`false`?",
120                            );
121                        }
122                    }
123                    let span = crate::ast::Span {
124                        start: offset,
125                        end: offset + d.span.len(),
126                    };
127                    ParseError::with_span(ParseErrorCode::InvalidKdl, span, msg)
128                }
129                None => ParseError::spanless(
130                    ParseErrorCode::InvalidKdl,
131                    format!("KDL parse error: {e}"),
132                ),
133            }
134        })?;
135
136        // Step 3: transform the KDL tree into the Zenith AST.
137        transform::transform(&kdl_doc)
138    }
139
140    fn format(&self, doc: &Document) -> Result<Vec<u8>, FormatError> {
141        format_document(doc)
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::ast::{Node, PropertyValue, TokenLiteral, TokenType, TokenValue, Unit};
149
150    /// Read the magnitude of a geometry `PropertyValue` that is a raw dimension
151    /// literal (the geometry axes accept either a `(px)N` literal or a token ref).
152    fn geom_value(pv: Option<&PropertyValue>) -> Option<f64> {
153        match pv {
154            Some(PropertyValue::Dimension(d)) => Some(d.value),
155            _ => None,
156        }
157    }
158
159    /// A minimal but realistic `.zen` document exercising the full v0 parse
160    /// surface: project, tokens (color + fontFamily + dimension + second color),
161    /// empty styles, document → page → rect + text.
162    const MINIMAL_DOC: &str = r##"zenith version=1 {
163  project id="proj.test" name="Test Project"
164
165  tokens format="zenith-token-v1" {
166    token id="color.bg" type="color" value="#f8fafc"
167    token id="font.family.body" type="fontFamily" value="Inter"
168    token id="size.title" type="dimension" value=(pt)48
169    token id="color.text" type="color" value="#111827"
170  }
171
172  styles {
173  }
174
175  document id="doc.test" title="Test Doc" {
176    page id="page.one" name="One" w=(px)640 h=(px)360 background=(token)"color.bg" {
177      rect id="bg.rect" x=(px)0 y=(px)0 w=(px)640 h=(px)360 fill=(token)"color.bg"
178      text id="label" x=(px)10 y=(px)10 w=(px)200 h=(px)50 align="center" fill=(token)"color.text" {
179        span "Hello Zenith"
180      }
181    }
182  }
183}
184"##;
185
186    #[test]
187    fn test_minimal_doc_parses() {
188        let adapter = KdlAdapter;
189        let doc = adapter
190            .parse(MINIMAL_DOC.as_bytes())
191            .expect("parse must succeed");
192
193        // Root version.
194        assert_eq!(doc.version, 1);
195
196        // Token count.
197        assert_eq!(doc.tokens.tokens.len(), 4);
198        assert_eq!(doc.tokens.format, "zenith-token-v1");
199
200        // First token: color literal.
201        let t0 = &doc.tokens.tokens[0];
202        assert_eq!(t0.id, "color.bg");
203        assert_eq!(t0.token_type, TokenType::Color);
204        match &t0.value {
205            TokenValue::Literal(TokenLiteral::String(s)) => assert_eq!(s, "#f8fafc"),
206            other => panic!("expected string literal, got {other:?}"),
207        }
208
209        // Second token: fontFamily.
210        let t1 = &doc.tokens.tokens[1];
211        assert_eq!(t1.id, "font.family.body");
212        assert_eq!(t1.token_type, TokenType::FontFamily);
213
214        // Third token: dimension.
215        let t2 = &doc.tokens.tokens[2];
216        assert_eq!(t2.id, "size.title");
217        assert_eq!(t2.token_type, TokenType::Dimension);
218        match &t2.value {
219            TokenValue::Literal(TokenLiteral::Dimension(d)) => {
220                assert_eq!(d.value, 48.0);
221                assert_eq!(d.unit, Unit::Pt);
222            }
223            other => panic!("expected dimension literal, got {other:?}"),
224        }
225
226        // Page dimensions.
227        assert_eq!(doc.body.pages.len(), 1);
228        let page = &doc.body.pages[0];
229        assert_eq!(page.width.value, 640.0);
230        assert_eq!(page.width.unit, Unit::Px);
231        assert_eq!(page.height.value, 360.0);
232        assert_eq!(page.height.unit, Unit::Px);
233
234        // Page has exactly 2 children.
235        assert_eq!(page.children.len(), 2);
236
237        // First child: rect with token fill.
238        match &page.children[0] {
239            Node::Rect(r) => {
240                assert_eq!(r.id, "bg.rect");
241                assert_eq!(geom_value(r.x.as_ref()), Some(0.0));
242                assert_eq!(geom_value(r.w.as_ref()), Some(640.0));
243                match &r.fill {
244                    Some(PropertyValue::TokenRef(tok)) => assert_eq!(tok, "color.bg"),
245                    other => panic!("expected token ref fill, got {other:?}"),
246                }
247            }
248            other => panic!("expected Rect, got {other:?}"),
249        }
250
251        // Second child: text with a span.
252        match &page.children[1] {
253            Node::Text(t) => {
254                assert_eq!(t.id, "label");
255                assert_eq!(t.align.as_deref(), Some("center"));
256                assert_eq!(t.spans.len(), 1);
257                assert_eq!(t.spans[0].text, "Hello Zenith");
258            }
259            other => panic!("expected Text, got {other:?}"),
260        }
261    }
262
263    /// A literal (non-token) visual dimension must parse into
264    /// `PropertyValue::Dimension`, preserving its numeric value and unit, rather
265    /// than being silently dropped to a `Literal` string.
266    #[test]
267    fn test_literal_visual_dimension_parses() {
268        use crate::ast::Dimension;
269        let src = r##"zenith version=1 {
270  project id="proj.dim" name="Dim"
271  tokens format="zenith-token-v1" {
272  }
273  styles {
274  }
275  document id="doc.dim" title="Dim" {
276    page id="page.one" w=(px)640 h=(px)360 {
277      text id="t" x=(px)0 y=(px)0 w=(px)200 h=(px)50 font-size=(px)24 {
278        span "Hi"
279      }
280      rect id="r" x=(px)0 y=(px)0 w=(px)10 h=(px)10 stroke-width=(pt)13
281    }
282  }
283}
284"##;
285        let adapter = KdlAdapter;
286        let doc = adapter.parse(src.as_bytes()).expect("parse must succeed");
287        let page = &doc.body.pages[0];
288
289        match &page.children[0] {
290            Node::Text(t) => assert_eq!(
291                t.font_size,
292                Some(PropertyValue::Dimension(Dimension {
293                    value: 24.0,
294                    unit: Unit::Px,
295                })),
296                "literal font-size=(px)24 must parse as a Dimension"
297            ),
298            other => panic!("expected Text, got {other:?}"),
299        }
300
301        match &page.children[1] {
302            Node::Rect(r) => assert_eq!(
303                r.stroke_width,
304                Some(PropertyValue::Dimension(Dimension {
305                    value: 13.0,
306                    unit: Unit::Pt,
307                })),
308                "literal stroke-width=(pt)13 must parse as a Dimension with Pt unit"
309            ),
310            other => panic!("expected Rect, got {other:?}"),
311        }
312    }
313
314    /// A text node with `font-weight=(token)"weight.bold"` must parse the
315    /// property into `font_weight = Some(TokenRef("weight.bold"))`.
316    #[test]
317    fn test_text_font_weight_token_parses() {
318        let src = r##"zenith version=1 {
319  project id="proj.fw" name="FW"
320  tokens format="zenith-token-v1" {
321    token id="weight.bold" type="fontWeight" value=700
322  }
323  styles {
324  }
325  document id="doc.fw" title="FW" {
326    page id="page.one" w=(px)640 h=(px)360 {
327      text id="t" x=(px)0 y=(px)0 w=(px)200 h=(px)50 font-weight=(token)"weight.bold" {
328        span "Bold"
329      }
330    }
331  }
332}
333"##;
334        let adapter = KdlAdapter;
335        let doc = adapter.parse(src.as_bytes()).expect("parse must succeed");
336        match &doc.body.pages[0].children[0] {
337            Node::Text(t) => assert_eq!(
338                t.font_weight,
339                Some(PropertyValue::TokenRef("weight.bold".to_owned())),
340                "font-weight token ref must parse into font_weight"
341            ),
342            other => panic!("expected Text, got {other:?}"),
343        }
344    }
345
346    /// An unknown node kind must parse into `Node::Unknown`, never error.
347    #[test]
348    fn test_unknown_node_kind_forward_compat() {
349        let src = r#"zenith version=1 {
350  project id="proj.fc" name="FC"
351  tokens format="zenith-token-v1" {
352  }
353  styles {
354  }
355  document id="doc.fc" title="FC" {
356    page id="page.fc" w=(px)100 h=(px)100 {
357      sparkle id="spark.one" magic=#true {}
358    }
359  }
360}
361"#;
362        let adapter = KdlAdapter;
363        let doc = adapter
364            .parse(src.as_bytes())
365            .expect("forward-compat unknown node must not error");
366        let page = &doc.body.pages[0];
367        assert_eq!(page.children.len(), 1);
368        match &page.children[0] {
369            Node::Unknown(u) => assert_eq!(u.kind, "sparkle"),
370            other => panic!("expected Unknown node, got {other:?}"),
371        }
372    }
373
374    /// An unknown property on a rect must land in `unknown_props`, not panic/error.
375    #[test]
376    fn test_unknown_property_preserved() {
377        let src = r#"zenith version=1 {
378  project id="proj.up" name="UP"
379  tokens format="zenith-token-v1" {
380  }
381  styles {
382  }
383  document id="doc.up" title="UP" {
384    page id="page.up" w=(px)100 h=(px)100 {
385      rect id="r.one" x=(px)0 y=(px)0 w=(px)10 h=(px)10 future-prop="hello"
386    }
387  }
388}
389"#;
390        let adapter = KdlAdapter;
391        let doc = adapter
392            .parse(src.as_bytes())
393            .expect("unknown property must not error");
394        match &doc.body.pages[0].children[0] {
395            Node::Rect(r) => {
396                assert!(
397                    r.unknown_props.contains_key("future-prop"),
398                    "unknown_props should contain future-prop; got: {:?}",
399                    r.unknown_props
400                );
401                // The value must be typed as a String, not flattened to some
402                // other variant — this is the forward-compat round-trip guarantee.
403                assert_eq!(
404                    r.unknown_props["future-prop"].value,
405                    crate::ast::UnknownValue::String("hello".to_owned()),
406                    "unknown string property must parse as UnknownValue::String"
407                );
408            }
409            other => panic!("expected Rect, got {other:?}"),
410        }
411    }
412
413    /// A `code` node carries its verbatim source as a `content` child whose
414    /// escapes (`\n`, `\t`, `\"`, `\\`) decode into the stored content blob.
415    #[test]
416    fn test_code_node_content_decoded() {
417        let src = r#"zenith version=1 {
418  project id="proj.code" name="C"
419  tokens format="zenith-token-v1" {
420  }
421  styles {
422  }
423  document id="doc.code" title="C" {
424    page id="page.code" w=(px)100 h=(px)100 {
425      code id="snippet" x=(px)8 y=(px)8 w=(px)80 h=(px)40 overflow="clip" language="rust" line-numbers=#false tab-width=4 {
426        content "fn main() {\n\tlet s = \"a\\\\b\";\n}"
427      }
428    }
429  }
430}
431"#;
432        let adapter = KdlAdapter;
433        let doc = adapter.parse(src.as_bytes()).expect("code node must parse");
434        match &doc.body.pages[0].children[0] {
435            Node::Code(c) => {
436                assert_eq!(c.id, "snippet");
437                assert_eq!(c.overflow.as_deref(), Some("clip"));
438                assert_eq!(c.language.as_deref(), Some("rust"));
439                assert_eq!(c.line_numbers, Some(false));
440                assert_eq!(c.tab_width, Some(4));
441                // Decoded content: literal newline, tab, quote, and backslash.
442                assert_eq!(c.content, "fn main() {\n\tlet s = \"a\\\\b\";\n}");
443            }
444            other => panic!("expected Code node, got {other:?}"),
445        }
446    }
447
448    /// Invalid UTF-8 bytes must yield `ParseErrorCode::NotUtf8`.
449    #[test]
450    fn test_invalid_utf8_error() {
451        let adapter = KdlAdapter;
452        let bad_bytes: &[u8] = &[0xff, 0xfe, 0x00];
453        let err = adapter
454            .parse(bad_bytes)
455            .expect_err("must fail on invalid UTF-8");
456        assert_eq!(
457            err.code,
458            crate::error::ParseErrorCode::NotUtf8,
459            "expected NotUtf8, got {:?}",
460            err.code
461        );
462    }
463
464    /// Malformed KDL must yield `ParseErrorCode::InvalidKdl`.
465    #[test]
466    fn test_malformed_kdl_error() {
467        let adapter = KdlAdapter;
468        let bad_kdl = b"this is {{{ not valid kdl at all!!!";
469        let err = adapter
470            .parse(bad_kdl)
471            .expect_err("must fail on malformed KDL");
472        assert_eq!(
473            err.code,
474            crate::error::ParseErrorCode::InvalidKdl,
475            "expected InvalidKdl, got {:?}",
476            err.code
477        );
478    }
479
480    /// A KDL parse error on a known line must produce a message that starts
481    /// with `"KDL parse error at line N, column M"`.
482    #[test]
483    fn test_malformed_kdl_error_message_contains_location() {
484        let adapter = KdlAdapter;
485        // Three lines of valid KDL then a syntax error on line 4.
486        let bad_kdl = b"foo\nbar\nbaz\n{{{ invalid";
487        let err = adapter
488            .parse(bad_kdl)
489            .expect_err("must fail on malformed KDL");
490        assert!(
491            err.message.starts_with("KDL parse error at line "),
492            "error message must start with location prefix; got: {:?}",
493            err.message
494        );
495    }
496
497    /// Attributes split across lines (no `\` continuation) misparse as an
498    /// unclosed child block; the error must carry the multi-line hint so the
499    /// author looks at the right cause, not the `{`.
500    #[test]
501    fn test_multiline_attributes_error_has_hint() {
502        let adapter = KdlAdapter;
503        let src = b"zenith version=1 {\n  document id=\"d\" title=\"t\" {\n    page id=\"p\" w=(px)100 h=(px)100 {\n      rect id=\"r\"\n        x=(px)10\n        y=(px)10 {\n      }\n    }\n  }\n}\n";
504        let err = adapter
505            .parse(src)
506            .expect_err("split attributes must fail to parse");
507        assert!(
508            err.message.contains("on ONE line") && err.message.contains('\\'),
509            "multi-line-attribute error must include the continuation hint; got: {:?}",
510            err.message
511        );
512    }
513
514    /// A bare `false` attribute (missing the KDL v2 `#` prefix) must produce a
515    /// parse error whose message contains the boolean hint.
516    #[test]
517    fn test_bare_bool_false_error_has_hint() {
518        let adapter = KdlAdapter;
519        // `visible=false` is invalid KDL v2 — the correct form is `visible=#false`.
520        let src = b"node visible=false";
521        let err = adapter
522            .parse(src)
523            .expect_err("bare `false` must fail to parse");
524        assert!(
525            err.message.contains("#true")
526                || err.message.contains("#false")
527                || err.message.contains("leading `#`"),
528            "bare-bool error must include the #true/#false hint; got: {:?}",
529            err.message
530        );
531    }
532
533    /// A bare `true` attribute must also trigger the boolean hint.
534    #[test]
535    fn test_bare_bool_true_error_has_hint() {
536        let adapter = KdlAdapter;
537        let src = b"node enabled=true";
538        let err = adapter
539            .parse(src)
540            .expect_err("bare `true` must fail to parse");
541        assert!(
542            err.message.contains("#true")
543                || err.message.contains("#false")
544                || err.message.contains("leading `#`"),
545            "bare-bool error must include the #true/#false hint; got: {:?}",
546            err.message
547        );
548    }
549
550    /// A correct `#false` must parse without error (no hint emitted).
551    #[test]
552    fn test_hash_false_parses_fine() {
553        // `#false` is valid KDL v2 keyword syntax; parsing a minimal document
554        // that uses it must succeed.
555        let src = r#"zenith version=1 {
556  project id="proj.hf" name="HF"
557  tokens format="zenith-token-v1" {
558  }
559  styles {
560  }
561  document id="doc.hf" title="HF" {
562    page id="page.hf" w=(px)100 h=(px)100 {
563      code id="c" x=(px)0 y=(px)0 w=(px)10 h=(px)10 line-numbers=#false tab-width=4 {
564        content "x"
565      }
566    }
567  }
568}"#;
569        let adapter = KdlAdapter;
570        adapter
571            .parse(src.as_bytes())
572            .expect("#false must parse successfully");
573    }
574
575    /// An unrelated parse error (a genuinely invalid token that is NOT a bare
576    /// bool) must NOT include the boolean hint.
577    #[test]
578    fn test_unrelated_error_no_bool_hint() {
579        let adapter = KdlAdapter;
580        // `:::` is simply invalid KDL — it has nothing to do with booleans.
581        let src = b":::";
582        let err = adapter.parse(src).expect_err("invalid KDL must fail");
583        // The hint must not appear for unrelated errors.
584        assert!(
585            !err.message.contains("leading `#`"),
586            "unrelated error must NOT contain the bool hint; got: {:?}",
587            err.message
588        );
589    }
590
591    // ── line_col helper ──────────────────────────────────────────────────────
592
593    #[test]
594    fn line_col_first_line() {
595        assert_eq!(line_col("hello world", 0), (1, 1));
596        assert_eq!(line_col("hello world", 5), (1, 6));
597    }
598
599    #[test]
600    fn line_col_second_line() {
601        // "foo\nbar" — offset 4 is 'b', line 2 col 1.
602        assert_eq!(line_col("foo\nbar", 4), (2, 1));
603        assert_eq!(line_col("foo\nbar", 6), (2, 3));
604    }
605
606    #[test]
607    fn line_col_clamps_past_end() {
608        let text = "ab";
609        // offset beyond length must not panic.
610        let (l, c) = line_col(text, 999);
611        assert_eq!(l, 1);
612        assert_eq!(c, 3); // clamped to text.len() = 2, col = 2 - 0 + 1 = 3
613    }
614
615    #[test]
616    fn line_col_empty_string() {
617        assert_eq!(line_col("", 0), (1, 1));
618        assert_eq!(line_col("", 5), (1, 1));
619    }
620
621    /// A gradient token (angle + 2 stops) parses into the expected AST shape:
622    /// `TokenType::Gradient` + `TokenLiteral::Gradient` with both stops in order.
623    #[test]
624    fn test_gradient_token_parses() {
625        let src = r##"zenith version=1 {
626  project id="proj.grad" name="Grad"
627  tokens format="zenith-token-v1" {
628    token id="color.navy.top" type="color" value="#001133"
629    token id="color.black.bottom" type="color" value="#000000"
630    token id="gradient.bg.hero" type="gradient" angle=(deg)90 {
631      stop offset=0.0 color=(token)"color.navy.top"
632      stop offset=1.0 color=(token)"color.black.bottom"
633    }
634  }
635  styles {
636  }
637  document id="doc.grad" title="Grad" {
638    page id="p" w=(px)100 h=(px)100 {
639    }
640  }
641}
642"##;
643        let adapter = KdlAdapter;
644        let doc = adapter.parse(src.as_bytes()).expect("parse must succeed");
645
646        let grad = doc
647            .tokens
648            .tokens
649            .iter()
650            .find(|t| t.id == "gradient.bg.hero")
651            .expect("gradient token present");
652        assert_eq!(grad.token_type, TokenType::Gradient);
653        match &grad.value {
654            TokenValue::Literal(TokenLiteral::Gradient(g)) => {
655                assert_eq!(g.angle_deg, 90.0);
656                assert_eq!(g.stops.len(), 2);
657                assert_eq!(g.stops[0].offset, 0.0);
658                assert_eq!(g.stops[0].color_token, "color.navy.top");
659                assert_eq!(g.stops[1].offset, 1.0);
660                assert_eq!(g.stops[1].color_token, "color.black.bottom");
661            }
662            other => panic!("expected gradient literal, got {other:?}"),
663        }
664    }
665
666    /// When `angle=` is absent the gradient defaults to 90 degrees.
667    #[test]
668    fn test_gradient_token_default_angle() {
669        let src = r##"zenith version=1 {
670  project id="proj.grad" name="Grad"
671  tokens format="zenith-token-v1" {
672    token id="color.a" type="color" value="#001133"
673    token id="color.b" type="color" value="#000000"
674    token id="gradient.bg" type="gradient" {
675      stop offset=0.0 color=(token)"color.a"
676      stop offset=1.0 color=(token)"color.b"
677    }
678  }
679  styles {
680  }
681  document id="doc.grad" title="Grad" {
682    page id="p" w=(px)100 h=(px)100 {
683    }
684  }
685}
686"##;
687        let adapter = KdlAdapter;
688        let doc = adapter.parse(src.as_bytes()).expect("parse must succeed");
689        let grad = doc
690            .tokens
691            .tokens
692            .iter()
693            .find(|t| t.id == "gradient.bg")
694            .expect("gradient token present");
695        match &grad.value {
696            TokenValue::Literal(TokenLiteral::Gradient(g)) => assert_eq!(g.angle_deg, 90.0),
697            other => panic!("expected gradient literal, got {other:?}"),
698        }
699    }
700
701    /// A shadow token (2 layers: drop shadow + outer glow) parses into the
702    /// expected AST shape, and a text node's `shadow=(token)"..."` prop parses
703    /// into a `TokenRef`.
704    #[test]
705    fn test_shadow_token_and_node_prop_parse() {
706        let src = r##"zenith version=1 {
707  project id="proj.shadow" name="Shadow"
708  tokens format="zenith-token-v1" {
709    token id="color.shadow.black" type="color" value="#000000"
710    token id="color.glow.cyan" type="color" value="#00ffff"
711    token id="shadow.headline" type="shadow" {
712      layer dx=(px)8 dy=(px)8 blur=(px)24 color=(token)"color.shadow.black"
713      layer dx=(px)0 dy=(px)0 blur=(px)20 color=(token)"color.glow.cyan"
714    }
715  }
716  styles {
717  }
718  document id="doc.shadow" title="Shadow" {
719    page id="p" w=(px)100 h=(px)100 {
720      text id="headline" x=(px)0 y=(px)0 w=(px)100 h=(px)40 shadow=(token)"shadow.headline" {
721        span "Hi"
722      }
723    }
724  }
725}
726"##;
727        let adapter = KdlAdapter;
728        let doc = adapter.parse(src.as_bytes()).expect("parse must succeed");
729
730        let shadow = doc
731            .tokens
732            .tokens
733            .iter()
734            .find(|t| t.id == "shadow.headline")
735            .expect("shadow token present");
736        assert_eq!(shadow.token_type, TokenType::Shadow);
737        match &shadow.value {
738            TokenValue::Literal(TokenLiteral::Shadow(s)) => {
739                assert_eq!(s.layers.len(), 2);
740                assert_eq!(s.layers[0].dx, 8.0);
741                assert_eq!(s.layers[0].dy, 8.0);
742                assert_eq!(s.layers[0].blur, 24.0);
743                assert_eq!(s.layers[0].color_token, "color.shadow.black");
744                assert_eq!(s.layers[1].dx, 0.0);
745                assert_eq!(s.layers[1].dy, 0.0);
746                assert_eq!(s.layers[1].blur, 20.0);
747                assert_eq!(s.layers[1].color_token, "color.glow.cyan");
748            }
749            other => panic!("expected shadow literal, got {other:?}"),
750        }
751
752        // The text node carries the shadow token ref.
753        let page = &doc.body.pages[0];
754        let text = page
755            .children
756            .iter()
757            .find_map(|n| match n {
758                Node::Text(t) if t.id == "headline" => Some(t),
759                _ => None,
760            })
761            .expect("headline text node present");
762        assert_eq!(
763            text.shadow,
764            Some(PropertyValue::TokenRef("shadow.headline".to_owned()))
765        );
766    }
767
768    // ── Toc node parse / round-trip ───────────────────────────────────────────
769
770    #[test]
771    fn toc_node_parses_fields_correctly() {
772        let src = r##"zenith version=1 {
773  project id="proj.toc" name="Toc"
774  tokens format="zenith-token-v1" {
775  }
776  styles {
777  }
778  document id="d" {
779    page id="p1" w=(px)595 h=(px)842 {
780      toc id="contents" match-role="heading" leader="." folio-style="decimal" \
781        x=(px)50 y=(px)100 w=(px)400 h=(px)300 style="body"
782    }
783  }
784}"##;
785        let doc = KdlAdapter
786            .parse(src.as_bytes())
787            .expect("parse must succeed");
788        let page = &doc.body.pages[0];
789        assert_eq!(page.children.len(), 1);
790        match &page.children[0] {
791            crate::ast::Node::Toc(t) => {
792                assert_eq!(t.id, "contents");
793                assert_eq!(t.match_role.as_deref(), Some("heading"));
794                assert_eq!(t.match_style, None);
795                assert_eq!(t.leader.as_deref(), Some("."));
796                assert_eq!(t.folio_style.as_deref(), Some("decimal"));
797                assert_eq!(geom_value(t.x.as_ref()), Some(50.0));
798                assert_eq!(geom_value(t.y.as_ref()), Some(100.0));
799                assert_eq!(geom_value(t.w.as_ref()), Some(400.0));
800                assert_eq!(geom_value(t.h.as_ref()), Some(300.0));
801                assert_eq!(t.style.as_deref(), Some("body"));
802            }
803            other => panic!("expected Toc, got {other:?}"),
804        }
805    }
806
807    #[test]
808    fn toc_node_round_trips_through_writer() {
809        let src = "zenith version=1 {\n  project id=\"proj.t\" name=\"T\"\n  tokens format=\"zenith-token-v1\" {\n  }\n  styles {\n  }\n  document id=\"d\" {\n    page id=\"p1\" w=(px)595 h=(px)842 {\n      toc id=\"toc.1\" match-role=\"heading\"\n    }\n  }\n}";
810        let doc = KdlAdapter.parse(src.as_bytes()).expect("first parse");
811        let formatted = format_document(&doc).expect("format");
812        let doc2 = KdlAdapter.parse(&formatted).expect("second parse");
813        // After round-trip, the toc node must still exist and have the same id.
814        match &doc2.body.pages[0].children[0] {
815            crate::ast::Node::Toc(t) => {
816                assert_eq!(t.id, "toc.1");
817                assert_eq!(t.match_role.as_deref(), Some("heading"));
818            }
819            other => panic!("expected Toc after round-trip, got {other:?}"),
820        }
821    }
822}