Expressões Regulares em PHP - Recursos avançados

Artigo que expõe alguns recursos avançados sobre expressões regulares e algumas curiosidades.

Introdução

No artigo sobre Expressões Regulares em PHP, vimos os conceitos básicos da sintaxe de Expressões Regulares usadas no PHP, que é o padrão PCRE, além das funções do PHP para realizar diferentes operações com expressões regulares. Após 6 anos, resolvi complementar aquele artigo com os recursos mais avançados sobre regex e algumas curiosidades extras sobre o assunto. Caso você não conheça o básico sobre Expressões Regulares, é altamente recomendável que primeiro vocẽ leia o artigo anterior para depois continuar.

Captura de sub-sub-expressões

A captura de sub-sub-expressões é usada quando você tem duas ou mais palavras possíveis, mas você deseja capturar apenas um pedaço específico da palavra casada. Para isso, usa-se a sintaxe (?|expressões)

Por exemplo, podemos montar uma expressão para capturar o valor abreviado (primeiras letras) de um dia da semana desta forma:

$string = 'terça-feira';
if (preg_match('/^(?|(seg)unda|(ter)ça|(qua)rta|(qui)nta|(sex)ta)-feira$/', $string, $matches)) {
    echo $matches[0];         // terça-feira
    echo $matches[1];         // ter
}

Observe que o valor capturado na posição 1 é apenas ter.

Caso não usássemos a notação (?|), veja o que aconteceria se usássemos os parênteses simples sobre cada sigla, mas usássemos o (?:) (para ignorar a captura da palavra completa) no lugar do (?|):

$string = 'terça-feira';
if (preg_match('/^(?:(seg)unda|(ter)ça|(qua)rta|(qui)nta|(sex)ta)-feira$/', $string, $matches)) {
    echo $matches[0];         // terça-feira
    echo $matches[1];         // (string vazia)
    echo $matches[2];         // ter
}

Observe que a posição 1 não capturou nada, pois ela seria responsável por capturar apenas "seg" (de "segunda"), mas como a string informada foi "terça-feira", então foi a posição 2 que capturou "ter". Seguindo a lógica, caso fosse informada "quarta-feira", só seria capturado "qua" na posição 3 e assim sucessivamente.

Agora vejamos um exemplo que também trata o "sábado" e "domingo", que não possuem "-feira":

$string = 'terça-feira';
if (preg_match('/^(?|(?|(seg)unda|(ter)ça|(qua)rta|(qui)nta|(sex)ta)-feira|(sáb)ado|(dom)ingo)$/', $string, $matches)) {
    echo $matches[0];        // terça-feira
    echo $matches[1];        // ter
}

Note que foi necessário usar o (?|) duas vezes, para que não precissássemos reescrever o "-feira" múltiplas vezes.

Asserções em Expressões Regulares

Início e término de string ou de linha

No artigo anterior, vimos que podemos usar ^ para assegurar que a string começa com determinado padrão, ou o $ para assegurar que a string termina com determinado padrão. Estes são os tipos básicos de asserções. Numa regex mais avançada, podemos utilizar tais asserções de forma condicional, como nos exemplos abaixo:

$string = 'Rubens';
if (preg_match('/(?:^| )Rubens(?:$| )/', $string, $matches)) {
    echo $matches[0];     // Rubens
}

Na expressão acima, estamos indicando que a palavra "Rubens" será capturada sendo início da string ou sendo precedido por um espaço (?:^| ) e sendo o fim da string ou sendo sucedido por um espaço (?:$| ). Portanto, a expressão terá os seguintes comportamentos com estes exemplos de string:

  • "Rubens" → captura "Rubens"
  • "Foi o Rubens" → captura " Rubens" (note o espaço antes do "R")
  • "Rubens é um cara bem legal" → captura "Rubens " (note o espaço após o "s")
  • "O Rubens está me ensinando um bocado" → captura " Rubens " (note o espaço antes do "R" e depois do "s")
  • "O Rubensinho é danado"não captura nada, pois após o "s" não vem um espaço e nem um final de string.
  • "O T-Rubens caiu"não captura nada, pois antes do "R" não vem um espaço e nem um início de string.

