Velo

Reference

Grammar & Operator Precedence

A formal reference for Velo's surface syntax. The grammar is written in EBNF:

  • = defines a rule, terminated by ;
  • | alternatives, [ x ] optional, { x } zero or more, ( x ) grouping
  • "lit" a literal token, 'c' a literal character
  • lexical rules are UPPERCASE, grammar rules are lowerCamel

Expression structure (which operator binds first) is governed by the precedence table at the end, not by the EBNF alone.

Lexical structure

program        = { statement [ terminator ] } ;
terminator     = NEWLINE | ";" ;          (* a newline ends a statement; ";" only
                                             needed to separate statements on one line *)
comment        = "#" { any-char-except-newline } NEWLINE ;

IDENT          = ( LETTER | "_" ) { LETTER | DIGIT | "_" } ;
LETTER         = "a".."z" | "A".."Z" ;
DIGIT          = "0".."9" ;

NUMBER         = INT_LIT | FLOAT_LIT | HEX_LIT | BIN_LIT ;   (* no type suffixes *)
INT_LIT        = DIGIT { DIGIT | "_" } ;   (* int; auto-long if > 32-bit; widens to float / fits a byte by context *)
FLOAT_LIT      = DIGIT { DIGIT | "_" } "." DIGIT { DIGIT } ;
HEX_LIT        = "0x" HEXDIGIT { HEXDIGIT | "_" } ;
BIN_LIT        = "0b" ( "0" | "1" ) { "0" | "1" | "_" } ;
CHAR_LIT       = "'" ( CHARACTER | ESCAPE ) "'" ;           (* value is its code point *)

STRING         = '"' { STRCHAR | ESCAPE | interpolation } '"' ;
interpolation  = "$" IDENT | "${" expression "}" ;         (* write \$ for a literal $ *)
ESCAPE         = "\" ( "n" | "t" | "\" | '"' | "$" | "'" ) ;

Declarations & statements

A statement is any of these forms or a bare expression.

statement      = import
               | typedDecl | letDecl
               | funcDecl  | extDecl  | operatorDecl
               | classDecl | dataDecl | actorDecl | interfaceDecl | enumDecl
               | if | when | while | for | return | "break" | "continue"
               | try | throw
               | expression ;

import         = "import" STRING [ "as" IDENT ] ;
                                     (* .vel optional; std/ = stdlib; else relative to file.
                                        `as ns` namespaces members: ns.func(), new ns.Class() *)

typedDecl      = type IDENT [ "=" expression ] ;           (* explicit type; mutable *)
letDecl        = "let" IDENT "=" expression ;              (* inferred type; immutable *)

funcDecl       = "func" [ IDENT ] [ typeParams ] "(" params ")" type block ;
extDecl        = "ext" "(" param ")" IDENT "(" params ")" type block ;
operatorDecl   = "operator" operatorName "(" params ")" type block ;
operatorName   = OPERATOR | "[" "]" [ "=" ] ;              (* e.g. +, ==, [], []= *)

classDecl      = "class"  IDENT [ typeParams ] "(" params ")" [ ":" typeList ] block ;
dataDecl       = "data" classDecl ;                        (* immutable value type *)
actorDecl      = "actor" classDecl ;                       (* concurrent, message-driven *)
interfaceDecl  = "interface" IDENT block ;                 (* body holds method signatures *)
enumDecl       = "enum" IDENT "{" { variant [ terminator ] } "}" ; (* closed sum type *)
variant        = IDENT [ "(" params ")" ] ;               (* each variant is a value-type record *)

if             = "if" [ "(" ] expression [ ")" ]
                 ( block [ "else" ( block | if ) ]
                 | "then" expression "else" expression ) ; (* the then/else form is an expression *)
when           = "when" expression "{"                    (* pattern match / switch, an expression *)
                 { pattern "->" expression [ terminator ] }
                 [ "else" "->" expression [ terminator ] ] "}" ;
pattern        = IDENT [ "(" [ IDENT { "," IDENT } ] ")" ] (* enum variant, optional field bindings *)
               | expression ;                             (* literal value (primitive switch)      *)
while          = "while" [ "(" ] expression [ ")" ] block ;
for            = "for" IDENT "in" ( expression ".." expression   (* range, end exclusive *)
                                  | expression ) block ;         (* array iteration       *)
return         = "return" [ expression ] ;
try            = "try" block "catch" "(" "Error" IDENT ")" block ;  (* the caught type is always Error *)
throw          = "throw" expression ;                (* an Error value, or a string literal shorthand *)

