Skip to content

Bars Specification 0.1.0 — Lexical Structure

Source text becomes an AST in two stages: a hand-written pre-pass normalization, then ANTLR tokenization and parsing against the grammar embedded below.

Pre-pass normalization

Before the grammar below ever sees the source, a normative pre-pass rewrites every unterminated opener delimiter ({{, {{{, {{!, {{{{, {{#if, {{#unless, {{#with, {{#each, {{=, {{>, or any trimmed variant) into an escaped opener, by inserting a \ immediately before it. An opener is unterminated when the source ends (or another opener begins) before that opener's own closing delimiter is reached. This is not an implementation convenience: it is the reason a malformed or truncated template (for example, one cut off mid-edit) fails to parse as a dangling tag rather than throwing a lexer/parser error over unconsumed input. A well-formed template — every opener paired with its closer — is passed through completely unchanged.

The pre-pass operates on raw source text and has no counterpart in the grammar below: the grammar's ESCAPED_OPEN : '\{{' lexer rule only recognizes the backslash the pre-pass may have inserted, and has no rule of its own for detecting "unterminated." A port must implement this rewrite as a distinct string-processing stage prior to tokenization, not attempt to fold it into the grammar.

Normative fixtures: conformance/prepass/. Representative cases:

  • unterminated-expr.json: prefix {{ foo (no closing }}) becomes prefix \{{ foo.
  • unterminated-comment.json, unterminated-raw.json, unterminated-unesc.json, unterminated-if-unless-blocks.json: the same rewrite for {{!, {{{{, {{{, {{#if/{{#unless.
  • multiple-unterminated.json: two openers on the same line with no closer between or after them both get escaped — {{ a and {{ b becomes \{{ a and \{{ b.
  • unterminated-then-wellformed.json: only the opener that is actually unterminated is escaped — {{ open then {{ x }} becomes \{{ open then {{ x }} (the second {{ x }} is well-formed and is left alone).
  • raw-containing-fake-tags.json: a well-formed raw block's body is never scanned for openers — {{{{ {{ y }} }}}} passes through unchanged; the {{ y }} inside it is raw text, not a dangling tag.
  • escape-already-present.json: an opener already preceded by \ (\{{ x }}) passes through unchanged — the pre-pass never double-escapes.
  • trailing-backslash.json, trailing-brace.json: a lone \ or { at end of input with no opener to complete is left as plain text.

Grammar

The reference implementation's ANTLR grammar, embedded verbatim. It is informative: it describes only the post-pre-pass token stream. See the Overview for the precedence rule.

lexer grammar KBarsLexer;

// Virtual token types referenced via -> type(...) rewrites.
tokens { AND, ASSIGN, COLON, EQ, FALSE, GE, GT, IDENTIFIER, LE, LPAREN, LT, NE, NULL, NUMBER, OR, PIPE, RPAREN, STRING, TRANSFORM, TRUE, WS }

fragment A : [aA] ;
fragment D : [dD] ;
fragment N : [nN] ;
fragment O : [oO] ;
fragment R : [rR] ;

// ============================================================
// Lexer — default (text) mode
// ============================================================
//
// All multi-character openers are distinct lengths, so ANTLR's
// maximum-munch tokenizer disambiguates them automatically. The
// declaration order below is stylistic (longest first).

RAW_OPEN        : '{{{{'  -> pushMode(RAW)        ;
ESCAPED_OPEN    : '\\{{'                          ;
UNESC_TRIM_OPEN : '{{{~' -> pushMode(EXPR_UNESC) ;
UNESC_OPEN      : '{{{'  -> pushMode(EXPR_UNESC) ;
// Comment openers. The trim variant strips surrounding template whitespace.
COMMENT_OPEN      : '{{!'  -> pushMode(COMMENT)    ;
COMMENT_TRIM_OPEN : '{{~!' -> pushMode(COMMENT)    ;

// Block openers and closers. Each construct has a plain form plus, per the
// Handlebars-parity whitespace model, `~` variants on either delimiter. Open-side
// trim needs a distinct opener token; the close-side `~}}` of an opener is the
// EXPR-mode TRIM_CLOSE token. Block closers are monolithic default-mode tokens, so
// each needs three trim variants (trim-open, trim-close, trim-both). Maximum-munch
// tokenization selects the longest match, so declaration order among these is
// stylistic; TRIM_OPEN (`{{~`) below is always out-competed by any longer `{{~…`.
WITH_OPEN               : '{{#with'     -> pushMode(EXPR) ;
WITH_TRIM_OPEN          : '{{~#with'    -> pushMode(EXPR) ;
WITH_CLOSE              : '{{/with}}'                     ;
WITH_CLOSE_TRIM_OPEN    : '{{~/with}}'                    ;
WITH_CLOSE_TRIM_CLOSE   : '{{/with~}}'                    ;
WITH_CLOSE_TRIM_BOTH    : '{{~/with~}}'                   ;
EACH_OPEN               : '{{#each'     -> pushMode(EXPR) ;
EACH_TRIM_OPEN          : '{{~#each'    -> pushMode(EXPR) ;
EACH_CLOSE              : '{{/each}}'                     ;
EACH_CLOSE_TRIM_OPEN    : '{{~/each}}'                    ;
EACH_CLOSE_TRIM_CLOSE   : '{{/each~}}'                    ;
EACH_CLOSE_TRIM_BOTH    : '{{~/each~}}'                   ;
IF_OPEN                 : '{{#if'       -> pushMode(EXPR) ;
IF_TRIM_OPEN            : '{{~#if'      -> pushMode(EXPR) ;
IF_CLOSE                : '{{/if}}'                       ;
IF_CLOSE_TRIM_OPEN      : '{{~/if}}'                      ;
IF_CLOSE_TRIM_CLOSE     : '{{/if~}}'                      ;
IF_CLOSE_TRIM_BOTH      : '{{~/if~}}'                     ;
UNLESS_OPEN             : '{{#unless'   -> pushMode(EXPR) ;
UNLESS_TRIM_OPEN        : '{{~#unless'  -> pushMode(EXPR) ;
UNLESS_CLOSE            : '{{/unless}}'                   ;
UNLESS_CLOSE_TRIM_OPEN  : '{{~/unless}}'                  ;
UNLESS_CLOSE_TRIM_CLOSE : '{{/unless~}}'                  ;
UNLESS_CLOSE_TRIM_BOTH  : '{{~/unless~}}'                 ;
INLINE_OPEN             : '{{#inline'    -> pushMode(EXPR) ;
INLINE_TRIM_OPEN        : '{{~#inline'   -> pushMode(EXPR) ;
INLINE_CLOSE            : '{{/inline}}'                    ;
INLINE_CLOSE_TRIM_OPEN  : '{{~/inline}}'                  ;
INLINE_CLOSE_TRIM_CLOSE : '{{/inline~}}'                  ;
INLINE_CLOSE_TRIM_BOTH  : '{{~/inline~}}'                 ;
ELSE_IF_OPEN            : '{{else'  ( ' ' | '\t' | '\r' | '\n' )+ 'if' -> pushMode(EXPR) ;
ELSE_IF_TRIM_OPEN       : '{{~else' ( ' ' | '\t' | '\r' | '\n' )+ 'if' -> pushMode(EXPR) ;
ELSE_TAG                : '{{else}}'                      ;
ELSE_TAG_TRIM_OPEN      : '{{~else}}'                     ;
ELSE_TAG_TRIM_CLOSE     : '{{else~}}'                     ;
ELSE_TAG_TRIM_BOTH      : '{{~else~}}'                    ;
ASSIGN_OPEN             : '{{='        -> pushMode(EXPR)  ;
ASSIGN_TRIM_OPEN        : '{{~='       -> pushMode(EXPR)  ;
TRIM_OPEN               : '{{~'        -> pushMode(EXPR)  ;
PARTIAL_OPEN            : '{{>'        -> pushMode(EXPR)  ;
PARTIAL_TRIM_OPEN       : '{{~>'       -> pushMode(EXPR)  ;
// Partial block: `{{#> key }} fallback {{/>}}` or `{{/> key }}`. Both the opener and the closer push EXPR mode, so
// the closer's optional name and its own `}}`/`~}}` delimiter reuse the ordinary CLOSE/TRIM_CLOSE tokens instead of
// needing a monolithic closer with three trim variants (contrast INLINE_CLOSE above).
PARTIAL_BLOCK_OPEN       : '{{#>'       -> pushMode(EXPR)  ;
PARTIAL_BLOCK_TRIM_OPEN  : '{{~#>'      -> pushMode(EXPR)  ;
PARTIAL_BLOCK_CLOSE      : '{{/>'       -> pushMode(EXPR)  ;
PARTIAL_BLOCK_CLOSE_TRIM : '{{~/>'      -> pushMode(EXPR)  ;
OPEN                    : '{{'         -> pushMode(EXPR)  ;

// Literal text — anything that is not the start of a recognized
// opener. The main alternation consumes:
//   - any char that is neither '{' nor '\'
//   - '{' followed by a non-'{'  (a lone brace is legal text)
//   - '\' followed by a non-'{'  (a lone backslash is legal text)
//
// The two trailing single-character alternatives handle a lone '{'
// or '\' at EOF (or immediately before an opener). Maximum-munch
// guarantees the longer forms win whenever a following character is
// available, so the fallbacks only fire in the no-following-char
// case. Input like '{ {' at EOF tokenizes as two TEXT tokens; the
// parser's `text : TEXT+` rule reassembles them.
TEXT
    : ( ~[{\\]
      | '{'  ~'{'
      | '\\' ~'{'
      )+
    | '{'
    | '\\'
    ;

// ============================================================
// Expression mode — inside {{ ... }} or {{~ ... ~}}
// ============================================================

mode EXPR;

TRIM_CLOSE : '~}}' -> popMode ;
CLOSE      : '}}'  -> popMode ;

EXPR_WS    : [ \t\r\n]+ -> type(WS) ;

EXPR_STRING  : ('"' ( '\\' . | ~["\\] )* '"'
             | '\'' ( '\\' . | ~['\\] )* '\'')
             -> type(STRING)
             ;

EXPR_NUMBER  : ('-'? [0-9]+ ( '.' [0-9]+ )?)
             -> type(NUMBER)
             ;

EXPR_TRUE    : 'true'  -> type(TRUE)  ;
EXPR_FALSE   : 'false' -> type(FALSE) ;
EXPR_NULL    : 'null'  -> type(NULL)  ;

EXPR_EQ      : '=='  -> type(EQ)  ;
EXPR_NE      : '!='  -> type(NE)  ;
EXPR_LE      : '<='  -> type(LE)  ;
EXPR_GE      : '>='  -> type(GE)  ;
EXPR_LT      : '<'   -> type(LT)  ;
EXPR_GT      : '>'   -> type(GT)  ;
EXPR_ASSIGN  : '='   -> type(ASSIGN) ;
EXPR_OR      : O R   -> type(OR)  ;
EXPR_AND     : A N D -> type(AND) ;

EXPR_PIPE    : '|'   -> type(PIPE) ;

EXPR_COLON     : ':' -> type(COLON)  ;
EXPR_LPAREN    : '(' -> type(LPAREN) ;
EXPR_RPAREN    : ')' -> type(RPAREN) ;
EXPR_TRANSFORM : [a-zA-Z0-9_-]+ -> type(TRANSFORM) ;

// Identifier: any contiguous run of non-whitespace characters that
// does not consume the close delimiter. Lone '}' and lone '~' are
// allowed in the interior of an identifier (e.g. 'foo~bar', 'a}b');
// the lexer only stops when it sees a real close delimiter
// ('}}' or '~}}').
//
// ':', '(', and ')' are carved out so they can serve as standalone
// COLON / LPAREN / RPAREN tokens in transform argument syntax. When
// a non-whitespace run contains these characters it is longer than
// TRANSFORM's [a-zA-Z0-9_-]+ match, so IDENT still wins for identifiers
// like 'foo.bar' or 'items.0'.
//
// The alternation enumerates "safe" prefixes:
//   - any non-WS char that is not '}', '~', ':', '(', or ')'
//   - '}'   followed by non-'}'   (lone '}' is fine)
//   - '~'   followed by non-'}'   (lone '~' is fine)
//   - '~}'  followed by non-'}'   ('~}' inside an identifier is fine)
EXPR_IDENT
    : ( ~[ \t\r\n}~:()]
      | '}'  ~'}'
      | '~'  ~'}'
      | '~}' ~'}'
      )+
      -> type(IDENTIFIER)
    ;

// ============================================================
// Expression mode — inside {{{ ... }}} or {{{~ ... ~}}}
// ============================================================
//
// Separate mode so that '}}}' and '~}}}' are unambiguous against
// '}}' / '~}}'.

mode EXPR_UNESC;

UNESC_TRIM_CLOSE : '~}}}' -> popMode ;
UNESC_CLOSE      : '}}}'  -> popMode ;

UNESC_WS         : [ \t\r\n]+ -> type(WS) ;

UNESC_STRING  : ('"' ( '\\' . | ~["\\] )* '"'
              | '\'' ( '\\' . | ~['\\] )* '\'')
              -> type(STRING)
              ;

UNESC_NUMBER  : ('-'? [0-9]+ ( '.' [0-9]+ )?)
              -> type(NUMBER)
              ;

UNESC_TRUE    : 'true'  -> type(TRUE)  ;
UNESC_FALSE   : 'false' -> type(FALSE) ;
UNESC_NULL    : 'null'  -> type(NULL)  ;

UNESC_EQ      : '=='  -> type(EQ)  ;
UNESC_NE      : '!='  -> type(NE)  ;
UNESC_LE      : '<='  -> type(LE)  ;
UNESC_GE      : '>='  -> type(GE)  ;
UNESC_LT      : '<'   -> type(LT)  ;
UNESC_GT      : '>'   -> type(GT)  ;
UNESC_OR      : O R   -> type(OR)  ;
UNESC_AND     : A N D -> type(AND) ;

UNESC_PIPE   : '|'   -> type(PIPE) ;

UNESC_COLON     : ':' -> type(COLON)  ;
UNESC_LPAREN    : '(' -> type(LPAREN) ;
UNESC_RPAREN    : ')' -> type(RPAREN) ;
UNESC_TRANSFORM : [a-zA-Z0-9_-]+ -> type(TRANSFORM) ;

// Identifier in unescaped mode: same idea as EXPR_IDENT, but the
// close delimiter is one brace longer, so the "safe prefix"
// enumeration extends one position further. ':', '(', and ')' are
// carved out here too, matching the EXPR mode exclusion.
UNESC_IDENT
    : ( ~[ \t\r\n}~:()]
      | '}'   ~'}'
      | '}}'  ~'}'
      | '~'   ~'}'
      | '~}'  ~'}'
      | '~}}' ~'}'
      )+
      -> type(IDENTIFIER)
    ;

// ============================================================
// Comment mode — inside {{! ... }} or {{~! ... ~}}
// ============================================================
//
// A comment closes on '}}' (plain) or '~}}' (trim-close, stripping
// surrounding template whitespace). To let '~}}' close the comment,
// COMMENT_TEXT must not swallow a '~' that immediately precedes '}}';
// the tilde-aware "safe prefix" alternation below mirrors EXPR_IDENT.

mode COMMENT;

COMMENT_TRIM_CLOSE : '~}}' -> popMode ;
COMMENT_CLOSE      : '}}'  -> popMode ;

// Comment text continues until the next '}}' or '~}}'. Allows:
//   - any char that is neither '}' nor '~'
//   - '}'  followed by non-'}'   (lone '}' is fine)
//   - '~'  followed by non-'}'   (lone '~' is fine)
//   - '~}' followed by non-'}'   ('~}' inside a comment is fine)
COMMENT_TEXT
    : ( ~[}~]
      | '}'  ~'}'
      | '~'  ~'}'
      | '~}' ~'}'
      )+
    ;

// ============================================================
// Raw mode — inside {{{{ ... }}}}
// ============================================================

mode RAW;

RAW_CLOSE : '}}}}' -> popMode ;

// Raw text continues until '}}}}'. Allows runs of 0, 1, 2, or 3
// '}' characters followed by any non-'}' character.
//
// Limitation: a raw block cannot contain four or more consecutive
// '}' characters. This matches Handlebars's behaviour and is
// almost never a problem in practice.
RAW_TEXT
    : ( ~'}'
      | '}'   ~'}'
      | '}}'  ~'}'
      | '}}}' ~'}'
      )+
    ;
parser grammar KBarsParser;

options { tokenVocab = KBarsLexer; }

// ============================================================
// Parser
// ============================================================

template
    : segment* EOF
    ;

expressionChain
    : expression ( WS PIPE WS transformInvocation )*
    ;

transformInvocation
    : TRANSFORM ( COLON WS transformArg ( WS transformArg )* )?
    ;

transformArg
    : primary                                # PrimaryArg
    | LPAREN WS? expressionChain WS? RPAREN  # ParenArg
    ;

expression
    : operand                              # PrimaryExpression
    | operand WS operator WS operand       # BinaryExpression
    ;

operand
    : primary                                   # PrimaryOperand
    | LPAREN WS? expressionChain WS? RPAREN     # GroupedOperand
    ;

primary
    : IDENTIFIER
    | TRANSFORM
    | STRING
    | NUMBER
    | TRUE
    | FALSE
    | NULL
    ;

operator
    : OR
    | AND
    | EQ
    | NE
    | LE
    | GE
    | LT
    | GT
    ;

segment
    : TEXT+                                                          # TextSegment
    | ESCAPED_OPEN                                                   # EscapedDelimiterSegment
    | ( COMMENT_OPEN | COMMENT_TRIM_OPEN ) COMMENT_TEXT? ( COMMENT_CLOSE | COMMENT_TRIM_CLOSE )  # CommentSegment
    | RAW_OPEN RAW_TEXT? RAW_CLOSE                                   # RawBlockSegment
    | UNESC_OPEN WS expressionChain WS UNESC_CLOSE                   # UnescapedInterpolationSegment
    | UNESC_TRIM_OPEN WS expressionChain WS UNESC_CLOSE              # UnescapedInterpolationTrimOpenSegment
    | UNESC_OPEN WS expressionChain WS UNESC_TRIM_CLOSE              # UnescapedInterpolationTrimCloseSegment
    | UNESC_TRIM_OPEN WS expressionChain WS UNESC_TRIM_CLOSE         # UnescapedInterpolationBothTrimSegment
    | OPEN WS expressionChain WS CLOSE                               # InterpolationSegment
    | TRIM_OPEN WS expressionChain WS CLOSE                          # InterpolationTrimOpenSegment
    | OPEN WS expressionChain WS TRIM_CLOSE                          # InterpolationTrimCloseSegment
    | TRIM_OPEN WS expressionChain WS TRIM_CLOSE                     # InterpolationBothTrimSegment
    | ( WITH_OPEN | WITH_TRIM_OPEN ) WS expressionChain WS ( CLOSE | TRIM_CLOSE )
        segment*
        ( WITH_CLOSE | WITH_CLOSE_TRIM_OPEN | WITH_CLOSE_TRIM_CLOSE | WITH_CLOSE_TRIM_BOTH )       # WithBlockSegment
    | ( EACH_OPEN | EACH_TRIM_OPEN ) WS expressionChain WS ( CLOSE | TRIM_CLOSE )
        segment* eachElseArm?
        ( EACH_CLOSE | EACH_CLOSE_TRIM_OPEN | EACH_CLOSE_TRIM_CLOSE | EACH_CLOSE_TRIM_BOTH )        # EachBlockSegment
    | ( IF_OPEN | IF_TRIM_OPEN ) WS expressionChain WS ( CLOSE | TRIM_CLOSE )
        segment* elseIfArm* elseArm?
        ( IF_CLOSE | IF_CLOSE_TRIM_OPEN | IF_CLOSE_TRIM_CLOSE | IF_CLOSE_TRIM_BOTH )                # IfBlockSegment
    | ( UNLESS_OPEN | UNLESS_TRIM_OPEN ) WS expressionChain WS ( CLOSE | TRIM_CLOSE )
        segment* elseArm?
        ( UNLESS_CLOSE | UNLESS_CLOSE_TRIM_OPEN | UNLESS_CLOSE_TRIM_CLOSE | UNLESS_CLOSE_TRIM_BOTH ) # UnlessBlockSegment
    | ( ASSIGN_OPEN | ASSIGN_TRIM_OPEN ) WS IDENTIFIER WS ASSIGN WS expressionChain WS ( CLOSE | TRIM_CLOSE )  # AssignSegment
    | ( PARTIAL_OPEN | PARTIAL_TRIM_OPEN ) WS partialKey ( WS expressionChain )? WS ( CLOSE | TRIM_CLOSE )     # PartialSegment
    | ( INLINE_OPEN | INLINE_TRIM_OPEN ) WS inlineName WS ( CLOSE | TRIM_CLOSE )
        segment*
        ( INLINE_CLOSE | INLINE_CLOSE_TRIM_OPEN | INLINE_CLOSE_TRIM_CLOSE | INLINE_CLOSE_TRIM_BOTH )          # InlineBlockSegment
    | ( PARTIAL_BLOCK_OPEN | PARTIAL_BLOCK_TRIM_OPEN ) WS partialKey ( WS expressionChain )? WS ( CLOSE | TRIM_CLOSE )
        segment*
        partialBlockClose                                                                                    # PartialBlockSegment
    ;

inlineName
    : IDENTIFIER
    | TRANSFORM
    ;

// The bare `{{/>}}` and named `{{/> name }}` close forms are isolated in their own sub-rule so their CLOSE/TRIM_CLOSE
// delimiter token is a distinct rule-context accessor from the open tag's, since both go through EXPR mode.
partialBlockClose
    : ( PARTIAL_BLOCK_CLOSE | PARTIAL_BLOCK_CLOSE_TRIM ) ( WS inlineName WS )? ( CLOSE | TRIM_CLOSE )
    ;

partialKey
    : IDENTIFIER
    | TRANSFORM
    | LPAREN WS? expressionChain WS? RPAREN
    ;

eachElseArm
    : ( ELSE_TAG | ELSE_TAG_TRIM_OPEN | ELSE_TAG_TRIM_CLOSE | ELSE_TAG_TRIM_BOTH ) segment*
    ;

elseIfArm
    : ( ELSE_IF_OPEN | ELSE_IF_TRIM_OPEN ) WS expressionChain WS ( CLOSE | TRIM_CLOSE ) segment*
    ;

elseArm
    : ( ELSE_TAG | ELSE_TAG_TRIM_OPEN | ELSE_TAG_TRIM_CLOSE | ELSE_TAG_TRIM_BOTH ) segment*
    ;

Code-point lexing

Every source location attached to a parse- or render-time failure reports line (1-based, advancing once per line break — a \r\n pair advances it exactly once, not twice) and column (0-based, counted in Unicode code points, not UTF-16 code units and not raw bytes). A character outside the Basic Multilingual Plane (for example, an emoji) is one code point, and therefore advances column by exactly one, even though it occupies two UTF-16 Chars in a Kotlin String and a variable number of bytes in UTF-8. A port whose native string type is UTF-16-backed (Java, C#, JavaScript) or UTF-8-backed (Rust, Go, Swift) must count in Unicode code points explicitly for column — the host string type's native "length" or indexing operations typically count code units or bytes, not code points.

This rule governs both the grammar's own parse-time failures (KB-1001, KB-1002) and render-time failures — a syntax error on line three of a template reports line 3, not a fixed line 1 for every parse error. See Errors for the full location model, including the templateStack carried alongside line/column.

Normative fixture: conformance/render/source-location.json — a non-BMP emoji before a failing expression (proving code-point, not UTF-16-unit or byte, counting), LF-only vs. CRLF multi-line templates reporting the same line number, and a multi-line template whose failure is a grammar-level KB-1001.