tally/build.zig

302 lines
12 KiB
Zig

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);
}
}