-
What's New in v0.3.0
+
What's New in v0.3.4
+
+ Current toolchain support, clearer benchmarks, and Fast Mode performance visibility.
+
+
+
+
+ {(group) => (
+
+
{group.title}
+
+
+ {(feature) => {feature} }
+
+
+
+ )}
+
+
+
+
Previously in v0.3.0
- Major feature release — 22 new CLI flags, security hardening, Fast Mode, and robust error handling.
+ Major feature release — 22 CLI flags, security hardening, Fast Mode, and robust error handling.
diff --git a/landing/src/entry-server.tsx b/landing/src/entry-server.tsx
index 52865ef..0638bc1 100644
--- a/landing/src/entry-server.tsx
+++ b/landing/src/entry-server.tsx
@@ -8,20 +8,20 @@ export default createHandler(() => (
-
+
{/* Open Graph / Social Media */}
-
-
+
+
{/* Twitter Card */}
-
-
+
+
{assets}
diff --git a/src/ts/parser.ts b/src/ts/parser.ts
index 41f1df4..0e65034 100644
--- a/src/ts/parser.ts
+++ b/src/ts/parser.ts
@@ -1043,7 +1043,18 @@ export class CSVParser>
while (lib.csv_next_row(this.handle)) {
if (!this.cachedRows.has(rowIndex)) {
const fieldCount = lib.csv_get_field_count(this.handle);
- const row = new CSVRow(this.handle, fieldCount, this.headers, this.options.schema ?? null);
+ const row = new CSVRow(
+ this.handle,
+ fieldCount,
+ this.headers,
+ this.options.schema ?? null,
+ false,
+ null,
+ null,
+ 0,
+ null,
+ this.headerRow
+ );
const rowData: string[] = [];
for (let i = 0; i < fieldCount; i++) {
@@ -1170,7 +1181,13 @@ export class CSVParser>
this.handle,
fieldCount,
this.headers,
- this.options.schema ?? null
+ this.options.schema ?? null,
+ false,
+ null,
+ null,
+ 0,
+ null,
+ this.headerRow
);
const data = this.headers ? row.toObject() : row.toArray();
@@ -1198,7 +1215,13 @@ export class CSVParser>
this.handle,
fieldCount,
this.headers,
- this.options.schema ?? null
+ this.options.schema ?? null,
+ false,
+ null,
+ null,
+ 0,
+ null,
+ this.headerRow
);
chunk.push(this.headers ? row.toObject() : row.toArray());
@@ -1432,6 +1455,7 @@ export class CSVParser>
trimConfig,
this.dataRowIndex,
this.options.cast ?? null,
+ this.headerRow,
);
// Skip records with all empty values
@@ -1532,7 +1556,18 @@ export class CSVParser>
// onRecord callback: extract fields, allow modification or skipping
if (this.options.onRecord) {
- const rawRow = new CSVRow(this.handle, fieldCount, this.headers, this.options.schema ?? null);
+ const rawRow = new CSVRow(
+ this.handle,
+ fieldCount,
+ this.headers,
+ this.options.schema ?? null,
+ false,
+ null,
+ null,
+ 0,
+ null,
+ this.headerRow
+ );
const fields: (string | null)[] = [];
for (let i = 0; i < fieldCountNum; i++) {
fields.push(rawRow.get(i));
@@ -1555,6 +1590,7 @@ export class CSVParser>
trimConfig,
this.dataRowIndex,
this.options.cast ?? null,
+ this.headerRow,
);
if (skipEmptyValues && this.isRowEmpty(row, result.length, greedyEmpty)) {
@@ -1581,6 +1617,7 @@ export class CSVParser>
trimConfig,
this.dataRowIndex,
this.options.cast ?? null,
+ this.headerRow,
);
// Skip records with all empty values (greedy checks after trim)
@@ -1672,7 +1709,18 @@ export class CSVParser>
// onRecord callback: extract fields, allow modification or skipping
if (this.options.onRecord) {
- const rawRow = new CSVRow(this.handle, fieldCount, this.headers, this.options.schema ?? null);
+ const rawRow = new CSVRow(
+ this.handle,
+ fieldCount,
+ this.headers,
+ this.options.schema ?? null,
+ false,
+ null,
+ null,
+ 0,
+ null,
+ this.headerRow
+ );
const fields: (string | null)[] = [];
for (let i = 0; i < fieldCountNum; i++) {
fields.push(rawRow.get(i));
@@ -1694,6 +1742,7 @@ export class CSVParser>
trimConfig,
this.dataRowIndex,
this.options.cast ?? null,
+ this.headerRow,
);
if (skipEmptyValues && this.isRowEmpty(row, result.length, greedyEmpty)) {
@@ -1725,6 +1774,7 @@ export class CSVParser>
trimConfig,
this.dataRowIndex,
this.options.cast ?? null,
+ this.headerRow,
);
// Skip records with all empty values (greedy checks after trim)
diff --git a/src/ts/row.ts b/src/ts/row.ts
index 36a1775..a4c5ec5 100644
--- a/src/ts/row.ts
+++ b/src/ts/row.ts
@@ -101,6 +101,7 @@ export class CSVRow> {
trimConfig: TrimConfig | null = null,
rowIndex: number = 0,
castConfig: CastConfig = null,
+ columns: string[] | null = headers ? Array.from(headers.keys()) : null,
) {
this.handle = handle;
this.fieldCount = fieldCount;
@@ -111,7 +112,7 @@ export class CSVRow> {
this.transform = transform;
this.trimConfig = trimConfig;
this.index = rowIndex;
- this.columns = headers ? Array.from(headers.keys()) : null;
+ this.columns = columns;
this.castConfig = castConfig;
}
@@ -127,6 +128,7 @@ export class CSVRow> {
trimConfig: TrimConfig | null = null,
rowIndex: number = 0,
castConfig: CastConfig = null,
+ columns: string[] | null = headers ? Array.from(headers.keys()) : null,
): CSVRow {
const row = new CSVRow(
0, // no native handle
@@ -138,6 +140,7 @@ export class CSVRow> {
trimConfig,
rowIndex,
castConfig,
+ columns,
);
row.preloadedFields = fields;
return row;
diff --git a/src/zig/dataframe.zig b/src/zig/dataframe.zig
index 3738c74..788e2bc 100644
--- a/src/zig/dataframe.zig
+++ b/src/zig/dataframe.zig
@@ -92,7 +92,7 @@ pub const DataFrame = struct {
column_map: std.StringHashMap(usize),
/// Row references (indices into source data)
- rows: std.ArrayListUnmanaged(RowRef),
+ rows: std.ArrayList(RowRef),
/// Reference to original parser data
source_data: []const u8,
@@ -108,7 +108,7 @@ pub const DataFrame = struct {
.allocator = allocator,
.columns = &.{},
.column_map = std.StringHashMap(usize).init(allocator),
- .rows = .{},
+ .rows = .empty,
.source_data = source_data,
.owns_source = false,
};
@@ -425,14 +425,14 @@ pub const DataFrame = struct {
/// Compute median
pub fn median(self: *Self, col_idx: usize) f64 {
// Collect all numeric values
- var values = std.ArrayList(f64).init(self.allocator);
- defer values.deinit();
+ var values: std.ArrayList(f64) = .empty;
+ defer values.deinit(self.allocator);
for (self.rows.items) |row| {
if (row.getField(col_idx)) |field| {
const str = field.getData(self.source_data);
if (parseFloat(str)) |val| {
- values.append(val) catch continue;
+ values.append(self.allocator, val) catch continue;
}
}
}
@@ -523,7 +523,7 @@ pub const GroupedDataFrame = struct {
source: *DataFrame,
group_col: usize,
/// Map from group key (as string) to row indices
- groups: std.StringHashMap(std.ArrayListUnmanaged(usize)),
+ groups: std.StringHashMap(std.ArrayList(usize)),
const Self = @This();
@@ -533,7 +533,7 @@ pub const GroupedDataFrame = struct {
.allocator = allocator,
.source = source,
.group_col = group_col,
- .groups = std.StringHashMap(std.ArrayListUnmanaged(usize)).init(allocator),
+ .groups = std.StringHashMap(std.ArrayList(usize)).init(allocator),
};
// Build groups
@@ -547,7 +547,7 @@ pub const GroupedDataFrame = struct {
// Need to duplicate the key since it points to source data
const key_copy = try allocator.dupe(u8, key);
result.key_ptr.* = key_copy;
- result.value_ptr.* = .{};
+ result.value_ptr.* = .empty;
}
try result.value_ptr.append(allocator, row_idx);
}
@@ -725,7 +725,7 @@ pub fn joinDataFrames(
}
// Build lookup for right side
- var right_lookup = std.StringHashMap(std.ArrayListUnmanaged(usize)).init(allocator);
+ var right_lookup = std.StringHashMap(std.ArrayList(usize)).init(allocator);
defer {
var it = right_lookup.iterator();
while (it.next()) |entry| {
@@ -739,7 +739,7 @@ pub fn joinDataFrames(
const key = field.getData(right.source_data);
const entry = try right_lookup.getOrPut(key);
if (!entry.found_existing) {
- entry.value_ptr.* = .{};
+ entry.value_ptr.* = .empty;
}
try entry.value_ptr.append(allocator, idx);
}
@@ -905,7 +905,7 @@ fn compareFields(a: FieldRef, b: FieldRef, col_type: ColumnType, source: []const
// FFI Exports
// ============================================================================
-var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+var gpa = std.heap.DebugAllocator(.{}).init;
const global_allocator = gpa.allocator();
/// Create DataFrame from source data
diff --git a/src/zig/mmap.zig b/src/zig/mmap.zig
index ce4e7ac..b31112a 100644
--- a/src/zig/mmap.zig
+++ b/src/zig/mmap.zig
@@ -6,8 +6,9 @@
//!
//! Usage:
//! ```zig
-//! const file = try std.fs.cwd().openFile("data.csv", .{});
-//! const stat = try file.stat();
+//! const io = std.Io.Threaded.global_single_threaded.io();
+//! const file = try std.Io.Dir.cwd().openFile(io, "data.csv", .{});
+//! const stat = try file.stat(io);
//! const mapped = try MappedFile.init(file, stat.size);
//! defer mapped.deinit();
//!
@@ -70,7 +71,7 @@ pub const MappedFile = struct {
const Self = @This();
/// Memory map a file for reading
- pub fn init(file: std.fs.File, size: usize) !Self {
+ pub fn init(file: std.Io.File, size: usize) !Self {
if (size == 0) {
return error.EmptyFile;
}
@@ -82,11 +83,11 @@ pub const MappedFile = struct {
}
}
- fn initPosix(file: std.fs.File, size: usize) !Self {
+ fn initPosix(file: std.Io.File, size: usize) !Self {
const mapped = try std.posix.mmap(
null,
size,
- std.posix.PROT.READ,
+ .{ .READ = true },
.{ .TYPE = .PRIVATE },
file.handle,
0,
@@ -97,7 +98,7 @@ pub const MappedFile = struct {
};
}
- fn initWindows(file: std.fs.File, size: usize) !Self {
+ fn initWindows(file: std.Io.File, size: usize) !Self {
// Create file mapping
const file_mapping = win32.CreateFileMappingW(
file.handle,
diff --git a/src/zig/parallel.zig b/src/zig/parallel.zig
index d0c8c08..e43c6a3 100644
--- a/src/zig/parallel.zig
+++ b/src/zig/parallel.zig
@@ -40,7 +40,7 @@ pub const ParsedRow = struct {
pub const ChunkResult = struct {
chunk_id: usize,
start_row_index: usize,
- rows: std.ArrayListUnmanaged(ParsedRow),
+ rows: std.ArrayList(ParsedRow),
err_msg: ?[]const u8,
bytes_processed: usize,
@@ -132,12 +132,11 @@ pub const ChunkProcessor = struct {
// Thread management
thread_pool: ?[]std.Thread,
- results: std.ArrayListUnmanaged(ChunkResult),
+ results: std.ArrayList(ChunkResult),
chunks: []ChunkBoundary,
// Synchronization
- mutex: std.Thread.Mutex,
- results_ready: std.Thread.Condition,
+ mutex: std.Io.Mutex,
completed_count: usize,
// Reorder buffer
@@ -156,10 +155,9 @@ pub const ChunkProcessor = struct {
.config = config,
.data = data,
.thread_pool = null,
- .results = .{},
+ .results = .empty,
.chunks = &.{},
- .mutex = .{},
- .results_ready = .{},
+ .mutex = .init,
.completed_count = 0,
.reorder_buffer = ReorderBuffer.init(allocator, data, config.max_buffer_rows),
.total_rows_parsed = 0,
@@ -194,7 +192,7 @@ pub const ChunkProcessor = struct {
const thread_count = self.getOptimalThreadCount();
const target_chunk_size = @max(self.data.len / thread_count, self.config.min_chunk_size);
- var chunks_list: std.ArrayListUnmanaged(ChunkBoundary) = .{};
+ var chunks_list: std.ArrayList(ChunkBoundary) = .empty;
var chunk_id: usize = 0;
var pos: usize = 0;
var estimated_row: usize = 0;
@@ -301,23 +299,21 @@ pub const ChunkProcessor = struct {
const error_result = ChunkResult{
.chunk_id = chunk.id,
.start_row_index = chunk.estimated_start_row,
- .rows = .{},
+ .rows = .empty,
.err_msg = std.fmt.allocPrint(self.allocator, "Chunk {} error: {}", .{ chunk.id, err }) catch null,
.bytes_processed = 0,
};
- self.mutex.lock();
+ std.Io.Threaded.mutexLock(&self.mutex);
self.results.append(self.allocator, error_result) catch {};
self.completed_count += 1;
- self.mutex.unlock();
- self.results_ready.signal();
+ std.Io.Threaded.mutexUnlock(&self.mutex);
return;
};
- self.mutex.lock();
+ std.Io.Threaded.mutexLock(&self.mutex);
self.results.append(self.allocator, result) catch {};
self.completed_count += 1;
- self.mutex.unlock();
- self.results_ready.signal();
+ std.Io.Threaded.mutexUnlock(&self.mutex);
}
/// Process a single chunk and return parsed rows
@@ -325,7 +321,7 @@ pub const ChunkProcessor = struct {
var result = ChunkResult{
.chunk_id = chunk.id,
.start_row_index = chunk.estimated_start_row,
- .rows = .{},
+ .rows = .empty,
.err_msg = null,
.bytes_processed = 0,
};
@@ -336,7 +332,7 @@ pub const ChunkProcessor = struct {
var in_quote = false;
while (pos < chunk_data.len) {
- var fields: std.ArrayListUnmanaged(FieldLoc) = .{};
+ var fields: std.ArrayList(FieldLoc) = .empty;
var field_start = pos;
const row_start = pos;
@@ -542,15 +538,14 @@ test "single chunk processing" {
test "parallel processing preserves order" {
// Create larger test data
var buffer: [4096]u8 = undefined;
- var stream = std.io.fixedBufferStream(&buffer);
- const writer = stream.writer();
+ var writer: std.Io.Writer = .fixed(&buffer);
writer.writeAll("id,value\n") catch unreachable;
for (0..50) |i| {
writer.print("{d},test{d}\n", .{ i, i }) catch unreachable;
}
- const data = stream.getWritten();
+ const data = writer.buffered();
const processor = try ChunkProcessor.init(std.testing.allocator, data, .{ .min_chunk_size = 100 });
defer processor.deinit();
diff --git a/src/zig/parser.zig b/src/zig/parser.zig
index 6d68097..0a25f95 100644
--- a/src/zig/parser.zig
+++ b/src/zig/parser.zig
@@ -12,6 +12,10 @@ const parallel = if (is_wasm) @import("parallel_stub.zig") else @import("paralle
const Allocator = std.mem.Allocator;
+fn defaultIo() std.Io {
+ return std.Io.Threaded.global_single_threaded.io();
+}
+
/// Opaque handle for FFI - hides internal Parser struct from JS
pub const ParserHandle = *anyopaque;
@@ -71,7 +75,7 @@ pub const Parser = struct {
// Memory mapped file data
data: []const u8,
data_len: usize,
- file_handle: ?std.fs.File,
+ file_handle: ?std.Io.File,
mapped_file: ?mmap.MappedFile,
// Current parsing state
@@ -80,7 +84,7 @@ pub const Parser = struct {
in_quote: bool,
// Field index for current row
- field_offsets: std.ArrayListUnmanaged(FieldLocation),
+ field_offsets: std.ArrayList(FieldLocation),
// SIMD scanner for accelerated parsing
simd_scanner: simd.SimdScanner,
@@ -102,17 +106,18 @@ pub const Parser = struct {
// State flags
is_paused: bool,
is_closed: bool,
- file_mtime: i128,
+ file_mtime: std.Io.Timestamp,
file_size: u64,
const Self = @This();
/// Initialize parser from file path
pub fn initFromFile(allocator: Allocator, path: []const u8, config: ParserConfig) !*Self {
- const file = try std.fs.cwd().openFile(path, .{ .mode = .read_only });
- errdefer file.close();
+ const io = defaultIo();
+ const file = try std.Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only });
+ errdefer file.close(io);
- const stat = try file.stat();
+ const stat = try file.stat(io);
const file_size = stat.size;
const mtime = stat.mtime;
@@ -165,7 +170,7 @@ pub const Parser = struct {
.cursor = 0,
.current_row = 0,
.in_quote = false,
- .field_offsets = .{},
+ .field_offsets = .empty,
.simd_scanner = simd.SimdScanner.init(config.delimiter, config.quote_char),
.string_cache = std.StringHashMap([]const u8).init(allocator),
.cache_size = 0,
@@ -238,7 +243,7 @@ pub const Parser = struct {
.cursor = 0,
.current_row = 0,
.in_quote = false,
- .field_offsets = .{},
+ .field_offsets = .empty,
.simd_scanner = simd.SimdScanner.init(config.delimiter, config.quote_char),
.string_cache = std.StringHashMap([]const u8).init(allocator),
.cache_size = 0,
@@ -256,7 +261,7 @@ pub const Parser = struct {
},
.is_paused = false,
.is_closed = false,
- .file_mtime = 0,
+ .file_mtime = .zero,
.file_size = data.len,
};
@@ -401,8 +406,8 @@ pub const Parser = struct {
/// Check if file was modified externally
pub fn checkFileModified(self: *Self) bool {
if (self.file_handle) |file| {
- const stat = file.stat() catch return true;
- return stat.mtime != self.file_mtime or stat.size != self.file_size;
+ const stat = file.stat(defaultIo()) catch return true;
+ return stat.mtime.nanoseconds != self.file_mtime.nanoseconds or stat.size != self.file_size;
}
return false;
}
@@ -438,7 +443,7 @@ pub const Parser = struct {
}
}
if (self.file_handle) |file| {
- file.close();
+ file.close(defaultIo());
}
self.is_closed = true;
@@ -500,7 +505,7 @@ pub const Parser = struct {
// FFI Exports (C ABI)
// ============================================================================
-var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+var gpa: std.heap.DebugAllocator(.{}) = .init;
const global_allocator = gpa.allocator();
/// Initialize parser from file path
@@ -1126,27 +1131,27 @@ export fn csv_parse_all_json(handle: ParserHandle) ?[*]const u8 {
json_parse_buffer = null;
}
- // Use ArrayListUnmanaged for dynamic JSON building
- var json: std.ArrayListUnmanaged(u8) = .{};
+ // Build JSON output incrementally.
+ var json: std.ArrayList(u8) = .empty;
defer json.deinit(global_allocator);
// Start array
- json.append(global_allocator,'[') catch return null;
+ json.append(global_allocator, '[') catch return null;
var first_row = true;
while (parser.nextRow()) {
if (!first_row) {
- json.append(global_allocator,',') catch return null;
+ json.append(global_allocator, ',') catch return null;
}
first_row = false;
// Start row array
- json.append(global_allocator,'[') catch return null;
+ json.append(global_allocator, '[') catch return null;
var first_field = true;
for (parser.field_offsets.items) |field| {
if (!first_field) {
- json.append(global_allocator,',') catch return null;
+ json.append(global_allocator, ',') catch return null;
}
first_field = false;
@@ -1154,10 +1159,10 @@ export fn csv_parse_all_json(handle: ParserHandle) ?[*]const u8 {
if (field_data.len == 0) {
// Empty field -> null
- json.appendSlice(global_allocator,"null") catch return null;
+ json.appendSlice(global_allocator, "null") catch return null;
} else if (field.needs_unescape and field_data.len >= 2 and field_data[0] == parser.config.quote_char) {
// Quoted field - unescape and write as JSON string
- json.append(global_allocator,'"') catch return null;
+ json.append(global_allocator, '"') catch return null;
const inner = field_data[1 .. field_data.len - 1];
var j: usize = 0;
@@ -1165,74 +1170,74 @@ export fn csv_parse_all_json(handle: ParserHandle) ?[*]const u8 {
const c = inner[j];
if (c == parser.config.quote_char and j + 1 < inner.len and inner[j + 1] == parser.config.quote_char) {
// Escaped quote "" -> "
- json.append(global_allocator,'"') catch return null;
+ json.append(global_allocator, '"') catch return null;
j += 2;
} else if (c == '"') {
// Escape quote for JSON
- json.appendSlice(global_allocator,"\\\"") catch return null;
+ json.appendSlice(global_allocator, "\\\"") catch return null;
j += 1;
} else if (c == '\\') {
- json.appendSlice(global_allocator,"\\\\") catch return null;
+ json.appendSlice(global_allocator, "\\\\") catch return null;
j += 1;
} else if (c == '\n') {
- json.appendSlice(global_allocator,"\\n") catch return null;
+ json.appendSlice(global_allocator, "\\n") catch return null;
j += 1;
} else if (c == '\r') {
- json.appendSlice(global_allocator,"\\r") catch return null;
+ json.appendSlice(global_allocator, "\\r") catch return null;
j += 1;
} else if (c == '\t') {
- json.appendSlice(global_allocator,"\\t") catch return null;
+ json.appendSlice(global_allocator, "\\t") catch return null;
j += 1;
} else if (c < 0x20) {
// Control character - use unicode escape
- json.appendSlice(global_allocator,"\\u00") catch return null;
+ json.appendSlice(global_allocator, "\\u00") catch return null;
const hex = "0123456789abcdef";
- json.append(global_allocator,hex[c >> 4]) catch return null;
- json.append(global_allocator,hex[c & 0xf]) catch return null;
+ json.append(global_allocator, hex[c >> 4]) catch return null;
+ json.append(global_allocator, hex[c & 0xf]) catch return null;
j += 1;
} else {
- json.append(global_allocator,c) catch return null;
+ json.append(global_allocator, c) catch return null;
j += 1;
}
}
- json.append(global_allocator,'"') catch return null;
+ json.append(global_allocator, '"') catch return null;
} else {
// Unquoted field - write as JSON string with escaping
- json.append(global_allocator,'"') catch return null;
+ json.append(global_allocator, '"') catch return null;
for (field_data) |c| {
if (c == '"') {
- json.appendSlice(global_allocator,"\\\"") catch return null;
+ json.appendSlice(global_allocator, "\\\"") catch return null;
} else if (c == '\\') {
- json.appendSlice(global_allocator,"\\\\") catch return null;
+ json.appendSlice(global_allocator, "\\\\") catch return null;
} else if (c == '\n') {
- json.appendSlice(global_allocator,"\\n") catch return null;
+ json.appendSlice(global_allocator, "\\n") catch return null;
} else if (c == '\r') {
- json.appendSlice(global_allocator,"\\r") catch return null;
+ json.appendSlice(global_allocator, "\\r") catch return null;
} else if (c == '\t') {
- json.appendSlice(global_allocator,"\\t") catch return null;
+ json.appendSlice(global_allocator, "\\t") catch return null;
} else if (c < 0x20) {
- json.appendSlice(global_allocator,"\\u00") catch return null;
+ json.appendSlice(global_allocator, "\\u00") catch return null;
const hex = "0123456789abcdef";
- json.append(global_allocator,hex[c >> 4]) catch return null;
- json.append(global_allocator,hex[c & 0xf]) catch return null;
+ json.append(global_allocator, hex[c >> 4]) catch return null;
+ json.append(global_allocator, hex[c & 0xf]) catch return null;
} else {
- json.append(global_allocator,c) catch return null;
+ json.append(global_allocator, c) catch return null;
}
}
- json.append(global_allocator,'"') catch return null;
+ json.append(global_allocator, '"') catch return null;
}
}
// End row array
- json.append(global_allocator,']') catch return null;
+ json.append(global_allocator, ']') catch return null;
}
// End array and null terminate
- json.append(global_allocator,']') catch return null;
- json.append(global_allocator,0) catch return null;
+ json.append(global_allocator, ']') catch return null;
+ json.append(global_allocator, 0) catch return null;
// Transfer ownership to static buffer
json_parse_buffer = json.toOwnedSlice(global_allocator) catch return null;
@@ -1280,7 +1285,7 @@ export fn csv_parse_all_fast(handle: ParserHandle) ?[*]const u8 {
}
// Build output buffer
- var output: std.ArrayListUnmanaged(u8) = .{};
+ var output: std.ArrayList(u8) = .empty;
defer output.deinit(global_allocator);
var row_count: u32 = 0;
@@ -1289,7 +1294,7 @@ export fn csv_parse_all_fast(handle: ParserHandle) ?[*]const u8 {
while (parser.nextRow()) {
if (!first_row) {
// Row separator
- output.append(global_allocator,0x01) catch return null;
+ output.append(global_allocator, 0x01) catch return null;
}
first_row = false;
row_count += 1;
@@ -1298,7 +1303,7 @@ export fn csv_parse_all_fast(handle: ParserHandle) ?[*]const u8 {
for (parser.field_offsets.items) |field| {
if (!first_field) {
// Field separator
- output.append(global_allocator,0x00) catch return null;
+ output.append(global_allocator, 0x00) catch return null;
}
first_field = false;
@@ -1311,16 +1316,16 @@ export fn csv_parse_all_fast(handle: ParserHandle) ?[*]const u8 {
while (j < inner.len) {
const c = inner[j];
if (c == parser.config.quote_char and j + 1 < inner.len and inner[j + 1] == parser.config.quote_char) {
- output.append(global_allocator,parser.config.quote_char) catch return null;
+ output.append(global_allocator, parser.config.quote_char) catch return null;
j += 2;
} else {
- output.append(global_allocator,c) catch return null;
+ output.append(global_allocator, c) catch return null;
j += 1;
}
}
} else {
// Copy directly
- output.appendSlice(global_allocator,field_data) catch return null;
+ output.appendSlice(global_allocator, field_data) catch return null;
}
}
}
diff --git a/src/zig/simd.zig b/src/zig/simd.zig
index a304e3b..fc8d3d2 100644
--- a/src/zig/simd.zig
+++ b/src/zig/simd.zig
@@ -308,15 +308,13 @@ pub const SimdScanner = struct {
field_starts: *std.ArrayList(usize),
allocator: std.mem.Allocator,
) ?usize {
- _ = allocator;
-
const scan = self.scanRowFast(data, start);
if (!scan.found_row and scan.field_count == 0) return null;
// Convert field_ends to field_starts format
var field_start = start;
for (0..scan.field_count) |i| {
- field_starts.append(field_start) catch return null;
+ field_starts.append(allocator, field_start) catch return null;
field_start = scan.field_ends[i] + 1;
}
@@ -335,8 +333,8 @@ pub const SimdScanner = struct {
field_counts: []usize,
bytes_consumed: usize,
} {
- var row_ends = std.ArrayList(usize).init(allocator);
- var field_counts = std.ArrayList(usize).init(allocator);
+ var row_ends: std.ArrayList(usize) = .empty;
+ var field_counts: std.ArrayList(usize) = .empty;
var pos = start;
var rows_found: usize = 0;
@@ -345,23 +343,43 @@ pub const SimdScanner = struct {
const scan = self.scanRowFast(data, pos);
if (scan.found_row) {
- row_ends.append(scan.row_end) catch break;
- field_counts.append(scan.field_count) catch break;
+ row_ends.append(allocator, scan.row_end) catch break;
+ field_counts.append(allocator, scan.field_count) catch break;
pos = scan.row_end;
rows_found += 1;
} else if (scan.field_count > 0) {
// Last row without newline
- row_ends.append(scan.row_end) catch break;
- field_counts.append(scan.field_count) catch break;
+ row_ends.append(allocator, scan.row_end) catch break;
+ field_counts.append(allocator, scan.field_count) catch break;
break;
} else {
break;
}
}
+ const owned_row_ends = row_ends.toOwnedSlice(allocator) catch {
+ row_ends.deinit(allocator);
+ field_counts.deinit(allocator);
+ return .{
+ .row_ends = &.{},
+ .field_counts = &.{},
+ .bytes_consumed = pos - start,
+ };
+ };
+
+ const owned_field_counts = field_counts.toOwnedSlice(allocator) catch {
+ allocator.free(owned_row_ends);
+ field_counts.deinit(allocator);
+ return .{
+ .row_ends = &.{},
+ .field_counts = &.{},
+ .bytes_consumed = pos - start,
+ };
+ };
+
return .{
- .row_ends = row_ends.toOwnedSlice() catch &.{},
- .field_counts = field_counts.toOwnedSlice() catch &.{},
+ .row_ends = owned_row_ends,
+ .field_counts = owned_field_counts,
.bytes_consumed = pos - start,
};
}
diff --git a/src/zig/writer.zig b/src/zig/writer.zig
index d85468e..93f22be 100644
--- a/src/zig/writer.zig
+++ b/src/zig/writer.zig
@@ -1,5 +1,9 @@
const std = @import("std");
+fn defaultIo() std.Io {
+ return std.Io.Threaded.global_single_threaded.io();
+}
+
/// CSV Writer configuration
pub const WriterConfig = struct {
delimiter: u8 = ',',
@@ -26,7 +30,7 @@ pub const Writer = struct {
config: WriterConfig,
// Output destination
- file: ?std.fs.File,
+ file: ?std.Io.File,
buffer: std.ArrayList(u8),
// State tracking
@@ -38,14 +42,15 @@ pub const Writer = struct {
/// Initialize writer to file
pub fn initToFile(allocator: std.mem.Allocator, path: []const u8, config: WriterConfig) !*Self {
- const file = try std.fs.cwd().createFile(path, .{ .truncate = true });
+ const io = defaultIo();
+ const file = try std.Io.Dir.cwd().createFile(io, path, .{ .truncate = true });
const writer = try allocator.create(Self);
writer.* = Self{
.allocator = allocator,
.config = config,
.file = file,
- .buffer = std.ArrayList(u8).init(allocator),
+ .buffer = .empty,
.rows_written = 0,
.rows_in_buffer = 0,
.bytes_written = 0,
@@ -61,7 +66,7 @@ pub const Writer = struct {
.allocator = allocator,
.config = config,
.file = null,
- .buffer = std.ArrayList(u8).init(allocator),
+ .buffer = .empty,
.rows_written = 0,
.rows_in_buffer = 0,
.bytes_written = 0,
@@ -74,15 +79,15 @@ pub const Writer = struct {
pub fn writeRow(self: *Self, fields: []const []const u8) !void {
for (fields, 0..) |field, i| {
if (i > 0) {
- try self.buffer.append(self.config.delimiter);
+ try self.buffer.append(self.allocator, self.config.delimiter);
}
try self.writeField(field);
}
// Write line ending
switch (self.config.line_ending) {
- .lf => try self.buffer.append('\n'),
- .crlf => try self.buffer.appendSlice("\r\n"),
+ .lf => try self.buffer.append(self.allocator, '\n'),
+ .crlf => try self.buffer.appendSlice(self.allocator, "\r\n"),
}
self.rows_in_buffer += 1;
@@ -98,19 +103,19 @@ pub const Writer = struct {
const needs_quote = self.fieldNeedsQuoting(field);
if (needs_quote or self.config.quote_style == .all) {
- try self.buffer.append(self.config.quote_char);
+ try self.buffer.append(self.allocator, self.config.quote_char);
for (field) |byte| {
if (byte == self.config.quote_char) {
// Escape quote by doubling
- try self.buffer.append(self.config.quote_char);
+ try self.buffer.append(self.allocator, self.config.quote_char);
}
- try self.buffer.append(byte);
+ try self.buffer.append(self.allocator, byte);
}
- try self.buffer.append(self.config.quote_char);
+ try self.buffer.append(self.allocator, self.config.quote_char);
} else {
- try self.buffer.appendSlice(field);
+ try self.buffer.appendSlice(self.allocator, field);
}
}
@@ -131,7 +136,7 @@ pub const Writer = struct {
/// Flush buffer to file
pub fn flush(self: *Self) !void {
if (self.file) |file| {
- try file.writeAll(self.buffer.items);
+ try file.writeStreamingAll(defaultIo(), self.buffer.items);
self.bytes_written += self.buffer.items.len;
}
@@ -157,23 +162,25 @@ pub const Writer = struct {
// Close file if writing to file
if (self.file) |file| {
- file.close();
+ file.close(defaultIo());
}
}
pub fn deinit(self: *Self) void {
self.close() catch {};
- self.buffer.deinit();
+ self.buffer.deinit(self.allocator);
self.allocator.destroy(self);
}
};
/// Modifications tracker for copy-on-write
pub const ModificationLog = struct {
+ const CellKey = struct { row: usize, col: usize };
+
allocator: std.mem.Allocator,
/// Cell modifications: (row, col) -> new_value
- cell_edits: std.AutoHashMap(struct { row: usize, col: usize }, []const u8),
+ cell_edits: std.AutoHashMap(CellKey, []const u8),
/// Deleted row indices
deleted_rows: std.AutoHashMap(usize, void),
@@ -187,7 +194,7 @@ pub const ModificationLog = struct {
const log = try allocator.create(Self);
log.* = Self{
.allocator = allocator,
- .cell_edits = std.AutoHashMap(struct { row: usize, col: usize }, []const u8).init(allocator),
+ .cell_edits = std.AutoHashMap(CellKey, []const u8).init(allocator),
.deleted_rows = std.AutoHashMap(usize, void).init(allocator),
.inserted_rows = std.AutoHashMap(usize, []const []const u8).init(allocator),
};
@@ -264,7 +271,7 @@ pub const ModificationLog = struct {
// FFI Exports
// ============================================================================
-var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+var gpa: std.heap.DebugAllocator(.{}) = .init;
const ffi_allocator = gpa.allocator();
/// Create writer to file
diff --git a/test/benchmark-comparison.ts b/test/benchmark-comparison.ts
index bab4443..c862dc4 100644
--- a/test/benchmark-comparison.ts
+++ b/test/benchmark-comparison.ts
@@ -112,11 +112,28 @@ async function benchmarkTurboCSV(filePath: string): Promise<{ rows: number; time
return { rows, timeMs };
}
+// Benchmark TurboCSV fast mode (simple CSV, no quote handling)
+async function benchmarkTurboCSVFastMode(filePath: string): Promise<{ rows: number; timeMs: number }> {
+ const start = performance.now();
+ const parser = new CSVParser(filePath, { fastMode: true });
+ let rows = 0;
+
+ for (const row of parser) {
+ rows++;
+ // Access a field to ensure parsing
+ row.get(0);
+ }
+
+ parser.close();
+ const timeMs = performance.now() - start;
+
+ return { rows, timeMs };
+}
+
// Benchmark PapaParse
async function benchmarkPapaParse(filePath: string): Promise<{ rows: number; timeMs: number }> {
- const content = readFileSync(filePath, "utf-8");
-
const start = performance.now();
+ const content = readFileSync(filePath, "utf-8");
const result = Papa.parse(content, {
header: true,
skipEmptyLines: true,
@@ -128,9 +145,8 @@ async function benchmarkPapaParse(filePath: string): Promise<{ rows: number; tim
// Benchmark csv-parse (sync)
async function benchmarkCsvParse(filePath: string): Promise<{ rows: number; timeMs: number }> {
- const content = readFileSync(filePath, "utf-8");
-
const start = performance.now();
+ const content = readFileSync(filePath, "utf-8");
const records = csvParse(content, {
columns: true,
skip_empty_lines: true,
@@ -142,10 +158,9 @@ async function benchmarkCsvParse(filePath: string): Promise<{ rows: number; time
// Benchmark fast-csv
async function benchmarkFastCsv(filePath: string): Promise<{ rows: number; timeMs: number }> {
- const content = readFileSync(filePath, "utf-8");
-
return new Promise((resolve) => {
const start = performance.now();
+ const content = readFileSync(filePath, "utf-8");
let rows = 0;
fastCsvParse(content, { headers: true })
@@ -173,6 +188,7 @@ async function benchmarkFile(filePath: string): Promise {
const libraries = [
{ name: "TurboCSV", fn: benchmarkTurboCSV },
+ { name: "TurboCSV fast", fn: benchmarkTurboCSVFastMode },
{ name: "PapaParse", fn: benchmarkPapaParse },
{ name: "csv-parse", fn: benchmarkCsvParse },
{ name: "fast-csv", fn: benchmarkFastCsv },
@@ -278,32 +294,36 @@ async function main() {
console.log("║ Summary ║");
console.log("╚════════════════════════════════════════════════════════════════════╝");
- // Calculate average speedup for TurboCSV vs others
- const turboResults = allResults.filter(r => r.library === "TurboCSV");
+ // Calculate average speedup for TurboCSV variants vs others
+ const turboVariants = ["TurboCSV", "TurboCSV fast"];
const otherLibraries = ["PapaParse", "csv-parse", "fast-csv"];
- console.log("\nAverage speedup (TurboCSV vs others):");
- console.log("─".repeat(40));
+ for (const variant of turboVariants) {
+ const turboResults = allResults.filter(r => r.library === variant);
- for (const lib of otherLibraries) {
- const libResults = allResults.filter(r => r.library === lib);
- let totalSpeedup = 0;
- let count = 0;
+ console.log(`\nAverage speedup (${variant} vs others):`);
+ console.log("─".repeat(40));
- for (const turbo of turboResults) {
- const other = libResults.find(r => r.file === turbo.file);
- if (other) {
- totalSpeedup += turbo.throughputMBs / other.throughputMBs;
- count++;
+ for (const lib of otherLibraries) {
+ const libResults = allResults.filter(r => r.library === lib);
+ let totalSpeedup = 0;
+ let count = 0;
+
+ for (const turbo of turboResults) {
+ const other = libResults.find(r => r.file === turbo.file);
+ if (other) {
+ totalSpeedup += turbo.throughputMBs / other.throughputMBs;
+ count++;
+ }
}
- }
- const avgSpeedup = count > 0 ? totalSpeedup / count : 0;
- const speedupStr = avgSpeedup >= 1
- ? `\x1b[32m${avgSpeedup.toFixed(2)}x faster\x1b[0m`
- : `\x1b[31m${(1/avgSpeedup).toFixed(2)}x slower\x1b[0m`;
+ const avgSpeedup = count > 0 ? totalSpeedup / count : 0;
+ const speedupStr = avgSpeedup >= 1
+ ? `\x1b[32m${avgSpeedup.toFixed(2)}x faster\x1b[0m`
+ : `\x1b[31m${(1 / avgSpeedup).toFixed(2)}x slower\x1b[0m`;
- console.log(` vs ${lib.padEnd(12)}: ${speedupStr}`);
+ console.log(` vs ${lib.padEnd(12)}: ${speedupStr}`);
+ }
}
// Winner summary
@@ -321,7 +341,7 @@ async function main() {
for (const [file, results] of fileGroups) {
results.sort((a, b) => b.throughputMBs - a.throughputMBs);
const winner = results[0];
- const winnerColor = winner.library === "TurboCSV" ? "\x1b[32m" : "\x1b[33m";
+ const winnerColor = winner.library.startsWith("TurboCSV") ? "\x1b[32m" : "\x1b[33m";
console.log(` ${file.padEnd(25)}: ${winnerColor}${winner.library}\x1b[0m (${winner.throughputMBs.toFixed(1)} MB/s)`);
}
diff --git a/test/benchmark.ts b/test/benchmark.ts
new file mode 100644
index 0000000..86d7719
--- /dev/null
+++ b/test/benchmark.ts
@@ -0,0 +1,211 @@
+#!/usr/bin/env bun
+/**
+ * TurboCSV benchmark runner.
+ *
+ * This is the target of `bun run benchmark`. It benchmarks TurboCSV itself
+ * against generated sample files by default, or against explicit file paths
+ * passed on the command line.
+ */
+
+import { existsSync, mkdirSync, statSync, writeFileSync } from "fs";
+import { basename, join } from "path";
+import { CSVParser } from "../src/ts/parser";
+import { generateCSV } from "../src/ts/testing";
+
+const SAMPLES_DIR = join(import.meta.dir, "..", "samples");
+const DEFAULT_ITERATIONS = 5;
+
+interface Options {
+ iterations: number;
+ files: string[];
+}
+
+interface RunResult {
+ rows: number;
+ timeMs: number;
+ throughputMBps: number;
+}
+
+function parseArgs(argv: string[]): Options {
+ const files: string[] = [];
+ let iterations = DEFAULT_ITERATIONS;
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === "--iterations" || arg === "-i") {
+ const value = argv[i + 1];
+ if (!value) {
+ throw new Error(`${arg} requires a value`);
+ }
+ iterations = Number.parseInt(value, 10);
+ i++;
+ } else if (arg?.startsWith("--iterations=")) {
+ iterations = Number.parseInt(arg.slice("--iterations=".length), 10);
+ } else if (arg === "--help" || arg === "-h") {
+ printUsage();
+ process.exit(0);
+ } else if (arg) {
+ files.push(arg);
+ }
+ }
+
+ if (!Number.isFinite(iterations) || iterations < 1) {
+ throw new Error("--iterations must be a positive integer");
+ }
+
+ return { iterations, files };
+}
+
+function printUsage(): void {
+ console.log(`TurboCSV benchmark
+
+Usage:
+ bun run benchmark
+ bun run benchmark -- --iterations 10
+ bun run benchmark -- samples/custom.csv
+
+Options:
+ -i, --iterations Number of measured runs per file (default: ${DEFAULT_ITERATIONS})
+`);
+}
+
+function ensureBenchmarkFile(name: string, rows: number): string {
+ mkdirSync(SAMPLES_DIR, { recursive: true });
+
+ const filePath = join(SAMPLES_DIR, name);
+ if (existsSync(filePath)) {
+ return filePath;
+ }
+
+ const csv = generateCSV({
+ rows,
+ seed: rows,
+ columns: [
+ "id:integer",
+ "name:name",
+ "email:email",
+ "city:city",
+ "signup_date:date",
+ "active:boolean",
+ "score:float",
+ "department:string",
+ ],
+ });
+
+ writeFileSync(filePath, csv);
+ return filePath;
+}
+
+function defaultFiles(): string[] {
+ return [
+ ensureBenchmarkFile("benchmark-1k.csv", 1_000),
+ ensureBenchmarkFile("benchmark-10k.csv", 10_000),
+ ensureBenchmarkFile("benchmark-100k.csv", 100_000),
+ ];
+}
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
+}
+
+function average(values: number[]): number {
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
+}
+
+function median(values: number[]): number {
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ if (sorted.length % 2 === 0) {
+ return ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
+ }
+ return sorted[mid] ?? 0;
+}
+
+function parseFile(filePath: string): number {
+ const parser = new CSVParser(filePath);
+ let rows = 0;
+
+ for (const row of parser) {
+ rows++;
+ row.get(0);
+ }
+
+ parser.close();
+ return rows;
+}
+
+function benchmarkFile(filePath: string, iterations: number): RunResult[] {
+ const fileSize = statSync(filePath).size;
+ const fileSizeMB = fileSize / 1024 / 1024;
+
+ // Warm up native library loading and parser setup.
+ parseFile(filePath);
+
+ const results: RunResult[] = [];
+ for (let i = 0; i < iterations; i++) {
+ const start = performance.now();
+ const rows = parseFile(filePath);
+ const timeMs = performance.now() - start;
+
+ results.push({
+ rows,
+ timeMs,
+ throughputMBps: fileSizeMB / (timeMs / 1000),
+ });
+ }
+
+ return results;
+}
+
+async function main(): Promise {
+ const options = parseArgs(Bun.argv.slice(2));
+ const files = options.files.length > 0 ? options.files : defaultFiles();
+
+ console.log("TurboCSV Benchmark");
+ console.log("=".repeat(70));
+ console.log(`Iterations: ${options.iterations}`);
+ console.log(`SIMD width: ${CSVParser.getSIMDWidth()} bytes`);
+ console.log("");
+
+ for (const filePath of files) {
+ if (!existsSync(filePath)) {
+ throw new Error(`File not found: ${filePath}`);
+ }
+
+ const fileSize = statSync(filePath).size;
+ const results = benchmarkFile(filePath, options.iterations);
+ const times = results.map((result) => result.timeMs);
+ const throughputs = results.map((result) => result.throughputMBps);
+ const rows = results[0]?.rows ?? 0;
+
+ console.log(`${basename(filePath)} (${formatSize(fileSize)})`);
+ console.log("-".repeat(70));
+
+ for (const [index, result] of results.entries()) {
+ console.log(
+ ` Run ${String(index + 1).padStart(2)}: ` +
+ `${result.timeMs.toFixed(1).padStart(8)} ms ` +
+ `${result.throughputMBps.toFixed(1).padStart(8)} MB/s ` +
+ `${result.rows.toLocaleString()} rows`
+ );
+ }
+
+ console.log(
+ ` Avg: ${average(times).toFixed(1).padStart(8)} ms ` +
+ `${average(throughputs).toFixed(1).padStart(8)} MB/s`
+ );
+ console.log(
+ ` Median: ${median(times).toFixed(1).padStart(8)} ms ` +
+ `${median(throughputs).toFixed(1).padStart(8)} MB/s`
+ );
+ console.log(` Rows: ${rows.toLocaleString()}`);
+ console.log("");
+ }
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});
diff --git a/test/fixtures/.gitkeep b/test/fixtures/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/test/fixtures/.gitkeep
@@ -0,0 +1 @@
+
From 00fe874eca20e05aa10ea4923adebb4487974e2f Mon Sep 17 00:00:00 2001
From: bytebrujo
Date: Sat, 13 Jun 2026 09:16:20 +0800
Subject: [PATCH 2/2] ci: use zig 0.16
---
.github/workflows/ci.yml | 4 ++--
.github/workflows/release.yml | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f9a639f..641b60a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,7 @@ jobs:
- name: Setup Zig
uses: goto-bus-stop/setup-zig@v2
with:
- version: 0.14.0
+ version: 0.16.0
- name: Install dependencies
run: bun install
@@ -79,7 +79,7 @@ jobs:
- name: Setup Zig
uses: goto-bus-stop/setup-zig@v2
with:
- version: 0.14.0
+ version: 0.16.0
- name: Build native library
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dbbe497..71f6b91 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -50,7 +50,7 @@ jobs:
- name: Setup Zig
uses: goto-bus-stop/setup-zig@v2
with:
- version: 0.14.0
+ version: 0.16.0
- name: Build native library
run: |
@@ -95,7 +95,7 @@ jobs:
- name: Setup Zig
uses: goto-bus-stop/setup-zig@v2
with:
- version: 0.14.0
+ version: 0.16.0
- name: Download all artifacts
uses: actions/download-artifact@v4