Skip to content

Grammar

This is the complete, single, internally-consistent EBNF grammar of Quill. It is the authoritative grammar to parse, highlight, or reason about Quill. It conforms to the prose in the Language Reference: the expression ladder is the seventeen-level ladder, the statement set is the closed keyword set, the lexical model is the two-mode TEXT/CODE machine, and the type-annotation grammar is the gradual layer described in Types.

The grammar is presented in four layers, mirroring how the front end consumes a file: the lexical grammar, the structural grammar, the expression grammar, and the type-annotation grammar. The last section is the ambiguity-resolution catalogue.

Notation. = defines a rule; . ends it; | alternates; [ ] is optional; { } is zero-or-more; ( ) groups; double-quoted "..." is a terminal literal (the literal | operator terminal is written "\|" to distinguish it from EBNF alternation). Lexical terminals produced by the scanner are UPPER_CASE (NAME, STRING, INT, FLOAT, TEXT_RUN, NL). A production the scanner (not the parser) recognizes is marked (* lexical *).

Two grammars, one design

Quill has two grammars because the input is two languages interleaved: literal TEXT (emitted verbatim) and Quill CODE (parsed). The scanner runs the lexical grammar to split bytes into TEXT spans and CODE tokens; the parser runs the structural and expression grammars over the CODE tokens. The single boundary rule (a bare {/} is never a delimiter; only {{, {#, an @-sigil statement lead, and verbatim open CODE) is what makes the split decidable with zero heuristic lookahead.

The default statement form is the explicit-close @-sigil mode: a statement head is led by an @ immediately before its keyword (@for, @if, @block, ...) and a block is closed by the explicit @} token. Under this default a bare { or } anywhere in template TEXT (including a lone } line at column 0) is unconditionally literal output: no escaping, no grammar-shape rejection, no lone-} collision, no line-leading-keyword diagnostic. This makes brace-dense TEXT correct by default at the cost of one @ per statement. Interpolation {{ }}, comments {# #}, and string interpolation #{ } are unchanged.

The bare keyword-led mode (no @, with a lone } line closing the innermost block) remains valid Quill as an explicit per-template opt-in (pragma bare, also spelled pragma sigil off). It suits markup and other templates where literal braces are rare. Under bare mode the lone-} escapes below (leading-pipe text marker, verbatim, interpolation) apply to any literal } line; under the @-default they are needed only for edge cases.

The lexical grammar

(* A source file is an alternation of TEXT spans and CODE constructs. *)
SourceFile   = { TextSpan | Interp | Comment | Statement | Verbatim } .  (* lexical-driven *)

(* TEXT: any run of bytes that does not open a CODE construct. *)
TextSpan     = TEXT_RUN .                                  (* lexical *)
(* TEXT_RUN is the maximal run of bytes in TEXT mode in which:
     - no '{' is immediately followed by '{' or '#'   (the sigil predicate)
     - under the @-default, the line is not an @-sigil statement head;
       under bare mode (pragma bare), the line is not a leading-keyword head
     - '\{' '\}' '\\' are consumed as the literal '{' '}' '\'
   A lone '{' or '}' is an ordinary TEXT byte. Under the @-default a lone '}'
   line is ALSO ordinary TEXT (only @} closes a block); under bare mode a lone
   '}' line closes the innermost block (R4a). *)

Sigil        = "{{" | "{#" .                               (* lexical, atSigil predicate *)
(* atSigil(src,i) := src[i]=='{' AND i+1<len AND src[i+1] IN {'{','#'} *)
(* StmtLead opens a statement: '@'+keyword under the @-default; a line-leading
   keyword under bare mode. BlockClose is '@}' under the @-default; a lone '}'
   line under bare mode. *)

TrimL        = "-" | "~" .         (* hard trim / line trim, opening side *)
TrimR        = "-" | "~" | "+" .   (* hard trim / line trim / keep-newline, closing side *)

Interp       = "{{" [TrimL] Expr [ PostfixCond ] [TrimR] "}}" .
PostfixCond  = ("if" | "unless") Expr [ "else" Expr ] .
Comment      = "{#" { ANY_BYTE } "#}" .                    (* lexical; eats one trailing NL *)

