Ir al contenido
Allin

Buscar y reemplazar: las trampas de las expresiones regulares, demostradas una a una

Publicado el 30/5/2025 · 14 min de lectura · Herramientas de texto e idioma

Daniel Okonkwo

Daniel OkonkwoDesarrollador front-end y redactor de Tecnología en Allin

Rendimiento web · Formatos de archivo

Verificado con 5 fuentes

Ver perfil
En resumen

Las formas en que un buscar-y-reemplazar falla son concretas y reproducibles, así que se pueden demostrar en vez de advertirlas. Los cuantificadores son codiciosos por defecto: sobre «<b>bold</b> and <i>italic</i>», reemplazar /<.+>/g por nada devuelve una cadena vacía, porque .+ corre hasta el último > de la línea; el perezoso /<.+?>/g y el negado /<[^>]+>/g devuelven ambos «bold and italic». El punto nunca reconoce un salto de línea sin la bandera s: /Precio.*unidades/ es falso a dos líneas y /Precio.*unidades/s es verdadero. La cadena de reemplazo tiene su propia sintaxis, ajena al patrón: $& inserta la coincidencia entera, $1 un grupo, y $$ es la única manera de emitir un dólar literal — «$$$1» sobre «Total: 42 EUR» da «Total: $42» mientras que «$$&» da el literal «$&». Un objeto regex con la bandera g conserva un lastIndex entre llamadas, así que reutilizarlo de fila en fila salta coincidencias: la misma /\d+/g probada sobre tres líneas de pedido devolvió verdadero, falso, verdadero. Y /i es puro plegado de caja, no conocimiento lingüístico: /i/i no reconoce İ, y /I/i no reconoce ı. Cuenta las coincidencias primero, reemplaza después.

Codicioso contra perezoso sobre la misma cadena, el punto que se salta los saltos de línea, $& y $$ en el reemplazo, una regex /g reutilizada que se salta una fila en silencio, y por qué /i no sabe nada de la i turca. Cada fallo ejecutado en Node, con una rutina contar-y-luego-reemplazar que los atrapa.

Codicioso y perezoso sobre la misma entrada

Toma la cadena <b>bold</b> and <i>italic</i> e intenta borrar las etiquetas con /<.+>/g. El resultado es una cadena vacía. El cuantificador + es codicioso: toma todo lo que puede y solo devuelve caracteres cuando el resto del patrón falla, así que .+ se traga todo hasta el último > y una sola coincidencia cubre la línea entera. La primera coincidencia de /<.+>/ es literalmente la entrada completa.

Dos arreglos funcionan, y no son equivalentes. Añadir un signo de interrogación vuelve perezoso el cuantificador: /<.+?>/g toma lo menos posible, su primera coincidencia es <b>, y el reemplazo devuelve bold and italic. Sustituir el punto por una clase negada hace el mismo trabajo de otro modo: /<[^>]+>/g no puede cruzar un > en absoluto, así que también devuelve bold and italic. Prefiere la clase negada cuando exista: dice dónde está la frontera en vez de confiar en el retroceso del motor para pararse en el sitio correcto, y no vuelve a ser codiciosa en silencio cuando luego añades una parte opcional al patrón.

El punto se detiene al final de la línea

En JavaScript, . reconoce cualquier carácter salvo un terminador de línea. Toma una ficha de dos líneas: «Precio: 12,50 €», un salto de línea, y luego «Stock: 3 unidades». La prueba /Precio.*unidades/ devuelve falso. El mismo patrón con la bandera s, /Precio.*unidades/s, devuelve verdadero, porque s — dotAll — elimina la excepción del terminador de línea. El apaño antiguo, una clase de caracteres que lo cubre todo, /Precio[\s\S]*unidades/, también devuelve verdadero y funciona en motores anteriores a la bandera.

Aquí es donde un reemplazo falla en silencio y no a gritos. Un patrón pensado para abarcar un párrafo simplemente no encuentra nada, el recuento de reemplazos vuelve como cero, y un pipeline que no comprueba ese recuento informa de éxito. Es también la razón por la que /.+/g sirve de divisor de líneas: sobre la misma ficha de dos líneas devuelve dos coincidencias, una por línea, lo cual es una función cuando la quieres y una sorpresa cuando no.

La cadena de reemplazo tiene su propia sintaxis

