Описываем синтаксис регулярками.
2022.07.19
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.
Грамматики описываются набором из правил примерно такого вида:
Каждое правило матчит некоторую область (scope
), в примере выше - от вхождения //
и до конца строки, и вешает на них указанное имя comment.line
. Эти имена используются редактором для подкраски редактируемого текста.
Выглядит безобидно и даже удобно, но это регулярки, а регулярки читать невозможно:
Да, я психически здоровый человек, дайте мне пожалуйста
\\b\\s*\\b([a-zA-Z_][a-zA-Z_0-9+]*)\\b
, спасибо.
Для выполнения регулярок используется oniguruma. Это очень полезная библиотека, которая работает с кучей кодировок и реализует множество расширений регулярок. С непривычки такие регулярки очень тяжко писать и читать, см. документацию. Как минимум, она притаскивает нам negative look-behind/ahead, что очень полезно для описания синтаксиса.
Возьмём относительно сложный пример:
Хотим разобрать конструкцию из вложенных 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"
}
]
Но и этого не всегда достаточно. Во многих правилах гарантированно будут переиспользоваться конструкции, например для матча имени переменной:
Эту проблему 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
, который будет показывать для активной строки дебажную инфу:
Так же есть дебажные логи того, чем занимается токенайзер, но они почти полностью бесполезны.
Из всего изложенного выше можно извлечь то, что textmate-грамматики нужно использовать в трёх случаях:
Серьёзно, не надо, но иногда выхода нет. Тот же vscode поддерживает только их и упомянутую semantic highlight.
Из возможных альтернатив есть tree-sitter, но issue на его поддержку в vscode висит уже пятый год. Есть расширение syntax-highlighter, которое вроде бы уже делает это, но я не проверял.