///|
pub(all) struct CsvRecord {
line : Int32
cells : Array[String]
} derive(Debug)
///|
pub(all) struct CsvTable {
headers : Array[String]
rows : Array[CsvRecord]
} derive(Debug)
///|
fn csv_error(code : String, line : Int32, message : String) -> Diagnostic {
issue("error", code, "csv:\{line}", message)
}
///|
fn finish_csv_row(
rows : Array[CsvRecord],
cells : Array[String],
field : StringBuilder,
line : Int32,
) -> Unit {
cells.push(field.to_string())
rows.push({ line, cells: cells.copy(), })
cells.clear()
field.reset()
}
///|
/// Parse RFC 4180-style CSV with doubled quotes and quoted newlines. CRLF
/// and CR are normalized to LF. A malformed quoted field is rejected with a
/// row number rather than silently shifting columns.
pub fn parse_csv(text : String) -> Result[CsvTable, Diagnostic] {
if text.length() > 16000000 {
return Err(
csv_error("csv-too-large", 1, "CSV input exceeds 16 million code units"),
)
}
let normalized = text
.replace_all(old="\r\n", new="\n")
.replace_all(old="\r", new="\n")
let rows : Array[CsvRecord] = []
let cells : Array[String] = []
let field = StringBuilder()
let mut line = 1
let mut row_line = 1
let mut quoted = false
let mut after_quote = false
let mut field_started = false
let mut quote_pending = false
for char in normalized {
if quoted {
if quote_pending {
if char == '"' {
field.write_char('"')
quote_pending = false
continue
}
quoted = false
after_quote = true
quote_pending = false
} else if char == '"' {
quote_pending = true
continue
} else {
field.write_char(char)
if char == '\n' {
line += 1
}
continue
}
}
if after_quote {
if char == ',' {
cells.push(field.to_string())
field.reset()
field_started = false
after_quote = false
} else if char == '\n' {
finish_csv_row(rows, cells, field, row_line)
field_started = false
after_quote = false
line += 1
row_line = line
} else {
return Err(
csv_error(
"csv-trailing-character", line, "only a comma or newline may follow a closing quote",
),
)
}
continue
}
if char == '"' {
if field_started {
return Err(
csv_error("csv-bare-quote", line, "quote inside an unquoted field"),
)
}
quoted = true
field_started = true
} else if char == ',' {
cells.push(field.to_string())
field.reset()
field_started = false
} else if char == '\n' {
finish_csv_row(rows, cells, field, row_line)
field_started = false
line += 1
row_line = line
} else {
field.write_char(char)
field_started = true
}
if cells.length() > 64 || rows.length() > 100000 {
return Err(
csv_error("csv-limit", line, "CSV exceeds the row or column limit"),
)
}
}
if quoted && !quote_pending {
return Err(
csv_error("csv-unclosed-quote", line, "quoted field is not closed"),
)
}
if quote_pending {
quoted = false
after_quote = true
}
if field_started || after_quote || !cells.is_empty() {
finish_csv_row(rows, cells, field, row_line)
}
if rows.is_empty() {
return Err(csv_error("csv-empty", 1, "CSV has no header row"))
}
let headers = rows[0].cells
let seen : Map[String, Bool] = Map([])
for header in headers {
if header.trim().is_empty() {
return Err(
csv_error("csv-empty-header", 1, "header names cannot be empty"),
)
}
if seen.contains(header) {
return Err(
csv_error(
"csv-duplicate-header",
1,
"header name is repeated: \{header}",
),
)
}
seen[header] = true
}
let records : Array[CsvRecord] = []
for i in 1.. Int32? {
for index, header in self.headers {
if header == name {
return Some(index)
}
}
None
}
///|
pub fn CsvTable::has_exact_headers(
self : CsvTable,
expected : Array[String],
) -> Bool {
if self.headers.length() != expected.length() {
return false
}
for index, header in expected {
if self.headers[index] != header {
return false
}
}
true
}