В этой серии статей мы погружаемся в возможности языка AutoHotkey на примере вывода цветного текста в терминал для утилиты Launcher.
Часть 1: Наивный алгоритм и его оптимизация
Часть 2: Управляющие последовательности
Часть 3: Атомарные регулярные выражения (вы здесь)
Часть 4: Декларативное программирование
Часть 5: 256 цветов и стилизация текста
В прошлой части мы разобрались с управляющими последовательностями и научились применять их в алгоритме для изменения цвета текста. В этой статье мы разберемся в некоторых возможностях регулярных выражений, которые позволят изменить цвет целого фрагмента текста.
Если вы не знакомы с регулярными выражениями на базовом уровне, рекомендую прочитать эту статью. Если вы не знакомы с синтаксисом AutoHotkey, рекомендую прочитать первую часть. Однако для понимания данной статьи это не обязательно 😉
Строим выражения
Парные символы
В прошлой части мы написали алгоритм для обработки пар символов: " " или * *. Сначала мы получаем промежуточный массив aRegexColor из пар “символ-цвет”, преобразуем его в HashMap chrColors с парами “символ-код”, а затем ищем каждую пару и добавляем перед открывающим символом соответствующий ему символ из chrColors:
Color(msg, aRegexColor) { static colors := Map( 'black', 30, 'red', 31, 'orange', 33, 'magenta', 35, 'gray', 90, 'crimson', 91, 'green', 92, 'yellow', 93, 'blue', 94, 'purple', 95, 'cyan', 96, ) static esc := Chr(27) static end := esc '[0m' regex := '' ; итоговое регулярное выражение chars := '' ; строка символов chrColors := Map() index := 1 loop (aRegexColor.length / 2) { ; `aRegexColor` - входной массив пар "символ-цвет" str := aRegexColor[index++] color := aRegexColor[index++] chars .= str chrColors[str] := colors[color] ; `colors` - HashMap с парами "color-code" } if chars regex .= '([' chars '])' else regex := regex.RTrim('|') stack := [] while (pos <= len) { if !RegExMatch(msg, regex, &match, pos) { ; Оставшийся текст clrMsg .= msg.Slice(pos) break } ; Обычный текст перед найденным символом clrMsg .= msg.Slice(pos, match.pos - pos) ; Движемся вперед pos := match.pos + match.len if (stack.Has(-1) && stack[-1] = match[1]) { clrMsg .= match[1] . end stack.Pop() } else { begin := esc '[0;' chrColors[match[1]] 'm' clrMsg .= begin . match[1] stack.Push(match[1]) } } return clrMsg}
RegExMatch ищет символы из множества (например ["*]) и кладет один из найденных символов в match[1]. В результате алгоритм обрабатывает по одному символу за итерацию. Это позволяет корректно раскрашивать текст внутри парных символов: "text and text". А также текст внутри этого текста: "text *and* text".

Однако у нас имеется более сложное сообщение со справкой, в котором необходимо раскрасить отдельные конструкции: --switches, @file, переменные вроде %AhkDir%. Нам необходимо усовершенствовать алгоритм и добавить в него возможность захватывать целые выражения, а не просто отдельные символы.

Ключ-значение
В алгоритм выше мы передавали массив “символ-цвет”, где ключ — это символ, а значение — это цветовой код:
msg.Color([ '"', 'cyan', '*', 'green'])
Такой массив преобразовывался в chrColors:
" 96* 92
В результате мы могли вызывать chrColors['"'] или chrColors['*'], где каждый символ служил одновременно шаблоном для поиска (регулярным выражением, переменная regex), найденным символом (переменная match[1]) и ключом в chrColors.
Теперь мы ходим передавать “выражение-цвет” (для упрощения временно забудем про символы):
msg.Color([ '(@(file|list\.ini))', 'yellow', ; list '(mainDir|AhkDir)(?=\=)', 'purple', ; variables names '(%[^%]+%)', 'blue', ; variables values '(\-+[\-\w]+)(?=[ =])', 'cyan', ; switches])
При таком подходе каждое переданное выражение больше не может одновременно служить шаблоном для поиска и найденным текстом, а следовательно — не может быть ключом в chrColors.
Каждое регулярное выражение, которое мы передаем в RegExMatch, это простой текст. Сам по себе он ничего не ищет, а сначала компилируется в набор инструкций для процессора. То есть регулярные выражения — это нечто, что можно назвать низкоуровневым компилируемым языком, где каждый символ вроде \w превращается в конкретную инструкцию.
В нашем случае это говорит о том, что выражение вроде (%[^%]+%) превращается в код, который найден текст вроде %AhkDir%, %mainDir% и т.д. То есть вход ((%[^%]+%)) не равен выходу (%mainDir%) и как следствие — теряется информация о самом исходном регулярном выражении, которое нам позволяло применить нужный цвет к найденному тексту. Получается, само выражение не может служить ключом в chrColors для извлечения цвета.
Mark
Как вы могли заметить ранее, переменная match чем-то похожа на массив: match[0] хранит все что удалось найти, а match[1] хранит то, что нашла захватывающая группа 1. Однако на самом деле это match объект, который хранит не только индексы, но и поля: match.pos, match.len, match.count. Также у него есть поле mark. Когда RegExMatch() встречает в шаблоне конструкцию (*MARK:..), поле mark принимает значение после двоеточия : Например, после (*MARK:chr) значение match.mark будет равно "chr".
Рассмотрим другой пример:
(%[^%]+%)(*MARK:1)|(~[^~]+~)(*MARK:2)
Если будет захвачена группа 1, match.mark будет равен “1”. Если будет захвачена группа 2, match.mark будет равен “2”.
Мы можем использовать эту возможность для маркировки (индексации) шаблонов: 1, 2, 3, … Каждый шаблон будет иметь уникальный mark, то есть уникальный ключ по которому мы будем извлекать цвет из chrColors:
begin := esc '[0;' chrColors[match.mark] 'm'clrMsg .= begin . match[1]
Для этого необходимо добавить в конец каждого шаблона из массива aRegexColor mark с индексом, а затем объединить все шаблоны в единое регулярное выражение с помощью оператора OR |
regex := '' ; итоговое регулярное выражение chars := '' ; отдельно итоговое выражение с символами chrColors := Map() index := 1; индекс массива idxColor := 1 ; индекс для mark loop (aRegexColor.length / 2) { str := aRegexColor[index++] color := aRegexColor[index++] if (str.length = 1) { ; Символы объединяются в множество вида [..] chars .= str chrColors[str] := colors[color]; символ-код } else { ; Шаблоны объединяются в один большой regex .= str '(*MARK:' idxColor ')|' chrColors[String(idxColor++)] := colors[color]; индекс-код } } if chars regex .= '([' chars '])(*MARK:chr)' ; добавляем символы отдельно в конец else regex := regex.RTrim('|'); удаляем лишний OR |
В результате получится одно большое выражение вроде (..)(*MARK:1)|(..)(*MARK:2)|...|([..])(*MARK:chr). Для удобства мы используем всю ту же HashMap, В которой есть пары “индекс-код” и уже знакомые нам пары “символ-код”:
-
Выражения: ключ
match.mark(индекс). -
Символы: ключ сам символ
match[1].
Так как для каждого выражения (символ или индексированный) получается разный ключ, необходимо отличать выражение с индексом от выражения с символами с помощью (*MARK:chr):
if (match.mark != 'chr') { ; Индексированный шаблон: оборачиваем в цвет begin := esc '[0;' chrColors[match.mark] 'm' clrMsg .= begin . match[1] . end continue } ; Парный символ if (stack.Has(-1) && stack[-1] = match[1]) { clrMsg .= end stack.Pop() } else { begin := esc '[0;' chrColors[match[1]] 'm' clrMsg .= begin stack.Push(match[1]) }
Branch Reset
У построенного выражения есть критический недостаток: оно имеет много захватывающих групп. Как следствие, каждый захваченный фрагмент текста будет связан со своей захватывающей группой: 1, 2, …
Рассмотрим фрагмент кода:
"%variable% is ~gray~".Color([ '(%[^%]+%)', 'blue', '(~[^~]+~)', 'gray'])
Color построит выражение:
(%[^%]+%)(*MARK:1)|(~[^~]+~)(*MARK:2)
В нем 2 захватывающие группы:
|
Индекс |
Группа |
Текст |
|---|---|---|
|
1 |
(%[^%]+%) |
%variable% |
|
2 |
(~[^~]+~) |
~gray~ |
Соответственно для текста ~gray~ значение лежит в match[2], а match[1] (который мы используем в алгоритме) пуст. То есть мы должны знать индекс группы чтобы получить текст. А если пользователь передал в каждом шаблоне еще несколько групп, вроде =(%([^@](\w+))%) то определить индекс будет сложно. А мы хотим, чтобы текст или символ всегда лежал в match[1].
Для решения данной проблемы существует Branch Reset. Branch Reset представляет собой конструкцию вида (?|..), которая делает индекс каждой группы относительным. Если мы представим выражение ()|()|... как список элементов, разделенных |, то индекс каждой захватывающей группы внутри каждого элемента будет относительным для данного элемента:
( () )|( () () )1 2 1 2 3
То есть мы рассматриваем не все выражение целиком, а его отдельные элементы, а за тем группы внутри элементов. Без Branch Reset индексация абсолютная:
( () )|( () () )1 2 3 4 5
Обернем построенное выражение в (?|..):
(?|(%[^%]+%)(*MARK:1)|(~[^~]+~)(*MARK:2))
Важно понимать, что (?|..) — не захватывающая группа, а конструкция “Branch Reset”. И (*MARK:..) — “глагол” (verb). Скобки лишь часть синтаксиса.
В итоге у нас получится следующая индексация:
|
Индекс |
Mark |
Группа |
Текст |
|---|---|---|---|
|
1 |
1 |
(%[^%]+%) |
%variable% |
|
1 |
2 |
(~[^~]+~) |
~gray~ |
В match[1] всегда будет лежать захваченный текст из 1-й, 2-й или 3-й группы. При этом match.mark будет по-прежнему хранить корректный индекс:
if chars regex .= '([' chars '])(*MARK:chr)' else regex := regex.RTrim('|') regex := '(?|' regex ')'
Переключение цветов
В данный момент наш алгоритм просто оборачивает найденный фрагмент текста в открывающий и закрывающий цвет:
static esc := Chr(27) static end := esc '[0m' if (match.mark != 'chr') { ; Индексированный шаблон: оборачиваем в цвет begin := esc '[0;' chrColors[match.mark] 'm' clrMsg .= begin . match[1] . end continue }
Однако, как вы помните из предыдущей части, управляющие последовательности “переключают” цвета:
\e[96m" <- свой цвет# уровень 1text \e[92m* <- свой цвет # уровень 2 and *\e[96m <- предыдущий цвет text"\e[0m <- нет предыдущего цвета
Закрывающий цвет каждой пары символов зависит от уровня вложенности этой пары. Именно поэтому мы используем стек, чтобы отслеживать, какой цвет нужно применить в данный момент: открывающий (переменная begin) или закрывающий \e[0m (переменная end).
В текущем алгоритме закрывающий цвет ни от чего не зависит, он всегда сбрасывает последующий цвет сообщения на белый с помощью \e[0m.

Однако правильнее было бы проверять, находимся ли мы внутри пары символов (например " ") и переключать цвет на предыдущий, открытый парным символом, а не на \e[0m:
if (match.mark != 'chr') { ; открывающий цвет берем из chrColors begin := esc '[0;' chrColors[match.mark] 'm' if (stack.Has(-1)) { ; внутри пары - закрывающий цвет ищем по "ключу" со стека _end := esc '[0;' chrColors[stack[-1]] 'm' clrMsg .= begin . match[1] . _end } else { ; независимый - закрываем цвет clrMsg .= begin . match[1] . end } continue }

В итоге получаем следующий алгоритм, исходный код которого доступен на GitHub:
; Объявим доп. метод для читабельности({}.DefineProp)(String.prototype, 'Match', {call: RegExMatch})Color(msg, aRegexColor) {; Имени цвета соответсвует цветовой код static colors := Map( 'black', 30, 'red', 31, 'orange', 33, 'magenta', 35, 'gray', 90, 'crimson', 91, 'green', 92, 'yellow', 93, 'blue', 94, 'purple', 95, 'cyan', 96, ) ; Константы static esc := Chr(27) static end := esc '[0m'; Преобразуем входной массив regex := '' chars := '' chrColors := Map() chrColors.capacity := aRegexColor.capacity index := 1 ; индекс массива idxColor := 1 ; индекс mark loop (aRegexColor.length / 2) { str := aRegexColor[index++] color := aRegexColor[index++] if (str.length = 1) { ; Символы объединяем в множество [..] chars .= str chrColors[str] := colors[color] ; символ-цвет } else { ; Атомарные шаблоны объединяем через OR | regex .= str '(*MARK:' idxColor ')|' chrColors[String(idxColor++)] := colors[color] ; индекс-цвет } } if chars regex .= '([' chars '])(*MARK:chr)' else regex := regex.RTrim('|') ; Применяем Branch Reset к каждой группе regex := options '(?|' regex ')' ; Раскрашиваем сообщение pos := 1 len := msg.length clrMsg := '' stack := [] stack.capacity := aRegexColor.capacity * 2 while (pos <= len) { if !msg.Match(regex, &match, pos) { ; Оставшийся текст clrMsg .= msg.Slice(pos) break } ; Обычный текст перед фрагментом clrMsg .= msg.Slice(pos, match.pos - pos) ; Движемся вперед pos := match.pos + match.len if (match.mark != 'chr') { ; Атомарный шаблон. ; Открывающий цвет берем из chrColors begin := esc '[0;' chrColors[match.mark] 'm' if (stack.Has(-1)) { ; Внутри пары - закрывающий цвет ищем по "ключу" со стека _end := esc '[0;' chrColors[stack[-1]] 'm' clrMsg .= begin . match[1] . _end } else { ; Независимый - закрываем цвет clrMsg .= begin . match[1] . end } continue } ; Парный шаблон (символ) if (stack.Has(-1) && stack[-1] = match[1]) { ; Нашли завершающий пару символ. ; Закрываем цвет clrMsg .= end stack.Pop() } else { ; Открываем соотв. паре цвет begin := esc '[0;' chrColors[match[1]] 'm' clrMsg .= begin stack.Push(match[1]) } } return clrMsg}
Теперь этот алгоритм можно использовать, чтобы раскрасить сообщение со справкой, с которого мы начинали в первой части:
PrintHelp(*) {; ... msg := msg.Color([ '(@(file|list\.ini))', 'yellow', ; list '(mainDir|AhkDir)(?=\=)', 'purple', ; variables names '(%[^%]+%)', 'blue', ; variables values '(\-+[\-\w]+)(?=[ =])', 'cyan', ; switches '\*\*([^\*]+)\*\*', 'crimson', '__([^_]+)__', 'magenta', '~', 'gray', '``', 'green', '#', 'orange' ]).Print()}

Заключение
Регулярные Выражения — весьма удобный способ парсинга текста, пусть и не самый читабельный. Они позволяют нам находить фрагменты текста с помощью низкоуровневого кода, и обрабатывать их с помощью высокоуровневого языка AutoHotkey.
В данной статье мы увидели, что регулярные выражение обладают возможностями отслеживания пройденного пути и некоторой рефлексией. Благодаря этим возможностям мы можем смогли их с большей эффективностью и написать лаконичный и читабельный алгоритм.
В следующей статье мы рассмотрим, как можно описывать парсинг текста понятным человеку языком с помощью AutoHotkey и посмотрим на язык с точки зрения декларативного программирования. Заглядывайте на мой GitHub, если вы хотите увидеть возможности AutoHotkey на практике.
ссылка на оригинал статьи https://habr.com/ru/articles/1057658/