Além disso, estas sequências de asserções são afetadas pelo modificador m, que indica que a expressão será aplicada sobre cada linha de uma string com múltiplas linhas. Portanto, caso seja usado o modificador m na expressão do exemplo anterior, ela poderia capturar as strings "Foo\nRubens", "Foo\nRubens\nBar" ou "Rubens\nBar" (note que "\n" indica uma quebra de linha).

Delimitador de palavra (Word boundary)

No exemplo anterior, fizemos uma expressão para capturar a palavra "Rubens" com uma notação para assegurar que, caso tenha uma palavra antes ou depois, ela precisa ser um espaço, assegurando que a palavra "Rubens" sempre estará na string de forma exata (sem prefixos e sufixos). Porém, nem sempre é uma boa abordagem, já que na construção de frases, por exemplo, podemos utilizar algumas pontuações como vírgula, ponto-e-vírgula, ponto final, ponto de interrogação, ponto de exclamação, etc. Para assegurar que uma palavra foi inserida numa string de forma "exata" e, caso seja precedida ou seguida por algum caractere, ele seja um "delimitador de palavra" válido, como um espaço ou pontuação. Para isso, utiliza-se a sequência \b para indicar um delimitador de palavra. O \b em si não representa um caractere na string (assim como o ^ e o $), portanto serve apenas para assegurar determinada condição.

Veja um exemplo:

$string = 'Rubens';
if (preg_match('/\bRubens\b/', $string, $matches)) {
    echo $matches[0];     // Rubens
}

Com a expressão acima, teremos os seguintes comportamentos com estes exemplos de string:

  • "Rubens" → captura "Rubens"
  • "Foi o Rubens" → captura "Rubens" (note que não captura espaço antes do "R")
  • "Rubens é um cara bem legal" → captura "Rubens" (note que não captura o espaço após o "s")
  • "O Rubens está me ensinando um bocado" → captura "Rubens" (note que não captura o espaço antes do "R" nem o espaço após o "s")
  • "Rubens, preste atenção!" → captura "Rubens" (note que não captura a vírgula após o "s")
  • "Preste atenção, Rubens!" → captura "Rubens" (note que não captura o espaço antes do "R" nem o ponto de exclamação após o "s")
  • "O Rubensinho é danado"não captura nada, pois após o "s" vem uma letra
  • "O TRubens caiu"não captura nada, pois antes do "R" vem uma letra.

Ou seja, o \b assegura que no ponto em que ele foi colocado não existe uma letra nem número.

De forma análoga, o \B assegura o inverso: que no ponto em que ele foi colocado existe uma letra ou número, mas ele não é capturado pela expressão.

Início e término de string

Vimos que o ^ assegura o início da string e o $ assegura o fim da string, mas que são afetados pelo modificador m, onde passam a significar "início de string ou de linha" e "fim de string ou de linha" respectivamente. Para assegurar o início de string independentemente de se estar no modo multi-linhas, utiliza-se a sequência \A. E para assegurar o fim de string independentemente de se estar no modo multi-linhas, utiliza-se a sequência \z. Veja o exemplo:

$string = "Rubens\nOutra linha";
if (preg_match('/\ARubens$/m', $string, $matches)) {
    echo $matches[0];     // Rubens
}

Na expressão acima, queremos que a string comece com "Rubens", e após o "s" pode ser o fim da linha ou o fim da string (pois foi usado o modificador m). Se usássemos o ^ no lugar do \A, então a palavra "Rubens" poderia estar no início de qualquer linha.

De forma análoga, a expressão /^Rubens\z/m serviria para assegurar que a linha começa com "Rubens" e que não existe nada após o "s".

