///|
/// RFC-style quoting without delimiter guessing; CRLF and LF record endings.
fn csv_records(text : String) -> Array[Array[String]] raise ChoiceError {
if text.length() > 1048576 {
raise Budget("CSV exceeds 1 Mi code units")
}
let chars = text.to_array()
let records : Array[Array[String]] = []
let mut row : Array[String] = []
let field = StringBuilder()
let mut field_units = 0
let mut quoted = false
let mut closed = false
let mut started = false
let mut i = 0
while i < chars.length() {
let c = chars[i]
field_units += if c.to_int() > 65535 { 2 } else { 1 }
if quoted {
if c == '"' {
if i + 1 < chars.length() && chars[i + 1] == '"' {
field.write_char('"')
i += 1
} else {
quoted = false
closed = true
}
} else {
field.write_char(c)
}
} else if c == ',' || c == '\n' || c == '\r' {
row.push(field.to_string())
if row.length() > 33 {
raise Budget("CSV record exceeds 33 fields")
}
if field.to_string().length() > 4096 {
raise Budget("CSV field exceeds 4096 code units")
}
field.reset()
field_units = 0
started = false
closed = false
if c != ',' {
records.push(row)
if records.length() > 258 {
raise Budget("CSV exceeds 258 records")
}
row = []
if c == '\r' {
if i + 1 >= chars.length() || chars[i + 1] != '\n' {
raise Invalid("unquoted CR must be followed by LF")
}
i += 1
}
}
} else {
if closed {
raise Invalid("unexpected character after closing CSV quote")
}
if c == '"' {
if started {
raise Invalid("CSV quote must start a field")
}
quoted = true
started = true
} else {
started = true
field.write_char(c)
}
}
if field_units > 8194 {
raise Budget("CSV encoded field exceeds 8194 code units")
}
i += 1
}
if quoted {
raise Invalid("unterminated CSV quoted field")
}
if started || closed || row.length() > 0 {
if field.to_string().length() > 4096 {
raise Budget("CSV field exceeds 4096 code units")
}
row.push(field.to_string())
if row.length() > 33 {
raise Budget("CSV record exceeds 33 fields")
}
records.push(row)
}
if records.length() > 258 {
raise Budget("CSV exceeds 258 records")
}
records
}
///|
/// Header alternative,; second record direction,.
pub fn decode_csv(text : String) -> DecisionMatrix raise ChoiceError {
let records = csv_records(text)
if records.length() < 3 {
raise Invalid("CSV requires headers, directions and alternatives")
}
let header = records[0]
let directions_row = records[1]
if header.length() < 2 || header[0] != "alternative" {
raise Invalid("CSV first header must be alternative")
}
if directions_row.length() != header.length() ||
directions_row[0] != "direction" {
raise Invalid("CSV direction record has wrong shape or marker")
}
let criteria = [ for j in 1.. header[j] ]
let directions = [
for j in 1.. {
match directions_row[j] {
"benefit" => Benefit
"cost" => Cost
_ => raise Invalid("CSV direction must be benefit or cost")
}
}
]
let names : Array[String] = []
let values : Array[Array[Double]] = []
for i in 2.. {
let value = row[j].trim().to_owned()
if value.length() == 0 {
raise Invalid("empty numeric CSV field")
}
json_number(parse_document(value))
}
],
)
}
decision_matrix(names, criteria, directions, values)
}
///|
fn csv_field(text : String) -> String {
if text.contains_char(',') ||
text.contains_char('"') ||
text.contains_char('\n') ||
text.contains_char('\r') {
"\"" + text.replace_all(old="\"", new="\"\"") + "\""
} else {
text
}
}
///|
fn csv_line(fields : Array[String]) -> String {
let builder = StringBuilder()
for i, field in fields {
if i > 0 {
builder.write_char(',')
}
builder.write_string(csv_field(field))
}
builder.write_string("\r\n")
builder.to_string()
}
///|
pub fn encode_csv(m : DecisionMatrix) -> String {
let builder = StringBuilder()
let header = ["alternative"]
for name in m.criteria {
header.push(name)
}
builder.write_string(csv_line(header))
let directions = ["direction"]
for d in m.directions {
directions.push(if d == Benefit { "benefit" } else { "cost" })
}
builder.write_string(csv_line(directions))
for i, name in m.names {
let row = [name]
for value in m.cells[i] {
row.push(Json(value).stringify())
}
builder.write_string(csv_line(row))
}
builder.to_string()
}