add tidy script

This commit is contained in:
Loïc Grégoire
2026-09-27 16:21:40 -04:00
parent c7673dc438
commit 672f04a833
+392
View File
@@ -0,0 +1,392 @@
//! Whines about bad code style.
//! Inspired by TigerBeetle's similar tool.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
/// We aren't in the 80s anymore, but 80 columns is optimal for comment
/// readability (and also sunk cost fallacy).
const max_cols = 80;
/// Afford this being a bit bigger than 70 because we only have 80 cols to work
/// with.
const max_function_length = 90;
/// Error on files and functions not being documented.
const error_not_documented = true;
test "tidy" {
const gpa = std.testing.allocator;
const io = std.testing.io;
var errors: Errors = .{};
const outbuf = try gpa.alloc(u8, 1024 * 8);
defer gpa.free(outbuf);
const out = try run_command(
io,
&.{ "git", "ls-files", "-z" },
outbuf,
);
var path_iterator = std.mem.splitScalar(
u8,
out[0 .. out.len - 1],
0,
);
const filebuf = try gpa.alloc(u8, 1 * 1024 * 1024);
defer gpa.free(filebuf);
while (path_iterator.next()) |path| {
const file = try SourceFile.read(
io,
path,
filebuf,
);
try tidy_file(gpa, file, &errors);
}
if (errors.count > 0) {
return error.Untidy;
}
}
const Errors = struct {
count: u32 = 0,
pub fn add_long_line(
errors: *Errors,
file: SourceFile,
line_num: usize,
) void {
errors.emit(
"{s}:{d}: error: line exceeds {} columns\n",
.{ file.path, line_num, max_cols },
);
}
pub fn add_file_not_documented(
errors: *Errors,
file: SourceFile,
) void {
errors.emit(
"{s}: error: file not documented\n",
.{file.path},
);
}
pub fn add_wrong_fn_type_name(
errors: *Errors,
name: []const u8,
file: SourceFile,
line_num: usize,
) void {
errors.emit(
"{s}:{d}: error: type name '{s}' should be PascalCase\n",
.{ file.path, line_num, name },
);
}
pub fn add_wrong_fn_name(
errors: *Errors,
name: []const u8,
file: SourceFile,
line_num: usize,
) void {
errors.emit(
"{s}:{d}: error: function name '{s}' should be snake_case\n",
.{ file.path, line_num, name },
);
}
pub fn add_defer_without_newline(
errors: *Errors,
file: SourceFile,
line_num: usize,
keyword: []const u8,
) void {
errors.emit(
"{s}:{d}: error: {s} must be followed by a blank line\n",
.{ file.path, line_num, keyword },
);
}
pub fn add_orelse_unreachable(
errors: *Errors,
file: SourceFile,
line_num: usize,
) void {
errors.emit(
"{s}:{d}: error: use '.?' instead of 'orelse unreachable'\n",
.{ file.path, line_num },
);
}
pub fn add_function_too_long(
errors: *Errors,
name: []const u8,
file: SourceFile,
line_num: usize,
) void {
errors.emit(
"{s}:{d}: error: function '{s}' exceeds {} lines\n",
.{ file.path, line_num, name, max_function_length },
);
}
pub fn add_function_not_documented(
errors: *Errors,
name: []const u8,
file: SourceFile,
line_num: usize,
) void {
errors.emit(
"{s}:{d}: error: function '{s}' not documented\n",
.{ file.path, line_num, name },
);
}
pub fn add_function_unused(
errors: *Errors,
name: []const u8,
file: SourceFile,
line_num: usize,
) void {
errors.emit(
"{s}:{d}: error: private function '{s}' appears unused\n",
.{ file.path, line_num, name },
);
}
fn emit(errors: *Errors, comptime fmt: []const u8, args: anytype) void {
comptime assert(fmt[fmt.len - 1] == '\n');
errors.count += 1;
std.debug.print(fmt, args);
}
};
const SourceFile = struct {
path: []const u8,
text: [:0]const u8,
pub fn read(
io: std.Io,
path: []const u8,
buffer: []u8,
) !SourceFile {
var dir = std.Io.Dir.cwd();
var file = try dir.openFile(io, path, .{});
defer file.close(io);
// Try and buffer reads a bit.
var buf: [4096]u8 = undefined;
var reader = file.reader(io, &buf);
const bytes_read = try reader.interface.readSliceShort(buffer);
buffer[bytes_read] = 0;
return .{
.path = path,
.text = buffer[0..bytes_read :0],
};
}
fn has_extension(file: SourceFile, extension: []const u8) bool {
assert(extension.len > 0);
assert(extension[0] == '.');
return std.mem.endsWith(u8, file.path, extension);
}
};
fn tidy_file(gpa: Allocator, file: SourceFile, errors: *Errors) !void {
if (!file.has_extension(".zig")) return;
// Tidy lines.
var line_iterator = std.mem.splitScalar(u8, file.text, '\n');
var line_index: u32 = 0;
while (line_iterator.next()) |line| : (line_index += 1) {
tidy_line(file, line, line_index + 1, errors);
}
// Tidy the AST.
var tree = try std.zig.Ast.parse(gpa, file.text, .zig);
defer tree.deinit(gpa);
const has_file_doc = tree.tokenTag(0) == .container_doc_comment;
if (!has_file_doc and error_not_documented) {
errors.add_file_not_documented(file);
}
tidy_ast(file, tree, errors);
}
fn tidy_line(
file: SourceFile,
line: []const u8,
line_num: usize,
errors: *Errors,
) void {
if (line.len > max_cols) {
errors.add_long_line(file, line_num);
}
}
fn tidy_ast(file: SourceFile, tree: std.zig.Ast, errors: *Errors) void {
tidy_defer(file, tree, errors);
tidy_optional_unwrap(file, tree, errors);
for (tree.rootDecls()) |decl| {
if (tree.nodeTag(decl) != .fn_decl) continue;
tidy_function(file, tree, decl, errors);
}
}
fn tidy_function(
file: SourceFile,
tree: std.zig.Ast,
decl: std.zig.Ast.Node.Index,
errors: *Errors,
) void {
var buffer: [1]std.zig.Ast.Node.Index = undefined;
const proto = tree.fullFnProto(&buffer, decl).?;
const name = tree.tokenSlice(proto.name_token.?);
const lineno = tree.tokenLocation(
0,
proto.name_token.?,
).line;
const start_line = tree.tokenLocation(0, tree.firstToken(decl)).line + 1;
const end_line = tree.tokenLocation(0, tree.lastToken(decl)).line + 1;
const line_count = end_line - start_line + 1;
const return_node = proto.ast.return_type.unwrap().?;
const return_token = tree.firstToken(return_node);
const returns_type = tree.nodeTag(return_node) == .identifier and
std.mem.eql(u8, tree.tokenSlice(return_token), "type");
const first = tree.firstToken(decl);
const documented = first > 0 and
tree.tokenTag(first - 1) == .doc_comment;
if (!documented and error_not_documented) {
errors.add_function_not_documented(name, file, lineno + 1);
}
if (returns_type and std.ascii.isLower(name[0])) {
errors.add_wrong_fn_type_name(name, file, lineno + 1);
}
if (!returns_type) {
blk: for (name) |c| {
if (std.ascii.isUpper(c)) {
errors.add_wrong_fn_name(name, file, lineno + 1);
break :blk;
}
}
}
if (!returns_type and line_count > max_function_length) {
// Functions that return types are allowed to be more lines,
// since they are basically struct/type definitions.
errors.add_function_too_long(name, file, lineno + 1);
}
if (!function_is_externally_visible(tree, first, proto.ast.fn_token) and
!function_has_reference(tree, proto.name_token.?))
{
errors.add_function_unused(name, file, lineno + 1);
}
}
fn function_is_externally_visible(
tree: std.zig.Ast,
first: std.zig.Ast.TokenIndex,
fn_token: std.zig.Ast.TokenIndex,
) bool {
for (first..fn_token) |index| {
const token: std.zig.Ast.TokenIndex = @intCast(index);
switch (tree.tokenTag(token)) {
.keyword_pub, .keyword_export, .keyword_extern => return true,
else => {},
}
}
return false;
}
fn function_has_reference(
tree: std.zig.Ast,
name_token: std.zig.Ast.TokenIndex,
) bool {
const name = tree.tokenSlice(name_token);
for (0..tree.tokens.len) |index| {
const token: std.zig.Ast.TokenIndex = @intCast(index);
if (token == name_token or tree.tokenTag(token) != .identifier) continue;
if (std.mem.eql(u8, tree.tokenSlice(token), name)) return true;
}
return false;
}
fn tidy_optional_unwrap(file: SourceFile, tree: std.zig.Ast, errors: *Errors) void {
for (0..tree.nodes.len) |index| {
const node: std.zig.Ast.Node.Index = @enumFromInt(index);
if (tree.nodeTag(node) != .@"orelse") continue;
const rhs = tree.nodeData(node).node_and_node[1];
if (tree.nodeTag(rhs) != .unreachable_literal) continue;
const line_num = tree.tokenLocation(0, tree.nodeMainToken(node)).line +
1;
errors.add_orelse_unreachable(file, line_num);
}
}
fn tidy_defer(file: SourceFile, tree: std.zig.Ast, errors: *Errors) void {
for (0..tree.nodes.len) |index| {
const node: std.zig.Ast.Node.Index = @enumFromInt(index);
if (tree.nodeTag(node) != .@"defer" and
tree.nodeTag(node) != .@"errdefer") continue;
const last_token = tree.lastToken(node);
const end_token = if (tree.tokenTag(last_token + 1) == .semicolon)
last_token + 1
else
last_token;
const end = tree.tokenStart(end_token) + tree.tokenSlice(end_token).len;
if (!std.mem.startsWith(u8, file.text[end..], "\n\n")) {
const main_token = tree.nodeMainToken(node);
const line_num = tree.tokenLocation(0, main_token).line + 1;
errors.add_defer_without_newline(
file,
line_num,
tree.tokenSlice(main_token),
);
}
}
}
fn run_command(io: std.Io, command: []const []const u8, outbuf: []u8) ![]u8 {
var proc = try std.process.spawn(
io,
.{ .argv = command, .stdout = .pipe },
);
defer _ = proc.wait(io) catch {};
var buf: [128]u8 = undefined;
var freader = proc.stdout.?.reader(io, &buf);
const reader = &freader.interface;
const bytes_read = try reader.readSliceShort(outbuf);
return outbuf[0..bytes_read];
}