Criar CLI Profissional em Zig Passo a Passo

Ferramentas de linha de comando são uma das aplicações mais naturais para Zig. A combinação de binários estáticos pequenos, cross-compilation trivial e inicialização instantânea torna Zig uma escolha superior para CLIs que precisam ser rápidas, confiáveis e fáceis de distribuir.

Neste tutorial, vamos construir uma CLI completa do zero — um buscador de texto em arquivos chamado ziggrep — cobrindo parsing de argumentos, subcomandos, flags, saída colorida e empacotamento para distribuição.

Estrutura do Projeto

Comece criando o projeto com o build system padrão:

mkdir ziggrep && cd ziggrep
zig init

Isso gera a estrutura com build.zig, build.zig.zon e src/main.zig. A configuração do package manager via build.zig.zon permite adicionar dependências externas se necessário, mas para nossa CLI, a biblioteca padrão é suficiente.

Parsing de Argumentos com std.process

O ponto de partida é capturar os argumentos da linha de comando. Zig oferece std.process.argsAlloc para isso:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len < 2) {
        try printUsage();
        std.process.exit(1);
    }

    // args[0] é o nome do executável
    // args[1] em diante são os argumentos do usuário
    for (args[1..]) |arg| {
        std.debug.print("Argumento: {s}\n", .{arg});
    }
}

fn printUsage() !void {
    const stderr = std.io.getStdErr().writer();
    try stderr.print(
        \\Uso: ziggrep [opções] <padrão> [arquivo...]
        \\
        \\Opções:
        \\  -i, --ignore-case    Ignorar maiúsculas/minúsculas
        \\  -n, --line-number    Exibir número da linha
        \\  -c, --count          Contar ocorrências
        \\  -r, --recursive      Buscar recursivamente
        \\  -h, --help           Exibir esta ajuda
        \\  -v, --version        Exibir versão
        \\
    , .{});
}

O uso do GeneralPurposeAllocator com defer para liberação garante que não haja vazamentos de memória — um padrão fundamental que detalhamos no guia de alocação de memória.

Implementando Flags e Opções

Para uma CLI profissional, precisamos parsear flags como --ignore-case e -n. Veja uma implementação robusta:

const std = @import("std");

const Config = struct {
    ignore_case: bool = false,
    line_number: bool = false,
    count_only: bool = false,
    recursive: bool = false,
    pattern: ?[]const u8 = null,
    files: std.ArrayList([]const u8),

    fn init(allocator: std.mem.Allocator) Config {
        return .{
            .files = std.ArrayList([]const u8).init(allocator),
        };
    }

    fn deinit(self: *Config) void {
        self.files.deinit();
    }
};

fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) !Config {
    var config = Config.init(allocator);
    errdefer config.deinit();

    var i: usize = 0;
    while (i < args.len) : (i += 1) {
        const arg = args[i];

        if (std.mem.startsWith(u8, arg, "-")) {
            if (std.mem.eql(u8, arg, "-i") or std.mem.eql(u8, arg, "--ignore-case")) {
                config.ignore_case = true;
            } else if (std.mem.eql(u8, arg, "-n") or std.mem.eql(u8, arg, "--line-number")) {
                config.line_number = true;
            } else if (std.mem.eql(u8, arg, "-c") or std.mem.eql(u8, arg, "--count")) {
                config.count_only = true;
            } else if (std.mem.eql(u8, arg, "-r") or std.mem.eql(u8, arg, "--recursive")) {
                config.recursive = true;
            } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) {
                try printUsage();
                std.process.exit(0);
            } else if (std.mem.eql(u8, arg, "-v") or std.mem.eql(u8, arg, "--version")) {
                const stdout = std.io.getStdOut().writer();
                try stdout.print("ziggrep 1.0.0\n", .{});
                std.process.exit(0);
            } else {
                const stderr = std.io.getStdErr().writer();
                try stderr.print("Opção desconhecida: {s}\n", .{arg});
                std.process.exit(1);
            }
        } else if (config.pattern == null) {
            config.pattern = arg;
        } else {
            try config.files.append(arg);
        }
    }

    return config;
}

