Cours JavaScript et TypeScript · Module 5: JavaScript avancé

Expressions régulières avancées

17 min de lecture
Dans cette leçon8

Le Dr Rex se tient devant le mur d'écrans du laboratoire génétique de Jurassic Park. Sur l'un d'eux défile le journal système : vingt mille lignes venues des capteurs de clôture, des incubateurs et des portails de secteur. Quelque part dans ce flot se cache une seule panne de courant, dans le secteur sept. Le parcourir des yeux prendrait la nuit entière.

  • Nous ne cherchons pas une phrase précise, dit le Dr Rex sans quitter l'écran des yeux. Nous cherchons la forme d'une phrase. Un code d'erreur a toujours la même allure : trois majuscules, un tiret, trois chiffres. Peu importe lesquelles.

C'est exactement cela, une expression régulière (RegExp) : la description de la forme d'un texte. De même qu'une séquence d'ADN décrit le motif selon lequel un organisme s'assemble, une expression régulière décrit le motif selon lequel un fragment de texte s'assemble. Tu fournis le motif, et le moteur JavaScript examine pour toi chaque caractère de l'échantillon.

Deux façons d'écrire un motif

Avant de chercher quoi que ce soit, il te faut un objet motif. En JavaScript, deux chemins y mènent, exactement deux et pas un de plus. Le premier est le littéral, c'est-à-dire le motif écrit tel quel dans le code, entre deux barres obliques. Le second est le constructeur new RegExp("motif", "drapeaux"), qui fabrique le même objet à partir de simples chaînes de caractères.

Retiens bien cette paire, car plusieurs fausses pistes circulent autour d'elle. JSON.parse() et JSON.stringify() font passer des données entre le texte et l'objet, et n'ont rien à voir avec la recherche de motifs. document.regex() et window.match() n'existent tout simplement pas : document est l'arbre de la page, window l'objet global du navigateur, et aucun des deux ne fabrique d'expression régulière. String.regex() et Array.match() n'existent pas davantage : une chaîne possède bien une méthode match(), mais celle-ci ne crée aucun motif, elle le reçoit en argument, et les tableaux n'ont pas de méthode match() du tout.

1// Facon 1 : le litteral - le motif ecrit directement dans le code
2const pattern = /ERROR/gi;
3
4console.log(pattern.test("Error : portail du secteur 7"));  // true
5
6// Facon 2 : le constructeur - le motif construit a partir d'une chaine
7const espece = "Velociraptor";
8const dynamicPattern = new RegExp(espece, "i");
9
10console.log(dynamicPattern.test("velociraptor dans le secteur B"));  // true
11
12// Le constructeur sert quand le motif depend des donnees
13function chercherEspece(ligne, nom) {
14  return new RegExp(nom, "i").test(ligne);
15}
16
17console.log(chercherEspece("ALERTE : le T-Rex a quitte son enclos", "t-rex"));  // true

Regarde la première ligne de près, car son ordre est immuable. Tu déclares d'abord le nom : const pattern. Tu ouvres ensuite le littéral : = /. Tu écris le corps du motif : ERROR. Tu refermes enfin par une barre oblique suivie des drapeaux : /gi. L'ensemble se lit const pattern = /ERROR/gi. Les drapeaux se placent toujours après la barre fermante, jamais avant le motif : une écriture comme /gi ERROR/ n'a rien de spécial, c'est un motif ordinaire qui cherche la suite littérale gi ERROR.

Le drapeau g (global) demande au moteur de collecter toutes les occurrences au lieu de s'arrêter à la première. Le drapeau i (ignore case) désactive la distinction entre majuscules et minuscules, si bien qu'un seul motif attrape ERROR, Error et error. Remarque aussi ce qui n'a pas changé du côté du constructeur : new RegExp(espece, "i") produit un objet strictement identique à /Velociraptor/i, seule la manière de l'écrire diffère. Choisis le littéral chaque fois que le motif est fixe, il est plus court et plus lisible. Réserve le constructeur aux motifs qui ne naissent qu'à l'exécution, par exemple à partir d'un nom d'espèce saisi par l'opérateur du centre de contrôle.

