1use std::collections::BTreeMap;
12
13use zenith_core::{
14 Dimension, FrameNode, GroupNode, KdlAdapter, KdlSource, Node, Page, PropertyValue,
15 ResolvedToken, ResolvedValue, Unit, resolve_tokens,
16};
17
18use crate::commands::serialize_pretty;
19use crate::json_types::RecipeInspectJson;
20
21use super::recipes;
22
23#[derive(Debug)]
27pub struct InspectCmdErr {
28 pub message: String,
30 pub exit_code: u8,
32}
33
34impl InspectCmdErr {
35 fn new(msg: impl Into<String>, exit_code: u8) -> Self {
36 Self {
37 message: msg.into(),
38 exit_code,
39 }
40 }
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
48pub struct NodeGeometry {
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub x: Option<f64>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub y: Option<f64>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub w: Option<f64>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub h: Option<f64>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub x1: Option<f64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub y1: Option<f64>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub x2: Option<f64>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub y2: Option<f64>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub point_count: Option<usize>,
76}
77
78#[derive(Debug, Clone, serde::Serialize)]
80pub struct NodeEntry {
81 pub id: String,
82 pub kind: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
87 pub role: Option<String>,
88 pub geometry: Option<NodeGeometry>,
89 pub visible: Option<bool>,
90 pub locked: Option<bool>,
91 pub children: Vec<NodeEntry>,
92}
93
94type Resolved = BTreeMap<String, ResolvedToken>;
97
98#[derive(Debug, Clone, serde::Serialize)]
100pub struct PageEntry {
101 pub id: String,
102 pub name: Option<String>,
103 pub width: f64,
104 pub height: f64,
105 pub children: Vec<NodeEntry>,
106}
107
108#[derive(Debug, serde::Serialize)]
110pub struct InspectOutput {
111 pub schema: &'static str,
112 pub pages: Vec<PageEntry>,
113 pub recipes: Vec<RecipeInspectJson>,
115}
116
117#[derive(Debug, serde::Serialize)]
119pub struct InspectNodeOutput {
120 pub schema: &'static str,
121 pub node: NodeEntry,
122}
123
124pub fn run(src: &str, node_id: Option<&str>, json: bool) -> Result<String, InspectCmdErr> {
135 let doc = KdlAdapter
137 .parse(src.as_bytes())
138 .map_err(|e| InspectCmdErr::new(format!("error[parse.error]: {}", e.message), 2))?;
139
140 let resolved = resolve_tokens(&doc.tokens).resolved;
141
142 if let Some(id) = node_id {
143 let entry = find_node_tree(&doc.body.pages, id, &resolved)
145 .ok_or_else(|| InspectCmdErr::new(format!("error: node '{}' not found", id), 2))?;
146
147 let out = if json {
148 let output = InspectNodeOutput {
149 schema: "zenith-inspect-v1",
150 node: entry,
151 };
152 serialize_pretty(&output)
153 } else {
154 render_node_human(&entry, 0).trim_end().to_owned()
155 };
156 Ok(out)
157 } else {
158 let pages = build_doc_tree(&doc.body.pages, &resolved);
160
161 let out = if json {
162 let recipe_entries = recipes::build_recipe_entries(&doc.recipes);
163 let output = InspectOutput {
164 schema: "zenith-inspect-v1",
165 pages,
166 recipes: recipe_entries,
167 };
168 serialize_pretty(&output)
169 } else {
170 let mut text = render_pages_human(&pages);
171 let recipe_section = recipes::render_recipes_human(&doc.recipes);
172 if !recipe_section.is_empty() {
173 text.push('\n');
174 text.push('\n');
175 text.push_str(&recipe_section);
176 }
177 text
178 };
179 Ok(out)
180 }
181}
182
183pub fn summary(
198 src: &str,
199 node: Option<&str>,
200 depth: usize,
201 detail: bool,
202) -> Result<serde_json::Value, InspectCmdErr> {
203 let doc = KdlAdapter
204 .parse(src.as_bytes())
205 .map_err(|e| InspectCmdErr::new(format!("error[parse.error]: {}", e.message), 2))?;
206
207 let resolved = resolve_tokens(&doc.tokens).resolved;
208
209 if let Some(id) = node {
210 let entry = find_node_tree(&doc.body.pages, id, &resolved)
211 .ok_or_else(|| InspectCmdErr::new(format!("error: node '{id}' not found"), 2))?;
212 Ok(serde_json::json!({
213 "schema": "zenith-inspect-summary-v1",
214 "node": trim_node(&entry, depth, detail),
215 }))
216 } else {
217 let pages = build_doc_tree(&doc.body.pages, &resolved);
218 let page_values: Vec<serde_json::Value> =
219 pages.iter().map(|p| trim_page(p, depth, detail)).collect();
220 Ok(serde_json::json!({
221 "schema": "zenith-inspect-summary-v1",
222 "pages": page_values,
223 "recipe_count": doc.recipes.len(),
224 }))
225 }
226}
227
228fn trim_page(p: &PageEntry, depth: usize, detail: bool) -> serde_json::Value {
230 let mut obj = serde_json::Map::new();
231 obj.insert("id".into(), p.id.clone().into());
232 if let Some(name) = &p.name {
233 obj.insert("name".into(), name.clone().into());
234 }
235 obj.insert("width".into(), p.width.into());
236 obj.insert("height".into(), p.height.into());
237 insert_children(&mut obj, &p.children, depth, detail);
238 serde_json::Value::Object(obj)
239}
240
241fn trim_node(n: &NodeEntry, depth: usize, detail: bool) -> serde_json::Value {
243 let mut obj = serde_json::Map::new();
244 obj.insert("id".into(), n.id.clone().into());
245 obj.insert("kind".into(), n.kind.clone().into());
246 if detail {
247 if let Some(role) = &n.role {
248 obj.insert("role".into(), role.clone().into());
249 }
250 if let Some(g) = &n.geometry {
251 obj.insert(
252 "geometry".into(),
253 serde_json::to_value(g).unwrap_or(serde_json::Value::Null),
254 );
255 }
256 if let Some(v) = n.visible {
257 obj.insert("visible".into(), v.into());
258 }
259 if let Some(l) = n.locked {
260 obj.insert("locked".into(), l.into());
261 }
262 }
263 insert_children(&mut obj, &n.children, depth, detail);
264 serde_json::Value::Object(obj)
265}
266
267fn insert_children(
270 obj: &mut serde_json::Map<String, serde_json::Value>,
271 children: &[NodeEntry],
272 depth: usize,
273 detail: bool,
274) {
275 if children.is_empty() {
276 return;
277 }
278 if depth == 0 {
279 obj.insert("child_count".into(), children.len().into());
280 } else {
281 let kids: Vec<serde_json::Value> = children
282 .iter()
283 .map(|c| trim_node(c, depth - 1, detail))
284 .collect();
285 obj.insert("children".into(), serde_json::Value::Array(kids));
286 }
287}
288
289pub fn build_doc_tree(pages: &[Page], resolved: &Resolved) -> Vec<PageEntry> {
296 pages
297 .iter()
298 .map(|p| build_page_entry(p, resolved))
299 .collect()
300}
301
302fn build_page_entry(page: &Page, resolved: &Resolved) -> PageEntry {
303 PageEntry {
304 id: page.id.clone(),
305 name: page.name.clone(),
306 width: dim_to_f64(&page.width),
307 height: dim_to_f64(&page.height),
308 children: page
309 .children
310 .iter()
311 .map(|n| build_node_entry(n, resolved))
312 .collect(),
313 }
314}
315
316fn build_node_entry(node: &Node, resolved: &Resolved) -> NodeEntry {
317 match node {
318 Node::Rect(n) => NodeEntry {
319 id: n.id.clone(),
320 kind: "rect".into(),
321 role: n.role.clone(),
322 geometry: bbox_geom(
323 n.x.as_ref(),
324 n.y.as_ref(),
325 n.w.as_ref(),
326 n.h.as_ref(),
327 resolved,
328 ),
329 visible: n.visible,
330 locked: n.locked,
331 children: vec![],
332 },
333 Node::Ellipse(n) => NodeEntry {
334 id: n.id.clone(),
335 kind: "ellipse".into(),
336 role: n.role.clone(),
337 geometry: bbox_geom(
338 n.x.as_ref(),
339 n.y.as_ref(),
340 n.w.as_ref(),
341 n.h.as_ref(),
342 resolved,
343 ),
344 visible: n.visible,
345 locked: n.locked,
346 children: vec![],
347 },
348 Node::Line(n) => NodeEntry {
349 id: n.id.clone(),
350 kind: "line".into(),
351 role: n.role.clone(),
352 geometry: Some(NodeGeometry {
353 x: None,
354 y: None,
355 w: None,
356 h: None,
357 x1: n.x1.as_ref().map(dim_to_f64),
358 y1: n.y1.as_ref().map(dim_to_f64),
359 x2: n.x2.as_ref().map(dim_to_f64),
360 y2: n.y2.as_ref().map(dim_to_f64),
361 point_count: None,
362 }),
363 visible: n.visible,
364 locked: n.locked,
365 children: vec![],
366 },
367 Node::Text(n) => NodeEntry {
368 id: n.id.clone(),
369 kind: "text".into(),
370 role: n.role.clone(),
371 geometry: bbox_geom(
372 n.x.as_ref(),
373 n.y.as_ref(),
374 n.w.as_ref(),
375 n.h.as_ref(),
376 resolved,
377 ),
378 visible: n.visible,
379 locked: n.locked,
380 children: vec![],
381 },
382 Node::Code(n) => NodeEntry {
383 id: n.id.clone(),
384 kind: "code".into(),
385 role: n.role.clone(),
386 geometry: bbox_geom(
387 n.x.as_ref(),
388 n.y.as_ref(),
389 n.w.as_ref(),
390 n.h.as_ref(),
391 resolved,
392 ),
393 visible: n.visible,
394 locked: n.locked,
395 children: vec![],
396 },
397 Node::Image(n) => NodeEntry {
398 id: n.id.clone(),
399 kind: "image".into(),
400 role: n.role.clone(),
401 geometry: bbox_geom(
402 n.x.as_ref(),
403 n.y.as_ref(),
404 n.w.as_ref(),
405 n.h.as_ref(),
406 resolved,
407 ),
408 visible: n.visible,
409 locked: n.locked,
410 children: vec![],
411 },
412 Node::Frame(n) => NodeEntry {
413 id: n.id.clone(),
414 kind: "frame".into(),
415 role: n.role.clone(),
416 geometry: bbox_geom(
417 n.x.as_ref(),
418 n.y.as_ref(),
419 n.w.as_ref(),
420 n.h.as_ref(),
421 resolved,
422 ),
423 visible: n.visible,
424 locked: n.locked,
425 children: n
426 .children
427 .iter()
428 .map(|c| build_node_entry(c, resolved))
429 .collect(),
430 },
431 Node::Group(n) => NodeEntry {
432 id: n.id.clone(),
433 kind: "group".into(),
434 role: n.role.clone(),
435 geometry: bbox_geom(
436 n.x.as_ref(),
437 n.y.as_ref(),
438 n.w.as_ref(),
439 n.h.as_ref(),
440 resolved,
441 ),
442 visible: n.visible,
443 locked: n.locked,
444 children: n
445 .children
446 .iter()
447 .map(|c| build_node_entry(c, resolved))
448 .collect(),
449 },
450 Node::Polygon(n) => NodeEntry {
451 id: n.id.clone(),
452 kind: "polygon".into(),
453 role: n.role.clone(),
454 geometry: Some(NodeGeometry {
455 x: None,
456 y: None,
457 w: None,
458 h: None,
459 x1: None,
460 y1: None,
461 x2: None,
462 y2: None,
463 point_count: Some(n.points.len()),
464 }),
465 visible: n.visible,
466 locked: n.locked,
467 children: vec![],
468 },
469 Node::Polyline(n) => NodeEntry {
470 id: n.id.clone(),
471 kind: "polyline".into(),
472 role: n.role.clone(),
473 geometry: Some(NodeGeometry {
474 x: None,
475 y: None,
476 w: None,
477 h: None,
478 x1: None,
479 y1: None,
480 x2: None,
481 y2: None,
482 point_count: Some(n.points.len()),
483 }),
484 visible: n.visible,
485 locked: n.locked,
486 children: vec![],
487 },
488 Node::Instance(n) => NodeEntry {
489 id: n.id.clone(),
490 kind: "instance".into(),
491 role: n.role.clone(),
492 geometry: Some(NodeGeometry {
495 x: opt_dim_to_f64(n.x.as_ref()),
496 y: opt_dim_to_f64(n.y.as_ref()),
497 w: None,
498 h: None,
499 x1: None,
500 y1: None,
501 x2: None,
502 y2: None,
503 point_count: None,
504 }),
505 visible: n.visible,
506 locked: n.locked,
507 children: vec![],
508 },
509 Node::Field(n) => NodeEntry {
510 id: n.id.clone(),
511 kind: "field".into(),
512 role: n.role.clone(),
513 geometry: bbox_geom(
516 n.x.as_ref(),
517 n.y.as_ref(),
518 n.w.as_ref(),
519 n.h.as_ref(),
520 resolved,
521 ),
522 visible: n.visible,
523 locked: n.locked,
524 children: vec![],
525 },
526 Node::Toc(n) => NodeEntry {
527 id: n.id.clone(),
528 kind: "toc".into(),
529 role: n.role.clone(),
530 geometry: bbox_geom(
533 n.x.as_ref(),
534 n.y.as_ref(),
535 n.w.as_ref(),
536 n.h.as_ref(),
537 resolved,
538 ),
539 visible: n.visible,
540 locked: n.locked,
541 children: vec![],
542 },
543 Node::Footnote(n) => NodeEntry {
544 id: n.id.clone(),
545 kind: "footnote".into(),
546 role: n.role.clone(),
547 geometry: None,
550 visible: None,
551 locked: None,
552 children: vec![],
553 },
554 Node::Table(n) => NodeEntry {
555 id: n.id.clone(),
556 kind: "table".into(),
557 role: n.role.clone(),
558 geometry: bbox_geom(
559 n.x.as_ref(),
560 n.y.as_ref(),
561 n.w.as_ref(),
562 n.h.as_ref(),
563 resolved,
564 ),
565 visible: n.visible,
566 locked: n.locked,
567 children: n
570 .rows
571 .iter()
572 .flat_map(|row| row.cells.iter())
573 .flat_map(|cell| cell.children.iter())
574 .map(|c| build_node_entry(c, resolved))
575 .collect(),
576 },
577 Node::Shape(n) => NodeEntry {
578 id: n.id.clone(),
579 kind: "shape".into(),
580 role: n.role.clone(),
581 geometry: bbox_geom(
582 n.x.as_ref(),
583 n.y.as_ref(),
584 n.w.as_ref(),
585 n.h.as_ref(),
586 resolved,
587 ),
588 visible: n.visible,
589 locked: n.locked,
590 children: vec![],
593 },
594 Node::Connector(n) => NodeEntry {
595 id: n.id.clone(),
596 kind: "connector".into(),
597 role: n.role.clone(),
598 geometry: None,
601 visible: n.visible,
602 locked: n.locked,
603 children: vec![],
604 },
605 Node::Pattern(n) => NodeEntry {
606 id: n.id.clone(),
607 kind: "pattern".into(),
608 role: n.role.clone(),
609 geometry: bbox_geom(
610 n.x.as_ref(),
611 n.y.as_ref(),
612 n.w.as_ref(),
613 n.h.as_ref(),
614 resolved,
615 ),
616 visible: n.visible,
617 locked: n.locked,
618 children: vec![],
619 },
620 Node::Chart(n) => NodeEntry {
621 id: n.id.clone(),
622 kind: "chart".into(),
623 role: n.role.clone(),
624 geometry: bbox_geom(
625 n.x.as_ref(),
626 n.y.as_ref(),
627 n.w.as_ref(),
628 n.h.as_ref(),
629 resolved,
630 ),
631 visible: n.visible,
632 locked: n.locked,
633 children: vec![],
634 },
635 Node::Unknown(n) => NodeEntry {
636 id: n.id.clone().unwrap_or_default(),
637 kind: n.kind.clone(),
638 role: None,
640 geometry: None,
641 visible: None,
642 locked: None,
643 children: n
644 .children
645 .iter()
646 .map(|c| build_node_entry(c, resolved))
647 .collect(),
648 },
649 }
650}
651
652pub fn find_node_tree(pages: &[Page], id: &str, resolved: &Resolved) -> Option<NodeEntry> {
657 for page in pages {
658 if let Some(entry) = search_nodes(&page.children, id, resolved) {
659 return Some(entry);
660 }
661 }
662 None
663}
664
665fn search_nodes(nodes: &[Node], id: &str, resolved: &Resolved) -> Option<NodeEntry> {
666 for node in nodes {
667 let node_id = node_id_str(node);
669 if node_id == id {
670 return Some(build_node_entry(node, resolved));
671 }
672 if let Some(children) = node_children(node)
674 && let Some(found) = search_nodes(children, id, resolved)
675 {
676 return Some(found);
677 }
678 if let Node::Table(t) = node {
680 for row in &t.rows {
681 for cell in &row.cells {
682 if let Some(found) = search_nodes(&cell.children, id, resolved) {
683 return Some(found);
684 }
685 }
686 }
687 }
688 }
689 None
690}
691
692fn node_id_str(node: &Node) -> &str {
694 match node {
695 Node::Rect(n) => &n.id,
696 Node::Ellipse(n) => &n.id,
697 Node::Line(n) => &n.id,
698 Node::Text(n) => &n.id,
699 Node::Code(n) => &n.id,
700 Node::Frame(n) => &n.id,
701 Node::Group(n) => &n.id,
702 Node::Image(n) => &n.id,
703 Node::Polygon(n) => &n.id,
704 Node::Polyline(n) => &n.id,
705 Node::Instance(n) => &n.id,
706 Node::Field(n) => &n.id,
707 Node::Toc(n) => &n.id,
708 Node::Footnote(n) => &n.id,
709 Node::Table(n) => &n.id,
710 Node::Shape(n) => &n.id,
711 Node::Connector(n) => &n.id,
712 Node::Pattern(n) => &n.id,
713 Node::Chart(n) => &n.id,
714 Node::Unknown(n) => n.id.as_deref().unwrap_or(""),
715 }
716}
717
718fn node_children(node: &Node) -> Option<&[Node]> {
721 match node {
722 Node::Frame(FrameNode { children, .. }) | Node::Group(GroupNode { children, .. }) => {
723 Some(children)
724 }
725 Node::Unknown(n) => Some(&n.children),
726 Node::Rect(_)
727 | Node::Ellipse(_)
728 | Node::Line(_)
729 | Node::Text(_)
730 | Node::Code(_)
731 | Node::Image(_)
732 | Node::Polygon(_)
733 | Node::Polyline(_)
734 | Node::Instance(_)
735 | Node::Field(_)
736 | Node::Footnote(_)
737 | Node::Toc(_)
738 | Node::Table(_)
739 | Node::Shape(_)
740 | Node::Connector(_)
741 | Node::Pattern(_)
742 | Node::Chart(_) => None,
743 }
744}
745
746fn dim_to_f64(d: &Dimension) -> f64 {
749 match d.unit {
750 Unit::Pt => d.value * 96.0 / 72.0,
751 Unit::Px | Unit::Pct | Unit::Deg | Unit::Unknown(_) => d.value,
752 }
753}
754
755fn opt_dim_to_f64(d: Option<&Dimension>) -> Option<f64> {
756 d.map(dim_to_f64)
757}
758
759fn opt_pv_to_f64(pv: Option<&PropertyValue>, resolved: &Resolved) -> Option<f64> {
765 match pv? {
766 PropertyValue::Dimension(d) => Some(dim_to_f64(d)),
767 PropertyValue::TokenRef(id) => match resolved.get(id).map(|t| &t.value) {
768 Some(ResolvedValue::Dimension(d)) => Some(dim_to_f64(d)),
769 _ => None,
770 },
771 PropertyValue::Literal(_) | PropertyValue::DataRef(_) => None,
772 }
773}
774
775fn bbox_geom(
776 x: Option<&PropertyValue>,
777 y: Option<&PropertyValue>,
778 w: Option<&PropertyValue>,
779 h: Option<&PropertyValue>,
780 resolved: &Resolved,
781) -> Option<NodeGeometry> {
782 Some(NodeGeometry {
783 x: opt_pv_to_f64(x, resolved),
784 y: opt_pv_to_f64(y, resolved),
785 w: opt_pv_to_f64(w, resolved),
786 h: opt_pv_to_f64(h, resolved),
787 x1: None,
788 y1: None,
789 x2: None,
790 y2: None,
791 point_count: None,
792 })
793}
794
795fn render_pages_human(pages: &[PageEntry]) -> String {
798 let mut out = String::new();
799 for page in pages {
800 let name_part = page
801 .name
802 .as_deref()
803 .map(|n| format!(" \"{}\"", n))
804 .unwrap_or_default();
805 out.push_str(&format!(
806 "page {}{} ({}x{})\n",
807 page.id, name_part, page.width, page.height
808 ));
809 for child in &page.children {
810 out.push_str(&render_node_human(child, 1));
811 }
812 }
813 out.trim_end().to_owned()
814}
815
816fn render_node_human(node: &NodeEntry, depth: usize) -> String {
819 let indent = " ".repeat(depth);
820 let geom = render_geom_summary(node);
821 let flags = render_flags(node);
822 let suffix = [geom, flags]
823 .into_iter()
824 .filter(|s| !s.is_empty())
825 .collect::<Vec<_>>()
826 .join(" ");
827 let suffix_part = if suffix.is_empty() {
828 String::new()
829 } else {
830 format!(" {}", suffix)
831 };
832
833 let mut out = format!("{}{} {}{}\n", indent, node.kind, node.id, suffix_part);
834 for child in &node.children {
835 out.push_str(&render_node_human(child, depth + 1));
836 }
837 out
838}
839
840fn render_geom_summary(node: &NodeEntry) -> String {
841 let Some(ref g) = node.geometry else {
842 return String::new();
843 };
844
845 if g.x.is_some() || g.y.is_some() || g.w.is_some() || g.h.is_some() {
847 let x = g.x.unwrap_or(0.0);
848 let y = g.y.unwrap_or(0.0);
849 let w = g.w.unwrap_or(0.0);
850 let h = g.h.unwrap_or(0.0);
851 return format!(
852 "{},{} {}x{}",
853 fmt_f64(x),
854 fmt_f64(y),
855 fmt_f64(w),
856 fmt_f64(h)
857 );
858 }
859
860 if g.x1.is_some() || g.y1.is_some() || g.x2.is_some() || g.y2.is_some() {
862 let x1 = g.x1.unwrap_or(0.0);
863 let y1 = g.y1.unwrap_or(0.0);
864 let x2 = g.x2.unwrap_or(0.0);
865 let y2 = g.y2.unwrap_or(0.0);
866 return format!(
867 "({},{})→({},{})",
868 fmt_f64(x1),
869 fmt_f64(y1),
870 fmt_f64(x2),
871 fmt_f64(y2)
872 );
873 }
874
875 if let Some(count) = g.point_count {
877 return format!("{} pts", count);
878 }
879
880 String::new()
881}
882
883fn render_flags(node: &NodeEntry) -> String {
884 let mut flags = Vec::new();
885 if node.visible == Some(false) {
886 flags.push("[hidden]");
887 }
888 if node.locked == Some(true) {
889 flags.push("[locked]");
890 }
891 flags.join(" ")
892}
893
894fn fmt_f64(v: f64) -> String {
896 if v.fract() == 0.0 {
897 (v as i64).to_string()
898 } else {
899 v.to_string()
900 }
901}
902
903#[cfg(test)]
906#[path = "document_tests.rs"]
907mod tests;