add expires and to methods
All checks were successful
Generic zig build / build (push) Successful in 27s

This commit is contained in:
Emil Lerch 2026-03-03 20:49:54 -08:00
parent c4a59cfbd3
commit 8df8c93df1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 189 additions and 2 deletions

View file

@ -30,7 +30,7 @@ Long format:
# empty lines ignored # empty lines ignored
key::string value, with any data except a \n. an optional string length between the colons key::string value, with any data except a \n. an optional string length between the colons
this is a number:num: 5 this is a number:num: 5
null value:null: null value:null:
array::array's don't exist. Use json or toml or something array::array's don't exist. Use json or toml or something
data with newlines must have a length:7:foo data with newlines must have a length:7:foo
@ -39,7 +39,7 @@ boolean value:bool:false
# Empty line separates records, but comments don't count as empty # Empty line separates records, but comments don't count as empty
key::this is the second record key::this is the second record
this is a number:num:42 this is a number:num:42
null value:null: null value:null:
array::array's still don't exist array::array's still don't exist
data with newlines must have a length::single line data with newlines must have a length::single line
@ -84,3 +84,8 @@ key::this is the second record
**Error handling:** **Error handling:**
- Clear error types needed for different parse failure modes - Clear error types needed for different parse failure modes
- Distinguish between format errors, data errors, and I/O errors - Distinguish between format errors, data errors, and I/O errors
## AI Use
AI was used in this project for comments, parts of the README, and unit test
generation. All other code is human generated.

View file

