Skip to content

ER/Schema Visualizer — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
//!
//! Language: Zig (0.16, standard library only)
//! Source:   CosmoDev polyglot showcase port of the `schema-visualizer` tool;
//!           canonical TypeScript lives in src/lib/schema-visualizer.ts and the
//!           verified ports in src/tool-sources/schema-visualizer/{javascript.js,
//!           python.py, ruby.rb, php.php} + cli/schema-visualizer (Go twin).
//! License:  display source — part of CosmoDev's polyglot tool pages.
//!
//! Tolerant common subset of Postgres/MySQL/SQLite: unparseable statements
//! degrade to notes, never error. Geometry is integer-only (half-up rounding,
//! JS Math.round parity), so this port draws the byte-identical diagram.
//!
//! Everything `ddlToSvg` allocates lives in one arena owned by the returned
//! `Result`; the caller frees everything with `result.deinit()` (which returns
//! the memory to the allocator passed in).
//!
//! Test: zig test zig.zig

const std = @import("std");

const Allocator = std.mem.Allocator;

/// Integer layout constants shared by every port (see LAYOUT in the TS source).
const row_height: i64 = 24;
const char_width: i64 = 7;
const padding: i64 = 8;
const layer_gap: i64 = 60;
const column_gap: i64 = 40;

/// Word tokens that end a column's type and start its modifiers.
const modifiers = [_][]const u8{
    "NOT",           "NULL",     "PRIMARY", "KEY",     "UNIQUE",
    "DEFAULT",       "REFERENCES", "AUTO_INCREMENT", "AUTOINCREMENT", "ON",
    "COMMENT",       "CHECK",    "CONSTRAINT",
};

// ---------------------------------------------------------------------------
// Data model (mirrors the TS `Schema`, snake_case per Zig convention)
// ---------------------------------------------------------------------------

pub const Column = struct {
    name: []const u8,
    type: []const u8,
    nullable: bool = true,
    is_primary_key: bool = false,
};

pub const Table = struct {
    name: []const u8,
    columns: []const Column = &.{},
};

/// FK as emitted after resolution: `to_column` is always set.
pub const ForeignKey = struct {
    from_table: []const u8,
    from_column: []const u8,
    to_table: []const u8,
    to_column: []const u8,
};

/// FK as parsed: `to_column` may still be null (resolved to the target
/// table's first primary key, or `id`, after the whole DDL is parsed).
const RawFk = struct {
    from_table: []const u8,
    from_column: []const u8,
    to_table: []const u8,
    to_column: ?[]const u8,
};

pub const Schema = struct {
    tables: []const Table = &.{},
    foreign_keys: []const ForeignKey = &.{},
    notes: []const []const u8 = &.{},
};

// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------

const TokenKind = enum { word, string, qident, punct };

const Token = struct {
    kind: TokenKind,
    /// Word/punct text aliases the input; quoted text is arena-owned with the
    /// quotes stripped and SQL '' escapes folded to '.
    text: []const u8,
};

/// Token at index i, or null past the end — the role `_g(toks, i)` plays in
/// the Python port.
fn g(toks: []const Token, i: usize) ?Token {
    return if (i < toks.len) toks[i] else null;
}

fn isP(tok: ?Token, p: u8) bool {
    const t = tok orelse return false;
    return t.kind == .punct and t.text.len == 1 and t.text[0] == p;
}

/// Case-insensitive keyword test — `tok.text.toUpperCase() === w` in JS.
fn kw(tok: ?Token, w: []const u8) bool {
    const t = tok orelse return false;
    return t.kind == .word and std.ascii.eqlIgnoreCase(t.text, w);
}

fn isModifier(text: []const u8) bool {
    for (modifiers) |m| if (std.ascii.eqlIgnoreCase(text, m)) return true;
    return false;
}

fn isBlank(s: []const u8) bool {
    for (s) |c| if (!std.ascii.isWhitespace(c)) return false;
    return true;
}