El segundo argumento de replace no es texto plano. Es un pequeño lenguaje, y su único metacarácter es $. Sobre «Total: 42 EUR» con /(\d+) (USD|EUR)/, el reemplazo [$&] da «Total: [42 EUR]», $1 da «Total: 42», y $<amount> — con el grupo nombrado en el patrón — da «Total: [42]». La lista completa es corta: $&, $1 a $99, $<nombre>, $` para todo lo anterior a la coincidencia, $' para todo lo posterior, y $$ para un dólar literal.

De ahí se siguen dos trampas. La primera: el $ de un reemplazo no es el $ de un patrón. En un patrón, $ es un ancla que significa fin de entrada; en un reemplazo nunca significa eso. Escribir $ donde quieres un símbolo de moneda produce un $ literal solo por suerte — replace(/EUR/, '$') sí da «Total: 42 $», porque un $ solitario seguido de nada especial pasa tal cual, pero en cuanto le sigue un dígito o un ampersand cambia el significado. Escribe $$ y deja de pensarlo.

La segunda trampa es una ambigüedad de dígito. $10 significa el grupo 10 cuando el patrón tiene diez grupos de captura, y el grupo 1 seguido del carácter 0 cuando no: sobre un patrón con diez grupos, el reemplazo [$10] devolvió [j], y sobre un patrón con un solo grupo devolvió [a0]. El significado de tu cadena de reemplazo depende, por tanto, de cuántos grupos contenga el patrón: añade un grupo y el reemplazo cambia de comportamiento sin haber sido editado. Los grupos con nombre eliminan la ambigüedad, y si el reemplazo es un dato y no código, pasa una función: su valor de retorno se inserta tal cual, de modo que replace(/\d+/, () => '$&') sobre «Total: 42 EUR» produce el literal «$&» en vez del número encontrado.

Una regex /g recuerda dónde se detuvo

Un objeto RegExp con la bandera g lleva una propiedad mutable lastIndex, que .test() y .exec() leen y escriben. Reutiliza el mismo objeto sobre varias cadenas y cada búsqueda empieza donde acabó la anterior. Toma const re = /\d+/g y pruébala sobre tres filas — «order 12», «order 7», «order 349» — y los resultados son verdadero, falso, verdadero. La fila del medio sí tiene un número. Se saltó porque lastIndex valía 8 cuando empezó la búsqueda sobre ella, y tras la tercera llamada lastIndex valía 9.

El mismo efecto aparece sobre una sola cadena. Cuatro llamadas consecutivas re.test('banana') con /a/g devuelven verdadero, verdadero, verdadero, falso: tres aes, y luego una búsqueda fallida que devuelve lastIndex a 0, de forma que una quinta llamada volvería a dar verdadero. Nada de esto es un error — es lo que hace usable exec() en un bucle while — pero convierte una constante regex de nivel de módulo en una variable mutable compartida disfrazada.

Tres arreglos, por orden de preferencia. No pongas g en una regex que uses con .test(): la bandera no aporta nada ahí y provoca todo lo anterior. Usa matchAll o match con g cuando quieras todas las apariciones, ya que ambos gestionan el índice por ti. O, si tienes que reutilizar un objeto, pon re.lastIndex = 0 antes de cada búsqueda, sabiendo que has elegido la opción frágil.

La insensibilidad a mayúsculas no sabe de lenguas

La bandera i aplica el plegado de caja Unicode, que es una tabla fija y no una regla de locale. El contraejemplo más claro es el turco, cuyo alfabeto tiene dos íes: la i con punto i/İ y la i sin punto ı/I. Ejecuta las pruebas: /i/i no reconoce İ, y /I/i no reconoce ı — ambas devuelven falso. Mientras tanto, 'I'.toLowerCase() devuelve 'i' pero 'I'.toLocaleLowerCase('tr') devuelve 'ı', y 'i'.toLocaleUpperCase('tr') devuelve 'İ'. A las funciones de caja se les puede indicar un locale. A la bandera de regex, no.

Tres resultados más de la misma ejecución, cada uno de los cuales le ha costado una mañana a alguien. /ss/i no reconoce ß y /ß/i no reconoce SS, porque el plegado de caja es una correspondencia carácter a carácter y la eszett alemana se expande a dos letras. /k/i no reconoce el signo kelvin K, pero /k/iu — con la bandera Unicode — sí, porque u activa el plegado de caja simple: añadir una bandera de apariencia puramente sintáctica cambia qué caracteres se consideran iguales. Y /é/i no reconoce una É descompuesta, una e seguida de un acento agudo combinante: el plegado de caja no es la normalización, y ambas son decisiones independientes, como detalla el artículo de esta serie dedicado a los acentos.

El anclaje, y por qué \b miente sobre las palabras acentuadas

\b no es un carácter. Es una aserción de anchura cero que triunfa allí donde hay un carácter de palabra (en JavaScript: [A-Za-z0-9_]) exactamente a un lado. Esa definición es puro ASCII, y produce un resultado exactamente al revés para cualquier lengua con diacríticos. /\bcafé\b/ no reconoce «un café noir», porque tras la é — que no es carácter de palabra — viene un espacio, que tampoco lo es, así que no hay frontera. El mismo patrón sí reconoce «un cafés», porque la é va seguida de s y eso es una frontera. La aserción se dispara donde la palabra continúa y falla donde termina.

El sustituto de \b es un par de aserciones Unicode: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u reconoce «un café noir» y rechaza «un cafés», que es lo que una persona entiende por palabra entera. La bandera u es imprescindible para que \p{…} se reconozca siquiera. Cuando la palabra reemplazada es puro ASCII, \b sigue valiendo — /\bpan\b/ se porta bien — pero en cuanto el patrón contiene una letra fuera de A–Z, comprueba los dos extremos a mano.

Contar y luego reemplazar: un ejemplo resuelto

Toma la línea: pan, pantalla, panadería, el pan está. Se trata de reemplazar el alimento por arroz. Cuenta primero: la línea.match(/pan/g).length vale 4. Esperabas 2. Ese solo número, leído antes de escribir nada, es toda la protección — y si hubieras reemplazado en vez de contar, la salida habría sido «arroz, arroztalla, arrozadería, el arroz está».

Ancla y vuelve a contar: /\bpan\b/g encuentra 2, el número que esperabas, y solo entonces es seguro reemplazar. El resultado es «arroz, pantalla, panadería, el arroz está». Después, cuenta otra vez: /\bpan\b/g debe encontrar ahora 0 y /\barroz\b/g debe encontrar 2. Tres recuentos y un reemplazo, cuatro líneas en total, y cada una es una línea que puedes enseñar a quien pregunte qué hizo el cambio.

Un detalle de la versión alemana del mismo ejercicio merece guardarse. Reemplazar Bahn por Zug en «Bahn, Bahnhof, Autobahn, die Bahn fährt» sin anclas da 3 coincidencias, no 4: el patrón distingue mayúsculas, así que golpea Bahnhof pero se pierde el bahn minúsculo dentro de Autobahn. Un recuento por debajo de lo esperado informa tanto como uno por encima, y señala un error distinto.

Qué significa cada ficha en una cadena de reemplazo, ejecutada sobre «Total: 42 EUR» con el patrón /(\d+) (USD|EUR)/. El $ de un reemplazo no tiene nada que ver con el $ de un patrón: aquí nunca significa fin de cadena.
FichaQué insertaReemplazo escritoResultado
$&La coincidencia entera[$&]Total: [42 EUR]
$1Grupo de captura 1$1Total: 42
$$Un signo de dólar literal$$$1Total: $42
$10El grupo 10 si existe, si no el grupo 1 y luego el carácter 0$10Total: 420
$` y $'Todo lo anterior, todo lo posterior a la coincidencia<$`>Total: <Total: >
$<nombre>Un grupo con nombre; se deja literal si el patrón no tiene ninguno[$<amount>]Total: [42]
Buscar y reemplazar textoReemplaza de una vez cada aparición de una palabra o frase en tu texto.Probar la herramienta

