Grammar Specification
This document provides a formal grammar specification for the Kit programming language in Extended Backus-Naur Form (EBNF).
Notation
| Symbol | Meaning |
|---|---|
| | Alternation |
[ ] | Optional |
{ } | Zero or more repetitions |
( ) | Grouping |
" " | Terminal string |
UPPER_CASE | Token/terminal |
lower_case | Non-terminal |
Program Structure
program = { declaration } EOF ;
declaration = type_def
| import_stmt
| export_stmt
| module_decl
| test_block
| extern_decl
| trait_def
| trait_impl
| binding ;
Type Definitions
type_def = "type" TYPE_NAME { TYPE_VAR } "=" type_body ;
type_body = constructor { "|" constructor }
| "|" constructor { "|" constructor } ;
constructor = CONSTRUCTOR_NAME [ constructor_args ] ;
constructor_args = "(" field_list ")"
| type { type } ;
field_list = field { "," field } ;
field = IDENT { attribute } ;
Attributes
Attributes provide metadata annotations on constructor fields. They are used for ORM mappings, serialization hints, validation rules, and other metadata.
attribute = "@" attr_name [ "(" attr_args ")" ] ;
attr_name = IDENT { "-" IDENT } ; (* supports hyphenated names *)
attr_args = attr_arg { "," attr_arg } ;
attr_arg = STRING | INT | FLOAT | "true" | "false" | IDENT ;
Import/Export
import_stmt = "import" import_path [ "as" IDENT ]
| "import" import_path "." "{" import_list "}" ;
import_path = STRING | module_path ;
module_path = IDENT { "." IDENT } ;
import_list = import_item { "," import_item } ;
import_item = IDENT [ "as" IDENT ] ;
export_stmt = "export" ( binding | type_def ) ;
module_decl = "module" module_path ;
Test Blocks
test_block = [ "@skip" [ STRING ] ] "test" [ test_name ] indented_block ;
test_name = STRING | KEYWORD_LITERAL ;
The @skip attribute marks a test to be skipped during execution, with an optional reason string.
Match Macros
Kit provides three concise pattern matching macros for common operations:
is_expr = expression "is" pattern ;
as_expr = expression "as" pattern "->" expression "else" expression ;
guard_stmt = "guard" pattern "=" expression "else" expression ;
is- Check if a value matches a pattern, returnsBoolas- Extract value if pattern matches, return alternative otherwiseguard- Bind pattern variables if match succeeds, return early otherwise
These macros provide concise alternatives to verbose match expressions.
Compile-Time Macros
Kit supports user-defined compile-time macros for code generation:
macro_def = "macro" IDENT { IDENT } "=" quasiquote ;
quasiquote = "`" "(" expression ")" ;
unquote = "$" IDENT ;
Macros are expanded at compile time. The quasiquote syntax (`) creates a code
template, and $param unquotes (substitutes) parameter values into the template.
# Define a macro
macro double x = `($x + $x)
# Usage - expands to (5 + 5) at compile time
result = double(5) # 10
Extern Declarations
extern_decl = extern_c_decl | extern_zig_decl ;
extern_c_decl = "extern-c" IDENT "(" [ param_list ] ")" "->" type
"from" STRING "link" STRING ;
extern_zig_decl = "extern-zig" IDENT "(" [ param_list ] ")" "->" type
"from" STRING "link" STRING ;
param_list = param { "," param } ;
param = IDENT ":" type ;
extern-cdeclares a C function bindingextern-zigdeclares a Zig function binding
Traits
trait_def = "trait" IDENT TYPE_VAR [ "requires" constraint_list ]
trait_body ;
trait_body = { trait_method } ;
trait_method = IDENT ":" type
| IDENT "=" expression ;
trait_impl = "extend" type "with" IDENT [ "where" constraint_list ]
impl_body ;
impl_body = { method_def } ;
method_def = IDENT "=" expression ;
constraint_list = constraint { "," constraint } ;
constraint = IDENT TYPE_VAR ;
Bindings
binding = { contract } pattern [ ":" type ] "=" expression
| qualified_binding ;
qualified_binding = TYPE_NAME "." IDENT "=" expression ;
contract = "@pre" "(" expression [ "," STRING ] ")"
| "@post" "(" expression [ "," STRING ] ")" ;
Contracts (@pre and @post) provide precondition and postcondition
assertions for functions. The optional string argument provides a custom error message.
Destructuring in Bindings
Bindings support pattern matching with tuple and record patterns, allowing you to extract values directly:
# Tuple destructuring
(x, y) = (10, 20)
(a, b, c) = get-triple()
# Record destructuring with shorthand (field name becomes binding name)
{name, age} = person
# Record destructuring with explicit rename
{name: user-name, age: user-age} = person
# Partial record destructuring (extract only some fields)
{x, z} = {x: 1, y: 2, z: 3}
# Nested destructuring
{outer: {inner}} = nested-record
((a, b), (c, d)) = nested-tuples
# Wildcards to ignore values
{keep, ignore: _} = data
(_, important, _) = triple
Expressions
Precedence (lowest to highest)
expression = defer_expr ;
defer_expr = "defer" expression | pipe_expr ;
pipe_expr = null_coalesce { ( "|>" | "|>>" ) null_coalesce } ;
null_coalesce = or_expr { "??" or_expr } ;
or_expr = and_expr { "||" and_expr } ;
and_expr = equality { "&&" equality } ;
equality = comparison { ( "==" | "!=" ) comparison } ;
comparison = concat { ( "<" | "<=" | ">" | ">=" ) concat } ;
concat = cons { "++" cons } ;
cons = term { ( "::" | "@" ) term } ;
term = factor { ( "+" | "-" ) factor } ;
factor = unary { ( "*" | "/" | "%" ) unary } ;
unary = ( "-" | "!" ) unary | call_expr ;
call_expr = primary { call_suffix } ;
call_suffix = "(" [ arg_list ] ")" (* C-style call *)
| "." IDENT (* field access *)
| prefix_arg ; (* Haskell-style *)
prefix_arg = primary ; (* excluding operators *)
arg_list = expression { "," expression } ;
Primary Expressions
primary = INT | FLOAT | STRING
| "true" | "false"
| "()" (* unit *)
| KEYWORD_LITERAL (* :symbol *)
| IDENT
| field_accessor
| lambda | if_expr | match_expr | for_expr | sql_expr
| group_or_tuple | list_literal | set_literal
| record_literal | interpolated_string | heredoc ;
field_accessor = "." IDENT ; (* shorthand for fn(x) => x.IDENT *)
lambda = "fn" [ "(" [ param_patterns ] ")" ] "=>" lambda_body ;
param_patterns = param_pattern { "," param_pattern } ;
param_pattern = [ "~" ] pattern ; (* ~ marks lazy-accepting parameter *)
lambda_body = expression | indented_block ;
if_expr = "if" expression "then" if_branch "else" if_branch ;
if_branch = expression | indented_block ;
match_expr = "match" expression match_arms ;
match_arms = { "|" match_arm } ;
match_arm = or_pattern [ guard ] "->" expression
| or_pattern [ guard ] "=>" expression ;
or_pattern = pattern { "|" pattern } ;
guard = "when" expression | "if" expression ;
for_expr = "for" pattern "in" expression "=>" for_body ;
for_body = expression | indented_block ;
sql_expr = "sql" [ expression [ "as" type_name ] ] "{" sql_content "}" ;
sql_content = { SQL_CHAR | "${" expression [ ":" FORMAT_SPEC ] "}" } ;
group_or_tuple = "(" expression [ "," expression { "," expression } ] ")" ;
list_literal = "[" [ list_elements ] "]" ;
list_elements = expression { "," expression } [ "|" expression ] ;
set_literal = "#{" [ expression { "," expression } ] "}" ;
record_literal = "{" [ record_fields ] "}" ;
record_fields = record_field { "," record_field } ;
record_field = "..." expression (* spread *)
| IDENT ":" expression
| IDENT ; (* shorthand *)
interpolated_string = STRING_START { string_part } STRING_END ;
string_part = STRING_CONTENT
| "${" expression [ ":" FORMAT_SPEC ] "}" ;
heredoc = "<<~" IDENT NEWLINE heredoc_content IDENT ;
heredoc_content = { HEREDOC_CHAR | "${" expression [ ":" FORMAT_SPEC ] "}" } ;
Note: Zero-arity functions can omit parentheses:
fn => expr is equivalent to fn() => expr.
Heredocs use squiggly syntax (<<~) which strips common leading indentation.
They support the same string interpolation as regular strings.
Patterns
pattern = "_" (* wildcard *)
| INT | FLOAT | STRING
| "true" | "false"
| KEYWORD_LITERAL
| IDENT (* binding or constructor *)
| constructor_pattern
| tuple_pattern | list_pattern | record_pattern ;
constructor_pattern = CONSTRUCTOR_NAME [ constructor_payload ] ;
constructor_payload = "(" pattern { "," pattern } ")"
| "{" record_pat_fields [ ".." ] "}"
| pattern { pattern } ; (* space-separated *)
tuple_pattern = "(" pattern "," pattern { "," pattern } ")" ;
list_pattern = "[" [ list_pat_elements ] "]" ;
list_pat_elements = ".." pattern (* rest at start *)
| pattern { "," pattern } [ ( "|" | ".." ) pattern ] ;
record_pattern = "{" [ record_pat_fields ] "}" ;
record_pat_fields = record_pat_field { "," record_pat_field } ;
record_pat_field = IDENT [ ":" pattern ] ;
Types
type = simple_type [ "->" type ] ; (* function type *)
simple_type = "Int" | "Float" | "String" | "Bool" | "Unit"
| TYPE_NAME [ type_args ]
| tuple_type | record_type | refinement_type
| "(" type ")" ;
type_args = simple_type { simple_type } ;
tuple_type = "(" type "," type { "," type } ")" ;
record_type = "{" [ record_type_fields ] [ "..." ] "}" ;
record_type_fields = record_type_field { "," record_type_field } ;
record_type_field = IDENT ":" type ;
refinement_type = "{" IDENT ":" type "|" expression "}" ;
Refinement Types
Refinement types constrain values beyond what the base type expresses.
The syntax follows Liquid Haskell: {binding: BaseType | predicate}.
Refinement Construction
refinement_construct = TYPE_NAME "!" "(" expression ")" (* assert *)
| TYPE_NAME "?" "(" expression ")" (* Option *)
| TYPE_NAME "?!" "(" expression ")" ; (* Result *)
T!(expr)- Assert construction; panics if predicate failsT?(expr)- Safe construction; returnsOption TT?!(expr)- Safe construction; returnsResult T String
Row Polymorphism
Kit supports opt-in row polymorphism for record types using ... syntax.
By default, record types are closed (require exact fields). Adding ...
makes them open (accept extra fields).
- Closed record (default):
{name: String}- requires exactly the specified fields - Open record (opt-in):
{name: String, ...}- requires specified fields, allows extras - Empty open record:
{...}- accepts any record
Indented Blocks
indented_block = NEWLINE INDENT { block_item } DEDENT ;
block_item = binding | expression ;
Indentation is significant. An indented block begins when the indentation level increases and ends when it returns to the previous level.
Lexical Elements
Identifiers
IDENT = LOWER_IDENT | UPPER_IDENT ;
LOWER_IDENT = ( LETTER_LOWER | "_" ) { LETTER | DIGIT | "_" | "-" } ;
UPPER_IDENT = LETTER_UPPER { LETTER | DIGIT | "_" | "-" } ;
LETTER = LETTER_LOWER | LETTER_UPPER ;
LETTER_LOWER = "a" ... "z" ;
LETTER_UPPER = "A" ... "Z" ;
DIGIT = "0" ... "9" ;
Numeric Literals
INT = DECIMAL_INT | HEX_INT | BINARY_INT | OCTAL_INT ;
DECIMAL_INT = [ "-" ] DIGIT { DIGIT | "_" } [ INT_SUFFIX ] ;
HEX_INT = [ "-" ] "0" ( "x" | "X" ) HEX_DIGIT { HEX_DIGIT | "_" } ;
BINARY_INT = [ "-" ] "0" ( "b" | "B" ) BINARY_DIGIT { BINARY_DIGIT | "_" } ;
OCTAL_INT = [ "-" ] "0" ( "o" | "O" ) OCTAL_DIGIT { OCTAL_DIGIT | "_" } ;
FLOAT = [ "-" ] DIGIT { DIGIT } "." DIGIT { DIGIT } [ FLOAT_SUFFIX ] ;
HEX_DIGIT = DIGIT | "a" ... "f" | "A" ... "F" ;
BINARY_DIGIT = "0" | "1" ;
OCTAL_DIGIT = "0" ... "7" ;
INT_SUFFIX = "L" (* int64 *)
| "u" | "ul" (* uint32 *)
| "I" ; (* bigint *)
FLOAT_SUFFIX = "f" | "F" (* float32 *)
| "m" | "M" ; (* decimal *)
Examples: 255, 0xFF, 0b1111_1111, 0o377, 1_000_000
String Literals
STRING = '"' { STRING_CHAR | ESCAPE_SEQ } '"' ;
STRING_CHAR = any character except '"', '\', or '${' ;
ESCAPE_SEQ = "\" ( "n" | "r" | "t" | "\" | '"' | "$" ) ;
Keyword Literals
KEYWORD_LITERAL = ":" IDENT ;
Comments
COMMENT = "#" { any character except newline } NEWLINE ;
DOC_COMMENT = "##" { any character except newline } NEWLINE ;
Doc comments (##) are associated with the following declaration
and can be used for documentation generation.
Operator Precedence Table
| Precedence | Operators | Associativity | Description |
|---|---|---|---|
| 1 (lowest) | defer | prefix | Deferred execution |
| 2 | |> |>> | left | Pipe operators |
| 3 | ?? | right | Null coalesce |
| 4 | || | left | Logical OR |
| 5 | && | left | Logical AND |
| 6 | == != | left | Equality |
| 7 | < <= > >= | left | Comparison |
| 8 | ++ | left | String concatenation |
| 9 | :: @ | left | List cons/append |
| 10 | + - | left | Addition/subtraction |
| 11 | * / % | left | Multiplication/etc |
| 12 | - ! | right | Unary negation/not |
| 13 | calls, . | left | Application/access |
| 14 (high) | literals | - | Primary expressions |
Unicode Operator Aliases
Kit supports Unicode characters as aliases for common operators:
| Unicode | ASCII | Description |
|---|---|---|
× | * | Multiplication |
÷ | / | Division |
⇒ | => | Fat arrow |
→ | -> | Right arrow |
− | - | Minus sign |
≠ | != | Not equal |
≤ | <= | Less than or equal |
≥ | >= | Greater than or equal |
… | ... | Spread operator |
𝑓 | fn | Lambda keyword |
Reserved Words
as defer else export extend extern-c extern-zig
false fn for from guard if import
in is link macro match module requires
sql test then trait true type when
where with
Pre-registered Constructors
The following constructors are pre-registered and can be used in patterns without a type definition:
# Result and Option types
Ok Err Some None
# Backoff strategies
NoBackoff Constant Linear Exponential
# Jitter strategies
NoJitter FullJitter EqualJitter ProportionalJitter
Destructuring
Kit supports destructuring in bindings, allowing you to extract values from tuples and records directly into variables.
Tuple Destructuring
# Basic tuple destructuring
point = (10, 20)
(x, y) = point
println "x = ${x}, y = ${y}"
# Multiple values
(a, b, c) = (1, 2, 3)
# Nested tuple destructuring
((x1, y1), (x2, y2)) = ((0, 0), (10, 10))
# Ignore elements with wildcard
(first, _, third) = (1, 2, 3)
Record Destructuring
# Shorthand syntax - field name becomes variable name
person = {name: "Alice", age: 30}
{name, age} = person
println "Name: ${name}, Age: ${age}"
# Explicit rename - bind to different variable names
{name: user-name, age: user-age} = person
println "User: ${user-name}"
# Partial destructuring - only extract needed fields
config = {host: "localhost", port: 8080, debug: true}
{port} = config
# Mixed shorthand and rename
{name, age: years-old} = person
# Nested record destructuring
user = {info: {name: "Bob", email: "bob@example.com"}}
{info: {name, email}} = user
# Ignore fields with wildcard
{keep, ignore: _} = {keep: 42, ignore: 999}
Destructuring in Functions
# Tuple destructuring in function parameters
add-points = fn((x1, y1), (x2, y2)) =>
(x1 + x2, y1 + y2)
# Record destructuring from function return
make-point = fn(x, y) => {x: x, y: y}
{x, y} = make-point 5 10
# In for loops
for (key, value) in Map.entries(m) =>
println "${key}: ${value}"
Examples
Type Definition
type Option a = Some a | None
type Result a e =
| Ok a
| Err e
type Point = Point(x, y)
Type Definition with Attributes
# Single-line with attributes
type User = User(id @primary-key @auto-increment, name, email @unique)
# Multi-line with attributes
type Product = Product(
id @primary-key,
price @default(0.0),
category_id @fkey(Category, id),
tags @json("tags", omitempty)
)
Lambda Functions
add = fn(a, b) => a + b
# Zero-arity function (parentheses optional)
get-time = fn => now()
process = fn(items) =>
result = items |> map square
result
# Lazy-accepting parameter (~ sigil)
maybe-compute = fn(~value, should-force?) =>
if should-force? then force(value) else 0
For Expressions
# Simple for loop
for x in [1, 2, 3] => print(x)
# With tuple destructuring
for (k, v) in map-entries(m) => print("${k}: ${v}")
# Multi-line body
for item in items =>
processed = transform(item)
save(processed)
SQL Expressions
# Simple SQL
sql { SELECT * FROM users }
# With connection
sql db { SELECT * FROM users WHERE id = ${user_id} }
# With type casting
sql db as User { SELECT * FROM users WHERE id = 1 }
Heredocs
html = <<~HTML
<div>
<h1>${title}</h1>
<p>${content}</p>
</div>
HTML
Contracts
@pre(n >= 0, "n must be non-negative")
@post(result >= 0)
factorial = fn(n) =>
if n <= 1 then 1 else n * factorial(n - 1)
Test Blocks
test "addition works"
assert-eq!(1 + 1, 2)
@skip "not implemented yet"
test "future feature"
todo()
Pattern Matching
match value
| Some x -> x
| None -> default
match point
| Point(0, 0) -> "origin"
| Point(x, 0) -> "on x-axis"
| Point(0, y) -> "on y-axis"
| Point(x, y) -> "at (${x}, ${y})"
List Patterns
# Head and tail
match list
| [head | tail] -> process(head, tail)
| [] -> empty()
# Rest pattern
match args
| [first, second, ..rest] -> handle(first, second, rest)
| [..all] -> handle-all(all)
Numeric Literals
decimal = 1_000_000
hex = 0xFF
binary = 0b1010_1010
octal = 0o755
int64 = 100L
bigint = 999999999999999999I
float32 = 3.14f
Pipe Operators
data |> transform |> output # thread-first
list |>> map double |>> filter # thread-last
Traits
trait Eq a
eq: (a, a) -> Bool
ne = fn(a, b) => !(eq a b)
extend Int with Eq
eq = fn(a, b) => a == b
Match Macros
# is: Check if value matches pattern (returns Bool)
opt = Some 42
if opt is Some _ then "has value" else "empty"
# as: Extract value with default
name = user as Some u -> u.name else "anonymous"
# guard: Bind or return early
process = fn(opt) =>
guard Some value = opt else Err "was None"
guard true = value > 0 else Err "not positive"
Ok (value * 2)
Compile-Time Macros
# Define macros with quasiquote syntax
macro double x = `($x + $x)
macro square x = `($x * $x)
macro unless cond then-val else-val =
`(if not($cond) then $then-val else $else-val)
# Use macros (expanded at compile time)
result = double(5) # expands to (5 + 5)
squared = square(4) # expands to (4 * 4)
msg = unless((x > 0), "negative", "positive")
Row Polymorphism
# Closed record (default) - requires exact fields
greet-closed = fn(person: {name: String}) =>
"Hello, " ++ person.name
greet-closed({name: "Kit"}) # OK
# greet-closed({name: "Kit", age: 1}) # Type error: extra field
# Open record (opt-in with ...) - accepts extra fields
greet-open = fn(person: {name: String, ...}) =>
"Hello, " ++ person.name
greet-open({name: "Kit"}) # OK
greet-open({name: "Kit", age: 1}) # OK - extra field allowed
# Empty open record - accepts any record
log-record = fn(r: {...}) => println(r)
Field Accessor Shorthand
# .field is shorthand for fn(x) => x.field
users = [{name: "Alice", age: 30}, {name: "Bob", age: 25}]
# These are equivalent:
names1 = users |> map(fn(u) => u.name)
names2 = users |> map .name
# Useful in pipelines
users |> filter(fn(u) => u.age > 21) |> map .name
Notes
- Indentation: Kit uses significant whitespace for multi-line constructs. Indented blocks are used in lambda bodies, if/else branches, match arms, and test blocks.
- Kebab-case: Identifiers can contain hyphens (
my-function), which is idiomatic for function names. - Constructor Recognition: Uppercase identifiers are treated as constructors in patterns.
- Negative Numbers in Application: When using Haskell-style function
application, negative numbers must be parenthesized:
func (-1)notfunc -1. - For Expressions: For loops (
for x in list => body) desugar toeach. They support pattern destructuring in the binding. - SQL Expressions: SQL blocks support string interpolation with
${expr}. The optional connection expression allows specifying a database connection. - Heredocs: Squiggly heredocs (
<<~DELIM) strip common leading indentation from the content, making embedded code and markup easier to read. - Contracts:
@preand@postattributes on bindings define preconditions and postconditions that are checked at runtime. - Zero-arity Functions: Lambda expressions without parameters can omit
the parentheses:
fn => expris equivalent tofn() => expr. - Lazy-accepting Parameters: The
~sigil before a parameter indicates it accepts lazy values without auto-forcing. Regular parameters auto-force anyLazyorMemovalues passed to them. Example:fn(~value) => .... - Match Macros: The
is,as, andguardmacros provide concise pattern matching.isreturns a boolean,asextracts with a default, andguardbinds or returns early. These expand to equivalent match expressions. - Compile-Time Macros: User-defined macros use quasiquote syntax:
`(expr)creates a code template, and$paramsubstitutes parameters. Macros are expanded at compile time before type checking. - Row Polymorphism: Record types are closed by default (require exact fields).
Add
...to make them open:{name: String, ...}accepts records with at least anamefield. Use{...}to accept any record. - Field Accessor Shorthand: The syntax
.fieldis shorthand forfn(x) => x.field, useful in pipelines:users |> map .name.