jeudi 9 avril 2020
Ce billet a été vu pour la première fois sur le blog de Synbioz le 09 April 2020 sous licence CC BY-NC-SA.

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 :

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