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. Se a sua ferramenta precisar de navegação, painéis e atalhos interativos, veja também o guia de TUI em Zig para apps de terminal.

Atualização 2026: onde uma CLI Zig vira ferramenta de produto

Uma CLI Zig deixa de ser exercício quando entra em uma rotina real: valida um deploy, transforma dados, chama uma API interna, gera relatório, empacota um release ou roda em CI sem depender de runtime. Para esse tipo de uso, pense em três camadas antes de escrever o primeiro switch de subcomando.

A primeira camada é interface: nomes de comandos, flags curtas e longas, ajuda útil, mensagens de erro e códigos de saída. Um usuário precisa entender rapidamente se deve rodar ziggrep scan, ziggrep report ou ziggrep check. Também precisa saber se o exit code 1 significa resultado inválido, erro de entrada ou falha temporária de rede.

A segunda camada é operação. CLIs que viram ferramentas internas precisam de configuração previsível, logs sem segredo, modo JSON para automação e build reproduzível. O guia de configuração segura em Zig mostra como tratar variáveis, arquivos locais e flags sem vazar token. Para ferramentas que guardam cache, histórico ou estado local, Zig com SQLite é um caminho natural.

A terceira camada é distribuição. O valor de Zig aparece quando a equipe baixa um binário para Linux, macOS ou Windows e ele simplesmente roda. Se a CLI vai circular fora da sua máquina, conecte este tutorial ao fluxo de GitHub Actions para releases multiplataforma e ao guia de cross-compilation. Para times de plataforma, a visão mais ampla está em ferramentas internas em Zig para DevOps.

Alguns bons próximos passos depois do ziggrep deste artigo:

  • transformar o parser de argumentos em um módulo cli.zig testável;
  • adicionar --format text|json para uso humano e CI;
  • separar erros de uso, erros temporários e falhas de validação;
  • adicionar subcomandos pequenos em vez de uma única lista gigante de flags;
  • usar ETL em Zig quando a CLI processa CSV, JSONL ou migrações;
  • publicar artefatos com checksums antes de entregar a ferramenta para outras pessoas.

Esse contexto importa para SEO e para o leitor: quem procura “zig cli” raramente quer só imprimir argumentos. Quer saber se Zig serve para uma ferramenta de linha de comando séria, distribuível e mantida por equipe. A resposta é sim, desde que você trate interface, operação e release como partes do mesmo produto.

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.