5 de noviembre de 2024

Grupos de captura

Una parte de un patrón se puede incluir entre paréntesis (...). Esto se llama “grupo de captura”.

Esto tiene dos resultados:

  1. Permite obtener una parte de la coincidencia como un elemento separado en la matriz de resultados.
  2. Si colocamos un cuantificador después del paréntesis, se aplica a los paréntesis en su conjunto.

Ejemplos

Veamos cómo funcionan los paréntesis en los ejemplos.

Ejemplo: gogogo

Sin paréntesis, el patrón go+ significa el carácter g, seguido por o repetido una o más veces. Por ejemplo, goooo o gooooooooo.

Los paréntesis agrupan los carácteres juntos, por lo tanto (go)+ significa go, gogo, gogogo etcétera.

alert( 'Gogogo now!'.match(/(go)+/ig) ); // "Gogogo"

Ejemplo: dominio

Hagamos algo más complejo: una expresión regular para buscar un dominio de sitio web.

Por ejemplo:

mail.com
users.mail.com
smith.users.mail.com

Como podemos ver, un dominio consta de palabras repetidas, un punto después de cada una excepto la última.

En expresiones regulares eso es (\w+\.)+\w+:

let regexp = /(\w+\.)+\w+/g;

alert( "site.com my.site.com".match(regexp) ); // site.com,my.site.com

La búsqueda funciona, pero el patrón no puede coincidir con un dominio con un guión, por ejemplo, my-site.com, porque el guión no pertenece a la clase \w.

Podemos arreglarlo al reemplazar \w con [\w-] en cada palabra excepto el último: ([\w-]+\.)+\w+.

Ejemplo: email

El ejemplo anterior puede ser extendido. Podemos crear una expresión regular para emails en base a esto.

El formato de email es: name@domain. Cualquier palabra puede ser el nombre, guiones y puntos están permitidos. En expresiones regulares esto es [-.\w]+.

El patrón:

let regexp = /[-.\w]+@([\w-]+\.)+[\w-]+/g;

alert("my@mail.com @ his@site.com.uk".match(regexp)); // my@mail.com, his@site.com.uk

Esa expresión regular no es perfecta, pero sobre todo funciona y ayuda a corregir errores de escritura accidentales. La única verificación verdaderamente confiable para un correo electrónico solo se puede realizar enviando una carta.

Contenido del paréntesis en la coincidencia (match)

Los paréntesis están numerados de izquierda a derecha. El buscador memoriza el contenido que coincide con cada uno de ellos y permite obtenerlo en el resultado.

El método str.match(regexp), si regexp no tiene indicador (flag) g, busca la primera coincidencia y lo devuelve como un array:

  1. En el índice 0: la coincidencia completa.
  2. En el índice 1: el contenido del primer paréntesis.
  3. En el índice 2: el contenido del segundo paréntesis.
  4. …etcétera…

Por ejemplo, nos gustaría encontrar etiquetas HTML <.*?>, y procesarlas. Sería conveniente tener el contenido de la etiqueta (lo que está dentro de los ángulos), en una variable por separado.

Envolvamos el contenido interior en paréntesis, de esta forma: <(.*?)>.

Ahora obtendremos ambos, la etiqueta entera <h1> y su contenido h1 en el array resultante:

let str = '<h1>Hello, world!</h1>';

let tag = str.match(/<(.*?)>/);

alert( tag[0] ); // <h1>
alert( tag[1] ); // h1

Grupos anidados

Los paréntesis pueden ser anidados. En este caso la numeración también va de izquierda a derecha.

Por ejemplo, al buscar una etiqueta en <span class="my"> tal vez nos pueda interesar:

  1. El contenido de la etiqueta como un todo: span class="my".
  2. El nombre de la etiqueta: span.
  3. Los atributos de la etiqueta: class="my".

Agreguemos paréntesis: <(([a-z]+)\s*([^>]*))>.

