Expressões Regulares com Intervalos Numéricos
Autor: Rubens Takiguti Ribeiro | Publicado: 2016-09-17T00:45:00-03:00



Introdução
No artigo sobre Expressões Regulares em PHP, vimos os conceitos básicos sobre expressões regulares. Porém, algo que pode gerar certa confusão é sobre a criação de uma expressão regular que verifique se determinada string é um número presente em um determinado intervalo numérico (range). Neste artigo veremos qual é o problema e é apresentada uma classe que gera a expressão regular de um intervalo numérico.





O problema do range em RegExp
O problema de se expressar intervalos numéricos na forma de expressões regulares é que expressões regulares são baseadas em strings com tamanhos específicos, enquanto intervalos numéricos seguem uma lógica matemática sequencial.
Embora seja muito simples criar uma expressão regular para um intervalo simples como "um número de 3 a 7" (expresso na forma /^[3-7]$/), o mesmo não vale para um intervalo como 13 a 27.
Se você pensou em algo como /^[13-27]$/, você já errou feio. Esta expressão casaria apenas strings contendo um único dígito que pode ser "1", ou algum dígito no intervalo de "3" a "2", ou o "7". Como o 3 é maior que 2, então a expressão regular nem chega a ser compilada. Nem preciso dizer que a expressão não vai funcionar.
Se você pensou na expressão /^[1-2][3-7]$/, então está dizendo que o primeiro dígito pode ser 1 ou 2, e o segundo dígito pode ser algum entre 3 e 7. Isso significa que a expressão não casaria os números 18, 19, 20, 21 e 22, então não funciona.
Por outro lado, se você pensou na expressão /^[1-2][0-9]$/, então está dizendo que o primeiro dígito pode ser 1 ou 2, e o segundo dígito pode ser algum entre 0 e 9. Isso significa que a expressão casaria os números 18, 19, 20, 21 e 22, porém, também casaria o 28 e o 29, que não fazem parte do intervalo, portanto, também não funciona.
Mas se você pensou em /^(13|14|15|16|17|18|19|20|21|22|23|24|25|26|27)$/, significa que você é bem preguiçoso, mas resolve o problema.
Mas a forma mais correta de se montar a expressão regular para casar números no intervalo de 13 a 27 é com /^(1[3-9]|2[0-7])$/. Ou seja, se o primeiro dígito for 1, ele deve ser seguido de algum dígito entre 3 e 9, mas se o primeiro dígito for 2, ele deve ser seguido de algum dígito entre 0 e 7. Isso contempla todos números entre 13 e 27.
Poxa, parece fácil agora? Então experimente montar uma expressão que contemple o intervalo de 222100 e 272099, por exemplo. A expressão é realmente bem mais complexa.
Na verdade, a melhor forma de se validar que um número está dentro de um intervalo é com os operadores de maior ou menor. Mas se o seu número estiver representado em uma string e ela tiver muitos dígitos (ser maior que PHP_INT_MAX, por exemplo), talvez você precise usar a função bccomp da extensão BCMath para comparar sem perda de precisão. Porém, as vezes seu sistema já está preparado para receber uma expressão regular em determinado ponto, e você precisa validar um intervalo complexo. Neste caso, sugiro que utilize a classe abaixo para gerar a expressão pra você.



Classe RangeRegex
Observação: esta classe requer a extensão BCMath.


Linguagem: PHP
Copyright 2016 Rubens Takiguti Ribeiro
Licença: LGPL 3 ou superior
/**
 * Classe que consegue gerar uma expressao regular que expressa um numero de algum intervalo
 * com o numero de digitos constante
 */
class RangeRegex
{
    public function build($start, $end)
    {
        $start = strval($start);
        $end = strval($end);
        if (strlen($start) != strlen($end)) {
            throw new InvalidArgumentException('Start and end must have same size');
        }
        if ($start &gt; $end) {
            throw new InvalidArgumentException('Start must be lower than end');
        }

        $ranges = $this-&gt;getSubRanges($start, $end);

        $regexTokens = array();
        foreach ($ranges as $range) {
            $regexTokens[] = $this-&gt;buildRegexTokens($range[0], $range[1]);
        }

        return $this-&gt;optimizeAndFormat($regexTokens);
    }

    private function getSubRanges($start, $end)
    {
        if ($start === $end) {
            return array(array($start, $end));
        }

        $len = strlen($start);

        $begin = '';
        for ($i = 0; $i &lt; $len &amp;&amp; $start[$i] == $end[$i]; $i++) {
            $begin .= $start[$i];
        }

        $r1 = preg_match('/^\d0*$/', substr($start, $i));
        $r2 = preg_match('/^\d9*$/', substr($end, $i));

        if ($r1 &amp;&amp; $r2) {
            return array(array($start, $end));
        }

        $middle1 = $begin . $start[$i] . ($len - $i - 1 &gt; 0 ? str_repeat('9', $len - $i - 1) : '');
        if (bccomp($end, $middle1) &lt;= 0) {
            return array(array($start, $end));
        }

        $ranges = array();
        if ($r1) {
            $ranges[] = array($start, $middle1);
        } else {
            $ranges = array_merge($ranges, $this-&gt;getSubRanges($start, $middle1));
        }

        $middle1plus1 = str_pad(bcadd($middle1, '1'), strlen($middle1), '0', STR_PAD_LEFT);

        if ($r2) {
            $ranges[] = array($middle1plus1, $end);
        } else {
            $middle2 = $begin . $end[$i] . ($len - $i - 1 &gt; 0 ? str_repeat('0', $len - $i - 1) : '');
            $middle2sub1 = str_pad(bcsub($middle2, '1'), strlen($middle2), '0', STR_PAD_LEFT);
            if (bccomp($middle1plus1, $middle2sub1) &lt;= 0) {
                $ranges[] = array($middle1plus1, $middle2sub1);
            }
            $ranges = array_merge($ranges, $this-&gt;getSubRanges($middle2, $end));
        }

        return $ranges;
    }

    private function buildRegexTokens($start, $end)
    {
        $regex = array();
        $len = strlen($start);
        for ($i = 0; $i &lt; $len; $i++) {
            if ($start[$i] == $end[$i]) {
                $regex[] = $start[$i];
            } elseif ($start[$i] == '0' &amp;&amp; $end[$i] == '9') {
                $regex[] = '\d';
            } elseif ($start[$i] &lt; $end[$i]) {
                $regex[] = sprintf('[%d-%d]', $start[$i], $end[$i]);
            } else {
                throw new \InvalidArgumentException(sprintf('Invalid range: %s - %s', $start, $end));
            }
        }
        return $regex;
    }

    private function optimizeAndFormat(array $regexTokens)
    {
        $optimized = array();
        $len = count(current($regexTokens));
        foreach ($regexTokens as $i =&gt; $regex) {
            $regexOpt = array();
            $buffer = $regex[0];
            $count = 1;
            for ($j = 1; $j &lt; $len; $j++) {
                if ($regex[$j - 1] == $regex[$j]) {
                    $count++;
                } else {
                    $regexOpt[] = $buffer . ($count &gt; 1 ? '{' . $count . '}' : '');
                    $count = 1;
                    $buffer = $regex[$j];
                }
            }
            $regexOpt[] = $buffer . ($count &gt; 1 ? '{' . $count . '}' : '');
            $optimized[] = $regexOpt;
        }

        $regularExpressions = array_map('implode', $optimized);

        return '/^(?:' . implode('|', $regularExpressions) . ')$/';
    }
}