lib/preserves/src/text/test.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const namespace = @import("root.zig");
  2 const std = @import("std");
  3 const preserves = @import("../root.zig");
  4 
  5 const format = @import("format.zig");
  6 const parser = @import("parser.zig");
  7 const reader = namespace.reader;
  8 const writer = namespace.writer;
  9 const text = namespace;
 10 const integer_mod = preserves.integer_mod;
 11 const AnyEmbedded = namespace.AnyEmbedded;
 12 const Value = namespace.Value;
 13 const ParseError = namespace.ParseError;
 14 const toText = namespace.toText;
 15 const parse = namespace.parse;
 16 const decode = namespace.decode;
 17 const encode = namespace.encode;
 18 test {
 19     std.testing.refAllDecls(format);
 20     std.testing.refAllDecls(parser);
 21     std.testing.refAllDecls(reader);
 22     std.testing.refAllDecls(writer);
 23 }
 24 
 25 test "toText round-trips primitives through parse" {
 26     const allocator = std.testing.allocator;
 27 
 28     const text_true = try toText(allocator, Value.initBoolean(true));
 29     defer allocator.free(text_true);
 30     try std.testing.expectEqualStrings("#t", text_true);
 31 
 32     const text_int = try toText(allocator, Value.initI128(-42));
 33     defer allocator.free(text_int);
 34     try std.testing.expectEqualStrings("-42", text_int);
 35 
 36     const text_sym = try toText(allocator, Value{ .symbol = "hello" });
 37     defer allocator.free(text_sym);
 38     try std.testing.expectEqualStrings("hello", text_sym);
 39 
 40     const text_str = try toText(allocator, Value{ .string = "hi" });
 41     defer allocator.free(text_str);
 42     try std.testing.expectEqualStrings("\"hi\"", text_str);
 43 }
 44 
 45 test "toText treats foreign semantic bundles as opaque" {
 46     const Foreign = struct {
 47         fn eql(a: *anyopaque, b: *anyopaque) bool {
 48             return a == b;
 49         }
 50 
 51         fn hash(value: *anyopaque) u64 {
 52             return @intFromPtr(value);
 53         }
 54 
 55         fn order(a: *anyopaque, b: *anyopaque) std.math.Order {
 56             return std.math.order(@intFromPtr(a), @intFromPtr(b));
 57         }
 58 
 59         const ops: preserves.SemanticOps = .{
 60             .eql = &eql,
 61             .hash = &hash,
 62             .order = &order,
 63         };
 64     };
 65     const allocator = std.testing.allocator;
 66     var payload: u32 = 42;
 67     const value = Value.initEmbedded(.{ .value = &payload, .semantic_ops = &Foreign.ops });
 68     const rendered = try toText(allocator, value);
 69     defer allocator.free(rendered);
 70 
 71     var expected_buffer: [32]u8 = undefined;
 72     const expected = try std.fmt.bufPrint(
 73         &expected_buffer,
 74         "#:{d}",
 75         .{@intFromPtr(&payload)},
 76     );
 77     try std.testing.expectEqualStrings(expected, rendered);
 78 }
 79 
 80 test "parse assigns the parsed semantic bundle to embedded values" {
 81     const allocator = std.testing.allocator;
 82     var parsed = try parse(allocator, "#:42");
 83     defer parsed.deinit(allocator);
 84 
 85     try std.testing.expect(parsed == .embedded);
 86     try std.testing.expect(
 87         parsed.embedded.semantic_ops == preserves.parsedEmbeddedOps(Value),
 88     );
 89     try std.testing.expect(
 90         parsed.embedded.deinit_fn ==
 91             preserves.embedded_mod.parsedEmbeddedDeinit(Value),
 92     );
 93     try std.testing.expect(
 94         parsed.embedded.clone_fn ==
 95             preserves.embedded_mod.parsedEmbeddedClone(Value),
 96     );
 97 
 98     var cloned = try preserves.cloneValueDeep(allocator, parsed);
 99     defer cloned.deinit(allocator);
100     try std.testing.expect(parsed.eql(cloned));
101 }
102 
103 test "toText round-trips extreme finite doubles" {
104     const allocator = std.testing.allocator;
105     const extremes = [_]f64{
106         std.math.floatMax(f64),
107         -std.math.floatMax(f64),
108         std.math.floatTrueMin(f64),
109         -std.math.floatTrueMin(f64),
110         1e300,
111         -1e-300,
112     };
113     for (extremes) |v| {
114         const rendered = try toText(allocator, Value.initDouble(v));
115         defer allocator.free(rendered);
116         var parsed = try parse(allocator, rendered);
117         defer parsed.deinit(allocator);
118         try std.testing.expect(parsed.eql(Value.initDouble(v)));
119     }
120 }
121 
122 test "toText encodes byte strings in hash-quoted form" {
123     const allocator = std.testing.allocator;
124     const bytes = [_]u8{ 0x00, 'a', 0x1f, 0x22, 0x5c, 0x7e, 0x7f };
125     const out = try toText(allocator, Value{ .byte_string = &bytes });
126     defer allocator.free(out);
127     try std.testing.expectEqualStrings("#\"\\x00a\\x1f\\x22\\x5c~\\x7f\"", out);
128 }
129 
130 test "parse accepts unpadded base64 byte strings from the text writer" {
131     const allocator = std.testing.allocator;
132     var value = try parse(allocator, "#[AQI]");
133     defer value.deinit(allocator);
134 
135     try std.testing.expect(value == .byte_string);
136     try std.testing.expectEqualSlices(u8, &.{ 1, 2 }, value.byte_string);
137 }
138 
139 test "toText renders rest_pattern as `[prefix . rest]`" {
140     const allocator = std.testing.allocator;
141     var arena = std.heap.ArenaAllocator.init(allocator);
142     defer arena.deinit();
143     const a = arena.allocator();
144 
145     const prefix = try a.alloc(Value, 2);
146     prefix[0] = Value.initI128(1);
147     prefix[1] = Value.initI128(2);
148     const rest = try a.create(Value);
149     rest.* = .{ .discard = {} };
150     const rp = Value{ .rest_pattern = .{ .prefix = prefix, .rest = rest } };
151     const out = try toText(allocator, rp);
152     defer allocator.free(out);
153     try std.testing.expectEqualStrings("[1 2 . <_>]", out);
154 }
155 
156 test "toText renders discard as `<_>` and capture via wire record" {
157     const allocator = std.testing.allocator;
158     var arena = std.heap.ArenaAllocator.init(allocator);
159     defer arena.deinit();
160     const a = arena.allocator();
161 
162     const d_out = try toText(allocator, Value{ .discard = {} });
163     defer allocator.free(d_out);
164     try std.testing.expectEqualStrings("<_>", d_out);
165 
166     const inner = try a.create(Value);
167     inner.* = Value.initI128(9);
168     const cap = Value{ .capture = inner };
169     const cap_out = try toText(allocator, cap);
170     defer allocator.free(cap_out);
171     try std.testing.expectEqualStrings("<bind <lit 9>>", cap_out);
172 }
173 
174 test "parse handles primitives and records" {
175     const allocator = std.testing.allocator;
176     var v = try parse(allocator, "<hello 42>");
177     defer v.deinit(allocator);
178 
179     try std.testing.expectEqual(std.meta.Tag(Value).record, std.meta.activeTag(v));
180     try std.testing.expectEqualStrings("hello", v.record.label.symbol);
181     try std.testing.expectEqual(@as(i128, 42), try v.record.fields[0].signed_integer.toI128());
182 }
183 
184 test "parse owns quoted symbols" {
185     const allocator = std.testing.allocator;
186     var value = try parse(allocator, "'quoted symbol'");
187     defer value.deinit(allocator);
188 
189     try std.testing.expect(value == .symbol);
190     try std.testing.expectEqualStrings("quoted symbol", value.symbol);
191 }
192 
193 test "parse preserves an encoded symbol with underscored digits" {
194     const allocator = std.testing.allocator;
195     const encoded = try encode(AnyEmbedded, allocator, Value{ .symbol = "1_000" });
196     defer allocator.free(encoded);
197     try std.testing.expectEqualStrings("1_000", encoded);
198 
199     var parsed = try parse(allocator, encoded);
200     defer parsed.deinit(allocator);
201     try std.testing.expect(parsed == .symbol);
202     try std.testing.expectEqualStrings("1_000", parsed.symbol);
203 }
204 
205 test "parse preserves encoded bare symbols shaped like doubles" {
206     const allocator = std.testing.allocator;
207     const symbols = [_][]const u8{ ".5", "5.", "0xe", "1_2e3" };
208     const bits = [_]u8{ 1, 2, 4, 8 };
209     var failures: u8 = 0;
210     for (symbols, 0..) |symbol, index| {
211         const encoded = try encode(AnyEmbedded, allocator, Value{ .symbol = symbol });
212         defer allocator.free(encoded);
213         try std.testing.expectEqualStrings(symbol, encoded);
214 
215         var parsed = try parse(allocator, encoded);
216         defer parsed.deinit(allocator);
217         if (parsed != .symbol or !std.mem.eql(u8, symbol, parsed.symbol)) {
218             failures |= bits[index];
219         }
220     }
221     try std.testing.expectEqual(@as(u8, 0), failures);
222 }
223 
224 test "parse reads doubles written by encode" {
225     const allocator = std.testing.allocator;
226     const doubles = [_]f64{ 1.5, -2.5e-3, 1e300 };
227     for (doubles) |number| {
228         const encoded = try encode(AnyEmbedded, allocator, Value.initDouble(number));
229         defer allocator.free(encoded);
230 
231         var parsed = try parse(allocator, encoded);
232         defer parsed.deinit(allocator);
233         try std.testing.expect(parsed == .double);
234         try std.testing.expectEqual(number, parsed.double);
235     }
236 }
237 
238 test "parse preserves an encoded symbol with an apostrophe" {
239     const allocator = std.testing.allocator;
240     const encoded = try encode(AnyEmbedded, allocator, Value{ .symbol = "can't" });
241     defer allocator.free(encoded);
242     try std.testing.expectEqualStrings("'can\\'t'", encoded);
243 
244     var parsed = try parse(allocator, encoded);
245     defer parsed.deinit(allocator);
246     try std.testing.expect(parsed == .symbol);
247     try std.testing.expectEqualStrings("can't", parsed.symbol);
248 }
249 
250 test "parse accepts an escaped apostrophe in a string" {
251     const allocator = std.testing.allocator;
252     var parsed = try parse(allocator, "\"can\\'t\"");
253     defer parsed.deinit(allocator);
254     try std.testing.expect(parsed == .string);
255     try std.testing.expectEqualStrings("can't", parsed.string);
256 }
257 
258 test "parse rejects invalid UTF-8 in strings and symbols" {
259     const allocator = std.testing.allocator;
260     const invalid_string = [_]u8{ '"', 0xff, '"' };
261     const invalid_quoted_symbol = [_]u8{ '\'', 0xff, '\'' };
262     const invalid_bare_symbol = [_]u8{ 'a', 0xff };
263     try std.testing.expectError(ParseError.InvalidUtf8, parse(allocator, &invalid_string));
264     try std.testing.expectError(ParseError.InvalidUtf8, parse(allocator, &invalid_quoted_symbol));
265     try std.testing.expectError(ParseError.InvalidUtf8, parse(allocator, &invalid_bare_symbol));
266 }
267 
268 test "parse promotes oversized integers past i64.max into tier-correct values" {
269     const allocator = std.testing.allocator;
270     const huge = "170141183460469231731687303715884105728";
271     var v = try parse(allocator, huge);
272     defer v.deinit(allocator);
273 
274     try std.testing.expectEqual(std.meta.Tag(Value).signed_integer, std.meta.activeTag(v));
275     try std.testing.expectEqual(
276         integer_mod.Tier.u128,
277         @as(integer_mod.Tier, v.signed_integer.repr),
278     );
279 }
280 
281 test "parse owns bare symbols after the source is released" {
282     const allocator = std.testing.allocator;
283     var value = blk: {
284         const source = try allocator.dupe(u8, "<hello [world 'quoted symbol']>");
285         defer allocator.free(source);
286         break :blk try parse(allocator, source);
287     };
288     defer value.deinit(allocator);
289 
290     try std.testing.expect(value == .record);
291     try std.testing.expectEqualStrings("hello", value.record.label.symbol);
292     try std.testing.expectEqualStrings("world", value.record.fields[0].sequence[0].symbol);
293     try std.testing.expectEqualStrings(
294         "quoted symbol",
295         value.record.fields[0].sequence[1].symbol,
296     );
297 }
298 
299 test "parse sorts distinct set entries" {
300     const allocator = std.testing.allocator;
301     var v = try parse(allocator, "#{3 1 2}");
302     defer v.deinit(allocator);
303 
304     try std.testing.expectEqual(@as(usize, 3), v.set.len);
305     try std.testing.expectEqual(@as(i128, 1), try v.set[0].signed_integer.toI128());
306     try std.testing.expectEqual(@as(i128, 2), try v.set[1].signed_integer.toI128());
307     try std.testing.expectEqual(@as(i128, 3), try v.set[2].signed_integer.toI128());
308 }
309 
310 test "parse rejects duplicate set entries and releases nested embedded values" {
311     const allocator = std.testing.allocator;
312 
313     try std.testing.expectError(
314         text.ParseError.DuplicateSetElement,
315         parse(allocator, "#{#:[\"owned\" 'quoted'] #:[\"owned\" 'quoted']}"),
316     );
317 }
318 
319 test "parse sorts distinct dictionary entries by key" {
320     const allocator = std.testing.allocator;
321     var v = try parse(allocator, "{3: c, 1: a, 2: b}");
322     defer v.deinit(allocator);
323 
324     try std.testing.expectEqual(@as(usize, 3), v.dictionary.len);
325     try std.testing.expectEqual(@as(i128, 1), try v.dictionary[0].key.signed_integer.toI128());
326     try std.testing.expectEqualStrings("a", v.dictionary[0].value.symbol);
327 }
328 
329 test "parse rejects duplicate dictionary keys and releases owned entries" {
330     const allocator = std.testing.allocator;
331 
332     try std.testing.expectError(
333         text.ParseError.DuplicateDictionaryKey,
334         parse(
335             allocator,
336             "{\"key\": #:<\"label\" 'first'>, \"key\": #:<\"label\" 'second'>}",
337         ),
338     );
339 }
340 
341 test "toText rejects duplicate set elements and dictionary keys" {
342     const allocator = std.testing.allocator;
343     var set_items = [_]Value{ Value.initI128(1), Value.initI128(1) };
344     var entries = [_]Value.DictionaryEntry{
345         .{ .key = Value.initI128(1), .value = Value.initBoolean(true) },
346         .{ .key = Value.initI128(1), .value = Value.initBoolean(false) },
347     };
348 
349     try std.testing.expectError(
350         error.DuplicateSetElement,
351         toText(allocator, Value.initSet(&set_items)),
352     );
353     try std.testing.expectError(
354         error.DuplicateDictionaryKey,
355         toText(allocator, Value.initDictionary(&entries)),
356     );
357 }
358 
359 test "parse skips `# ` line comments" {
360     const allocator = std.testing.allocator;
361     var v = try parse(allocator,
362         \\# a leading comment
363         \\42
364     );
365     defer v.deinit(allocator);
366 
367     try std.testing.expectEqual(@as(i128, 42), try v.signed_integer.toI128());
368 }
369 
370 fn writeMixedNesting(buffer: []u8, depth: usize) []const u8 {
371     var length: usize = 0;
372     for (0..depth) |index| {
373         const opening = switch (index % 4) {
374             0 => "[",
375             1 => "<r ",
376             2 => "#{",
377             3 => "{0:",
378             else => unreachable,
379         };
380         @memcpy(buffer[length .. length + opening.len], opening);
381         length += opening.len;
382     }
383     buffer[length] = '0';
384     length += 1;
385     var index = depth;
386     while (index > 0) {
387         index -= 1;
388         buffer[length] = switch (index % 4) {
389             0 => ']',
390             1 => '>',
391             2, 3 => '}',
392             else => unreachable,
393         };
394         length += 1;
395     }
396     return buffer[0..length];
397 }
398 
399 const NestingEdge = enum {
400     record_label,
401     record_field,
402     sequence_item,
403     set_item,
404     dictionary_key,
405     dictionary_value,
406     annotation_metadata,
407     annotation_target,
408 };
409 
410 fn writeEdgeNesting(buffer: []u8, depth: usize, edge: NestingEdge) []const u8 {
411     var length: usize = 0;
412     for (0..depth) |_| {
413         const opening = switch (edge) {
414             .record_label => "<",
415             .record_field => "<r ",
416             .sequence_item => "[",
417             .set_item => "#{",
418             .dictionary_key => "{",
419             .dictionary_value => "{0:",
420             .annotation_metadata => "@",
421             .annotation_target => "@0 ",
422         };
423         @memcpy(buffer[length .. length + opening.len], opening);
424         length += opening.len;
425     }
426     buffer[length] = '0';
427     length += 1;
428     for (0..depth) |_| {
429         const closing = switch (edge) {
430             .record_label, .record_field => ">",
431             .sequence_item => "]",
432             .set_item, .dictionary_value => "}",
433             .dictionary_key => ":0}",
434             .annotation_metadata => " 0",
435             .annotation_target => "",
436         };
437         @memcpy(buffer[length .. length + closing.len], closing);
438         length += closing.len;
439     }
440     return buffer[0..length];
441 }
442 
443 fn writeAnnotationEmbeddedNesting(buffer: []u8, depth: usize) []const u8 {
444     buffer[0] = '@';
445     var length: usize = 1;
446     for (0..depth) |_| {
447         @memcpy(buffer[length .. length + 2], "#:");
448         length += 2;
449     }
450     @memcpy(buffer[length .. length + 3], "0 1");
451     return buffer[0 .. length + 3];
452 }
453 
454 fn expectBothNestingSuccess(source: []const u8) !void {
455     const allocator = std.testing.allocator;
456     var parsed = try parse(allocator, source);
457     defer parsed.deinit(allocator);
458     var decoded = try text.decode(preserves.NoEmbedded, allocator, source);
459     defer decoded.deinit(allocator);
460 }
461 
462 fn expectBothNestingFailure(source: []const u8) !void {
463     const allocator = std.testing.allocator;
464     try std.testing.expectError(
465         text.NestingError.NestingLimitExceeded,
466         parse(allocator, source),
467     );
468     try std.testing.expectError(
469         text.NestingError.NestingLimitExceeded,
470         text.decode(preserves.NoEmbedded, allocator, source),
471     );
472 }
473 
474 test "text codecs enforce the ordinary value nesting boundary" {
475     const maximum: usize = text.max_nesting_depth;
476     var buffer: [maximum * 4 + 5]u8 = undefined;
477     const edges = [_]NestingEdge{
478         .record_label,
479         .record_field,
480         .sequence_item,
481         .set_item,
482         .dictionary_key,
483         .dictionary_value,
484     };
485 
486     for (edges) |edge| {
487         try expectBothNestingSuccess(writeEdgeNesting(&buffer, maximum, edge));
488         try expectBothNestingFailure(writeEdgeNesting(&buffer, maximum + 1, edge));
489     }
490 }
491 
492 test "text codecs enforce the annotation nesting boundary" {
493     const maximum: usize = text.max_nesting_depth;
494     var buffer: [maximum * 4 + 5]u8 = undefined;
495     const edges = [_]NestingEdge{ .annotation_metadata, .annotation_target };
496 
497     for (edges) |edge| {
498         try expectBothNestingSuccess(writeEdgeNesting(&buffer, maximum, edge));
499         try expectBothNestingFailure(writeEdgeNesting(&buffer, maximum + 1, edge));
500     }
501 }
502 
503 test "text codecs enforce nesting through annotation embedded values" {
504     const maximum: usize = text.max_nesting_depth;
505     var buffer: [maximum * 2 + 4]u8 = undefined;
506 
507     try expectBothNestingSuccess(writeAnnotationEmbeddedNesting(&buffer, maximum - 1));
508     try expectBothNestingFailure(writeAnnotationEmbeddedNesting(&buffer, maximum));
509 }
510 
511 test "generic text reader leaves the rejected nesting opener unread" {
512     const maximum: usize = text.max_nesting_depth;
513     var buffer: [maximum * 4 + 5]u8 = undefined;
514     const source = writeEdgeNesting(&buffer, maximum + 1, .sequence_item);
515     var index: usize = 0;
516 
517     try std.testing.expectError(
518         text.NestingError.NestingLimitExceeded,
519         text.reader.readValue(preserves.NoEmbedded, std.testing.allocator, source, &index),
520     );
521     try std.testing.expectEqual(maximum, index);
522 }
523 
524 fn writeOwnedOverLimitAnnotation(buffer: []u8) []const u8 {
525     const prefix = "@[\"owned\" ";
526     @memcpy(buffer[0..prefix.len], prefix);
527     const nested = writeMixedNesting(buffer[prefix.len..], text.max_nesting_depth - 1);
528     const length = prefix.len + nested.len;
529     @memcpy(buffer[length .. length + 3], "] 0");
530     return buffer[0 .. length + 3];
531 }
532 
533 fn expectNestingWithAllocator(
534     comptime decodeFn: anytype,
535     allocator: std.mem.Allocator,
536     source: []const u8,
537 ) !void {
538     if (decodeFn(allocator, source)) |decoded| {
539         var value = decoded;
540         value.deinit(allocator);
541         return error.ExpectedNestingLimit;
542     } else |err| switch (err) {
543         error.OutOfMemory => return err,
544         error.NestingLimitExceeded => return,
545         else => return err,
546     }
547 }
548 
549 fn decodeGenericForNesting(
550     allocator: std.mem.Allocator,
551     source: []const u8,
552 ) text.reader.DecodeError!preserves.Value(preserves.NoEmbedded) {
553     return text.decode(preserves.NoEmbedded, allocator, source);
554 }
555 
556 fn checkNestingAllocationFailures(allocator: std.mem.Allocator) !void {
557     const maximum: usize = text.max_nesting_depth;
558     var buffer: [maximum * 4 + 32]u8 = undefined;
559     const source = writeOwnedOverLimitAnnotation(&buffer);
560     try expectNestingWithAllocator(parse, allocator, source);
561     try expectNestingWithAllocator(decodeGenericForNesting, allocator, source);
562 }
563 
564 test "text nesting errors release every allocation failure path" {
565     try std.testing.checkAllAllocationFailures(
566         std.testing.allocator,
567         checkNestingAllocationFailures,
568         .{},
569     );
570 }