Skip to content

Build a CLI Calculator

In this project, we will build a small command-line calculator.

In this project, we will build a small command-line calculator.

The program will read an expression from the command line, parse it, compute the result, and print the answer.

We will start with simple expressions like this:

zig build run -- "1 + 2"

Output:

3

Then:

zig build run -- "10 - 4"

Output:

6

And:

zig build run -- "6 * 7"

Output:

42

This is a small project, but it teaches several important Zig ideas:

You will read command-line arguments.

You will work with slices.

You will parse text.

You will return errors.

You will use try.

You will separate code into small functions.

You will build a real executable with build.zig.

The Goal

Our calculator will support four operators:

+
-
*
/

It will accept expressions with this simple shape:

number operator number

Examples:

1 + 2
10 - 3
4 * 5
20 / 4

For now, we will not support parentheses, operator precedence, or long expressions like:

1 + 2 * 3

That will come later. A good first project should have a small shape. We want to finish a complete working program before adding more features.

Create the Project

Create a new folder:

mkdir cli-calculator
cd cli-calculator

Then initialize a Zig executable project:

zig init

You should now have files similar to:

build.zig
src/main.zig

The file src/main.zig is where our program starts.

First Version

Open src/main.zig and replace its contents with this:

const std = @import("std");

pub fn main() !void {
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &stdout_writer.interface;

    try stdout.print("CLI Calculator\n", .{});
    try stdout.flush();
}

Run it:

zig build run

You should see:

CLI Calculator

This does not calculate anything yet. It only proves that the project builds and runs.

Reading Command-Line Arguments

A command-line program receives arguments from the shell.

When you run:

zig build run -- "1 + 2"

The string "1 + 2" is passed to your program as an argument.

Update src/main.zig:

const std = @import("std");

pub fn main() !void {
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &stdout_writer.interface;

    var stderr_buffer: [1024]u8 = undefined;
    var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
    const stderr = &stderr_writer.interface;

    var args = try std.process.argsWithAllocator(std.heap.page_allocator);
    defer args.deinit();

    _ = args.next();

    const expression = args.next() orelse {
        try stderr.print("usage: calc \"number operator number\"\n", .{});
        try stderr.flush();
        return;
    };

    try stdout.print("expression: {s}\n", .{expression});
    try stdout.flush();
}

Run:

zig build run -- "1 + 2"

Output:

expression: 1 + 2

Now the program can read input.

This line skips the program name:

_ = args.next();

The first command-line argument is usually the program path. We do not need it, so we discard it.

This line reads the actual expression:

const expression = args.next() orelse {
    try stderr.print("usage: calc \"number operator number\"\n", .{});
    try stderr.flush();
    return;
};

args.next() returns an optional value. It may return an argument, or it may return null if there is no argument.

The orelse block handles the missing-argument case.

Splitting the Expression

Our expression has three parts:

1 + 2

That means:

left number: 1
operator: +
right number: 2

We can split the string by spaces.

Add this helper function above main:

fn parseExpression(expression: []const u8) !void {
    var parts = std.mem.splitScalar(u8, expression, ' ');

    const left_text = parts.next() orelse return error.InvalidExpression;
    const op_text = parts.next() orelse return error.InvalidExpression;
    const right_text = parts.next() orelse return error.InvalidExpression;

    if (parts.next() != null) {
        return error.InvalidExpression;
    }

    _ = left_text;
    _ = op_text;
    _ = right_text;
}

This function does not calculate yet. It only checks that the expression has exactly three pieces.

Now update main to call it:

try parseExpression(expression);
try stdout.print("valid expression\n", .{});

Full file:

const std = @import("std");

fn parseExpression(expression: []const u8) !void {
    var parts = std.mem.splitScalar(u8, expression, ' ');

    const left_text = parts.next() orelse return error.InvalidExpression;
    const op_text = parts.next() orelse return error.InvalidExpression;
    const right_text = parts.next() orelse return error.InvalidExpression;

    if (parts.next() != null) {
        return error.InvalidExpression;
    }

    _ = left_text;
    _ = op_text;
    _ = right_text;
}

