Writing a Workalike¶
So you want a kBars-compatible template processor in another language. The conformance suite is your acceptance test and the language-neutral spec. This page is the part the fixtures cannot give you: the translator's note — which layers to copy verbatim, which to make idiomatic, and the handful of Kotlin idioms and design decisions in the reference that need a conscious decision in your target language.
It is short on purpose, and aimed squarely at you, six months from now, starting the port. When you hit a question mid-port that no fixture answers, see Using the Reference CLI as an Oracle before guessing.
What to copy verbatim, what to make idiomatic¶
Copy the grammar and evaluation semantics verbatim. Delimiter syntax, the pre-pass
normalization rule, expression evaluation (truthiness, equality, ordering dispatch), block semantics
(#if/#unless/#each/#with), identifier-classification priority, transform argument evaluation,
and number rendering are pinned by grammar/*, prepass/*, and render/*. Translate them case by
case against the fixtures; resist "improving" them. Every deviation is a conformance risk.
Make the host binding idiomatic. How a template source string is read, how a context document
is parsed into an addressable tree, and how transforms are registered are all things your host
language will want to do its own way. The reference bridges to
kPointer's KpaElement/KpaStruct/KpaList/KpaPrimitive adapter model
for context data; if you are not also porting kPointer, re-express the same contract (read a struct
key, read a list index, distinguish absent from a container, coerce a leaf to a render string) in
whatever your host considers idiomatic for a JSON/YAML-like tree.
The dividing line: if a fixture pins it, copy it; if only the reference's internal structure implies it, redesign it.
What is not pinned: the grammar engine itself¶
The reference implementation parses with an ANTLR4-generated Kotlin parser plus a hand-written
pre-pass that rewrites unterminated opener delimiters (so the lexer never enters a mode it cannot
leave). Neither ANTLR nor that specific two-phase design is normative — only the observable
grammar (what parses, what each construct means, what pre-pass rewriting produces) is. A hand-written
recursive-descent parser, a parser combinator, or a regex-based tokenizer in your host language is
equally valid, provided it agrees with grammar/*.json and prepass/*.json.
Kotlin idioms that need a conscious mapping¶
Sealed hierarchies → discriminated unions¶
Expression (Identifier / Literal / Binary), the AST segment types (Text / Interpolation /
Comment / …), and the KbException family are Kotlin sealed types — a closed set, exhaustively
matchable. Map each to your language's closed sum type:
- Rust / Swift → an
enumwith associated data. - TypeScript → a discriminated union with a
type/kindtag (the fixtures already use exactly this shape for AST segments and typed properties). - A language without sum types → an abstract base plus a
kinddiscriminant, and amatch/switchthat a reviewer can see is exhaustive.
The payoff is the same as in Kotlin: block rendering and expression evaluation are written as exhaustive matches over the node kind, and adding a case is a compile-time (or code-review) prompt to handle it everywhere.
Poko / structural equality → explicit deep compare¶
Public value types (KbSourceLocation, KbSoftFailEvent, KbTemplateOrigin) use
Poko — kBars's stand-in for data class on Kotlin
Multiplatform — so == compares by content. Fixture expect/context comparisons are deep
structural matches. If your language's == is reference equality by default (Java without
records, JavaScript objects, many others), write the deep compare yourself and route every
"does this rendered output/AST/context equal that one" through it.
Soft-fail vs. hard-fail is a semantic decision, not a syntax detail¶
The reference distinguishes two failure modes with different observable behavior, not just different exception types:
- Hard failures (
KB-1xxx–KB-4xxx) throw and abort the render. - Soft failures (
KB-5xxx) recover tonull/empty and report aKbSoftFailEvent, and rendering continues — the fixture'sexpectedOutputassumes the rest of the template still renders.
Get this split right per condition, not just per code: a port that throws where the reference
soft-fails will produce a different (aborted) render, not merely a different error type, and will
fail every render fixture whose expectedOutput follows a soft-fail recovery. See
Error Codes for the full table and the hard/soft classification of every code.
Binary expressions do not chain¶
The grammar's expression rule is exactly primary WS operator WS primary — one operator between two
atomic primaries, never another expression on either side. There is no operator-precedence table to
port because chaining never parses in the first place: {{ a and b and c }}, {{ a or b and c }},
and {{ a < b < c }} are all grammar errors (KB-1001), not left-associative or right-associative
parses. A hand-rolled recursive-descent parser will often accept these by default (each operand
recursing back into the expression rule), which silently makes your port more permissive than the
reference and diverges on conformance/grammar/chained-binary-operators-rejected.json. If your
templates need to express multiple conditions, that is what nested {{#if}}/{{#unless}} blocks or
transform-chain-style piping are for — kBars deliberately does not support boolean chaining inside
a single expression.
Number rendering: shortest round-trip, ECMA-262 notation¶
kBars renders numbers via a shortest-round-trip digit generator (the reference ports
ulfjack/ryu) plus an ECMA-262 fixed/exponential notation selector —
not your host language's native Double.toString(), which on the JVM and in many other runtimes is
not shortest-round-trip and uses different notation thresholds. See
Number Rendering for the exact algorithm and fixture-pinned boundary cases.
This is one of the easiest things to get subtly wrong, because a naive port will pass small manual
tests ("1", "3.14") and only diverge at the 1e21 notation boundary or on values like
Double.MIN_VALUE.
Struct iteration order¶
{{#each}} over a struct/object/map must iterate in insertion order. See
Iteration Order — this fails silently with a hash-backed map unless you check
it against the deliberately non-sorted-key fixture.
Source location counting: Unicode code points¶
line/column on parse/render errors count Unicode code points, not UTF-16 code units or raw
bytes. See Source Locations — get this wrong and every fixture with a non-BMP
character (emoji) before a failing expression will report the wrong column.
String transform counting and sort collation: also Unicode code points¶
The same code-point rule governs size, slice, truncate, empty-delimiter split, and sort's
string ordering. See String Semantics — a naive UTF-16-based port slices
surrogate pairs in half and sorts supplementary-plane characters in the wrong position relative to
BMP private-use characters.
Suggested porting order¶
- Grammar (
grammar/*.json) and pre-pass (prepass/*.json) — get delimiter parsing, identifier forms, and unterminated-opener rewriting agreeing with the fixtures before anything else; every later layer depends on a correct AST. - Core rendering (
render/*.json, excludingtransforms/) — interpolation, blocks, expressions, truthiness/equality/ordering. This is the bulk of the normative language. - Number rendering and iteration order — easy to defer, easy to get subtly wrong; verify against Number Rendering and Iteration Order specifically, not just the general render corpus.
- Partials (
partials/*.json) — the{{> key }}partial mechanism and its failure matrix. This is part of core rendering, so a core-tier port needs it. - The standard library — transforms (
transforms/*.json) and the@envproperty roster (env-properties/*.json) — required only if you are targeting full-tier conformance. Implement the baseline roster faithfully; you may add transforms and properties beyond it, and even widen a standard-library transform, as long as the baseline behavior is preserved (see Implementation-Defined Behavior).
Wire each fixture directory into your test framework as you go, keyed on the fixture name. Once
every core directory is green you have a faithful core-tier workalike; add the standard-library
directories (transforms/ and env-properties/) to reach full-tier conformance. Each fixture's tier
is recorded in manifest.json, so a core-tier runner can legitimately skip the full entries.
Once your runner is green, see Claiming Conformance for how to turn that into
a publishable, verifiable proof — emitting a full-report.schema.json-shaped report rather than
asserting conformance in prose.
Contributing a Fixture Upstream¶
The best outcome of an ambiguity you resolve with the oracle workflow is not just an answer for your own port — it's a new fixture, so the next porter gets the answer from the suite directly instead of re-deriving it. If what you learned looks like a general rule rather than an artifact of your one example, contribute it back.
1. Write the fixture against the schema¶
Pick the directory matching what you're pinning — Fixture Schemas is the
authoritative field reference for all five shapes (grammar/, prepass/, render/ and
transforms/, partials/, env-properties/). Add your case to an existing file if it belongs
alongside related cases, or create a new file if it starts a new group; either way its cases array
gets one new object matching that directory's schema, with a stable kebab-case name (this is the id
a runner reports on pass/fail). Set the file's own version field to match the suite version you're
targeting (see step 3) — a fixture file's version is a distinct field from its name, one you'll
usually leave alone when only adding a case to an already-versioned file.
If your case is a worked example for a standard-library transform or @env property, also check
whether it belongs in transforms/registry.json or env-properties/registry.json (see
transforms/registry.json and
env-properties/registry.json) — a fixture for a
new standard-library entry needs a matching registry entry, not just the fixture file itself.
2. Regenerate and verify the manifest¶
conformance/manifest.json is generated, not hand-edited, and is the suite's own integrity check
against silently-missed fixture files (see The manifest):
./gradlew generateConformanceManifest # regenerate conformance/manifest.json
./gradlew checkConformanceManifest # verify it matches the fixture files
If your fixture adds or edits a registry entry (the previous step), also regenerate the derived spec
pages and their doc-link check — generateSpecTransforms/checkSpecTransforms and
checkTransformDocLinks for transforms/registry.json, or generateSpecEnvProperties/
checkSpecEnvProperties and checkEnvPropertyDocLinks for env-properties/registry.json.
3. Suite-version bump rules¶
The suite adheres to Semantic Versioning against
suiteVersion (the conformanceSuite catalog entry, which every fixture file's own version field
must match exactly). Applying SemVer to a fixture change:
- Patch — a fixture case's
expectwas simply wrong (the reference behavior didn't change; the assertion was a bug) and you're correcting it, or a purely editorial fixture-comment/description fix with no assertion change. - Minor — a new fixture file or case that pins previously-unpinned behavior without changing
what any existing conformant implementation already does — this is the common case for an
ambiguity you resolved with the oracle. A new standard-library transform or
@envproperty (with its registry entry and worked-example fixture) is also a minor bump: it's additive, and a full-tier implementation from before the bump is out of date but not broken. - Major — changing the
expectof an existing case such that a previously-conformant implementation would now fail against it (a genuine behavioral change, not a bug-in-the-fixture fix), or removing/renaming a fixture case or a required schema field.
When in doubt, or if your fixture belongs in the next release rather than amending an unpublished one
— check the current top entry in conformance/CHANGELOG.md for whether suiteVersion has already
shipped; an unpublished version's entries amend in place rather than forcing a bump (see that file's
own [0.1.0] note for the precedent) — open the pull request described below and let a maintainer
make the final version-bump call rather than guessing at a new conformanceSuite value yourself.
4. Update the changelog and open a pull request¶
Add an entry to conformance/CHANGELOG.md (mirrored onto site-spec/docs/porting/changelog.md)
describing the new or changed fixture and, if relevant, the ambiguity that prompted it — see either
file's [0.1.0] section for the level of detail expected. Then open a pull request against
the kBars repository with:
- The new/edited fixture file(s) and the regenerated
manifest.json(and, if applicable, regenerated spec pages). - The changelog entry (both copies).
- A short note on the ambiguity you hit and how you resolved it via the oracle — this is exactly the context a maintainer needs to judge the SemVer bump and merge with confidence.