Fly Language Reference
Version: 0.13.3
Project: Fly Programming Language
License: Apache License v2.0
Table of Contents
- Introduction
- Lexical Elements
- Types
- Variables
- Functions
- Classes and Structures
- Enumerations
- Expressions
- Statements
- Namespaces and Imports
- Modifiers
- Comments
- Grammar Summary
1. Introduction
Fly is a compiled, high-level, general-purpose programming language with particular attention to simplicity, readability, and multi-paradigm support. Fly is built on LLVM infrastructure and aims to provide optional Garbage Collection.
Design Principles:
- Simple - Easy to read and write
- Fast - Compiled with LLVM for optimal performance
- Powerful - Multi-paradigm with modern features
2. Lexical Elements
2.1 Keywords
Fly reserves the following keywords:
abstract as bool break byte
case char class const continue
default double else elsif enum
error fail false final float
for handle if import in
int interface long namespace new
null private protected public return
short static string struct suite
switch test true uint ulong
unset ushort void while
outis not a reserved keyword — it is a special identifier automatically declared inside any function that has a return type. It holds the value to be returned. For functions with multiple return types, useout[0],out[1], … See Section 5.4.
thisis not a reserved keyword either — it is a special identifier available inside instance methods that refers to the current object (e.g.this.value). See Section 6.4.
deleteis not a keyword in Fly. It is an ordinary identifier (the standard library uses it as a regular method name, e.g.fs.delete(path)). Heap memory is managed automatically — see Section 6.6.
2.2 Identifiers
Identifiers must start with a letter or underscore, followed by any combination of letters, digits, or underscores.
Syntax:
Identifier ::= [a-zA-Z_][a-zA-Z0-9_]*
Examples:
myVariable
_privateVar
counter123
MyClass
getValue
2.3 Literals
2.3.1 Numeric Literals
42 // integer literal
0 // zero
3.14 // floating-point literal
0.0 // floating-point zero
2.3.2 Boolean Literals
true // boolean true
false // boolean false
2.3.3 Character Literals
'a' // character
'Z' // uppercase character
'\n' // newline escape
2.3.4 String Literals
"Hello, World!"
"Fly Language"
"" // empty string
2.3.5 Null Literal
null // null value for reference types
2.3.6 Unset Literal
unset is a special literal denoting the absence of a value (an uninitialized / "no value yet" state), distinct from null.
unset // unset value
2.4 Operators and Punctuators
Arithmetic Operators
+ // addition
- // subtraction
* // multiplication
/ // division
% // modulo
++ // increment
-- // decrement
Compound Assignment Operators
+= // add and assign
-= // subtract and assign
*= // multiply and assign
/= // divide and assign
%= // modulo and assign
Comparison Operators
== // equal to
!= // not equal to
< // less than
> // greater than
<= // less than or equal
>= // greater than or equal
Logical Operators
&& // logical AND
|| // logical OR
! // logical NOT
Bitwise Operators
& // bitwise AND
| // bitwise OR
^ // bitwise XOR
<< // left shift
>> // right shift
&= // bitwise AND and assign
|= // bitwise OR and assign
^= // bitwise XOR and assign
<<= // left shift and assign
>>= // right shift and assign
Other Operators and Punctuators
= // assignment
?: // ternary conditional
. // member access
[] // array subscript (bounds-checked — see 8.1.3)
() // function call / grouping
{} // block delimiters
, // separator
; // statement separator (used between the clauses of a for loop)
: // label / case / base-type / type-bound separator
3. Types
3.1 Built-in Types
3.1.1 Integer Types
| Type | Size | Range | Description |
|---|---|---|---|
byte | 8-bit | 0 to 255 | Unsigned byte |
short | 16-bit | -32,768 to 32,767 | Signed short integer |
ushort | 16-bit | 0 to 65,535 | Unsigned short integer |
int | 32-bit | -2,147,483,648 to 2,147,483,647 | Signed integer |
uint | 32-bit | 0 to 4,294,967,295 | Unsigned integer |
long | 64-bit | -9,223,372,036,854,775,808 to ... | Signed long integer |
ulong | 64-bit | 0 to 18,446,744,073,709,551,615 | Unsigned long integer |
Examples:
byte age = 25
short temperature = -10
ushort port = 8080
int count = 1000
uint id = 12345
long bigNum = 9999999999
ulong hugeNum = 18446744073709551615
3.1.2 Floating-Point Types
| Type | Size | Description |
|---|---|---|
float | 32-bit | Single-precision float |
double | 64-bit | Double-precision float |
Examples:
float pi = 3.14
double precise = 3.14159265359
3.1.3 Other Built-in Types
| Type | Description |
|---|---|
bool | Boolean type (true or false) |
char | Character type |
string | String type (heap-managed, see §6.6) |
error | Error type for error handling |
void | Absence of a value — used only as a function return type |
Examples:
bool isActive = true
char letter = 'A'
string name = "Fly"
voidis written as the return type of functions that do not produce a value:void main() { … }. A return type is mandatory on every function and method (see Section 5.1).
3.2 Array Types
Arrays can be declared with or without explicit size.
Syntax:
ArrayType ::= Type '[' [ Expression ] ']'
Examples:
// Dynamic array (size unspecified)
byte[] dynamicArray
int[] numbers
// Fixed-size array
byte[10] fixedBuffer
int[5] coordinates
// Multi-dimensional arrays
int[][] matrix
byte[][][] cube
A declaration must give either a size or an initializer — int[] xs alone is an error, because there is nothing to say how large the array is.
Elements are read and written with the subscript operator, which is bounds-checked (see 8.1.3 Array Subscript), and iterated with for in (see 9.5.3).
Arrays are heap-backed, reference counted, and have reference semantics — assigning one array to another shares the buffer instead of copying it. Their lifetime, and what an array does and does not own, is specified in 6.6 Allocation and Lifetime.
3.3 Named Types
User-defined types include classes, structures, and enumerations.
Examples:
MyClass obj
Point location
Status currentStatus
3.4 Qualified Type Names
Types can be qualified with namespace prefixes.
Examples:
// Using dotted notation
utils.Helper helper
mylib.DataType data
4. Variables
4.1 Local Variables
Local variables are declared within functions or blocks.
Syntax:
LocalVar ::= [ Modifiers ] Type Identifier [ '=' Expression ]
Examples:
void func() {
// Simple declaration
int x = 10
// Without initialization
bool flag
// Constant local variable
const int limit = 100
}
4.2 Variable Initialization
4.2.1 Basic Types
bool flag = true
int count = 42
float value = 3.14
string message = "Hello"
4.2.2 Null Initialization
MyClass obj = null
Type instance = null
4.2.3 Array Initialization
// Empty array
byte[] empty = {}
// Array with values
byte[] values = {1, 2, 3, 4, 5}
int[] numbers = {10, 20, 30}
// Fixed-size array
byte[3] buffer = {1, 2, 3}
4.2.4 Struct Literal Initialization
A brace literal that contains field = value pairs builds a struct value. Plain comma-separated values (without =) build an array value (see above); the two forms are distinguished by the presence of field =.
// Struct value — field = value pairs
Point p = {x = 10, y = 20}
// Array value — bare values
int[] xs = {10, 20, 30}
5. Functions
5.1 Function Declaration
Every function and method must declare a return type before the function name — this is mandatory. A function that produces no value declares void. When a non-void return type is present, the special identifier out is implicitly declared inside the body and holds the value to be returned.
Important: A missing return type is a compile error (
err_parser_missing_return_type). Writevoid doSomething() { … }, notdoSomething() { … }. The only exceptions are constructors (a method named exactly like its class — see §6.4) and interface method declarations, which omit the return type.
Syntax:
Function ::= [ Modifiers ] ReturnType Identifier
[ '<' TypeParam ( ',' TypeParam )* '>' ]
'(' [ Parameters ] ')' ( Block | ';' )
ReturnType ::= Type ( ',' Type )*
Examples:
// Void function — declares 'void' explicitly
void doSomething() {
// function body
}
// Function with a return type — assign to 'out' to return a value
int add(const int a, const int b) {
out = a + b
}
// Multiple return types — use out[0], out[1], …
int,int minMax(const int a, const int b) {
if (a < b) {
out[0] = a
out[1] = b
} else {
out[0] = b
out[1] = a
}
}
5.2 Function Parameters
The const modifier on a parameter is optional. A const parameter is read-only inside the body (an input); a non-const parameter may be written and is the mechanism used for output parameters (including the hidden out parameter generated for return values). A parameter may also declare a default value with = <literal>.
Syntax:
Parameters ::= Parameter ( ',' Parameter )*
Parameter ::= [ Modifiers ] Type Identifier [ '=' Value ]
Examples:
// const inputs (read-only)
void process(const int x, const float y, const bool flag) {
// implementation
}
// Parameters without const are writable (e.g. output parameters)
void clamp(const int value, const int lo, const int hi, int result) {
if (value < lo) { result = lo }
elsif (value > hi) { result = hi }
else { result = value }
}
// Default parameter value
void retry(const string url, const int attempts = 3) {
// attempts defaults to 3 when the caller omits it
}
// Generic parameter without const (see §6.7)
T identity<T>(T v) {
out = v
}
5.3 Visibility Modifiers
Functions can have different visibility levels:
// Default visibility (package-private)
void defaultFunction() {}
// Private function (internal use only)
private void privateHelper() {}
// Public function (exported)
public void publicAPI() {}
// Protected function (for inheritance)
protected void protectedMethod() {}
5.4 Return Values
When a function declares a return type, the special identifier out is implicitly available inside the body. Assigning to out sets the return value. The caller receives it as if the function returned by value — but the compiler generates a hidden by-reference output parameter, so the result is never copied out of the callee's frame.
That is a statement about the calling convention, not a promise that assigning to out is free. out = expr is an ordinary assignment and obeys the ownership rules of 6.6: returning a borrowed string — a parameter or a field — clones its buffer, which is exactly what makes every call result independently owned; returning an array transfers ownership without copying a single element.
// Looks like return-by-value to the caller…
int square(const int n) {
out = n * n
}
void main() {
int x = square(5) // x = 25
}
This resolves the classic C++ ergonomics/performance tension: in C++ you must choose between File f = open(path) (readable, but implies a copy) or open(path, &f) (efficient, but noisy). Fly does both with the same syntax — the source reads as a normal assignment, and the compiler silently passes x by reference to square.
Because out is a real writable variable, it can also be passed directly as the output argument of another call — the standard library uses this idiom heavily:
public int size() {
// 'out' is forwarded as the destination of the read; no extra local needed
fly.llvm.ptrReadInt(this.ptr, 8, out)
}
Multiple return types use out[0], out[1], …:
int,int divmod(const int a, const int b) {
out[0] = a / b // quotient
out[1] = a % b // remainder
}
void main() {
int q = divmod(17, 5) // q = 3 — a plain assignment binds out[0]
}
At the call site, a multi-assignment consumes every slot: list the
receiving variables (comma-separated) before = — they bind to the return
slots in order. Every receiver must be an already-declared variable
(a multi-assignment never declares), the right side must be a call, and the
receiver count must match the callee's return count exactly:
void main() {
int q = 0
int r = 0
q, r = divmod(17, 5) // q ← out[0] = 3, r ← out[1] = 2
}
Early exit in void functions still uses return (without a value). return never carries a value in Fly — return expr is a compile error; use out to set a result instead:
void process(const int x) {
if (x < 0) {
return // exit early — no value
}
// continue processing
}
5.5 The Main Function
The main() function is the entry point of a Fly application.
Syntax:
void main() {
// Application code
}
Key Characteristics:
- Function signature: Must be declared as
void main() {}with no parameters and void return type - Entry point: The application starts execution from
main() - Automatic error handling: The main function has special error handling behavior
The signature is enforced, not merely conventional. Declaring a return type
on main — single (int main()) or multiple (int, int main()) — is a compile
error in both compilers:
error: 'main()' must be declared 'void': the exit code comes from an unhandled 'fail', not from 'out'
It follows that the implicit out variable does not exist inside main:
a void function has no out-parameter, so writing to it there is an ordinary
"cannot find 'out' in this scope" error. The process exit code is not something
main returns — it is derived from the error state described next, so the only
things a program can act on are fail (to set an error) and handle (to stop
one from propagating).
Error Handling and Return Codes:
When the application runs, main() automatically returns an exit code to the operating system:
- Return 0: If no unhandled errors occur (success)
- Return
code: If an unhandled error occurs, the exit code is the error's integer code (fail 404, "…"→ exit 404; a barefailor afailwith only a string/object → exit 1)
Before exiting with a non-zero code, main() also prints the unhandled error to
stderr in the form:
error <code>: <message>
(or just error <code> when the error carries no message). This behavior is
automatic—you don't explicitly return an integer from main(), and only the
unhandled error that is still recorded when main() ends is printed; handled
errors are never printed.
Example 1: Successful Execution
void main() {
// Code executes successfully
int x = 10
int y = 20
// Automatically returns 0 (success)
}
Example 2: Unhandled Error
void err0() {
fail "Something went wrong"
}
void main() {
err0() // Error is not handled
// Automatically returns 1 (failure)
}
Example 3: Handled Error
void err0() {
fail "Something went wrong"
}
void main() {
handle err0() // Error is caught and handled
// Continues execution
// Automatically returns 0 (success)
}
Example 4: Captured Error with Graceful Handling
void riskyOperation() {
fail "Operation failed"
}
void main() {
handle {
riskyOperation()
}
if (error) {
// Error was caught and handled
// Continue with fallback logic
}
// Automatically returns 0 (success)
}
Best Practices:
- Always handle errors in main: Unhandled errors will cause the application to exit with code 1
- Use handle blocks: Wrap risky operations in
handleblocks to ensure graceful error handling - Check error variables: Use
if (error)to detect and respond to errors appropriately - Provide fallback logic: When errors occur, provide alternative execution paths
Summary:
main()is required (void return type, no parameters)- Exit code 0 = success (no unhandled errors)
- Exit code 1 = failure (unhandled error occurred)
- Use
handleto catch errors and ensure successful exit
6. Classes and Structures
6.1 Class Declaration
A class declares its base types after a colon. The grammar accepts a comma-separated list of base types: a class may extend a base struct and/or implement one or more interfaces.
Syntax:
Class ::= [ Modifiers ] 'class' Identifier
[ '<' TypeParam ( ',' TypeParam )* '>' ]
[ ':' BaseType ( ',' BaseType )* ] '{' ClassMember* '}'
BaseType ::= NamedType
Examples:
// Simple class
class MyClass {
}
// Public class
public class Application {
}
// Class extending a struct
class Derived : BaseStruct {
}
// Class implementing an interface
class MyImpl : Drawable {
}
// Class with a base struct and one or more interfaces
class Widget : BaseStruct, Drawable, Resizable {
}
abstractandfinalmay be applied as class modifiers (see Section 11).
6.2 Structure Declaration
Structures are value types similar to classes. A struct can only extend another struct.
Syntax:
Struct ::= [ Modifiers ] 'struct' Identifier [ ':' Identifier ] '{' StructMember* '}'
Examples:
// Simple structure
struct Point {
int x
int y
}
// Public structure
public struct Vector {
float x
float y
float z
}
// Struct extending another struct
struct Point3D : Point {
int z
}
6.3 Interface Declaration
Interfaces define contracts for classes. An interface can only extend another interface.
Syntax:
Interface ::= [ Modifiers ] 'interface' Identifier [ ':' Identifier ] '{' InterfaceMember* '}'
Examples:
// Simple interface
interface Drawable {
draw()
}
// Public interface
public interface Serializable {
serialize(const string path)
deserialize(const string data)
}
// Interface extending another interface
interface Resizable : Drawable {
resize(const int width, const int height)
}
6.4 Class Members
Classes can contain fields (attributes) and methods.
Constructors are methods whose name is exactly the class name and which declare no return type. They are the one exception to the mandatory-return-type rule and run when the object is created with new (see §6.5).
this is available inside any instance method and refers to the current object. Use this.field to access members.
All other methods require a return type (void for methods that return nothing).
Examples:
public class Person {
// Private fields
private string name
private int age
// Public field
public bool isActive
// Static field
static int instanceCount = 0
// Constructor — same name as the class, no return type
public Person(const string personName, const int personAge) {
this.name = personName
this.age = personAge
this.isActive = true
instanceCount++
}
// Public method with return type
public string getName() {
out = this.name
}
// Private void method
private void validate() {
// validation logic
}
// Static method with return type
public static int getCount() {
out = instanceCount
}
}
6.5 Object Creation
Objects are created with new, which invokes a constructor. There is no delete operator in the language; how the memory is reclaimed depends on whether the type is a struct or a class (see §6.6).
Examples:
// Create a class instance (heap-allocated)
MyClass obj = new MyClass()
// Construct with arguments and call a return-type method
Person person = new Person("John", 30)
string name = person.getName() // name = "John"
int count = Person.getCount() // count = 1
6.6 Allocation and Lifetime
The new keyword allocates a new instance. Where the memory comes from — and how it is reclaimed — depends on whether the type is a struct or a class. Fly has no delete operator.
Struct: stack-allocated, freed automatically
A new on a struct allocates the data on the stack (via LLVM alloca). It is freed automatically when the enclosing scope exits — nothing to release manually.
struct Point { int x; int y }
void process() {
Point p = new Point() // ← stack alloca
p.x = 10
p.y = 20
} // ← p released automatically when the scope exits
Class: heap-allocated, freed by convention
A new on a class allocates on the heap (malloc(sizeof(T))). The current compiler does not insert an automatic free for a plain class allocation and there is no delete operator, so a class that owns resources should expose its own free() method (an ordinary void method) that the caller invokes when done. This is exactly the convention used throughout the standard library.
import fly.data.List
void main() {
List l = new List() // ← heap allocation
l.add(1)
l.add(2)
// … use l …
l.free() // ← release via the class's own free() method
}
Strings: managed automatically
string values are heap-backed but managed for you. A non-const string variable with an initializer owns its buffer: the compiler frees it at scope exit, and reassigning the variable frees the previous buffer before storing the new one. const strings and uninitialized strings point at static/null data and are never freed.
void demo() {
string s = str.toUpper("hello") // owns a heap buffer
s = str.toLower("WORLD") // old buffer freed automatically before reassignment
} // ← final buffer freed automatically at scope exit
The rule that makes this work. Everything above follows from one invariant: every owned string slot holds a buffer that nothing else owns. That is what lets the compiler free at scope exit unconditionally, with no escape analysis and no reference counting. Storing into an owned slot therefore takes one of three forms, chosen by what the right-hand side is:
| Right-hand side | What is stored | Why |
|---|---|---|
| a string literal | a fresh heap copy of the constant | the constant lives in static memory and must never be freed |
| a string lvalue — variable, parameter, field, member, array element | a deep clone of its buffer | the original still owns its buffer; sharing it would free it twice |
| a fresh producer — a call result, a concatenation | the buffer as-is, moved | it is already unique and unowned, so copying it would be waste |
void demo(const string param) {
string a = "hello" // copy of the constant
string b = a // deep clone — a and b own different buffers
string c = param // deep clone — the caller still owns param's buffer
string d = a + b // moved — the concatenation's buffer was already fresh
} // ← a, b, c and d each free their own buffer
The empty string is the one case that goes the other way: it owns no heap at all, so cloning it allocates nothing and freeing it is a no-op.
Because out = <lvalue> goes through the same rule, a function that returns a borrowed string — a parameter or a field — returns a clone. That is what makes every call result a fresh buffer, and why the third row above is safe.
Arrays: reference-counted, freed automatically
An array is a fat pointer, {data, size}, held in the variable's own storage. The elements live in a heap buffer preceded by an 8-byte reference count:
variable heap buffer
┌────────┬──────┐ ┌──────────┬─────────────────────┐
│ data │ size │ │ refcount │ element 0, 1, 2, … │
└───┬────┴──────┘ └──────────┴─────────────────────┘
│ ◄─8 bytes─►▲
└─────────────────────────── points HERE
Arrays therefore have reference semantics, and this is the one place where Fly deliberately diverges from the rest of the language — structs and strings copy, arrays do not:
void demo() {
int[] k = {1, 2, 3}
int[] j = k // NO copy: one buffer, two names, count = 2
j[0] = 9
int x = k[0] // ← 9. The write through j is visible through k.
} // ← k releases (count 1), then j releases (count 0) → the buffer is freed, once
The rules, all of them:
- Declaring an array variable makes it an owner: it holds a reference and releases it at scope exit. The buffer goes back when the last owner releases it.
- Binding another array (
int[] j = k) copies the fat pointer and retains. No element is ever copied, whatever the array's size. - Reassigning (
k = {7, 8}) retains the new buffer, releases the old one, then stores — in that order, sok = kis harmless. - Passing as an argument is a borrow: no retain, no release. The callee sees the array for the duration of the call.
- Returning through
outtransfers ownership to the caller: the callee retains on the caller's behalf, then its own variable releases, leaving the caller holding the only reference. - The item of a
for inis a view of the element, not an owner. - Nested arrays (
int[][]) have one buffer and one count per level. Releasing the outer array walks its elements and releases each inner buffer first. - An empty array (
int[0], or a runtime size of zero) has no buffer at all. Declaring, binding and iterating one are all legal and allocate nothing.
The ownership rule. An array owns its buffer and nothing else. Freeing it drops the elements it holds — it never frees what they point at. Every object is released by whoever is responsible for it, exactly as in the summary table below. The one apparent exception, an array of arrays, is not one: an inner array is itself reference counted, so what it receives is a decrement, not a free.
Two consequences deserve stating plainly.
An array of classes is born full, but the instances are yours. Declaring C[3] builds three distinct instances, one per index, with C's no-argument constructor — every class has one, implicitly, when it does not declare it. They are ordinary new allocations and follow the class convention: the array releasing its buffer does not free them.
void demo() {
Cell[3] cells // three separate instances, already constructed
Cell first = cells[0]
first.set(7) // does not affect cells[1] or cells[2]
} // ← the buffer of pointers is freed; the three Cell instances are NOT
A sized array of an interface is rejected at the declaration: an interface has no constructor to build the elements with. Supply them with an array literal instead.
Elements are borrowed, and a borrow can outlive its owner. Storing a string, a class or a struct into an array neither copies it nor transfers ownership. Reading one back out into an owned slot clones it, by the string rule above, so the common direction is safe. The opposite direction is not:
string[] names = {"a", "b"}
{
string s = str.toUpper("hello")
names[0] = s // borrow — names does not own this buffer
} // ← s is freed here; names[0] now dangles
That is the direct consequence of the ownership rule, not an oversight: cloning on store would mean the array owned the element, which is exactly what it must not do. Keep the owner alive at least as long as the array borrowing from it.
String literals inside an array literal are unaffected: they are stored as pointers into static memory, allocate nothing per element, and need no freeing.
When the release happens. An owned array is released where the scope falls through its end. There is no cleanup on an early return, on an uncaught fail, or on break / continue out of a scope — an array left behind on one of those paths simply is not released. This is a pre-existing limitation of the compiler's scope handling, shared with heap-allocated strings and class handles, not something specific to arrays; it is recorded here because reference counting otherwise reads as a complete guarantee, and on those paths it is not.
Class fields of array type are likewise never released: only locals are registered as owners.
Summary
| Expression | Memory | Reclaimed by |
|---|---|---|
struct S = new S() | stack (alloca) | automatic at scope exit |
class C = new C() | heap (malloc) | call the class's own free() method by convention |
string s = … (non-const, initialized) | heap | automatic at scope exit; reassignment frees the old buffer |
const string s = … | static/null | nothing to free |
int[] xs = … / int[3] xs | heap, [refcount | elements] | reference counted: released at scope exit, freed when the last owner goes |
| an array's elements | wherever they came from | never the array — each object is freed by whoever is responsible for it |
C[3] cells (array of classes) | heap buffer + one new C() per index | the buffer automatically; the instances by the class convention |
Planned: ownership qualifiers (not yet implemented)
The language design reserves smart-pointer ownership qualifiers — new unique, new shared, and new weak — to make heap lifetimes automatic for classes:
unique— exclusive ownership; freed automatically at scope exit; copying is a compile error.shared— reference-counted ([i64 refcount | data]block); freed when the count reaches 0.weak— untracked alias; no reference count.
⚠️ These qualifiers are not accepted by the current parser. The corresponding code paths exist in the compiler internals but cannot yet be reached from source —
new unique T()/new shared T()/new weak T()will not parse today. Until they are wired up, use plainnewplus the conventions in the summary table above. This subsection documents intended future behaviour only.
6.7 Generics
Fly supports generic classes and generic functions via monomorphization. Each unique instantiation is compiled into a distinct, fully specialized implementation — no type erasure, no boxing overhead, no runtime cost.
6.7.1 Generic Class Declaration
Add one or more type parameters in angle brackets after the class name.
Syntax:
GenericClass ::= [ Modifiers ] 'class' Identifier '<' TypeParam ( ',' TypeParam )* '>' [ ':' BaseType ( ',' BaseType )* ] '{' ClassMember* '}'
TypeParam ::= Identifier [ ':' Type ]
A type parameter may carry an optional bound after a colon (<T : SomeType>), constraining the types it can be instantiated with.
Example:
public class Wrapper<T> {
private T value
public Wrapper(T v) {
value = v
}
public T get() {
out = value
}
public void set(T v) {
value = v
}
}
6.7.2 Instantiation
Provide concrete type arguments in angle brackets when declaring a variable. Each unique combination of type arguments produces a separate monomorphized type at compile time.
import fly.data
void main() {
// Wrapper<string> — holds a string
Wrapper<string> ws = new Wrapper<string>("hello")
string s = ws.get() // s = "hello"
ws.set("world")
// Wrapper<int> — holds an int
Wrapper<int> wi = new Wrapper<int>(42)
int n = wi.get() // n = 42
// Wrapper<bool>
Wrapper<bool> wb = new Wrapper<bool>(true)
bool b = wb.get() // b = true
}
Wrapper<string> and Wrapper<int> are entirely separate types: the compiler emits a distinct LLVM struct and a distinct set of methods for each instantiation.
6.7.3 Generic Functions
Functions can also declare type parameters, placed between the function name and the parameter list.
Syntax:
GenericFunc ::= [ Modifiers ] ReturnType Identifier '<' TypeParam ( ',' TypeParam )* '>' '(' [ Parameters ] ')' Block
TypeParam ::= Identifier [ ':' Type ]
Example:
// Generic identity function — returns its argument unchanged
T identity<T>(const T v) {
out = v
}
void main() {
int i = identity<int>(10) // explicit type argument
string s = identity<string>("fly") // explicit type argument
}
Type inference — when the argument type is unambiguous, the type argument can be omitted and the compiler infers it automatically:
void main() {
int i = identity(10) // T inferred as int
string s = identity("fly") // T inferred as string
bool b = identity(true) // T inferred as bool
}
6.7.4 Managing a List of Strings — fly.data.List<string> Pattern
fly.data.List is an untyped dynamic array that stores long values (raw integers or object addresses). To maintain a typed list of strings, wrap each string in a Wrapper<string> and store the wrapper reference in the list. Retrieve the wrapper and call .get() to recover the string.
import fly.data.list
import fly.data.wrapper
void main() {
List lst = new List()
// Box each string into a Wrapper<string>
Wrapper<string> a = new Wrapper<string>("apple")
Wrapper<string> b = new Wrapper<string>("banana")
Wrapper<string> c = new Wrapper<string>("cherry")
lst.add(a)
lst.add(b)
lst.add(c)
// Iterate — retrieve wrapper, then unwrap the string
int total = lst.size() // total = 3
for int i = 0; i < total; i++ {
Wrapper<string> item = lst.get(i)
string text = item.get()
// use text …
}
lst.free()
}
The same pattern applies to any heap-allocated type: Wrapper<int>, Wrapper<MyClass>, etc.
| Goal | Approach |
|---|---|
| Store strings in a list | Wrapper<string> + List |
| Store ints in a list | Wrapper<int> + List (or raw long directly) |
| Single typed value | Wrapper<T> alone |
7. Enumerations
7.1 Enum Declaration
Enumerations define a set of named constants. Enums cannot extend any other type.
Syntax:
Enum ::= [ Modifiers ] 'enum' Identifier '{' EnumEntryList '}'
EnumEntryList ::= EnumEntry ( ',' EnumEntry )*
EnumEntry ::= Identifier
Examples:
// Simple enum with comma-separated entries
enum Color {
RED, GREEN, BLUE
}
// Public enum
public enum Status {
IDLE, RUNNING, STOPPED, FAILED
}
// Multi-line enum for readability
enum Direction {
NORTH,
SOUTH,
EAST,
WEST
}
7.2 Using Enums
Examples:
void processColor() {
// Declare and initialize
Color c = Color.RED
// Assignment
c = Color.BLUE
// Pass to function
setColor(Color.GREEN)
// Compare
if (c == Color.RED) {
// handle red
}
}
void setColor(const Color c) {
// use color
}
8. Expressions
8.1 Primary Expressions
8.1.1 Literals
42 // integer literal
3.14 // float literal
true // boolean literal
'c' // character literal
"string" // string literal
null // null literal
8.1.2 Identifiers
myVariable // simple identifier
obj.field // member access
array[0] // array access
8.1.3 Array Subscript
xs[i] reads or writes one element of an array. The index is any integer expression; the element takes the array's element type.
int[] k = {5, 6, 7}
int x = k[1] // read → 6
k[0] = 40 // write
int y = k[x - 5] // the index is an ordinary expression
Both forms are bounds-checked at run time, and on both sides: the index is compared against the array's size field and must satisfy 0 <= i < size. A negative index is caught as surely as one past the end — the check is signed precisely so it can be.
An out-of-range access fails with the dedicated error code 2989 (0x0BAD), which behaves like any other fail: it propagates to the caller, sets the exit code of main, and can be intercepted with handle.
Reading the code from a shell.
mainreturns 2989 on every platform, but POSIX passes only the low 8 bits of a process status throughwait(), so a Unix shell reports173. Windows preserves the full value. Intercepting withhandle— below — is unaffected and is the portable way to inspect the code.
void demo() {
int[] k = {5, 6, 7}
handle {
int bad = k[9] // out of range → fail 2989
}
bool caught = error // ← true; execution continues after the handle
}
A caught failure leaves the array — and every other local — untouched and still usable: control resumes in the same scope, so nothing is released early.
What the subscript yields depends on the element type, and follows the ownership rules of §6.6: a number is a value, a class element is the instance itself (a reference), a struct element is copied by value, and a string element is a borrow that is cloned when it is bound to an owned slot.
Note. Chaining a subscript directly into a member access —
cells[0].set(7)— is accepted by the self-host compiler but not by the reference, whose subscript does not chain. For code that must build with either, bind the element first:Cell c = cells[0]thenc.set(7).
8.1.4 Parenthesized Expressions
(a + b)
(x * y + z)
8.2 Unary Expressions
Syntax:
UnaryExpr ::= ( '++' | '--' | '!' | '-' ) Expression
| Expression ( '++' | '--' )
Unary - negates any numeric operand (-5, -x, -(a * 2), -2.5).
There is no unary +. Postfix ++/-- bind same-line only — a
++/-- at the start of a line is always a prefix statement.
Examples:
// Pre-increment/decrement
++counter
--index
// Post-increment/decrement
value++
count--
// Logical negation
!flag
!isActive
// Unary minus/plus
-value
+number
8.3 Binary Expressions
8.3.1 Arithmetic Operators
a + b // addition
x - y // subtraction
m * n // multiplication
p / q // division
r % s // modulo
8.3.2 Comparison Operators
a == b // equal to
x != y // not equal to
m < n // less than
p > q // greater than
i <= j // less than or equal
k >= l // greater than or equal
8.3.3 Logical Operators
flag1 && flag2 // logical AND
cond1 || cond2 // logical OR
Both operators short-circuit: the right operand is evaluated only when the
left one does not already decide the result. a && f() never calls f() when
a is false, and a || f() never calls f() when a is true — so the
right operand may safely guard on the left (p != null && p.ready()).
8.3.4 Bitwise Operators
a & b // bitwise AND
x | y // bitwise OR
m ^ n // bitwise XOR
p << 2 // left shift
q >> 1 // right shift
8.4 Assignment Expressions
Syntax:
Assignment ::= Identifier AssignOp Expression
AssignOp ::= '=' | '+=' | '-=' | '*=' | '/=' | '%='
| '&=' | '|=' | '^=' | '<<=' | '>>='
Examples:
// Simple assignment
x = 10
name = "Fly"
// Compound assignment
a += 5 // a = a + 5
b -= 3 // b = b - 3
c *= 2 // c = c * 2
d /= 4 // d = d / 4
e %= 7 // e = e % 7
// Bitwise compound assignment
f &= mask // f = f & mask
g |= flag // g = g | flag
h ^= toggle // h = h ^ toggle
i <<= 2 // i = i << 2
j >>= 1 // j = j >> 1
8.4.1 Assignment vs Equality: Important Distinction
Fly clearly distinguishes between the assignment operator = and the equality comparison operator ==:
=(Assignment): Stores a value into a variable. This is a statement-level operation.==(Equality): Compares two values for equality. This is an expression that evaluates to a boolean.
Examples:
// Assignment: stores the value 5 into variable x
x = 5
// Equality comparison: compares x with 5, evaluates to boolean
if (x == 5) {
// x is equal to 5
}
// Assignment with equality comparison on right side
result = x == 5 // result gets true or false
// Complex example
a = a + 1 // a = (a + 1) - addition then assignment
b = a == 10 // b = (a == 10) - comparison then assignment
Parser Representation: Under the hood, the parser creates different AST structures:
- Assignment
a = exprcreatesASTBinaryOp(OP_BINARY_ASSIGN)with left=aand right=expr - Equality
a == bcreatesASTBinaryOp(OP_BINARY_EQ)with left=aand right=b - Assignment with equality
a = (b == c)creates nested structure:- Outer:
ASTBinaryOp(OP_BINARY_ASSIGN)with left=a - Right child:
ASTBinaryOp(OP_BINARY_EQ)with left=band right=c
- Outer:
Common Mistake:
// WRONG: Using = instead of == in condition
if (x = 5) { // This assigns 5 to x, then evaluates the result
// ...
}
// CORRECT: Using == for comparison
if (x == 5) { // This compares x with 5
// ...
}
8.5 Ternary Conditional Expression
Syntax:
TernaryExpr ::= Condition '?' TrueExpr ':' FalseExpr
Examples:
result = condition ? valueIfTrue : valueIfFalse
max = a > b ? a : b
status = isActive ? Status.RUNNING : Status.IDLE
8.6 Function Call Expressions
Examples:
// Function call without arguments
result = calculate()
// Function call with arguments
sum = add(10, 20)
process(x, y, z)
// Method call
obj.doSomething()
person.getName()
8.7 Array Value Expressions
Examples:
// Empty array
empty = {}
// Array with values
values = {1, 2, 3, 4, 5}
matrix = {{1, 2}, {3, 4}}
Each literal allocates its own reference-counted buffer, and a nested literal allocates one per level — matrix above is three buffers, the outer one holding the two rows. They are released like any other array (see 6.6).
An empty literal allocates nothing at all.
9. Statements
9.1 Expression Statements
Any expression can be used as a statement.
Examples:
// Function call
doSomething()
calculate()
// Increment/decrement
counter++
--index
// Assignment
x = 42
9.2 Block Statements
Syntax:
Block ::= '{' Statement* '}'
Examples:
{
int x = 10
int y = 20
int z = x + y
}
9.3 If Statements
Syntax:
IfStmt ::= 'if' [ '(' ] Expression [ ')' ] Statement
( 'elsif' [ '(' ] Expression [ ')' ] Statement )*
[ 'else' Statement ]
Examples:
// Simple if
if (condition) {
// code
}
// If without parentheses
if condition {
// code
}
// If-else
if (x > 0) {
positive = true
} else {
positive = false
}
// If-elsif-else
if (a == 1) {
b = 0
} elsif (a == 2) {
b = 1
} elsif (a == 3) {
b = 2
} else {
b = -1
}
// Inline if (without braces)
if (condition) doSomething()
9.4 Switch Statements
Syntax:
SwitchStmt ::= 'switch' [ '(' ] Expression [ ')' ] '{' CaseClause* [ DefaultClause ] '}'
CaseClause ::= 'case' Expression ':' Statement*
DefaultClause ::= 'default' ':' Statement*
Examples:
switch (value) {
case 1:
// code for case 1
break
case 2:
// code for case 2
break
case 3:
case 4:
// code for case 3 and 4 (fall-through)
break
default:
// default code
}
// Without parentheses
switch value {
case 0:
result = "zero"
break
default:
result = "other"
}
Fall-through is C-style: a case body that does not end in break (or
return/fail) falls through into the next case body — the last open
case falls into default (or out of the switch). Use break to stop.
Stacked empty labels (case 3: case 4:) share the following body. Case
bodies may be braced: case 1: { r = 10 break }.
9.5 Loop Statements
9.5.1 While Loop
Syntax:
WhileStmt ::= 'while' [ '(' ] Expression [ ')' ] Statement
Examples:
// While with parentheses
while (count < 10) {
count++
}
// While without parentheses
while count < 10 {
count++
}
// Infinite loop
while true {
// loop body
if (shouldBreak) break
}
// Inline while
while condition doSomething()
9.5.2 For Loop
Syntax:
ForStmt ::= 'for' VarDecl ( ',' VarDecl )* ';' Expression ';'
Expression ( ',' Expression )* Statement
Examples:
// Standard for loop
for int i = 0; i < 10; i++ {
// loop body
}
// Multiple initialization and post expressions
for int i = 0, int j = 10; i < j; i++, j-- {
// loop body
}
// For loop without parentheses
for int i = 0; i < length; i++ {
process(array[i])
}
9.5.3 For-In Loop
Fly also provides a for-in loop that iterates a loop variable over the elements of a collection expression.
Syntax:
ForInStmt ::= 'for' [ '(' ] Identifier 'in' Expression [ ')' ] Statement
Examples:
// Iterate over the elements of a list
for item in items {
process(item)
}
// Parentheses are optional
for (line in lines) {
print(line)
}
9.6 Jump Statements
9.6.1 Return Statement
return exits a void function early. Functions with a return type use out to carry the result; return may still be used to exit early from such functions.
Syntax:
ReturnStmt ::= 'return'
Examples:
// Return exits the function
return
// Early return based on condition
if (done) {
return
}
9.6.2 Break Statement
Syntax:
BreakStmt ::= 'break'
Examples:
while true {
if (condition) {
break // exit loop
}
}
switch (value) {
case 1:
doSomething()
break // exit switch
}
9.6.3 Continue Statement
Syntax:
ContinueStmt ::= 'continue'
Examples:
for int i = 0; i < 10; i++ {
if (i % 2 == 0) {
continue // skip even numbers
}
process(i)
}
9.7 Error Handling Statements
Fly error handling is built on two keywords — fail and handle — and a built-in error type. The mechanism is not exception-based stack unwinding. Instead, every function receives a hidden first parameter: a pointer to an error struct. When fail fires, it writes into that struct and either jumps past the surrounding handle block (if one exists in the same function) or returns immediately. The error value propagates upward through the call stack only when no caller intercepts it with handle.
9.7.1 The Error Type
The error type is a built-in type that holds the result of a fail. Internally it is:
%error = type { i32 code, ptr str_ptr, ptr obj_ptr }
code— integer code. Non-zero means an error occurred.str_ptr— pointer to an error string (null if none).obj_ptr— pointer to an error object (null if none).
From Fly code you declare an error variable and test it with if:
error err // declare
// …
if (error) { /* error occurred */ }
9.7.2 Fail Statement
fail signals an error. It accepts zero, one, two, or three comma-separated arguments (integer, string, and/or object instance, in any order and combination, up to one of each).
Syntax:
FailStmt ::= 'fail' [ Expr [ ',' Expr [ ',' Expr ] ] ]
Forms:
fail // code = 1, no message, no object
fail 404 // code = 404
fail "file not found" // str = "file not found", code = 1
fail new MyError() // obj = MyError instance, code = 1
fail 404, "not found" // code = 404, str = "not found"
fail 1, "oops", new Ctx() // code = 1, str = "oops", obj = Ctx instance
Behavior of fail:
| Context | What happens |
|---|---|
Inside a handle block (same function) | Writes to error struct; jumps directly to the safe block (skips remaining handle body) |
Outside any handle (no enclosing handle in the current function) | Writes to error struct; returns void immediately |
Any code after fail within the same basic block is unreachable.
void validate(const int age) {
if (age < 0) {
fail 400, "age must be non-negative"
// unreachable
}
if (age > 150) {
fail 1001
}
// continues here if no fail
}
9.7.3 Automatic Error Propagation
Every function (except main) has a hidden first parameter: a pointer to the caller's error struct. When a function fails without a handle in its own body, it writes to that pointer and returns void. The caller's code continues from where the call returned — the error data is already in the shared struct.
void fetchData() {
fail 503, "service unavailable" // writes error, returns void
}
void main() {
fetchData() // error is written to main's error struct
// execution continues here, but error struct is now populated
// main() prints "error 503: service unavailable" to stderr
// and returns exit code 503
}
Because propagation is not stack unwinding, a failing callee does NOT unwind the caller — the next line after the call still executes. Use a handle block to intercept failures before they reach the caller.
9.7.4 Handle Statement
handle creates a guarded region. Failures raised inside it — directly or in
a callee — are consumed by the handle instead of reaching the caller.
After the block, the implicit error variable tells whether the guarded
region recorded a failure.
Syntax:
HandleStmt ::= 'handle' Block
How it works:
The compiler emits two LLVM basic blocks for each handle:
handle— the guarded codesafe— the continuation (code after the handle)
When fail fires directly inside the handle body (same function), execution jumps to safe, skipping the rest of the handle body. When fail fires in a callee, the callee returns void and the handle body continues at the next statement.
On exit the handle consumes the error: the failure never propagates to
the caller, and the implicit error variable — automatically in scope after
any handle, no declaration needed — holds the outcome. It reads as a boolean:
true when the guarded block recorded a failure, false otherwise. Each
handle opens a fresh window: a later handle that stays clean resets error
to false.
Forms:
1. Detect and recover:
void main() {
handle {
riskyOperation()
}
if (error) {
// failure recorded — take the fallback path
return
}
// success path
}
2. Discard — swallow any failure:
void main() {
handle {
riskyOperation()
anotherOp()
}
// execution always reaches here; a failure was consumed silently
}
3. Nested handles — the innermost intercepts first:
void process() {
handle {
handle {
deepOp() // a failure here sets the INNER window
}
if (error) {
fail // re-raise to the outer handle
}
followupOp()
}
if (error) {
// handle the top-level failure
}
}
Legacy form. Older code may name the variable explicitly —
error err handle { … }followed byif (err). The unnamed form with the impliciterrorvariable is the canonical syntax; the named form is deprecated and will be removed.
9.7.5 Complete Examples
Example 1: Propagation without handle
void openFile(const string path) {
if (path == "") {
fail 400, "empty path"
}
}
void main() {
openFile("") // writes error 400 to main's struct, returns
openFile("/tmp") // STILL CALLED — propagation is not unwinding
// main returns exit code 400
}
Example 2: Intercepting with handle
void openFile(const string path) {
if (path == "") { fail 400, "empty path" }
}
void main() {
handle {
openFile("") // fails and writes the error; handle body continues
openFile("/tmp") // STILL CALLED (callee fail ≠ jump in caller)
}
if (error) {
// the 400 failure was recorded and consumed here
}
}
Example 3: Direct fail in handle — jumps immediately
void main() {
handle {
if (someCondition) {
fail 500 // jumps directly to safe block
}
neverReached() // skipped when fail fires above
}
if (error) { /* the failure (code 500) was recorded */ }
}
Example 4: Re-raise to caller
void inner() {
fail 503
}
void outer() {
handle {
inner()
}
if (error) {
fail // re-raise; outer's caller sees the error
}
}
Example 5: Object payload
class NetError {
int code
string host
}
void connect(const string host) {
NetError e = new NetError()
e.code = 503
e.host = host
fail e
}
9.7.6 The Main Function and Exit Codes
main() allocates its own error struct. On exit, the compiler emits:
ret i32 load(error.code)
So the process exit code equals the error code of the last unhandled failure — 0 means clean exit. See Section 5.5 for the full behaviour table.
9.7.7 Key Differences from try-catch
| Feature | Fly fail/handle | Traditional try/catch |
|---|---|---|
| Signal error | fail | throw |
| Intercept | handle { } | try { } catch { } |
| Payload | int, string, or object (comma-separated, up to one each) | typed exception object |
| Stack unwinding | No — failing callee just returns; caller continues | Yes — stack frames are unwound |
| Callee fail bypasses rest of caller? | No (unless fail is direct in handle block) | Yes |
| Error propagation | via hidden pointer parameter; must re-fail manually | automatic until caught |
| Overhead | zero-cost on success; single function return on failure | unwinder overhead |
9.8 Testing Constructs
Fly has built-in testing support based on three constructs: inline test blocks, suite declarations, and case labels. These are compiled only when the compiler runs in test mode (the --test driver flag, also implied by --suite, which additionally builds and runs the suite). Outside test mode, test blocks are stripped during semantic analysis and have no effect on the produced binary. See the Testing guide for the full runner, --suite=Name/--test=Method filters, and assertion reference.
9.8.1 Inline test Block
A test { … } block embeds test-only code inside ordinary functions. The body is parsed normally but is included in code generation only under --test.
Syntax:
TestStmt ::= 'test' Block
void compute() {
int result = doWork()
test {
// Only runs when compiled with --test
assert.assertEqI(result, 42, 1)
}
}
9.8.2 suite Declaration
A suite is a declaration that groups related tests. It is declared like a class (same modifiers and member syntax) but uses the suite keyword and has no constructors.
Syntax:
Suite ::= [ Modifiers ] 'suite' Identifier '{' SuiteMember* '}'
public suite MathTests {
void additions() {
case "adds positives":
assert.assertEqI(add(2, 3), 5, 1)
case "adds negatives":
assert.assertEqI(add(-2, -3), -5, 2)
}
}
9.8.3 case Label (inside a suite method)
Inside a suite test-method body, a standalone case "label": … statement names an individual test scenario. Consecutive case statements in the same block each describe a labelled scenario. (The same case keyword is also used inside switch statements — see §9.4.)
Syntax:
SuiteCase ::= 'case' StringLiteral ':' Statement
The testing framework is available in the compiler but is not yet exercised by the standard library's own tests, which currently use a plain
void main()plus thefly.asserthelpers. Treat the exact runner behaviour as still evolving.
10. Namespaces and Imports
10.1 Namespace Declaration
Namespaces organize code and prevent name conflicts.
Syntax:
Namespace ::= 'namespace' Identifier ( '.' Identifier )*
Examples:
// Single namespace
namespace mylib
// Nested namespace (dotted notation)
namespace my.library
namespace company.project.module
Rules:
- A namespace declaration must appear before any imports or top-level declarations
- Only one namespace declaration per file
- If no namespace is declared, a default namespace based on the filename is used
10.2 Import Declaration
Imports make symbols from other namespaces available. Fly supports four import forms, all modelled on Java-style imports.
Syntax:
Import ::= 'import' Name ( '.' Name )* // namespace import
| 'import' Name ( '.' Name )* '.' '*' // wildcard import
| 'import' Name ( '.' Name )* 'as' Name ( '.' Name )* // alias import
10.2.1 Namespace import
Brings the last namespace segment into scope. Access symbols with the segment prefix.
import fly.str // 'str' is in scope
import fly.os.time // 'time' is in scope
void main() {
int n = str.len("hello") // qualified access
Time t = time.now()
}
A namespace import binds only the prefix — it does not bring the namespace's members into scope, so bare calls are an error:
import fly.str
void main() {
int n = len("hello") // → compile error: no function 'len' in scope
// (use str.len(...), or import fly.str.*)
}
10.2.2 Class import (Java style)
When the last component of the path names a class (or enum/struct), that type is placed directly in the current scope — no prefix needed. This is the Fly equivalent of Java's import java.util.List.
import fly.data.List // 'List' class is in scope
import fly.data.Stack // 'Stack' class is in scope
void main() {
List l = new List() // no 'data.' prefix needed
Stack s = new Stack()
l.free()
s.free()
}
There is no coupling between the filename and the class name. The import path navigates the namespace hierarchy; the filename is irrelevant. By convention, stdlib files use the capitalized class name (list.fly → List), but this is optional for user code.
10.2.3 Wildcard import
.* brings all public symbols (classes, enums, structs, functions) declared directly in the target namespace into the current scope. The target must be a namespace — using .* on a class or function is a compile-time error.
import fly.data.* // List, Stack, Queue, Deque, Map, Set, Tree all in scope
void main() {
List l = new List()
Map m = new Map()
l.free()
m.free()
}
// Error: fly.data.List is a class, not a namespace
import fly.data.List.* // → compile error: wildcard requires a namespace target
A wildcard import brings in the symbols, never the namespace prefix — the two forms are complementary, not overlapping:
import fly.str.*
void main() {
int n = len("hello") // OK: bare call, 'len' is in scope
int m = str.len("hello") // → compile error: 'str' is not defined in this
// scope (a wildcard does not bind the prefix;
// add `import fly.str` for prefixed calls)
}
10.2.4 Alias import
Binds the imported namespace or symbol under a different local name. Cannot be combined with wildcard (.*).
import fly.str as s // 's' is in scope
import fly.data.List as L
void main() {
int n = s.len("hello")
L myList = new L()
myList.free()
}
10.3 Using Imported Symbols
Full example:
// File: shapes.fly
namespace geom
public class Circle {
public int radius
}
public int area(const int r) {
out = r * r
}
// File: main.fly — class import (Java style)
import geom.Circle
void main() {
Circle c = new Circle()
c.radius = 5
// c is a heap-allocated class instance (see §6.6)
}
// File: main.fly — namespace import
import geom
void main() {
geom.Circle c = new geom.Circle()
int a = geom.area(5)
// c is a heap-allocated class instance (see §6.6)
}
// File: main.fly — wildcard import
import geom.*
void main() {
Circle c = new Circle()
int a = area(5) // function in scope too
// c is a heap-allocated class instance (see §6.6)
}
11. Modifiers
11.1 Visibility Modifiers
Control the accessibility of declarations.
| Modifier | Scope | Applies To |
|---|---|---|
private | Only within the same file/class | Functions, classes, members |
protected | Within the class and derived classes | Class members |
public | Accessible from anywhere | Functions, classes, members |
| (default) | Package-private (same namespace) | Functions, classes |
Examples:
// Private function
private void internalHelper() {}
// Protected member
class Base {
protected int value
}
// Public class
public class PublicAPI {
public void exportedMethod() {}
}
// Default visibility
void packageFunction() {}
class DefaultClass {}
11.2 Constant Modifier
The const modifier marks values as immutable.
Examples:
// Constant function parameter — const is optional on parameters,
// and marks the parameter read-only inside the body
void process(const int size) {
// size cannot be modified
}
// Constant local variable
void func() {
const int limit = 50
// limit = 100 // Error: cannot modify const
}
11.3 Static Modifier
The static modifier creates class-level members.
Examples:
class Counter {
static int totalCount = 0
public static int getTotal() {
out = totalCount
}
public void increment() {
totalCount++
}
}
// Usage
Counter c = new Counter()
c.increment()
int total = Counter.getTotal() // total = 1
11.4 Combining Modifiers
Multiple modifiers can be combined.
Examples:
// In a class context
class Configuration {
// Public constant (class-level)
public const int BUFFER_SIZE = 1024
// Private static field
private static int instanceCounter = 0
// Public static constant
public static const string APP_NAME = "FlyApp"
}
// In a function
void process() {
// Constant local variable
const int maxRetries = 3
}
11.5 Abstract and Final Modifiers
Two further modifiers apply mainly to type and method declarations:
| Modifier | Meaning |
|---|---|
abstract | The declaration is incomplete and must be implemented by a subtype (e.g. an abstract class or method). |
final | The declaration cannot be further extended or overridden. |
Examples:
// Abstract class — cannot be instantiated directly
public abstract class Shape {
public abstract int area()
}
// Final class — cannot be subclassed
public final class Vector2 {
int x
int y
}
12. Comments
12.1 Line Comments
Line comments start with // and continue to the end of the line. A newline
always terminates a line comment: Fly has no line-splicing, so a backslash
at the end of the line is an ordinary comment character and does not
continue the comment onto the next line.
Examples:
// This is a line comment
int value = 42 // End-of-line comment
// Multiple line comments
// can be used for
// multi-line documentation
// A trailing backslash does not extend this comment \
int next = 1 // this line is code, not comment
12.2 Block Comments
Block comments are enclosed between /* and */.
Examples:
/* This is a block comment */
/*
* Multi-line block comment
* for detailed documentation
*/
void calculate() {
/* inline comment */ return
}
Note: Block comments can span multiple lines and are preserved by the parser for documentation purposes. A block comment ends only at a literal */: since Fly has no line-splicing, a backslash-newline between * and / does not terminate the comment.
12.3 No Line Splicing
Unlike C and C++, Fly performs no line-splicing anywhere: a backslash followed by a newline is never a line continuation. Inside comments it is ordinary comment text; outside comments and string literals a stray backslash is a lexical error.
13. Grammar Summary
13.1 Program Structure
Program ::= [ Namespace ] Import* TopDecl*
Namespace ::= 'namespace' Name ( '.' Name )*
Import ::= 'import' Name ( '.' Name )*
| 'import' Name ( '.' Name )* '.' '*'
| 'import' Name ( '.' Name )* 'as' Name ( '.' Name )*
TopDecl ::= Comment
| ClassDecl
| EnumDecl
| FunctionDecl
Modifiers ::= ( 'public' | 'private' | 'protected'
| 'const' | 'static' | 'abstract' | 'final' )*
13.2 Type System
Type ::= BuiltinType
| NamedType
| ArrayType
BuiltinType ::= 'void' | 'bool' | 'byte' | 'char'
| 'short' | 'ushort' | 'int' | 'uint'
| 'long' | 'ulong' | 'float' | 'double'
| 'string' | 'error'
NamedType ::= Name ( '.' Name )*
ArrayType ::= Type '[' [ Expression ] ']'
13.3 Declarations
ClassDecl ::= Modifiers ( 'class' | 'struct' | 'interface' | 'suite' )
Identifier [ '<' TypeParam ( ',' TypeParam )* '>' ]
[ ':' BaseType ( ',' BaseType )* ] '{' ClassMember* '}'
BaseType ::= NamedType
TypeParam ::= Identifier [ ':' Type ]
GenericFunc ::= Modifiers ReturnType Identifier
'<' TypeParam ( ',' TypeParam )* '>'
'(' [ ParamList ] ')' Block
InterfaceDecl ::= Modifiers 'interface'
Identifier [ ':' Identifier ] '{' InterfaceMember* '}'
EnumDecl ::= Modifiers 'enum' Identifier '{' EnumEntryList '}'
EnumEntryList ::= EnumEntry ( ',' EnumEntry )*
EnumEntry ::= [ Modifiers ] Identifier
FunctionDecl ::= Modifiers ReturnType Identifier
[ '<' TypeParam ( ',' TypeParam )* '>' ]
'(' [ ParamList ] ')' ( Block | ';' )
ReturnType ::= Type ( ',' Type )*
ParamList ::= Param ( ',' Param )*
Param ::= [ Modifiers ] Type Identifier [ '=' Value ]
Notes:
ReturnTypeis mandatory (usevoidfor no value). Constructors — a method named like its class — and interface methods are the only declarations that omit it.- A class may list one or more base types after
:(a base struct and/or interfaces). Structs extend only structs, interfaces extend only interfaces, and enums cannot extend anything.
13.4 Statements
Statement ::= Block
| IfStmt
| SwitchStmt
| WhileStmt
| ForStmt
| ForInStmt
| ReturnStmt
| BreakStmt
| ContinueStmt
| FailStmt
| HandleStmt
| TestStmt
| ExprStmt
| VarDeclStmt
| AssignStmt
| MultiAssignStmt
Block ::= '{' Statement* '}'
IfStmt ::= 'if' [ '(' ] Expr [ ')' ] Statement
( 'elsif' [ '(' ] Expr [ ')' ] Statement )*
[ 'else' Statement ]
SwitchStmt ::= 'switch' [ '(' ] Expr [ ')' ] '{'
CaseClause* [ DefaultClause ] '}'
WhileStmt ::= 'while' [ '(' ] Expr [ ')' ] Statement
ForStmt ::= 'for' [ '(' ] VarDecl ( ',' VarDecl )* ';' Expr ';'
Expr ( ',' Expr )* [ ')' ] Statement
ForInStmt ::= 'for' [ '(' ] Identifier 'in' Expr [ ')' ] Statement
ReturnStmt ::= 'return'
BreakStmt ::= 'break'
ContinueStmt ::= 'continue'
FailStmt ::= 'fail' [ Expr [ ',' Expr [ ',' Expr ] ] ]
HandleStmt ::= 'handle' Block (* implicit `error` var after the block; the
named `error Ident handle` form is legacy *)
TestStmt ::= 'test' Block
VarDeclStmt ::= Modifiers Type Identifier [ '=' Expr ]
AssignStmt ::= Identifier AssignOp Expr
MultiAssignStmt ::= Identifier ( ',' Identifier )+ '=' CallExpr
(* every receiver is an ALREADY-DECLARED variable; the
call's return slots bind in order — see §5.4 *)
13.5 Expressions
The grammar below reflects the compiler's flat precedence (six binary levels). See Appendix B for the ordering and the important note that bitwise/shift bind looser than comparisons.
Expression ::= AssignExpr
AssignExpr ::= TernaryExpr ( AssignOp AssignExpr )?
TernaryExpr ::= LogicalExpr [ '?' Expr ':' Expr ]
// One level: logical, bitwise and shift operators together
LogicalExpr ::= RelationalExpr
( ( '||' | '&&' | '|' | '&' | '^' | '<<' | '>>' ) RelationalExpr )*
// One level: equality and relational operators together
RelationalExpr ::= AddExpr
( ( '==' | '!=' | '<' | '>' | '<=' | '>=' ) AddExpr )*
AddExpr ::= MultExpr ( ( '+' | '-' ) MultExpr )*
MultExpr ::= UnaryExpr ( ( '*' | '/' | '%' ) UnaryExpr )*
UnaryExpr ::= ( '++' | '--' | '!' | '-' | '+' ) UnaryExpr
| PostfixExpr
PostfixExpr ::= PrimaryExpr ( '++' | '--' | '(' ArgList ')'
| '[' Expr ']' | '.' Identifier )*
PrimaryExpr ::= Literal
| Identifier
| '(' Expr ')'
| 'new' NamedType [ '<' TypeArg ( ',' TypeArg )* '>' ] '(' ArgList ')'
| ArrayValue
| StructValue
ArrayValue ::= '{' [ Expr ( ',' Expr )* ] '}'
StructValue ::= '{' [ Identifier '=' Value ( ',' Identifier '=' Value )* ] '}'
Literal ::= NumericLiteral | CharLiteral | StringLiteral
| 'true' | 'false' | 'null' | 'unset'
AssignOp ::= '=' | '+=' | '-=' | '*=' | '/=' | '%='
| '&=' | '|=' | '^=' | '<<=' | '>>='
14. Complete Example
Here's a comprehensive example demonstrating various Fly language features:
namespace myapp
import utils
import data.models as models
// Enum declaration (enums cannot extend anything)
public enum Status {
IDLE, RUNNING, PAUSED, STOPPED
}
// Class declaration
public class Application {
// Private fields
private string name
private int value
private Status currentStatus
// Static field
static int instanceCount = 0
// Constructor — same name as the class, no return type
public Application(const string appName) {
this.name = appName
this.value = 0
this.currentStatus = Status.IDLE
instanceCount++
}
// Method with return type — 'out' carries the result
public int getValue() {
out = this.value
}
// Public void method with error handling
public void process() {
handle {
this.calculateResult()
}
if (error) {
// Error occurred
this.currentStatus = Status.STOPPED
}
}
// Private helper method that may fail
private void calculateResult() {
if (this.value < 0) {
fail "Invalid value" // Fail with string message
}
if (this.value > 1000) {
fail 999 // Fail with error code
}
}
// Method demonstrating void error handling
public void validate() {
error validationErr handle {
if (this.name == "") {
fail "Name cannot be empty"
}
}
if (validationErr) {
this.currentStatus = Status.STOPPED
}
}
// Setter method
public void setValue(const int newValue) {
this.value = newValue
}
// Static method
public static void incrementCount() {
instanceCount++
}
}
// Structure declaration (struct can extend only struct)
public struct Point {
int x
int y
}
// Struct extending another struct
public struct Point3D : Point {
int z
}
// Interface declaration (interface can extend only interface)
public interface Drawable {
draw()
}
// Class implementing an interface
public class Shape : Drawable {
private int width
private int height
public void draw() {
// drawing logic
}
}
// Main entry point
// Note: main() automatically returns 0 if all errors are handled,
// or returns 1 if an unhandled error occurs
void main() {
// Create application instance (constructor invoked by new)
Application app = new Application("MyApp")
// Error handling example: validate the application
handle app.validate()
// Set status
Status status = Status.RUNNING
// Control flow with error handling
if (status == Status.RUNNING) {
error processErr handle {
app.process()
}
if (processErr) {
// Handle error gracefully
status = Status.STOPPED
} else {
handleResult()
}
} elsif (status == Status.PAUSED) {
// Handle paused state
} else {
// Handle other states
}
// Loop through array
int[] numbers = {1, 2, 3, 4, 5}
for int i = 0; i < 5; i++ {
processNumber(numbers[i])
}
// While loop
int count = 0
while (count < 10) {
count++
}
// Switch statement
switch (count) {
case 10:
// count is 10
break
default:
// other value
}
// Create structure (stack-allocated)
Point p = new Point()
p.x = 10
p.y = 20
// Error handling with structure
error distErr handle {
int dist = p.x * p.x + p.y * p.y
if (dist > 1000) {
fail "Distance too large"
}
}
}
// Private void helper function
private void handleResult() {
// handle result logic
}
// Function with const parameter
private void processNumber(const int num) {
if (num % 2 == 0) {
// even number
} else {
// odd number
}
}
15. Best Practices
15.1 Naming Conventions
- Classes, Structs, Enums: Use PascalCase (e.g.,
MyClass,StatusType) - Functions, Variables: Use camelCase (e.g.,
calculateTotal,userName) - Constants: Use UPPER_SNAKE_CASE (e.g.,
MAX_SIZE,DEFAULT_VALUE) - Private members: Prefix with underscore or use clear naming (e.g.,
_internal,privateHelper)
15.2 Code Organization
- One namespace per file
- Group related functionality in the same namespace
- Use imports to reference external code
- Keep functions focused and small
15.3 Error Handling
- Use
failfor unrecoverable errors - Use
handleblocks to catch and recover from errors - Validate inputs at function boundaries
15.4 Comments
- Use line comments for brief explanations
- Use block comments for detailed documentation
- Document public APIs thoroughly
- Explain complex algorithms and business logic
Appendix A: Reserved Keywords
All keywords are reserved and cannot be used as identifiers:
abstract as bool break byte
case char class const continue
default double else elsif enum
error fail false final float
for handle if import in
int interface long namespace new
null private protected public return
short static string struct suite
switch test true uint ulong
unset ushort void while
out,this, anddeleteare not reserved keywords (see Section 2.1).
Appendix B: Operator Precedence
Fly uses a flat precedence scheme with only six binary levels. From highest to lowest precedence (tightest to loosest binding):
- Primary / postfix / unary: literals, identifiers, calls
(), subscript[], member., then prefix/postfix++--,!, unary-. Postfix++/--bind same-line only: a leading++/--on the next line is a new prefix statement, never the previous operand's postfix. - Multiplicative:
*,/,% - Additive:
+,- - Relational & Equality (one level):
==,!=,<,>,<=,>= - Logical, Bitwise & Shift (one level):
||,&&,|,&,^,<<,>> - Ternary:
?: - Assignment (right-associative):
=,+=,-=,*=,/=,%=,&=,|=,^=,<<=,>>=
⚠️ Differs from C/C++/Java. Bitwise (
&|^) and shift (<<>>) operators sit at the same, looser level as the logical operators — below the comparison operators. As a result,a & b == cparses asa & (b == c), anda | b && cgroups left-to-right within the single logical/bitwise level. Use explicit parentheses when mixing bitwise/shift with comparisons or logical operators.
Appendix C: Error Handling Quick Reference
Fly uses fail and handle keywords for error handling, which differs from traditional try-catch mechanisms.
Quick Comparison
| Concept | Fly Syntax | Traditional (Java/C++) |
|---|---|---|
| Throw exception | fail | throw |
| Throw with message | fail "Error message" | throw new Exception("Error message") |
| Throw with code | fail 404 | throw 404 or custom exception |
| Catch exception | handle { ... } | try { ... } catch { ... } |
| Inspect the outcome | handle { ... } then if (error) | catch (Exception err) { ... } |
| Error type | error | Exception or custom class |
Common Patterns
// Pattern 1: Simple fail
void operation() {
fail // Throw exception
}
// Pattern 2: Fail with integer code
void check() {
fail 404 // Error code
}
// Pattern 3: Fail with string message
void load() {
fail "File not found" // Error message
}
// Pattern 4: Simple handle
handle operation() // Catch and ignore
// Pattern 5: Handle with error capture
handle {
riskyOperation()
}
if (error) {
// Handle error
}
// Pattern 6: Handle with recovery
handle {
computation()
}
if (error) {
fallbackOperation()
}
Error Types
- void:
fail(no value) - integer:
fail 404,fail 500,fail -1 - string:
fail "Error message",fail "Not found" - object:
fail errorObject
Key Points
failimmediately terminates function executionhandlecatches exceptions in the enclosed blockerrortype stores exception information- Multiple operations can be wrapped in a single
handleblock - Error handling is more concise than traditional try-catch
- No exception type hierarchy needed—use simple values
main()function: Unhandled errors cause the application to return exit code 1; handled errors allow return code 0
Main Function and Exit Codes
The main() function has special error handling behavior:
void main() {
// If no error occurs or all errors are handled: returns 0
// If an unhandled error occurs: returns 1
}
Examples:
// Returns 0 (success)
void main() {
handle mayFail()
}
// Returns 1 (failure)
void main() {
mayFail() // Error not handled
}
// Returns 0 (success) - error is caught and handled
void main() {
handle {
riskyOperation()
}
if (error) {
// Handle gracefully
}
}
© Fly Project - https://flylang.org
Licensed under Apache License v2.0
Documentation Version 1.1 - June 2026 — revised to match the compiler (Parser / AST / Resolver): mandatory return types, constructors & this, for-in loops, generics with bounds, testing constructs, automatic memory management, and corrected operator precedence.