Nova — синтаксис

Минимальные примеры

// Hello world — никаких main, package, import для stdlib
print("hello")

// Чистая функция: нет эффектов, нет ошибок, детерминирована
fn double(x int) -> int => x * 2

Tagged template literals — tag\…“

Литерал с префиксом-тегом обрабатывается функцией tag. Возвращает тип, который выбирает функция (не обязательно str):

let j = json`{"name": "alice"}`              // -> Json
let q = sql`SELECT * FROM users WHERE id = ${user_id}`   // -> Sql, безопасно
let r = regex`\d+\.\d+`                       // -> Regex, raw
let b = bytes`deadbeef`                       // -> Bytes

Интерполяция через ${expr} — tag-функция получает части и аргументы раздельно, что обеспечивает безопасность (защита от SQL injection):

sql`SELECT * FROM users WHERE name = ${name}`
// → sql(["SELECT * FROM users WHERE name = ", ""], [name])
// функция передаёт name как параметр, не склеивает в строку

Multiline работает естественно. Escape: \`, \\, \${ — буквальные. Остальные символы — raw (удобно для regex и SQL).

Стандартные теги в stdlib: json, sql, regex, bytes.

Свой тег — обычная функция:

export fn url(parts []str, args []str) -> Url => ...

let u = url`https://api.example.com/users/${user_id}`

Подробно — D48.

Интерполяция строк — "... ${expr} ..."

В обычном строковом литерале "..." (без tag-префикса) разрешена интерполяция выражений через ${expr}. Это sugar над конкатенацией с str.from(...):

let name = "alice"
let age  = 30
let s = "Hello, ${name}, you are ${age}"
// = "Hello, " + str.from(name) + ", you are " + str.from(age)

