summaryrefslogtreecommitdiffstats
path: root/src/csv.zig
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:48:43 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:48:43 -0300
commit87166a5299aa551517a86379a0c05eb2110b52b0 (patch)
tree3e77e7dd89cca736a22a5f2219451aa57dd99068 /src/csv.zig
downloadzig-87166a5299aa551517a86379a0c05eb2110b52b0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src/csv.zig')
-rw-r--r--src/csv.zig207
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é"},
+ });
+}