JSON ↔ CSV Converter — Ruby 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 Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# =============================================================================
# json-csv — Ruby port
# =============================================================================
# Convert between JSON and RFC 4180 CSV in either direction:
# • json_to_csv — serialize a JSON document (object or array of objects) to CSV
# • csv_to_json — parse RFC 4180 CSV (with quoting) into a list of row hashes
#
# Language: Ruby 3.2 — 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.
# =============================================================================
# Ruby's stdlib ships `json` (require 'json'), so no parser is hand-rolled here.
# Equivalent in Rust would be an ecosystem crate such as `serde_json`; the Rust
# sibling snippet embeds a minimal parser instead to stay dependency-free.
require 'json'
# A field must be quoted when it contains any of: comma, double quote, CR, LF.
NEEDS_QUOTING = /[",\n\r]/
# ---------------------------------------------------------------------------
# Number formatting (JS String(number) parity)
# ---------------------------------------------------------------------------
# Ruby's `30.0.to_s` is "30.0" whereas JavaScript's `String(30.0)` is "30".
# CSV data is overwhelmingly string-typed, but to stay functionally equivalent
# on numeric values we drop a trailing ".0" on integral floats.
def num_str(n)
return n.to_s unless n.is_a?(Float)
return n.to_i.to_s if n == n.truncate && n.abs < 1e16
n.to_s # shortest round-trip form for non-integral floats
end
# Coerce a JSON value to its display string, replicating JavaScript's String().
# nil -> "", booleans -> "true"/"false", numbers -> decimal form, arrays ->
# elements joined by "," (so a comma-bearing cell re-quotes), and hashes ->
# "[object Object]".
def js_string(value)
case value
when nil then ''
when true then 'true'
when false then 'false'
when Integer, Float then num_str(value)
when String then value
when Array then value.map { |e| js_string(e) }.join(',')
else '[object Object]' # Hash (or any other object)
end
end
# Quote a single CSV field per RFC 4180.
def csv_escape(field)
s = js_string(field)
return "\"#{s.gsub('"', '""')}\"" if NEEDS_QUOTING.match?(s)
s
end
# ---------------------------------------------------------------------------
# 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, or hash keys in order.
def keys_of(value)
case value
when Hash then value.keys.map(&:to_s)
when Array then (0...value.length).map(&:to_s)
else []
end
end
# JS `obj[key]` parity: hash lookup, or array element at a non-negative integer
# index. Returns nil when absent (which renders as the empty field).
def get_field(value, key)
if value.is_a?(Hash)
# JSON keys are always strings; honor a Symbol key transparently.
value.key?(key.to_s) ? value[key.to_s] : value[key]
elsif value.is_a?(Array)
idx = Integer(key, exception: false)
return nil if idx.nil? || idx.negative? || idx >= value.length
value[idx]
end
end
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
# Serialize a JSON document to CSV.
#
# Accepts a single object or an array of objects. Returns nil on invalid JSON,
# or when the document yields no object rows (and thus no column headers) —
# e.g. a bare array of primitives such as `[1, 2, 3]`.
def json_to_csv(text)
data = JSON.parse(text)
rescue JSON::ParserError
nil
else
# A bare value is treated as a one-row table.
rows = data.is_a?(Array) ? data : [data]
# Header union across object-like rows, first-seen order, de-duplicated.
headers = []
seen = {}
rows.each do |row|
keys_of(row).each do |k|
next if seen.key?(k)
seen[k] = true
headers << k
end
end
return nil if headers.empty?
# First line is the (escaped) header row; subsequent lines are the rows.
lines = [headers.map { |h| csv_escape(h) }.join(',')]
rows.each do |row|
# A non-object row (nil, number, string) yields an empty line: every
# header lookup on it returns nil -> the empty field.
lines << headers.map { |h| csv_escape(get_field(row, h)) }.join(',')
end
lines.join("\n")
end
# Parse RFC 4180 CSV into an array of row hashes keyed by the first row.
#
# Handles quoted fields, doubled-quote escapes, and embedded commas/newlines;
# bare carriage returns outside quotes are ignored. Returns [] for empty input,
# or for input that is only a header row.
def csv_to_json(text)
# Single-pass character-state machine. `text` is indexed by position so we
# can look one character ahead for the doubled-quote escape.
rows = []
field = +''
row = []
in_quotes = false
n = text.length
i = 0
while i < n
ch = text[i]
if in_quotes
if ch == '"'
# Doubled quote -> one literal quote; lone quote -> close field.
if i + 1 < n && text[i + 1] == '"'
field << '"'
i += 2
next
end
in_quotes = false
else
field << ch
end
elsif ch == '"'
in_quotes = true
elsif ch == ','
row << field
field = +''
elsif ch == "\n"
row << field
rows << row
row = []
field = +''
elsif ch != "\r"
field << ch
end
i += 1
end
# 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.
row << field unless field.empty? && row.empty?
return [] if rows.empty?
headers = rows[0]
rows[1..].map do |r|
headers.each_with_index.to_h { |h, i| [h, i < r.length ? r[i] : ''] }
end
end
if __FILE__ == $PROGRAM_NAME
# Small end-to-end demo so this file is runnable as a showcase.
raw = '[{"name":"Doe, John","note":"say \"hi\""},{"name":"Jane","note":"plain"}]'
csv = json_to_csv(raw)
puts csv
p csv_to_json(csv)
end
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 →