Каждое ${expr} должно иметь тип, удовлетворяющий Into[str] (D73) — для всех примитивов и prelude-типов это автоматически. Буквальное ${ в строке — через escape: "\${name}".

Подробно — D44 → «Строковые литералы и интерполяция».

Statement separator: newline или ;

Перенос строки разделяет statement’ы. ; опционален — нужен только для нескольких statement’ов на одной строке:

let x = 1                        // newline разделяет
let y = 2
foo(x, y)

let a = 1; let b = 2; foo(a, b)  // ; для одной строки

Newline игнорируется в позициях, где statement продолжается:

// 1. После висящего бинарного оператора
let total = a +
            b +
            c

// 2. Внутри открытых () [] {}
let user = User {
    name: "alice",
    age: 30,
}

// 3. Перед .method() (chain)
let result = list
    .filter(|x| x > 0)
    .sum()

// 4. Перед ? (error propagation)
let user = find_user(id)
    ?

// 5. Перед else / else if (продолжение if-выражения)
let label =
    if s is Origin { "at-origin" }
    else if s is Circle { "circle" }
    else { "square" }

Бинарные операторы — в конец строки (Go-стиль), не в начало:

let total = a +              ✅
            b
let total = a
          + b                 ❌ парсится как унарный +b

Подробно — D49.

Числовые литералы

// Целые
1
1_000_000_000              // разделитель `_` между цифрами
0xFF_FF_FF_FF              // hex (любой регистр)
0b1010_0001                // binary
0o755                      // octal

// Float
1.5
1_234.567_89
1e10                       // научная нотация
1.5e-3

Default-типы без контекста: int для целых, f64 для float. С аннотацией/контекстом — берётся тип контекста:

let x u8 = 200             // 200 это u8
let arr []f32 = [1.0, 2.0]

Type-suffixes (100u32, 1.5f32) не вводятся. Для редких случаев дисамбигуации — as-cast: 100 as u32, 0xFF as u8.

Разделитель _ разрешён только между цифрами, не подряд, не в начале/конце, не сразу после префикса (0x_FF ❌), не вокруг точки или e. Подробно — D44.

Аннотации типа — без двоеточия

В позициях, где компилятор однозначно знает «дальше идёт тип», двоеточие опускается:

fn save(u User, amount money) Fail Db -> ()    // параметры
let users []User = []                            // let
type User { id u64, name str }                   // поля типа
for id u64 in ids { ... }                        // for-loop

: остаётся там, где это разделитель ключ-значение:

let alice = User { id: 1, name: "alice" }       // record-литерал
let cfg = { "host": "localhost", "port": 8080 } // dict-литерал

Возврат: -> обязателен, () опционален

fn compute(x int) -> int => x * 2    // явный тип возврата
fn log_event(e Event) Log            // -> () можно опускать
fn save(u User) Fail Db            // эффекты + dropped -> ()

Closure: light |...| и full fn(...)

В Nova две формы closure (D22):

closure-light — компактная untyped форма, тело bare expr или block:

let inc   = |x| x + 1
let zero  = || 0
let block = |x| { let y = x*2; y + 1 }
let any   = |_| 0                            // wildcard

list.filter(|x| x > 0)
list.fold(0, |acc, x| acc + x)
m.get_or_insert("k", || 0)
spawn(|| compute())

|...| валиден только когда контекст однозначно задаёт сигнатуру (параметр fn-call’а, annotated let, return-position, first-use inference). Без контекста — переключайся на fn(...).

closure-full — типизированная форма, идентична named fn без имени. Тело => expr или { block }:

let typed    = fn(x int) -> int => x * 2
let block    = fn(x int, y int) -> int { let z = x+y; z * 2 }
let with_eff = fn(req Request) Db Log -> Response { process(req) }

Эффекты в closure-light не пишутся — они наследуются из ambient effect-set (= эффекты enclosing-функции ∪ активные with-блоки). Если тело closure’а использует эффект, недоступный в parent’е — compile error. Closure-full объявляет эффекты явно, как named fn.

Trailing — блок/функция-аргумент за скобками вызова

Если последний параметр функции — функционального типа, аргумент можно вынести за () вызова в одну из двух форм:

trailing-block — для callback’ов без параметров (DSL):

with_timeout(2.seconds) {
    Db.exec(sql`UPDATE counters SET v = v + 1`)
}

retry(3) {
    Net.get(url)
}

trailing-fn — для callback’ов с параметрами, синтаксис идентичен closure-full без имени:

list.filter() fn(x) => x > 0
list.fold(0) fn(acc, x) { acc + x }
list.map() fn(s str) Fail -> int { parse(s)? }

Правила:

  • { (для trailing-block) или fn (для trailing-fn) на той же строке, что ). Перенос запрещён.
  • () обязательны (даже пустые).
  • Тип последнего параметра — функциональный.
  • Один trailing на вызов.
  • |...| (closure-light) в trailing-position запрещён — передавай через args (f(|x| body)) или используй fn(...).

spawn — keyword-конструкция, не функция, поэтому не подчиняется правилу D43. Его синтаксис описан отдельно ниже.

Когда trailing-fn vs closure-light в args:

  • f(|x| body) — компактнее для one-liner’ов.
  • f(args) fn(x) { ... } — лучше для длинных тел с let’ами, визуально маркирует «это блок-аргумент к вызову».

Подробно — D22, D43.

Тело функции: => для выражения, {} для блока

Два взаимоисключающих способа:

// expression-body — ровно одно выражение
fn double(x int) => x * 2                    // -> int выведен (D45)
fn classify(n int) -> str => match n {       // -> str для ясности
    0 => "zero",
    n if n > 0 => "positive",
    _ => "negative",
}

// block-body — несколько шагов; последнее выражение = значение блока
fn next_pow2(n int) -> int {                 // -> int обязателен
    if n <= 1 { return 1 }
    let mut p = 1
    while p < n { p *= 2 }
    p
}

В expression-body -> T опционален — тип выводится из тела (D45). В block-body -> T обязателен (если не unit).

Indentation не значим. fn f() => stmt1; stmt2 или multiline без {} — ошибка. Если шагов больше одного — {} обязательны.

Style: для export-функций (public API) рекомендуется писать -> T явно — это документация и стабильность. Для приватных и tiny helpers можно опускать.

Подробно — D40, D45.

Перегрузка операторов

Стандартные операторы автоматически вызывают методы с фиксированными именами:

fn Duration @plus(other Duration) => Duration { nanos: @nanos + other.nanos }
fn Duration @times(n i64) => Duration { nanos: @nanos * n }

let total = 1.hour() + 30.minutes()       // вызывает @plus
let triple = 5.seconds() * 3              // вызывает @times
if elapsed > 1.second() { ... }           // вызывает @gt
ОператорМетодОператорМетод
+@plus(o)==@eq(o) -> bool
- (binary)@minus(o)<@lt(o) -> bool
- (unary)@neg()<=@le(o) -> bool
*@times(o)>@gt(o) -> bool
/@div(o)>=@ge(o) -> bool
%@rem(o)!@not()
|@or(o)<<@shl(n)
&@and(o)>>@shr(n)
^@xor(o)
a[i]@get(i)a[i]=v@set(i, v)

!= выводится из @eq. &&/|| не перегружаются (short-circuit семантика). Custom-операторы (:+, <>) не разрешены. Подробно — D46.

Математические операции на числовых типах

Стандартные математические функции на f64 / f32 / int объявлены как instance-методы через @, не как static Math.sin(...). Это согласовано с D35 (методы — основной механизм для type-bound функций) и даёт chain-friendly формулы:

let r = (x * x + y * y).sqrt()
let phi = im.atan2(re)
let dist = a.hypot(b)
let s = (theta + offset).sin()

Стандартный набор на f64 (prelude):

КатегорияМетоды
Корни и степени@sqrt(), @cbrt(), @sqr(), @pow(exp f64), @powi(n int)
Тригонометрия@sin(), @cos(), @tan(), @asin(), @acos(), @atan()
atan2 (двух-арг)@atan2(other f64) -> f64 (y.atan2(x))
Гиперболические@sinh(), @cosh(), @tanh()
Экспонента / лог@exp(), @ln(), @log10(), @log2(), @log(base f64)
Норма / расстояние@abs(), @hypot(other f64)
Округление@floor(), @ceil(), @round(), @trunc(), @fract()
Знак / минимум@signum(), @min(other f64), @max(other f64)
Предикаты@is_finite(), @is_nan(), @is_infinite()

Аналогичный набор на int для целочисленных операций (@abs(), @pow(n int), @signum(), @min, @max).

Имена, на которые стоит обратить внимание:

  • @sqr() — квадрат x*x. Прецедент Pascal Sqr(x), короче чем squared. Согласовано с Complex @sqr() и любыми другими типами, где квадрат — частая операция.
  • @hypot(other) / @atan2(other) — двухаргументные функции, второй аргумент идёт как параметр; receiver — первый аргумент по математической конвенции (y.atan2(x), a.hypot(b)).

Static-функции на типе для тех случаев, где нет естественного receiver’а:

f64.PI                   // константа
f64.E                    // константа
f64.NAN                  // константа
f64.INFINITY             // константа
f64.try_parse(s str) -> Option[f64]

Конвенции именования

ЧтоСтильПример
Типы, эффекты, протоколы, варианты sumPascalCaseUser, HashMap, Db, Hashable, Some
Generic-параметрыPascalCase, односимвольныеT, K, V, E
Функции, методы (@name), параметры, поляsnake_caseparse_url, @deposit, user_id, created_at
Константы (const)SCREAMING_SNAKE_CASEMAX_PAYLOAD, DEFAULT_TIMEOUT
Модулиsnake_case через точкиmodule admin.audit, module std.duration

Акронимы — PascalCase, не UPPERCASE. Db, не DB. Http, не HTTP. Json, не JSON. Url, не URL. Правило: акроним = обычное слово.

Зарезервированные имена методов (operator overloading, D46): @plus, @minus, @times, @div, @rem, @neg, @or, @and, @xor, @shl, @shr, @eq, @lt, @le, @gt, @ge, @not, @get, @set. Не использовать для других целей.

Договорные конвенции:

  • T.new(...) — стандартный конструктор; T.from(v X) — из значения через From[X] protocol (D73); T.from_X(...) — доменный конструктор когда from(v) не передаёт смысл (from_secs, from_polar, from_imag).
  • @into() — конверсия в другой тип через Into[T] (D73); тип цели берётся из контекста (let s str = v.into()). Конверсия в строку — str.from(v) или v.into() с context = str (раньше было @to_str() через old D70 ToStr, отменено в v3).
  • @hash() — хеш, @clone() — копия, @iter()/@next() — iterator.
  • Имена ошибок (D30) — с типом / доменом: ParseComplexError, ParseIntError, DbError, OverflowError. Не использовать generic ParseError, ValueError, Exception — коллизии импорта, неоднозначность для AI.

Конвенции @to_X(), @as_X(), @is_X() не вводятся — они дублируют существующие механизмы:

  • @to_X() дублирует X.from(v) / v.into() (D73).
  • @as_X() дублирует keyword as (D54) для дешёвых cast’ов или X.from для нетривиальных.
  • @is_X() дублирует v is X (D54): для sum-типов и any оператор is работает напрямую (shape is Circle, arg is int для arg any). Для извлечения значения варианта с биндингом — if let X(n) = v (D34).
  • _prefixтолько для полей (используй методы вместо прямого доступа). Для функций/методов не используется.
  • Test-имена — строки естественного языка: test "insert and get", не "test_insert_and_get".

Зарезервированные identifier’ы

Помимо grammar-keyword’ов, Nova имеет identifier’ы со специальной семантикой, известной компилятору. Их можно переопределить локально, но это анти-паттерн (линтер предупреждает).

Special types:

  • Self — referential type, refers к receiver-типу метода или типу, удовлетворяющему protocol’у (D66). Валиден в любом type-контексте.
  • any — top-type для runtime type-check (D54).
  • never — bottom-type для не-возвращающих функций.

Prelude types:

  • Option[T], Some(v), None — sum-тип
  • Result[T, E], Ok(v), Err(e) — sum-тип
  • Error — record { msg str } для throw err
  • RuntimeError — sum bottom-уровневых runtime-ошибок
  • RuntimeNoneError — unit-тип, бросается через expr!! на Option (D85)
  • Effect[E] — first-class тип handler’а эффекта
  • From[T] — protocol со static-методом from(v T) -> Self (D73)
  • Into[T] — protocol с instance-методом @into() -> T (D73). Авто-выводится из From[T] и наоборот; пишется одна сторона, другая синтезируется компилятором.

Стандартные эффекты:

  • Fail[E], Fail — failable-эффект
  • Io, Net, Db, Fs, Time, Random, Log, Trace — основные
  • Ask[T] — Reader-style контекст
  • Alloc[R] — аллокация в region
  • Detach, Blocking — (D50)

Примитивные типы (lowercase, исключение из PascalCase-правила):

  • int, i8, i16, i32, i64, u8, u16, u32, u64
  • f32, f64
  • str, bool, byte

Подробно — D30, D46, D47.

Видимость: export для публичных деклараций

export перед декларацией = публичная (видна снаружи модуля). Без export = приватная (видна только внутри модуля).

Применяется единообразно к типам, функциям, методам, константам и протоколам:

module account

export type Account {                    // публичный тип
    readonly owner str
    balance money
    _internal_id u64                     // convention: `_` = приватное-по-договору
}

type InternalState { ... }               // приватный тип

export const ACCOUNT_MIN_BALANCE money = 0
const _INTERNAL_TIMEOUT_MS int = 5_000

export fn Account.new(owner str) -> Account => ...      // публичный конструктор
export fn Account @balance() => @balance                // публичный метод
fn Account @validate(amount money) => amount > 0       // приватный helper

export type Hashable protocol {
    hash() -> u64
    eq(other Self) -> bool
}

Поля record: в MVP все поля export-типа публичны. Convention _prefix для приватных-по-договору, не enforced компилятором — обычно инкапсуляция делается через методы (геттеры/сеттеры).

Подробно — D47, D29 (модули).

Объявление типов

После type Имя идётЧто это
|sum-type
(tuple-структура
{record-структура
aliasalias
идентификатор/типnewtype
ничегоunit-тип
// newtype — type X Y, новый тип, типизированно отличный от Y
type UserId u64
type Email str

// alias — type X alias Y, для длинных дженериков
type StringMap[V] alias HashMap[str, V]

// record (форма сразу после имени, без `=`)
type User { id u64, name str }

// позиционная структура
type Point(f64, f64)

// unit-тип
type Marker

// sum-type — варианты через leading |
type Color | Red | Green | Blue

type Shape
    | Circle { radius f64 }
    | Square { side f64 }
    | Triangle { a f64, b f64, c f64 }

type Result[T, E] | Ok(T) | Err(E)
type Option[T] | Some(T) | None

Sum-варианты могут иметь числовые discriminants с auto-increment:

type ExitStatus | Ok | Failure | Critical              // 0, 1, 2 (auto)
type ErrorCode
    | NotFound       = 404
    | Unauthorized   = 401
    | InternalError  = 500
type Bit u8 | Off = 0 | On = 1                         // явный базовый тип

type X u8 | … (явный базовый тип) пока не реализован — parser drift, см. Plan 105. Работают только формы без базового типа (implicit int).

Подробно — decisions/02-types.md → D52.

Варианты sum-type — те же три формы, что top-level type

Каждый вариант sum-type объявляется по тем же правилам, что top-level объявление:

После имени вариантаЧто этоПример
( ... )позиционный вариантSome(T), Ok(T), Point(f64, f64)
{ ... }record-вариантCircle { radius f64 }
ничегоunit-вариантNone, Red, Origin
type Option[T]
    | Some(T)                 // позиционный — несёт значение T
    | None                    // unit — без полей, само по себе значение

type Shape
    | Circle { radius f64 }   // record-вариант
    | Point(f64, f64)         // позиционный
    | Origin                  // unit

None — это значение типа Option[T], не функция и не конструктор. Используется без скобок:

let x = Some(42)              // позиционный — нужен аргумент
let y = None                  // unit — без скобок

Подробно — D17.

Создание значений и pattern matching

let p = Point(1.0, 2.0)
let u = User { id: 1, name: "alice" }
let c = Circle { radius: 5.0 }
let s = Active

// доступ к полям (D37)
println(u.name)              // record — по имени
println(p.0, p.1)            // позиционная — по индексу
let pair = (1, "alice")
println(pair.0, pair.1)      // кортеж — то же

// создание массивов (D38)
let xs []int = []                          // пустой, тип из annotation
let ys = []int.new()                       // через static-метод
let buf = []u8.with_capacity(1024)         // с pre-allocation
let zeros = []u8.filled(0, 16)             // заполненный

// turbofish для дженериков (D38)
let n = parse[int]("42")?                  // явный T = int
let m = HashMap[str, int].new()            // явные K, V

match shape {
    Circle { radius }    => 3.14159 * radius * radius
    Square { side }      => side * side
    Triangle { a, b, c } => heron(a, b, c)
}

match result {
    Ok(value)  => value
    Err(error) => default
}

Pattern matching

fn classify(x) => match x {
    0          => "zero"
    1..=9      => "digit"
    n if n < 0 => "negative"
    _          => "big"
}

Каждая arm имеет форму pattern => result, опционально с guard’ом pattern if condition => result. Компилятор пробует arm’ы сверху вниз, берёт первую, где паттерн совпал И guard истинный.

Виды паттернов:

ФормаПримерЧто делает
Литерал0, "hello", trueсравнение по значению
Range1..=9, 0..100попадание в диапазон
Имя (binding)n, xловит любое значение, привязывает к имени
Wildcard_ловит любое значение, не привязывает
КонструкторSome(v), Ok(value), Noneразбор варианта sum-type
RecordUser { id, name }разбор record-полей
Tuple(a, b), (_, value)разбор кортежа
Guardn if n < 0паттерн + дополнительное условие

Exhaustiveness check. Компилятор проверяет, что match покрывает все возможные случаи. Если нет — ошибка с указанием непокрытого варианта. Это работает для sum-type, range’ей, bool. Для общих типов (int, str) нужен либо _-wildcard, либо явная проверка всех рассматриваемых значений.

type Color | Red | Green | Blue

fn name(c Color) -> str => match c {
    Red   => "red"
    Green => "green"
    // ОШИБКА: missing variant `Blue`
}

match — это выражение, возвращает значение. Все ветви должны иметь совместимый тип (или общий supertype, либо обёрнутые в sum-type).

Record-литералы и patterns

Shorthand — когда имя поля совпадает с именем переменной в scope:

let key = "alice"
let value = 42

let entry = Entry { key, value }                 // shorthand обязателен (D52)
let entry = Entry { key, value, extra: "data" }  // можно смешивать
// `Entry { key: key }` — ОШИБКА: используйте shorthand `{ key }`.

Partial pattern matching — указывать только нужные поля:

match @buckets[idx] {
    Occupied { value }     => Some(value)        // partial: key игнорируется
    Occupied { value, .. } => Some(value)        // явный .. — то же самое
    _                      => None
}

Обе формы валидны (.. или без) — выбор по контексту. .. — сигнал «у типа есть ещё поля». Без — короче.

Переименование при деструктуризации:

Occupied { key: k, value }      // key переименовано в k, value совпадает

Подробно — D17.

Циклы for / while / loop

for x in list { ... }            // x — immutable binding на каждой итерации
for mut x in list { ... }         // x можно мутировать в теле
for x int in nums { ... }         // явный тип элемента
for mut id u64 in ids { ... }     // mut + явный тип элемента
for (i, x) in list.enumerate() { ... }   // индекс через метод

while cond { ... }                // условный цикл
loop { ... }                      // бесконечный, выход через break/return

Явный тип элемента — for x TYPE in iter — опционален и следует универсальному правилу «name type» (как let x int, fn(x int), [T Bound]). Аннотация проверяется компилятором: если TYPE не совпадает с фактическим типом элемента итератора — compile error. Это делает её checked assertion (фиксирует ожидание; смена типа источника → loud error), а не молчаливым документирующим сахаром. Go/Rust/TS аннотацию loop-переменной не дают вовсе — Nova получает её как строгий проверяемый superset.

Переменная в for x in iterimmutable binding (как let без mut), на каждой итерации получает новое значение. В теле блока переприсвоить нельзя:

for x in list {
    x = 5                         // ОШИБКА: x immutable
}

for mut x in list {
    x = transform(x)              // ок
}

Это согласовано с правилом D32/D33 — все binding’и иммутабельны по умолчанию, мутация явно через mut. Никакого const или final маркера в Nova нет — иммутабельность и так дефолт.

break / continue — стандартные. break value выходит из loop со значением (loop — выражение).

if let и while let

Паттерн-матч прямо в условии — короткая альтернатива match для одного варианта:

// если в кеше есть — вернуть
if let Some(data) = cache.get(key) {
    return data
}

// извлечение из Result
if let Ok(user) = Db.find(id) {
    process(user)
} else {
    Log.warn("user not found")
}

// while let — итерация пока паттерн совпадает
while let Some(line) = reader.read_line()? {
    process(line)
}

// несколько условий через запятую
if let Some(user) = lookup(id), user.is_active {
    process(user)
}

Chain-форма (if let … , …) пока не реализована — parser drift, см. Plan 106. Реализовано только одиночное if let pattern = expr.

Локальные binding’и (data, user, line) доступны только в теле блока. После закрывающей } — недоступны.

Подробно — D34.

Методы инстанса и static-функции

В Nova — два вида функций ассоциированных с типом, различимых по синтаксису декларации:

// конструктор / static — через точку, без @
fn Account.new(owner str) -> Account =>
    Account { _balance: 0, owner }

// метод инстанса — через пробел и @, неявный self
fn Account @balance() -> money => @_balance

fn Account @is_solvent() -> bool => @_balance > 0

// мутирующий метод — mut перед @name
fn Account mut @deposit(amount money) {
    @_balance += amount
}

Использование:

let acc = Account.new("alice")    // вызов constructor через точку
acc.deposit(100)                   // вызов метода — точка + скобки
let bal = acc.balance()            // getter, обязательные скобки

@field для доступа к полям

Внутри метода (@method или mut @method) поля self доступны через @field — единственная форма:

fn Account @summary() -> str =>
    "${@owner}: ${@_balance}"      // = self.owner, self._balance

@.field невалидно — точка не используется. @field — единственно верно.

@ без поля — это значение текущего инстанса:

fn Account @copy() -> Account => @
fn Account @send_to(ch Channel[Account]) => ch.send(@)

Скобки обязательны для вызова

acc.balance()              // вызов метода
acc.balance                // bound method value (не вызов!), тип: fn() -> money
Account.@balance           // unbound method value, тип: fn(Account) -> money
Account.new                // static-функция как значение, тип: fn(str) -> Account

Программист и LLM мгновенно различают: вызов = со скобками, значение = без скобок. Никаких property с побочками.

Generic’и

fn HashMap[K, V].new() -> HashMap[K, V] => ...        // generic на типе
fn HashMap[K, V] @get(key K) -> Option[V] => ...      // тоже
fn []T @map[U](f fn(T) -> U) -> []U => ...            // generic на методе [U]

Подробно — D35.

Embed и delegation: use Type и use name Type

Композиция вместо наследования. use — это поле + автопрокси методов:

type Account {
    owner str
    balance money
}

fn Account mut @deposit(amount money) => @balance += amount

// embed: имя поля обязательно (D39 — alias всегда явный)
type AuditedAccount {
    use account Account
    audit_log []AuditEntry
}

fn AuditedAccount mut @withdraw(amount money) Fail[AuditError] {
    @account.deposit(-amount)               // явный вызов "родителя" через имя поля
    @audit_log.push(AuditEntry.new(amount))
}

let aa = AuditedAccount { ... }
aa.deposit(100)                              // авто-прокси: account.deposit
aa.balance                                   // авто-прокси: account.balance

Имя поля обязательно при use (D39) — согласовано с D30 (поля snake_case):

type Wrapper[K, V] {
    use w HashMapIter[K, V]      // имя поля = "w"
    extra int
}

fn Wrapper[K, V] @next() -> Option[Pair[K, V]] => @w.next()

// конфликт двух embed — псевдонимы обязательны
type Composite {
    use a TimerA
    use b TimerB                  // оба определяют tick() — нужны имена
}

Override. Метод того же имени на внешнем типе перекрывает прокси. Доступ к «родительскому» — через имя поля:

fn AuditedAccount mut @deposit(amount money) {
    @account.deposit(amount)                // вызов оригинала через имя поля
    @audit_log.push(AuditEntry.new(amount))
}

use — это не наследование. AuditedAccount не подтип Account. Функции fn(Account) принимают Account, не AuditedAccount. Структурные интерфейсы — отдельный механизм (см. ниже).

Подробно — D39.

Передача параметров

Объекты (record, sum-type, массивы) передаются по ссылке в managed heap. Примитивы (int, bool, f64, …) — по значению. Префикс mut разрешает мутацию.

type Account { balance money }    // обычное поле — мутируется у mut binding'а

// без mut — иммутабельный view, мутация запрещена
fn show(acc Account) Io => println("${acc.balance}")

// с mut — мутации видны вызывающему
fn deposit(mut acc Account, amount money) {
    acc.balance += amount
}

let mut my_acc = Account { balance: 100 }
deposit(my_acc, 50)
// my_acc.balance == 150  ← мутация видна

show(my_acc)
// показывает 150, my_acc не изменён

Поля типа: let для never-mut, mut для cache

type Account {
    readonly id u64                // никогда не меняется (D36)
    readonly owner str             // тоже
    balance money                  // мутируется у mut-binding
    closed bool                    // тоже
    mut last_cached_total money    // мутируется ВСЕГДА (для cache/lazy)
}

// group-syntax — несколько полей одного типа через запятую
type Point { x, y, z f64 }
type Color { r, g, b u8 }

Подробно про правила мутации полей — D36.

ФормаПередачаМутация снаружи
x intby valueнет
o Ordermanaged referenceнет (immutable)
mut o Ordermanaged referenceда

Для perf-критичного кода компилятор использует escape analysis: не утекающие значения остаются на стеке, без аллокаций в managed heap. Программист не пишет ничего особого. Для real-time — блок realtime nogc { } (D64), внутри region { } для arena-allocations (D6).

Подробно — D32.

Опциональные параметры — через record + spread, не через defaults

Default-значений у параметров функции в Nova нет (намеренно — см. history/rejected.md). Когда у функции много параметров с разумными дефолтами, используется паттерн опции-record + spread: комбинация record-типа с константой-дефолтом (D52), record-coercion в позиции с известным типом (D55) и spread ...obj для override отдельных полей (D60).

type ServerOpts {
    port     int
    host     str
    max_conn int
    timeout  Duration
}

const SERVER_DEFAULTS ServerOpts = {
    port:     8080,
    host:     "0.0.0.0",
    max_conn: 1024,
    timeout:  30.seconds(),
}

fn serve(opts ServerOpts) Net -> () => ...

// Все дефолты:
serve({ ...SERVER_DEFAULTS })

// Override одного-двух полей:
serve({ ...SERVER_DEFAULTS, port: 9000 })
serve({ ...SERVER_DEFAULTS, port: 9000, max_conn: 4096 })

// Совсем кастом:
serve({ port: 9000, host: "127.0.0.1", max_conn: 16, timeout: 5.seconds() })

Преимущества над default-значениями:

  1. Все опции видны на call-site — программист и LLM не гадают что значит «остальные дефолты». ...SERVER_DEFAULTS явно говорит «возьми всё остальное оттуда».
  2. Дефолты переиспользуютсяSERVER_DEFAULTS, TEST_DEFAULTS, DEV_DEFAULTS для разных сред.
  3. Refactoring безопасен — добавил поле в record, спред-вызовы подхватывают новое поле; вызовы без спреда — compile error «missing field», программист увидит каждое место.
  4. Композиция — несколько spread’ов: { ...BASE, ...OVERRIDES, port: 9000 }.
  5. Без новой грамматики — работает через существующие D52/D55/D60.

Когда такой паттерн избыточен:

  • Функция имеет 2–3 параметра без дефолтов — пишутся напрямую: fn move(x int, y int).
  • Дефолты семантически разные («режимы») — лучше отдельные функции или sum-type: fn parse_strict(s str), fn parse_lenient(s str).

Подробно: D52 record, D55 coercion, D60 spread.

Эффекты в сигнатуре

Любое взаимодействие с внешним миром — эффект, объявляется между ) и ->:

fn double(x int) -> int                          // чистая
fn parse(s str) Fail -> int                    // может бросить
fn save(u User) Fail Db Log -> ()              // три эффекта
fn fetch(url str) Net Fail -> Response   // сеть + async + ошибки

? и !! — два постфиксных оператора для Option/Result (D85):

  • expr? — ранний return обёртки (нужен -> Option/Result).
  • expr!! — throw через Fail[E] (нужен Fail[E] в сигнатуре).
// throw-стиль через !!
fn pipeline(s str) Fail[ParseError] -> int {
    let n = parse(s)!!
    let doubled = n * 2
    validate(doubled)!!
    doubled
}

// return-стиль через ?
fn pipeline_r(s str) -> Result[int, ParseError] {
    let n = parse(s)?
    let doubled = n * 2
    validate(doubled)?
    Ok(doubled)
}

Подробнее — effects.md, revolutionary.md.

Контракты (опциональны)

fn withdraw(mut acc Account, amount money) Fail -> ()
    requires amount > 0
    requires acc.balance >= amount
    ensures acc.balance == old(acc.balance) - amount
=>
    acc.balance -= amount

Без контрактов код работает как обычно. С ними компилятор пытается доказать статически, что не может — превращает в runtime-проверку в debug-режиме.

Handler’ы — литералы у protocol-эффектов

type Logger effect {
    log(msg str) -> ()
}

fn process(x int) Logger -> int {
    Logger.log("processing ${x}")
    x * 2
}

// handler — обычное значение через keyword `handler` (D61)
let console = effect Logger {
    log(msg) => println("[LOG] ${msg}")
}

// применение через with
fn main() Io -> () {
    with Logger = console {
        process(42)
    }
}

return value или финальное выражение в handler-method’е продолжает вычисление с возвращённым значением. Для досрочного выхода из всего with-блока — interrupt v (D61). resume в Nova не существует.

Имя эффекта в коде — три позиции

fn process() Db -> ()                // 1. позиция типа
Db.query(sql`...`)                   // 2. операция активного handler'а
let captured = Db                    // 3. сам активный handler как значение

Парсер различает по позиции.

With-блок — несколько подмен в одном

test "complex flow" {
    with Logger = collect_into(buf),
         Db = in_memory,
         Time = fixed(t0) {
        process_order(o)
    }
    assert(buf.contains("processed"))
}

После with — список «эффект = handler-выражение» через запятую, потом один блок тела.

Параллелизм — без async/await

fn fetch_all(ids []u64) Net Fail -> []User =>
    parallel for id in ids {
        fetch_user(id)
    }

Suspension в Nova — ambient runtime-инфраструктура, не эффект и не специальная конструкция (D62). Тип возврата []User, не Future<[]User>. Подробно — revolutionary.md R7.

parallel for — structured concurrency: ждёт всех, отменяет хвост при ошибке.

Capability-режим

fn run_user_script(code str) Fail -> Result =>
    forbid Net, Fs, Db {
        eval(code)
    }

Внутри forbid компилятор не пропустит вызов функции с запрещёнными эффектами. Sandbox в типах, не в рантайме.

Производительность — escape analysis и regions

Программист пишет обычный код:

fn hot_loop(data []f64) -> f64 =>
    data.iter().sum()  // SIMD-авто, zero-alloc через escape analysis

Компилятор сам решает: примитивы — в регистрах, не утекающие объекты — на стеке, остальное — в managed heap. Никаких ссылок вручную.

Для real-time hot path — блок realtime nogc { body } (D64). Внутри блока запрещены suspend-операции и аллокации в managed heap; region { ... } используется для arena-allocations (D6).

Структурные «интерфейсы» — protocol

Никаких interface/trait. Структурный контракт — отдельным keyword protocol:

// именованный
type Printable protocol {
    show() -> str
}

fn log_one(x Printable) Log -> () => Log.info(x.show())

// или прямо в сигнатуре, без имени — анонимный структурный тип
fn log_one(x { show() -> str }) Log -> () => Log.info(x.show())

Совместимость автоматическая по структуре — любой тип с подходящими методами автоматически удовлетворяет protocol’у, никаких impl-блоков не нужно. Self валиден внутри любого type-контекста (protocol-блок, effect-блок, instance-метод, static-метод, sum-вариант) по D66:

type Hashable protocol {
    hash() -> u64
    eq(other Self) -> bool
}

type Iterator[T] protocol {
    next() -> Option[T]
}

type — для данных (record, sum-type, alias). protocol — для поведения (методы как контракт). Подробно — D42, D9 / D15.

Дженерики

fn map[T, U](xs []T, f T -> U) -> []U =>
    [f(x) for x in xs]

// дженерик по эффектам — функция наследует эффекты `f`
fn map_eff[T, U, E](xs []T, f (T) E -> U) E -> []U =>
    [f(x) for x in xs]

Параметры типа — после имени в квадратных скобках Имя[T], не <T>. Подробно — D16. Массивы — []T (динамический), [N]T (фиксированный), D27.

Generic bounds — [T Protocol]

Параметр-тип ограничивается protocol’ом через единое правило «name type» (без двоеточия):

fn dedup[T Hashable](xs []T) -> []T => ...
fn map[K Hashable, V](m HashMap[K, V]) -> ...
fn fold[T, Acc](xs Iter[T], init Acc, f fn(Acc, T) -> Acc) -> Acc

Bound — это protocol-тип (D53). Тот же Hashable стоит и в позиции типа значения (existential), и в bound’е (universal через мономорфизацию):

fn dump(x Hashable) -> u64 => x.hash()        // existential, dynamic dispatch
fn dump2[T Hashable](x T) -> u64 => x.hash()  // universal, mono dispatch

Порядок параметров — слева направо. Имя в bound’е должно быть объявлено раньше:

fn func[K, T From[K]](v K) -> T => T.from(v)   // ok: K объявлен первым
fn func[T From[K], K](v K) -> T                 // ОШИБКА: K используется до объявления

Множественные bounds — через анонимный protocol:

fn min[T protocol { @lt(other Self) -> bool, @eq(other Self) -> bool }](xs []T) -> T

Если паттерн повторяется — выносится в именованный protocol (type Ord protocol { ... }).

Подробно — D72.

Конверсии: as и From/Into

Два способа конверсии под разные сценарии:

// 1. as — compile-time, тривиальные cast'ы (D54)
let n = 100 as u32                          // numeric
let u = 42 as UserId                         // newtype ↔ underlying
let code = NotFound as int                   // sum → int

// 2. From / Into — нетривиальная конверсия с runtime-логикой (D73)
type Celsius f64
type Fahrenheit f64

// Программист пишет ОДНУ сторону пары — компилятор синтезирует парную.
fn Fahrenheit.from(c Celsius) -> Self =>
    Self((c as f64) * 9.0 / 5.0 + 32.0)

// Две формы вызова из одной реализации:
let f1 = Fahrenheit.from(Celsius(100.0))    // static, explicit-type
let f2 = Celsius(100.0).into()              // instance, тип из контекста (требует let-аннотации
                                              // или return-position)
let f3 Fahrenheit = Celsius(100.0).into()   // тип цели — Fahrenheit (из аннотации)

// Конверсия в строку — частный случай:
let s = str.from(42)                         // "42"
let s2 str = (42).into()                    // "42"
let msg = "id=${user_id}"                    // sugar над str.from(user_id)

Когда какая форма:

  • T.from(v) — целевой тип в начале, читается «build a Fahrenheit from this Celsius». Хорош в выражениях.
  • v.into() — короче в method-chains: c.into().log(). Тип цели — из контекста (let x T = ..., параметр функции, return-type).

Граница as vs From:

  • as — bit/tag-уровень, без runtime-кода: 100 as u32, id as u64.
  • From — арифметика, парсинг, валидация: Fahrenheit.from(c), User.from(json).

Граница D73 vs D55: D55 — automatic coercion для record/sum-литералов в позиции с известным типом (let u User = { id: 1, name: "x" }). D73 — explicit method call для произвольных типов.

Подробно: D54, D73.

spawn / supervised / parallel for / detach

См. D14, D50, D71.

spawn expr

spawn — keyword-конструкция (не функция). По спеке D50 — разрешён только внутри structured-scope (supervised, в т.ч. supervised(cancel:), parallel for, select; и stdlib race/with_timeout внутри своих тел); вне scope — compile error. В bootstrap-реализации spawn вне scope временно разрешён в eager-blocking семантике (D71 legacy).

Внутри scope spawn кладёт fiber в очередь и возвращает unit; результат работы — через захваченные mut-переменные или каналы. spawn() { body } с пустыми скобками запрещён (нет смысла; spawn — не функция).

supervised {
    spawn fetch_users()           // spawn + вызов функции
    spawn { compute(x) }          // spawn + inline-блок
}

Тип результата

spawn body возвращает unit, всегда (D50/D71, resolution 2026-05-06). Результат body не доступен caller’у. Чтобы получить значение от concurrent-выполнения:

// (1) прямой вызов — async прозрачный, suspension сама
let users = fetch_users()

// (2) гомогенный fan-out — массив результатов
let responses = parallel for url in urls { fetch(url) }

// (3) гетерогенная параллельность — mut-захваты
let mut a = 0; let mut b = 0
supervised {
    spawn { a = compute_a() }
    spawn { b = compute_b() }
}

Bootstrap-исключение: let r = spawn { ... } вне supervised временно работает (legacy eager-blocking). Удалится вместе с ужесточением «spawn вне scope = compile error».

supervised { body }

Structured-concurrency scope. Все spawn внутри ждут scope-exit перед запуском; scheduler крутит resume по очереди (round-robin) пока все не завершатся. См. D71 для bootstrap-семантики.

Возвращает unit. Trailing expression body отбрасывается. Результаты концurrent-выполнения — через mut-захваты или (для гомогенных) parallel for.

supervised {
    spawn handle_requests()
    spawn periodic_cleanup()
}                                  // ← ждёт пока обе fiber'ы не завершатся

Time.sleep(0) внутри supervised body (на main-уровне) даёт main-flow yield к queued fibers’ам — один full pass scheduler’а очереди.

parallel for x in iter { body }

Fan-out parallel map: для каждого элемента iter запускается fiber с body, результаты собираются в массив в порядке итерации. Тип возврата — []T, где T — тип body. Десугарится в supervised { for x in iter { spawn { body } } }. Loop-переменная захватывается по value (snapshot на момент spawn’а).

// Семантически: параллельный map.
let responses []Response = parallel for url in urls { fetch(url) }

// Или с inferred return type:
fn fetch_all(urls []str) Net Fail -> []Response =>
    parallel for url in urls {
        fetch(url)
    }

Не путать с обычным for! for x in iter { body } — это statement (тип unit), тело для side-effects:

for url in urls {
    Log.info(url)         // только side effect, ничего не возвращается
}

Для sequential map (собрать массив результатов последовательно) — использовать .map(), не for:

let names []str = users.map(|u| u.name)
let names []str = users.map() fn(u) => u.name      // trailing-fn

Сводка:

ФормаТипСемантика
for x in iter { body }unitstatement, side-effects
iter.map(|x| body)[]Tsequential map
parallel for x in iter { body } (body has trailing)[]Tparallel map (fan-out)
parallel for x in iter { body } (no trailing)unitparallel side-effect loop

Bootstrap-реализация (2026-05-06): array-mode работает для T ∈ {int, bool, f64, str} и итераторов a..b, a..=b, array literal. Без trailing — старая семантика (statement, unit). См. D71 в decisions/06-concurrency.md.

detach { body }

Fire-and-forget: тело живёт после возврата вызывающей функции, привязано к глобальному supervisor’у. Требует эффекта Detach в сигнатуре (D50). В bootstrap- default’е — SyncDetach исполняет тело inline.

fn handle_request(req Request) Net Db Detach -> Response {
    let resp = process(req)
    detach { write_audit(req, resp) }
    resp
}

supervised(cancel: tok) { body }

Structured cancellation с внешним токеном. Обычный supervised-scope с именованным аргументом cancel: (D102). tokcaller-owned значение типа CancelToken: создаётся вызывающим кодом, переживает scope, может быть захвачен/передан. tok.cancel() извне валит все fiber’ы scope’а — на следующем yield-point они бросят "scope cancelled".

let tok = CancelToken.new()
supervised(cancel: tok) {
    spawn { do_thing() }
    spawn { do_other() }
}

// внешний kill-switch:
let tok = CancelToken.new()
spawn { Time.sleep(5_000); tok.cancel() }
fetch_with_kill(urls, tok)

Token capabilities: tok.cancel(), tok.is_cancelled(), tok.bind(other) для каскадной отмены. Один токен — один живой scope (bind-check). Подробно — D75. Keyword cancel_scope удалён (ревизия D75, 2026-05-14).

Channel[T] и select

Coordination между fiber’ами через message-passing. Channel[T] — typed bounded channel с blocking-семантикой. Единственный safe способ разделять данные между fiber’ами в production-runtime (альтернатива — shared mut — UB при preemption).

let ch = Channel.new(10)            // capacity = 10 (0 = unbuffered)
ch.send(value)                       // блокирует если буфер полон
let v = ch.recv()                    // Option[T]; None = closed + drained
ch.close()                            // idempotent

// drain pattern:
while let Some(msg) = ch.recv() {
    process(msg)
}

select { ... } — мультиплексирование recv-операций с опциональным timeout case:

select {
    msg <- ch_a       => process_a(msg)
    msg <- ch_b       => process_b(msg)
    timeout(5.seconds()) => default_action()
}

Если несколько каналов готовы одновременно — выбор non-deterministic. <- — recv-оператор только в pattern-position select-арма, не general.

Полная семантика (closed-channel, owner-actor pattern, отказ от Mutex/Atomic) — D79.

Bootstrap-status: Channel base реализован (раунд 5); select parser отложен до spawn-block fix.

Time.sleep(ms)

Yield-point. По D62 — обычная функция, callable откуда угодно (Async ambient). Семантика: блокирует текущий fiber на не менее чем ms миллисекунд.

Реализация (Plan 22 Ф.4): под капотом — libuv uv_timer_t. Fiber паркуется через park/wake API (D93) до срабатывания timer-callback’а. Scheduler в это время резюмит других fiber’ов либо идёт в uv_run UV_RUN_ONCE (kernel-wait, CPU idle).

КонтекстРеализация
Внутри fiber-body (spawn) внутри supervisedpark-on-uv_timer_t (D93) — CPU idle, реальное время
Вне fiber, внутри supervised bodydrain queue пока deadline не пройдёт (Plan 22 Ф.5 → libuv-driven main)
Полностью вне scopenative OS sleep (Plan 22 Ф.5 → implicit main-scope, libuv)

Cancel (D75) прерывает sleep немедленно через generic stop_cb mechanism (D93): cancel-token закрывает таймер и wake’ает parked fiber, который throw’ает "scope cancelled". Не нужно ждать срабатывания timer’а.

Time.sleep(0) — fast yield (один scheduler-pass, ~µs).

Тестирование без моков

test "name" { body } — тест-блок верхнего уровня. Имя — строковый литерал (любые символы, обычно человеческое описание поведения). Тело — обычный блок выражений; assert(cond) — функция из prelude (D26), обязательно со скобками как любой fn-call.

test "withdraw decreases balance" {
    with Db = in_memory_db([acc1, acc2]) {
        let acc = Account.new("alice")
        acc.deposit(100)?
        acc.withdraw(30)?
        assert(acc.balance == 70)
    }
}

test "insert and get" {
    let mut m = HashMap[str, int].new()
    m.insert("a", 1)
    assert(m.get("a") == Some(1))
    assert(m.get("b") == None)
}

Тесты собираются и запускаются только под nova test. В обычной сборке тело пропускается — никаких #[cfg(test)]-обвязок. Эффекты подменяются теми же with-блоками что и в проде, никакого mock-фреймворка.

Panic — не эффект, ловится только runtime’ом

Деление на ноль, выход за границы массива, переполнение — это не эффект, это Panic. Программист не ловит panic в коде — panic означает смерть текущего fiber’а, runtime обрабатывает на границе:

fn mean(xs []int) -> int =>
    xs.sum() / xs.len()                  // никакого Fail[DivByZero]

fn handle(r Request) Db Log -> Response =>
    process(r)             // если panic — fiber умирает, runtime вернёт 500

panic — это смерть fiber’а, не процесса. В сервере падает только текущий запрос, остальное работает. Если нужно гарантированно гасить процесс — отдельная функция exit(code int, msg str) -> never (D13).

Подробно — revolutionary.md R11, D13.