Así es cómo se enumeran (izquierda a derecha, por el paréntesis de apertura):

En acción:

let str = '<span class="my">';

let regexp = /<(([a-z]+)\s*([^>]*))>/;

let result = str.match(regexp);
alert(result[0]); // <span class="my">
alert(result[1]); // span class="my"
alert(result[2]); // span
alert(result[3]); // class="my"

El índice cero de result siempre contiene la coincidencia completa.

Luego los grupos, numerados de izquierda a derecha por un paréntesis de apertura. El primer grupo se devuelve como result[1]. Aquí se encierra todo el contenido de la etiqueta.

Luego en result[2] va el grupo desde el segundo paréntesis de apertura ([a-z]+) – nombre de etiqueta, luego en result[3] la etiqueta: ([^>]*).

El contenido de cada grupo en el string:

Grupos opcionales

Incluso si un grupo es opcional y no existe en la coincidencia (p.ej. tiene el cuantificador (...)?), el elemento array result correspondiente está presente y es igual a undefined.

Por ejemplo, consideremos la expresión regular a(z)?(c)?. Busca "a" seguida por opcionalmente "z", seguido por "c" opcionalmente.

Si lo ejecutamos en el string con una sola letra a, entonces el resultado es:

let match = 'a'.match(/a(z)?(c)?/);

alert( match.length ); // 3
alert( match[0] ); // a (coincidencia completa)
alert( match[1] ); // undefined
alert( match[2] ); // undefined

El array tiene longitud de 3, pero todos los grupos están vacíos.

Y aquí hay una coincidencia más compleja para el string ac:

let match = 'ac'.match(/a(z)?(c)?/)

alert( match.length ); // 3
alert( match[0] ); // ac (coincidencia completa)
alert( match[1] ); // undefined, ¿porque no hay nada para (z)?
alert( match[2] ); // c

La longitud del array es permanente: 3. Pero no hay nada para el grupo (z)?, por lo tanto el resultado es ["ac", undefined, "c"].

Buscar todas las coincidencias con grupos: matchAll

matchAll es un nuevo método, polyfill puede ser necesario

El método matchAll no es compatible con antiguos navegadores.

Un polyfill puede ser requerido, tal como https://github.com/ljharb/String.prototype.matchAll.

Cuando buscamos todas las coincidencias (flag g), el método match no devuelve contenido para los grupos.

Por ejemplo, encontremos todas las etiquetas en un string:

let str = '<h1> <h2>';

let tags = str.match(/<(.*?)>/g);

alert( tags ); // <h1>,<h2>

El resultado es un array de coincidencias, pero sin detalles sobre cada uno de ellos. Pero en la práctica normalmente necesitamos contenidos de los grupos de captura en el resultado.

Para obtenerlos tenemos que buscar utilizando el método str.matchAll(regexp).

Fue incluido a JavaScript mucho después de match, como su versión “nueva y mejorada”.

Al igual que match, busca coincidencias, pero hay 3 diferencias:

  1. No devuelve un array sino un objeto iterable.
  2. Cuando está presente el indicador g, devuelve todas las coincidencias como un array con grupos.
  3. Si no hay coincidencias, no devuelve null sino un objeto iterable vacío.

Por ejemplo:

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

// results - no es un array, sino un objeto iterable
alert(results); // [object RegExp String Iterator]

alert(results[0]); // undefined (*)

results = Array.from(results); // lo convirtamos en array

alert(results[0]); // <h1>,h1 (1er etiqueta)
alert(results[1]); // <h2>,h2 (2da etiqueta)

Como se demuestra en la línea (*), la primera diferencia es muy importante. No podemos obtener la coincidencia como results[0], porque ese objeto es un pseudo array. Lo podemos convertir en un Array real utilizando Array.from. Hay más detalles sobre pseudo arrays e iterables en el artículo. Iterables.

No necesitamos Array.from si estamos iterando sobre los resultados:

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

