Getting started with RZig

RZig lets an R package implement functions in Zig. The package author writes ordinary Zig functions and marks the exported ones; RZig generates the registration, the R wrappers and the conversion of every argument and result, and runs the Zig build when the package is installed. This vignette goes through the workflow one step at a time. Every step up to the installation is plain R and runs without Zig; the steps that compile run when a supported Zig compiler is found, and this copy of the vignette ran them.

Find the compiler

find_zig() looks for a Zig release of the supported series (0.16 and 0.17) in ZIG, on PATH, in the folder of install_zig() and in the usual install locations. With required = FALSE, a missing compiler is a result and not an error:

compiler
#> Zig compiler: /opt/homebrew/bin/zig (version 0.16.0, supported)
#> rzig supports Zig 0.16.x or 0.17.x.

A user without Zig can install a verified release with install_zig(), which downloads it into the user cache folder of rzig after asking for permission.

Create a package

create_rzig_package() creates a new package with the RZig scaffold, a starter function and a test; use_rzig() adds the same scaffold to an existing package. The package here lives in R’s temporary directory:

path <- file.path(tempdir(), "hellozig")
create_rzig_package(path, fields = list(
  Title = "Greetings from Zig",
  Description = "Shows the workflow of 'rzig'."
))
#> Created package hellozig at /private/var/folders/vh/njwnyy0141n0zbrgjw2vbz800000gn/T/RtmppmxRLi/hellozig
#> Write Zig functions in src/rzig/src/main.zig, then run rzig::document(path) and rzig::build_zig(path)

The Zig code lives in src/rzig/src/main.zig. The starter function shows the three parts of an export: documentation comments, which become the help page, the marker /// @export, and a public function with supported types:

main_zig <- file.path(path, "src", "rzig", "src", "main.zig")
cat(readLines(main_zig), sep = "\n")
const std = @import("std");
const builtin = @import("builtin");
const rzig = @import("rzig");

/// Route ReleaseSafe failures through R while retaining Zig test behavior.
pub const panic = if (builtin.is_test)
    std.debug.FullPanic(std.debug.defaultPanic)
else
    rzig.Panic;

/// Return a friendly greeting.
/// @param name A name to greet.
/// @return A character string.
/// @examples
/// hello_zig("R")
/// @export
pub fn hello_zig(ctx: *rzig.Ctx, name: []const u8) rzig.Error![]const u8 {
    return std.fmt.allocPrint(ctx.allocator(), "Hello, {s}!", .{name}) catch
        rzig.raise("out of memory while building the greeting", .{});
}

comptime {
    rzig.registerModule(@This());
}

Add a function

A second export takes a numeric vector, which Zig reads as a borrowed slice, and returns a numeric vector that Zig allocates in the call context ctx:

cat(
  "",
  "/// The cumulative sum of a numeric vector.",
  "/// @param x A numeric vector.",
  "/// @return A numeric vector of the same length.",
  "/// @examples",
  "/// cumulative(c(1, 2, 3))",
  "/// @export",
  "pub fn cumulative(ctx: *rzig.Ctx, x: []const f64) rzig.Error![]f64 {",
  "    const out = try ctx.alloc(f64, x.len);",
  "    var total: f64 = 0;",
  "    for (x, out) |value, *slot| {",
  "        total += value;",
  "        slot.* = total;",
  "    }",
  "    return out;",
  "}",
  sep = "\n", file = main_zig, append = TRUE
)

Scan, generate and check

scan_exports() lists the exported functions with their R arguments and the R values they accept and return:

scan_exports(path)
#> Zig exports of package hellozig (src/rzig/src/main.zig): 2 functions
#> 
#> hello_zig(name)
#>   name       []const u8             character, length 1, copied
#>   returns    rzig.Error![]const u8  character, length 1
#> 
#> cumulative(x)
#>   x          []const f64            double vector, borrowed
#>   returns    rzig.Error![]f64       double vector

document() writes the generated files: the Zig manifest, the R wrappers, the NAMESPACE lines and the help pages. Run it after every change of an exported signature or its documentation:

bindings <- document(path)
#> Generated RZig bindings for hellozig: 2 exported functions
cat(bindings$wrappers)
#> # Generated by rzig::document(). Do not edit.
#> 
#> #' Return a friendly greeting.
#> #' @param name A name to greet.
#> #' @return A character string.
#> #' @examples
#> #' hello_zig("R")
#> #'
#> #' @export
#> hello_zig <- function(name) {
#>   .Call(hello_zig_, name)
#> }
#> 
#> #' The cumulative sum of a numeric vector.
#> #' @param x A numeric vector.
#> #' @return A numeric vector of the same length.
#> #' @examples
#> #' cumulative(c(1, 2, 3))
#> #'
#> #' @export
#> cumulative <- function(x) {
#>   .Call(cumulative_, x)
#> }

rzig_status() runs the checks that build_zig() runs before it compiles: the DESCRIPTION, the scaffold, the Zig source, the types of the exports, the generated files and the toolchain:

rzig_status(path)
#> RZig status of package hellozig
#>   [ok] DESCRIPTION   package hellozig
#>   [ok] Scaffold      build files and framework sources present
#>   [ok] Zig source    src/rzig/src/main.zig
#>   [ok] Exports       2 functions: hello_zig, cumulative
#>   [ok] Bindings      generated files match the exports
#>   [ok] Zig compiler  /opt/homebrew/bin/zig (version 0.16.0)
#>   [ok] C toolchain   C compiler clang

Build and call

build_zig() installs the package from a copy, so that the source folder stays clean, and reports compiler errors by file and line:

build <- build_zig(path)
build
#> Build of package hellozig: succeeded in 8.8 seconds
#> Zig compiler: /opt/homebrew/bin/zig (version 0.16.0)
#> Installed into a library in the session temporary directory
library(build$package, character.only = TRUE, lib.loc = build$library)
hello_zig("R")
#> [1] "Hello, R!"
cumulative(c(1, 2, 3, 4))
#> [1]  1  3  6 10

Arguments of the wrong type are R errors that name the argument; the R session continues:

try(cumulative("a"))
#> Error : rzig: argument `x` of cumulative must be a numeric vector; got character; use as.numeric() in R

Test the Zig code

Functions that do not use the rzig module can be tested with Zig’s own test runner, without R. test_zig() runs the test blocks reached from src/rzig/src/tests.zig:

writeLines(c(
  "const std = @import(\"std\");",
  "",
  "fn add(a: f64, b: f64) f64 {",
  "    return a + b;",
  "}",
  "",
  "test \"add\" {",
  "    try std.testing.expectEqual(@as(f64, 3), add(1, 2));",
  "}"
), file.path(path, "src", "rzig", "src", "tests.zig"))
tests <- test_zig(path)
tests
#> Zig tests passed in 6.9 seconds

Try Zig from the R prompt

zig_function() compiles one Zig function into a temporary package and returns an R function, which suits experiments before code moves into a package:

twice <- zig_function("pub fn twice(x: f64) f64 { return 2 * x; }")
twice(21)
#> [1] 42

Next steps

The README of rzig lists the supported types. The other vignettes describe updating the scaffold, exports and classes, threads and random numbers, numerical routines from R, packages that share code, and distribution.