Commit 927f233ff8

mlugg <mlugg@mlugg.co.uk>
2025-04-20 18:54:11
compiler: allow emitting tests to an object file
This is fairly straightforward; the actual compiler changes are limited to the CLI, since `Compilation` already supports this combination. A new `std.Build` API is introduced to allow representing this. By passing the `emit_object` option to `std.Build.addTest`, you get a `Step.Compile` which emits an object file; you can then use that as you would any other object, such as either installing it for external use, or linking it into another step. A standalone test is added to cover the build system API. It builds a test into an object, and links it into a final executable, which it then runs. Using this build system mechanism prevents the build system from noticing that you're running a `zig test`, so the build runner and test runner do not communicate over stdio. However, that's okay, because the real-world use cases for this feature don't want to do that anyway! Resolves: #23374
1 parent 6a7ca4b
Changed files (8)
lib
src
test
standalone
test_obj_link_run
lib/std/Build/Step/Compile.zig
@@ -293,6 +293,7 @@ pub const Kind = enum {
     lib,
     obj,
     @"test",
+    test_obj,
 };
 
 pub const HeaderInstallation = union(enum) {
@@ -370,7 +371,7 @@ pub fn create(owner: *std.Build, options: Options) *Compile {
     }
 
     // Avoid the common case of the step name looking like "zig test test".
-    const name_adjusted = if (options.kind == .@"test" and mem.eql(u8, name, "test"))
+    const name_adjusted = if ((options.kind == .@"test" or options.kind == .test_obj) and mem.eql(u8, name, "test"))
         ""
     else
         owner.fmt("{s} ", .{name});
@@ -385,6 +386,7 @@ pub fn create(owner: *std.Build, options: Options) *Compile {
             .lib => "zig build-lib",
             .obj => "zig build-obj",
             .@"test" => "zig test",
+            .test_obj => "zig test-obj",
         },
         name_adjusted,
         @tagName(options.root_module.optimize orelse .Debug),
@@ -396,7 +398,7 @@ pub fn create(owner: *std.Build, options: Options) *Compile {
         .target = target,
         .output_mode = switch (options.kind) {
             .lib => .Lib,
-            .obj => .Obj,
+            .obj, .test_obj => .Obj,
             .exe, .@"test" => .Exe,
         },
         .link_mode = options.linkage,
@@ -1053,6 +1055,7 @@ fn getZigArgs(compile: *Compile, fuzz: bool) ![][]const u8 {
         .exe => "build-exe",
         .obj => "build-obj",
         .@"test" => "test",
+        .test_obj => "test-obj",
     };
     try zig_args.append(cmd);
 
@@ -1222,9 +1225,9 @@ fn getZigArgs(compile: *Compile, fuzz: bool) ![][]const u8 {
                             switch (other.kind) {
                                 .exe => return step.fail("cannot link with an executable build artifact", .{}),
                                 .@"test" => return step.fail("cannot link with a test", .{}),
-                                .obj => {
+                                .obj, .test_obj => {
                                     const included_in_lib_or_obj = !my_responsibility and
-                                        (dep_compile.kind == .lib or dep_compile.kind == .obj);
+                                        (dep_compile.kind == .lib or dep_compile.kind == .obj or dep_compile.kind == .test_obj);
                                     if (!already_linked and !included_in_lib_or_obj) {
                                         try zig_args.append(other.getEmittedBin().getPath2(b, step));
                                         total_linker_objects += 1;
lib/std/Build/Step/InstallArtifact.zig
@@ -56,7 +56,7 @@ pub fn create(owner: *std.Build, artifact: *Step.Compile, options: Options) *Ins
     const dest_dir: ?InstallDir = switch (options.dest_dir) {
         .disabled => null,
         .default => switch (artifact.kind) {
-            .obj => @panic("object files have no standard installation procedure"),
+            .obj, .test_obj => @panic("object files have no standard installation procedure"),
             .exe, .@"test" => .bin,
             .lib => if (artifact.isDll()) .bin else .lib,
         },
lib/std/Build/Module.zig
@@ -474,7 +474,7 @@ pub fn addObjectFile(m: *Module, object: LazyPath) void {
 }
 
 pub fn addObject(m: *Module, object: *Step.Compile) void {
-    assert(object.kind == .obj);
+    assert(object.kind == .obj or object.kind == .test_obj);
     m.linkLibraryOrObject(object);
 }
 
lib/std/Build.zig
@@ -1019,6 +1019,10 @@ pub const TestOptions = struct {
     use_llvm: ?bool = null,
     use_lld: ?bool = null,
     zig_lib_dir: ?LazyPath = null,
+    /// Emits an object file instead of a test binary.
+    /// The object must be linked separately.
+    /// Usually used in conjunction with a custom `test_runner`.
+    emit_object: bool = false,
 
     /// Prefer populating this field (using e.g. `createModule`) instead of populating
     /// the following fields (`root_source_file` etc). In a future release, those fields
@@ -1067,7 +1071,7 @@ pub fn addTest(b: *Build, options: TestOptions) *Step.Compile {
     }
     return .create(b, .{
         .name = options.name,
-        .kind = .@"test",
+        .kind = if (options.emit_object) .test_obj else .@"test",
         .root_module = options.root_module orelse b.createModule(.{
             .root_source_file = options.root_source_file orelse @panic("`root_module` and `root_source_file` cannot both be null"),
             .target = options.target orelse b.graph.host,
src/main.zig
@@ -278,6 +278,9 @@ fn mainArgs(gpa: Allocator, arena: Allocator, args: []const []const u8) !void {
     } else if (mem.eql(u8, cmd, "test")) {
         dev.check(.test_command);
         return buildOutputType(gpa, arena, args, .zig_test);
+    } else if (mem.eql(u8, cmd, "test-obj")) {
+        dev.check(.test_command);
+        return buildOutputType(gpa, arena, args, .zig_test_obj);
     } else if (mem.eql(u8, cmd, "run")) {
         dev.check(.run_command);
         return buildOutputType(gpa, arena, args, .run);
@@ -764,6 +767,7 @@ const ArgMode = union(enum) {
     cpp,
     translate_c,
     zig_test,
+    zig_test_obj,
     run,
 };
 
@@ -975,7 +979,10 @@ fn buildOutputType(
         .dynamic_linker = null,
         .modules = .{},
         .opts = .{
-            .is_test = arg_mode == .zig_test,
+            .is_test = switch (arg_mode) {
+                .zig_test, .zig_test_obj => true,
+                .build, .cc, .cpp, .translate_c, .run => false,
+            },
             // Populated while parsing CLI args.
             .output_mode = undefined,
             // Populated in the call to `createModule` for the root module.
@@ -1030,7 +1037,7 @@ fn buildOutputType(
     var n_jobs: ?u32 = null;
 
     switch (arg_mode) {
-        .build, .translate_c, .zig_test, .run => {
+        .build, .translate_c, .zig_test, .zig_test_obj, .run => {
             switch (arg_mode) {
                 .build => |m| {
                     create_module.opts.output_mode = m;
@@ -1042,6 +1049,9 @@ fn buildOutputType(
                 .zig_test, .run => {
                     create_module.opts.output_mode = .Exe;
                 },
+                .zig_test_obj => {
+                    create_module.opts.output_mode = .Obj;
+                },
                 else => unreachable,
             }
 
@@ -2834,6 +2844,10 @@ fn buildOutputType(
         },
     }
 
+    if (arg_mode == .zig_test_obj and !test_no_exec and listen == .none) {
+        fatal("test-obj requires --test-no-exec", .{});
+    }
+
     if (arg_mode == .translate_c and create_module.c_source_files.items.len != 1) {
         fatal("translate-c expects exactly 1 source file (found {d})", .{create_module.c_source_files.items.len});
     }
@@ -2903,10 +2917,10 @@ fn buildOutputType(
             create_module.opts.any_error_tracing = true;
 
         const src_path = try introspect.resolvePath(arena, unresolved_src_path);
-        const name = if (arg_mode == .zig_test)
-            "test"
-        else
-            fs.path.stem(fs.path.basename(src_path));
+        const name = switch (arg_mode) {
+            .zig_test => "test",
+            .build, .cc, .cpp, .translate_c, .zig_test_obj, .run => fs.path.stem(fs.path.basename(src_path)),
+        };
 
         try create_module.modules.put(arena, name, .{
             .paths = .{
@@ -2935,7 +2949,7 @@ fn buildOutputType(
         rc_source_files_owner_index = create_module.rc_source_files.items.len;
     }
 
-    if (!create_module.opts.have_zcu and arg_mode == .zig_test) {
+    if (!create_module.opts.have_zcu and create_module.opts.is_test) {
         fatal("`zig test` expects a zig source file argument", .{});
     }
 
@@ -3037,16 +3051,36 @@ fn buildOutputType(
         break :m null;
     };
 
-    const root_mod = if (arg_mode == .zig_test) root_mod: {
-        const test_mod = if (test_runner_path) |test_runner| test_mod: {
-            const test_mod = try Package.Module.create(arena, .{
+    const root_mod = switch (arg_mode) {
+        .zig_test, .zig_test_obj => root_mod: {
+            const test_mod = if (test_runner_path) |test_runner| test_mod: {
+                const test_mod = try Package.Module.create(arena, .{
+                    .global_cache_directory = global_cache_directory,
+                    .paths = .{
+                        .root = .{
+                            .root_dir = Cache.Directory.cwd(),
+                            .sub_path = fs.path.dirname(test_runner) orelse "",
+                        },
+                        .root_src_path = fs.path.basename(test_runner),
+                    },
+                    .fully_qualified_name = "root",
+                    .cc_argv = &.{},
+                    .inherited = .{},
+                    .global = create_module.resolved_options,
+                    .parent = main_mod,
+                    .builtin_mod = main_mod.getBuiltinDependency(),
+                    .builtin_modules = null, // `builtin_mod` is specified
+                });
+                test_mod.deps = try main_mod.deps.clone(arena);
+                break :test_mod test_mod;
+            } else try Package.Module.create(arena, .{
                 .global_cache_directory = global_cache_directory,
                 .paths = .{
                     .root = .{
-                        .root_dir = Cache.Directory.cwd(),
-                        .sub_path = fs.path.dirname(test_runner) orelse "",
+                        .root_dir = zig_lib_directory,
+                        .sub_path = "compiler",
                     },
-                    .root_src_path = fs.path.basename(test_runner),
+                    .root_src_path = "test_runner.zig",
                 },
                 .fully_qualified_name = "root",
                 .cc_argv = &.{},
@@ -3056,28 +3090,11 @@ fn buildOutputType(
                 .builtin_mod = main_mod.getBuiltinDependency(),
                 .builtin_modules = null, // `builtin_mod` is specified
             });
-            test_mod.deps = try main_mod.deps.clone(arena);
-            break :test_mod test_mod;
-        } else try Package.Module.create(arena, .{
-            .global_cache_directory = global_cache_directory,
-            .paths = .{
-                .root = .{
-                    .root_dir = zig_lib_directory,
-                    .sub_path = "compiler",
-                },
-                .root_src_path = "test_runner.zig",
-            },
-            .fully_qualified_name = "root",
-            .cc_argv = &.{},
-            .inherited = .{},
-            .global = create_module.resolved_options,
-            .parent = main_mod,
-            .builtin_mod = main_mod.getBuiltinDependency(),
-            .builtin_modules = null, // `builtin_mod` is specified
-        });
 
-        break :root_mod test_mod;
-    } else main_mod;
+            break :root_mod test_mod;
+        },
+        else => main_mod,
+    };
 
     const target = main_mod.resolved_target.result;
 
@@ -3202,7 +3219,7 @@ fn buildOutputType(
             .directory = blk: {
                 switch (arg_mode) {
                     .run, .zig_test => break :blk null,
-                    else => {
+                    .build, .cc, .cpp, .translate_c, .zig_test_obj => {
                         if (output_to_cache) {
                             break :blk null;
                         } else {
test/standalone/build.zig.zon
@@ -5,6 +5,9 @@
         .simple = .{
             .path = "simple",
         },
+        .test_obj_link_run = .{
+            .path = "test_obj_link_run",
+        },
         .test_runner_path = .{
             .path = "test_runner_path",
         },