При работе над 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) завершится на символе \$ и рекурсии не произойдет.

Мы получили рекурсию, в которой есть два условия выхода:

  1. Найден недопустимый символ пути: = : \ < > ; или конец строки.

  2. Не найден элемент пути (открывающая кавычка, префикс, …) (\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 в языках с поддержкой следующих интепретаторов (модулей):

  1. PCRE 

    1. PHP (версия 7.3 и выше)

    2. C и C++ (версия 7.0 и выше)

    3. Perl, Raku, Ruby (версия 1.9 и выше),

    4. Java (версия 1.4 и выше, с использованием библиотеки PCRE)

    5. AutoHotkey (версия 1.1 и выше)

  2. Raku - Raku (все версии)

  3. Ruby Regex - Ruby (версии 1.9 и выше)

  4. .NET Regex - .NET Framework (версии 2.0 и выше)

  5. Tcl Regex - Tcl (версии 8.0 и выше)

  6. Python - Python (версия 3.0 и выше)

Для остальных языков нужны сторонние библиотеки, например для JavaScript могут подойти: 1 2 3

В JavaScript, и только в JavaScript возможно использовать positive/negative lookbehind с квантором вместо \G.

Комментарии (1)


  1. LemeRus
    29.07.2026 18:33

    Открыв статью, был в сильном замешательстве: известные мне регулярки не предусматривают конструкций вида (?x), поэтому я полез искать по другим статьям автора, из какого же контекста мне читать эту статью. Почитал, не понял, какой же диалект привычен автору и использован тут, полез шерстить все ссылки, указанные в теле статьи. Одна сломана, одна ничего не дала, третья таки привела на цикл в регулярках, который зародил во мне догаки о том, что тут замешан перл, который пока вне моей сферы интересов. Ок, поглядел всю статью, чтоб убедиться, обнаружил, что есть чётко очерченные требования к тому, где применим материал статьи.

    Я к чему… Автор, спасибо за квест, но всё таки гораздо удобнее было бы увидеть, что именно используется в статье в самом начале, сразу после постановки задачи!