Tree-sitter and Preprocessing: A Syntax Showdown

WILD

Administrator
Staff member
ADMIN
SELLER
SUPREME
MEMBER
Joined
Jan 21, 2025
Messages
219
Reaction score
636
Deposit
0$
Согласно описанию ,



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: (числовой_литерал)
int a = 3; // (объявление)
#else // альтернатива: (preproc_else)
int b = 3; // (объявление)))
#endif //

int main(void) { // (тело определения функции: (составное утверждение)
#if 9 // (условие preproc_if: (числовой_литерал)
int a = 3; // (объявление)
#else // альтернатива: (preproc_else)
int b = 3; // (объявление)))
#endif //
} // ))

структура { // (struct_specifier body: (field_declaration_list)
#if 9 // (условие preproc_if: (числовой_литерал)
int a; // (field_declaration)
#else // альтернатива: (preproc_else)
int b; // (field_declaration)))
#endif //
}; // ))

enum { // (enum_specifier body: (enumerator_list
#if 9 // (условие preproc_if: (числовой_литерал)
a = 2, // (перечислитель)
#else // альтернатива: (preproc_else)
b = 3, // (перечислитель)))
#endif //
}; // ))




Однако небольшое изменение в последнем примере может привести к сбою tree-sitter-c:


enum { // (enum_specifier body: (enumerator_list
#if 9 // (условие preproc_if: (числовой_литерал)
a = 2, // (перечислитель)
#else // альтернатива: (preproc_else)
b = 3 // (ОШИБКА (перечислитель)))
#endif //
}; // ))




В совершенно корректном фрагменте кода на языке C без завершающей запятой действуют разные грамматические правила для разных ветвей директивы препроцессора: перечислитель с запятой и без нее.



Более сложный пример:


int a = // (ОШИБКА)
#if 1 // (условие preproc_if: (числовой_литерал)
3 // (ОШИБКА (число_буквенное))
#else // альтернатива: (preproc_else)
4 // (выражение_оператор (числовой_литерал)
#endif // (ОШИБКА))))
; //



В этом случае tree-sitter-c даже не может корректно обработать директиву #else:


int a // (объявление)
#if 1 // (условие preproc_if: (числовой_литерал)
= 3 // (ОШИБКА (числовой_литерал)
#еще // )
= 4 // (выражение_оператор (числовой_литерал)
#endif // (ОШИБКА)
; // )))




В то время как результат подстановки с помощью директивы #if можно предсказать на основе исходного кода, результат подстановки с помощью директивы #include совершенно непредсказуем для парсера. Тем не менее, в грамматиках C и C++ директива #include разрешена только в глобальной области видимости и внутри блоков.


#include "a" // (preproc_include path: (string_literal))
int main(void) { // (тело определения функции: (составное утверждение)
#include "b" // (preproc_include path: (string_literal))
} // ))
int a = // (declaration (init_declarator
#include "c" // (ОШИБКА) значение: (string_literal)
; // ))




C#​



В 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 = // (объявление_переменной)
#if 1 // (условие preproc_if: (целочисленный_литерал)
3 // (целочисленный_литерал)
#else // альтернатива: (preproc_else)
4 // (целочисленный_литерал))))))
#endif //
; //




Однако это нарушает работу примера с перечислением в tree-sitter-c:


enum A { // (enum_declaration body: (enum_member_declaration_list
#if 9 // (условие preproc_if: (целочисленный_литерал)
a = 2, // (enum_member_declaration) (ERROR)
#else // альтернатива: (preproc_else)
b = 3, // (enum_member_declaration) (ERROR)))
#endif //
}; // ))

enum A { // (enum_declaration body: (enum_member_declaration_list
#if 9 // (условие preproc_if: (целочисленный_литерал)
a = 2, // (enum_member_declaration) (ERROR)
#else // альтернатива: (preproc_else)
b = 3 // (enum_member_declaration)))
#endif //
}; // ))




В данном случае узлам ошибок соответствуют только запятые, поэтому успешная попытка засчитывается.



Тем не менее, более сложные правила, такие как операторы, по-прежнему не учитываются:


int a // (ERROR (variable_declaration)
#if 1 // (условие preproc_if: (целочисленный_литерал)
= 3 // (ОШИБКА) (целочисленный_литерал)
#else // альтернатива: (preproc_else)
= 4 // (ОШИБКА) (целочисленный_литерал))
#endif // ))
; // (пустое_заявление)




Другие директивы​



Отличительной особенностью грамматики C# является интерпретация других директив препроцессора. В Tree-sitter есть поле в грамматике, называемое extras, которое позволяет помечать специальные правила, которые могут встречаться где угодно. Обычно этот список включает пробелы и комментарии. Грамматику можно значительно упростить, добавив директивы в этот список:


дополнительные материалы: $ => [
/[\s\u00A0\uFEFF\u3000]+/,
$.comment,
$.preproc_region,
$.preproc_endregion,
$.preproc_line,
$.preproc_pragma,
$.preproc_nullable,
$.preproc_error,
$.preproc_define,
$.preproc_undef,
],




Таким образом, эти директивы по-прежнему включены в синтаксическое дерево и участвуют в подсветке синтаксиса, но не влияют на другие правила.


int a // (объявление_переменной (объявление_переменной)
#pragma warning disable warning-list // (preproc_pragma)
= 3 // (целочисленный_литерал)
#pragma warning restore warning-list // (preproc_pragma)
; // ))




Несмотря на небольшую ошибку в правиле preproc_pragma, все остальное было интерпретировано корректно.



До этого запроса на слияние директива #if также находилась в разделе extras, что позволяло обрабатывать файлы с меньшим количеством ошибок.



Заключение​



В целом, грамматики для C/C++ и C# работают довольно хорошо, и благодаря устойчивости Tree-sitter к ошибкам, недопустимые конструкции не влияют на разбор последующего текста. Ошибки разбора действительно можно заметить по некорректной подсветке синтаксиса или сбоям в работе других функций редактора, реализованных в Tree-sitter, но при использовании языкового сервера подсветку можно немного улучшить с помощью семантических токенов . Например, clangd помечает отсутствующие ветви #if как комментарии:



семантические токены




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