Ce que renvoie chaque méthode

Une fois le motif en main, il faut s'en servir, et c'est là que naissent les confusions les plus fréquentes. Les quatre méthodes de base se ressemblent dans le code, mais chacune rend quelque chose de totalement différent. Le Dr Rex les traite comme quatre instruments de laboratoire distincts : le premier dit si l'échantillon contient la séquence cherchée, le deuxième extrait laquelle, le troisième la remplace par une autre, et le quatrième indique où elle se trouve exactement. Dans l'exemple qui suit, une seule et même ligne de journal passe entre les quatre mains, pour que la différence saute aux yeux.

1const journal = "2024-06-15 ALERTE : panne de courant - ERR-503 - secteur 7";
2
3// test() - renvoie une valeur booleenne
4console.log(/ALERTE/.test(journal));         // true
5console.log(/AUCUNE DONNEE/.test(journal));  // false
6
7// match() - renvoie un tableau de correspondances
8console.log(journal.match(/ERR-\d{3}/));     // ["ERR-503", index: 39, ...]
9
10// replace() - renvoie une NOUVELLE chaine, l'originale reste intacte
11console.log(journal.replace(/ALERTE/, "AVERTISSEMENT"));
12console.log(journal);  // toujours "... ALERTE : ..." - les chaines sont immuables
13
14// search() - renvoie l'indice de la premiere correspondance (ou -1)
15console.log(journal.search(/ERR-/));         // 39
16console.log(journal.search(/RAPTOR/));       // -1

Arrête-toi sur test(), car c'est la méthode sur laquelle on t'interrogera le plus souvent. test() renvoie une valeur booléenne : true quand le motif correspond au texte, false sinon. Rien d'autre. Elle ne renvoie pas une nouvelle chaîne dont les correspondances auraient été remplacées, ce travail revient à replace(). Elle ne renvoie pas un tableau des fragments correspondants, c'est l'affaire de match(). Elle ne renvoie pas non plus l'indice de la première correspondance, cet indice vient de search(), qui rend -1 lorsqu'il ne trouve rien. Emploie donc test() partout où seule la réponse oui ou non t'intéresse, typiquement dans une condition if.

Note au passage ce qui ne s'est pas produit : après l'appel à replace(), la variable journal contient toujours le texte d'origine avec son ALERTE. En JavaScript, les chaînes sont immuables, donc replace() fabrique une copie modifiée et te la rend, sans jamais toucher à l'originale. Deux autres méthodes méritent d'être connues. matchAll(), avec le drapeau g, renvoie un itérateur de toutes les correspondances accompagnées de leurs groupes, tandis que split() sait découper une chaîne selon un motif : split(/[\s,]+/) sépare le texte sur n'importe quelle suite d'espaces et de virgules. Attention toutefois à un piège : un motif porteur du drapeau g mémorise sa position dans le champ lastIndex, si bien que des appels répétés de test() sur le même objet peuvent renvoyer true une fois et false la fois suivante.

L'alphabet du motif - classes de caractères et quantificateurs

Le motif /ERROR/ cherche une suite littérale et devient de ce fait inutile face aux codes d'erreur, puisque chacun porte des chiffres différents. La véritable puissance des expressions régulières vient des classes de caractères, ces symboles qui désignent des catégories entières, et des quantificateurs, qui disent combien de fois un élément doit se répéter.

Trois classes se retiennent immédiatement : \d est n'importe quel chiffre, \w un caractère de mot (lettre, chiffre ou tiret bas) et \s un caractère d'espacement, c'est-à-dire un espace, une tabulation ou un saut de ligne. Leurs équivalents en majuscule \D, \W et \S signifient exactement l'inverse. Tu composes ta propre catégorie entre crochets : [A-Z] est une majuscule, [0-9] un chiffre et [^0-9] tout sauf un chiffre. Le point, lui, correspond à n'importe quel caractère hormis le saut de ligne.