O uso de errdefer é essencial aqui — se qualquer operação falhar durante o parsing, o ArrayList é liberado automaticamente. Esse padrão de error handling é idiomático em Zig e evita vazamentos em caminhos de erro.

Busca em Arquivos com Buffered I/O

A lógica central do ziggrep lê arquivos linha por linha usando I/O com buffer:

fn searchFile(
    file_path: []const u8,
    pattern: []const u8,
    config: Config,
    writer: anytype,
) !u32 {
    const file = std.fs.cwd().openFile(file_path, .{}) catch |err| {
        const stderr = std.io.getStdErr().writer();
        try stderr.print("Erro ao abrir '{s}': {}\n", .{ file_path, err });
        return 0;
    };
    defer file.close();

    var buf_reader = std.io.bufferedReader(file.reader());
    var line_buf: [4096]u8 = undefined;
    var line_num: u32 = 0;
    var match_count: u32 = 0;

    while (buf_reader.reader().readUntilDelimiterOrEof(&line_buf, '\n')) |maybe_line| {
        const line = maybe_line orelse break;
        line_num += 1;

        const haystack = if (config.ignore_case)
            std.ascii.lowerString(&line_buf, line)
        else
            line;

        const needle = if (config.ignore_case)
            blk: {
                var lower: [256]u8 = undefined;
                break :blk std.ascii.lowerString(&lower, pattern);
            }
        else
            pattern;

        if (std.mem.indexOf(u8, haystack, needle)) |_| {
            match_count += 1;
            if (!config.count_only) {
                if (config.line_number) {
                    try writer.print("{s}:{d}: {s}\n", .{ file_path, line_num, line });
                } else {
                    try writer.print("{s}: {s}\n", .{ file_path, line });
                }
            }
        }
    } else |err| {
        const stderr = std.io.getStdErr().writer();
        try stderr.print("Erro ao ler '{s}': {}\n", .{ file_path, err });
    }

    return match_count;
}

O bufferedReader reduz syscalls agrupando leituras em blocos maiores — uma técnica que detalhamos no contexto de networking e I/O.

Saída Colorida no Terminal

Para uma experiência profissional, adicione cores usando códigos ANSI escape:

const Color = struct {
    const reset = "\x1b[0m";
    const red = "\x1b[31m";
    const green = "\x1b[32m";
    const yellow = "\x1b[33m";
    const cyan = "\x1b[36m";
    const bold = "\x1b[1m";
};

fn printMatch(
    writer: anytype,
    file_path: []const u8,
    line_num: u32,
    line: []const u8,
    show_line_num: bool,
) !void {
    try writer.print("{s}{s}{s}", .{ Color.cyan, file_path, Color.reset });
    if (show_line_num) {
        try writer.print(":{s}{d}{s}", .{ Color.green, line_num, Color.reset });
    }
    try writer.print(": {s}\n", .{line});
}

Verifique se a saída é um terminal antes de usar cores, para evitar caracteres estranhos em pipes ou redirecionamentos:

const use_color = std.io.getStdOut().supportsAnsiEscapeCodes();

Exit Codes e Convenções

Uma CLI bem comportada usa exit codes padronizados:

const ExitCode = enum(u8) {
    success = 0,          // Encontrou pelo menos uma ocorrência
    no_match = 1,         // Nenhuma ocorrência encontrada
    error_occurred = 2,   // Erro de I/O ou argumento inválido
};

pub fn main() !void {
    // ... parsing e busca ...

    if (had_errors) {
        std.process.exit(@intFromEnum(ExitCode.error_occurred));
    } else if (total_matches == 0) {
        std.process.exit(@intFromEnum(ExitCode.no_match));
    }
    // Exit 0 implícito — sucesso
}

