Skip to content

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 enum with associated data.
  • TypeScript → a discriminated union with a type/kind tag (the fixtures already use exactly this shape for AST segments and typed properties).
  • A language without sum types → an abstract base plus a kind discriminant, and a match/switch that 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-1xxxKB-4xxx) throw and abort the render.
  • Soft failures (KB-5xxx) recover to null/empty and report a KbSoftFailEvent, and rendering continues — the fixture's expectedOutput assumes 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

  1. 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.
  2. Core rendering (render/*.json, excluding transforms/) — interpolation, blocks, expressions, truthiness/equality/ordering. This is the bulk of the normative language.
  3. 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.
  4. Partials (partials/*.json) — the {{> key }} partial mechanism and its failure matrix. This is part of core rendering, so a core-tier port needs it.
  5. The standard librarytransforms (transforms/*.json) and the @env property 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 expect was 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 @env property (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 expect of 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.