Ir para o conteúdo
Allin

Localizar e substituir: as armadilhas das expressões regulares, demonstradas uma a uma

Publicado a 30/05/2025 · 14 min de leitura · Ferramentas de texto e idioma

Daniel Okonkwo

Daniel OkonkwoProgramador front-end e redator de Tecnologia na Allin

Desempenho web · Formatos de ficheiro

Verificado a partir de 5 fontes

Ver perfil
Em resumo

As maneiras como um localizar-e-substituir falha são concretas e reproduzíveis, por isso podem ser demonstradas em vez de avisadas. Os quantificadores são gananciosos por omissão: sobre «<b>bold</b> and <i>italic</i>», substituir /<.+>/g por nada devolve uma cadeia vazia, porque .+ corre até ao último > da linha; o preguiçoso /<.+?>/g e o negado /<[^>]+>/g devolvem ambos «bold and italic». O ponto nunca reconhece uma quebra de linha sem a bandeira s: /Preço.*unidades/ é falso em duas linhas e /Preço.*unidades/s é verdadeiro. A cadeia de substituição tem a sua própria sintaxe, alheia ao padrão: $& insere a correspondência inteira, $1 um grupo, e $$ é a única forma de emitir um cifrão literal — «$$$1» sobre «Total: 42 EUR» dá «Total: $42», enquanto «$$&» dá o literal «$&». Um objeto regex com a bandeira g guarda um lastIndex entre chamadas, por isso reutilizá-lo de linha em linha salta correspondências: a mesma /\d+/g testada sobre três linhas de encomenda devolveu verdadeiro, falso, verdadeiro. E /i é puro dobramento de caixa, não conhecimento linguístico: /i/i não reconhece İ, e /I/i não reconhece ı. Conte as correspondências primeiro, substitua depois.

Ganancioso contra preguiçoso sobre a mesma cadeia, o ponto que salta as quebras de linha, $& e $$ na substituição, uma regex /g reutilizada que salta uma linha em silêncio, e porque /i nada sabe do i turco. Cada falha executada no Node, com uma rotina contar-e-depois-substituir que as apanha.

Ganancioso e preguiçoso sobre a mesma entrada

Tome a cadeia <b>bold</b> and <i>italic</i> e tente apagar as etiquetas com /<.+>/g. O resultado é uma cadeia vazia. O quantificador + é ganancioso: toma tudo o que pode e só devolve caracteres quando o resto do padrão falha, por isso .+ engole tudo até ao último > e uma só correspondência cobre a linha inteira. A primeira correspondência de /<.+>/ é literalmente a entrada completa.

Duas correções funcionam, e não são equivalentes. Acrescentar um ponto de interrogação torna o quantificador preguiçoso: /<.+?>/g toma o menos possível, a sua primeira correspondência é <b>, e a substituição devolve bold and italic. Substituir o ponto por uma classe negada faz o mesmo trabalho de outro modo: /<[^>]+>/g não pode atravessar um > de todo, por isso também devolve bold and italic. Prefira a classe negada quando exista: diz onde está a fronteira em vez de confiar no retrocesso do motor para parar no sítio certo, e não volta a ficar ganancioso em silêncio quando mais tarde acrescenta uma parte opcional ao padrão.

O ponto para no fim da linha

Em JavaScript, . reconhece qualquer caráter salvo um terminador de linha. Tome uma ficha de duas linhas: «Preço: 12,50 €», uma quebra de linha, e depois «Stock: 3 unidades». O teste /Preço.*unidades/ devolve falso. O mesmo padrão com a bandeira s, /Preço.*unidades/s, devolve verdadeiro, porque s — dotAll — elimina a exceção do terminador de linha. O remendo antigo, uma classe de caracteres que cobre tudo, /Preço[\s\S]*unidades/, também devolve verdadeiro e funciona em motores anteriores à bandeira.

É aqui que uma substituição falha em silêncio e não aos gritos. Um padrão pensado para abranger um parágrafo simplesmente não encontra nada, a contagem de substituições volta como zero, e um pipeline que não verifica essa contagem anuncia sucesso. É também a razão pela qual /.+/g serve de divisor de linhas: sobre a mesma ficha de duas linhas devolve duas correspondências, uma por linha, o que é uma funcionalidade quando se quer e uma surpresa quando não.

A cadeia de substituição tem a sua própria sintaxe

