Skip to content

ER/Schema Visualizer — Rust source

Paste CREATE TABLE DDL and get an ER diagram as SVG: tables with typed columns, primary keys, and foreign-key arrows in a deterministic layered layout. Pan and zoom the live diagram; export the SVG.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
//! Rust port (canonical TS: src/lib/schema-visualizer.ts; Go twin:
//! cli/schema-visualizer). Tolerant common subset of Postgres/MySQL/SQLite:
//! unparseable statements degrade to notes, never panic. Integer geometry
//! only (half-up rounding — JS Math.round parity), so every port draws the
//! byte-identical diagram. No external crates.

#[derive(Clone)]
pub struct ColumnDef { pub name: String, pub type_: String, pub nullable: bool, pub is_primary_key: bool }
#[derive(Clone)]
pub struct ForeignKey { pub from_table: String, pub from_column: String, pub to_table: String, pub to_column: Option<String> }
#[derive(Clone)]
pub struct TableDef { pub name: String, pub columns: Vec<ColumnDef> }
pub struct ParsedSchema { pub tables: Vec<TableDef>, pub foreign_keys: Vec<ForeignKey>, pub notes: Vec<String> }

#[derive(Clone, Copy)]
pub struct Box { pub x: i32, pub y: i32, pub w: i32, pub h: i32 }
pub struct LaidOutTable { pub table: TableDef, pub box_: Box, pub title_bar: Box, pub column_rows: Vec<Box> }
pub struct Edge { pub fk: ForeignKey, pub path: String, pub label: String }
pub struct Geometry { pub width: i32, pub height: i32, pub tables: Vec<LaidOutTable>, pub edges: Vec<Edge> }

const ROW_HEIGHT: i32 = 24;
const CHAR_WIDTH: i32 = 7;
const PADDING: i32 = 8;
const LAYER_GAP: i32 = 60;
const COLUMN_GAP: i32 = 40;

fn r_half_up(v: f64) -> i32 { (v + 0.5).floor() as i32 } // JS Math.round parity

#[derive(Clone, Copy, PartialEq)]
enum Kind { Word, QIdent, Str, Punct }
#[derive(Clone, Copy)]
struct Token { kind: Kind, start: usize, end: usize }

fn quotes_close(open: char) -> char {
    match open { '[' => ']', q => q }
}

/// Split on `;` outside strings/quoted identifiers (depth-agnostic: an
/// unterminated paren cannot swallow the statements after it).
fn split_statements(ddl: &str) -> Vec<String> {
    let chars: Vec<char> = ddl.chars().collect();
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut i = 0;
    while i < chars.len() {
        let ch = chars[i];
        if ch == '\'' || ch == '"' || ch == '`' || ch == '[' {
            let close = quotes_close(ch);
            cur.push(ch);
            i += 1;
            while i < chars.len() {
                cur.push(chars[i]);
                if chars[i] == close {
                    if close == '\'' && i + 1 < chars.len() && chars[i + 1] == '\'' {
                        cur.push(chars[i + 1]);
                        i += 2;
                        continue;
                    }
                    break;
                }
                i += 1;
            }
            i += 1;
            continue;
        }
        if ch == ';' {
            out.push(std::mem::take(&mut cur));
            i += 1;
            continue;
        }
        cur.push(ch);
        i += 1;
    }
    if !cur.trim().is_empty() { out.push(cur); }
    out
}

fn tokenize(s: &str) -> Vec<(Token, String)> {
    let chars: Vec<char> = s.chars().collect();
    let mut toks = Vec::new();
    let mut i = 0;
    while i < chars.len() {
        let ch = chars[i];
        if ch.is_whitespace() { i += 1; continue; }
        if ch == '\'' || ch == '"' || ch == '`' || ch == '[' {
            let close = quotes_close(ch);
            let mut text = String::new();
            i += 1;
            while i < chars.len() {
                if chars[i] == close {
                    if close == '\'' && i + 1 < chars.len() && chars[i + 1] == '\'' {
                        text.push('\'');
                        i += 2;
                        continue;
                    }
                    break;
                }
                text.push(chars[i]);
                i += 1;
            }
            i += 1;
            toks.push((Token { kind: if ch == '\'' { Kind::Str } else { Kind::QIdent }, start: 0, end: 0 }, text));
            continue;
        }
        if ch == '(' || ch == ')' || ch == ',' || ch == '.' {
            toks.push((Token { kind: Kind::Punct, start: 0, end: 0 }, ch.to_string()));
            i += 1;
            continue;
        }
        let mut word = String::new();
        while i < chars.len() {
            let c = chars[i];
            if c.is_whitespace() || c == '\'' || c == '"' || c == ',' || c == '(' || c == ')' || c == '.' || c == '`' || c == '[' || c == ']' { break; }
            word.push(c);
            i += 1;
        }
        toks.push((Token { kind: Kind::Word, start: 0, end: 0 }, word));
    }
    toks
}

