Aller au contenu
Allin

Rechercher-remplacer : les pièges des expressions régulières, démontrés un par un

Publié le 30/05/2025 · 15 min de lecture · Outils texte & langage

Daniel Okonkwo

Daniel OkonkwoDéveloppeur front-end et rédacteur Tech chez Allin

Performance web · Formats de fichiers

Vérifié à partir de 5 sources

Voir le profil
En bref

Les façons dont un rechercher-remplacer échoue sont précises et reproductibles : on peut donc les démontrer au lieu d'en avertir. Les quantificateurs sont gourmands par défaut : sur « <b>bold</b> and <i>italic</i> », remplacer /<.+>/g par rien rend une chaîne vide, car .+ court jusqu'au dernier > de la ligne ; le paresseux /<.+?>/g et le nié /<[^>]+>/g rendent tous deux « bold and italic ». Le point ne reconnaît jamais un retour à la ligne sans le drapeau s : /Prix.*unités/ est faux sur deux lignes, /Prix.*unités/s est vrai. La chaîne de remplacement a sa propre syntaxe, sans rapport avec le motif : $& insère toute la correspondance, $1 un groupe, et $$ est le seul moyen d'émettre un dollar littéral — « $$$1 » sur « Total : 42 EUR » donne « Total : $42 » tandis que « $$& » donne le littéral « $& ». Un objet regex portant le drapeau g conserve un lastIndex entre les appels : le réutiliser d'une ligne à l'autre fait sauter des correspondances, la même /\d+/g testée sur trois lignes de commande ayant rendu vrai, faux, vrai. Et /i n'est qu'un repli de casse, pas une connaissance des langues : /i/i ne reconnaît pas İ, et /I/i ne reconnaît pas ı. Compte les correspondances d'abord, remplace ensuite.

Gourmand contre paresseux sur la même chaîne, le point qui saute les retours à la ligne, $& et $$ dans le remplacement, une regex /g réutilisée qui saute une ligne en silence, et pourquoi /i ignore tout du i turc. Chaque échec exécuté dans Node, avec une routine compter-puis-remplacer qui les attrape.

Gourmand et paresseux sur la même entrée

Prends la chaîne <b>bold</b> and <i>italic</i> et tente d'effacer les balises avec /<.+>/g. Le résultat est une chaîne vide. Le quantificateur + est gourmand : il prend tout ce qu'il peut et ne rend des caractères que si la suite du motif échoue, si bien que .+ avale tout jusqu'au dernier > et qu'une seule correspondance couvre toute la ligne. La première correspondance de /<.+>/ est littéralement l'entrée entière.

Deux corrections marchent, et elles ne sont pas équivalentes. Ajouter un point d'interrogation rend le quantificateur paresseux : /<.+?>/g prend le moins possible, sa première correspondance est <b>, et le remplacement rend bold and italic. Remplacer le point par une classe niée fait le même travail autrement : /<[^>]+>/g ne peut pas franchir un > du tout, et rend donc aussi bold and italic. Préfère la classe niée quand elle existe : elle dit où est la frontière au lieu de compter sur le retour arrière du moteur pour s'arrêter au bon endroit, et elle ne redevient pas gourmande en silence lorsque tu ajoutes plus tard une partie facultative au motif.

Le point s'arrête à la fin de la ligne

En JavaScript, . reconnaît n'importe quel caractère sauf un terminateur de ligne. Prends une fiche sur deux lignes : « Prix : 12,50 € », un retour à la ligne, puis « Stock : 3 unités ». Le test /Prix.*unités/ rend faux. Le même motif avec le drapeau s, /Prix.*unités/s, rend vrai, car s — dotAll — supprime l'exception du terminateur de ligne. L'ancien contournement, une classe de caractères qui couvre tout, /Prix[\s\S]*unités/, rend vrai également et fonctionne dans les moteurs antérieurs au drapeau.

C'est là qu'un remplacement échoue en silence plutôt qu'avec fracas. Un motif censé couvrir un paragraphe ne trouve simplement rien, le compte de remplacements revient à zéro, et un pipeline qui ne vérifie pas ce compte annonce un succès. C'est aussi pourquoi /.+/g fait un découpeur de lignes utilisable : sur la même fiche de deux lignes, il rend deux correspondances, une par ligne — une fonctionnalité quand on la veut, une surprise sinon.

La chaîne de remplacement a sa propre syntaxe