/// Split on `;` outside strings/quoted identifiers. Statements are plain
/// substrings of the input (quotes are copied verbatim). Depth-agnostic: an
/// unterminated paren cannot swallow the statements after it.
fn splitStatements(arena: Allocator, ddl: []const u8) error{OutOfMemory}![]const []const u8 {
    var out: std.ArrayList([]const u8) = .empty;
    var start: usize = 0;
    var i: usize = 0;
    while (i < ddl.len) {
        const ch = ddl[i];
        if (ch == '\'' or ch == '"' or ch == '`' or ch == '[') {
            const close: u8 = if (ch == '[') ']' else ch;
            i += 1;
            while (i < ddl.len) {
                if (ddl[i] == close) {
                    if (close == '\'' and i + 1 < ddl.len and ddl[i + 1] == '\'') {
                        i += 2; // SQL doubled-quote escape
                        continue;
                    }
                    break;
                }
                i += 1;
            }
            i += 1; // past the closing quote
            continue;
        }
        if (ch == ';') {
            try out.append(arena, ddl[start..i]);
            start = i + 1;
            i += 1;
            continue;
        }
        i += 1;
    }
    if (!isBlank(ddl[start..])) try out.append(arena, ddl[start..]);
    return out.toOwnedSlice(arena);
}

/// Chars that terminate a word: whitespace, quotes, and the punct set. Note
/// `]` is a terminator too — a stray `]` would loop forever in the JS/Python
/// ports, so here it is swallowed as a one-char word to guarantee progress.
fn isWordStop(c: u8) bool {
    return c == '"' or c == '\'' or c == ',' or c == '(' or c == ')' or
        c == '.' or c == '`' or c == '[' or c == ']';
}

/// Tokens: quoted identifiers/strings carry their text (quotes stripped);
/// ( ) , . are punct; everything else is a word.
fn tokenize(arena: Allocator, s: []const u8) error{OutOfMemory}![]Token {
    var toks: std.ArrayList(Token) = .empty;
    var i: usize = 0;
    while (i < s.len) {
        const ch = s[i];
        if (std.ascii.isWhitespace(ch)) {
            i += 1;
            continue;
        }
        if (ch == '\'' or ch == '"' or ch == '`' or ch == '[') {
            const close: u8 = if (ch == '[') ']' else ch;
            var text: std.ArrayList(u8) = .empty;
            i += 1;
            while (i < s.len) {
                if (s[i] == close) {
                    if (close == '\'' and i + 1 < s.len and s[i + 1] == '\'') {
                        try text.append(arena, '\'');
                        i += 2;
                        continue;
                    }
                    break;
                }
                try text.append(arena, s[i]);
                i += 1;
            }
            i += 1;
            try toks.append(arena, .{
                .kind = if (ch == '\'') .string else .qident,
                .text = try text.toOwnedSlice(arena),
            });
            continue;
        }
        if (ch == '(' or ch == ')' or ch == ',' or ch == '.') {
            try toks.append(arena, .{ .kind = .punct, .text = s[i .. i + 1] });
            i += 1;
            continue;
        }
        var j = i;
        while (j < s.len and !std.ascii.isWhitespace(s[j]) and !isWordStop(s[j])) j += 1;
        if (j == i) j = i + 1; // stray `]` — consume one byte, see isWordStop
        try toks.append(arena, .{ .kind = .word, .text = s[i..j] });
        i = j;
    }
    return toks.toOwnedSlice(arena);
}

// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------

const NameRef = struct { name: []const u8, end: usize };

/// `name` or `schema.name` (dotted segments are joined back with '.').
fn takeName(arena: Allocator, toks: []const Token, i: usize) error{OutOfMemory}!?NameRef {
    const first = g(toks, i) orelse return null;
    if (first.kind != .qident and first.kind != .word) return null;
    var name: std.ArrayList(u8) = .empty;
    try name.appendSlice(arena, first.text);
    var j = i + 1;
    while (true) {
        if (!isP(g(toks, j), '.')) break;
        const next = g(toks, j + 1) orelse break;
        if (next.kind != .qident and next.kind != .word) break;
        try name.append(arena, '.');
        try name.appendSlice(arena, next.text);
        j += 2;
    }
    return NameRef{ .name = try name.toOwnedSlice(arena), .end = j };
}

const NamesRef = struct { names: []const []const u8, end: usize };

/// `( a , b , c )` → the names plus the index just past the ')'.
fn parenList(arena: Allocator, toks: []const Token, i: usize) error{OutOfMemory}!?NamesRef {
    if (!isP(g(toks, i), '(')) return null;
    var names: std.ArrayList([]const u8) = .empty;
    var j = i + 1;
    while (true) {
        const n = (try takeName(arena, toks, j)) orelse return null;
        try names.append(arena, n.name);
        j = n.end;
        if (isP(g(toks, j), ',')) {
            j += 1;
            continue;
        }
        if (isP(g(toks, j), ')'))
            return NamesRef{ .names = try names.toOwnedSlice(arena), .end = j + 1 };
        return null;
    }
}

