1 import Base 2 3 # CSV, RFC 4180 quoting. A field is quoted when it holds a comma, a double 4 # quote, CR or LF; inside quotes a double quote is written twice. Records end 5 # in LF (the parser takes LF or CRLF). Pure: no effects. 6 7 # ---- emitter ----------------------------------------------------------------- 8 9 def special(+c: U32) -> Bool: 10 U32.is_eq(c, 44) || U32.is_eq(c, 34) || U32.is_eq(c, 10) || U32.is_eq(c, 13) 11 12 def needs(s: String) -> Bool: 13 match s: 14 case SNil{}: 15 False{} 16 case SCon{Chr{c}, t}: 17 special(c) || needs(t) 18 19 def dq(s: String) -> String: 20 match s: 21 case SNil{}: 22 "" 23 case SCon{Chr{+c}, t}: 24 Bool.pick(String, U32.is_eq(c, 34), "\"\"", SCon{Chr{c}, SNil{}}) ++ dq(t) 25 26 def field.q(c: Bool, +s: String) -> String: 27 Bool.pick(String, c, "\"" ++ dq(s) ++ "\"", s) 28 29 # One field, quoted when it must be. 30 def field(+s: String) -> String: 31 field.q(needs(s), s) 32 33 # One record, without the line end. 34 def row(fs: List<&2, String>) -> String: 35 match fs: 36 case Nil{}: 37 "" 38 case Con{h, Nil{}}: 39 field(h) 40 case Con{h, Con{h2, t2}}: 41 field(h) ++ "," ++ row(h2 <> t2) 42 43 # Records, each ending in LF. 44 def table(rs: List<&2, List<&2, String>>) -> String: 45 match rs: 46 case Nil{}: 47 "" 48 case Con{r, t}: 49 row(r) ++ "\n" ++ table(t) 50 51 # ---- parser ------------------------------------------------------------------ 52 53 type Mode is Data: 54 MStart{} 55 MPlain{} 56 MQuoted{} 57 MClosed{} 58 59 # rows and row are reversed; cur is the reversed text of the field in progress. 60 type St is Data: 61 St{rows: List<&2, List<&2, String>>, row: List<&2, String>, cur: String, mode: Mode, err: String} 62 63 def start() -> St: 64 St{[], [], "", MStart{}, ""} 65 66 def with_mode(+st: St, m: Mode) -> St: 67 match st: 68 case St{rows, row, cur, mode, err}: 69 St{rows, row, cur, m, err} 70 71 def push(+c: U32, +st: St) -> St: 72 match st: 73 case St{rows, row, cur, mode, err}: 74 St{rows, row, SCon{Chr{c}, cur}, mode, err} 75 76 # The field in progress ends; the next field starts in `m`. 77 def end_field(+st: St, m: Mode) -> St: 78 match st: 79 case St{rows, row, cur, mode, err}: 80 St{rows, String.reverse(cur) <> row, "", m, err} 81 82 def end_row(+st: St) -> St: 83 match st: 84 case St{rows, row, cur, mode, err}: 85 St{List.reverse(&2, String, row) <> rows, [], cur, MStart{}, err} 86 87 def end_both(+st: St) -> St: 88 end_row(end_field(st, MStart{})) 89 90 def fail(+st: St, msg: String) -> St: 91 match st: 92 case St{rows, row, cur, mode, err}: 93 St{rows, row, cur, mode, Bool.pick(String, String.is_empty(err), msg, err)} 94 95 def row_empty(+st: St) -> Bool: 96 match st: 97 case St{rows, row, cur, mode, err}: 98 List.is_empty(&2, String, row) 99 100 def blank.pick(empty: Bool, +st: St) -> St: 101 match empty: 102 case True{}: 103 st 104 case False{}: 105 end_both(st) 106 107 def sel.st(c: Bool, +a: St, +b: St) -> St: 108 match c: 109 case True{}: 110 a 111 case False{}: 112 b 113 114 def at_start(+c: U32, +st: St) -> St: 115 sel.st(U32.is_eq(c, 34), with_mode(st, MQuoted{}), 116 sel.st(U32.is_eq(c, 44), end_field(st, MStart{}), 117 sel.st(U32.is_eq(c, 10), blank.pick(row_empty(st), st), 118 sel.st(U32.is_eq(c, 13), st, 119 with_mode(push(c, st), MPlain{}))))) 120 121 def in_plain(+c: U32, +st: St) -> St: 122 sel.st(U32.is_eq(c, 44), end_field(st, MStart{}), 123 sel.st(U32.is_eq(c, 10), end_both(st), 124 sel.st(U32.is_eq(c, 13), st, 125 push(c, st)))) 126 127 def in_quoted(+c: U32, +st: St) -> St: 128 sel.st(U32.is_eq(c, 34), with_mode(st, MClosed{}), push(c, st)) 129 130 def after_quote(+c: U32, +st: St) -> St: 131 sel.st(U32.is_eq(c, 34), with_mode(push(34, st), MQuoted{}), 132 sel.st(U32.is_eq(c, 44), end_field(st, MStart{}), 133 sel.st(U32.is_eq(c, 10), end_both(st), 134 sel.st(U32.is_eq(c, 13), st, 135 fail(st, "text after a closing quote"))))) 136 137 def step(m: Mode, +c: U32, +st: St) -> St: 138 match m: 139 case MStart{}: 140 at_start(c, st) 141 case MPlain{}: 142 in_plain(c, st) 143 case MQuoted{}: 144 in_quoted(c, st) 145 case MClosed{}: 146 after_quote(c, st) 147 148 def mode_of(+st: St) -> Mode: 149 match st: 150 case St{rows, row, cur, mode, err}: 151 mode 152 153 def go(s: String, +st: St) -> St: 154 match s: 155 case SNil{}: 156 st 157 case SCon{Chr{c}, t}: 158 go(t, step(mode_of(st), c, st)) 159 160 # The input ended: close the record in progress. 161 def close(m: Mode, +st: St) -> St: 162 match m: 163 case MStart{}: 164 blank.pick(row_empty(st), st) 165 case MQuoted{}: 166 fail(st, "unterminated quoted field") 167 case MPlain{}: 168 end_both(st) 169 case MClosed{}: 170 end_both(st) 171 172 def result(+st: St) -> Result<&2, &2, String, List<&2, List<&2, String>>>: 173 match st: 174 case St{rows, row, cur, mode, err}: 175 Bool.pick(Result<&2, &2, String, List<&2, List<&2, String>>>, String.is_empty(err), Done{List.reverse(&2, List<&2, String>, rows)}, Fail{err}) 176 177 def parse.fin(+st: St) -> Result<&2, &2, String, List<&2, List<&2, String>>>: 178 result(close(mode_of(st), st)) 179 180 # Records of fields. Blank lines are skipped; `Fail` names a quoting error. 181 def parse(s: String) -> Result<&2, &2, String, List<&2, List<&2, String>>>: 182 parse.fin(go(s, start()))