fn is_p(t: Option<&(Token, String)>, p: &str) -> bool {
    matches!(t, Some((tok, text)) if tok.kind == Kind::Punct && text == p)
}

fn kw(t: Option<&(Token, String)>, w: &str) -> bool {
    matches!(t, Some((tok, text)) if tok.kind == Kind::Word && text.to_uppercase() == w)
}

fn g<'a>(toks: &'a [(Token, String)], i: usize) -> Option<&'a (Token, String)> {
    toks.get(i)
}

fn take_name(toks: &[(Token, String)], i: usize) -> Option<(String, usize)> {
    let first = g(toks, i)?;
    if first.0.kind != Kind::QIdent && first.0.kind != Kind::Word { return None; }
    let mut name = first.1.clone();
    let mut j = i + 1;
    while is_p(g(toks, j), ".") {
        let nxt = g(toks, j + 1);
        match nxt {
            Some((tok, text)) if tok.kind == Kind::QIdent || tok.kind == Kind::Word => {
                name.push('.');
                name.push_str(text);
                j += 2;
            }
            _ => break,
        }
    }
    Some((name, j))
}

fn paren_list(toks: &[(Token, String)], i: usize) -> Option<(Vec<String>, usize)> {
    if !is_p(g(toks, i), "(") { return None; }
    let mut names = Vec::new();
    let mut j = i + 1;
    loop {
        let name = take_name(toks, j)?;
        names.push(name.0);
        j = name.1;
        if is_p(g(toks, j), ",") { j += 1; continue; }
        if is_p(g(toks, j), ")") { return Some((names, j + 1)); }
        return None;
    }
}

fn is_modifier(text: &str) -> bool {
    const MODS: [&str; 13] = ["NOT", "NULL", "PRIMARY", "KEY", "UNIQUE", "DEFAULT", "REFERENCES",
        "AUTO_INCREMENT", "AUTOINCREMENT", "ON", "COMMENT", "CHECK", "CONSTRAINT"];
    MODS.contains(&text.to_uppercase().as_str())
}

fn join_type(toks: &[(Token, String)]) -> String {
    let raw = toks.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>().join(" ");
    let mut out = String::new();
    for c in raw.chars() {
        match c {
            '(' | ')' | ',' => {
                while out.ends_with(' ') { out.pop(); }
                out.push(c);
            }
            ' ' => {
                // no space directly after an opening paren or comma
                if !out.ends_with('(') && !out.ends_with(',') { out.push(' '); }
            }
            c => out.push(c),
        }
    }
    out.trim().to_uppercase()
}

