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
- Shared decisions
- The pipeline each backend runs
- Per-backend choices
- Naming rules by backend
- The main types and functions
- Tests
- Known weaknesses and plans
Where it sits#
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:
| Language | Variant | Description | translate |
Naming rules | Module translation | Test tools | Runtime files |
|---|---|---|---|---|---|---|---|
| C++ | CPP |
BACKEND |
translate |
RULES |
module |
TOOLS |
FILES |
| C# | CSHARP |
BACKEND |
translate |
RULES |
module |
TOOLS |
FILES |
| Java | JAVA |
BACKEND |
translate |
RULES |
module |
TOOLS |
FILES |
| JavaScript | JS |
BACKEND |
translate |
RULES |
module |
TOOLS |
FILES |
| Lua | LUA |
BACKEND |
translate |
RULES |
module |
TOOLS |
FILES |
| Python | PY |
BACKEND |
translate |
RULES |
module |
TOOLS |
FILES |
| Rust | RUST |
BACKEND |
translate |
RULES |
module |
TOOLS |
FILES |
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#
- Runtime version against patches. Package files name
temper-core0.6.0 while the output needs patched API. Options: a distinct pre-release runtime version until the patches land upstream, or saying plainly that output targets the patched runtimes. C++ should name its runtime version too. - Untested MSRV and older targets. One CI job on Rust 1.71.1, or a
newer MSRV (api-compat.md, proposed change 11); C#'s
net48and .NET 8 are a to-do in roadmap.md ("State"). - Hoisting in every translator. The rest of the planned
hoistpass would let translators handle only statements and plain expressions; then profiles could say truthfully which backends keep statements inside expressions. - Hand-written pipelines. One
prepare(program, profile)in tmpl, in a fixed order, validated in debug builds; C++'s double naming pass could then share one map. - Rust builds code as strings. Its nullables write
if let Some(x) = xas a path whose text starts withlet, parse that text back, and the printer special-cases it, though the tree hasExpr::Let; a tuple is a call of an empty path. Plan: useExpr::Letand add a tuple expression; goldens unchanged. - Java decides nullability by text. Whether a field's type is
nullable is read from its printed type (
@Nullable …) and a list of primitive names; it should come from theTypeId. - Dead and repeated code.
VARIANTSin all seven crates, never read;NotYetand itsDisplayin each;Variantfields that differ only in which version they carry; std special-cased by the name"std"in several crates. Plans: deleteVARIANTS, oneNotYetin tmpl, and one way to ask whether a library is std, each as restructuring. - Seven printers. Helpers shared by several (float literals, string parts, host wrappers) could move to tmpl first, a shared printer core later.
- Long functions. Several translators'
modulefunctions run to 400 lines, and Rust's and Java'slib.rsmix packaging with translation; splitting packaging out is restructuring for later. - Stale comments. C++'s variant doc still says a program needing
statements out of expressions is
NotYetbecause the hoisting pass isn't built, thoughspilland its translator handle them now.