При работе над Awesome Notepad++ я довольно много работал над созданием мощной подсветки синтаксиса на основе регулярных выражений. Для разных языков я решил добавить отдельный цвет для строк, которые содержат путь к файлу или директории. Однако в некоторых языках путь может содержать переменные и выражения, к которым также будет применен цвет пути. А мне хотелось подсветить все кроме переменных.
Задача раскрасить путь, обойдя стороной переменные, усложнялась тем, что я мог использовать только одно регулярное выражение. А раскрасить нужно как минимум две части пути: перед переменной и после нее.

В одной статье мы рассматривали RegEx цикл, который решает похожую задачу, но не захватывает первую часть текста. В этой статье мы рассмотрим RegEx рекурсию, которая может захватывать все части текста, а также имеет условия выхода. Мы увидим, что даже одно выражение может быть невероятно мощным при использовании разных конструкций.
Наивное решение
Начнем с захвата полного (абсолютного) пути. Обычно такой путь имеет вид C:\Windows\System32 или G:/Backup/Saved.
" [A-Z] : # буква диска и двоеточие [\/\\] # прямой или обратный слэш \ / [^"]{1,} # все до кавычки " "
Такое выражение явно требует открывающую кавычку, одну заглавную букву, двоеточие и любой слэш после. Такого префикса вполне достаточно, чтобы посчитать найденную строку за путь.
Это выражение при записи в одну строку не требует никаких флагов. Однако я рекомендую добавить (?x) или включить “extended” флаг при записи такого выражения в несколько строк. Здесь и далее я пишу выражения в несколько строк для читабельности. Также я добавляю комментарии в стиле Python в качестве пояснений. Убедитесь, что ваш язык программирования/песочница/среда поддерживает такую запись.
Так как я упомянул “префикс”, можно вспомнить, что в некоторые системах существует также ~ - домашняя директория; . - относительный путь; : - при конкатенации вроде drive + ":/Windows". Добавим захват относительных путей.
" (?<prefix> [A-Z] : # буква диска и двоеточие | # или [\.~:] # относительный путь .\ или дом ~\ ) [\/\\] # прямой или обратный слэш \ / [^"]{1,} # все до кавычки " "
Для читабельности мы добавили именованную захватывающую группу (?<prefix>). Она улучшает читабельность. Позднее мы попробуем переиспользовать ее.
Оператор OR | работает только в рамках этой группы: если не будет найдена буква диска, поиск начнется с выражения после |. Если ни один из символов [\.~:] не будет найден, поиск остановится.
Это важно запомнить, поскольку принцип работы OR | меняется в зависимости от того, используется ли он глобально (вне любой группы) или локально (внутри одной группы). Смысл оператора меняется от его контекста, именно это мы увидим далее.
Переменные
В PowerShell, JavaScript или Python строки в двойных кавычках могут содержать переменные: "hello ${name}". В PowerShell перед имем переменной есть префикс $: $name. Нам необходимо или найти путь, или обойти стороной переменную.
" (?<prefix> [A-Z] : # буква диска и двоеточие | # или [\.~:] # относительный путь .\ или дом ~\ ) [\/\\] # прямой или обратный слэш \ / [^\$"]{1,} # все, кроме префикса переменной $ до кавычки " " | # встретили переменную... \$ \w{1,} # захватываем переменную \K # ...и выкидываем ее из результата

Если условие [^\$"] (любой символ, кроме $ и ") не выполнилось, например мы встретили префикс переменной $, интерпретатор регулярных выражений попробует выполнить ветвь после OR |: найти переменную вида \$\w+ и пропустить ее мимо с помощью слова \K.
Ветвь (ветка) - альтернативный путь в регулярном выражении, описанный после оператора OR
|. Такое выражение имеет видветвь1|ветвь2... Ветвь 2 исполняется только в том случае, если по какой-то причине не удалось полностью пройти Ветвь 1 (не выполнилось условие, не найден символ, и т.п.).
В данном случае доллар \$ экранирован, так как он также может служить условием “конец строки” ($). Чтобы интерпретатор увидел доллар как символ, его необходимо экранировать \.
Обратите внимание на слово \K: оно означает, что все, что было найдено прежде, ныне не имеет значения и исчезает из результата (в нашем случае - переменная). В каком-то смысле это способ осуществить сдвиг каретки/курсора, а не выделить текст.

