Согласно описанию,
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, используя этот подход.
Ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/835166/
Добавить комментарий