[![Actions Status](https://github.com/m-doughty/Template-Jinja2/actions/workflows/test.yml/badge.svg)](https://github.com/m-doughty/Template-Jinja2/actions)

Template::Jinja2
================

Complete Jinja2 template engine for Raku, targeting compatibility with the Python reference implementation (Jinja2 3.1). Designed for HuggingFace chat templates and general-purpose templating.

Synopsis
--------

```raku
use Template::Jinja2;

my $env = Template::Jinja2.new;
my $tmpl = $env.from-string('Hello {{ name }}!');
say $tmpl.render(name => 'World');  # Hello World!
```

HuggingFace Chat Templates
--------------------------

```raku
use Template::Jinja2;
use JSON::Fast;

my $config = from-json('tokenizer_config.json'.IO.slurp);
my $chat-template = $config<chat_template>;

my $env = Template::Jinja2.new;
my $result = $env.from-string($chat-template).render(
    messages => [
        { role => 'system', content => 'You are helpful.' },
        { role => 'user', content => 'Hello!' },
    ],
    bos_token => '<s>',
    add_generation_prompt => True,
);
```

Produces byte-identical output to Python Jinja2 for ChatML, Llama 3, Mistral, Gemma 2, Zephyr, and Cohere Command A templates.

Features
--------

### Tags

All 15 Jinja2 tag types plus loop controls:

  * `{% if %}` / `{% elif %}` / `{% else %}` / `{% endif %}`

  * `{% for %}` / `{% else %}` / `{% endfor %}` with full loop context

  * `{% set %}` / `{% endset %}` with filter chains and namespace support

  * `{% with %}` / `{% endwith %}`

  * `{% block %}` / `{% endblock %}` with scoped blocks

  * `{% extends %}` / `{% include %}`

  * `{% import %}` / `{% from ... import %}`

  * `{% macro %}` / `{% endmacro %}` with defaults, varargs, kwargs

  * `{% call %}` / `{% endcall %}` with parameterized caller

  * `{% filter %}` / `{% endfilter %}`

  * `{% do %}` / `{% raw %}` / `{# comment #}`

  * `{% autoescape %}` / `{% endautoescape %}`

  * `{% break %}` / `{% continue %}`

### Expressions

  * Arithmetic: `+`, `-`, `*`, `/`, `//`, `%`, `**`

  * Comparison: `==`, `!=`, `<`, `>`, `<=`, `>=`>, chained

  * Logical: `and`, `or`, `not`

  * Membership: `in`, `not in`

  * Identity: `is`, `is not`

  * Ternary: `expr if test else expr`

  * String concat: `~`

  * Filters: `value | filter(args)`

  * Subscript: `items[0]`, `items[-1]`, `items[1:3]`, `items[::-1]`

  * Dot access: `obj.attr`, `list.0`

  * Literals: strings, integers, floats, hex/octal/binary, scientific notation, lists, dicts, tuples

### Python Methods

Jinja2 has no method syntax of its own: `value.method()` resolves the name on the underlying Python object and calls it. Templates in the wild — HuggingFace chat templates especially — rely on that constantly, so the Python `str` and `dict` methods are implemented with *Python* semantics, not those of Raku's similarly-named methods.

```jinja
{%- for m in messages -%}
    {#- strip() as both a guard and as output -#}
    {%- if m.content.strip() -%}
        <|{{ m.role }}|>{{ m.content.strip() }}
    {%- endif -%}

    {#- reasoning split, straight out of the GLM chat template -#}
    {%- if '</think>' in m.content -%}
        {%- set reasoning = m.content.split('</think>')[0].split('<think>')[-1] -%}
    {%- endif -%}

    {#- get() with a default, straight out of the Kimi chat template -#}
    {%- set name = m.get('name') or m['role'] -%}
    {%- set rc = m.get('reasoning', m.get('reasoning_content', '')) -%}
{%- endfor -%}

{#- dict iteration for tool schemas -#}
{%- for k, v in tool.items() -%}"{{ k }}": {{ v | tojson }}{%- endfor -%}
```

String methods:

  * `strip(chars?)`, `lstrip(chars?)`, `rstrip(chars?)` — no argument strips whitespace; with an argument it strips *any* of the given characters, so `"abcHIcba".strip("abc")` is `"HI"`, not a prefix trim

  * `startswith(prefix)`, `endswith(suffix)` — the argument may be a tuple (or list) of candidates, any of which may match: `name.startswith(('sys_', 'usr_'))`

  * `replace(old, new, count?)` — replaces every occurrence unless `count` is given; an empty `old` inserts between every character, as in Python

  * `split(sep?, maxsplit?)` — with no separator, splits on runs of whitespace and drops empty fields (`"a b c".split()` is `['a', 'b', 'c']`, however many blanks separate the words); with a separator it is a literal split that keeps them (`"a||b".split("|")` is `['a', '', 'b']`). `maxsplit` caps the number of splits and leaves the remainder verbatim

  * `lower()`, `upper()` — Unicode-aware case folding

  * `title()` — Python's `str.title()`: the first letter after every non-letter is uppercased, so `"it's a test"` becomes `"It'S A Test"`. Note this is deliberately *not* the `title` **filter**, which follows Jinja2's gentler word splitting and yields `"It's A Test"`

  * `find(needle, start?, end?)` — the first index, or **-1** when absent (never an undefined value); negative `start`/`end` count from the end

Dict methods:

  * `items()` — `(key, value)` tuples, ready for `{% for k, v in d.items() %}`

  * `keys()`, `values()`

  * `get(key)`, `get(key, default)` — a missing key yields `None` (falsy, renders as `None`) with no default, so `m.get('name') or m['role']` works exactly as it does in Python

Method results chain, feed filters and tests, and bind through `{% set %}`:

```jinja
{{ msg.content.strip().split('\n')[0] }}
{{ msg.content.strip() | tojson }}
{% set parts = ref.split('|') %}{{ parts[0] }}/{{ parts[-1] }}
```

Two deliberate divergences from CPython, both consequences of a Raku `Hash` having no insertion order to preserve:

  * `items()`, `keys()` and `values()` iterate in **sorted-key order**, which is what `{% for k in d %}`, `|items` and `|dictsort` already do in this engine. Output is deterministic, but a template that rebuilds JSON by iterating a dict emits its keys sorted rather than in the order they were written.

  * `{{ d.keys() }}` renders as a plain list (`['a', 'b']`) rather than Python's `dict_keys(['a', 'b'])` view repr. Iterating, indexing and filtering are unaffected.

Anything else raises a `TemplateRuntimeError` naming the method and the receiver's type — `'str' object has no method 'casefold'` — rather than reaching a same-named Raku method or leaking an internal error. Calling a method on an undefined value raises `UndefinedError` identifying the expression that produced it.

### Filters (52)

abs, attr, batch, capitalize, center, count, d/default, dictsort, e/escape, filesizeformat, first, float, forceescape, format, groupby, indent, int, items, join, last, length, list, lower, lstrip, map, max, min, pprint, random, reject, rejectattr, replace, reverse, round, rstrip, safe, select, selectattr, slice, sort, string, striptags, sum, title, tojson, trim, truncate, unique, upper, urlencode, urlize, wordcount, wordwrap, xmlattr

### Tests (20+)

defined, undefined, none, boolean, integer, float, number, string, sequence, mapping, iterable, callable, sameas, eq/equalto, ne, lt/lessthan, le, gt/greaterthan, ge, even, odd, divisibleby, in, true, false, lower, upper

### Template Inheritance

```jinja
{# base.html #}
<html>{% block content %}default{% endblock %}</html>

{# child.html #}
{% extends "base.html" %}
{% block content %}{{ super() }} + override{% endblock %}
```

Supports multi-level inheritance with `super()` chaining.

### Whitespace Control

```jinja
{%- trim left -%}    {# trim both sides #}
{%+ no_lstrip +%}    {# disable lstrip/trim_blocks #}
```

```raku
my $env = Template::Jinja2.new(:trim-blocks, :lstrip-blocks);
```

### Custom Delimiters

```raku
# ERB-style
my $env = Template::Jinja2.new(
    :block-start('<%'), :block-end('%>'),
    :variable-start('<%='), :variable-end('%>'),
    :comment-start('<%#'), :comment-end('%>'));

# PHP-style
my $env = Template::Jinja2.new(
    :block-start('<?'), :block-end('?>'),
    :variable-start('<?='), :variable-end('?>'),
    :comment-start('<!--'), :comment-end('-->'));
```

### Loaders

```raku
use Template::Jinja2::Loader;

# File system
my $env = Template::Jinja2.new(
    loader => FileSystemLoader.new(searchpath => 'templates/'));

# In-memory
my $env = Template::Jinja2.new(
    loader => DictLoader.new(templates => {
        'base.html' => '...',
        'child.html' => '...',
    }));
```

### Configuration

```raku
my $env = Template::Jinja2.new(
    :autoescape,                  # Auto-escape {{ }} output
    :trim-blocks,                 # Strip newline after block tags
    :lstrip-blocks,               # Strip leading whitespace before block tags
    :!keep-trailing-newline,      # Strip trailing newline from output
    :line-statement-prefix('#'),  # Line-based block syntax
    :line-comment-prefix('##'),   # Line-based comments
    globals => { site => 'My Site' },
    filters => { custom => sub ($v) { ... } },
    tests => { custom => sub ($v) { ... } },
);
```

Architecture
------------

    Template String -> [Lexer] -> Tokens -> [Parser] -> AST -> [Renderer] + Context -> Output

  * **Lexer** — Raku grammar tokenizer (default delimiters) or regex chunker (custom delimiters)

  * **Parser** — Pratt-style expression parser with block tag dispatch

  * **AST** — 30+ node types covering all Jinja2 constructs

  * **Renderer** — AST walker with expression evaluation, scope chain, filter/test/method dispatch

  * **Methods** — Python `str`/`dict` method semantics for `value.method()` calls

  * **Context** — Scope stack with Undefined sentinel and Namespace support

Testing
-------

644 tests across 24 test files, including:

  * Ported tests from the Python Jinja2 reference test suite

  * Real-world HuggingFace chat template validation

  * Byte-identical output verification against Python Jinja2 3.1.6

  * Python `str`/`dict` method semantics, expectation-for-expectation against CPython

```shell
# Run all tests
prove6 -I lib t/

# Run a single test
raku -I lib t/09-huggingface.rakutest
```

Author
------

Matt Doughty

License
-------

Artistic-2.0