/// Join type tokens with spaces, then collapse `\s*([(),])\s*` to the punct
/// char, trim, and uppercase — exactly the JS `.replace(...)` chain. The
/// whitespace run is buffered so inner runs (e.g. inside quoted type text)
/// survive verbatim, like the regex leaves them.
fn joinType(arena: Allocator, toks: []const Token) error{OutOfMemory}![]const u8 {
    var joined: std.ArrayList(u8) = .empty;
    for (toks, 0..) |t, ti| {
        if (ti > 0) try joined.append(arena, ' ');
        try joined.appendSlice(arena, t.text);
    }
    var out: std.ArrayList(u8) = .empty;
    var pending: std.ArrayList(u8) = .empty;
    var prev_punct = false; // whitespace right after ( ) , is swallowed too
    for (joined.items) |c| {
        if (std.ascii.isWhitespace(c)) {
            if (!prev_punct) try pending.append(arena, c);
            continue;
        }
        if (c == '(' or c == ')' or c == ',') {
            pending.clearRetainingCapacity();
            try out.append(arena, c);
            prev_punct = true;
            continue;
        }
        if (pending.items.len > 0 and out.items.len > 0)
            try out.appendSlice(arena, pending.items);
        pending.clearRetainingCapacity();
        try out.append(arena, c);
        prev_punct = false;
    }
    const up = try arena.alloc(u8, out.items.len);
    return std.ascii.upperString(up, out.items);
}

/// Parse one column line; unknown modifiers are tolerated and skipped.
/// Inline `REFERENCES` clauses append to `raw_fks` as a side effect.
fn parseColumn(
    arena: Allocator,
    line: []const Token,
    table_name: []const u8,
    raw_fks: *std.ArrayList(RawFk),
) ParseError!?Column {
    const name = (try takeName(arena, line, 0)) orelse return null;
    var i = name.end;
    var type_toks: std.ArrayList(Token) = .empty;
    while (i < line.len and !(line[i].kind == .word and isModifier(line[i].text))) {
        try type_toks.append(arena, line[i]);
        i += 1;
    }
    var nullable = true;
    var pk = false;
    while (i < line.len) {
        const t = line[i];
        if (kw(t, "NOT") and kw(g(line, i + 1), "NULL")) {
            nullable = false;
            i += 2;
            continue;
        }
        if (kw(t, "NULL")) {
            i += 1;
            continue;
        }
        if (kw(t, "PRIMARY") and kw(g(line, i + 1), "KEY")) {
            pk = true;
            nullable = false;
            i += 2;
            continue;
        }
        if (kw(t, "UNIQUE") or kw(t, "AUTO_INCREMENT") or kw(t, "AUTOINCREMENT")) {
            i += 1;
            continue;
        }
        if (kw(t, "DEFAULT")) {
            i += 1;
            if (isP(g(line, i), '(')) {
                // Skip the balanced paren group (do-while in the JS port).
                var d: i64 = 0;
                while (true) {
                    if (isP(line[i], '(')) d += 1;
                    if (isP(line[i], ')')) d -= 1;
                    i += 1;
                    if (!(i < line.len and d > 0)) break;
                }
            } else if (i < line.len) {
                i += 1; // DEFAULT <one token>
            }
            continue;
        }
        if (kw(t, "COMMENT")) {
            i += 1;
            if (g(line, i)) |nt| {
                if (nt.kind == .string) i += 1;
            }
            continue;
        }
        if (kw(t, "ON")) {
            i += 2; // ON UPDATE / ON DELETE ...
            if (kw(g(line, i), "SET") or kw(g(line, i), "NO")) {
                i += 2; // ... SET NULL / NO ACTION
            } else if (i < line.len) {
                i += 1; // ON <something else>
            }
            continue;
        }
        if (kw(t, "REFERENCES")) {
            i += 1;
            if (try takeName(arena, line, i)) |target| {
                i = target.end;
                var to_col: ?[]const u8 = null;
                if (isP(g(line, i), '(')) {
                    if (try parenList(arena, line, i)) |lst| {
                        to_col = lst.names[0];
                        i = lst.end;
                    }
                }
                try raw_fks.append(arena, .{
                    .from_table = table_name,
                    .from_column = name.name,
                    .to_table = target.name,
                    .to_column = to_col,
                });
            }
            continue;
        }
        i += 1; // unknown modifier tolerated
    }
    return Column{
        .name = name.name,
        .type = try joinType(arena, type_toks.items),
        .nullable = nullable,
        .is_primary_key = pk,
    };
}

