RZig lets R package authors write plain Zig functions and generate safe .Call
bindings without C++ or hand-written SEXP conversion code.
0.16.x or 0.17.x), needed only
to install a package created with RZig. RZig itself, its examples and its tests run without Zig.
Zig changes its language and standard library between release series, so
other versions are rejected with a message naming the version found.Download Zig from https://ziglang.org/download/ and confirm the version:
zig version
# 0.16.0
From CRAN:
install.packages("rzig")
The development version from GitHub:
install.packages("remotes")
remotes::install_github("MarekDejaUJ/rzig")
If Zig is not on PATH, tell RZig where it is:
Sys.setenv(ZIG = "/absolute/path/to/zig")
find_zig() and the generated package configuration search in this order:
ZIG, PATH, ~/.local/share/zig/*/zig, then ~/zig/zig. An R session
launched from an IDE may not inherit variables exported by a shell; in that
case, set ZIG with Sys.setenv() first.
rzig::find_zig(required = FALSE)
#> Zig compiler: /usr/local/bin/zig (version 0.16.0, supported)
#> rzig supports Zig 0.16.x or 0.17.x.
The workflow has five steps, each an R function. For a copy-paste-safe
example, create the package in R's session temporary directory. Replace
tempdir() with an explicit project directory when keeping the result.
1. Scaffold. use_rzig() adds the build files, the framework sources and
a starter src/rzig/src/main.zig with a working hello_zig() function:
pkg <- file.path(tempdir(), "rzhello")
dir.create(pkg)
writeLines(
c(
"Package: rzhello",
"Type: Package",
"Title: A Small RZig Example",
"Version: 0.0.1",
"Authors@R: person('Your', 'Name', email = 'you@example.org', role = c('aut', 'cre'))",
"Description: Demonstrates a Zig implementation called safely from R.",
"License: GPL-3",
"Encoding: UTF-8"
),
file.path(pkg, "DESCRIPTION")
)
rzig::use_rzig(pkg)
#> Generated RZig bindings for rzhello: 1 exported function
#> Created RZig scaffold for package rzhello
2. Write Zig. Open src/rzig/src/main.zig, keep its imports, panic
declaration and comptime registration block, and replace the generated
hello_zig() function with:
/// Add two numeric vectors elementwise.
/// @param a The first numeric vector.
/// @param b The second numeric vector.
/// @return The elementwise sums.
/// @export
pub fn add_vectors(
ctx: *rzig.Ctx,
a: []const f64,
b: []const f64,
) rzig.Error![]f64 {
if (a.len != b.len) {
return rzig.raise("lengths differ: {d} vs {d}", .{ a.len, b.len });
}
const result = try ctx.alloc(f64, a.len);
for (a, b, result) |left, right, *value| value.* = left + right;
return result;
}
The @param and @return lines are optional. When they are absent,
document() generates neutral placeholders from the Zig signature. Only
/// @export is required to expose a public function to R. Exported functions
are top-level pub fn declarations starting at the beginning of a line, as
zig fmt writes them.
3. Scan. scan_exports() lists what R will see, with the R value of every
parameter and of the return value:
rzig::scan_exports(pkg)
#> Zig exports of package rzhello (src/rzig/src/main.zig): 1 function
#>
#> add_vectors(a, b)
#> a []const f64 double vector, borrowed
#> b []const f64 double vector, borrowed
#> returns rzig.Error![]f64 double vector
4. Generate. document() writes the Zig manifest, the R wrappers and the
NAMESPACE block; render_bindings() shows the same texts without writing
them, and rzig_status() reports whether every step is complete:
rzig::document(pkg)
#> Generated RZig bindings for rzhello: 1 exported function
rzig::rzig_status(pkg)
#> RZig status of package rzhello
#> [ok] DESCRIPTION package rzhello
#> [ok] Scaffold build files and framework sources present
#> [ok] Zig source src/rzig/src/main.zig
#> [ok] Exports 1 function: add_vectors
#> [ok] Bindings generated files match the exports
#> [ok] Zig compiler /usr/local/bin/zig (version 0.16.0)
#> [ok] C toolchain C compiler clang
rzig_status() also reports, before anything is compiled, argument and return
types the boundary rejects, more than 32 arguments, misuse of
rzig.Mut([]f64), a missing registration block or panic handler in
main.zig, R functions of the same name elsewhere in R/, framework sources
from another rzig version, non-executable configure scripts and a missing C
toolchain. A failed build names its cause:
rzig::build_zig(pkg, preflight = FALSE)
#> Build of package rzhello: failed at the compile step
#> src/rzig/framework/convert.zig:138:9 (function total)
#> rzig: function `total`, parameter 1 has unsupported type '[]const f32'.
#> nearest supported alternative: []const f64 for an R numeric vector
#> The complete installation log (61 lines) is in $log.
5. Build and call. build_zig() runs the checks of rzig_status(),
compiles and installs the package from a temporary copy, and reports the
first compiler, linker or load error with its file and line when the build
fails. The package also installs like any source package, with
install.packages(pkg, repos = NULL, type = "source") or R CMD INSTALL.
build <- rzig::build_zig(pkg)
build
#> Build of package rzhello: succeeded in 8.1 seconds
#> Zig compiler: /usr/local/bin/zig (version 0.16.0)
#> Installed into a library in the session temporary directory
library(rzhello, lib.loc = build$library)
add_vectors(c(1, 2, 3), c(10, 20, 30))
#> [1] 11 22 33
add_vectors(c(1, 2), c(1, 2, 3))
#> Error: lengths differ: 2 vs 3
# The session remains usable after the native error.
add_vectors(c(4, 5), c(6, 7))
#> [1] 10 12
The same workflow applies to an existing package: run use_rzig() once, edit
src/rzig/src/main.zig, and run document() whenever exported Zig signatures
or comments change. The scaffolded cleanup and cleanup.win scripts remove
generated Makevars and Zig build caches after package installation and checks.
If roxygen2 manages the rest of the package documentation and NAMESPACE, use
this order after changing a Zig export:
rzig::document(pkg) # generate wrappers for roxygen2 to read
roxygen2::roxygenise(pkg) # regenerate Rd files and the ordinary NAMESPACE
rzig::document(pkg) # restore RZig's explicit native-registration block
The final call preserves non-RZig directives while replacing generated Zig
exports, so both tools retain ownership of their respective blocks. If
roxygenise() loaded the package in the current R session, unload it or restart
R before calling newly installed native exports.
document() generatesEvery public Zig function with a /// @export line produces:
NAMESPACE; andThe first *rzig.Ctx parameter is supplied by RZig and omitted from the R
function. The complete input surface is:
| Zig parameter | Accepted R value | Conversion |
|---|---|---|
f64 |
length-one double, integer, or logical | checked scalar |
i32 |
length-one double, integer, or logical | checked whole number |
bool |
length-one logical | checked scalar |
usize |
length-one double, integer, or logical | checked whole number from 0 to 2147483647 |
?f64, ?i32, ?bool, ?usize
|
corresponding scalar, NA, or NULL
|
missing values become null
|
[]const f64 |
double vector | borrowed, read-only |
[]const i32 |
integer vector | borrowed, read-only |
[]const bool |
logical vector without NA
|
copied into the call arena |
[]const u8 |
one non-NA character value |
copied as UTF-8 |
[]const []const u8 |
character vector without NA
|
copied as UTF-8 |
rzig.Matrix |
double matrix | borrowed, read-only, column-major |
rzig.Mut([]f64) |
double vector | duplicated before writable access |
rzig.Sexp |
any R object | borrowed low-level handle |
Supported return values are:
| Zig return | R result |
|---|---|
void |
NULL |
f64, i32, bool
|
length-one double, integer, or logical |
[]const f64 / []f64
|
double vector |
[]const i32 / []i32
|
integer vector |
[]const bool / []bool
|
logical vector |
[]const u8 |
length-one character vector |
[]const []const u8 / [][]const u8
|
character vector |
rzig.List |
named R list |
rzig.Attributed(T) |
supported vector T with names, dimensions, or classes |
rzig.Sexp |
the supplied R object |
?T |
supported T, or NULL when null
|
Any supported return may be wrapped in rzig.Error!T. usize and
rzig.Matrix are parameter-only. Unsupported signatures fail at compile time
with the function and parameter position in the error.
rzig.raise() records a message and returns rzig.Error, so it can be returned
directly or propagated with try. rzig.warn() queues a warning for delivery
after Zig cleanup and returns void, so call it without try:
if (values.len == 0) rzig.warn("received an empty vector", .{});
rzig.List collects supported return values in Zig-owned memory and converts
them to one named R list only after the function returns:
/// Return values together with their length.
/// @export
pub fn summarize(ctx: *rzig.Ctx, values: []const f64) rzig.Error!rzig.List {
if (values.len > std.math.maxInt(i32)) return rzig.raise("too many values", .{});
var result = rzig.List.init(ctx);
try result.put("values", values);
try result.put("count", @as(i32, @intCast(values.len)));
return result;
}
List entries may contain f64, i32, bool, double, integer, or logical
slices, UTF-8 strings or string vectors, optional values, or rzig.Sexp. Names
are copied into the call context, and no R object is allocated until boundary
conversion begins.
Mutation is opt-in with rzig.Mut([]f64). RZig duplicates and protects the R
vector before Zig receives writable storage, then returns that duplicate:
/// Scale a copy of a numeric vector.
/// @export
pub fn scale(values: rzig.Mut([]f64), factor: f64) void {
for (values.data) |*value| value.* *= factor;
}
The caller's vector and any aliases remain unchanged. A function using Mut
accepts one mutable vector and returns void or rzig.Error!void; its generated
R wrapper returns the mutated duplicate automatically.
rzig.Matrix borrows an R double matrix in column-major order and provides its
validated shape through nrow and ncol. Integer matrices are rejected rather
than silently copied; convert them in R with storage.mode(x) <- "double".
Use rzig.Attributed([]const f64) to return a numeric vector with metadata:
pub fn labeled(
ctx: *rzig.Ctx,
values: []const f64,
labels: []const []const u8,
) rzig.Error!rzig.Attributed([]const f64) {
var result = rzig.Attributed([]const f64).init(ctx, values);
try result.setNames(labels);
try result.setClass("labeled_values");
return result;
}
setNames, setDim, setClass, and setClasses copy their metadata into the
call context. Lengths and dimension products are checked before R allocation.
Call try rzig.checkInterrupt() about every 100,000 iterations of a long loop.
It probes through an R trampoline that catches the runtime's non-local interrupt
exit, allowing Zig cleanup to finish before the boundary returns an R error.
rzig.parallelFor distributes an indexed computation across Zig worker threads.
Its state is checked at compile time: Ctx, Sexp, matrices, list builders,
mutable inputs, attributed results, opaque pointers, and function pointers cannot
cross into a worker.
const Work = struct {
input: []const f64,
output: []f64,
fn square(work: *@This(), index: usize) void {
work.output[index] = work.input[index] * work.input[index];
}
};
pub fn squares(ctx: *rzig.Ctx, input: []const f64) rzig.Error![]f64 {
const output = try ctx.alloc(f64, input.len);
var work = Work{ .input = input, .output = output };
try rzig.parallelFor(ctx, input.len, &work, Work.square);
return output;
}
The callback has the exact signature fn(*State, usize) void and always runs
off the R thread. It must be panic-free and must not import or call R or RZig
APIs. If a worker can fail, record that in atomic plain-data state, wait for
parallelFor to join every worker, and call rzig.raise on the calling thread.
RZig keeps R's non-local error mechanism at the outer native boundary. Internal
Zig code returns explicit errors, all Zig-owned cleanup finishes before an R
error is raised, and cleanup is protected if an R API call jumps out. Inputs are
borrowed read-only, return slices are copied into R-owned memory, and generated
packages build in ReleaseSafe mode so bounds and overflow checks stay enabled.
R's API remains single-threaded. Zig code may parallelize pure computation, but must not call R from worker threads.
The package tests run with R CMD check and need no Zig. The repository
test suite runs with Zig 0.16.0 and 0.17.0:
zig build test
zig build --release=safe
zig build lint
R CMD build tests/fixtures/rzigtest
Continuous integration builds and checks the fixture package on Linux, macOS, and Windows, and runs GC-stress and memory-analysis profiles where available.
GPL-3. See COPYING for the full license text. RZig 0.2.3 and earlier releases on CRAN were published under the MIT license.