Além disso, existe a sequência \Z que é similar ao \z, mas ele tem uma "tolerância" a mais uma única quebra de linha ao final da string. Portanto, a expressão /^Rubens\Z/m casaria com "Linha\nRubens" ou "Linha\nRubens\n", mas não casaria com "Linha\nRubens\nOutra linha".

Lookahead assertion

As asserções do tipo Lookahead servem para assegurar que a expressão casará apenas se for sucedida por uma determinada sub-expressão. Para isso, é utilizada a notação (?=expressão) para especificar a sub-expressão que deseja-se assegurar que exista após determinada expressão.

Exemplo para capturar o prefixo de uma palavra que termina com "ando":

$string = 'observando';
if (preg_match('/^\w+(?=ando$)/', $string, $matches)) {
    echo $matches[0];     // observ
}

Note que o $ foi colocado dentro da sub-expressão. Caso ela fosse colocada após o ), a expressão não funcionaria, pois o lookahead assertion não captura o texto da sub-expressão, portanto o $ está dizendo que após o prefixo, já deve vir o final da string. Ou seja, a expressão como um todo seria sempre inválida, pois é impossível um prefixo ser seguido de "ando" e ser seguido do fim da string ao mesmo tempo.

Note também que, mesmo a sub-expressão fazendo parte da expressão, a posição 0 da captura não incluiu o conteúdo da sub-expressão. Este tipo de situação não parece muito útil quando se está fazendo preg_match, já que poderíamos usar a expressão /^(\w+)(?:ando)/ para que fosse capturado "observando" na posição 0 e "observ" na posição 1, ou seja, conseguiríamos obter o "observ" do mesmo modo, só que em outra posição do $matches. Porém, este tipo de construção é útil quando estamos tratando de uma substitução. Por exemplo:

$string = 'Eu estou observando.';

$replace1 = preg_replace('/\b(\w+)(?:ando\b)/', 'cant', $string);
echo $replace1; // Exibe: "Eu estou cant."

$replace2 = preg_replace('/\b\w+(?=ando\b)/', 'cant', $string);
echo $replace2; // Exibe: "Eu estou cantando."

Existem situações em que uma função espera receber apenas uma expressão regular para realizar uma substituição e não oferece acesso ao segundo parâmetro do preg_replace, então precisamos usar este recuro da sintaxe das expressões regulares para assegurar que a expressão capture apenas o trexo desejado para substituição.

Caso quiséssemos negar um lookahead assertion, poderíamos utilizar a sintaxe (?!expressão) para capturar apenas uma string caso a sub-expressão não esteja naquela posição. Por exemplo, na expressão a seguir, podemos identificar se a string possui uma frase com a palavra "sexo", mas não é sucedida por espaços e a palavra "masculino" ou espaços e a palavra "feminino":

$string = 'O meu cachorro é do sexo masculino.';
if (preg_match('/\bsexo(?!\s+masculino|\s+feminino)/', $string, $matches)) {
    // nao entrara aqui
}

Lookbehind assertion

O lookbehind assertion tem um comportamento semelhante ao do lookahead assertion, com a diferença que ele assegura que uma sub-expressão está presente à esquerda (antes) de determinada expressão. A sinxate do lookbehind assertion é (?<=expressão). Por exemplo, para montar uma expressão para casar qualquer palavra que seja precedida por "porta-", fazemos a expressão:

$string = 'Veja que porta-retrato bonito.';
if (preg_match('/(?<=\bporta-)\w+\b/', $string, $matches)) {
    echo $matches[0];     // retrato
}

E para assegurar que determinada expressão não está presente antes da sua expressão, usa-se a sintaxe (?<!expressão). Por exemplo, para capturar a palavra "retrato" apenas se não for precedida por "porta-":

$string = 'Veja que retrato bonito.';
if (preg_match('/(?<!\bporta-)retrato\b/', $string, $matches)) {
    echo $matches[0];     // retrato
}

Combinação de asserções

Uma observação importante sobre asserções é que, como as elas não capturam nada (seja pelas sub-expressões ou sequências com \), então é possível combinar múltiplas asserções em sequência. Por exemplo, é possível utilizar dois lookbehind assertions negativos, como no exemplo abaixo, que assegura que a palavra "retrato" não seja precedida nem por "porta-" nem por "quadro-":

