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
casestatements
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
flagpackage - 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
- Best: Type-driven, Declarative schema, Builder
- Good: Subcommand-oriented, Context-based
- Poor: Ad-hoc, Streaming
Flexibility
- Best: Ad-hoc, Context-based
- Good: Builder, Parser combinators
- Poor: Type-driven, Declarative schema
Performance
- Best: Ad-hoc, Streaming
- Good: All others (negligible difference for most uses)
Ease of Use (Simple Cases)
- Best: Type-driven, Declarative
- Good: Builder
- Poor: Parser combinators, Ad-hoc
Ease of Use (Complex Cases)
- Best: Parser combinators, Context-based
- Good: Builder, Subcommand
- 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"
- Allow scattered parsing for flexibility
- But require registration in a central registry
- Registry generates help automatically
- 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
- How to handle argument conflicts between modules?
- Should we support subcommands natively?
- How to integrate with existing Zig std.process.args()?
- Should we generate shell completions?
- How to handle environment variables?
- Do we need config file integration?
- What's the story for validation (ranges, enums, etc.)?
Next Steps
- Prototype the comptime registration system
- Design the help generation format
- Create examples for common use cases
- Benchmark different approaches
- Get community feedback