Basics
Lexical rules, bindings, functions, control flow, operators, strings, lambdas, loops, and modules in Sakura Lang.
Pre-alpha
The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.
Sakura Lang has a small surface: braces, fn, let/var, match, if/else. The keywords are:
fn let var type error effect handler with try catch fail if else match for in while
return break continue import pub opaque as test property requires ensures where true falseverify proof ghost control resume region spawn actor interface impl are reserved for later and rejected today (V0007). A reserved word is still accepted directly after . or fn, so Task.spawn(s, body) is legal. Two names are special only in one place: it inside a where refinement and result inside an ensures clause. There is no null, no class, no self, no exceptions, no async/await, no macros, and no operator overloading.
Names and casing
Casing is part of the grammar. Values, functions, modules, fields, and effect-row variables are snake_case. Types, variants, error tags, effects, and type variables are PascalCase. A TypeName is never a value and an Ident is never a type; the resolver reports V1004 on a mismatch.
Comments and attributes
// A line comment. /* Block comments */ nest.
/// A doc comment. It attaches to the next item and is exported with its signature.
pub fn double(n: Int) -> Int {
n * 2
}@name(args) attributes precede items. The v0 set is @tool, @llm, @external, @total, @contract_test, @deprecated, @server, and @test_only. Attribute arguments are positional or name: value, as in @llm(system: "…") or @contract_test(handlers: [Sql.memory()]); this is the only place name: value appears in an argument list. @trust("reason") is the one attribute written as an expression, at a trust sink. An unknown attribute is V1010.
Statements and newlines
There are no mandatory semicolons. The lexer ends a statement at a line break only when both hold (design §2.4):
- The previous token can end an expression: a name, a hole, a literal,
),],},>, postfix?,true,false,break,continue, orreturn. - The next token cannot continue one: it is not
.,else,catch,),],},,,=>,=,->,|,.., or a binary operator other than-.
In practice:
- A line ending in an operator other than
>, or in(,[,{, or,, continues.>ends a line so thattype Ids = List<Int>terminates; write a continued comparison with>at the start of the next line. - A line starting with
.,else, orcatchcontinues the previous one, so method chains and} else {work across lines. foofollowed by(bar)on the next line is two statements. So isfoofollowed by-1.- Newlines inside
(and[are never significant.;is always accepted;sakura fmtremoves redundant ones.
Bindings
let binds an immutable name. var binds a name that can be reassigned. Local types are inferred.
fn bindings() -> Int {
let big = 20_000 // underscores between digits
let mask = 0xFF // also 0b1010
let ratio = 0.75 // Float needs a digit on both sides of the dot
let primes = [2, 3, 5, 7] // List<Int>
let pair = (1, "one") // tuple; pair.0, pair.1
var count = 0
count = count + List.len(primes)
count + big + mask
}() is the Unit value. There is no unary plus; -5 is unary minus applied to 5. Reassigning a var changes the binding, never a value seen elsewhere (Memory model). Top-level let declares a constant; a pub let needs a type annotation.
Functions
Every top-level function has a full signature: parameter types, -> Result, and an effect row unless it is pure.
fn add(a: Int, b: Int) -> Int {
a + b
}
fn clamp(n: Int, lo: Int, hi: Int) -> Int {
if n < lo { return lo }
if n > hi { return hi }
n
}
pub fn greet(name: String) -> Unit ! {Console} {
Console.print("hello, {name}")
}- A block's value is its final expression.
return eexits the enclosing function or lambda early. - A signature without
!is pure (! {}). Effects are covered in Effects. -> Unitis written out; there is no default return type.- Items are private to their module unless marked
pub. - A non-final expression statement whose value is not
Unitis an error (V2040). Writelet _ = eto discard on purpose.
Control flow
if is an expression. With else, both branches must have the same type. Without else, its type is Unit.
fn label(score: Int) -> String {
if score >= 90 { "excellent" } else if score >= 50 { "pass" } else { "keep going" }
}
fn can_vote(age: Int, registered: Bool, suspended: Bool) -> Bool {
age >= 18 && registered && !suspended
}match branches on the shape of a value. Arms end with a newline or ,. Guards use if. A match must be exhaustive, and a literal pattern on Int, String, or Float needs a _ arm. See Types.
fn describe(n: Option<Int>) -> String {
match n {
Some(0) => "zero"
Some(x) if x > 0 => "positive"
Some(_) => "negative"
None => "missing"
}
}Operators
Tightest first (design §3.1):
| Level | Operators | Associativity |
|---|---|---|
| 1 | call f(x), field .x, tuple index .0, index [i], raise ? | postfix |
| 2 | - ! | prefix |
| 3 | * / % | left |
| 4 | + - | left |
| 5 | .. (range, exclusive end) | none |
| 6 | == != < <= > >= | none |
| 7 | && | left |
| 8 | || | left |
- Both operands must have the same type.
Int + Floatis a type error; convert withInt.to_floatorFloat.round. +also concatenatesString. Nothing else is overloaded, and there are no user-defined operators.Intarithmetic is checked. Overflow (D001) and division by zero (D002) are defects, not errors.a < b < cisV0010.&&and||short-circuit.xs[i]isList.get(xs, i)and returnsOption.
Four parsing rules remove guesswork (design §3.2):
- In expressions,
<is always comparison. There are no call-site type arguments:List<Int>.empty()andf<T>(x)areV0011. Name the type with a binding annotation instead:let xs: List<Int> = List.empty(),let rows: List<User> = Sql.query(q). - There are no named arguments:
f(name: v)isV0014. Pass values positionally, or pass a record when there are many options (Budget.limits(Limits { tokens: Some(20_000), usd: None, calls: None })). - In the header of
if,while,for,match, andwith,Name {starts the block, not a record literal. Parenthesize record literals there. if,match,with,try,fn,handler, andfailexpressions are never operands without parentheses:(match x { … }).len(), notmatch x { … }.len()(V0012).
Strings
Strings are UTF-8 and may span lines. {expr} interpolates any expression whose type is Show. Escapes are \\ \" \n \r \t \0 \{ \} and \u{…}; write \{ for a literal brace.
fn inbox(name: String, unread: List<String>) -> String {
"Hello, {name}. You have {List.len(unread)} messages. \{not interpolated\}"
}Interpolating a Secret<T> is a compile error (V2031). Tagged strings sql"…", html"…", url"…", and shell"…" pass interpolations to the tag as values and never splice them raw. See Types.
Lambdas and function types
There is one lambda form: fn(params) { body }, with optional parameter and result annotations.
fn twice<e>(f: fn(Int) -> Int ! e, x: Int) -> Int ! e {
f(f(x))
}
test "twice applies f two times" {
let add3 = fn(x) { x + 3 }
let mul2 = fn(x: Int) -> Int { x * 2 }
expect_eq(twice(add3, 3), 9)
expect_eq(twice(mul2, 3), 12)
}A function type is fn(A, B) -> C ! row; without ! it is pure. The lowercase e is an effect-row variable, so twice performs whatever f performs. Lambdas capture values, not variables, and cannot assign to an outer var (V2041). A local lambda cannot call itself (V1020); lift it to a top-level function.
Method-call syntax
x.f(a) means T.f(x, a), where T is the type of x and f is looked up in T's home module. So xs.map(f) is List.map(xs, f) and s.len() is String.len(s). There are no methods, no self, and no pipe operator. Chains continue across lines with a leading .:
type User = { name: String, active: Bool }
fn names(users: List<User>) -> List<String> {
users
.filter(fn(u) { u.active })
.map(fn(u) { u.name })
}The full lookup order is in Types.
Loops
for pattern in expr { } iterates a List, Range, Set, or Map (as (key, value) pairs). while cond { } loops while a condition holds. break and continue work in both. Loops are statements and produce no value; accumulate in a var, or use List.fold.
fn total(xs: List<Int>) -> Int {
var sum = 0
for x in xs {
if x < 0 { continue }
sum = sum + x
}
sum
}
fn total_fold(xs: List<Int>) -> Int {
List.fold(xs, 0, fn(acc, x) { if x < 0 { acc } else { acc + x } })
}
fn countdown(n: Int) -> Unit ! {Console} {
for i in 0..n {
Console.print("{n - i}")
}
}Holes
?name is a typed hole: an unfinished expression. sakura check accepts holes and reports each one's expected type, the locals in scope, and ranked candidates. sakura build and sakura run reject them (V2050). See Typed holes.
fn unfinished(x: Int) -> Int { ?result }Modules
One file is one module. The module path is the file path under the package's src/: src/users/repo.sakura is module users/repo. The first import segment is a package name (std, the current package, or a dependency in sakura.toml).
import std/http.{Http, Request, Response} // bind names directly
import std/sql // bind only the module name `sql`
import acme/billing/invoices as inv // rename- Nothing outside the prelude is in scope implicitly.
import std/sqlbindssqland nothing else, so a row that mentionsSqlneedsimport std/sql.{Sql}. On an unresolved std name,V1002offers the import as a machine-applicable fix. - No wildcard imports, no relative imports, no re-exports, and no import cycles (
V1030). - Variants and error tags live directly in their module's namespace (
Some,EmailTaken);Option.Someis also accepted. pub typeexports a type with its constructors and fields.pub opaque typeexports only the name: other modules can use its values but not construct or destructure them (V1006).- The prelude needs no import: the built-in types (
Int,String,List,Map,Set,Secret,Untrusted, …),Option,Result,Duration,Instant,Uuid,Json,Level, the constructorsSome None Ok Err, the effectsFail Console Clock Random Env Log Task Test,showand theexpect…test functions, and the tagssql html url shell. - The entry point is
pub fn main() -> Unit ! {…}insrc/main.sakura.
What the compiler checks
- Every top-level function has a complete signature (
V3001lists effects a body performs beyond it). - Casing matches the namespace (
V1004). Reserved words cannot be identifiers (V0007). - Unused non-
Unitresults are errors (V2040). - Name conflicts within a module are errors (
V1001). A local shadowing an import is a warning (W1002). - Every
matchis exhaustive (V4001). Unreachable arms are reported (V4002). sakura fmthas no options: 2-space indent, width 100, one canonical layout.
Next
Types: records, variants, refinements, generics, and why there is no null.