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:
- an explicit resolver override (
--flavor default|ml|pythonfor single-file CLI use, or[project].flavorwhile loading a project); - a leading
// osprey: flavor=default|mlor# osprey: flavor=pythonmarker, preceded only by comments and blank lines; .osp,.ospmlor.pyextension;- 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_astnodes; - 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 MLf (x, y)are flat and do not partially apply. - Default
fn f(x) = fn(y) => ...and MLf x y = ...are curried. - ML
f a bisCall(Call(f, [a]), [b]); MLf (a, b)isCall(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.