diff --git a/.gitignore b/.gitignore index 9affdae..79aa481 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .zig-cache/ zig-out/ +coverage/ .env *.srf !metadata.srf diff --git a/build.zig b/build.zig index f4fb4e7..13f6275 100644 --- a/build.zig +++ b/build.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Coverage = @import("build/Coverage.zig"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); @@ -106,4 +107,7 @@ pub fn build(b: *std.Build) void { .install_dir = .prefix, .install_subdir = "docs", }).step); + + // Coverage: `zig build coverage` (Linux only, uses kcov) + _ = Coverage.addCoverageStep(b, mod, "zfin"); } diff --git a/build/Coverage.zig b/build/Coverage.zig new file mode 100644 index 0000000..a0c97e9 --- /dev/null +++ b/build/Coverage.zig @@ -0,0 +1,182 @@ +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 }); +} diff --git a/build/bcov.css b/build/bcov.css new file mode 100644 index 0000000..861cfb1 --- /dev/null +++ b/build/bcov.css @@ -0,0 +1,46 @@ +/* Based upon the lcov CSS style, style files can be reused - Dark Theme */ +body { color: #e0e0e0; background-color: #1e1e1e; } +a:link { color: #6b9aff; text-decoration: underline; } +a:visited { color: #4dbb7a; text-decoration: underline; } +a:active { color: #ff6b8a; text-decoration: underline; } +td.title { text-align: center; padding-bottom: 10px; font-size: 20pt; font-weight: bold; } +td.ruler { background-color: #4a6ba8; } +td.headerItem { text-align: right; padding-right: 6px; font-family: sans-serif; font-weight: bold; } +td.headerValue { text-align: left; color: #6b9aff; font-family: sans-serif; font-weight: bold; } +td.versionInfo { text-align: center; padding-top: 2px; } +th.headerItem { text-align: right; padding-right: 6px; font-family: sans-serif; font-weight: bold; } +th.headerValue { text-align: left; color: #6b9aff; font-family: sans-serif; font-weight: bold; } +pre.source { font-family: monospace; white-space: pre; overflow: hidden; text-overflow: ellipsis; } +span.lineNum { background-color: #5a5a2a; } +span.lineNumLegend { background-color: #5a5a2a; width: 96px; font-weight: bold ;} +span.lineCov { background-color: #2d5a2d; } +span.linePartCov { background-color: #707000; } +span.lineNoCov { background-color: #762c2c; } +span.orderNum { background-color: #5a4a2a; float: right; width:5em; text-align: left; } +span.orderNumLegend { background-color: #5a4a2a; width: 96px; font-weight: bold ;} +span.coverHits { background-color: #4a4a2a; padding-left: 3px; padding-right: 1px; text-align: right; list-style-type: none; display: inline-block; width: 5em; } +span.coverHitsLegend { background-color: #4a4a2a; width: 96px; font-weight: bold; margin: 0 auto;} +td.tableHead { text-align: center; color: #e0e0e0; background-color: #4a6ba8; font-family: sans-serif; font-size: 120%; font-weight: bold; } +td.coverFile { text-align: left; padding-left: 10px; padding-right: 20px; color: #6b9aff; font-family: monospace; background-color: #3a3a3a; } +td.coverBar { padding-left: 10px; padding-right: 10px; background-color: #3a3a3a; } +td.coverBarOutline { background-color: #4a4a4a; } +td.coverPer { text-align: left; padding-left: 10px; padding-right: 10px; font-weight: bold; background-color: #3a3a3a; color: #e0e0e0; } +td.coverPerLeftMed { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #5a5a00; font-weight: bold; color: #e0e0e0; } +td.coverPerLeftLo { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #5a2d2d; font-weight: bold; color: #e0e0e0; } +td.coverPerLeftHi { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #2d5a2d; font-weight: bold; color: #e0e0e0; } +td.coverNum { text-align: right; padding-left: 10px; padding-right: 10px; background-color: #3a3a3a; color: #e0e0e0; } + +/* Override tablesorter hover styles for dark theme */ +.tablesorter-blue tbody > tr:hover > td, +.tablesorter-blue tbody > tr:hover + tr.tablesorter-childRow > td, +.tablesorter-blue tbody > tr:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td, +.tablesorter-blue tbody > tr.even:hover > td, +.tablesorter-blue tbody > tr.even:hover + tr.tablesorter-childRow > td, +.tablesorter-blue tbody > tr.even:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td { + background: #4a4a4a; +} +.tablesorter-blue tbody > tr.odd:hover > td, +.tablesorter-blue tbody > tr.odd:hover + tr.tablesorter-childRow > td, +.tablesorter-blue tbody > tr.odd:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td { + background: #4a4a4a; +} 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(); +}