//! 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é"}, }); }