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 }