JSON ↔ CSV Converter — Zig source
Convert a JSON array of objects to CSV and back. Handles quoted fields, embedded commas, newlines and escaped quotes (RFC 4180). 100% in-browser.
This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
// =============================================================================
// json-csv — Zig port
// =============================================================================
// Convert between JSON and RFC 4180 CSV in either direction:
// • jsonToCsv — serialize a JSON document (object or array of objects) to CSV
// • csvToJson — parse RFC 4180 CSV (with quoting) into a list of row objects
//
// Language: Zig 0.13 — std.json from the standard library.
// Source: CosmoDev polyglot showcase port of json-csv,
// ported from src/lib/csv.ts (the canonical, live TypeScript lib).
// License: display source — part of CosmoDev's polyglot tool pages.
//
// Pure and deterministic — depends only on its inputs. RFC 4180 quoting: any
// field containing a comma, double quote, carriage return, or line feed is
// wrapped in double quotes, and each embedded quote is doubled.
//
// This is display source — part of CosmoDev's polyglot tool pages.
// =============================================================================
// Zig's stdlib ships `std.json`; its ObjectMap is an insertion-ordered hash
// map, so key order — which is observable (it determines CSV column order) —
// is preserved, exactly as in the canonical lib. (The Rust sibling snippet
// embeds a minimal parser instead, as Rust's stdlib has no JSON.)
const std = @import("std");
/// One deserialized CSV record: an ordered list of (header, cell) pairs. We use
/// a list rather than a map so duplicate/empty headers survive round-trips,
/// exactly as in the TS lib's `Record<string, string>` indexing.
pub const CsvRow = std.ArrayList(Tuple);
pub const Tuple = struct { header: []const u8, cell: []const u8 };
/// Failure modes surfaced by the two public entry points.
pub const Error = error{
/// Input was not valid JSON (jsonToCsv only).
InvalidJson,
/// Document yielded no object rows, hence no column headers (jsonToCsv only).
NoHeaders,
OutOfMemory,
};
// ---------------------------------------------------------------------------
// JS-equivalent value semantics
// ---------------------------------------------------------------------------
// The canonical lib uses `typeof x === 'object'` and Object.keys(x), which in
// JavaScript treat BOTH objects and arrays as "object" and expose array indices
// as string keys ("0", "1", ...). We mirror that so degenerate inputs (e.g. an
// array of arrays) produce byte-identical output to the TS.
/// Object.keys parity: array indices as strings ("0", "1", ...), or the
/// object's insertion-ordered keys. Primitives and null yield no keys.
fn keysOf(v: std.json.Value, allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
var keys = std.ArrayList([]const u8).init(allocator);
switch (v) {
.array => |items| {
for (items.items, 0..) |_, i| {
try keys.append(try std.fmt.allocPrint(allocator, "{d}", .{i}));
}
},
.object => |map| {
var it = map.iterator();
while (it.next()) |entry| try keys.append(try allocator.dupe(u8, entry.key_ptr.*));
},
else => {},
}
return keys;
}
/// JS `obj[key]` parity: object lookup, or array element at a non-negative
/// integer index. Returns null when absent (which renders as the empty field).
fn getField(v: std.json.Value, key: []const u8) ?std.json.Value {
switch (v) {
.object => |map| return map.get(key),
.array => |items| {
const i = std.fmt.parseInt(usize, key, 10) catch return null;
return if (i < items.items.len) items.items[i] else null;
},
else => return null,
}
}
/// Render a number the way JS String(number) does on common inputs: integral
/// doubles print without a trailing ".0" (e.g. `30.0` -> "30"); everything
/// else uses Zig's shortest round-trip decimal.
fn formatNumber(writer: anytype, f: f64) !void {
if (f == @trunc(f) and @abs(f) < 1e16) {
try writer.print("{d}", .{@as(i64, @intFromFloat(f))});
} else {
try writer.print("{d}", .{f});
}
}
/// Coerce a JSON value to its display string, replicating JavaScript's
/// String(): null -> "", booleans -> "true"/"false", numbers -> decimal form,
/// arrays -> elements joined by "," (so a comma-bearing cell re-quotes), and
/// objects -> "[object Object]".
fn jsString(writer: anytype, v: std.json.Value) !void {
switch (v) {
.null => {},
.bool => |b| try writer.writeAll(if (b) "true" else "false"),
// std.json parses integral literals as .integer, fractional as .float.
.integer => |i| try writer.print("{d}", .{i}),
.float => |f| try formatNumber(writer, f),
// Only produced when the number overflows i64/f64 parsing; print as-is.
.number_string => |s| try writer.writeAll(s),
.string => |s| try writer.writeAll(s),
.array => |items| {
for (items.items, 0..) |item, i| {
if (i > 0) try writer.writeAll(",");
try jsString(writer, item);
}
},
.object => try writer.writeAll("[object Object]"),
}
}
/// Quote a single CSV field per RFC 4180.
fn csvEscape(writer: anytype, v: std.json.Value) !void {
var s = std.ArrayList(u8).init(std.heap.page_allocator);
defer s.deinit();
try jsString(s.writer(), v);
const needs_quoting = std.mem.indexOfAny(u8, s.items, ",\"\n\r") != null;
if (!needs_quoting) {
try writer.writeAll(s.items);
return;
}
try writer.writeAll("\"");
for (s.items) |c| {
if (c == '"') try writer.writeAll("\"\"");
try writer.writeAll(&[_]u8{c});
}
try writer.writeAll("\"");
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Serialize a JSON document to CSV.
///
/// Returns the caller-owned CSV string. Errors with `error.NoHeaders` when the
/// document yields no object rows (and thus no headers) — e.g. `[1, 2, 3]` —
/// and `error.InvalidJson` when the input is not valid JSON. Accepts a single
/// object or an array of objects.
pub fn jsonToCsv(allocator: std.mem.Allocator, text: []const u8) Error![]u8 {
var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch {
return Error.InvalidJson;
};
defer parsed.deinit();
const data = parsed.value;
// A bare value is treated as a one-row table.
const rows: []const std.json.Value = switch (data) {
.array => |items| items.items,
single => &[_]std.json.Value{single},
};
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
// Header union across object-like rows, first-seen order, de-duplicated.
var headers = std.ArrayList([]const u8).init(arena.allocator());
var seen = std.StringHashMap(void).init(arena.allocator());
for (rows) |row| {
var row_keys = try keysOf(row, arena.allocator());
defer row_keys.deinit();
for (row_keys.items) |k| {
if (!seen.contains(k)) {
try seen.put(k, {});
try headers.append(k);
}
}
}
if (headers.items.len == 0) return Error.NoHeaders;
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
const w = out.writer();
// First line is the (escaped) header row; subsequent lines are the rows.
for (headers.items, 0..) |h, i| {
if (i > 0) try w.writeAll(",");
try csvEscape(w, std.json.Value{ .string = h });
}
for (rows) |row| {
try w.writeAll("\n");
// A non-object row (null, number, string) yields an empty line: every
// header lookup on it returns null -> the empty field.
for (headers.items, 0..) |h, i| {
if (i > 0) try w.writeAll(",");
const cell = getField(row, h) orelse std.json.Value{ .null = {} };
try csvEscape(w, cell);
}
}
return out.toOwnedSlice();
}
/// Parse RFC 4180 CSV into a list of rows keyed by the first row.
///
/// Handles quoted fields, doubled-quote escapes, and embedded
/// commas/newlines; bare carriage returns outside quotes are ignored. Returns
/// an empty list for empty input, or for input that is only a header row.
/// Caller owns the returned rows (and their strings) via the allocator.
pub fn csvToJson(allocator: std.mem.Allocator, text: []const u8) Error!std.ArrayList(CsvRow) {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
// Single-pass character-state machine over bytes. Field content outside
// ASCII passes through untouched (UTF-8 is transparent to the machine).
var rows = std.ArrayList([]const []const u8).init(a);
var field = std.ArrayList(u8).init(a);
var row = std.ArrayList([]const u8).init(a);
var in_quotes = false;
var i: usize = 0;
const n = text.len;
while (i < n) : (i += 1) {
const ch = text[i];
if (in_quotes) {
if (ch == '"') {
// Doubled quote -> one literal quote; lone quote -> close field.
if (i + 1 < n and text[i + 1] == '"') {
try field.append('"');
i += 1;
continue;
}
in_quotes = false;
} else {
try field.append(ch);
}
} else if (ch == '"') {
in_quotes = true;
} else if (ch == ',') {
try row.append(try field.toOwnedSlice());
} else if (ch == '\n') {
try row.append(try field.toOwnedSlice());
try rows.append(try row.toOwnedSlice());
row = std.ArrayList([]const u8).init(a);
} else if (ch != '\r') {
try field.append(ch);
}
}
// Flush a trailing row only when there is pending content. Input that ended
// with a newline already flushed; this guard avoids an empty final row.
if (field.items.len > 0 or row.items.len > 0) {
try row.append(try field.toOwnedSlice());
try rows.append(try row.toOwnedSlice());
}
var out = std.ArrayList(CsvRow).init(allocator);
errdefer out.deinit();
if (rows.items.len == 0) return out;
const headers = rows.items[0];
for (rows.items[1..]) |r| {
var rec = CsvRow.init(allocator);
errdefer {
for (rec.items) |t| allocator.free(t.header);
rec.deinit();
}
for (headers, 0..) |h, j| {
try rec.append(.{
.header = try allocator.dupe(u8, h),
.cell = try allocator.dupe(u8, if (j < r.len) r[j] else ""),
});
}
try out.append(rec);
}
return out;
}
/// Release a list produced by [csvToJson].
pub fn freeRows(allocator: std.mem.Allocator, rows: std.ArrayList(CsvRow)) void {
for (rows.items) |rec| {
for (rec.items) |t| allocator.free(t.header);
rec.deinit();
}
rows.deinit();
}
pub fn main() !void {
// Small end-to-end demo so this file is runnable as a showcase.
const alloc = std.heap.page_allocator;
const raw = "[{\"name\":\"Doe, John\",\"note\":\"say \\\"hi\\\"\"},{\"name\":\"Jane\",\"note\":\"plain\"}]";
const csv = jsonToCsv(alloc, raw) catch |e| switch (e) {
Error.NoHeaders => {
std.debug.print("(no CSV produced)\n", .{});
return;
},
else => return e,
};
defer alloc.free(csv);
const stdout = std.io.getStdOut().writer();
try stdout.print("{s}\n", .{csv});
var rows = try csvToJson(alloc, csv);
defer freeRows(alloc, rows);
for (rows.items) |rec| {
try stdout.print("{{", .{});
for (rec.items, 0..) |t, i| {
if (i > 0) try stdout.print(", ", .{});
try stdout.print("{s}=\"{s}\"", .{ t.header, t.cell });
}
try stdout.print("}}\n", .{});
}
}
test "json_to_csv quoting and empty cases" {
const alloc = std.testing.allocator;
const csv = try jsonToCsv(alloc, "[{\"a\":\"1\",\"b\":\"2\"},{\"a\":\"3\",\"b\":\"4\"}]");
defer alloc.free(csv);
try std.testing.expectEqualStrings("a,b\n1,2\n3,4", csv);
try std.testing.expectError(Error.NoHeaders, jsonToCsv(alloc, "[1,2,3]"));
try std.testing.expectError(Error.InvalidJson, jsonToCsv(alloc, "{oops"));
const single = try jsonToCsv(alloc, "{\"a\":\"1\"}");
defer alloc.free(single);
try std.testing.expectEqualStrings("a\n1", single);
}
test "csv_to_json parses quoted fields" {
const alloc = std.testing.allocator;
var rows = try csvToJson(alloc, "name\n\"Doe, John\"");
defer freeRows(alloc, rows);
try std.testing.expectEqual(@as(usize, 1), rows.items.len);
try std.testing.expectEqualStrings("name", rows.items[0].items[0].header);
try std.testing.expectEqualStrings("Doe, John", rows.items[0].items[0].cell);
var empty = try csvToJson(alloc, "");
defer freeRows(alloc, empty);
try std.testing.expectEqual(@as(usize, 0), empty.items.len);
}
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 →