English version.
В этой серии статей мы погружаемся в возможности языка 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, В которой есть пары “индекс-код” и уже знакомые нам пары “символ-код”:

  1. Выражения: ключ match.mark (индекс).

  2. Символы: ключ сам символ 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"  <- свой цвет
	# уровень 1
	text 
   \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 на практике.