LEVIATHAN v0.1 · in development

Foundations

Expressions & Operators

How expressions combine: operator precedence, the primary forms an expression bottoms out to, how calls and construction bind their arguments, how . and :: reach a member, and how method dispatch and operator overloading resolve at runtime.

Precedence

Loosest to tightest. Assignment and the ternary/range level are right-associative; everything else below them is left-associative. Postfix forms (.name, ::name, (args), [index]) bind tightest of all.

LevelOperatorsAssociativity
1 (loosest)= += -= *= /= %=right
2?: (ternary), .. (range)right
3||left
4&&left
5| & ^left
6== !=left
7< > <= >=left
8<< >>left
9+ -left
10 (tightest infix)* / %left
prefix! - ~
postfix.name ::name (args) [index]left

Primary expressions

Every expression bottoms out to one of these forms:

42  3.14  "s"  true  false          // literals
name                                 // variable / member / function / type
this                                 // enclosing instance (or primitive receiver value)
(expr)                               // grouping
[e1, e2, ...]                        // array literal (ranges spread)
(p1, p2) => expr                     // lambda (params untyped; captures by closure)
await expr                           // suspension point — parks the current task
inject Type                          // explicit injection selector
a .. b                               // range value

Keywords such as get, set, is, and in are reserved only at statement or declaration start — they may still be used as member names after ./::, and as method names following a return type.

Match expressions

match (subject) { pattern => body; ... } — a pattern is a type (Type =>, narrows the subject in that arm), a value or range (0 =>, 1..9 =>), or else. First match wins. Bodies are => expr; or => { block }. match is an expression when its arms yield a value, or a statement when written with no trailing ;. It's exhaustive over a closed union with no else required; an open hierarchy requires one. Type patterns lower to the same is/IsType test used by catch and is — one type-dispatch path everywhere.

string describe(Shape s) => match (s) {
  Circle c => "circle r=${c.radius}";
  Square sq => "square side=${sq.side}";
  else => "unknown shape";
};

match (code) {           // statement form — no trailing `;`
  0 => console.writeln("ok");
  1..9 => console.writeln("client range");
  else => console.writeln("other");
}

match is a reserved word.

Lambdas

A lambda takes either an expression body or a full statement block. Parameters are untyped and inferred from context; a lambda captures its enclosing scope by closure.

var inc = (x) => x + 1;              // expression body

var process = (x) => {                // statement block body
  var y = x * 2;
  return y;
};

String interpolation

"...${expr}..." (either quote style) desugars in the parser to concatenation with .toString():

string msg = "code=${resp.status}!";
// exactly equivalent to:
string msg = "code=" + (resp.status).toString() + "!";

