Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.direnv/
.zig-cache
zig-out
.DS_Store
112 changes: 95 additions & 17 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,34 @@ pub fn build(b: *std.Build) !void {
"add apple SDK paths from Xcode installation",
) orelse true;

// Translate the Objective-C runtime headers once in the build so the Zig
// code can import a stable generated module instead of invoking @cImport
// from every compile.
const objc_c = try translateCModule(b, target, optimize);

const objc = b.addModule("objc", .{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
objc.addImport("objc-c", objc_c);
if (add_paths) try addAppleSDK(b, objc);
objc.linkSystemLibrary("objc", .{});
objc.linkFramework("Foundation", .{});

const tests_root = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
tests_root.addImport("objc-c", objc_c);
const tests = b.addTest(.{
.name = "objc-test",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
.root_module = tests_root,
});
tests.linkSystemLibrary("objc");
tests.linkFramework("Foundation");
tests.linkFramework("AppKit"); // Required by 'tagged pointer' test.
tests.root_module.linkSystemLibrary("objc", .{});
tests.root_module.linkFramework("Foundation", .{});
tests.root_module.linkFramework("AppKit", .{}); // Required by 'tagged pointer' test.
try addAppleSDK(b, tests.root_module);
b.installArtifact(tests);

Expand All @@ -37,13 +45,86 @@ pub fn build(b: *std.Build) !void {
test_step.dependOn(&tests_run.step);
}

/// Returns a translated Objective-C header module built from the Apple SDK.
///
/// This patches the single `objc/runtime.h` declaration that currently breaks
/// Zig 0.16 `translate-c`, then translates `objc/runtime.h` and
/// `objc/message.h` into an importable Zig module. Bug report:
/// https://codeberg.org/ziglang/zig/issues/31917
fn translateCModule(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
) !*std.Build.Module {
const sdk_path = try appleSDKPath(b, target);
const include_path = b.pathJoin(&.{ sdk_path, "/usr/include" });
const runtime_path = b.pathJoin(&.{ include_path, "/objc/runtime.h" });
const runtime_h = try std.Io.Dir.cwd().readFileAlloc(
b.graph.io,
runtime_path,
b.allocator,
.limited(1024 * 1024),
);

// Zig 0.16's translate-c cannot parse Clang block declarators (`^`) in
// objc/runtime.h. Patch just the offending declaration so we still
// translate the real Apple headers rather than maintaining a local shim.
const needle =
\\objc_enumerateClasses(const void * _Nullable image,
\\ const char * _Nullable namePrefix,
\\ Protocol * _Nullable conformingTo,
\\ Class _Nullable subclassing,
\\ void (^ _Nonnull block)(Class _Nonnull aClass, BOOL * _Nonnull stop)
\\ OBJC_NOESCAPE)
;
// Fail loudly if Apple changes the declaration so we don't silently stop
// patching the one line this workaround depends on.
if (std.mem.indexOf(u8, runtime_h, needle) == null) {
return error.ObjCRuntimeHeaderChanged;
}

const patched_runtime_h = try std.mem.replaceOwned(u8, b.allocator, runtime_h, needle,
\\objc_enumerateClasses(const void * _Nullable image,
\\ const char * _Nullable namePrefix,
\\ Protocol * _Nullable conformingTo,
\\ Class _Nullable subclassing,
\\ void * _Nonnull block)
);

const wf = b.addWriteFiles();
_ = wf.add("objc/runtime.h", patched_runtime_h);
const import_h = wf.add("objc-import.h",
\\#include <objc/runtime.h>
\\#include <objc/message.h>
\\
);

const c = b.addTranslateC(.{
.root_source_file = import_h,
.target = target,
.optimize = optimize,
});
// Search the generated directory first so <objc/runtime.h> resolves to the
// patched copy, while every other include still falls through to the SDK.
c.addIncludePath(wf.getDirectory());
c.addSystemIncludePath(.{ .cwd_relative = include_path });
return c.createModule();
}

/// Add the SDK framework, include, and library paths to the given module.
/// The module target is used to determine the SDK to use so it must have
/// a resolved target.
///
/// The Apple SDK is determined based on the build target and found using
/// xcrun, so it requires a valid Xcode installation.
pub fn addAppleSDK(b: *std.Build, m: *std.Build.Module) !void {
const path = try appleSDKPath(b, m.resolved_target.?);
m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/System/Library/Frameworks" }) });
m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/include" }) });
m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/lib" }) });
}

fn appleSDKPath(b: *std.Build, target: std.Build.ResolvedTarget) ![]const u8 {
// The cache. This always uses b.allocator and never frees memory
// (which is idiomatic for a Zig build exe).
const Cache = struct {
Expand All @@ -56,24 +137,24 @@ pub fn addAppleSDK(b: *std.Build, m: *std.Build.Module) !void {
var map: std.AutoHashMapUnmanaged(Key, ?[]const u8) = .{};
};

const target = m.resolved_target.?.result;
const gop = try Cache.map.getOrPut(b.allocator, .{
.arch = target.cpu.arch,
.os = target.os.tag,
.abi = target.abi,
.arch = target.result.cpu.arch,
.os = target.result.os.tag,
.abi = target.result.abi,
});

// This executes `xcrun` to get the SDK path. We don't want to execute
// this multiple times so we cache the value.
if (!gop.found_existing) {
gop.value_ptr.* = std.zig.system.darwin.getSdk(
b.allocator,
&m.resolved_target.?.result,
b.graph.io,
&target.result,
);
}

// The active SDK we want to use
const path = gop.value_ptr.* orelse return switch (target.os.tag) {
return gop.value_ptr.* orelse switch (target.result.os.tag) {
// Return a more descriptive error. Before we just returned the
// generic error but this was confusing a lot of community members.
// It costs us nothing in the build script to return something better.
Expand All @@ -83,7 +164,4 @@ pub fn addAppleSDK(b: *std.Build, m: *std.Build.Module) !void {
.watchos => error.XcodeWatchOSSDKNotFound,
else => error.XcodeAppleSDKNotFound,
};
m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/System/Library/Frameworks" }) });
m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/include" }) });
m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/lib" }) });
}
45 changes: 14 additions & 31 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
description = "Objective-C runtime bindings for Zig";

inputs = {
nixpkgs.url = "github:nixos/nixpkgs/release-25.05";
nixpkgs.url = "github:nixos/nixpkgs/release-25.11";
flake-utils.url = "github:numtide/flake-utils";
zig.url = "github:mitchellh/zig-overlay";

Expand Down Expand Up @@ -35,7 +35,7 @@
in rec {
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
zigpkgs."0.15.1"
zigpkgs."0.16.0"
];
};

Expand Down
65 changes: 27 additions & 38 deletions src/block.zig
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ pub fn Block(
_Block_release(@ptrCast(@alignCast(ctx)));
}

fn descCopyHelper(src: *anyopaque, dst: *anyopaque) callconv(.c) void {
const real_src: *Context = @ptrCast(@alignCast(src));
fn descCopyHelper(dst: *anyopaque, src: *anyopaque) callconv(.c) void {
const real_dst: *Context = @ptrCast(@alignCast(dst));
const real_src: *Context = @ptrCast(@alignCast(src));
inline for (captures_info.fields) |field| {
if (field.type == objc.c.id) {
_Block_object_assign(
Expand Down Expand Up @@ -150,21 +150,11 @@ pub fn Block(
/// the first arg. The first arg is a pointer so from an ABI perspective
/// this is always the same and can be safely casted.
fn FnType(comptime ContextArg: type) type {
var params: [Args.len + 1]std.builtin.Type.Fn.Param = undefined;
params[0] = .{ .is_generic = false, .is_noalias = false, .type = *const ContextArg };
for (Args, 1..) |Arg, i| {
params[i] = .{ .is_generic = false, .is_noalias = false, .type = Arg };
}
var param_types: [Args.len + 1]type = undefined;
param_types[0] = *const ContextArg;
for (Args, 1..) |Arg, i| param_types[i] = Arg;

return @Type(.{
.@"fn" = .{
.calling_convention = .c,
.is_generic = false,
.is_var_args = false,
.return_type = Return,
.params = &params,
},
});
return @Fn(&param_types, &@splat(.{}), Return, .{ .@"callconv" = .c });
}
};
}
Expand Down Expand Up @@ -216,24 +206,19 @@ fn BlockContext(comptime Captures: type, comptime InvokeFn: type) type {
comptime_float => @compileError("capture should not be a comptime_float, try using @as"),
else => {},
}
fields[i] = .{ .name = capture.name, .type = capture.type, .default_value_ptr = null, .is_comptime = false, .alignment = capture.alignment };
}

fields[i] = .{
.name = capture.name,
.type = capture.type,
.default_value_ptr = null,
.is_comptime = false,
.alignment = capture.alignment,
};
var field_names: [fields.len][]const u8 = undefined;
var field_types: [fields.len]type = undefined;
var field_attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined;
for (fields, 0..) |field, i| {
field_names[i] = field.name;
field_types[i] = field.type;
field_attrs[i] = .{ .@"align" = field.alignment };
}

return @Type(.{
.@"struct" = .{
.layout = .@"extern",
.fields = &fields,
.decls = &.{},
.is_tuple = false,
},
});
return @Struct(.@"extern", null, &field_names, &field_types, &field_attrs);
}

// Pointer to opaque instead of anyopaque: https://github.com/ziglang/zig/issues/18461
Expand Down Expand Up @@ -312,18 +297,22 @@ test "Block copy objc id" {

const TestBlock = Block(struct {
id: objc.c.id,
}, .{}, i32);
}, .{}, objc.c.id);

var block = TestBlock.init(.{
.id = obj.value,
}, (struct {
fn addFn(block: *const TestBlock.Context) callconv(.c) i32 {
_ = block;
return 0;
fn blockFn(block: *const TestBlock.Context) callconv(.c) objc.c.id {
return block.id;
}
}).addFn);
}).blockFn);

// Try copy and release
// Copy the block — this exercises descCopyHelper(dst, src).
// If dst/src are swapped, the copied block's captured id will be garbage
// rather than obj.value, and invoke will return the wrong pointer.
const copied = try TestBlock.copy(&block);
TestBlock.release(copied);
defer TestBlock.release(copied);

const result = TestBlock.invoke(copied, .{});
try std.testing.expectEqual(obj.value, result);
}
5 changes: 1 addition & 4 deletions src/c.zig
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
pub const c = @cImport({
@cInclude("objc/runtime.h");
@cInclude("objc/message.h");
});
pub const c = @import("objc-c");

/// On some targets, Objective-C uses `i8` instead of `bool`.
/// This helper casts a target value type to `bool`.
Expand Down
Loading
Loading