for(let result of results) {
  alert(result);
  // primer alert: <h1>,h1
  // segundo: <h2>,h2
}

…O utilizando desestructurización:

let [tag1, tag2] = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

Cada coincidencia devuelta por matchAll tiene el mismo formato que el devuelto por match sin el flag g: es un array con propiedades adicionales index (coincide índice en el string) e input (fuente string):

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

let [tag1, tag2] = results;

alert( tag1[0] ); // <h1>
alert( tag1[1] ); // h1
alert( tag1.index ); // 0
alert( tag1.input ); // <h1> <h2>
¿Por qué el resultado de matchAll es un objeto iterable y no un array?

¿Por qué el método está diseñado de esa manera? La razón es simple – por la optimización.

El llamado a matchAll no realiza la búsqueda. En cambio devuelve un objeto iterable, en un principio sin los resultados. La búsqueda es realizada cada vez que iteramos sobre ella, es decir, en el bucle.

Por lo tanto, se encontrará tantos resultados como sea necesario, no más.

Por ejemplo, posiblemente hay 100 coincidencias en el texto, pero en un bucle for..of encontramos 5 de ellas: entonces decidimos que es suficiente y realizamos un break. Así el buscador no gastará tiempo buscando otras 95 coincidencias.

Grupos con nombre

Es difícil recordar a los grupos por su número. Para patrones simples, es factible, pero para los más complejos, contar los paréntesis es inconveniente. Tenemos una opción mucho mejor: poner nombres entre paréntesis.

Eso se hace poniendo ?<name> inmediatamente después del paréntesis de apertura.

Por ejemplo, busquemos una fecha en el formato “año-mes-día”:

let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;
let str = "2019-04-30";

let groups = str.match(dateRegexp).groups;

alert(groups.year); // 2019
alert(groups.month); // 04
alert(groups.day); // 30

Como puedes ver, los grupos residen en la propiedad .groups de la coincidencia.

Para buscar todas las fechas, podemos agregar el flag g.

También vamos a necesitar matchAll para obtener coincidencias completas, junto con los grupos:

let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;

let str = "2019-10-30 2020-01-01";

let results = str.matchAll(dateRegexp);

for(let result of results) {
  let {year, month, day} = result.groups;

  alert(`${day}.${month}.${year}`);
  // primer alert: 30.10.2019
  // segundo: 01.01.2020
}

Grupos de captura en reemplazo

El método str.replace(regexp, replacement) que reemplaza todas las coincidencias con regexp en str nos permite utilizar el contenido de los paréntesis en el string replacement. Esto se hace utilizando $n, donde n es el número de grupo.

Por ejemplo,

let str = "John Bull";
let regexp = /(\w+) (\w+)/;

alert( str.replace(regexp, '$2, $1') ); // Bull, John

Para los paréntesis con nombre la referencia será $<name>.

Por ejemplo, volvamos a darle formato a las fechas desde “year-month-day” a “day.month.year”:

let regexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;

let str = "2019-10-30, 2020-01-01";

alert( str.replace(regexp, '$<day>.$<month>.$<year>') );
// 30.10.2019, 01.01.2020

Grupos que no capturan con ?:

A veces necesitamos paréntesis para aplicar correctamente un cuantificador, pero no queremos su contenido en los resultados.

Se puede excluir un grupo agregando ?: al inicio.

Por ejemplo, si queremos encontrar (go)+, pero no queremos el contenido del paréntesis (go) como un ítem separado del array, podemos escribir: (?:go)+.

En el ejemplo de arriba solamente obtenemos el nombre John como un miembro separado de la coincidencia:

let str = "Gogogo John!";

// ?: excluye 'go' de la captura
let regexp = /(?:go)+ (\w+)/i;

let result = str.match(regexp);

alert( result[0] ); // Gogogo John (coincidencia completa)
alert( result[1] ); // John
alert( result.length ); // 2 (no hay más ítems en el array)

Resumen

