Chapter 23Project Library And Executable Workspace

Project

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) and zig 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

Text
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 point

This 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

Zig

// 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

Zig
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: TextStats struct 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)

See v0.15.2 and Io.zig.

Library Root: Public API

Zig
//! 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.StringUtils and textkit.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

Zig
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")

2

Command Handler Functions

Zig
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 statistics
  • reverseText: Stack buffer usage, string manipulation
  • countCharacter: 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

Zig
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

Zig
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 (via b.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

Zig
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

Zig
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:

  1. Creates a run artifact step

  2. Depends on installation (ensures binary is in zig-out/bin/)

  3. Forwards CLI arguments after --

  4. Wires to top-level run step

22, Run.zig

Test Integration

Zig
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

Zig
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

Shell
$ 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

Shell
$ zig build test
Output (success)
All 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

Shell
$ zig build run
Output
Shell
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

Reverse Text

Shell
$ zig build run -- reverse "Hello World"
Output
Shell
Original: Hello World
Reversed: dlroW olleH

Count Characters

Shell
$ zig build run -- count "mississippi" "s"
Output
Shell
Character 's' appears 4 time(s) in: mississippi

Analyze File

Shell
$ zig build run -- analyze sample.txt
Output
Shell
File: sample.txt
  Lines: 7
  Words: 51
  Characters: 336
  ASCII only: true

Running Demo Step

Shell
$ zig build demo
Output
Shell
Original: Hello Zig!
Reversed: !giZ olleH
Character 's' appears 4 time(s) in: mississippi

Executes 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

Shell
$ zig build-exe src/main.zig --name textkit-cli --pkg-begin textkit src/textkit.zig --pkg-end

This 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

Shell
$ zig build

This declarative command:

  • Executes build.zig to construct a dependency graph
  • Caches artifacts and skips unchanged steps
  • Parallelizes independent compilations
  • Supports user customization via -D flags
  • 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.zig provides unified public API
  • Test co-location: Tests live next to implementation for maintainability

20

Build Script Patterns

  • Standard options first: Always start with standardTargetOptions() and standardOptimizeOption()
  • 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

22

CLI Design Patterns

  • Subcommand dispatch: Central router delegates to handler functions
  • Graceful degradation: Usage messages for invalid input
  • Resource cleanup: defer ensures allocator and file handle cleanup
  • Library separation: All logic in library, CLI is thin wrapper

Exercises

  • Add a new subcommand trim that removes leading/trailing whitespace from text, implementing the function in string_utils.zig with 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-batch that processes multiple files in parallel using threads, sharing the same library module. 37
  • Add a custom build step bench that runs performance benchmarks on StringUtils.reverse with various input sizes.

Notes & Caveats

  • The static library (.a file) 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 to zig-out/ by default; override with .prefix option for custom installation paths.
  • Tests in main.zig typically 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_file in ExecutableOptions; always use root_module wrapper as shown here.
  • For C interop scenarios, you’d add lib.linkLibC() and potentially generate headers with lib.addCSourceFile() plus installHeader().
  • Large projects might split build.zig into helper functions or separate files included via @import("build_helpers.zig")—the build script is regular Zig code.

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.