roc/ci/zig_lints.zig
Matthieu Pizenberg 0392a93d1b
Implement bitwise shifts (#8469)
* Implement bitwise shifts

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Add low_level_interp shift tests

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Fix wrong test

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Use appropriate byte size for shifts

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Simplify shift left and right interpreter

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Factorize the num_shift_right_zf_by in interpreter

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Add edge cases tests for shifts

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Fix an edge case test

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Truncate shift left to the correct precision

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* Add shift operators to the borrow switch

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>

* restore accidental deletions

---------

Signed-off-by: Matthieu Pizenberg <matthieu.pizenberg@gmail.com>
Co-authored-by: Anton-4 <17049058+Anton-4@users.noreply.github.com>
2025-12-17 19:32:38 +01:00

364 lines
12 KiB
Zig

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 separator comments (// ====)
try stdout.print("Checking for separator comments (// ====)...\n", .{});
{
var zig_files = PathList{};
defer freePathList(&zig_files, gpa);
// Scan src/, build.zig, and test/ (not ci/ since zig_lints.zig mentions the pattern)
try walkTree(gpa, "src", &zig_files);
try walkTree(gpa, "test", &zig_files);
// Add build.zig directly
try zig_files.append(gpa, try gpa.dupe(u8, "build.zig"));
for (zig_files.items) |file_path| {
const errors = try checkSeparatorComments(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("Separator comments like '// ====' are not allowed. Please delete these lines.\n", .{});
try stdout.print("\n", .{});
try stdout.flush();
std.process.exit(1);
}
}
// Lint 2: 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 that explains the purpose of the file BEFORE any other code.\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 checkSeparatorComments(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 lines = std.mem.splitScalar(u8, source, '\n');
while (lines.next()) |line| {
defer line_num += 1;
// Trim leading whitespace
const trimmed = std.mem.trimLeft(u8, line, " \t");
// Check if line starts with // and is a separator comment
// Separator comments are lines like "// ====" or "// ==== Section ===="
// We detect them by checking if, after the //, the line consists only of
// whitespace, equals signs, and letters (for section titles)
if (std.mem.startsWith(u8, trimmed, "//")) {
const after_slashes = trimmed[2..];
if (isSeparatorComment(after_slashes)) {
try errors.writer(allocator).print("{s}:{d}: separator comment '// ====' not allowed\n", .{ file_path, line_num });
}
}
}
return errors.toOwnedSlice(allocator);
}
/// Checks if a line (after the //) is a separator comment.
/// Separator comments are lines consisting only of equals signs (with optional
/// whitespace and a section title between them).
/// Examples that should match:
/// " ===="
/// " ===== Section ====="
/// " ==== Title ===="
/// Examples that should NOT match:
/// " 2. Stdout contains "=====""
/// " This is a normal comment about ===="
fn isSeparatorComment(after_slashes: []const u8) bool {
// Must contain ====
if (std.mem.indexOf(u8, after_slashes, "====") == null) return false;
// Trim whitespace
const content = std.mem.trim(u8, after_slashes, " \t");
if (content.len == 0) return false;
// Must start with ====
if (!std.mem.startsWith(u8, content, "====")) return false;
// Find where the leading equals end
var i: usize = 0;
while (i < content.len and content[i] == '=') : (i += 1) {}
// Everything after leading equals should be whitespace, letters, or trailing equals
while (i < content.len) : (i += 1) {
const c = content[i];
if (c == '=' or c == ' ' or c == '\t' or (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) {
continue;
}
// Found a character that's not allowed in separator comments
return false;
}
return true;
}
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 = readSourceFile(allocator, file_path) catch |err| {
if (err == error.FileNotFound) {
// File was deleted but still shows in git diff - skip it
return true;
}
return err;
};
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);
}