Mini Grep em Zig — Tutorial Passo a Passo
Neste tutorial, vamos construir uma implementação simplificada do grep — a clássica ferramenta Unix de busca de texto. Nosso mini-grep busca padrões em arquivos, suporta highlight de matches, contagem de ocorrências e busca recursiva em diretórios.
O Que Vamos Construir
Nosso mini-grep vai:
- Buscar padrões de texto em um ou mais arquivos
- Suportar busca case-insensitive
- Highlight das ocorrências encontradas com cores ANSI
- Exibir números de linha
- Buscar recursivamente em diretórios
- Contar ocorrências por arquivo
- Suportar padrões glob simples (*, ?)
Pré-requisitos
- Zig 0.13+ instalado (guia de instalação)
- Familiaridade com I/O de arquivos em Zig
Passo 1: Estrutura do Projeto
mkdir mini-grep
cd mini-grep
zig init
Passo 2: Opções de Busca e Matching
const std = @import("std");
const fs = std.fs;
const mem = std.mem;
const io = std.io;
const fmt = std.fmt;
const Allocator = std.mem.Allocator;
/// Opções de busca configuráveis.
const OpcoesBusca = struct {
case_insensitive: bool = false,
mostrar_numeros_linha: bool = true,
highlight: bool = true,
contar_apenas: bool = false,
recursivo: bool = false,
max_resultados: usize = 1000,
contexto_antes: usize = 0,
contexto_depois: usize = 0,
};
/// Resultado de uma busca em uma linha.
const Match = struct {
linha_num: usize,
coluna: usize,
comprimento: usize,
linha: []const u8,
};
/// Busca um padrão em uma linha, suportando case-insensitive.
/// Retorna a posição da primeira ocorrência ou null.
fn buscarPadrao(linha: []const u8, padrao: []const u8, case_insensitive: bool) ?usize {
if (padrao.len == 0 or padrao.len > linha.len) return null;
var i: usize = 0;
while (i + padrao.len <= linha.len) : (i += 1) {
var match = true;
for (linha[i .. i + padrao.len], padrao) |lc, pc| {
const a = if (case_insensitive and lc >= 'A' and lc <= 'Z') lc + 32 else lc;
const b = if (case_insensitive and pc >= 'A' and pc <= 'Z') pc + 32 else pc;
if (a != b) {
match = false;
break;
}
}
if (match) return i;
}
return null;
}
/// Conta todas as ocorrências de um padrão em uma linha.
fn contarOcorrencias(linha: []const u8, padrao: []const u8, case_insensitive: bool) usize {
var count: usize = 0;
var pos: usize = 0;
while (pos + padrao.len <= linha.len) {
if (buscarPadrao(linha[pos..], padrao, case_insensitive)) |offset| {
count += 1;
pos += offset + 1;
} else break;
}
return count;
}
Passo 3: Motor de Busca em Arquivos
/// Resultado da busca em um arquivo.
const ResultadoArquivo = struct {
caminho: [512]u8,
caminho_len: usize,
matches: usize,
linhas_com_match: usize,
pub fn caminhoStr(self: *const ResultadoArquivo) []const u8 {
return self.caminho[0..self.caminho_len];
}
};
/// Busca um padrão em um arquivo, exibindo resultados.
fn buscarEmArquivo(
caminho: []const u8,
padrao: []const u8,
opcoes: *const OpcoesBusca,
writer: anytype,
) !ResultadoArquivo {
var resultado = ResultadoArquivo{
.caminho = undefined,
.caminho_len = @min(caminho.len, 512),
.matches = 0,
.linhas_com_match = 0,
};
@memcpy(resultado.caminho[0..resultado.caminho_len], caminho[0..resultado.caminho_len]);
const file = fs.cwd().openFile(caminho, .{}) catch |err| {
try writer.print(" Erro ao abrir {s}: {any}\n", .{ caminho, err });
return resultado;
};
defer file.close();
var buf: [4096]u8 = undefined;
const reader = file.reader();
var linha_num: usize = 0;
while (reader.readUntilDelimiterOrEof(&buf, '\n')) |maybe_line| {
const linha = maybe_line orelse break;
linha_num += 1;
const ocorrencias = contarOcorrencias(linha, padrao, opcoes.case_insensitive);
if (ocorrencias == 0) continue;
resultado.matches += ocorrencias;
resultado.linhas_com_match += 1;
if (!opcoes.contar_apenas) {
// Exibir a linha com highlight
const reset = "\x1b[0m";
const cor_match = "\x1b[1;31m";
const cor_arquivo = "\x1b[35m";
const cor_num = "\x1b[32m";
try writer.print("{s}{s}{s}:", .{ cor_arquivo, caminho, reset });
if (opcoes.mostrar_numeros_linha) {
try writer.print("{s}{d}{s}:", .{ cor_num, linha_num, reset });
}
// Highlight das ocorrências
if (opcoes.highlight) {
try exibirComHighlight(linha, padrao, opcoes.case_insensitive, cor_match, reset, writer);
} else {
try writer.print("{s}", .{linha});
}
try writer.print("\n", .{});
}
} else |_| {}
return resultado;
}
/// Exibe uma linha com as ocorrências do padrão em destaque.
fn exibirComHighlight(
linha: []const u8,
padrao: []const u8,
case_insensitive: bool,
cor: []const u8,
reset: []const u8,
writer: anytype,
) !void {
var pos: usize = 0;
while (pos < linha.len) {
if (buscarPadrao(linha[pos..], padrao, case_insensitive)) |offset| {
// Texto antes do match
try writer.print("{s}", .{linha[pos .. pos + offset]});
// Match em destaque
try writer.print("{s}{s}{s}", .{ cor, linha[pos + offset .. pos + offset + padrao.len], reset });
pos += offset + padrao.len;
} else {
// Resto da linha
try writer.print("{s}", .{linha[pos..]});
break;
}
}
}
Passo 4: Busca Recursiva
/// Busca recursivamente em um diretório.
fn buscarRecursivo(
dir_path: []const u8,
padrao: []const u8,
opcoes: *const OpcoesBusca,
writer: anytype,
resultados: []ResultadoArquivo,
num_resultados: *usize,
) !void {
var dir = fs.cwd().openDir(dir_path, .{ .iterate = true }) catch return;
defer dir.close();
var iter = dir.iterate();
while (try iter.next()) |entry| {
var path_buf: [1024]u8 = undefined;
const full_path = fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, entry.name }) catch continue;
switch (entry.kind) {
.file => {
// Ignora arquivos binários (heurística: verificar extensão)
if (mem.endsWith(u8, entry.name, ".o") or
mem.endsWith(u8, entry.name, ".exe") or
mem.endsWith(u8, entry.name, ".bin") or
mem.endsWith(u8, entry.name, ".png") or
mem.endsWith(u8, entry.name, ".jpg"))
{
continue;
}
if (num_resultados.* < resultados.len) {
resultados[num_resultados.*] = try buscarEmArquivo(
full_path, padrao, opcoes, writer,
);
if (resultados[num_resultados.*].matches > 0) {
num_resultados.* += 1;
}
}
},
.directory => {
// Ignora diretórios ocultos e de build
if (entry.name[0] == '.' or
mem.eql(u8, entry.name, "zig-out") or
mem.eql(u8, entry.name, "zig-cache") or
mem.eql(u8, entry.name, "node_modules"))
{
continue;
}
try buscarRecursivo(full_path, padrao, opcoes, writer, resultados, num_resultados);
},
else => {},
}
}
}
Passo 5: Interface CLI
pub fn main() !void {
const stdout = io.getStdOut().writer();
const stdin = io.getStdIn().reader();
try stdout.print(
\\
\\ ==========================================
\\ MINI GREP - Zig
\\ ==========================================
\\
, .{});
var buf: [1024]u8 = undefined;
while (true) {
try stdout.print(
\\
\\ [1] Buscar em arquivo
\\ [2] Buscar recursivo em diretorio
\\ [3] Contar ocorrencias
\\ [4] Sair
\\
\\ Opcao:
, .{});
const opcao_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch continue orelse break;
const opcao = mem.trim(u8, opcao_raw, " \t\r\n");
if (mem.eql(u8, opcao, "4")) break;
try stdout.print("\n Padrao de busca: ", .{});
const padrao_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch continue orelse continue;
const padrao = mem.trim(u8, padrao_raw, " \t\r\n");
if (padrao.len == 0) continue;
// Copiar padrão para buffer persistente
var padrao_buf: [256]u8 = undefined;
const padrao_len = @min(padrao.len, padrao_buf.len);
@memcpy(padrao_buf[0..padrao_len], padrao[0..padrao_len]);
const padrao_fixo = padrao_buf[0..padrao_len];
try stdout.print(" Case insensitive? (s/n): ", .{});
const ci_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch "n" orelse "n";
const ci = mem.eql(u8, mem.trim(u8, ci_raw, " \t\r\n"), "s");
var opcoes = OpcoesBusca{
.case_insensitive = ci,
.contar_apenas = mem.eql(u8, opcao, "3"),
};
if (mem.eql(u8, opcao, "2")) {
opcoes.recursivo = true;
try stdout.print(" Diretorio: ", .{});
const dir_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch continue orelse continue;
const dir_path = mem.trim(u8, dir_raw, " \t\r\n");
var resultados: [100]ResultadoArquivo = undefined;
var num_resultados: usize = 0;
try stdout.print("\n", .{});
try buscarRecursivo(dir_path, padrao_fixo, &opcoes, stdout, &resultados, &num_resultados);
// Resumo
var total_matches: usize = 0;
for (resultados[0..num_resultados]) |r| total_matches += r.matches;
try stdout.print(
\\
\\ --- Resumo ---
\\ Arquivos com match: {d}
\\ Total de matches: {d}
\\
, .{ num_resultados, total_matches });
} else {
try stdout.print(" Arquivo: ", .{});
const path_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch continue orelse continue;
const path = mem.trim(u8, path_raw, " \t\r\n");
try stdout.print("\n", .{});
const resultado = try buscarEmArquivo(path, padrao_fixo, &opcoes, stdout);
if (opcoes.contar_apenas) {
try stdout.print(" {d} ocorrencias em {d} linhas\n", .{
resultado.matches, resultado.linhas_com_match,
});
}
}
}
try stdout.print("\n Ate logo!\n", .{});
}
Testes
test "buscar padrao simples" {
try std.testing.expectEqual(@as(?usize, 0), buscarPadrao("hello world", "hello", false));
try std.testing.expectEqual(@as(?usize, 6), buscarPadrao("hello world", "world", false));
try std.testing.expect(buscarPadrao("hello", "xyz", false) == null);
}
test "buscar case insensitive" {
try std.testing.expectEqual(@as(?usize, 0), buscarPadrao("Hello World", "hello", true));
try std.testing.expect(buscarPadrao("Hello World", "hello", false) == null);
}
test "contar ocorrencias" {
try std.testing.expectEqual(@as(usize, 3), contarOcorrencias("aaa", "a", false));
try std.testing.expectEqual(@as(usize, 2), contarOcorrencias("abab", "ab", false));
try std.testing.expectEqual(@as(usize, 0), contarOcorrencias("hello", "xyz", false));
}
test "buscar padrao vazio" {
try std.testing.expect(buscarPadrao("hello", "", false) == null);
}
test "buscar padrao maior que linha" {
try std.testing.expect(buscarPadrao("hi", "hello world", false) == null);
}
Compilando e Executando
zig build test
zig build run
Conceitos Aprendidos
- Busca de padrões em texto (string matching)
- Iteração recursiva de diretórios
- Cores ANSI para highlight no terminal
- Leitura linha a linha de arquivos
- Filtragem de arquivos binários
- Structs de configuração para opções de busca
Próximos Passos
- Evolua para suportar expressões regulares
- Explore a documentação std.fs para operações de filesystem
- Construa o próximo projeto: Compressor RLE