const ParseError = error{ Unparseable, OutOfMemory };

/// One `CREATE TABLE` statement: throws (returns error.Unparseable) exactly
/// where the JS port throws, degrades the rest to notes.
fn parseStatement(
    arena: Allocator,
    toks: []const Token,
    tables: *std.ArrayList(Table),
    raw_fks: *std.ArrayList(RawFk),
    notes: *std.ArrayList([]const u8),
) ParseError!void {
    var i: usize = 0;
    if (!kw(g(toks, i), "CREATE")) return error.Unparseable;
    i += 1;
    while (kw(g(toks, i), "TEMP") or kw(g(toks, i), "TEMPORARY") or kw(g(toks, i), "UNLOGGED")) i += 1;
    if (!kw(g(toks, i), "TABLE")) {
        try notes.append(arena, "Skipped non-table statement.");
        return;
    }
    i += 1;
    if (kw(g(toks, i), "IF") and kw(g(toks, i + 1), "NOT") and kw(g(toks, i + 2), "EXISTS")) i += 3;
    const name = (try takeName(arena, toks, i)) orelse return error.Unparseable;
    if (!isP(g(toks, name.end), '(')) return error.Unparseable;
    i = name.end + 1;

    // Body = tokens until the matching close paren (depth-aware).
    var body: std.ArrayList(Token) = .empty;
    var depth: i64 = 0;
    while (i < toks.len) : (i += 1) {
        if (isP(toks[i], '(')) depth += 1;
        if (isP(toks[i], ')')) {
            if (depth == 0) break;
            depth -= 1;
        }
        try body.append(arena, toks[i]);
    }
    if (i >= toks.len) return error.Unparseable; // unterminated

    // Split the body into top-level comma lines (depth is 0 at the break).
    var lines: std.ArrayList([]const Token) = .empty;
    var line: std.ArrayList(Token) = .empty;
    depth = 0;
    for (body.items) |t| {
        if (isP(t, '(')) depth += 1;
        if (isP(t, ')')) depth -= 1;
        if (isP(t, ',') and depth == 0) {
            try lines.append(arena, try line.toOwnedSlice(arena));
            line = .empty;
            continue;
        }
        try line.append(arena, t);
    }
    if (line.items.len > 0) try lines.append(arena, try line.toOwnedSlice(arena));

    // The table joins the list before its lines are parsed so that
    // self-referencing FKs resolve later.
    try tables.append(arena, .{ .name = name.name });
    const table = &tables.items[tables.items.len - 1];
    var cols: std.ArrayList(Column) = .empty;
    for (lines.items) |toks2| {
        if (toks2.len == 0) continue;
        const first = toks2[0];
        if (first.kind == .word and kw(first, "PRIMARY") and kw(g(toks2, 1), "KEY")) {
            if (try parenList(arena, toks2, 2)) |lst| {
                for (lst.names) |cn| {
                    for (cols.items) |*c| {
                        if (std.mem.eql(u8, c.name, cn)) {
                            c.is_primary_key = true;
                            c.nullable = false;
                        }
                    }
                }
            }
            continue;
        }
        if (first.kind == .word and kw(first, "FOREIGN") and kw(g(toks2, 1), "KEY")) {
            if (try parenList(arena, toks2, 2)) |frm| {
                if (kw(g(toks2, frm.end), "REFERENCES")) {
                    if (try takeName(arena, toks2, frm.end + 1)) |target| {
                        var to_cols: ?[]const []const u8 = null;
                        if (isP(g(toks2, target.end), '(')) {
                            if (try parenList(arena, toks2, target.end)) |to| to_cols = to.names;
                        }
                        for (frm.names, 0..) |fc, idx| {
                            var to_col: ?[]const u8 = null;
                            if (to_cols) |tc|
                                to_col = if (idx < tc.len) tc[idx] else tc[tc.len - 1];
                            try raw_fks.append(arena, .{
                                .from_table = table.name,
                                .from_column = fc,
                                .to_table = target.name,
                                .to_column = to_col,
                            });
                        }
                    }
                }
            }
            continue;
        }
        if (first.kind == .word and (kw(first, "UNIQUE") or kw(first, "KEY") or
            kw(first, "INDEX") or kw(first, "CHECK") or kw(first, "EXCLUDE") or
            kw(first, "CONSTRAINT"))) continue;
        if (try parseColumn(arena, toks2, table.name, raw_fks)) |col|
            try cols.append(arena, col);
    }
    table.columns = try cols.toOwnedSlice(arena);
}

