Les expressions régulières en Rust avec regex
Une expression régulière (en abrégé regex) est un petit langage de motifs pour le texte. Au lieu de dire « trouve le mot chat », vous pouvez dire « trouve quatre chiffres, un tiret, deux chiffres, un tiret, deux chiffres », et repérer d’un coup toutes les dates comme 2024-05-17.
La bibliothèque standard de Rust propose des méthodes pratiques sur les chaînes, comme contains, split et find (voir Les chaînes de caractères). Quand un morceau de texte fixe ne suffit plus et que vous cherchez une forme de texte, utilisez la crate regex (en anglais). Elle est maintenue par le projet Rust et elle est très rapide.
Ajouter regex et trouver votre première correspondance
Section intitulée « Ajouter regex et trouver votre première correspondance »Dans le dossier de votre projet, lancez :
cargo add regexCela ajoute regex = "1" sous [dependencies] dans votre Cargo.toml. (Les crates sont nouvelles pour vous ? Voir Cargo et les crates.) Les boutons « Essayer » ouvrent le Playground Rust, qui inclut déjà regex.
use regex::Regex;
fn main() { let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("Publié le 2024-05-17.")); println!("{}", re.is_match("Publié au printemps dernier."));}Sortie :
truefalse\d signifie « n’importe quel chiffre » et {4} signifie « exactement quatre fois l’élément précédent ». is_match répond à une seule question : y a-t-il une correspondance quelque part dans le texte ?
Remarquez le r devant le motif : r"\d{4}". C’est une chaîne brute (raw string). Dans une chaîne Rust normale, \ commence une séquence d’échappement comme \n (retour à la ligne). Les motifs regex sont pleins de barres obliques inverses, et les chaînes brutes vous évitent d’écrire "\\d{4}". Utilisez toujours des chaînes brutes pour vos motifs.
Pourquoi Regex::new renvoie un Result
Section intitulée « Pourquoi Regex::new renvoie un Result »Un motif est un petit programme, et il peut contenir des erreurs, comme une parenthèse que vous avez oublié de fermer. Regex::new vérifie le motif et renvoie un Result : Ok(Regex) s’il est valide, ou Err avec un message utile s’il ne l’est pas :
use regex::Regex;
fn main() { match Regex::new(r"(\d+") { Ok(_) => println!("motif valide"), Err(e) => println!("motif invalide :\n{e}"), }}Sortie :
motif invalide :regex parse error: (\d+ ^error: unclosed groupLe message d’erreur vient de la crate et reste en anglais : « erreur d’analyse de la regex : groupe non fermé », avec un ^ sous la parenthèse fautive.
Quand le motif est écrit dans votre code et que vous savez qu’il est correct, .unwrap() convient : s’il est faux, vous le découvrirez dès la première exécution. Quand le motif vient d’un utilisateur (par exemple d’un champ de recherche), gérez le Err au lieu de faire planter le programme.
Trouver des correspondances : find et find_iter
Section intitulée « Trouver des correspondances : find et find_iter »find renvoie la première correspondance sous forme d’Option. find_iter vous donne un itérateur sur toutes les correspondances :
use regex::Regex;
fn main() { let re = Regex::new(r"\d+").unwrap(); let text = "3 pommes, 12 poires et 7 prunes";
if let Some(m) = re.find(text) { println!("premier nombre : {} (octets {}..{})", m.as_str(), m.start(), m.end()); }
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); println!("tous les nombres : {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum(); println!("total des fruits : {total}");}Sortie :
premier nombre : 3 (octets 0..1)tous les nombres : ["3", "12", "7"]total des fruits : 22\d+ signifie « un chiffre ou plus », donc 12 est trouvé comme une seule correspondance, pas deux. Chaque correspondance connaît son texte (as_str()) et sa position (start() et end(), en octets).
Groupes de capture et groupes nommés
Section intitulée « Groupes de capture et groupes nommés »Souvent, vous ne voulez pas seulement la correspondance, mais des morceaux de celle-ci. Entourez une partie du motif de parenthèses pour la capturer. Donnez-lui un nom avec (?<nom>...) pour que votre code soit plus facile à lire :
use regex::Regex;
fn main() { let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap(); let text = "Sauvegarde effectuée le 2024-05-17 et le 2024-06-02.";
if let Some(caps) = re.captures(text) { println!("correspondance complète : {}", &caps[0]); println!("année : {}", &caps["year"]); println!("mois : {}", &caps[2]); println!("jour : {}", caps.name("day").unwrap().as_str()); }
for caps in re.captures_iter(text) { println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]); }}Sortie :
correspondance complète : 2024-05-17année : 2024mois : 05jour : 1717/05/202402/06/2024Trois façons de lire un groupe :
caps[0]est toujours la correspondance complète. Les groupes sont numérotés à partir de 1, de gauche à droite :caps[2]est le mois.caps["year"]lit un groupe par son nom. L’indexation avec[...]provoque une panic si ce groupe n’a pas participé à la correspondance.caps.name("day")renvoie uneOption: c’est le choix sûr quand un groupe est facultatif.
captures trouve la première correspondance. captures_iter parcourt toutes les correspondances, exactement comme find_iter.
Remplacer du texte
Section intitulée « Remplacer du texte »replace modifie la première correspondance, et replace_all modifie toutes les correspondances. Dans le texte de remplacement, $nom (ou $1) insère ce qu’un groupe a capturé :
use regex::Regex;
fn main() { let spaces = Regex::new(r"\s+").unwrap(); println!("{}", spaces.replace_all("beaucoup trop d'espaces", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap(); let text = "Du 2024-05-17 au 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}"));}Sortie :
beaucoup trop d'espacesDu 17/05/2024 au 2024-06-02Du 17/05/2024 au 02/06/2024Du 2024 au 2024Du 05_2024 au 06_2024Regardez la quatrième ligne. Elle affiche Du 2024 au 2024, et non 05_2024. Pourquoi ? Un nom de groupe peut contenir _, donc $m_ a été lu comme un groupe appelé m_. Ce groupe n’existe pas, il a donc été remplacé par du texte vide. Mettez le nom entre accolades, ${m}_, pour indiquer exactement où il s’arrête. Dans le doute, utilisez les accolades.
\s+ signifie « un caractère d’espacement ou plus » : le remplacer par une seule espace est une façon classique de nettoyer une saisie désordonnée.
Découper du texte
Section intitulée « Découper du texte »split coupe une chaîne partout où le motif correspond. C’est comme str::split, sauf que le séparateur peut être un motif :
use regex::Regex;
fn main() { let sep = Regex::new(r"\s*[,;]\s*").unwrap(); let parts: Vec<&str> = sep.split("pomme, banane;cerise , datte").collect(); println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect(); println!("{:?}", two);}Sortie :
["pomme", "banane", "cerise", "datte"]["a", "b, c, d"][,;] signifie « une virgule ou un point-virgule », et le \s* de chaque côté avale les espaces autour. splitn(text, 2) s’arrête après deux morceaux.
Compiler une fois, pas dans une boucle
Section intitulée « Compiler une fois, pas dans une boucle »Construire une Regex, c’est la partie lente : la crate transforme votre motif en un moteur de recherche efficace. L’utiliser, en revanche, est rapide. N’appelez donc jamais Regex::new dans une boucle ou dans une fonction appelée souvent. Construisez-la une fois et réutilisez-la.
Pour un motif utilisé un peu partout dans votre programme, placez-le dans un static avec std::sync::LazyLock. Il est construit la première fois que vous l’utilisez, puis partagé :
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)); }}Sortie :
[email protected] -> true[email protected] -> truenot-an-email -> falsex@y -> falseLes entrées restent en anglais : {input:22} aligne le texte sur 22 colonnes, et les lettres accentuées fausseraient facilement ce genre d’alignement.
Les nouveautés de ce motif :
(?i)active la recherche insensible à la casse, donc[a-z]correspond aussi àBetM.^et$signifient « début du texte » et « fin du texte ». Sans eux,is_matchrépondrait oui pour"bonjour [email protected] !!", car l’adresse apparaît quelque part dedans.\.est un vrai point. Un.seul signifie « n’importe quel caractère ».
C’est une vérification du type « ressemble à une adresse e-mail », pratique pour repérer les fautes de frappe. Valider vraiment toutes les adresses e-mail légales avec une regex est réputé très difficile : le seul vrai test consiste à envoyer un e-mail à l’adresse.
Exemple : extraire des données de lignes de log
Section intitulée « Exemple : extraire des données de lignes de log »Combinons tout cela. Nous lisons un journal (log), nous ignorons les lignes qui ne respectent pas le format, et pour chaque ligne ERROR, nous affichons la date, l’heure et les adresses e-mail présentes dans le message :
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 utilisateur [email protected] connecté2024-05-17 09:15:44 ERROR échec du paiement pour [email protected]cette ligne est illisible2024-05-18 11:02:10 WARN disque plein à 91 %
let mut errors = 0; for line in log.lines() { let Some(caps) = LOG_LINE.captures(line) else { println!("ignorée : {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} erreurs trouvées");}Sortie :
2024-05-17 09:15:44 -> ["[email protected]"]ignorée : "cette ligne est illisible"2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]2 erreurs trouvéesQuelques remarques :
- Le
"\au début delogest une astuce de Rust : une barre oblique inverse en fin de ligne, à l’intérieur d’une chaîne, saute le retour à la ligne, si bien que la première ligne du log commence tout de suite. let Some(caps) = ... else { ... }s’appelle let-else : si la ligne ne correspond pas, nous l’affichons et passons à la suivante aveccontinue.(?<level>[A-Z]+) +correspond au niveau puis à une ou plusieurs espaces, carINFOetWARNsont suivis d’une espace supplémentaire pour s’aligner surERROR.- Dans un vrai programme, vous liriez le log depuis un fichier, ligne par ligne. Voir Lire et écrire des fichiers.
Ce que regex ne fait pas (exprès)
Section intitulée « Ce que regex ne fait pas (exprès) »Si vous avez utilisé des regex en JavaScript, en Python ou en Perl, vous aurez peut-être envie d’utiliser les assertions avant et arrière (look-around : (?=...), (?<=...)) ou les références arrière (backreferences : \1, « le même texte que le groupe 1 »). La crate regex ne les prend pas en charge, et vous le dit :
regex parse error: foo(?=bar) ^^^error: look-around, including look-ahead and look-behind, is not supportedLe message (en anglais) indique que le look-around n’est pas pris en charge. C’est un compromis délibéré. Ces fonctionnalités peuvent faire prendre un temps exponentiel à certains motifs sur certaines entrées : un motif court et une chaîne assez longue suffisent alors à figer un programme (on parle de catastrophic backtracking, ou retour arrière catastrophique, et cela a déjà fait tomber de vrais sites web). Sans elles, la crate regex peut garantir que le temps de recherche n’augmente que proportionnellement à la longueur du texte (multipliée par la taille du motif). Deux fois plus de texte, c’est à peu près deux fois plus de travail, jamais une explosion. Elle reste donc sûre même pour des motifs ou des textes fournis par les utilisateurs.
En général, on peut obtenir le même résultat autrement : utilisez un groupe de capture et ne lisez que la partie qui vous intéresse, ou faites une deuxième vérification simple en Rust ordinaire. Si vous avez vraiment besoin de ces fonctionnalités, la crate fancy-regex (en anglais) les ajoute, mais sans la garantie de temps.
Aide-mémoire
Section intitulée « Aide-mémoire »| Motif | Correspond à |
|---|---|
. |
n’importe quel caractère sauf un retour à la ligne |
\d \w \s |
un chiffre, un caractère de « mot » (lettre, chiffre, _), un espacement |
\D \W \S |
l’inverse : pas un chiffre, pas un caractère de mot, pas un espacement |
[abc] [a-z] [^0-9] |
a, b ou c ; n’importe quelle lettre minuscule ; tout sauf un chiffre |
* + ? |
zéro ou plus, un ou plus, zéro ou un de l’élément précédent |
{3} {2,5} {2,} |
exactement 3, de 2 à 5, au moins 2 |
^ $ |
début et fin du texte |
\b |
une limite de mot (entre un caractère de mot et un caractère qui n’en est pas un) |
(...) (?<nom>...) |
un groupe numéroté, un groupe nommé |
(?i) |
insensible à la casse à partir d’ici |
Encore une : la barre verticale signifie « ou ». Par exemple, chat|chien correspond à l’un ou l’autre mot, et gr(a|e)y correspond à gray et à grey. Pour trouver un caractère spécial tel quel, placez une barre oblique inverse devant : \., \(, \$.
Erreurs courantes
Section intitulée « Erreurs courantes »Oublier la chaîne brute. Dans une chaîne normale, \d n’est pas une séquence d’échappement valide, donc Rust s’arrête avant même que la crate regex voie votre motif :
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(); | +Le compilateur signale une « séquence d’échappement inconnue » et propose même la solution : ajouter r devant la chaîne.
Construire la regex dans une boucle. Ce code compile et fonctionne, mais il est lent, car le motif est recompilé pour chaque ligne :
for line in lines { let re = Regex::new(r"\d+").unwrap(); // recompilée encore et encore if re.is_match(line) { /* ... */ }}Déplacez Regex::new au-dessus de la boucle, ou dans un static LazyLock comme montré plus haut.
Oublier ^ et $ pour valider. is_match cherche le motif n’importe où. Pour vérifier que l’entrée entière a une certaine forme, ancrez le motif aux deux bouts.
Récapitulatif
Section intitulée « Récapitulatif »- Ajoutez la crate avec
cargo add regex, et écrivez les motifs sous forme de chaînes brutes :r"\d+". Regex::newrenvoie unResult, car un motif peut être invalide.is_matchrépond oui ou non ;find/find_iterdonnent le texte trouvé ;captures/captures_iterdonnent les groupes par numéro ou par(?<nom>...).replaceetreplace_allpeuvent insérer des groupes avec$nom; utilisez${nom}quand des lettres ou un_suivent.- Compilez un motif une seule fois et réutilisez-le, par exemple dans un
staticLazyLock. - Pas de look-around ni de références arrière, par choix : en échange, la recherche se fait toujours en temps linéaire.
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Pourquoi les motifs regex en Rust commencent-ils généralement par r, comme dans r"\d+" ?
2. Avec le motif (?<y>\d{4})-(?<m>\d{2}), que donne caps["m"] pour le texte 2024-05 ?
3. Pourquoi la crate regex ne prend-elle pas en charge le look-around et les références arrière ?
4. Où vaut-il mieux appeler Regex::new pour un motif utilisé sur chaque ligne d'un gros fichier ?
Score : 0 / 4
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Documentation de la crate
regex— docs.rs (en anglais) : le guide officiel, avec de nombreux exemples et la référence complète de la syntaxe. - API
Regex— docs.rs (en anglais) : toutes les méthodes, dontcaptures_iter,replacenetsplit. LazyLock— documentation de la bibliothèque standard (en anglais) : comment fonctionnent les statics initialisés à la demande.- Regular Expressions — Rust Cookbook (en anglais) : de courtes recettes pour extraire des hashtags, des numéros de téléphone, des e-mails et plus encore.