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_CASEToken/terminal
lower_caseNon-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, returns Bool
  • as - Extract value if pattern matches, return alternative otherwise
  • guard - 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-c declares a C function binding
  • extern-zig declares 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 fails
  • T?(expr) - Safe construction; returns Option T
  • T?!(expr) - Safe construction; returns Result 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)deferprefixDeferred execution
2|> |>>leftPipe operators
3??rightNull coalesce
4||leftLogical OR
5&&leftLogical AND
6== !=leftEquality
7< <= > >=leftComparison
8++leftString concatenation
9:: @leftList cons/append
10+ -leftAddition/subtraction
11* / %leftMultiplication/etc
12- !rightUnary negation/not
13calls, .leftApplication/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
𝑓fnLambda 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

  1. Indentation: Kit uses significant whitespace for multi-line constructs. Indented blocks are used in lambda bodies, if/else branches, match arms, and test blocks.
  2. Kebab-case: Identifiers can contain hyphens (my-function), which is idiomatic for function names.
  3. Constructor Recognition: Uppercase identifiers are treated as constructors in patterns.
  4. Negative Numbers in Application: When using Haskell-style function application, negative numbers must be parenthesized: func (-1) not func -1.
  5. For Expressions: For loops (for x in list => body) desugar to each. They support pattern destructuring in the binding.
  6. SQL Expressions: SQL blocks support string interpolation with ${expr}. The optional connection expression allows specifying a database connection.
  7. Heredocs: Squiggly heredocs (<<~DELIM) strip common leading indentation from the content, making embedded code and markup easier to read.
  8. Contracts: @pre and @post attributes on bindings define preconditions and postconditions that are checked at runtime.
  9. Zero-arity Functions: Lambda expressions without parameters can omit the parentheses: fn => expr is equivalent to fn() => expr.
  10. Lazy-accepting Parameters: The ~ sigil before a parameter indicates it accepts lazy values without auto-forcing. Regular parameters auto-force any Lazy or Memo values passed to them. Example: fn(~value) => ....
  11. Match Macros: The is, as, and guard macros provide concise pattern matching. is returns a boolean, as extracts with a default, and guard binds or returns early. These expand to equivalent match expressions.
  12. Compile-Time Macros: User-defined macros use quasiquote syntax: `(expr) creates a code template, and $param substitutes parameters. Macros are expanded at compile time before type checking.
  13. Row Polymorphism: Record types are closed by default (require exact fields). Add ... to make them open: {name: String, ...} accepts records with at least a name field. Use {...} to accept any record.
  14. Field Accessor Shorthand: The syntax .field is shorthand for fn(x) => x.field, useful in pipelines: users |> map .name.