Le second argument de replace n'est pas du texte brut. C'est un petit langage, et son unique métacaractère est $. Sur « Total : 42 EUR » avec /(\d+) (USD|EUR)/, le remplacement [$&] donne « Total : [42 EUR] », $1 donne « Total : 42 », et $<amount> — le groupe étant nommé dans le motif — donne « Total : [42] ». La liste complète est courte : $&, $1 à $99, $<nom>, $` pour tout ce qui précède la correspondance, $' pour tout ce qui suit, et $$ pour un dollar littéral.

Deux pièges en découlent. Le premier : le $ d'un remplacement n'est pas le $ d'un motif. Dans un motif, $ est une ancre signifiant fin d'entrée ; dans un remplacement, il ne signifie jamais cela. Écrire $ là où l'on veut un symbole monétaire ne produit un $ littéral que par chance — replace(/EUR/, '$') rend bien « Total : 42 $ », car un $ isolé suivi de rien de particulier passe tel quel, mais dès qu'il est suivi d'un chiffre ou d'une esperluette le sens change. Écris $$ et n'y pense plus.

Le second piège est une ambiguïté de chiffre. $10 signifie le groupe 10 quand le motif compte dix groupes capturants, et le groupe 1 suivi du caractère 0 quand ce n'est pas le cas : sur un motif à dix groupes, le remplacement [$10] a rendu [j], et sur un motif à un seul groupe, il a rendu [a0]. Le sens de ta chaîne de remplacement dépend donc du nombre de groupes que contient le motif : ajoute un groupe et le remplacement change de comportement sans avoir été modifié. Les groupes nommés suppriment l'ambiguïté, et si le remplacement est une donnée plutôt que du code, passe une fonction : sa valeur de retour est insérée telle quelle, si bien que replace(/\d+/, () => '$&') sur « Total : 42 EUR » produit le littéral « $& » au lieu du nombre trouvé.

Une regex /g se souvient de son point d'arrêt

Un objet RegExp portant le drapeau g transporte une propriété mutable lastIndex, que .test() et .exec() lisent et écrivent. Réutilise le même objet sur plusieurs chaînes et chaque recherche démarre là où la précédente s'est arrêtée. Prends const re = /\d+/g et teste-la sur trois lignes — « order 12 », « order 7 », « order 349 » — et les résultats sont vrai, faux, vrai. La ligne du milieu contient bien un nombre. Elle a été sautée parce que lastIndex valait 8 au début de la recherche, et après le troisième appel lastIndex valait 9.

Le même effet apparaît sur une seule chaîne. Quatre appels consécutifs re.test('banana') avec /a/g rendent vrai, vrai, vrai, faux : trois a, puis une recherche infructueuse qui remet lastIndex à 0, si bien qu'un cinquième appel rendrait vrai de nouveau. Rien de tout cela n'est un bug — c'est ce qui rend exec() utilisable dans une boucle while — mais cela transforme une constante regex de niveau module en variable mutable partagée déguisée.

Trois corrections, par ordre de préférence. Ne mets pas g sur une regex utilisée avec .test() : le drapeau n'y apporte rien et provoque tout ce qui précède. Utilise matchAll ou match avec g quand tu veux toutes les occurrences, car les deux gèrent l'index pour toi. Ou, si tu dois réutiliser un objet, remets re.lastIndex = 0 avant chaque recherche, en sachant que tu as choisi l'option fragile.

L'insensibilité à la casse n'est pas une connaissance des langues

Le drapeau i applique le repli de casse Unicode, qui est une table fixe et non une règle de locale. Le contre-exemple le plus net est le turc, dont l'alphabet compte deux i : le i pointé i/İ et le i sans point ı/I. Exécute les tests : /i/i ne reconnaît pas İ, et /I/i ne reconnaît pas ı — les deux rendent faux. Pendant ce temps, 'I'.toLowerCase() rend 'i' mais 'I'.toLocaleLowerCase('tr') rend 'ı', et 'i'.toLocaleUpperCase('tr') rend 'İ'. On peut informer les fonctions de casse d'une locale. Le drapeau de regex, non.

Trois autres résultats de la même exécution, dont chacun a coûté une matinée à quelqu'un. /ss/i ne reconnaît pas ß et /ß/i ne reconnaît pas SS, parce que le repli de casse est une correspondance caractère à caractère et que l'eszett allemand se développe en deux lettres. /k/i ne reconnaît pas le signe kelvin K, mais /k/iu — avec le drapeau Unicode — le reconnaît, car u active le repli de casse simple : ajouter un drapeau d'apparence purement syntaxique change donc les caractères considérés comme égaux. Et /é/i ne reconnaît pas un É décomposé, un e suivi d'un accent aigu combinant : le repli de casse n'est pas la normalisation, et les deux sont des décisions indépendantes, comme l'article de cette série consacré aux accents le détaille.

L'ancrage, et pourquoi \b ment sur les mots accentués

\b n'est pas un caractère. C'est une assertion de largeur nulle qui réussit là où un caractère de mot (en JavaScript : [A-Za-z0-9_]) se trouve d'un seul côté. Cette définition est purement ASCII, et elle produit un résultat exactement inverse pour toute langue à signes diacritiques. /\bcafé\b/ ne reconnaît pas « un café noir », parce qu'après le é — qui n'est pas un caractère de mot — vient une espace, qui n'en est pas un non plus : il n'y a donc pas de frontière. Le même motif reconnaît « un cafés », parce que le é est suivi d'un s et que cela constitue une frontière. L'assertion se déclenche là où le mot continue et échoue là où il finit.

Le remplaçant de \b est une paire d'assertions Unicode : /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u reconnaît « un café noir » et refuse « un cafés », ce qui correspond à ce qu'une personne entend par mot entier. Le drapeau u est indispensable pour que \p{…} soit seulement reconnu. Quand le mot remplacé est purement ASCII, \b reste convenable — /\bchat\b/ se comporte bien — mais dès que le motif contient une lettre hors de A–Z, vérifie les deux extrémités à la main.

Compter, puis remplacer : un exemple traité

Prends la ligne : chat, château, tchat, le chat dort. Il s'agit de remplacer l'animal par chien. Compte d'abord : la ligne.match(/chat/g).length vaut 3. Tu en attendais 2. Ce seul nombre, lu avant d'écrire quoi que ce soit, constitue toute la protection — et si tu avais remplacé au lieu de compter, la sortie aurait été « chien, château, tchien, le chien dort », le mot tchat ayant été mutilé au passage.

Ancre puis recompte : /\bchat\b/g trouve 2, le nombre attendu, et c'est seulement alors qu'il est sûr de remplacer. Le résultat est « chien, château, tchat, le chien dort ». Ensuite, recompte : /\bchat\b/g doit maintenant trouver 0 et /\bchien\b/g doit trouver 2. Trois comptes et un remplacement, quatre lignes en tout, et chacune est une ligne que tu peux montrer à quelqu'un qui demande ce qu'a fait la modification.

Un détail de la version allemande du même exercice mérite d'être retenu. Remplacer Bahn par Zug dans « Bahn, Bahnhof, Autobahn, die Bahn fährt » sans ancres donne 3 correspondances, pas 4 : le motif est sensible à la casse, il frappe donc Bahnhof mais rate le bahn minuscule à l'intérieur d'Autobahn. Un compte inférieur à l'attente est aussi informatif qu'un compte supérieur, et il désigne un autre bug.

Ce que signifie chaque jeton dans une chaîne de remplacement, exécuté sur « Total : 42 EUR » avec le motif /(\d+) (USD|EUR)/. Le $ d'un remplacement n'a rien à voir avec le $ d'un motif : ici, il ne signifie jamais fin de chaîne.
JetonCe qu'il insèreRemplacement écritRésultat
$&Toute la correspondance[$&]Total : [42 EUR]
$1Groupe capturant 1$1Total : 42
$$Un dollar littéral$$$1Total : $42
$10Le groupe 10 s'il existe, sinon le groupe 1 puis le caractère 0$10Total : 420
$` et $'Tout ce qui précède, tout ce qui suit la correspondance<$`>Total : <Total : >
$<nom>Un groupe nommé ; laissé littéral si le motif n'en contient aucun[$<amount>]Total : [42]
Rechercher et remplacerRemplace d'un coup chaque occurrence d'un mot ou d'une expression dans ton texte.Essayer l'outil