pub fn main() !void {
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &stdout_writer.interface;

    var stderr_buffer: [1024]u8 = undefined;
    var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
    const stderr = &stderr_writer.interface;

    var args = try std.process.argsWithAllocator(std.heap.page_allocator);
    defer args.deinit();

    _ = args.next();

    const expression = args.next() orelse {
        try stderr.print("usage: calc \"number operator number\"\n", .{});
        try stderr.flush();
        return;
    };

    parseExpression(expression) catch {
        try stderr.print("invalid expression\n", .{});
        try stderr.flush();
        return;
    };

    try stdout.print("valid expression\n", .{});
    try stdout.flush();
}

Run:

zig build run -- "1 + 2"

Output:

valid expression

Run:

zig build run -- "1 +"

Output:

invalid expression

Now we have basic validation.

Returning a Parsed Expression

Instead of returning void, parseExpression should return useful data.

Create a struct:

const Expression = struct {
    left: i64,
    operator: u8,
    right: i64,
};

This struct stores the parsed form of the expression.

Now change parseExpression:

fn parseExpression(expression: []const u8) !Expression {
    var parts = std.mem.splitScalar(u8, expression, ' ');

    const left_text = parts.next() orelse return error.InvalidExpression;
    const op_text = parts.next() orelse return error.InvalidExpression;
    const right_text = parts.next() orelse return error.InvalidExpression;

    if (parts.next() != null) {
        return error.InvalidExpression;
    }

    if (op_text.len != 1) {
        return error.InvalidOperator;
    }

    const left = try std.fmt.parseInt(i64, left_text, 10);
    const right = try std.fmt.parseInt(i64, right_text, 10);

    return Expression{
        .left = left,
        .operator = op_text[0],
        .right = right,
    };
}

This line parses the left number:

const left = try std.fmt.parseInt(i64, left_text, 10);

The type is i64, which is a signed 64-bit integer.

The final argument, 10, means base 10.

So this parses normal decimal numbers like:

123
-50
0

Evaluating the Expression

Now we need a function that computes the result.

Add this:

fn evaluate(expr: Expression) !i64 {
    return switch (expr.operator) {
        '+' => expr.left + expr.right,
        '-' => expr.left - expr.right,
        '*' => expr.left * expr.right,
        '/' => {
            if (expr.right == 0) {
                return error.DivisionByZero;
            }

            return @divTrunc(expr.left, expr.right);
        },
        else => error.InvalidOperator,
    };
}

This function takes an Expression and returns either an i64 result or an error.

The division case is special because division by zero is invalid.

if (expr.right == 0) {
    return error.DivisionByZero;
}

For integer division, we use:

@divTrunc(expr.left, expr.right)

This divides two integers and truncates the result toward zero.

So:

7 / 2 = 3
-7 / 2 = -3

This is integer arithmetic, not floating-point arithmetic.

The Complete Program

Here is the complete version:

const std = @import("std");

const Expression = struct {
    left: i64,
    operator: u8,
    right: i64,
};

fn parseExpression(expression: []const u8) !Expression {
    var parts = std.mem.splitScalar(u8, expression, ' ');

    const left_text = parts.next() orelse return error.InvalidExpression;
    const op_text = parts.next() orelse return error.InvalidExpression;
    const right_text = parts.next() orelse return error.InvalidExpression;

    if (parts.next() != null) {
        return error.InvalidExpression;
    }

    if (op_text.len != 1) {
        return error.InvalidOperator;
    }

    const left = try std.fmt.parseInt(i64, left_text, 10);
    const right = try std.fmt.parseInt(i64, right_text, 10);

    return Expression{
        .left = left,
        .operator = op_text[0],
        .right = right,
    };
}

fn evaluate(expr: Expression) !i64 {
    return switch (expr.operator) {
        '+' => expr.left + expr.right,
        '-' => expr.left - expr.right,
        '*' => expr.left * expr.right,
        '/' => {
            if (expr.right == 0) {
                return error.DivisionByZero;
            }

            return @divTrunc(expr.left, expr.right);
        },
        else => error.InvalidOperator,
    };
}

