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}}) becomesprefix \{{ 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 {{ bbecomes\{{ 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.