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:
3Then:
zig build run -- "10 - 4"Output:
6And:
zig build run -- "6 * 7"Output:
42This 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 numberExamples:
1 + 2
10 - 3
4 * 5
20 / 4For now, we will not support parentheses, operator precedence, or long expressions like:
1 + 2 * 3That 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-calculatorThen initialize a Zig executable project:
zig initYou should now have files similar to:
build.zig
src/main.zigThe 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 runYou should see:
CLI CalculatorThis 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 + 2Now 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 + 2That means:
left number: 1
operator: +
right number: 2We 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 expressionRun:
zig build run -- "1 +"Output:
invalid expressionNow 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
0Evaluating 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 = -3This 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:
3Run:
zig build run -- "10 - 4"Output:
6Run:
zig build run -- "6 * 7"Output:
42Run:
zig build run -- "20 / 4"Output:
5Run:
zig build run -- "1 / 0"Output:
calculation error: DivisionByZeroWhy 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
rightThat 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) !ExpressionThis function may also fail:
fn evaluate(expr: Expression) !i64The ! 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 + 2This works.
But this does not:
1+2Why?
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 testTests 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+2Support floating-point numbers:
1.5 + 2.25Support more than one operator:
1 + 2 + 3Support precedence:
1 + 2 * 3Support parentheses:
(1 + 2) * 3Support an interactive mode:
calc> 1 + 2
3
calc> 10 / 2
5But 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.