Los paréntesis agrupan una parte de la expresión regular, de modo que el cuantificador se aplique a ella como un todo.

Los grupos de paréntesis se numeran de izquierda a derecha y, opcionalmente, se pueden nombrar con (?<name>...).

El contenido, emparejado por un grupo, se puede obtener en los resultados:

  • El método str.match devuelve grupos de captura únicamente sin el indicador (flag) g.
  • El método str.matchAll siempre devuelve grupos de captura.

Si el paréntesis no tiene nombre, entonces su contenido está disponible en el array de coincidencias por su número. Los paréntesis con nombre también están disponible en la propiedad groups.

También podemos utilizar el contenido del paréntesis en el string de reemplazo de str.replace: por el número $n o el nombre $<name>.

Un grupo puede ser excluido de la enumeración al agregar ?: en el inicio. Eso se usa cuando necesitamos aplicar un cuantificador a todo el grupo, pero no lo queremos como un elemento separado en el array de resultados. Tampoco podemos hacer referencia a tales paréntesis en el string de reemplazo.

Tareas

La Dirección MAC de una interfaz de red consiste en 6 números hexadecimales de dos dígitos separados por dos puntos.

Por ejemplo: '01:32:54:67:89:AB'.

Escriba una expresión regular que verifique si una cadena es una Dirección MAC.

Uso:

let regexp = /your regexp/;

alert( regexp.test('01:32:54:67:89:AB') ); // true

alert( regexp.test('0132546789AB') ); // false (sin dos puntos)

alert( regexp.test('01:32:54:67:89') ); // false (5 números, necesita 6)

alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ al final)

Un número hexadecimal de dos dígitos es [0-9a-f]{2} (suponiendo que se ha establecido el indicador i).

Necesitamos ese número NN, y luego :NN repetido 5 veces (más números);

La expresión regular es: [0-9a-f]{2}(:[0-9a-f]{2}){5}

Ahora demostremos que la coincidencia debe capturar todo el texto: comience por el principio y termine por el final. Eso se hace envolviendo el patrón en ^...$.

Finalmente:

let regexp = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i;

alert( regexp.test('01:32:54:67:89:AB') ); // true

alert( regexp.test('0132546789AB') ); // false (sin dos puntos)

alert( regexp.test('01:32:54:67:89') ); // false (5 números, necesita 6)

alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ al final)

Escriba una expresión regular que haga coincidir los colores en el formato #abc o #abcdef. Esto es: # seguido por 3 o 6 dígitos hexadecimales.

Ejemplo del uso:

let regexp = /your regexp/g;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef

P.D. Esto debe ser exactamente 3 o 6 dígitos hexadecimales. Valores con 4 dígitos, tales como #abcd, no deben coincidir.

Una expresión regular para buscar colores de 3 dígitos #abc: /#[a-f0-9]{3}/i.

Podemos agregar exactamente 3 dígitos hexadecimales opcionales más. No necesitamos más ni menos. El color tiene 3 o 6 dígitos.

Utilicemos el cuantificador {1,2} para esto: llegaremos a /#([a-f0-9]{3}){1,2}/i.

Aquí el patrón [a-f0-9]{3} está rodeado en paréntesis para aplicar el cuantificador {1,2}.

En acción:

let regexp = /#([a-f0-9]{3}){1,2}/gi;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef #abc

Hay un pequeño problema aquí: el patrón encontrado #abc en #abcd. Para prevenir esto podemos agregar \b al final:

let regexp = /#([a-f0-9]{3}){1,2}\b/gi;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef

Escribe una expresión regular que busque todos los números decimales, incluidos los enteros, con el punto flotante y los negativos.

Un ejemplo de uso:

let regexp = /your regexp/g;

let str = "-1.5 0 2 -123.4.";

alert( str.match(regexp) ); // -1.5, 0, 2, -123.4

Un número positivo con una parte decimal opcional es: \d+(\.\d+)?.

Agreguemos el opcional al comienzo -:

