Pular para o conteúdo

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.

Na pasta do seu projeto, rode:

Terminal window
cargo add regex

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

true
false
▶ Experimente você mesmo

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

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 group
▶ Experimente você mesmo

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

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
▶ Experimente você mesmo

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

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-17
ano: 2024
mês: 05
dia: 17
17/05/2024
02/06/2024
▶ Experimente você mesmo

Trê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 um Option, que é a escolha segura quando um grupo é opcional.

captures encontra o primeiro match. captures_iter percorre todos os matches, igualzinho ao find_iter.

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 aqui
De 17/05/2024 a 2024-06-02
De 17/05/2024 a 02/06/2024
De 2024 a 2024
De 05_2024 a 06_2024
▶ Experimente você mesmo

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

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"]
▶ Experimente você mesmo

[,;] 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.

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() {
for input in ["[email protected]", "[email protected]", "not-an-email", "x@y"] {
println!("{input:22} -> {}", is_email(input));
}
}

Saída:

not-an-email -> false
x@y -> false
▶ Experimente você mesmo

Os 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 aceita B e M.
  • ^ e $ significam “início do texto” e “fim do texto”. Sem eles, is_match diria 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.

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] entrou
2024-05-17 09:15:44 ERROR pagamento falhou para [email protected]
esta linha é lixo
2024-05-18 11:02:10 WARN disco 91% cheio
2024-05-18 11:05:00 ERROR não deu para mandar e-mail para [email protected] ou [email protected]";
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 encontrados
▶ Experimente você mesmo

Algumas observações:

  • O "\ no começo de log é 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 usamos continue para ir à próxima.
  • (?<level>[A-Z]+) + encontra o nível e depois um ou mais espaços, porque INFO e WARN têm um espaço extra para ficarem alinhados com ERROR.
  • Em um programa de verdade, você leria o log de um arquivo, linha por linha. Veja Leitura e escrita de arquivos.

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 supported

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

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: \., \(, \$.

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 regex e escreva os padrões como raw strings: r"\d+".
  • Regex::new devolve um Result, porque um padrão pode ser inválido.
  • is_match responde sim ou não; find/find_iter dão o texto encontrado; captures/captures_iter dão os grupos por número ou por (?<nome>...).
  • replace e replace_all podem inserir grupos com $nome; use ${nome} quando vierem letras ou _ logo depois.
  • Compile um padrão uma vez e reutilize, por exemplo em um static com LazyLock.
  • Sem look-around nem backreferences, por design: em troca, a busca sempre leva tempo linear.

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