Obtener todas las coincidencias solapadas en expresiones regulares – javascript regex

Pregunta:


Tengo este string de ejemplo en Javascript, "246126667", y quiero obtener todas las subcadenas que tengan 2 números cualquiera a la izquierda y un 6 al final, es decir, ["246","126","266","666"], y para eso le aplico un match con la expresión regular [0-9]{2}6, y el parámetro g para obtener los resultados, pero no funciona como yo quiero.

console.log(
  "246126667".match(/[0-9]{2}6/g)
)

El problema es que en vez de devolver los 4 números, solo devuelve ["246","126"]. ¿Es posible resolver esto mediante expresiones regulares? ¿Hay alguna otra manera?

Preguntado por: ArtEze

Respuesta Mariano:

Problema

La expresión que utilizaste ([0-9]{2}6) está muy bien. El tema principal de por qué no coincide con caracteres solapados es porque el motor de expresiones regulares consume los caracteres con los que coincide. Por lo tanto, luego de coincidir con "126" en

246126667
   ^^^

la posición para intentar la siguiente coincidencia será:

246126|667
      ^
     acá

y no encontrará otros 3 dígitos terminados en 6.

Solución

Una inspección positiva (o positive lookahead) intenta una coincidencia pero, si coincide, vuelve a la posición en la que se encontraba antes de intentarlo. Es decir, no consume caracteres. La sintaxis es

(?=expresión)

Entonces, este regex coincidirá con lo que estás buscando

/(?=d{2}6)/g

d es lo mismo que [0-9] en JavaScript.

Pero hay un agregado: como no consume caracteres, coincide con la posición, y no con los 3 caracteres que estás queriendo obtener. Para eso, usamos un grupo (lo rodeamos entre paréntesis) para capturar el texto.

Además, es necesario usar .exec() para luego obtener el texto capturado en match[1], y una condición extra para evitar loops infinitos que se deben a un bug conocido de JavaScript.

Regex

/(?=(d{2}6))/g

Código

const texto = "246126667",
      regex = /(?=(d{2}6))/g;
let   match;

while ((match = regex.exec(texto)) !== null) {
    if (match.index === regex.lastIndex) { //evitar loop infinito
        regex.lastIndex++;
    }
    
    //imprimir el texto capturado por el grupo 1
    console.log(match[1]);
}

¿Por qué es necesario evitar un loop infinito?

En la solución, agregué una condición para ver si el inicio de la
coincidencia (match.index) es la misma posición que donde se
iniciará el siguiente intento (regex.lastIndex). En caso de que sea
la misma, se incrementa en 1 la posición.

if (match.index === regex.lastIndex) { //evitar loop infinito
    regex.lastIndex++;
}

Esto se debe a que, como la coincidencia es en realidad de largo
cero
, ya que la aserción no consume caracteres, y el resultado del
método realmente es una cadena vacía (""), la posición desde donde
se intenta el regex no avanza ninguna posición. Por eso, se modifica
manualmente para que no siga intentando coincidir desde la misma posición,
entrando en un loop infinito.

Esta condición es una buena práctica a utilizar en todos los casos en
los que se utilice el modificador /g (global) y un bucle
con RegExp.prototype.exec(). Además, no todos los navegadores se
comportan igual. Incluso en expresiones que no deberían jamás devolver
una cadena vacía, es una buena salvaguarda, y recomiendo utilizar
siempre esta construcción.

JavaScript tiene una de las peores implementaciones de expresiones
regulares dentro de los lenguajes comúnmente utilizados (ver más
info
). Si bien este comportamiento no es un “bug” per se
(RESOLVED INVALID en Bugzilla), utilicé
el término porque es un concepto erróneo en la implementación de
Oniguruma (el motor de RegExp) sobre este comportamiento. Todos
los otros motores de expresiones regulares, luego de coincidir con una
cadena vacía muestran uno de estos dos comportamientos:

Una nota de color es que versiones previas de IE incrementaban automáticamente
a .lastIndex luego de una coincidencia de largo cero con expresiones
globales.
En el artículo An IE lastIndex Bug with Zero-Length Regex Matches
se describe con más detalle, y aunque Steven Levithan lo menciona como “bug”,
en mi opinión era en realidad la forma correcta de realizarlo, más en
concordancia con lo que expone Jan Goyvaerts en
Watch Out for Zero-Length Matches.

Sin embargo, IE terminó dejando a .lastIndex sin incrementar para mostrar
el mismo comportamiento que la mayoría de los navegadores desde IE9
(sólo si se especifica un DOCTYPE de HTML 4.01 o HTML5) o en IE10+
(sin importar el DOCTYPE).

JavaScript tiene un error de concepción en no utilizar una de
estas dos estrategias, rompe la norma del resto de los dialectos
(flavors) de regex, y en ese sentido es un bug.

El problema radica originalmente en el estándar. En ECMA-262 se
define a regexp.lastIndex como “el índice en el String en donde
iniciar el próximo intento de coincidencia
” (en 21.2.6.1) (no
dejen que el nombre los engañe, no es el final de la coincidencia),
y luego:

