diff options
Diffstat (limited to 'src/csv.zig')
| -rw-r--r-- | src/csv.zig | 207 |
1 files changed, 207 insertions, 0 deletions
diff --git a/src/csv.zig b/src/csv.zig new file mode 100644 index 0000000..953e5eb --- /dev/null +++ b/src/csv.zig @@ -0,0 +1,207 @@ +//! A small RFC 4180 reader for the `/csv` batch endpoint. +//! +//! Values may contain commas and are then double-quoted with `""` escaping, +//! which is why the batch cannot be split on `,`. The reader is deliberately +//! lenient: blank lines are skipped and a stray quote inside an unquoted field +//! is kept as data, because a malformed byte in one row must not cost the whole +//! batch. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// One parsed row: a slice of owned field values. +pub const Record = [][]const u8; + +/// Split CSV text into records, honouring quoting, `""` escapes, embedded +/// commas and newlines, CRLF and LF line endings, and a leading BOM. +/// +/// ## Ownership +/// +/// Every field, every record and the outer slice are allocated from `gpa` and +/// owned by the caller. Release the lot with `free`; `input` is only borrowed +/// for the duration of the call. +pub fn parse(gpa: Allocator, input: []const u8) Allocator.Error![]Record { + const text = if (std.mem.startsWith(u8, input, "\xEF\xBB\xBF")) input[3..] else input; + + var records: std.ArrayList(Record) = .empty; + errdefer free(gpa, records.items); + errdefer records.deinit(gpa); + + var record: std.ArrayList([]const u8) = .empty; + errdefer freeRecord(gpa, record.items); + errdefer record.deinit(gpa); + + var field: std.ArrayList(u8) = .empty; + defer field.deinit(gpa); + + var quoted = false; + var i: usize = 0; + while (i < text.len) : (i += 1) { + const c = text[i]; + + if (quoted) { + if (c == '"') { + if (i + 1 < text.len and text[i + 1] == '"') { + try field.append(gpa, '"'); + i += 1; + } else { + quoted = false; + } + } else { + try field.append(gpa, c); + } + continue; + } + + switch (c) { + '"' => if (field.items.len == 0) { + quoted = true; + } else { + try field.append(gpa, c); + }, + ',' => try endField(gpa, &record, &field), + '\r', '\n' => { + if (c == '\r' and i + 1 < text.len and text[i + 1] == '\n') i += 1; + try endField(gpa, &record, &field); + try endRecord(gpa, &records, &record); + }, + else => try field.append(gpa, c), + } + } + + if (field.items.len != 0 or record.items.len != 0) { + try endField(gpa, &record, &field); + try endRecord(gpa, &records, &record); + } + + return records.toOwnedSlice(gpa); +} + +/// Release everything `parse` returned. +pub fn free(gpa: Allocator, records: []Record) void { + for (records) |record| freeRecord(gpa, record); + gpa.free(records); +} + +fn freeRecord(gpa: Allocator, record: []const []const u8) void { + for (record) |value| gpa.free(value); + gpa.free(record); +} + +fn endField( + gpa: Allocator, + record: *std.ArrayList([]const u8), + field: *std.ArrayList(u8), +) Allocator.Error!void { + const owned = try gpa.dupe(u8, field.items); + errdefer gpa.free(owned); + try record.append(gpa, owned); + field.clearRetainingCapacity(); +} + +/// Append the record unless every one of its fields is empty. A blank line +/// carries no data and would otherwise be mistaken for a row of empties, which +/// is exactly what the batch endpoint uses to mean "not found". +fn endRecord( + gpa: Allocator, + records: *std.ArrayList(Record), + record: *std.ArrayList([]const u8), +) Allocator.Error!void { + var any = false; + for (record.items) |value| { + if (value.len != 0) any = true; + } + if (!any) { + freeRecord(gpa, try record.toOwnedSlice(gpa)); + return; + } + const owned = try record.toOwnedSlice(gpa); + errdefer freeRecord(gpa, owned); + try records.append(gpa, owned); +} + +const testing = std.testing; + +fn expectRecords(input: []const u8, expected: []const []const []const u8) !void { + const records = try parse(testing.allocator, input); + defer free(testing.allocator, records); + + try testing.expectEqual(expected.len, records.len); + for (expected, records) |want_record, got_record| { + try testing.expectEqual(want_record.len, got_record.len); + for (want_record, got_record) |want, got| { + try testing.expectEqualStrings(want, got); + } + } +} + +test "splits plain rows" { + try expectRecords("a,b,c\n1,2,3\n", &.{ + &.{ "a", "b", "c" }, + &.{ "1", "2", "3" }, + }); +} + +test "keeps empty fields" { + const records = try parse(testing.allocator, "99999999,,,,,,,,,,,,\n"); + defer free(testing.allocator, records); + + try testing.expectEqual(@as(usize, 1), records.len); + try testing.expectEqual(@as(usize, 13), records[0].len); + try testing.expectEqualStrings("99999999", records[0][0]); + try testing.expectEqualStrings("", records[0][12]); +} + +test "reads a quoted field containing a comma" { + try expectRecords("cep,logradouro\n01001000,\"Rua Um, Dois\"\n", &.{ + &.{ "cep", "logradouro" }, + &.{ "01001000", "Rua Um, Dois" }, + }); +} + +test "unescapes doubled quotes" { + try expectRecords("a\n\"say \"\"hi\"\"\"\n", &.{ + &.{"a"}, + &.{"say \"hi\""}, + }); +} + +test "accepts an embedded newline and CRLF endings" { + try expectRecords("a,b\r\n\"line\nbreak\",2\r\n", &.{ + &.{ "a", "b" }, + &.{ "line\nbreak", "2" }, + }); +} + +test "ignores a BOM and blank lines" { + try expectRecords("\xEF\xBB\xBFa,b\n\n1,2", &.{ + &.{ "a", "b" }, + &.{ "1", "2" }, + }); +} + +test "a final row without a trailing newline is kept" { + try expectRecords("a,b\n1,2", &.{ + &.{ "a", "b" }, + &.{ "1", "2" }, + }); +} + +test "a stray quote inside an unquoted field is data" { + try expectRecords("a\n12\"34\n", &.{ + &.{"a"}, + &.{"12\"34"}, + }); +} + +test "empty input yields no records" { + try expectRecords("", &.{}); + try expectRecords("\n\n", &.{}); +} + +test "UTF-8 passes through untouched" { + try expectRecords("logradouro\nPraça da Sé\n", &.{ + &.{"logradouro"}, + &.{"Praça da Sé"}, + }); +} |