Espressioni regolari in Rust con regex
Un’espressione regolare (in breve regex) è un minuscolo linguaggio di pattern per il testo. Invece di dire «trova la parola gatto», puoi dire «trova quattro cifre, un trattino, due cifre, un trattino, due cifre», e riconoscere in un colpo solo ogni data come 2024-05-17.
La libreria standard di Rust ha comodi metodi per le stringhe come contains, split e find (vedi Stringhe). Quando un pezzo di testo fisso non basta, e ti serve una forma di testo, usa il crate regex (in inglese). È mantenuto dal progetto Rust ed è velocissimo.
Aggiungere regex e il primo riscontro
Sezione intitolata “Aggiungere regex e il primo riscontro”Nella cartella del progetto, esegui:
cargo add regexQuesto aggiunge regex = "1" sotto [dependencies] nel tuo Cargo.toml. (Sei alle prime armi con i crate? Vedi Cargo e crate.) I pulsanti «Prova» aprono il Rust Playground, che ha già regex.
use regex::Regex;
fn main() { let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Uscito il 2024-05-17.")); println!("{}", re.is_match("Uscito la primavera scorsa."));}Output:
truefalse\d significa «una cifra qualsiasi» e {4} significa «esattamente quattro volte l’elemento precedente». is_match risponde a una sola domanda: c’è un riscontro da qualche parte nel testo?
Nota la r prima del pattern: r"\d{4}". È una stringa grezza (raw string). In una normale stringa Rust, \ inizia una sequenza di escape come \n (a capo). I pattern regex sono pieni di backslash, quindi le stringhe grezze ti evitano di scrivere "\\d{4}". Usa sempre le stringhe grezze per i pattern.
Perché Regex::new restituisce un Result
Sezione intitolata “Perché Regex::new restituisce un Result”Un pattern è un piccolo programma, e può contenere errori, come una parentesi che hai dimenticato di chiudere. Regex::new controlla il pattern e restituisce un Result: Ok(Regex) se è valido, Err con un messaggio utile se non lo è:
use regex::Regex;
fn main() { match Regex::new(r"(\d+") { Ok(_) => println!("pattern valido"), Err(e) => println!("pattern sbagliato:\n{e}"), }}Output:
pattern sbagliato:regex parse error: (\d+ ^error: unclosed groupIl messaggio del crate resta in inglese: dice che c’è un gruppo non chiuso. Quando il pattern è scritto nel tuo codice e sai che è corretto, .unwrap() va bene: se è sbagliato, te ne accorgi la prima volta che esegui il programma. Quando il pattern arriva da un utente (per esempio da una casella di ricerca), gestisci l’Err invece di far andare in crash il programma.
Trovare i riscontri: find e find_iter
Sezione intitolata “Trovare i riscontri: find e find_iter”find restituisce il primo riscontro come Option. find_iter ti dà un iteratore su tutti i riscontri:
use regex::Regex;
fn main() { let re = Regex::new(r"\d+").unwrap(); let text = "3 mele, 12 pere e 7 prugne";
if let Some(m) = re.find(text) { println!("primo numero: {} (ai byte {}..{})", m.as_str(), m.start(), m.end()); }
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); println!("tutti i numeri: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum(); println!("frutti in totale: {total}");}Output:
primo numero: 3 (ai byte 0..1)tutti i numeri: ["3", "12", "7"]frutti in totale: 22\d+ significa «una o più cifre», quindi 12 viene trovato come un unico riscontro, non due. Ogni riscontro conosce il suo testo (as_str()) e la sua posizione (start() ed end(), in byte).
Gruppi di cattura e gruppi con nome
Sezione intitolata “Gruppi di cattura e gruppi con nome”Spesso non vuoi solo il riscontro, ma dei pezzi del riscontro. Racchiudi una parte del pattern tra parentesi per catturarla. Dagli un nome con (?<nome>...), così il tuo codice è più facile da leggere:
use regex::Regex;
fn main() { let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap(); let text = "Il backup è stato eseguito il 2024-05-17 e il 2024-06-02.";
if let Some(caps) = re.captures(text) { println!("riscontro intero: {}", &caps[0]); println!("anno: {}", &caps["year"]); println!("mese: {}", &caps[2]); println!("giorno: {}", caps.name("day").unwrap().as_str()); }
for caps in re.captures_iter(text) { println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]); }}Output:
riscontro intero: 2024-05-17anno: 2024mese: 05giorno: 1717/05/202402/06/2024Tre modi per leggere un gruppo:
caps[0]è sempre il riscontro intero. I gruppi sono numerati da 1, da sinistra a destra:caps[2]è il mese.caps["year"]legge un gruppo per nome. L’indicizzazione con[...]va in panic se quel gruppo non ha partecipato al riscontro.caps.name("day")restituisce unOption, ed è la scelta sicura quando un gruppo è facoltativo.
captures trova il primo riscontro. captures_iter percorre tutti i riscontri, proprio come find_iter.
Sostituire il testo
Sezione intitolata “Sostituire il testo”replace cambia il primo riscontro, e replace_all cambia tutti i riscontri. Nel testo di sostituzione, $nome (o $1) inserisce quello che un gruppo ha catturato:
use regex::Regex;
fn main() { let spaces = Regex::new(r"\s+").unwrap(); println!("{}", spaces.replace_all("troppi spazi inutili", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap(); let text = "Dal 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}"));}Output:
troppi spazi inutiliDal 17/05/2024 al 2024-06-02Dal 17/05/2024 al 02/06/2024Dal 2024 al 2024Dal 05_2024 al 06_2024Guarda la quarta riga. Ha stampato Dal 2024 al 2024, non 05_2024. Perché? Il nome di un gruppo può contenere _, quindi $m_ è stato letto come un gruppo chiamato m_. Quel gruppo non esiste, quindi è diventato testo vuoto. Metti il nome tra graffe, ${m}_, per dire esattamente dove finisce. Nel dubbio, usa le graffe.
\s+ significa «uno o più caratteri di spaziatura», quindi sostituirlo con un solo spazio è un modo classico per mettere ordine in un input disordinato.
Dividere il testo
Sezione intitolata “Dividere il testo”split taglia una stringa in ogni punto in cui il pattern trova un riscontro. È come str::split, ma il separatore può essere un pattern:
use regex::Regex;
fn main() { let sep = Regex::new(r"\s*[,;]\s*").unwrap(); let parts: Vec<&str> = sep.split("mela, banana;ciliegia , dattero").collect(); println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect(); println!("{:?}", two);}Output:
["mela", "banana", "ciliegia", "dattero"]["a", "b, c, d"][,;] significa «una virgola o un punto e virgola», e lo \s* su ogni lato inghiotte gli eventuali spazi attorno. splitn(text, 2) si ferma dopo due pezzi.
Compilare una volta, non in un ciclo
Sezione intitolata “Compilare una volta, non in un ciclo”Costruire una Regex è la parte lenta: il crate trasforma il tuo pattern in un riconoscitore efficiente. Usarla è veloce. Quindi non chiamare mai Regex::new dentro un ciclo o in una funzione che gira spesso. Costruiscila una volta e riusala.
Per un pattern usato in tutto il programma, mettilo in una static con std::sync::LazyLock. Viene costruito la prima volta che lo usi, e poi condiviso:
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)); }}Output:
[email protected] -> true[email protected] -> truenot-an-email -> falsex@y -> falseI pezzi nuovi in questo pattern:
(?i)attiva il riconoscimento senza distinzione tra maiuscole e minuscole, quindi[a-z]riconosce ancheBeM.^e$significano «inizio del testo» e «fine del testo». Senza di loro,is_matchdirebbe sì a"hello [email protected]!!", perché l’email compare da qualche parte al suo interno.\.è un punto vero e proprio. Un.semplice significa «qualsiasi carattere».
Questo è un controllo del tipo «sembra un’email», utile per scovare errori di battitura. Validare davvero ogni indirizzo email ammesso con una regex è notoriamente difficile, quindi l’unico test vero è mandarci un’email.
Esempio: estrarre dati dalle righe di log
Sezione intitolata “Esempio: estrarre dati dalle righe di log”Mettiamo insieme tutto. Leggiamo un log, saltiamo le righe che non rispettano il formato e, per ogni riga ERROR, stampiamo la data, l’ora e gli eventuali indirizzi email nel messaggio:
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 user [email protected] logged in2024-05-17 09:15:44 ERROR payment failed for [email protected]questa riga non ha senso2024-05-18 11:02:10 WARN disk 91% full
let mut errors = 0; for line in log.lines() { let Some(caps) = LOG_LINE.captures(line) else { println!("saltata: {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} errori trovati");}Output:
2024-05-17 09:15:44 -> ["[email protected]"]saltata: "questa riga non ha senso"2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]2 errori trovatiAlcune note:
- Il
"\all’inizio dilogè un trucco di Rust: un backslash alla fine di una riga dentro una stringa salta l’a capo, così la prima riga del log comincia subito. let Some(caps) = ... else { ... }è il let-else: se la riga non corrisponde, la stampiamo e passiamo alla successiva concontinue.(?<level>[A-Z]+) +riconosce il livello e poi uno o più spazi, perchéINFOeWARNhanno uno spazio in più per allinearsi conERROR.- In un programma vero leggeresti il log da un file, riga per riga. Vedi Lettura e scrittura di file.
Cosa regex non fa (apposta)
Sezione intitolata “Cosa regex non fa (apposta)”Se hai usato le regex in JavaScript, Python o Perl, potresti cercare i look-around ((?=...), (?<=...)) o i riferimenti all’indietro (backreference: \1, «lo stesso testo del gruppo 1»). Il crate regex non li supporta, e te lo dice:
regex parse error: foo(?=bar) ^^^error: look-around, including look-ahead and look-behind, is not supportedIl messaggio dice che il look-around, compresi look-ahead e look-behind, non è supportato. È uno scambio voluto. Queste funzionalità possono far impiegare a certi pattern un tempo esponenziale su determinati input, quindi un pattern corto e una stringa abbastanza lunga possono bloccare un programma (si chiama catastrophic backtracking, e ha messo fuori uso siti web veri). Senza di esse, il crate regex può garantire che il tempo di riconoscimento cresca solo in proporzione alla lunghezza del testo (moltiplicata per la dimensione del pattern). Il doppio del testo significa all’incirca il doppio del lavoro, mai un’esplosione. Questo lo rende sicuro anche per pattern o testi che arrivano dagli utenti.
Di solito puoi ottenere lo stesso risultato in un altro modo: usa un gruppo di cattura e leggi solo la parte che ti interessa, oppure fai un secondo controllo semplice in normale codice Rust. Se ti servono davvero queste funzionalità, il crate fancy-regex (in inglese) le aggiunge, senza la garanzia sul tempo.
Promemoria
Sezione intitolata “Promemoria”| Pattern | Riconosce |
|---|---|
. |
qualsiasi carattere tranne l’a capo |
\d \w \s |
una cifra, un carattere «di parola» (lettera, cifra, _), una spaziatura |
\D \W \S |
il contrario: non una cifra, non un carattere di parola, non una spaziatura |
[abc] [a-z] [^0-9] |
uno tra a, b, c; qualsiasi lettera minuscola; qualsiasi cosa tranne una cifra |
* + ? |
zero o più, uno o più, zero o uno dell’elemento precedente |
{3} {2,5} {2,} |
esattamente 3, da 2 a 5, almeno 2 |
^ $ |
inizio e fine del testo |
\b |
un confine di parola (tra un carattere di parola e uno che non lo è) |
(...) (?<name>...) |
un gruppo numerato, un gruppo con nome |
(?i) |
senza distinzione tra maiuscole e minuscole da qui in poi |
Un’ultima cosa: la barra verticale significa «oppure». Per esempio, cat|dog riconosce l’una o l’altra parola, e gr(a|e)y riconosce gray e grey. Per riconoscere letteralmente un carattere speciale, mettici davanti un backslash: \., \(, \$.
Errori comuni
Sezione intitolata “Errori comuni”Dimenticare la stringa grezza. In una stringa normale, \d non è un escape valido, quindi Rust si ferma prima ancora che il crate regex veda il tuo pattern:
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(); | +Il compilatore segnala un carattere di escape sconosciuto e suggerisce persino la soluzione: aggiungi una r davanti.
Costruire la regex dentro un ciclo. Questo compila e funziona, ma è lento, perché il pattern viene ricompilato per ogni riga:
for line in lines { let re = Regex::new(r"\d+").unwrap(); // ricompilata ancora e ancora if re.is_match(line) { /* ... */ }}Sposta Regex::new sopra il ciclo, oppure in una static LazyLock come visto prima.
Dimenticare ^ e $ quando validi. is_match cerca il pattern ovunque. Per controllare che l’input intero abbia una certa forma, ancora il pattern a entrambe le estremità.
Riepilogo
Sezione intitolata “Riepilogo”- Aggiungi il crate con
cargo add regexe scrivi i pattern come stringhe grezze:r"\d+". Regex::newrestituisce unResult, perché un pattern può non essere valido.is_matchdice sì o no;find/find_iterdanno il testo riconosciuto;captures/captures_iterdanno i gruppi per numero o per(?<name>...).replaceereplace_allpossono inserire i gruppi con$name; usa${name}quando seguono lettere o_.- Compila un pattern una volta sola e riusalo, per esempio in una
staticLazyLock. - Niente look-around né riferimenti all’indietro, per scelta: in cambio, il riconoscimento richiede sempre un tempo lineare.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Perché in Rust i pattern regex di solito iniziano con r, come in r"\d+"?
2. Con il pattern (?<y>\d{4})-(?<m>\d{2}), cosa dà caps["m"] per il testo 2024-05?
3. Perché il crate regex non supporta look-around e riferimenti all'indietro?
4. Dov'è il posto migliore per chiamare Regex::new per un pattern che usi su ogni riga di un file grande?
Punteggio: 0 / 4
Per approfondire
Sezione intitolata “Per approfondire”- Documentazione del crate
regex— docs.rs (in inglese): la guida ufficiale, con tanti esempi e il riferimento completo della sintassi. - API di
Regex— docs.rs (in inglese): ogni metodo, compresicaptures_iter,replacenesplit. LazyLock— documentazione della libreria standard (in inglese): come funzionano le static inizializzate in modo pigro.- Regular Expressions — Rust Cookbook (in inglese): ricette brevi per estrarre hashtag, numeri di telefono, email e altro.