authorgravatar for goon.pri.low@gmail.comKendall Condon <goon.pri.low@gmail.com> 2025-07-28 13:24:31-04:00
committergravatar for goon.pri.low@gmail.comKendall Condon <goon.pri.low@gmail.com> 2026-03-12 17:14:29-04:00
log476c3a1f379eadbd200d568340a2f479d5b6d260
tree22ca8d72089676e0dd89c34e06033a933844cf85
parent0978566db8b7ed2b730cf01a7d359e9b52ec66ec

zig fmt: add a fuzz test

This fuzz test checks several properties of zig fmt are upheld, namely idempotency, textual equivilence, and no trailing whitespace. All functions in the fuzz test have @disableInstrumentation for performance and since their branches are not interesting to the fuzzer.

1 files changed, 261 insertions(+), 0 deletions(-)

lib/std/zig/parser_test.zig+261
......@@ -1,6 +1,7 @@
11const std = @import("std");
22const Io = std.Io;
33const Allocator = std.mem.Allocator;
4const Token = std.zig.Token;
45
56test "zig fmt: remove extra whitespace at start and end of file with comment between" {
67 try testTransform(
......@@ -6426,3 +6427,263 @@ fn fuzzTestOneParse(_: void, smith: *std.testing.Smith) !void {
64266427 var fba: std.heap.FixedBufferAllocator = .init(&fixed_buffer_mem);
64276428 _ = std.zig.Ast.parseTokens(fba.allocator(), tokens.source(), tokens.list(), mode) catch return;
64286429}
6430
6431test "zig fmt: fuzz" {
6432 try std.testing.fuzz({}, fuzzRender, .{});
6433}
6434
6435fn parseTokens(
6436 fba: Allocator,
6437 source: [:0]const u8,
6438) error{ SkipZigTest, OutOfMemory }!struct {
6439 toks: std.zig.Ast.TokenList,
6440 maybe_rewritable: bool,
6441 skip_idempotency: bool,
6442} {
6443 @disableInstrumentation();
6444 // Byte-order marker is stripped
6445 var maybe_rewritable = std.mem.startsWith(u8, source, "\xEF\xBB\xBF");
6446 var skip_idempotency = false; // This should be able to be removed once all the bugs are fixed
6447
6448 var tokens: std.zig.Ast.TokenList = .{};
6449 try tokens.ensureTotalCapacity(fba, source.len / 2);
6450 var tokenizer: std.zig.Tokenizer = .init(source);
6451 while (true) {
6452 const tok = tokenizer.next();
6453 switch (tok.tag) {
6454 .invalid,
6455 .invalid_periodasterisks,
6456 => return error.SkipZigTest,
6457 // Extra colons can be removed
6458 .keyword_asm,
6459 // Qualifiers can be reordered
6460 // keyword_const is intentionally excluded since it is used in other contexts and
6461 // having only one qualifier will never lead to reordering.
6462 .keyword_addrspace,
6463 .keyword_align,
6464 .keyword_allowzero,
6465 .keyword_callconv,
6466 .keyword_linksection,
6467 .keyword_volatile,
6468 => maybe_rewritable = true,
6469 .builtin,
6470 // Pointer casts can be reordered
6471 => for ([_][]const u8{
6472 "ptrCast",
6473 "alignCast",
6474 "addrSpaceCast",
6475 "constCast",
6476 "volatileCast",
6477 }) |id| {
6478 if (std.mem.eql(u8, source[tok.loc.start + 1 .. tok.loc.end], id)) {
6479 maybe_rewritable = false;
6480 }
6481 },
6482 // Quoted identifiers can be unquoted
6483 .identifier => maybe_rewritable = maybe_rewritable or source[tok.loc.start] == '@',
6484 else => {},
6485 // #23754
6486 .container_doc_comment,
6487 => if (std.mem.endsWith(Token.Tag, tokens.items(.tag), &.{.l_brace})) {
6488 return error.SkipZigTest;
6489 },
6490 // #24507
6491 .keyword_inline,
6492 .keyword_for,
6493 .keyword_while,
6494 .l_brace,
6495 => if (std.mem.endsWith(Token.Tag, tokens.items(.tag), &.{ .identifier, .colon })) {
6496 maybe_rewritable = true;
6497 skip_idempotency = true;
6498 },
6499 }
6500 try tokens.append(fba, .{
6501 .tag = tok.tag,
6502 .start = @intCast(tok.loc.start),
6503 });
6504 if (tok.tag == .eof)
6505 break;
6506 }
6507 return .{
6508 .toks = tokens,
6509 .maybe_rewritable = maybe_rewritable,
6510 .skip_idempotency = skip_idempotency,
6511 };
6512}
6513
6514/// Checks equivelence of non-whitespace characters.
6515/// If there are commas in `source`, then it is checked they are also present
6516/// in `rendered`. Extra commas in `rendered` are ignored.
6517fn isRewritten(source: [:0]const u8, rendered: [:0]const u8) bool {
6518 @disableInstrumentation();
6519 var i: usize = 0;
6520 for (source[0 .. source.len + 1]) |c| switch (c) {
6521 ' ', '\r', '\t', '\n' => {},
6522 else => while (true) {
6523 defer i += 1;
6524 switch (rendered[i]) {
6525 ' ', '\n' => {},
6526 ',' => if (c == ',') break,
6527 else => |r| if (c != r) return false else break,
6528 }
6529 },
6530 };
6531 std.debug.assert(i >= rendered.len);
6532 return false;
6533}
6534
6535/// Checks that no line ends in whitespace
6536fn checkBetweenTokens(src: []const u8, fmt_on: *bool) error{
6537 TrailingLineWhitespace,
6538 DoubleEmptyLine,
6539}!void {
6540 @disableInstrumentation();
6541 var pos: usize = 0;
6542 while (true) {
6543 const nl_pos = std.mem.indexOfScalarPos(u8, src, pos, '\n');
6544 var check_trailing = fmt_on.*;
6545
6546 const line = src[pos .. nl_pos orelse src.len];
6547 if (std.mem.indexOfScalar(u8, line, '/')) |comment_start| {
6548 const comment_content = line[comment_start..][2..];
6549 const trimmed_comment = std.mem.trim(u8, comment_content, &std.ascii.whitespace);
6550 if (std.mem.eql(u8, trimmed_comment, "zig fmt: off")) {
6551 fmt_on.* = false;
6552 } else if (std.mem.eql(u8, trimmed_comment, "zig fmt: on")) {
6553 fmt_on.* = true;
6554 check_trailing = true;
6555 }
6556 }
6557
6558 pos = nl_pos orelse break;
6559 if (check_trailing and pos != 0) switch (src[pos - 1]) {
6560 ' ', '\t', '\r' => return error.TrailingLineWhitespace,
6561 '\n' => if (pos != 1 and src[pos - 2] == '\n') return error.DoubleEmptyLine,
6562 else => {},
6563 };
6564 pos += 1;
6565 }
6566}
6567
6568/// Ignores extre `.comma` tokens in `rendered`
6569fn reparseTokens(
6570 fba: Allocator,
6571 rendered: [:0]const u8,
6572 expected_tags: [:.eof]const Token.Tag,
6573) error{
6574 OutOfMemory,
6575 SameLineMultilineStringLiteral,
6576 TrailingLineWhitespace,
6577 DoubleEmptyLine,
6578}!struct {
6579 toks: std.zig.Ast.TokenList,
6580 rewritten: bool,
6581} {
6582 @disableInstrumentation();
6583 var rewritten = false;
6584 var tokens: std.zig.Ast.TokenList = .{};
6585 var last_token_end: usize = 0;
6586 var fmt_on = true;
6587
6588 try tokens.ensureTotalCapacity(fba, expected_tags.len + 2); // 1 for EOF and 1 for maybe a comma
6589 var tokenizer: std.zig.Tokenizer = .init(rendered);
6590 var i: usize = 0;
6591 while (true) {
6592 const tok = tokenizer.next();
6593 try tokens.append(fba, .{
6594 .tag = tok.tag,
6595 .start = @intCast(tok.loc.start),
6596 });
6597
6598 const between = rendered[last_token_end..tok.loc.start];
6599 last_token_end = tok.loc.end;
6600 try checkBetweenTokens(between, &fmt_on);
6601 if (tok.tag == .multiline_string_literal_line and fmt_on) blk: {
6602 if (tokens.len == 1)
6603 break :blk; // first token
6604 if (std.mem.indexOfScalar(u8, between, '\n') == null)
6605 return error.SameLineMultilineStringLiteral;
6606 }
6607 if (tok.tag == expected_tags[i]) {
6608 if (tok.tag == .eof)
6609 break;
6610 i += 1;
6611 } else if (tok.tag != .comma or !fmt_on) {
6612 rewritten = true;
6613 }
6614 }
6615 std.debug.assert(i == expected_tags.len);
6616 try checkBetweenTokens(rendered[last_token_end..], &fmt_on);
6617
6618 return .{ .toks = tokens, .rewritten = rewritten };
6619}
6620
6621fn fuzzRender(_: void, smith: *std.testing.Smith) !void {
6622 @disableInstrumentation();
6623
6624 var src_buf: [512]u8 = undefined;
6625 const src_len = smith.sliceWeighted(&src_buf, &.{
6626 .rangeLessThan(u32, 0, 32, 256),
6627 .rangeLessThan(u32, 32, 64, 64),
6628 .rangeLessThan(u32, 64, src_buf.len, 1),
6629 }, &.{
6630 .rangeAtMost(u8, 0x20, 0x7e, 8),
6631 .value(u8, '\n', 32),
6632 .value(u8, '\t', 8),
6633 .value(u8, '\r', 4),
6634 .rangeAtMost(u8, 0x7f, 0xff, 1),
6635 });
6636 src_buf[src_len] = 0;
6637
6638 var fba_ctx = std.heap.FixedBufferAllocator.init(&fixed_buffer_mem);
6639 fuzzRenderInner(src_buf[0..src_len :0], fba_ctx.allocator()) catch |e| return switch (e) {
6640 error.OutOfMemory => {},
6641 else => e,
6642 };
6643}
6644
6645fn fuzzRenderInner(source: [:0]const u8, fba: Allocator) !void {
6646 @disableInstrumentation();
6647
6648 const src_toks = try parseTokens(fba, source);
6649 const src_tree = try std.zig.Ast.parseTokens(fba, source, src_toks.toks.slice(), .zig);
6650 if (src_tree.errors.len != 0)
6651 return;
6652 for (src_tree.nodes.items(.tag)) |tag| switch (tag) {
6653 // #24507 (`switch(x) { inline for (a) |a| a => {} }` to
6654 // `switch(x) { { inline for (a) |a| a => {} }` since
6655 // AST determines inline case token as one before the case expression's first)
6656 .switch_case_inline, .switch_case_inline_one => return error.SkipZigTest,
6657 else => {},
6658 };
6659
6660 var rendered_w: std.Io.Writer.Allocating = .init(fba);
6661 try rendered_w.ensureUnusedCapacity(source.len + source.len / 2);
6662 try src_tree.render(fba, &rendered_w.writer, .{});
6663 // `toOwnedSliceSentinel` is not used since it reallocates the entire
6664 // list to save space which is useless for fixed buffer allocators.
6665 try rendered_w.writer.writeByte(0);
6666 const rendered = rendered_w.written()[0 .. rendered_w.written().len - 1 :0];
6667
6668 // First check that the non-whitespace characters match. This ensures that
6669 // identifier names, numbers, comments, et cetera are preserved.
6670 if (!src_toks.maybe_rewritable and isRewritten(source, rendered))
6671 return error.Rewritten;
6672 // Next check that the tokens are the same since whitespace removal can change the tokens
6673 const src_tags = src_toks.toks.items(.tag);
6674 const rendered_toks = try reparseTokens(fba, rendered, src_tags[0 .. src_tags.len - 1 :.eof]);
6675 if (!src_toks.maybe_rewritable and rendered_toks.rewritten)
6676 return error.Rewritten;
6677
6678 // Rerender the tree to check idempotency and that new commas
6679 // and whitespace changes did not create an AST error.
6680 const rendered_tree = try std.zig.Ast.parseTokens(fba, rendered, rendered_toks.toks.slice(), .zig);
6681 if (rendered_tree.errors.len != 0)
6682 return error.Rewritten;
6683 if (!src_toks.skip_idempotency) {
6684 var rerendered_w: std.Io.Writer.Allocating = .init(fba);
6685 try rerendered_w.ensureUnusedCapacity(source.len);
6686 try rendered_tree.render(fba, &rerendered_w.writer, .{});
6687 try std.testing.expectEqualStrings(rendered, rerendered_w.written());
6688 }
6689}