2: Language Semantics — Core
Contents
2.0: Statement Delimiting and Block Syntax
2.0.1: Newlines as Statement Terminators
Leaf uses newlines as statement and declaration terminators. Each statement or declaration occupies one logical line; a newline at the end of that line ends it.
Continuation lines. A newline is not significant when the last token on the current line cannot end a statement — the statement continues on the next line. Newlines are ignored (treated as whitespace) after:
- Binary or unary operators:
+,-,*,/,%,&&,||,!,==,!=,<,>,<=,>=,&,|,^,~,<<,>>,matches,|> - Assignment and compound-assignment operators:
=,+=,-=, etc. - A comma
, - An opening parenthesis
(, bracket[, or brace{ - The
->return-type arrow in function signatures
A newline is significant (ends the statement) when the last token can end a statement: an identifier, literal, ), ], }, end, true, false, self, break, continue, return, yield, or ? (postfix propagation).
This lets long expressions and argument lists span lines naturally:
result = someFunction(
firstArgument,
secondArgument,
thirdArgument
)
total = a +
b +
c
Semicolons. Leaf has no semicolons. The newline is the only statement delimiter. Multiple statements on one logical line are not permitted.
2.0.2: Block Bodies
Every construct that introduces a block — function bodies, if/elseif/else branches, while loops, for loops, and match arm bodies — follows the same rule for its body:
Inline form. If a statement or expression follows on the same line as the block introducer, that expression is the entire body. No end keyword is used.
Multi-statement form. If a newline follows the block introducer, the body consists of zero or more statements on subsequent lines, terminated by end.
# Inline form — expression body on the same line as the introducer:
fn double(x: int) -> int x * 2
fn greet(name: str) println("Hello, {name}")
if condition println("yes")
x = if n > 0 n else -n
while i < 10 i += 1
for n in items process(n)
items.map(fn(x) x * 2)
items.filter(fn(x) x > 0)
# Multi-statement form — newline after introducer, `end` required:
fn greet(name: str) -> str
log("greeting called")
"Hello, {name}"
end
if condition
doA()
doB()
end
while i < 10
doStuff()
i += 1
end
items.map(fn(x)
y = x * 2
y + 1
end)
if/elseif/else chains. The keywords else and elseif are branch separators within an if construct. They implicitly close the preceding branch's body (inline or multi-statement) and introduce the next. When any branch uses multi-statement form, one end closes the entire chain at the end:
# All inline — no `end` written:
x = if n > 0 "positive" elseif n < 0 "negative" else "zero"
# Multi-statement — one `end` at the end:
if condition
doA()
doB()
else
fallback()
end
Dangling else disambiguation. When an inline if body is itself an if expression, a following else is ambiguous:
if outerCond if innerCond doA() else doB()
Leaf uses the nearest unmatched if rule: an else always belongs to the nearest preceding if that has not yet been matched by an else. In the example above, else doB() is the else-branch of the inner if. The outer if has no else-branch. To attach the else to the outer if, use parentheses around the inner if expression or use the multi-statement form:
# else on the inner if (default — nearest unmatched if):
if outerCond if innerCond doA() else doB()
# else on the outer if — use parentheses to force grouping:
if outerCond (if innerCond doA()) else doB()
# else on the outer if — use multi-statement form:
if outerCond
if innerCond doA()
else
doB()
end
match arms. Match arm bodies use the same rule — inline expression or multi-statement terminated by end — but arms are independent blocks with no implicit cross-arm closing. A multi-statement arm body needs its own end before the next arm or the match's closing end. See §3.1 and §3.5.2 for full match syntax.
struct, enum, and interface bodies always use the multi-statement form. They contain declarations rather than expressions, so the inline form does not apply.
2.1: Scoping
Identifiers: An identifier in Leaf is a sequence of characters that:
- Begins with a letter (
a–z,A–Z) or underscore (_) - Followed by zero or more letters, digits (
0–9), or underscores
Identifiers are case-sensitive: foo, Foo, and FOO are three distinct names. The following keywords cannot be used as identifiers:
as |
bool |
break |
byte |
continue |
else |
elseif |
end |
enum |
false |
float |
fn |
for |
gen |
if |
implements |
in |
int |
interface |
match |
matches |
mut |
never |
out |
pkg |
pub |
return |
Self |
self |
str |
struct |
then |
true |
type |
uint |
use |
where |
while |
yield |
Leaf uses block scoping. A variable declared inside an if, while, for, or other block is not visible outside that block.
Named function declarations inside function bodies (see Closures and Nested Functions) are scoped like any other local binding — they are visible from the point of declaration to the end of the enclosing block. Top-level declarations in .leaf files (functions, structs, enums, interfaces, type aliases, and constant bindings) are visible throughout the file regardless of order.
Name shadowing is permitted — an inner scope may declare a binding with the same name as an outer scope's binding, and the inner binding takes precedence within its scope. There is no syntax to refer to the shadowed outer binding from within the inner scope. The recommendation is to avoid shadowing module-level names when continued access to the outer binding is needed. Prelude names (built-in types, functions, and interfaces) can be shadowed by top-level or local definitions, following the same rules. See stdlib/interfaces.md for details.
The identifier _ is a special discard binding — it ignores the assigned value and cannot be read. Multiple _ bindings in the same scope do not conflict. See §2.14.6 for details.
2.2: Mutability
Immutable by default. Variables are immutable unless declared with mut:
x = 42 # immutable
mut y = 42 # mutable
y += 1 # ok
x += 1 # error: x is immutable
Mutability is a front-end constraint enforced by the type checker. Leaf is reference-based — when a struct is passed to a function, the function receives a reference to the same object, not a copy.
mut is a shallow, per-binding annotation. It controls whether this particular binding can be reassigned or used to call mutating methods. It does not guarantee anything about the underlying object's immutability. Other mut bindings that alias the same object can still mutate it:
mut a = Counter.new()
b = a # b is an alias — same underlying object
a.increment() # ok: a is mut
println(b.count) # b sees the mutation — count is now 1
A non-mut binding means you cannot mutate the object through that binding. It does not mean the object itself is frozen. This is similar to a const reference in C++ — except that Leaf does not track aliasing or enforce exclusivity. There is no ownership or borrow-checking model.
Mutability flows through field access. An expression like a.b or a.b.c inherits the mutability of the root binding a. If a is immutable, then a.b is also immutable — you cannot call mutating methods on it, assign to it, or use it as a mutable source in spreads or for mut. If a is mut, then a.b is mutable. This applies transitively through any chain of field accesses:
struct Inner
pub value: int
pub fn modify(mut self)
self.value = 0
end
end
struct Outer
pub inner: Inner
end
outer = Outer { inner: Inner { value: 42 } }
outer.inner.modify() # error — outer is immutable, so outer.inner is too
mut mutableOuter = Outer { inner: Inner { value: 42 } }
mutableOuter.inner.modify() # ok — mutableOuter is mut, so mutableOuter.inner is too
The mut annotation on parameters serves as a signal of intent: a function that takes mut c: Counter is declaring that it may mutate the object through that parameter. A function that takes c: Counter (without mut) promises that it will not mutate the object through c.
Function parameters follow the same rule — they are immutable by default. A function must declare mut on a parameter to modify it:
fn process(data: List[int])
data.push(4) # error: data is immutable
end
fn processMut(mut data: List[int])
data.push(4) # ok
end
Struct methods that modify self must declare mut self. Calling a mutating method on an immutable binding is a type error:
struct Counter
count: int
pub fn new()
return Self { count: 0 }
end
pub fn increment(mut self)
self.count += 1
end
end
c = Counter.new()
c.increment() # error: cannot call mutating method on immutable binding
mut d = Counter.new()
d.increment() # ok
When a mutable binding is passed to a function that takes a mut parameter, the function operates on the same underlying object. Mutations are visible to the caller:
fn takesMutCounter(mut c: Counter)
c.increment() # ok — mutates the caller's object
end
mut x = Counter.new()
takesMutCounter(x) # ok — x.count is now incremented
Aliasing summary: Because Leaf is reference-based with no ownership model, multiple bindings can refer to the same object. mut controls what each binding is allowed to do — it does not control what happens to the object as a whole. This is a deliberate trade-off: a full borrow-checking or ownership-tracking system would add significant complexity.
2.3: Visibility
Private by default. Struct fields, methods, and top-level definitions are private unless marked with a visibility modifier.
Leaf has three visibility levels:
| Modifier | Scope |
|---|---|
| (none) | Private — visible only within the defining module (file). Applies uniformly to top-level symbols, struct fields, and struct methods. |
pkg |
Package-public — visible to any module within the same package, but not exported to consumers of the package. |
pub |
Public — visible to everything, including external packages that depend on this one. |
The hierarchy is: private < pkg < pub.
struct Person
pub name: str # public — accessible by anyone
pkg age: int # package-public — accessible within this package
ssn: str # private — accessible only within this module
pub fn greet(self) -> str
return "Hello, I'm {self.name}"
end
pkg fn validate(self) -> bool
return self.age > 0
end
fn internalHelper(self) # private, returns unit
# ...
end
end
The pkg modifier is useful for internal APIs that multiple modules within a package need to share, but that should not be part of the package's public contract.
Top-level visibility: The pub and pkg modifiers can be applied to top-level functions, structs, enums, interfaces, type aliases, and constant bindings. All unmarked symbols — whether top-level or struct members — are module-private.
Struct literal syntax (Point { x: 1, y: 2 } or Self { ... }) requires access to all fields being set. Private fields can be set from anywhere within the defining module. pkg fields can be set from any module in the same package. If a struct has any fields that the caller cannot see, the caller must use a public constructor (new) or factory method instead of a struct literal.