Questions fréquentes

Pourquoi mon remplacement a-t-il effacé une ligne entière ?
Presque toujours un quantificateur gourmand entre deux délimiteurs qui apparaissent plusieurs fois. /<.+>/ sur une ligne à deux balises va du premier < au dernier > : la correspondance unique est la ligne entière. Ajoute un point d'interrogation pour rendre le quantificateur paresseux, /<.+?>/, ou mieux, interdis le délimiteur fermant à l'intérieur de la correspondance avec une classe niée, /<[^>]+>/.
Pourquoi la même regex donne-t-elle une autre réponse au second appel ?
Parce qu'elle porte le drapeau g et un lastIndex qui survit d'un appel à l'autre. /a/g testée quatre fois sur « banana » rend vrai, vrai, vrai, faux. Retire le drapeau g quand tu ne veux qu'une réponse oui ou non, utilise matchAll quand tu veux toutes les occurrences, ou remets re.lastIndex = 0 avant chaque recherche si l'objet doit vraiment être partagé.
Comment mettre un dollar littéral dans le remplacement ?
Écris $$. Un $ isolé ne fonctionne que si rien de particulier ne le suit, ce qui en fait un bug qui attend la prochaine retouche : « $$$1 » sur « Total : 42 EUR » donne « Total : $42 », tandis que l'intuitif « $$& » donne le littéral « $& » plutôt que la correspondance. Si le texte de remplacement est une donnée — une valeur de formulaire, une traduction, tout ce que tu n'as pas tapé —, passe une fonction plutôt qu'une chaîne : sa valeur de retour est insérée sans aucun traitement des $.
Le drapeau i comprend-il les accents et les autres alphabets ?
Il comprend le repli de casse et rien d'autre. Il assimilera a et A sur presque tout Unicode, mais il ne normalise pas : /é/i échoue sur un É décomposé ; il ne développe pas : /ss/i échoue sur ß ; et il ne connaît aucune locale : /i/i échoue sur le İ turc. Si tu as besoin de l'un de ces comportements, normalise le texte d'abord et utilise un collateur de sensibilité « base » pour la comparaison, plutôt que d'attendre du drapeau une connaissance des langues qu'il n'a jamais eue.
Comment faire correspondre sur deux lignes ?
Ajoute le drapeau s, qui fait reconnaître au point les terminateurs de ligne : /Prix.*unités/ est faux sur deux lignes et /Prix.*unités/s est vrai. Ne le confonds pas avec m, qui concerne les ancres et non le point : m fait correspondre ^ et $ au début et à la fin de chaque ligne plutôt qu'à l'entrée entière, et laisse le point inchangé. On veut souvent les deux, et ils sont indépendants.
Pourquoi \b échoue-t-il sur un mot finissant par une lettre accentuée ?
Parce que \b est défini sur la classe de mot ASCII [A-Za-z0-9_], et qu'une lettre accentuée n'en fait pas partie. Une frontière exige un caractère de mot d'un seul côté : entre é et l'espace qui suit, il n'y en a aucun, et /\bcafé\b/ ne reconnaît pas « un café noir » — alors qu'il reconnaît « un cafés », où le é est suivi d'un s ASCII. Remplace les assertions par des assertions Unicode : /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u, en gardant à l'esprit que \p{…} exige le drapeau u.

