fix: lstrip_blocks, strftime_now, loop controls, caller() and filters on none - #26
Merged
Merged
Conversation
…re an else-block signal, escape loop output once
This was referenced Sep 25, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #24
Closes #25
Closes #27
Closes #28
Closes #29
Five parity fixes. Reference: llama.cpp 7fe450e1
test-jinja, built locally with a render hook. Jinja2 3.1.6 runs asSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True, loopcontrols), llama.cpp's-pysetup. Where the two disagree, dinja follows llama.cpp, except for one llama.cpp bug, noted below.What changed
lstrip_blocks(lstrip_blocks keeps indentation before a tag on the line after a block tag #24): the lexer decided whether a{%or{#starts a line aftertrim_blockshad already removed the text's leading newline. Its fallback, the character before the text, is always the previous tag's}, so the indentation was never removed. The check now runs on the raw text first, as in llama.cpp'slexer.cpp, for every tag and comment.strftime_now(strftime_now ignores most strftime conversions (%b, %B, %a, %c, %%, ...) #25): newlib/src/runtime/strftime.dart, a port of glibc 2.41strftimein the C locale, which llama.cpp calls. It covers every conversion, theE/Omodifiers, flags and widths. As in llama.cpp, it throws without a string argument and on an empty or 100+ byte result. Before,strftime_now()fell back to a default format. The README now says it reads the system clock in the local time zone; values passed torender(such as llamadart'snow) don't change it.continue/break(continue and break drop the output of the current loop pass #27): the signal now carries the output the pass rendered before it. Afor'selseruns unless some pass finishes withoutcontinueorbreak, as in both engines.{% if %}x{% continue %}{% endif %}) is kept, as in Jinja2. llama.cpp drops the output of the nested blocks, which I count as a llama.cpp bug. No template in either corpus renders anything before a signal in a nested block.breakorcontinuethat leaves a captured body (blockset,filterblock, macro,caller()body) now discards the captured text, as in llama.cpp and Jinja2. At 2d60d4d it leaked into the loop output.b{% set s %}a{% continue %}{% endset %}in a loop givesbb, as in both.caller()body, Jinja2 rejectscontinueoutside a loop, so this follows llama.cpp alone.break/continuein afor'selseblock now keeps the loop's earlier output. For{% for i in [1, 2] %}{% for j in [1] %}a{% continue %}{% else %}x{% continue %}{% endfor %}|{% endfor %}, dinja gives Jinja2'saxax. llama.cpp gives'', its nested-block bug, and base gave||.foroutput is now marked safe, so input-marked values in the loop body or itselseblock are escaped once. Base already double-escaped the body.caller()(caller() renders None for a body statement without output #28): renders its body withexecStatements, as a macro body does. Statements without output add nothing, andJinjaString.uservalues are now escaped. Before, they were not escaped.select,reject,selectattr,rejectattranduniquenow return[]for none.mapanditemsalready did. This matches llama.cpp'svalue_none_t::get_builtins.defaultnow also replaces none, as llama.cpp'sdefault_valuedoes (Jinja2 keeps none).strftime_nowand the system clock.## Unreleased. origin/main (docs: lead README and pubspec with chat template rendering #23) is merged in, not rebased.Not changed:
none | list,join,length,count,first,last,sort,reverse,sum,min,maxanddictsort. llama.cpp and Jinja2 both raise for these; dinja still returns a value ([None],None,0,""or[]). Making them raise would turn renders that work today into errors, so I left them for a separate decision.Parity with llama.cpp 7fe450e1
models/templates) × 4 shapes: same / both error / differraise_exceptionfor shapes they reject (gemma-2 system role, gemma alternation, Mistral tool-call IDs).a{% if true %}b{% endif %}rendersbin llama.cpp; dinja keeps Jinja2'sab, pinned by a test.{{ none | selectattr("a") }}(printed without| list) now shows[]; llama.cpp prints any empty list as"". This follows dinja's existing Jinja2-style list printing ({{ [1, 2] }}is[1, 2], while llama.cpp gives12), which this PR doesn't change.strftime: 9,382 formats × 18 timestamps × 5 time zones gives 844,380 cases against glibc 2.41 (Docker
gcc:14), with 0 differences.%ZisDateTime.timeZoneName, whose format depends on the platform. On the web it is the browser's long name (Eastern Daylight Time, where llama.cpp givesEDT), as the README and Dartdoc now say. No template uses%Z.Tests
Every expected value was checked against llama.cpp and Jinja2 (or glibc for
strftime), except where a comment names the one engine that gives it.lstrip_blocksstrftime_nowstrftimecases for the new function)%bgives the full name → 20continue/breakcaller()selectattrpasses none through → 3;defaultkeeps none → 1break/continue(audit B1) andelse-block signalsllamadart consumer check
llamadart main (c71de5976), with
dependency_overridesto dinja main (74d487e), 2d60d4d and b3eaffc. b3eaffc and 2d60d4d give identical results: the same test results and 0 of 1,096 renders differ. Against main:test/unit/core/template: 661 pass and 2 fail, identical on both. The 2 (Llama 3.2/Ministral 3 renders a tool-call conversation) expect the compacttojsonof the pinned 1.1.0.ChatTemplateEngine.render, 4 conversations × tools on/off, 1,096 renders. 94 differ, and each was checked:%B→September(strftime_now ignores most strftime conversions (%b, %B, %a, %c, %%, ...) #25).Environment: ipythonand an empty function list (none | selectattr(...) | list gives [None] instead of [] #29).content: null, andmessage.content | default('')now gives''. Main raisedCall to non-function: Undefined. The new output equals llama.cpp's for the same input.Gates
dart formatclean ·dart analyzeno issues ·dart test+1069 ~11 · Chrome JS and Wasm (test/src, cross tests) +967 ~11 each ·dart pub publish --dry-run0 warnings · pana 160/160 · README and example snippets run with the stated output.