O segundo argumento de replace não é texto simples. É uma pequena linguagem, e o seu único metacaráter é $. Sobre «Total: 42 EUR» com /(\d+) (USD|EUR)/, a substituição [$&] dá «Total: [42 EUR]», $1 dá «Total: 42», e $<amount> — com o grupo nomeado no padrão — dá «Total: [42]». A lista completa é curta: $&, $1 a $99, $<nome>, $` para tudo o que precede a correspondência, $' para tudo o que segue, e $$ para um cifrão literal.

Daí decorrem duas armadilhas. A primeira: o $ de uma substituição não é o $ de um padrão. Num padrão, $ é uma âncora que significa fim de entrada; numa substituição nunca significa isso. Escrever $ onde se quer um símbolo monetário produz um $ literal só por sorte — replace(/EUR/, '$') dá de facto «Total: 42 $», porque um $ solitário seguido de nada especial passa tal e qual, mas assim que lhe segue um dígito ou um e comercial o significado muda. Escreva $$ e deixe de pensar nisso.

A segunda armadilha é uma ambiguidade de dígito. $10 significa o grupo 10 quando o padrão tem dez grupos de captura, e o grupo 1 seguido do caráter 0 quando não tem: sobre um padrão com dez grupos, a substituição [$10] devolveu [j], e sobre um padrão com um só grupo devolveu [a0]. O significado da sua cadeia de substituição depende portanto de quantos grupos o padrão contém: acrescente um grupo e a substituição muda de comportamento sem ter sido editada. Os grupos com nome eliminam a ambiguidade, e se a substituição for um dado e não código, passe uma função: o seu valor de retorno é inserido tal e qual, de modo que replace(/\d+/, () => '$&') sobre «Total: 42 EUR» produz o literal «$&» em vez do número encontrado.

Uma regex /g lembra-se de onde parou

Um objeto RegExp com a bandeira g carrega uma propriedade mutável lastIndex, que .test() e .exec() leem e escrevem. Reutilize o mesmo objeto sobre várias cadeias e cada pesquisa começa onde a anterior acabou. Tome const re = /\d+/g e teste-a sobre três linhas — «order 12», «order 7», «order 349» — e os resultados são verdadeiro, falso, verdadeiro. A linha do meio tem mesmo um número. Foi saltada porque lastIndex valia 8 quando a pesquisa nela começou, e depois da terceira chamada lastIndex valia 9.

O mesmo efeito aparece sobre uma só cadeia. Quatro chamadas consecutivas re.test('banana') com /a/g devolvem verdadeiro, verdadeiro, verdadeiro, falso: três aes, e depois uma pesquisa falhada que repõe lastIndex a 0, de modo que uma quinta chamada voltaria a dar verdadeiro. Nada disto é um erro — é o que torna exec() utilizável num ciclo while — mas transforma uma constante regex ao nível do módulo numa variável mutável partilhada disfarçada.

Três correções, por ordem de preferência. Não ponha g numa regex que use com .test(): a bandeira nada acrescenta aí e provoca tudo o que ficou acima. Use matchAll ou match com g quando quiser todas as ocorrências, já que ambos gerem o índice por si. Ou, se tiver de reutilizar um objeto, ponha re.lastIndex = 0 antes de cada pesquisa, sabendo que escolheu a opção frágil.

A insensibilidade a maiúsculas não sabe de línguas

A bandeira i aplica o dobramento de caixa Unicode, que é uma tabela fixa e não uma regra de locale. O contraexemplo mais claro é o turco, cujo alfabeto tem dois is: o i com ponto i/İ e o i sem ponto ı/I. Execute os testes: /i/i não reconhece İ, e /I/i não reconhece ı — ambas devolvem falso. Entretanto, 'I'.toLowerCase() devolve 'i' mas 'I'.toLocaleLowerCase('tr') devolve 'ı', e 'i'.toLocaleUpperCase('tr') devolve 'İ'. Às funções de caixa pode indicar-se um locale. À bandeira da regex, não.

Mais três resultados da mesma execução, cada um dos quais custou uma manhã a alguém. /ss/i não reconhece ß e /ß/i não reconhece SS, porque o dobramento de caixa é uma correspondência caráter a caráter e o eszett alemão expande-se para duas letras. /k/i não reconhece o sinal kelvin K, mas /k/iu — com a bandeira Unicode — reconhece, porque u ativa o dobramento de caixa simples: acrescentar uma bandeira de aparência puramente sintática muda que caracteres são considerados iguais. E /é/i não reconhece um É decomposto, um e seguido de um acento agudo combinante: o dobramento de caixa não é a normalização, e ambos são decisões independentes, como detalha o artigo desta série dedicado aos acentos.

A ancoragem, e porque \b mente sobre as palavras acentuadas

\b não é um caráter. É uma asserção de largura zero que triunfa onde um caráter de palavra (em JavaScript: [A-Za-z0-9_]) está exatamente de um lado. Essa definição é puro ASCII, e produz um resultado exatamente ao contrário para qualquer língua com diacríticos. /\bcafé\b/ não reconhece «un café noir», porque depois do é — que não é caráter de palavra — vem um espaço, que também não é, por isso não há fronteira. O mesmo padrão reconhece «un cafés», porque o é é seguido de s e isso é uma fronteira. A asserção dispara onde a palavra continua e falha onde acaba.

O substituto de \b é um par de asserções Unicode: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u reconhece «un café noir» e recusa «un cafés», que é o que uma pessoa entende por palavra inteira. A bandeira u é indispensável para que \p{…} seja sequer reconhecido. Quando a palavra substituída é puro ASCII, \b continua a servir — /\bmar\b/ porta-se bem — mas assim que o padrão contém uma letra fora de A–Z, verifique as duas pontas à mão.

Contar e depois substituir: um exemplo resolvido

Tome a linha: mar, marca, marisco, o mar está. Trata-se de substituir a água salgada por rio. Conte primeiro: a linha.match(/mar/g).length vale 4. Esperava 2. Esse único número, lido antes de escrever seja o que for, é toda a proteção — e se tivesse substituído em vez de contar, a saída teria sido «rio, rioca, rioisco, o rio está».

Ancore e volte a contar: /\bmar\b/g encontra 2, o número que esperava, e só então é seguro substituir. O resultado é «rio, marca, marisco, o rio está». Depois, conte outra vez: /\bmar\b/g deve agora encontrar 0 e /\brio\b/g deve encontrar 2. Três contagens e uma substituição, quatro linhas ao todo, e cada uma é uma linha que pode mostrar a quem pergunte o que fez a alteração.

Um detalhe da versão alemã do mesmo exercício merece ser guardado. Substituir Bahn por Zug em «Bahn, Bahnhof, Autobahn, die Bahn fährt» sem âncoras dá 3 correspondências, não 4: o padrão distingue maiúsculas, por isso atinge Bahnhof mas falha o bahn minúsculo dentro de Autobahn. Uma contagem abaixo do esperado informa tanto como uma acima, e aponta para um erro diferente.

O que significa cada ficha numa cadeia de substituição, executada sobre «Total: 42 EUR» com o padrão /(\d+) (USD|EUR)/. O $ de uma substituição nada tem a ver com o $ de um padrão: aqui nunca significa fim de cadeia.
FichaO que insereSubstituição escritaResultado
$&A correspondência inteira[$&]Total: [42 EUR]
$1Grupo de captura 1$1Total: 42
$$Um cifrão literal$$$1Total: $42
$10O grupo 10 se existir, senão o grupo 1 e depois o caráter 0$10Total: 420
$` e $'Tudo o que precede, tudo o que segue a correspondência<$`>Total: <Total: >
$<nome>Um grupo com nome; fica literal se o padrão não tiver nenhum[$<amount>]Total: [42]
Localizar e substituir textoSubstitua de uma vez cada ocorrência de uma palavra ou frase no seu texto.Experimentar a ferramenta

