Backlog/lib/zargs/research/design.md

9.6 KiB

Argument Parser Design Research

Existing Paradigms

1. Ad-hoc / Scattered Parser (Game Engine Style)

Description: argv is passed around the program, and individual subsystems parse what they need on-the-spot using simple string matching or helper functions.

Examples:

  • Many game engines (UE, Unity command-line tools)
  • Simple C programs with strcmp() loops
  • Shell scripts with case statements

Pros:

  • Extremely simple to implement
  • Zero overhead - no framework needed
  • Very flexible - anyone can add arguments anywhere
  • Scales well with codebase size
  • Perfect for plugin architectures
  • No initialization order dependencies
  • Easy to add temporary debug flags

Cons:

  • No automatic help generation
  • No validation of argument conflicts
  • Typos go unnoticed (silent failures)
  • Hard to audit what arguments exist
  • No standardization across modules
  • Duplicate parsing code everywhere
  • Hard to maintain consistency

Use Cases:

  • Large codebases with many contributors
  • Plugin/module systems
  • Debug/development builds with experimental flags
  • When flexibility > user experience

2. Declarative Schema Parser (argparse / Builder Style)

Description: Define all arguments upfront in a schema/configuration, then parse once. The parser uses this schema to validate and generate help. This includes both declarative schemas (Python argparse) and builder patterns (Rust clap's builder API, cxxopts) - both require assembling the complete argument specification before parsing.

Examples:

  • Python's argparse
  • Rust's clap (builder API with .arg() chaining)
  • Go's flag package
  • Node.js commander / yargs
  • C++ cxxopts
  • Java JCommander

Pros:

  • Excellent help generation
  • Centralized documentation
  • Validation built-in (types, conflicts, requirements)
  • IDE autocomplete for defined args
  • Can generate man pages, shell completions
  • User-friendly error messages
  • Clear contract of what's supported

Cons:

  • All arguments must be known at startup
  • Harder to add plugin-specific arguments
  • More boilerplate for simple cases
  • Initialization overhead
  • Tight coupling between parser and business logic
  • Can become verbose for complex scenarios

Use Cases:

  • CLI tools with stable interfaces
  • Public-facing user applications
  • When documentation is critical
  • Standard Unix-style utilities

3. Type-Driven Parser (Compile-Time)

Description: Define arguments through struct fields with annotations/attributes. Parser reflects on types to derive behavior.

Examples:

  • Rust's clap (derive macro): #[derive(Parser)]
  • Rust's structopt (now merged into clap)
  • Zig's potential with comptime reflection
  • Haskell's optparse-applicative

Pros:

  • Minimal boilerplate
  • Type safety enforced at compile time
  • Help generated from struct
  • Arguments become regular struct fields
  • Documentation co-located with types
  • Compile errors for invalid configs

Cons:

  • Limited to languages with strong metaprogramming
  • Less dynamic - can't add args at runtime
  • Learning curve for annotations
  • Magic can be hard to debug
  • Inflexible for plugin architectures

Use Cases:

  • Type-safe languages with good metaprogramming
  • When compile-time guarantees are valuable
  • Static CLI tools

4. Subcommand-Oriented Parser (Git-Style)

Description: Hierarchical commands where each subcommand has its own parser. Think git commit, git push, etc.

Examples:

  • Git
  • Docker CLI
  • Kubernetes kubectl
  • Cargo

Pros:

  • Natural organization for complex tools
  • Each subcommand isolated
  • Easy to add new subcommands
  • Clear mental model for users
  • Help can be hierarchical

Cons:

  • Overkill for simple tools
  • More complex routing logic
  • Harder to share common flags
  • Can fragment the interface too much

Use Cases:

  • Multi-function tools (package managers, version control)
  • When functionality naturally groups
  • Large CLI applications

5. Context-Based Parser (Implicit State)

Description: Parser maintains context/state that different parts of the program query, often with defaults and cascading priorities.

Examples:

  • Configuration systems (environment vars → config files → CLI args)
  • Viper (Go)
  • Click (Python) with context objects

Pros:

  • Unified configuration from multiple sources
  • Priorities handled automatically
  • Can layer defaults elegantly
  • Good for complex applications
  • Handles environment variables naturally

Cons:

  • Global state can be problematic
  • Hard to reason about precedence
  • Testing becomes harder
  • Implicit behavior can surprise users

Use Cases:

  • Applications with multiple config sources
  • When env vars and files matter as much as CLI args
  • Complex deployment scenarios

6. Parser Combinators (Functional Style)

Description: Build complex parsers by composing smaller parser functions. Very flexible but requires functional thinking.

Examples:

  • Haskell's optparse-applicative
  • Some functional-style libraries in Scala, OCaml

Pros:

  • Extremely composable
  • Very expressive for complex scenarios
  • Reusable parser pieces
  • Elegant in functional languages
  • Can still generate help

Cons:

  • Steep learning curve
  • Verbose for simple cases
  • Requires functional programming mindset
  • Can be overkill

Use Cases:

  • Functional programming languages
  • When you need maximum composability
  • Complex parsing logic

7. Streaming/Event Parser

Description: Parse arguments as a stream of events, allowing handlers to react to each argument in sequence.

Examples:

  • SAX-style XML parsing applied to arguments
  • Some minimal C libraries

Pros:

  • Memory efficient
  • Can short-circuit early
  • Good for very large argument lists
  • Handlers decoupled

Cons:

  • Awkward programming model
  • Hard to validate dependencies between args
  • No natural help generation
  • Uncommon pattern

Use Cases:

  • Embedded systems with memory constraints
  • Processing huge argument lists
  • Rare in practice

Comparative Analysis

Documentation Quality

  1. Best: Type-driven, Declarative schema, Builder
  2. Good: Subcommand-oriented, Context-based
  3. Poor: Ad-hoc, Streaming

Flexibility

  1. Best: Ad-hoc, Context-based
  2. Good: Builder, Parser combinators
  3. Poor: Type-driven, Declarative schema

Performance

  1. Best: Ad-hoc, Streaming
  2. Good: All others (negligible difference for most uses)

Ease of Use (Simple Cases)

  1. Best: Type-driven, Declarative
  2. Good: Builder
  3. Poor: Parser combinators, Ad-hoc

Ease of Use (Complex Cases)

  1. Best: Parser combinators, Context-based
  2. Good: Builder, Subcommand
  3. Poor: Ad-hoc

Hybrid Approaches

Several modern parsers combine paradigms:

1. Layered Parser

  • Core declarative schema for main arguments
  • Extensibility hooks for plugins to register additional args
  • Best of both worlds: good docs + flexibility

2. Two-Pass Parser

  • First pass: lightweight scan for special flags (e.g., --help, --version)
  • Second pass: full validation and parsing
  • Common in practice

3. Schema + Callback

  • Define schema for structure and docs
  • Callbacks for complex custom validation
  • Used by many mature libraries

Recommendations for Zig

Given Zig's philosophy and strengths, here are some architectural considerations:

Leverage Comptime

Zig's compile-time execution is powerful. A type-driven approach using struct tags could work well:

const Args = struct {
    verbose: bool = false,
    output: ?[]const u8 = null,
    count: u32 = 1,
    
    pub const meta = .{
        .verbose = .{ .short = 'v', .help = "Enable verbose output" },
        .output = .{ .short = 'o', .help = "Output file path" },
        .count = .{ .short = 'n', .help = "Number of iterations" },
    };
};

Hybrid Design: "Structured Ad-hoc"

  1. Allow scattered parsing for flexibility
  2. But require registration in a central registry
  3. Registry generates help automatically
  4. Get both flexibility AND documentation
pub const ArgParser = struct {
    registry: Registry,
    argv: [][]const u8,
    
    pub fn register(comptime name: []const u8, comptime T: type, comptime opts: Options) void {
        // Register at comptime
    }
    
    pub fn parse(self: *ArgParser, comptime name: []const u8) ?T {
        // Parse on demand, but from registered args only
    }
    
    pub fn generateHelp(self: *ArgParser) []const u8 {
        // Use registry to generate
    }
};

Module-Scoped Parsers

Each module gets its own parser instance but they all feed into a global registry:

// In physics module
const args = ArgParser.forModule("physics");
const use_simd = args.parse("use_simd", bool, .{ .default = true });

// In renderer module  
const args = ArgParser.forModule("renderer");
const vsync = args.parse("vsync", bool, .{ .default = true });

// Global help combines all modules

This approach:

  • Maintains scattered parsing flexibility
  • Generates comprehensive help
  • Zig-idiomatic (comptime for registration)
  • Scales to large codebases
  • No runtime overhead if help not requested

Open Questions

  1. How to handle argument conflicts between modules?
  2. Should we support subcommands natively?
  3. How to integrate with existing Zig std.process.args()?
  4. Should we generate shell completions?
  5. How to handle environment variables?
  6. Do we need config file integration?
  7. What's the story for validation (ranges, enums, etc.)?

Next Steps

  1. Prototype the comptime registration system
  2. Design the help generation format
  3. Create examples for common use cases
  4. Benchmark different approaches
  5. Get community feedback