Наткнулся на чрезвычайно простую но интересную задачку, потребовавшую немного выйти за рамки рабоче-крестьянского курса регулярных выражений — и надеюсь краткий рассказ о ней будет полезен тем, кто еще не стал регулярным джедаем. Безусловно, читая документацию регулярных выражений по диагонали вы, как и я — наверняка не раз наталкивались на опережающие и ретроспективные проверки, но без осознания для какой задачи они могут быть нужны — они и не всплывут в памяти когда это нужно.
Задача банальная — заменить переводы строк на <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 но название у неё страшное и читать её нужно усидчиво :-)