Грамматики TextMate

Описываем синтаксис регулярками.

2022.07.19

Грамматики TextMate

TextMate — хороший, но вытесненный конкурентами текстовый редактор под MacOS. Особо он выделился тем, что предоставил “очень хороший” инструмент для подсветки синтаксиса.

Wiki: >TextMate language grammars allows users to create their own arbitrarily complex syntax highlighting modes by assigning each document keyword a unique name.

Формат textmate-грамматик (или их принцип построения) поддерживают так же Atom, vscode и Sublime Text. При чём, в vscode это единственный способ задания подсветки синтаксиса (не считая semantic highlight). И способ невероятно отвратительный.

Как в vscode задаются textmate-грамматики? Конечно же огромным JSON-ом или YAML-ом из регулярок. Посмотрим описание синтаксиса языка C из better-cpp-syntax:

    ...335 строк
    {
      "match": "\\b(u_char|u_short|u_int|u_long|ushort|uint|...)\\b",
      "name": "support.type.sys-types.c"
    },
    {
      "match": "\\b(pthread_attr_t|pthread_cond_t|...",
      "name": "support.type.pthread.c"
    },
    {
      "match": "(?x) \\b\n(int8_t|int16_t|int32_t|....",
      "name": "support.type.stdint.c"
    },
    {
      "match": "\\b(noErr|kNilOptions|kInvalidID|kVariableLengthArray)\\b",
      "name": "support.constant.mac-classic.c"
    },
    {
      "match": "(?x) \\b\n(AbsoluteTime|Boolean|Byte|....",
      "name": "support.type.mac-classic.c"
    },
    ...ещё 3000 строк

Наверняка в такое количество строк можно уложить простенький компилятор этого же C.

Как описываются грамматики

Грамматики описываются набором из правил примерно такого вида:

{
    "name": "comment.line",
    "begin": "//",
    "end": "$"
},

Каждое правило матчит некоторую область (scope), в примере выше - от вхождения // и до конца строки, и вешает на них указанное имя comment.line. Эти имена используются редактором для подкраски редактируемого текста.

Выглядит безобидно и даже удобно, но это регулярки, а регулярки читать невозможно:

"match": "^\\s*(package)\\b\\s*\\b([a-zA-Z_][a-zA-Z_0-9+]*)\\b",

Да, я психически здоровый человек, дайте мне пожалуйста \\b\\s*\\b([a-zA-Z_][a-zA-Z_0-9+]*)\\b, спасибо.

Для выполнения регулярок используется oniguruma. Это очень полезная библиотека, которая работает с кучей кодировок и реализует множество расширений регулярок. С непривычки такие регулярки очень тяжко писать и читать, см. документацию. Как минимум, она притаскивает нам negative look-behind/ahead, что очень полезно для описания синтаксиса.

Вложенные скобочки

Возьмём относительно сложный пример:

def {
    ...
    def {
        ...
    }
}

Хотим разобрать конструкцию из вложенных def {} с учётом скобочек, рассчитывая на такой результат:

def {           // definition
    ...         // definition > body
    def {       // definition > body > definition
        ...     // definition > body > definition > body
    }           // definition > body > definition
    ...         // definition > body
}               // definition

definition начинается с def и заканчивается }. body начинается с { и заканчивается тем же самым }. Проблема: если body заматчит и “съест” }, definition не сможет закончиться и будет продолжаться до самого конца файла. Чтобы обойти это, приходится пользоваться тем самым negative look-behind и добавлять дополнительные скопы:

{
    "name": "definition",
    "begin": "^\\s*(def)\\b",
    "end": "(?<=\\})",
    "beginCaptures": {
    "1": {
        ...
    }
    },
    "patterns": [
    {
        "name": "tail",
        "begin": ".",
        "end": "(?<=\\})",
        "patterns": [
            {
                "name": "body",
                "begin": "\\{",
                "end": "\\}",
                "patterns": [
                    ... // include definition, чтобы была вложенность
                ]
            }
        ]
    }
    ]
}

