Backends, in detail#

A backend takes a tmpl program and writes one language's source and package files. Tempest has seven, one crate each: temper-be-cpp, -csharp, -java, -js, -lua, -py, and -rust, from about 5,600 to 9,800 lines each. Each depends only on temper-tmpl. The overview page shows triple.temper in all seven languages; tmpl's detail page covers the IR and the lowerings backends share. This page covers what the seven share, where they differ, why, and how they're tested.

Contents#

Where it sits#

BACKENDtoolstemper-driver's Registrytranslate(program,variant)Shared lowerings, byprofilenaming::assign_checkedwith names::RULEStranslate.rs: Translator,per moduleops.rs: operatorsconnected.rs: core andstdtree.rs: output treePrinterSource files, packagefiles, teststools.rs: run thelanguage's tests
BACKENDtoolstemper-driver's Registrytranslate(program,variant)Shared lowerings, byprofilenaming::assign_checkedwith names::RULEStranslate.rs: Translator,per moduleops.rs: operatorsconnected.rs: core andstdtree.rs: output treePrinterSource files, packagefiles, teststools.rs: run thelanguage's tests
Diagram source
flowchart LR
  registry["temper-driver's Registry"] -->|"BACKEND"| translate["translate(program, variant)"]
  translate --> lower["Shared lowerings, by profile"]
  lower --> names["naming::assign_checked with names::RULES"]
  names --> tx["translate.rs: Translator, per module"]
  tx --> ops["ops.rs: operators"]
  tx --> conn["connected.rs: core and std"]
  tx --> tree["tree.rs: output tree"]
  tree --> print["Printer"]
  print --> files["Source files, package files, tests"]
  registry -->|"tools"| run["tools.rs: run the language's tests"]

Shared decisions#

Each says what was chosen, the alternatives, pros and cons, how Kotlin Temper does it, and whose call it was: team-agreed, tempest's (the user's call), or open.

Compatible API, prettier bodies#

Chosen: what a library's users can see (layout, package files, public names and signatures, the runtime it calls) matches what Kotlin Temper writes for that language. Function bodies are free to read better: source names, real loops, no needless temporaries or labels. Where Kotlin's output is unsafe or wrong, tempest's differs on purpose, and each crate's lib.rs doc comment says where.

Alternatives: a fresh API per language, or byte-for-byte Kotlin output.

Pros: existing users of Temper-built libraries can't tell the difference; reviewers can read the generated code.

Cons: inherits Kotlin's API choices, including ones tempest would change (notes/api-compat.md, "Proposed changes", lists eleven).

Status: tempest's goal from the start; the API rules per language are in api-compat.md. Changing a public API needs the Temper team.

A small tree and printer per language#

Chosen: each backend has its own hand-written output tree and printer (tree.rs, about 900 to 1,950 lines), which adds parentheses only where precedence needs them and lays code out in the language's usual style.

Alternatives: Kotlin's generated per-language ASTs (from *.out-grammar files, about 42,000 generated lines over seven backends), or one shared output tree and printer.

Pros: small, readable, no code generator; each printer can follow its language's conventions (rustfmt's widths, PEP 8's blank lines, Lua's ends), which share little.

Cons: seven printers repeat helpers: float literals in four or more, string concatenation parts in six, host wrappers in six. The "shared document builder" in the size plan (size-and-speed.md) wasn't built.

Status: tempest's (design.md, section 9).

Profiles pick the shared lowerings#

Chosen: each backend's Profile says what it keeps natively, and its translate calls the shared lowerings it lacks (tmpl's page lists them). Anything still language-specific stays in the backend.

Pros: a fix to generators, switches, or evaluation order lands once for every backend that uses it.