Seguir essas convenções permite que sua ferramenta funcione bem em scripts e pipelines, integrando-se com outras ferramentas em automação de scripts.

Leitura de stdin

Para suportar pipes como cat arquivo.txt | ziggrep padrão, adicione leitura de stdin quando nenhum arquivo é especificado:

fn searchStdin(pattern: []const u8, config: Config, writer: anytype) !u32 {
    const stdin = std.io.getStdIn();
    var buf_reader = std.io.bufferedReader(stdin.reader());
    var line_buf: [4096]u8 = undefined;
    var line_num: u32 = 0;
    var match_count: u32 = 0;

    while (buf_reader.reader().readUntilDelimiterOrEof(&line_buf, '\n')) |maybe_line| {
        const line = maybe_line orelse break;
        line_num += 1;

        if (std.mem.indexOf(u8, line, pattern)) |_| {
            match_count += 1;
            if (!config.count_only) {
                if (config.line_number) {
                    try writer.print("{d}: {s}\n", .{ line_num, line });
                } else {
                    try writer.print("{s}\n", .{line});
                }
            }
        }
    } else |err| {
        return err;
    }

    return match_count;
}

Configurando o build.zig para Distribuição

Para gerar binários otimizados e prontos para distribuição via Docker ou download direto:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "ziggrep",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .strip = optimize != .Debug,
    });

    b.installArtifact(exe);

    // Step de execução para testes rápidos
    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", "Executar ziggrep");
    run_step.dependOn(&run_cmd.step);
}

A flag strip remove símbolos de debug no binário final, reduzindo o tamanho. Para distribuição multiplataforma, use cross-compilation:

zig build -Dtarget=x86_64-linux -Doptimize=ReleaseSafe
zig build -Dtarget=aarch64-macos -Doptimize=ReleaseSafe
zig build -Dtarget=x86_64-windows -Doptimize=ReleaseSafe

Com três comandos, você gera binários para Linux, macOS e Windows — sem instalar toolchains adicionais.

Testando a CLI

Integre testes automatizados diretamente no código da CLI:

test "parseArgs reconhece flags corretamente" {
    const allocator = std.testing.allocator;
    const args = &[_][]const u8{ "-i", "-n", "padrão", "arquivo.txt" };
    var config = try parseArgs(allocator, args);
    defer config.deinit();

    try std.testing.expect(config.ignore_case);
    try std.testing.expect(config.line_number);
    try std.testing.expectEqualStrings("padrão", config.pattern.?);
    try std.testing.expectEqual(@as(usize, 1), config.files.items.len);
}

test "parseArgs sem argumentos retorna pattern null" {
    const allocator = std.testing.allocator;
    const args = &[_][]const u8{};
    var config = try parseArgs(allocator, args);
    defer config.deinit();

    try std.testing.expect(config.pattern == null);
}

O std.testing.allocator detecta automaticamente vazamentos de memória nos testes — se algum defer estiver faltando, o teste falha com erro de leak.

Comparação com CLIs em Outras Linguagens

Zig se destaca para CLIs que precisam de inicialização rápida e distribuição simples. Enquanto Go usa frameworks como Cobra com reflexão em runtime, Zig resolve tudo em tempo de compilação com comptime.

Para quem vem de Rust com clap, a abordagem de Zig é mais manual, mas produz binários menores e com tempo de compilação significativamente inferior. A tabela abaixo ilustra as diferenças:

AspectoZigGoRust
Binário estáticoSim (padrão)Sim (CGO_ENABLED=0)Sim (musl)
Tamanho típico200-500 KB5-10 MB1-3 MB
Tempo de compilaçãoRápidoRápidoLento
Cross-compilationNativoNativoVia rustup target
Framework CLIBiblioteca padrãoCobra/urfaveclap/structopt

Próximos Passos

Com sua CLI funcional, explore:

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.