La diferencia entre ambos métodos es inconsistente. Incluso, se
reafirma en las notas de compatibilidad hacia atrás, pero sólo para
String.prototype.match() y String.prototype.replace():

El comportamiento correcto es que lastIndex debe ser incrementado en 1, sólo si el patrón coincidió con una cadena vacía.
(en D).

Y, si bien, sólo sucede con RegExp.prototype.exec(), este método
es el único que aporta la información completa de una coincidencia.
Por ejemplo, es la única forma de obtener el texto capturado por
grupos en una expresión global.

En el estándar queda claro que la falta de una definición consistente
con el uso de expresiones regulares en cualquier otro lenguaje hace
que sea necesario agregar está condición extra para incrementar el
índice manualmente, algo totalmente ilógico e innecesario. Este IF
es una buena práctica recomendada siempre que se haga un bucle de este
estilo.

Para completar la respuesta de @Mariano, voy a intentar arrojar un poco más de luz en el asunto para que quede totalmente explicado el tema de las aserciones positivas.

Como bien explicó @Mariano, usando la siguiente expresión regular /[0-9]{2}6/g, estas serían las operaciones que realiza el método match sobre el String que situaste de ejemplo:

Con el carácter | indicaré el inicio del match y con el carácter ^ indicaré los caracteres consumidos y desde dónde se realizaría la próxima búsqueda.

1 - |246126667 // hace match de 246
        ^
2 - 246|126667 // hace match de 126
           ^

Entonces, para que no ocurra esto puedes hacer dos cosas:

1 – Usar el método exec y manipular el lastIndex de la expresión regular, restándole 2 para que empiece la siguiente búsqueda a partir del siguiente carácter después del inicio del match:

var texto = "246126667",
    regex = /[0-9]{2}6/g,
    match,
    matches = [];

while ((match = regex.exec(texto)) !== null) {
    matches.push(match[0]);
    regex.lastIndex -= 2;
}

console.log(matches);

2 – O hacer lo que te recomendó @Mariano: una aserción positiva.

Pero con su expresión regular tendrías que manipular también la propiedad lastIndex ya que, una aserción positiva no es que haga match y regrese al inicio del match sino que nunca se mueve de ese sitio (no consume caracteres, porque la misma no forma parte del match). No se trata de un bug de JavaScript sino que le estamos indicando explícitamente al script que no consuma ningún carácter. Tomando de ejemplo la expresión regular /(?=(d{2}6))/g, notemos que la misma no intenta hacer match con ningún carácter, pero lo que sí hace es que captura un grupo formado por dos dígitos seguidos de un 6 por lo que podríamos acceder a esta captura con el método exec. Veamos cuáles serían los pasos que seguiría el método exec en esta expresión regular:

1 - |246126667 // hace match al inicio de la cadena, captura el grupo 246 pero no consume caracteres
    ^
2 - |246126667 // hace match al inicio de la cadena, captura el grupo 246 pero no consume caracteres
    ^
3 - |246126667 // hace match al inicio de la cadena, captura el grupo 246 pero no consume caracteres
    ^
.
.
.

N - Loop infinito

Por lo tanto si usamos el método exec con un while la pila de llamadas se llenaría con un loop infinito, para evitar esto deberíamos sumar uno a la propiedad lastIndex de la expresión regular para que el próximo match lo busque a partir de ese sitio:

var texto = "246126667",
    regex = /(?=(d{2}6))/g,
    match,
    matches = [];

while ((match = regex.exec(texto)) !== null) {
    matches.push(match[1]);
    regex.lastIndex++;
}

console.log(matches);

No hace falta hacer la condición match.index === regex.lastIndex porque a no ser que la variemos, la propiedad lastIndex de la expresión regular se mantendrá invariablemente igual al inicio del match debido a que no se consume ningún carácter en dicha expresión.

NOTA: Después de los comentarios mantenidos con @Mariano, he comprobado y ciertamente en IExplorer 9 e inferiores sería necesaria esta condición ya que estos navegadores pueden incrementar el valor de lastIndex si se devuelve un match vacío). Si se quisiera crear un código que funcionase en todos los navegadores, se debería seguir el consejo de @Mariano y situar esta condición en el código.

Un método que podrías seguir para no tener que actualizar la propiedad lastIndex es utilizar una expresión regular que sí consuma caracteres, por ejemplo esta expresión /(?=(d{2}6))d/g. Nota que la única cosa que tiene de diferente a la que ha situado @Mariano es que sí hace match de un dígito por lo que esta expresión irá consumiendo caracteres. Sigamos los pasos que seguiría el método exec con esta expresión regular:

1 - |246126667 // hace match de 2, captura el grupo 246
      ^
2 - 246|126667 // hace match de 1, captura el grupo 126
         ^
3 - 2461|26667 // hace match de 2, captura el grupo 266
          ^
4 - 24612|6667 // hace match de 6, captura el grupo 666
           ^

var texto = "246126667",
    regex = /(?=(d{2}6))d/g,
    match,
    matches = [];

while ((match = regex.exec(texto)) !== null) {
    matches.push(match[1]);
}

console.log(matches);

Fuente

Add a Comment

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *