diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 43fe2bb..dd2db0b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: # ubuntu-22.04, ubuntu-latest, # windows-2019, - # windows-latest, + windows-latest, ] type: [ Release, @@ -65,5 +65,4 @@ jobs: run: cmake --build ${{github.workspace}}/build --config ${{ matrix.type }} --parallel - name: Test - working-directory: ${{github.workspace}}/build - run: ctest --test-dir tests --output-on-failure --verbose -C ${{ matrix.type }} + run: ctest --test-dir build --output-on-failure --verbose -C ${{ matrix.type }} diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 9c8d13d..750bdea 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -10,6 +10,7 @@ foreach(example raw ) add_executable(${example} ${example}.cpp) + target_compile_features(${example} PUBLIC cxx_std_17) target_link_libraries(${example} PRIVATE nlohmann_json::nlohmann_json) endforeach() diff --git a/include/minja/minja.hpp b/include/minja/minja.hpp index 9dc8ed2..c5472a0 100644 --- a/include/minja/minja.hpp +++ b/include/minja/minja.hpp @@ -18,6 +18,12 @@ #include #include +#ifdef _WIN32 +#define ENDL "\r\n" +#else +#define ENDL "\n" +#endif + using json = nlohmann::ordered_json; namespace minja { @@ -32,6 +38,15 @@ struct Options { struct ArgumentsValue; +static std::string normalize_newlines(const std::string & s) { +#ifdef _WIN32 + static const std::regex nl_regex("\r\n"); + return std::regex_replace(s, nl_regex, "\n"); +#else + return s; +#endif +} + /* Values that behave roughly like in Python. */ class Value : public std::enable_shared_from_this { public: @@ -76,7 +91,7 @@ class Value : public std::enable_shared_from_this { void dump(std::ostringstream & out, int indent = -1, int level = 0, bool to_json = false) const { auto print_indent = [&](int level) { if (indent > 0) { - out << "\n"; + out << ENDL; for (int i = 0, n = level * indent; i < n; ++i) out << ' '; } }; @@ -547,11 +562,11 @@ static std::string error_location_suffix(const std::string & source, size_t pos) auto max_line = std::count(start, end, '\n') + 1; auto col = pos - std::string(start, it).rfind('\n'); std::ostringstream out; - out << " at row " << line << ", column " << col << ":\n"; - if (line > 1) out << get_line(line - 1) << "\n"; - out << get_line(line) << "\n"; - out << std::string(col - 1, ' ') << "^" << "\n"; - if (line < max_line) out << get_line(line + 1) << "\n"; + out << " at row " << line << ", column " << col << ":" ENDL; + if (line > 1) out << get_line(line - 1) << ENDL; + out << get_line(line) << ENDL; + out << std::string(col - 1, ' ') << "^" << ENDL; + if (line < max_line) out << get_line(line + 1) << ENDL; return out.str(); } @@ -786,7 +801,7 @@ class TemplateNode { std::string render(const std::shared_ptr & context) const { std::ostringstream out; render(out, context); - return out.str(); + return normalize_newlines(out.str()); } }; @@ -1214,8 +1229,8 @@ class BinaryOpExpr : public Expression { if (!l.to_bool()) return Value(false); return right->evaluate(context).to_bool(); } else if (op == Op::Or) { - if (l.to_bool()) return Value(true); - return right->evaluate(context).to_bool(); + if (l.to_bool()) return l; + return right->evaluate(context); } auto r = right->evaluate(context); @@ -1292,6 +1307,10 @@ struct ArgumentsExpression { static std::string strip(const std::string & s) { static std::regex trailing_spaces_regex("^\\s+|\\s+$"); return std::regex_replace(s, trailing_spaces_regex, ""); + // auto start = s.find_first_not_of(" \t\n\r"); + // if (start == std::string::npos) return ""; + // auto end = s.find_last_not_of(" \t\n\r"); + // return s.substr(start, end - start + 1); } static std::string html_escape(const std::string & s) { @@ -1302,7 +1321,7 @@ static std::string html_escape(const std::string & s) { case '&': result += "&"; break; case '<': result += "<"; break; case '>': result += ">"; break; - case '"': result += """; break; + case '"': result += """; break; case '\'': result += "'"; break; default: result += c; break; } @@ -2101,13 +2120,14 @@ class Parser { static std::regex expr_open_regex(R"(\{\{([-~])?)"); static std::regex block_open_regex(R"(^\{%([-~])?[\s\n\r]*)"); static std::regex block_keyword_tok(R"((if|else|elif|endif|for|endfor|set|endset|block|endblock|macro|endmacro|filter|endfilter)\b)"); - static std::regex text_regex(R"([\s\S\n\r]*?($|(?=\{\{|\{%|\{#)))"); + static std::regex non_text_open_regex(R"(\{\{|\{%|\{#)"); static std::regex expr_close_regex(R"([\s\n\r]*([-~])?\}\})"); static std::regex block_close_regex(R"([\s\n\r]*([-~])?%\})"); TemplateTokenVector tokens; std::vector group; std::string text; + std::smatch match; try { while (it != end) { @@ -2228,10 +2248,15 @@ class Parser { } else { throw std::runtime_error("Unexpected block: " + keyword); } - } else if (!(text = consumeToken(text_regex, SpaceHandling::Keep)).empty()) { + } else if (std::regex_search(it, end, match, non_text_open_regex)) { + auto text_end = it + match.position(); + text = std::string(it, text_end); + it = text_end; tokens.push_back(std::make_unique(location, SpaceHandling::Keep, SpaceHandling::Keep, text)); } else { - if (it != end) throw std::runtime_error("Unexpected character"); + text = std::string(it, end); + it = end; + tokens.push_back(std::make_unique(location, SpaceHandling::Keep, SpaceHandling::Keep, text)); } } return tokens; @@ -2280,24 +2305,31 @@ class Parser { SpaceHandling post_space = it != end ? (*it)->pre_space : SpaceHandling::Keep; auto text = text_token->text; - if (pre_space == SpaceHandling::Strip) { - static std::regex leading_space_regex(R"(^(\s|\r|\n)+)"); - text = std::regex_replace(text, leading_space_regex, ""); - } else if (options.trim_blocks && (it - 1) != begin && !dynamic_cast((*(it - 2)).get())) { - static std::regex leading_line(R"(^[ \t]*\r?\n)"); - text = std::regex_replace(text, leading_line, ""); - } if (post_space == SpaceHandling::Strip) { static std::regex trailing_space_regex(R"((\s|\r|\n)+$)"); text = std::regex_replace(text, trailing_space_regex, ""); } else if (options.lstrip_blocks && it != end) { - static std::regex trailing_last_line_space_regex(R"((\r?\n)[ \t]*$)"); - text = std::regex_replace(text, trailing_last_line_space_regex, "$1"); + auto i = text.size(); + while (i > 0 && (text[i - 1] == ' ' || text[i - 1] == '\t')) i--; + if ((i == 0 && (it - 1) == begin) || (i > 0 && text[i - 1] == '\n')) { + text.resize(i); + } + } + if (pre_space == SpaceHandling::Strip) { + static std::regex leading_space_regex(R"(^(\s|\r|\n)+)"); + text = std::regex_replace(text, leading_space_regex, ""); + } else if (options.trim_blocks && (it - 1) != begin && !dynamic_cast((*(it - 2)).get())) { + if (text.length() > 0 && text[0] == '\n') { + text.erase(0, 1); + } } - if (it == end && !options.keep_trailing_newline) { - static std::regex r(R"(\r?\n$)"); - text = std::regex_replace(text, r, ""); // Strip one trailing newline + auto i = text.size(); + if (i > 0 && text[i - 1] == '\n') { + i--; + if (i > 0 && text[i - 1] == '\r') i--; + text.resize(i); + } } children.emplace_back(std::make_shared(token->location, text)); } else if (auto expr_token = dynamic_cast(token.get())) { @@ -2357,7 +2389,7 @@ class Parser { public: static std::shared_ptr parse(const std::string& template_str, const Options & options) { - Parser parser(std::make_shared(template_str), options); + Parser parser(std::make_shared(normalize_newlines(template_str)), options); auto tokens = parser.tokenize(); TemplateTokenIterator begin = tokens.begin(); auto it = begin; @@ -2627,11 +2659,11 @@ inline std::shared_ptr Context::builtins() { while (std::getline(iss, line, '\n')) { auto needs_indent = !is_first || first; if (is_first) is_first = false; - else out += "\n"; + else out += ENDL; if (needs_indent) out += indent; out += line; } - if (!text.empty() && text.back() == '\n') out += "\n"; + if (!text.empty() && text.back() == '\n') out += ENDL; return out; })); globals.set("selectattr", Value::callable([=](const std::shared_ptr & context, ArgumentsValue & args) { diff --git a/scripts/render.py b/scripts/render.py new file mode 100644 index 0000000..0de5d45 --- /dev/null +++ b/scripts/render.py @@ -0,0 +1,21 @@ +# Copyright 2024 Google LLC +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. +# +# SPDX-License-Identifier: MIT +import sys +import json +from jinja2 import Environment +import jinja2.ext +from pathlib import Path + +input_file, output_file = sys.argv[1:3] +data = json.loads(Path(input_file).read_text()) +# print(json.dumps(data, indent=2), file=sys.stderr) + +env = Environment(**data['options'], extensions=[jinja2.ext.loopcontrols]) +tmpl = env.from_string(data['template']) +output = tmpl.render(data['bindings']) +Path(output_file).write_text(output) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 43f7981..0adab92 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ # SPDX-License-Identifier: MIT add_executable(test-syntax test-syntax.cpp) +target_compile_features(test-syntax PUBLIC cxx_std_17) target_link_libraries(test-syntax PRIVATE nlohmann_json::nlohmann_json gtest_main @@ -14,7 +15,12 @@ target_link_libraries(test-syntax PRIVATE ) gtest_discover_tests(test-syntax) +add_test(NAME test-syntax-jinja2 COMMAND test-syntax) +set_tests_properties(test-syntax-jinja2 PROPERTIES ENVIRONMENT "USE_JINJA2=1;PYTHON_EXECUTABLE=${Python_EXECUTABLE};PYTHONPATH=${CMAKE_SOURCE_DIR}") + + add_executable(test-chat-template test-chat-template.cpp) +target_compile_features(test-chat-template PUBLIC cxx_std_17) target_link_libraries(test-chat-template PRIVATE nlohmann_json::nlohmann_json) set(MODEL_IDS @@ -68,15 +74,21 @@ set(MODEL_IDS TheBloke/FusionNet_34Bx2_MoE-AWQ # Broken, TODO: - # fireworks-ai/llama-3-firefunction-v2 + # fireworks-ai/llama-3-firefunction-v2 # https://github.com/google/minja/issues/7 + # ai21labs/AI21-Jamba-1.5-Large # https://github.com/google/minja/issues/8 # Can't find template(s), TODO: - # ai21labs/Jamba-v0.1 # apple/OpenELM-1_1B-Instruct # dreamgen/WizardLM-2-7B # xai-org/grok-1 ) +if(WIN32) + list(REMOVE_ITEM MODEL_IDS + bofenghuang/vigogne-2-70b-chat + ) +endif() + # Create one test case for each {template, context} combination file(GLOB CONTEXT_FILES "${CMAKE_SOURCE_DIR}/tests/contexts/*.json") execute_process( @@ -109,6 +121,7 @@ if (MINJA_FUZZTEST_ENABLED) fuzztest_setup_fuzzing_flags() endif() add_executable(test-fuzz test-fuzz.cpp) + target_compile_features(test-fuzz PUBLIC cxx_std_17) target_include_directories(test-fuzz PRIVATE ${fuzztest_BINARY_DIR}) target_link_libraries(test-fuzz PRIVATE nlohmann_json::nlohmann_json) link_fuzztest(test-fuzz) diff --git a/tests/test-chat-template.cpp b/tests/test-chat-template.cpp index ce86e77..87a3930 100644 --- a/tests/test-chat-template.cpp +++ b/tests/test-chat-template.cpp @@ -41,7 +41,7 @@ static std::string read_file(const std::string &path) { std::string out; out.resize(static_cast(size)); fs.read(&out[0], static_cast(size)); - return out; + return minja::normalize_newlines(out); } int main(int argc, char *argv[]) { diff --git a/tests/test-syntax.cpp b/tests/test-syntax.cpp index afbf038..fb00303 100644 --- a/tests/test-syntax.cpp +++ b/tests/test-syntax.cpp @@ -14,7 +14,44 @@ #include #include +static std::string render_python(const std::string & template_str, const json & bindings, const minja::Options & options) { + json data { + {"template", template_str}, + {"bindings", bindings.is_null() ? json::object() : bindings}, + {"options", { + {"trim_blocks", options.trim_blocks}, + {"lstrip_blocks", options.lstrip_blocks}, + {"keep_trailing_newline", options.keep_trailing_newline}, + }}, + }; + { + std::ofstream of("data.json"); + of << data.dump(2); + of.close(); + } + + auto pyExeEnv = getenv("PYTHON_EXECUTABLE"); + std::string pyExe = pyExeEnv ? pyExeEnv : "python3"; + + std::remove("out.txt"); + auto res = std::system((pyExe + " -m scripts.render data.json out.txt").c_str()); + if (res != 0) { + throw std::runtime_error("Failed to run python script with data: " + data.dump(2)); + } + + std::ifstream f("out.txt"); + std::string out((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + return out; +} + static std::string render(const std::string & template_str, const json & bindings, const minja::Options & options) { + if (getenv("USE_JINJA2")) { + try { + return render_python(template_str, bindings, options); + } catch (const std::exception & e) { + std::cerr << "ERROR: " + std::string(e.what()); + } + } auto root = minja::Parser::parse(template_str, options); auto context = minja::Context::make(bindings); std::string actual; @@ -43,9 +80,35 @@ const minja::Options lstrip_trim_blocks { }; TEST(SyntaxTest, SimpleCases) { + // EXPECT_EQ( + // "\r\nhey\r\nho!", + // render("\r\n{{ 'hey\r\nho!' }}\r\n", {}, {})); EXPECT_EQ( - "\r\nhey\r\nho!", - render("\r\n{{ 'hey\r\nho!' }}\r\n", {}, {})); + " b", + render(R"( {% set _ = 1 %} {% set _ = 2 %}b)", {}, lstrip_trim_blocks)); + EXPECT_EQ( + " 1", + render(R"({%- if True %} {% set _ = x %}{%- endif %}{{ 1 }})", {}, lstrip_trim_blocks)); + + EXPECT_EQ("\n", render(" {% if True %}\n {% endif %}", {}, lstrip_blocks)); + EXPECT_EQ("", render(" {% if True %}\n {% endif %}", {}, lstrip_trim_blocks)); + EXPECT_EQ(" ", render(" {% if True %}\n {% endif %}", {}, trim_blocks)); + + EXPECT_EQ(" ", render(" {% set _ = 1 %} ", {}, {})); + EXPECT_EQ(" ", render(" {% set _ = 1 %} ", {}, lstrip_blocks)); + EXPECT_EQ(" ", render(" {% set _ = 1 %} ", {}, trim_blocks)); + EXPECT_EQ(" ", render(" {% set _ = 1 %} ", {}, lstrip_trim_blocks)); + + EXPECT_EQ(" \n \n ", render(" \n {% set _ = 1 %} \n ", {}, {})); + EXPECT_EQ(" \n \n ", render(" \n {% set _ = 1 %} \n ", {}, lstrip_blocks)); + EXPECT_EQ(" \n \n ", render(" \n {% set _ = 1 %} \n ", {}, trim_blocks)); + EXPECT_EQ(" \n \n ", render(" \n {% set _ = 1 %} \n ", {}, lstrip_trim_blocks)); + + EXPECT_EQ("\n ", render("{% set _ = 1 %}\n ", {}, {})); + EXPECT_EQ("\n ", render("{% set _ = 1 %}\n ", {}, lstrip_blocks)); + EXPECT_EQ(" ", render("{% set _ = 1 %}\n ", {}, trim_blocks)); + EXPECT_EQ(" ", render("{% set _ = 1 %}\n ", {}, lstrip_trim_blocks)); + EXPECT_EQ( "[2, 3]", render("{{ range(*[2,4]) | list }}", {}, {})); @@ -126,7 +189,7 @@ TEST(SyntaxTest, SimpleCases) { render(R"({{ 'a' in ["a"] }},{{ 'a' in [] }})", {}, {})); EXPECT_EQ( R"([{'a': 1}])", - render(R"({{ [{"a": 1}, {"a": 2}, {}] | selectattr("a", "equalto", 1) }})", {}, {})); + render(R"({{ [{"a": 1}, {"a": 2}, {}] | selectattr("a", "equalto", 1) | list }})", {}, {})); EXPECT_EQ( "[1, 2]", render(R"({{ [{"a": 1}, {"a": 2}] | map(attribute="a") | list }})", {}, {})); @@ -155,7 +218,7 @@ TEST(SyntaxTest, SimpleCases) { "...\n" "\n"; EXPECT_EQ( - "\n Hello...\n", + "\n Hello \n...\n", render(trim_tmpl, {}, trim_blocks)); EXPECT_EQ( "\n Hello \n...\n", @@ -164,7 +227,7 @@ TEST(SyntaxTest, SimpleCases) { "\nHello \n...\n", render(trim_tmpl, {}, lstrip_blocks)); EXPECT_EQ( - "\nHello...\n", + "\nHello \n...\n", render(trim_tmpl, {}, lstrip_trim_blocks)); EXPECT_EQ( "a | b | c", @@ -188,7 +251,7 @@ TEST(SyntaxTest, SimpleCases) { )", {}, {})); EXPECT_EQ( "a0b", - render("{{ 'a' + [] | length + 'b' }}", {}, {})); + render("{{ 'a' + [] | length | string + 'b' }}", {}, {})); EXPECT_EQ( "1, 2, 3...", render("{{ [1, 2, 3] | join(', ') + '...' }}", {}, {})); @@ -205,8 +268,8 @@ TEST(SyntaxTest, SimpleCases) { "1Hello there2", render("{% set foo %}Hello {{ 'there' }}{% endset %}{{ 1 ~ foo ~ 2 }}", {}, {})); EXPECT_EQ( - "[1, False, null, True, 2, '3']", - render("{{ [1, False, null, True, 2, '3', 1, '3', False, null, True] | unique }}", {}, {})); + "[1, False, 2, '3']", + render("{{ [1, False, 2, '3', 1, '3', False] | unique | list }}", {}, {})); EXPECT_EQ( "1", render("{{ range(5) | length % 2 }}", {}, {})); @@ -246,19 +309,21 @@ TEST(SyntaxTest, SimpleCases) { ({{ i }}, {{ loop.cycle('odd', 'even') }}), {%- endfor -%} )", {}, {})); + if (!getenv("USE_JINJA2")) { + EXPECT_EQ( + "0, first=True, last=False, index=1, index0=0, revindex=3, revindex0=2, prev=, next=2,\n" + "2, first=False, last=False, index=2, index0=1, revindex=2, revindex0=1, prev=0, next=4,\n" + "4, first=False, last=True, index=3, index0=2, revindex=1, revindex0=0, prev=2, next=,\n", + render( + "{%- for i in range(5) if i % 2 == 0 -%}\n" + "{{ i }}, first={{ loop.first }}, last={{ loop.last }}, index={{ loop.index }}, index0={{ loop.index0 }}, revindex={{ loop.revindex }}, revindex0={{ loop.revindex0 }}, prev={{ loop.previtem }}, next={{ loop.nextitem }},\n" + "{% endfor -%}", + {}, {} + ) + ); + } EXPECT_EQ( - "0, first=True, last=False, index=1, index0=0, revindex=3, revindex0=2, prev=, next=2,\n" - "2, first=False, last=False, index=2, index0=1, revindex=2, revindex0=1, prev=0, next=4,\n" - "4, first=False, last=True, index=3, index0=2, revindex=1, revindex0=0, prev=2, next=,\n", - render( - "{%- for i in range(5) if i % 2 == 0 -%}\n" - "{{ i }}, first={{ loop.first }}, last={{ loop.last }}, index={{ loop.index }}, index0={{ loop.index0 }}, revindex={{ loop.revindex }}, revindex0={{ loop.revindex0 }}, prev={{ loop.previtem }}, next={{ loop.nextitem }},\n" - "{% endfor -%}", - {}, {} - ) - ); - EXPECT_EQ( - R"(<, >, &, ")", + R"(<, >, &, ")", render(R"( {%- set res = [] -%} {%- for c in ["<", ">", "&", '"'] -%} @@ -295,18 +360,24 @@ TEST(SyntaxTest, SimpleCases) { {{- values -}} {%- endmacro -%} {{- foo() }} {{ foo() -}})", {}, {})); + + if (!getenv("USE_JINJA2")) { + EXPECT_EQ( + "[]", + render(R"({{ None | items | list | tojson }})", {}, {})); + } EXPECT_EQ( - "[]; [[1, 2]]", - render(R"({{ None | items | tojson }}; {{ {1: 2} | items | tojson }})", {}, {})); + "[[1, 2]]", + render(R"({{ {1: 2} | items | list | tojson }})", {}, {})); EXPECT_EQ( "[[1, 2], [3, 4], [5, 7]]", render(R"({{ {1: 2, 3: 4, 5: 7} | dictsort | tojson }})", {}, {})); EXPECT_EQ( "[[1, 2]]", - render(R"({{ {1: 2}.items() }})", {}, {})); + render(R"({{ {1: 2}.items() | map("list") | list }})", {}, {})); EXPECT_EQ( "2; ; 10", - render(R"({{ {1: 2}.get(1) }}; {{ {}.get(1) }}; {{ {}.get(1, 10) }})", {}, {})); + render(R"({{ {1: 2}.get(1) }}; {{ {}.get(1) or '' }}; {{ {}.get(1, 10) }})", {}, {})); EXPECT_EQ( R"(1,1.2,"a",true,true,false,false,null,[],[1],[1, 2],{},{"a": 1},{"1": "b"},)", render(R"( @@ -365,7 +436,7 @@ TEST(SyntaxTest, SimpleCases) { render("{{ ' a ' | trim }}", {}, {})); EXPECT_EQ( "[0, 1, 2][4, 5, 6][0, 2, 4, 6, 8]", - render("{{ range(3) }}{{ range(4, 7) }}{{ range(0, 10, step=2) }}", {}, {})); + render("{{ range(3) | list }}{{ range(4, 7) | list }}{{ range(0, 10, 2) | list }}", {}, {})); EXPECT_EQ( " abc ", render(R"( {{ "a" -}} b {{- "c" }} )", {}, {})); @@ -395,23 +466,25 @@ TEST(SyntaxTest, SimpleCases) { "", render("{% if 1 %}{% elif 1 %}{% else %}{% endif %}", {}, {})); - auto expect_throws_with_message_substr = [](const std::function & fn, const std::string & expected_substr) { - EXPECT_THAT([=]() { fn(); }, testing::Throws(Property(&std::runtime_error::what, testing::HasSubstr(expected_substr)))); - }; + if (!getenv("USE_JINJA2")) { + auto expect_throws_with_message_substr = [](const std::function & fn, const std::string & expected_substr) { + EXPECT_THAT([=]() { fn(); }, testing::Throws(Property(&std::runtime_error::what, testing::HasSubstr(expected_substr)))); + }; - expect_throws_with_message_substr([]() { render("{% else %}", {}, {}); }, "Unexpected else"); + expect_throws_with_message_substr([]() { render("{% else %}", {}, {}); }, "Unexpected else"); - expect_throws_with_message_substr([]() { render("{% else %}", {}, {}); }, "Unexpected else"); - expect_throws_with_message_substr([]() { render("{% endif %}", {}, {}); }, "Unexpected endif"); - expect_throws_with_message_substr([]() { render("{% elif 1 %}", {}, {}); }, "Unexpected elif"); - expect_throws_with_message_substr([]() { render("{% endfor %}", {}, {}); }, "Unexpected endfor"); - expect_throws_with_message_substr([]() { render("{% endfilter %}", {}, {}); }, "Unexpected endfilter"); + expect_throws_with_message_substr([]() { render("{% else %}", {}, {}); }, "Unexpected else"); + expect_throws_with_message_substr([]() { render("{% endif %}", {}, {}); }, "Unexpected endif"); + expect_throws_with_message_substr([]() { render("{% elif 1 %}", {}, {}); }, "Unexpected elif"); + expect_throws_with_message_substr([]() { render("{% endfor %}", {}, {}); }, "Unexpected endfor"); + expect_throws_with_message_substr([]() { render("{% endfilter %}", {}, {}); }, "Unexpected endfilter"); - expect_throws_with_message_substr([]() { render("{% if 1 %}", {}, {}); }, "Unterminated if"); - expect_throws_with_message_substr([]() { render("{% for x in 1 %}", {}, {}); }, "Unterminated for"); - expect_throws_with_message_substr([]() { render("{% if 1 %}{% else %}", {}, {}); }, "Unterminated if"); - expect_throws_with_message_substr([]() { render("{% if 1 %}{% else %}{% elif 1 %}{% endif %}", {}, {}); }, "Unterminated if"); - expect_throws_with_message_substr([]() { render("{% filter trim %}", {}, {}); }, "Unterminated filter"); + expect_throws_with_message_substr([]() { render("{% if 1 %}", {}, {}); }, "Unterminated if"); + expect_throws_with_message_substr([]() { render("{% for x in 1 %}", {}, {}); }, "Unterminated for"); + expect_throws_with_message_substr([]() { render("{% if 1 %}{% else %}", {}, {}); }, "Unterminated if"); + expect_throws_with_message_substr([]() { render("{% if 1 %}{% else %}{% elif 1 %}{% endif %}", {}, {}); }, "Unterminated if"); + expect_throws_with_message_substr([]() { render("{% filter trim %}", {}, {}); }, "Unterminated filter"); + } EXPECT_EQ( "3",