Рекурсия
Переменную мы пропустили, а дальше как? Необходимо как-то продолжить парсинг текста. Вернуться назад и дойти наконец до закрывающей кавычки.
Для “прыжка” назад, к парсингу пути, мы можем “вызвать” существующую группу по имени с помощью конструкции (?&name).
Можно также использовать
\g<name>или(?1)(по индексу) или даже рекурсивный всего выражения(?R). Однако такие конструкции не везде поддерживаются.
" # ... (?<path> <--* [^"$]{1,} | ) | " | | | \$ \w{1,} | \K | (?&path) ---*
Мы также могли бы использовать рекурсию (?R), но гораздо эффективнее не проверять заново кавычки, префикс и т.п., а сразу продолжить с того места, где случилась неудача.
В регулярных выражениях неприменимо понятие цикла ввиду того, что спецификация явно указывает на рекурсию с выделением места на стеке и вызовом внутренних функций.
Однако у второй ветви | \$ \w... есть существенный недостаток. Он захватывает странный большой кусок пустоты после имени переменной $data, которая находится вне строки " ".

Происходит это из-за особенностей работы OR | , о которых я упомянул ранее. В глобальной области видимости этот оператор создает альтернативную ветвь “поиск переменной”. Если по какой-то причине не удалось найти кавычку, префикс или в пути был доллар, начинается исполнение альтернативной ветви. То есть во всем тексте не удалось найти путь, зато нашлась переменная из альтернативной ветви. После этого имя переменной игнорируется с помощью \K и происходит вызов (?&path), который начинает поиск “всего, кроме кавычки и доллара”: [^\$"]{1,}
То есть вместо ожидаемого поведения “цикл: захвати путь, в случае неудачи обойди переменную” мы получили поведение “захвати путь или цикл: обойди переменную, захвати что-то”. Необходимо добавить условие: “если удалось захватить путь, цикл: обойди переменную, захвати путь”.
Условия выхода
Для этого существует проверка позиции \G - конец предыдущего захвата. Если прежде не удалось что-то захватить (в нашем случае - путь), поиск прекращается после того как будет встречен \G.
" (?<prefix> [A-Z] : # буква диска и двоеточие | # или [\.~:] # относительный путь .\ или дом ~\ ) [\/\\] # прямой или обратный слэш \ / (?<path> [^\$"]{1,} # все, кроме префикса переменной $ до кавычки " " ) | # встретили переменную... \G # начинаем с предыдущей позиции (если ничего не нашли ранее, поиск обрывается) \$ \w{1,} # захватываем переменную \K # ...и выкидываем ее из результата (?&path) # снова ищем путь
Если мы нашли кавычки, префикс, …, но оступились на проверке исключенных символов [^\$"], считается, что нам удалось что-то захватить и мы можем переступить через \G и начать пропуск переменных. Если мы не дошли и до префикса, поиск остановится после \G . Ничего не будет захвачено.
Добавим символы, которые не должны быть в пути: [^\$=:\<>;\n"]. Если мы встретили один из этих символов, альтернативная ветвь \G \$ .. (?&path) завершится на символе \$ и рекурсии не произойдет.
Мы получили рекурсию, в которой есть два условия выхода:
Найден недопустимый символ пути:
= : \ < > ;или конец строки.Не найден элемент пути (открывающая кавычка, префикс, …) (
\G).
" (?<prefix> [A-Z] : # одна буква диска и двоеточие | # или [\.~:] # относительный путь .\ или дом ~\ ) [\/\\] # один прямой или обратный слэш \ / (?<path> [^\$=:\<>;\n"]+ # все, кроме префикса переменной $ до кавычки " "? # не более одной закрывающей кавычки ) | # встретили переменную... \G # начинаем с предыдущей позиции (если ничего не нашли ранее, поиск обрывается) \$ \w+ # захыватываем переменную \K # ...и выкидываем ее из результата (?&path) # прыжок наверх (цикл)
Данная рекурсия работает как нам требуется: “если удалось захватить путь, цикл: обойди переменную, захвати путь”. Мы также можем учесть очень короткие пути: C:, A:/, ./, ~/
Для этого необходимо заменить квантор у исключенных символов на “ноль и более”: [^\$=:\<>;\n"]*.
Квантор (англ. quantifier) - оператор, указывающий допустимое число повторений (вхождений) элемента при его захвате.
Тогда это множество может быть пустым: после “prefix” может сразу идти как закрывающая кавычка, так и переменная. “prefix” и является путем в таком случае: "C:/$name", "./$name"
Обратите внимание, что закрывающая кавычка получила квантор “ноль или один”:
"? # не более одной закрывающей кавычки
Если кавычка есть, она будет захвачена. Если ее нет, условие выхода у нас достаточно надежное: нет префикса, не тот символ или конец строки. А вот жесткое требование “должна быть закрывающая кавычка” создает ситуацию, при которой интерпретатор будет пытаться искать фрагменты пути ближе к закрывающей кавычке, чтобы выполнить поставленное требование. Путь может быть не захвачен вообще из-за внутренних особенностей парсинга рекурсивных выражений.

А вот открывающая кавычка должна быть обязательно, так как при прыжке (?&path) не выполняется проверка на те символы, которые идут перед (кавычки, префикс, …), только на символы, которые идут после.
Выражения
Мы рассмотрели базовые переменные: $name. Однако в JavaScript или Python переменные в строке могут быть только вида ${name} (формат-строка). А в PowerShell может быть как “необычная” переменная ${name with spaces}, так и выражение $($env:WinDir) или $($obj.property).
Нам необходимо найти и пропустить мимо переменные вида ${..}, $(..) и $name. В данном случае не имеет значения, какие символы допустимы, какие содержатся внутри и т.п. Нас интересует все тот же префикс \$ и скобки после него.
Однако простое выражение \${.+} или \$\(.+\), которое просто захватывает все до ближайшей закрывающей скобки, здесь не подойдет.

Выражения $(..) вполне могут содержать другие выражения, вызовы и методы, в которых есть скобки (). Поэтому мы вновь обращаемся к рекурсии, чтобы обработать открывающие и закрывающие скобки.
\$ # префикс переменной (?<paren> \( <--* (?: | [^\(\)]+ | # все кроме скобок | | (?&paren)--* # рекурсивно захватываем внутри скобок ) \) )

Если мы нашли открывающую скобку, ищем все до закрывающей скобки или снова находим открывающую скобку и снова ищем все до закрывающей или…
Мы можем также учесть, что путь не должен быть в несколько строк, а следовательно и выражения не могут содержать переносы строк \n.
\$ # префикс переменной (?<expression> (?<paren> \( # $(..) (?: [^\(\)\n]+ # все кроме скобок | (?&paren) # рекурсивно захватываем внутри скобок ) \) ) | (?<brace> { # ${..} (?: [^{}\n]+ # все кроме фигурных скобок | (?&brace) # рекурсивно захватываем внутри ) } ) | [\w\-\._:]+ # $variable )
Имя переменной может содержать нетипичные символы: [\w\-\._:]. Мы постарались это учесть.
Теперь мы можем все это объединить в одно большое выражение для решения искомой задачи.
" (?<prefix> [A-Z] : # одна буква диска и двоеточие | [\.~:] # относительный путь .\ или дом ~\ ) [\/\\] # один прямой или обратный слэш \ / (?<path> [^\$=:\<>;\n"]* # возможно, дальше есть путь "? # не более одной закрывающей кавычки ) | # встретили переменную... \G # начинаем с предыдущей позиции (если ничего не нашли ранее, поиск обрывается) \$ # префикс переменной (?<expression> (?<paren> \( # $(..) (?: [^\(\)\n]+ # все кроме скобок | (?&paren) # рекурсивно захватываем внутри скобок ) \) ) | (?<brace> { # ${..} (?: [^{}\n]+ # все кроме фигурных скобок | (?&brace) # рекурсивно захватываем внутри ) } ) | [\w\-\._:]+ # $variable ) \K # ...и выкидываем из результата (?&path) # прыжок наверх (цикл)

Теперь у абсолютных и относительных путей один цвет, а у переменных и выражений - другой.

Заключение
Мы смогли убедиться, что даже одно регулярное выражение способно работать с условием и разными фрагментами текста, и при этом парсить текст не теряя контекст (с чего парсинг начался). И, что самое главное, у него есть практическое применение - подсветка синтаксиса в коде, с учетом наличия переменных в строке.
Данное выражение будет работать только со флагом (?g) (global) /regex/g в языках с поддержкой следующих интепретаторов (модулей):
-
PCRE
PHP (версия 7.3 и выше)
C и C++ (версия 7.0 и выше)
Perl, Raku, Ruby (версия 1.9 и выше),
Java (версия 1.4 и выше, с использованием библиотеки PCRE)
AutoHotkey (версия 1.1 и выше)
Raku - Raku (все версии)
Ruby Regex - Ruby (версии 1.9 и выше)
.NET Regex - .NET Framework (версии 2.0 и выше)
Tcl Regex - Tcl (версии 8.0 и выше)
Python - Python (версия 3.0 и выше)
Для остальных языков нужны сторонние библиотеки, например для JavaScript могут подойти: 1 2 3
В JavaScript, и только в JavaScript возможно использовать positive/negative lookbehind с квантором вместо \G.
LemeRus
Открыв статью, был в сильном замешательстве: известные мне регулярки не предусматривают конструкций вида
(?x), поэтому я полез искать по другим статьям автора, из какого же контекста мне читать эту статью. Почитал, не понял, какой же диалект привычен автору и использован тут, полез шерстить все ссылки, указанные в теле статьи. Одна сломана, одна ничего не дала, третья таки привела на цикл в регулярках, который зародил во мне догаки о том, что тут замешан перл, который пока вне моей сферы интересов. Ок, поглядел всю статью, чтоб убедиться, обнаружил, что есть чётко очерченные требования к тому, где применим материал статьи.Я к чему… Автор, спасибо за квест, но всё таки гораздо удобнее было бы увидеть, что именно используется в статье в самом начале, сразу после постановки задачи!