Атомарные регулярные выражения

от автора

В этой серии статей мы погружаемся в возможности языка 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"  <- свой цвет# уровень 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/