Verbatim     = "verbatim" "{" RawBody "}"                  (* brace-balanced, never scanned *)
             | "verbatim" FENCE RawLines FENCE .           (* heredoc form *)
RawBody      = (* bytes, inner { } tracked by a raw-brace depth counter *) .
FENCE        = (* an author-chosen token, e.g. ~~~END, on its own line *) .

(* String, number, identifier, and operator tokens, scanned only inside CODE. *)
STRING       = "'" { SBYTE } "'"                           (* single: no interpolation *)
             | "\"" { DBYTE | "#{" Expr "}" } "\""         (* double: interpolation *)
             | "`" { RBYTE } "`" .                         (* backtick: raw, no escapes *)
INT          = DIGITS | "0x" HEXD | "0b" BIND | "0o" OCTD .
FLOAT        = DIGITS "." DIGITS [ EXP ] | DIGITS EXP .
DIGITS       = DIGIT { [ "_" ] DIGIT } .                   (* digit-group separators *)
NAME         = (LETTER | "_") { LETTER | DIGIT | "_" } .
NL           = "\n" .                                      (* after CR/CRLF normalization *)

The interpolation closer }} is recognized only at brace-depth zero relative to its opener, so a mapping literal's } inside {{ ... }} does not close it (R3). The comment closer #} and a statement's closing } each eat one immediately-following newline; }} eats none (R5).

The structural grammar

The productions below are written in the @-default spelling: a statement head carries a leading @ and a block body is closed by the explicit @} token (written BLOCK_CLOSE). The opening { after a head is a block-open marker, not a literal brace, and it is the only { the parser consumes structurally. Under the bare opt-in (pragma bare), the leading @ is absent and BLOCK_CLOSE is a lone } line; the productions are otherwise identical.

Template = { TopItem } .
TopItem  = Extends | Block | Macro | Use | Import | From
         | Stmt | TextSpan | Interp | Comment | Verbatim .

(* The explicit block close. BLOCK_CLOSE is "@}" under the @-default and a lone
   "}" line under pragma bare. *)
BLOCK_CLOSE = "@}" .

(* Composition heads. *)
Extends  = "@extends" Expr NL .
Block    = "@block" NAME [ "(" [Params] ")" ] [ "->" Type ]
           ( "{" { Item } BLOCK_CLOSE | Expr NL ) .
Macro    = "@macro" NAME "(" [Params] ")" [ "->" Type ] "{" { Item } BLOCK_CLOSE .
Import   = "@import" ImportSrc "as" NAME NL .
From     = "@from" ImportSrc "import" ImportList NL .
ImportSrc = Expr | "_self" .              (* a path expression, or the current template *)
ImportList = NAME [ "as" NAME ] { "," NAME [ "as" NAME ] } .
Use      = "@use" Expr [ "with" Map ] NL .
Embed    = "@embed" Expr [ "with" Map ] [ "only" ] [ "ignore" "missing" ]
           "{" { Block } BLOCK_CLOSE .

(* Statements (the closed keyword set). *)
Stmt     = If | For | Set | Capture | With | Apply | Do | Flush
         | Deprecated | Guard | Types | Escape | Sandbox | Line | Cache
         | Log | TabBlock | Provide | Yield | CallBlock
         | Include | Embed .
(* Capture is a set-tail form, not a free statement and not an Expr (R12). *)

If       = "@if" Expr "{" { Item }
           { "@elseif" Expr "{" { Item } }
           [ "@else" "{" { Item } ] BLOCK_CLOSE .
For      = "@for" Target [ "," Target ] "in" Expr [ "recursive" ] [ "if" Expr ]
           "{" { Item } [ "@else" "{" { Item } ] BLOCK_CLOSE .
Target   = NAME [ ":" Type ] .
Set      = "@set" Target { "," Target } "=" Expr { "," Expr } NL .
Capture  = "@set" NAME [ ":" Type ] "=" "capture" "{" { Item } BLOCK_CLOSE .  (* set-tail block-capture form *)
With     = "@with" Map [ "only" ] "{" { Item } BLOCK_CLOSE .
Apply    = "@apply" { "\|" NAME [ "(" Args ")" ] } "{" { Item } BLOCK_CLOSE .
Do       = "@do" Expr NL .
Flush    = "@flush" NL .
Deprecated = "@deprecated" STRING [ "since" STRING ] NL .
Guard    = "@guard" CallableRef "{" { Item } [ "@else" "{" { Item } ] BLOCK_CLOSE .
CallableRef = ( "filter" | "function" | "test" ) "(" STRING ")" .
Types    = "@types" "{" { TypeDecl } BLOCK_CLOSE .
TypeDecl = NAME ":" Type [ "," ] .
Escape   = "@escape" ( NAME | "off" ) "{" { Item } BLOCK_CLOSE .
Sandbox  = "@sandbox" "{" { Item } BLOCK_CLOSE .
Line     = "@line" INT NL .
Cache    = "@cache" { NAME "=" Expr } "{" { Item } BLOCK_CLOSE .
Log      = "@log" Expr NL .
TabBlock = "@tab" "(" Expr ")" "{" { Item } BLOCK_CLOSE .
Provide  = "@provide" NAME "{" { Item } BLOCK_CLOSE .
Yield    = "@yield" NAME NL .
CallBlock = "@call" [ "(" [Params] ")" ] NAME "(" [Args] ")" "{" { Item } BLOCK_CLOSE .
Include  = "@include" Expr [ "with" Expr ] [ "only" ] [ "ignore" "missing" ] NL .

(* The body of a block: text, output, comment, or nested statement. *)
Item     = TextSpan | Interp | Comment | Stmt | Block | Macro | Verbatim .

The @elseif/@else continuation heads and the closing BLOCK_CLOSE make If, For, and Guard close at one explicit @}; the intermediate heads re-open the body without closing it. The capture form is @set X = capture { ... @}, a dedicated set-tail production (not a general expression); capture is reachable only immediately after @set NAME [: Type] =. The Block shortcut value form @block title "Default" is the Expr NL alternative of Block.