pub fn main() !void {
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &stdout_writer.interface;

    var stderr_buffer: [1024]u8 = undefined;
    var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
    const stderr = &stderr_writer.interface;

    var args = try std.process.argsWithAllocator(std.heap.page_allocator);
    defer args.deinit();

    _ = args.next();

    const expression_text = args.next() orelse {
        try stderr.print("usage: calc \"number operator number\"\n", .{});
        try stderr.flush();
        return;
    };

    const expression = parseExpression(expression_text) catch |err| {
        try stderr.print("parse error: {s}\n", .{@errorName(err)});
        try stderr.flush();
        return;
    };

    const result = evaluate(expression) catch |err| {
        try stderr.print("calculation error: {s}\n", .{@errorName(err)});
        try stderr.flush();
        return;
    };

    try stdout.print("{d}\n", .{result});
    try stdout.flush();
}

Run it:

zig build run -- "1 + 2"

Output:

3

Run:

zig build run -- "10 - 4"

Output:

6

Run:

zig build run -- "6 * 7"

Output:

42

Run:

zig build run -- "20 / 4"

Output:

5

Run:

zig build run -- "1 / 0"

Output:

calculation error: DivisionByZero

Why We Used a Struct

The Expression struct gives a name to the shape of our data.

Without a struct, we would pass around three separate values:

left
operator
right

That works for very small code, but it becomes messy quickly.

With a struct, we can pass one value:

const result = try evaluate(expression);

This makes the code easier to read.

The struct says:

const Expression = struct {
    left: i64,
    operator: u8,
    right: i64,
};

That means every expression has exactly these three fields.

Why We Used Errors

Parsing can fail.

Evaluation can fail.

So the function types should say that clearly.

This function may fail:

fn parseExpression(expression: []const u8) !Expression

This function may also fail:

fn evaluate(expr: Expression) !i64

The ! is important. It tells the reader that the result is either a value or an error.

This is one of Zig’s strongest habits: failure is visible in the type.

Why Spaces Are Required

Our parser expects spaces:

1 + 2

This works.

But this does not:

1+2

Why?

Because we used:

std.mem.splitScalar(u8, expression, ' ')

That splits only on spaces. The string "1+2" has no spaces, so the parser sees one piece instead of three.

This limitation is acceptable for the first version. A more advanced calculator would scan the input character by character.

Add Tests

Zig has built-in tests. Add these tests at the bottom of src/main.zig:

test "parse addition expression" {
    const expr = try parseExpression("1 + 2");

    try std.testing.expectEqual(@as(i64, 1), expr.left);
    try std.testing.expectEqual(@as(u8, '+'), expr.operator);
    try std.testing.expectEqual(@as(i64, 2), expr.right);
}

test "evaluate addition" {
    const expr = Expression{
        .left = 1,
        .operator = '+',
        .right = 2,
    };

    const result = try evaluate(expr);

    try std.testing.expectEqual(@as(i64, 3), result);
}

test "division by zero fails" {
    const expr = Expression{
        .left = 1,
        .operator = '/',
        .right = 0,
    };

    try std.testing.expectError(error.DivisionByZero, evaluate(expr));
}

Run:

zig build test

Tests are useful because they let us check small pieces of the program without running the whole command-line interface manually.

Improving the Program

This calculator is intentionally simple. Here are natural next steps:

Support expressions without spaces:

1+2

Support floating-point numbers:

1.5 + 2.25

Support more than one operator:

1 + 2 + 3

Support precedence:

1 + 2 * 3

Support parentheses:

(1 + 2) * 3

Support an interactive mode:

calc> 1 + 2
3
calc> 10 / 2
5

But each of those features needs a better parser. The version in this section is a good first step because it has the same basic structure that larger programs use:

Read input.

Parse input.

Represent the parsed data.

Evaluate the data.

Print the result.

Handle errors clearly.

What You Learned

You built a complete command-line calculator.

You used command-line arguments with std.process.argsWithAllocator.

You parsed text with std.mem.splitScalar.

You converted text to integers with std.fmt.parseInt.

You modeled data with a struct.

You used switch to select behavior by operator.

You returned errors for invalid input and division by zero.

You wrote tests for the parser and evaluator.

This is the basic shape of many real command-line tools. The details change, but the structure stays the same: input, parse, process, output.