Skip to content

Bars Specification 0.1.0 — Data Model

What values are, and how they behave once the AST is evaluated against a context.

Value model

Every value that flows through evaluation and the transform pipeline is one of three kinds:

  • An element — a scalar (string, number, boolean, or null), a list, or a struct (an ordered key/value map; the JSON-object / YAML-map shape). This is the data supplied as the render context, and the result of resolving an identifier or a transform against it.
  • A date-time — an instant, serialized when rendered as an ISO-8601 string in the UTC time zone.
  • Empty — the sentinel for an absent or unresolved value (for example, an unregistered @env property). It renders as the empty string, distinct from an element whose value is null (which renders as the literal text "null" — see Rendering).

A conforming implementation need not reproduce this exact three-way split internally, but must reproduce its externally observable behavior: element values compare/render/iterate as described below; a date-time value round-trips through the date/time transform family and renders as an ISO-8601 UTC string; an unresolved reference renders empty, not as "null" or an error.

Date-time rendering

A value of the date-time kind renders as an ISO-8601 / RFC-3339 instant in UTC: YYYY-MM-DDThh:mm:ss[.f{3|6|9}]Z.

  • The date and time are joined by a literal T; the zone designator is a literal Z (UTC). A port must normalize any input offset to UTC before rendering — a date-time constructed from 2020-01-01T00:00:00+02:00 renders 2019-12-31T22:00:00Z.
  • Seconds are always present (two digits), even when zero.
  • Fractional seconds are present only when non-zero, in groups of exactly 3, 6, or 9 digits (milliseconds, microseconds, nanoseconds) — the shortest group that preserves the value, not the shortest digit count within a group: .100 stays three digits (it is not trimmed to .1), but a value with no sub-millisecond precision omits the microsecond/nanosecond groups entirely. A whole-second instant has no . and no fractional digits.

The construction path (an @env date-time property, the date/time transform family) is implementation-defined; this render format is not — see Implementation-Defined Behavior. A port that has no date-time kind at all is unaffected.

Normative fixtures: conformance/env-properties/datetime-property.json (env-datetime-property, env-datetime-renders-milliseconds, env-datetime-renders-microseconds, env-datetime-renders-nanoseconds, env-datetime-keeps-trailing-zero-within-group, env-datetime-normalizes-offset-to-utc).

Pointer resolution