/// Parse DDL text into tables, foreign keys (targets resolved), and notes.
/// Never fails on bad input — the worst outcome is a note.
pub fn parseDdl(arena: Allocator, ddl: []const u8) error{OutOfMemory}!Schema {
    if (isBlank(ddl))
        return .{ .notes = &.{"No DDL input."} };
    var tables: std.ArrayList(Table) = .empty;
    var raw_fks: std.ArrayList(RawFk) = .empty;
    var notes: std.ArrayList([]const u8) = .empty;
    for (try splitStatements(arena, ddl)) |stmt| {
        if (isBlank(stmt)) continue;
        const toks = try tokenize(arena, stmt);
        parseStatement(arena, toks, &tables, &raw_fks, &notes) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            error.Unparseable => try notes.append(arena, "Skipped unparseable statement."),
        };
    }
    // Resolve omitted FK target columns to the referenced table's first PK.
    const fks = try arena.alloc(ForeignKey, raw_fks.items.len);
    for (raw_fks.items, 0..) |raw, fi| {
        var to_col: ?[]const u8 = raw.to_column;
        if (to_col == null) {
            to_col = "id";
            outer: for (tables.items) |t| {
                if (std.mem.eql(u8, t.name, raw.to_table)) {
                    for (t.columns) |c| {
                        if (c.is_primary_key) {
                            to_col = c.name;
                            break;
                        }
                    }
                    break :outer;
                }
            }
        }
        fks[fi] = .{
            .from_table = raw.from_table,
            .from_column = raw.from_column,
            .to_table = raw.to_table,
            .to_column = to_col.?,
        };
    }
    return .{
        .tables = try tables.toOwnedSlice(arena),
        .foreign_keys = fks,
        .notes = try notes.toOwnedSlice(arena),
    };
}

// ---------------------------------------------------------------------------
// Layout — integer geometry, half-up rounding (JS Math.round parity)
// ---------------------------------------------------------------------------

pub const Box = struct { x: i64 = 0, y: i64 = 0, w: i64, h: i64 };
pub const Row = struct { x: i64, y: i64, w: i64, h: i64 };

pub const LaidTable = struct {
    table: Table,
    box: Box,
    title_bar: Box,
    column_rows: []const Row,
};

pub const Edge = struct {
    fk: ForeignKey,
    /// SVG path data, e.g. "M 29 56 V 86 H 47 V 116".
    path: []const u8,
    /// "from_col → to_col" (kept for parity; not rendered).
    label: []const u8,
};

pub const Geometry = struct {
    width: i64,
    height: i64,
    tables: []const LaidTable = &.{},
    edges: []const Edge = &.{},
};

/// v / 2 rounded half-up for v >= 0 — the only rounding the JS port's
/// Math.round ever does here (w/2 and (y1+y2)/2 of non-negative integers).
fn halfUpDiv2(v: i64) i64 {
    return @divTrunc(v + 1, 2);
}

