Un error de IE lastIndex con coincidencias Regex de longitud cero

La conclusión de esta publicación de blog es que Internet Explorer incrementa incorrectamente el lastIndex de un objeto regex propiedad después de una coincidencia exitosa de longitud cero. Sin embargo, para cualquiera que no esté seguro de lo que estoy hablando o esté interesado en cómo solucionar el problema, describiré el problema con ejemplos de iteración sobre cada coincidencia en una cadena usando el RegExp.prototype.exec método. Ahí es donde he encontrado el error con mayor frecuencia, y creo que ayudará a explicar por qué existe el problema en primer lugar.

En primer lugar, si aún no está familiarizado con el uso de exec para iterar sobre una cadena, se está perdiendo una funcionalidad muy poderosa. Aquí está la construcción básica:

var	regex = /.../g,
	subject = "test",
	match = regex.exec(subject);

while (match != null) {
	// matched text: match[0]
	// match start: match.index
	// match end: regex.lastIndex
	// capturing group n: match[n]

	...

	match = regex.exec(subject);
}

Cuando el exec se llama al método para una expresión regular que usa el /g modificador (global), busca desde el punto en la cadena de asunto especificada por el lastIndex de la expresión regular property (que inicialmente es cero, por lo que busca desde el principio de la cadena). Si el exec método encuentra una coincidencia, actualiza el lastIndex de la expresión regular al índice de caracteres al final de la coincidencia y devuelve una matriz que contiene el texto coincidente y las subexpresiones capturadas. Si no hay ninguna coincidencia desde el punto de la cadena donde comenzó la búsqueda, lastIndex se restablece a cero, y null es devuelto.

Puede ajustar el código anterior moviendo el exec llamada al método en el while condición del bucle, así:

var	regex = /.../g,
	subject = "test",
	match;

while (match = regex.exec(subject)) {
	...
}

Esta versión más limpia funciona esencialmente igual que antes. Tan pronto como exec no puede encontrar más coincidencias y, por lo tanto, devuelve null , el ciclo termina. Sin embargo, hay un par de problemas entre navegadores a tener en cuenta con cualquiera de las versiones de este código. Una es que si la expresión regular contiene grupos de captura que no participan en la coincidencia, algunos valores en la matriz devuelta podrían ser undefined o una cadena vacía. Anteriormente, analicé ese tema en profundidad en una publicación sobre lo que llamé grupos de captura no participantes.

Otro problema (el tema de this post) ocurre cuando su expresión regular coincide con una cadena vacía. Hay muchas razones por las que podría permitir que una expresión regular haga eso, pero si no puede pensar en ninguna, considere los casos en los que está aceptando expresiones regulares de una fuente externa. Aquí hay un ejemplo simple de una expresión regular de este tipo:

var	regex = /^/gm,
	subject = "A\nB\nC",
	match,
	endPositions = [];

while (match = regex.exec(subject)) {
	endPositions.push(regex.lastIndex);
}

Puede esperar el endPositions matriz que se establecerá en [0,2,4] , ya que esas son las posiciones de los caracteres para el comienzo de la cadena y justo después de cada carácter de nueva línea. Gracias al /m modificador, esas son las posiciones donde coincidirá la expresión regular; y dado que la expresión regular coincide con cadenas vacías, regex.lastIndex debe ser igual a match.index . Sin embargo, Internet Explorer (probado con v5.5–7) establece endPositions a [1,3,5] . Otros navegadores entrarán en un bucle infinito hasta que cortocircuites el código.

Entonces, ¿qué está pasando aquí? Recuerda que cada vez exec se ejecuta, intenta hacer coincidir dentro de la cadena de asunto comenzando en la posición especificada por el lastIndex propiedad de la expresión regular. Dado que nuestra expresión regular coincide con una cadena de longitud cero, lastIndex permanece exactamente donde comenzamos la búsqueda. Por lo tanto, cada vez que pase por el ciclo, nuestra expresión regular coincidirá en la misma posición:el comienzo de la cadena. Internet Explorer intenta ser útil y evitar esta situación incrementando automáticamente lastIndex cuando se hace coincidir una cadena de longitud cero. Eso puede parecer una buena idea (de hecho, he visto a personas argumentar firmemente que es un error que Firefox no hace lo mismo), pero significa que en Internet Explorer el lastIndex no se puede confiar en la propiedad para determinar con precisión la posición final de un partido.

Podemos corregir esta situación entre navegadores con el siguiente código:

var	regex = /^/gm,
	subject = "A\nB\nC",
	match,
	endPositions = [];

while (match = regex.exec(subject)) {
	var zeroLengthMatch = !match[0].length;
	// Fix IE's incorrect lastIndex
	if (zeroLengthMatch && regex.lastIndex > match.index)
		regex.lastIndex--;

	endPositions.push(regex.lastIndex);

	// Avoid an infinite loop with zero-length matches
	if (zeroLengthMatch)
		regex.lastIndex++;
}

Puede ver un ejemplo del código anterior en el método de división entre navegadores que publiqué hace un tiempo. Tenga en cuenta que no se necesita ningún código adicional aquí si su expresión regular no puede coincidir con una cadena vacía.

Otra forma de solucionar este problema es usar String.prototype.replace para iterar sobre la cadena de asunto. El replace El método avanza automáticamente después de las coincidencias de longitud cero, evitando este problema por completo. Desafortunadamente, en los tres navegadores más grandes (IE, Firefox, Safari), replace no parece tratar con el lastIndex propiedad excepto para restablecerla a cero. Opera lo hace bien (según mi lectura de la especificación) y actualiza lastIndex por el camino. Dada la situación actual, no puedes confiar en lastIndex en su código al iterar sobre una cadena usando replace , pero aún puede derivar fácilmente el valor para el final de cada partido. He aquí un ejemplo:

var	regex = /^/gm,
	subject = "A\nB\nC",
	endPositions = [];

subject.replace(regex, function (match) {
	// Not using a named argument for the index since capturing
	// groups can change its position in the list of arguments
	var	index = arguments[arguments.length - 2],
		lastIndex = index + match.length;

	endPositions.push(lastIndex);
});

Quizás sea menos lúcido que antes (ya que en realidad no estamos reemplazando nada), pero ahí lo tienen... dos formas de navegador cruzado para solucionar un problema poco conocido que de otro modo podría causar errores latentes y complicados en su código.