krikri-jinja
Version, currently 0.4.2739 versions
- 0.4.36latestNov 3, 2026
- 0.4.35not indexedNov 3, 2026
- 0.4.34not indexedNov 3, 2026
- 0.4.33Oct 11, 2026
- 0.4.32not indexedOct 12, 2026
- 0.4.31Oct 6, 2026
- 0.4.30not indexedOct 6, 2026
- 0.4.29not indexedOct 6, 2026
- 0.4.28not indexedOct 6, 2026
- 0.4.27Oct 4, 2026
- 0.4.26not indexedOct 4, 2026
- 0.4.24Sep 28, 2026
- 0.4.23not indexedSep 28, 2026
- 0.4.22not indexedSep 28, 2026
- 0.4.21not indexedSep 28, 2026
- 0.4.20not indexedSep 28, 2026
- 0.4.19not indexedSep 28, 2026
- 0.4.18not indexedSep 28, 2026
- 0.4.17not indexedSep 28, 2026
- 0.4.16not indexedSep 28, 2026
- 0.4.15not indexedSep 28, 2026
- 0.4.14not indexedSep 28, 2026
- 0.4.13not indexedSep 28, 2026
- 0.4.12not indexedSep 28, 2026
- 0.4.11not indexedSep 28, 2026
- 0.4.10not indexedSep 28, 2026
- 0.4.9not indexedSep 28, 2026
- 0.4.8not indexedSep 28, 2026
- 0.4.7not indexedSep 28, 2026
- 0.4.6not indexedSep 28, 2026
- 0.4.5not indexedSep 28, 2026
- 0.4.4not indexedSep 28, 2026
- 0.4.3not indexedSep 28, 2026
- 0.4.2not indexedSep 28, 2026
- 0.4.1not indexedSep 28, 2026
- 0.4.0not indexedSep 28, 2026
- 0.3.2not indexedSep 28, 2026
- 0.3.1not indexedSep 28, 2026
- 0.3.0not indexedSep 28, 2026
github.com/weirdbricks/krikri-jinja
Clean-room Jinja2 template engine in Crystal. Implemented from the Jinja2 Template Designer / Developer documentation, not from any existing Jinja2 implementation's source.
Installation
# Add this to your shard.yml
dependencies:
krikri-jinja:
github: weirdbricks/krikri-jinja
version: ~> 0.4.27Then run:
shards installshard.yml
- Crystal
>= 1.10.0- License
- MIT
- Author
- Krikri Contributors
Dependencies
Development Dependencies
- minitest~> 1.6github: ysbaddaden/minitest.crdev
README
This README is the one indexed from the repository at its latest ref, not from the tag for this version.
krikri-jinja
A from-scratch Jinja2 template engine in Crystal, written as a clean-room reimplementation for the krikri-playbook project.
Clean-room basis
This implementation is written from the Jinja2 Template Designer Documentation and the Jinja2 Template Developer Documentation - the public specifications of what Jinja2 templates must mean - not from the source code of any existing Jinja2 implementation. No implementation source was read or referenced while writing the lexer, parser, or evaluator. Behavior is verified against the documented semantics and against expected-output examples written from the docs.
Status (v0.4.15)
Implemented:
- Lexer for
{{ ... }},{% ... %},{# ... #}with configurable delimiters - Full expression grammar: arithmetic (
+ - * / // % **), comparisons (including chained),in/not in,is/is nottests,~concat, filters with args/kwargs, attribute/item access, slicing (incl. step), calls with positional/keyword arguments and*args/**kwargsexpansion, conditional expressions (a if b else c), list / dict / tuple literals, adjacent string concatenation - Statements:
if/elif/else,for(withiffilter,else, unpacking),set(incl.namespaceattribute assignment),block(incl.super(),required, andscopedmodifiers),macro(with defaults),call(withcaller()),filter,with,include(ignore missing,with/without context),extends,import/from ... import,do,autoescape loopvariable:index,index0,revindex,revindex0,first,last,length,previtem,nextitem,depth,depth0- 54 built-in filters (upper, lower, sort, map, select, groupby, batch, slice, join, default, tojson, ...) and 39 built-in tests
- Globals:
range,dict,namespace,cycler,joiner,lipsum,random - Template inheritance (multi-level
extends+ block override), includes, imports;DictLoader/FileSystemLoader - Python-style semantics: truthiness,
True == 1, floor division/modulo sign behavior,Nonestringification, CPython string repr, and arbitrary-precision integer arithmetic beyondInt64
Additional compatibility features:
{% raw %}blocks (including{%- raw -%}marker forms)- Whitespace control:
{{- -}},{%- -%}markers,trim_blocks,lstrip_blocks,keep_trailing_newline(default false, matching the documented environment default) - Custom delimiters per engine (
block_start/end,var_start/end,comment_start/end) - Recursive
{% for %}with{{ loop(children) }}recursion andloop.depth;loop.cycle(...)andloop.changed(...) - Macro introspection:
varargs,kwargs,name,caller {% call(x, y) macro() %}caller-body parameters{% set x %}...{% endset %}block form{% include ['a.html', 'b.html'] %}fallback listsnotbinds looser than comparisons (not x in y==not (x in y))- Hex/octal/binary integer literals
- Filters:
dictsort,filesizeformat,forceescape,center,random(sequence choice plus Ansible-compatiblerandom(n),random(start, stop),random(start, stop, step)ranges and deterministicseed=),pprint,urlize,int(base=),unique(attribute=),formatwith full %-conversion support,Markup-awaresafe/escapeunder autoescape - Tests:
escaped,filter,test,sameas Engine#render(name)for loader-based rendering, engine-levelautoescape,StrictUndefined, structured expression evaluation, and engine-local filter/test/global/function registration
Known gaps: {% trans %} / i18n (out of scope), spaceless,
debug tag, truncate nowrap, groupby secondary sort guarantees,
wordwrap break_long_words tuning, and complex results from negative
fractional powers such as -5 ** 2.5. Lazy filter generators are iterated by
templates but do not reproduce Python's process-specific <generator ... at 0x...> repr. Integers beyond Int64 use a dedicated runtime value type, so
digit-shaped strings remain ordinary strings.
Usage
require "krikri-jinja"
KrikriJinja.render("Hello {{ name | upper }}!", {"name" => "world"})
# => "Hello WORLD!"
engine = KrikriJinja::Engine.new(KrikriJinja::FileSystemLoader.new("templates"))
engine.render_string("{% extends 'base.html' %}")
Structured expressions and extensions
Expression evaluation returns a typed JSON::Any value instead of rendered
text. JSON null remains distinct from an undefined expression result:
lenient undefined is returned as nil, while strict undefined raises a
TemplateError.
vars = {"items" => JSON.parse(%([{"name": "a"}, {"name": "b"}]))}
result = KrikriJinja.evaluate_expression("items | map(attribute='name')", vars)
result.to_json
# => ["a","b"]
Use an engine to configure strict undefined and register extensions locally.
The register_json_* APIs accept only JSON-compatible values, so callers do
not need to construct internal AnyValue instances.
engine = KrikriJinja::Engine.new(
KrikriJinja::DictLoader.new({"partial.html" => "hello"}),
undefined: KrikriJinja::StrictUndefined.new
)
engine.register_json_filter("exclaim") do |value, _args, _kwargs|
JSON::Any.new("#{value.as_s}!")
end
engine.register_json_test("text") do |value, _args, _kwargs|
value.raw.is_a?(String)
end
engine.register_json_function("identity") do |args, _kwargs|
args[0]
end
engine.register_global("answer", JSON::Any.new(42))
engine.register_loader_function("partial", "partial.html")
engine.render_string("{{ 'hello' | exclaim }} {{ 'hello' is text }} {{ identity([1, true]) | tojson }} {{ partial() }}")
# => "hello! True [1,true] hello"
register_filter, register_test, and register_function expose the native
value API for extensions that need AnyValue; register_global accepts plain
values and deep-converts nested arrays and hashes. known_filter? and
known_test? support compile-time feature checks. TemplateError#to_json
returns a structured error object containing its kind, message, line, and
optional operation or template metadata.
Shared default engine
Callers that do not want to build and pass an engine at every call site can
register extensions once on the engine shared by the module-level render,
evaluate_expression, and evaluate_expression_result helpers:
KrikriJinja.register_default_json_filter("exclaim") do |value, _args, _kwargs|
JSON::Any.new("#{value.as_s}!")
end
KrikriJinja.render("{{ 'hello' | exclaim }}") # => "hello!"
register_default_test, register_default_filter, register_default_json_test,
register_default_json_function, register_default_function, register_default_global,
and register_default_loader_function mirror their Engine counterparts;
default_known_filter? / default_known_test? and reset_default_engine round
out the surface. Each call to evaluate_expression still gets its own engine,
derived from the default one, so per-call strict: and loader: arguments keep
working and cannot leak state into the shared engine.
Host context
Hosts that need controller-side state inside a registration (variable scope,
role paths, plugin runners) subclass KrikriJinja::HostContext and hand an
instance to the call:
class AnsibleContext < KrikriJinja::HostContext
getter vars : Hash(String, JSON::Any)
def initialize(@vars)
end
end
KrikriJinja.register_default_function("lookup") do |args, kwargs, ctx|
host = ctx.host_context.as(AnsibleContext)
# ... call out to the controller's lookup plugin with host.vars ...
end
KrikriJinja.render(template, vars, host_context: AnsibleContext.new(vars))
render, evaluate_expression, evaluate_expression_result, and
evaluate_expression_value all accept host_context:; the engine passes it to
every registered filter, test, and function it invokes, including inside
includes, imports, and macros.
Undefined versus null
Explicit {%+ ... +%} KEEP markers are supported: a tag closed with +%}
does not eat the newline that follows it, even with trim_blocks enabled.
A {%-/-%} marker strips only the whitespace that directly abuts its tag.
When trim_blocks (or a preceding -%}) has already consumed the whitespace
between a tag and a following {%-, the {%- has nothing left to strip and
does not reach back into earlier content: a {% for ... -%} ... {% endfor %} {%- endif %} block keeps each iteration's own trailing newline even though
trim_blocks ate the newline between {% endfor %} and {%- endif %}.
x in list never raises for an undefined left operand; it reports False,
matching Python and real Ansible.
KrikriJinja.ansible_strict_undefined is Ansible's own strict undefined:
it raises when an undefined value is used, and chains through attribute and
subscript access on the way there, so x | default(other.thing.y) stays lazy
when x is defined. Plain StrictUndefined keeps Jinja2's non-chaining
behavior.
Strict-undefined failures now name the variable that was missing ("'missing_thing' is undefined"), matching real Ansible's own message.
evaluate_expression returns nil both for a JSON null result and for an
undefined expression under lenient undefined. Callers that must tell those
apart (for strict-conditional semantics) use evaluate_expression_result:
KrikriJinja.evaluate_expression_result("missing").undefined? # => true
KrikriJinja.evaluate_expression_result("none").undefined? # => false
KrikriJinja.evaluate_expression_result("none").value.to_json # => "null"
Differential testing against real Jinja2
compare/ renders a shared case corpus with both real Jinja2 (via
python3 with the jinja2 package installed) and this engine, then diffs
the outputs byte for byte:
./compare/run.sh
# total: 2,000 identical: 1,756 both-error: 244 divergent: 0
compare/gen_cases.pygeneratescases.json(2,000 cases: literals, arbitrary-precision arithmetic, filters, tests, statements, string and integer methods, whitespace control, raw blocks, inheritance/includes/ imports, autoescape, recursive loops, and fuzz-class regressions)compare/render.pyrenders with real Jinja2 (same environment defaults:trim_blocks=false,keep_trailing_newline=false, defaultUndefined)compare/render.crrenders with krikri-jinjacompare/compare.pydiffs; both-error counts as compatible (the two engines use different exception vocabularies), one-ok-one-error or any output difference is a divergencecompare/fuzz_run.shruns generated multi-round sweeps and reports each completed seed. Render timeouts are reported as failed rounds while the sweep continues; Python outputs containing generator memory addresses are skipped, and any remaining addresses are normalized before comparison.
This harness found and fixed: undefined rendering as "" (not "None"),
filters binding tighter than unary minus, map('filtername') dispatch,
min/max(attribute=) returning the item, Python round semantics via
sprintf, string methods (replace, split, ...), tuple repr for
dictsort/items, Python-style urlencode, slice padding to the
longest column, sum(start=), indent(2, true), scientific-notation
literals, {% raw %} scanning past embedded {%, CPython string-repr
escaping, CPython %-format argument rules, lazy slice iteration,
arbitrary-precision integer semantics, empty Markup falsiness, safe
out-of-range negative indexing, bare test-argument and chained-is
parsing, and the constant-folding precedence trap for negative-literal-base
** expressions.
Development
crystal spec # run the unit/integration suite (199 specs)
./compare/run.sh # differential test against real Jinja2 (2,000 cases)
./compare/fuzz_run.sh 8 3000 1000 # 8 fuzz rounds, starting at seed 1001
Documentation
Built from the current release. The first visit to a release nobody has asked for starts its build.
Links
This release
- Version
0.4.27- Tagged
- Oct 4, 2026
- Commit
b993632757b8- Crystal
>= 1.10.0- Indexed
- yes
Dependents
Repository
github.com/weirdbricks/krikri-jinja
Metadata
- Created
- Sep 29, 2026
- Updated
- Nov 9, 2026
- Synced
- Nov 9, 2026
- Versions
- 39