///|
fn validate_columns(columns : Array[String]) -> Unit raise LoomError {
if columns.is_empty() || columns.length() > 64 {
raise LoomError("expected 1..64 columns")
}
let seen : Map[String, Bool] = Map([])
for name in columns {
if name.trim().is_empty() || seen.contains(name) {
raise LoomError("column names must be nonempty and unique")
}
seen[name] = true
}
}
///|
/// Reject ragged rows rather than shifting or guessing column alignment.
pub fn validate_table(table : Table) -> Unit raise LoomError {
validate_columns(table.columns)
if table.rows.length() > 20000 {
raise LoomError("table exceeds 20000 rows")
}
for i, row in table.rows {
if row.length() != table.columns.length() {
raise LoomError("row \{i + 1} has incorrect column count")
}
for value in row {
if value.length() > 65536 {
raise LoomError("cell exceeds 65536 UTF-16 units")
}
}
}
}
///|
/// Strict comma CSV with headers, doubled quotes, quoted newlines and optional BOM.
pub fn parse_csv(source : String) -> Table raise LoomError {
if source.length() > 2000000 {
raise LoomError("input exceeds 2000000 UTF-16 units")
}
let c = source.to_array()
let records : Array[Array[String]] = []
let mut row : Array[String] = []
let field = StringBuilder()
// 0: field start; 1: unquoted; 2: quoted; 3: closed quote.
let mut state = 0
let mut i = if c.length() > 0 && c[0].to_int() == 65279 { 1 } else { 0 }
while i < c.length() {
let ch = c[i]
if state == 2 {
if ch == '"' {
if i + 1 < c.length() && c[i + 1] == '"' {
field.write_char('"')
i = i + 1
} else {
state = 3
}
} else {
field.write_char(ch)
}
} else if ch == ',' || ch == '\n' || ch == '\r' {
row.push(field.to_string())
field.reset()
state = 0
if ch != ',' {
records.push(row)
row = []
if ch == '\r' && i + 1 < c.length() && c[i + 1] == '\n' {
i = i + 1
}
}
} else if state == 3 {
raise LoomError("unexpected text after closing CSV quote")
} else if ch == '"' {
if state != 0 {
raise LoomError("quote inside unquoted CSV field")
}
state = 2
} else {
field.write_char(ch)
state = 1
}
i = i + 1
}
if state == 2 {
raise LoomError("unterminated CSV quote")
}
if state != 0 || !row.is_empty() {
row.push(field.to_string())
records.push(row)
}
if records.is_empty() {
raise LoomError("CSV requires a header")
}
let table = { columns: records[0], rows: records[1:].to_owned(), }
validate_table(table)
table
}
///|
fn csv_cell(value : String, spreadsheet_safe : Bool) -> String {
let first = value.trim().to_owned().to_array()
let needs_escape = spreadsheet_safe &&
first.length() > 0 &&
(first[0] == '=' || first[0] == '+' || first[0] == '-' || first[0] == '@')
let s = if needs_escape { "'" + value } else { value }
if s.contains_char(',') ||
s.contains_char('"') ||
s.contains_char('\n') ||
s.contains_char('\r') {
"\"" + s.replace_all(old="\"", new="\"\"") + "\""
} else {
s
}
}
///|
/// Spreadsheet-safe output prefixes formula-like cells with an apostrophe.
/// Disable only when exact machine-readable round trips are intended.
pub fn render_csv(
table : Table,
spreadsheet_safe~ : Bool = true,
) -> String raise LoomError {
validate_table(table)
let out = StringBuilder()
out.write_string(
table.columns.map(v => csv_cell(v, spreadsheet_safe)).join(","),
)
out.write_string("\r\n")
for row in table.rows {
out.write_string(row.map(v => csv_cell(v, spreadsheet_safe)).join(","))
out.write_string("\r\n")
}
out.to_string()
}
///|
fn parse_json_text(source : String) -> Json raise LoomError {
@json.parse(source, max_nesting_depth=32, reject_duplicate_keys=true) catch {
_ => raise LoomError("invalid JSON (or duplicate key / depth limit)")
}
}
///|
/// JSONL accepts string/null-valued objects. Numbers are rejected to avoid
/// rounding identifiers and losing leading zeros. Null is explicitly empty.
pub fn parse_jsonl(source : String) -> Table raise LoomError {
if source.length() > 2000000 {
raise LoomError("input exceeds 2000000 UTF-16 units")
}
let columns : Array[String] = []
let rows : Array[Array[String]] = []
for line in source.split("\n") {
if line.trim().is_empty() {
continue
}
let obj = match parse_json_text(line.to_owned()) {
Object(obj) => obj
_ => raise LoomError("JSONL rows must be objects")
}
if rows.is_empty() {
for key, _ in obj {
columns.push(key)
}
columns.sort()
validate_columns(columns)
}
if obj.length() != columns.length() {
raise LoomError("JSONL schema differs between rows")
}
let row : Array[String] = []
for name in columns {
match obj.get(name) {
Some(String(value)) => row.push(value)
Some(Null) => row.push("")
_ =>
raise LoomError(
"JSONL requires the same string/null fields on every row",
)
}
}
rows.push(row)
}
let table = { columns, rows, }
validate_table(table)
table
}