fn parse_column(line: &[(Token, String)], table_name: &str, fks: &mut Vec<ForeignKey>) -> Option<ColumnDef> {
    let name = take_name(line, 0)?;
    let mut i = name.1;
    let mut type_toks: Vec<(Token, String)> = Vec::new();
    while i < line.len() && !(line[i].0.kind == Kind::Word && is_modifier(&line[i].1)) {
        type_toks.push(line[i].clone());
        i += 1;
    }
    let mut nullable = true;
    let mut pk = false;
    while i < line.len() {
        let t = &line[i];
        let upper = t.1.to_uppercase();
        if upper == "NOT" && kw(g(line, i + 1), "NULL") { nullable = false; i += 2; continue; }
        if upper == "NULL" { i += 1; continue; }
        if upper == "PRIMARY" && kw(g(line, i + 1), "KEY") { pk = true; nullable = false; i += 2; continue; }
        if upper == "UNIQUE" || upper == "AUTO_INCREMENT" || upper == "AUTOINCREMENT" { i += 1; continue; }
        if upper == "DEFAULT" {
            i += 1;
            if is_p(g(line, i), "(") {
                let mut depth = 0;
                while i < line.len() {
                    if is_p(g(line, i), "(") { depth += 1; }
                    if is_p(g(line, i), ")") { depth -= 1; }
                    i += 1;
                    if depth == 0 { break; }
                }
            } else if i < line.len() { i += 1; }
            continue;
        }
        if upper == "COMMENT" {
            i += 1;
            if g(line, i).map(|t| t.0.kind) == Some(Kind::Str) { i += 1; }
            continue;
        }
        if upper == "ON" {
            i += 2;
            if kw(g(line, i), "SET") || kw(g(line, i), "NO") { i += 2; }
            else if i < line.len() { i += 1; }
            continue;
        }
        if upper == "REFERENCES" {
            i += 1;
            if let Some(target) = take_name(line, i) {
                i = target.1;
                let mut to_col: Option<String> = None;
                if is_p(g(line, i), "(") {
                    if let Some(list) = paren_list(line, i) {
                        to_col = list.0.first().cloned();
                        i = list.1;
                    }
                }
                fks.push(ForeignKey {
                    from_table: table_name.to_string(),
                    from_column: name.0.clone(),
                    to_table: target.0,
                    to_column: to_col,
                });
            }
            continue;
        }
        i += 1; // unknown modifier tolerated
    }
    Some(ColumnDef { name: name.0, type_: join_type(&type_toks), nullable, is_primary_key: pk })
}

pub fn parse_ddl(ddl: &str) -> ParsedSchema {
    if ddl.trim().is_empty() {
        return ParsedSchema { tables: Vec::new(), foreign_keys: Vec::new(), notes: vec!["No DDL input.".into()] };
    }
    let mut tables: Vec<TableDef> = Vec::new();
    let mut fks: Vec<ForeignKey> = Vec::new();
    let mut notes: Vec<String> = Vec::new();
    for stmt in split_statements(ddl) {
        if stmt.trim().is_empty() { continue; }
        let toks = tokenize(&stmt);
        let mut ok = false;
        if kw(g(&toks, 0), "CREATE") {
            let mut i = 1;
            while kw(g(&toks, i), "TEMP") || kw(g(&toks, i), "TEMPORARY") || kw(g(&toks, i), "UNLOGGED") { i += 1; }
            if kw(g(&toks, i), "TABLE") {
                i += 1;
                if kw(g(&toks, i), "IF") && kw(g(&toks, i + 1), "NOT") && kw(g(&toks, i + 2), "EXISTS") { i += 3; }
                if let Some(name) = take_name(&toks, i) {
                    if is_p(g(&toks, name.1), "(") {
                        let mut i2 = name.1 + 1;
                        let mut body: Vec<(Token, String)> = Vec::new();
                        let mut depth = 0;
                        let mut closed = false;
                        while i2 < toks.len() {
                            if is_p(g(&toks, i2), "(") { depth += 1; }
                            if is_p(g(&toks, i2), ")") {
                                if depth == 0 { closed = true; break; }
                                depth -= 1;
                            }
                            body.push(toks[i2].clone());
                            i2 += 1;
                        }
                        if closed {
                            let mut lines: Vec<Vec<(Token, String)>> = Vec::new();
                            let mut line: Vec<(Token, String)> = Vec::new();
                            let mut d2 = 0;
                            for t in &body {
                                if is_p(Some(t), "(") { d2 += 1; }
                                if is_p(Some(t), ")") { d2 -= 1; }
                                if is_p(Some(t), ",") && d2 == 0 {
                                    lines.push(std::mem::take(&mut line));
                                    continue;
                                }
                                line.push(t.clone());
                            }
                            if !line.is_empty() { lines.push(line); }
                            let mut table = TableDef { name: name.0.clone(), columns: Vec::new() };
                            for toks2 in &lines {
                                if toks2.is_empty() { continue; }
                                let first = &toks2[0];
                                let u = if first.0.kind == Kind::Word { first.1.to_uppercase() } else { String::new() };
                                if u == "PRIMARY" && kw(g(toks2, 1), "KEY") {
                                    if let Some(list) = paren_list(toks2, 2) {
                                        for cn in list.0 {
                                            if let Some(col) = table.columns.iter_mut().find(|c| c.name == cn) {
                                                col.is_primary_key = true;
                                                col.nullable = false;
                                            }
                                        }
                                    }
                                    continue;
                                }
                                if u == "FOREIGN" && kw(g(toks2, 1), "KEY") {
                                    if let Some(from) = paren_list(toks2, 2) {
                                        if kw(g(toks2, from.1), "REFERENCES") {
                                            if let Some(target) = take_name(toks2, from.1 + 1) {
                                                let mut to_cols: Option<Vec<String>> = None;
                                                if is_p(g(toks2, target.1), "(") {
                                                    if let Some(to) = paren_list(toks2, target.1) { to_cols = Some(to.0); }
                                                }
                                                for (idx, fc) in from.0.iter().enumerate() {
                                                    let to_col = to_cols.as_ref().map(|tc| {
                                                        tc.get(idx).cloned().unwrap_or_else(|| tc.last().cloned().unwrap())
                                                    });
                                                    fks.push(ForeignKey {
                                                        from_table: table.name.clone(),
                                                        from_column: fc.clone(),
                                                        to_table: target.0.clone(),
                                                        to_column: to_col,
                                                    });
                                                }
                                            }
                                        }
                                    }
                                    continue;
                                }
                                if ["UNIQUE", "KEY", "INDEX", "CHECK", "EXCLUDE", "CONSTRAINT"].contains(&u.as_str()) { continue; }
                                if let Some(col) = parse_column(toks2, &table.name, &mut fks) {
                                    table.columns.push(col);
                                }
                            }
                            tables.push(table);
                            ok = true;
                        }
                    }
                }
            } else {
                notes.push("Skipped non-table statement.".into());
                ok = true;
            }
        }
        if !ok { notes.push("Skipped unparseable statement.".into()); }
    }
    let foreign_keys = fks.into_iter().map(|fk| {
        match fk.to_column {
            Some(_) => fk,
            None => {
                let target = tables.iter().find(|t| t.name == fk.to_table);
                let pk = target.and_then(|t| t.columns.iter().find(|c| c.is_primary_key));
                ForeignKey { to_column: Some(pk.map(|c| c.name.clone()).unwrap_or_else(|| "id".into())), ..fk }
            }
        }
    }).collect();
    ParsedSchema { tables, foreign_keys, notes }
}

