///|
fn csv_field(value : String) -> String {
let out = StringBuilder()
out.write_char('"')
for c in value.to_array() {
if c == '"' {
out.write_char('"')
}
out.write_char(c)
}
out.write_char('"')
out.to_string()
}
///|
pub fn plan_csv(p : Plan) -> String raise DesignError {
validate_plan(p)
let lines = []
let header = ["id", "block"]
for f in p.factors {
header.push(f.name)
}
lines.push(header.map(csv_field).join(","))
for r in p.runs {
let fields = [csv_field(r.id), r.block.to_string()]
for value in r.point {
fields.push(value.to_string())
}
lines.push(fields.join(","))
}
lines.join("\r\n") + "\r\n"
}
///|
pub fn csv_records(text : String) -> Array[Array[String]] raise DesignError {
if text.length() > 2000000 {
raise Budget("CSV exceeds two million UTF-16 units")
}
let chars = text.to_array()
let rows = []
let fields : Array[String] = []
let mut field = StringBuilder()
let mut state = 0
let mut pending = false
let mut i = 0
while i < chars.length() {
let c = chars[i]
if state == 1 {
if c == '"' {
if i + 1 < chars.length() && chars[i + 1] == '"' {
field.write_char('"')
i += 1
} else {
state = 2
}
} else {
field.write_char(c)
}
pending = true
} else if c == ',' || c == '\n' || c == '\r' {
let value = field.to_string()
if value.length() > 4096 || fields.length() >= 18 {
raise Budget("CSV field/column limit exceeded")
}
fields.push(value)
field = StringBuilder()
state = 0
pending = true
if c != ',' {
if c == '\r' {
if i + 1 >= chars.length() || chars[i + 1] != '\n' {
raise Invalid("bare CR outside quoted CSV field")
}
i += 1
}
if rows.length() >= 4097 {
raise Budget("CSV row limit exceeded")
}
rows.push(fields.copy())
fields.clear()
pending = false
}
} else {
if state == 2 {
raise Invalid("characters after closing CSV quote")
}
if c == '"' {
if field.to_string().length() != 0 {
raise Invalid("quote in unquoted CSV field")
}
state = 1
} else {
field.write_char(c)
}
pending = true
}
i += 1
}
if state == 1 {
raise Invalid("unterminated CSV quote")
}
if pending {
let value = field.to_string()
if value.length() > 4096 || fields.length() >= 18 || rows.length() >= 4097 {
raise Budget("CSV limit exceeded")
}
fields.push(value)
rows.push(fields.copy())
}
rows
}
///|
fn parse_number(text : String) -> Double raise DesignError {
if text.length() > 128 {
raise Budget("numeric text exceeds 128 units")
}
let json = @json.parse(text) catch {
_ => raise Invalid("invalid JSON-format numeric field")
}
let value = match json {
Number(x, ..) => x
_ => raise Invalid("numeric field required")
}
require_number(value)
value
}
///|
pub fn plan_from_csv(
text : String,
factors : Array[Factor],
) -> Plan raise DesignError {
validate_factors(factors)
let rows = csv_records(text)
if rows.length() < 2 {
raise Invalid("CSV header and runs required")
}
let header = ["id", "block"]
for f in factors {
header.push(f.name)
}
if rows[0] != header {
raise Invalid("CSV header must match exact factor names")
}
let runs = []
for i in 1.. 2147483647.0 ||
number != number.to_int().to_double() {
raise Invalid("block must be a nonnegative Int")
}
let point = [ for j in 2.. parse_number(row[j]) ]
runs.push({ id: row[0], block: number.to_int(), point, })
}
plan(factors, runs)
}