1use std::collections::{HashMap, HashSet};
24
25use crate::ast::*;
26use crate::{lexer, parser};
27
28#[derive(Clone, Copy, PartialEq, Eq)]
30enum VizKind {
31 Func,
33 Register,
36 Extern,
39}
40
41struct VizNode {
43 id: String,
45 label: String,
47 inputs: Vec<String>,
49 outputs: Vec<String>,
51 kind: VizKind,
53}
54
55struct VizEdge {
57 from_node: String,
58 from_port: String,
59 to_node: String,
60 to_port: String,
61}
62
63pub fn polydat_to_dot(source: &str) -> Result<String, String> {
65 let (nodes, edges) = build_graph(source)?;
66 let mut dot = String::new();
67
68 dot.push_str("digraph Polydat {\n");
69 dot.push_str(" rankdir=TB;\n");
70 dot.push_str(" bgcolor=\"#1a1a2e\";\n");
71 dot.push_str(" node [shape=record, style=filled, fontname=\"monospace\", fontsize=11];\n");
72 dot.push_str(" edge [color=\"#4da6ff\", fontcolor=\"#8888a0\", fontname=\"monospace\", fontsize=9];\n");
73 dot.push('\n');
74
75 for node in &nodes {
76 if node.kind == VizKind::Extern {
77 let ports: Vec<String> = node
79 .outputs
80 .iter()
81 .map(|name| format!("<o_{name}> {name}"))
82 .collect();
83 dot.push_str(&format!(
84 " {} [label=\"{{ {} | {{ {} }} }}\", fillcolor=\"#0f3460\", \
85 fontcolor=\"#ffb454\", color=\"#ffb454\", penwidth=2];\n",
86 node.id,
87 dot_escape(&node.label),
88 ports.join(" | "),
89 ));
90 } else if node.kind == VizKind::Register {
91 if node.outputs.is_empty() && !node.inputs.is_empty() {
93 let ports: Vec<String> = node
95 .inputs
96 .iter()
97 .map(|name| format!("<i_{name}> {name}"))
98 .collect();
99 dot.push_str(&format!(
100 " {} [label=\"{{ {{ {} }} | {} }}\", fillcolor=\"#0f3460\", \
101 fontcolor=\"#4ecca3\", color=\"#4ecca3\", penwidth=2];\n",
102 node.id,
103 ports.join(" | "),
104 dot_escape(&node.label),
105 ));
106 } else if !node.outputs.is_empty() && node.inputs.is_empty() {
107 let ports: Vec<String> = node
109 .outputs
110 .iter()
111 .map(|name| format!("<o_{name}> {name}"))
112 .collect();
113 dot.push_str(&format!(
114 " {} [label=\"{{ {} | {{ {} }} }}\", fillcolor=\"#0f3460\", \
115 fontcolor=\"#4da6ff\", color=\"#4da6ff\", penwidth=2];\n",
116 node.id,
117 dot_escape(&node.label),
118 ports.join(" | "),
119 ));
120 } else {
121 dot.push_str(&format!(
123 " {} [label=\"{}\", shape=oval, fillcolor=\"#16213e\", \
124 fontcolor=\"#4da6ff\", color=\"#4da6ff\"];\n",
125 node.id,
126 dot_escape(&node.label)
127 ));
128 }
129 } else {
130 let input_ports = if node.inputs.is_empty() {
132 String::new()
133 } else {
134 let ports: Vec<String> = node
135 .inputs
136 .iter()
137 .map(|name| format!("<i_{name}> {name}"))
138 .collect();
139 format!("{{ {} }} | ", ports.join(" | "))
140 };
141
142 let output_ports = if node.outputs.is_empty() {
143 String::new()
144 } else {
145 let ports: Vec<String> = node
146 .outputs
147 .iter()
148 .map(|name| format!("<o_{name}> {name}"))
149 .collect();
150 format!(" | {{ {} }}", ports.join(" | "))
151 };
152
153 dot.push_str(&format!(
154 " {} [label=\"{}{}{}\", fillcolor=\"#16213e\", \
155 fontcolor=\"#e0e0e0\", color=\"#0f3460\"];\n",
156 node.id,
157 input_ports,
158 dot_escape(&node.label),
159 output_ports,
160 ));
161 }
162 }
163
164 dot.push('\n');
165
166 for edge in &edges {
167 let from = if edge.from_port.is_empty() {
168 edge.from_node.clone()
169 } else {
170 format!("{}:o_{}", edge.from_node, edge.from_port)
171 };
172 let to = if edge.to_port.is_empty() {
173 edge.to_node.clone()
174 } else {
175 format!("{}:i_{}", edge.to_node, edge.to_port)
176 };
177 dot.push_str(&format!(" {} -> {};\n", from, to));
178 }
179
180 dot.push_str("}\n");
181 Ok(dot)
182}
183
184pub fn polydat_to_mermaid(source: &str) -> Result<String, String> {
186 let (nodes, edges) = build_graph(source)?;
187 let mut lines = vec!["flowchart TD".to_string()];
188
189 for node in &nodes {
190 let escaped = node.label.replace('"', "'");
191 if node.kind == VizKind::Func {
192 lines.push(format!(" {}[\"{}\"]", node.id, escaped));
193 } else {
194 lines.push(format!(" {}([\"{}\"])", node.id, escaped));
195 }
196 }
197
198 for edge in &edges {
199 let label = if edge.from_port.is_empty() && edge.to_port.is_empty() {
200 String::new()
201 } else {
202 let port_name = if !edge.from_port.is_empty() {
203 &edge.from_port
204 } else {
205 &edge.to_port
206 };
207 format!("|{}|", port_name)
208 };
209 lines.push(format!(
210 " {} -->{} {}",
211 edge.from_node, label, edge.to_node
212 ));
213 }
214
215 lines.push(" classDef coord fill:#16213e,stroke:#4da6ff,color:#4da6ff".into());
216 lines.push(" classDef func fill:#16213e,stroke:#0f3460,color:#e0e0e0".into());
217 if nodes.iter().any(|n| n.kind == VizKind::Extern) {
218 lines.push(" classDef extern fill:#16213e,stroke:#ffb454,color:#ffb454".into());
219 }
220 for node in &nodes {
221 let class = match node.kind {
222 VizKind::Register => "input",
223 VizKind::Extern => "extern",
224 VizKind::Func => "func",
225 };
226 lines.push(format!(" class {} {class}", node.id));
227 }
228
229 Ok(lines.join("\n"))
230}
231
232pub fn polydat_to_svg(source: &str) -> Result<String, String> {
238 let dot_source = polydat_to_dot(source)?;
239
240 let mut parser = layout::gv::DotParser::new(&dot_source);
242 let graph = parser
243 .process()
244 .map_err(|e| format!("DOT parse error: {e}"))?;
245
246 let mut builder = layout::gv::GraphBuilder::new();
248 builder.visit_graph(&graph);
249 let mut visual = builder.get();
250
251 let mut svg_writer = layout::backends::svg::SVGWriter::new();
253 visual.do_it(false, false, false, &mut svg_writer);
254
255 let raw = svg_writer.finalize();
256 let styled = raw.replacen("<svg ", "<svg style=\"background:#1a1a2e\" ", 1);
258 Ok(styled)
259}
260
261fn build_graph(source: &str) -> Result<(Vec<VizNode>, Vec<VizEdge>), String> {
264 let tokens = lexer::lex(source)?;
265 let ast = parser::parse(tokens)?;
266
267 let mut nodes: Vec<VizNode> = Vec::new();
268 let mut edges: Vec<VizEdge> = Vec::new();
269 let mut name_to_node_id: HashMap<String, String> = HashMap::new();
270 let mut node_counter = 0usize;
271
272 let mut input_names: Vec<String> = Vec::new();
274 let mut externs: Vec<&ExternPort> = Vec::new();
275 let mut defined_names: HashSet<String> = HashSet::new();
276 let mut all_output_names: Vec<String> = Vec::new();
277
278 for stmt in &ast.statements {
279 match stmt {
280 Statement::InputDecl(d) => input_names.push(d.name.clone()),
281 Statement::Binding(b) => {
282 for t in &b.targets {
283 defined_names.insert(t.clone());
284 all_output_names.push(t.clone());
285 }
286 }
287 Statement::ExternPort(e) => externs.push(e),
288 Statement::ModuleDef(_) => {}
289 Statement::Cursor(_) => {}
290 Statement::Pragma { .. } => {}
291 Statement::For(_) => {}
292 Statement::Tile(_) => {}
293 }
294 }
295
296 if input_names.is_empty() {
298 let mut refs: HashSet<String> = HashSet::new();
299 for stmt in &ast.statements {
300 let expr = match stmt {
301 Statement::InputDecl(_)
302 | Statement::ModuleDef(_)
303 | Statement::ExternPort(_)
304 | Statement::Cursor(_)
305 | Statement::Pragma { .. }
306 | Statement::For(_)
307 | Statement::Tile(_) => continue,
308 Statement::Binding(b) => &b.value,
309 };
310 collect_expr_idents(expr, &mut refs);
311 }
312 let extern_names: HashSet<&str> = externs.iter().map(|e| e.name.as_str()).collect();
313 for name in refs {
314 if !defined_names.contains(&name) && !extern_names.contains(name.as_str()) {
315 input_names.push(name);
316 }
317 }
318 input_names.sort();
319 }
320
321 let mut consumed: HashSet<String> = HashSet::new();
323 for stmt in &ast.statements {
324 let expr = match stmt {
325 Statement::InputDecl(_)
326 | Statement::ModuleDef(_)
327 | Statement::ExternPort(_)
328 | Statement::Cursor(_)
329 | Statement::Pragma { .. }
330 | Statement::For(_)
331 | Statement::Tile(_) => continue,
332 Statement::Binding(b) => &b.value,
333 };
334 collect_expr_idents(expr, &mut consumed);
335 }
336 let terminal_outputs: Vec<String> = all_output_names
337 .iter()
338 .filter(|name| !consumed.contains(*name))
339 .cloned()
340 .collect();
341
342 let inputs_id = "inputs".to_string();
345 {
346 let mut input_ports: Vec<String> = Vec::new();
347 for name in &input_names {
348 input_ports.push(name.clone());
349 }
350 nodes.push(VizNode {
351 id: inputs_id.clone(),
352 label: "INPUTS".into(),
353 inputs: vec![],
354 outputs: input_ports,
355 kind: VizKind::Register,
356 });
357 for name in &input_names {
358 name_to_node_id.insert(name.clone(), inputs_id.clone());
359 }
360 }
361
362 for (idx, port) in externs.iter().enumerate() {
370 let id = format!("x{idx}");
371 let label = match &port.default {
372 Some(default) => format!(
373 "extern {}: {} = {}",
374 port.name,
375 port.typ,
376 format_expr_short(default)
377 ),
378 None => format!("extern {}: {} (unset)", port.name, port.typ),
379 };
380 name_to_node_id.insert(port.name.clone(), id.clone());
381 nodes.push(VizNode {
382 id,
383 label,
384 inputs: vec![],
385 outputs: vec![port.name.clone()],
386 kind: VizKind::Extern,
387 });
388 }
389
390 for stmt in &ast.statements {
392 match stmt {
393 Statement::InputDecl(_)
394 | Statement::ModuleDef(_)
395 | Statement::ExternPort(_)
396 | Statement::Cursor(_)
397 | Statement::Pragma { .. }
398 | Statement::For(_)
399 | Statement::Tile(_) => continue,
400 Statement::Binding(b) => {
401 let id = format!("n{node_counter}");
402 node_counter += 1;
403
404 let target_label = if b.targets.len() == 1 {
405 b.targets[0].clone()
406 } else {
407 format!("({})", b.targets.join(", "))
408 };
409
410 let mut input_refs: Vec<String> = Vec::new();
411 collect_expr_idents_ordered(&b.value, &mut input_refs);
412
413 let label = format_node_label(&b.value, &target_label);
414
415 for ref_name in &input_refs {
416 if let Some(src_id) = name_to_node_id.get(ref_name) {
417 edges.push(VizEdge {
418 from_node: src_id.clone(),
419 from_port: ref_name.clone(),
420 to_node: id.clone(),
421 to_port: ref_name.clone(),
422 });
423 }
424 }
425
426 for t in &b.targets {
427 name_to_node_id.insert(t.clone(), id.clone());
428 }
429 nodes.push(VizNode {
430 id,
431 label,
432 inputs: input_refs,
433 outputs: b.targets.clone(),
434 kind: VizKind::Func,
435 });
436 }
437 }
438 }
439
440 if !terminal_outputs.is_empty() {
443 let outputs_id = "outputs".to_string();
444 for name in &terminal_outputs {
445 if let Some(src_id) = name_to_node_id.get(name) {
446 edges.push(VizEdge {
447 from_node: src_id.clone(),
448 from_port: name.clone(),
449 to_node: outputs_id.clone(),
450 to_port: name.clone(),
451 });
452 }
453 }
454 nodes.push(VizNode {
455 id: outputs_id,
456 label: "OUTPUTS".into(),
457 inputs: terminal_outputs,
458 outputs: vec![],
459 kind: VizKind::Register,
460 });
461 }
462
463 Ok((nodes, edges))
464}
465
466fn format_node_label(expr: &Expr, target: &str) -> String {
467 match expr {
468 Expr::Call(call) => {
469 let args: Vec<String> = call
470 .args
471 .iter()
472 .map(|a| match a {
473 Arg::Positional(e) => format_expr_short(e),
474 Arg::Named(n, e) => format!("{}: {}", n, format_expr_short(e)),
475 })
476 .collect();
477 format!("{} := {}({})", target, call.func, args.join(", "))
478 }
479 Expr::Ident(id, _) => format!("{} := {}", target, id),
480 Expr::IntLit(v, _) => format!("{} = {}", target, v),
481 Expr::FloatLit(v, _) => format!("{} = {}", target, v),
482 Expr::StringLit(s, _) => {
483 let trunc = if s.len() > 20 {
484 format!("{}...", &s[..20])
485 } else {
486 s.clone()
487 };
488 format!("{} = \"{}\"", target, trunc)
489 }
490 _ => target.to_string(),
491 }
492}
493
494fn format_expr_short(expr: &Expr) -> String {
495 match expr {
496 Expr::Ident(id, _) => id.clone(),
497 Expr::IntLit(v, _) => v.to_string(),
498 Expr::FloatLit(v, _) => format!("{v}"),
499 Expr::StringLit(s, _) => format!("\"{s}\""),
500 Expr::Call(call) => format!("{}(..)", call.func),
501 _ => "..".into(),
502 }
503}
504
505fn collect_expr_idents(expr: &Expr, out: &mut HashSet<String>) {
506 match expr {
507 Expr::Ident(name, _) => {
508 out.insert(name.clone());
509 }
510 Expr::Call(call) => {
511 for arg in &call.args {
512 let inner = match arg {
513 Arg::Positional(e) | Arg::Named(_, e) => e,
514 };
515 collect_expr_idents(inner, out);
516 }
517 }
518 Expr::ArrayLit(elems, _) => {
519 for e in elems {
520 collect_expr_idents(e, out);
521 }
522 }
523 _ => {}
524 }
525}
526
527fn collect_expr_idents_ordered(expr: &Expr, out: &mut Vec<String>) {
529 match expr {
530 Expr::Ident(name, _) => {
531 if !out.contains(name) {
532 out.push(name.clone());
533 }
534 }
535 Expr::Call(call) => {
536 for arg in &call.args {
537 let inner = match arg {
538 Arg::Positional(e) | Arg::Named(_, e) => e,
539 };
540 collect_expr_idents_ordered(inner, out);
541 }
542 }
543 Expr::ArrayLit(elems, _) => {
544 for e in elems {
545 collect_expr_idents_ordered(e, out);
546 }
547 }
548 _ => {}
549 }
550}
551
552fn dot_escape(s: &str) -> String {
553 s.replace('\\', "\\\\")
554 .replace('"', "\\\"")
555 .replace('{', "\\{")
556 .replace('}', "\\}")
557 .replace('<', "\\<")
558 .replace('>', "\\>")
559 .replace('|', "\\|")
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 const SIMPLE_POLYDAT: &str = "input cycle: u64\nh := hash(cycle)\nuser_id := mod(h, 1000000)";
567
568 #[test]
569 fn dot_has_ports() {
570 let dot = polydat_to_dot(SIMPLE_POLYDAT).unwrap();
571 assert!(dot.contains("shape=record"));
572 assert!(dot.contains("bgcolor"));
573 assert!(dot.contains(":o_")); assert!(dot.contains(":i_")); }
576
577 #[test]
578 fn dot_dark_theme() {
579 let dot = polydat_to_dot(SIMPLE_POLYDAT).unwrap();
580 assert!(dot.contains("#1a1a2e")); assert!(dot.contains("#16213e")); assert!(dot.contains("#e0e0e0")); }
584
585 #[test]
586 fn mermaid_output() {
587 let mermaid = polydat_to_mermaid(SIMPLE_POLYDAT).unwrap();
588 assert!(mermaid.contains("flowchart TD"));
589 assert!(mermaid.contains("-->"));
590 }
591
592 #[test]
593 fn svg_dark_background() {
594 let svg = polydat_to_svg(SIMPLE_POLYDAT).unwrap();
595 assert!(svg.contains("<svg"));
596 assert!(svg.contains("#1a1a2e"));
597 }
598
599 #[test]
600 fn inferred_coords() {
601 let src = "h := hash(cycle)\nid := mod(h, 100)";
602 let dot = polydat_to_dot(src).unwrap();
603 assert!(dot.contains("cycle"));
604 }
605
606 #[test]
607 fn multi_output() {
608 let src = "input cycle: u64\n(x, y) := mixed_radix(cycle, 100, 0)\nhx := hash(x)";
609 let dot = polydat_to_dot(src).unwrap();
610 assert!(dot.contains("mixed_radix"));
611 assert!(dot.contains("hash"));
612 }
613
614 const EXTERN_POLYDAT: &str = "input cycle: u64\n\
616 extern balance: f64 = 0.5\n\
617 extern session_id: u64\n\
618 h := hash(cycle)\n\
619 scaled := f64_mul(balance, 2.0)\n\
620 token := u64_add(h, session_id)";
621
622 #[test]
623 fn extern_ports_are_drawn_with_edges() {
624 let dot = polydat_to_dot(EXTERN_POLYDAT).unwrap();
625 assert!(dot.contains("inputs [label=\"{ INPUTS | { <o_cycle> cycle } }\""));
627 assert!(
629 dot.contains("x0 [label=\"{ extern balance: f64 = 0.5 | { <o_balance> balance } }\"")
630 );
631 assert!(dot.contains(
632 "x1 [label=\"{ extern session_id: u64 (unset) | { <o_session_id> session_id } }\""
633 ));
634 assert!(dot.contains("#ffb454"));
636 assert!(dot.contains("inputs:o_cycle -> n0:i_cycle;"));
638 assert!(dot.contains("x0:o_balance -> n1:i_balance;"));
639 assert!(dot.contains("x1:o_session_id -> n2:i_session_id;"));
640 assert!(dot.contains("n0:o_h -> n2:i_h;"));
641 }
642
643 #[test]
644 fn extern_ports_are_not_inferred_as_coordinates() {
645 let src = "extern k: u64 = 7\nh := hash(cycle)\nz := u64_add(h, k)";
647 let dot = polydat_to_dot(src).unwrap();
648 assert!(dot.contains("inputs [label=\"{ INPUTS | { <o_cycle> cycle } }\""));
649 assert!(dot.contains("x0 [label=\"{ extern k: u64 = 7 | { <o_k> k } }\""));
650 assert!(dot.contains("x0:o_k -> n1:i_k;"));
651 }
652
653 #[test]
654 fn extern_ports_in_mermaid_and_svg() {
655 let mermaid = polydat_to_mermaid(EXTERN_POLYDAT).unwrap();
656 assert!(mermaid.contains("x0([\"extern balance: f64 = 0.5\"])"));
657 assert!(mermaid.contains("x1([\"extern session_id: u64 (unset)\"])"));
658 assert!(mermaid.contains("x0 -->|balance| n1"));
659 assert!(mermaid.contains("classDef extern"));
660 assert!(mermaid.contains("class x0 extern"));
661
662 let svg = polydat_to_svg(EXTERN_POLYDAT).unwrap();
663 assert!(svg.contains("<svg"));
664 assert!(svg.contains("balance"));
665 assert!(svg.contains("session_id"));
666 }
667
668 #[test]
669 fn programs_without_externs_draw_no_extern_nodes() {
670 let dot = polydat_to_dot(SIMPLE_POLYDAT).unwrap();
671 assert!(!dot.contains("extern"));
672 assert!(!dot.contains("#ffb454"));
673 let mermaid = polydat_to_mermaid(SIMPLE_POLYDAT).unwrap();
674 assert!(!mermaid.contains("extern"));
675 }
676}