const std = @import("std"); const Coverage = @import("build/Coverage.zig"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); // -- The engine: the calculation library every frontend imports -- const engine_mod = b.addModule("engine", .{ .root_source_file = b.path("engine/src/engine.zig"), .target = target, .optimize = optimize, }); // -- Vaxis dependency (TUI library) -- const vaxis_dep = b.dependency("vaxis", .{ .target = target, .optimize = optimize, }); // -- Engine static library (for linking into the main binary) -- const engine_lib = b.addLibrary(.{ .linkage = .static, .name = "tally-engine", .root_module = engine_mod, }); b.installArtifact(engine_lib); // -- Engine shared library (the C ABI, for Android JNI and any other FFI caller) -- const engine_shared = b.addLibrary(.{ .linkage = .dynamic, .name = "tally", .root_module = b.createModule(.{ .root_source_file = b.path("engine/src/c_api.zig"), .target = target, .optimize = optimize, }), }); engine_shared.installHeader(b.path("include/tally.h"), "tally.h"); b.installArtifact(engine_shared); // -- Main binary (CLI + TUI in one) -- const exe = b.addExecutable(.{ .name = "tally", .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "engine", .module = engine_mod }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }), }); b.installArtifact(exe); // -- Tests -- const engine_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("engine/src/engine.zig"), .target = target, .optimize = optimize, }), }); const cli_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "engine", .module = engine_mod }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }), }); const run_engine_tests = b.addRunArtifact(engine_tests); const run_cli_tests = b.addRunArtifact(cli_tests); // The C ABI gets its own root: `c_api.zig` imports the engine by path rather than by // name (it is the root of the shared library, which has no import graph of its own), // so the engine test target does not reach it. const abi_tests = b.addTest(.{ // Only this file's tests: `c_api.zig` imports the engine by path, so an // unfiltered run would execute the whole engine suite a second time. .filters = &.{"c_api."}, .root_module = b.createModule(.{ .root_source_file = b.path("engine/src/c_api.zig"), .target = target, .optimize = optimize, }), }); const run_abi_tests = b.addRunArtifact(abi_tests); // The JNI layer is Android-only code, but its tests are not: they run against a // synthetic function table on the host, which is the only way to exercise the // marshalling without a device. const jni_tests = b.addTest(.{ .filters = &.{"jni."}, .root_module = b.createModule(.{ .root_source_file = b.path("engine/src/jni.zig"), .target = target, .optimize = optimize, }), }); const run_jni_tests = b.addRunArtifact(jni_tests); // And the boundary is also tested from C, through the installed header, linked // against the real library. That is the only way to catch a header that disagrees // with the library: struct field order and symbol names are invisible from Zig. const c_abi_mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, }); c_abi_mod.addCSourceFile(.{ .file = b.path("engine/test/c_abi_test.c"), .flags = &.{ "-std=c99", "-Wall", "-Wextra", "-Werror" }, }); c_abi_mod.addIncludePath(b.path("include")); c_abi_mod.linkLibrary(engine_shared); const c_abi_test = b.addExecutable(.{ .name = "c-abi-test", .root_module = c_abi_mod }); const run_c_abi_test = b.addRunArtifact(c_abi_test); // TUI tests need their own root: src/main.zig only reaches tui.zig from // main(), which is never analyzed in test mode, so its tests would be // silently skipped if we relied on the CLI test target. const tui_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/tui.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "engine", .module = engine_mod }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }), }); const run_tui_tests = b.addRunArtifact(tui_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_engine_tests.step); test_step.dependOn(&run_cli_tests.step); test_step.dependOn(&run_tui_tests.step); test_step.dependOn(&run_abi_tests.step); test_step.dependOn(&run_jni_tests.step); test_step.dependOn(&run_c_abi_test.step); // -- Coverage step (uses kcov, Linux x86_64/aarch64 only) -- // // One report per source tree, with disjoint include patterns so no file is // accounted for twice. The CLI test binary also compiles tui.zig (main.zig // imports it) but never runs its tests, so the CLI report is narrowed to // main.zig and src/cli; without that narrowing the TUI would appear // near-uncovered in one report and covered in another. { var cov = Coverage.init(b); const engine_cov = b.createModule(.{ .root_source_file = b.path("engine/src/engine.zig"), .target = target, .optimize = optimize, }); _ = cov.addReport(engine_cov, "tally-engine", &.{"engine/src"}); const cli_cov = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "engine", .module = engine_mod }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }); _ = cov.addReport(cli_cov, "tally-cli", &.{ "src/main.zig", "src/cli" }); const tui_cov = b.createModule(.{ .root_source_file = b.path("src/tui.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "engine", .module = engine_mod }, .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, }, }); // tui.zig plus its views. main.zig and src/cli are deliberately excluded // even though they are not compiled into this binary, so the two app reports // stay disjoint by construction rather than by accident. _ = cov.addReport(tui_cov, "tally-tui", &.{ "src/tui.zig", "src/tui" }); // The C ABI reports separately. Its test binary contains all of `engine/src`, // but the include path narrows the report to the one file that is not already // accounted for by the engine report: `c_api.zig` is unreachable from // `engine.zig`, so the engine's binary does not contain it and the two reports // stay disjoint by file even though one include path nests inside the other. const abi_cov = b.createModule(.{ .root_source_file = b.path("engine/src/c_api.zig"), .target = target, .optimize = optimize, }); _ = cov.addReport(abi_cov, "tally-abi", &.{"engine/src/c_api.zig"}); // And the JNI layer, whose tests run against a synthetic function table. Android // code measured on a desktop: everything except the four table indices, which // only a JVM can confirm. const jni_cov = b.createModule(.{ .root_source_file = b.path("engine/src/jni.zig"), .target = target, .optimize = optimize, }); _ = cov.addReport(jni_cov, "tally-jni", &.{"engine/src/jni.zig"}); } // -- Android shared libraries -- // // One `.so` per ABI, laid out the way Gradle's `jniLibs` expects, so packaging is a // copy rather than a build step: Gradle never invokes Zig (design 1). // // The root here is `jni.zig` rather than `c_api.zig`: it pulls in the C ABI and adds // the `Java_*` entry points, which are the only Android-specific code in the tree // (design 6.3). The host library stays C-only. // // Always ReleaseSmall. A debug build of this library is twenty times the size and // would be a strange thing to ship inside an APK; `zig build` without this step // still produces a host library at whatever `-Doptimize` says. { const android_step = b.step("android", "Cross-compile libtally.so for Android ABIs"); const abis = [_]struct { triple: []const u8, dir: []const u8 }{ .{ .triple = "aarch64-linux-android", .dir = "arm64-v8a" }, .{ .triple = "x86_64-linux-android", .dir = "x86_64" }, .{ .triple = "arm-linux-androideabi", .dir = "armeabi-v7a" }, }; for (abis) |abi| { const query = std.Target.Query.parse(.{ .arch_os_abi = abi.triple }) catch @panic("bad Android target triple"); const lib = b.addLibrary(.{ .linkage = .dynamic, .name = "tally", .root_module = b.createModule(.{ .root_source_file = b.path("engine/src/jni.zig"), .target = b.resolveTargetQuery(query), .optimize = .ReleaseSmall, }), }); // Android 15 introduced 16KB memory pages, and Play requires shared // libraries to be aligned for them. The default here is 4KB, which loads // fine on a 4KB device and is rejected at submission. lib.link_z_max_page_size = 16384; const install = b.addInstallArtifact(lib, .{ .dest_dir = .{ .override = .{ .custom = b.fmt("android/{s}", .{abi.dir}) } }, }); android_step.dependOn(&install.step); } } // -- The JNI layer against a real JVM -- // // Its own step rather than part of `test`, because it needs a JDK and the rest of the // suite needs nothing but Zig. // // This is the check that was supposed to require a device. JNI's function table is // fixed by the specification rather than by the platform, so the indices hand-written // in `jni.zig` resolve the same way on a desktop JVM as on Android: `System.load` // runs `JNI_OnLoad`, which round-trips a string through two of them and fails loudly // if they are not where it thinks. { const step = b.step("jvm-test", "Prove the JNI function table against a real JVM (needs a JDK)"); const jni_lib = b.addLibrary(.{ .linkage = .dynamic, .name = "tally", .root_module = b.createModule(.{ .root_source_file = b.path("engine/src/jni.zig"), .target = target, .optimize = optimize, }), }); const javac = b.addSystemCommand(&.{ "javac", "-d" }); const classes = javac.addOutputDirectoryArg("classes"); javac.addFileArg(b.path("engine/test/java/dev/lerch/tally/TallyEngine.java")); const java = b.addSystemCommand(&.{"java"}); java.addPrefixedDirectoryArg("-Djava.library.path=", jni_lib.getEmittedBinDirectory()); java.addArg("-cp"); java.addDirectoryArg(classes); java.addArg("dev.lerch.tally.TallyEngine"); java.expectStdOutEqual("jvm jni test: all checks passed\n"); // A failing check exits non-zero, which the run step treats as a build failure. step.dependOn(&java.step); } // -- Run step -- const run_step = b.step("run", "Run tally"); const run_cmd = b.addRunArtifact(exe); run_step.dependOn(&run_cmd.step); run_cmd.step.dependOn(b.getInstallStep()); if (b.args) |args| { run_cmd.addArgs(args); } }