Neste projeto, vamos construir um clone simplificado do famoso comando grep do Unix. O grep é uma das ferramentas mais utilizadas no dia a dia de qualquer desenvolvedor — ele busca padrões de texto em arquivos e exibe as linhas correspondentes. Ao recriá-lo em Rust, você vai aprender sobre manipulação de strings, expressões regulares, leitura de arquivos, parsing de argumentos de linha de comando e saída formatada com cores.
Este projeto é ideal para quem já conhece os fundamentos de Rust e quer colocar em prática conceitos como tratamento de erros com Result, iteradores e o sistema de módulos. Ao final, você terá uma ferramenta funcional que pode usar no seu terminal.
O Que Vamos Construir
Nosso minigrep terá os seguintes recursos:
- Busca por padrão usando expressões regulares
- Leitura de um ou múltiplos arquivos
- Busca recursiva em diretórios
- Saída colorida destacando os trechos encontrados
- Exibição de números de linha
- Opção de busca case-insensitive
- Contagem de ocorrências
Estrutura do Projeto
minigrep/
├── Cargo.toml
└── src/
├── main.rs
├── cli.rs
├── buscador.rs
└── formatador.rs
Configurando o Projeto
Crie o projeto com o Cargo:
cargo new minigrep
cd minigrep
Edite o Cargo.toml para incluir as dependências:
[package]
name = "minigrep"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
regex = "1"
walkdir = "2"
colored = "2"
Usamos clap para parsing de argumentos, regex para os padrões de busca, walkdir para varredura recursiva de diretórios e colored para saída colorida no terminal.
Passo 1: Definindo a Interface de Linha de Comando
Vamos começar criando o módulo cli.rs, que define os argumentos aceitos pelo programa usando o clap com a macro derive.
// src/cli.rs
use clap::Parser;
/// minigrep - um clone simplificado do grep em Rust
#[derive(Parser, Debug)]
#[command(name = "minigrep")]
#[command(about = "Busca padrões de texto em arquivos")]
pub struct Argumentos {
/// O padrão (regex) a ser buscado
pub padrao: String,
/// Caminhos de arquivos ou diretórios para buscar
#[arg(required = true)]
pub caminhos: Vec<String>,
/// Busca case-insensitive
#[arg(short = 'i', long = "ignorar-caso")]
pub ignorar_caso: bool,
/// Busca recursiva em diretórios
#[arg(short = 'r', long = "recursivo")]
pub recursivo: bool,
/// Exibe apenas a contagem de ocorrências
#[arg(short = 'c', long = "contagem")]
pub contagem: bool,
/// Exibe números de linha
#[arg(short = 'n', long = "numero-linha", default_value_t = true)]
pub numero_linha: bool,
}
Cada campo da struct Argumentos corresponde a um argumento ou flag do programa. O clap cuida de gerar a mensagem de ajuda (--help) e validar as entradas automaticamente.
Passo 2: Implementando o Motor de Busca
O módulo buscador.rs contém a lógica central — compilar a regex e buscar correspondências em cada linha de um arquivo.
// src/buscador.rs
use regex::Regex;
use std::fs;
use std::io;
use std::path::Path;
use walkdir::WalkDir;
/// Representa uma correspondência encontrada em um arquivo
#[derive(Debug)]
pub struct Correspondencia {
pub arquivo: String,
pub numero_linha: usize,
pub conteudo: String,
pub inicio: usize,
pub fim: usize,
}
/// Compila o padrão regex com suporte a case-insensitive
pub fn compilar_regex(padrao: &str, ignorar_caso: bool) -> Result<Regex, regex::Error> {
let padrao_final = if ignorar_caso {
format!("(?i){}", padrao)
} else {
padrao.to_string()
};
Regex::new(&padrao_final)
}
/// Busca o padrão em um único arquivo e retorna as correspondências
pub fn buscar_em_arquivo(
regex: &Regex,
caminho: &Path,
) -> io::Result<Vec<Correspondencia>> {
let conteudo = fs::read_to_string(caminho)?;
let nome_arquivo = caminho.display().to_string();
let mut resultados = Vec::new();
for (indice, linha) in conteudo.lines().enumerate() {
if let Some(encontrado) = regex.find(linha) {
resultados.push(Correspondencia {
arquivo: nome_arquivo.clone(),
numero_linha: indice + 1,
conteudo: linha.to_string(),
inicio: encontrado.start(),
fim: encontrado.end(),
});
}
}
Ok(resultados)
}
/// Coleta todos os arquivos a serem buscados, expandindo diretórios se recursivo
pub fn coletar_arquivos(caminhos: &[String], recursivo: bool) -> Vec<String> {
let mut arquivos = Vec::new();
for caminho in caminhos {
let path = Path::new(caminho);
if path.is_file() {
arquivos.push(caminho.clone());
} else if path.is_dir() && recursivo {
for entrada in WalkDir::new(caminho)
.into_iter()
.filter_map(|e| e.ok())
{
if entrada.file_type().is_file() {
arquivos.push(entrada.path().display().to_string());
}
}
} else if path.is_dir() {
eprintln!(
"minigrep: '{}' é um diretório. Use -r para busca recursiva.",
caminho
);
}
}
arquivos
}
A função buscar_em_arquivo lê o arquivo inteiro para a memória e itera linha a linha. Para cada linha que contém o padrão, criamos uma Correspondencia com a posição exata do trecho encontrado — isso será usado pelo formatador para colorir a saída.
Passo 3: Formatando a Saída com Cores
O módulo formatador.rs é responsável por exibir os resultados de forma legível, com destaque colorido nos trechos encontrados.
// src/formatador.rs
use crate::buscador::Correspondencia;
use colored::*;
/// Exibe uma correspondência com cores e número de linha
pub fn exibir_correspondencia(resultado: &Correspondencia, mostrar_arquivo: bool, mostrar_numero: bool) {
let mut saida = String::new();
// Nome do arquivo em magenta
if mostrar_arquivo {
saida.push_str(&format!("{}:", resultado.arquivo.magenta()));
}
// Número da linha em verde
if mostrar_numero {
saida.push_str(&format!("{}:", resultado.numero_linha.to_string().green()));
}
// Linha com o trecho encontrado em vermelho/negrito
let linha = &resultado.conteudo;
let antes = &linha[..resultado.inicio];
let destaque = &linha[resultado.inicio..resultado.fim];
let depois = &linha[resultado.fim..];
saida.push_str(&format!("{}{}{}", antes, destaque.red().bold(), depois));
println!("{}", saida);
}
/// Exibe o resumo de contagem por arquivo
pub fn exibir_contagem(arquivo: &str, contagem: usize) {
println!("{}:{}", arquivo.magenta(), contagem.to_string().green());
}
Usamos a crate colored para aplicar cores ANSI ao terminal. O trecho encontrado aparece em vermelho e negrito, o nome do arquivo em magenta e o número da linha em verde — similar ao grep --color=always.
Passo 4: Juntando Tudo no main.rs
Agora vamos integrar todos os módulos no main.rs:
// src/main.rs
mod cli;
mod buscador;
mod formatador;
use clap::Parser;
use std::process;
fn main() {
let args = cli::Argumentos::parse();
// Compilar a expressão regular
let regex = match buscador::compilar_regex(&args.padrao, args.ignorar_caso) {
Ok(r) => r,
Err(e) => {
eprintln!("Erro no padrão regex '{}': {}", args.padrao, e);
process::exit(1);
}
};
// Coletar todos os arquivos para busca
let arquivos = buscador::coletar_arquivos(&args.caminhos, args.recursivo);
if arquivos.is_empty() {
eprintln!("minigrep: nenhum arquivo encontrado para buscar.");
process::exit(1);
}
let mostrar_nome_arquivo = arquivos.len() > 1;
let mut total_correspondencias: usize = 0;
let mut houve_erro = false;
for arquivo in &arquivos {
let caminho = std::path::Path::new(arquivo);
match buscador::buscar_em_arquivo(®ex, caminho) {
Ok(resultados) => {
if args.contagem {
// Modo contagem: exibe apenas o total por arquivo
if !resultados.is_empty() {
formatador::exibir_contagem(arquivo, resultados.len());
}
} else {
// Modo normal: exibe cada correspondência
for resultado in &resultados {
formatador::exibir_correspondencia(
resultado,
mostrar_nome_arquivo,
args.numero_linha,
);
}
}
total_correspondencias += resultados.len();
}
Err(e) => {
eprintln!("minigrep: erro ao ler '{}': {}", arquivo, e);
houve_erro = true;
}
}
}
// Código de saída: 0 = encontrou, 1 = não encontrou, 2 = erro
if houve_erro {
process::exit(2);
} else if total_correspondencias == 0 {
process::exit(1);
}
}
O fluxo principal é direto: parsear argumentos, compilar a regex, coletar arquivos, buscar em cada um e exibir os resultados. Seguimos a convenção do grep real para os códigos de saída: 0 quando encontra correspondências, 1 quando não encontra e 2 quando ocorre um erro.
Como Executar
Compile e execute o projeto:
cargo build --release
Exemplos de uso:
# Buscar "fn main" em um arquivo
./target/release/minigrep "fn main" src/main.rs
# Saída:
# 10:fn main() {
# Busca case-insensitive recursiva
./target/release/minigrep -i -r "struct" src/
# Saída:
# src/cli.rs:8:pub struct Argumentos {
# src/buscador.rs:8:pub struct Correspondencia {
# Contar ocorrências de "use" em todos os arquivos .rs
./target/release/minigrep -c -r "use" src/
# Saída:
# src/main.rs:2
# src/cli.rs:1
# src/buscador.rs:4
# src/formatador.rs:2
# Buscar com regex: linhas que começam com "pub"
./target/release/minigrep -r "^pub" src/
# Buscar endereços de email em arquivos de texto
./target/release/minigrep "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" emails.txt
Desafios para Expandir
Suporte a glob patterns: Permita que o usuário passe padrões como
*.rsousrc/**/*.txtem vez de caminhos fixos. Use a crateglobpara expandir os padrões.Contexto ao redor: Implemente as flags
-A(linhas depois),-B(linhas antes) e-C(linhas ao redor), como o grep real faz. Isso exige manter um buffer das linhas anteriores.Busca invertida: Adicione a flag
-vque exibe apenas as linhas que não correspondem ao padrão — útil para filtrar logs.Saída em formato JSON: Adicione uma flag
--jsonque retorna os resultados em formato JSON estruturado, facilitando a integração com outras ferramentas via pipe.Busca paralela com threads: Use
rayonoustd::threadpara buscar em múltiplos arquivos simultaneamente, melhorando a performance em diretórios grandes.
Veja Também
- Manipulação de Strings — operações com
Stringe&str - Módulo fs — leitura e escrita de arquivos
- Módulo io — entrada e saída no Rust
- Usando Expressões Regulares — receita prática com a crate
regex - Lendo Arquivos — padrões para leitura de arquivos
- Lendo Argumentos CLI — como parsear argumentos de linha de comando