Utiliser des captures nommées
Par Christophe Porteneuve • Publié le 17 mai 2020 • 4 min

This page is also available in English.

Déjà le quatorzième article de notre série quotidienne « 19 pépites de JS pur », et nous reparlons expressions rationnelles (regex) pour mettre en lumière une des nouveautés les plus agréables à leur sujet dans ES2018 : les groupes de capture nommés.

Dans la série…

Extrait de la liste des articles :

  1. Définir proprement des paramètres nommés optionnels
  2. const is the new var
  3. Utiliser des captures nommées (cet article)
  4. Object spread vs. Object.assign
  5. Convertir un objet en Map et réciproquement
  6. …au-delà, c’est la surprise ! (mais la liste est déjà calée)…

Petit rappel sur les groupes

Dans une expression rationnelle, on utilise un groupe pour appliquer un quantificateur ou une alternative à plus d’un caractère.

Par exemple, pour dire « au moins une fois la lettre “b” », on écrirait simplement b+. Mais pour dire « au moins une fois le texte “ba” », on ne peut pas dire ba+ : ça, ça voudrait dire « la lettre “b”, suivie d’au moins une fois la lettre “a” ». On crée donc un groupe autour du texte, sur lequel le quantificateur s’applique : (ba)+.

De la même façon, baba|bébé signifie « “baba” ou “bébé” », mais pour dire « “salut”, suivi de “baba” ou “bébé”, suivi de “!” » il faudra dire salut (baba|bébé) ! afin de restreindre le périmètre de l’alternative, sans quoi on dirait « “salut baba” ou “bébé !” ».

Groupes capturants

Par défaut, les groupes sont capturants : la portion du texte examiné qui leur correspond in fine est isolée dans un groupe de capture numéroté. Le groupe zéro est toujours là : c’est la correspondance intégrale de l’expression. Les groupes à partir de un (1) sont les groupes capturants. Ainsi, si on examine le “match result” (un Array étendu pour l’occasion) produit par l’expression dans le code ci-dessous, on y trouve notamment des propriétés 1, 2 et 3 qui correspondent aux trois groupes capturés.

const REGEX_US_PHONE = /\b(\d{3})-(\d{3})-(\d{4})\b/
const result = 'Twitter HQ: 415-222-9670'.match(REGEX_US_PHONE)
result[0] // => '415-222-9760'
result[1] // => '415'
result[2] // => '222'
result[3] // => '9760'

