Introduction Link to heading
In this series of posts we are going to implement a basic weather CLI using Zig and tomorrow.io. The final result will take in a location and return weather data and format it with colors and emojis.
Note: The final code for this series is complete and lives in the git repo here. Feel free to take a look and if you want to look at the code yourself
1. Setup and Dependencies Link to heading
We are going to use the following packages:
- Zig v0.14.0
- It’s the current stable release of Zig
- Chameleon
- This will give us terminal styling for our text output
- Zig-Clap
- An argument parser that will allow us to pass flags into our program to feed into our API call
- Tomorrow.io API Access
- This is the API that will provide our weather data. It is free to use and get started with!
We are going to start in a new directory called zweather
and run our zig init
command to generate the boilerplate.
mkdir zweather
cd zweather
zig init
Before we do anything else we are going to fetch our two packages and set them up into the Zig build system.
zig fetch --save https://github.com/Hejsil/zig-clap/archive/refs/tags/0.10.0.tar.gz
zig fetch --save git+https://github.com/tr1ckydev/chameleon
You may notice that the way to fetch these packages is slightly different between zig-clap
and chameleon
. zig-clap
offers releases that target releases of Zig and so we will target that by fetching the archive from the GitHub repo. For chameleon
, they currently do not publish releases so instead we pass in the git+
to the start of our call and point directly to the repo. This will target the primary branch of chameleon, which should be fine as of the time of writing this.
The two commands above will bring our imports into our project and place the references to them inside our build.zig.zon
file.
.{
.name = .zweather,
.version = "0.0.0",
.fingerprint = 0x890e5e21f0a2f4b6, // Changing this has security and trust implications.
.minimum_zig_version = "0.14.0",
.dependencies = .{
.chameleon = .{
.url = "git+https://github.com/tr1ckydev/chameleon#9724f89ca2e56b33609090a6c6c6c1097844d1ee",
.hash = "12208314a762057bf5ed82b26b02cbfcf426066b9920bfdd1ddcb68d1d0c55c45ce3",
},
.clap = .{
.url = "git+https://github.com/Hejsil/zig-clap#068c38f89814079635692c7d0be9f58508c86173",
.hash = "1220ff14a53e9a54311c9b0e665afeda2c82cfd6f57e7e1b90768bf13b85f9f29cd0",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}
There are only two more steps to tackle now. We want to:
- Add packages to
build.zig
- Clean up our repo and do a test import for both packages
The default build.zig
can be a bit loud but the important lines to add here are the following:
// LOOK TO PUT THIS AFTER `const exe` is defined
const cham = b.dependency("chameleon", .{});
exe.root_module.addImport("chameleon", cham.module("chameleon"));
const clap = b.dependency("clap", .{});
exe.root_module.addImport("clap", clap.module("clap"));
While we are in build.zig
you can clean up any references to root.zig
and remove them. Typically when you run zig init
it will setup both for an executable AND a library. We are building an executable and main.zig
is our entry as opposed to root.zig
so we will remove it from the build file. Additionally we can delete the root.zig
file, but it’s not something we need to do since it will now be ignored by the zig build
command. The resulting build.zig
should look a little like this.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "zweather",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const cham = b.dependency("chameleon", .{});
exe.root_module.addImport("chameleon", cham.module("chameleon"));
const clap = b.dependency("clap", .{});
exe.root_module.addImport("clap", clap.module("clap"));
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
const exe_unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_exe_unit_tests.step);
}
If you run a zig build
it should complete without any issues by this point. The final bit we need to checkout is that now these imports are working. Head into main.zig
and delete everything in that file. We are simply going to import the 2 packages and print out some basic checkout.
const std = @import("std");
const Chameleon = @import("chameleon");
const clap = @import("clap");
pub fn main() !void {
std.debug.print("Testing our imports");
}
You can execute a zig build run
and you should see the message “Testing our imports”. We did it! We are setup and ready to tackle the business logic of our CLI!
Constructing Our Application Link to heading
Let’s think about the thing we know we are going to need in order for this to work.
- A way to make http requests to an API in Zig
- The structure of the return data from the API call
- A way to parse the return data into the structure above
- Handling of dynamic memory allocation that takes place throughout our app
Let’s start with the basics and setup our CLI with clap and our text styling.
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var c = Chameleon.initRuntime(.{ .allocator = allocator });
defer c.deinit();
const params = comptime clap.parseParamsComptime(
\\-h, --help Display this help and exit
\\-l, --location <str> City to lookup weather (ex. New York City)
\\-u, --units <str> Which units to use (metric vs imperial)
);
var diag = clap.Diagnostic{};
var res = clap.parse(clap.Help, ¶ms, clap.parsers.default, .{
.diagnostic = &diag,
.allocator = allocator,
}) catch |err| {
diag.report(std.io.getStdErr().writer(), err) catch {};
return err;
};
defer res.deinit();
if (res.args.help != 0) {
return clap.help(std.io.getStdErr().writer(), clap.Help, ¶ms, .{});
}
const location = res.args.location orelse "New York City";
const units = res.args.units orelse "imperial";
std.debug.print("Location is: {s}\nUnits is: {s}\n", .{location, units});
}
We are doing a few things here. First we initialize our memory allocator which is used by Chameleon to initialize itself at runtime. We are using an ArenaAllocator
to follow the pattern described inside the Zig reference documentation about creating CLIs.
Additionally we setup clap with our flags we are going to use. In the case of our weather CLI we are interested in the location to look up and the type of units (metric vs. imperial) to display to our users. We then parse those arguments and store them off into variables to be used by our application. The above example should output the following.
zweather on master [!] via ↯ v0.14.0
⬢ [Docker] ❯ zig build run -- -l "new york" -u "imperial"
Location is: new york
Units is: imperial
This works great! We now the ability to pass arguments to the program, and use them to make a GET request to the tomorrow.io API. If you haven’t already, head over to the developer site and get your API token. We don’t want to share this out so we will opt instead to store it as an environment variable and fetch it from our program at runtime.
Creating state for our application Link to heading
Before we move into the http request, I want to step back and think about tracking state in our application. We have a few things we need to track for the lifetime of the CLI such as the location, units, our allocator, chameleon runtime, and our API key. To do this we are going to create an App
structure which will hold this information for us. This way we can pass it around to our different functions and keep track of the current state of our application.
At the top of main.zig
add the following declaration:
pub const App = struct {
apiKey: []u8,
allocator: std.mem.Allocator,
units: []const u8,
c: *Chameleon.RuntimeChameleon,
location: []const u8,
};
We then are going to modify our main function to make use of this.
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
const apiKey = std.process.getEnvVarOwned(allocator, "TOMORROW_API_KEY") catch {
std.log.info("API Key for tomorrow.io not found. Please set the environment variable \"TOMORROW_API_KEY\" to your tomorrow.io API key", .{});
std.process.exit(1);
};
var c = Chameleon.initRuntime(.{ .allocator = allocator });
defer c.deinit();
const params = comptime clap.parseParamsComptime(
\\-h, --help Display this help and exit
\\-l, --location <str> City to lookup weather (ex. New York City)
\\-u, --units <str> Which units to use (metric vs imperial)
);
var diag = clap.Diagnostic{};
var res = clap.parse(clap.Help, ¶ms, clap.parsers.default, .{
.diagnostic = &diag,
.allocator = allocator,
}) catch |err| {
diag.report(std.io.getStdErr().writer(), err) catch {};
return err;
};
defer res.deinit();
if (res.args.help != 0) {
return clap.help(std.io.getStdErr().writer(), clap.Help, ¶ms, .{});
}
const location = res.args.location orelse "New York City";
const units = res.args.units orelse "imperial";
const app = App{
.apiKey = apiKey,
.allocator = allocator,
.units = units,
.location = location,
.c = &c,
};
std.debug.print("API Key: {s}\n", .{app.apiKey});
std.debug.print("Units: {s}\n", .{app.units});
std.debug.print("Location: {s}\n", .{app.location});
}
The resulting output should look a little like this
zweather on master [!?] via ↯ v0.14.0
⬢ [Docker] ❯ zig build run -- -l "new york" -u "imperial"
API Key: <your-api-key>
Units: imperial
Location: new york
If you do not have your API key set, go ahead and do it now (i.e. export TOMORROW_API_KEY='<key here>'
). If you do not have it specified, you should see the error message we added show up! Our app is at a great place now to move on to the http request!
End Of Part 1 Link to heading
In part 2 we will set up the http requests using the built in http module and building our response structure along with the JSON module to get the response into a structure we can use within Zig. I hope you enjoyed part 1 of this series. If you want to keep in touch I have my socials listed on the homepage, and you can subscribe to my RSS feed