$string = 'Veja que retrato bonito.';
if (preg_match('/(?<!\bporta-)(?<!\bquadro-)retrato\b/', $string, $matches)) {
    echo $matches[0];     // retrato
}

Também podemos utilizar lookbehind assertion negativo em conjunto com um \b pra assegurar que a palavra "retrato" não tenha alguma letra antes do "r":

$string = 'Veja que retrato bonito.';
if (preg_match('/(?<!\bporta-)(?<!\bquadro-)\bretrato\b/', $string, $matches)) {
    echo $matches[0];     // retrato
}

Ou então, capturar uma palavra precedida por 3 dígitos numéricos, mas que não sejam "888":

$string = 'Resolvi a task 123XPTO ontem';
if (preg_match('/(?<=\d{3})(?<!888)\w+\b/', $string, $matches)) {
    echo $matches[0];     // XPTO
}

Back references

As "back references" (referências anteriores) servem para assegurar que em determinado ponto da expressão apareçam exatamente os mesmos caracteres que já haviam sido capturados por uma sub-expressão anterior. Por exemplo, na linguagem XML, quando abrimos uma tag, precisamos fechá-la com o mesmo nome de tag, mas com um / na frente da palavra, por exemplo: <number>123</number>. Para fazer uma expressão regular que capture uma tag "number" ou "string" de um XML, podemos usar o lookback para assegurar que a tag de fechamento usa o mesmo nome da tag de abertura:

$string = '<person><number>123</number><name>Rubens</name></person>';
if (preg_match('/(?:<(number|string)>)(\d+)(?:<\/\1>)/', $string, $matches)) {
    echo $matches[0];     // <number>123</number>
    echo $matches[1];     // number
    echo $matches[2];     // 123
}

Portanto, a sintaxe para utilizar back references é com uma / seguida de um número entre 1 e 99 que representa a posição da sub-expressão capturada anteriormente.

As back references podem se repetir em diferentes pontos da expressão regular, desde que apareçam à direita da sub-expressão que referenciam. Existem casos especiais em que a back reference pode referenciar a própria sub-expressão em que se encontra. Veja sobre back references para mais detalhes.

Além disso, é possível usar a sintaxe \g{N} onde N é um número entre 1 e 99 ou entre -1 e -99, sendo que os números negativos servem para referenciar alguma sub-expressão anterior de forma relativa, ou seja, -1 indica a última sub-expressão à esquerda daquele ponto, -2 indica a penúltima, e assim por diante.

Modo extendido

Eventualmente precisamos montar expressões bem complexas e que ficam muito extensas se colocadas em uma única string em uma única linha. Para facilitar a legibilidade destes tipos de expressões, pode ser utilizado o modo extendido das expressões regulares com o modificador x. Neste modo, os espaços na expressão são ignorados (a não ser que estejam escapados ou dentro de uma classe de caracteres como [ ]). Além disso, é possível utilizar o caractere # para adicionar comentários de linha na expressão regular (que só não são tratados como indicador de comentário quando escapados ou dentro de uma classe de caracteres como [#]). Veja um exemplo de uma expressão para capturar as partes de uma data no formato brasileiro:

$string = '01/10/2021';
$regex = <<<REGEX
(
    ^
    (\d{2})    # Capturar o dia
    /
    (\d{2})    # Capturar o mes
    /
    (\d{4})    # Capturar o ano
    $
)x
REGEX;

if (preg_match($regex, $string, $matches)) {
    echo $matches[0];     // 01/10/2021
    echo $matches[1];     // 01
    echo $matches[2];     // 10
    echo $matches[3];     // 2021
}