Les groupes capturants sont aussi pratiques pour faire des backrefs (back references) : indiquer dans le motif qu’on doit trouver « le même texte source que celui qui a correspondu à un emplacement antérieur de l’expression ». Par exemple, imaginons que tu veuilles correspondre à un attribut HTML, dont la valeur peut être délimitée par une apostrophe (') ou un guillemet ("). L’important, c’est qu’on ait le même délimiteur des deux côtés. Tu peux faire une backref en utilisant le numéro de capture voulu :

// Volontairement simplifié pour la partie “nom”…
const REGEX_HTML_ATTR = /[\w-]+=(['"])(.+?)\1/
REGEX_HTML_ATTR.exec(`name="foo"`) // => ['name="foo"', '"', 'foo']
REGEX_HTML_ATTR.exec(`name='foo'`) // => ["name='foo'", "'", 'foo']
REGEX_HTML_ATTR.exec(`name='foo"`) // => null
REGEX_HTML_ATTR.exec(`name="foo'`) // => null

Ici la correspondance de délimiteur (['"]) est dans le premier groupe capturant : pour faire une backref, on utilisera donc \1.

Dans le même ordre d’idée, si on utilise la regex avec l’API String#replace et qu’on précise un schéma de remplacement textuel, on peut y référencer un groupe avec la notation $numéro. Par exemple :

'415-222-9670'.replace(REGEX_US_PHONE, '$3/$2/$1')
// => '9670/222/415

Groupes non capturants

Note que ces numéros sont vite casse-gueule : dès qu’on ajoute un niveau de groupe quelque-part, ça décale tout le reste ! Imagine qu’on veuille préciser que le numéro de téléphone a potentiellement “tel:” devant, ça décale tout le reste :

const REGEX_US_PHONE = /\b(tel:)?(\d{3})-(\d{3})-(\d{4})\b/
const result = 'Twitter HQ: 415-222-9670'.match(REGEX_US_PHONE)
result[0] // => '415-222-9760'
result[1] // => undefined -- ARGH!
result[2] // => '415' -- Damned.
result[3] // => '222' -- Foutrepeste.
result[4] // => '9760' -- Palsambleu.

Si ça se trouve, on ne s’intéressait même pas au fait que le protocole “tel:” soit présent ou non, au final, on voulait juste l’intégrer à la correspondance, sans mettre le boxon dans les numéros de capture. On pourrait alors utiliser un groupe non capturant, en démarrant par (?: au lieu de simplement ( :

const REGEX_US_PHONE = /\b(?:tel:)?(\d{3})-(\d{3})-(\d{4})\b/
const result = 'Twitter HQ: 415-222-9670'.match(REGEX_US_PHONE)
result[0] // => '415-222-9760'
result[1] // => '415' -- Yay!
result[2] // => '222' -- Yowza!
result[3] // => '9760' -- Banzaï!

Spécialisations de groupe

D’une façon générale, toute spécialisation de groupe démarre par (? :

  • (?: pour les groupes non capturants,
  • (?= pour les tests de succession (lookaheads),
  • (?! pour les tests de succession négative (negative lookaheads),
  • (?<= pour les recherches arrières (lookbehinds),
  • (?<! pour les recherches arrières négatives (negative lookbehinds),

Les groupes de capture nommés

De nombreux langages ont une meilleure façon de capturer les groupes : en les nommant. C’est plus lisible et plus résistant à l’évolution de l’expression : pas de décalage par inadvertance, comme avec les numéros.

ES2018 ajoute enfin ça ! L’API correspondante est multiple :

  • On définit un groupe de capture nommé avec (?<nom>expr) (donc entre chevrons, avant l’expression de correspondance).
  • On peut faire une backref avec \k<nom>.
  • Le match result est doté d’une propriété groups, qui devient un objet dont les propriétés ont les noms des groupes de capture.
  • Le motif textuel de remplacement dans String#replace permet $<nom> pour référencer les groupes de capture.

Et pour la petite histoire, ces groupes restent numérotés par-dessus le marché (mais OSEF).

Reprenons nos exemples précédents en mode « capture nommée » :

const HTML_ATTR = /(?<name>[\w-]+)=(?<delim>['"])(?<value>.+?)\k<delim>/
HTML_ATTR.exec(`name="foo"`).groups
// => { name: 'name', delim: '"', value: 'foo' }
HTML_ATTR.exec(`name='foo'`).groups
// => { name: 'name', delim: "'", value: 'foo' }

const US_PHONE = /\b(?<area>\d{3})-(?<prefix>\d{3})-(?<line>\d{4})\b/
'415-222-9670'.replace(US_PHONE, '$<line>/$<prefix>/$<area>')
// => '9670/222/415'

Moi je dis 😍.

C’est dispo où ?!

Nativement : pas dans IE11, on s’y attendait bien, mais pas dans Firefox (ça traîne de ouf depuis 3 ans, ils attendent d’avoir fini de récupérer la lib de regex issue de v8…), sinon à partir de Chrome 64, Edge 79, Safari 11.1, Opera 51 et Node 10.

Heureusement, Babel transpile (notamment avec les presets env et latest).

Cadeau bonus : String#matchAll(…)

Un des soucis récurrents avec String#match (et sa réciproque RegExp#exec), c’est qu’on ne pouvait pas avoir le beurre et l’argent du beurre quand on avait des groupes capturants :

  • Soit on utilisait le drapeau g (global : renvoyer toutes les correspondances de l’expression), et on obtenait un tableau des correspondances intégrales, mais sans leurs groupes individuels.
  • Soit on n’utilisait pas ce drapeau, et on obtenait null ou un match result, avec les groupes capturants individuels.

Démonstration de la lose :

const US_PHONES = /\b(?<area>\d{3})-(?<prefix>\d{3})-(?<line>\d{4})\b/g
const text = `
HQ: 412-222-9670
Support: 415-865-5405
`

text.match(US_PHONES)
// => ['412-222-9670', '415-865-5405']
// --> Mais… mais ! Où sont mes groupes individuels !?

Mais depuis ES2020, on a enfin String#matchAll, qui renverra un itérateur (encore mieux qu’un bête Array) des match results :

Array.from(text.matchAll(US_PHONES))
// => [
// ['412-222-9670', '412', '222', '9670'],
// ['415-865-5405', '415', '865', '5405'],
// ]

Array.from(text.matchAll(US_PHONES)).map((a) => a.groups)
// => [
// { area: '412', prefix: '222', line: '9670' },
// { area: '415', prefix: '865', line: '5405' },
// ]

Trop. La. Classe.

Dispo nativement depuis Firefox 67, Chrome 73, Edge 79, Opera 60, Safari 13 et Node 12. Pas IE évidemment. core-js (utilisé notamment par Babel) le polyfille depuis la 3.4.

Back to the future

Je t’expliquais déjà tout sur les regex (de l’époque) en 2012. De nombreuses limitations que j’y soulignais ont été corrigées depuis ! 😉