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


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, используя этот подход.


Ссылки


Комментарии (0)