nornir 0.3.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Static SVG graph renderer powered by the typst engine.
//!
//! Why typst? We already ship typst-as-lib + typst-pdf for PDF docs;
//! typst-svg is the sibling crate that emits SVG from the same
//! `PagedDocument`. Reusing it means one rendering engine for every
//! visual artifact (depgraph, callgraph, lineage) and zero JavaScript
//! anywhere in the consumption path — markdown viewers, terminal
//! previews, PDF exports all see the same static picture.
//!
//! The layout is a simple layered DAG (BFS levels → columns) computed
//! in Rust; typst is only used as the geometry renderer. We deliberately
//! do *not* depend on CeTZ or other typst packages yet — keeps the
//! toolchain minimal. CeTZ can come later if we need fancier layouts.
//!
//! Cargo feature: `docs-export`.

use std::collections::{HashMap, HashSet};

use anyhow::{anyhow, Context, Result};
use typst_as_lib::typst_kit_options::TypstKitFontOptions;
use typst_as_lib::TypstEngine;

use crate::introspect::{Edge, EdgeKind, Graph};

/// Render `g` to a standalone SVG string using the typst engine.
///
/// Layout: BFS-layered (sources on the left, sinks on the right).
/// Cycles are broken in a stable, deterministic order so the output is
/// idempotent for a given input.
pub fn render_graph_svg(g: &Graph) -> Result<String> {
    let layers = layered(&g.nodes, &g.edges);
    let typst_src = emit_typst(g, &layers);
    let engine = TypstEngine::builder()
        .main_file(typst_src)
        .search_fonts_with(
            TypstKitFontOptions::default()
                .include_system_fonts(false)
                .include_embedded_fonts(true),
        )
        .build();
    let result = engine.compile();
    let doc = result
        .output
        .map_err(|err| anyhow!("typst compile failed for graph svg: {:?}", err))?;
    let svg = typst_svg::svg_merged(&doc, typst::layout::Abs::pt(8.0));
    Ok(svg)
}

/// Compute layers of node *indices*. Each layer is a horizontal column
/// in the rendered graph. Layer 0 = sources (no incoming edges).
fn layered(nodes: &[String], edges: &[Edge]) -> Vec<Vec<usize>> {
    let n = nodes.len();
    let idx: HashMap<&str, usize> =
        nodes.iter().enumerate().map(|(i, s)| (s.as_str(), i)).collect();

    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
    let mut indeg: Vec<usize> = vec![0; n];
    for e in edges {
        if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
            if f != t {
                adj[f].push(t);
                indeg[t] += 1;
            }
        }
    }

    let mut layer_of = vec![0usize; n];
    let mut remaining: HashSet<usize> = (0..n).collect();
    let mut level = 0usize;
    while !remaining.is_empty() {
        let ready: Vec<usize> = remaining
            .iter()
            .copied()
            .filter(|&i| indeg[i] == 0)
            .collect();
        if ready.is_empty() {
            for &i in &remaining {
                layer_of[i] = level;
            }
            break;
        }
        for &i in &ready {
            layer_of[i] = level;
            remaining.remove(&i);
        }
        for &i in &ready {
            for &j in &adj[i] {
                if indeg[j] > 0 {
                    indeg[j] -= 1;
                }
            }
        }
        level += 1;
    }

    let max_level = *layer_of.iter().max().unwrap_or(&0);
    let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
    let mut order: Vec<usize> = (0..n).collect();
    order.sort_by(|&a, &b| nodes[a].cmp(&nodes[b]));
    for i in order {
        layers[layer_of[i]].push(i);
    }
    layers
}

