El crate regex en Rust: expresiones regulares con rendimiento y seguridad garantizados

El crate regex es la solución estándar para expresiones regulares en Rust. A diferencia de los motores de regex de muchos lenguajes, este no usa backtracking: está basado en autómatas finitos, lo que garantiza tiempo de ejecución O(n) respecto al tamaño del texto, sin importar la complejidad de la expresión. Esto hace imposible el ataque ReDoS (Regular Expression Denial of Service).

[dependencies]
regex = "1"

Crear y usar un Regex básico

use regex::Regex;

fn main() {
    let re = Regex::new(r"bd{5}b").unwrap();

    let texto = "El código postal de Madrid es 28001 y el de Barcelona 08001.";

    if re.is_match(texto) {
        println!("Encontrado un código postal");
    }

    // Encontrar la primera coincidencia
    if let Some(m) = re.find(texto) {
        println!("Primera coincidencia: '{}' en posición {}", m.as_str(), m.start());
    }
}

El prefijo r en las raw strings (r"bd{5}b") evita tener que escapar las barras. Sin él, habría que escribir "\b\d{5}\b".

Compilar el regex una sola vez con OnceLock

Compilar un regex es costoso. Si lo llamas en un loop o en una función que se ejecuta muchas veces, deberías compilarlo una sola vez y reutilizarlo. La forma moderna es con OnceLock (desde Rust 1.70) o el crate once_cell en versiones anteriores:

use std::sync::OnceLock;
use regex::Regex;

fn regex_codigo_postal() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"bd{5}b").unwrap())
}

fn contiene_codigo_postal(texto: &str) -> bool {
    regex_codigo_postal().is_match(texto)
}

También existe el crate lazy_regex que simplifica esto con una macro:

[dependencies]
lazy-regex = "3"
use lazy_regex::regex;

fn contiene_email(texto: &str) -> bool {
    regex!(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}").is_match(texto)
}

Capturas y grupos nombrados

Los grupos de captura se delimitan con paréntesis. Los grupos nombrados usan la sintaxis (?P<nombre>...):

use regex::Regex;

fn parsear_fecha(fecha: &str) -> Option<(u32, u32, u32)> {
    let re = Regex::new(
        r"(?P<dia>d{2})/(?P<mes>d{2})/(?P<anio>d{4})"
    ).unwrap();

    let caps = re.captures(fecha)?;

    let dia: u32  = caps["dia"].parse().ok()?;
    let mes: u32  = caps["mes"].parse().ok()?;
    let anio: u32 = caps["anio"].parse().ok()?;

    Some((dia, mes, anio))
}

fn main() {
    if let Some((dia, mes, anio)) = parsear_fecha("15/03/2024") {
        println!("{}/{}/{}", dia, mes, anio);  // 15/3/2024
    }
}

También puedes acceder a los grupos por índice (el grupo 0 es siempre la coincidencia completa):

let re = Regex::new(r"(d{2})/(d{2})/(d{4})").unwrap();
if let Some(caps) = re.captures("15/03/2024") {
    println!("Día: {}", &caps[1]);
    println!("Mes: {}", &caps[2]);
    println!("Año: {}", &caps[3]);
}

find_iter y captures_iter

Para encontrar todas las coincidencias en un texto, usa find_iter() o captures_iter():

use regex::Regex;

fn extraer_numeros(texto: &str) -> Vec<i64> {
    let re = Regex::new(r"-?d+").unwrap();
    re.find_iter(texto)
        .filter_map(|m| m.as_str().parse().ok())
        .collect()
}

fn extraer_emails(texto: &str) -> Vec<String> {
    let re = Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}").unwrap();
    re.find_iter(texto)
        .map(|m| m.as_str().to_string())
        .collect()
}

fn main() {
    let texto = "Los precios son: 100, -25 y 1337 euros.";
    println!("{:?}", extraer_numeros(texto));  // [100, -25, 1337]

    let texto = "Contacto: [email protected] y [email protected]";
    println!("{:?}", extraer_emails(texto));
}

replace y replace_all

use regex::Regex;

fn anonimizar_emails(texto: &str) -> String {
    let re = Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}").unwrap();
    re.replace_all(texto, "[EMAIL]").to_string()
}

fn normalizar_espacios(texto: &str) -> String {
    let re = Regex::new(r"s+").unwrap();
    re.replace_all(texto.trim(), " ").to_string()
}

// Con una función de reemplazo
fn mayusculas_grupos(texto: &str) -> String {
    let re = Regex::new(r"bw+b").unwrap();
    re.replace_all(texto, |caps: ®ex::Captures| {
        caps[0].to_uppercase()
    }).to_string()
}

println!("{}", anonimizar_emails("Escríbeme a [email protected]"));
// "Escríbeme a [EMAIL]"

println!("{}", normalizar_espacios("  mucho   espacio   extra  "));
// "mucho espacio extra"

RegexSet: múltiples patrones eficientes

RegexSet permite comprobar si un texto coincide con múltiples patrones en una sola pasada, más eficiente que comprobarlos uno a uno:

use regex::RegexSet;

fn clasificar_texto(texto: &str) -> Vec<&'static str> {
    let set = RegexSet::new([
        r"d{5}",                    // código postal
        r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}",  // email
        r"https?://S+",             // URL
        r"+?[ds-]{9,}",         // teléfono
    ]).unwrap();

    let nombres = ["código postal", "email", "URL", "teléfono"];
    let coincidencias = set.matches(texto);

    coincidencias.into_iter()
        .map(|i| nombres[i])
        .collect()
}

println!("{:?}", clasificar_texto("Email: [email protected], CP: 28001"));
// ["código postal", "email"]

Syntax de Rust regex: referencia rápida

El crate regex implementa la mayoría de la sintaxis Perl-compatible, con algunas diferencias:

  • d, w, s: dígito, palabra, espacio.
  • b: límite de palabra.
  • [abc], [^abc]: clase de caracteres.
  • a{2,5}: cuantificadores.
  • (?i): insensible a mayúsculas.
  • (?m): modo multilínea (^ y $ coinciden con inicio/fin de línea).
  • (?s): modo dotall (. coincide con n).
  • (?x): modo extendido (ignora espacios y permite comentarios).

Lo que no está: lookaheads/lookbehinds, backreferences y grupos atómicos. Estas construcciones requieren backtracking y por eso están excluidas para mantener la garantía de tiempo lineal. Si necesitas esas características, existe el crate fancy-regex que las soporta (con las penalizaciones de rendimiento correspondientes).

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP