Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d6600f0
Map keys as template values (0036 step 1, no behaviour change)
claude Oct 18, 2026
51c371b
Non-string mapping keys in template dicts (0036)
claude Oct 18, 2026
1f80a00
Lend host map keys and slim dictsort's sort items (0036)
claude Oct 18, 2026
fadd45c
Clear clang-tidy hits on the 0036 changes
claude Oct 18, 2026
530d07b
Guard tuple key hashing against deep nesting; object keys find nothin…
claude Oct 18, 2026
c92e734
Merge master into 0036 (cycle collector follows non-string keys)
claude Oct 18, 2026
944cb80
Deep tuple key test: an optimised build may fit the depth on its stack
claude Oct 18, 2026
8414a57
Parity expectations: keep Python 3.12's ZeroDivisionError text
claude Oct 18, 2026
c8f4b06
Deep tuple key test: stay within TSan's shadow call stack
claude Oct 18, 2026
c129875
Hold tuple dict keys to MaxSequenceSize
claude Oct 19, 2026
c4a0aaf
Add scripts/abi_namespace.py: wrap namespace jinja2 in inline namespa…
claude Oct 19, 2026
ae4649c
Wrap include/ and src/ in inline namespace jinja2::v2 (0076)
claude Oct 19, 2026
a553328
ABI gates for the inline namespace v2: nm test, link probes, SONAME, …
claude Oct 19, 2026
577860d
0076 PR-2 verifier fixes: generators emit v2, abidiff gates layout ch…
claude Oct 19, 2026
1c84b54
Tests: define detail specialisations by qualified name for MSVC (0076)
claude Oct 19, 2026
ede6707
0076 PR-2 Codex fixes: SONAME from SOVERSION, baseline must match SONAME
claude Oct 19, 2026
c3cc4ff
Merge master (train 26) and wrap big_int.h in inline namespace v2 (0076)
claude Oct 19, 2026
43ceb97
Merge #594
claude Oct 19, 2026
818df46
Merge #604
claude Oct 19, 2026
ab8d075
Merge train 28: dict keys of any hashable type (#594), ABI namespace …
claude Oct 19, 2026
0b652be
Train 28: YAML writer spells non-string dict keys with DictKeyText in…
claude Oct 19, 2026
b051f04
Train 28: nested pointer test and count() == 0 in the key walk (clang…
claude Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@

# docs/tasks/0060: braces around every single-statement body (clang-format InsertBraces)
33b30998156056c4de59dcb0ce598390602ab253

# docs/tasks/0076: namespace jinja2 wrapped in inline namespace v2 (scripts/abi_namespace.py)
ae4649c98baaf1999e99096f7d39ef7a4dca2b8d
5 changes: 5 additions & 0 deletions .github/workflows/format-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ jobs:
- name: Check formatting of src/ and include/
run: git ls-files 'src/**.h' 'src/**.cpp' 'include/**.h' | xargs clang-format-18 --dry-run --Werror

# Every namespace jinja2 in src/ and include/ opens inline namespace v2, the ABI tag
# (docs/tasks/0076); the nm test checks the symbols, this also covers header-only code
- name: Check the inline ABI namespace
run: python3 scripts/abi_namespace.py --check

# test/ still has hand-aligned tables, so only lines touched by the pull request
# are checked there. src/ and include/ are left to the whole-file step above: given
# a vendored file, git clang-format reports it as emptied (.clang-format-ignore).
Expand Down
52 changes: 49 additions & 3 deletions .github/workflows/linux-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ jobs:
# Two rows also install the library and build a consumer against the installed package
# at another standard (`package`, docs/tasks/0068): a static and a shared install, each
# read through find_package and pkg-config. It is a check after the tests, not an axis.
# One shared row per standard builds RelWithDebInfo (`abi`) so that abidiff can compare
# the library's exported ABI with the previous one (docs/tasks/0076): the headers differ
# by standard, so the ABI is checked once per standard. For the pairwise design those rows
# count as Release (optimised); the remaining Release rows still meet every standard,
# compiler, linkage, binding and noderef value on their own.
linux-build:
name: C++${{ matrix.std }} ${{ matrix.compiler }} ${{ matrix.config }} shared=${{ matrix.shared }} json=${{ matrix.bindings }}${{ matrix.noderef == 'OFF' && ' noderef=OFF' || '' }}
runs-on: ubuntu-24.04
Expand All @@ -56,14 +61,14 @@ jobs:
include:
- { std: 17, compiler: gcc-12, config: Release, shared: 'OFF', bindings: boost, noderef: 'OFF' }
- { std: 17, compiler: gcc-14, config: Debug, shared: 'OFF', bindings: rapid }
- { std: 17, compiler: clang-18, config: Release, shared: 'ON', bindings: nlohmann }
- { std: 17, compiler: clang-18, config: RelWithDebInfo, shared: 'ON', bindings: nlohmann, abi: true }
- { std: 17, compiler: clang-20, config: Release, shared: 'OFF', bindings: boost, package: 23 }
- { std: 20, compiler: gcc-12, config: Release, shared: 'ON', bindings: rapid }
- { std: 20, compiler: gcc-12, config: RelWithDebInfo, shared: 'ON', bindings: rapid, abi: true }
- { std: 20, compiler: gcc-14, config: Release, shared: 'ON', bindings: nlohmann }
- { std: 20, compiler: clang-18, config: Debug, shared: 'OFF', bindings: boost }
- { std: 20, compiler: clang-20, config: Debug, shared: 'OFF', bindings: nlohmann, noderef: 'OFF' }
- { std: 23, compiler: gcc-12, config: Debug, shared: 'OFF', bindings: nlohmann }
- { std: 23, compiler: gcc-14, config: Release, shared: 'ON', bindings: boost, noderef: 'OFF', package: 17 }
- { std: 23, compiler: gcc-14, config: RelWithDebInfo, shared: 'ON', bindings: boost, noderef: 'OFF', package: 17, abi: true }
- { std: 23, compiler: clang-18, config: Release, shared: 'ON', bindings: rapid, noderef: 'OFF' }
- { std: 23, compiler: clang-20, config: Debug, shared: 'ON', bindings: rapid }
- { std: 26, compiler: gcc-14, config: Release, shared: 'OFF', bindings: boost }
Expand Down Expand Up @@ -99,6 +104,47 @@ jobs:
- name: Test
run: ctest --test-dir .build --output-on-failure

# abidiff against the ABI of the previous master build (report only) or, once the RC
# commits abi/libjinja2cpp.so.2.cxx<std>.abi, against that baseline: an incompatible
# change then fails unless SOVERSION is bumped and the baseline regenerated (0076)
- name: ABI dump
if: matrix.abi
run: |
sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends abigail-tools
mkdir -p .abi
abidw --headers-dir include --drop-private-types --no-show-locs \
--suppressions abi/suppressions.ini \
--out-file .abi/libjinja2cpp.so.2.cxx${{ matrix.std }}.abi .build/libjinja2cpp.so

- name: Previous ABI dump
if: matrix.abi && github.event_name == 'pull_request'
uses: actions/cache/restore@v4
with:
path: .abi-base
key: abi-cxx${{ matrix.std }}-${{ github.event.pull_request.base.sha }}
restore-keys: abi-cxx${{ matrix.std }}-

- name: ABI report
if: matrix.abi
run: scripts/abi_report.sh ${{ matrix.std }}

- name: Save ABI dump
if: matrix.abi && github.event_name == 'push'
run: mkdir -p .abi-base && cp .abi/*.abi .abi-base/

- if: matrix.abi && github.event_name == 'push'
uses: actions/cache/save@v4
with:
path: .abi-base
key: abi-cxx${{ matrix.std }}-${{ github.sha }}

# also when the report fails: its message points at this dump
- if: ${{ !cancelled() && matrix.abi }}
uses: actions/upload-artifact@v4
with:
name: abi-cxx${{ matrix.std }}
path: .abi/

- name: Installed package
if: matrix.package
run: |
Expand Down
7 changes: 7 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ set(JINJA2CPP_SOURCES
src/statements.cpp
src/string_converter_filter.cpp
src/template.cpp
src/template_dict.cpp
src/template_env.cpp
src/template_parser.cpp
src/tester_factories.cpp
Expand Down Expand Up @@ -456,6 +457,12 @@ if (JINJA2CPP_BUILD_TESTS)
add_test(NAME jinja2cpp_tests COMMAND jinja2cpp_tests --gtest_filter=-Parity*)
# Differential corpus against Python Jinja2, see test/parity/README.md
add_test(NAME jinja2cpp_parity COMMAND jinja2cpp_tests --gtest_filter=Parity*)
# Every jinja2 symbol the library defines is in the inline ABI namespace v2 (docs/tasks/0076)
if (CMAKE_NM AND NOT MSVC)
add_test(NAME jinja2cpp_abi_namespace
COMMAND ${CMAKE_COMMAND} -DNM=${CMAKE_NM} -DLIBRARY=$<TARGET_FILE:jinja2cpp>
-P ${CMAKE_CURRENT_SOURCE_DIR}/test/abi_symbols.cmake)
endif ()

if ("${JINJA2CPP_WITH_JSON_BINDINGS}" STREQUAL "boost")
set(_bindings_define "-DJINJA2CPP_WITH_JSON_BINDINGS_BOOST")
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ The install carries a CMake package and a pkg-config file. With CMake, point
target:

```cmake
find_package(jinja2cpp 1 REQUIRED)
find_package(jinja2cpp 2 REQUIRED)
target_link_libraries(my_app PRIVATE jinja2cpp::jinja2cpp)
```

Expand All @@ -201,6 +201,32 @@ it in the default internal dependencies mode). The un-namespaced `jinja2cpp` tar
`lib/cmake/jinja2cpp` (was `lib/jinja2cpp`), the static library in `lib` (was
`lib/static`) and `jinja2cpp.pc` in `lib/pkgconfig` (was `share/pkgconfig`). Without CMake, use `pkg-config --cflags --libs jinja2cpp`
(`--static` for a static build).

### Compatibility and versioning

- **Names.** The 1.x names that 2.0 could keep still compile, marked `[[deprecated]]`;
they are removed in 3.0.
- **ABI across majors.** Everything is declared in `jinja2::v2`, an inline namespace: you
keep writing `jinja2::Value`, but the major version is part of every mangled name, so a
program compiled against 1.x headers fails to link with a 2.x library (static or shared)
instead of misbehaving at run time. Never spell `v2` yourself. To declare Jinja2C++ types
without their definitions, include `<jinja2cpp/fwd.h>`: a hand-written
`namespace jinja2 { class Value; }` declares a different, undefined class and makes later
uses ambiguous. `<jinja2cpp/jinja2cpp.h>` includes the whole API except the JSON bindings.
If your code says `using namespace jinja2;` and has its own namespace-scope `v2`, qualify
one of them. Specialise the extension points inside `namespace jinja2 { ... }` or with their
qualified name (`template<> struct jinja2::Reflector<MyType>`). A 1.x-style
`detail::Reflector` specialisation must use the qualified form
(`template<> struct jinja2::detail::Reflector<MyType>`): MSVC does not look through the
inline namespace when `namespace detail` is reopened inside `namespace jinja2`, and declares
a second `jinja2::detail` instead.
- **ABI within 2.x.** The shared library's SONAME carries the ABI version
(`libjinja2cpp.so.2`, `libjinja2cpp.2.dylib`). It is bumped by any release that changes the
layout of a type you allocate (a `Settings` field, a `Value` alternative) or the signature
of an exported function, so a shared consumer is never loaded against an incompatible
library. A static consumer has no such guard and rebuilds after a minor upgrade; patch
releases keep the ABI.

### Usage with conan.io dependency manager
Jinja2C++ can be used as conan.io package. In this case, you should do the following steps:

Expand Down
22 changes: 22 additions & 0 deletions abi/suppressions.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# libabigail suppressions for the abidiff job (.github/workflows/linux-build.yml, docs/tasks/0076).
# Only vendored internals that no public signature exposes are suppressed. jinja2::v2::detail
# is not: inline functions in the public headers call exported detail functions, so those
# are ABI too.

# jbcoe/value_types, vendored as jinja2::v2::detail::xyz; header-only templates, their
# layout reaches users only through Value, which is reported on its own
[suppress_function]
name_regexp = ^jinja2::v2::detail::xyz::

[suppress_variable]
name_regexp = ^jinja2::v2::detail::xyz::

# robin_hood.h and lexertk.h are private to src/ and hidden by -fvisibility=hidden
[suppress_type]
name_regexp = ^(robin_hood|lexertk)::

[suppress_function]
name_regexp = ^(robin_hood|lexertk)::

[suppress_variable]
name_regexp = ^(robin_hood|lexertk)::
2 changes: 1 addition & 1 deletion docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ source and committed), `system` (found on the machine), `ci` (GitHub Actions).
| Boost | 1.92.0 | BSL-1.0 | mandatory | link | fetched | the nine libraries below | Yes, piece by piece (docs/tasks/0230) |
| robin-hood-hashing | 3.11.5, modified | MIT | mandatory | internal | vendored | flat hash map for values and scopes, faster than `std::unordered_map` here | Keep the idea; upstream is archived (docs/tasks/0231) |
| lexertk | unrecorded, modified | MIT | mandatory | internal | vendored | expression tokenizer | Keep; effectively forked code |
| value_types | 57f2e7a, modified | MIT | mandatory | public-api | vendored | `polymorphic`/`indirect` in `jinja2::detail::xyz`, behind `jinja2::types` (Value, GenericList) | Keep; `std::` at C++26 |
| value_types | 57f2e7a, modified | MIT | mandatory | public-api | vendored | `polymorphic`/`indirect` in `jinja2::v2::detail::xyz`, behind `jinja2::types` (Value, GenericList) | Keep; `std::` at C++26 |
| Unicode tables | UCD 15.0.0 via Python 3.12 | Unicode-3.0 | mandatory | internal | generated | Python-identical `isprintable`, case mapping, digits without ICU | Data only; acquisition decision open in 0187/0186 |
| HTML entities | Python 3.12 `html.entities` | PSF-2.0 | mandatory | internal | generated | MarkupSafe-compatible unescape | Data only; keep |
| Boost.JSON | 1.92.0 | BSL-1.0 | alternative (default) | public-optional | fetched | parses `{% meta %}` JSON for `GetMetadata` | Yes: the heaviest backend for a niche feature |
Expand Down
20 changes: 10 additions & 10 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ How far Jinja2C++ is from Python [Jinja2](https://jinja.palletsprojects.com/) 3.
area, and which task in `docs/tasks/` closes each gap.

Every statement marked with a case id is backed by the differential corpus in
`test/parity/` (1939 templates rendered by both engines, see
`test/parity/` (1995 templates rendered by both engines, see
[test/parity/README.md](../test/parity/README.md)); `ctest -R parity` re-checks all of
them. Statements in the last section (API level) are read from the headers and are not
corpus-checked yet.
Expand All @@ -20,24 +20,24 @@ Snapshot of `python3 test/parity/generate.py --report` (Jinja2 3.1.6, Oct 2026):
|---|---|---|---|---|---|---|---|---|---|
| autoescape | 124 | 122 | 1 | 1 | 0 | 0 | 0 | 0 | 0210, 0216 |
| custom | 28 | 28 | 0 | 0 | 0 | 0 | 0 | 0 | |
| errors | 97 | 93 | 0 | 2 | 2 | 0 | 0 | 0 | 0036, 0213 |
| filters | 309 | 303 | 4 | 2 | 0 | 0 | 0 | 0 | 0101, 0178, 0203, 0215 |
| errors | 103 | 100 | 0 | 2 | 1 | 0 | 0 | 0 | 0213 |
| filters | 328 | 322 | 4 | 2 | 0 | 0 | 0 | 0 | 0101, 0178, 0203, 0215 |
| fuzz | 32 | 32 | 0 | 0 | 0 | 0 | 0 | 0 | |
| globals | 74 | 73 | 1 | 0 | 0 | 0 | 0 | 0 | 0212 |
| globals | 78 | 77 | 1 | 0 | 0 | 0 | 0 | 0 | 0212 |
| i18n | 54 | 54 | 0 | 0 | 0 | 0 | 0 | 0 | |
| literals | 68 | 65 | 2 | 1 | 0 | 0 | 0 | 0 | 0015, 0036, 0041 |
| literals | 75 | 73 | 1 | 1 | 0 | 0 | 0 | 0 | 0015, 0041 |
| loader | 168 | 162 | 5 | 1 | 0 | 0 | 0 | 0 | 0165, 0211, 0274 |
| methods | 96 | 93 | 2 | 1 | 0 | 0 | 0 | 0 | 0049 |
| operators | 118 | 115 | 0 | 3 | 0 | 0 | 0 | 0 | 0015 |
| methods | 105 | 102 | 2 | 1 | 0 | 0 | 0 | 0 | 0049 |
| operators | 122 | 119 | 0 | 3 | 0 | 0 | 0 | 0 | 0015 |
| options | 35 | 35 | 0 | 0 | 0 | 0 | 0 | 0 | |
| output | 38 | 38 | 0 | 0 | 0 | 0 | 0 | 0 | |
| sequences | 47 | 47 | 0 | 0 | 0 | 0 | 0 | 0 | |
| statements | 394 | 379 | 10 | 3 | 2 | 0 | 0 | 0 | 0156, 0165, 0209, 0214, 0219, 0220 |
| subscripts | 39 | 39 | 0 | 0 | 0 | 0 | 0 | 0 | |
| statements | 398 | 383 | 10 | 3 | 2 | 0 | 0 | 0 | 0156, 0165, 0209, 0214, 0219, 0220 |
| subscripts | 42 | 42 | 0 | 0 | 0 | 0 | 0 | 0 | |
| tests | 58 | 57 | 0 | 1 | 0 | 0 | 0 | 0 | 0101 |
| undefined | 106 | 106 | 0 | 0 | 0 | 0 | 0 | 0 | |
| whitespace | 54 | 54 | 0 | 0 | 0 | 0 | 0 | 0 | |
| **total** | **1939** | **1895** | **25** | **15** | **4** | **0** | **0** | **0** | |
| **total** | **1995** | **1953** | **24** | **15** | **3** | **0** | **0** | **0** | |

*output*: both render, text differs. *rejects*: C++ errors on a valid template.
*accepts*: C++ renders a template Jinja2 rejects. *unsupported*: needs an Environment
Expand Down
25 changes: 24 additions & 1 deletion docs/tasks/0036-non-string-mapping-keys.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
status: open
status: done
priority: low
area: parity
depends: [0013]
Expand All @@ -26,3 +26,26 @@ tuple), which touches the public API and is worth doing only together with 0031.
**Release decision (Ruslan 2026-10-18, D13 in docs/release-2.0.md).** In 2.0, template-internal
only: the public `ValuesMap` key type stays `std::string` unless the architect plan shows a
public need.

**Shipped (D13 = b).** Dict literals, `dict()`, `namespace()` and the results of the dict
methods take any hashable key (`src/template_dict.*`): `1`, `1.0` and `True` are one key
(first spelling and position kept, last value wins), so are `(1, 2.0)` and `(1.0, 2)`;
`none`, floats, tuples and ranges are keys; a list or a dict raises `TypeError: unhashable
type`. Subscript, `in`, `get`, `setdefault`, `update`, `pop`, `popitem`, `copy`, iteration,
`items`, `dictsort`, `pprint`, `tojson`, `xmlattr`, `urlencode` and `==` follow Python. The
public `ValuesMap`/`GenericMap` keys stay `std::string`.

*Conversion rule at the public boundary* (`CreateGenericMap` of a template dict, what a user
callable receives): a dict with a key that is not a string is handed over as a snapshot, not a
live view, keyed by `str(key)` (`{1: 'x', None: 'y'}` has the keys `"1"` and `"None"`). When two
keys spell alike (`{1: 'x', '1': 'y'}`) the later value wins at the position of the first, as
Python's `{str(k): v for k, v in d.items()}` has it. A dict of string keys stays a live view.
Changing this rule later is a break for callers who rely on it.

*Not done:* object keys (`{namespace(): 1}`, host objects, callables, undefined) have no
identity token yet and all read as the empty string when stored, as before, while a lookup by
one finds nothing (`nothing in {'': 1}` is False); that is the first bullet of
0212 (`globals.object_dict_key` stays listed). All NaN keys are one key (Python compares NaN by
identity).
The render's cycle collector (0110) follows the kept keys too, so a cycle through a key
(`{% set d = {(ns, 1): 1} %}{% set ns.d = d %}`) is freed when the render ends.
20 changes: 18 additions & 2 deletions docs/tasks/0076-abi-namespace-and-headers.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
status: in-progress
status: done
priority: high
area: release
depends: [0072, 0073, 0074, 0075, 0043]
touches: [include/, src/, CMakeLists.txt, cmake/public/, test/package/, test/fwd_test.cpp, test/value_ptr_test.cpp, README.md, sbom/, scripts/abi_namespace.py, .git-blame-ignore-revs, .github/workflows/format-check.yml]
touches: [include/, src/, CMakeLists.txt, cmake/public/, test/package/, test/fwd_test.cpp, test/value_ptr_test.cpp, test/abi_symbols.cmake, test/template_api_test.cpp, README.md, sbom/, abi/, scripts/abi_namespace.py, scripts/abi_report.sh, .git-blame-ignore-revs, .github/workflows/format-check.yml, .github/workflows/linux-build.yml]
---
# 2.0: inline ABI namespace, header layout, version

Expand Down Expand Up @@ -36,3 +36,19 @@ future 2.x build is binary-compatible. Settle the API-shaping decisions listed i
script-generated `inline namespace v2` commit, the nm/link-probe/SONAME gates, the abidiff
job and the README policy. Follow-ups: 0259 (robin_hood/lexertk namespaces), 0260
(`<iosfwd>`), 0261 (Windows package link check).

**Done.** PR-1 #580. PR-2: `scripts/abi_namespace.py` wrote `inline namespace v2` into
every `namespace jinja2` of include/ and src/ (one commit, in .git-blame-ignore-revs) and
checks it in the format-check job; CTest `jinja2cpp_abi_namespace` (test/abi_symbols.cmake)
fails on a jinja2 symbol outside `v2` or a global `xyz`; test/package links
`abi_probe_v2` and expects `abi_probe_v1` (1.x mangling) not to link, in the static and
shared package rows; check_package.cmake checks the SONAME; the version asserts require
major 2; README "Compatibility and versioning". The abidiff job runs on one shared
RelWithDebInfo row per standard and reports against the last master build; committing the
baselines at the RC is 0285. Deliberate divergence from the plan: `jinja2::v2::detail` is
not suppressed in abidiff (exported detail functions are called from inline headers), and
the C++20 abi row is gcc-12, not gcc-14, so that the Release rows keep every pair; the C++17
abi row stays clang-18 (the plan preferred GCC, but the only C++17 shared row is clang-18).
The gate counts any change left after `--no-added-syms` as a break, since abidiff reports
a changed layout (a new `Settings` field) with bit 4, not bit 8. The four Unicode/HTML
generators in scripts/ emit the `v2` form, so their `--check` stays green.
Loading
Loading