/// Longest-path layering (relaxation, at most n passes), then pack each
/// layer left-to-right and stack layers top-to-bottom.
pub fn layoutSchema(arena: Allocator, schema: Schema) error{OutOfMemory}!Geometry {
    if (schema.tables.len == 0) return .{ .width = 0, .height = 0 };
    const index = try buildIndex(arena, schema.tables);

    const boxes = try arena.alloc(Box, schema.tables.len);
    for (schema.tables, 0..) |t, i| {
        var text_len: i64 = @intCast(t.name.len);
        for (t.columns) |c|
            text_len = @max(text_len, @as(i64, @intCast(c.name.len)) + 1 + @as(i64, @intCast(c.type.len)));
        text_len = @max(text_len, 1);
        // w/h are exact integers — Math.round is the identity here.
        boxes[i] = .{
            .w = text_len * char_width + 2 * padding,
            .h = row_height * (1 + @as(i64, @intCast(t.columns.len))) + padding,
        };
    }

    const layer_of = try arena.alloc(i64, schema.tables.len);
    @memset(layer_of, 0);
    var pass: usize = 0;
    while (pass < schema.tables.len) : (pass += 1) {
        var changed = false;
        for (schema.foreign_keys) |fk| {
            const ti = index.get(fk.from_table) orelse continue;
            const tj = index.get(fk.to_table) orelse continue;
            if (ti == tj) continue;
            if (layer_of[ti] < layer_of[tj] + 1) {
                layer_of[ti] = layer_of[tj] + 1;
                changed = true;
            }
        }
        if (!changed) break;
    }

    var layers: std.ArrayList(std.ArrayList(usize)) = .empty;
    for (layer_of, 0..) |l, i| {
        while (layers.items.len <= @as(usize, @intCast(l)))
            try layers.append(arena, .empty);
        try layers.items[@intCast(l)].append(arena, i);
    }

    var y: i64 = 0;
    var width: i64 = 0;
    var height: i64 = 0;
    for (layers.items) |layer| {
        var x: i64 = 0;
        var layer_h: i64 = 0;
        for (layer.items) |i| {
            boxes[i].x = x;
            boxes[i].y = y;
            x += boxes[i].w + column_gap;
            layer_h = @max(layer_h, boxes[i].h);
        }
        width = @max(width, x - column_gap);
        height = @max(height, y + layer_h);
        y += layer_h + layer_gap;
    }

    var edges: std.ArrayList(Edge) = .empty;
    for (schema.foreign_keys) |fk| {
        const fi = index.get(fk.from_table) orelse continue;
        const ti = index.get(fk.to_table) orelse continue;
        const from = boxes[fi];
        const to = boxes[ti];
        const x1 = to.x + halfUpDiv2(to.w);
        const y1 = to.y + to.h;
        const x2 = from.x + halfUpDiv2(from.w);
        const y2 = from.y;
        const mid_y = halfUpDiv2(y1 + y2);
        try edges.append(arena, .{
            .fk = fk,
            .path = try std.fmt.allocPrint(
                arena,
                "M {d} {d} V {d} H {d} V {d}",
                .{ x1, y1, mid_y, x2, y2 },
            ),
            .label = try std.fmt.allocPrint(
                arena,
                "{s} → {s}",
                .{ fk.from_column, fk.to_column },
            ),
        });
    }

    var laid: std.ArrayList(LaidTable) = .empty;
    for (schema.tables, 0..) |t, i| {
        const rows = try arena.alloc(Row, t.columns.len);
        for (t.columns, 0..) |_, ci| {
            rows[ci] = .{
                .x = boxes[i].x,
                .y = boxes[i].y + row_height * (1 + @as(i64, @intCast(ci))),
                .w = boxes[i].w,
                .h = row_height,
            };
        }
        try laid.append(arena, .{
            .table = t,
            .box = boxes[i],
            .title_bar = .{ .x = boxes[i].x, .y = boxes[i].y, .w = boxes[i].w, .h = row_height },
            .column_rows = rows,
        });
    }
    return .{
        .width = width,
        .height = height,
        .tables = try laid.toOwnedSlice(arena),
        .edges = try edges.toOwnedSlice(arena),
    };
}

/// table name → first index (setdefault semantics, like the other ports).
fn buildIndex(arena: Allocator, tables: []const Table) error{OutOfMemory}!std.StringHashMap(usize) {
    var index = std.StringHashMap(usize).init(arena);
    for (tables, 0..) |t, i| {
        if (!index.contains(t.name)) try index.put(t.name, i);
    }
    return index;
}

// ---------------------------------------------------------------------------
// SVG rendering — byte-identical to the TS/Go/JS/Python/Ruby/PHP ports
// ---------------------------------------------------------------------------

/// XML-escape the four characters that matter inside attribute-free text.
fn esc(arena: Allocator, s: []const u8) error{OutOfMemory}![]const u8 {
    var out: std.ArrayList(u8) = .empty;
    for (s) |c| {
        const rep: ?[]const u8 = switch (c) {
            '&' => "&amp;",
            '<' => "&lt;",
            '>' => "&gt;",
            '"' => "&quot;",
            else => null,
        };
        if (rep) |r| try out.appendSlice(arena, r) else try out.append(arena, c);
    }
    return out.items;
}

