Overview
Chapter 22 taught the std.Build API mechanics; this chapter consolidates that knowledge through a complete project: TextKit, a text processing library paired with a CLI tool that demonstrates real-world patterns for structuring workspaces, organizing modules, linking artifacts, integrating tests, and creating custom build steps. See 22 and Build.zig.
By walking through TextKit’s implementation—from module organization to build script orchestration—you will understand how professional Zig projects separate concerns between reusable libraries and application-specific executables, while maintaining a single unified build graph that handles compilation, testing, and distribution. See 21 and Compile.zig.
Learning Goals
- Structure a workspace with both library and executable artifacts sharing a common
build.zig. - Organize library code into multiple modules for maintainability and testability. 20
- Build a static library with
b.addLibrary()and install it for external consumption. - Create an executable that imports and uses the library module. 22
- Integrate comprehensive tests for both library and executable components. 13
- Define custom build steps beyond the default install, run, and test targets.
- Understand the contrast between
zig build(graph-based) andzig build-exe(imperative).
Project Structure: TextKit
TextKit is a text processing utility consisting of:
- Library (): Reusable text processing functions exposed as a module
- Executable (): Command-line interface consuming the library
- Tests: Comprehensive coverage for library functionality
- Custom Steps: Demonstration commands beyond standard build/test/run
Directory Layout
textkit/
├── build.zig # Build graph definition
├── build.zig.zon # Package metadata
├── sample.txt # Demo input file
└── src/
├── textkit.zig # Library root (public API)
├── string_utils.zig # String manipulation utilities
├── text_stats.zig # Text analysis functions
└── main.zig # CLI executable entry pointThis layout follows Zig conventions: src/ contains all source files, build.zig orchestrates compilation, and build.zig.zon declares package identity. See 21 and init templates.
Library Implementation
The TextKit library exposes two primary modules: StringUtils for character-level operations and TextStats for document analysis. See Module.zig.
String Utilities Module
// Import the standard library for testing utilities
const std = @import("std");
/// String utilities for text processing
pub const StringUtils = struct {
/// Count occurrences of a character in a string
/// Returns the total number of times the specified character appears
pub fn countChar(text: []const u8, char: u8) usize {
var count: usize = 0;
// Iterate through each character in the text
for (text) |c| {
// Increment counter when matching character is found
if (c == char) count += 1;
}
return count;
}
/// Check if string contains only ASCII characters
/// ASCII characters have values from 0-127
pub fn isAscii(text: []const u8) bool {
for (text) |c| {
// Any character with value > 127 is non-ASCII
if (c > 127) return false;
}
return true;
}
/// Reverse a string in place
/// Modifies the input buffer directly using two-pointer technique
pub fn reverse(text: []u8) void {
// Early return for empty strings
if (text.len == 0) return;
var left: usize = 0;
var right: usize = text.len - 1;
// Swap characters from both ends moving towards the center
while (left < right) {
const temp = text[left];
text[left] = text[right];
text[right] = temp;
left += 1;
right -= 1;
}
}
};
// Test suite verifying countChar functionality with various inputs
test "countChar counts occurrences" {
const text = "hello world";
// Verify counting of 'l' character (appears 3 times)
try std.testing.expectEqual(@as(usize, 3), StringUtils.countChar(text, 'l'));
// Verify counting of 'o' character (appears 2 times)
try std.testing.expectEqual(@as(usize, 2), StringUtils.countChar(text, 'o'));
// Verify counting returns 0 for non-existent character
try std.testing.expectEqual(@as(usize, 0), StringUtils.countChar(text, 'x'));
}
// Test suite verifying ASCII detection for different character sets
test "isAscii detects ASCII strings" {
// Standard ASCII letters should return true
try std.testing.expect(StringUtils.isAscii("hello"));
// ASCII digits should return true
try std.testing.expect(StringUtils.isAscii("123"));
// String with non-ASCII character (é = 233) should return false
try std.testing.expect(!StringUtils.isAscii("héllo"));
}
// Test suite verifying in-place string reversal
test "reverse reverses string" {
// Create a mutable buffer to test in-place reversal
var buffer = [_]u8{ 'h', 'e', 'l', 'l', 'o' };
StringUtils.reverse(&buffer);
// Verify the buffer contents are reversed
try std.testing.expectEqualSlices(u8, "olleh", &buffer);
}
This module demonstrates:
- Struct-based organization: Static methods grouped under
StringUtils - Inline tests: Each function paired with its test cases for locality
- Simple algorithms: Character counting, ASCII validation, in-place reversal
Text Statistics Module
const std = @import("std");
/// Text statistics and analysis structure
/// Provides functionality to analyze text content and compute various metrics
/// such as word count, line count, and character count.
pub const TextStats = struct {
/// Total number of words found in the analyzed text
word_count: usize,
/// Total number of lines in the analyzed text
line_count: usize,
/// Total number of characters in the analyzed text
char_count: usize,
/// Analyze text and compute statistics
/// Iterates through the input text to count words, lines, and characters.
/// Words are defined as sequences of non-whitespace characters separated by whitespace.
/// Lines are counted based on newline characters, with special handling for text
/// that doesn't end with a newline.
pub fn analyze(text: []const u8) TextStats {
var stats = TextStats{
.word_count = 0,
.line_count = 0,
.char_count = text.len,
};
// Track whether we're currently inside a word to avoid counting multiple
// consecutive whitespace characters as separate word boundaries
var in_word = false;
for (text) |c| {
if (c == '\n') {
stats.line_count += 1;
in_word = false;
} else if (std.ascii.isWhitespace(c)) {
// Whitespace marks the end of a word
in_word = false;
} else if (!in_word) {
// Transition from whitespace to non-whitespace marks a new word
stats.word_count += 1;
in_word = true;
}
}
// Count last line if text doesn't end with newline
if (text.len > 0 and text[text.len - 1] != '\n') {
stats.line_count += 1;
}
return stats;
}
// Format and write statistics to the provided writer
// Outputs the statistics in a human-readable format: "Lines: X, Words: Y, Chars: Z"
pub fn format(self: TextStats, writer: *std.Io.Writer) std.Io.Writer.Error!void {
try writer.print("Lines: {d}, Words: {d}, Chars: {d}", .{
self.line_count,
self.word_count,
self.char_count,
});
}
};
// Verify that TextStats correctly analyzes multi-line text with multiple words
test "TextStats analyzes simple text" {
const text = "hello world\nfoo bar";
const stats = TextStats.analyze(text);
try std.testing.expectEqual(@as(usize, 2), stats.line_count);
try std.testing.expectEqual(@as(usize, 4), stats.word_count);
try std.testing.expectEqual(@as(usize, 19), stats.char_count);
}
// Verify that TextStats correctly handles edge case of empty input
test "TextStats handles empty text" {
const text = "";
const stats = TextStats.analyze(text);
try std.testing.expectEqual(@as(usize, 0), stats.line_count);
try std.testing.expectEqual(@as(usize, 0), stats.word_count);
try std.testing.expectEqual(@as(usize, 0), stats.char_count);
}
Key patterns:
- State aggregation:
TextStatsstruct holds computed statistics - Analysis function: Pure function taking text, returning stats
- Format method: Zig 0.15.2 format interface for printing
- Comprehensive tests: Edge cases (empty text, no trailing newline)
Library Root: Public API
//! TextKit - A text processing library
//!
//! This library provides utilities for text manipulation and analysis,
//! including string utilities and text statistics.
pub const StringUtils = @import("string_utils.zig").StringUtils;
pub const TextStats = @import("text_stats.zig").TextStats;
const std = @import("std");
/// Library version information
pub const version = std.SemanticVersion{
.major = 1,
.minor = 0,
.patch = 0,
};
test {
// Ensure all module tests are run
std.testing.refAllDecls(@This());
}
The root file (textkit.zig) serves as the library’s public interface:
- Re-exports: Makes submodules accessible as
textkit.StringUtilsandtextkit.TextStats - Version metadata: Semantic version for external consumers
- Test aggregation:
std.testing.refAllDecls()ensures all module tests run
This pattern allows internal reorganization without breaking consumer imports. 20, testing.zig
Executable Implementation
The CLI tool wraps library functionality in a user-friendly command-line interface with subcommands for different operations. process.zig
CLI Structure and Argument Parsing
const std = @import("std");
const textkit = @import("textkit");
pub fn main() !void {
// Set up a general-purpose allocator for dynamic memory allocation
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Retrieve command line arguments passed to the program
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
// Ensure at least one command argument is provided (args[0] is the program name)
if (args.len < 2) {
try printUsage();
return;
}
// Extract the command verb from the first argument
const command = args[1];
// Dispatch to the appropriate handler based on the command
if (std.mem.eql(u8, command, "analyze")) {
// 'analyze' requires a filename argument
if (args.len < 3) {
std.debug.print("Error: analyze requires a filename\n", .{});
return;
}
try analyzeFile(allocator, args[2]);
} else if (std.mem.eql(u8, command, "reverse")) {
// 'reverse' requires text to reverse
if (args.len < 3) {
std.debug.print("Error: reverse requires text\n", .{});
return;
}
try reverseText(args[2]);
} else if (std.mem.eql(u8, command, "count")) {
// 'count' requires both text and a single character to count
if (args.len < 4) {
std.debug.print("Error: count requires text and character\n", .{});
return;
}
// Validate that the character argument is exactly one byte
if (args[3].len != 1) {
std.debug.print("Error: character must be single byte\n", .{});
return;
}
try countCharacter(args[2], args[3][0]);
} else {
// Handle unrecognized commands
std.debug.print("Unknown command: {s}\n", .{command});
try printUsage();
}
}
/// Print usage information to guide users on available commands
fn printUsage() !void {
const usage =
\\TextKit CLI - Text processing utility
\\
\\Usage:
\\ textkit-cli analyze <file> Analyze text file statistics
\\ textkit-cli reverse <text> Reverse the given text
\\ textkit-cli count <text> <char> Count character occurrences
\\
;
std.debug.print("{s}", .{usage});
}
/// Read a file and display statistical analysis of its text content
fn analyzeFile(allocator: std.mem.Allocator, filename: []const u8) !void {
// Open the file in read-only mode from the current working directory
const file = try std.fs.cwd().openFile(filename, .{});
defer file.close();
// Read the entire file content into memory (limited to 1MB)
const content = try file.readToEndAlloc(allocator, 1024 * 1024);
defer allocator.free(content);
// Use textkit library to compute text statistics
const stats = textkit.TextStats.analyze(content);
// Display the computed statistics to the user
std.debug.print("File: {s}\n", .{filename});
std.debug.print(" Lines: {d}\n", .{stats.line_count});
std.debug.print(" Words: {d}\n", .{stats.word_count});
std.debug.print(" Characters: {d}\n", .{stats.char_count});
std.debug.print(" ASCII only: {}\n", .{textkit.StringUtils.isAscii(content)});
}
/// Reverse the provided text and display both original and reversed versions
fn reverseText(text: []const u8) !void {
// Allocate a stack buffer for in-place reversal
var buffer: [1024]u8 = undefined;
// Ensure the input text fits within the buffer
if (text.len > buffer.len) {
std.debug.print("Error: text too long (max {d} chars)\n", .{buffer.len});
return;
}
// Copy input text into the mutable buffer for reversal
@memcpy(buffer[0..text.len], text);
// Perform in-place reversal using textkit utility
textkit.StringUtils.reverse(buffer[0..text.len]);
// Display both the original and reversed text
std.debug.print("Original: {s}\n", .{text});
std.debug.print("Reversed: {s}\n", .{buffer[0..text.len]});
}
/// Count occurrences of a specific character in the provided text
fn countCharacter(text: []const u8, char: u8) !void {
// Use textkit to count character occurrences
const count = textkit.StringUtils.countChar(text, char);
// Display the count result
std.debug.print("Character '{c}' appears {d} time(s) in: {s}\n", .{
char,
count,
text,
});
}
// Test that all declarations in this module are reachable and compile correctly
test "main program compiles" {
std.testing.refAllDecls(@This());
}
The executable demonstrates:
- Command dispatch: Routing subcommands to handler functions
- Argument validation: Checking parameter counts and formats
- Error handling: Graceful failures with informative messages
- Library consumption: Clean imports via
@import("textkit")
Command Handler Functions
const std = @import("std");
const textkit = @import("textkit");
pub fn main() !void {
// Set up a general-purpose allocator for dynamic memory allocation
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Retrieve command line arguments passed to the program
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
// Ensure at least one command argument is provided (args[0] is the program name)
if (args.len < 2) {
try printUsage();
return;
}
// Extract the command verb from the first argument
const command = args[1];
// Dispatch to the appropriate handler based on the command
if (std.mem.eql(u8, command, "analyze")) {
// 'analyze' requires a filename argument
if (args.len < 3) {
std.debug.print("Error: analyze requires a filename\n", .{});
return;
}
try analyzeFile(allocator, args[2]);
} else if (std.mem.eql(u8, command, "reverse")) {
// 'reverse' requires text to reverse
if (args.len < 3) {
std.debug.print("Error: reverse requires text\n", .{});
return;
}
try reverseText(args[2]);
} else if (std.mem.eql(u8, command, "count")) {
// 'count' requires both text and a single character to count
if (args.len < 4) {
std.debug.print("Error: count requires text and character\n", .{});
return;
}
// Validate that the character argument is exactly one byte
if (args[3].len != 1) {
std.debug.print("Error: character must be single byte\n", .{});
return;
}
try countCharacter(args[2], args[3][0]);
} else {
// Handle unrecognized commands
std.debug.print("Unknown command: {s}\n", .{command});
try printUsage();
}
}
/// Print usage information to guide users on available commands
fn printUsage() !void {
const usage =
\\TextKit CLI - Text processing utility
\\
\\Usage:
\\ textkit-cli analyze <file> Analyze text file statistics
\\ textkit-cli reverse <text> Reverse the given text
\\ textkit-cli count <text> <char> Count character occurrences
\\
;
std.debug.print("{s}", .{usage});
}
/// Read a file and display statistical analysis of its text content
fn analyzeFile(allocator: std.mem.Allocator, filename: []const u8) !void {
// Open the file in read-only mode from the current working directory
const file = try std.fs.cwd().openFile(filename, .{});
defer file.close();
// Read the entire file content into memory (limited to 1MB)
const content = try file.readToEndAlloc(allocator, 1024 * 1024);
defer allocator.free(content);
// Use textkit library to compute text statistics
const stats = textkit.TextStats.analyze(content);
// Display the computed statistics to the user
std.debug.print("File: {s}\n", .{filename});
std.debug.print(" Lines: {d}\n", .{stats.line_count});
std.debug.print(" Words: {d}\n", .{stats.word_count});
std.debug.print(" Characters: {d}\n", .{stats.char_count});
std.debug.print(" ASCII only: {}\n", .{textkit.StringUtils.isAscii(content)});
}
/// Reverse the provided text and display both original and reversed versions
fn reverseText(text: []const u8) !void {
// Allocate a stack buffer for in-place reversal
var buffer: [1024]u8 = undefined;
// Ensure the input text fits within the buffer
if (text.len > buffer.len) {
std.debug.print("Error: text too long (max {d} chars)\n", .{buffer.len});
return;
}
// Copy input text into the mutable buffer for reversal
@memcpy(buffer[0..text.len], text);
// Perform in-place reversal using textkit utility
textkit.StringUtils.reverse(buffer[0..text.len]);
// Display both the original and reversed text
std.debug.print("Original: {s}\n", .{text});
std.debug.print("Reversed: {s}\n", .{buffer[0..text.len]});
}
/// Count occurrences of a specific character in the provided text
fn countCharacter(text: []const u8, char: u8) !void {
// Use textkit to count character occurrences
const count = textkit.StringUtils.countChar(text, char);
// Display the count result
std.debug.print("Character '{c}' appears {d} time(s) in: {s}\n", .{
char,
count,
text,
});
}
// Test that all declarations in this module are reachable and compile correctly
test "main program compiles" {
std.testing.refAllDecls(@This());
}
Each handler showcases different library features:
analyzeFile: File I/O, memory allocation, text statisticsreverseText: Stack buffer usage, string manipulationcountCharacter: Simple library delegation
Build Script: Orchestrating the Workspace
The build.zig file ties everything together, defining how library and executable relate and how users interact with the project.
Complete Build Script
const std = @import("std");
pub fn build(b: *std.Build) void {
// Standard target and optimization options
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// ===== LIBRARY =====
// Create the TextKit library module
const textkit_mod = b.addModule("textkit", .{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
});
// Build static library artifact
const lib = b.addLibrary(.{
.name = "textkit",
.root_module = b.createModule(.{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
.optimize = optimize,
}),
.version = .{ .major = 1, .minor = 0, .patch = 0 },
.linkage = .static,
});
// Install the library (to zig-out/lib/)
b.installArtifact(lib);
// ===== EXECUTABLE =====
// Create executable that uses the library
const exe = b.addExecutable(.{
.name = "textkit-cli",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "textkit", .module = textkit_mod },
},
}),
});
// Install the executable (to zig-out/bin/)
b.installArtifact(exe);
// ===== RUN STEP =====
// Create a run step for the executable
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
// Forward command-line arguments to the application
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the TextKit CLI");
run_step.dependOn(&run_cmd.step);
// ===== TESTS =====
// Library tests
const lib_tests = b.addTest(.{
.root_module = textkit_mod,
});
const run_lib_tests = b.addRunArtifact(lib_tests);
// Executable tests (minimal for main.zig)
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
// Test step that runs all tests
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_lib_tests.step);
test_step.dependOn(&run_exe_tests.step);
// ===== CUSTOM STEPS =====
// Demo step that shows usage
const demo_step = b.step("demo", "Run demo commands");
const demo_reverse = b.addRunArtifact(exe);
demo_reverse.addArgs(&.{ "reverse", "Hello Zig!" });
demo_step.dependOn(&demo_reverse.step);
const demo_count = b.addRunArtifact(exe);
demo_count.addArgs(&.{ "count", "mississippi", "s" });
demo_step.dependOn(&demo_count.step);
}
Build Script Sections Explained
Library Creation
const std = @import("std");
pub fn build(b: *std.Build) void {
// Standard target and optimization options
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// ===== LIBRARY =====
// Create the TextKit library module
const textkit_mod = b.addModule("textkit", .{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
});
// Build static library artifact
const lib = b.addLibrary(.{
.name = "textkit",
.root_module = b.createModule(.{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
.optimize = optimize,
}),
.version = .{ .major = 1, .minor = 0, .patch = 0 },
.linkage = .static,
});
// Install the library (to zig-out/lib/)
b.installArtifact(lib);
// ===== EXECUTABLE =====
// Create executable that uses the library
const exe = b.addExecutable(.{
.name = "textkit-cli",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "textkit", .module = textkit_mod },
},
}),
});
// Install the executable (to zig-out/bin/)
b.installArtifact(exe);
// ===== RUN STEP =====
// Create a run step for the executable
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
// Forward command-line arguments to the application
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the TextKit CLI");
run_step.dependOn(&run_cmd.step);
// ===== TESTS =====
// Library tests
const lib_tests = b.addTest(.{
.root_module = textkit_mod,
});
const run_lib_tests = b.addRunArtifact(lib_tests);
// Executable tests (minimal for main.zig)
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
// Test step that runs all tests
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_lib_tests.step);
test_step.dependOn(&run_exe_tests.step);
// ===== CUSTOM STEPS =====
// Demo step that shows usage
const demo_step = b.step("demo", "Run demo commands");
const demo_reverse = b.addRunArtifact(exe);
demo_reverse.addArgs(&.{ "reverse", "Hello Zig!" });
demo_step.dependOn(&demo_reverse.step);
const demo_count = b.addRunArtifact(exe);
demo_count.addArgs(&.{ "count", "mississippi", "s" });
demo_step.dependOn(&demo_count.step);
}
Two module creations serve different purposes:
textkit_mod: Public module for consumers (viab.addModule)lib: Static library artifact with separate module configuration
The library module specifies only .target because optimization is user-facing, while the library artifact requires both .target and .optimize for compilation.
We use .linkage = .static to produce a .a archive file; change to .dynamic for .so/.dylib/.dll shared libraries. 22
Executable with Library Import
const std = @import("std");
pub fn build(b: *std.Build) void {
// Standard target and optimization options
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// ===== LIBRARY =====
// Create the TextKit library module
const textkit_mod = b.addModule("textkit", .{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
});
// Build static library artifact
const lib = b.addLibrary(.{
.name = "textkit",
.root_module = b.createModule(.{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
.optimize = optimize,
}),
.version = .{ .major = 1, .minor = 0, .patch = 0 },
.linkage = .static,
});
// Install the library (to zig-out/lib/)
b.installArtifact(lib);
// ===== EXECUTABLE =====
// Create executable that uses the library
const exe = b.addExecutable(.{
.name = "textkit-cli",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "textkit", .module = textkit_mod },
},
}),
});
// Install the executable (to zig-out/bin/)
b.installArtifact(exe);
// ===== RUN STEP =====
// Create a run step for the executable
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
// Forward command-line arguments to the application
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the TextKit CLI");
run_step.dependOn(&run_cmd.step);
// ===== TESTS =====
// Library tests
const lib_tests = b.addTest(.{
.root_module = textkit_mod,
});
const run_lib_tests = b.addRunArtifact(lib_tests);
// Executable tests (minimal for main.zig)
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
// Test step that runs all tests
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_lib_tests.step);
test_step.dependOn(&run_exe_tests.step);
// ===== CUSTOM STEPS =====
// Demo step that shows usage
const demo_step = b.step("demo", "Run demo commands");
const demo_reverse = b.addRunArtifact(exe);
demo_reverse.addArgs(&.{ "reverse", "Hello Zig!" });
demo_step.dependOn(&demo_reverse.step);
const demo_count = b.addRunArtifact(exe);
demo_count.addArgs(&.{ "count", "mississippi", "s" });
demo_step.dependOn(&demo_count.step);
}
The .imports table connects main.zig to the library module, enabling @import("textkit"). The name "textkit" is arbitrary—you could rename it to "lib" and use @import("lib") instead.
Run Step with Argument Forwarding
const std = @import("std");
pub fn build(b: *std.Build) void {
// Standard target and optimization options
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// ===== LIBRARY =====
// Create the TextKit library module
const textkit_mod = b.addModule("textkit", .{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
});
// Build static library artifact
const lib = b.addLibrary(.{
.name = "textkit",
.root_module = b.createModule(.{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
.optimize = optimize,
}),
.version = .{ .major = 1, .minor = 0, .patch = 0 },
.linkage = .static,
});
// Install the library (to zig-out/lib/)
b.installArtifact(lib);
// ===== EXECUTABLE =====
// Create executable that uses the library
const exe = b.addExecutable(.{
.name = "textkit-cli",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "textkit", .module = textkit_mod },
},
}),
});
// Install the executable (to zig-out/bin/)
b.installArtifact(exe);
// ===== RUN STEP =====
// Create a run step for the executable
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
// Forward command-line arguments to the application
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the TextKit CLI");
run_step.dependOn(&run_cmd.step);
// ===== TESTS =====
// Library tests
const lib_tests = b.addTest(.{
.root_module = textkit_mod,
});
const run_lib_tests = b.addRunArtifact(lib_tests);
// Executable tests (minimal for main.zig)
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
// Test step that runs all tests
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_lib_tests.step);
test_step.dependOn(&run_exe_tests.step);
// ===== CUSTOM STEPS =====
// Demo step that shows usage
const demo_step = b.step("demo", "Run demo commands");
const demo_reverse = b.addRunArtifact(exe);
demo_reverse.addArgs(&.{ "reverse", "Hello Zig!" });
demo_step.dependOn(&demo_reverse.step);
const demo_count = b.addRunArtifact(exe);
demo_count.addArgs(&.{ "count", "mississippi", "s" });
demo_step.dependOn(&demo_count.step);
}
This standard pattern:
Creates a run artifact step
Depends on installation (ensures binary is in
zig-out/bin/)Forwards CLI arguments after
--Wires to top-level
runstep
Test Integration
const std = @import("std");
pub fn build(b: *std.Build) void {
// Standard target and optimization options
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// ===== LIBRARY =====
// Create the TextKit library module
const textkit_mod = b.addModule("textkit", .{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
});
// Build static library artifact
const lib = b.addLibrary(.{
.name = "textkit",
.root_module = b.createModule(.{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
.optimize = optimize,
}),
.version = .{ .major = 1, .minor = 0, .patch = 0 },
.linkage = .static,
});
// Install the library (to zig-out/lib/)
b.installArtifact(lib);
// ===== EXECUTABLE =====
// Create executable that uses the library
const exe = b.addExecutable(.{
.name = "textkit-cli",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "textkit", .module = textkit_mod },
},
}),
});
// Install the executable (to zig-out/bin/)
b.installArtifact(exe);
// ===== RUN STEP =====
// Create a run step for the executable
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
// Forward command-line arguments to the application
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the TextKit CLI");
run_step.dependOn(&run_cmd.step);
// ===== TESTS =====
// Library tests
const lib_tests = b.addTest(.{
.root_module = textkit_mod,
});
const run_lib_tests = b.addRunArtifact(lib_tests);
// Executable tests (minimal for main.zig)
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
// Test step that runs all tests
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_lib_tests.step);
test_step.dependOn(&run_exe_tests.step);
// ===== CUSTOM STEPS =====
// Demo step that shows usage
const demo_step = b.step("demo", "Run demo commands");
const demo_reverse = b.addRunArtifact(exe);
demo_reverse.addArgs(&.{ "reverse", "Hello Zig!" });
demo_step.dependOn(&demo_reverse.step);
const demo_count = b.addRunArtifact(exe);
demo_count.addArgs(&.{ "count", "mississippi", "s" });
demo_step.dependOn(&demo_count.step);
}
Separating library and executable tests isolates failures and enables parallel execution. Both depend on the same test step so zig build test runs everything. 13
Custom Demo Step
const std = @import("std");
pub fn build(b: *std.Build) void {
// Standard target and optimization options
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// ===== LIBRARY =====
// Create the TextKit library module
const textkit_mod = b.addModule("textkit", .{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
});
// Build static library artifact
const lib = b.addLibrary(.{
.name = "textkit",
.root_module = b.createModule(.{
.root_source_file = b.path("src/textkit.zig"),
.target = target,
.optimize = optimize,
}),
.version = .{ .major = 1, .minor = 0, .patch = 0 },
.linkage = .static,
});
// Install the library (to zig-out/lib/)
b.installArtifact(lib);
// ===== EXECUTABLE =====
// Create executable that uses the library
const exe = b.addExecutable(.{
.name = "textkit-cli",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "textkit", .module = textkit_mod },
},
}),
});
// Install the executable (to zig-out/bin/)
b.installArtifact(exe);
// ===== RUN STEP =====
// Create a run step for the executable
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
// Forward command-line arguments to the application
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the TextKit CLI");
run_step.dependOn(&run_cmd.step);
// ===== TESTS =====
// Library tests
const lib_tests = b.addTest(.{
.root_module = textkit_mod,
});
const run_lib_tests = b.addRunArtifact(lib_tests);
// Executable tests (minimal for main.zig)
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
// Test step that runs all tests
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_lib_tests.step);
test_step.dependOn(&run_exe_tests.step);
// ===== CUSTOM STEPS =====
// Demo step that shows usage
const demo_step = b.step("demo", "Run demo commands");
const demo_reverse = b.addRunArtifact(exe);
demo_reverse.addArgs(&.{ "reverse", "Hello Zig!" });
demo_step.dependOn(&demo_reverse.step);
const demo_count = b.addRunArtifact(exe);
demo_count.addArgs(&.{ "count", "mississippi", "s" });
demo_step.dependOn(&demo_count.step);
}
Custom steps showcase functionality without requiring user input. zig build demo runs predefined commands sequentially, demonstrating the CLI’s capabilities.
Using the Project
TextKit supports multiple workflows for building, testing, and running. 22
Building Library and Executable
$ zig build- Library:
zig-out/lib/libtextkit.a - Executable:
zig-out/bin/textkit-cli
Both artifacts are installed to standard locations by default.
Running Tests
$ zig build testAll 5 tests passed.
Tests from both string_utils.zig, text_stats.zig, and main.zig run together, reporting aggregate results. 13
Running the CLI
View Usage
$ zig build runTextKit CLI - Text processing utility
Usage:
textkit-cli analyze <file> Analyze text file statistics
textkit-cli reverse <text> Reverse the given text
textkit-cli count <text> <char> Count character occurrencesReverse Text
$ zig build run -- reverse "Hello World"Original: Hello World
Reversed: dlroW olleHCount Characters
$ zig build run -- count "mississippi" "s"Character 's' appears 4 time(s) in: mississippiAnalyze File
$ zig build run -- analyze sample.txtFile: sample.txt
Lines: 7
Words: 51
Characters: 336
ASCII only: trueRunning Demo Step
$ zig build demoOriginal: Hello Zig!
Reversed: !giZ olleH
Character 's' appears 4 time(s) in: mississippiExecutes multiple commands in sequence without user interaction—useful for CI/CD pipelines or quick verification.
Contrasting Build Workflows
Understanding when to use zig build versus zig build-exe clarifies the build system’s purpose.
Direct compilation with
$ zig build-exe src/main.zig --name textkit-cli --pkg-begin textkit src/textkit.zig --pkg-endThis imperative command:
- Compiles immediately without graph construction
- Requires manual specification of all modules and flags
- Produces no caching or incremental compilation benefits
- Suitable for quick one-off builds or debugging
Graph-based build with
$ zig buildThis declarative command:
- Executes
build.zigto construct a dependency graph - Caches artifacts and skips unchanged steps
- Parallelizes independent compilations
- Supports user customization via
-Dflags - Integrates testing, installation, and custom steps
The graph-based approach scales better as projects grow, making zig build the standard for non-trivial codebases. 22
Design Patterns and Best Practices
TextKit demonstrates several professional patterns worth adopting.
Module Organization
- Single responsibility: Each module (
string_utils,text_stats) focuses on one concern - Root re-exports:
textkit.zigprovides unified public API - Test co-location: Tests live next to implementation for maintainability
Build Script Patterns
- Standard options first: Always start with
standardTargetOptions()andstandardOptimizeOption() - Logical grouping: Comment sections (===== LIBRARY =====) improve readability
- Artifact installation: Call
installArtifact()for everything users should access - Test separation: Independent library and executable test steps isolate failures
CLI Design Patterns
- Subcommand dispatch: Central router delegates to handler functions
- Graceful degradation: Usage messages for invalid input
- Resource cleanup:
deferensures allocator and file handle cleanup - Library separation: All logic in library, CLI is thin wrapper
Exercises
- Add a new subcommand
trimthat removes leading/trailing whitespace from text, implementing the function instring_utils.zigwith tests. ascii.zig - Convert the library from static (
.linkage = .static) to dynamic (.linkage = .dynamic) and observe the output file differences. - Create a second executable
textkit-batchthat processes multiple files in parallel using threads, sharing the same library module. 37 - Add a custom build step
benchthat runs performance benchmarks onStringUtils.reversewith various input sizes.
Notes & Caveats
- The static library (
.afile) is not strictly necessary since Zig can link modules directly, but producing the library artifact demonstrates traditional library distribution patterns. - When creating both a public module (
b.addModule) and a library artifact (b.addLibrary), ensure both point to the same root source file to avoid confusion. - The
installArtifact()step installs tozig-out/by default; override with.prefixoption for custom installation paths. - Tests in
main.zigtypically verify only that the executable compiles; comprehensive functionality tests belong in library modules. 13
Caveats, alternatives, edge cases
- If the library were header-only (no runtime code), you wouldn’t need
addLibrary()—only the module definition suffices. 20 - Zig 0.14.0 deprecated direct
root_source_fileinExecutableOptions; always useroot_modulewrapper as shown here. - For C interop scenarios, you’d add
lib.linkLibC()and potentially generate headers withlib.addCSourceFile()plusinstallHeader(). - Large projects might split
build.ziginto helper functions or separate files included via@import("build_helpers.zig")—the build script is regular Zig code.