Specifications / Language

Language Flavors

Every Osprey flavor is a concrete syntax tree lowered into one canonical AST. A flavor owns parsing and lowering; type checking, effect checking, project assembly, and code generation are shared, so the set of flavors is open.

The ML surface is specified in ML Flavor Syntax and the Python surface in Python Flavor Syntax.

Canonical AST Boundary

[FLAVOR-BOUNDARY] Everything below the canonical AST is flavor-specific. Everything at or above osprey_ast::Program is shared.

Type inference, effect checking, project resolution, and code generation must not branch on Flavor. Parsed.flavor is retained for frontend and editor presentation only.

Dotted calls that may use UFCS retain that choice in the canonical AST until shared type resolution selects a field or free function. A generic receiver defers selection to each instantiation. ML field application denotes field access directly. When both select the same field, the shared lowering uses the same call semantics and ABI; no backend branch inspects the source flavor.

Supported Flavors

Flavor Blocks Calls Function default Extension
Default braces f(x: a, y: b) flat multi-parameter .osp
ML offside layout f a b curried .ospml
Python indentation f(a, b) flat multi-parameter .py with # osprey: flavor=python

Default remains the default API and source flavor. One file uses one flavor; a project may mix flavors.

Lowering Pipeline

Every frontend preserves declarations, handler arms and operations in the canonical AST. Module assembly, source validation, static discharge and residual validation follow STAGE-LOWER-ORDER-PHASE. A flavor lowerer must not erase an effect contract before shared validation.

Flavor Frontend

[FLAVOR-FRONTEND] src/toolchain/crates/osprey-syntax/src/lib.rs exposes the shared entry points:

pub enum Flavor { Default, Ml, Python }

pub struct Parsed {
    pub program: Program,
    pub errors: Vec<SyntaxError>,
    pub flavor: Flavor,
}

pub fn parse_program(source: &str) -> Parsed;
pub fn parse_program_with_flavor(source: &str, flavor: Flavor) -> Parsed;

parse_program selects Default. parse_program_with_flavor dispatches to default::parse, ml::parse_ml or python::parse.

The physical split is:

src/toolchain/crates/osprey-syntax/src/
  lib.rs       selection and dispatch
  strings.rs   shared interpolation and escape helpers
  default/     tree-sitter parsing and lowering
  ml/          layout lexing, parsing, CST, and lowering
  src/toolchain/python/      tree-sitter-python CST validation and lowering

Each flavor supplies its own interpolation fragment parser to the shared text helpers.

Flavor Selection

[FLAVOR-SELECT] osprey_syntax::resolve_flavor(flag, path, source) applies this precedence:

  1. an explicit resolver override (--flavor default|ml|python for single-file CLI use, or [project].flavor while loading a project);
  2. a leading // osprey: flavor=default|ml or # osprey: flavor=python marker, preceded only by comments and blank lines;
  3. .osp, .ospml or .py extension;
  4. Default.

Without an explicit flag, a marker that disagrees with the extension is an error. The CLI exits with that error. The language server reports one flavor-error diagnostic rather than parsing under a guessed flavor.

The Lowering Contract

[FLAVOR-LOWER-CONTRACT] A flavor lowerer must:

  • return only canonical osprey_ast nodes;
  • preserve source positions, documentation comments, and parameter names;
  • erase spelling-only differences before semantic analysis; and
  • reject a construct when the canonical AST cannot represent its semantics.

Flavor and Shared-Core Concerns

[FLAVOR-LAYER] The following forms use the same canonical vocabulary. Curried and flat functions intentionally have different canonical shapes, as specified in Currying Canonicalisation.

