diff --git a/CMakeLists.txt b/CMakeLists.txt index ba8a3a47..72c35b95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -231,6 +231,7 @@ set(JINJA2CPP_SOURCES src/filesystem_handler.cpp src/filter_factories.cpp src/filters.cpp + src/function_base.cpp src/json_reader.cpp src/generic_list.cpp src/global_functions.cpp diff --git a/docs/tasks/0306-filter-test-argument-unpacking.md b/docs/tasks/0306-filter-test-argument-unpacking.md index 46176d6f..bd1fb3ec 100644 --- a/docs/tasks/0306-filter-test-argument-unpacking.md +++ b/docs/tasks/0306-filter-test-argument-unpacking.md @@ -1,5 +1,5 @@ --- -status: open +status: in-progress priority: low area: parity depends: [0220] diff --git a/docs/tasks/0308-divisibleby-non-number.md b/docs/tasks/0308-divisibleby-non-number.md index 4362725c..a949c54e 100644 --- a/docs/tasks/0308-divisibleby-non-number.md +++ b/docs/tasks/0308-divisibleby-non-number.md @@ -1,5 +1,5 @@ --- -status: open +status: in-progress priority: low area: parity depends: [] diff --git a/docs/tasks/0310-undefined-filter-argument-is-omitted.md b/docs/tasks/0310-undefined-filter-argument-is-omitted.md index 52b3f3dd..61d865fd 100644 --- a/docs/tasks/0310-undefined-filter-argument-is-omitted.md +++ b/docs/tasks/0310-undefined-filter-argument-is-omitted.md @@ -1,5 +1,5 @@ --- -status: open +status: in-progress priority: low area: parity touches: [src/function_base.h, src/string_converter_filter.cpp] diff --git a/src/expression_evaluator.h b/src/expression_evaluator.h index c2253a50..c1372c3f 100644 --- a/src/expression_evaluator.h +++ b/src/expression_evaluator.h @@ -118,12 +118,10 @@ struct CallParamsInfo std::vector> posParams; }; -// The arguments of a call as parsed: f(*args, **kwargs) is unpacked when the call is evaluated, -// null when not written. Only calls take them, so filters and tests keep CallParamsInfo -struct CallArgsInfo +// The arguments of a call, a filter or a test as parsed: f(*args, **kwargs) is unpacked when +// the call is evaluated, null when not written. A call without them is its CallParamsInfo +struct CallArgsInfo : CallParamsInfo { - OrderedMap> kwParams; - std::vector> posParams; NodeRef dynArgs; NodeRef dynKwargs; }; diff --git a/src/expression_parser.cpp b/src/expression_parser.cpp index 990b9d09..1d93016b 100644 --- a/src/expression_parser.cpp +++ b/src/expression_parser.cpp @@ -3,6 +3,7 @@ #include "engine_errors.h" #include "error_handling.h" #include "expression_evaluator.h" +#include "filters.h" #include "internal_value.h" #include "lexer.h" #include "loop_attr.h" @@ -13,6 +14,7 @@ #include "render_context.h" #include "renderer.h" #include "small_vector.h" +#include "testers.h" #include "value_visitors.h" #include @@ -709,11 +711,11 @@ ExpressionParser::ParseResult> ExpressionParser::ParseTest(L return MakeParseError(ErrorCode::ExpectedIdentifier, nameTok); } - CallParamsInfo params; + CallArgsInfo params; const auto& argTok = lexer.PeekNextToken(); if (lexer.EatIfEqual('(')) { - auto parsedParams = ParseCallParams(lexer); + auto parsedParams = ParseCallArgs(lexer); if (!parsedParams) { return MakeUnexpected(parsedParams.error()); @@ -763,7 +765,16 @@ ExpressionParser::ParseResult> ExpressionParser::ParseTest(L try { bool unknown = false; - result = IsExpression::Make(m_nodes, valueRef, name, params, FindRegisteredTester(name), &unknown, !m_volatile); + if (params.dynArgs || params.dynKwargs) + { + // Bound to the test each time it runs (docs/tasks/0306) + const auto test = CreateUnpackingTester(m_nodes, name, params, FindRegisteredTester(name), &unknown); + result = m_nodes.Make(valueRef, test, false); + } + else + { + result = IsExpression::Make(m_nodes, valueRef, name, params, FindRegisteredTester(name), &unknown, !m_volatile); + } if (unknown) { m_unknown.Add(nameTok, true); @@ -957,21 +968,11 @@ std::optional ClassifyUnpacked(const Token& tok, const CallArgsInfo return params.dynKwargs || params.dynArgs ? std::nullopt : std::optional(CallArgKind::Args); } -bool HasUnpacked(const CallParamsInfo& /*params*/) -{ - return false; -} - bool HasUnpacked(const CallArgsInfo& params) { return params.dynArgs || params.dynKwargs; } -bool HasUnpackedKeywords(const CallParamsInfo& /*params*/) -{ - return false; -} - bool HasUnpackedKeywords(const CallArgsInfo& params) { return static_cast(params.dynKwargs); @@ -981,15 +982,11 @@ bool HasUnpackedKeywords(const CallArgsInfo& params) // arguments, then keywords and one `*expr`, then one `**expr`. Nothing where it is out of that // order ("invalid syntax for function call expression") or a keyword is repeated; a keyword's // name goes to `name` -template -std::optional ClassifyCallArg(const Token& tok, LexScanner& lexer, const Params& params, std::string& name) +std::optional ClassifyCallArg(const Token& tok, LexScanner& lexer, const CallArgsInfo& params, std::string& name) { - if constexpr (std::is_same_v) + if (tok == '*' || tok == Token::MulMul) { - if (tok == '*' || tok == Token::MulMul) - { - return ClassifyUnpacked(tok, params); - } + return ClassifyUnpacked(tok, params); } if (tok == Token::Identifier && lexer.PeekNextToken() == '=') { @@ -1001,20 +998,10 @@ std::optional ClassifyCallArg(const Token& tok, LexScanner& lexer, } } // namespace -ExpressionParser::ParseResult ExpressionParser::ParseCallParams(LexScanner& lexer) -{ - return ParseArguments(lexer); -} - +// The arguments of a call, a filter or a test, `*args` and `**kwargs` included ExpressionParser::ParseResult ExpressionParser::ParseCallArgs(LexScanner& lexer) { - return ParseArguments(lexer); -} - -template -ExpressionParser::ParseResult ExpressionParser::ParseArguments(LexScanner& lexer) -{ - Params result; + CallArgsInfo result; if (lexer.EatIfEqual(')')) { @@ -1054,7 +1041,7 @@ ExpressionParser::ParseResult ExpressionParser::ParseArguments(LexScanne { result.kwParams[paramName] = *valueExpr; } - else if constexpr (std::is_same_v) + else { (*kind == CallArgKind::Args ? result.dynArgs : result.dynKwargs) = *valueExpr; } @@ -1299,16 +1286,13 @@ ExpressionParser::ParseResult> ExpressionParser::Parse } std::string name = lexer.GetAsString(tok); - ParseResult params; - - if (lexer.NextToken() == '(') - { - params = ParseCallParams(lexer); - } - else + const bool hasArgs = lexer.NextToken() == '('; + if (!hasArgs) { lexer.ReturnToken(); } + // Made in place: assigning an expected swaps the arguments + auto params = hasArgs ? ParseCallArgs(lexer) : ParseResult(); if (!params) { @@ -1316,7 +1300,17 @@ ExpressionParser::ParseResult> ExpressionParser::Parse } bool unknown = false; - auto filter = ExpressionFilter::Make(m_nodes, name, *params, FindRegisteredFilter(name), &unknown, !m_volatile); + NodeRef filter; + if (params->dynArgs || params->dynKwargs) + { + // Bound to the filter each time it runs (docs/tasks/0306); such a call is not folded + const auto unpacking = CreateUnpackingFilter(m_nodes, name, *params, FindRegisteredFilter(name), &unknown); + filter = m_nodes.Make(unpacking, ArenaText(), false); + } + else + { + filter = ExpressionFilter::Make(m_nodes, name, *params, FindRegisteredFilter(name), &unknown, !m_volatile); + } if (unknown) { m_unknown.Add(tok, false); diff --git a/src/expression_parser.h b/src/expression_parser.h index ffc8ada6..74679b04 100644 --- a/src/expression_parser.h +++ b/src/expression_parser.h @@ -150,10 +150,7 @@ class ExpressionParser ParseResult> ParseFullExpression(LexScanner& lexer, bool includeIfPart = true); // Jinja2's parse_tuple without parentheses: 'a, b' is a tuple, 'a' stays an expression ParseResult> ParseTupleOrExpression(LexScanner& lexer, bool includeIfPart = true); - // The arguments of a filter or test, which bind them at Load and so take no `*args` or - // `**kwargs` yet (docs/tasks/0306) - ParseResult ParseCallParams(LexScanner& lexer); - // The arguments of a call, `*args` and `**kwargs` included + // The arguments of a call, a filter or a test, `*args` and `**kwargs` included ParseResult ParseCallArgs(LexScanner& lexer); ParseResult> ParseFilterExpression(LexScanner& lexer); // Settings::finalize as a callable; undefined if it is not set @@ -196,8 +193,6 @@ class ExpressionParser ParseResult> ParseBracedExpressionOrTuple(LexScanner& lexer); ParseResult> ParseDictionary(LexScanner& lexer); ParseResult> ParseTuple(LexScanner& lexer); - template - ParseResult ParseArguments(LexScanner& lexer); ParseResult> ParseCall(LexScanner& lexer, NodeRef valueRef); ParseResult> ParseSubscript(LexScanner& lexer, NodeRef valueRef); NodeRef MakeSubscript(NodeRef value, const std::string& attrName); diff --git a/src/filter_factories.cpp b/src/filter_factories.cpp index 7342550e..b8c2d179 100644 --- a/src/filter_factories.cpp +++ b/src/filter_factories.cpp @@ -4,6 +4,8 @@ #include "filters.h" #include "expression_evaluator.h" +#include "engine_errors.h" +#include "function_base.h" #include "internal_value.h" #include "node_arena.h" #include "render_context.h" @@ -13,10 +15,12 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -131,7 +135,7 @@ const FilterEntry* FindBuiltinFilter(std::string_view filterName) } } // namespace -FilterPtr CreateFilter(std::string_view filterName, const CallParamsInfo& params) +FilterPtr CreateFilter(std::string_view filterName, FunctionArgs params) { if (const auto* entry = FindBuiltinFilter(filterName)) { @@ -140,7 +144,7 @@ FilterPtr CreateFilter(std::string_view filterName, const CallParamsInfo& params return std::make_shared(std::string(filterName), params); } -NodeRef CreateFilter(NodeArena& nodes, std::string_view filterName, const CallParamsInfo& params, bool* unknown) +NodeRef CreateFilter(NodeArena& nodes, std::string_view filterName, FunctionArgs params, bool* unknown) { if (const auto* entry = FindBuiltinFilter(filterName)) { @@ -153,7 +157,7 @@ NodeRef CreateFilter(NodeArena& nodes, std: return nodes.MakeObject(std::string(filterName), params); } -FilterPtr CreateFilter(const std::string& filterName, const CallParamsInfo& params, RenderContext& context) +FilterPtr CreateFilter(const std::string& filterName, FunctionArgs params, RenderContext& context) { auto* env = context.GetEnv(); auto registered = env ? env->FindFilter(filterName) : std::optional(); @@ -165,5 +169,103 @@ FilterPtr CreateFilter(const std::string& filterName, const CallParamsInfo& para return std::make_shared(filterName, params, std::move(callable)); } +NodeRef CreateUnpackingFilter(NodeArena& nodes, const std::string& filterName, const CallArgsInfo& params, InternalValue registered, bool* unknown) +{ + if (unknown && !GetIf(®istered) && !FindBuiltinFilter(filterName)) + { + *unknown = true; + } + // The arguments first: an arena makes one object at a time + const auto args = ArenaCallParams::Make(nodes, params); + return nodes.MakeObject(filterName, args, std::move(registered)); +} + +namespace filters +{ +// The constructors of the filters that forward their extra arguments are templates over the +// expressions and the values of a call (FunctionArgs::Visit). Here, out of filters.cpp: there they +// changed GCC's inlining of the filters' render paths enough to cost up to 1% (docs/tasks/0306) +Map::Map(const FilterParams& params) +{ + // map(attribute='x', default=d) reads the attribute of each item; it binds no filter + const bool byAttribute = params.Visit([this](const auto& args) { + const auto attributeIt = args.kwParams.find("attribute"); + if (!args.posParams.empty() || attributeIt == args.kwParams.end()) + { + return false; + } + std::decay_t mapping; + mapping.kwParams["name"] = attributeIt->second; + for (const auto& [name, value] : args.kwParams) + { + if (name == "default") + { + mapping.kwParams["default"] = value; + } + else if (name != "attribute") + { + // Jinja2's prepare_map raises FilterArgumentError + SetArgumentsError("got an unexpected keyword argument '" + name + "'"); + break; + } + } + m_mappingParams = ForwardedArgs(std::move(mapping)); + return true; + }); + if (byAttribute) + { + m_byAttribute = true; + return; + } + + static const auto args = MakeArgumentsTable({ { "filter", true } }); + ParseParams(args, params, ExtraArgs::Accept, &m_mappingParams); +} + +Tester::Tester(const FilterParams& params, Tester::Mode mode) + : m_mode(mode) +{ + // Python's select_or_reject takes the attribute and the test name by position and passes + // the other arguments, keywords included, to the test; without a test it calls bool() and + // drops them + const bool isAttrMode = mode == SelectAttrMode || mode == RejectAttrMode; + const std::size_t leadCount = std::min(params.PosCount(), isAttrMode ? std::size_t{ 2 } : std::size_t{ 1 }); + m_hasAttribute = isAttrMode && leadCount > 0; + + params.Visit([&](const auto& args) { + const auto split = args.posParams.begin() + static_cast(leadCount); + std::decay_t leadParams; + leadParams.posParams.assign(args.posParams.begin(), split); + if (isAttrMode) + { + static const auto table = MakeArgumentsTable({ { "attribute", false }, { "tester", false } }); + ParseParams(table, leadParams); + } + else + { + static const auto table = MakeArgumentsTable({ { "tester", false } }); + ParseParams(table, leadParams); + } + + std::decay_t testingParams; + testingParams.posParams.assign(split, args.posParams.end()); + testingParams.kwParams = args.kwParams; + m_testingParams = ForwardedArgs(std::move(testingParams)); + }); +} + +InternalValue UnpackingFilter::Filter(const InternalValue& baseVal, RenderContext& context) +{ + const auto args = helpers::EvaluateCallParams(m_params, context); + // Filters added to the environment take precedence over the builtins (ExpressionFilter::Make) + const FilterPtr filter = GetIf(&m_registered) ? std::make_shared(m_filterName, args, m_registered) : CreateFilter(m_filterName, args); + if (auto error = filter->GetArgumentsError(); !error.empty()) + { + throw TemplateRuntimeError(m_filterName + "() " + error); + } + return filter->Filter(baseVal, context); +} +} // namespace filters + } // namespace v2 } // namespace jinja2 diff --git a/src/filters.cpp b/src/filters.cpp index 14e2770d..61269a98 100644 --- a/src/filters.cpp +++ b/src/filters.cpp @@ -11,6 +11,7 @@ #include "loop_attr.h" #include "markup.h" #include "out_stream.h" // IWYU pragma: keep (GetStreamOnString returns an OutStream by value) +#include "recursion_guard.h" #include "render_context.h" #include "stable_sort_values.h" #include "string_builder.h" @@ -211,10 +212,11 @@ bool IsNotIterable(const InternalValue& val) } // Jinja2's make_multi_attrgetter: "a,b.c" sorts by the list [item.a, item.b.c] -std::vector SortKeyPaths(const InternalValue& attrName) +// isSet: FunctionBase::IsArgumentSet of attrName; an undefined attribute is a key that finds nothing +std::vector SortKeyPaths(const InternalValue& attrName, bool isSet) { std::vector paths; - if (IsEmpty(attrName)) + if (!isSet) { return paths; } @@ -248,7 +250,8 @@ Join::Join(const FilterParams& params) InternalValue Join::Filter(const InternalValue& baseVal, RenderContext& context) { - InternalValue attrName = GetArgumentValue("attribute", context); + bool attrGiven = false; + InternalValue attrName = GetArgumentValue("attribute", context, attrGiven); bool isConverted = false; ListAdapter values = ConvertToList(baseVal, isConverted, false); @@ -263,7 +266,8 @@ InternalValue Join::Filter(const InternalValue& baseVal, RenderContext& context) return InternalValue(); } // Jinja2's make_attrgetter: a dotted path, digit parts as indexes - if (!IsEmpty(attrName)) + // An undefined attribute passed is a key that finds nothing (docs/tasks/0310) + if (IsArgumentSet(attrName, attrGiven)) { auto path = AttributePath(attrName); InternalValueList items; @@ -323,7 +327,8 @@ Sort::Sort(const FilterParams& params) InternalValue Sort::Filter(const InternalValue& baseVal, RenderContext& context) { - InternalValue attrName = GetArgumentValue("attribute", context); + bool attrGiven = false; + InternalValue attrName = GetArgumentValue("attribute", context, attrGiven); InternalValue isReverseVal = GetArgumentValue("reverse", context, InternalValue(false)); InternalValue isCsVal = GetArgumentValue("case_sensitive", context, InternalValue(false)); @@ -342,7 +347,7 @@ InternalValue Sort::Filter(const InternalValue& baseVal, RenderContext& context) BinaryExpression::Operation oper = ConvertToBool(isReverseVal) ? BinaryExpression::LogicalGt : BinaryExpression::LogicalLt; BinaryExpression::CompareType compType = ConvertToBool(isCsVal) ? BinaryExpression::CaseSensitive : BinaryExpression::CaseInsensitive; - std::vector paths = SortKeyPaths(attrName); + std::vector paths = SortKeyPaths(attrName, IsArgumentSet(attrName, attrGiven)); auto isLess = [oper, compType](const InternalValue& key1, const InternalValue& key2, bool& isEqual) { // Equal items never reach `<`, so a list of equal dicts or Nones sorts as in Python @@ -401,9 +406,9 @@ Attribute::Attribute(const FilterParams& params) ParseParams(args, params); // default is a Jinja2C++ extension that Python's do_attr(obj, name) lacks: it can only be // passed by name - if (params.posParams.size() > 1) + if (params.PosCount() > 1) { - SetArgumentsError("got " + std::to_string(params.posParams.size() - 1) + " more positional argument(s) than it takes"); + SetArgumentsError("got " + std::to_string(params.PosCount() - 1) + " more positional argument(s) than it takes"); } } @@ -681,7 +686,7 @@ InternalValue ApplyMacro::Filter(const InternalValue& baseVal, RenderContext& co return InternalValue(); } - CallParams tmpCallParams = helpers::EvaluateCallParams(m_mappingParams, context); + CallParams tmpCallParams = m_mappingParams.Evaluate(context); CallParams callParams; callParams.kwParams = std::move(tmpCallParams.kwParams); callParams.posParams.reserve(tmpCallParams.posParams.size() + 1); @@ -706,36 +711,11 @@ InternalValue ApplyMacro::Filter(const InternalValue& baseVal, RenderContext& co return result; } -Map::Map(const FilterParams& params) -{ - // map(attribute='x', default=d) reads the attribute of each item; it binds no filter - const auto attributeIt = params.kwParams.find("attribute"); - if (params.posParams.empty() && attributeIt != params.kwParams.cend()) - { - m_byAttribute = true; - m_mappingParams.kwParams["name"] = attributeIt->second; - for (const auto& [name, value] : params.kwParams) - { - if (name == "default") - { - m_mappingParams.kwParams["default"] = value; - } - else if (name != "attribute") - { - // Jinja2's prepare_map raises FilterArgumentError - SetArgumentsError("got an unexpected keyword argument '" + name + "'"); - break; - } - } - return; - } - - static const auto args = MakeArgumentsTable({ { "filter", true } }); - ParseParams(args, params, ExtraArgs::Accept, &m_mappingParams); -} InternalValue Map::Filter(const InternalValue& baseVal, RenderContext& context) { + // map('map', 'map', ...) nests a filter per argument, which unpacking makes cheap to write + CheckStack(); if (m_byAttribute) { bool isConverted = false; @@ -754,7 +734,7 @@ InternalValue Map::Filter(const InternalValue& baseVal, RenderContext& context) return InternalValue(); } - auto params = helpers::EvaluateCallParams(m_mappingParams, context); + auto params = m_mappingParams.Evaluate(context); auto path = AttributePath(params.kwParams["name"]); auto defaultVal = params.kwParams["default"]; InternalValueList resultList; @@ -989,13 +969,15 @@ InternalValue Reverse(const ListAdapter& list, const InternalValue& baseVal) return ListAdapter::CreateAdapter(std::move(resultList)); } -InternalValue Sum(const ListAdapter& list, const InternalValue& attrName, const InternalValue& start, RenderContext& context) +// start: what the call passes, or nothing for Python's 0 +InternalValue Sum(const ListAdapter& list, const InternalValueList& path, std::optional start, RenderContext& context) { // Python's sum starts from 0, so a string item raises like `0 + 'h'` - InternalValue resultVal = IsEmpty(start) ? InternalValue(static_cast(0)) : start; - auto path = IsEmpty(attrName) ? InternalValueList() : AttributePath(attrName); + InternalValue resultVal = start ? std::move(*start) : InternalValue(static_cast(0)); list.ForEach([&](InternalValue&& val) { auto item = path.empty() ? std::move(val) : GetAttributeByPath(val, path, InternalValue(), context); + // An undefined start fails the first addition (docs/tasks/0310) + CheckUndefinedUse(resultVal, UndefinedUse::Arithmetic); resultVal = Apply2(resultVal, item, BinaryExpression::Plus); return true; }); @@ -1050,7 +1032,7 @@ void DropDuplicates(std::vector& items, BinaryExpression::CompareTyp InternalValue Unique(const ListAdapter& list, const InternalValue& baseVal, - const InternalValue& attrName, + const InternalValueList& path, BinaryExpression::CompareType compType, RenderContext& context) { @@ -1058,9 +1040,8 @@ InternalValue Unique(const ListAdapter& list, InternalValueList values; std::vector items; int64_t idx = 0; - const auto path = IsEmpty(attrName) ? InternalValueList() : AttributePath(attrName); list.ForEach([&](InternalValue&& v) { - items.push_back(UniqueItem{ IsEmpty(attrName) ? v : GetAttributeByPath(v, path, InternalValue(), context), idx++ }); + items.push_back(UniqueItem{ path.empty() ? v : GetAttributeByPath(v, path, InternalValue(), context), idx++ }); values.push_back(std::move(v)); return true; }); @@ -1148,12 +1129,13 @@ InternalValue SequenceAccessor::Filter(const InternalValue& baseVal, RenderConte return {}; } - InternalValue attrName = GetArgumentValue("attribute", context); + bool attrGiven = false; + InternalValue attrName = GetArgumentValue("attribute", context, attrGiven); InternalValue isCsVal = GetArgumentValue("case_sensitive", context, InternalValue(false)); BinaryExpression::CompareType compType = ConvertToBool(isCsVal) ? BinaryExpression::CaseSensitive : BinaryExpression::CaseInsensitive; - const auto attrPath = IsEmpty(attrName) ? InternalValueList() : AttributePath(attrName); + const auto attrPath = IsArgumentSet(attrName, attrGiven) ? AttributePath(attrName) : InternalValueList(); // Python's min and max take each item's key once and keep the first extreme item auto extreme = [&](bool isMax) { std::optional best; @@ -1188,16 +1170,17 @@ InternalValue SequenceAccessor::Filter(const InternalValue& baseVal, RenderConte return KeepMarkup(baseVal, Reverse(list, baseVal)); case SumItemsMode: { - auto start = GetArgumentValue("start", context); + bool startGiven = false; + auto start = GetArgumentValue("start", context, startGiven); // Python's sum refuses a string start ("sum() can't sum strings") if (IsStringValue(start)) { context.GetRendererCallback()->ThrowRuntimeError(ErrorCode::InvalidValueType, ValuesList{}); } - return Sum(list, attrName, start, context); + return Sum(list, attrPath, startGiven ? std::optional(std::move(start)) : std::nullopt, context); } case UniqueItemsMode: - return Unique(list, baseVal, attrName, compType, context); + return Unique(list, baseVal, attrPath, compType, context); } return {}; @@ -1243,7 +1226,8 @@ InternalValue Slice::Filter(const InternalValue& baseVal, RenderContext& context // Port of Jinja2's do_slice: `slices` columns, the first length % slices of them one // item longer; with fill_with every shorter column gets one fill item int64_t slices = ConvertToInt(GetArgumentValue("slices", context)); - InternalValue fillWith = GetArgumentValue("fill_with", context); + bool fillGiven = false; + InternalValue fillWith = GetArgumentValue("fill_with", context, fillGiven); // Python raises ZeroDivisionError if (slices == 0) { @@ -1271,7 +1255,7 @@ InternalValue Slice::Filter(const InternalValue& baseVal, RenderContext& context { column.push_back(protectedValue(items[static_cast(idx)])); } - if (!IsEmpty(fillWith) && slice >= withExtra) + if (IsArgumentSet(fillWith, fillGiven) && slice >= withExtra) { column.push_back(fillWith); } @@ -1301,7 +1285,8 @@ InternalValue Slice::Batch(const InternalValue& baseVal, RenderContext& context) // Port of Jinja2's do_batch: rows of `linecount` items, the last one padded only with fill_with auto linecount = ConvertToInt(GetArgumentValue("linecount", context)); - InternalValue fillWith = GetArgumentValue("fill_with", context); + bool fillGiven = false; + InternalValue fillWith = GetArgumentValue("fill_with", context, fillGiven); InternalValueList resultList; InternalValueList row; @@ -1316,7 +1301,7 @@ InternalValue Slice::Batch(const InternalValue& baseVal, RenderContext& context) } if (!row.empty()) { - if (!IsEmpty(fillWith)) + if (IsArgumentSet(fillWith, fillGiven)) { while (static_cast(row.size()) < linecount) { @@ -1333,32 +1318,6 @@ StringFormat::StringFormat(const FilterParams& params) ParseParams(NoArguments(), params, ExtraArgs::Accept, &m_params); } -Tester::Tester(const FilterParams& params, Tester::Mode mode) - : m_mode(mode) -{ - // Python's select_or_reject takes the attribute and the test name by position and passes - // the other arguments, keywords included, to the test; without a test it calls bool() and - // drops them - const bool isAttrMode = mode == SelectAttrMode || mode == RejectAttrMode; - const size_t leadCount = std::min(params.posParams.size(), isAttrMode ? size_t{ 2 } : size_t{ 1 }); - m_hasAttribute = isAttrMode && leadCount > 0; - - FilterParams leadParams; - leadParams.posParams.assign(params.posParams.begin(), params.posParams.begin() + static_cast(leadCount)); - if (isAttrMode) - { - static const auto args = MakeArgumentsTable({ { "attribute", false }, { "tester", false } }); - ParseParams(args, leadParams); - } - else - { - static const auto args = MakeArgumentsTable({ { "tester", false } }); - ParseParams(args, leadParams); - } - - m_testingParams.posParams.assign(params.posParams.begin() + static_cast(leadCount), params.posParams.end()); - m_testingParams.kwParams = params.kwParams; -} InternalValue Tester::Filter(const InternalValue& baseVal, RenderContext& context) { @@ -2630,7 +2589,7 @@ InternalValue UserDefinedFilter::Filter(const InternalValue& baseVal, RenderCont return InternalValue(); } - CallParams tmpCallParams = helpers::EvaluateCallParams(m_callParams, context); + CallParams tmpCallParams = m_callParams.Evaluate(context); CallParams callParams; callParams.kwParams = std::move(tmpCallParams.kwParams); callParams.posParams.reserve(tmpCallParams.posParams.size() + 1); diff --git a/src/filters.h b/src/filters.h index 8cabfe8a..6870f982 100644 --- a/src/filters.h +++ b/src/filters.h @@ -12,21 +12,25 @@ #include #include #include +#include namespace jinja2 { inline namespace v2 { using FilterPtr = std::shared_ptr; -using FilterParams = CallParamsInfo; +using FilterParams = FunctionArgs; -extern FilterPtr CreateFilter(std::string_view filterName, const CallParamsInfo& params); +extern FilterPtr CreateFilter(std::string_view filterName, FunctionArgs params); // The same in the arena of the template that names the filter // A name that is no built-in filter makes a UserDefinedFilter and sets `*unknown` -extern NodeRef CreateFilter(NodeArena& nodes, std::string_view filterName, const CallParamsInfo& params, bool* unknown = nullptr); +extern NodeRef CreateFilter(NodeArena& nodes, std::string_view filterName, FunctionArgs params, bool* unknown = nullptr); // For filters named at render time (`map('name')`): a filter added to the environment of the // template comes first, as in Jinja2's env.filters -extern FilterPtr CreateFilter(const std::string& filterName, const CallParamsInfo& params, RenderContext& context); +extern FilterPtr CreateFilter(const std::string& filterName, FunctionArgs params, RenderContext& context); +// For a call that unpacks `*args` or `**kwargs` (docs/tasks/0306): the filter is made from the +// values each time the call runs. registered and unknown as for ExpressionFilter::Make +extern NodeRef CreateUnpackingFilter(NodeArena& nodes, const std::string& filterName, const CallArgsInfo& params, InternalValue registered, bool* unknown); namespace filters { @@ -56,10 +60,10 @@ class ApplyMacro : public FilterBase void VisitRefs(detail::RefChecker& refs) const override { FilterBase::VisitRefs(refs); - VisitCallParams(refs, m_mappingParams); + m_mappingParams.VisitRefs(refs); } private: - FilterParams m_mappingParams; + ForwardedArgs m_mappingParams; }; class Attribute : public FilterBase @@ -111,10 +115,10 @@ class Map : public FilterBase void VisitRefs(detail::RefChecker& refs) const override { FilterBase::VisitRefs(refs); - VisitCallParams(refs, m_mappingParams); + m_mappingParams.VisitRefs(refs); } private: - FilterParams m_mappingParams; + ForwardedArgs m_mappingParams; // map(attribute=...) looks items up like getattr with a fallback to [], not like attr bool m_byAttribute = false; }; @@ -257,13 +261,13 @@ class StringFormat : public FilterBase void VisitRefs(detail::RefChecker& refs) const override { FilterBase::VisitRefs(refs); - VisitCallParams(refs, m_params); + m_params.VisitRefs(refs); } // A literal printf-style format is parsed here, once void SetConstantBase(const InternalValue& base) override; private: - FilterParams m_params; + ForwardedArgs m_params; // The literal format, parsed; null unless it is a narrow string std::shared_ptr m_constFormat; }; @@ -285,11 +289,11 @@ class Tester : public FilterBase void VisitRefs(detail::RefChecker& refs) const override { FilterBase::VisitRefs(refs); - VisitCallParams(refs, m_testingParams); + m_testingParams.VisitRefs(refs); } private: Mode m_mode; - FilterParams m_testingParams; + ForwardedArgs m_testingParams; bool m_hasAttribute = false; }; @@ -340,15 +344,37 @@ class UserDefinedFilter : public FilterBase void VisitRefs(detail::RefChecker& refs) const override { FilterBase::VisitRefs(refs); - VisitCallParams(refs, m_callParams); + m_callParams.VisitRefs(refs); } private: std::string m_filterName; - FilterParams m_callParams; + ForwardedArgs m_callParams; InternalValue m_callable; }; +// A filter call that unpacks `*args` or `**kwargs`: Python binds it when it runs, so the filter +// is made then, from the values, and checks them as a call written out would be checked +class UnpackingFilter : public ExpressionFilter::IExpressionFilter +{ +public: + UnpackingFilter(std::string filterName, ArenaCallParams params, InternalValue registered) + : m_filterName(std::move(filterName)) + , m_params(params) + , m_registered(std::move(registered)) + { + } + + InternalValue Filter(const InternalValue& baseVal, RenderContext& context) override; + void VisitRefs(detail::RefChecker& refs) const override { m_params.VisitRefs(refs); } + +private: + std::string m_filterName; + ArenaCallParams m_params; + // The filter the environment adds under this name, if any + InternalValue m_registered; +}; + } // namespace filters } // namespace v2 } // namespace jinja2 diff --git a/src/function_base.cpp b/src/function_base.cpp new file mode 100644 index 00000000..2ed1651e --- /dev/null +++ b/src/function_base.cpp @@ -0,0 +1,113 @@ +#include "function_base.h" + +#include "expression_evaluator.h" +#include "internal_value.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace jinja2 +{ +inline namespace v2 +{ +// Out of line: every filter and test binds its call through ParseParams at Load. Inlined into +// each file that makes one, it grew those files enough to change GCC's inlining of their render +// paths (docs/tasks/0306) +template +void FunctionBase::CheckBound(const ArgumentsTable& argsInfo, const IsBound& isBound, const ArgumentInfo* multipleValues, std::size_t extraPos, const Extras& extraKw, ExtraArgs extraArgs) +{ + m_argsError.reset(); + for (std::size_t idx = 0; idx < argsInfo.size(); ++idx) + { + if (argsInfo[idx].mandatory && !isBound(idx)) + { + SetArgumentsError("missing required argument: '" + argsInfo[idx].name + "'"); + break; + } + } + // Python reports it before a missing argument + if (multipleValues) + { + SetArgumentsError("got multiple values for argument '" + multipleValues->name + "'"); + } + + auto isDeclared = [&argsInfo](const char* name) { + return std::any_of(argsInfo.begin(), argsInfo.end(), [name](auto& arg) { return arg.name == name; }); + }; + if (!m_argsError && extraArgs == ExtraArgs::Reject) + { + if (extraPos != 0 && !isDeclared("*args")) + { + SetArgumentsError("got " + std::to_string(extraPos) + " more positional argument(s) than it takes"); + } + else if (!extraKw.empty() && !isDeclared("**kwargs")) + { + SetArgumentsError("got an unexpected keyword argument '" + extraKw.begin()->first + "'"); + } + } +} + +bool FunctionBase::ParseParams(const ArgumentsTable& argsInfo, FunctionArgs params, ExtraArgs extraArgs, ForwardedArgs* extras) +{ + bool result = true; + params.Visit([&](const auto& args) { + using Args = std::decay_t; + if constexpr (std::is_same_v) + { + auto parsed = helpers::ParseCallParamsInfo(argsInfo, args, result); + CheckBound(argsInfo, [&parsed](std::size_t idx) { return static_cast(parsed.args[idx]); }, parsed.multipleValues, parsed.extraPosArgs.size(), parsed.extraKwArgs, extraArgs); + if (extras) + { + auto& forwarded = extras->EmplaceExprs(); + forwarded.kwParams = std::move(parsed.extraKwArgs); + forwarded.posParams = std::move(parsed.extraPosArgs); + } + m_args = BoundArguments(argsInfo, std::move(parsed.args)); + } + else + { + // Bound without the defaults, so that what the call passes can be told from them + std::vector declared(argsInfo.begin(), argsInfo.end()); + for (auto& info : declared) + { + info.defaultVal = InternalValue(); + } + auto parsed = helpers::ParseCallParams(declared, args, result); + std::vector> values(argsInfo.size()); + for (std::size_t idx = 0; idx < argsInfo.size(); ++idx) + { + auto arg = parsed.args.find(argsInfo[idx].name); + if (arg != parsed.args.end()) + { + values[idx] = std::move(arg->second); + } + } + const auto* multipleValues = parsed.multipleValues ? &argsInfo[static_cast(parsed.multipleValues - declared.data())] : nullptr; + CheckBound(argsInfo, [&values](std::size_t idx) { return values[idx].has_value(); }, multipleValues, parsed.extraPosArgs.size(), parsed.extraKwArgs, extraArgs); + if (extras) + { + CallParams forwarded; + forwarded.kwParams = std::move(parsed.extraKwArgs); + forwarded.posParams = std::move(parsed.extraPosArgs); + *extras = ForwardedArgs(std::move(forwarded)); + } + m_args = BoundArguments(argsInfo, std::move(values)); + } + }); + return result; +} + +InternalValue FunctionBase::GetArgumentValue(std::string_view argName, RenderContext& context, bool& given) const +{ + given = false; + return m_args.Evaluate(argName, context, InternalValue(), &given); +} + +} // namespace v2 +} // namespace jinja2 diff --git a/src/function_base.h b/src/function_base.h index f23e76e8..05eec2b2 100644 --- a/src/function_base.h +++ b/src/function_base.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -35,9 +36,84 @@ inline const ArgumentsTable& NoArguments() return table; } -// What a filter or tester call binds at Load: one node per declared parameter, in the order of -// its table, null where the call passes none. A parameter left unbound reads the table's default, -// so no default is stored, or made into a node, per instance +// The arguments a filter or test is made with: the expressions of its call, bound at Load, or +// the values of a call that unpacks `*args` or `**kwargs`, bound each time the call is evaluated +// (docs/tasks/0306). A view of arguments that outlive it +class FunctionArgs +{ +public: + FunctionArgs(const CallParamsInfo& exprs) noexcept // NOLINT(google-explicit-constructor) + : m_exprs(&exprs) + { + } + FunctionArgs(const CallParams& values) noexcept // NOLINT(google-explicit-constructor) + : m_values(&values) + { + } + + // Calls fn with the CallParamsInfo or the CallParams; both have posParams and kwParams + template + decltype(auto) Visit(Fn&& fn) const + { + return m_exprs ? std::forward(fn)(*m_exprs) : std::forward(fn)(*m_values); + } + [[nodiscard]] std::size_t PosCount() const { return m_exprs ? m_exprs->posParams.size() : m_values->posParams.size(); } + [[nodiscard]] std::size_t KwCount() const { return m_exprs ? m_exprs->kwParams.size() : m_values->kwParams.size(); } + +private: + const CallParamsInfo* m_exprs = nullptr; + const CallParams* m_values = nullptr; +}; + +// The arguments a filter passes on to the filter, test or callable it calls (map, select, +// applymacro, format, a filter of the environment): the expressions of a call bound at Load, or +// the values of a call that unpacks. The values are behind a pointer, so a call bound at Load +// pays one null check for them +class ForwardedArgs +{ +public: + ForwardedArgs() = default; + explicit ForwardedArgs(CallParamsInfo exprs) + : m_exprs(std::move(exprs)) + { + } + explicit ForwardedArgs(CallParams values) + : m_values(std::make_unique(std::move(values))) + { + } + + // NOLINTNEXTLINE(google-explicit-constructor) + operator FunctionArgs() const { return m_values ? FunctionArgs(*m_values) : FunctionArgs(m_exprs); } + [[nodiscard]] bool HasPositional() const { return m_values ? !m_values->posParams.empty() : !m_exprs.posParams.empty(); } + [[nodiscard]] bool HasKeywords() const { return m_values ? !m_values->kwParams.empty() : !m_exprs.kwParams.empty(); } + // Makes them expressions, to fill + CallParamsInfo& EmplaceExprs() + { + m_values.reset(); + m_exprs = CallParamsInfo(); + return m_exprs; + } + [[nodiscard]] CallParams Evaluate(RenderContext& context) const + { + if (m_values) + { + return *m_values; + } + return helpers::EvaluateCallParams(m_exprs, context); + } + + void VisitRefs(detail::RefChecker& refs) const { VisitCallParams(refs, m_exprs); } + +private: + CallParamsInfo m_exprs; + // Only a call that unpacks has them + std::unique_ptr m_values; +}; + +// What a filter or tester call binds: one node per declared parameter, in the order of its +// table, null where the call passes none; or, for a call that unpacks, the values it passes. A +// parameter left unbound reads the table's default, so no default is stored, or made into a +// node, per instance class BoundArguments { public: @@ -51,9 +127,15 @@ class BoundArguments std::copy(exprs.begin(), exprs.end(), m_exprs.get()); } } + BoundArguments(const ArgumentsTable& table, std::vector> values) + : m_table(&table) + , m_values(std::make_unique>>(std::move(values))) + { + } - // The value of a declared parameter: its argument, else its default, else defVal - InternalValue Evaluate(std::string_view name, RenderContext& context, InternalValue defVal) const + // The value of a declared parameter: its argument, else its default, else defVal. given, + // when not null, tells whether the call passes the parameter, an undefined value included + InternalValue Evaluate(std::string_view name, RenderContext& context, InternalValue defVal, bool* given = nullptr) const { if (!m_table) { @@ -68,8 +150,23 @@ class BoundArguments } if (m_exprs && m_exprs[idx]) { + if (given) + { + *given = true; + } return context.Nodes()[m_exprs[idx]].Evaluate(context); } + if (m_values) + { + if (const auto& value = (*m_values)[idx]) + { + if (given) + { + *given = true; + } + return value.value(); + } + } if (IsEmpty(info.defaultVal)) { return defVal; @@ -90,6 +187,8 @@ class BoundArguments private: const ArgumentsTable* m_table = nullptr; std::unique_ptr[]> m_exprs; + // Only a call that unpacks has them + std::unique_ptr>> m_values; }; class FunctionBase @@ -115,63 +214,34 @@ class FunctionBase // argsInfo must outlive the instance (a function-local static). The arguments beyond the // declared ones go to extras when it is given - bool ParseParams(const ArgumentsTable& argsInfo, const CallParamsInfo& params, ExtraArgs extraArgs = ExtraArgs::Reject, CallParamsInfo* extras = nullptr); + bool ParseParams(const ArgumentsTable& argsInfo, FunctionArgs params, ExtraArgs extraArgs = ExtraArgs::Reject, ForwardedArgs* extras = nullptr); InternalValue GetArgumentValue(std::string_view argName, RenderContext& context, InternalValue defVal = InternalValue()) const { return m_args.Evaluate(argName, context, std::move(defVal)); } + // The value of an optional parameter, and in given whether the call passes it: Python passes + // an undefined argument as the Undefined object, which a parameter left out is not + // (docs/tasks/0310). Read in the same lookup as the value + // Out of line, so as not to grow the render paths that call it + InternalValue GetArgumentValue(std::string_view argName, RenderContext& context, bool& given) const; + // Whether an optional argument read as `value` is set, as Python's `is not None` tells: None + // and a parameter left out are not, an undefined value passed is + [[nodiscard]] static bool IsArgumentSet(const InternalValue& value, bool given) { return !value.IsNone() && (!value.IsUndefined() || given); } void SetArgumentsError(std::string message) { m_argsError = std::make_unique(std::move(message)); } +private: + // The checks Python makes as it binds the call, once the parameters are bound + // isBound(idx): whether the call binds the parameter argsInfo[idx] + template + void CheckBound(const ArgumentsTable& argsInfo, const IsBound& isBound, const ArgumentInfo* multipleValues, std::size_t extraPos, const Extras& extraKw, ExtraArgs extraArgs); + +protected: BoundArguments m_args; // Only a call that does not fit allocates its message std::unique_ptr m_argsError; }; -inline bool FunctionBase::ParseParams(const ArgumentsTable& argsInfo, const CallParamsInfo& params, ExtraArgs extraArgs, CallParamsInfo* extras) -{ - bool result = true; - auto parsed = helpers::ParseCallParamsInfo(argsInfo, params, result); - - m_argsError.reset(); - for (std::size_t idx = 0; idx < argsInfo.size(); ++idx) - { - if (argsInfo[idx].mandatory && !parsed.args[idx]) - { - SetArgumentsError("missing required argument: '" + argsInfo[idx].name + "'"); - break; - } - } - // Python reports it before a missing argument - if (parsed.multipleValues) - { - SetArgumentsError("got multiple values for argument '" + parsed.multipleValues->name + "'"); - } - - auto isDeclared = [&argsInfo](const char* name) { - return std::any_of(argsInfo.begin(), argsInfo.end(), [name](auto& arg) { return arg.name == name; }); - }; - if (!m_argsError && extraArgs == ExtraArgs::Reject) - { - if (!parsed.extraPosArgs.empty() && !isDeclared("*args")) - { - SetArgumentsError("got " + std::to_string(parsed.extraPosArgs.size()) + " more positional argument(s) than it takes"); - } - else if (!parsed.extraKwArgs.empty() && !isDeclared("**kwargs")) - { - SetArgumentsError("got an unexpected keyword argument '" + parsed.extraKwArgs.begin()->first + "'"); - } - } - - if (extras) - { - extras->kwParams = std::move(parsed.extraKwArgs); - extras->posParams = std::move(parsed.extraPosArgs); - } - m_args = BoundArguments(argsInfo, std::move(parsed.args)); - return result; -} - } // namespace v2 } // namespace jinja2 diff --git a/src/serialize_filters.cpp b/src/serialize_filters.cpp index 250ad539..c7532c41 100644 --- a/src/serialize_filters.cpp +++ b/src/serialize_filters.cpp @@ -1490,15 +1490,15 @@ InternalValue StringFormat::Filter(const InternalValue& baseVal, RenderContext& format = *formatView; } // str() of an undefined is empty, and Python's '' % args has an argument left over - if (GetUndefinedInfo(baseVal) && !m_params.posParams.empty()) + if (GetUndefinedInfo(baseVal) && m_params.HasPositional()) { throw TemplateRuntimeError("not all arguments converted during string formatting"); } - if (!m_params.posParams.empty() && !m_params.kwParams.empty()) + if (m_params.HasPositional() && m_params.HasKeywords()) { throw TemplateRuntimeError("format(): can't handle positional and keyword arguments at the same time"); } - auto params = helpers::EvaluateCallParams(m_params, context); + auto params = m_params.Evaluate(context); bool markup = baseVal.IsMarkup(); std::string formatted; if (!params.kwParams.empty()) diff --git a/src/string_converter_filter.cpp b/src/string_converter_filter.cpp index 97bdaec5..8523b3fa 100644 --- a/src/string_converter_filter.cpp +++ b/src/string_converter_filter.cpp @@ -537,8 +537,10 @@ class Urlizer using String = std::basic_string; using View = std::basic_string_view; - Urlizer(std::optional trimUrlLimit, const String& rel, const String& target, std::vector extraSchemes) + // undefinedLimit: a trim_url_limit the call passes as undefined, which fails slicing a URL + Urlizer(std::optional trimUrlLimit, const String& rel, const String& target, std::vector extraSchemes, InternalValue undefinedLimit = InternalValue()) : m_trimUrlLimit(trimUrlLimit) + , m_undefinedLimit(std::move(undefinedLimit)) , m_extraSchemes(std::move(extraSchemes)) { if (!rel.empty()) @@ -839,6 +841,7 @@ class Urlizer [[nodiscard]] String TrimUrl(const String& url) const { + CheckUndefinedUse(m_undefinedLimit, UndefinedUse::Arithmetic); if (!m_trimUrlLimit) { return url; @@ -968,6 +971,7 @@ class Urlizer } std::optional m_trimUrlLimit; + InternalValue m_undefinedLimit; std::vector m_extraSchemes; String m_relAttr; String m_targetAttr; @@ -1568,6 +1572,18 @@ int64_t CodePointCount(const std::basic_string& str) return std::count_if(str.begin(), str.end(), [](CharT ch) { return (static_cast(ch) & 0xC0) != 0x80; }); } +// truncate's leeway; isSet: FunctionBase::IsArgumentSet. Unset is the policy's 5; Python adds +// an undefined one to the length, which fails (docs/tasks/0310) +int64_t TruncateLeeway(const InternalValue& leewayVal, bool isSet) +{ + if (!isSet) + { + return 5; + } + CheckUndefinedUse(leewayVal, UndefinedUse::Arithmetic); + return NumericArgument(leewayVal, "truncate", "leeway", NumberKind::Any); +} + } // namespace StringConverter::StringConverter(const FilterParams& params, StringConverter::Mode mode) @@ -1690,7 +1706,8 @@ InternalValue StringConverter::ApplyReplace(const InternalValue& baseVal, Render } } // str.replace's count: None (Jinja2's default, -1) or a negative count replaces every one - const auto countVal = GetArgumentValue("count", context); + bool countGiven = false; + const auto countVal = GetArgumentValue("count", context, countGiven); uint64_t count = std::numeric_limits::max(); if (const auto* intVal = GetIf(&countVal)) { @@ -1704,7 +1721,7 @@ InternalValue StringConverter::ApplyReplace(const InternalValue& baseVal, Render { throw TemplateRuntimeError("Python int too large to convert to C ssize_t"); } - else if (!IsEmpty(countVal)) + else if (IsArgumentSet(countVal, countGiven)) { throw TemplateRuntimeError("'"s + Apply(countVal) + "' object cannot be interpreted as an integer"); } @@ -1732,6 +1749,8 @@ InternalValue StringConverter::ApplyTruncate(const InternalValue& baseVal, Rende { // Jinja2 asserts length >= len(end) first, so end must have a length const auto endVal = GetArgumentValue("end", context); + // len(end) of an undefined end is 0, which StrictUndefined refuses + CheckUndefinedUse(endVal, UndefinedUse::Operator); if (!IsStringValue(endVal) && !endVal.IsUndefined()) { throw TemplateRuntimeError("object of type '"s + Apply(endVal) + "' has no len()"); @@ -1740,14 +1759,15 @@ InternalValue StringConverter::ApplyTruncate(const InternalValue& baseVal, Rende { return TruncateNonString(baseVal, context); } - return ConvertText(baseVal, [this, &baseVal, &context](auto srcStr) { + return ConvertText(baseVal, [this, &baseVal, &endVal, &context](auto srcStr) { using CharT = typename decltype(srcStr)::value_type; using String = std::basic_string; - auto leewayVal = this->GetArgumentValue("leeway", context); + bool leewayGiven = false; + auto leewayVal = this->GetArgumentValue("leeway", context, leewayGiven); auto length = NumericArgument(this->GetArgumentValue("length", context), "truncate", "length", NumberKind::Any); auto killWords = ConvertToBool(this->GetArgumentValue("killwords", context)); - auto end = GetAsSameString(srcStr, this->GetArgumentValue("end", context)).value_or(String()); - auto leeway = IsEmpty(leewayVal) ? 5 : NumericArgument(leewayVal, "truncate", "leeway", NumberKind::Any); + auto end = GetAsSameString(srcStr, endVal).value_or(String()); + auto leeway = TruncateLeeway(leewayVal, FunctionBase::IsArgumentSet(leewayVal, leewayGiven)); auto endLength = CodePointCount(end); // Jinja2 asserts both if (length < endLength || leeway < 0) @@ -1776,6 +1796,8 @@ InternalValue StringConverter::ApplyTruncate(const InternalValue& baseVal, Rende truncated = truncated.substr(0, space); } } + // s[:n] + end, which an undefined end fails + CheckUndefinedUse(endVal, UndefinedUse::Arithmetic); // Markup + end escapes end if (baseVal.IsMarkup()) { @@ -1790,9 +1812,10 @@ InternalValue StringConverter::ApplyTruncate(const InternalValue& baseVal, Rende // fails where the string code meets it (s[:n].rsplit(), s[:n] + end) InternalValue StringConverter::TruncateNonString(const InternalValue& baseVal, RenderContext& context) { - auto leewayVal = GetArgumentValue("leeway", context); + bool leewayGiven = false; + auto leewayVal = GetArgumentValue("leeway", context, leewayGiven); auto length = NumericArgument(GetArgumentValue("length", context), "truncate", "length", NumberKind::Any); - auto leeway = IsEmpty(leewayVal) ? 5 : NumericArgument(leewayVal, "truncate", "leeway", NumberKind::Any); + auto leeway = TruncateLeeway(leewayVal, IsArgumentSet(leewayVal, leewayGiven)); int64_t endLength = 0; ApplyStringConverter(GetArgumentValue("end", context), [&endLength](auto endStr) -> TargetString { endLength = CodePointCount(std::basic_string(endStr)); @@ -1878,7 +1901,8 @@ TargetString StringConverter::ApplyUrlize(const InternalValue& baseVal, RenderCo return ApplyStringConverter(baseVal, [this, &context, &baseVal](auto srcStr) -> TargetString { using CharT = typename decltype(srcStr)::value_type; using String = std::basic_string; - auto limitVal = this->GetArgumentValue("trim_url_limit", context); + bool limitGiven = false; + auto limitVal = this->GetArgumentValue("trim_url_limit", context, limitGiven); std::optional limit; if (!IsEmpty(limitVal)) { @@ -1944,7 +1968,8 @@ TargetString StringConverter::ApplyUrlize(const InternalValue& baseVal, RenderCo throw TemplateRuntimeError("'"s + Apply(schemesVal) + "' object is not iterable"); } - return Urlizer(limit, rel, target, std::move(extraSchemes))(srcStr, baseVal.IsMarkup()); + // Python slices each URL with the limit, which fails for an undefined one (docs/tasks/0310) + return Urlizer(limit, rel, target, std::move(extraSchemes), limitGiven ? limitVal : InternalValue())(srcStr, baseVal.IsMarkup()); }); } @@ -1981,8 +2006,10 @@ TargetString StringConverter::ApplyWordWrap(const InternalValue& baseVal, Render // Jinja2 wraps with the environment's newline_sequence unless wrapstring is given auto* callback = context.GetRendererCallback(); const std::string newline = callback ? callback->GetSettings().newlineSequence : "\n"s; - auto wrapString = - GetAsSameString(srcStr, this->GetArgumentValue("wrapstring", context)).value_or(std::basic_string(newline.begin(), newline.end())); + const auto wrapStringVal = this->GetArgumentValue("wrapstring", context); + // wrapstring.join(...), which an undefined wrapstring has not + CheckUndefinedUse(wrapStringVal, UndefinedUse::Method); + auto wrapString = GetAsSameString(srcStr, wrapStringVal).value_or(std::basic_string(newline.begin(), newline.end())); // Python raises "invalid width", except for an empty text it returns early if ((floatWidth ? *floatWidth <= 0 : width <= 0) && !srcStr.empty()) { @@ -2049,9 +2076,11 @@ InternalValue StringConverter::Convert(const InternalValue& baseVal, RenderConte { case TrimMode: return ConvertText(baseVal, [this, &context](auto srcStr) { - const auto charsVal = this->GetArgumentValue("chars", context); + bool charsGiven = false; + const auto charsVal = this->GetArgumentValue("chars", context, charsGiven); auto chars = GetAsSameString(srcStr, charsVal); - if (!chars && !IsEmpty(charsVal)) + // An undefined argument is Python's Undefined object, no str (docs/tasks/0310) + if (!chars && FunctionBase::IsArgumentSet(charsVal, charsGiven)) { throw TemplateRuntimeError("strip arg must be None or str"); } diff --git a/src/tester_factories.cpp b/src/tester_factories.cpp index 8136a2b8..a28b67f1 100644 --- a/src/tester_factories.cpp +++ b/src/tester_factories.cpp @@ -4,6 +4,7 @@ #include "testers.h" #include "expression_evaluator.h" +#include "function_base.h" #include "internal_value.h" #include "node_arena.h" #include "render_context.h" @@ -117,7 +118,7 @@ bool IsBuiltinTester(std::string_view testerName) return FindBuiltinTester(testerName) != nullptr; } -TesterPtr CreateTester(std::string testerName, const CallParamsInfo& params) +TesterPtr CreateTester(std::string testerName, FunctionArgs params) { if (const auto* entry = FindBuiltinTester(testerName)) { @@ -126,7 +127,7 @@ TesterPtr CreateTester(std::string testerName, const CallParamsInfo& params) return std::make_shared(std::move(testerName), params); } -NodeRef CreateTester(NodeArena& nodes, const std::string& testerName, const CallParamsInfo& params, bool* unknown) +NodeRef CreateTester(NodeArena& nodes, const std::string& testerName, FunctionArgs params, bool* unknown) { if (const auto* entry = FindBuiltinTester(testerName)) { @@ -139,7 +140,7 @@ NodeRef CreateTester(NodeArena& nodes, const std::string& return nodes.MakeObject(testerName, params); } -TesterPtr CreateTester(std::string testerName, const CallParamsInfo& params, RenderContext& context) +TesterPtr CreateTester(std::string testerName, FunctionArgs params, RenderContext& context) { auto* env = context.GetEnv(); auto registered = env ? env->FindTest(testerName) : std::optional(); @@ -151,5 +152,27 @@ TesterPtr CreateTester(std::string testerName, const CallParamsInfo& params, Ren return std::make_shared(std::move(testerName), params, std::move(callable)); } +NodeRef CreateUnpackingTester(NodeArena& nodes, const std::string& testerName, const CallArgsInfo& params, InternalValue registered, bool* unknown) +{ + if (unknown && !GetIf(®istered) && !FindBuiltinTester(testerName)) + { + *unknown = true; + } + // The arguments first: an arena makes one object at a time + const auto args = ArenaCallParams::Make(nodes, params); + return nodes.MakeObject(testerName, args, std::move(registered)); +} + +namespace testers +{ +bool UnpackingTester::Test(const InternalValue& baseVal, RenderContext& context) +{ + const auto args = helpers::EvaluateCallParams(m_params, context); + // Tests added to the environment take precedence over the builtins (IsExpression::Make) + const TesterPtr tester = GetIf(&m_registered) ? std::make_shared(m_testerName, args, m_registered) : CreateTester(m_testerName, args); + return tester->Test(baseVal, context); +} +} // namespace testers + } // namespace v2 } // namespace jinja2 diff --git a/src/testers.cpp b/src/testers.cpp index 289a015e..e857b086 100644 --- a/src/testers.cpp +++ b/src/testers.cpp @@ -7,6 +7,7 @@ #include "function_base.h" #include "host_object.h" #include "internal_value.h" +#include "python_format.h" #include "render_context.h" #include "template_dict.h" #include "undefined.h" @@ -41,7 +42,7 @@ Comparator::Comparator(const TesterParams& params, BinaryExpression::Operation o static const auto args = MakeArgumentsTable({ { "b", true } }); ParseParams(args, params); // Python's operator.eq and friends take their arguments by position only - if (!params.kwParams.empty()) + if (params.KwCount() != 0) { SetArgumentsError("takes no keyword arguments"); } @@ -552,6 +553,21 @@ bool ValueTester::Test(const InternalValue& baseVal, RenderContext& context) { // Jinja2: value % num == 0, with Python's errors for zero and non-numbers auto num = GetArgumentValue("num", context); + if (IsStringValue(baseVal)) + { + // str % num is printf-style formatting, which raises Python's TypeErrors; what it + // makes is a str, never equal to 0 + if (const auto narrow = AsStringView(baseVal)) + { + PythonPercentFormat(*narrow, num, baseVal.IsMarkup()); + } + else + { + PythonPercentFormat(ApplyStringConverter(baseVal, [](auto str) { return ConvertString(str); }), num, baseVal.IsMarkup()); + } + result = false; + break; + } auto rem = Apply2(baseVal, num, BinaryExpression::DivRemainder); result = ConvertToBool(Apply2(rem, InternalValue(static_cast(0)), BinaryExpression::LogicalEq)); break; @@ -652,7 +668,7 @@ bool UserDefinedTester::Test(const InternalValue& baseVal, RenderContext& contex throw TemplateRuntimeError("No test named '" + m_testerName + "' found."); } - CallParams tmpCallParams = helpers::EvaluateCallParams(m_callParams, context); + CallParams tmpCallParams = m_callParams.Evaluate(context); CallParams callParams; callParams.kwParams = std::move(tmpCallParams.kwParams); callParams.posParams.reserve(tmpCallParams.posParams.size() + 1); diff --git a/src/testers.h b/src/testers.h index 0c2e8298..7f4d0f5c 100644 --- a/src/testers.h +++ b/src/testers.h @@ -10,21 +10,25 @@ #include #include #include +#include namespace jinja2 { inline namespace v2 { using TesterPtr = std::shared_ptr; -using TesterParams = CallParamsInfo; +using TesterParams = FunctionArgs; -extern TesterPtr CreateTester(std::string testerName, const CallParamsInfo& params); +extern TesterPtr CreateTester(std::string testerName, FunctionArgs params); // The same in the arena of the template that names the test // A name that is no built-in test makes a UserDefinedTester and sets `*unknown` -extern NodeRef CreateTester(NodeArena& nodes, const std::string& testerName, const CallParamsInfo& params, bool* unknown = nullptr); +extern NodeRef CreateTester(NodeArena& nodes, const std::string& testerName, FunctionArgs params, bool* unknown = nullptr); // For tests named at render time (`select('name')`): a test added to the environment of the // template comes first, as in Jinja2's env.tests -extern TesterPtr CreateTester(std::string testerName, const CallParamsInfo& params, RenderContext& context); +extern TesterPtr CreateTester(std::string testerName, FunctionArgs params, RenderContext& context); +// For a call that unpacks `*args` or `**kwargs` (docs/tasks/0306): the test is made from the +// values each time the call runs. registered and unknown as for IsExpression::Make +extern NodeRef CreateUnpackingTester(NodeArena& nodes, const std::string& testerName, const CallArgsInfo& params, InternalValue registered, bool* unknown); // Whether `testerName` is one of the built-in tests bool IsBuiltinTester(std::string_view testerName); @@ -111,13 +115,34 @@ class UserDefinedTester : public TesterBase void VisitRefs(detail::RefChecker& refs) const override { TesterBase::VisitRefs(refs); - VisitCallParams(refs, m_callParams); + m_callParams.VisitRefs(refs); } private: std::string m_testerName; - TesterParams m_callParams; + ForwardedArgs m_callParams; InternalValue m_callable; }; +// A test call that unpacks `*args` or `**kwargs`: Python binds it when it runs, so the test is +// made then, from the values +class UnpackingTester : public IsExpression::ITester +{ +public: + UnpackingTester(std::string testerName, ArenaCallParams params, InternalValue registered) + : m_testerName(std::move(testerName)) + , m_params(params) + , m_registered(std::move(registered)) + { + } + + bool Test(const InternalValue& baseVal, RenderContext& context) override; + void VisitRefs(detail::RefChecker& refs) const override { m_params.VisitRefs(refs); } + +private: + std::string m_testerName; + ArenaCallParams m_params; + // The test the environment adds under this name, if any + InternalValue m_registered; +}; } // namespace testers } // namespace v2 } // namespace jinja2 diff --git a/test/parity/cases/calls.py b/test/parity/cases/calls.py index 7aa97fa6..79c9773c 100644 --- a/test/parity/cases/calls.py +++ b/test/parity/cases/calls.py @@ -48,6 +48,29 @@ ("filter_star_kwargs", "{{ 'a'|replace('a', **{'new': 'b'}) }}"), ("test_star_args", "{{ 4 is divisibleby(*[2]) }}"), ("filter_block_star_args", "{% filter replace(*['a', 'b']) %}a{% endfilter %}"), + ("filter_block_star_kwargs", "{% filter replace('a', **{'new': 'b'}) %}a{% endfilter %}"), + ("test_star_kwargs", "{{ 'T' if 4 is divisibleby(**{'num': 3}) else 'F' }}"), + ("filter_star_args_missing", "{{ 'a'|replace(*['a']) }}"), + ("filter_star_args_too_many", "{{ 'a'|replace(*['a', 'b', 1, 2]) }}"), + ("filter_star_args_multiple_values", "{{ 'a'|replace('a', *['b'], new='x') }}"), + ("filter_star_args_keywords", "{{ 'a'|replace(*[], **{'old': 'a', 'new': 'c'}) }}"), + ("filter_star_args_not_iterable", "{{ 'a'|replace(*1) }}"), + ("filter_star_kwargs_not_mapping", "{{ 'a'|replace(**[1]) }}"), + ("filter_star_args_undefined", "{{ 'x'|replace(*nope) }}"), + ("filter_star_args_unknown", "{{ 'a'|nosuch(*[1]) }}"), + ("test_star_args_unknown", "{{ 1 is nosuch(*[1]) }}"), + ("test_star_args_missing", "{{ 4 is divisibleby(*[]) }}"), + ("filter_star_args_chain", "{{ 'ab'|replace(*['a', 'x'])|replace(*['b', 'y'])|upper }}"), + ("filter_star_args_from_context", "{{ 'a'|replace(*args) }}", {"ctx": {"args": ["a", "z"]}}), + # Filters that pass arguments on take unpacked ones too + ("map_star_args", "{{ ['a', 'b']|map(*['replace', 'a', 'z'])|join }}"), + ("map_star_kwargs_attribute", "{{ [{'x': 1}, {}]|map(**{'attribute': 'x', 'default': 0})|join }}"), + ("select_star_args", "{{ [1, 2, 3]|select('divisibleby', *[3])|join }}"), + ("reject_star_args", "{{ [1, 2, 3]|reject(*['divisibleby', 2])|join }}"), + ("selectattr_star_args", "{{ [{'a': 1}, {'a': 2}]|selectattr(*['a', 'eq', 2])|map(attribute='a')|join }}"), + ("format_star_args", "{{ '%s-%s'|format(*[1, 2]) }}"), + ("format_star_kwargs", "{{ '%(a)s'|format(**{'a': 5}) }}"), + ("format_star_both", "{{ '%s'|format(*[1], **{'a': 2}) }}"), # The order of the arguments: Jinja2's "invalid syntax for function call expression" ("positional_after_keyword", "{{ range(stop=2, 1) }}"), ("positional_after_keyword_test", "{{ 1 is divisibleby(num=1, 2) }}"), diff --git a/test/parity/cases/filters.py b/test/parity/cases/filters.py index 1582c6d5..8d1d7795 100644 --- a/test/parity/cases/filters.py +++ b/test/parity/cases/filters.py @@ -399,6 +399,24 @@ ("urlize_extra_schemes_string", "{{ 'see ftp://x.org'|urlize(extra_schemes='ftp') }}"), ("urlize_extra_schemes_empty", "{{ 'see ftp://x.org'|urlize(extra_schemes='') }}|{{ 'see ftp://x.org'|urlize(extra_schemes=[]) }}"), ("trim_undefined_chars", "{{ 'xyx'|trim(nope) }}"), + # An undefined argument is Python's Undefined object, not an argument left out (0310) + ("replace_undefined_count", "{{ 'aaa'|replace('a', 'b', nope) }}"), + ("truncate_undefined_leeway", "{{ 'abc'|truncate(leeway=nope) }}"), + ("truncate_undefined_end", "{{ 'abc'|truncate(9, end=nope) }}|{{ 'abc def ghi jkl'|truncate(9, end=nope) }}"), + ("urlize_undefined_trim_url_limit", "{{ 'see nothing'|urlize(nope) }}|{{ 'see http://a.org/xyz'|urlize(nope) }}"), + ("truncate_undefined_end_strict", "{{ 'abc'|truncate(9, end=nope) }}", {"env": {"undefined": "strict"}}), + ("wordwrap_undefined_wrapstring", "{{ 'abc def'|wordwrap(3, wrapstring=nope) }}"), + ("join_undefined_attribute", "{{ [1, 2]|join(attribute=nope) }}|"), + ("max_undefined_attribute", "{{ [1, 2]|max(attribute=nope) }}"), + ("min_undefined_attribute", "{{ [1, 2]|min(attribute=nope) }}"), + ("sort_undefined_attribute", "{% for i in [2, 1]|sort(attribute=nope) %}{{ i }},{% endfor %}"), + ("unique_undefined_attribute", "{% for i in [1, 2]|unique(attribute=nope) %}{{ i }},{% endfor %}"), + ("sum_undefined_attribute", "{{ [1, 2]|sum(attribute=nope) }}"), + ("sum_undefined_start", "{{ []|sum(start=nope) }}|{{ [1, 2]|sum(start=nope) }}"), + ("sum_none_start", "{{ []|sum(start=none) }}|{{ [1, 2]|sum(start=none) }}"), + ("batch_undefined_fill_with", "{% for row in [1, 2, 3]|batch(2, fill_with=nope) %}{{ row|length }}{% for i in row %}{{ 'U' if i is undefined else i }}{% endfor %},{% endfor %}"), + ("slice_undefined_fill_with", "{% for col in [1, 2, 3]|slice(2, fill_with=nope) %}{{ col|length }}{% for i in col %}{{ 'U' if i is undefined else i }}{% endfor %},{% endfor %}"), + ("undefined_optional_arguments_fine", "{{ 2048|filesizeformat(nope) }}|{{ 'abcdef'|wordwrap(3, nope) }}|{{ 'http://a.org'|urlize(target=nope, rel=nope) }}|{{ [1]|tojson(nope) }}"), ("urlize_extra_schemes_int", "{{ 'see ftp://x.org'|urlize(extra_schemes=1) }}"), ("urlize_extra_schemes_dict", "{{ 'see ftp://x.org'|urlize(extra_schemes={'ab': 1}) }}"), ("urlize_extra_schemes_dict_keys", "{{ 'see ftp://x.org'|urlize(extra_schemes={'ftp:': 1}) }}"), diff --git a/test/parity/cases/tests.py b/test/parity/cases/tests.py index aa0e4953..45f03e0f 100644 --- a/test/parity/cases/tests.py +++ b/test/parity/cases/tests.py @@ -34,6 +34,8 @@ ("sameas_extra_arg", "{{ 1 is sameas(1, 2) }}"), # value % num == 0: a str is formatted, which fails without a format spec (docs/tasks/0308) ("divisibleby_string", "{{ 'a' is divisibleby 2 }}"), + ("divisibleby_format_string", "{{ 'T' if '%d' is divisibleby 2 else 'F' }}{{ 'T' if '%s%%' is divisibleby 3 else 'F' }}"), + ("divisibleby_empty_string", "{{ '' is divisibleby 2 }}"), ("divisibleby_arity", "{{ 6 is divisibleby() }}"), ("divisibleby_extra_arg", "{{ 6 is divisibleby(3, 2) }}"), ("in_arity", "{{ 1 is in() }}"), diff --git a/test/parity/divergences/calls.txt b/test/parity/divergences/calls.txt index b7efb254..a29b45ab 100644 --- a/test/parity/divergences/calls.txt +++ b/test/parity/divergences/calls.txt @@ -1,8 +1,4 @@ # Known divergences of Jinja2C++ from Python Jinja2 in the calls area (cases/calls.py). # Format and kinds: test/parity/README.md. -calls.filter_star_args rejects 0306 filters bind their arguments at Load, so *args in a filter call is a Load error -calls.filter_star_kwargs rejects 0306 filters bind their arguments at Load, so **kwargs in a filter call is a Load error -calls.test_star_args rejects 0306 tests bind their arguments at Load, so *args in a test call is a Load error -calls.filter_block_star_args rejects 0306 a filter block binds its arguments at Load, so *args in it is a Load error calls.dunder_call_is_a_bound_method output 0214 deliberate: f.__call__ is f itself, so it equals f; Python makes a bound method that does not diff --git a/test/parity/divergences/filters.txt b/test/parity/divergences/filters.txt index 38481716..22123142 100644 --- a/test/parity/divergences/filters.txt +++ b/test/parity/divergences/filters.txt @@ -1,6 +1,5 @@ # Known divergences of Jinja2C++ from Python Jinja2 in the filters area (cases/filters.py). # Format and kinds: test/parity/README.md. -filters.trim_undefined_chars accepts 0310 an argument passed as an undefined value reads as omitted, so trim(nope) strips whitespace; Python raises TypeError filters.select_single_pass output 0101 deliberate: a select/map/reject result can be iterated again; a Python generator is empty the second time wide.filters.format_c_surrogate output 0106 deliberate: % formats in UTF-8, where a lone surrogate from %c cannot survive the conversion back to a wide string diff --git a/test/parity/divergences/tests.txt b/test/parity/divergences/tests.txt index 312c3e4f..c3a5e60c 100644 --- a/test/parity/divergences/tests.txt +++ b/test/parity/divergences/tests.txt @@ -2,4 +2,3 @@ # Format and kinds: test/parity/README.md. tests.select_unread_extra_arg rejects 0101 deliberate: select runs its test eagerly, so an argument error raises though nothing reads the result -tests.divisibleby_string accepts 0308 divisibleby of a str answers False; Python's % formatting raises TypeError diff --git a/test/parity/expected/calls.json b/test/parity/expected/calls.json index 511aee5e..dc7ddf36 100644 --- a/test/parity/expected/calls.json +++ b/test/parity/expected/calls.json @@ -377,6 +377,227 @@ "templates": {}, "output": "b" }, + { + "id": "calls.filter_block_star_kwargs", + "template": "{% filter replace('a', **{'new': 'b'}) %}a{% endfilter %}", + "context": {}, + "env": {}, + "templates": {}, + "output": "b" + }, + { + "id": "calls.test_star_kwargs", + "template": "{{ 'T' if 4 is divisibleby(**{'num': 3}) else 'F' }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "F" + }, + { + "id": "calls.filter_star_args_missing", + "template": "{{ 'a'|replace(*['a']) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "do_replace() missing 1 required positional argument: 'new'" + } + }, + { + "id": "calls.filter_star_args_too_many", + "template": "{{ 'a'|replace(*['a', 'b', 1, 2]) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "do_replace() takes from 4 to 5 positional arguments but 6 were given" + } + }, + { + "id": "calls.filter_star_args_multiple_values", + "template": "{{ 'a'|replace('a', *['b'], new='x') }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "do_replace() got multiple values for argument 'new'" + } + }, + { + "id": "calls.filter_star_args_keywords", + "template": "{{ 'a'|replace(*[], **{'old': 'a', 'new': 'c'}) }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "c" + }, + { + "id": "calls.filter_star_args_not_iterable", + "template": "{{ 'a'|replace(*1) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "Value after * must be an iterable, not int" + } + }, + { + "id": "calls.filter_star_kwargs_not_mapping", + "template": "{{ 'a'|replace(**[1]) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "jinja2.filters.do_replace() argument after ** must be a mapping, not list" + } + }, + { + "id": "calls.filter_star_args_undefined", + "template": "{{ 'x'|replace(*nope) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "do_replace() missing 2 required positional arguments: 'old' and 'new'" + } + }, + { + "id": "calls.filter_star_args_unknown", + "template": "{{ 'a'|nosuch(*[1]) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "compile", + "type": "TemplateAssertionError", + "message": "No filter named 'nosuch'." + } + }, + { + "id": "calls.test_star_args_unknown", + "template": "{{ 1 is nosuch(*[1]) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "compile", + "type": "TemplateAssertionError", + "message": "No test named 'nosuch'." + } + }, + { + "id": "calls.test_star_args_missing", + "template": "{{ 4 is divisibleby(*[]) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "test_divisibleby() missing 1 required positional argument: 'num'" + } + }, + { + "id": "calls.filter_star_args_chain", + "template": "{{ 'ab'|replace(*['a', 'x'])|replace(*['b', 'y'])|upper }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "XY" + }, + { + "id": "calls.filter_star_args_from_context", + "template": "{{ 'a'|replace(*args) }}", + "context": { + "args": [ + "a", + "z" + ] + }, + "env": {}, + "templates": {}, + "output": "z" + }, + { + "id": "calls.map_star_args", + "template": "{{ ['a', 'b']|map(*['replace', 'a', 'z'])|join }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "zb" + }, + { + "id": "calls.map_star_kwargs_attribute", + "template": "{{ [{'x': 1}, {}]|map(**{'attribute': 'x', 'default': 0})|join }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "10" + }, + { + "id": "calls.select_star_args", + "template": "{{ [1, 2, 3]|select('divisibleby', *[3])|join }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "3" + }, + { + "id": "calls.reject_star_args", + "template": "{{ [1, 2, 3]|reject(*['divisibleby', 2])|join }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "13" + }, + { + "id": "calls.selectattr_star_args", + "template": "{{ [{'a': 1}, {'a': 2}]|selectattr(*['a', 'eq', 2])|map(attribute='a')|join }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "2" + }, + { + "id": "calls.format_star_args", + "template": "{{ '%s-%s'|format(*[1, 2]) }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "1-2" + }, + { + "id": "calls.format_star_kwargs", + "template": "{{ '%(a)s'|format(**{'a': 5}) }}", + "context": {}, + "env": {}, + "templates": {}, + "output": "5" + }, + { + "id": "calls.format_star_both", + "template": "{{ '%s'|format(*[1], **{'a': 2}) }}", + "context": {}, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "FilterArgumentError", + "message": "can't handle positional and keyword arguments at the same time" + } + }, { "id": "calls.positional_after_keyword", "template": "{{ range(stop=2, 1) }}", diff --git a/test/parity/expected/filters.json b/test/parity/expected/filters.json index 3e50d6ba..fa996442 100644 --- a/test/parity/expected/filters.json +++ b/test/parity/expected/filters.json @@ -24772,6 +24772,1178 @@ "message": "strip arg must be None or str" } }, + { + "id": "filters.replace_undefined_count", + "template": "{{ 'aaa'|replace('a', 'b', nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "'Undefined' object cannot be interpreted as an integer" + } + }, + { + "id": "filters.truncate_undefined_leeway", + "template": "{{ 'abc'|truncate(leeway=nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "'nope' is undefined" + } + }, + { + "id": "filters.truncate_undefined_end", + "template": "{{ 'abc'|truncate(9, end=nope) }}|{{ 'abc def ghi jkl'|truncate(9, end=nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "'nope' is undefined" + } + }, + { + "id": "filters.urlize_undefined_trim_url_limit", + "template": "{{ 'see nothing'|urlize(nope) }}|{{ 'see http://a.org/xyz'|urlize(nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "'nope' is undefined" + } + }, + { + "id": "filters.truncate_undefined_end_strict", + "template": "{{ 'abc'|truncate(9, end=nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": { + "undefined": "strict" + }, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "'nope' is undefined" + } + }, + { + "id": "filters.wordwrap_undefined_wrapstring", + "template": "{{ 'abc def'|wordwrap(3, wrapstring=nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "'nope' is undefined" + } + }, + { + "id": "filters.join_undefined_attribute", + "template": "{{ [1, 2]|join(attribute=nope) }}|", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "output": "|" + }, + { + "id": "filters.max_undefined_attribute", + "template": "{{ [1, 2]|max(attribute=nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "int object has no element Undefined" + } + }, + { + "id": "filters.min_undefined_attribute", + "template": "{{ [1, 2]|min(attribute=nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "int object has no element Undefined" + } + }, + { + "id": "filters.sort_undefined_attribute", + "template": "{% for i in [2, 1]|sort(attribute=nope) %}{{ i }},{% endfor %}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "output": "2,1," + }, + { + "id": "filters.unique_undefined_attribute", + "template": "{% for i in [1, 2]|unique(attribute=nope) %}{{ i }},{% endfor %}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "output": "1," + }, + { + "id": "filters.sum_undefined_attribute", + "template": "{{ [1, 2]|sum(attribute=nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "int object has no element Undefined" + } + }, + { + "id": "filters.sum_undefined_start", + "template": "{{ []|sum(start=nope) }}|{{ [1, 2]|sum(start=nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "'nope' is undefined" + } + }, + { + "id": "filters.sum_none_start", + "template": "{{ []|sum(start=none) }}|{{ [1, 2]|sum(start=none) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "unsupported operand type(s) for +: 'NoneType' and 'int'" + } + }, + { + "id": "filters.batch_undefined_fill_with", + "template": "{% for row in [1, 2, 3]|batch(2, fill_with=nope) %}{{ row|length }}{% for i in row %}{{ 'U' if i is undefined else i }}{% endfor %},{% endfor %}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "output": "212,23U," + }, + { + "id": "filters.slice_undefined_fill_with", + "template": "{% for col in [1, 2, 3]|slice(2, fill_with=nope) %}{{ col|length }}{% for i in col %}{{ 'U' if i is undefined else i }}{% endfor %},{% endfor %}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "output": "212,23U," + }, + { + "id": "filters.undefined_optional_arguments_fine", + "template": "{{ 2048|filesizeformat(nope) }}|{{ 'abcdef'|wordwrap(3, nope) }}|{{ 'http://a.org'|urlize(target=nope, rel=nope) }}|{{ [1]|tojson(nope) }}", + "context": { + "x": 3, + "neg": -3, + "y": 2.567, + "s": "Hello World", + "l": [ + 3, + 1, + 2 + ], + "dup": [ + 3, + 1, + 3, + 2, + 1 + ], + "words": [ + "b", + "A", + "c" + ], + "d": { + "b": 2, + "a": 1, + "C": 3 + }, + "e": [], + "n": null, + "html": "Tom & \"Jerry\"", + "long": "The quick brown fox jumps over the lazy dog", + "users": [ + { + "name": "bob", + "age": 30, + "city": "Oslo" + }, + { + "name": "alice", + "age": 25, + "city": "Rome" + }, + { + "name": "carl", + "age": 30, + "city": "Oslo" + }, + { + "name": "dan", + "age": 41 + } + ], + "lines": "a\nb\n\nc", + "nums_str": [ + "10", + "9", + "100" + ] + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "UndefinedError", + "message": "'nope' is undefined" + } + }, { "id": "filters.urlize_extra_schemes_int", "template": "{{ 'see ftp://x.org'|urlize(extra_schemes=1) }}", diff --git a/test/parity/expected/tests.json b/test/parity/expected/tests.json index b7ae1dd2..7f5f105f 100644 --- a/test/parity/expected/tests.json +++ b/test/parity/expected/tests.json @@ -727,6 +727,56 @@ "message": "not all arguments converted during string formatting" } }, + { + "id": "tests.divisibleby_format_string", + "template": "{{ 'T' if '%d' is divisibleby 2 else 'F' }}{{ 'T' if '%s%%' is divisibleby 3 else 'F' }}", + "context": { + "x": 3, + "y": 2.5, + "s": "hello", + "S": "HELLO", + "l": [ + 1, + 2 + ], + "d": { + "a": 1 + }, + "n": null, + "t": true, + "e": "" + }, + "env": {}, + "templates": {}, + "output": "FF" + }, + { + "id": "tests.divisibleby_empty_string", + "template": "{{ '' is divisibleby 2 }}", + "context": { + "x": 3, + "y": 2.5, + "s": "hello", + "S": "HELLO", + "l": [ + 1, + 2 + ], + "d": { + "a": 1 + }, + "n": null, + "t": true, + "e": "" + }, + "env": {}, + "templates": {}, + "error": { + "phase": "render", + "type": "TypeError", + "message": "not all arguments converted during string formatting" + } + }, { "id": "tests.divisibleby_arity", "template": "{{ 6 is divisibleby() }}", diff --git a/test/recursion_limits_test.cpp b/test/recursion_limits_test.cpp index 747629b1..d27c58b2 100644 --- a/test/recursion_limits_test.cpp +++ b/test/recursion_limits_test.cpp @@ -172,6 +172,24 @@ TEST_F(RecursionLimitsTest, DeepTupleKeyRendersOrIsRenderError) } } +TEST_F(RecursionLimitsTest, DeepMapOfMapsRendersOrIsRenderError) +{ + // Each 'map' argument nests one more map filter, applied as the outer one runs: unpacked + // arguments (docs/tasks/0306) let a short template, or context data, nest it deep + const std::string source = "{{ 'a'|map(*(['map'] * 5000 + ['upper']))|list|length }}"; + Template tpl(&m_env); + ASSERT_TRUE(tpl.Load(source).has_value()); + auto result = tpl.RenderAsString({}); + if (result.has_value()) + { + EXPECT_EQ("1", result.value()); + } + else + { + EXPECT_EQ(ErrorCode::RecursionLimitExceeded, result.error().GetCode()); + } +} + TEST_F(RecursionLimitsTest, InfiniteRecursionIsRenderError) { AddFile("include_self", "{% include 'include_self' %}");