Skip to main content

polydat_grammar/
viz.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! DAG visualization for Polydat Kernels.
5//!
6//! Renders a Polydat Kernel's node graph as DOT (with record nodes and
7//! port-based edge routing), Mermaid, or self-contained SVG.
8//!
9//! The DOT output uses graphviz record syntax:
10//! - Each node has named input ports (top) and output ports (bottom)
11//! - Edges connect from output ports to input ports
12//! - Dark theme colors via graph/node/edge attributes
13//!
14//! The program's inputs are drawn as port nodes at the top of the graph,
15//! distinct from the binding nodes that read them:
16//! - Coordinates (`input name: type`) share one `INPUTS` register with one
17//!   output port per coordinate.
18//! - External ports (`extern name: type [= default]`, the kernel's
19//!   `InputKind::ExternalWrite` slots the host writes between cycles) each
20//!   get their own register labeled with the port's kind, name, type, and
21//!   default, with one output port wired to every node that reads it.
22
23use std::collections::{HashMap, HashSet};
24
25use crate::ast::*;
26use crate::{lexer, parser};
27
28/// How a visualization node is drawn.
29#[derive(Clone, Copy, PartialEq, Eq)]
30enum VizKind {
31    /// A binding: input ports | label | output ports.
32    Func,
33    /// The `INPUTS` or `OUTPUTS` register: coordinates or terminal wires
34    /// as ports, with the blue or green accent.
35    Register,
36    /// One external port (`extern name: type [= default]`): a register
37    /// with a single output port and the amber accent.
38    Extern,
39}
40
41/// A node in the visualization graph.
42struct VizNode {
43    /// Unique ID for DOT (e.g., "n0", "n1", "x0")
44    id: String,
45    /// Display label (function name or binding expression)
46    label: String,
47    /// Input wire names (from upstream nodes/coords)
48    inputs: Vec<String>,
49    /// Output wire names (what this node produces)
50    outputs: Vec<String>,
51    /// How the node is drawn.
52    kind: VizKind,
53}
54
55/// An edge connecting an output to an input.
56struct VizEdge {
57    from_node: String,
58    from_port: String,
59    to_node: String,
60    to_port: String,
61}
62
63/// Render a Polydat source string as DOT with record nodes and ports.
64pub 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            // External port: output port only (top of graph), amber accent
78            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            // Register nodes (INPUTS / OUTPUTS): record with labeled ports
92            if node.outputs.is_empty() && !node.inputs.is_empty() {
93                // OUTPUTS register: input ports only (bottom of graph)
94                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                // INPUTS register: output ports only (top of graph)
108                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                // Fallback
122                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            // Function nodes: record with input ports | label | output ports
131            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
184/// Render a Polydat source string as a Mermaid flowchart.
185pub 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
232/// Render a Polydat source string as self-contained SVG.
233///
234/// Generates DOT with record nodes and port syntax, then renders
235/// through layout-rs (pure Rust, no external graphviz needed).
236/// Layout-rs supports record shapes and port-based edge routing.
237pub fn polydat_to_svg(source: &str) -> Result<String, String> {
238    let dot_source = polydat_to_dot(source)?;
239
240    // Parse DOT through layout-rs
241    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    // Build visual graph from parsed DOT
247    let mut builder = layout::gv::GraphBuilder::new();
248    builder.visit_graph(&graph);
249    let mut visual = builder.get();
250
251    // Layout and render to SVG
252    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    // Inject dark background
257    let styled = raw.replacen("<svg ", "<svg style=\"background:#1a1a2e\" ", 1);
258    Ok(styled)
259}
260
261// ─── Graph building ─────────────────────────────────────────
262
263fn 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    // Collect coordinates, external ports, and defined names
273    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    // Infer coordinates
297    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    // Determine which outputs are terminal (not consumed by other nodes)
322    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    // ─── INPUTS register (top) ──────────────────────────
343    // Single record node with all coordinates as output ports
344    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    // ─── External ports (top) ───────────────────────────
363    // One register per `extern name: type [= default]`. These are the
364    // kernel's `InputKind::ExternalWrite` slots: written by the host
365    // between cycles and read by nodes like any other input.
366    // The label carries kind, name, type, and default so a reader can
367    // tell a port with a declared default from one that is unset until
368    // the host writes it.
369    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    // ─── Function nodes (middle) ────────────────────────
391    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    // ─── OUTPUTS register (bottom) ──────────────────────
441    // Single record node with all terminal outputs as input ports
442    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
527/// Like collect_expr_idents but preserves order and avoids duplicates.
528fn 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_")); // output port syntax
574        assert!(dot.contains(":i_")); // input port syntax
575    }
576
577    #[test]
578    fn dot_dark_theme() {
579        let dot = polydat_to_dot(SIMPLE_POLYDAT).unwrap();
580        assert!(dot.contains("#1a1a2e")); // dark bg
581        assert!(dot.contains("#16213e")); // node fill
582        assert!(dot.contains("#e0e0e0")); // light text
583    }
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    /// One coordinate, one extern with a default, one extern without.
615    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        // The coordinate register is unchanged and holds only coordinates.
626        assert!(dot.contains("inputs [label=\"{ INPUTS | { <o_cycle> cycle } }\""));
627        // Each extern is its own port node, labeled kind / name / type / default.
628        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        // Externs carry their own accent, distinct from coordinates and outputs.
635        assert!(dot.contains("#ffb454"));
636        // Edges run from each input port to the node that reads it.
637        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        // No `input` declaration: `cycle` is inferred, `k` is an extern.
646        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}