///|
/// CSV parsing and rendering failures. This codec operates on one logical
/// record, so physical line framing remains the caller's responsibility.
pub(all) suberror CsvError {
InvalidDialect(String)
MalformedCsv(offset~ : Int, message~ : String)
CsvFieldMissing(index~ : Int, available~ : Int)
} derive(Debug, Eq)
///|
/// Syntax characters for the single-record CSV codec.
pub(all) struct CsvDialect {
delimiter : Char
quote : Char
} derive(Debug, Eq)
///|
/// Construct a validated dialect.
pub fn CsvDialect::new(
delimiter? : Char = ',',
quote? : Char = '"',
) -> CsvDialect raise CsvError {
if delimiter == quote {
raise InvalidDialect("delimiter and quote must be different")
}
if delimiter == '\r' || delimiter == '\n' || quote == '\r' || quote == '\n' {
raise InvalidDialect("delimiter and quote must not be a line break")
}
{ delimiter, quote, }
}
///|
/// Parse one CSV record. Quotes are allowed only at the beginning of a field,
/// and a doubled quote inside a quoted field decodes to one quote.
pub fn parse_csv_record(
record : String,
dialect? : CsvDialect = { delimiter: ',', quote: '"', },
) -> Array[String] raise CsvError {
let fields : Array[String] = []
let mut field = StringBuilder()
let mut state = 0
let mut offset = 0
for char in record {
if char == '\r' || char == '\n' {
raise MalformedCsv(offset~, message="line break inside one CSV record")
}
match state {
0 =>
if char == dialect.delimiter {
fields.push("")
} else if char == dialect.quote {
state = 2
} else {
field.write_char(char)
state = 1
}
1 =>
if char == dialect.delimiter {
fields.push(field.to_string())
field = StringBuilder()
state = 0
} else if char == dialect.quote {
raise MalformedCsv(offset~, message="quote inside an unquoted field")
} else {
field.write_char(char)
}
2 =>
if char == dialect.quote {
state = 3
} else {
field.write_char(char)
}
3 =>
if char == dialect.quote {
field.write_char(char)
state = 2
} else if char == dialect.delimiter {
fields.push(field.to_string())
field = StringBuilder()
state = 0
} else {
raise MalformedCsv(
offset~,
message="unexpected character after closing quote",
)
}
_ => abort("unreachable CSV parser state")
}
offset += char.to_string().length()
}
if state == 2 {
raise MalformedCsv(offset~, message="unterminated quoted field")
}
fields.push(field.to_string())
fields
}
///|
/// Select one decoded field and report the available field count on failure.
pub fn select_csv_field(
record : String,
index : Int,
dialect? : CsvDialect = { delimiter: ',', quote: '"', },
) -> String raise CsvError {
if index < 0 {
raise CsvFieldMissing(index~, available=0)
}
let fields = parse_csv_record(record, dialect~)
if index >= fields.length() {
raise CsvFieldMissing(index~, available=fields.length())
}
fields[index]
}
///|
/// Render one field with the minimum quoting necessary for the dialect.
pub fn encode_csv_field(
value : String,
dialect? : CsvDialect = { delimiter: ',', quote: '"', },
) -> String {
let mut quoted = value == ""
for char in value {
if char == dialect.delimiter ||
char == dialect.quote ||
char == '\r' ||
char == '\n' {
quoted = true
}
}
if !quoted {
return value
}
let output = StringBuilder()
output.write_char(dialect.quote)
for char in value {
if char == dialect.quote {
output.write_char(dialect.quote)
}
output.write_char(char)
}
output.write_char(dialect.quote)
output.to_string()
}
///|
/// Encode a complete record using the supplied dialect.
pub fn encode_csv_record(
fields : Array[String],
dialect? : CsvDialect = { delimiter: ',', quote: '"', },
) -> String {
let output = StringBuilder()
for index, field in fields {
if index > 0 {
output.write_char(dialect.delimiter)
}
output.write_string(encode_csv_field(field, dialect~))
}
output.to_string()
}