From 338f09a5d1ac52d2df9db1357921b67b4f132eb0 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 8 Jan 2026 09:31:07 -0800 Subject: [PATCH] add code coverage build step --- .gitignore | 1 + build.zig | 4 ++ build/coverage.zig | 134 ++++++++++++++++++++++++++++++++++++++++ build/download_kcov.zig | 82 ++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 build/coverage.zig create mode 100644 build/download_kcov.zig diff --git a/.gitignore b/.gitignore index fc6ac72..ca7c4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.swp .zig-cache/ zig-out/ +coverage/ diff --git a/build.zig b/build.zig index db55d2e..1fd2751 100644 --- a/build.zig +++ b/build.zig @@ -1,5 +1,6 @@ const std = @import("std"); const GitVersion = @import("build/GitVersion.zig"); +const coverage = @import("build/coverage.zig"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); @@ -169,4 +170,7 @@ pub fn build(b: *std.Build) void { const run_tests = b.addRunArtifact(tests); const test_step = b.step("test", "Run tests"); test_step.dependOn(&run_tests.step); + + // Coverage step + coverage.addCoverageStep(b, tests); } diff --git a/build/coverage.zig b/build/coverage.zig new file mode 100644 index 0000000..43ae7f0 --- /dev/null +++ b/build/coverage.zig @@ -0,0 +1,134 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const Build = std.Build; + +pub fn addCoverageStep(b: *Build, test_exe: *Build.Step.Compile) void { + //verify host requirements + { + const supported = builtin.os.tag == .linux and + (builtin.cpu.arch == .x86_64 or builtin.cpu.arch == .aarch64); + + if (!supported) + @panic("Coverage only supported on x86_64-linux or aarch64-linux"); + } + + // Add options + const coverage_threshold = b.option(u8, "coverage-threshold", "Minimum coverage percentage required") orelse 0; + const coverage_dir = b.option([]const u8, "coverage-dir", "Coverage output directory") orelse + b.pathJoin(&.{ b.build_root.path orelse ".", "coverage" }); + const coverage_step = b.step("coverage", "Generate test coverage report"); + + // Set up kcov download + // We can't do it directly because we are sandboxed during build, but + // we can create a program and run that program. First we need the destination + // directory + const kcov = blk: { + const arch_name = switch (builtin.cpu.arch) { + .x86_64 => "x86_64", + .aarch64 => "aarch64", + else => unreachable, + }; + + const Algo = std.crypto.hash.sha2.Sha256; + var hasher = Algo.init(.{}); + hasher.update("kcov-"); + hasher.update(arch_name); + var cache_hash: [Algo.digest_length]u8 = undefined; + hasher.final(&cache_hash); + + const cache_dir = b.pathJoin(&.{ + b.cache_root.path.?, + "o", + b.fmt("{s}", .{std.fmt.bytesToHex(cache_hash, .lower)}), + }); + + const kcov_name = b.fmt("kcov-{s}", .{arch_name}); + break :blk .{ .path = b.pathJoin(&.{ cache_dir, kcov_name }), .arch = arch_name }; + }; + + // Create download and coverage build steps + { + const download_exe = b.addExecutable(.{ + .name = "download-kcov", + .root_module = b.createModule(.{ + .root_source_file = b.path("build/download_kcov.zig"), + .target = b.resolveTargetQuery(.{}), + }), + }); + + const run_download = b.addRunArtifact(download_exe); + run_download.addArg(kcov.path); + run_download.addArg(kcov.arch); + + const run_coverage = b.addSystemCommand(&.{kcov.path}); + run_coverage.addArgs(&.{ "--include-path", "src/" }); + run_coverage.addArg(coverage_dir); + run_coverage.addArtifactArg(test_exe); + run_coverage.step.dependOn(&test_exe.step); + run_coverage.step.dependOn(&run_download.step); + // we need to set the test exe to use llvm as the self hosted backend + // does not support the data kcov needs + test_exe.use_llvm = true; + + if (coverage_threshold > 0) { + const xml_path = b.fmt("{s}/test/cobertura.xml", .{coverage_dir}); + const check_step = CheckCoverage.create(b, xml_path, coverage_threshold); + check_step.step.dependOn(&run_coverage.step); + coverage_step.dependOn(&check_step.step); + } else { + coverage_step.dependOn(&run_coverage.step); + } + } +} + +pub const CheckCoverage = struct { + step: Build.Step, + xml_path: []const u8, + threshold: u8, + + pub fn create(owner: *Build, xml_path: []const u8, threshold: u8) *CheckCoverage { + const check = owner.allocator.create(CheckCoverage) catch @panic("OOM"); + check.* = .{ + .step = Build.Step.init(.{ + .id = .custom, + .name = "check coverage", + .owner = owner, + .makeFn = make, + }), + .xml_path = xml_path, + .threshold = threshold, + }; + return check; + } + + fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void { + _ = options; + const check: *CheckCoverage = @fieldParentPtr("step", step); + const allocator = step.owner.allocator; + + const file = try std.fs.cwd().openFile(check.xml_path, .{}); + defer file.close(); + + const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); + defer allocator.free(content); + + const needle = "line-rate=\""; + const start = std.mem.indexOf(u8, content, needle) orelse return error.CoverageNotFound; + const value_start = start + needle.len; + const value_end = std.mem.indexOfScalarPos(u8, content, value_start, '"') orelse return error.CoverageNotFound; + const value_str = content[value_start..value_end]; + + const coverage = try std.fmt.parseFloat(f64, value_str); + const coverage_int: u8 = @intFromFloat(coverage * 100.0); + + if (step.owner.verbose) { + var stdout_buffer: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + try stdout.print("Coverage: {d}%\n", .{coverage_int}); + } + + if (coverage_int < check.threshold) + return step.fail("Coverage {d}% is below threshold {d}%", .{ coverage_int, check.threshold }); + } +}; diff --git a/build/download_kcov.zig b/build/download_kcov.zig new file mode 100644 index 0000000..f04c85a --- /dev/null +++ b/build/download_kcov.zig @@ -0,0 +1,82 @@ +const std = @import("std"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + if (args.len != 3) return error.InvalidArgs; + + const kcov_path = args[1]; + const arch_name = args[2]; + + // Check to see if file exists. If it does, we have nothing more to do + const stat = std.fs.cwd().statFile(kcov_path) catch |err| blk: { + if (err == error.FileNotFound) break :blk null else return err; + }; + // This might be better checking whether it's executable and >= 7MB, but + // for now, we'll do a simple exists check + if (stat != null) return; + var stdout_buffer: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + + try stdout.writeAll("Determining latest kcov version\n"); + try stdout.flush(); + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + // Get redirect to find latest version + const list_uri = try std.Uri.parse("https://git.lerch.org/lobo/-/packages/generic/kcov/"); + var req = try client.request(.GET, list_uri, .{ .redirect_behavior = .unhandled }); + defer req.deinit(); + + try req.sendBodiless(); + var redirect_buf: [1024]u8 = undefined; + const response = try req.receiveHead(&redirect_buf); + + if (response.head.status != .see_other) return error.UnexpectedResponse; + + const location = response.head.location orelse return error.NoLocation; + const version_start = std.mem.lastIndexOf(u8, location, "/") orelse return error.InvalidLocation; + const version = location[version_start + 1 ..]; + + try stdout.print( + "Downloading kcov version {s} for {s} to {s}...", + .{ version, arch_name, kcov_path }, + ); + try stdout.flush(); + + const binary_url = try std.fmt.allocPrint( + allocator, + "https://git.lerch.org/api/packages/lobo/generic/kcov/{s}/kcov-{s}", + .{ version, arch_name }, + ); + defer allocator.free(binary_url); + + const cache_dir = std.fs.path.dirname(kcov_path) orelse return error.InvalidPath; + std.fs.cwd().makeDir(cache_dir) catch |e| switch (e) { + error.PathAlreadyExists => {}, + else => return e, + }; + + const uri = try std.Uri.parse(binary_url); + const file = try std.fs.cwd().createFile(kcov_path, .{ .mode = 0o755 }); + defer file.close(); + + var buffer: [8192]u8 = undefined; + var writer = file.writer(&buffer); + const result = try client.fetch(.{ + .location = .{ .uri = uri }, + .response_writer = &writer.interface, + }); + + if (result.status != .ok) return error.DownloadFailed; + try writer.interface.flush(); + + try stdout.writeAll("done\n"); + try stdout.flush(); +}