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("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
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 →