Perguntas frequentes

Porque é que a minha substituição apagou uma linha inteira?
Quase sempre um quantificador ganancioso entre dois delimitadores que aparecem mais de uma vez. /<.+>/ sobre uma linha com duas etiquetas vai do primeiro < ao último >, por isso a única correspondência é a linha inteira. Acrescente um ponto de interrogação para o tornar preguiçoso, /<.+?>/, ou melhor, proíba o delimitador de fecho dentro da correspondência com uma classe negada, /<[^>]+>/.
Porque é que a mesma regex dá outra resposta na segunda chamada?
Porque tem a bandeira g e um lastIndex que sobrevive entre chamadas. /a/g testada quatro vezes sobre «banana» devolve verdadeiro, verdadeiro, verdadeiro, falso. Retire a bandeira g quando só quiser uma resposta de sim ou não, use matchAll quando quiser todas as ocorrências, ou ponha re.lastIndex = 0 antes de cada pesquisa se o objeto tiver mesmo de ser partilhado.
Como ponho um cifrão literal na substituição?
Escreva $$. Um $ solitário só funciona se nada de especial o seguir, o que faz dele um erro à espera da próxima edição: «$$$1» sobre «Total: 42 EUR» dá «Total: $42», enquanto o intuitivo «$$&» dá o literal «$&» em vez da correspondência. Se o texto de substituição for um dado — um valor de formulário, uma tradução, algo que não escreveu —, passe uma função em vez de uma cadeia: o seu valor de retorno é inserido sem qualquer processamento de $.
A bandeira i entende os acentos e outros alfabetos?
Entende o dobramento de caixa e nada mais. Igualará a com A em quase todo o Unicode, mas não normaliza: /é/i falha sobre um É decomposto; não expande: /ss/i falha sobre ß; e não conhece nenhum locale: /i/i falha sobre o İ turco. Se precisar de algum desses comportamentos, normalize o texto primeiro e use um colador com sensibilidade «base» para a comparação, em vez de esperar que a bandeira ganhe um conhecimento linguístico que nunca teve.
Como faço uma correspondência através de duas linhas?
Acrescente a bandeira s, que faz o ponto reconhecer também os terminadores de linha: /Preço.*unidades/ é falso em duas linhas e /Preço.*unidades/s é verdadeiro. Não a confunda com m, que trata de âncoras e não do ponto: m faz ^ e $ corresponderem ao início e ao fim de cada linha em vez da entrada inteira, e deixa o ponto tal e qual. Muitas vezes quer as duas, e são independentes.
Porque é que \b falha numa palavra terminada em letra acentuada?
Porque \b está definido contra a classe de palavra ASCII [A-Za-z0-9_], e uma letra acentuada não pertence a ela. Uma fronteira exige um caráter de palavra de um só lado, e entre o é e o espaço seguinte não há nenhum, por isso /\bcafé\b/ não reconhece «un café noir» — enquanto reconhece «un cafés», onde o é é seguido de um s ASCII. Substitua as asserções por asserções Unicode: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u, lembrando que \p{…} exige a bandeira u.

