Skip to content

A Command-Line Parser

Many programs begin the same way: they read command-line arguments, decide what the user requested, then execute an operation.

Many programs begin the same way: they read command-line arguments, decide what the user requested, then execute an operation.

A command-line parser converts raw argument strings into structured program state.

Suppose we want a small utility named calc. It accepts two subcommands:

calc add 10 20
calc mul 4 5

The first version can be written directly.

const std = @import("std");

pub fn main() !void {
    var args = std.process.args();

    _ = args.next();

    const command = args.next() orelse {
        std.debug.print("missing command\n", .{});
        return;
    };

    const a_text = args.next() orelse {
        std.debug.print("missing first number\n", .{});
        return;
    };

    const b_text = args.next() orelse {
        std.debug.print("missing second number\n", .{});
        return;
    };

    const a = try std.fmt.parseInt(i32, a_text, 10);
    const b = try std.fmt.parseInt(i32, b_text, 10);

    if (std.mem.eql(u8, command, "add")) {
        std.debug.print("{d}\n", .{a + b});
    } else if (std.mem.eql(u8, command, "mul")) {
        std.debug.print("{d}\n", .{a * b});
    } else {
        std.debug.print("unknown command: {s}\n", .{command});
    }
}

Run it:

zig run main.zig -- add 10 20

The output is:

30

The -- separates Zig’s own options from the program’s arguments.

The function:

std.process.args()

returns an iterator over command-line arguments.

The first argument is normally the executable path. This program ignores it:

_ = args.next();

The underscore assignment means: evaluate the value, but discard it.

Arguments are read one at a time:

const command = args.next() orelse {
    ...
};

args.next() returns an optional slice:

?[]const u8

If no argument exists, the value is null.

The orelse expression handles the null case immediately.

Strings in Zig are byte slices:

[]const u8

To compare strings, Zig uses library functions:

std.mem.eql(u8, command, "add")

This compares two slices byte-by-byte.

The arguments "10" and "20" are text. They must be converted into integers:

const a = try std.fmt.parseInt(i32, a_text, 10);

The arguments are:

ArgumentMeaning
i32destination type
a_textsource text
10numeric base

If parsing fails, parseInt returns an error. try propagates the error to the caller.

The structure of this program is common in Zig:

  1. Read raw input.
  2. Validate it early.
  3. Convert text into typed values.
  4. Dispatch using explicit control flow.
  5. Report errors directly.

As the number of commands grows, nested if expressions become awkward.

An enum gives the commands names.

const Command = enum {
    add,
    mul,
};

Now parsing can be separated from execution.

const std = @import("std");

const Command = enum {
    add,
    mul,
};

fn parseCommand(text: []const u8) !Command {
    if (std.mem.eql(u8, text, "add")) {
        return .add;
    }

    if (std.mem.eql(u8, text, "mul")) {
        return .mul;
    }

    return error.UnknownCommand;
}

pub fn main() !void {
    var args = std.process.args();

    _ = args.next();

    const command_text = args.next() orelse {
        return error.MissingCommand;
    };

    const a_text = args.next() orelse {
        return error.MissingArgument;
    };

    const b_text = args.next() orelse {
        return error.MissingArgument;
    };

    const command = try parseCommand(command_text);

    const a = try std.fmt.parseInt(i32, a_text, 10);
    const b = try std.fmt.parseInt(i32, b_text, 10);

    switch (command) {
        .add => {
            std.debug.print("{d}\n", .{a + b});
        },
        .mul => {
            std.debug.print("{d}\n", .{a * b});
        },
    }
}

This version divides the program into smaller operations:

FunctionResponsibility
parseCommandconvert text into an enum
maincoordinate program flow
parseIntconvert numbers
switchdispatch execution

This style scales well.

A larger program may add:

  • flags such as --verbose
  • optional arguments
  • configuration files
  • subcommands
  • environment variables

The core structure remains the same: parse first, execute later.

Exercise 20-1. Add a sub command.

Exercise 20-2. Print a usage message when arguments are missing.

Exercise 20-3. Add support for hexadecimal input.

Exercise 20-4. Add a --help flag.

Exercise 20-5. Move argument parsing into a separate file.