Observação: mesmo utilizando o modificador x, foram utilizados os delimitadores ( e ) para esta expressão. Não foi utilizado o delimitador / pois a data no formato brasileiro é composta de /, que precisaria ser escapada. Além disso, foi utilizada a notação Heredoc apenas por conveniência (por se tratar de uma linguagem (PCRE) sendo declarada numa string de outra linguagem (PHP).

Uma outra observação é que comentários podem ser inseridos em expressões regulares simples (sem o modificador x), mas utilizando a notação (?#comentário). Por exemplo:

$string = '01/10/2021';
if (preg_match('(^(?# dia )(\d{2})/(?# mes )(\d{2})/(?# ano )(\d{4})$)', $string, $matches)) {
...

Sub-expressões com casamento único

Expressões regulares, por natureza, tentam casar com qualquer trecho da string provida para teste. Isso significa que, por exemplo, se a expressão /^\d+foo$/ é aplicada sobre a string "1234bar", o trecho da expressão \d+ inicialmente vai casar com o "1234", mas em seguida o "f" de "foo" não vai casar com o "b" de "bar". Portanto, a expressão regular vai tentar casar novamente o \d+ com outro trecho da string, por exemplo com o "234", mas novamente vai falhar pois o "f" não casará com o "b". Para se evitar que a expressão regular fique testando essas possibilidades de forma inútil, podem ser especificadas sub-expressões com casamento único, através da sintaxe (?>expressão). Então, se montarmos uma expressão como (?>\d+)foo, estamos dizendo que assim que a sub-expressão \d casar com o "1234", esta será a única tentativa de casamento que a expressão irá tentar.

Expressões recursivas

Expressões recursivas são aquelas que especificam uma regra e, dentro desta regra, há uma refefência para a própria expressão. Utiliza-se a sintaxe (?R) para indicar o ponto de recursividade, ou seja, o ponto em que seria possível incluir exatamente a mesma expressão regular declarada no escopo mais amplo. Um exemplo de aplicação seria para capturar um bloco que esteja entre parênteses, mas precisa assegurar que internamente o bloco tenha parênteses que se abriram e fecharam corretamente:

$string = 'a(b(c)(d)e)f';

$regex = <<<REGEX
/
    \(               # Abertura de parenteses literal
    
    (                # Uma sequencia de zero ou mais:
    	(?>          # Casamento unico
            [^()]+   # qualquer caractere que nao sejam parenteses
        )
        |            # OU
        (?R)         # a propria expressao regular recursivamente
    )*
    
    \)               # Fechamento de parenteses literal
/x
REGEX;

if (preg_match($regex, $string, $matches)) {
    echo $matches[0];     // (b(c)(d)e)
    echo $matches[1];     // e
}

Curiosidades sobre Expressões Regulares

Existem diferentes padrões de expressões regulares. Embora o PHP utilize o padrão da linguagem Perl (que é o PCRE - Perl Compatible Regular Expression), existem também outros padrões como o POSIX BRE (Basic Regular Expression) ou o POSIX ERE (Extended Regular Expression). O padrão BRE é o padrão utilizado nativamente no comando grep, por exemplo. Embora o grep também aceite receber uma expressão no padrão ERE através do argumento -E ou até mesmo o padrão PCRE através do argumento -P.

O padrão BRE segue a maioria da sintaxe mostrada no artigo anterior, sobre os conceitos básicos de expressões regulares. As principais diferenças são: (1) para especificar uma repetição de uma classe de caracteres ou repetição de uma sub-expressão, é preciso escapar o { e o } com uma / (por exemplo [0-9]\{3\} para casar 3 dígitos); (2) para especificar uma sub-expressão, também é preciso escapar o ( e ); e (3) ele não possui os grupos de caracteres definidos com o \ como o \d para indicar um dígito numérico (embora possa ser usado [0-9] ou [:digit:]). Já a ERE não precisa de escape para especificar repetições ou para sub-expressões, mas também não reconhece \d, nem recursos mais avançados, como alguns mostrados neste artigo.

Na Wikipedia, você também pode checar quais padrões são usados por diferentes linguagens/tecnologias, no artigo Comparison of regular-expression engines.

2 comentários