@ -282,6 +282,90 @@ pub const Record = struct {
pub fn fmt(value: Record, options: FormatOptions) RecordFormatter { pub fn fmt(value: Record, options: FormatOptions) RecordFormatter {
return .{ .value = value, .options = options }; return .{ .value = value, .options = options };
} }
pub fn firstFieldByName(self: Record, field_name: []const u8) ?Field {
for (self.fields) |f|
if (std.mem.eql(u8, f.key, field_name)) return f;
return null;
}
fn coerce(name: []const u8, comptime T: type, val: ?Value) !T {
// Here's the deduplicated set of field types that coerce needs to handle:
// Direct from SRF values:
// Need parsing from string:
// - Date, ?Date -- Date.parse(string)
//
// Won't work with Record.to(T) generically:
// - []const OptionContract -- nested sub-records (OptionsChain has calls/puts arrays)
// - ?[]const Holding, ?[]const SectorWeight -- nested sub-records in EtfProfile
//
const ti = @typeInfo(T);
if (val == null and ti != .optional)
return error.NullValueCannotBeAssignedToNonNullField;
// []const u8 is classified as a pointer
switch (ti) {
.optional => |o| if (val) |_|
return try coerce(name, o.child, val)
else
return null,
.pointer => |p| {
// We don't have an allocator, so the only thing we can do
// here is manage []const u8 or []u8
if (p.size != .slice or p.child != u8)
return error.CoercionNotPossible;
if (val.? != .string and val.? != .bytes)
return error.CoercionNotPossible;
if (val.? == .string)
return val.?.string;
return val.?.bytes;
},
.type, .void, .noreturn => return error.CoercionNotPossible,
.comptime_float, .comptime_int, .undefined, .null, .error_union => return error.CoercionNotPossible,
.error_set, .@"fn", .@"opaque", .frame => return error.CoercionNotPossible,
.@"anyframe", .vector, .enum_literal => return error.CoercionNotPossible,
.int => return @as(T, @intFromFloat(val.?.number)),
.float => return @as(T, @floatCast(val.?.number)),
.bool => return val.?.boolean,
.@"enum" => return std.meta.stringToEnum(T, val.?.string).?,
.array => return error.NotImplemented,
.@"struct", .@"union" => {
if (std.meta.hasMethod(T, "srfParse")) {
if (val.? == .string)
return T.srfParse(val.?.string) catch |e| {
log.err(
"custom parse of value {s} failed : {}",
.{ val.?.string, e },
);
return error.CustomParseFailed;
};
}
return error.CoercionNotPossible;
},
}
return null;
}
/// Coerce Record to a type. Does not handle fields with arrays
pub fn to(self: Record, comptime T: type) !T {
// SAFETY: all fields updated below or error is returned
var obj: T = undefined;
inline for (std.meta.fields(T)) |type_field| {
// find the field in the data by field name, set the value
// if not found, return an error
if (self.firstFieldByName(type_field.name)) |srf_field| {
@field(obj, type_field.name) = try coerce(type_field.name, type_field.type, srf_field.value);
} else {
// No srf_field found...revert to default value
if (type_field.default_value_ptr) |ptr| {
@field(obj, type_field.name) = @as(*const type_field.type, @ptrCast(@alignCast(ptr))).*;
} else {
log.err("Record could not be coerced. Field {s} not found on srf data, and no default value exists on the type", .{type_field.name});
return error.FieldNotFoundOnFieldWithoutDefaultValue;
}
}
}
return obj;
}
}; };
/// The Parsed struct is equivalent to Parsed(T) in std.json. Since most are /// The Parsed struct is equivalent to Parsed(T) in std.json. Since most are
@ -305,6 +389,10 @@ pub const Record = struct {
pub const Parsed = struct { pub const Parsed = struct {
records: std.ArrayList(Record), records: std.ArrayList(Record),
arena: *std.heap.ArenaAllocator, arena: *std.heap.ArenaAllocator,
/// optional expiry time for the data. Useful for caching
/// Note that on a parse, data will always be returned and it will be up
/// to the caller to check is_fresh and determine the right thing to do
expires: ?i64,
pub fn deinit(self: Parsed) void { pub fn deinit(self: Parsed) void {
const child_allocator = self.arena.child_allocator; const child_allocator = self.arena.child_allocator;
@ -315,6 +403,14 @@ pub const Parsed = struct {
_ = self; _ = self;
_ = writer; _ = writer;
} }
pub fn is_fresh(self: Parsed) bool {
if (self.expires) |exp|
return std.time.timestamp > exp;
// no expiry: always fresh, never frozen
return true;
}
}; };
pub const ParseOptions = struct { pub const ParseOptions = struct {
@ -333,6 +429,7 @@ const Directive = union(enum) {
compact_format, compact_format,
require_eof, require_eof,
eof, eof,
expires: i64,
pub fn parse(allocator: std.mem.Allocator, str: []const u8, state: ParseState, options: ParseOptions) ParseError!?Directive { pub fn parse(allocator: std.mem.Allocator, str: []const u8, state: ParseState, options: ParseOptions) ParseError!?Directive {
if (!std.mem.startsWith(u8, str, "#!")) return null; if (!std.mem.startsWith(u8, str, "#!")) return null;
@ -348,6 +445,11 @@ const Directive = union(enum) {
if (std.mem.eql(u8, "eof", line)) return .eof; if (std.mem.eql(u8, "eof", line)) return .eof;
if (std.mem.eql(u8, "compact", line)) return .compact_format; if (std.mem.eql(u8, "compact", line)) return .compact_format;
if (std.mem.eql(u8, "long", line)) return .long_format; if (std.mem.eql(u8, "long", line)) return .long_format;
if (std.mem.startsWith(u8, line, "expires=")) {
return .{ .expires = std.fmt.parseInt(i64, line["expires=".len..], 10) catch return ParseError.ParseFailed };
// try parseError(allocator, options, "#!requireof found. Did you mean #!requireeof?", state);
// return null;
}
return null; return null;
} }
}; };
@ -356,6 +458,9 @@ pub const FormatOptions = struct {
/// Will emit the eof directive as well as requireeof /// Will emit the eof directive as well as requireeof
emit_eof: bool = false, emit_eof: bool = false,
/// Specify an expiration time for the data being written
expires: ?i64 = null,
}; };
/// Returns a formatter that formats the given value /// Returns a formatter that formats the given value
@ -389,6 +494,8 @@ pub const Formatter = struct {
try writer.writeAll("#!long\n"); try writer.writeAll("#!long\n");
if (self.options.emit_eof) if (self.options.emit_eof)
try writer.writeAll("#!requireeof\n"); try writer.writeAll("#!requireeof\n");
if (self.options.expires) |e|
try writer.print("#!expires={d}\n", .{e});
var first = true; var first = true;
for (self.value) |record| { for (self.value) |record| {
if (!first and self.options.long_format) try writer.writeByte('\n'); if (!first and self.options.long_format) try writer.writeByte('\n');
@ -461,6 +568,7 @@ pub fn parse(reader: *std.Io.Reader, allocator: std.mem.Allocator, options: Pars
var parsed: Parsed = .{ var parsed: Parsed = .{
.records = .empty, .records = .empty,
.arena = arena, .arena = arena,
.expires = null,
}; };
const first_data = blk: { const first_data = blk: {
while (nextLine(reader, &state)) |line| { while (nextLine(reader, &state)) |line| {
@ -470,6 +578,7 @@ pub fn parse(reader: *std.Io.Reader, allocator: std.mem.Allocator, options: Pars
.long_format => long_format = true, .long_format => long_format = true,
.compact_format => long_format = false, // what if we have both? .compact_format => long_format = false, // what if we have both?
.require_eof => require_eof = true, .require_eof => require_eof = true,
.expires => |exp| parsed.expires = exp,
.eof => { .eof => {
// there needs to be an eof then // there needs to be an eof then
if (nextLine(reader, &state)) |_| { if (nextLine(reader, &state)) |_| {
@ -847,6 +956,79 @@ test "format all the things" {
const parsed_compact = try parse(&compact_reader, std.testing.allocator, .{}); const parsed_compact = try parse(&compact_reader, std.testing.allocator, .{});
defer parsed_compact.deinit(); defer parsed_compact.deinit();
try std.testing.expectEqualDeep(records, parsed_compact.records.items); try std.testing.expectEqualDeep(records, parsed_compact.records.items);
const expected_expires: i64 = 1772589213;
const compact_expires = try std.fmt.bufPrint(
&buf,
"{f}",
.{fmt(records, .{ .expires = expected_expires })},
);
try std.testing.expectEqualStrings(
\\#!srfv1
\\#!expires=1772589213
\\foo::bar,foo:null:,foo:binary:YmFy,foo:num:42
\\foo::bar,foo:null:,foo:binary:YmFy,foo:num:42
\\
, compact_expires);
// Round trip and make sure we get equivalent objects back
var expires_reader = std.Io.Reader.fixed(compact_expires);
const parsed_expires = try parse(&expires_reader, std.testing.allocator, .{});
defer parsed_expires.deinit();
try std.testing.expectEqualDeep(records, parsed_expires.records.items);
try std.testing.expectEqual(expected_expires, parsed_expires.expires.?);
}
test "serialize/deserialize" {
const RecType = enum {
foo,
bar,
};
const Custom = struct {
const Self = @This();
pub fn srfParse(val: []const u8) !Self {
if (std.mem.eql(u8, "hi", val)) return .{};
return error.ValueNotEqualHi;
}
};
const Data = struct {
foo: []const u8,
bar: u8,
qux: ?RecType = .foo,
b: bool = false,
f: f32 = 4.2,
custom: ?Custom = null,
};
// var buf: [4096]u8 = undefined;
// const compact = try std.fmt.bufPrint(
// &buf,
// "{f}",
// .{fmt(records, .{})},
// );
const compact =
\\#!srfv1
\\foo::bar,foo:null:,foo:binary:YmFy,foo:num:42,bar:num:42
\\foo::bar,foo:null:,foo:binary:YmFy,foo:num:42,bar:num:42
\\foo::bar,foo:null:,foo:binary:YmFy,foo:num:42,bar:num:42,qux::bar
\\foo::bar,foo:null:,foo:binary:YmFy,foo:num:42,bar:num:42,qux::bar,b:bool:true,f:num:6.9,custom:string:hi
\\
;
// Round trip and make sure we get equivalent objects back
var compact_reader = std.Io.Reader.fixed(compact);
const parsed = try parse(&compact_reader, std.testing.allocator, .{});
defer parsed.deinit();
const rec1 = try parsed.records.items[0].to(Data);
try std.testing.expectEqualStrings("bar", rec1.foo);
try std.testing.expectEqual(@as(u8, 42), rec1.bar);
try std.testing.expectEqual(@as(RecType, .foo), rec1.qux);
const rec4 = try parsed.records.items[3].to(Data);
try std.testing.expectEqualStrings("bar", rec4.foo);
try std.testing.expectEqual(@as(u8, 42), rec4.bar);
try std.testing.expectEqual(@as(RecType, .bar), rec4.qux.?);
try std.testing.expectEqual(true, rec4.b);
try std.testing.expectEqual(@as(f32, 6.9), rec4.f);
} }
test "compact format length-prefixed string as last field" { test "compact format length-prefixed string as last field" {
// When a length-prefixed value is the last field on the line, // When a length-prefixed value is the last field on the line,