let regexp = /-?\d+(\.\d+)?/g;

let str = "-1.5 0 2 -123.4.";

alert( str.match(regexp) );   // -1.5, 0, 2, -123.4

Una expresión aritmética consta de 2 números y un operador entre ellos, por ejemplo:

  • 1 + 2
  • 1.2 * 3.4
  • -3 / -6
  • -2 - 2

El operador es uno de estos: "+", "-", "*" o "/".

Puede haber espacios adicionales al principio, al final o entre las partes.

Crea una función parse(expr) que tome una expresión y devuelva un array de 3 ítems:

  1. El primer número.
  2. El operador.
  3. El segundo número.

Por ejemplo:

let [a, op, b] = parse("1.2 * 3.4");

alert(a); // 1.2
alert(op); // *
alert(b); // 3.4

Una expresión regular para un número es: -?\d+(\.\d+)?. La creamos en tareas anteriores.

Un operador es [-+*/]. El guión - va primero dentro de los corchetes porque colocado en el medio significaría un rango de caracteres, cuando nosotros queremos solamente un carácter -.

La barra inclinada / debe ser escapada dentro de una expresión regular de JavaScript /.../, eso lo haremos más tarde.

Necesitamos un número, un operador y luego otro número. Y espacios opcionales entre ellos.

La expresión regular completa: -?\d+(\.\d+)?\s*[-+*/]\s*-?\d+(\.\d+)?.

Tiene 3 partes, con \s* en medio de ellas:

  1. -?\d+(\.\d+)? – el primer número,
  2. [-+*/] – el operador,
  3. -?\d+(\.\d+)? – el segundo número.

Para hacer que cada una de estas partes sea un elemento separado del array de resultados, encerrémoslas entre paréntesis: (-?\d+(\.\d+)?)\s*([-+*/])\s*(-?\d+(\.\d+)?).

En acción:

let regexp = /(-?\d+(\.\d+)?)\s*([-+*\/])\s*(-?\d+(\.\d+)?)/;

alert( "1.2 + 12".match(regexp) );

El resultado incluye:

  • result[0] == "1.2 + 12" (coincidencia completa)
  • result[1] == "1.2" (primer grupo (-?\d+(\.\d+)?) – el primer número, incluyendo la parte decimal)
  • result[2] == ".2" (segundo grupo (\.\d+)? – la primera parte decimal)
  • result[3] == "+" (tercer grupo ([-+*\/]) – el operador)
  • result[4] == "12" (cuarto grupo (-?\d+(\.\d+)?) – el segundo número)
  • result[5] == undefined (quinto grupo (\.\d+)? – la última parte decimal no está presente, por lo tanto es indefinida)

Solo queremos los números y el operador, sin la coincidencia completa o las partes decimales, así que “limpiemos” un poco el resultado.

La coincidencia completa (el primer elemento del array) se puede eliminar cambiando el array result.shift().

Los grupos que contengan partes decimales (número 2 y 4) (.\d+) pueden ser excluídos al agregar ?: al comienzo: (?:\.\d+)?.

La solución final:

function parse(expr) {
  let regexp = /(-?\d+(?:\.\d+)?)\s*([-+*\/])\s*(-?\d+(?:\.\d+)?)/;

  let result = expr.match(regexp);

  if (!result) return [];
  result.shift();

  return result;
}

alert( parse("-1.23 * 3.45") );  // -1.23, *, 3.45

Como alternativa al uso de la exclusión de captura ?:, podemos dar nombre a los grupos:

function parse(expr) {
  let regexp = /(?<a>-?\d+(?:\.\d+)?)\s*(?<operator>[-+*\/])\s*(?<b>-?\d+(?:\.\d+)?)/;

  let result = expr.match(regexp);

  return [result.groups.a, result.groups.operator, result.groups.b];
}

alert( parse("-1.23 * 3.45") );  // -1.23, *, 3.45;
Mapa del Tutorial