1use crate::ast::Document;
5use crate::error::{FormatError, ParseError, ParseErrorCode};
6use crate::format::format_document;
7use crate::parse::transform;
8
9use super::KdlSource;
10
11#[derive(Debug, Clone, Default)]
16pub struct KdlAdapter;
17
18fn line_col(text: &str, offset: usize) -> (usize, usize) {
24 let safe_offset = offset.min(text.len());
25 let prefix = match text.get(..safe_offset) {
28 Some(s) => s,
29 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 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 let kdl_doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| {
57 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 msg.push_str(": ");
84 msg.push_str(&e.to_string());
85 }
86 }
87 if let Some(m) = &d.message
94 && m.contains("No closing")
95 && m.contains("child block")
96 {
97 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 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 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 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 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 assert_eq!(doc.version, 1);
195
196 assert_eq!(doc.tokens.tokens.len(), 4);
198 assert_eq!(doc.tokens.format, "zenith-token-v1");
199
200 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 let t1 = &doc.tokens.tokens[1];
211 assert_eq!(t1.id, "font.family.body");
212 assert_eq!(t1.token_type, TokenType::FontFamily);
213
214 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 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 assert_eq!(page.children.len(), 2);
236
237 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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[test]
483 fn test_malformed_kdl_error_message_contains_location() {
484 let adapter = KdlAdapter;
485 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 #[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 #[test]
517 fn test_bare_bool_false_error_has_hint() {
518 let adapter = KdlAdapter;
519 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 #[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 #[test]
552 fn test_hash_false_parses_fine() {
553 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 #[test]
578 fn test_unrelated_error_no_bool_hint() {
579 let adapter = KdlAdapter;
580 let src = b":::";
582 let err = adapter.parse(src).expect_err("invalid KDL must fail");
583 assert!(
585 !err.message.contains("leading `#`"),
586 "unrelated error must NOT contain the bool hint; got: {:?}",
587 err.message
588 );
589 }
590
591 #[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 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 let (l, c) = line_col(text, 999);
611 assert_eq!(l, 1);
612 assert_eq!(c, 3); }
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 #[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 #[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 #[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 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 #[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 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}