Я на дереве сижу, препроцессинг провожу

от автора

Согласно описанию,

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 как коммантарии:

semantic tokens

Можно даже сказать, что Tree-sitter в каком-то смысле наказывает за чрезмерное использование препроцессора. Мне лично больше симпатизирует подход с помещением правил директив в extras. В следующей статье я расскажу, как решил проблему препроцессинга при написании грамматики для FastBuild, используя этот подход.

Ссылки


ссылка на оригинал статьи https://habr.com/ru/articles/835166/