Concept Default ML Canonical AST
immutable binding let x = e x = e Stmt::Let { mutable: false }
mutable binding mut x = e mut x = e Stmt::Let { mutable: true }
assignment x = e x := e Stmt::Assignment
flat function fn f(x, y) = e f (x, y) = e one two-parameter Stmt::Function
curried function fn f(x) = fn(y) => e f x y = e one-parameter Function returning Lambda
flat call f(x: a, y: b) f (a, b) one two-argument Expr::Call
curried call f(a)(b) f a b nested one-argument Expr::Calls
lambda fn(x) => e \x => e Expr::Lambda
block { statements; value } indented region Expr::Block
match braced arms indented arms Expr::Match
equational clauses explicit match adjacent f 0 = a / f n = b Function over Match
union type T = A | B(X) type T = A | B X Stmt::Type
record construction T { f: v } T(f = v) or layout Expr::TypeConstructor
record update r { f: v } r(f = v) Expr::Update
list [a, b] [a, b] Expr::List
map { k: v } [k => v] Expr::Map
index xs[i] xs[i] Expr::Index
external function extern fn f(x: T) -> U extern f (x : T) -> U Stmt::Extern
effect braced operations layout operations Stmt::Effect
lexical handler handle E { ... } for the rest of a block layout handle E for the rest of a block Expr::Handler
callable handler handler E { ... }, applied with h(work) layout handler E, applied with h work shared handler-value lowering
fiber operations spawn, await, yield, send, recv same keywords with ML application shared expression nodes

Positional union payloads use numeric internal field names that source cannot spell. Wildcard parameters lower to generated names that source cannot spell. Equational clauses are merged before AST lowering and may select on at most one parameter column.

Currying Canonicalisation

[FLAVOR-CURRY] The canonical function type is flat: Type::Fun { params, ret }. Currying is represented by nesting one-parameter function types and lambda/call nodes.

  • Default fn f(x, y) and ML f (x, y) are flat and do not partially apply.
  • Default fn f(x) = fn(y) => ... and ML f x y = ... are curried.
  • ML f a b is Call(Call(f, [a]), [b]); ML f (a, b) is Call(f, [a, b]).

The AST-equivalence tests assert three cases: the curried twins are equal, the flat twins are equal, and ML curried is not equal to Default flat.

Shared-Core Additions

[FLAVOR-HANDLER-VALUE] Default handler E { ... } and ML handler E with indented arms define callable handlers. Apply them with h(work) or h work; neither needs in or do. Construction, application, capture, modes and staging follow Algebraic Effects. Implementation status belongs in plan 0016.

Converting Between Flavors

There is no flavor converter. osprey fmt formats a file within its own flavor and never changes it. Moving a source between flavors is a manual rewrite; the shared-golden twins below are what prove a rewrite preserved meaning.

Cross-Flavor Interop

[FLAVOR-INTEROP] Project assembly parses each source with its own flavor and merges the resulting canonical declarations into one namespace graph.

A Default flat function is called from ML with f (a, b). An ML curried function remains curried when imported; a Default caller applies the returned function value explicitly. Records, unions, results, and effects retain one canonical identity across flavors.

Cross-Flavor Equivalence Tests

src/toolchain/crates/osprey-cli/tests/cross_flavor_equiv.rs compares canonical ASTs after removing source positions. It covers equal curried twins, equal flat twins, and the expected unequal curried/flat pair.

[FLAVOR-IR-EQUIV] src/toolchain/crates/osprey-cli/tests/cross_flavor_ir_equiv.rs compiles paired .osp and .ospml examples and requires byte-identical LLVM IR. Each ML example has a Default twin and shares its expected-output file, except for the explicit ML-only allowlist in that test.

mixed_flavor_project_graphs_emit_identical_ir checks all eight flavor assignments to a three-file project. The graph splits one namespace across physical folders and combines imported handlers and arithmetic policies with abstract and manifest signature types. Every graph must type-check and emit the same LLVM IR.

Twins must express the same typing contract. A helper explicitly constrained to Source<int> is not the twin of an unannotated helper that generalizes over records with the required field. The paired sources must agree on that constraint or both retain the generic helper. Equal output for the particular values in one run does not establish that their inferred contracts agree.