JSON ↔ CSV Converter — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// =============================================================================
// json-csv — Swift 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 row dictionaries
//
// Language: Swift 5.9 — no Foundation; minimal embedded JSON parser.
// 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.
// =============================================================================
// Foundation's JSONSerialization returns an unordered dictionary, but key order
// is observable here (it determines CSV column order). So this file embeds a
// small recursive-descent parser instead — mirroring the Rust sibling snippet,
// which is likewise self-contained.
// ---------------------------------------------------------------------------
// JSON value tree
// ---------------------------------------------------------------------------
indirect enum Json {
case null
case bool(Bool)
case number(Double)
case str(String)
case array([Json])
/// Insertion-ordered (key, value) pairs; duplicates keep their first position.
case object([(String, Json)])
}
// ---------------------------------------------------------------------------
// Minimal JSON parser
// ---------------------------------------------------------------------------
// Compact recursive-descent parser. Sufficient for any RFC 8259 document a
// caller is likely to feed this tool.
struct JsonParser {
let chars: [Character]
var pos = 0
init(_ input: String) {
chars = Array(input)
}
mutating func skipWs() {
while pos < chars.count {
switch chars[pos] {
case " ", "\t", "\n", "\r": pos += 1
default: return
}
}
}
func peek() -> Character? {
pos < chars.count ? chars[pos] : nil
}
mutating func parseValue() throws -> Json {
skipWs()
guard let c = peek() else { throw ParseError("unexpected end of input") }
switch c {
case "{": return try parseObject()
case "[": return try parseArray()
case "\"": return .str(try parseString())
case "t", "f": return try parseBool()
case "n": return try parseNull()
case "-", "0"..."9": return try parseNumber()
default: throw ParseError("unexpected character \(c)")
}
}
mutating func parseObject() throws -> Json {
pos += 1 // {
var pairs: [(String, Json)] = []
skipWs()
if peek() == "}" {
pos += 1
return .object(pairs)
}
while true {
skipWs()
guard peek() == "\"" else { throw ParseError("expected string key in object") }
let key = try parseString()
skipWs()
guard peek() == ":" else { throw ParseError("expected ':' after object key") }
pos += 1
let val = try parseValue()
// First occurrence of a key wins, matching JS object-literal semantics.
if !pairs.contains(where: { $0.0 == key }) {
pairs.append((key, val))
}
skipWs()
switch peek() {
case ",": pos += 1
case "}":
pos += 1
return .object(pairs)
default: throw ParseError("expected ',' or '}' in object")
}
}
}
mutating func parseArray() throws -> Json {
pos += 1 // [
var items: [Json] = []
skipWs()
if peek() == "]" {
pos += 1
return .array(items)
}
while true {
items.append(try parseValue())
skipWs()
switch peek() {
case ",": pos += 1
case "]":
pos += 1
return .array(items)
default: throw ParseError("expected ',' or ']' in array")
}
}
}
mutating func parseString() throws -> String {
pos += 1 // opening quote
var out = ""
while let c = peek() {
pos += 1
switch c {
case "\"": return out
case "\\":
guard let e = peek() else { throw ParseError("trailing escape") }
pos += 1
switch e {
case "\"": out.append("\"")
case "\\": out.append("\\")
case "/": out.append("/")
case "n": out.append("\n")
case "t": out.append("\t")
case "r": out.append("\r")
case "b": out.append("\u{0008}")
case "f": out.append("\u{000C}")
case "u":
let cp = try parseCodepoint()
out.unicodeScalars.append(cp)
default: throw ParseError("bad escape \\\(e)")
}
default: out.append(c)
}
}
throw ParseError("unterminated string")
}
mutating func parseCodepoint() throws -> Unicode.Scalar {
guard pos + 4 <= chars.count else { throw ParseError("short \\u escape") }
let hex = String(chars[pos..<(pos + 4)])
guard var code = UInt32(hex, radix: 16) else { throw ParseError("bad \\u escape") }
pos += 4
// UTF-16 surrogate pair handling.
if (0xD800...0xDBFF).contains(code),
pos + 6 <= chars.count,
chars[pos] == "\\", chars[pos + 1] == "u" {
let loHex = String(chars[(pos + 2)..<(pos + 6)])
pos += 6
if let lo = UInt32(loHex, radix: 16), (0xDC00...0xDFFF).contains(lo) {
code = 0x10000 + ((code - 0xD800) << 10) + (lo - 0xDC00)
}
}
guard let scalar = Unicode.Scalar(code) else { throw ParseError("invalid unicode codepoint") }
return scalar
}
mutating func parseBool() throws -> Json {
let rest = String(chars[pos...])
if rest.hasPrefix("true") {
pos += 4
return .bool(true)
}
if rest.hasPrefix("false") {
pos += 5
return .bool(false)
}
throw ParseError("invalid literal")
}
mutating func parseNull() throws -> Json {
guard String(chars[pos...]).hasPrefix("null") else { throw ParseError("invalid literal") }
pos += 4
return .null
}
mutating func parseNumber() throws -> Json {
let start = pos
if peek() == "-" { pos += 1 }
loop: while let c = peek() {
switch c {
case "0"..."9", ".", "e", "E", "+", "-": pos += 1
default: break loop
}
}
let s = String(chars[start..<pos])
guard let n = Double(s) else { throw ParseError("bad number \(s)") }
return .number(n)
}
}
struct ParseError: Error {
let message: String
init(_ message: String) { self.message = message }
}
/// Parse a JSON document into a `Json` value. Callers holding a raw JSON
/// string enter here.
func parseJson(_ input: String) throws -> Json {
var p = JsonParser(input)
let v = try p.parseValue()
p.skipWs()
guard p.pos == p.chars.count else { throw ParseError("trailing data at index \(p.pos)") }
return v
}
// ---------------------------------------------------------------------------
// 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.
func keysOf(_ v: Json) -> [String] {
switch v {
case .array(let items): return items.indices.map(String.init)
case .object(let pairs): return pairs.map { $0.0 }
default: return []
}
}
/// JS `obj[key]` parity: object lookup, or array element at a non-negative
/// integer index. Returns nil when absent (which renders as the empty field).
func getField(_ v: Json, _ key: String) -> Json? {
switch v {
case .object(let pairs):
return pairs.first { $0.0 == key }?.1
case .array(let items):
guard let i = Int(key), i >= 0, i < items.count else { return nil }
return items[i]
default:
return nil
}
}
/// 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 Swift's shortest round-trip form.
func formatNumber(_ f: Double) -> String {
if f == f.rounded(), abs(f) < 1e16 {
return String(Int64(f))
}
return "\(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]".
func jsString(_ v: Json) -> String {
switch v {
case .null: return ""
case .bool(let b): return b ? "true" : "false"
case .number(let n): return formatNumber(n)
case .str(let s): return s
case .array(let items): return items.map(jsString).joined(separator: ",")
case .object: return "[object Object]"
}
}
/// Quote a single CSV field per RFC 4180.
func csvEscape(_ v: Json) -> String {
let s = jsString(v)
if s.contains(where: { $0 == "," || $0 == "\"" || $0 == "\n" || $0 == "\r" }) {
// Each embedded quote is doubled, then the whole field is wrapped.
var doubled = ""
for ch in s {
doubled.append(ch == "\"" ? "\"\"" : String(ch))
}
return "\"" + doubled + "\""
}
return s
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// One deserialized CSV record: an ordered list of (header, cell) pairs. We use
/// an array rather than a Dictionary so duplicate/empty headers survive
/// round-trips, exactly as in the TS lib's `Record<string, string>` indexing.
typealias CsvRow = [(String, String)]
/// Serialize a JSON document to CSV.
///
/// Returns `.success(String)` on success, `.success(nil)` when the document
/// yields no object rows (and thus no headers) — e.g. `[1, 2, 3]` — and
/// `.failure` when the input is not valid JSON. Accepts a single object or an
/// array of objects.
func jsonToCsv(_ input: String) -> Result<String?, Error> {
let data: Json
do {
data = try parseJson(input)
} catch {
return .failure(error)
}
// A bare value is treated as a one-row table.
let rows: [Json]
if case .array(let items) = data {
rows = items
} else {
rows = [data]
}
// Header union across object-like rows, first-seen order, de-duplicated.
var headers: [String] = []
var seen = Set<String>()
for row in rows {
for k in keysOf(row) where !seen.contains(k) {
seen.insert(k)
headers.append(k)
}
}
if headers.isEmpty {
return .success(nil)
}
var lines: [String] = []
lines.append(headers.map { csvEscape(.str($0)) }.joined(separator: ","))
for row in rows {
// A non-object row (null, number, string) yields an empty line: every
// header lookup on it returns nil -> the empty field.
let cells = headers.map { h in
csvEscape(getField(row, h) ?? .null)
}
lines.append(cells.joined(separator: ","))
}
return .success(lines.joined(separator: "\n"))
}
/// Parse RFC 4180 CSV into an array 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 array for empty input, or for input that is only a header row.
func csvToJson(_ csv: String) -> [CsvRow] {
// Single-pass character-state machine over Characters, so multi-byte field
// content is preserved. CSV structural characters are ASCII.
var rows: [[String]] = []
var field = ""
var row: [String] = []
var inQuotes = false
let chars = Array(csv)
var i = 0
while i < chars.count {
let ch = chars[i]
if inQuotes {
if ch == "\"" {
// Doubled quote -> one literal quote; lone quote -> close field.
if i + 1 < chars.count && chars[i + 1] == "\"" {
field.append("\"")
i += 2
continue
}
inQuotes = false
} else {
field.append(ch)
}
} else if ch == "\"" {
inQuotes = true
} else if ch == "," {
row.append(field)
field = ""
} else if ch == "\n" {
row.append(field)
rows.append(row)
row = []
field = ""
} else if ch != "\r" {
field.append(ch)
}
i += 1
}
// 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.isEmpty || !row.isEmpty {
row.append(field)
rows.append(row)
}
guard let headers = rows.first else { return [] }
return rows.dropFirst().map { r in
headers.enumerated().map { i, h in
(h, i < r.count ? r[i] : "")
}
}
}
// Small end-to-end demo so this file is runnable as a showcase.
let raw = "[{\"name\":\"Doe, John\",\"note\":\"say \\\"hi\\\"\"},{\"name\":\"Jane\",\"note\":\"plain\"}]"
switch jsonToCsv(raw) {
case .success(.some(let csv)):
print(csv)
for row in csvToJson(csv) {
print(row)
}
case .success(.none):
print("(no CSV produced)")
case .failure(let error):
let message = error is ParseError ? (error as! ParseError).message : "\(error)"
print("error: \(message)")
}
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 →