Converting Relative to Absolute Path in Zig

const std = @import("std");

const c = @cImport({
    @cInclude("stdlib.h");
    @cInclude("errno.h");
});

extern var errno: c_int;

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const args = try std.process.argsAlloc(allocator);

    if (args.len != 2) {
        std.debug.print("Usage: {s} <relative-path>\n", .{args[0]});
        return error.InvalidArgs;
    }

    const inputPath = args[1];

    var resolvedBuffer: [std.fs.max_path_bytes]u8 = undefined;

    const resolvedPath = c.realpath(inputPath, &resolvedBuffer);
    if (resolvedPath == null) {
        const err = errno;
        std.debug.print("realpath() failed with errno {}\n", .{err});
        return error.RealpathFailed;
    }

    const absPath = std.mem.span(resolvedPath);
    std.debug.print("C: {s}\n", .{absPath});

    const absolutePath = std.fs.realpathAlloc(allocator, inputPath) catch |err| {
        std.debug.print("Cannot resolve absolute path: {s}\n", .{@errorName(err)});
        return;
    };
    defer allocator.free(absolutePath);
    std.debug.print("Zig: {s}\n", .{absolutePath});
}

This Zig program is a small but insightful command-line tool that demonstrates two different ways to resolve a relative path into its absolute (canonical) form on UNIX-like systems: one using the classic C library function realpath() and the other using Zig’s own standard library facilities. When run, the program expects exactly one argument—a file or directory path—and prints that path in its fully resolved absolute form twice: once obtained via the C interface and once via pure Zig code, allowing the reader to compare both approaches side-by-side.

The program begins by importing Zig’s standard library (std) and selectively bringing in two C headers (stdlib.h and errno.h) through @cImport and @cInclude, which lets Zig call C functions directly. It also declares the global errno variable as an extern so it can inspect C library error codes. In main(), it grabs a page allocator and collects the command-line arguments. If the user doesn’t supply exactly one path argument, it prints a helpful usage message and exits gracefully.

The core work happens next. First, it creates a fixed-size buffer large enough to hold the longest possible path on the system (std.fs.max_path_bytes). It then calls the C function realpath() from <stdlib.h>, passing the user-provided relative path and the address of that buffer. If realpath() returns null, something went wrong (e.g., the path doesn’t exist or contains too many symlinks), so the program reads the global errno, prints a clear error message, and aborts. When successful, realpath() returns a pointer into the buffer containing a null-terminated string; std.mem.span safely converts that pointer into a Zig slice, and the absolute path obtained the “C way” is printed.

Immediately afterward, the program does the same job using Zig’s native filesystem utilities with std.fs.realpathAlloc. This function allocates memory for the resolved path using the provided allocator, follows symlinks, removes . and .. components, and returns a properly owned Zig string slice. The catch block handles any errors (such as the path not existing) by printing a friendly message, and a defer ensures the allocated memory is freed before the function exits. Finally, the Zig-resolved absolute path is printed.

Save the code as rel2abs.zig and execute it on your UNIX machine:

$ zig version
0.15.2
$ zig run rel2abs.zig -- /var/tmp
C: /private/var/tmp
Zig: /private/var/tmp
$ zig run rel2abs.zig -- ~
C: /Users/mtsouk
Zig: /Users/mtsouk

Happy coding in Zig!