Regex : attrapez-les tous !
Encore un article sur les regex me direz-vous !? Effectivement, après avoir traité des quantificateurs, des propriétés Unicode, et même des emojis, que pourrais-je encore raconter que vous ne sachiez déjà ? Les groupes de capture vous connaissez ? On parle de group constructs en anglais, et il en existe plus d’une vingtaine ! Je vous invite cette semaine à en découvrir les principaux !
Des groupes, pour quoi faire ?
Les groupes dans une regex servent à isoler un sous-ensemble de la regex pour
lui appliquer des quantificateurs ou un traitement particulier par le moteur de
regex. Il existe des groupes capturants ou non capturants, conditionnels,
exploratoires, récursifs, etc. Syntaxiquement, ils ont tous en commun
l’utilisation de parenthèses ()
pour délimiter leur portée. Voici comment se
notent les plus couramment utilisés et leur signification :
(...)
capture le contenu des parenthèses(a|b)
capture soita
, soitb
(?:...)
est un groupe sans mémoire(?>...)
est un groupe possessif sans mémoire(?#...)
représente un commentaire(?<name>...)
est un groupe capturant nommé(?=...)
déclare une exploration positive(?!...)
déclare une exploration négative(?<=...)
déclare une exploration arrière positive(?<!...)
déclare une exploration arrière négative
Voyons ensemble leur fonctionnement général et quelques cas d’usage.
Capturé ! J’en fais quoi maintenant ?
Le cas le plus simple est celui où l’on souhaite extraire un sous-ensemble de la recherche d’un certain motif au sein d’une chaîne de caractères. Coiffons-nous de notre casquette rouge et prenons pour exemple la phrase « Pikachu est une espèce de Pokémon ». Et voici la regex qui nous permettra d’attraper cette petite bête :
/(Pika)chu/
Nous obtenons ainsi la concordance suivante :
Concordance Complète (0-6) : Pikachu
Groupe 1 (0-3) : Pika
En Ruby, pour obtenir ce résultat, nous utiliserions la méthode Regexp#match
qui nous renverrait un objet MatchData
, qu’il nous serait alors possible de
parcourir à la manière d’un Array
: le premier élément serait la concordance
complète et les suivants les groupes capturés. Voici un exemple :
text = "Pikachu est une espèce de Pokémon"
match = /(Pika)chu/.match(text)
# => #<MatchData "Pikachu" 1:"Pika">
puts match[0]
# => "Pikachu"
puts match[1]
# => "Pika"
Évolution
Faisons évoluer notre regex. Non content de capturer des Pikachu, on aimerait
aussi attraper des Raichu et des Pichu ! Pour cela, rien de plus simple, il nous
suffit d’utiliser la syntaxe (a|b)
. Partons du texte suivant :
Dans la seconde génération de jeux Pokémon apparaît Pichu, la pré-évolution de Pikachu, qui est issu d'un œuf de Pikachu ou de Raichu, son évolution.
Et voici notre nouvelle regex :
/(Pi|Pika|Rai)chu/g
On a à présent quatre concordances : un Pichu, deux Pikachu et un Raichu. Notez
la présence du modificateur g
pour effectuer une recherche globale. Cela nous
permet de trouver toutes les concordances dans le texte.
Concordance 1 (52-57) : Pichu
Groupe 1 (52-54) : Pi
Concordance 2 (79-86) : Pikachu
Groupe 1 (79-83) : Pika
Concordance 3 (113-120) : Pikachu
Groupe 1 (113-117) : Pika
Concordance 4 (127-133) : Raichu
Groupe 1 (127-130) : Rai
Et là, en Ruby ça se complique un peu ! On se serait attendu à une méthode comme
Regexp#match_all
ou quelque chose de similaire, mais j’ai beau chercher, je
n’en trouve aucune trace. Ce que l’on a de plus approchant, c’est la méthode
String#scan
. La voici à l’œuvre :
text = <<~TXT
Dans la seconde génération de jeux Pokémon apparaît Pichu, la pré-évolution de
Pikachu, qui est issu d'un œuf de Pikachu ou de Raichu, son évolution.
TXT
matches = text.scan(/(Pi|Pika|Rai)chu/)
# => [["Pi"], ["Pika"], ["Pika"], ["Rai"]]
Bien mais pas top. En effet, nous avons là ce qui a été capturé, mais aucune trace de notre concordance complète ! Pour y remédier, nous avons deux options. La première consiste à capturer l’entièreté de notre regex à l’aide de parenthèses englobantes :
text.scan(/((Pi|Pika|Rai)chu)/)
# => [["Pichu", "Pi"], ["Pikachu", "Pika"], ["Pikachu", "Pika"], ["Raichu", "Rai"]]
La seconde est plus subtile et nous permet de manipuler in fine des objets
MatchData
. La voici :
text.to_enum(:scan, /(Pi|Pika|Rai)chu/).map { Regexp.last_match }
# => [#<MatchData "Pichu" 1:"Pi">,
# #<MatchData "Pikachu" 1:"Pika">,
# #<MatchData "Pikachu" 1:"Pika">,
# #<MatchData "Raichu" 1:"Rai">]
Pi ou Pika ?
Vous aurez noté que Pichu
et Pikachu
sont très similaires. N’aurions-nous
pas une autre manière d’écrire notre regex ? Si, tout à fait ! Et c’est le
moment choisi pour utiliser un quantificateur de groupe ! Voyons ça. Ce que
l’on veut c’est Pi
, suivi éventuellement de ka
, suivi de chu
. On le note
ainsi :
/Pi(ka)?chu/
Le point d’interrogation ?
a sur un groupe le même effet que sur un caractère
seul : il permet d’indiquer qu’on s’attend à le rencontrer une ou zéro fois.
Nous pouvons donc réécrire notre regex comme ceci :
/(Pi(ka)?|Rai)chu/
Concordance 1 (52-57) : Pichu
Groupe 1 (52-54) : Pi
Concordance 2 (79-86) : Pikachu
Groupe 1 (79-83) : Pika
Groupe 2 (81-83) : ka
Concordance 3 (113-120) : Pikachu
Groupe 1 (113-117) : Pika
Groupe 2 (115-117) : ka
Concordance 4 (127-133) : Raichu
Groupe 1 (127-130) : Rai
Cela nous donne quasiment le même résultat. Et ce « quasiment » tient à la présence d’un second groupe capturant dans notre premier groupe. Ce n’est pas bien gênant, il nous suffit de ne pas en tenir compte à l’usage, mais c’est toutefois l’occasion rêvée d’introduire la notion de groupe non capturant.
Relâchez-le !
Pour faire disparaitre le second groupe qui se manifeste lorsqu’on trouve une
occurrence de Pikachu
dans notre texte, nous allons déclarer notre groupe
comme non capturant. Pour cela, la syntaxe appropriée se trouve être (?:)
. Ce
qui donne ceci :
/(Pi(?:ka)?|Rai)chu/
Cela fonctionne à l’instar d’un groupe classique ()
, à ceci près qu’aucune
capture ne sera faite. On obtient ainsi exactement le résultat attendu :
Concordance 1 (52-57) : Pichu
Groupe 1 (52-54) : Pi
Concordance 2 (79-86) : Pikachu
Groupe 1 (79-83) : Pika
Concordance 3 (113-120) : Pikachu
Groupe 1 (113-117) : Pika
Concordance 4 (127-133) : Raichu
Groupe 1 (127-130) : Rai
NOTE : Nous aurions pu tout aussi bien utiliser un groupe atomique (?>)
dans ce cas précis, cela n’aurait fait aucune différence. La particularité d’un
groupe atomique, en plus d’être non capturant, est qu’il est possessif.
C’est-à-dire que tout caractère qu’il consomme ne sera pas restitué, ce qui dans
certains cas peut empêcher le moteur de regex à trouver une concordance.
Le caractère possessif d’un groupe — comme d’un quantificateur (voir l’article
« Le gourmand, le fainéant et le possessif ») — a pour
objectif principal d’optimiser la performance d’une regex en lui permettant
d’échouer plus rapidement.
Pika Pika
text = <<~TXT
Dans la sixième génération, les cris des anciens Pokémon ont presque tous été
modifiés, afin de les adapter aux capacités sonores de la Nintendo 3DS.
Pikachu, quant à lui, a eu son cri totalement refait, et a désormais la voix
d'Ikue Ōtani, sa doubleuse du dessin animé.
Dans le dessin animé, au lieu des cris des jeux, les acteurs ont doublé la
voix des Pokémon rien qu'en prononçant une partie de leur nom, conduisant au
fameux "Pika !" du Pikachu de Sacha.
TXT
source : pokedia.fr
Dans le texte ci-dessus, nous aimerions à présent relever toutes les occurrences
du cri de Pikachu. Pour cela, il faut être en mesure de distinguer le nom du
Pokémon Pikachu
de son cri Pika
. Pour complexifier un peu l’exercice, on va
accepter l’idée que le cri de Pichu
est Pi
et que c’est aussi une réponse
acceptable. Il y a bien entendu de nombreuses techniques pour arriver à nos fins.
Celle que je vous présente ici n’est certainement pas la plus simple, mais a le
mérite d’introduire le concept d’exploration, et plus précisément d’exploration
avant négative (?!)
, appelée negative lookahead en anglais.
Ainsi, nous recherchons Pi
ou Pika
, mais nous n’acceptons de concordance que
si le terme ainsi trouvé n’est pas directement suivi de chu
. Ce qui nous
donne :
/Pi(?:ka)?(?!chu)/
Concordance 1 (150-152) : Pi
Concordance 2 (432-436) : Pika
Concordance 3 (443-445) : Pi
Aïe ! Nous n’aurions dû trouver qu’une occurrence de Pika
et c’est tout. Que
s’est-il passé ? Eh bien tout simplement, notre moteur de regex a considéré, à
juste titre, que Pi
suivi de kachu
était tout à fait acceptable ! Comment
nous sortir de là ? Avec une petite pirouette dont nous avons fait mention
quelques paragraphes plus haut : un quantificateur possessif ?+
.
/Pi(?:ka)?+(?!chu)/
Concordance 1 (432-436) : Pika
Et voilà le travail ! Notez que les groupes exploratoires ne sont pas capturants eux non plus, c’est pourquoi nous n’avons qu’un seul groupe par concordance.
NOTE: Je ne m’attarderai pas dessus ici, mais sachez qu’il existe aussi des
groupes exploratoires positifs (?=)
, ainsi que des groupes exploratoires
arrières positifs (?<=)
et négatifs (?<!)
.
Pour les curieux, une autre approche aurait été d’employer le caractère \b
qui n’est rien d’autre qu’un délimiteur de début ou fin de mot. Ce qui
donnerait :
/\bPi(?:ka)?+\b/
Nommez-les tous !
Arrive un moment où faire référence aux groupes de nos regex à l’aide d’indices
rend la chose très confuse, et où il serait bienvenu de pouvoir les nommer. Qu’à
cela ne tienne, il suffit de demander ! La syntaxe pour nommer un groupe est
(?<name>)
où name
sera le nom du groupe. On peut parfois trouver, selon les
langages, la notation (?'name')
ou encore (?P<name>)
.
Si l’on reprend le texte de notre dernier exemple, et sachant que le cri de
notre Pokémon correspond à la première partie de son nom (sans chu
, donc),
voici ce que l’on obtiendrait :
/(?<pokemon>(?<cri>Pi(?:ka)?)chu)/
Concordance 1 (150-157) : Pikachu
pokemon (150-157) : Pikachu
cri (150-154) : Pika
Concordance 2 (443-450) : Pikachu
pokemon (443-450) : Pikachu
cri (443-447) : Pika
Ainsi, en Ruby nous pouvons à présent y faire référence comme ceci :
match = text.match(/(?<pokemon>(?<cri>Pi(?:ka)?)chu)/)
# => #<MatchData "Pikachu" pokemon:"Pikachu" cri:"Pika">
puts match[:pokemon]
# "Pikachu"
puts match[:cri]
# "Pika"
S’envoler vers d’autres cieux
Des groupes, il en existe encore de nombreux que je vous laisse découvrir par vous-même. Attention cependant, certains ne sont supportés que par un ensemble très restreint de langages ; le plus abouti en la matière étant Perl. Tout ceci est très bien documenté sur regex101.com, un outil interactif extrêmement évolué et intuitif, ainsi que sur regular-expressions.info (en anglais), pour ne citer qu’eux. Notez que pour les rubyistes, il existe aussi rubular comme alternative à regex101.