fn emit_typst(g: &Graph, layers: &[Vec<usize>]) -> String {
    let col_w_pt: f64 = 160.0;
    let row_h_pt: f64 = 36.0;
    let box_w_pt: f64 = 130.0;
    let box_h_pt: f64 = 22.0;
    let margin_pt: f64 = 12.0;

    let cols = layers.len().max(1);
    let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
    let page_w = margin_pt * 2.0 + col_w_pt * cols as f64;
    let page_h = margin_pt * 2.0 + row_h_pt * rows as f64 + box_h_pt;

    let mut pos: HashMap<usize, (f64, f64)> = HashMap::new();
    for (ci, layer) in layers.iter().enumerate() {
        let n = layer.len() as f64;
        let total_h = n * row_h_pt;
        let start_y = margin_pt + (page_h - margin_pt * 2.0 - total_h) / 2.0;
        for (ri, &node_i) in layer.iter().enumerate() {
            let x = margin_pt + ci as f64 * col_w_pt + 8.0;
            let y = start_y + ri as f64 * row_h_pt;
            pos.insert(node_i, (x, y));
        }
    }

    let name_to_idx: HashMap<&str, usize> = g
        .nodes
        .iter()
        .enumerate()
        .map(|(i, s)| (s.as_str(), i))
        .collect();

    let mut s = String::new();
    s.push_str(&format!(
        "#set page(width: {page_w:.1}pt, height: {page_h:.1}pt, margin: 0pt)\n"
    ));
    s.push_str("#set text(font: (\"DejaVu Sans\", \"Liberation Sans\"), size: 9pt)\n\n");

    for e in &g.edges {
        let (Some(&fi), Some(&ti)) =
            (name_to_idx.get(e.from.as_str()), name_to_idx.get(e.to.as_str()))
        else {
            continue;
        };
        let Some(&(fx, fy)) = pos.get(&fi) else { continue };
        let Some(&(tx, ty)) = pos.get(&ti) else { continue };
        let x1 = fx + box_w_pt;
        let y1 = fy + box_h_pt / 2.0;
        let x2 = tx;
        let y2 = ty + box_h_pt / 2.0;
        let style = match e.kind {
            EdgeKind::DependsOn => "(paint: rgb(120, 120, 130), thickness: 0.6pt, dash: \"dashed\")",
            EdgeKind::Calls => "(paint: rgb(40, 80, 160), thickness: 0.7pt)",
            EdgeKind::Reexports => "(paint: rgb(40, 130, 60), thickness: 0.8pt)",
            EdgeKind::Implements => "(paint: rgb(160, 80, 40), thickness: 0.7pt)",
        };
        s.push_str(&format!(
            "#place(top + left, dx: 0pt, dy: 0pt, \
             line(start: ({x1:.1}pt, {y1:.1}pt), end: ({x2:.1}pt, {y2:.1}pt), \
             stroke: {style}))\n"
        ));
    }

    for (i, name) in g.nodes.iter().enumerate() {
        let Some(&(x, y)) = pos.get(&i) else { continue };
        let label = escape_typst(name);
        s.push_str(&format!(
            "#place(top + left, dx: {x:.1}pt, dy: {y:.1}pt, \
             box(width: {box_w_pt}pt, height: {box_h_pt}pt, \
             stroke: 0.6pt + rgb(60,60,80), \
             fill: rgb(245,247,252), \
             inset: 4pt, \
             radius: 3pt)[#align(center + horizon)[{label}]])\n"
        ));
    }

    s
}

fn escape_typst(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace('[', "\\[")
        .replace(']', "\\]")
        .replace('#', "\\#")
        .replace('$', "\\$")
        .replace('@', "\\@")
        .replace('*', "\\*")
        .replace('_', "\\_")
}

/// Convenience: render `g` and write it to `out_path`, returning the
/// repo-relative path (or absolute path) that callers can embed in
/// markdown as `![alt](path)`.
pub fn render_to_file(g: &Graph, out_path: &std::path::Path) -> Result<()> {
    let svg = render_graph_svg(g)?;
    if let Some(parent) = out_path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("create {}", parent.display()))?;
    }
    std::fs::write(out_path, svg)
        .with_context(|| format!("write {}", out_path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn g_simple() -> Graph {
        Graph {
            nodes: vec!["a".into(), "b".into(), "c".into()],
            edges: vec![
                Edge { from: "a".into(), to: "b".into(), kind: EdgeKind::DependsOn },
                Edge { from: "b".into(), to: "c".into(), kind: EdgeKind::DependsOn },
            ],
        }
    }

    #[test]
    fn layered_topological() {
        let g = g_simple();
        let layers = layered(&g.nodes, &g.edges);
        assert_eq!(layers.len(), 3);
        assert_eq!(layers[0], vec![0]);
        assert_eq!(layers[1], vec![1]);
        assert_eq!(layers[2], vec![2]);
    }

    #[test]
    fn renders_non_empty_svg() {
        let g = g_simple();
        let svg = render_graph_svg(&g).expect("render");
        assert!(svg.starts_with("<svg"), "expected svg root, got: {}", &svg[..40.min(svg.len())]);
        assert!(svg.contains("</svg>"));
        assert!(svg.len() > 200);
    }

    #[test]
    fn cyclic_graph_does_not_hang() {
        let g = Graph {
            nodes: vec!["a".into(), "b".into()],
            edges: vec![
                Edge { from: "a".into(), to: "b".into(), kind: EdgeKind::Calls },
                Edge { from: "b".into(), to: "a".into(), kind: EdgeKind::Calls },
            ],
        };
        let _ = render_graph_svg(&g).expect("cycle should not hang");
    }
}