Expresiones regulares en Rust
Una expresión regular (en inglés regular expression, o regex para abreviar) es un pequeño lenguaje de patrones para texto. En vez de decir “busca la palabra gato”, puedes decir “busca cuatro dígitos, un guion, dos dígitos, un guion, dos dígitos”, y encontrar de golpe cualquier fecha como 2024-05-17.
La biblioteca estándar de Rust tiene métodos de cadena muy útiles como contains, split y find (consulta Strings). Cuando un trozo de texto fijo no basta y necesitas una forma de texto, usa el crate regex (en inglés). Lo mantiene el propio proyecto Rust y es muy rápido.
Añadir regex y tu primera coincidencia
Sección titulada «Añadir regex y tu primera coincidencia»En la carpeta de tu proyecto, ejecuta:
cargo add regexEso añade regex = "1" bajo [dependencies] en tu Cargo.toml. (¿Los crates son nuevos para ti? Consulta Cargo y crates.) Los botones para probar el código abren el Playground de Rust, que ya tiene regex.
use regex::Regex;
fn main() { let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Publicado el 2024-05-17.")); println!("{}", re.is_match("Publicado la primavera pasada."));}Salida:
truefalse\d significa “cualquier dígito” y {4} significa “exactamente cuatro de lo anterior”. is_match responde a una sola pregunta: ¿hay una coincidencia en algún lugar del texto?
Fíjate en la r delante del patrón: r"\d{4}". Es una cadena raw (raw string, cadena literal sin escapes). En una cadena normal de Rust, \ empieza una secuencia de escape como \n (salto de línea). Los patrones de regex están llenos de barras invertidas, así que las cadenas raw te ahorran escribir "\\d{4}". Usa siempre cadenas raw para los patrones.
Por qué Regex::new devuelve un Result
Sección titulada «Por qué Regex::new devuelve un Result»Un patrón es un pequeño programa, y puede tener errores, como un paréntesis que olvidaste cerrar. Regex::new comprueba el patrón y devuelve un Result: Ok(Regex) si es válido, o Err con un mensaje útil si no lo es:
use regex::Regex;
fn main() { match Regex::new(r"(\d+") { Ok(_) => println!("patrón válido"), Err(e) => println!("patrón incorrecto:\n{e}"), }}Salida:
patrón incorrecto:regex parse error: (\d+ ^error: unclosed groupEl mensaje de error lo escribe el crate regex, y está en inglés: dice que hay un grupo sin cerrar (unclosed group) y señala con ^ dónde empieza.
Cuando el patrón está escrito en tu código y sabes que es correcto, .unwrap() está bien: si estuviera mal, lo descubrirías la primera vez que se ejecute el programa. Cuando el patrón viene de un usuario (por ejemplo, de un cuadro de búsqueda), maneja el Err en vez de dejar que el programa falle.
Buscar coincidencias: find y find_iter
Sección titulada «Buscar coincidencias: find y find_iter»find devuelve la primera coincidencia como un Option. find_iter te da un iterador sobre todas las coincidencias:
use regex::Regex;
fn main() { let re = Regex::new(r"\d+").unwrap(); let text = "3 manzanas, 12 peras y 7 ciruelas";
if let Some(m) = re.find(text) { println!("primer número: {} (en los bytes {}..{})", m.as_str(), m.start(), m.end()); }
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); println!("todos los números: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum(); println!("frutas en total: {total}");}Salida:
primer número: 3 (en los bytes 0..1)todos los números: ["3", "12", "7"]frutas en total: 22\d+ significa “uno o más dígitos”, así que 12 se encuentra como una sola coincidencia, no como dos. Cada coincidencia conoce su texto (as_str()) y su posición (start() y end(), en bytes).
Grupos de captura y grupos con nombre
Sección titulada «Grupos de captura y grupos con nombre»A menudo no quieres solo la coincidencia, sino trozos de ella. Rodea una parte del patrón con paréntesis para capturarla. Ponle un nombre con (?<nombre>...) para que tu código se lea mejor:
use regex::Regex;
fn main() { let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap(); let text = "La copia de seguridad se hizo el 2024-05-17 y el 2024-06-02.";
if let Some(caps) = re.captures(text) { println!("coincidencia completa: {}", &caps[0]); println!("año: {}", &caps["year"]); println!("mes: {}", &caps[2]); println!("día: {}", caps.name("day").unwrap().as_str()); }
for caps in re.captures_iter(text) { println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]); }}Salida:
coincidencia completa: 2024-05-17año: 2024mes: 05día: 1717/05/202402/06/2024Tres formas de leer un grupo:
caps[0]es siempre la coincidencia completa. Los grupos se numeran desde 1, de izquierda a derecha:caps[2]es el mes.caps["year"]lee un grupo por su nombre. Indexar con[...]provoca un panic si ese grupo no participó en la coincidencia.caps.name("day")devuelve unOption, que es la opción segura cuando un grupo es opcional.
captures encuentra la primera coincidencia. captures_iter recorre todas las coincidencias, igual que find_iter.
Reemplazar texto
Sección titulada «Reemplazar texto»replace cambia la primera coincidencia, y replace_all cambia todas. En el texto de reemplazo, $nombre (o $1) inserta lo que capturó un grupo:
use regex::Regex;
fn main() { let spaces = Regex::new(r"\s+").unwrap(); println!("{}", spaces.replace_all("demasiados espacios aquí", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap(); let text = "Del 2024-05-17 al 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}"));}Salida:
demasiados espacios aquíDel 17/05/2024 al 2024-06-02Del 17/05/2024 al 02/06/2024Del 2024 al 2024Del 05_2024 al 06_2024Mira la cuarta línea. Imprimió Del 2024 al 2024, no 05_2024. ¿Por qué? El nombre de un grupo puede contener _, así que $m_ se leyó como un grupo llamado m_. Ese grupo no existe, así que se convirtió en texto vacío. Pon el nombre entre llaves, ${m}_, para decir exactamente dónde termina. Si tienes dudas, usa llaves.
\s+ significa “uno o más caracteres de espacio en blanco”, así que cambiarlo por un solo espacio es una forma clásica de ordenar una entrada desordenada.
Dividir texto
Sección titulada «Dividir texto»split corta una cadena allí donde coincide el patrón. Es como str::split, pero el separador puede ser un patrón:
use regex::Regex;
fn main() { let sep = Regex::new(r"\s*[,;]\s*").unwrap(); let parts: Vec<&str> = sep.split("manzana, pera;cereza , uva").collect(); println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect(); println!("{:?}", two);}Salida:
["manzana", "pera", "cereza", "uva"]["a", "b, c, d"][,;] significa “una coma o un punto y coma”, y el \s* de cada lado se traga los espacios que haya alrededor. splitn(text, 2) se detiene después de dos trozos.
Compila una vez, no dentro de un bucle
Sección titulada «Compila una vez, no dentro de un bucle»Construir un Regex es la parte lenta: el crate convierte tu patrón en un buscador eficiente. Usarlo es rápido. Así que nunca llames a Regex::new dentro de un bucle o de una función que se ejecuta a menudo. Constrúyelo una vez y reutilízalo.
Para un patrón que usas en todo tu programa, ponlo en un static con std::sync::LazyLock. Se construye la primera vez que lo usas, y después se comparte:
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)); }}Salida:
[email protected] -> true[email protected] -> trueno-es-un-email -> falsex@y -> falsePiezas nuevas en este patrón:
(?i)activa la búsqueda sin distinguir mayúsculas y minúsculas, así que[a-z]también coincide conByM.^y$significan “inicio del texto” y “final del texto”. Sin ellos,is_matchdiría que sí a"hola [email protected]!!", porque el email aparece en algún lugar.\.es un punto de verdad. Un.normal significa “cualquier carácter”.
Esto comprueba si algo “parece un email”, y sirve para detectar errores al escribir. Validar de verdad cualquier dirección de email legal con una regex es famosamente difícil, así que la única prueba real es enviarle un correo.
Ejemplo: sacar datos de líneas de log
Sección titulada «Ejemplo: sacar datos de líneas de log»Vamos a juntarlo todo. Leemos un log, nos saltamos las líneas que no tienen el formato correcto y, por cada línea ERROR, imprimimos la fecha, la hora y las direcciones de email que aparezcan en el mensaje:
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 el usuario [email protected] inició sesión2024-05-17 09:15:44 ERROR falló el pago de [email protected]esta línea es basura2024-05-18 11:02:10 WARN disco lleno al 91%
let mut errors = 0; for line in log.lines() { let Some(caps) = LOG_LINE.captures(line) else { println!("omitida: {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} errores encontrados");}Salida:
2024-05-17 09:15:44 -> ["[email protected]"]omitida: "esta línea es basura"2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]2 errores encontradosAlgunas notas:
- El
"\al principio deloges un truco de Rust: una barra invertida al final de una línea dentro de una cadena se salta el salto de línea, así que la primera línea del log empieza enseguida. let Some(caps) = ... else { ... }es let-else: si la línea no coincide, la imprimimos y hacemoscontinuepara pasar a la siguiente.(?<level>[A-Z]+) +coincide con el nivel y luego con uno o más espacios, porqueINFOyWARNllevan un espacio extra para quedar alineados conERROR.- En un programa real leerías el log de un archivo, línea a línea. Consulta Archivos (File I/O).
Lo que regex no hace (a propósito)
Sección titulada «Lo que regex no hace (a propósito)»Si has usado regex en JavaScript, Python o Perl, quizá busques los look-around ((?=...), (?<=...), mirar lo que hay delante o detrás) o las referencias hacia atrás (backreferences: \1, “el mismo texto que el grupo 1”). El crate regex no los admite, y te lo dice:
regex parse error: foo(?=bar) ^^^error: look-around, including look-ahead and look-behind, is not supportedEl mensaje dice que los look-around (tanto look-ahead como look-behind) no están soportados. Es un intercambio deliberado. Esas funciones pueden hacer que algunos patrones tarden un tiempo exponencial con ciertas entradas, así que un patrón corto y una cadena lo bastante larga pueden congelar un programa (se llama catastrophic backtracking, retroceso catastrófico, y ha tumbado sitios web reales). Sin ellas, el crate regex puede garantizar que el tiempo de búsqueda crece solo en proporción a la longitud del texto (multiplicada por el tamaño del patrón). El doble de texto significa más o menos el doble de trabajo, nunca una explosión. Eso lo hace seguro incluso con patrones o textos que vienen de los usuarios.
Normalmente puedes conseguir lo mismo de otra forma: usa un grupo de captura y lee solo la parte que quieres, o haz una segunda comprobación sencilla en código Rust normal. Si de verdad necesitas esas funciones, el crate fancy-regex (en inglés) las añade, pero sin la garantía de tiempo.
Chuleta
Sección titulada «Chuleta»| Patrón | Coincide con |
|---|---|
. |
cualquier carácter excepto un salto de línea |
\d \w \s |
un dígito, un carácter de “palabra” (letra, dígito, _), un espacio en blanco |
\D \W \S |
lo contrario: no dígito, no carácter de palabra, no espacio en blanco |
[abc] [a-z] [^0-9] |
una de a, b, c; cualquier letra minúscula; cualquier cosa menos un dígito |
* + ? |
cero o más, uno o más, cero o uno de lo anterior |
{3} {2,5} {2,} |
exactamente 3, de 2 a 5, al menos 2 |
^ $ |
inicio y final del texto |
\b |
un límite de palabra (entre un carácter de palabra y uno que no lo es) |
(...) (?<name>...) |
un grupo numerado, un grupo con nombre |
(?i) |
sin distinguir mayúsculas y minúsculas desde aquí |
Uno más: una barra vertical significa “o”. Por ejemplo, gato|perro coincide con cualquiera de las dos palabras, y gr(a|e)y coincide con gray y con grey. Para que un carácter especial coincida tal cual, ponle una barra invertida delante: \., \(, \$.
Errores comunes
Sección titulada «Errores comunes»Olvidar la cadena raw. En una cadena normal, \d no es una secuencia de escape válida, así que Rust se detiene antes de que el crate regex llegue a ver tu patrón:
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(); | +El compilador dice que \d es un escape desconocido e incluso te sugiere la solución: añadir r delante.
Construir la regex dentro de un bucle. Esto compila y funciona, pero es lento, porque el patrón se vuelve a compilar en cada línea:
for line in lines { let re = Regex::new(r"\d+").unwrap(); // se compila una y otra vez if re.is_match(line) { /* ... */ }}Mueve Regex::new encima del bucle, o a un static con LazyLock, como vimos antes.
Olvidar ^ y $ al validar. is_match busca el patrón en cualquier lugar. Para comprobar que la entrada completa tiene una forma, ancla el patrón por los dos extremos.
Resumen
Sección titulada «Resumen»- Añade el crate con
cargo add regexy escribe los patrones como cadenas raw:r"\d+". Regex::newdevuelve unResult, porque un patrón puede ser inválido.is_matchdice sí o no;find/find_iterdan el texto encontrado;captures/captures_iterdan los grupos por número o por(?<name>...).replaceyreplace_allpueden insertar grupos con$name; usa${name}cuando detrás vengan letras o_.- Compila un patrón una vez y reutilízalo, por ejemplo en un
staticconLazyLock. - Sin look-around ni referencias hacia atrás, por diseño: a cambio, la búsqueda siempre tarda un tiempo lineal.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. ¿Por qué los patrones de regex en Rust suelen empezar con r, como en r"\d+"?
2. Con el patrón (?<y>\d{4})-(?<m>\d{2}), ¿qué da caps["m"] para el texto 2024-05?
3. ¿Por qué el crate regex no admite look-around ni referencias hacia atrás?
4. ¿Dónde es mejor llamar a Regex::new para un patrón que usas en cada línea de un archivo grande?
Puntuación: 0 / 4
Para leer más
Sección titulada «Para leer más»- Documentación del crate
regex— docs.rs (en inglés): la guía oficial, con muchos ejemplos y la referencia completa de la sintaxis. - API de
Regex— docs.rs (en inglés): todos los métodos, incluidoscaptures_iter,replacenysplit. LazyLock— documentación de la biblioteca estándar (en inglés): cómo funcionan losstaticque se inicializan de forma perezosa.- Regular Expressions — Rust Cookbook (en inglés): recetas cortas para extraer hashtags, números de teléfono, emails y más.