pub fn layout_schema(schema: &ParsedSchema) -> Geometry {
    if schema.tables.is_empty() {
        return Geometry { width: 0, height: 0, tables: Vec::new(), edges: Vec::new() };
    }
    let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    for (i, t) in schema.tables.iter().enumerate() { index.entry(t.name.clone()).or_insert(i); }
    let mut boxes: Vec<Box> = schema.tables.iter().map(|t| {
        let mut lens = vec![t.name.len() as i32];
        for c in &t.columns { lens.push((c.name.len() + 1 + c.type_.len()) as i32); }
        lens.push(1);
        Box {
            x: 0, y: 0,
            w: r_half_up(*lens.iter().max().unwrap() as f64 * CHAR_WIDTH as f64 + 2.0 * PADDING as f64),
            h: r_half_up(ROW_HEIGHT as f64 * (1 + t.columns.len()) as f64 + PADDING as f64),
        }
    }).collect();
    let mut layer_of = vec![0usize; schema.tables.len()];
    for _ in 0..schema.tables.len() {
        let mut changed = false;
        for fk in &schema.foreign_keys {
            let ti = index.get(&fk.from_table).copied();
            let tj = index.get(&fk.to_table).copied();
            match (ti, tj) {
                (Some(ti), Some(tj)) if ti != tj => {
                    if layer_of[ti] < layer_of[tj] + 1 { layer_of[ti] = layer_of[tj] + 1; changed = true; }
                }
                _ => {}
            }
        }
        if !changed { break; }
    }
    let mut layers: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
    for (i, l) in layer_of.iter().enumerate() { layers.entry(*l).or_default().push(i); }
    let mut y = 0;
    let mut width = 0;
    let mut height = 0;
    for (_, layer) in layers.iter() {
        let mut x = 0;
        let mut layer_h = 0;
        for &i in layer {
            boxes[i].x = x;
            boxes[i].y = y;
            x += boxes[i].w + COLUMN_GAP;
            layer_h = layer_h.max(boxes[i].h);
        }
        width = width.max(x - COLUMN_GAP);
        height = height.max(y + layer_h);
        y += layer_h + LAYER_GAP;
    }
    let mut edges: Vec<Edge> = Vec::new();
    for fk in &schema.foreign_keys {
        let frm = index.get(&fk.from_table).copied();
        let to = index.get(&fk.to_table).copied();
        if let (Some(frm), Some(to)) = (frm, to) {
            let x1 = boxes[to].x + r_half_up(boxes[to].w as f64 / 2.0);
            let y1 = boxes[to].y + boxes[to].h;
            let x2 = boxes[frm].x + r_half_up(boxes[frm].w as f64 / 2.0);
            let y2 = boxes[frm].y;
            let mid_y = r_half_up((y1 + y2) as f64 / 2.0);
            edges.push(Edge {
                fk: fk.clone(),
                path: format!("M {} {} V {} H {} V {}", x1, y1, mid_y, x2, y2),
                label: format!("{} → {}", fk.from_column, fk.to_column.clone().unwrap_or_else(|| "id".into())),
            });
        }
    }
    let laid: Vec<LaidOutTable> = schema.tables.iter().enumerate().map(|(i, t)| {
        let rows: Vec<Box> = (0..t.columns.len()).map(|ci| Box {
            x: boxes[i].x,
            y: boxes[i].y + ROW_HEIGHT * (1 + ci as i32),
            w: boxes[i].w,
            h: ROW_HEIGHT,
        }).collect();
        LaidOutTable {
            table: t.clone(),
            box_: boxes[i],
            title_bar: Box { x: boxes[i].x, y: boxes[i].y, w: boxes[i].w, h: ROW_HEIGHT },
            column_rows: rows,
        }
    }).collect();
    Geometry { width, height, tables: laid, edges }
}

