Overview
With the cross-compilation mechanics from the previous chapter (see 41), we can now assemble a complete WASI project that compiles to both native and WebAssembly targets using a single build.zig. This chapter constructs a small log analyzer CLI that reads input, processes it, and emits summary statistics—functionality that maps cleanly to WASI’s file and stdio capabilities (see wasi.zig). You’ll write the application once, then generate and test both a Linux executable and a .wasm module using runtimes like Wasmtime or Wasmer (see v0.15.2).
The build system will define multiple targets, each with its own artifact, and you’ll wire run steps that automatically launch the correct runtime based on target (see 22). By the end, you’ll have a working template for shipping portable command-line tools as both native binaries and WASI modules.
Learning Goals
- Structure a Zig project with shared source code that compiles cleanly to
x86_64-linuxandwasm32-wasi(see Target.zig). - Integrate multiple
addExecutabletargets inbuild.zigwith distinct optimization and naming strategies (see Build.zig). - Configure run steps with runtime detection (native vs Wasmtime/Wasmer) and pass arguments through to the final binary (see 22).
- Test the same logic path in both native and WASI environments, validating cross-platform behavior (see #Command-line-flags).
Project Structure
We organize the analyzer as a single-package workspace with a src/ directory containing the entry point and analysis logic. The build.zig will create two artifacts: log-analyzer-native and log-analyzer-wasi.
Directory Layout
42-log-analyzer/
├── build.zig
├── build.zig.zon
└── src/
├── main.zig
└── analysis.zigThe build.zig.zon is minimal since we have no external dependencies; it serves as metadata for potential future packaging (see 21).
Package Metadata
.{
// Package identifier used in dependencies and imports
// Must be a valid Zig identifier (no hyphens or special characters)
.name = .log_analyzer,
// Semantic version of this package
// Format: major.minor.patch following semver conventions
.version = "0.1.0",
// Minimum Zig compiler version required to build this package
// Ensures compatibility with language features and build system APIs
.minimum_zig_version = "0.15.2",
// List of paths to include when publishing or distributing the package
// Empty string includes all files in the package directory
.paths = .{
"",
},
// Unique identifier generated by the package manager for integrity verification
// Used to detect changes and ensure package authenticity
.fingerprint = 0xba0348facfd677ff,
}
The .minimum_zig_version field prevents accidental builds with older compilers that lack WASI improvements introduced in 0.15.2.
Build System Setup
Our build.zig defines two executables sharing the same root source file but targeting different platforms. We also add a custom run step for the WASI binary that detects available runtimes.
Multi-Target Build Script
const std = @import("std");
/// Build script for log-analyzer project demonstrating native and WASI cross-compilation.
/// Produces two executables: one for native execution and one for WASI runtimes.
pub fn build(b: *std.Build) void {
// Standard target and optimization options from command-line flags
// These allow users to specify --target and --optimize when building
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Native executable: optimized for fast runtime performance on the host system
// This target respects user-specified target and optimization settings
const exe_native = b.addExecutable(.{
.name = "log-analyzer-native",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
// Register the native executable for installation to zig-out/bin
b.installArtifact(exe_native);
// WASI executable: cross-compiled to WebAssembly with WASI support
// Uses ReleaseSmall to minimize binary size for portable distribution
const wasi_target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .wasi,
});
const exe_wasi = b.addExecutable(.{
.name = "log-analyzer-wasi",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = wasi_target,
.optimize = .ReleaseSmall, // Prioritize small binary size over speed
}),
});
// Register the WASI executable for installation to zig-out/bin
b.installArtifact(exe_wasi);
// Create run step for native target that executes the compiled binary directly
const run_native = b.addRunArtifact(exe_native);
// Ensure the binary is built and installed before attempting to run it
run_native.step.dependOn(b.getInstallStep());
// Forward any command-line arguments passed after -- to the executable
if (b.args) |args| {
run_native.addArgs(args);
}
// Register the run step so users can invoke it with `zig build run-native`
const run_native_step = b.step("run-native", "Run the native log analyzer");
run_native_step.dependOn(&run_native.step);
// Create run step for WASI target with automatic runtime detection
// First, attempt to detect an available WASI runtime (wasmtime or wasmer)
const run_wasi = b.addSystemCommand(&.{"echo"});
const wasi_runtime = detectWasiRuntime(b) orelse {
// If no runtime is found, provide a helpful error message
run_wasi.addArg("ERROR: No WASI runtime (wasmtime or wasmer) found in PATH");
const run_wasi_step = b.step("run-wasi", "Run the WASI log analyzer (requires wasmtime or wasmer)");
run_wasi_step.dependOn(&run_wasi.step);
return;
};
// Construct the command to run the WASI binary with the detected runtime
const run_wasi_cmd = b.addSystemCommand(&.{wasi_runtime});
// Both wasmtime and wasmer require the 'run' subcommand
if (std.mem.eql(u8, wasi_runtime, "wasmtime") or std.mem.eql(u8, wasi_runtime, "wasmer")) {
run_wasi_cmd.addArg("run");
// Grant access to the current directory for file I/O operations
run_wasi_cmd.addArg("--dir=.");
}
// Add the WASI binary as the target to execute
run_wasi_cmd.addArtifactArg(exe_wasi);
// Forward user arguments after the -- separator to the WASI program
if (b.args) |args| {
run_wasi_cmd.addArg("--");
run_wasi_cmd.addArgs(args);
}
// Ensure the WASI binary is built before attempting to run it
run_wasi_cmd.step.dependOn(b.getInstallStep());
// Register the WASI run step so users can invoke it with `zig build run-wasi`
const run_wasi_step = b.step("run-wasi", "Run the WASI log analyzer (requires wasmtime or wasmer)");
run_wasi_step.dependOn(&run_wasi_cmd.step);
}
/// Detect available WASI runtime in the system PATH.
/// Checks for wasmtime first, then wasmer as a fallback.
/// Returns the name of the detected runtime, or null if neither is found.
fn detectWasiRuntime(b: *std.Build) ?[]const u8 {
// Attempt to locate wasmtime using the 'which' command
var exit_code: u8 = undefined;
_ = b.runAllowFail(&.{ "which", "wasmtime" }, &exit_code, .Ignore) catch {
// If wasmtime is not found, try wasmer as a fallback
_ = b.runAllowFail(&.{ "which", "wasmer" }, &exit_code, .Ignore) catch {
// Neither runtime was found in PATH
return null;
};
return "wasmer";
};
// wasmtime was successfully located
return "wasmtime";
}
$ zig build(no output on success; artifacts installed to zig-out/bin/)The WASI target sets -OReleaseSmall to minimize module size, while the native target uses -OReleaseFast for runtime speed—demonstrating per-artifact optimization control.
Analysis Logic
The analyzer reads the entire log content, splits it by newlines, counts occurrences of severity keywords (ERROR, WARN, INFO), and prints a summary. We factor the parsing into analysis.zig so it can be unit-tested independently of I/O.
Core Analysis Module
// This module provides log analysis functionality for counting severity levels in log files.
// It demonstrates basic string parsing and struct usage in Zig.
const std = @import("std");
// LogStats holds the count of each log severity level found during analysis.
// All fields are initialized to zero by default, representing no logs counted yet.
pub const LogStats = struct {
info_count: u32 = 0,
warn_count: u32 = 0,
error_count: u32 = 0,
};
/// Analyze log content, counting severity keywords.
/// Returns statistics in a LogStats struct.
pub fn analyzeLog(content: []const u8) LogStats {
// Initialize stats with all counts at zero
var stats = LogStats{};
// Create an iterator that splits the content by newline characters
// This allows us to process the log line by line
var it = std.mem.splitScalar(u8, content, '\n');
// Process each line in the log content
while (it.next()) |line| {
// Count occurrences of severity keywords
// indexOf returns an optional - if found, we increment the corresponding counter
if (std.mem.indexOf(u8, line, "INFO")) |_| {
stats.info_count += 1;
}
if (std.mem.indexOf(u8, line, "WARN")) |_| {
stats.warn_count += 1;
}
if (std.mem.indexOf(u8, line, "ERROR")) |_| {
stats.error_count += 1;
}
}
return stats;
}
// Test basic log analysis with multiple severity levels
test "analyzeLog basic counting" {
const input = "INFO startup\nERROR failed\nWARN retry\nINFO success\n";
const stats = analyzeLog(input);
// Verify each severity level was counted correctly
try std.testing.expectEqual(@as(u32, 2), stats.info_count);
try std.testing.expectEqual(@as(u32, 1), stats.warn_count);
try std.testing.expectEqual(@as(u32, 1), stats.error_count);
}
// Test that empty input produces zero counts for all severity levels
test "analyzeLog empty input" {
const input = "";
const stats = analyzeLog(input);
// All counts should remain at their default zero value
try std.testing.expectEqual(@as(u32, 0), stats.info_count);
try std.testing.expectEqual(@as(u32, 0), stats.warn_count);
try std.testing.expectEqual(@as(u32, 0), stats.error_count);
}
By accepting content as a slice, analyzeLog remains simple and testable. main.zig handles file reading, and the function just processes text (see mem.zig).
Main Entry Point
The entry point parses command-line arguments, reads the entire file content (or stdin), delegates to analyzeLog, and prints the results. Both native and WASI builds share this code path; WASI handles file access through its virtualized filesystem or stdin.
Main Source File
const std = @import("std");
const analysis = @import("analysis.zig");
pub fn main() !void {
// Initialize general-purpose allocator for dynamic memory allocation
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Parse command-line arguments into an allocated slice
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
// Check for optional --input flag to specify a file path
var input_path: ?[]const u8 = null;
var i: usize = 1; // Skip program name at args[0]
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--input")) {
i += 1;
if (i < args.len) {
input_path = args[i];
} else {
std.debug.print("ERROR: --input requires a file path\n", .{});
return error.MissingArgument;
}
}
}
// Read input content from either file or stdin
// Using labeled blocks to unify type across both branches
const content = if (input_path) |path| blk: {
std.debug.print("analyzing: {s}\n", .{path});
// Read entire file content with 10MB limit
break :blk try std.fs.cwd().readFileAlloc(allocator, path, 10 * 1024 * 1024);
} else blk: {
std.debug.print("analyzing: stdin\n", .{});
// Construct File handle directly from stdin file descriptor
const stdin = std.fs.File{ .handle = std.posix.STDIN_FILENO };
// Read all available stdin data with same 10MB limit
break :blk try stdin.readToEndAlloc(allocator, 10 * 1024 * 1024);
};
defer allocator.free(content);
// Delegate log analysis to the analysis module
const stats = analysis.analyzeLog(content);
// Print summary statistics to stderr (std.debug.print)
std.debug.print("results: INFO={d} WARN={d} ERROR={d}\n", .{
stats.info_count,
stats.warn_count,
stats.error_count,
});
}
The --input flag allows testing with files; omit it to read from stdin, which WASI runtimes can pipe easily. Note that WASI filesystem access requires explicit capability grants from the runtime (see posix.zig).
Building and Running
With the source complete, we can build both targets and run them side-by-side to confirm identical behavior.
Native Execution
$ zig build
$ echo -e "INFO startup\nERROR failed\nWARN retry\nINFO success" > sample.log
$ ./zig-out/bin/log-analyzer-native --input sample.loganalyzing: sample.log
results: INFO=2 WARN=1 ERROR=1WASI Execution with Wasmer (Stdin)
$ zig build
$ echo -e "INFO startup\nERROR failed\nWARN retry\nINFO success" | wasmer run zig-out/bin/log-analyzer-wasi.wasmanalyzing: stdin
results: INFO=2 WARN=1 ERROR=1WASI stdin piping works reliably across runtimes. File access with --input requires capability grants (--dir or --mapdir) which vary by runtime implementation and may have limitations in preview1.
Native Stdin Test for Comparison
$ echo -e "INFO startup\nERROR failed\nWARN retry\nINFO success" | ./zig-out/bin/log-analyzer-nativeanalyzing: stdin
results: INFO=2 WARN=1 ERROR=1Both native and WASI produce identical output when reading from stdin, demonstrating true source-level portability for command-line tools.
Using Run Steps
The build.zig includes run step definitions for both targets. Invoke them directly:
$ zig build run-native -- --input sample.loganalyzing: sample.log
results: INFO=2 WARN=1 ERROR=1$ echo -e "INFO test" | zig build run-wasianalyzing: stdin
results: INFO=1 WARN=0 ERROR=0The run-wasi step automatically selects an installed WASI runtime (Wasmtime or Wasmer) or errors if neither is available. See the detectWasiRuntime helper in build.zig.
Binary Size Comparison
WASI modules built with -OReleaseSmall produce compact artifacts:
$ ls -lh zig-out/bin/log-analyzer-*-rwxrwxr-x 1 user user 7.9M Nov 6 14:29 log-analyzer-native
-rwxr--r-- 1 user user 18K Nov 6 14:29 log-analyzer-wasi.wasmThe .wasm module is dramatically smaller (18KB vs 7.9MB) because it omits native OS integration and relies on the host runtime for system calls, making it ideal for edge deployment or browser environments.
Extending the Project
This template serves as a foundation for more complex CLI tools targeting WASI:
- JSON output: Emit structured results using
std.json.stringify, enabling downstream processing by other tools (see json.zig). - Streaming from stdin: The current implementation already handles stdin efficiently by reading all content at once, suitable for logs up to 10MB with the current limit (see 28).
- Multi-format support: Accept different log formats (JSON, syslog, custom) and detect them automatically based on content patterns.
- HTTP frontend: Package the WASI module for use in a serverless function that accepts logs via POST and returns JSON summaries (see 31).
Notes & Caveats
- WASI preview1 (current snapshot) lacks networking, threading, and has limited filesystem features. Stdin/stdout work reliably, but file access requires runtime-specific capability grants.
- The
zig libceffort introduced in 0.15.2 shares implementation between musl and wasi-libc, improving consistency and enabling features likereadToEndAllocto work identically across platforms. - WASI runtimes vary in their permission model. Wasmer’s
--mapdirhad issues in testing, while stdin piping works universally. Design CLI tools to prefer stdin when targeting WASI.
Exercises
- Add a
--format jsonflag that emits{"info": N, "warn": N, "error": N}instead of the plaintext summary, then validate the output by piping tojq. - Extend
analysis.zigwith a unit test that verifies case-insensitive matching (e.g., "info" and "INFO" both count), demonstratingstd.ascii.eqlIgnoreCase(see 13). - Create a third build target for
wasm32-freestanding(no WASI) that exposes the analyzer as an exported function callable from JavaScript via@export(see wasm.zig). - Benchmark native vs WASI execution time with a large log file (generate 100k lines), comparing startup overhead and throughput (see 40).
Caveats, Alternatives, Edge Cases
- If you need threading, WASI preview2 (component model) introduces experimental concurrency primitives. Consult upstream WASI specs for migration paths.
- For browser targets, switch to
wasm32-freestandingand use JavaScript interop (@export/@extern) instead of WASI syscalls (see 33). - Some WASI runtimes (e.g., Wasmedge) support non-standard extensions like sockets or GPU access. Stick to preview1 for maximum portability, or document runtime-specific dependencies clearly.