Preguntas frecuentes

¿Por qué mi reemplazo borró una línea entera?
Casi siempre un cuantificador codicioso entre dos delimitadores que aparecen más de una vez. /<.+>/ sobre una línea con dos etiquetas va del primer < al último >, así que la única coincidencia es la línea entera. Añade un signo de interrogación para volverlo perezoso, /<.+?>/, o mejor, prohíbe el delimitador de cierre dentro de la coincidencia con una clase negada, /<[^>]+>/.
¿Por qué la misma regex da otra respuesta la segunda vez que la llamo?
Porque lleva la bandera g y un lastIndex que sobrevive entre llamadas. /a/g probada cuatro veces sobre «banana» devuelve verdadero, verdadero, verdadero, falso. Quita la bandera g cuando solo quieras una respuesta de sí o no, usa matchAll cuando quieras todas las apariciones, o pon re.lastIndex = 0 antes de cada búsqueda si el objeto de verdad debe compartirse.
¿Cómo pongo un signo de dólar literal en el reemplazo?
Escribe $$. Un $ solitario funciona solo si no le sigue nada especial, lo que lo convierte en un error a la espera de la siguiente edición: «$$$1» sobre «Total: 42 EUR» da «Total: $42», mientras que el intuitivo «$$&» da el literal «$&» en vez de la coincidencia. Si el texto de reemplazo es un dato — un valor de formulario, una traducción, algo que no tecleaste tú —, pasa una función en vez de una cadena: su valor de retorno se inserta sin ningún procesamiento de $.
¿Entiende la bandera i los acentos y otros alfabetos?
Entiende el plegado de caja y nada más. Igualará a con A en casi todo Unicode, pero no normaliza: /é/i falla sobre una É descompuesta; no expande: /ss/i falla sobre ß; y no conoce ningún locale: /i/i falla sobre la İ turca. Si necesitas alguno de esos comportamientos, normaliza el texto primero y usa un colador con sensibilidad «base» para la comparación, en vez de esperar que la bandera adquiera un conocimiento lingüístico que nunca tuvo.
¿Cómo hago coincidir a través de dos líneas?
Añade la bandera s, que hace que el punto reconozca también los terminadores de línea: /Precio.*unidades/ es falso a dos líneas y /Precio.*unidades/s es verdadero. No la confundas con m, que va de anclas y no del punto: m hace que ^ y $ coincidan al principio y al final de cada línea en vez de en la entrada entera, y deja el punto tal cual. A menudo quieres ambas, y son independientes.
¿Por qué \b falla en una palabra terminada en letra acentuada?
Porque \b está definido contra la clase de palabra ASCII [A-Za-z0-9_], y una letra acentuada no está en ella. Una frontera exige un carácter de palabra a un solo lado, y entre la é y el espacio siguiente no hay ninguno, así que /\bcafé\b/ no reconoce «un café noir» — mientras que sí reconoce «un cafés», donde la é va seguida de una s ASCII. Sustituye las aserciones por aserciones Unicode: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u, recordando que \p{…} exige la bandera u.

