Clone do grep em Rust

Construa um clone simplificado do grep em Rust com busca por regex, saída colorida e busca recursiva em diretórios. Walkthrough completo.

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(&regex, 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

  1. Suporte a glob patterns: Permita que o usuário passe padrões como *.rs ou src/**/*.txt em vez de caminhos fixos. Use a crate glob para expandir os padrões.

  2. 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.

  3. Busca invertida: Adicione a flag -v que exibe apenas as linhas que não correspondem ao padrão — útil para filtrar logs.

  4. Saída em formato JSON: Adicione uma flag --json que retorna os resultados em formato JSON estruturado, facilitando a integração com outras ferramentas via pipe.

  5. Busca paralela com threads: Use rayon ou std::thread para buscar em múltiplos arquivos simultaneamente, melhorando a performance em diretórios grandes.

Veja Também