\${ escapes a literal ${; a bare $ with no { stays literal — there is no $name shorthand (that syntax belongs to quasiquote holes and is untouched here). An empty hole (${}) or an unterminated one is a compile error. A hole may contain any expression, including one with a nested string literal, as long as it uses the opposite quote style from the outer literal — the lexer has no concept of interpolation, so a nested string using the same quote character ends the outer token right there, exactly as it would in any ordinary string:

"${'has a } in it'}"      // OK — inner literal uses the opposite quote

A type without toString() fails an interpolation hole the same way a hand-written .toString() call on it would — see Strictness.

Calls & construction

f(args)                    // function call; overload chosen by argument types
f(x, label: value)         // positional arguments first; named arguments may reorder
obj.method(args)           // method call; overload chosen by argument types
Type(args)                 // construction — NO `new` at the call site
Type::Label(args)          // constructor selected by label
NS::Type(args)             // construction of a namespaced class, reached by qualification
NS::Type::Label(args)      // ...combined with label selection
Base::Ctor(args)           // inside a constructor: base constructor applied to `this`

Construction never writes new at the call site — a class type used as a callable is construction. Constructor candidates share a label; the overload is chosen by argument types (most-specific wins, first-declared breaks ties). Generic type arguments are inferred from the constructor arguments, then from the target type.

class Point { Point(int x, int y) => ...; }

Point p1 = Point(3, 4);          // positional
Point p2 = Point(x: 3, y: 4);    // named — order may differ
Point p3 = Point(3, y: 4);       // positional first, then named

class User {
  User::FromName(string name) => ...;
  User::FromId(int id) => ...;
}

User u = User::FromName("Ada");  // constructor selected by label

At every call site, positional arguments must precede named ones. A named argument binds the parameter carrying that name and may appear in any order among the named suffix. Functions, methods, constructors, and attributes all use this same spelling and binding rule. A ::-reached callable without a following (...) is not a call at all — it's a method reference, a first-class function value.

Member access & qualification

. navigates an instance. :: navigates the base / static / non-instantiated side: base classes, constructor labels, namespaces, and class static sides. Inside a generic callable, its left operand may also be a callable-level type parameter, resolved per concrete instantiation. One operator, one meaning: "the non-instantiated version."

this.Counter::value        // a base class member, reached through `this`
Type::Label                // a constructor label / static side
NS::name                   // a namespace-qualified name
Refuse to guess

A bare read that cannot be resolved by type — for example a distinct-collided member with no qualifier — is a compile error rather than a best-effort guess.

Method references

A ::-reached callable member in value position (not immediately called) is a first-class function value — the same way a ::-reached field read yields the field's value; a member is a typed slot, and some slot types happen to be executable.

var f = NS::fn;                  // namespace function: its signature directly
b.handler = Controller::Login;   // instance method: UNBOUND — the receiver becomes
                                  //   the FIRST parameter: (Controller, Req) => Resp
var g = User::FromName;          // labeled constructor: (string) => User
Route('/login', AuthController::Login)   // as a call/constructor argument
  • The reference's type spells as an ordinary function type ((A, B) => R) — assignable to fields/params/locals of that type and storable in containers like Array<(A,B)=>R>.
  • Resolution is by target function type. An overloaded name resolves against the declared type of the slot being assigned or initialized, or the chosen overload's parameter type in argument position — deferred like a lambda argument, so it does not help pick the outer overload. An overloaded name with no target in context (var h = C::overloaded;) is a compile error — annotate the target type. A missing member is a compile error at the reference site.
  • Dispatch follows the ordinary receiver-dispatch rule, because the reference is the eta-expansion lambda (C c, ...) => c.m(...): both an interface reference (IAnimal::speak) and a concrete-class reference (Animal::speak) dispatch on the runtime object — see Method dispatch.
  • v1 limits: a reference to a generic callable (M::identity where R identity<R>(R x)) is a compile error — its type parameters are unbound in value position. Each evaluation of a reference yields a fresh function value (like two identical hand-written lambdas), so identity comparison is not a "same handler" test — compare by key, or store the value once.

Bound method references

A .-reached method in value position captures its receiver and removes that receiver from the function type:

Editor editor = Editor();
var save = editor.save;                // equivalent to () => editor.save()
menu.onKey(this.onKeyDown);            // `this` may be captured directly
  • The receiver must be a bare local, parameter, or this. An embedded expression such as a.b.method, this.field.method, or make().method is a compile error with a "bind the receiver to a local first" fix — capture is a one-time snapshot, never a silent re-evaluation of an expression on every call.
  • The captured value is the object reference: later mutation of that object is visible, but rebinding the original local does not retarget the closure.
  • Overloads use the same target-function-type resolution as unbound references, including argument and typed-container contexts. A closure-valued field wins ordinary field-read resolution over a same-named method.
  • Generic methods remain unsupported in value position. Each evaluation is fresh, and runtime override dispatch is exactly the dispatch of the eta-expansion lambda.

Method dispatch

An unqualified instance-method call dispatches on the receiver's runtime class — uniformly for interface-typed and class-typed receivers. A subclass overriding a method runs its override through any base-typed binding, whether that binding is a field, a parameter, a local, or a method reference:

class Animal { string speak() => "..."; }
class Dog : Animal { string speak() => "Woof"; }

string callSpeak(Animal a) => a.speak();
callSpeak(Dog());        // "Woof" — the override runs, not the statically-named Animal::speak

The compiler devirtualizes to a direct call whenever the candidate set is provably closed — no class anywhere in the whole-program gather overrides the resolved method below the receiver's static type. This is a whole-program optimization that can only change how fast the call runs, never what runs. Qualified access (this.Base::m()), operators, constructor selection, and static/namespace functions remain statically resolved and are unaffected.

Limit: overridden overload sets sharing an arity

Runtime dispatch is by name + arity — no type disambiguation. An overridden method that shares its (name, arity) with another overload on the same receiver static type cannot be picked correctly at runtime and is a compile error at the call site: give the overloads distinct arities/names, or qualify the call explicitly.

class Animal { string speak(string s) => "a-str"; string speak(int n) => "a-int"; }
class Dog : Animal { string speak(int n) => "d-int"; }   // overrides speak(int); ambiguous with speak(string)

Animal a = Dog();
a.speak(5);   // compile error: shares its arity with another overload

Different-arity overridden overload sets — the common case — stay legal and dispatch correctly; only same-arity siblings are rejected. Signature-aware runtime dispatch (carrying the resolved parameter types into the by-name lookup) is roadmapped, and would benefit interface dispatch identically, which has carried this same name+arity limitation for longer.

Operators on objects

a op b resolves (op) on a's class by the type of b — overloads are supported. (==) must return bool; (!=) derives automatically as !(==). Defining an operator means declaring a member with a symbolic selector, the same shape as an ordinary method:

class Vec2 {
  int x; int y;
  Vec2(int x, int y) => ...;

  Vec2 (+)(Vec2 other) => Vec2(x + other.x, y + other.y);   // operator: symbolic selector
  bool (==)(Vec2 other) => x == other.x && y == other.y;
}

Vec2 sum = Vec2(1, 2) + Vec2(3, 4);   // dispatches (+) on Vec2
bool  eq = Vec2(1, 2) == Vec2(1, 2);  // true; (!=) auto-derives to !(==)

Integer operators

<< (shift left), >> (shift right, arithmetic — sign-extending, since int is signed 64-bit), ^ (xor), and prefix ~ (complement) are defined only on int — no shifts on float, no ^/~ on bool (use !=/!) or string. A shift count outside 0..63 throws RuntimeException ("shift count out of range") rather than silently masking to 6 bits (the x86 default) or invoking C++'s undefined behavior.

int x = 1 << 4;        // 16
int y = 256 >> 4;       // 16
int z = 5 ^ 3;          // 6
int w = ~0;             // -1

Resolution stays by type: on an object left operand, <</>> still dispatch the (<<)/(>>) operator method (the stream transfer operators) — the same rule that lets + be both int-add and a user-defined (+).

Indexing

a[i] dispatches to the ([]) get accessor of a's class; a[i] = v dispatches to the ([]) set accessor. On a mutable object the set accessor mutates in place. On a pure array, a[i] = v is rebind-sugar: a is rebound to a new array with slot i replaced.

class Grid {
  get ([])(int i) => cells[i];               // indexer (computed accessor)
  set ([])(int i, int v) cells[i] = v;        // value parameter last
}

Grid g = Grid();
int  first = g[0];     // dispatches the ([]) get accessor
g[0] = 9;               // dispatches the ([]) set accessor

Strictness

  • An unknown name or function is a compile error (System excepted until modeled; console is modeled).
  • A generic construction whose type argument has no inference source is a compile error — provide a target type or a type-bearing argument (inferred when recoverable, required when not — see Types & Generics).
  • Runtime failures throw catchable RuntimeExceptions: index out of bounds, Map.at on a missing key, unresolvable call targets, missing operators on a class.