Articles qui pourraient t'intéresser

Tous les guides
TutorielFiltrer des lignes selon un motif, sans ligne de commandeC'est grep pour ceux qui n'utilisent pas grep, avec une différence de taille : la recherche est une sous-chaîne littérale, si bien qu'une vraie expression régulière renvoie une zone vide sans message d'erreur. Chaque affirmation a été vérifiée en exécutant l'outil.TutorielLes bases des regex : guide du débutantUne expression régulière est un motif pour rechercher du texte. Voici les briques de base — classes de caractères, quantificateurs et ancres — avec un exemple.TutorielNuméroter les lignes d'un texte pour une relecture à plusieursLa numérotation commence à 1 et ne peut pas être mise à 0, l'alignement se fait avec des espaces et non des zéros, et l'outil de suppression annule huit des onze séparateurs sans toucher à l'indentation. Ce qu'il ne sait toujours pas faire, c'est distinguer tes numéros des siens.GuideFormater les nombres pour six langues : séparateurs, monnaie et le retour à la valeur1 234,56 et 1,234.56 sont le même nombre, et les confondre change la valeur que lit un lecteur. Nous avons lancé Intl.NumberFormat pour les six locales du site et imprimé chaque séparateur — dont l'invisible qu'emploie le français — puis mesuré pourquoi parseFloat ne peut rien défaire.ExplicationLes emoji sont plus difficiles qu'ils n'en ont l'air : pourquoi « il suffit de les retirer » n'a pas de réponse en une ligneUn emoji visible peut valoir un point de code ou quatorze unités UTF-16. Nous avons lancé trois expressions régulières répandues sur une vraie phrase : chacune a cassé différemment, l'une a supprimé les chiffres. Voici pourquoi, quelle propriété Unicode répond à quelle question, et la règle de groupes de graphèmes qui marche vraiment.ExplicationOù une ligne peut se couper : l'algorithme Unicode derrière chaque paragraphe justifié« Couper aux espaces » échoue dans la plupart des systèmes d'écriture. UAX #14 donne à chaque caractère une classe de coupure ; nous avons cherché les nôtres dans Unicode 17.0.0 et exécuté une implémentation conforme sur espaces insécables, traits d'union conditionnels, espaces de largeur nulle, URL, japonais et thaï.

Outils similaires

Sources

Tu as repéré une erreur dans cet article ?