An identifier resolves against the current top-of-stack context (or, with a ../ prefix, an ancestor context — one .. per enclosing block frame) using one of these forms, tried in this priority order:

  1. this or . — the entire current context.
  2. this.foo, this/foo, ./foofoo resolved against the current context.
  3. ../foo, ../../foo, … — foo resolved against an ancestor context.
  4. @env/name, @env.name — an environment property (see Rendering).
  5. @local/name, @local.name — a scoped variable bound with {{= }} (see Rendering).
  6. @index, @key (optionally ../-prefixed) — the current or an ancestor loop position.
  7. Anything else — resolved against the current context as either an RFC 6901 JSON Pointer (a leading /), an RFC 6901 URI fragment identifier with percent-decoding applied first (a leading #), or Mustache-style dot notation (everything else; a leading dot is stripped, and trailing or consecutive dots are a parse-time error).

An identifier that resolves to nothing — an absent path, or a null context — renders as the literal text "null"; a soft-fail event with code KB-5005 (UnresolvedReference) is reported for this case (see Errors). This is distinct from an @env/@local lookup miss, which renders empty with no code, per the value-model split above.

Normative fixtures: conformance/render/identifier-formats.json, parent-pointer.json, this-identifier.json, null-context.json, unresolved-reference.json.

Iteration order

{{#each }} over a struct iterates its entries in insertion order — the order keys appear in the source document — never sorted or hashed order. A port backed by a container without an insertion-order guarantee (a raw hash table, for example) will diverge from this rule for any document whose keys are not already alphabetical, even though such a divergence is easy to miss in a small hand-written test.

Normative fixture: conformance/render/each-block.json, case each-object-non-sorted-keys-preserve-insertion-order — its keys are deliberately out of alphabetical order (z, a, m) specifically to catch a hash-ordered or sorted implementation.

Number rendering

{{ n }} renders a number exactly as ECMA-262 Number.prototype.toString() (radix 10) would: the shortest decimal digit string that round-trips to the same IEEE-754 double, with ECMA's fixed/exponential notation selection. Every kBars target — and every port to another language — must produce byte-identical output for the same numeric value, rather than delegating to a host language's native Double.toString() (which, on the JVM and Kotlin/Native, is not always shortest-round-trip).

1.0   -> "1"        2.0  -> "2"      100.0 -> "100"
1e20  -> "100000000000000000000"     1e21  -> "1e+21"
1e-6  -> "0.000001"                  1e-7  -> "1e-7"
-0.0  -> "0"        0.1+0.2 -> "0.30000000000000004"
Double.MAX_VALUE -> "1.7976931348623157e+308"   Double.MIN_VALUE -> "5e-324"
NaN -> "NaN"   Infinity -> "Infinity"   -Infinity -> "-Infinity"

Given the shortest round-trip digit string and its decimal exponent, ECMA-262 chooses between fixed and exponential notation using fixed magnitude thresholds, not "however many digits look reasonable": magnitudes from 1e-6 (inclusive) up to 1e21 (exclusive) use fixed notation; everything else uses exponential notation, formatted as <digit>[.<digits>]e<+|->EXP. -0.0 renders "0" (the sign is dropped); NaN, Infinity, and -Infinity render as those exact literal words.

2 and 2.0 both render "2" — kBars does not echo the source literal's int/float distinction, and the distinction plays no role in comparison either: ==/</etc. already coerce both operands to Double before comparing. A port must not echo the source literal's formatting, and must not delegate digit generation to a host numeric-formatting routine without first verifying it produces shortest-round-trip output.

Normative fixtures: conformance/render/number-rendering.json (the notation-boundary cases above) and int-float-parity.json (2 and 2.0 render, compare, and behave identically).

String code-point semantics

Four string transforms and one list transform measure or compare strings; all five operate in Unicode code points, matching the code-point rule governing source locations — never UTF-16 code units, never raw bytes.

  • size (string input) returns the code-point count, not the UTF-16 Char count.
  • slice takes its offset and length in code points, so a surrogate pair is always sliced whole — never split into a lone half.
  • truncate takes its length (and its ellipsis's length) in code points, for the same reason.
  • split with an empty ("") delimiter explodes the string into one element per code point, so a surrogate pair becomes a single element, not two.
  • sort's string branch orders by Unicode code-point value, not by raw UTF-16 code-unit comparison — a distinction that is observable because UTF-16 surrogate pairs for supplementary-plane characters (U+10000 and above) start at 0xD800, numerically less than Basic Multilingual Plane characters in the U+E000U+FFFF range. A naive UTF-16 comparison sorts a supplementary- plane character before a private-use-area BMP character — the opposite of true code-point order.

An unpaired (lone) surrogate counts as its own single code-point-sized unit and passes through unchanged: never zero-width, never merged with a neighboring character, never normalized to the Unicode replacement character. Only a well-formed high+low surrogate pair counts as one two-Char unit.

remove, remove_first, remove_last, replace, replace_first, and replace_last locate a whole target substring and act on its real length, so they are unaffected by the code-point rule and need no special handling in a port.

upcase/downcase use Unicode default (locale-invariant) case mapping, which is not always length-preserving — the German sharp s (ß, one code point) upcases to "SS" (two code points).

Normative fixtures: conformance/transforms/transform-size.json (size-astral-string-counts-code-points, size-lone-surrogate-counts-as-one), transform-slice.json (slice-astral-does-not-split-surrogate), transform-truncate.json (truncate-astral-counts-code-points), transform-split.json (split-empty-delimiter-astral), transform-sort.json (sort-strings-code-point-order), transform-upcase.json (upcase-sharp-s-expands), transform-downcase.json (downcase-greek-capital).