Skip to content

Syntax

Line comments start with # and run to the end of the line:

# this is a comment
let x = 1 # trailing comments work too
int age = 25
float rate = 1.5
bool active = true
string name = "Pluvial"
auto count = 10 # inferred as int
auto title = "Rain" # inferred as string
  • Syntax: <type> <name> = <initializer>. The initializer is required — there is no default initialization.

  • With an explicit type, the initializer’s static type must match exactly. int x = 1.5 is a compile error.

  • auto infers the type from the initializer and then fixes it permanently. auto is not dynamic:

    auto x = 10
    x = 20 # OK
    x = "hello" # compile error — type is fixed as int
  • Reassignment requires a matching type. Assignment is a statement, not an expression: y = x = 1 is invalid.

  • Redeclaring a name in the same scope is an error. Shadowing in nested scopes is allowed.

  • Both operands must be the same numeric type (int OP int → int, float OP float → float).
  • Mixing types (int + float) is a compile error. No implicit promotion.
  • string + string concatenates. Other arithmetic on string is an error.
  • Integer division truncates: 10 / 4 → 2. Float division follows IEEE-754.
  • Integer division by zero is a runtime error (exit 70). Integer % by zero is a runtime error.
  • int ** int is repeated multiplication; a negative exponent is a runtime error. float ** float calls pow().
  • ** is right-associative: 2 ** 3 ** 2 == 2 ** 9 == 512.
  • Unary - binds tighter than **, so -2 ** 4 == 16 (this differs from Python).
  • All bitwise operators require int operands. Mixing with float/bool/string/nullable/etc. is a compile error: "bitwise '<op>' requires int operands".
  • Shifts are arithmetic (the sign of the left operand is preserved on right shift).
  • Shift count < 0 is a runtime error. Shift count >= 49 is a runtime error: Pluvial’s int is a 49-bit payload (see Types).
  • < > <= >= require both operands to be the same numeric type. Mixed types, string, and bool are compile errors. Result is bool.
  • == != require the same type on both sides. Cross-type comparisons (int == float, "a" == 1) are compile errors.
  • string == string compares contents.
  • x == null / x != null is allowed only when x is nullable. Comparing a non-nullable value against null is a compile error ("'int' is never null; comparison is meaningless").
  • Operands and result are bool. There is no truthiness conversion.
  • && and || short-circuit.
  • Logical and bitwise are distinct: && / || / ! are bool-only, & | ~ are int-only.
  • !bool → bool, -int → int / -float → float, ~int → int.
  • r is ok and r is err evaluate to bool.
  • The right-hand side is restricted to the keywords ok and err.
  • The left-hand side must be a Result. Anything else is a compile error.
  • See Types — Result for narrowing rules.
|| → && → | → ^ → & → == != is →
< > <= >= → << >> → + - → * / % → ** (right-assoc) →
unary ! - ~ → . / [i] → grouping ( )

+= -= *= /= %= **= &= |= ^= <<= >>= are pure syntactic sugar for x = x op e. The base operator’s type rules and error messages apply unchanged.

There are three legal left-hand sides:

  1. A variablex += 1. The readonly check is the same as =, so for-loop variables, match bindings, and self are still not assignable.
  2. An array elementa[i] += 1. The receiver and index are evaluated oncea[get_idx()] += 100 calls get_idx() exactly once.
  3. A struct fieldobj.field += 1, including nested paths like o.inner.v += 1.

Augmented assignment on a map index (m[k] op= e) and chained forms (x += y += 1) are not currently supported.

Braces are mandatory; conditions live in parentheses.

if (cond) {
...
} else if (cond2) {
...
} else {
...
}
while (cond) {
...
}
for item in arr {
...
}
break # exit the innermost for/while
continue # next iteration of the innermost loop
  • Conditions must be bool. There is no truthiness conversion.
  • Single-statement bodies without braces are not allowed.

for-in walks arrays and maps:

array<int> a = [10, 20, 30]
for x in a {
println(x) # 10, 20, 30
}
for (i, x) in a { # enumerate form: i is the 0-based index
println(to_string(i) + ":" + to_string(x))
}
map<string, int> m = {"x": 10, "y": 20}
for k in m {
println(k) # keys only
}
for (k, v) in m {
if (v != null) { println(k + "=" + to_string(v)) }
}
  • The iterable is evaluated once.
  • The loop variables are new read-only locals — assigning to them is a compile error.
  • For arrays, iteration is index-ordered (0 to arr.length - 1).
  • For maps, iteration walks live hash-table slots; the order is not insertion order. The iteration takes a snapshot of m.capacity at the start, so inserting or deleting inside the loop does not crash; deleted keys are skipped, and new keys placed outside the snapshot range are not visited.
  • The tuple form for (X, Y) in ... dispatches on the static type of the subject: array → enumerate (int + T), map → key + value (K + V?).

match matches a value against patterns and runs the first matching arm. It is a statement, not an expression; arms do not fall through and bodies must use braces.

match expr {
pattern => { body }
pattern => { body }
_ => { body } # wildcard, only as the last arm
}

The four pattern forms (16d):

  1. Literal — an int, float, bool, or string literal. The subject must have the same base type. Duplicate literals are a compile error.
  2. ok(v) / err(e) — the subject must be a Result. For Result<T>, e is bound as string; for Result<T, E>, e is bound as the struct E.
  3. null or a bare identifier — the subject must be T?. null matches the absent case; a bare identifier is a binding pattern that matches when the value is present and binds the narrowed (non-null) value to a new read-only local.
  4. _ — wildcard. Must be the last arm.

The compiler enforces exhaustiveness:

SubjectRequired arms
Result<T> / Result<T,E>ok(_) AND err(_) (or _)
booltrue AND false (or _)
T?null AND a catch-all (binding or _), or just _
int / float / string_ required (infinite domain)
struct / array / map_ required (no destructuring yet)

A non-exhaustive match is a compile error.

Nullable values must be narrowed before they can be used as their base type. Pluvial recognises exactly two condition shapes (level B):

  • if (x != null) { ... }x is narrowed to T inside the then branch.
  • if (x == null) { ... } else { ... }x is narrowed to T inside the else branch.

Result narrowing uses the same machinery, triggered by is ok / is err:

  • if (r is ok) { THEN } else { ELSE }r.value is accessible in THEN, r.error in ELSE.
  • if (r is err) { ... } else { ... } is the mirror.

Unlike nullable narrowing, Result is narrowed on both branches, since ok and err are the only two states.

Reassigning a narrowed variable cancels the narrowing. This is the soundness rule:

int? a = 5
if (a != null) {
int c = a + 1 # OK: a is int here
a = null
int d = a + 1 # compile error — a is int? again
}