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.zigtestável; - adicionar
--format text|jsonpara 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:
| Aspecto | Zig | Go | Rust |
|---|---|---|---|
| Binário estático | Sim (padrão) | Sim (CGO_ENABLED=0) | Sim (musl) |
| Tamanho típico | 200-500 KB | 5-10 MB | 1-3 MB |
| Tempo de compilação | Rápido | Rápido | Lento |
| Cross-compilation | Nativo | Nativo | Via rustup target |
| Framework CLI | Biblioteca padrão | Cobra/urfave | clap/structopt |
Próximos Passos
Com sua CLI funcional, explore:
- Benchmarking para medir a performance da sua ferramenta contra alternativas
- Error handling avançado para mensagens de erro mais informativas
- Ecossistema de ferramentas para descobrir bibliotecas CLI da comunidade
- Concorrência para busca paralela em múltiplos arquivos
- Containers Docker para empacotar e distribuir sua CLI