При работе над 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... есть существенный недостаток. В идеальном случае такое выражение захватывает фрагменты пути. Однако возможен случай, когда после самой обычной переменной (не в кавычках, а в коде) идет поиск пути из-за вызова (?&path).

Происходит это из-за особенностей работы 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.
ссылка на оригинал статьи https://habr.com/ru/articles/1064614/