The expression grammar

The productions encode the seventeen-level ladder (Expressions); a left-associative operator at binding power p recurses on its right operand at p+1, a right-associative operator at p. The implementation is a Pratt table; the two agree by construction.

Expr        = Assign .
Assign      = Ternary [ "=" Assign ] .                     (* right-assoc; LHS may be a target *)
Ternary     = Coalesce [ "?" Expr [ ":" Expr ] ] .
Coalesce    = Or { ("??" | "?:") Or } .
Or          = Xor { ("or" | "||") Xor } .
Xor         = And { "xor" And } .
And         = BitOr { ("and" | "&&") BitOr } .
BitOr       = BitXor { ("b_or" | "|||") BitXor } .
BitXor      = BitAnd { ("b_xor" | "^") BitAnd } .
BitAnd      = Cmp { ("b_and" | "&") Cmp } .
Cmp         = Range { CmpOp Range | TestApp } .
CmpOp       = "==" | "!=" | "<" | ">" | "<=" | ">=" | "<=>"
            | "in" | "not" "in" | "matches"
            | "starts" "with" | "ends" "with"
            | "has" "some" | "has" "every" .
TestApp     = ("is" | "is" "not") TestName [ TestArgs ] .
TestName    = NAME [ NAME ] .                              (* two-word tests, greedy (R7) *)
TestArgs    = Primary | "(" Args ")" .                     (* one-positional short form, or full Args *)
Range       = Concat { ".." Concat } .
Concat      = Add { "~" Add } .
Add         = Mul { ("+" | "-") Mul } .
Mul         = Power { ("*" | "/" | "//" | "%") Power } .
Power       = Unary [ "**" Power ] .                       (* right-assoc; RHS at prefix (R6) *)
Unary       = ("not" | "!" | "-" | "+" | "...") Unary | Postfix .
Postfix     = Primary { "." Name | "?." Name
                      | "[" Slice "]" | "?[" Expr "]"
                      | "(" Args ")"
                      | "\|" Name [ "(" Args ")" ] } .
Slice       = Expr | [Expr] ":" [Expr] .
Primary     = Literal | SpecialName | Name | "(" Expr ")" | Seq | Map | Arrow .
Name        = NAME .                                       (* word-operators are NAME here (R2) *)
SpecialName = "_self" | "_context" | "_charset" .          (* reserved, engine-resolved (R11) *)
Arrow       = ( NAME | "(" [ParamList] ")" ) "=>" Expr .
ParamList   = Param { "," Param } .                        (* arrow params are positional-only *)
Param       = NAME [ ":" Type ] [ "=" Expr ] | "..." NAME .
Params      = MacroParamList .                             (* macro/block/call-block heads *)
MacroParamList = [ Param { "," Param } ] [ "," ] [ "..." NAME [ "," ] ] [ "**" NAME ] .
                                                           (* ordered tail: optional "..." NAME then optional "**" NAME, each last (R13) *)
Seq         = "[" [ Expr { "," Expr } [","] ] "]" .
Map         = "{" [ MapEntry { "," MapEntry } [","] ] "}" .
MapEntry    = Name | Name ":" Expr | "(" Expr ")" ":" Expr | "..." Expr .
Args        = [ Arg { "," Arg } ] .
Arg         = Name ":" Expr | "..." Expr | Expr .
Literal     = INT | FLOAT | STRING | "true" | "false" | "null" | "none" .

The assignment LHS, when = follows, is reinterpreted as a destructuring target:

Target_      = Name | Seq_ | Map_ .
Seq_         = "[" [ TgtSlot { "," TgtSlot } [ "," "..." Name ] ] "]" .
TgtSlot      = [ Target_ [ "?" ] ] .                       (* elided slots; optional slot *)
Map_         = "{" MapTgt { "," MapTgt } "}" .
MapTgt       = Name | Name ":" Name .                      (* {name} or {key: alias} *)

The type-annotation grammar

Type     = UnionType .
UnionType = AtomType { "|" AtomType } [ "?" ] .            (* A | B, trailing ? is nullable *)
AtomType = "any" | "null" | "bool" | "int" | "float" | "string"
         | "list" "<" Type ">"
         | "map" "<" Type "," Type ">"
         | "Object" "<" STRING ">"                         (* host-registered named type *)
         | "(" [ TypeList ] ")" "=>" Type                  (* arrow/callable type *)
         | "(" Type ")" .
TypeList = Type { "," Type } .

Annotation sites: types { ... } declarations, macro f(p: T = d) -> T, block b(in: T) -> T, set x: T = e, for x: T in e, and arrow params (x: T) => e. The | inside a UnionType appears only in a type context (after :, ->, or inside < >), where it can never be the filter pipe (R8).

Ambiguity-resolution catalogue

Every ambiguity an implementer hits, with the exact rule that resolves it.

R1: TEXT vs CODE at a brace. A { opens CODE only when the next byte is { or # (the atSigil predicate). Otherwise it is a TEXT byte. Under the @-default, statement heads are led by @ and blocks close at @}, so neither a bare { nor a bare } (including a lone } line at column 0) is ever a delimiter; both are literal output with no escaping. This needs no lookahead beyond one byte.

R2: word-operator vs identifier. A word-operator spelling (and, or, not, in, is, matches, xor, starts, ends, has) is lexed as a NAME. The parser reclassifies it to an operator only in infix/prefix position. In primary position and immediately after . or |, it stays a NAME, so u.in, data | matches_count, and a context variable named and all resolve as identifiers.

R3: interpolation close vs literal } inside CODE. Inside {{ ... }} the lexer balances (), [], {}; the close }} is recognized only at brace-depth zero relative to the opener. A mapping literal {a: 1} inside an interpolation is at depth 1 and does not close it. A bare }} in TEXT with no open {{ is two literal } bytes.

R4: statement head vs literal output line. Under the @-default, a line is a statement only when its first non-whitespace token is @ immediately followed by a keyword from the closed set and a word boundary; an unprefixed line beginning with a keyword spelling (a C for (int i...), a Java if (x) {) is unconditionally TEXT, because no @ leads it. Under the bare opt-in (pragma bare), a line is a statement when its first non-whitespace token is the bare keyword followed by a word boundary AND the line parses as a complete statement head; there a C for (int i...) falls back to TEXT only because ( is not the required Target, and the leading-pipe | marker or a verbatim region forces TEXT for a line that would otherwise parse as a head.

R5: newline-eating asymmetry. A block close (@} under the default, a lone } line under bare mode) and a comment's #} consume one immediately-following newline; an interpolation's }} consumes none. This is the default, overridden per-site by the -/~ trim modifiers and suppressed by the + keep modifier (@}+, #}+, R14) or by a per-template pragma keep-close-newline.

