const builtin = @import("builtin"); const std = @import("std"); const Build = std.Build; const Coverage = @This(); /// Adds test coverage. This will create a new test coverage executable to the /// build graph, generated only if coverage is a target. It will create an /// option -Dcoverage-threshold that will fail the build if the threshold is /// not met. It will also add a step that downloads a zig fork of the kcov /// executable into zig cache if it doesn't already exist /// /// Because it is creating a new test executable from the root module provided, /// if there are any linking steps being done to your test executable, those /// must also be done to the test_exe returned by this function pub fn addCoverageStep(b: *Build, root_module: *Build.Module, coverage_name: []const u8) *Coverage { //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(u7, "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 return blk: { 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}); const include_path = b.pathJoin(&.{ b.build_root.path.?, "src" }); run_coverage.addArgs(&.{ "--include-path", include_path }); const css_file = b.pathJoin(&.{ b.build_root.path.?, "build", "bcov.css" }); run_coverage.addArg(b.fmt("--configure=css-file={s}", .{css_file})); run_coverage.addArg(coverage_dir); const test_exe = b.addTest(.{ .name = coverage_name, .root_module = root_module, // we need to set the test exe to use llvm as the self hosted backend // does not support the data kcov needs .use_llvm = true, }); run_coverage.addArtifactArg(test_exe); run_coverage.step.dependOn(&test_exe.step); run_coverage.step.dependOn(&run_download.step); const json_path = b.fmt("{s}/{s}/coverage.json", .{ coverage_dir, coverage_name }); const verbose = b.option(bool, "coverage-verbose", "Show test coverage for each file") orelse false; const check_step = create(b, test_exe, json_path, coverage_threshold, verbose); check_step.step.dependOn(&run_coverage.step); coverage_step.dependOn(&check_step.step); break :blk check_step; }; } step: Build.Step, json_path: []const u8, threshold: u7, test_exe: *std.Build.Step.Compile, verbose: bool, pub fn create(owner: *Build, test_exe: *std.Build.Step.Compile, xml_path: []const u8, threshold: u7, verbose: bool) *Coverage { const check = owner.allocator.create(Coverage) catch @panic("OOM"); check.* = .{ .step = Build.Step.init(.{ .id = .custom, .name = "check coverage", .owner = owner, .makeFn = make, }), .json_path = xml_path, .threshold = threshold, .test_exe = test_exe, .verbose = verbose, }; return check; } // This must be kept in step with kcov coverage.json format const CoverageReport = struct { percent_covered: f64, covered_lines: usize, total_lines: usize, percent_low: u7, percent_high: u7, command: []const u8, date: []const u8, files: []File, }; const File = struct { file: []const u8, percent_covered: f64, covered_lines: usize, total_lines: usize, pub fn coverageLessThanDesc(context: []File, lhs: File, rhs: File) bool { _ = context; return lhs.percent_covered > rhs.percent_covered; } }; fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void { _ = options; const check: *Coverage = @fieldParentPtr("step", step); const allocator = step.owner.allocator; const file = try std.fs.cwd().openFile(check.json_path, .{}); defer file.close(); const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); defer allocator.free(content); const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{}); defer json.deinit(); const coverage = json.value; var stdout_buffer: [1024]u8 = undefined; var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); const stdout = &stdout_writer.interface; if (step.owner.verbose or check.verbose) { const files = coverage.files; std.mem.sort(File, files, files, File.coverageLessThanDesc); for (files) |f| try stdout.print( "{d: >5.1}% {d: >5}/{d: <5}:{s}\n", .{ f.percent_covered, f.covered_lines, f.total_lines, f.file }, ); } try stdout.print( "Total test coverage: {d}% ({d}/{d})\n", .{ coverage.percent_covered, coverage.covered_lines, coverage.total_lines }, ); try stdout.flush(); if (@as(u7, @intFromFloat(@floor(coverage.percent_covered))) < check.threshold) return step.fail("Coverage {d}% is below threshold {d}%", .{ coverage.percent_covered, check.threshold }); }