Backlog/lib/zargs/research/builder_pattern_example.md

8.0 KiB

Builder Pattern for Argument Parsing

Summary

The builder pattern uses method chaining to programmatically construct the argument parser configuration. Instead of declaring everything in a static schema or struct, you call a series of methods that each add one piece of configuration, returning the builder object so you can chain the next call.

Think of it like building with LEGO blocks - you start with a base and keep adding pieces one at a time.

Core Concept

parser = new Parser()
    .addArg(...)
    .addArg(...)
    .addArg(...)
    .parse()

Each .addArg() returns the parser object, so you can keep chaining.

Concrete Examples

Example 1: Simple CLI Tool (Rust-style with clap)

use clap::{App, Arg};

fn main() {
    let matches = App::new("MyApp")
        .version("1.0")
        .author("John Doe")
        .about("Does awesome things")
        
        .arg(Arg::new("verbose")
            .short('v')
            .long("verbose")
            .help("Enable verbose output"))
        
        .arg(Arg::new("output")
            .short('o')
            .long("output")
            .value_name("FILE")
            .help("Output file path")
            .takes_value(true)
            .required(false))
        
        .arg(Arg::new("count")
            .short('n')
            .long("count")
            .value_name("NUM")
            .help("Number of iterations")
            .takes_value(true)
            .default_value("1")
            .validator(|s| s.parse::<u32>().map(|_| ()).map_err(|_| "Must be a number")))
        
        .arg(Arg::new("config")
            .short('c')
            .long("config")
            .value_name("PATH")
            .help("Config file path")
            .takes_value(true)
            .conflicts_with("output"))
        
        .get_matches();
    
    // Use the parsed arguments
    let verbose = matches.is_present("verbose");
    let output = matches.value_of("output");
    let count: u32 = matches.value_of_t("count").unwrap();
}

Example 2: Hypothetical Zig Builder Style

const std = @import("std");
const ArgParser = @import("zargs").ArgParser;

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();
    
    // Build the parser with chained calls
    var parser = ArgParser.init(allocator)
        .name("mytool")
        .version("1.0.0")
        .description("Does awesome things")
        
        .flag("verbose")
            .short('v')
            .long("verbose")
            .help("Enable verbose output")
            .done()
        
        .option("output")
            .short('o')
            .long("output")
            .help("Output file path")
            .value_name("FILE")
            .required(false)
            .done()
        
        .option("count")
            .short('n')
            .long("count")
            .help("Number of iterations")
            .value_name("NUM")
            .default_value("1")
            .value_parser(parseU32)
            .done()
        
        .option("config")
            .short('c')
            .long("config")
            .help("Config file path")
            .value_name("PATH")
            .conflicts_with(&.{"output"})
            .done();
    
    // Parse the arguments
    const args = try parser.parse();
    
    // Access the results
    const verbose = args.getFlag("verbose");
    const output = args.getString("output");
    const count = args.getInt("count") orelse 1;
}

fn parseU32(s: []const u8) !u32 {
    return std.fmt.parseInt(u32, s, 10);
}

Example 3: Java-style with JCommander

import com.beust.jcommander.JCommander;
import com.beust.jcommander.Parameter;

public class MyApp {
    @Parameter(names = {"-v", "--verbose"}, description = "Enable verbose output")
    private boolean verbose = false;
    
    @Parameter(names = {"-o", "--output"}, description = "Output file path")
    private String output;
    
    @Parameter(names = {"-n", "--count"}, description = "Number of iterations")
    private int count = 1;
    
    public static void main(String[] args) {
        MyApp app = new MyApp();
        
        // Builder pattern for the parser itself
        JCommander commander = JCommander.newBuilder()
            .addObject(app)
            .programName("myapp")
            .build();
        
        commander.parse(args);
        
        // Use the parsed values
        System.out.println("Verbose: " + app.verbose);
        System.out.println("Output: " + app.output);
        System.out.println("Count: " + app.count);
    }
}

Example 4: C++ with cxxopts

#include <cxxopts.hpp>
#include <iostream>

int main(int argc, char* argv[]) {
    cxxopts::Options options("MyApp", "Does awesome things");
    
    // Builder pattern for adding options
    options
        .add_options()
            ("v,verbose", "Enable verbose output")
            ("o,output", "Output file path", 
                cxxopts::value<std::string>())
            ("n,count", "Number of iterations", 
                cxxopts::value<int>()->default_value("1"))
            ("c,config", "Config file path",
                cxxopts::value<std::string>())
            ("h,help", "Print help");
    
    auto result = options.parse(argc, argv);
    
    if (result.count("help")) {
        std::cout << options.help() << std::endl;
        return 0;
    }
    
    bool verbose = result["verbose"].as<bool>();
    std::string output = result["output"].as<std::string>();
    int count = result["count"].as<int>();
}

Key Characteristics

Fluent Interface

Each method returns self (or the builder) so you can chain:

builder.method1().method2().method3()

Incremental Construction

Build up the configuration step by step:

var parser = ArgParser.init(allocator);
parser = parser.name("mytool");
parser = parser.version("1.0");
// ... etc

Nested Builders

Often there's a hierarchy:

parser
    .option("output")      // Start building an option
        .short('o')         // Configure the option
        .long("output")     // More config
        .help("...")        // More config
        .done()             // Return to parent parser
    .option("count")        // Start next option
        .short('n')
        .done()

Advantages for Zig

  1. No macros needed - Pure runtime construction
  2. Conditional arguments - Easy to add args based on runtime conditions:
    var parser = ArgParser.init(allocator);
    if (enable_debug_features) {
        parser = parser.flag("trace").help("Enable tracing").done();
    }
    
  3. Type-safe - Compiler checks method calls
  4. Readable - Sequential, easy to follow
  5. Still generates help - All metadata collected during building

Disadvantages

  1. Verbose - More code than declarative style
  2. Boilerplate - Lots of repeated method calls
  3. No compile-time validation - Errors happen at runtime
  4. Memory overhead - Must allocate storage for builder state

When to Use

  • When you need runtime flexibility in argument definition
  • When you want good help generation but can't use macros/comptime
  • When arguments depend on configuration or conditional compilation
  • When you prefer explicit, procedural code over declarative schemas

Comparison to Other Styles

Feature Builder Declarative Ad-hoc
Help generation Good Excellent Poor
Flexibility Good Poor Excellent
Verbosity ⚠️ Moderate Low Very Low
Runtime overhead ⚠️ Moderate ⚠️ Moderate Minimal
Type safety Good Excellent Poor

Builder Pattern in Zig Context

Zig could make this pattern very clean with:

  • Method chaining (returning *Self)
  • Comptime validation of method call sequences
  • Tagged unions for storing different arg types
  • Allocator control for builder state

The sweet spot might be a builder pattern that's mostly runtime but validates at comptime when possible.