block          = "{" { statement [ terminator ] } "}" ;

typeParams     = "[" typeParam { "," typeParam } "]" ;
typeParam      = IDENT [ ":" type ] ;                      (* interface-bounded generic *)
params         = [ param { "," param } ] ;
param          = type IDENT [ "=" expression ] ;           (* trailing default value    *)
typeList       = type { "," type } ;

Types

type           = "byte" | "int" | "long" | "float" | "str" | "bool"
               | "void" | "any" | "Self"
               | "array" "[" type "]"
               | "tuple" "[" type { "," type } "]"
               | "dict"  "[" type ":" type "]"           (* sugar for Map[K, V] *)
               | "ptr"   "[" type "]"
               | "actor" "[" type "]"
               | "future" "[" type "]"
               | funcType
               | IDENT [ "[" type { "," type } "]" ] ;   (* class / interface, maybe generic *)

funcType       = "func" "[" ( "(" [ typeList ] ")" type   (* full signature: func[(int,int) int] *)
                            | type ) "]" ;                (* loose form:     func[int]           *)

Expressions

Velo is expression-oriented: if, while, for and blocks are parsed as expressions (so let x = if c then a else b is valid). The statement/expression split above is only for readability.

expression     = assignment ;
assignment     = binary [ assignOp expression ] ;          (* right-associative *)
assignOp       = "=" | "+=" | "-=" | "*=" | "/=" | "%=" ;

binary         = unary { binOp unary } ;                   (* grouped per precedence table *)
binOp          = "||" | "|" | "&&" | "&" | "^"
               | "==" | "!=" | "<" | ">" | "<=" | ">="
               | "+" | "-" | "*" | "/" | "%" ;

unary          = [ "-" | "!" | "&" | "*" ] postfix ;       (* neg, not, address-of, deref *)

postfix        = primary { call | index | property | apply } ;
call           = "(" [ argList ] ")" ;
index          = "[" expression "]" ;
property       = "." ( IDENT | INT_LIT ) [ "(" [ argList ] ")" ] ;
                 (* .field / .N  → bare access;  .method(...) / .conv()  → call *)
apply          = "{" { statement [ terminator ] } "}" ;    (* apply block (e.g. UI builders) *)
argList        = expression { "," expression } ;

primary        = NUMBER | STRING | CHAR_LIT
               | "true" | "false" | "null" | "void"
               | IDENT
               | lambda
               | newExpr
               | asyncExpr | awaitExpr
               | if | while | for            (* control forms are expressions too *)
               | "(" expression ")" ;

lambda         = "func" [ typeParams ] "(" params ")" type block ;
newExpr        = "new" ( "array" "[" type "]" ( "(" expression ")" | "{" [ argList ] "}" )
                       | "tuple" "(" argList ")"
                       | "dict" "[" type ":" type "]" "{" [ dictEntries ] "}"
                       | "ptr" "[" type "]" "(" expression ")"
                       | IDENT [ "[" typeList "]" ] "(" [ argList ] ")" ) ;
dictEntries    = dictEntry { "," dictEntry } ;
dictEntry      = expression ":" expression ;
asyncExpr      = "async" postfix "." IDENT "(" [ argList ] ")" ;
awaitExpr      = "await" expression ;

Operator precedence

From loosest (binds last) to tightest (binds first). Higher rows are evaluated later. Example: a + b * c parses as a + (b * c) (multiplicative binds tighter than additive); a == b && c == d parses as (a == b) && (c == d).

LevelOperatorsKindAssociativity
1= += -= *= /= %=assignmentright
2|| |logical-or / bitwise-orleft
3&& &logical-and / bitwise-andleft
4^bitwise-xorleft
7== != < > <= >=comparisonleft
10+ -additiveleft
20* / %multiplicativeleft
25- ! & * (prefix)unary: negate, not, address-of, derefright
30(…) […] . {…}call, index, property, applyleft

Notes:

  • Prefix unary binds looser than the postfix chain, as in C/Java/Kotlin: the operand of - ! & * is the whole postfix production, so !d.key(k) is !(d.key(k)), -p.get() is -(p.get()) and &a[i] is &(a[i]). It still binds tighter than every binary operator, so -a * b is (-a) * b.
  • &&/|| are logical with short-circuit; &/|/^ are bitwise on ints (and &/| also short-circuit on bools, kept as aliases). Prefer &&/||.
  • .. (range) is not a general operator — it appears only in for i in a..b.
  • There are no shift operators; use x.shl(n) / x.shr(n).
  • < is lowered internally but behaves as a normal left-associative comparison.