csv
stdlib/extra/csv.hk: RFC 4180 comma-separated values, a reader with positioned errors, a header-keyed reading, and a writer with minimal correct quoting.
The data-scripting staple: a table as text, one record per line, fields split on ,. What makes it more than split(",") is quoting: a field holding a comma, a quote or a line break is wrapped in ", and a quote inside it is doubled (""). A reader that gets quoting almost right splits one field into two, or swallows the rest of the file into one field, with no word about either. parse therefore takes the grammar seriously and refuses what is not well-formed, naming the byte offset: a quoted field that never closes, a quote where one cannot be (inside an unquoted field, or after a closing quote before the next comma), and a record with more fields than _max_fields, which is the json TooManyKeys posture against an attacker-chosen header flooding the maps parse_records builds.
Both record terminators are read, \r\n (what RFC 4180 writes) and \n; a final terminator does not add an empty record, and a blank line is a record of one empty field, as the RFC has it. render writes \r\n, the RFC's terminator, and quotes a field only when it has to.
Pure and whole-input: the reader and the writer are @encapsulated, and the cursor and builder each uses live and die inside the call with nothing mutable escaping, which leaves both callable from meta and from ordinary pure code, on both tiers. Hand-written over the bytes and not as a peg grammar, as the stdlib's parsers are (the dogfood program measured the grammar form at several times the cost and decided a stdlib author cannot know whether a caller parses one row or a million); the paired tools/bench fixtures state the cost.
CsvError
type CsvError
Unterminated(int)
BadQuote(int)
TooManyFields(int)
RaggedRow(int)
end
Why a CSV text could not be read, with the byte offset at which the problem was found (the json.JsonError idiom).
impl Display<CsvError>
to_string
def to_string(self) -> string
Renders as <what> at offset <at>.
Unterminated(4).to_string() => "unterminated quoted field at offset 4"
impl Eq<CsvError>
eq?
def eq?(self, other: Self) -> bool
Equal when the kind and the offset match.
BadQuote(2).eq?(BadQuote(2)) => true
BadQuote(2).eq?(Unterminated(2)) => false
maxfields
def _max_fields() -> int
The most fields one record may carry. Generous for any real table and bounded against a header chosen to flood the per-row maps (json's _max_object_keys stance, and its number).
parse
def parse(input: string) -> Result<List<List<string>>, CsvError>
Read input as records of fields. Quoting follows RFC 4180: a field wrapped in " may carry commas, line breaks and doubled quotes ("" reads as one "); an unquoted field runs to the next , or line break. Records end at \r\n or \n, a final terminator adds nothing, and an empty input is no records at all.
parse("a,b\r\n1,2\r\n") => Ok([["a", "b"], ["1", "2"]])
parse("x,\"he said \"\"hi\"\"\",y") => Ok([["x", "he said \"hi\"", "y"]])
parse("\"multi\nline\",2") => Ok([["multi\nline", "2"]])
parse("") => Ok([])
parse("a,\"open") => Err(Unterminated(2))
parse("a,b\"c") => Err(BadQuote(3))
parse("\"a\"x,b") => Err(BadQuote(3))
StringBuilder
StringBuilder, or str.StringBuilder, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.
_plain!
def _plain!(raw: bytes, from: int, out: StringBuilder) -> Result<int, CsvError>
Read an unquoted field starting at from into out; answers the offset of the byte that ended it (a ,, a line break, or the length). A " inside is misplaced.
plainend
def _plain_end(raw: bytes, from: int) -> int
The offset of the first byte after from that is a ,, a ", or the start of a line break (\n, or a \r followed by \n), or the length.
_quoted!
def _quoted!(raw: bytes, from: int, out: StringBuilder) -> Result<int, CsvError>
Read a quoted field whose opening " is at from into out; answers the offset of the byte after the closing ", which must end the field.
parse_records
def parse_records(input: string) -> Result<List<Map<string, string>>, CsvError>
Read input with its first record as the header: every later record becomes a map from header name to field, in record order. A row whose field count differs from the header's is a RaggedRow; an input with no records, or only a header, is no rows.
parse_records("name,age\r\nann,41\r\nbo,7\r\n").map(|rs| rs.map(|r| r.get("age").unwrap_or("?"))).unwrap_or([]) => ["41", "7"]
parse_records("name,age\r\n") => Ok([])
parse_records("a,b\r\n1\r\n") => Err(RaggedRow(5))
_records
def _records(header: List<string>, rows: List<List<string>>, i: int, offsets: List<int>, acc: Result<List<Map<string, string>>, CsvError>) -> Result<List<Map<string, string>>, CsvError>
_zip
def _zip(names: List<string>, values: List<string>) -> Map<string, string>
rowoffsets
def _row_offsets(input: string) -> List<int>
The byte offset each record starts at: 0, then one past every line break that is outside quotes. That is what a second pass re-walking quoting would find, and so this pass walks quoting.
render
def render(rows: List<List<string>>) -> string
Write rows as CSV: fields joined by ,, each record ended by \r\n, and a field quoted only when it has a comma, a quote (doubled inside), or a line break. What needs no quoting round-trips byte for byte, and what does reads back through parse as written.
render([["a", "b"], ["1", "2"]]) => "a,b\r\n1,2\r\n"
render([["he said \"hi\"", "x,y"]]) => "\"he said \"\"hi\"\"\",\"x,y\"\r\n"
render([]) => ""
_field
def _field(s: string) -> string
A field as written: quoted when it must be.