Под «шаблонами» в контексте C++ обычно понимаются вполне конкретные языковые конструкции. Есть простые шаблоны, которые упрощают работу с однотипным кодом — это шаблоны классов и функций. Если у шаблона какой-то из параметров сам по себе шаблон, то это уже, можно сказать, шаблоны второго порядка и генерируют они другие шаблоны в зависимости от своих параметров. Но что если и их возможностей недостаточно и проще генерировать сразу исходный текст? Много исходного текста?
Любителям Python а также HTML-вёрстки знакомо средство (движок, библиотека) для работы с текстовыми шаблонами под названием Jinja2. На вход этот движок получает файл-шаблон, в котором текст может быть перемешан с управляющими конструкциями, на выходе получается чистый текст, в котором все управляющие конструкции заменены текстом в соответствии с заданными извне (или изнутри) параметрами. Грубо говоря, это что-то вроде ASP-страниц (или C++-препроцессора), только язык разметки другой.
До сих пор реализация этого движка была только для Python. Теперь же она есть и для C++. О том, как и почему так вышло, и пойдёт речь в статье.
Действительно, а зачем? Ведь есть же Python, для него — отличная реализация, куча фичей, цельная спецификация на язык. Бери и пользуйся! Не нравится Python — можно взять Jinja2CppLight или inja, частичные порты Jinja2 на C++. Можно, в конце концов, взять C++-порт {{Mustache}}. Дьявол, как обычно, в деталях. Вот мне, скажем, понадобилась функциональность фильтров от Jinja2 и возможности конструкции extends, которая позволяет создавать расширяемые шаблоны (а ещё макросы и include, но это потом). И ни одна из упомянутых реализаций этого не поддерживает. Мог ли я обойтись без всего этого? Тоже хороший вопрос. Судите сами. Есть у меня проект, цель которого создание C++-to-C++ автогенератора boilerplate-кода. Этот автогенератор получает на вход, скажем, вручную написанный заголовочный файл со структурами или enum'ами и генерирует на его основе функции сериализации/десериализации или, скажем, конвертации элементов enum'ов в строки (и обратно). Подробнее об этой утилите можно послушать в моих докладах здесь (eng) или здесь (рус).
Так вот, типовая задача, решаемая в процессе работы над утилитой — это создание заголовочных файлов, у каждого из которых есть шапка (с ifdef'ами и include'ами), тело с основным содержимым и подвал. Причём основное содержимое — это сгенерированные декларации, распиханные по namespace'ам. В исполнении на C++ код создания такого заголовочного файла выглядит примерно так (и это ещё не всё):
Причём код этот мало меняется от файла к файлу. Разумеется, для форматирования можно использовать clang-format. Но это не отменяет остальной ручной работы по генерации исходного текста.
И вот в один прекрасный момент я понял, что жизнь себе надо упрощать. Вариант с прикручиванием полноценного скриптового языка я не рассматривал из-за сложности поддержки итогового результата. А вот найти подходящий движок шаблонов — а почему бы и нет? Полез искать, нашёл, потом нашёл спецификацию на Jinja2 и понял, что это — именно то, что мне надо. Ибо в соответствии с этой спекой шаблоны для генерации заголовков выглядели бы так:
Отсюда.
Была только одна проблема: ни один из найденных мною движков не поддерживал всего набора нужных мне фичей. Ну и, разумеется, каждый имел стандартный фатальный недостаток. Я подумал немного и решил, что ещё от одной реализации шаблонизатора миру сильно хуже не станет. Тем более что, по прикидкам, базовый функционал было реализовать не то чтобы сильно сложно. Ведь теперь в C++ есть regexp'ы!
И так появился проект Jinja2Cpp. На счёт сложности реализации базового (совсем базового) функционала я почти угадал. В целом же — промахнулся аккурат на коэффициент Пи в квадрате: на написание всего нужного мне ушло чуть меньше трёх месяцев. Но когда всё было дописано, допилено и вставлено в “Автопрограммист” — я понял, что старался не зря. Фактически, утилита по генерации кода получила мощный скриптовый язык совмещённый с шаблонами, что открыло перед ней совершенно новые возможности для развития.
NB: У меня была мысль прикрутить Python (или Lua). Но никакой из существующих полноценных скриптовых движков не решает «из коробки» вопросы по генерации текста из шаблонов. То есть к Python пришлось бы всё равно прикручивать ту же Jinja2, а для Lua искать что-то своё. Зачем мне нужно было это лишнее звено?
Идея структуры Jinja2-шаблонов довольно проста. Если в тексте встречается что-то, заключенное в пару "{{" / "}}" — то это «что-то» — выражение, которое должно быть вычислено, преобразовано в текстовое представление и вставлено в итоговый результат. Внутри пары "{%" / "%}" — операторы типа for, if, set и т. п. Ну а в "{#" / "#}" — комментарии. Изучив реализацию Jinja2CppLight, я решил, что пытаться вручную найти в тексте шаблона все эти управляющие конструкции — не очень удачная идея. Поэтому я вооружился довольно простым regexp'ом: ((\{\{)|(\}\})|(\{%)|(%\})|(\{#)|(#\})|(\n)), с помощью которого побил текст на нужные фрагменты. И назвал это грубой фазой парсинга. На начальном этапе работы идея показала свою эффективность (да, собственно, и до сих пор показывает), но, по-хорошему, в будущем её надо будет отрефакторить, так как сейчас на текст шаблона накладываются незначительные ограничения: экранирование пар "{{" и "}}" в тексте обрабатывается слишком «в лоб».
Во второй фазе детально парсится только то, что оказалось внутри «скобок». И вот тут пришлось повозиться. Что в inja, что в Jinja2CppLight, парсер выражений довольно простой. В первом случае — на тех же regexp'ах, во втором — рукописный, но поддерживающий только очень простые конструкции. О поддержке фильтров, тестеров, сложной арифметики или индексирования там речи даже не идёт. А именно этих возможностей Jinja2 мне и хотелось больше всего. Поэтому у меня не оставалось другого выхода, как напедалить полноценный LL(1)-парсер (местами — контекстно-зависимый), реализующий необходимую грамматику. Лет десять-пятнадцать назад я бы, наверное, взял бы для этого Bison или ANTLR и реализовал бы парсер с их помощью. Лет семь бы назад я бы попробовал Boost.Spirit. Сейчас же я просто реализовал нужный мне парсер, работающий методом рекурсивного спуска, без порождения лишних зависимостей и значительного увеличения времени компиляции, как это случилось бы в случае использования внешних утилит или Boost.Spirit. На выходе парсера я получаю AST (для выражений или для операторов), которое и сохраняется как шаблон, готовый для последующего рендеринга.
Узлы AST имеют привязку только к тексту шаблона и преобразуются в итоговые значения в момент рендеринга с учётом текущего контекста рендеринга и его параметров. Это позволило сделать шаблоны thread-safe. Но подробнее об этом в части, касающейся собственно рендеринга.
В качестве первичного токенайзера я выбрал библиотеку lexertk. Она имеет нужную мне лицензию и header-only. Пришлось, правда, отрезать от неё все навороты по подсчёту баланса скобок и прочее и оставить только собственно токенайзер, который (после небольшой рихтовки напильником) научился работать не только с char, но и с wchar_t-символами. Сверху этот токенайзер был мною обёрнут ещё одним классом, выполняющим три основных функции: а) абстрагирует код парсера от типа символов, с которыми ведётся работа, б) распознаёт ключевые слова, специфичные для Jinja2 и в) предоставляет удобный интерфейс для работы с потоком токенов:
Таким образом, несмотря на то, что движок может работать как с char, так и с wchar_t-шаблонами, основной код разбора от типа символа не зависит. Но подробнее об этом — в разделе, посвящённом приключениям с типом символов.
Отдельно пришлось повозиться с управляющими конструкциями. В Jinja2 многие из них — парные. Например, for/endfor, if/endif, block/endblock и т. п. Каждый элемент пары идёт в своих «скобках», а между элементами может быть куча всего: и просто текст, и другие управляющие блоки. Поэтому алгоритм разбора шаблона пришлось делать на основе стека, к текущему верхнему элементу которого «цепляются» все вновь найденные конструкции и инструкции, а также фрагменты простого текста между ними. Посредством этого же стека проверяется отсутствие разбалансировки типа if-for-endif-endfor. В результате всего этого код получился не такой «компактный» как, скажем, у Jinja2CppLight (или inja), где вся реализация — в одном исходнике (или заголовке). Но логика разбора и, собственно, грамматика в коде видны более явно, что упрощает его поддержку и расширение. По крайней мере именно к этому я стремился. Минимизировать количество зависимостей или объём кода всё равно не получится, значит надо делать его более понятным.
В следующей части речь пойдёт про процесс рендеринга шаблонов, а пока — ссылки:
Спецификация Jinja2: http://jinja.pocoo.org/docs/2.10/templates/
Реализация Jinja2Cpp: https://github.com/flexferrum/Jinja2Cpp
Реализация Jinja2CppLight: https://github.com/hughperkins/Jinja2CppLight
Реализация inja: https://github.com/pantor/inja
Утилита для генерации кода на основе шаблонов Jinja2: https://github.com/flexferrum/autoprogrammer/tree/jinja2cpp_refactor
Любителям Python а также HTML-вёрстки знакомо средство (движок, библиотека) для работы с текстовыми шаблонами под названием Jinja2. На вход этот движок получает файл-шаблон, в котором текст может быть перемешан с управляющими конструкциями, на выходе получается чистый текст, в котором все управляющие конструкции заменены текстом в соответствии с заданными извне (или изнутри) параметрами. Грубо говоря, это что-то вроде ASP-страниц (или C++-препроцессора), только язык разметки другой.
До сих пор реализация этого движка была только для Python. Теперь же она есть и для C++. О том, как и почему так вышло, и пойдёт речь в статье.
Зачем я вообще за это взялся
Действительно, а зачем? Ведь есть же Python, для него — отличная реализация, куча фичей, цельная спецификация на язык. Бери и пользуйся! Не нравится Python — можно взять Jinja2CppLight или inja, частичные порты Jinja2 на C++. Можно, в конце концов, взять C++-порт {{Mustache}}. Дьявол, как обычно, в деталях. Вот мне, скажем, понадобилась функциональность фильтров от Jinja2 и возможности конструкции extends, которая позволяет создавать расширяемые шаблоны (а ещё макросы и include, но это потом). И ни одна из упомянутых реализаций этого не поддерживает. Мог ли я обойтись без всего этого? Тоже хороший вопрос. Судите сами. Есть у меня проект, цель которого создание C++-to-C++ автогенератора boilerplate-кода. Этот автогенератор получает на вход, скажем, вручную написанный заголовочный файл со структурами или enum'ами и генерирует на его основе функции сериализации/десериализации или, скажем, конвертации элементов enum'ов в строки (и обратно). Подробнее об этой утилите можно послушать в моих докладах здесь (eng) или здесь (рус).
Так вот, типовая задача, решаемая в процессе работы над утилитой — это создание заголовочных файлов, у каждого из которых есть шапка (с ifdef'ами и include'ами), тело с основным содержимым и подвал. Причём основное содержимое — это сгенерированные декларации, распиханные по namespace'ам. В исполнении на C++ код создания такого заголовочного файла выглядит примерно так (и это ещё не всё):
Много C++-кода
Отсюда.
void Enum2StringGenerator::WriteHeaderContent(CppSourceStream &hdrOs)
{
std::vector<reflection::EnumInfoPtr> enums;
WriteNamespaceContents(hdrOs, m_namespaces.GetRootNamespace(), [this, &enums](CppSourceStream &os, reflection::NamespaceInfoPtr ns) {
for (auto& enumInfo : ns->enums)
{
WriteEnumToStringConversion(os, enumInfo);
WriteEnumFromStringConversion(os, enumInfo);
enums.push_back(enumInfo);
}
});
hdrOs << "\n\n";
{
out::BracedStreamScope flNs("\nnamespace flex_lib", "\n\n", 0);
hdrOs << out::new_line(1) << flNs;
for (reflection::EnumInfoPtr enumInfo : enums)
{
auto scopedParams = MakeScopedParams(hdrOs, enumInfo);
{
hdrOs << out::new_line(1) << "template<>";
out::BracedStreamScope body("inline const char* Enum2String($enumFullQualifiedName$ e)", "\n");
hdrOs << out::new_line(1) << body;
hdrOs << out::new_line(1) << "return $namespaceQual$::$enumName$ToString(e);";
}
{
hdrOs << out::new_line(1) << "template<>";
out::BracedStreamScope body("inline $enumFullQualifiedName$ String2Enum<$enumFullQualifiedName$>(const char* itemName)", "\n");
hdrOs << out::new_line(1) << body;
hdrOs << out::new_line(1) << "return $namespaceQual$::StringTo$enumName$(itemName);";
}
}
}
{
out::BracedStreamScope flNs("\nnamespace std", "\n\n", 0);
hdrOs << out::new_line(1) << flNs;
for (reflection::EnumInfoPtr enumInfo : enums)
{
auto scopedParams = MakeScopedParams(hdrOs, enumInfo);
out::BracedStreamScope body("inline std::string to_string($enumFullQualifiedName$ e)", "\n");
hdrOs << out::new_line(1) << body;
hdrOs << out::new_line(1) << "return $namespaceQual$::$enumName$ToString(e);";
}
}
}
// Enum item to string conversion writer
void Enum2StringGenerator::WriteEnumToStringConversion(CppSourceStream &hdrOs, const reflection::EnumInfoPtr &enumDescr)
{
auto scopedParams = MakeScopedParams(hdrOs, enumDescr);
out::BracedStreamScope fnScope("inline const char* $enumName$ToString($enumScopedName$ e)", "\n");
hdrOs << out::new_line(1) << fnScope;
{
out::BracedStreamScope switchScope("switch (e)", "\n");
hdrOs << out::new_line(1) << switchScope;
out::OutParams innerParams;
for (auto& i : enumDescr->items)
{
innerParams["itemName"] = i.itemName;
hdrOs << out::with_params(innerParams)
<< out::new_line(-1) << "case $prefix$$itemName$:"
<< out::new_line(1) << "return \"$itemName$\";";
}
}
hdrOs << out::new_line(1) << "return \"Unknown Item\";";
}
// String to enum conversion writer
void Enum2StringGenerator::WriteEnumFromStringConversion(CppSourceStream &hdrOs, const reflection::EnumInfoPtr &enumDescr)
{
auto params = MakeScopedParams(hdrOs, enumDescr);
out::BracedStreamScope fnScope("inline $enumScopedName$ StringTo$enumName$(const char* itemName)", "\n");
hdrOs << out::new_line(1) << fnScope;
{
out::BracedStreamScope itemsScope("static std::pair<const char*, $enumScopedName$> items[] = ", ";\n");
hdrOs << out::new_line(1) << itemsScope;
out::OutParams& innerParams = params.GetParams();
auto items = enumDescr->items;
std::sort(begin(items), end(items), [](auto& i1, auto& i2) {return i1.itemName < i2.itemName;});
for (auto& i : items)
{
innerParams["itemName"] = i.itemName;
hdrOs << out::with_params(innerParams) << out::new_line(1) << "{\"$itemName$\", $prefix$$itemName$},";
}
}
hdrOs << out::with_params(params.GetParams()) << R"(
$enumScopedName$ result;
if (!flex_lib::detail::String2Enum(itemName, items, result))
flex_lib::bad_enum_name::Throw(itemName, "$enumName$");
return result;)";
}
Отсюда.
Причём код этот мало меняется от файла к файлу. Разумеется, для форматирования можно использовать clang-format. Но это не отменяет остальной ручной работы по генерации исходного текста.
И вот в один прекрасный момент я понял, что жизнь себе надо упрощать. Вариант с прикручиванием полноценного скриптового языка я не рассматривал из-за сложности поддержки итогового результата. А вот найти подходящий движок шаблонов — а почему бы и нет? Полез искать, нашёл, потом нашёл спецификацию на Jinja2 и понял, что это — именно то, что мне надо. Ибо в соответствии с этой спекой шаблоны для генерации заголовков выглядели бы так:
{% extends "header_skeleton.j2tpl" %}
{% block generator_headers %}
#include <flex_lib/stringized_enum.h>
#include <algorithm>
#include <utility>
{% endblock %}
{% block namespaced_decls %}{{super()}}{% endblock %}
{% block namespace_content %}
{% for enum in ns.enums | sort(attribute="name") %}
{% set enumName = enum.name %}
{% set scopeSpec = enum.scopeSpecifier %}
{% set scopedName = scopeSpec ~ ('::' if scopeSpec) ~ enumName %}
{% set prefix = (scopedName + '::') if not enumInfo.isScoped else (scopedName ~ '::' ~ scopeSpec ~ ('::' if scopeSpec)) %}
inline const char* {{enumName}}ToString({{scopedName}} e)
{
switch (e)
{
{% for itemName in enum.items | map(attribute="itemName") | sort%}
case {{prefix}}{{itemName}}:
return "{{itemName}}";
{% endfor %}
}
return "Unknown Item";
}
inline {{scopedName}} StringTo{{enumName}}(const char* itemName)
{
static std::pair<const char*, {{scopedName}}> items[] = {
{% for itemName in enum.items | map(attribute="itemName") | sort %}
{"{{itemName}}", {{prefix}}{{itemName}} } {{',' if not loop.last }}
{% endfor %}
};
{{scopedName}} result;
if (!flex_lib::detail::String2Enum(itemName, items, result))
flex_lib::bad_enum_name::Throw(itemName, "{{enumName}}");
return result;
}
{% endfor %}{% endblock %}
{% block global_decls %}
{% for ns in [rootNamespace] recursive %}
{% for enum in ns.enums %}
template<>
inline const char* flex_lib::Enum2String({{enum.fullQualifiedName}} e)
{
return {{enum.namespaceQualifier}}::{{enum.name}}ToString(e);
}
template<>
inline {{enum.fullQualifiedName}} flex_lib::String2Enum<{{enum.fullQualifiedName}}>(const char* itemName)
{
return {{enum.namespaceQualifier}}::StringTo{{enum.name}}(itemName);
}
inline std::string to_string({{enum.fullQualifiedName}} e)
{
return {{enum.namespaceQualifier}}::{{enum.name}}ToString(e);
}
{% endfor %}
{{loop(ns.namespaces)}}
{% endfor %}
{% endblock %}
Отсюда.
Была только одна проблема: ни один из найденных мною движков не поддерживал всего набора нужных мне фичей. Ну и, разумеется, каждый имел стандартный фатальный недостаток. Я подумал немного и решил, что ещё от одной реализации шаблонизатора миру сильно хуже не станет. Тем более что, по прикидкам, базовый функционал было реализовать не то чтобы сильно сложно. Ведь теперь в C++ есть regexp'ы!
И так появился проект Jinja2Cpp. На счёт сложности реализации базового (совсем базового) функционала я почти угадал. В целом же — промахнулся аккурат на коэффициент Пи в квадрате: на написание всего нужного мне ушло чуть меньше трёх месяцев. Но когда всё было дописано, допилено и вставлено в “Автопрограммист” — я понял, что старался не зря. Фактически, утилита по генерации кода получила мощный скриптовый язык совмещённый с шаблонами, что открыло перед ней совершенно новые возможности для развития.
NB: У меня была мысль прикрутить Python (или Lua). Но никакой из существующих полноценных скриптовых движков не решает «из коробки» вопросы по генерации текста из шаблонов. То есть к Python пришлось бы всё равно прикручивать ту же Jinja2, а для Lua искать что-то своё. Зачем мне нужно было это лишнее звено?
Реализация парсера
Идея структуры Jinja2-шаблонов довольно проста. Если в тексте встречается что-то, заключенное в пару "{{" / "}}" — то это «что-то» — выражение, которое должно быть вычислено, преобразовано в текстовое представление и вставлено в итоговый результат. Внутри пары "{%" / "%}" — операторы типа for, if, set и т. п. Ну а в "{#" / "#}" — комментарии. Изучив реализацию Jinja2CppLight, я решил, что пытаться вручную найти в тексте шаблона все эти управляющие конструкции — не очень удачная идея. Поэтому я вооружился довольно простым regexp'ом: ((\{\{)|(\}\})|(\{%)|(%\})|(\{#)|(#\})|(\n)), с помощью которого побил текст на нужные фрагменты. И назвал это грубой фазой парсинга. На начальном этапе работы идея показала свою эффективность (да, собственно, и до сих пор показывает), но, по-хорошему, в будущем её надо будет отрефакторить, так как сейчас на текст шаблона накладываются незначительные ограничения: экранирование пар "{{" и "}}" в тексте обрабатывается слишком «в лоб».
Во второй фазе детально парсится только то, что оказалось внутри «скобок». И вот тут пришлось повозиться. Что в inja, что в Jinja2CppLight, парсер выражений довольно простой. В первом случае — на тех же regexp'ах, во втором — рукописный, но поддерживающий только очень простые конструкции. О поддержке фильтров, тестеров, сложной арифметики или индексирования там речи даже не идёт. А именно этих возможностей Jinja2 мне и хотелось больше всего. Поэтому у меня не оставалось другого выхода, как напедалить полноценный LL(1)-парсер (местами — контекстно-зависимый), реализующий необходимую грамматику. Лет десять-пятнадцать назад я бы, наверное, взял бы для этого Bison или ANTLR и реализовал бы парсер с их помощью. Лет семь бы назад я бы попробовал Boost.Spirit. Сейчас же я просто реализовал нужный мне парсер, работающий методом рекурсивного спуска, без порождения лишних зависимостей и значительного увеличения времени компиляции, как это случилось бы в случае использования внешних утилит или Boost.Spirit. На выходе парсера я получаю AST (для выражений или для операторов), которое и сохраняется как шаблон, готовый для последующего рендеринга.
Пример логики разбора выражений
Отсюда.
ExpressionEvaluatorPtr<FullExpressionEvaluator> ExpressionParser::ParseFullExpression(LexScanner &lexer, bool includeIfPart)
{
ExpressionEvaluatorPtr<FullExpressionEvaluator> result;
LexScanner::StateSaver saver(lexer);
ExpressionEvaluatorPtr<FullExpressionEvaluator> evaluator = std::make_shared<FullExpressionEvaluator>();
auto value = ParseLogicalOr(lexer);
if (!value)
return result;
evaluator->SetExpression(value);
ExpressionEvaluatorPtr<ExpressionFilter> filter;
if (lexer.PeekNextToken() == '|')
{
lexer.EatToken();
filter = ParseFilterExpression(lexer);
if (!filter)
return result;
evaluator->SetFilter(filter);
}
ExpressionEvaluatorPtr<IfExpression> ifExpr;
if (lexer.PeekNextToken() == Token::If)
{
if (includeIfPart)
{
lexer.EatToken();
ifExpr = ParseIfExpression(lexer);
if (!ifExpr)
return result;
evaluator->SetTester(ifExpr);
}
}
saver.Commit();
return evaluator;
}
ExpressionEvaluatorPtr<Expression> ExpressionParser::ParseLogicalOr(LexScanner& lexer)
{
auto left = ParseLogicalAnd(lexer);
if (!left)
return ExpressionEvaluatorPtr<Expression>();
if (lexer.NextToken() != Token::LogicalOr)
{
lexer.ReturnToken();
return left;
}
auto right = ParseLogicalOr(lexer);
if (!right)
return ExpressionEvaluatorPtr<Expression>();
return std::make_shared<BinaryExpression>(BinaryExpression::LogicalOr, left, right);
}
ExpressionEvaluatorPtr<Expression> ExpressionParser::ParseLogicalAnd(LexScanner& lexer)
{
auto left = ParseLogicalCompare(lexer);
if (!left)
return ExpressionEvaluatorPtr<Expression>();
if (lexer.NextToken() != Token::LogicalAnd)
{
lexer.ReturnToken();
return left;
}
auto right = ParseLogicalAnd(lexer);
if (!right)
return ExpressionEvaluatorPtr<Expression>();
return std::make_shared<BinaryExpression>(BinaryExpression::LogicalAnd, left, right);
}
ExpressionEvaluatorPtr<Expression> ExpressionParser::ParseLogicalCompare(LexScanner& lexer)
{
auto left = ParseStringConcat(lexer);
if (!left)
return ExpressionEvaluatorPtr<Expression>();
auto tok = lexer.NextToken();
BinaryExpression::Operation operation;
switch (tok.type)
{
case Token::Equal:
operation = BinaryExpression::LogicalEq;
break;
case Token::NotEqual:
operation = BinaryExpression::LogicalNe;
break;
case '<':
operation = BinaryExpression::LogicalLt;
break;
case '>':
operation = BinaryExpression::LogicalGt;
break;
case Token::GreaterEqual:
operation = BinaryExpression::LogicalGe;
break;
case Token::LessEqual:
operation = BinaryExpression::LogicalLe;
break;
case Token::In:
operation = BinaryExpression::In;
break;
case Token::Is:
{
Token nextTok = lexer.NextToken();
if (nextTok != Token::Identifier)
return ExpressionEvaluatorPtr<Expression>();
std::string name = AsString(nextTok.value);
bool valid = true;
CallParams params;
if (lexer.NextToken() == '(')
params = ParseCallParams(lexer, valid);
else
lexer.ReturnToken();
if (!valid)
return ExpressionEvaluatorPtr<Expression>();
return std::make_shared<IsExpression>(left, std::move(name), std::move(params));
}
default:
lexer.ReturnToken();
return left;
}
auto right = ParseStringConcat(lexer);
if (!right)
return ExpressionEvaluatorPtr<Expression>();
return std::make_shared<BinaryExpression>(operation, left, right);
}
Отсюда.
Фрагмент классов AST-дерева выражений
Отсюда.
class ExpressionFilter;
class IfExpression;
class FullExpressionEvaluator : public ExpressionEvaluatorBase
{
public:
void SetExpression(ExpressionEvaluatorPtr<Expression> expr)
{
m_expression = expr;
}
void SetFilter(ExpressionEvaluatorPtr<ExpressionFilter> expr)
{
m_filter = expr;
}
void SetTester(ExpressionEvaluatorPtr<IfExpression> expr)
{
m_tester = expr;
}
InternalValue Evaluate(RenderContext& values) override;
void Render(OutStream &stream, RenderContext &values) override;
private:
ExpressionEvaluatorPtr<Expression> m_expression;
ExpressionEvaluatorPtr<ExpressionFilter> m_filter;
ExpressionEvaluatorPtr<IfExpression> m_tester;
};
class ValueRefExpression : public Expression
{
public:
ValueRefExpression(std::string valueName)
: m_valueName(valueName)
{
}
InternalValue Evaluate(RenderContext& values) override;
private:
std::string m_valueName;
};
class SubscriptExpression : public Expression
{
public:
SubscriptExpression(ExpressionEvaluatorPtr<Expression> value, ExpressionEvaluatorPtr<Expression> subscriptExpr)
: m_value(value)
, m_subscriptExpr(subscriptExpr)
{
}
InternalValue Evaluate(RenderContext& values) override;
private:
ExpressionEvaluatorPtr<Expression> m_value;
ExpressionEvaluatorPtr<Expression> m_subscriptExpr;
};
class ConstantExpression : public Expression
{
public:
ConstantExpression(InternalValue constant)
: m_constant(constant)
{}
InternalValue Evaluate(RenderContext&) override
{
return m_constant;
}
private:
InternalValue m_constant;
};
class TupleCreator : public Expression
{
public:
TupleCreator(std::vector<ExpressionEvaluatorPtr<>> exprs)
: m_exprs(std::move(exprs))
{
}
InternalValue Evaluate(RenderContext&) override;
private:
std::vector<ExpressionEvaluatorPtr<>> m_exprs;
};
Отсюда.
Пример классов AST-дерева операторов
Отсюда.
struct Statement : public RendererBase
{
};
template<typename T = Statement>
using StatementPtr = std::shared_ptr<T>;
template<typename CharT>
class TemplateImpl;
class ForStatement : public Statement
{
public:
ForStatement(std::vector<std::string> vars, ExpressionEvaluatorPtr<> expr, ExpressionEvaluatorPtr<> ifExpr, bool isRecursive)
: m_vars(std::move(vars))
, m_value(expr)
, m_ifExpr(ifExpr)
, m_isRecursive(isRecursive)
{
}
void SetMainBody(RendererPtr renderer)
{
m_mainBody = renderer;
}
void SetElseBody(RendererPtr renderer)
{
m_elseBody = renderer;
}
void Render(OutStream& os, RenderContext& values) override;
private:
void RenderLoop(const InternalValue& val, OutStream& os, RenderContext& values);
private:
std::vector<std::string> m_vars;
ExpressionEvaluatorPtr<> m_value;
ExpressionEvaluatorPtr<> m_ifExpr;
bool m_isRecursive;
RendererPtr m_mainBody;
RendererPtr m_elseBody;
};
class ElseBranchStatement;
class IfStatement : public Statement
{
public:
IfStatement(ExpressionEvaluatorPtr<> expr)
: m_expr(expr)
{
}
void SetMainBody(RendererPtr renderer)
{
m_mainBody = renderer;
}
void AddElseBranch(StatementPtr<ElseBranchStatement> branch)
{
m_elseBranches.push_back(branch);
}
void Render(OutStream& os, RenderContext& values) override;
private:
ExpressionEvaluatorPtr<> m_expr;
RendererPtr m_mainBody;
std::vector<StatementPtr<ElseBranchStatement>> m_elseBranches;
};
class ElseBranchStatement : public Statement
{
public:
ElseBranchStatement(ExpressionEvaluatorPtr<> expr)
: m_expr(expr)
{
}
bool ShouldRender(RenderContext& values) const;
void SetMainBody(RendererPtr renderer)
{
m_mainBody = renderer;
}
void Render(OutStream& os, RenderContext& values) override;
private:
ExpressionEvaluatorPtr<> m_expr;
RendererPtr m_mainBody;
};
Отсюда.
Узлы AST имеют привязку только к тексту шаблона и преобразуются в итоговые значения в момент рендеринга с учётом текущего контекста рендеринга и его параметров. Это позволило сделать шаблоны thread-safe. Но подробнее об этом в части, касающейся собственно рендеринга.
В качестве первичного токенайзера я выбрал библиотеку lexertk. Она имеет нужную мне лицензию и header-only. Пришлось, правда, отрезать от неё все навороты по подсчёту баланса скобок и прочее и оставить только собственно токенайзер, который (после небольшой рихтовки напильником) научился работать не только с char, но и с wchar_t-символами. Сверху этот токенайзер был мною обёрнут ещё одним классом, выполняющим три основных функции: а) абстрагирует код парсера от типа символов, с которыми ведётся работа, б) распознаёт ключевые слова, специфичные для Jinja2 и в) предоставляет удобный интерфейс для работы с потоком токенов:
LexScanner
Отсюда.
class LexScanner
{
public:
struct State
{
Lexer::TokensList::const_iterator m_begin;
Lexer::TokensList::const_iterator m_end;
Lexer::TokensList::const_iterator m_cur;
};
struct StateSaver
{
StateSaver(LexScanner& scanner)
: m_state(scanner.m_state)
, m_scanner(scanner)
{
}
~StateSaver()
{
if (!m_commited)
m_scanner.m_state = m_state;
}
void Commit()
{
m_commited = true;
}
State m_state;
LexScanner& m_scanner;
bool m_commited = false;
};
LexScanner(const Lexer& lexer)
{
m_state.m_begin = lexer.GetTokens().begin();
m_state.m_end = lexer.GetTokens().end();
Reset();
}
void Reset()
{
m_state.m_cur = m_state.m_begin;
}
auto GetState() const
{
return m_state;
}
void RestoreState(const State& state)
{
m_state = state;
}
const Token& NextToken()
{
if (m_state.m_cur == m_state.m_end)
return EofToken();
return *m_state.m_cur ++;
}
void EatToken()
{
if (m_state.m_cur != m_state.m_end)
++ m_state.m_cur;
}
void ReturnToken()
{
if (m_state.m_cur != m_state.m_begin)
-- m_state.m_cur;
}
const Token& PeekNextToken() const
{
if (m_state.m_cur == m_state.m_end)
return EofToken();
return *m_state.m_cur;
}
bool EatIfEqual(char type, Token* tok = nullptr)
{
return EatIfEqual(static_cast<Token::Type>(type), tok);
}
bool EatIfEqual(Token::Type type, Token* tok = nullptr)
{
if (m_state.m_cur == m_state.m_end)
{
if(type == Token::Type::Eof && tok)
*tok = EofToken();
return type == Token::Type::Eof;
}
if (m_state.m_cur->type == type)
{
if (tok)
*tok = *m_state.m_cur;
++ m_state.m_cur;
return true;
}
return false;
}
private:
State m_state;
static const Token& EofToken()
{
static Token eof;
eof.type = Token::Eof;
return eof;
}
};
Отсюда.
Таким образом, несмотря на то, что движок может работать как с char, так и с wchar_t-шаблонами, основной код разбора от типа символа не зависит. Но подробнее об этом — в разделе, посвящённом приключениям с типом символов.
Отдельно пришлось повозиться с управляющими конструкциями. В Jinja2 многие из них — парные. Например, for/endfor, if/endif, block/endblock и т. п. Каждый элемент пары идёт в своих «скобках», а между элементами может быть куча всего: и просто текст, и другие управляющие блоки. Поэтому алгоритм разбора шаблона пришлось делать на основе стека, к текущему верхнему элементу которого «цепляются» все вновь найденные конструкции и инструкции, а также фрагменты простого текста между ними. Посредством этого же стека проверяется отсутствие разбалансировки типа if-for-endif-endfor. В результате всего этого код получился не такой «компактный» как, скажем, у Jinja2CppLight (или inja), где вся реализация — в одном исходнике (или заголовке). Но логика разбора и, собственно, грамматика в коде видны более явно, что упрощает его поддержку и расширение. По крайней мере именно к этому я стремился. Минимизировать количество зависимостей или объём кода всё равно не получится, значит надо делать его более понятным.
В следующей части речь пойдёт про процесс рендеринга шаблонов, а пока — ссылки:
Спецификация Jinja2: http://jinja.pocoo.org/docs/2.10/templates/
Реализация Jinja2Cpp: https://github.com/flexferrum/Jinja2Cpp
Реализация Jinja2CppLight: https://github.com/hughperkins/Jinja2CppLight
Реализация inja: https://github.com/pantor/inja
Утилита для генерации кода на основе шаблонов Jinja2: https://github.com/flexferrum/autoprogrammer/tree/jinja2cpp_refactor