Получаем примерно такую разметку:

def {           // definition > tail
    ...         // definition > tail > body
    def {       // definition > tail > body > definition > tail
        ...     // definition > tail > body > definition > tail > body
    }           // definition > tail > body > definition > tail
    ...         // definition > tail > body
}               // definition > tail

Излишне, но работает.

Повторения правил

При описании грамматики часто потребуется использовать одни и те же правила. Например, для поддержки комментариев в примере выше, надо в каждый patterns вставить правило для их матча. В textmate-грамматиках для этого есть репозиторий и возможность призывать оттуда правила.

"patterns": [
    {
        "include": "#comments"
    },
    {
        "include": "#stringLiterals"
    },
    {
        "include": "#numberLiterals"
    }
]

Но и этого не всегда достаточно. Во многих правилах гарантированно будут переиспользоваться конструкции, например для матча имени переменной:

"match": "^\\s*([a-zA-Z_][a-zA-Z_0-9+]*)\\b\\s*:\\s*\\b([a-zA-Z_][a-zA-Z_0-9+]*)\\b\\s*",

Эту проблему textmate никак не решает, поэтому все адекватные люди (насколько адекватным может быть человек после таких регулярок?) генерируют грамматику скриптами.

Кодогенерация

В вышеупомянутом better-cpp-syntax никто не писал вручную файл на 3000 строк, а всё генерируется набором скриптов. Они тоже страшные, но более читаемые, легче правятся и позволяют удобно переиспользовать куски правил и регулярок:

Pattern.new(
    match: lookBehindToAvoid(/:/).then(/:/).lookAheadToAvoid(/:/),
    tag_as: "punctuation.separator.colon.range-based"
),

Ещё удобно описывать грамматику в JS - получаем похожие на JSON структуры со всеми удобствами скриптов:

patterns: [
    // подставим общие для всех правила из массива
    ...common_patterns,
    {
        name: `meta.component`,
        // * за счёт raw-литералов (r`...`) не надо экранировать \
        // * можем вызвать кусок регуярки из переменной
        match: r`\b(component)\b\s*\b(${matchWord})\b`,
        captures: {
            ...
        }
    },
]

Ещё один пример кодогенерации из самого vscode:

const languages = [
    { name: 'css', language: 'css', identifiers: ['css', 'css.erb'], source: 'source.css' },
    { name: 'basic', language: 'html', identifiers: ['html', 'htm', 'shtml', 'xhtml', 'inc', 'tmpl', 'tpl'], source: 'text.html.basic' },
    ...
];
const fencedCodeBlockDefinitions = () =>
    languages
        .map(language => fencedCodeBlockDefinition(language.name, language.identifiers, language.source, language.language, language.additionalContentName))
        .join('\n');

Здесь генерируется куча правил матча fenced code block из markdown с указанием синтаксиса, что в итоге раскрывается в лапшу на пару сотен строк.

Ограничения

Каждое правило textmate-грамматики матчится только на одну строчку кода. То есть, нельзя просто задать правилу зависимость от последующих строк, придётся извращаться с областями, метками и вложением.

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

Инструменты

Для дебага грамматик vscode даёт полезный, но не слишком удобный инструмент. В панели команд можно вызывать Developer: Inspect editor tokens and scopes, который будет показывать для активной строки дебажную инфу: Developer: Inspect editor tokens and scopes

Так же есть дебажные логи того, чем занимается токенайзер, но они почти полностью бесполезны.

Выводы

Из всего изложенного выше можно извлечь то, что textmate-грамматики нужно использовать в трёх случаях:

Серьёзно, не надо, но иногда выхода нет. Тот же vscode поддерживает только их и упомянутую semantic highlight.

Из возможных альтернатив есть tree-sitter, но issue на его поддержку в vscode висит уже пятый год. Есть расширение syntax-highlighter, которое вроде бы уже делает это, но я не проверял.