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:
| 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