Artículos que podrían interesarte

Todas las guías
TutorialFiltrar líneas por un patrón sin línea de comandosEsto es grep para quien no usa grep, con una diferencia importante: la búsqueda es una subcadena literal, así que una expresión regular de verdad devuelve un cuadro vacío y ningún error. Cada afirmación se comprobó ejecutando la herramienta.TutorialBases de regex: guía para principiantesUna expresión regular es un patrón para buscar texto. Aquí tienes los bloques — clases de caracteres, cuantificadores y anclas — con un ejemplo.TutorialNumerar las líneas de un texto para una revisión entre varias personasLa numeración empieza en 1 y no se puede poner a 0, la alineación se hace con espacios y no con ceros, y la herramienta que los quita deshace ocho de los once separadores sin tocar la indentación. Lo que sigue sin poder hacer es distinguir tus números de los suyos.GuíaFormatear números para seis idiomas: separadores, moneda y la vuelta al valor1.234,56 y 1,234.56 son el mismo número, y confundirlos cambia el valor que lee un lector. Ejecutamos Intl.NumberFormat para las seis locales del sitio e imprimimos cada separador —incluido el invisible que usa el francés— y luego medimos por qué parseFloat no puede deshacer nada de eso.ExplicaciónLos emoji son más difíciles de lo que parecen: por qué «basta con quitarlos» no tiene respuesta en una líneaUn emoji visible puede valer un punto de código o catorce unidades UTF-16. Lanzamos tres expresiones regulares populares sobre una frase real y cada una falló de otra forma; una borró los dígitos. Aquí está el porqué, qué propiedad Unicode responde a qué pregunta, y la regla de grupos de grafemas que sí funciona.ExplicaciónDónde puede cortarse una línea: el algoritmo Unicode detrás de cada párrafo ajustado«Cortar en los espacios» falla en la mayoría de los sistemas de escritura. UAX #14 da a cada carácter una clase de corte de línea; buscamos las nuestras en Unicode 17.0.0 y ejecutamos una implementación conforme sobre espacios duros, guiones blandos, espacios de ancho cero, URL, japonés y tailandés.

Herramientas relacionadas

Fuentes

¿Has detectado un error en este artículo?