pub fn renderSvg(arena: Allocator, geo: Geometry) error{OutOfMemory}![]const u8 {
    var out: std.ArrayList(u8) = .empty;
    try out.appendSlice(arena, try std.fmt.allocPrint(
        arena,
        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {d} {d}\" " ++
            "class=\"sv-root\" role=\"img\"><title>Schema diagram</title>",
        .{ geo.width, geo.height },
    ));
    // Name → box, last duplicate wins (JS Map / Python dict semantics).
    var box_of = std.StringHashMap(Box).init(arena);
    for (geo.tables) |t| try box_of.put(t.table.name, t.box);
    for (geo.edges) |e| {
        const from = box_of.get(e.fk.from_table) orelse continue;
        const ax = from.x + halfUpDiv2(from.w);
        try out.appendSlice(arena, try std.fmt.allocPrint(
            arena,
            "<path class=\"sv-edge\" d=\"{s}\"/>" ++
                "<polygon class=\"sv-arrow\" points=\"{d},{d} {d},{d} {d},{d}\"/>",
            .{ e.path, ax - 5, from.y - 8, ax + 5, from.y - 8, ax, from.y },
        ));
    }
    for (geo.tables) |t| {
        const b = t.box;
        const tb = t.title_bar;
        try out.appendSlice(arena, try std.fmt.allocPrint(
            arena,
            "<g class=\"sv-table\"><rect class=\"sv-box\" x=\"{d}\" y=\"{d}\" width=\"{d}\" height=\"{d}\" rx=\"6\"/>" ++
                "<rect class=\"sv-titlebar\" x=\"{d}\" y=\"{d}\" width=\"{d}\" height=\"{d}\" rx=\"6\"/>" ++
                "<text class=\"sv-title\" x=\"{d}\" y=\"{d}\">{s}</text>",
            .{ b.x, b.y, b.w, b.h, tb.x, tb.y, tb.w, tb.h, b.x + 8, tb.y + 17, try esc(arena, t.table.name) },
        ));
        for (t.table.columns, t.column_rows) |c, row| {
            try out.appendSlice(arena, try std.fmt.allocPrint(
                arena,
                "<text class=\"{s}\" x=\"{d}\" y=\"{d}\">{s} {s}</text>",
                .{
                    if (c.is_primary_key) "sv-pk" else "sv-col",
                    row.x + 8,
                    row.y + 17,
                    try esc(arena, c.name),
                    try esc(arena, c.type),
                },
            ));
        }
        try out.appendSlice(arena, "</g>");
    }
    try out.appendSlice(arena, "</svg>");
    return out.items;
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

/// The parse + layout + render result. `deinit` frees the SVG, the schema,
/// and every scratch allocation (one arena over the caller's allocator).
pub const Result = struct {
    svg: []const u8,
    schema: Schema,
    arena: std.heap.ArenaAllocator,

    pub fn deinit(self: *Result) void {
        self.arena.deinit();
    }
};

/// Pure function: DDL text → { svg, schema }. Never fails on bad input.
pub fn ddlToSvg(allocator: Allocator, ddl: []const u8) error{OutOfMemory}!Result {
    var arena = std.heap.ArenaAllocator.init(allocator);
    errdefer arena.deinit();
    const a = arena.allocator();
    const schema = try parseDdl(a, ddl);
    const svg = try renderSvg(a, try layoutSchema(a, schema));
    return .{ .svg = svg, .schema = schema, .arena = arena };
}

// ---------------------------------------------------------------------------
// Tests — expected SVGs generated with the verified python.py port, so any
// divergence here means the byte-identical contract broke.
// ---------------------------------------------------------------------------

const testing = std.testing;

const example_svg =
    \\<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93 220" class="sv-root" role="img"><title>Schema diagram</title><path class="sv-edge" d="M 29 56 V 86 H 47 V 116"/><polygon class="sv-arrow" points="42,108 52,108 47,116"/><g class="sv-table"><rect class="sv-box" x="0" y="0" width="58" height="56" rx="6"/><rect class="sv-titlebar" x="0" y="0" width="58" height="24" rx="6"/><text class="sv-title" x="8" y="17">users</text><text class="sv-pk" x="8" y="41">id INT</text></g><g class="sv-table"><rect class="sv-box" x="0" y="116" width="93" height="104" rx="6"/><rect class="sv-titlebar" x="0" y="116" width="93" height="24" rx="6"/><text class="sv-title" x="8" y="133">posts</text><text class="sv-pk" x="8" y="157">id INT</text><text class="sv-col" x="8" y="181">user_id INT</text><text class="sv-col" x="8" y="205">title TEXT</text></g></svg>
;

test "two-table example is byte-identical to the TS/Go/JS/Py ports" {
    var r = try ddlToSvg(testing.allocator,
        "CREATE TABLE users (id INT PRIMARY KEY);" ++
            "CREATE TABLE posts (id INT PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT);");
    defer r.deinit();
    try testing.expectEqualStrings(example_svg, r.svg);
    try testing.expectEqual(@as(usize, 2), r.schema.tables.len);
    try testing.expectEqual(@as(usize, 1), r.schema.foreign_keys.len);
    try testing.expectEqualStrings("user_id", r.schema.foreign_keys[0].from_column);
    try testing.expectEqualStrings("id", r.schema.foreign_keys[0].to_column);
}

test "blank DDL degrades to a note on an empty canvas" {
    var r = try ddlToSvg(testing.allocator, "   ");
    defer r.deinit();
    try testing.expectEqual(@as(usize, 1), r.schema.notes.len);
    try testing.expectEqualStrings("No DDL input.", r.schema.notes[0]);
    try testing.expectEqualStrings(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 0 0\" " ++
            "class=\"sv-root\" role=\"img\"><title>Schema diagram</title></svg>",
        r.svg,
    );
}

test "skipped statements, PK constraints, FK resolution, layered layout" {
    var r = try ddlToSvg(testing.allocator,
        "CREATE INDEX idx ON t(c);" ++
            "CREATE TABLE orders (oid BIGINT, customer_id INT REFERENCES customers, " ++
            "note TEXT NOT NULL, amount NUMERIC( 10 , 2 ) DEFAULT 0, PRIMARY KEY (oid, note));" ++
            "CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR(40));" ++
            "CREATE BOGUS;" ++
            "CREATE TABLE bad (unclosed;");
    defer r.deinit();
    try testing.expectEqual(@as(usize, 3), r.schema.notes.len);
    // orders is defined before customers but FK layering puts it below.
    try testing.expectEqualStrings("orders", r.schema.tables[0].name);
    try testing.expect(r.schema.tables[0].columns[2].is_primary_key); // note TEXT via PK constraint
    try testing.expectEqualStrings("NUMERIC(10,2)", r.schema.tables[0].columns[3].type);
    // FK without target column resolves to the target's first PK.
    try testing.expectEqualStrings("id", r.schema.foreign_keys[0].to_column);
    try testing.expectEqualStrings(messy_svg, r.svg);
}

const messy_svg =
    \\<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 156 268" class="sv-root" role="img"><title>Schema diagram</title><path class="sv-edge" d="M 64 80 V 110 H 78 V 140"/><polygon class="sv-arrow" points="73,132 83,132 78,140"/><g class="sv-table"><rect class="sv-box" x="0" y="140" width="156" height="128" rx="6"/><rect class="sv-titlebar" x="0" y="140" width="156" height="24" rx="6"/><text class="sv-title" x="8" y="157">orders</text><text class="sv-pk" x="8" y="181">oid BIGINT</text><text class="sv-col" x="8" y="205">customer_id INT</text><text class="sv-pk" x="8" y="229">note TEXT</text><text class="sv-col" x="8" y="253">amount NUMERIC(10,2)</text></g><g class="sv-table"><rect class="sv-box" x="0" y="0" width="128" height="80" rx="6"/><rect class="sv-titlebar" x="0" y="0" width="128" height="24" rx="6"/><text class="sv-title" x="8" y="17">customers</text><text class="sv-pk" x="8" y="41">id INT</text><text class="sv-col" x="8" y="65">name VARCHAR(40)</text></g></svg>
;

test "quoted names escape for XML; DEFAULT()/ON/COMMENT tolerated" {
    var r = try ddlToSvg(testing.allocator,
        "CREATE TABLE \"weird & <name>\" (id INT PRIMARY KEY, meta JSON DEFAULT ('{}'::jsonb), " ++
            "tag VARCHAR(9) COMMENT 'hi' ON UPDATE CASCADE);" ++
            "CREATE TABLE child (a INT, b INT, FOREIGN KEY (a, b) REFERENCES \"weird & <name>\" (id, nope));");
    defer r.deinit();
    try testing.expect(r.schema.notes.len == 0);
    // multi-column FK: positional mapping a→id, b→nope
    try testing.expectEqualStrings("id", r.schema.foreign_keys[0].to_column);
    try testing.expectEqualStrings("nope", r.schema.foreign_keys[1].to_column);
    try testing.expect(std.mem.indexOf(u8, r.svg, ">weird &amp; &lt;name&gt;</text>") != null);
    try testing.expect(std.mem.indexOf(u8, r.svg, ">tag VARCHAR(9)</text>") != null);
}

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 →