Согласно описанию,
Tree-sitter — это инструмент для генерации синтаксических анализаторов и библиотека инкрементного синтаксического анализа. Он может создавать конкретное синтаксическое дерево для исходного файла и эффективно обновлять синтаксическое дерево по мере редактирования исходного файла.
Но как Tree-sitter справляется с языками, в которых необходима стадия препроцессинга?
Так как препроцессор влияет на текстовое содержание, его очень сложно вписать в грамматику языка. Поэтому приходится придумывать, как с наименьшими потерями реализовать поддержку препроцессора, не проводя препроцессинг.
C/С++
tree-sitter-cpp наследует tree-sitter-c и не меняет правила для препроцессорных директив. В tree-sitter-c поступили принципиальным образом: парсер должен учитывать препроцессор как полноценную часть грамматики. Но любая директива препроцессора, модифицирующая текст (#if
, #include
) может появиться в середине грамматического правила и поменять его на абсолютно другое. Поэтому, для полной поддержки #if
в единой грамматике необходимо сгенерировать уникальное правило директивы препроцессора для любой возможной комбинации правил. Это можно сделать, используя один из плюсов Tree-sitter: скриптабельность через JavaScript. В данном парсере ограничились только четырьмя случаями:
...preprocIf('', $ => $._block_item),
...preprocIf('_in_field_declaration_list', $ => $._field_declaration_list_item),
...preprocIf('_in_enumerator_list', $ => seq($.enumerator, ',')),
...preprocIf('_in_enumerator_list_no_comma', $ => $.enumerator, -1),
Правило preproc_if
используется в правилах для выражений внутри блоков и глобальной области. Правила preproc_if_in_enumerator_list
и preproc_if_in_enumerator_list_no_comma
встречаются в списках перечисления, а preproc_if_in_field_declaration_list
, как вы уже успели догадаться, в структурах, объединениях и классах.
Такой набор правил успешно справляется с примитивными примерами:
#if 9 // (preproc_if condition: (number_literal)
int a = 3; // (declaration)
#else // alternative: (preproc_else
int b = 3; // (declaration)))
#endif //
int main(void) { // (function_definition body: (compound_statement
#if 9 // (preproc_if condition: (number_literal)
int a = 3; // (declaration)
#else // alternative: (preproc_else
int b = 3; // (declaration)))
#endif //
} // ))
struct { // (struct_specifier body: (field_declaration_list
#if 9 // (preproc_if condition: (number_literal)
int a; // (field_declaration)
#else // alternative: (preproc_else
int b; // (field_declaration)))
#endif //
}; // ))
enum { // (enum_specifier body: (enumerator_list
#if 9 // (preproc_if condition: (number_literal)
a = 2, // (enumerator)
#else // alternative: (preproc_else
b = 3, // (enumerator)))
#endif //
}; // ))
Но уже в последнем примере можно сделать небольшое изменение, которое поставит tree-sitter-c в тупик:
enum { // (enum_specifier body: (enumerator_list
#if 9 // (preproc_if condition: (number_literal)
a = 2, // (enumerator)
#else // alternative: (preproc_else)
b = 3 // (ERROR (enumerator)))
#endif //
}; // ))
Абсолютно валидный код на C без завершающей запятой содержит разные грамматические правила по разным веткам препроцессорной директивы: элемент перечисления с запятой и без.
Более сложный пример:
int a = // (ERROR)
#if 1 // (preproc_if condition: (number_literal)
3 // (ERROR (number_literal))
#else // alternative: (preproc_else
4 // (expression_statement (number_literal)
#endif // (ERROR))))
; //
А в таком случае tree-sitter-c не может даже корректно обработать #else
:
int a // (declaration)
#if 1 // (preproc_if condition: (number_literal)
= 3 // (ERROR (number_literal)
#else // )
= 4 // (expression_statement (number_literal)
#endif // (ERROR)
; // )))
Если результат подстановки #if
можно предсказать на основании исходного кода, результат подстановки #include
абсолютно непредсказуем для парсера. Тем не менее, в грамматиках для C и C++ директива #include
разрешается только в глобальной области и внутри блоков.
#include "a" // (preproc_include path: (string_literal))
int main(void) { // (function_definition body: (compound_statement
#include "b" // (preproc_include path: (string_literal))
} // ))
int a = // (declaration (init_declarator
#include "c" // (ERROR) value: (string_literal)
; // ))
Csharp
В tree-sitter-c-sharp поступили так же, но чуть больше разнообразили контекст:
...preprocIf('', $ => $.declaration),
...preprocIf('_in_top_level', $ => choice($._top_level_item_no_statement, $.statement)),
...preprocIf('_in_expression', $ => $.expression, -2, false),
...preprocIf('_in_enum_member_declaration', $ => $.enum_member_declaration, 0, false),
Что позволяет распарсить такой пример, благодаря специальному правилу для директивы препроцессора внутри выражений:
int a = // (variable_declaration
#if 1 // (preproc_if condition: (integer_literal)
3 // (integer_literal)
#else // alternative: (preproc_else
4 // (integer_literal))))))
#endif //
; //
Но ломает работающий в tree-sitter-c пример с перечислением:
enum A { // (enum_declaration body: (enum_member_declaration_list
#if 9 // (preproc_if condition: (integer_literal)
a = 2, // (enum_member_declaration) (ERROR)
#else // alternative: (preproc_else
b = 3, // (enum_member_declaration) (ERROR)))
#endif //
}; // ))
enum A { // (enum_declaration body: (enum_member_declaration_list
#if 9 // (preproc_if condition: (integer_literal)
a = 2, // (enum_member_declaration) (ERROR)
#else // alternative: (preproc_else
b = 3 // (enum_member_declaration)))
#endif //
}; // ))
Причём тут узлы ошибки соответствуют только запятым, поэтому засчитываем успешную попытку.
Тем не менее, более сложные правила типа операторов всё так же не учтены:
int a // (ERROR (variable_declaration)
#if 1 // (preproc_if condition: (integer_literal)
= 3 // (ERROR) (integer_literal)
#else // alternative: (preproc_else
= 4 // (ERROR) (integer_literal))
#endif // ))
; // (empty_statement)
Другие директивы
Чем отличилась грамматика для С#, так это интерпретацией остальных препроцессорных директив. В Tree-sitter существует поле грамматики extras
, которое позволяет помечать особенные правила, которые могут встречаться где угодно. Обычно в этот список добавляются пробелы и комментарии. Грамматику можно сильно упростить добавлением директив в этот список:
extras: $ => [
/[\s\u00A0\uFEFF\u3000]+/,
$.comment,
$.preproc_region,
$.preproc_endregion,
$.preproc_line,
$.preproc_pragma,
$.preproc_nullable,
$.preproc_error,
$.preproc_define,
$.preproc_undef,
],
Таким образом эти директивы всё равно включены в синтаксическое дерево и участвуют в подсветке синтаксиса, но никак не влияют на остальные правила.
int a // (variable_declaration (variable_declarator
#pragma warning disable warning-list // (preproc_pragma)
= 3 // (integer_literal)
#pragma warning restore warning-list // (preproc_pragma)
; // ))
Несмотря на небольшой баг в правиле preproc_pragma
, всё остальное было интерпретировано правильно.
До этого пулл-реквеста #if
тоже был в extras
, что позволяло распарсить файлы с меньшим количеством ошибок.
Резюме
В целом, грамматики для C/С++ и C# работают достаточно хорошо, а благодаря устойчивости Tree-sitter к ошибкам, невалидные конструкции не влияют на парсинг последующего текста. Ошибку парсинга, конечно, можно заметить по неправильной подсветке синтаксиса или неправильной работе других фич редактора, реализованных с помощью Tree-sitter, но при использовании языкового сервера подсветка может быть немного исправлена за счёт Semantic Tokens. Например, clangd помечает пропущенные ветки #if
как коммантарии:
Можно даже сказать, что Tree-sitter в каком-то смысле наказывает за чрезмерное использование препроцессора. Мне лично больше симпатизирует подход с помещением правил директив в extras
. В следующей статье я расскажу, как решил проблему препроцессинга при написании грамматики для FastBuild, используя этот подход.