1const fiche = "DINO_042_REX secteur 7 temperature 37";
2
3// \d - un chiffre, + - une ou plusieurs repetitions
4console.log(fiche.match(/\d+/g));  // ["042", "7", "37"]
5
6// \w - un caractere de mot (lettre, chiffre, tiret bas)
7console.log(fiche.match(/\w+/g));  // ["DINO_042_REX", "secteur", "7", "temperature", "37"]
8
9// [A-Z] - une classe personnalisee, {3} - exactement trois repetitions
10console.log("ERR-503 puis ERR-404".match(/[A-Z]{3}-\d{3}/g));  // ["ERR-503", "ERR-404"]
11
12// ? - zero ou une fois, * - zero ou plusieurs fois
13console.log(/secteur ?7/.test("secteur7"));    // true - espace facultatif
14console.log(/RAPTOR-0*42/.test("RAPTOR-42"));  // true - zeros initiaux facultatifs

Le quantificateur porte toujours sur l'élément qui le précède immédiatement. + exige au moins une répétition, * en accepte aussi zéro, ? rend l'élément facultatif, et les accolades donnent le compte exact : {3} signifie trois fois précisément, {2,4} de deux à quatre, {3,} au moins trois. Grâce à cela, /[A-Z]{3}-\d{3}/ se lit comme une phrase : trois majuscules, un tiret, trois chiffres, soit exactement la forme du code d'erreur que traquait le Dr Rex.

Remarque la différence entre \d+ et \d{3}. La première écriture attrape une suite de chiffres de longueur quelconque, d'où les trois résultats tirés d'une seule fiche. La seconde s'obstine à en vouloir trois, ni plus ni moins. Dans le journal du parc, cette nuance décide si le résultat contiendra uniquement le code de panne à trois chiffres ou aussi le numéro de secteur et le numéro de version du logiciel. Observe également que DINO_042_REX est ressorti d'un seul bloc face à \w+ : le tiret bas est un caractère de mot, contrairement au tiret ordinaire, qui aurait coupé le résultat en morceaux.

Les ancres - à quel endroit du texte

Les classes et les quantificateurs disent quoi faire correspondre. Les ancres disent où. L'accent circonflexe ^ marque le début du texte, le dollar $ sa fin, et \b une limite de mot, c'est-à-dire le point de contact entre un caractère de mot et un caractère qui n'en est pas un. Sans ancres, un motif se contente d'une correspondance n'importe où au milieu, ce qui est presque toujours insuffisant pour valider un format : l'identifiant d'un spécimen doit être la valeur entière, pas un fragment perdu à l'intérieur d'autre chose.

1// Sans ancres, le motif se contente d'un fragment
2console.log(/[A-Z]{3}-\d{3}/.test("bruit REX-001 bruit"));  // true
3
4// ^ et $ imposent la correspondance sur la chaine entiere
5const dinoId = /^[A-Z]{3}-\d{3}$/;
6
7console.log(dinoId.test("REX-001"));              // true
8console.log(dinoId.test("bruit REX-001 bruit"));  // false
9console.log(dinoId.test("REX-01"));               // false - pas assez de chiffres
10console.log(dinoId.test("rex-001"));              // false - minuscules
11
12// \b - une limite de mot
13console.log(/\bRex\b/.test("le T-Rex a quitte son enclos"));  // true
14console.log(/\bRex\b/.test("Rexona au laboratoire"));         // false

^[A-Z]{3}-\d{3}$ est un validateur d'identifiant prêt à l'emploi : la valeur entière doit se composer de trois majuscules, d'un tiret et de trois chiffres, car l'accent circonflexe et le dollar ne laissent de place à rien d'autre. Retire les ancres et le même motif laissera passer n'importe quelle chaîne parasite au milieu de laquelle se cache un code valide, exactement comme dans la première ligne de l'exemple. Remarque aussi que rex-001 échoue alors que sa forme est correcte : la classe [A-Z] ne couvre que les majuscules, et aucun drapeau i n'est venu adoucir la règle.

