=begin pod

=head1 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.

=head2 Synopsis

=begin code :lang<raku>
use Template::Jinja2;

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

=head2 HuggingFace Chat Templates

=begin code :lang<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,
);
=end code

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

=head2 Features

=head3 Tags

All 15 Jinja2 tag types plus loop controls:

=item C<{% if %}> / C<{% elif %}> / C<{% else %}> / C<{% endif %}>
=item C<{% for %}> / C<{% else %}> / C<{% endfor %}> with full loop context
=item C<{% set %}> / C<{% endset %}> with filter chains and namespace support
=item C<{% with %}> / C<{% endwith %}>
=item C<{% block %}> / C<{% endblock %}> with scoped blocks
=item C<{% extends %}> / C<{% include %}>
=item C<{% import %}> / C<{% from ... import %}>
=item C<{% macro %}> / C<{% endmacro %}> with defaults, varargs, kwargs
=item C<{% call %}> / C<{% endcall %}> with parameterized caller
=item C<{% filter %}> / C<{% endfilter %}>
=item C<{% do %}> / C<{% raw %}> / C<{# comment #}>
=item C<{% autoescape %}> / C<{% endautoescape %}>
=item C<{% break %}> / C<{% continue %}>

=head3 Expressions

=item Arithmetic: C<+>, C<->, C<*>, C</>, C<//>, C<%>, C<**>
=item Comparison: C<==>, C<!=>, C«<», C«>», C«<=», C«>=»>, chained
=item Logical: C<and>, C<or>, C<not>
=item Membership: C<in>, C<not in>
=item Identity: C<is>, C<is not>
=item Ternary: C<expr if test else expr>
=item String concat: C<~>
=item Filters: C<value | filter(args)>
=item Subscript: C<items[0]>, C<items[-1]>, C<items[1:3]>, C<items[::-1]>
=item Dot access: C<obj.attr>, C<list.0>
=item Literals: strings, integers, floats, hex/octal/binary, scientific notation, lists, dicts, tuples

=head3 Python Methods

Jinja2 has no method syntax of its own: C<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 C<str> and
C<dict> methods are implemented with I<Python> semantics, not those of Raku's
similarly-named methods.

=begin code :lang<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 -%}
=end code

String methods:

=item C<strip(chars?)>, C<lstrip(chars?)>, C<rstrip(chars?)> — no argument strips whitespace; with an argument it strips I<any> of the given characters, so C<"abcHIcba".strip("abc")> is C<"HI">, not a prefix trim
=item C<startswith(prefix)>, C<endswith(suffix)> — the argument may be a tuple (or list) of candidates, any of which may match: C<name.startswith(('sys_', 'usr_'))>
=item C<replace(old, new, count?)> — replaces every occurrence unless C<count> is given; an empty C<old> inserts between every character, as in Python
=item C<split(sep?, maxsplit?)> — with no separator, splits on runs of whitespace and drops empty fields (C<"a b c".split()> is C<['a', 'b', 'c']>, however many blanks separate the words); with a separator it is a literal split that keeps them (C<"a||b".split("|")> is C<['a', '', 'b']>). C<maxsplit> caps the number of splits and leaves the remainder verbatim
=item C<lower()>, C<upper()> — Unicode-aware case folding
=item C<title()> — Python's C<str.title()>: the first letter after every non-letter is uppercased, so C<"it's a test"> becomes C<"It'S A Test">. Note this is deliberately I<not> the C<title> B<filter>, which follows Jinja2's gentler word splitting and yields C<"It's A Test">
=item C<find(needle, start?, end?)> — the first index, or B<-1> when absent (never an undefined value); negative C<start>/C<end> count from the end

Dict methods:

=item C<items()> — C<(key, value)> tuples, ready for C<{% for k, v in d.items() %}>
=item C<keys()>, C<values()>
=item C<get(key)>, C<get(key, default)> — a missing key yields C<None> (falsy, renders as C<None>) with no default, so C<m.get('name') or m['role']> works exactly as it does in Python

Method results chain, feed filters and tests, and bind through C<{% set %}>:

=begin code :lang<jinja>
{{ msg.content.strip().split('\n')[0] }}
{{ msg.content.strip() | tojson }}
{% set parts = ref.split('|') %}{{ parts[0] }}/{{ parts[-1] }}
=end code

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

=item C<items()>, C<keys()> and C<values()> iterate in B<sorted-key order>, which is what C<{% for k in d %}>, C<|items> and C<|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.
=item C<{{ d.keys() }}> renders as a plain list (C<['a', 'b']>) rather than Python's C<dict_keys(['a', 'b'])> view repr. Iterating, indexing and filtering are unaffected.

Anything else raises a C<TemplateRuntimeError> naming the method and the
receiver's type — C<'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 C<UndefinedError> identifying the
expression that produced it.

=head3 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

=head3 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

=head3 Template Inheritance

=begin code :lang<jinja>
{# base.html #}
<html>{% block content %}default{% endblock %}</html>

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

Supports multi-level inheritance with C<super()> chaining.

=head3 Whitespace Control

=begin code :lang<jinja>
{%- trim left -%}    {# trim both sides #}
{%+ no_lstrip +%}    {# disable lstrip/trim_blocks #}
=end code

=begin code :lang<raku>
my $env = Template::Jinja2.new(:trim-blocks, :lstrip-blocks);
=end code

=head3 Custom Delimiters

=begin code :lang<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('-->'));
=end code

=head3 Loaders

=begin code :lang<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' => '...',
    }));
=end code

=head3 Configuration

=begin code :lang<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) { ... } },
);
=end code

=head2 Architecture

=begin code
Template String -> [Lexer] -> Tokens -> [Parser] -> AST -> [Renderer] + Context -> Output
=end code

=item B<Lexer> — Raku grammar tokenizer (default delimiters) or regex chunker (custom delimiters)
=item B<Parser> — Pratt-style expression parser with block tag dispatch
=item B<AST> — 30+ node types covering all Jinja2 constructs
=item B<Renderer> — AST walker with expression evaluation, scope chain, filter/test/method dispatch
=item B<Methods> — Python C<str>/C<dict> method semantics for C<value.method()> calls
=item B<Context> — Scope stack with Undefined sentinel and Namespace support

=head2 Testing

644 tests across 24 test files, including:

=item Ported tests from the Python Jinja2 reference test suite
=item Real-world HuggingFace chat template validation
=item Byte-identical output verification against Python Jinja2 3.1.6
=item Python C<str>/C<dict> method semantics, expectation-for-expectation against CPython

=begin code :lang<shell>
# Run all tests
prove6 -I lib t/

# Run a single test
raku -I lib t/09-huggingface.rakutest
=end code

=head2 Author

Matt Doughty

=head2 License

Artistic-2.0

=end pod
