const std = @import("std"); const Allocator = std.mem.Allocator; const PathList = std.ArrayList([]u8); const max_file_bytes: usize = 16 * 1024 * 1024; const TermColor = struct { pub const red = "\x1b[0;31m"; pub const green = "\x1b[0;32m"; pub const reset = "\x1b[0m"; }; pub fn main() !void { var gpa_impl = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa_impl.deinit(); const gpa = gpa_impl.allocator(); var stdout_buffer: [4096]u8 = undefined; var stdout_state = std.fs.File.stdout().writer(&stdout_buffer); const stdout = &stdout_state.interface; var found_errors = false; // Lint 1: Check for pub declarations without doc comments try stdout.print("Checking for pub declarations without doc comments...\n", .{}); var zig_files = PathList{}; defer freePathList(&zig_files, gpa); try walkTree(gpa, "src", &zig_files); for (zig_files.items) |file_path| { const errors = try checkPubDocComments(gpa, file_path); defer gpa.free(errors); if (errors.len > 0) { try stdout.print("{s}", .{errors}); found_errors = true; } } if (found_errors) { try stdout.print("\n", .{}); try stdout.print("Please add doc comments to the spots listed above, they make the code easier to understand for everyone.\n", .{}); try stdout.print("\n", .{}); try stdout.flush(); std.process.exit(1); } // Lint 2: Check for top level comments in new Zig files try stdout.print("Checking for top level comments in new Zig files...\n", .{}); var new_zig_files = try getNewZigFiles(gpa); defer { for (new_zig_files.items) |path| { gpa.free(path); } new_zig_files.deinit(gpa); } if (new_zig_files.items.len == 0) { try stdout.print("{s}[OK]{s} All lints passed!\n", .{ TermColor.green, TermColor.reset }); try stdout.flush(); return; } var failed_files = PathList{}; defer freePathList(&failed_files, gpa); for (new_zig_files.items) |file_path| { if (!try fileHasTopLevelComment(gpa, file_path)) { try stdout.print("Error: {s} is missing top level comment (//!)\n", .{file_path}); try failed_files.append(gpa, try gpa.dupe(u8, file_path)); } } if (failed_files.items.len > 0) { try stdout.print("\n", .{}); try stdout.print("The following files are missing a top level comment:\n", .{}); for (failed_files.items) |path| { try stdout.print(" {s}\n", .{path}); } try stdout.print("\n", .{}); try stdout.print("Add a //! comment BEFORE any other code that explains the purpose of the file.\n", .{}); try stdout.flush(); std.process.exit(1); } try stdout.print("{s}[OK]{s} All lints passed!\n", .{ TermColor.green, TermColor.reset }); try stdout.flush(); } fn walkTree(allocator: Allocator, dir_path: []const u8, zig_files: *PathList) !void { var dir = try std.fs.cwd().openDir(dir_path, .{ .iterate = true }); defer dir.close(); var it = dir.iterate(); while (try it.next()) |entry| { if (entry.kind == .sym_link) continue; const next_path = try std.fs.path.join(allocator, &.{ dir_path, entry.name }); switch (entry.kind) { .directory => { // Skip .zig-cache directories if (std.mem.eql(u8, entry.name, ".zig-cache")) { allocator.free(next_path); continue; } defer allocator.free(next_path); try walkTree(allocator, next_path, zig_files); }, .file => { if (std.mem.endsWith(u8, entry.name, ".zig")) { try zig_files.append(allocator, next_path); } else { allocator.free(next_path); } }, else => allocator.free(next_path), } } } fn checkPubDocComments(allocator: Allocator, file_path: []const u8) ![]u8 { const source = readSourceFile(allocator, file_path) catch |err| { // Skip files we can't read if (err == error.FileNotFound) return try allocator.dupe(u8, ""); return err; }; defer allocator.free(source); var errors = std.ArrayList(u8){}; errdefer errors.deinit(allocator); var line_num: usize = 1; var prev_line: []const u8 = ""; var lines = std.mem.splitScalar(u8, source, '\n'); while (lines.next()) |line| { defer { prev_line = line; line_num += 1; } // Check if line starts with "pub " (no leading whitespace - only top-level declarations) if (!std.mem.startsWith(u8, line, "pub ")) continue; // Check if previous line is a doc comment (allow indented doc comments) const prev_trimmed = std.mem.trimLeft(u8, prev_line, " \t"); if (std.mem.startsWith(u8, prev_trimmed, "///")) continue; // Skip exceptions: init, deinit, @import, and pub const re-exports // Note: "pub.*fn init\(" in bash matches "init" anywhere in function name if (std.mem.indexOf(u8, line, "fn init") != null) continue; if (std.mem.indexOf(u8, line, "fn deinit") != null) continue; if (std.mem.indexOf(u8, line, "@import") != null) continue; // Check for pub const re-exports (e.g., "pub const Foo = bar.Baz;") if (isReExport(line)) continue; try errors.writer(allocator).print("{s}:{d}: pub declaration without doc comment `///`\n", .{ file_path, line_num }); } return errors.toOwnedSlice(allocator); } fn isReExport(line: []const u8) bool { // Match pattern: pub const X = lowercase.something; // This detects re-exports like "pub const Foo = bar.Baz;" if (!std.mem.startsWith(u8, line, "pub const ")) return false; // Find the '=' sign const eq_pos = std.mem.indexOf(u8, line, "=") orelse return false; const after_eq = std.mem.trimLeft(u8, line[eq_pos + 1 ..], " \t"); // Check if it starts with a lowercase letter (module reference) if (after_eq.len == 0) return false; const first_char = after_eq[0]; if (first_char < 'a' or first_char > 'z') return false; // Check if it contains a dot and ends with semicolon (but not a function call) if (std.mem.indexOf(u8, after_eq, ".") == null) return false; if (std.mem.indexOf(u8, after_eq, "(") != null) return false; if (!std.mem.endsWith(u8, std.mem.trimRight(u8, after_eq, " \t"), ";")) return false; return true; } fn getNewZigFiles(allocator: Allocator) !PathList { var result = PathList{}; errdefer { for (result.items) |path| { allocator.free(path); } result.deinit(allocator); } // Run git diff to get new files var child = std.process.Child.init(&.{ "git", "diff", "--name-only", "--diff-filter=A", "origin/main", "HEAD", "--", "src/" }, allocator); child.stdout_behavior = .Pipe; child.stderr_behavior = .Ignore; _ = child.spawn() catch { // Git not available or not in a repo - return empty list return result; }; const stdout = child.stdout orelse return result; const output = stdout.readToEndAlloc(allocator, max_file_bytes) catch return result; defer allocator.free(output); const term = child.wait() catch return result; if (term.Exited != 0) return result; // Parse output line by line var lines = std.mem.splitScalar(u8, output, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; if (!std.mem.endsWith(u8, line, ".zig")) continue; try result.append(allocator, try allocator.dupe(u8, line)); } return result; } fn fileHasTopLevelComment(allocator: Allocator, file_path: []const u8) !bool { const source = try readSourceFile(allocator, file_path); defer allocator.free(source); return std.mem.indexOf(u8, source, "//!") != null; } fn readSourceFile(allocator: Allocator, path: []const u8) ![:0]u8 { return try std.fs.cwd().readFileAllocOptions( allocator, path, max_file_bytes, null, std.mem.Alignment.of(u8), 0, ); } fn freePathList(list: *PathList, allocator: Allocator) void { for (list.items) |path| { allocator.free(path); } list.deinit(allocator); }