Cons: statements inside expressions (an orelse, a block's value) are still mostly hoisted by each translator: C#'s hoist and sink, C++'s pending and temporary, Java's spilled, JS's pre list, Lua's and Python's own. Only the first step, keeping earlier operands in order (spill), is shared, and only C#, C++, and Lua use it. So profiles don't yet describe what each translator does (the pipeline).

Kotlin Temper chooses through each backend's SupportNetwork while building its per-backend TmpL. Status: the principle "what more than one backend needs belongs in tmpl" is the user's, 2026-11-06 (notes/tmpl/roadmap.md).

One naming pass, rules as data#

Chosen: names come from tmpl's shared pass, assign_checked, by a RULES constant in each backend's names.rs (table below). The checked form fails, as a NotYet, where an export's name would have to change. A backend never invents a name string; for helpers it asks the pass for hints.

Pros: no x__123 names; numbers only on real clashes; dependents spell another library's names from its interface.

Cons: each new scoping quirk is a new switch in NamingRules.

Kotlin Temper names per backend (selectNames), numbering compiler-made and many local names.

Operators and connected declarations as tables#

Chosen: ops.rs spells each of tmpl's 69 operators; connected.rs maps core's (and some of std's) connected declarations, keyed by qname, to the language's runtime. Each backend's tests/tables.rs checks that every operator is spelled and that each key names a connected declaration.

Pros: what a backend does with core is one sorted table, checked against core's own declarations.

Cons: the tables are code (templates and closures), so they can't be shared or diffed across backends as data.

Kotlin Temper keeps the same in each backend's SupportNetwork and helper files.

Kotlin's runtimes, vendored, with patches#

Chosen: each language's temper-core (and parts of std's host code) is copied from Kotlin Temper into vendor/temper/ by compat/sync_temper.py, with tempest's fixes kept as patches in vendor/temper-patches/ (23 so far) that each sync re-applies. Each backend's runtime.rs embeds its runtime's files for tempest build to write beside the output. CI checks the copy is current (sync_temper.py --check).

Pros: generated code calls the same runtime APIs as Kotlin's, so both compilers' output can mix; fixes are visible as patches and meant for upstream.

Cons: the copy follows Kotlin's main only as often as someone syncs it (vendor/temper/SOURCE.md names the commit), and a runtime change there, such as C++'s strings becoming a shared Str (#654), needs the backend to follow it in the same change. And some patches add API (a top-level turn and awakeUpon for Java, awake_upon for Rust, async for Lua), while six backends' package files still name temper-core 0.6.0, the version Kotlin publishes without them. A tempest-built package built against the published runtime may not compile (the audit's A1). C++ declares no runtime version at all. See weaknesses.

Status: vendoring is tempest's; each patch says whether it's upstream, in an open Kotlin PR (#532 for one turn at a time), or still to offer.

Pluggable: a description per backend#

Chosen: each crate exports BACKEND, a Backend: name, comment syntax, runtime files, naming rules, a translate function, whether build writes it by default (all but C++, as Kotlin), and its Tools. Registry in temper-driver lists them, one cargo feature each; the frontend and tmpl never do.

Pros: adding a backend is one crate, one registry line, and its name in KNOWN; the wasm build leaves out runtimes and tools.

Cons: each crate also defines a Variant struct (profile, rules, and version fields that differ by backend) and a VARIANTS list that nothing reads.

Status: tempest's ("Pluggable backends": frontend and tmpl never list or count backends).

Each language runs its own tests#

Chosen: each crate's tools.rs says how its language runs a built library's tests for tempest test -b, through Tools's test (and an optional prepare), and its top level for tempest run -b, through its run, with shared helpers in tmpl's tools. Most read JUnit XML; Rust reads what cargo test prints, and Java a line-per-test runner it compiles. temper-driver's test_run runs them for any backend that has them, summarizing as the interpreter's runner does, and its run runs a library, building quietly first where the language needs a build (Cargo, CMake, javac, dotnet build), then the program with the command's own output, so what it prints streams.

Backend How its tests run
C++ CMake on the CMakeLists.txt tempest writes, its test target, then the program, which writes JUnit XML
C# dotnet test --logger:junit in the tests project, then dotnet build-server shutdown
Java javac on runtime, std, and libraries, then JUnit 5's launcher from Maven's local repository (Kotlin runs Maven, which needs the network)
JavaScript Mocha with its xunit reporter, packages linked under temper.out/js/node_modules
Lua Each library's tests/init-test.lua with LuaUnit, writing JUnit XML
Python python3 -m temper_core.testing result.xml, as Kotlin's RunPython
Rust cargo test, offline, one shared target directory

Cons: the backends' own CI tests (tests/functional.rs, about 6,500 lines over seven crates) mostly run tools with their own commands rather than through tools.rs, so tempest test -b for Python, Lua, C++, and JS is checked only by the real-library runs in CI and locally.

The pipeline each backend runs#

Every backend's translate starts the same way: shared lowerings, then the naming pass, then its own translator, module by module.

Backend Profile: native Generators Lowerings, in order
C++ SWITCH state machines errors, coroutines, cells, naming (for its effects analysis), spill_with (what its order of evaluation needs), naming again
C# none C# iterators errors, switches, spill_with (panic()), naming
Java statements in expressions, SWITCH state machines errors, coroutines, cells, switches on Int64 only, naming
JavaScript RUNS_TYPE_ERRORS function* errors, switches, naming
Lua RUNS_TYPE_ERRORS coroutines errors, switches, spill, naming
Python statements in expressions, RUNS_TYPE_ERRORS generators errors, switches, naming
Rust statements in expressions, SWITCH state machines errors, coroutines, cells, naming

Read across, the order is the same everywhere: errors, coroutines, cells, switches, spill, naming. That's by care: each list is written out by hand in its crate. And "statements in expressions" means "handles them somehow": Rust has block expressions, but Java's and Python's translators hoist them, and JS lacks the feature yet neither spills nor fails; its translator hoists and orders operands itself.

Per-backend choices#

C++ C# Java JavaScript Lua Python Rust
Target C++14, as Kotlin .NET 6 (net6.0) Java 17 ES modules, Node Lua 5.1 declared; tested on 5.4.6 and LuaJIT Python 3.11+ rust-version 1.71.1, as Kotlin; tested on 1.85.0
Package init.hpp/.cpp per module, main.cpp, test.cpp, plus a CMake project (Kotlin writes none) src and program projects, ROOTGlobal class, a file per type Maven pom.xml, NAMEGlobal and NAMEMain, a file per type package.json, .internal.js modules re-exported by public ones init.lua per library, a rockspec pyproject.toml, a package, __all__ a crate, Cargo.toml
Failure (orelse) try/catch of TemperBubble try/catch; panic() uncaught try/catch (RuntimeException); panic() an AssertionError try/catch temper.pcall try/except Exception; panic() a BaseException Result and ?, closures for complex bodies
Null null shared_ptr or NullableParam T? @Nullable, boxed T | null (JSDoc) temper.null (Lua's nil can't be a table value) T | None Option<T>
Statements in expressions translator, after spill_with translator (hoist, sink), after spill_with translator (spilled) translator (pre, t$0) translator, after spill translator (:= for ??) native block expressions
Order of evaluation its own analysis (effects), since C++ leaves argument order open left to right left to right left to right left to right left to right left to right
Checked by g++ -Wall -Wextra -Wpedantic -Werror and more (policy below), at -O1 to -O3; ASan, UBSan, TSan csc, nullable references, warnings and analyzers as errors javac -Xlint:all -Werror tsc --checkJs --strict luac, LuaLS mypy and pyright, strict cargo build, clippy
Notable module vars under a lock; shifts defined for every count; only exports in headers; strings the runtime's shared core::Str, literals TEMPER_STR("…"), as Kotlin parameter names are API, never renamed; culture-invariant formatting lists that may hold null via Core.listOf source maps (translate_with_source_maps) goto only for continue and jumps out of inner loops; LuaCATS annotations module privates start with _ module state safe across threads; Arc<String> strings

Each row is from the crate's lib.rs doc comment and its profile; the per-language API rules are in api-compat.md, and the as-built notes per backend in notes/tmpl/profiles.md.

C++'s warning policy#

Generated C++ compiles cleanly under whatever strict settings its users build with, -Werror included, so no warning is turned off for generated code to pass. The tests' STRICT is -Wall -Wextra -Wpedantic -Werror and 33 more, -Wnull-dereference, -Wconversion, and -Wshadow among them; only -Weffc++, older advice than C++11's, is off. runs builds with it at -O1, and strict compiles everything again at -O2, where more inlining shows GCC's path-following warnings more, on g++ 12 and 14 (14 at -O3 too, CMake's Release), and in CI on clang++ with its own names for the same set (CLANG_STRICT). The runtime's headers are a system directory there, as in a user's build, so its own few (-Wshadow, -Wsign-conversion, -Wuseless-cast, -Wfloat-equal) are its own. When a warning is a false positive, the fix is in what's generated or the runtime, not a pragma: an upcast to an interface, a virtual base, goes through the runtime's to_base, which tells the compiler the value isn't null, since the conversion's null check otherwise gives -Wnull-dereference a path to report at the next call on the result (a class's value is called as the class, with no conversion); a list literal of Booleans skips reserve, whose libstdc++ code g++ misreads at -O3 (-Warray-bounds); and private functions that only tests call are inside #ifdef TEMPER_TESTS with the tests, so a build without them has no unused function.

Naming rules by backend#

Rule C++ C# Java JS Lua Python Rust
Private prefix _
Raw keywords @ r#
Block-scoped locals yes yes yes yes yes no no
Locals share a name only with the same type yes
Init's lambdas avoid module names yes yes
Members prefixed (storage _name) yes
Fields apart from methods yes
Types apart from functions and lets yes yes

Every backend escapes a reserved word with a trailing _. Case per kind of declaration (snake_case, PascalCase, or as written) is each RULES' case function.

The main types and functions#

Per crate, the same items:

Also worth knowing: each crate's NotYet (what it doesn't cover yet; translate returns it, never panics); its ops.rs unspelled (the operators it lacks, which its tables.rs test expects empty) and connected.rs TABLE; C++'s Effects and needing_temporaries, cmake_lists, and test_main; and Java's pom and main_class.

Shared by the driver: checked (to tmpl and validated), with_std (std's naming interface for a backend), and translate. Shared by the tests: temper-be-testkit.

Tests#

Test What it pins Where
Goldens Each covered functional test's and shared program's output, file by file, in tests/goldens/ (93 for C++ to 404 for C#); UPDATE_SNAPSHOTS=1 rewrites them for review each crate's tests/functional.rs
Runs The output runs with the language's own tools against the vendored runtime and logs what Kotlin's backend is expected to log; tests run as tests compare their failures with expectedFailures each crate's tests/functional.rs
Strict checkers The checkers in the table above, on every case; ill-typed programs' output is skipped, as Kotlin's each crate's tests/functional.rs
Strict at -O2 C++'s every generated file compiled at -O2 with STRICT by g++ 12 and 14 (14 at -O3 too), and in CI by clang++ too (policy) temper-be-cpp's tests/functional.rs, strict
Sanitizers C++ under ASan, UBSan, and TSan; module-state from 8 threads on every backend that has threads temper-be-cpp's tests/functional.rs; testkit's threads_expected
Tables Every operator spelled; every connection keyed by a real connected declaration each crate's tests/tables.rs
Host code A library's own connected functions and classes, with host files, as is and under --no-connected each crate's tests/host/ and CONNECTED cases
Against the interpreter The shared programs log on the interpreter what each backend must log programs_log_as_the_interpreter_does
Kotlin's layout tempest build's temper.out matches Kotlin's: every path, each runtime's files, package names and versions, with differences listed and justified temper_out_is_kotlins
Real libraries Hrefine, Chromarite, Ordable, Kelpto, and Wherewith pass their tests on all seven locally; in CI, Chromarite and Ordable on C#, Java, JS, Lua, and Python CI's tempest test -b steps

A missing tool skips its test with a message, unless TEMPEST_REQUIRE_CHECKERS is set, as in CI, where it fails, except what TEMPEST_ALLOW_MISSING names (comma-separated words in the message, as LuaLS), which the weekly platforms job sets where a platform can't have a tool (ci.md).

tempest test -b csharp and -b java hide the compilers' warnings, as Kotlin's do, unless TEMPEST_STRICT_WARNINGS is set, as in CI's runs of the real libraries: then a warning in std, a library, or its tests fails them, as in the backends' own tests (the C# runtime's nullable warnings, Kotlin's, aside). All five libraries compile without one (2026-11-10).

Every command a backend's tools start goes through temper_tmpl::tools's output and program (children.rs): each runs in a process group of its own, killed when it ends, times out (600 seconds by default), or tempest is interrupted, and on Linux dies with tempest; tools that want a memory limit ask for it with limit_memory (the CLI's page). The OS calls for this are temper-os's (OS calls in one crate).

Not covered: the declared MSRV (generated crates say 1.71.1; CI builds them with 1.85.0); PUC Lua 5.1 (LuaJIT runs the Lua tests); C#'s net48 side, and Java 8; output built against the published 0.6.0 runtimes rather than the patched copies; and tempest test -b through each tools.rs in the backends' own test suites.

Known weaknesses and plans#