R4a: which close pops a block. Under the @-default, the closer of a TEXT-bodied statement is the explicit @} token (optionally with a trim/keep modifier). A line whose only non-whitespace content is a bare } is ordinary TEXT and pops nothing. Only @} closes, so a bare } can never collide with block structure. An @} with an empty open-block stack, or an open block unclosed at end of file, is a hard unbalanced-block error that is never silently absorbed.

Under the bare opt-in (pragma bare), the closer is instead a line whose only non-whitespace content is }. Literal {/} in a TEXT body are not brace-counted (only CODE balances braces, R3), so such a lone } line always pops the innermost open Quill block. In bare mode a literal lone-} line emitted inside a Quill block must be disambiguated by the leading-pipe marker (| }), a verbatim region, or interpolation ({{ "}" }}); indentation alone does not exempt it.

R6: power vs unary minus. ** is right-associative and binds tighter than unary minus, but the unary prefix wraps the power node by AST shape: Unary -> ("-") Unary | Postfix and Power -> Unary [ "**" Power ] together yield -1 ** 0 = -(1 ** 0) = -1 and (-1) ** 2 = 1 from one consistent rule.

R7: two-word test names. After is/is not, the parser greedily consumes up to two NAME tokens to form the test name (same as, divisible by), then optionally an argument. Single-token tests take one NAME. A following ( begins a parenthesized argument list rather than a third name word.

R8: the pipe | vs union | vs bitwise-or. The bare | is exclusively the filter pipe in expression position. Bitwise OR is the word b_or (alias |||). Type union | appears only in a type context. The three uses never overlap because each is confined to a distinct syntactic position.

R9: arrow param list vs grouping. ( ... ) is an arrow param list only when immediately followed by =>; otherwise it is a grouped expression. A single bare NAME => is also an arrow. An arrow ParamList is positional-only; a ** NAME kwargs tail is a MacroParamList feature and is rejected on an arrow. Within a MacroParamList the two tail captures obey a fixed terminal order: an optional ... NAME positional variadic, then an optional ** NAME kwargs, each last.

R10: assignment target vs expression. The LHS of = is parsed as an Expr, then reinterpreted as a Target_ when = follows. A sequence literal [a, b] on the left of = is a destructuring target; the same [a, b] without a following = is a sequence value.

R11: special names vs context identifiers. _self, _context, and _charset are reserved SpecialName primaries, resolved by the engine and exempt from the strict-undefined rule. They are recognized in primary position and as an ImportSrc; a context variable of the same name is shadowed. After . or | they are ordinary NAMEs.

R12: capture is a set-tail, not an expression. capture { ... @} is grammatical only in the Capture production. It is not a free Stmt and not an Expr Primary, so a line statement's NL terminator can never fall inside its body. A bare capture { ... @} outside a @set tail is a parse error.

R13: the matches operand and the bare /. matches takes an ordinary Expr whose value is a string RE2 pattern; there is no regex-literal token. A / is always the division or floor-division operator, never a pattern delimiter. Inline flags ride inside the pattern string as RE2 (?flags).

R14: the + close modifier. On a block close or #} only, a + (@}+ under the default, }+ under bare mode, and #}+) suppresses the one-newline-eating of R5, preserving the following newline. + is a close-side trim modifier exclusively; the additive + appears only inside an Expr, never adjacent to a closing delimiter.

The grammar in one design

The productions above compose into one grammar. The entry point is SourceFile; Template is the top-level production the parser drives once the scanner has classified spans. The two grammars meet at exactly one seam, and nowhere else: the atSigil predicate (R1) and the statement-head test (R4: the @-sigil lead under the default, the leading-keyword test under pragma bare). This is why the whole language is decidable byte-by-byte without heuristic lookahead.