Опережающие и ретроспективные проверки в регулярных выражениях
Наткнулся на чрезвычайно простую но интересную задачку, потребовавшую немного выйти за рамки рабоче-крестьянского курса регулярных выражений - и надеюсь краткий рассказ о ней будет полезен тем, кто еще не стал регулярным джедаем.Безусловно, читая документацию регулярных выражений по диагонали вы, как и я - наверняка не раз наталкивались на опережающие и ретроспективные проверки, но без осознания для какой задачи они могут быть нужны - они и не всплывут в памяти когда это нужно.
Задача банальная - заменить переводы строк на <br/>, за исключением случая, если перед этим шел html-тэг (для простоты только символ >). Отходя от темы - такой алгоритм замены нужен чтобы иметь и автоматическое добавление переводов строки внутри блоков текста в стиле хабра, и при этом не ломать обычную HTML верстку.
Решение в лоб простое как топор - предыдущий символ - часть заменяемого паттерна, который мы повторно вставляем в результат:
preg_replace("/([^>])\n/","\\1<br />",$text);
И оно в принципе работало целый год пока внезапно не были "канонизированы" переводы строк т.е. чтобы код одинаково работал независимо от операционной системы, любые варианты перевода строк(\n, \r, \r\n) были заменены на \n. Внезапно 2 перевода строки подряд перестали заменятся на 2 <br/>Такое поведение вполне разумно (особенно после отладки) - preg_replace не пытяется еще раз проверять то, что он только что заменил во избежание зацикливания - а нам ведь нужно проверять предыдущий символ. Когда переводы строк были не канонизированы - у нас там на самом деле было \r\n\r\n (0xd 0xa 0xd 0xa, кстати, запоминать последовательность спец.символов можно как ReturN) - и мы заменяли \n, а \r - оставался, и именно он проверялся регулярным выражением на соответствие '>'. После канонизации, у нас пропадал этот "резерв" в 1 символ, и preg_replace начинал проверять строку на соответствие регулярному выражению непосредственно с символа \n - и естественно замены не происходило.
Именно для решения таких проблем и существуют Look-ahead и Look-behind выражения (с которыми я лично раньше не сталкивался).
Look-ahead & Look-behind Zero-Width Assertions (опережающие и ретроспективные проверки) - это возможность создать свои аналоги $ и ^: они задают условие, которое должно выполнятся или не выполнятся в начале или конце строки, и не являются частью "сматченого" выражения, т.е. не будут заменены в preg_replace. Это именно то, что нам нужно для этой задачи.
Look-behind - "смотрит" назад, соответственно ставится в начале регулярного выражения.
Look-ahead - в конце, и "смотрит" вперед.
Синтаксис у них такой: (?<=pattern) положительное look-behind условие
(?<!pattern) отрицательное look-behind условие
(?=pattern) положительное look-ahead условие
(?!pattern) отрицательное look-ahead условие
На Look-behind assertions движками регулярных выражений накладываются различные ограничения - в большинстве случаев он должен проверять выражение фиксированной, известной заранее длины (В Java и .NET парсерах ограничения слабее, в JavaScript - не поддерживается вообще, проверяйте документацию).
Благодаря @senia мы можем ознакомиться с исчерпывающей таблицей совместимости различных парсеров регулярных выражений, вот что касается нашей темы:
Feature | .NET | Java | Perl | PCRE | ECMA | Python | Ruby | Tcl ARE | POSIX BRE | POSIX ERE | GNU BRE | GNU ERE | XML | XPath |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
(?=regex) (positive lookahead) | YES | YES | YES | YES | YES | YES | YES | YES | no | no | no | no | no | no |
(?!regex) (negative lookahead) | YES | YES | YES | YES | YES | YES | YES | YES | no | no | no | no | no | no |
(?<=text) (positive lookbehind) | full regex | finite length | fixed length | fixed + altern ation | no | fixed length | no | no | no | no | no | no | no | no |
(?<!text) (negative lookbehind) | full regex | full regex | finite length | fixed length | fixed + altern ation | no | fixed length | no | no | no | no | no | no | no |
preg_replace("/(?<!>)\n/","<br />",$text);
А если переписать для демонстрации с положительной ретроспективной проверкой:("До" должен быть любой символ кроме ">")
preg_replace("/(?<=[^>])\n/","<br />",$text);
Теперь наш код работает и с канонизированными переводами строк, не требуя костылей вроде вставления части регулярного выражения в результат без изменений.PS. На хабре тема уже затрагивалась в статье Имитируем пересечение, исключение и вычитание, с помощью опережающих проверок, в регулярных выражениях в ECMAScript но название у неё страшное и читать её нужно усидчиво :-)