Syntax Reference
This page covers every lexical element of Fels, from comments and identifiers to literals, operators and punctuation.
Comments
Section titled “Comments”Fels has line comments only and no block comments. A line comment starts with // and
runs to the end of the line:
// This is a commentx := 42; // inline comment[!NOTE] Fels leaves out block comments (
/* ... */) on purpose. For a longer comment, write several line comments in a row.
Doc comments
Section titled “Doc comments”A comment that starts with /// documents the declaration right below it, and //! lines at
the top of a file document the module. Hover and completion in the editor show them, and
fels doc makes a reference out of them. A plain // comment is never documentation.
//! Shapes and their areas.
/// A circle of radius `r`.pub Circle :: struct { /// the radius, in metres r: f64,}
/// The area of `c`.pub area :: fn(c: Circle) -> f64 { return 3.14159 * c.r * c.r; }Identifiers
Section titled “Identifiers”Identifiers are the names you give to variables, functions, types and other declarations.
Rules:
- Must start with a letter (
a–z,A–Z) or underscore (_) - May continue with letters, digits (
0–9) or underscores - Are case-sensitive, so
foo,FooandFOOare three different identifiers
x := 1;my_variable := 2;_private := 3;Point2D :: struct { x: f64, y: f64 }Escaped Identifiers
Section titled “Escaped Identifiers”Put @ in front of an identifier to use a keyword as an ordinary name. The same prefix lets
you define special names (like the runtime’s @panic) that cannot clash with the names in
user code:
@if := 42; // uses the keyword 'if' as a variable name@panic("oh no"); // the runtime's panic functionKeywords
Section titled “Keywords”The tables below list every keyword in Fels, grouped by category.
Declaration Keywords
Section titled “Declaration Keywords”| Keyword | Purpose |
|---|---|
fn |
Function declaration |
struct |
Struct type definition |
union |
Union type definition (fields share their storage) |
interface |
Interface (trait) type definition |
enum |
Enum type definition |
errors |
Error set type definition |
distinct |
Distinct (newtype) wrapper |
opaque |
Opaque type (forward declaration) |
pub |
Public visibility modifier |
Qualifier Keywords
Section titled “Qualifier Keywords”| Keyword | Purpose |
|---|---|
atomic |
Atomic type qualifier |
borrow |
Borrow qualifier for zone-owned values |
const |
Const qualifier |
volatile |
Volatile qualifier |
Control Flow Keywords
Section titled “Control Flow Keywords”| Keyword | Purpose |
|---|---|
if, else |
Conditional branching |
while |
While loop |
for, in |
For loops (range and slice iteration) |
match |
Pattern matching |
return |
Return from function |
break |
Break from loop |
continue |
Continue to next loop iteration |
defer |
Deferred execution (runs at scope exit) |
Other Keywords
Section titled “Other Keywords”| Keyword | Purpose |
|---|---|
zone |
Memory allocation zone |
as |
Type cast / interface implementation |
unsafe |
Unsafe block or expression |
import |
Module import (used as #import) |
type |
Type keyword (used in generic parameters: $T: type) |
fail |
Fail with error |
catch |
Catch errors |
yield |
End a catch handler with the value to use in place of the call’s |
Literal Keywords
Section titled “Literal Keywords”| Keyword | Purpose |
|---|---|
null |
Null literal (for optionals) |
true |
Boolean true |
false |
Boolean false |
Words that are not reserved
Section titled “Words that are not reserved”Some words only mean something in one position, and everywhere else you can use them as
ordinary names. These are macro and constraint after :: (a macro or constraint
declaration) and where after a signature. The same goes for ok and err after defer.
and for the names after #, such as extern, run and quote. So macro := 5; is a valid
declaration.
Primitive Type Keywords
Section titled “Primitive Type Keywords”Fels has a fixed set of primitive types:
| Type | Description |
|---|---|
bool |
Boolean (true or false) |
void |
Void / unit type |
i8 |
8-bit signed integer |
i16 |
16-bit signed integer |
i32 |
32-bit signed integer |
i64 |
64-bit signed integer |
isize |
Pointer-sized signed integer |
u8 |
8-bit unsigned integer |
u16 |
16-bit unsigned integer |
u32 |
32-bit unsigned integer |
u64 |
64-bit unsigned integer |
usize |
Pointer-sized unsigned integer |
f32 |
32-bit IEEE 754 float |
f64 |
64-bit IEEE 754 float |
[!NOTE] The lexer sees
f32andf64as identifiers, not keywords. They are recognized as primitive types later, during semantic analysis.
Literals
Section titled “Literals”Integer Literals
Section titled “Integer Literals”You can write integer literals in decimal, hexadecimal or binary. Underscores may go anywhere in the digits to make long numbers easier to read.
42 // decimal, type inferred42i32 // explicit i32 suffix255u8 // explicit u8 suffix1000u64 // explicit u64 suffix0xFF // hexadecimal0xff // hex (case insensitive)0b1010 // binary1_000_000 // underscores for readability0xDEAD_BEEF // hex with underscoresBase prefixes:
| Prefix | Base | Digits |
|---|---|---|
| (none) | 10 (decimal) | 0–9 |
0x / 0X |
16 (hexadecimal) | 0–9, a–f, A–F |
0b / 0B |
2 (binary) | 0, 1 |
Type suffixes:
An integer literal may end with a type suffix that sets its exact type: i8, i16, i32,
i64, isize, u8, u16, u32, u64, usize. Without a suffix, the type is inferred
from the context.
Float Literals
Section titled “Float Literals”A float literal has a decimal point with digits after it, an exponent (e or E, an optional
sign, then digits), or both.
3.14 // f64 by default1.0 // decimal with fractional part required0.5 // leading zero2.5f32 // explicit f32 suffix (f32 and f64 are the only float suffixes)1e5 // 100000.0: an exponent makes a float, with or without the point1.5e-3 // 0.00156.02E23f32[!NOTE] A bare integer like
1is never treated as a float on its own. When you mean a floating-point value, write1.0(or1e0).
String Literals
Section titled “String Literals”String literals go in double quotes, and their type is []const u8.
"hello world" // simple string"line1\nline2" // escape sequences"tab\there" // tab character"quote\"inside" // escaped double-quote"backslash\\" // escaped backslashEscape sequences:
| Sequence | Meaning |
|---|---|
\\ |
Backslash |
\" |
Double quote |
\n |
Newline (LF) |
\t |
Horizontal tab |
\r |
Carriage return |
\' |
Single quote |
\0 |
Null byte |
\xHH |
The byte with hex value HH, two hex digits (\x41 is A) |
\u{H…} |
The UTF-8 bytes of a Unicode code point, 1 to 6 hex digits (\u{e9} is é, two bytes) |
A \u{…} names a code point up to 10FFFF that is no surrogate (D800 to DFFF). Any other
backslash in a literal is an error that lists these escapes.
Raw Strings
Section titled “Raw Strings”A raw string, r"…", is its bytes as written: a backslash in it is a backslash. It is for
Windows paths and regular expressions. It ends at the next " on its line, so it cannot hold a
".
dir := r"C:\Users\fels\new"; // the same as "C:\\Users\\fels\\new"number := r"\d+\.\d+";Multi-line Strings
Section titled “Multi-line Strings”A string of several lines goes between """ delimiters. Its lines start on the line after the
opening """, and the closing """ is on a line of its own. The indentation in front of the
closing """ is taken off every line, so the text can be indented with the code around it.
The newline before the closing line is no part of the string.
usage :: fn() -> []const u8 { return """ usage: tool [options] <file>
-o <out> write to "out" """;}That string is "usage: tool [options] <file>\n\n -o <out> write to \"out\"". A " needs
no backslash in it. The escapes are those of any string, and a {name} in a """ string
passed to println is printed as in any other string. A line that does not start with the
closing delimiter’s indentation is an error, unless it is blank.
Character Literals
Section titled “Character Literals”A character literal is written in single quotes and is sugar for its integer
code point. It is an untyped integer literal (like 65), so it coerces to u8,
i32, a rune and so on from the context. There is no dedicated char type.
'A' // 65'.' // 46'\n' // 10, same escapes as strings'\\' // 92'\'' // 39'\x41' // 65, hex escape'\u{20ac}' // 8364, the code point of €Because it is an ordinary integer literal, it works anywhere a number does:
nl: u8 = '\n';if name[i] == '.' { dots = dots + 1; } // coerces to u8 in the comparisonA bare multi-byte UTF-8 character, such as 'é', decodes to its Unicode code point.
Boolean Literals
Section titled “Boolean Literals”truefalseNull Literal
Section titled “Null Literal”You use the null literal with optional types:
null // for optionalsOperators
Section titled “Operators”Arithmetic Operators
Section titled “Arithmetic Operators”| Operator | Description | Example |
|---|---|---|
+ |
Addition | a + b |
- |
Subtraction | a - b |
- |
Unary negation (prefix) | -x |
* |
Multiplication | a * b |
/ |
Division | a / b |
% |
Remainder (modulo) | a % b |
Comparison Operators
Section titled “Comparison Operators”| Operator | Description | Example |
|---|---|---|
== |
Equal | a == b |
!= |
Not equal | a != b |
< |
Less than | a < b |
<= |
Less than or equal | a <= b |
> |
Greater than | a > b |
>= |
Greater than or equal | a >= b |
[!NOTE] Comparison operators do not chain, so an expression like
a < b < cis a compile error. Writea < b && b < cinstead.
Logical Operators
Section titled “Logical Operators”| Operator | Description | Example |
|---|---|---|
&& |
Logical AND (short-circuiting) | a && b |
|| |
Logical OR (short-circuiting) | a || b |
! |
Logical NOT (prefix) | !flag |
Bitwise Operators
Section titled “Bitwise Operators”| Operator | Description | Example |
|---|---|---|
& |
Bitwise AND | a & b |
| |
Bitwise OR | a | b |
^ |
Bitwise XOR | a ^ b |
~ |
Bitwise NOT (prefix) | ~mask |
<< |
Left shift | a << 4 |
>> |
Right shift | a >> 2 |
[!NOTE] Used as a prefix, the
&symbol is also the address-of operator:&my_var.
Assignment Operators
Section titled “Assignment Operators”| Operator | Description | Example |
|---|---|---|
= |
Assignment | x = 10 |
+= |
Add and assign | x += 1 |
-= |
Subtract and assign | x -= 1 |
*= |
Multiply and assign | x *= 2 |
/= |
Divide and assign | x /= 2 |
%= |
Remainder and assign | x %= 3 |
&= |
Bitwise AND and assign | x &= mask |
|= |
Bitwise OR and assign | x |= bits |
^= |
Bitwise XOR and assign | x ^= bits |
<<= |
Left shift and assign | x <<= 1 |
>>= |
Right shift and assign | x >>= 1 |
Special Operators
Section titled “Special Operators”| Operator | Kind | Description |
|---|---|---|
? |
Postfix | Error propagation: returns early on error |
?? |
Infix | Nil coalesce: gives a default for optional/error values |
!! |
Postfix | Force unwrap: panics on null or error |
as |
Infix | Type cast |
& |
Prefix | Address-of |
* |
Prefix | Pointer dereference |
. |
Infix | Field access / method call |
:: |
Infix | Constant or declaration binding |
:= |
Infix | Variable binding (type-inferred) |
-> |
Return type arrow (in function signatures) | |
=> |
Match arm arrow | |
.. |
Infix | Exclusive range (e.g., 0..10) |
..= |
Infix | Inclusive range (e.g., 0..=9) |
$ |
Prefix | Type parameter prefix (e.g., $T) |
Operator Precedence
Section titled “Operator Precedence”The table lists operators from highest to lowest precedence:
| Precedence | Operators | Associativity |
|---|---|---|
| 1 (highest) | ?, !!, catch (postfix), calls, . field access, indexing |
Left-to-right |
| 2 | !, ~, &, *, - (prefix) |
Right-to-left |
| 3 | as |
Left-to-right |
| 4 | *, /, %, *% |
Left-to-right |
| 5 | +, -, +%, -% |
Left-to-right |
| 6 | <<, >> |
Left-to-right |
| 7 | & (bitwise) |
Left-to-right |
| 8 | ^ |
Left-to-right |
| 9 | | |
Left-to-right |
| 10 | ==, !=, <, <=, >, >= |
Left-to-right |
| 11 | && |
Left-to-right |
| 12 (lowest) | || |
Left-to-right |
The postfix operators bind tighter than the prefix ones, so -a.b is -(a.b) and *p.next is
*(p.next). A prefix operator binds tighter than as: -x as u8 is (-x) as u8, *p as i32
casts the value *p loads, and &x as usize is the address of x as a number. as binds
tighter than the binary operators, so a == b as i64 is a == (b as i64).
?? has no single row, because it binds tightly on its left and loosely on its right. Its left
operand is only the operand just before it, so x + o ?? 0 is x + (o ?? 0). Its right operand
runs to the end of the expression, so o ?? 0 + 1 is o ?? (0 + 1). A chain groups from the
right, so a ?? b ?? c is a ?? (b ?? c).
[!TIP] When in doubt, add parentheses to make the precedence explicit. The code is easier to read that way, and you avoid subtle bugs.
Declarations
Section titled “Declarations”Constants (Compile-Time)
Section titled “Constants (Compile-Time)”You bind a constant with ::, and its value must be computable at compile time. By
convention, constant names are written in UPPER_SNAKE_CASE:
MAX :: 100;PI :: 3.14159;GREETING :: "hello";The initializer does not have to be a literal. Arithmetic over constants that are already declared is folded to a value at build time, and constants can build on each other to any depth:
W :: 800;STRIDE :: W * 3; // 2400, folded during the buildSIZE :: STRIDE * 450; // 1080000, folded through STRIDEA constant has to be declared before the constants that use it.
A string constant is a real []const u8 slice, so slice operations work on it directly.
GREETING.len is 5, and GREETING[0] is the first byte. (More generally, field and index
access work on any top-level constant, not only on locals.)
You can write a constant’s type between the two colons, NAME : T : value, much as a
variable has one (name: T = value). The value then takes that type. It can be a struct or
an array, which gives you a table the program reads but cannot change:
LIMIT : i64 : 5000000000;ORIGIN : Point : .{ 0.0, 0.0 };DIGITS : [10]u8 : .{ 48, 49, 50, 51, 52, 53, 54, 55, 56, 57 };A constant is read-only data. Assigning to it, to one of its fields or to one of its
elements is a compile error. &ORIGIN is a *const Point, so nothing can write to it
through a pointer either. (A pointer that a constant holds may still point at something
writable.)
A constant can also be declared in a function body. It belongs to its block, from the declaration to the end of the block. Its value is computed while compiling, as any constant’s is, so it can be the length of an array:
draw :: fn() { CELLS :: 16; WIDTH : u16 : CELLS * 8; grid: [CELLS]u8 = .{}; // ...}Its value can use literals and other constants. It cannot use the function’s locals or
parameters, because those have no value until the function runs: for that, use a local
(width := cells * 8;). A local in the same block, or in a block inside it, cannot reuse the
constant’s name.
Constants can also bind functions, types, and other compile-time constructs:
add :: fn(a: i32, b: i32) -> i32 { return a + b;}
Point :: struct { x: f64, y: f64,}Mutable globals
Section titled “Mutable globals”A top-level binding written with : T = (instead of ::) is a mutable
global. It is exactly the form of a local variable, lifted to module scope. The
operator makes the whole difference. :: gives an immutable compile-time constant,
and : T = (like a local’s =) gives a variable you can change at runtime. There
is no mut, var or static keyword.
counter: i64 = 0; // mutable global, explicit type requiredflags: u32 = 0u32;
bump :: fn() { counter += 1; } // read + write from anywhereThe initializer lives in static storage, like a C static. It is data in the image,
laid out by the compiler and placed by the linker, and nothing runs at startup to
put it there. You always write a global’s type, while an immutable :: constant
infers its type from the initializer (or has it written, NAME : T : value). Reading
a global loads its current value, and = and the compound assignments (+=, …) store to it.
Static aggregates
Section titled “Static aggregates”A global can hold more than a scalar. Its initializer can be any of the following, nested to any depth:
| Initializer | Type of the global (or of the field / element) |
|---|---|
a constant expression: a literal, other constants, core::sizeof, a #run call |
any integer, f32/f64, bool, []const u8 (a string) |
.{ ... }, positional or named |
a struct |
.{ ... } |
an array |
.{} |
anything above: a struct with every field defaulted or zero, an array of zeros, an empty slice (a union only this way) |
| the name of a top-level function | a function value: a fn(...) closure, or an extern fn(...) C pointer |
&OTHER, the address of another top-level global |
a pointer *T or ?*T |
null |
a pointer or an optional |
.variant or Enum.variant, and Enum.variant(payload) for one that carries a payload |
an enum, or an error of a set (.eof, HttpError.status(404)) |
#run call() that returns the whole value |
a struct, an array, an enum value, a function pointer, or a string, also as one field or element of a literal |
Point :: struct { x: i32, y: i32 }Vtbl :: struct { add: fn(i32, i32) -> i32, id: u32 }Node :: struct { v: i32, next: *Node }Mode :: enum { fast, slow }Shape :: enum { circle: f64, square: f64 }
add :: fn(a: i32, b: i32) -> i32 { return a + b; }dbl :: fn(x: i32) -> i32 { return x * 2; }
ORIGIN: Point = .{ 3, 4 };PRIMES: [5]u32 = .{ 2u32, 3u32, 5u32, 7u32, 11u32 };VT: Vtbl = .{ add, 7u32 };HANDLERS: [2]extern fn(i32) -> i32 = .{ dbl, dbl };A: Node = .{ 1, &B }; // the two refer to each other;B: Node = .{ 2, &A }; // the order they are written in does not matterMODE: Mode = .slow;FIRST: Shape = Shape.circle(1.5); // a payload, a string one too
use :: fn() -> i32 { return VT.add(1, 2) + HANDLERS[1](4); } // calls through the tablesA literal is read exactly as the same literal would be for a local variable. A named literal gives every omitted field its default, and it is an error if one of those fields has none. A positional (or empty) literal that stops short gives the fields after it their defaults, up to the first field that has none, and zero from there on. So put defaulted fields last, as you would for trailing default arguments. An array literal is zero past its last element.
The value is checked against the type, and a mistake is a compile error that points at the offending value:
- a number that does not fit (
300for au8) - a string for an integer
- a field that does not exist or is given twice
- a field with no default left out
- too many initializers
- a function that is generic or a lambda
- a pointer to anything but a top-level global
fels check reports these like any type error. A value that only the compile-time evaluator
can compute (a #run) is checked when the program is built.
A few things are not supported yet:
- an optional that is not a pointer and holds anything but
null - the address of an element or a field (
&TABLE[2],&OBJ.id). Take&TABLEor&OBJinstead. - a slice of anything but a string
There is no uninitialised form either. Write .{} when you want zeros.
You can also let the compiler build a table instead of typing it out. #run runs a function
at compile time, and what it returns is the initial value. Like a literal, it is data in the
image:
CRC: [256]u32 = #run make_crc_table(); // built once, by the compiler, on the comptime VMORIGIN :: #run find_origin(); // `::` takes the type of the callOnly data can come back. A pointer into compile-time memory, a payload variant or a lambda is refused, and the error names the place in the value that is wrong. See 09_comptime for more.
Code that runs at compile time (#run, #assert) can read static data. It sees a global’s
initial value, whether that is a struct, an array, an enum value or a function pointer. It can
index that value, take a field, call through it and take the address of an element. It cannot
change it, though. A write is a compile error, because the image is what runs and a write would
not be in it. Two kinds of global cannot be read yet: one that holds the address of another
global, and one that a #run computes. Reading either of them stops that #run and names the
global.
The function-pointer forms differ in what they hold. A fn(...) field of a struct and an
extern fn(...) are both the bare code address. That is what a C structure of function pointers
holds, so a Vtbl like the one above has the layout a C header gives it. A plain fn(...)
global is a closure { code, environment }, and when it is stored statically it is the
function with no environment. You call either one where it stands: F(x), VT.add(1, 2),
HANDLERS[i](x).
An address stored in a global is a number that the linker fixes. An executable linked by
felsld is always loaded at its own base address, and it holds the final value. A DLL, and an
executable linked by LLD (which randomizes the base address), carries a base-relocation table
that the loader applies. That way the stored addresses follow the image wherever it lands. For
this reason, felsld hands any DLL that holds an address to LLD.
Such a global is exported like a function, with #export. See
11_c_interop for the details.
Variables (Inferred Type)
Section titled “Variables (Inferred Type)”You declare a variable with :=, and its type is inferred from the right-hand side:
x := 42; // inferred as integername := "Fels"; // inferred as []const u8flag := true; // inferred as boolVariables (Explicit Type)
Section titled “Variables (Explicit Type)”When you want to state the type yourself, use the : Type = syntax:
count: i32 = 0;data: [4]u8 = .{ 1u8, 2u8, 3u8, 4u8 };buffer: [256]u8 = .{};[!TIP] Prefer
:=with type inference when the type is obvious from the context. Write the type out when the inferred one would be ambiguous, or when you need a specific numeric width.
Punctuation Reference
Section titled “Punctuation Reference”| Symbol | Usage |
|---|---|
; |
Statement terminator, required after every statement |
, |
Separator in lists (parameters, fields, arguments) |
: |
Type annotation separator |
:: |
Constant / declaration binding |
:= |
Variable binding (type inferred) |
( ) |
Grouping, function parameters, call arguments |
{ } |
Blocks, struct bodies, enum bodies |
[ ] |
Array / slice indexing and type syntax |
.{ } |
Compound literal (struct, array, or enum initialization) |
# |
Directive prefix (#import, #if, #run, attributes) |
|x| |
Payload capture (in match arms and if / while conditions) |
Quick Example
Section titled “Quick Example”To put it all together, here is a small Fels program that uses the core syntax elements:
#import std.io.{ print, println };
MAX_SIZE :: 1024;
Buffer :: struct { data: [MAX_SIZE]u8, len: usize,}
BufferError :: errors { overflow,}
make_buffer :: fn() -> Buffer { return .{ .data = .{}, .len = 0, };}
append :: fn(buf: *Buffer, byte: u8) -> void ! BufferError { if buf.len >= MAX_SIZE { fail .overflow; } buf.data[buf.len] = byte; buf.len += 1;}
main :: fn() -> i32 { buf := make_buffer();
// Fill the buffer with values for i in 0..10 { append(&buf, i as u8) catch err { println("append failed"); return 1; }; }
println("buffer has {buf.len} bytes"); return 0;}