Skip to content

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 →