Artigos que podem interessar-lhe

Todos os guias
TutorialFiltrar linhas por um padrão sem linha de comandosIsto é o grep para quem não usa grep, com uma diferença importante: a procura é uma subcadeia literal, pelo que uma expressão regular a sério devolve uma caixa vazia e nenhum erro. Cada afirmação foi verificada executando a ferramenta.TutorialBases de regex: guia para principiantesUma expressão regular é um padrão para procurar texto. Eis os blocos — classes de caracteres, quantificadores e âncoras — com um exemplo.TutorialNumerar as linhas de um texto para uma revisão a várias mãosA numeração começa em 1 e não pode ser posta a 0, o alinhamento faz-se com espaços e não com zeros, e a ferramenta que os remove desfaz oito dos onze separadores sem mexer na indentação. O que continua sem saber fazer é distinguir os seus números dos dela.GuiaFormatar números para seis línguas: separadores, moeda e o regresso ao valor1 234,56 e 1,234.56 são o mesmo número, e confundi-los muda o valor que um leitor lê. Corremos Intl.NumberFormat para as seis localizações do site e imprimimos cada separador — incluindo o invisível que o francês usa — e depois medimos porque parseFloat não consegue desfazer nada disso.ExplicaçãoOs emoji são mais difíceis do que parecem: porque «basta retirá-los» não tem resposta numa linhaUm emoji visível pode valer um ponto de código ou catorze unidades UTF-16. Corremos três expressões regulares populares sobre uma frase real e cada uma falhou de maneira diferente; uma apagou os algarismos. Eis porquê, que propriedade Unicode responde a que pergunta, e a regra de grupos de grafemas que funciona mesmo.ExplicaçãoOnde uma linha pode quebrar: o algoritmo Unicode por trás de cada parágrafo ajustado«Quebrar nos espaços» falha na maioria dos sistemas de escrita. O UAX #14 dá a cada caráter uma classe de quebra de linha; procurámos as nossas no Unicode 17.0.0 e executámos uma implementação conforme sobre espaços inquebráveis, hifenes condicionais, espaços de largura zero, URL, japonês e tailandês.

Ferramentas relacionadas

Fontes

Detetaste um erro neste artigo?