Expressões regulares em Rust com regex
Uma expressão regular (ou regex, para encurtar) é uma pequena linguagem de padrões para texto. Em vez de dizer “encontre a palavra gato”, você pode dizer “encontre quatro dígitos, um hífen, dois dígitos, um hífen, dois dígitos”, e encontrar de uma vez todas as datas como 2024-05-17.
A biblioteca padrão do Rust tem métodos práticos para strings, como contains, split e find (veja Strings). Quando um pedaço fixo de texto não basta e você precisa de um formato de texto, use o crate (pacote) regex (em inglês). Ele é mantido pelo próprio projeto Rust e é muito rápido.
Adicionando o regex e o seu primeiro match
Seção intitulada “Adicionando o regex e o seu primeiro match”Na pasta do seu projeto, rode:
cargo add regexIsso acrescenta regex = "1" em [dependencies] no seu Cargo.toml. (Crates são novidade para você? Veja Cargo e crates.) Os botões “Experimente você mesmo” abrem o Rust Playground, que já tem o regex.
use regex::Regex;
fn main() { let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Lançado em 2024-05-17.")); println!("{}", re.is_match("Lançado na primavera passada."));}Saída:
truefalse\d significa “qualquer dígito” e {4} significa “exatamente quatro do item anterior”. is_match responde a uma pergunta: existe um match (uma correspondência) em qualquer lugar do texto?
Repare no r antes do padrão: r"\d{4}". Isso é uma raw string (string crua). Em uma string normal do Rust, \ começa um escape como \n (quebra de linha). Padrões de regex são cheios de barras invertidas, então as raw strings poupam você de escrever "\\d{4}". Sempre use raw strings para padrões.
Por que Regex::new devolve um Result
Seção intitulada “Por que Regex::new devolve um Result”Um padrão é um pequeno programa, e ele pode ter erros, como um parêntese que você esqueceu de fechar. Regex::new verifica o padrão e devolve um Result: Ok(Regex) se ele for válido, ou Err com uma mensagem útil se não for:
use regex::Regex;
fn main() { match Regex::new(r"(\d+") { Ok(_) => println!("padrão válido"), Err(e) => println!("padrão inválido:\n{e}"), }}Saída:
padrão inválido:regex parse error: (\d+ ^error: unclosed groupA mensagem do crate fica em inglês: unclosed group quer dizer “grupo não fechado”, e o ^ aponta para o parêntese aberto.
Quando o padrão está escrito no seu código e você sabe que ele está certo, .unwrap() não tem problema: se estiver errado, você descobre na primeira vez que o programa rodar. Quando o padrão vem de um usuário (por exemplo, uma caixa de busca), trate o Err em vez de deixar o programa quebrar.
Encontrando matches: find e find_iter
Seção intitulada “Encontrando matches: find e find_iter”find devolve o primeiro match como um Option. find_iter dá um iterador sobre todos os matches:
use regex::Regex;
fn main() { let re = Regex::new(r"\d+").unwrap(); let text = "3 maçãs, 12 peras e 7 ameixas";
if let Some(m) = re.find(text) { println!("primeiro número: {} (nos bytes {}..{})", m.as_str(), m.start(), m.end()); }
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); println!("todos os números: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum(); println!("total de frutas: {total}");}Saída:
primeiro número: 3 (nos bytes 0..1)todos os números: ["3", "12", "7"]total de frutas: 22\d+ significa “um ou mais dígitos”, então 12 é encontrado como um único match, e não dois. Cada match sabe o seu texto (as_str()) e a sua posição (start() e end(), em bytes). Atenção: as posições são contadas em bytes, e letras acentuadas como ç e ã ocupam 2 bytes cada. Aqui o primeiro número está logo no começo, então a posição não muda.
Grupos de captura e grupos com nome
Seção intitulada “Grupos de captura e grupos com nome”Muitas vezes você não quer só o match, você quer pedaços dele. Coloque uma parte do padrão entre parênteses para capturá-la. Dê um nome a ela com (?<nome>...) para deixar o código mais fácil de ler:
use regex::Regex;
fn main() { let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap(); let text = "O backup rodou em 2024-05-17 e em 2024-06-02.";
if let Some(caps) = re.captures(text) { println!("match inteiro: {}", &caps[0]); println!("ano: {}", &caps["year"]); println!("mês: {}", &caps[2]); println!("dia: {}", caps.name("day").unwrap().as_str()); }
for caps in re.captures_iter(text) { println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]); }}Saída:
match inteiro: 2024-05-17ano: 2024mês: 05dia: 1717/05/202402/06/2024Três jeitos de ler um grupo:
caps[0]é sempre o match inteiro. Os grupos são numerados a partir do 1, da esquerda para a direita:caps[2]é o mês.caps["year"]lê um grupo pelo nome. Acessar com[...]dá panic se aquele grupo não participou do match.caps.name("day")devolve umOption, que é a escolha segura quando um grupo é opcional.
captures encontra o primeiro match. captures_iter percorre todos os matches, igualzinho ao find_iter.
Substituindo texto
Seção intitulada “Substituindo texto”replace troca o primeiro match, e replace_all troca todos os matches. No texto de substituição, $nome (ou $1) insere o que um grupo capturou:
use regex::Regex;
fn main() { let spaces = Regex::new(r"\s+").unwrap(); println!("{}", spaces.replace_all("espaços demais aqui", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap(); let text = "De 2024-05-17 a 2024-06-02"; println!("{}", date.replace(text, "$d/$m/$y")); println!("{}", date.replace_all(text, "$d/$m/$y")); println!("{}", date.replace_all(text, "$m_$y")); println!("{}", date.replace_all(text, "${m}_${y}"));}Saída:
espaços demais aquiDe 17/05/2024 a 2024-06-02De 17/05/2024 a 02/06/2024De 2024 a 2024De 05_2024 a 06_2024Olhe a quarta linha. Ela imprimiu De 2024 a 2024, e não 05_2024. Por quê? Um nome de grupo pode conter _, então $m_ foi lido como um grupo chamado m_. Esse grupo não existe, então virou um texto vazio. Coloque o nome entre chaves, ${m}_, para dizer exatamente onde ele termina. Na dúvida, use chaves.
\s+ significa “um ou mais caracteres de espaço em branco”, então trocá-lo por um único espaço é um jeito clássico de arrumar uma entrada bagunçada.
Dividindo texto
Seção intitulada “Dividindo texto”split corta uma string em todo lugar onde o padrão dá match. É como o str::split, mas o separador pode ser um padrão:
use regex::Regex;
fn main() { let sep = Regex::new(r"\s*[,;]\s*").unwrap(); let parts: Vec<&str> = sep.split("maçã, banana;cereja , tâmara").collect(); println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect(); println!("{:?}", two);}Saída:
["maçã", "banana", "cereja", "tâmara"]["a", "b, c, d"][,;] significa “uma vírgula ou um ponto e vírgula”, e o \s* de cada lado engole os espaços ao redor. splitn(text, 2) para depois de dois pedaços.
Compile uma vez, não dentro de um laço
Seção intitulada “Compile uma vez, não dentro de um laço”Montar um Regex é a parte lenta: o crate transforma o seu padrão em um buscador eficiente. Usar o regex é rápido. Então nunca chame Regex::new dentro de um laço ou de uma função que roda o tempo todo. Monte uma vez e reutilize.
Para um padrão usado no programa inteiro, coloque-o em um static com std::sync::LazyLock. Ele é montado na primeira vez que você o usa e depois é compartilhado:
use regex::Regex;use std::sync::LazyLock;
static EMAIL: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"(?i)^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$").unwrap()});
fn is_email(s: &str) -> bool { EMAIL.is_match(s)}
fn main() { println!("{input:22} -> {}", is_email(input)); }}Saída:
[email protected] -> true[email protected] -> truenot-an-email -> falsex@y -> falseOs textos de entrada ficaram em inglês porque a saída usa alinhamento ({input:22}), e letras acentuadas atrapalhariam as colunas.
Novidades neste padrão:
(?i)liga a busca sem diferenciar maiúsculas e minúsculas, então[a-z]também aceitaBeM.^e$significam “início do texto” e “fim do texto”. Sem eles,is_matchdiria sim para"olá [email protected]!!", porque o e-mail aparece em algum lugar do texto.\.é um ponto de verdade. Um.sozinho significa “qualquer caractere”.
Esta é uma verificação de “parece um e-mail”, boa para pegar erros de digitação. Validar de verdade todo endereço de e-mail possível com uma regex é famoso por ser difícil, então o único teste real é mandar um e-mail para o endereço.
Exemplo: extraindo dados de linhas de log
Seção intitulada “Exemplo: extraindo dados de linhas de log”Vamos juntar tudo. Lemos um log, pulamos as linhas que não seguem o formato e, para cada linha ERROR, imprimimos a data, a hora e os endereços de e-mail que aparecem na mensagem:
use regex::Regex;use std::sync::LazyLock;
static LOG_LINE: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +(?<msg>.*)$").unwrap()});
static EMAIL: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap()});
fn main() { let log = "\2024-05-17 09:12:03 INFO usuário [email protected] entrou2024-05-17 09:15:44 ERROR pagamento falhou para [email protected]esta linha é lixo2024-05-18 11:02:10 WARN disco 91% cheio
let mut errors = 0; for line in log.lines() { let Some(caps) = LOG_LINE.captures(line) else { println!("ignorada: {line:?}"); continue; }; if &caps["level"] == "ERROR" { errors += 1; let emails: Vec<&str> = EMAIL.find_iter(&caps["msg"]).map(|m| m.as_str()).collect(); println!("{} {} -> {:?}", &caps["date"], &caps["time"], emails); } } println!("{errors} erros encontrados");}Saída:
2024-05-17 09:15:44 -> ["[email protected]"]ignorada: "esta linha é lixo"2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]2 erros encontradosAlgumas observações:
- O
"\no começo delogé um truque do Rust: uma barra invertida no fim de uma linha dentro de uma string pula a quebra de linha, então a primeira linha do log começa direto. let Some(caps) = ... else { ... }é o let-else: se a linha não der match, nós a imprimimos e usamoscontinuepara ir à próxima.(?<level>[A-Z]+) +encontra o nível e depois um ou mais espaços, porqueINFOeWARNtêm um espaço extra para ficarem alinhados comERROR.- Em um programa de verdade, você leria o log de um arquivo, linha por linha. Veja Leitura e escrita de arquivos.
O que o regex não faz (de propósito)
Seção intitulada “O que o regex não faz (de propósito)”Se você já usou regex em JavaScript, Python ou Perl, talvez queira usar look-around ((?=...), (?<=...)) ou backreferences (retrorreferências: \1, “o mesmo texto do grupo 1”). O crate regex não tem suporte a eles, e avisa:
regex parse error: foo(?=bar) ^^^error: look-around, including look-ahead and look-behind, is not supportedO erro diz que look-around, incluindo look-ahead e look-behind, não é suportado. Essa é uma troca proposital. Esses recursos podem fazer alguns padrões levarem um tempo exponencial com certas entradas, então um padrão curto e uma string longa o bastante podem travar um programa (isso se chama catastrophic backtracking, ou backtracking catastrófico, e já derrubou sites de verdade). Sem eles, o crate regex consegue garantir que o tempo de busca cresce só na proporção do tamanho do texto (vezes o tamanho do padrão). O dobro de texto significa mais ou menos o dobro de trabalho, nunca uma explosão. Isso o torna seguro até para padrões ou textos que vêm de usuários.
Normalmente dá para chegar ao mesmo resultado de outro jeito: use um grupo de captura e leia só a parte que você quer, ou faça uma segunda verificação simples em código Rust comum. Se você precisa mesmo desses recursos, o crate fancy-regex (em inglês) os acrescenta, mas sem a garantia de tempo.
Resumo dos padrões
Seção intitulada “Resumo dos padrões”| Padrão | Encontra |
|---|---|
. |
qualquer caractere, menos a quebra de linha |
\d \w \s |
um dígito, um caractere de “palavra” (letra, dígito, _), espaço em branco |
\D \W \S |
o contrário: não dígito, não caractere de palavra, não espaço em branco |
[abc] [a-z] [^0-9] |
um entre a, b, c; qualquer letra minúscula; qualquer coisa menos um dígito |
* + ? |
zero ou mais, um ou mais, zero ou um do item anterior |
{3} {2,5} {2,} |
exatamente 3, de 2 a 5, pelo menos 2 |
^ $ |
início e fim do texto |
\b |
uma fronteira de palavra (entre um caractere de palavra e um que não é) |
(...) (?<name>...) |
um grupo numerado, um grupo com nome |
(?i) |
sem diferenciar maiúsculas e minúsculas daqui em diante |
Mais um: uma barra vertical significa “ou”. Por exemplo, gato|cão encontra qualquer uma das palavras, e gr(a|e)y encontra gray e grey. Para encontrar um caractere especial literalmente, coloque uma barra invertida na frente: \., \(, \$.
Erros comuns
Seção intitulada “Erros comuns”Esquecer a raw string. Em uma string normal, \d não é um escape válido, então o Rust para antes mesmo de o crate regex ver o seu padrão:
use regex::Regex;
fn main() { let re = Regex::new("\d+").unwrap(); println!("{}", re.is_match("id=22"));}error: unknown character escape: `d` --> src/main.rs:4:27 |4 | let re = Regex::new("\d+").unwrap(); | ^ unknown character escape |help: if you meant to write a literal backslash (perhaps escaping in a regular expression), consider a raw string literal |4 | let re = Regex::new(r"\d+").unwrap(); | +O erro diz que \d é um escape de caractere desconhecido, e o compilador até sugere a solução: acrescentar um r na frente para usar uma raw string.
Montar o regex dentro de um laço. Isso compila e funciona, mas é lento, porque o padrão é compilado de novo para cada linha:
for line in lines { let re = Regex::new(r"\d+").unwrap(); // compilado de novo, e de novo if re.is_match(line) { /* ... */ }}Leve o Regex::new para antes do laço, ou para um static com LazyLock, como mostramos antes.
Esquecer ^ e $ ao validar. is_match procura o padrão em qualquer lugar. Para verificar se a entrada inteira tem um formato, ancore o padrão nas duas pontas.
- Adicione o crate com
cargo add regexe escreva os padrões como raw strings:r"\d+". Regex::newdevolve umResult, porque um padrão pode ser inválido.is_matchresponde sim ou não;find/find_iterdão o texto encontrado;captures/captures_iterdão os grupos por número ou por(?<nome>...).replaceereplace_allpodem inserir grupos com$nome; use${nome}quando vierem letras ou_logo depois.- Compile um padrão uma vez e reutilize, por exemplo em um
staticcomLazyLock. - Sem look-around nem backreferences, por design: em troca, a busca sempre leva tempo linear.
Teste o que aprendeu
Seção intitulada “Teste o que aprendeu”Teste o que aprendeu
1. Por que os padrões de regex em Rust normalmente começam com r, como em r"\d+"?
2. Com o padrão (?<y>\d{4})-(?<m>\d{2}), o que caps["m"] dá para o texto 2024-05?
3. Por que o crate regex não tem suporte a look-around e backreferences?
4. Qual é o melhor lugar para chamar Regex::new com um padrão que você usa em cada linha de um arquivo grande?
Pontuação: 0 / 4
Para saber mais
Seção intitulada “Para saber mais”- Documentação do crate
regex— docs.rs (em inglês): o guia oficial, com muitos exemplos e a referência completa da sintaxe. - API do
Regex— docs.rs (em inglês): todos os métodos, incluindocaptures_iter,replacenesplit. LazyLock— documentação da biblioteca padrão (em inglês): como funcionam os statics inicializados sob demanda.- Regular Expressions — Rust Cookbook (em inglês): receitas curtas para extrair hashtags, telefones, e-mails e mais.