Salta ai contenuti

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.

Nella cartella del progetto, esegui:

Terminal window
cargo add regex

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

true
false
▶ Provalo tu

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

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 group
▶ Provalo tu

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

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
▶ Provalo tu

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

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-17
anno: 2024
mese: 05
giorno: 17
17/05/2024
02/06/2024
▶ Provalo tu

Tre 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 un Option, ed è la scelta sicura quando un gruppo è facoltativo.

captures trova il primo riscontro. captures_iter percorre tutti i riscontri, proprio come find_iter.

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 inutili
Dal 17/05/2024 al 2024-06-02
Dal 17/05/2024 al 02/06/2024
Dal 2024 al 2024
Dal 05_2024 al 06_2024
▶ Provalo tu

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

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"]
▶ Provalo tu

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

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

Output:

not-an-email -> false
x@y -> false
▶ Provalo tu

I pezzi nuovi in questo pattern:

  • (?i) attiva il riconoscimento senza distinzione tra maiuscole e minuscole, quindi [a-z] riconosce anche B e M.
  • ^ e $ significano «inizio del testo» e «fine del testo». Senza di loro, is_match direbbe 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.

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 in
2024-05-17 09:15:44 ERROR payment failed for [email protected]
questa riga non ha senso
2024-05-18 11:02:10 WARN disk 91% full
2024-05-18 11:05:00 ERROR could not email [email protected] or [email protected]";
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 trovati
▶ Provalo tu

Alcune note:

  • Il "\ all’inizio di log è 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 con continue.
  • (?<level>[A-Z]+) + riconosce il livello e poi uno o più spazi, perché INFO e WARN hanno uno spazio in più per allinearsi con ERROR.
  • In un programma vero leggeresti il log da un file, riga per riga. Vedi Lettura e scrittura di file.

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 supported

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

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

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

  • Aggiungi il crate con cargo add regex e scrivi i pattern come stringhe grezze: r"\d+".
  • Regex::new restituisce un Result, perché un pattern può non essere valido.
  • is_match dice sì o no; find/find_iter danno il testo riconosciuto; captures/captures_iter danno i gruppi per numero o per (?<name>...).
  • replace e replace_all possono inserire i gruppi con $name; usa ${name} quando seguono lettere o _.
  • Compila un pattern una volta sola e riusalo, per esempio in una static LazyLock.
  • Niente look-around né riferimenti all’indietro, per scelta: in cambio, il riconoscimento richiede sempre un tempo lineare.

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