# Csv — small, Base-only comma-separated values. # # Rows are List<&2, String>; a document is List<&2, List<&2, String>>. # The parser is deliberately a small line-oriented CSV subset. See README. import Base # A field is quoted when it contains syntax which would otherwise be data. def Csv.is_quote(+c: Char) -> Bool: match c: case Chr{+x}: U32.is_eq(x, 34) def Csv.is_comma(+c: Char) -> Bool: match c: case Chr{+x}: U32.is_eq(x, 44) # Finish a field. fields is stored backwards while scanning. def Csv.parse_line.finish( +quoted: Bool, +field: String, +fields: List<&2, String> ) -> List<&2, String>: List.reverse(&2, String, String.reverse(field) <> fields) # The scanner has an explicit state so doubled quotes need no mutual # recursion. AfterQuote re-processes the next non-quote character as plain. type Csv.Scan is Data: CPlain{} CQuoted{} CAfterQuote{} def Csv.parse_line.go( s: String, state: Csv.Scan, +field: String, +fields: List<&2, String> ) -> List<&2, String>: match s state: case SNil{} _: Csv.parse_line.finish(False{}, field, fields) case SCon{+h, +t} CPlain{}: Bool.pick( List<&2, String>, Csv.is_quote(h), Bool.pick( List<&2, String>, String.is_empty(field), Csv.parse_line.go(t, CQuoted{}, field, fields), Csv.parse_line.go(t, CPlain{}, SCon{h, field}, fields) ), Bool.pick( List<&2, String>, Csv.is_comma(h), Csv.parse_line.go(t, CPlain{}, SNil{}, String.reverse(field) <> fields), Csv.parse_line.go(t, CPlain{}, SCon{h, field}, fields) ) ) case SCon{+h, +t} CQuoted{}: Bool.pick( List<&2, String>, Csv.is_quote(h), Csv.parse_line.go(t, CAfterQuote{}, field, fields), Csv.parse_line.go(t, CQuoted{}, SCon{h, field}, fields) ) case SCon{+h, +t} CAfterQuote{}: Bool.pick( List<&2, String>, Csv.is_quote(h), Csv.parse_line.go(t, CQuoted{}, SCon{h, field}, fields), Csv.parse_line.go(t, CPlain{}, SCon{h, field}, fields) ) # Parse one physical line. Empty lines are one empty field. def Csv.parse_line(s: String) -> List<&2, String>: Csv.parse_line.go(s, CPlain{}, SNil{}, Nil{}) def Csv.parse.go( lines: List<&2, String> ) -> List<&2, List<&2, String>>: match lines: case Nil{}: Nil{} case h <> t: Csv.parse_line(h) <> Csv.parse.go(t) # Empty input is no document; a non-empty input is split on LF. CR is not # stripped, so callers wanting CRLF should trim it before parsing. def Csv.parse(s: String) -> List<&2, List<&2, String>>: match s: case SNil{}: Nil{} case SCon{h, t}: Csv.parse.go(String.lines(SCon{h, t})) # Double quotes in a quoted field are represented by two quotes. def Csv.encode_field.escaped(s: String) -> String: match s: case SNil{}: SNil{} case SCon{+h, +t}: Bool.pick( String, Csv.is_quote(h), String.append("\"\"", Csv.encode_field.escaped(t)), String.append(SCon{h, SNil{}}, Csv.encode_field.escaped(t)) ) def Csv.encode_field(+s: String) -> String: Bool.pick( String, Bool.or(String.contains(s, ","), String.contains(s, "\"")), String.append("\"", String.append(Csv.encode_field.escaped(s), "\"")), s ) def Csv.encode_row.go( cells: List<&2, String>, first: Bool ) -> String: match cells: case Nil{}: SNil{} case +h <> +t: Bool.pick( String, first, String.append(Csv.encode_field(h), Csv.encode_row.go(t, False{})), String.append(",", String.append(Csv.encode_field(h), Csv.encode_row.go(t, False{}))) ) def Csv.encode_row(cells: List<&2, String>) -> String: Csv.encode_row.go(cells, True{}) def Csv.encode.go( rows: List<&2, List<&2, String>>, first: Bool ) -> String: match rows: case Nil{}: SNil{} case +h <> +t: Bool.pick( String, first, String.append(Csv.encode_row(h), Csv.encode.go(t, False{})), String.append("\n", String.append(Csv.encode_row(h), Csv.encode.go(t, False{}))) ) def Csv.encode(rows: List<&2, List<&2, String>>) -> String: Csv.encode.go(rows, True{})