¿Cómo hacer coincidir texto sin consumirlo? Anticipación y retrospección en regex
Tu regex coincide, pero a match[0] le falta el trozo que esperabas; o tu comprobación de contraseña necesita tres reglas a la vez. Ambas cosas se reducen a una idea: la anticipación y la retrospección revisan el texto sin consumirlo. Aquí verás cómo funcionan de verdad las aserciones de ancho cero y el puñado de cosas que hacen fáciles.
Aquí va un pequeño misterio con el que tropieza casi todo el mundo la primera vez. Quieres el número que va justo antes de px, así que escribes \d+(?=px), lo corres sobre 12px y coincide con 12. Bien. Pero fíjate en lo que volvió: solo 12. El px que tú mismo escribiste en el patrón no está por ninguna parte del resultado: no se consumió, no se capturó, simplemente no está. Si no lo esperabas, parece que la regex se comió a escondidas un trozo de tu propio patrón.
No lo hizo. (?=px) es una anticipación (lookahead), y las anticipaciones pertenecen a una pequeña familia de piezas de regex —las aserciones— que revisan el texto sin quedárselo. Esa única idea, «mira pero no toma», es todo el juego. En cuanto encaja, la anticipación (lookahead) y la retrospección (lookbehind) dejan de ser sintaxis enrevesada que copias de Stack Overflow y se vuelven dos de las herramientas más afiladas del kit: sacar un valor sin la puntuación de alrededor, meter texto en un punto exacto sin perturbarlo, y exigir varias reglas independientes a la vez —una forma habitual de meter varios requisitos de contraseña en un solo patrón—.
Este es el tercer artículo de una serie. El primero construyó un modelo mental de cómo un motor de retroceso recorre el texto; el segundo usó ese modelo para explicar por qué algunos patrones te funden la CPU. Aquí nos apoyamos en el mismo modelo, así que si «el motor recorre de izquierda a derecha con un cursor» ya te dice algo, esto irá rápido.
Ancho cero: la única idea que lo desbloquea todo
Imagina el motor como en el primer artículo: un cursor sentado entre caracteres, avanzando de izquierda a derecha. Casi toda pieza de una regex consume —coincide con uno o más caracteres y arrastra el cursor más allá de ellos—. Corre \d+ sobre 12px y coincide con 1 y luego 2, dejando el cursor plantado justo antes de p. Esos dos caracteres están ahora «gastados»; el resto del patrón sigue desde donde quedaron.
Una aserción hace algo categóricamente distinto. Mira el texto alrededor del cursor, decide si pasa o no, y luego deja el cursor exactamente donde estaba. Coincide con una posición, no con caracteres —por eso la jerga la llama ancho cero—. (?=px) en el cursor hace una pregunta de sí o no —«¿los dos próximos caracteres deletrean px?»— y, cuando la respuesta es sí, triunfa sin haber avanzado nada. El px se inspeccionó y se devolvió tal cual; nunca se consumió, así que nunca aterriza en match[0]. Los caracteres fantasma nunca estuvieron realmente perdidos. Solo que nunca se tomaron.
Y aquí lo tranquilizador: casi seguro llevas años usando aserciones de ancho cero sin el vocabulario. ^ y $ no coinciden con un carácter —coinciden con «la posición al inicio / al final de la cadena»—. \b coincide con «la posición entre un carácter de palabra y uno que no lo es». La anticipación y la retrospección son ese mismo truco con las puertas abiertas de par en par: en lugar del puñado fijo de posiciones que trae el motor, puedes meter cualquier subpatrón dentro de la condición. (?=px) no es más que un \b crecido lo bastante para comprobar lo que se te antoje.
Las cuatro aserciones
Son exactamente cuatro, divididas por dos ejes: mirar hacia delante (lo que sigue al cursor) o hacia atrás (lo que lo precede), y afirmar que la cosa está presente (positiva) o ausente (negativa).
| Aserción | Nombre | Pasa cuando… |
|---|---|---|
(?=...) | anticipación positiva | lo que viene justo después coincide con ... |
(?!...) | anticipación negativa | lo que viene justo después no coincide con ... |
(?<=...) | retrospección positiva | lo que viene justo antes coincide con ... |
(?<!...) | retrospección negativa | lo que viene justo antes no coincide con ... |
Un ejemplo concreto de cada una, todas de ancho cero —fíjate en que, en todos los casos, el texto comprobado se queda fuera de la coincidencia—:
\d+(?=px) "12px" → coincide con 12 (una serie de dígitos seguida de "px")
foo(?!bar) "foobaz" → coincide con foo ("foo" no seguido de "bar"; falla en "foobar")
(?<=\$)\d+ "$100" → coincide con 100 (dígitos precedidos de un "$")
(?<!\$)\d+ "€100" → coincide con 100 (dígitos NO precedidos de un "$")
El par negativo merece leerse dos veces, porque «coincide cuando la cosa está ausente» es fácil de malinterpretar. foo(?!bar) no significa «foo seguido de algo que no sea bar» —significa «foo, y en esta posición bar no empieza»—. En foobar falla de plano; en foo al final de la cadena triunfa, porque ahí no hay nada que pueda ser bar. La ausencia incluye «nada en absoluto».
Si quieres que dejen de ser abstractas, pega cada una en el probador de regex y mira el resaltado. El probador sombrea solo la parte que de verdad se consume —así que el texto de la aserción se queda sin resaltar justo al lado—. Ese contraste, la condición ahí sin color mientras solo se enciende la coincidencia real, es la imagen más clara de «ancho cero» que vas a conseguir.
Poner el ancho cero a trabajar
Tres tareas cotidianas salen directas de «mira pero no toma» —una para match, otra para replace y otra para split— y juntas son la razón por la que la anticipación y la retrospección se ganan su sitio.
Devolver un valor sin sus delimitadores
Digamos que sacas el destino del enlace de <a href="/products/12">. El patrón obvio es un grupo de captura:
href="([^"]*)"
Funciona, pero la coincidencia entera incluye el href=" y la comilla de cierre, y el valor que de verdad quieres queda enterrado en el grupo 1 —tienes que meter la mano y leer match[1]—. La anticipación y la retrospección dejan que la coincidencia entera sea exactamente el valor:
(?<=href=")[^"]*(?=")
Léelo de izquierda a derecha: la retrospección afirma «tengo href=" justo detrás de mí», la anticipación afirma «tengo una " justo delante de mí», y la única parte que consume, en medio, [^"]*, agarra el valor mismo. Las comillas son condiciones, no contenido, así que nunca entran en la coincidencia. match[0] vuelve como /products/12, limpio, sin recortar después. (Es el mismo fragmento de dos enlaces que el primer artículo usó como ejemplo recurrente —allí lo resolvimos con un grupo de captura; la anticipación y la retrospección son la versión en la que la coincidencia es la respuesta.)
Insertar en un punto sin perturbarlo
Aquí es donde el ancho cero se vuelve casi mágico. La tarea clásica: convertir 1234567 en 1,234,567. No quieres reemplazar ningún dígito —quieres colar una coma en los huecos entre ellos—. Y una coincidencia de ancho cero es, precisamente, un hueco:
"1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",") // → "1,234,567"
Aquí no se consume ni un carácter. (?=(\d{3})+(?!\d)) coincide con cada posición que tiene a su derecha un número entero de grupos de tres dígitos y ningún dígito suelto después —es decir, exactamente los puntos donde va un separador de millares—. El \B (un no-límite, lo contrario de \b) evita que una coma aterrice al principio del todo. Como cada coincidencia está vacía, replace no quita nada; solo deja caer una , en cada hueco coincidente. Pega el patrón en el probador de regex y —como una coincidencia de ancho cero no tiene nada que pintar— la prueba aparece en la lista de coincidencias en vez de en el resaltado: cada acierto se lista en su posición con una longitud de 0 (el campo Longitud), una coincidencia que ocupa un hueco entre caracteres en lugar de caracteres. Ver esa longitud de 0 sentada entre dos dígitos es lo que convierte «coincidir con una posición» de una frase en algo que puedes señalar.
Dividir sin comerte el separador
split normalmente borra aquello por lo que divide —"a-b-c".split("-") tira los guiones—. Pero divide por una coincidencia de ancho cero y no hay nada que borrar, así que el límite se queda donde está. Así es como partes camelCase en palabras sin perder una sola letra:
"camelCase".split(/(?=[A-Z])/) // → ["camel", "Case"]
(?=[A-Z]) coincide con la posición vacía justo antes de cada mayúscula. split corta ahí, pero como la coincidencia no consumió nada, la mayúscula se va pegada al trozo que le sigue. La misma idea de ancho cero, un tercer método de cadena —match sacó un valor, replace coló uno, split cortó entre ellos— y ninguno de los tres tocó un carácter que no debía.
La jugada maestra: apilar anticipaciones para el «y»
Y ahora el uso que, él solo, justifica aprender la anticipación. Quieres una regla de contraseña: al menos ocho caracteres, y que contenga un dígito, una minúscula y una mayúscula —en cualquier orden—. Intenta expresar «contiene un dígito en algún sitio y una minúscula en algún sitio y una mayúscula en algún sitio, dispuestos como sea» como un patrón normal de izquierda a derecha y te ahogas, porque una sola pasada tiene que comprometerse con algún orden. La anticipación disuelve el problema:
^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$
El mecanismo es precioso una vez que lo ves, y es puro ancho cero. Tras ^, el cursor está en la posición 0. (?=.*\d) explora hacia delante —.* corre hasta el final y luego retrocede hasta encontrar un dígito en cualquier parte de la cadena— y, al triunfar, devuelve el cursor a 0, porque no consumió nada. Luego (?=.*[a-z]) corre, otra vez desde la posición 0. Luego (?=.*[A-Z]), otra vez desde 0. Cada anticipación es una prueba de sí o no independiente sobre toda la cadena, y como ninguna mueve el cursor, se apilan como un Y lógico: «hay un dígito, y hay una minúscula, y hay una mayúscula». Solo cuando las tres han pasado, .{8,}$ por fin consume —imponiendo la longitud y corriendo hasta el final—.
De aquí salen dos cosas que lo hacen infinitamente práctico. El orden no importa —las anticipaciones prueban todas desde el mismo punto, así que puedes listar las condiciones en cualquier secuencia—. Y añades o quitas una regla con solo añadir o quitar una anticipación: prohíbe los espacios con (?!.*\s), exige un símbolo con (?=.*[!@#$%^&*]). Cada cláusula es una aserción autónoma sobre toda la cadena, abrochada de forma independiente.
¿Cuándo compensa esto de verdad? En código de aplicación, sinceramente, tres llamadas .test() por separado suelen leerse mejor, y deberías tirar de ellas. La forma de anticipaciones apiladas se gana el sueldo cuando te permiten un patrón y ningún código alrededor —un atributo <input pattern="…"> de HTML, un pattern de JSON Schema, una regla de validación en un archivo de configuración o un framework que acepta una sola regex—. Ahí, «varias condiciones independientes en una expresión» no es un truco de fiesta —a menudo es la única forma de decirlo—.
Una advertencia que muerde: . no coincide con un salto de línea por defecto, así que .*\d solo ve hasta el primer salto. Si tu entrada puede abarcar varias líneas y quieres que la regla aplique a todo, añade la bandera s (dotall) o sustituye . por algo explícito como [\s\S]. (Y lo que .{8,} cuenta en realidad depende de las banderas: sin u, cada . es una unidad de código UTF-16 —un emoji formado por un par suplente cuenta como dos—, mientras que con u, . coincide con un punto de código completo y ese emoji cuenta como uno. Ninguno es un grafema, el carácter que percibe una persona, así que una contraseña llena de emojis puede sumar distinto de lo que esperabas. La misma arruga Unicode que cubrió el primer artículo.)
Detalles de JavaScript y un par de trampas
La anticipación lleva en JavaScript desde siempre. La retrospección es más joven —llegó en ES2018, y durante un tiempo fue la rezagada en el soporte de navegadores (Safari fue el último en llegar)—, así que está disponible universalmente en los runtimes modernos, pero échale un vistazo si tienes que dar soporte a algo muy antiguo.
Donde JavaScript es inusualmente generoso es en la retrospección de longitud variable. Algunos motores insisten en que una retrospección sea de ancho fijo —el que más probablemente te encuentres es el re de Python— porque coincidir hacia atrás es más simple cuando sabes exactamente cuánto retroceder. JavaScript deja que la retrospección tenga cualquier longitud: (?<=\w+), (?<=\d{1,3},), (?<=<[a-z]+>) son todas legales. Si has chocado con errores de «la retrospección requiere un patrón de ancho fijo» en otro sitio y diste por hecho que era una regla universal, JS la levanta sin ruido.
Hay, eso sí, un matiz que conviene conocer: JavaScript coincide una retrospección de derecha a izquierda, desde el cursor hacia atrás. La mayoría de las veces no lo notarás, pero un cuantificador o un grupo de captura dentro de una retrospección se resuelve en esa dirección invertida —así que lo que un grupo captura puede diferir de la lectura de izquierda a derecha que esperarías de la anticipación espejo—. Cuando una retrospección con un grupo dentro te sorprenda, esto suele ser el porqué; pruébala en vez de suponer. (Esto enlaza con el punto del segundo artículo: en qué motor estás cambia lo que es posible.)
Dos cosas menores que vale la pena saber:
- Los grupos de captura dentro de una aserción sí capturan.
/(?<=(\d+))px/rellena el grupo 1 con los dígitos aunque la retrospección misma no aporte nada amatch[0]. Útil, y de vez en cuando sorprendente. - El ancho cero desbloquea coincidencias solapadas. La coincidencia normal consume, así que dos coincidencias nunca pueden solaparse. Pero envuelve el trabajo real en una anticipación y cada coincidencia pasa a ser de ancho cero —y como una iteración global (
matchAll, o la banderag) avanza una posición tras una coincidencia vacía, la captura dentro de la anticipación va cosechando cada ventana solapada por turno—:/(?=(\d{2}))/gsobre1234da el grupo 1 =12, luego23, luego34. No hay otra forma limpia de sacar coincidencias solapadas de un solomatchAll.
Una advertencia: una aserción sigue siendo regex de verdad
Tienta tratar la anticipación y la retrospección como una anotación ligera, pero lo que metas dentro de (?=...) corre bajo exactamente las mismas reglas de retroceso que todo lo demás. Una aserción no añade riesgo exponencial por sí sola —las varias anticipaciones .* de una comprobación de contraseña son cada una un barrido lineal independiente, por eso esos patrones suelen estar bien—. Pero en cuanto anidas una repetición que se solapa consigo misma dentro de una aserción, has colado una bomba en una condición, y es tan catastrófica como lo sería a la vista. Las reglas de ReDoS del segundo artículo no se detienen en el paréntesis de una anticipación. Prueba los patrones cargados de aserciones igual que probarías cualquier otro —el chequeo de ReDoS del probador también mira dentro de ellas—.
Un punto preciso para los curiosos. Mientras una anticipación se está evaluando, retrocede internamente como cualquier otro subpatrón. Pero en el momento en que triunfa, el motor la da por zanjada: si una parte posterior del patrón falla, el motor no volverá a entrar dentro de la anticipación a probar otra coincidencia interna, y lo que sea que un grupo de dentro capturó queda congelado en el instante del triunfo. Las aserciones miran, se comprometen y siguen —lo que a veces es la diferencia entre la captura que obtuviste y la que dabas por hecho que obtendrías—.
Cuándo un grupo de captura es la mejor opción
La anticipación y la retrospección no siempre son la respuesta correcta, y echar mano de ellas por reflejo es un error en sí mismo.
- Si solo necesitas el valor en código, un grupo de captura suele ser más claro.
href="([^"]*)"y leermatch[1]le resulta más evidente a la siguiente persona que un sándwich de retrospección y anticipación, y funciona en todos los motores, incluidos los que no tienen retrospección siquiera. Reserva la anticipación y la retrospección para cuando necesites específicamente quematch[0]en sí sea el valor —unreplacecon$&, unsplit, o una herramienta o API que solo te entrega la coincidencia entera—. - No parsees HTML real con nada de esto. El ejemplo de
hreffunciona porque es una cadena pequeña y controlada. Apunta una regex a marcado arbitrario —atributos en cualquier orden, comillas simples o dobles, espacios sueltos, comentarios— y se equivocará sin hacer ruido. En un navegador, lee el DOM (element.getAttribute("href")); en un servidor, usa un parser de HTML de verdad. La regex es para la forma de una cadena que ya conoces, no para la gramática.
Una chuleta para guardar
- El ancho cero es toda la idea. Una aserción prueba una posición y deja el cursor quieto; su contenido se inspecciona, nunca se consume, así que nunca aparece en
match[0]. - Las cuatro:
(?=)/(?!)miran hacia delante;(?<=)/(?<!)miran hacia atrás; las versiones con!pasan cuando la cosa está ausente —y «ausente» incluye «no hay nada ahí»—. - Devolver un valor sin sus delimitadores:
(?<=apertura)valor(?=cierre)hace que la coincidencia entera sea el valor mismo. - Actuar en un punto sin perturbarlo: una coincidencia de ancho cero en
replaceinserta sin borrar (separadores de millares); ensplitcorta sin comerte el separador (partircamelCase) —como no se consumió nada, no se pierde nada—. - Exigir varias reglas independientes: apila anticipaciones justo tras
^; todas prueban desde el mismo punto y se combinan como Y, en cualquier orden. - JavaScript permite la retrospección de longitud variable —no des por hecha la restricción de ancho fijo que quizá conozcas de otros motores—.
- Lo que va dentro de una aserción también retrocede —ten presentes las reglas de ReDoS y pruébalo—.
Tres artículos, una máquina. El primero enseñó el cursor que camina y retrocede; el segundo mostró qué pasa cuando retrocede demasiado; y la anticipación y la retrospección, resulta, son solo ese mismo cursor al que se le pide mirar sin dar el paso. Las anclas llevaban haciéndolo desde siempre. Ahora puedes apuntarlo a cualquier cosa —y la recompensa es cada vez el mismo truco callado: decide qué es condición y qué es contenido, y deja que la coincidencia sea exactamente, y solo, lo que de verdad querías.