fn esc(s: &str) -> String {
    let mut out = String::new();
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            c => out.push(c),
        }
    }
    out
}

pub fn render_svg(geo: &Geometry) -> String {
    let mut out = format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {} {}\" class=\"sv-root\" role=\"img\"><title>Schema diagram</title>",
        geo.width, geo.height
    );
    let mut box_of: std::collections::HashMap<String, Box> = std::collections::HashMap::new();
    for t in &geo.tables { box_of.entry(t.table.name.clone()).or_insert(t.box_); }
    for e in &geo.edges {
        let frm = match box_of.get(&e.fk.from_table) { Some(b) => *b, None => continue };
        let ax = frm.x + r_half_up(frm.w as f64 / 2.0);
        out.push_str(&format!("<path class=\"sv-edge\" d=\"{}\"/>", e.path));
        out.push_str(&format!(
            "<polygon class=\"sv-arrow\" points=\"{},{} {},{} {},{}\"/>",
            ax - 5, frm.y - 8, ax + 5, frm.y - 8, ax, frm.y
        ));
    }
    for t in &geo.tables {
        let b = t.box_;
        let tb = t.title_bar;
        out.push_str(&format!(
            "<g class=\"sv-table\"><rect class=\"sv-box\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"6\"/>",
            b.x, b.y, b.w, b.h
        ));
        out.push_str(&format!(
            "<rect class=\"sv-titlebar\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"6\"/>",
            tb.x, tb.y, tb.w, tb.h
        ));
        out.push_str(&format!(
            "<text class=\"sv-title\" x=\"{}\" y=\"{}\">{}</text>",
            b.x + 8, tb.y + 17, esc(&t.table.name)
        ));
        for (ci, c) in t.table.columns.iter().enumerate() {
            let row = t.column_rows[ci];
            let cls = if c.is_primary_key { "sv-pk" } else { "sv-col" };
            out.push_str(&format!(
                "<text class=\"{}\" x=\"{}\" y=\"{}\">{} {}</text>",
                cls, row.x + 8, row.y + 17, esc(&c.name), esc(&c.type_)
            ));
        }
        out.push_str("</g>");
    }
    out.push_str("</svg>");
    out
}

pub fn ddl_to_svg(ddl: &str) -> (String, ParsedSchema) {
    let schema = parse_ddl(ddl);
    (render_svg(&layout_schema(&schema)), schema)
}

// Example:
//   let (svg, _) = ddl_to_svg("CREATE TABLE users (id INT PRIMARY KEY);" +
//       "CREATE TABLE posts (id INT PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT);");
//   → users box on layer 0, posts below, one FK edge — byte-identical to the
//     TS/Go/… ports (integer geometry, same defaults).

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →