English version.
Не так давно я начал обращать внимание, что многие консольные утилиты выводят цветной текст. Меня заинтересовало, смогу ли я тоже добавить цвета в вывод моей консольной версии Launcher.

Задача стояла написать алгоритм, который будет применять менять разные цвета к фрагментам выводимого текста. И в этой статьей мы детально рассмотрим, как можно написать такой алгоритм на моем любимом языке AutoHotkey, как его оптимизировать и что стоит учитывать, когда вы пишете код.

Если вы не знакомы с синтаксисом AutoHotkey, не переживайте, я проведу вас через него. Ведь одна из задач этой статьи - познакомить вас с этим языком.

Наивное решение

Виды функций

Начнем с идеи раннего алгоритма. Наша цель - менять цвет сообщения при выводе в консоль (далее - терминал, так как я работаю в эмуляторе Cmder с PowerShell). Для этого сначала в самом сообщении должны быть какие-то маркеры, которые будут указывать цвет для каждой части текста. Например, маркерами могут служить html теги: <color>text</color>. Далее необходимо найти все такие теги в тексте, извлечь имя цвета и применить его в консоль.

Сырой код
Сырой код
Текст в консоли
Текст в консоли

Благо Microsoft позволяет нам обращаться к Windows и просить ее что-нибудь сделать. Для этого существует Windows API (Windows Application Programming Interface, он же WinApi или просто функции API). Каждая функция имеет свою документацию на сайте Microsoft, и располагается в своих dynamic-link libraries (DLL), которые загружаются при запуске интерпретатора AutoHotkey. В результате мы можем вызывать любые функции из этих библиотек от Microsot из AutoHotkey с помощью встроенной функции языка DllCall. Все встроенные функции AutoHotkey вроде MsgBox или FileAppend вызывают те или иные функции WinApi, но при большом желании мы можем “спуститься на уровень ниже” и вызвать их самостоятельно.

Не стоит путать: пользовательские функции мы объявляем сами; функции языка предоставляет интерпретатор; функции WinApi можно вызвать только через DllCall;

Для изменения цвета в консоли существует функция WinApi SetConsoleTextAttribute. Она принимает handle консоли (ее уникальный номер/id) и число, которое “характеризует” цвет. Это не RGB и не HEX представление, а специальный внктренний флаг, на котором мы не будем заострять внимание.

Для дальнейшего вывода в консоль существует функция языка FileAppend которая умеет выводить сообщение в текст или в консоль (специальное имя файла - CONOUT$).

В самой ранней версии алгоритма использовалась рекурсивная обработка html тегов цвета и вызов SetConsoleTextAttribute() для каждого с последующим вызовом FileAppend():

Print(text, color := 'white') {
    ; Словарь/HashMap который сопоставляет цвету спец. код
    ; статич.: инициализируется один раз при загрузке скрипта,
    ; уменьшает время исполнения
    static colors := Map(
        'black',    0,
        'blue',     1,
        'green',    2,
        'cyan',     3,
        'red',      4,
        'magenta',  5,
        'yellow',   6,
        'white',    7,
        'gray',     8,
    )
    
    ; Цвет по-умолчанию, который будет использоваться
    ; для частей текста
    normalColor := colors.Get(color, 7)
    
    _Print(msg, _color := normalColor) {
        ; Замыкание: пользовательская функция внутри функции
        ; Весьма удобно использовать как макрос для повторяющегося текста

        ; статич.: инициализируется один раз при загрузке скрипта,
        ; уменьшает время исполнения
        static hConsole := GetOutputHandle()
        DllCall(
            'SetConsoleTextAttribute', 
            'ptr', hConsole, 
            'uint', _color
        )
        
        ; Выводим цветной текст
        FileAppend(msg, 'CONOUT$')
    }

    pos := 1
    while (pos <= text.length) {
        ; Ищем все цветовые теги начиная с `pos`, 
        ; сохраняем результат в переменной `match`
        if (RegExMatch(text, 's)<(\w+)>(.*?)</\1>', &match, pos)) {
            ; Выводим текст до цвтного участка
            normalText := text.Slice(pos, match.pos - pos)
            if (normalText)
                _Print(normalText)    ; замыкание (макрос) сверху
            
            ; Обрабатываем вложенные теги
            ; Рекурсивный вызов:
            ; группа 2 = сообщение, 
            ; группа 1 = цвет.
            Print(match[2], match[1])
            
            ; Движемся вперед
            pos := match.pos + match.len
        } else {
            ; Выводим оставшийся текст
            _Print(text.Slice(pos))
            break
        }
    }
}

Message(msg, icon := '', normalColor := 'white') {
    if IsConsole
        return Print(msg '`n', normalColor)
     
    msg := RegExReplace(msg, 's)<(\w+)>(.*?)</\1>', '$2')
    return MsgBox(msg, A_ScriptName, icon)
}

RegExMatch это функция языка, которая позволяет быстро парсить текст без промедлений со стороны высокоуровневых языков

Как видите, выглядит довольно просто: найти цвет, применить цвет, вывести в консоль, повторить. И такой алгоритм обрабатывает и вложенные теги: <yellow>my colorful<cyan>message</cyan></yellow>

Так как функция Print() только обрабатывает и выводит сообщение, нам нужна дополнительная функция Color() которая добавит html теги в текст:

Color(str, regex, colorTag) {
    return RegExReplace(
        str, 
        regex,
        Format('<{1}>$0</{1}>', colorTag)
    )
}

Еще одна функция языка, которая ищет кусок текста и обрачивает его в теги. Минималистично, удобно.

Новые методы строк

Для удобства мы можем объявить дополнительные методы строк как в Python. Только в AutoHotkey в отличие от Python мы можем создать какие угодно методы для наших строк, например добавить метод Color() который будет раскрашивать строку:

'My colorful message'.Color('message', 'cyan')
; or
'-help, -h or -? displays help message'.Color('-\w+', 'green')

Как вы, наверное, догадались, метод Color() будет вызывать нашу функцию Color() и передавать первым аргументом msg ту строку, к которой он применяется. Т.е. 'My colorful message'.Color('message', 'cyan') будет эквивалентно Color('My colorful message', 'message', 'cyan').

Такая конструкция объявляет новый метод Color() для примитивов типа String:

({}.DefineProp)(String.prototype, 'Color', {call: Color})

В алгоритме выше вы могли заметить методы Slice(), атрибут length. На самом деле это тоже “добавленные методы”, которые являются обертками над функциями языка, но делают код чуть-чуть читабельнее.

Давайте посмотрим, как можно применить наш алгоритм. В коде есть функция PrintHelp(). Внутри нее сообщения со справкой: с цветовыми тегами и без них. Ниже приведен ее небольшой фрагмент (так как сообщения достаточно большие):

PrintHelp() {
    usage := 
    (
    "<gray>Launch saved scripts and applications.
    Copyright (c) 2026 Rafaello
    https://github.com/JoyHak/Launcher</gray>
    
    Usage:
      launcher --param=script1<gray>[;script2;script3...]</gray>
      launcher --param=<yellow>@file</yellow>
      launcher -switch
      launcher <cyan>variable</cyan>=value"
    )
    synopsis := 
    (
    "Parameters:
      --run     run script(s)
      --close   close script(s)
      --add     add script(s)
      --remove  remove script(s)
      --sep     set separator between scripts"
    )

    synopsis := synopsis
      .Color('[\-]+[\-\w]+', 'cyan')
    
    ; Конкатенация строк: a . b
    Message(usage . synopsis)

Так как сообщение usage уже содержит цветовые теги, Color() применятся только к собщению synopsis и раскрашивает --switches.

Красиво. Но что лежит у этих функций под капотом и сколько операций они совершают при каждом вызове? Функция FileAppend() снова и снова открывает/закрывает доступ в консоль. Функция SetConsoleTextAttribute выполняет ряд операций, которые к смене цвета не имеют отношения: опрос режима консоли, проверка свободного буфера вывода и т.д. И наконец есть риск что рекурсия в Print() может обвалиться при многократно вложенных тегах и вызвать stack overflow.

Время выполнения

Давайте попробуем измерить время выполнения алгоритма. Для этого используем обертку над функцией WinApi QueryPerformanceCounter:

Timer(_start := false) {
    static previous  := 0, 
           frequency := 0, 
           current   := DllCall("QueryPerformanceFrequency", "Int64*", &frequency)
    
    _result := !DllCall("QueryPerformanceCounter", "Int64*", &current)
    if _start {
        previous := current
        _result += current / frequency
    } else {
        _result += (current - previous) / frequency
    }
    
    return _result * 1000  ; seconds to milliseconds
}

Приницип прост: Timer(1) запускает таймер, а Timer(0) - останаливает и возвращает время в миллисекундах. Измерим время обработки всей справки и сохраним в файл:

PrintHelp() {
	; ...
	Timer(1)
    synopsis := synopsis
      .Color('[\-]+[\-\w]+', 'cyan')	; switches
      
    examples := examples
      .Color('(mainDir|AhkDir)(?=\=)', 'cyan')	 ; variables names
      .Color('%[^%]+%',                'blue')	 ; variables values
      .Color('@(file|list\.ini)',      'yellow') ; list
    
    Message(usage . synopsis . examples)
    time := Timer(0)
    
    FileAppend(
        Format('Color tags, multiple SetConsoleTextAttribute() calls: {:.4f}ms`n', time), 
        'C:\Temp\launcher_bench.log'
    )

Так как вывод в консоль и обработка аргументов возможна только в исполнямых файлах, сначала соберем наш launcher.ahk в launcher.exe и сохраним его в директорию v1. Выполним &"C:\Temp\v1\launcher.exe" -help в терминале и увидим в файле время 77.6859ms.

Довольно неплохо, хотя и видно как в терминале текст появляется постепенно, по частям. Можем ли мы оптимизировать наш код?

Производительное решение

ANSI коды

Как я писал выше, самое медленное здесь - связка SetConsoleTextAttribute и FileAppend. Было бы здорово оставить только один вызов FileAppend, но тогда мы должны выполнить пре-процессинг сообщения самостоятельно, без вызова SetConsoleTextAttribute.

Мне удалось выяснить, что SetConsoleTextAttribute добавляет специальные коды в сообщение при выводе в терминал. И хотя исторически за ними сохранилось название ANSI codes, на самом деле они представляют собой расширение для текущей кодировки (обычно Windows-1252).

Причем сама функция довольно ограничена, ведь число этих кодов (и способов кодирования) гораздо больше. Более того, мой эмулятор Cmder, как и многие другие хорошие эмуляторы терминала способны обрабатывать эти коды выводить 256+ цветов. Не уверен, что Windows Terminal на это способен.

Таким образом, теперь наш алгоритм должен сам добавлять ANSI коды в полученный на вход текст. Для простоты мы ограничимся однобайтовым набором символов. Они имеют следующий вид: \e[0;31m - красный, \e[0;34m - синий, и т.д. И хотя есть также “стилизация” текста, например \e[1;31m - жирный красный или \e[0;41m - красный фон, для простоты мы будем рассматривать только цвета текста без стилизации.

Для окрашивания сообщения нам достаточно “обернуть” найденные фрагменты текста в соответствующе коды согласно найденным цветовым тегам: \e[1;31mmessage\e[0m где \e[0m - “завершающий” код, после которого текст приобретает цвет по-умолчанию (зависит от настроек эмулятора, но обычно - белый).

В таком случае перепишем наш алгоритм так, чтобы он добавлял необходимые коды. Каждому коду дадим понятное имя, которое и будем ожидать на входе в качестве параметра color:

Color(text, color := 'white') {
    ; Словарь/HashMap который сопоставляет цвету ANSI код.
    ; статич.: инициализируется один раз при загрузке скрипта,
    ; уменьшает время исполнения
    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,
    )
    ; Default color for text
    normalColor := colors.Get(color, 37)
    
    static esc := Chr(27)     ; ASCI escape character \e
    static end := esc '[0m'   ; Прекратить обработку цвета: \e[0m 
    begin  := esc '[0;' normalColor 'm'  ; Начать обработку цвета, код вроде \e[0;37m 
            
    clrText := ''  ; итоговое цветное сообщение
    pos := 1       ; начало сообщения
    
    while (pos <= text.length) {
        ; Ищем все цветовые теги начиная с `pos`, 
        ; сохраняем результат в переменной `match`
        if (RegExMatch(text, 's)<(\w+)>(.*?)</\1>', &match, pos)) {
            ; Нормальный текст до цветового участка
            ; (цвет зависит от уровня рекурсии)
            clrText .= begin . text.Slice(pos, match.pos - pos) . end
            
            ; Обрабатываем вложенные теги
            ; Рекурсивный вызов:
            ; группа 2 = сообщение, 
            ; группа 1 = цвет.
            clrText .= Output(match[2], match[1])
            
            ; Движемся вперед
            pos := match.pos + match.len
        } else {
            ; Выводим оставшийся текст
            clrText .= begin . text.Slice(pos) . end
            break
        }
    }
    
    return clrText
}

Print(msg, icon := '') {
    if IsConsole
        return FileAppend(msg '`n', 'CONOUT$')
   
    msg := RegExReplace(msg, 's)<(\w+)>(.*?)</\1>', '$2')
    return MsgBox(msg, A_ScriptName, icon)
}

Код стал значительно проще. Вместо двух независимых алгоритмов подсветки и парсинга текста мы получили один небольшой алгоритм в функции Color() и маленький алгоритм в функции Print(), который только выводит готовое сообщение одним вызовом FileAppend().

Выполним &"C:\Temp\v2\launcher.exe" -help и увидим время 3.5338ms. Ускорение в 21.9836 раз! Наглядный пример, почему много вызовов функций - это плохо.

Решение без тегов

Несмотря на удобный алгоритм у нас осталось несколько недостатков:

  1. Html теги неудобно писать руками в некоторых текстовых редакторах вроде Notepad++.

  2. Html теги удлинняют сообщение и замедляют парсинг.

Попробуем уменьшить количество символов в справке. Если вы знакомы с Markdown, то вы знаете символы форматирования текста: # __ `

При рендере .md документа они исчезают, чтобы сфокусировать внимание на тексте с помощью форматирования, а не с помощью доп. символов.

Вместо html тегов мы можем создать похожий синтаксис вроде #header#, magenta etc. который удобно парсить:

PrintHelp(*) {
	; ...
    msg := usage . synopsis . examples
    
    Timer(1)
    msg := 
      msg
        .Color('(@(file|list\.ini))',    'yellow')   ; list
        .Color('(mainDir|AhkDir)(?=\=)', 'purple')   ; variables names
        .Color('(%[^%]+%)',              'blue')     ; variables values
        .Color('(\-+[\-\w]+)(?=[ =])',   'cyan')     ; switches
        .Color('\*\*([^\*]+)\*\*',       'crimson')  
        .Color('__([^_]+)__',            'magenta')  
        .Color('(~)',                    'gray')
        .Color('(``)',                   'green') 
        .Color('(#)',                    'orange')
      .Print()
      
    time := Timer(0)
    
    FileAppend(
        Format('ANSI codes, multiple .Color() calls: {:.4f}ms`n', time), 
        'C:\Temp\launcher_bench.log'
    )
}

В таком коде мы ожидаем, что можно раскрасить текст внутри символов передав соответствующее выражение и ожидаемый цвет. Например, для синего текста, окруженного символами процента %, мы передаем выражение вроде (%[^%]+%).

Для поддержки таких ожиданий необходимо переписать Color():

Color(msg, regex, color) {   
    ; Словарь/HashMap который сопоставляет цвету спец. код
    ; статич.: инициализируется один раз при загрузке скрипта,
    ; уменьшает время исполнения 
    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)     ; ASCI escape character \e
    static end := esc '[0m'   ; Прекратить обработку цвета: \e[0m 
    begin := esc '[0;' colors.Get(color, 37) 'm'    ; Код текущего участка текста

    pos := 1
    len := msg.length
    clrMsg := ''
    
    while (pos <= len) {
        if !RegExMatch(msg, regex, &match, pos) {
            ; Remaining text
            clrMsg .= msg.Slice(pos)
            break
        }
        
        ; Текст перед цветным участком
        clrMsg .= msg.Slice(pos, match.pos - pos)
        ; Добавляем ANSI код
        clrMsg .= begin . match[1] . end
        ; Движемся дальше
        pos    := match.pos + match.len   
    }
    
    return clrMsg
}

Print(msg, icon := '') {
    if IsConsole
        return FileAppend(msg '`n', 'CONOUT$')
   
    msg := RegExReplace(str, 'U)' esc '\[\d+(;\d+)?m')
    return MsgBox(msg, A_ScriptName, icon)
}

; дополнительные методы для строк
({}.DefineProp)(String.prototype, 'Color',  {call: Color})
({}.DefineProp)(String.prototype, 'Print',  {call: Print})

Теперь мы последовательно ищем каждый шаблон с помощью цикла, и благодаря добавленному методу Print() можем создавать цепочки вызовов: "My colorful message".Color("message", "cyan").Print()

Выполним &"C:\Temp\v3\launcher.exe" -help в терминале и увидим время 2.5428ms. Весьма неплохо! Алгоритм из рекурсивного стал итеративный, и читабельность чуть-чуть улучшилась.

Исходный код алгоритма вывода цветного текста доступен на GitHub. А здесь вы можете прочитать про некоторые дополнительные возможности этого алгоритма.

Вывод

Первое решение далеко не всегда самое правильное. Конечно, если вы пишите маленький AutoHotkey скрипт, скорость написания зачастую имеет значение. А вот если вы пишите полноценные проекты, которые выполняют большие и сложные задачи, разумно попытаться “притормозить” и попытаться сделать кодовую базу более читабельной, а используемые алгоритмы - более производительными.

В данной статье мы увидели, что AutoHotkey вовсе не примитивный язык для написания макросов и переназначения клавиш. Это полноценный язык программирования, на котором можно писать алгоритмы и собственный синтаксический сахар.

В следующей статье мы попробуем еще больше оптимизировать полученный алгоритм, рассмотрим вложенные цвета и некоторые интересные возможности регулярных выражений. Заглядывайте на мой GitHub, если вы хотите увидеть возможности языка на практике.