Avec \b, souviens-toi qu'il s'agit d'une limite et non d'un caractère : elle n'occupe aucune place dans le texte. C'est précisément pour cela que \bRex\b retrouve Rex dans T-Rex (le tiret n'est pas un caractère de mot) mais rejette Rexona, où une lettre suit immédiatement Rex. Quand tu travailles sur un journal composé de plusieurs lignes, ajoute le drapeau m (multiline) : ^ et $ désigneront alors le début et la fin de chaque ligne prise séparément, et non plus ceux du texte entier.

Les groupes - découper les fragments

Vérifier qu'une ligne de journal correspond au motif n'est que la moitié du travail. Le Dr Rex en veut des données : la date d'un côté, le code d'erreur de l'autre, le numéro de secteur encore ailleurs. C'est le rôle des groupes capturants, ces morceaux de motif entourés de parenthèses. Chaque groupe atterrit dans le résultat de match() comme élément suivant du tableau : l'indice zéro porte toujours la correspondance complète, et à partir de un viennent les groupes dans l'ordre des parenthèses ouvrantes.

Les parenthèses servent aussi à l'alternative. La barre verticale | signifie "ou bien", si bien que (ALERTE|ERROR|FATAL) reconnaît l'un quelconque des trois mots. Si tu n'as besoin de la parenthèse que pour regrouper, sans vouloir mémoriser le résultat, emploie un groupe non capturant (?:...) : il n'encombre pas le tableau des résultats.

1const ligne = "2024-06-15 ERROR ERR-503 secteur 7";
2
3// Groupes capturants - numerotes a partir de 1
4const numerotes = /(\d{4}-\d{2}-\d{2}) (?:ALERTE|ERROR|FATAL) (ERR-\d{3}) secteur (\d+)/;
5const parts = ligne.match(numerotes);
6
7console.log(parts[0]);  // "2024-06-15 ERROR ERR-503 secteur 7" - toute la correspondance
8console.log(parts[1]);  // "2024-06-15"
9console.log(parts[2]);  // "ERR-503"
10console.log(parts[3]);  // "7"
11
12// Groupes nommes - plus lisibles que les indices
13const nommes = /(?<date>\d{4}-\d{2}-\d{2}) (?:ALERTE|ERROR|FATAL) (?<code>ERR-\d{3}) secteur (?<secteur>\d+)/;
14const champs = ligne.match(nommes).groups;
15
16console.log(champs.date);     // "2024-06-15"
17console.log(champs.code);     // "ERR-503"
18console.log(champs.secteur);  // "7"

L'écriture (?<nom>...) crée un groupe nommé. Au lieu de retenir que le numéro de secteur dort à l'indice trois, tu écris match.groups.secteur. Quand un motif finit par compter six ou sept groupes, ce changement sauve la lisibilité du code, car ajouter une parenthèse au milieu du motif ne décale plus la numérotation de toutes les autres. Remarque d'ailleurs que les deux motifs de l'exemple décrivent la même ligne et livrent les mêmes valeurs : seule la manière d'y accéder a changé.

Note que (?:ALERTE|ERROR|FATAL), malgré ses parenthèses, n'a pris aucun numéro : c'est pourquoi le code d'erreur s'est retrouvé à l'indice deux et non à l'indice trois. Le groupe non capturant existe exactement pour cela, il regroupe une alternative sans se faire passer pour une donnée. Attention aussi à un détail facile à oublier : match() renvoie null quand la ligne ne correspond pas, et lire .groups sur null fait tomber tout le programme. Enfin, si tu veux parcourir d'un coup toutes les lignes du journal, ajoute le drapeau g puis utilise matchAll() : tu obtiendras un itérateur dont chaque élément possède son propre tableau et son propre champ groups.

Le lookahead - une condition qui ne consomme rien

Il arrive qu'on veuille faire correspondre un fragment à condition que quelque chose de précis le suive, ou justement ne le suive pas, sans pour autant faire entrer cette condition dans le résultat. C'est le rôle du lookahead, l'aperçu vers l'avant. L'écriture (?=...) est un lookahead positif : la correspondance ne réussit que si le motif indiqué peut être reconnu juste derrière. L'écriture (?!...) est un lookahead négatif : la correspondance ne réussit que si le motif indiqué est absent à cet endroit.

La différence essentielle avec un groupe ordinaire tient en ceci : le lookahead ne consomme aucun caractère. Il inspecte le terrain devant lui puis revient exactement au même point, si bien que le fragment inspecté n'entre pas dans le résultat et que plusieurs conditions de ce type peuvent s'empiler l'une derrière l'autre.

1const rapport = "SECTEUR-3 OK SECTEUR-7 ALERTE SECTEUR-9 OK";
2
3// Lookahead positif - seulement les secteurs suivis de ALERTE
4console.log(rapport.match(/SECTEUR-\d(?= ALERTE)/g));  // ["SECTEUR-7"]
5
6// Lookahead negatif - tous ceux qui ne sont pas suivis de ALERTE
7console.log(rapport.match(/SECTEUR-\d(?! ALERTE)/g));  // ["SECTEUR-3", "SECTEUR-9"]
8
9// Plusieurs conditions a la fois - le code d'acces au laboratoire
10const codeAcces = /^(?=.*[A-Z])(?=.*\d).{8,}$/;
11
12console.log(codeAcces.test("Raptor42Lab"));  // true
13console.log(codeAcces.test("raptor42lab"));  // false - pas de majuscule
14console.log(codeAcces.test("RaptorLab"));    // false - pas de chiffre
15console.log(codeAcces.test("Rap42"));        // false - trop court

Dans le premier exemple, le résultat ne contient que SECTEUR-7 alors que le motif vérifiait aussi le mot ALERTE : voilà ce que signifie concrètement le fait qu'un lookahead n'entre pas dans la correspondance. Le mot ALERTE a servi de condition, puis il a disparu du résultat. Le deuxième motif inverse la condition et renvoie les deux autres secteurs, ceux qui vont bien.

Le troisième exemple est le plus intéressant. ^(?=.*[A-Z])(?=.*\d).{8,}$ se lit comme trois exigences indépendantes posées sur la même chaîne : une majuscule doit apparaître quelque part, un chiffre également, et l'ensemble doit compter au moins huit caractères. Sans lookahead, il faudrait énumérer tous les ordres dans lesquels ces éléments peuvent se présenter, et ils sont innombrables. Chaque (?=...) repart de la même position, vérifie sa condition et passe la main au suivant ; seul .{8,} traverse réellement le texte et en consomme les caractères. C'est ce qui explique les trois échecs de l'exemple, chacun pour une raison différente : la majuscule manque au premier, le chiffre au deuxième, et la longueur au troisième.

Pratique - un analyseur de journal du parc

Il est temps d'assembler tout cela en un seul outil. Le journal système du parc suit un format fixe : la date, le niveau de l'événement, le code d'erreur et le numéro de secteur. Nous allons construire une fonction qui extrait ces quatre informations d'un texte brut et renvoie une liste d'objets prête à l'emploi, exactement le genre de liste que l'on peut trier, compter ou afficher dans le centre de contrôle. Le motif est déclaré une seule fois, en dehors de la fonction, et nous exploitons le drapeau g avec matchAll() pour traverser toutes les lignes d'un seul mouvement.

1const MOTIF_JOURNAL = /(?<date>\d{4}-\d{2}-\d{2}) (?<niveau>ALERTE|ERROR|INFO) (?<code>[A-Z]{3}-\d{3}) secteur (?<secteur>\d+)/g;
2
3function analyserJournal(texte) {
4  return [...texte.matchAll(MOTIF_JOURNAL)].map(m => ({
5    date: m.groups.date,
6    niveau: m.groups.niveau,
7    code: m.groups.code,
8    secteur: Number(m.groups.secteur)
9  }));
10}
11
12const journal = "2024-06-15 INFO SYS-100 secteur 3\n2024-06-15 ALERTE PWR-503 secteur 7\n2024-06-16 ERROR FNC-404 secteur 7";
13
14const evenements = analyserJournal(journal);
15
16console.log(evenements.length);  // 3
17console.log(evenements[1]);
18// { date: "2024-06-15", niveau: "ALERTE", code: "PWR-503", secteur: 7 }
19
20// Les pannes du secteur 7 - avec de simples methodes de tableau
21console.log(evenements.filter(e => e.secteur === 7 && e.niveau !== "INFO").length);  // 2

Le motif a été écrit ici sous la forme d'une constante MOTIF_JOURNAL placée hors de la fonction, et ce n'est pas un hasard. Une expression régulière doit être compilée, donc la construire à l'intérieur d'une boucle ou à chaque appel de fonction est du travail perdu, alors qu'un objet créé une fois traitera tranquillement le journal entier. Remarque que la classe [A-Z]{3} accepte aussi bien SYS que PWR ou FNC : le motif décrit la forme du code, pas sa valeur, et c'est bien là tout l'intérêt.

Observe aussi comment les groupes nommés ont transformé un texte brut en structure de données. Le champ secteur mérite un mot de plus : la regex ne rend jamais que du texte, et c'est Number() qui a converti "7" en 7, sans quoi la comparaison e.secteur === 7 aurait échoué. Après map(), nous avons de simples objets et, à partir de cet instant, nous travaillons avec des outils que tu connais depuis longtemps : filter(), sort(), reduce(). L'expression régulière a fait sa part, à savoir traduire un texte désordonné en données, et elle n'est plus nécessaire ensuite. Voilà une bonne règle au quotidien : une regex doit être un pont vers les données, pas l'endroit où habite toute la logique du programme.

Récapitulatif

Le Dr Rex éteint l'écran du journal. Vingt mille lignes, une seule panne de courant dans le secteur sept, quelques secondes de travail pour le moteur d'expressions régulières. Voici ce qu'il faut emporter de ce laboratoire :

  1. Deux façons de créer un motif : le littéral /ERROR/gi ou le constructeur new RegExp("ERROR", "gi"). Dans le littéral, l'ordre est fixe : le nom, = /, le corps du motif, puis la barre fermante avec les drapeaux.
  2. Les valeurs renvoyées : test() rend true ou false, match() un tableau de correspondances, replace() une nouvelle chaîne, search() l'indice de la première correspondance.
  3. Les briques du motif : les classes \d, \w, \s et les classes personnalisées [A-Z] décrivent ce qui doit correspondre ; les quantificateurs +, *, ?, {n,m} disent combien de fois ; les ancres ^, $, \b indiquent l'endroit.
  4. Les groupes : (...) découpe un fragment, (?:...) se contente de regrouper, et (?<nom>...) donne accès par match.groups.nom.
  5. Le lookahead : (?=...) et (?!...) posent une condition sur la suite du texte sans l'emporter dans le résultat.

Un dernier conseil, celui que le Dr Rex répète à chaque stagiaire : les expressions régulières sont puissantes, mais elles ne sont pas obligatoires. Si tu cherches un fragment de texte simple et fixe, includes() dira la même chose plus simplement et plus vite. Sors la regex du tiroir quand tu décris vraiment la forme des données, et non leur contenu.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quelles sont les deux façons de créer des expressions régulières en JavaScript ?

  2. 2. Que renvoie la méthode test() d'une expression régulière ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Extrais les dates, les codes d'erreur et les numéros de secteur des journaux à l'aide de RegExp.

  • Tri horizontal

    Ordonne les éléments d'une expression régulière avec drapeaux :

Articles utiles