Specifications / Language

Syntax

This chapter uses Default spelling unless it shows an ML equivalent. Detailed semantics live in the linked chapters.

Flavors

Default (.osp) uses braces, fn, and parenthesized calls. ML (.ospml) uses layout, whitespace application, and currying by default. Python (.py with # osprey: flavor=python) uses def, indentation and flat calls; its supported subset is specified in Python Flavor Syntax. All three frontends lower to the same osprey_ast::Program before semantic analysis.

Shared AST does not mean every surface form exists in every flavor. Default has if and structural ternary spellings that ML omits. The surface/core contract is FLAVOR-BOUNDARY; complete ML grammar is in ML Flavor Syntax. Multi-file and cross-flavor project limits are specified in Modules and Namespaces.

Program Structure

A file contains declarations and expression statements. Declarations include bindings, functions, externs, types, effects, imports, namespaces, modules, and module signatures. Module and import forms are specified in Modules and Namespaces; effect forms are specified in Algebraic Effects.

Bindings

Default uses let for an immutable binding and mut for a mutable cell. Reassignment uses = and is rejected for an immutable or undeclared name.

mut is not a general-purpose imperative variable. Osprey is expression-oriented and immutable-first; a mut binding exists only to back handler-owned state for algebraic effects — a cell an effect handler reads and writes as it interprets operations (EFFECTS-HANDLER-STATE). The checker therefore permits reassignment only in an effect handler arm. A free procedural x = x + 1 in ordinary statement position is a type error.

effect Counter { tick: fn() -> int }
fn run() = perform Counter.tick()

let name = "Alice"

// Only the handler arm changes the cell.
mut count = 0
let counter = handler Counter {
    tick => {
        count = checkedAdd(count, 1) ?: count
        count
    }
}
let total = counter(run)
print(total)

ML omits let; its reassignment operator is := and follows the same rule.

effect Counter
    tick : Unit => int
run () = perform Counter.tick ()

name = "Alice"

mut count = 0
counter = handler Counter
    tick =>
        count := checkedAdd count 1 ?: count
        count
total = counter run
print total

Both examples print 1. The mutable cell may be declared in an enclosing lexical scope so the handler can capture it, but every assignment must occur in an arm that interprets an effect operation. The handled computation is client code, not a handler arm, and does not gain mutation authority.

A binding may include a type annotation after :. The annotation constrains inference; it is not required when inference already fixes the type.

A binding written at file scope is a declaration: its initializer runs where it is written, before the program entry, and a function declared below it may read it. The storage, ordering and rejection rules are MODULES-FILE-SCOPE-BINDING.

Function Declarations

function ::= "fn" identifier typeParameters? "(" parameters? ")"
             ("->" type)? effectSet? ("=" expression | block)
parameter ::= (identifier | "_") (":" type)?

Default functions have flat parameter lists. Calls are described in Function Calls.

fn double(x) = x * 2
fn add(x, y) = x + y
fn getValue() = 42

Ignored parameters [PARAM-WILDCARD]

_ declares a parameter the body cannot reference. Lowering gives each _ a distinct unspellable internal name, so repeated ignored parameters do not collide.

let count = range(0, 10) |> fold(0, |acc, _| => acc + 1)

A named function can use _ only where its caller supplies arguments positionally; there is no source name for a named argument to target.

Extern Declarations

An extern declares a C-ABI symbol and has no body. Parameter types are required; an omitted return type means Unit.

extern ::= "extern" "fn" identifier "(" externParameters? ")" ("->" type)?
extern fn sqlite3_open(filename: string, ppDb: Ptr) -> int

Supported ABI types, callbacks, linking, and the memory-safety boundary are in Foreign Function Interface.

Type Declarations

A type declaration defines a record, a union, or a type alias. Unions may have nullary variants, named-field payloads, or positional payloads.

type Point = { x: int, y: int }
type Shape = Circle { radius: int } | Rectangle { width: int, height: int }
type Color = Red | Green | Blue

Type parameters and variance are defined in Type System.

The Shape union declared above has this shape:

union Shape ONE OF ◇ Circle { radius: Int } ◇ Rectangle { width: Int, height: Int }

Positional variants [TYPE-UNION-POSITIONAL]

A positional payload is declared, constructed, and matched in slot order:

type Tree = Leaf | Node(Tree, Tree)
let tree = Node(Node(Leaf, Leaf), Leaf)

fn size(tree) = match tree {
    Leaf          => 1
    Node(left, _) => 1 + size(left)
}

Positional constructors require every slot and do not partially apply. Their slots have no source field names, so named arguments and field access cannot target them. Nested constructor patterns are not implemented; each positional pattern slot is a binding or _.

Records

Construction supplies every declared field by name. Construction-site field order does not change which value is assigned to each field.

type Person = { name: string, age: int }
let person = Person { age: 25, name: "Alice" }
let older = person { age: 26 }

Records are immutable; record { field: value } creates a modified copy. Field typing is specified in Type System. The parsed where validation suffix is rejected during type checking.

Expressions

Default supports literals, names, calls, lambdas, blocks, field access, indexing, pipes, arithmetic and boolean operators, matches, ternaries, effects, and concurrency expressions.

The relevant precedence, highest to lowest, is:

  1. Postfix call, field access, and indexing
  2. Pipe |>

A postfix ( must appear on the SAME LINE as its callee, with horizontal space allowed: f(x) and f (x) are the same call. A postfix [ is stricter — it must follow its target with no whitespace at all, so xs[0] is an index while xs [0] is not. In both cases an opener that begins the next line is not postfix: it starts a new expression, or the next match arm's pattern. This is what lets an arm body be followed by a tuple arm (a, b) => … or a list arm [h, ...t] => … without the body swallowing it (PATTERN-TUPLE, TYPE-LIST-PATTERNS).

The two rules differ because the source they have to keep working differs. Forbidding the space before ( outright rejects print(id (1)), which has always been valid. Allowing it before [ breaks the other direction: list-arm matches are written on a single line —

fn sumL(xs) = match xs { [] => 0  [head, ...tail] => add(head, sumL(tail)) }

— where a same-line [ would read 0 [head as an index. Tuple-pattern arms have no equivalent single-line usage, so ( can afford the looser rule and [ cannot. xs [0] is rejected: LEX-STATEMENT-BREAK prevents the bracketed value from becoming a second statement on the same line. Write xs[0] to index. The parser test an_index_bracket_must_touch_its_target_unlike_a_call pins both this rejection and the valid single-line list-arm match. 3. Unary !, -, + 4. Multiplicative *, /, % 5. Additive +, - 6. Comparison ==, !=, <, >, <=, >= 7. Logical AND && 8. Logical OR || 9. Ternary ? : and Result default ?:, both right-associative

?: supplies a fallback for a Result — an index, a lookup, a parse, a fallible call. Arithmetic is not a Result and never appears on its left (ARITH-TOTAL).

x |> f(a) lowers to f(x, a). The two Default lambda spellings lower to Expr::Lambda:

let one = |x| => x + 1
let zero = fn() => 0

The pipe-delimited form requires at least one parameter because || is the logical-OR token.

Call-site type arguments [SYNTAX-TYPE-ARGUMENTS]

A call may carry an explicit type-argument list between the callee and its arguments. The list is recognised only when the < immediately follows the callee name and the matching > immediately precedes the argument list, which is what keeps < the comparison operator everywhere else.

call          ::= callee typeArguments? "(" arguments? ")"
typeArguments ::= "<" typeList ">"
fn identity<T>(x: T) -> T = x
print("${identity<int>(5)}")

The meaning — positional binding against the declaration's binders, the arity contract, and the rejection of variance markers — is TYPE-GENERICS-APPLY. The ML spelling is FLAVOR-ML-GENERICS.

Indexing

Postfix indexing is available on lists, maps, and strings. It returns Result<T, Error> (or Result<string, Error> for a string), so callers can handle an invalid index or absent key.

match values[0] {
    Success { value }   => print(value)
    Error { message }   => print(message)
}

Field Access

value.field reads a record field. A union payload must first be narrowed by a constructor pattern. Record update uses value { field: replacement }.

Match Expressions

match ::= "match" expression "{" arm+ "}"
arm   ::= pattern "=>" expression

Patterns are scalar literals, _, a lower-case binding, union constructors with named or positional payloads, list patterns, structural row patterns ({ x, y }, { x, .. }) and tuple patterns ((a, b)). Pattern semantics and exhaustive matching are defined in Pattern Matching.

name: Type is accepted as a typed binding. The backend treats it as a catch-all binding rather than a runtime type test, so it is valid only when the scrutinee already has that static type; runtime discrimination is a structural pattern (PATTERN-STRUCTURAL).

Evaluation order

Statements and positional call arguments evaluate left to right. && and || short-circuit. A named call to a function or extern declaration reorders its arguments to that declaration's parameter order before evaluating them. A call through a function value evaluates and binds its arguments in written order, including when they carry labels. A selected callable record field uses the function-value rule; UFCS fallback uses the selected free declaration's order after its receiver. The call-site distinction is defined in CALL-ARGUMENTS.