Sakura Lang

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 false

verify 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):

  1. The previous token can end an expression: a name, a hole, a literal, ), ], }, >, postfix ?, true, false, break, continue, or return.
  2. 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 that type Ids = List<Int> terminates; write a continued comparison with > at the start of the next line.
  • A line starting with ., else, or catch continues the previous one, so method chains and } else { work across lines.
  • foo followed by (bar) on the next line is two statements. So is foo followed by -1.
  • Newlines inside ( and [ are never significant. ; is always accepted; sakura fmt removes 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 e exits the enclosing function or lambda early.
  • A signature without ! is pure (! {}). Effects are covered in Effects.
  • -> Unit is 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 Unit is an error (V2040). Write let _ = e to 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):

LevelOperatorsAssociativity
1call 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 + Float is a type error; convert with Int.to_float or Float.round.
  • + also concatenates String. Nothing else is overloaded, and there are no user-defined operators.
  • Int arithmetic is checked. Overflow (D001) and division by zero (D002) are defects, not errors.
  • a < b < c is V0010. && and || short-circuit.
  • xs[i] is List.get(xs, i) and returns Option.

Four parsing rules remove guesswork (design §3.2):

  • In expressions, < is always comparison. There are no call-site type arguments: List<Int>.empty() and f<T>(x) are V0011. 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) is V0014. 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, and with, Name { starts the block, not a record literal. Parenthesize record literals there.
  • if, match, with, try, fn, handler, and fail expressions are never operands without parentheses: (match x { … }).len(), not match 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/sql binds sql and nothing else, so a row that mentions Sql needs import std/sql.{Sql}. On an unresolved std name, V1002 offers 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.Some is also accepted.
  • pub type exports a type with its constructors and fields. pub opaque type exports 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 constructors Some None Ok Err, the effects Fail Console Clock Random Env Log Task Test, show and the expect… test functions, and the tags sql html url shell.
  • The entry point is pub fn main() -> Unit ! {…} in src/main.sakura.

What the compiler checks

  • Every top-level function has a complete signature (V3001 lists effects a body performs beyond it).
  • Casing matches the namespace (V1004). Reserved words cannot be identifiers (V0007).
  • Unused non-Unit results are errors (V2040).
  • Name conflicts within a module are errors (V1001). A local shadowing an import is a warning (W1002).
  • Every match is exhaustive (V4001). Unreachable arms are reported (V4002).
  • sakura fmt has no options: 2-space indent, width 100, one canonical layout.

Next

Types: records, variants, refinements, generics, and why there is no null.

Edit on GitHub

On this page