Syntax — синтаксис, литералы, операторы, методы
Решения этой группы фиксируют поверхностный синтаксис Nova: формы объявлений, стрелки, литералы, методы, операторы. Семантика типов и эффектов — в 02-types.md и 04-effects.md; здесь — только запись.
| # | Решение |
|---|---|
| D16 | Дженерики через [T], не <T> |
| D19 | Match-arms через =>, не -> |
| D20 | () вместо void + сводка стрелок |
| D22 | Closure: light |...| и full fn(...) |
| D23 | return — только для раннего выхода |
| D27 | Синтаксис массивов: []T префикс, [N]T фиксированные |
| D30 | Стиль именования |
| D33 | const vs let — compile-time vs runtime |
| D34 | if let и while let для pattern matching в условии |
| D35 | Методы инстанса через @, self отменён |
| D37 | Доступ к полям: .name для record, .N для позиционных |
| D38 | Создание массивов и turbofish для дженериков |
| D40 | Тело функции: => для одного выражения, {} для блока |
| D43 | Trailing: { block } без params, fn(p) body с params |
| D44 | Числовые литералы |
| D45 | Inferred return type для expression-body |
| D46 | Перегрузка операторов через @-методы |
| D48 | Tagged template literals |
| D49 | Statement separator и парсинг выражений |
| D54 | Операторы as (compile-time cast) и is (runtime type-check для any) |
| D58 | Range-литерал a..b, Iter[T] protocol, for x in c implicit iter |
| D59 | Array, tuple и позиционные partial patterns ([], [r], [_, ..], Cons(..)) |
| D60 | Spread ...x в литералах: массив [1, ...arr, 2] и record { ...obj, field: v } |
| D69 | Variadic-параметры через ...items []T |
| D83 | Keywords строго запрещены как identifier’ы (закрывает Q-keywords-as-fields) |
| D88 | Default-значения generic-параметров: [T = int], [T Bound = Default] |
| D90 | defer и errdefer — scope-level cleanup statement |
| D102 | Именованные аргументы f(name: val) и значения параметров по умолчанию fn f(x int = 0); параметр с дефолтом — keyword-only |
| D108 | Map-литерал [k: v] — конструирование HashMap[K, V] (D104-D107 зарезервированы Plan 45) |
| D126 | external type X[Generics] — opaque типы с runtime backing, без body (D109-D125 заняты другими планами) |
| D238 | Index[K, V] protocol: @index(key K) -> V — magic для a[key]; Range overload = slice mechanism |
| D239 | []T как сахар над Vec[T] — type alias, literal desugaring, migration |
| D240 | MutIndex[K, V] protocol: mut @index(key K, val V) — magic для a[key] = val |
| D241 | Канонический порядок type-модификаторов (scope-adjacency): value priv канон, order-independence запрещён |
| D262 | Slice-op surface (split_at/first_n/last_n/as_slice/chunks/windows) на []T-view модели — БЕЗ нового Slice-типа |
D16. Дженерики через [T], не <T>
Что
Параметры типа записываются в квадратных скобках, не угловых.
Правило
fn sort[T](xs []T, less fn(T, T) -> bool) -> []T
type Option[T] | Some(T) | None
type HashMap[K, V] { ... }
ro parsed = parse[int]("42")?
[T] — это generic-применение к именованному типу или функции
(Имя[T]). Само по себе [T] массивом не является — для массивов
есть []T (D27).
Грамматика однозначна:
Имя[T]после идентификатора — generic-применение.[]T,[N]Tбез имени слева — конструкция массива.arr[i]в позиции выражения — индексация.
Почему
- Парсер однозначен — после имени
[всегда генерик;<T>создаёт известную ambiguity (sort<int>(xs)— генерик или сравнение?). - Турбофиш не нужен —
parse[int]("42")работает напрямую (D38). - Скорость компиляции — нет backtracking, важно для AI-first, где LLM прогоняет компилятор много раз.
- Прецедент — Go и Scala 3 пришли к тому же по тем же причинам.
Что отвергнуто
<T>(Rust/TS/Java/C#) — парсер-ambiguity, требует turbofish::<>или backtracking;>>парсится как сдвиг.- Контекстный парсинг с backtracking — медленнее, ошибки непонятнее.
Связь
- D27 —
[]Tкак тип массива, разделение с[T]. - D38 —
явная передача параметров через
Имя[T], без::. - 02-types.md — generic-параметры в декларации типов.
Эволюция
В ранних черновиках [T] означал и «массив», и «генерик». D27
расщепил: []T для массива, [T] только в позиции generic-применения.
D19. Match-arms через =>, не ->
Что
В match разделитель «образец → результат» — =>, не ->. Match-arm
имеет две формы тела: pattern => expr (одно выражение) или
pattern => { block } (блок). Match-arm — исключение из общего
правила D40
«=> и {} не сочетаются».
Правило
-> — для типов и сигнатур:
fn f(x int) -> int // тип возврата
type Handler alias fn(Request) -> Response // функциональный тип через alias
=> — для тела и разветвлений:
match shape {
Circle { r } => 3.14 * r * r
Square { s } => s * s
}
ro inc = |x| x + 1
fn double(x int) -> int => x * 2
Match-arm с блоком — через => и {} (Rust-стиль):
match entry {
Empty => insert_new(idx, key, value) // одно выражение
Occupied { value: old } => { // блок через => { ... }
@entries[idx] = Occupied { key, value }
return Some(old)
}
Tombstone => {
@tombstones -= 1
@entries[idx] = Occupied { key, value }
return None
}
}
Грамматика:
match-expr = 'match' expr '{' { match-arm } '}'
match-arm = pattern [ guard ] '=>' arm-body
arm-body = expression | block
guard = 'if' expr
block = '{' { statement } [ expression ] '}'
«Параметры → тело» и «образец → результат» — одна семантика «дай мне
это, я отдам тебе то», везде один символ =>.
Почему
- Разделение ролей.
->декларативно (тип),=>вычислительно (выражение). Глаз видит границу. - Прецедент. C#, F#, Scala 3, Rust унифицируют
=>для лямбд и match-arms. - AI-first. Один символ — одна роль, меньше путаницы у LLM.
=>всегда в match-arm. Без=>parser не отличал бы блок-arm от guarded-armpattern if cond => exprили от вложенного блока внутри сложного pattern’а.=>остаётся гарантированным маркером «начало результата».
Что отвергнуто
->для match-arms (Rust до 1.0, OCaml/Haskell) — перегрузка с типом возврата.:(Python) — конфликт с record-литералами.then— лишнее ключевое слово ради того же эффекта.- Блок-arm без
=>(pattern { block }). Без=>теряется единый маркер «начало результата»; парсер хуже различает arm с блоком от arm с guarded-pattern и от нестед-блока в сложном pattern’е.
Связь
- D20 — сводная таблица стрелок.
- D22 — closure-light
|x|без=>, closure-fullfn(...)подчиняется D40 как named fn. - D40 — общий
закон «
=>и{}не сочетаются» и match-arm как единственное исключение.
Эволюция
Старые примеры match ... -> result обновлены на =>.
D20. () вместо void, сводка стрелок, function type syntax
Что
Тип «без значения» — () (unit), не void. Плюс сводная таблица
стрелок (каждая роль закреплена за одним символом) и обязательный
fn-keyword для function type везде.
Правило
fn cleanup() Io -> () // явно
fn cleanup() Io // -> () можно опустить
ro xs [()] = [(), (), ()] // unit как элемент массива
ro r Result[(), str] = Ok(()) // unit как generic-параметр
Сводка символов:
| Символ | Роль |
|---|---|
-> | тип возврата, функциональный тип |
=> | тело функции (именованной или анонимной), match-arm |
= | присваивание (let x = 5) |
Один символ — одна роль.
Function type — всегда с fn префиксом
Function type записывается только через fn(args) Effects? -> Ret.
Бесколонная форма (args) -> Ret запрещена во всех контекстах.
// ✓ — function type везде с fn
fn sort[T](xs []T, less fn(T, T) -> bool) -> []T
type Handler alias fn(Request) -> Response
ro callback fn() -> int = ...
type Server { handler fn(Request) -> Response }
fn measure[T](action fn() Io -> T) Time -> (T, Duration)
// ✗ — без fn запрещено
ro f () -> int = ... // ✗
type Handler alias (Request) -> Response // ✗
fn sort[T](xs []T, less (T, T) -> bool) // ✗
type Server { handler (Request) -> Response } // ✗
Где конкретно fn нужен:
| Контекст | Синтаксис |
|---|---|
| Type alias | type H alias fn(Args) -> Ret |
| Параметр функции | fn f(g fn(Args) -> Ret) -> ... |
| Let-annotation | let f fn(Args) -> Ret = ... |
| Поле record | type X { cb fn(Args) -> Ret } |
| Generic-bound | [T fn(Args) -> Ret] (если применимо) |
| Возврат функции | fn make() -> fn(int) -> int |
Почему fn обязателен
-
Парсер однозначен. Без
fnпарсер видит(int) -> boolи должен делать lookahead чтобы различить:- Group expression (parens around expression) в выражении.
- Tuple type
(int)в позиции типа (хотя одно-element tuple обычно не пишется в Nova). - Function type начало.
fnставит явный признак «дальше function type» — парсер не ошибается. -
AI-friendly. LLM, генерирующая код, не путает функциональный тип с tuple/grouping. Один синтаксис для function type, один путь.
-
Согласованность с named-fn.
fn name(args) -> Ret => body— именованная функция начинается сfn. Function typefn(args) -> Ret— то же начало. Это одна и та же концепция «function thing» —fnэто её префикс. -
D9 «один путь». Не два варианта (alias-form vs other-form). Везде одинаково.
-
Прецеденты. Rust (
fn(i32) -> bool), Go (func(int) bool) — оба требуют function-type keyword. TypeScript/Kotlin/Swift не требуют, потому что у них grammar не имеет(x)group-expr ambiguity (разные приоритеты parsing). Nova с её парсером ближе к Rust/Go.
Не путать с closure
Function type (тип) — fn(int) -> bool.
Closure value (выражение) — |x| x > 0 (light) или fn(x int) -> bool => x > 0 (full).
// Тип: fn(int) -> bool
ro pred fn(int) -> bool = |x| x > 0
// ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^
// type annotation closure-light value
// closure-full — анонимная fn (см. D22):
ro pred fn(int) -> bool = fn(x int) -> bool => x > 0 // closure-full
ro pred fn(int) -> bool = fn(x int) -> bool { x > 0 } // closure-full block
fn встречается в трёх ролях, различимых по контексту:
- Декларация —
fn name(...) ...(top-level statement-position). - Тип —
fn(int) -> bool(в type-annotation position). - Closure-full —
fn(x int) -> bool => body(в expression-position).
См. D22 для closure-light vs full.
Почему
()— обычный тип. Может быть generic-параметром, элементом массива, полем.voidв C/Java — особый случай с дырами.- Двухсимвольное разделение яснее «всё через
->» (Rust) или «всё через=>»: глаз видит границу «тип / выражение». - Прецедент. Rust/Haskell/OCaml/Swift/Kotlin —
()/Unitкак нормальный тип. Дыраvoid— известная боль во всех языках, где её оставили.
Что отвергнуто
void— не может быть generic-параметром (Result[void, E]), требует обходных путей.- Везде один символ (
->или=>) — перегрузка, теряется визуальная граница. - Третий символ (
~>,:>) — экзотика без выигрыша.
Связь
Эволюция
Ранее = отделял тело именованной функции (fn f() = expr). D22
перенёс эту роль на =>, чтобы убрать дублирующий синтаксис. =
теперь — только присваивание.
D22. Closure: light |...| и full fn(...)
Что
В Nova две взаимодополняющие формы closure:
- closure-light —
|params| body— компактная untyped форма. Без типов параметров, без-> T, без эффектов. Тело — bare expression ИЛИ block. - closure-full —
fn(params T) Effects -> Type body— типизированная форма, идентичная named fn без имени. Тело —=> exprили{ block }, как у named fn (D40).
Эти формы не пересекаются: как только нужен хоть один тип
параметра, return-type или эффект — переключаемся на fn(...).
|...| — только untyped.
Тело именованной функции остаётся как было: => expr или { block }
(D40). = — только для let.
Правило
closure-light
ro inc = |x| x + 1 // expr-body
ro zero = || 0 // no params
ro block = |x| { ro y = x*2; y + 1 } // block-body
ro any = |_| 0 // wildcard
list.filter(|x| x > 0) // closure-arg
list.fold(0, |acc, x| acc + x) // multiple params
list.map(|_| 42) // ignore element
spawn(|| compute()) // no-arg closure-arg
Грамматика:
closure-light = '|' params? '|' (expression | block)
params = identifier { ',' identifier }
identifier = name | '_'
В closure-light запрещено:
|x int| x + 1 // ❌ типы параметров — переключайся на fn(x int)
|x| -> int { ... } // ❌ return-type — переключайся на fn(x) -> int
|x| Db -> R { ... } // ❌ эффекты — переключайся на fn(x) Db -> R
|x| => x + 1 // ❌ нет `=>` в closure-light, body начинается сразу
closure-full
ro typed = fn(x int) -> int => x * 2
ro block = fn(x int, y int) -> int { ro z = x+y; z * 2 }
ro with_eff = fn(req Request) Db Log -> Response { process(req) }
ro void = fn(s str) Log { Log.info(s) }
Грамматика идентична named fn без имени:
closure-full = 'fn' '(' params ')' [ effects ] [ '->' type ] body
body = '=>' expression | block
params = param { ',' param }
param = identifier type // тип обязателен
Inference и context-sensitivity
closure-light валиден только когда контекст однозначно задаёт сигнатуру. Источники контекста:
- Параметр fn-call’а:
list.filter(|x| x > 0)— sig изfilter’а. - Annotated let:
let f fn(int) -> int = |x| x + 1. - Return-position:
fn make() -> fn(int) -> int => |x| x + 1. - Tuple-position при typed return:
(|x| ...)если parent объявил-> (fn(int) -> int, ...). - First-use inference (Rust-семантика):
ro f = |x| x + 1 f(5) // first use фиксирует x: int → sig: fn(int) -> int f(3.14) // ❌ ошибка: sig уже зафиксирован
Если контекст недостаточен (closure-light нигде не используется):
ro f = |x| x + 1 // ❌ cannot infer signature
→ либо использовать f далее, либо переключиться на closure-full:
ro f = fn(x int) -> int => x + 1
Эффекты
closure-light никогда не пишет эффекты в сигнатуре. Эффекты, реально используемые в теле closure-light, должны:
- быть подмножеством contextual-sig’а, И
- покрываться ambient effect-set в точке создания closure’а
(= эффекты enclosing-функции ∪ активные
with-блоки).
fn process(users []User) Db -> []Result =>
users.map(|u| Db.find(u.id)) // Db: ✅ есть в parent
fn pure(xs []int) -> int =>
xs.fold(0, |acc, x| acc + x) // эффектов нет — ✅
fn no_db(users []User) -> []Result => // Db в parent НЕТ
users.map(|u| Db.find(u.id)) // ❌ Db не доступен
closure-full эффекты пишет явно — она «полная» по сигнатуре:
fn make_handler() -> fn(Request) Db -> Response =>
fn(req) Db -> Response { process(req) }
Эффекты на named fn остаются обязательными — D62/R1 «эффекты всегда видны в сигнатуре» не ослабляется. Inference применим только к closure-light, потому что closure-light не пересекает границу модуля.
Captures
Closure захватывает свободные переменные по ссылке через scope.
Никаких move / &mut / lifetime — это не нужно благодаря
managed-heap (D32, D62).
- Примитивы (
int,bool,f64, …) — copy-by-value. - Объекты (record, sum-type, array) — managed-reference, shared с enclosing scope.
let mutпеременные — closure модифицирует тот же slot; изменения видны снаружи и между вызовами closure’а.- Escape — если closure уезжает за пределы создавшей fn, захваченные переменные автоматически живут в managed-heap.
fn make_counter() -> fn() -> int {
mut count = 0
|| { count = count + 1; count }
}
ro f = make_counter()
ro g = make_counter()
f() // 1 ← каждый вызов make_counter создаёт свежий scope
f() // 2
g() // 1 ← у g свой count, не shared с f
Несколько closure’ов, созданных в одном scope, разделяют capture:
fn make_counter() -> (fn() -> int, fn(int) -> int, fn() -> int) {
mut count = 0
(
|| { count = count + 1; count },
|a| { count = count + a; count },
|| count,
)
}
ro (f1, f2, f3) = make_counter()
f1() // 1 ← все три closure'а share один count
f1() // 2
f2(5) // 7
f3() // 7
Free-variable resolution
Свободные переменные резолвятся через lexical scoping на момент создания closure’а. Параметр одного closure’а не виден в теле другого:
mut count = 0
(|a| count += a, || a) // ❌ `a` undefined в `|| a`
// ^
// parameter of previous closure, not in scope here
Body-type matching
Тип тела closure (выводимый или явный) должен совпадать с ожидаемым return-type из contextual sig:
fn make() -> (fn() -> int, fn(int) -> int) =>
(|| 0, |a| count += a)
// ^^^^^^^^^^^^^ ❌ `count += a` returns `()`, sig expects `int`
// fix: |a| { count += a; count }
return в closure-light
return в |x| { ... } выходит из самого closure, не из
enclosing fn. Это согласовано с D43 (return в trailing-block выходит
из блока):
ro find = |xs []int| {
for x in xs {
if x > 100 { return Some(x) } // выход ИЗ closure
}
None
}
Wildcard _ в параметрах
_ валиден как имя параметра в closure-light, closure-full и named fn —
«параметр обязателен по арности, не используется в теле»
(расширение D59):
list.map(|_| 42)
fn handle(req Request, _meta Meta) Db -> Response { ... }
fn(_x int, y int) -> int => y * 2
Почему
- Освобождение
=>. В Nova=>— маркер тела (named fn, handler-method) и match-arm. Использование=>в лямбдах создавало перегрузку и запрещало блок-форму. Closure-light с|...|убирает перегрузку:=>остаётся только для тела/arm. - Two-level: light vs full. Untyped one-liner’ы (
filter,map,fold) получают компактный синтаксис. Typed/effect-aware closures пишутся полной формойfn(...), идентичной named fn — нет специальной грамматики anonymous-typed. - Парсер коммитится за один токен.
|...|в expression-position решается мгновенно (binary|без LHS невозможен). Старый(params) =>требовал unbounded look-ahead. - Trailing и closure ортогональны. closure-light только в
expression-position. Trailing — через
fn(...)или zero-param{}(D43). Парсер не путает. - Anonymous fn возвращается. D22-old запрещала
fn(...)без имени; новая D22 разрешает её как closure-full. - Блок-форма для closure-light.
|x| { stmts; expr }теперь разрешено — старая D22 явно запрещала=> { block }, что заставляло выносить любую closure сletв named fn. - Captures без
move/lifetime. Managed-heap (D32) делает escape автоматическим.
Что отвергнуто
(x) => expr(D22-old) — перегружает=>, требует unbounded look-ahead, не имеет блок-формы.x => exprбез скобок (JS-style) — не решает look-ahead для multi-param случая, оставляет=>перегруженным.fn(...)без типов (overlap с|...|) — две взаимозаменяемых формы создают выбор без правила. Граница «типы есть →fn, нет →|...|» чёткая.- Effect inference на named fn — отказ от R1 «эффекты всегда видны в сигнатуре». Inference допустим только для closure-light.
move-keyword / lifetime-маркеры — managed-heap автоматизирует escape.- Implicit
it— нелокальный reasoning, плохо для AI. - Trailing closure через
|x|—func(args) |x| bodyсоздавал ambiguity с binary|. Trailing с params — только черезfn(...), см. D43. => { block }для closure-light — closure-light не использует=>вообще. Тело всегда либо bare expression, либо block.
Связь
- D19, D20
—
=>остаётся в match-arm как маркер «начало результата». - D40 — правило
«
=>и{}не сочетаются» применяется к named fn, closure-full, handler-method. closure-light имеет отдельную грамматику. - D43 — trailing с params
через
fn(...), без params —{ block }.|...|в trailing-position запрещён. - 04-effects.md → D31 — handler-method, как fn, имеет две формы тела.
- D62 — closure-light наследует ambient effect-set.
- 02-types.md → D32 — captures через managed-heap.
Эволюция
Пересмотр D20: = исключён из «тел функций», его роль принял =>.
Ревизия (2026-05-1): «лямбда строго (params) => expr, без блок-формы».
Ревизия (2026-05-10): полная замена (params) => на two-level
closure: |x| (light, untyped) + fn(...) (full, typed). Триггер —
семантический перегруз =>, look-ahead в парсере, запрет блок-формы
лямбды, унификация с trailing-block. Anonymous-fn запрет (D22-old)
снимается — fn(...) без имени = closure-full. Block-форма closure
возвращается. Migration: ~30 примеров в spec/, патч parser/interp,
план — docs/plans/19-closure-and-error-ops.md.
D23. return — только для раннего выхода
Что
return есть, но используется исключительно для guard-clauses /
ранних выходов. Последнее выражение тела — автоматически результат.
return — это statement, поэтому он встречается только в блок-форме
тела (fn name(...) { ... }). В =>-теле (где должно быть ровно одно
выражение, D40)
guard-clauses через return не пишутся: либо вся функция выражается
одним match/if (тогда =>-тело подходит), либо нужны guard’ы — и
тогда блок-форма.
Правило
Разрешено:
// блок-форма с guard'ами
fn classify(x int) -> str {
if x < 0 { return "negative" }
if x == 0 { return "zero" }
"big" // последнее выражение = результат
}
fn process(req Request) Db Fail -> Response {
if req.method == "GET" { return next(req) }
do_work(req)
}
// =>-тело: одно выражение, return не нужен
fn classify(x int) -> str => match x {
n if n < 0 => "negative"
0 => "zero"
_ => "big"
}
Запрещено линтом (избыточно):
fn double(x int) -> int => return x * 2 // лишний return; и =>-тело
// вообще не допускает statement'ов
fn classify(x int) -> str {
if x < 0 { return "n" } else { return "p" } // обе ветки return
}
Если все ветви заканчиваются return — переписать через match/if
как выражение и использовать =>-тело.
Запрещено грамматически:
// =>-тело допускает ровно одно выражение, а не цепочку statement'ов
fn classify(x int) -> str =>
if x < 0 { return "negative" } // ← statement, не expression
if x == 0 { return "zero" }
"big"
Семантика:
returnв closure-light (|x| body) — выходит из самого closure, не из enclosing fn (D22). Аналогичноreturnв trailing-block.returnв closure-full (fn(...) body) — выходит из closure (точно как named fn).returnв match-arm — match-arm тоже строгоpattern => expr(D40), поэтомуreturnв arm тоже отсутствует. Если в arm нужен ранний выход — match вынесен в блок-форму fn, иreturnстоит после match’а.returnвwith-блоке (block-body) — выходит из enclosing-функции.returnв trailing-block (D43) — выходит из самого блока (это блок, не лямбда), не из enclosing fn.
Почему
- Guard-clauses естественно пишутся в блок-форме — middleware, валидация, ранние выходы.
- AI-first. LLM рефлекторно генерит
return— полный запрет требовал бы переучивания. - Один стиль на функцию. Линт против избыточного
returnв последней позиции. - Прецедент. Rust идиоматически использует
returnтолько для ранних выходов. =>строго одно выражение. Раньше D23 разрешал чередование guard-if {return}+ финальное выражение в=>-теле. Это нарушает «=>= одно выражение» (D40); убрано — guard’ы только в блок-форме.
Что отвергнуто
- Полное отсутствие
return(OCaml/Haskell) — заставляет вкладыватьif/elseглубже. break/done— нестандартно, без выгоды.returnобязателен (Go/Java) — противоречит «функция = выражение».- Guard-цепочки в
=>-теле (как было в старой D23). Конфликтовало с D40 —=>-тело это одно выражение, statement-цепочки требуют блок-формы.
Связь
- D22 —
returnв closure-light и closure-full выходит из самого closure, не из enclosing fn. - D19 — match-arm строго
pattern => exprилиpattern => { block };returnв arm выходит из enclosing fn (т.к. arm не функция). - D40 —
=>и{}не сочетаются; guard-цепочки требуют блок-формы.
Эволюция
Ревизия (2026-05): убраны примеры guard-clauses в =>-теле fn.
Раньше D23 допускал fn classify(x) -> str => if x<0 {return "n"} ... "big"
— цепочка statement’ов после =>. Это противоречило D40 («=> =
ровно одно выражение»). Теперь правило единое: guard’ы только в
блок-форме fn name(...) { ... }.
D27. Синтаксис массивов: []T префикс, [N]T фиксированные
Amended (Plan 138 D239, 2026-06-10):
[]Tтеперь синтаксический псевдонимVec[T](D239). Синтаксис записи не меняется, семантика — меняется:[]T ≡ Vec[T]на уровне типов.
arr[i]для типов реализующихIndex[K, V](D238) — десугаринг вarr.index(i)вместо compiler built-in.[]T/Vec[T]реализуетIndex[int, T]иIndex[Range, Vec[T]](zero-copy slicing).
Что
Массивы записываются префиксом (Go-стиль): []T динамический,
[N]T фиксированный, [N1][N2]T многомерный — порядок размеров
совпадает с порядком индексации.
Правило
ro xs []int = [1, 2, 3] // динамический
ro buf [5]u8 = [0, 0, 0, 0, 0] // фиксированный
ro zeros [4]u8 = [0; 4] // повторение через ;
ro matrix [2][3]int = [[1, 2, 3], [4, 5, 6]]
matrix[i][j] // i: 0..2, j: 0..3 — порядок совпадает
ro opt Option[int] = Some(42) // generic не меняется
Парсер по позиции:
- В позиции типа без имени слева — массив (
[]T,[5]T). - В позиции типа после имени — generic (
Option[T]). - В позиции выражения — индексация (
arr[i]).
Layout: [N]T — N подряд, без указателя. []T — { ptr, len, cap },
24 байта на 64-bit. [N1][N2]T — плоский row-major. [][]T —
jagged (массив указателей на массивы).
Почему
- Соответствие индексации —
[2][3]int↔arr[i][j]. В Rust[[T; 3]; 2]порядок обратный; программисты ошибаются. - Парсер однозначен —
[различается по позиции в грамматике. - Чтение слева направо — «массив 2×3 целых».
- Generic не страдает —
Option[T]остаётся. - Прецедент Go.
Что отвергнуто
- Java
T[]/int[2][3]— парсер сложнее, конфликт сOption[T]. - Rust
[T]/[[T; N]; M]— обратный порядок размеров, конфликт «массив vs generic» одного символа. [T; N]для одномерного —;читается странно в многомерных, нет соответствия индексации.
Связь
- D16 —
[T]теперь только generic-применение. - D38 — static-методы
на типе массива (
[]T.with_capacity(n)). - 02-types.md — sum/record не конфликтуют по грамматике.
Эволюция
Старо: [T] динамический, [T; N] фиксированный — конфликт с
generic. Перешли на Go-style; ~50 мест в документах исправлено.
D30. Стиль именования
Что
Один стиль на весь язык: PascalCase для типов и протоколов, snake_case для функций/полей/локальных, SCREAMING_SNAKE_CASE для констант. Акронимы — PascalCase без исключений.
Правило
| Что | Стиль | Пример |
|---|---|---|
| Типы, варианты sum-type, эффекты, протоколы | PascalCase | User, HashMap, Some, Db, Hash |
| Generic-параметры | PascalCase, односимвольные | T, K, V, E |
| Функции, методы, поля, параметры, локальные | snake_case | parse_url, @deposit, user_id |
| Константы | SCREAMING_SNAKE_CASE | MAX_PAYLOAD, DEFAULT_TIMEOUT |
| Модули | snake_case через точки | module admin.audit |
Акронимы PascalCase, не UPPERCASE:
type Db effect { ... } // не DB (эффект — protocol)
type Io effect { ... } // не IO
type Url str // не URL (newtype над str)
type Http effect { ... } // не HTTP
type JsonValue { ... } // не JSON (record)
type SqlBuilder { ... } // не SQL (record с полями)
Договорные конвенции имён методов:
| Имя | Когда |
|---|---|
T.new(...) | стандартный конструктор (вкл. из компонентов) |
T.of(a, b, c) | вариадик-литерал элементов коллекции (D259; НЕ пустой — пустой это new()) |
T.from(v X) | конструктор-конверсия из X — имя-конвенция, не протокол (D73 ретрактирован 2026-07-06) |
T.from_X(...) | доменный конструктор (from_secs, from_polar, from_imag) — когда from(v) не передаёт смысл |
@is_X() | bool-предикат |
голое существительное (bytes(), chars(), slice()) | O(1) вид/линза, заём (D410); копия — .clone()/.collect() на месте вызова |
@to_X() | трансформация в новое владеющее значение (to_str, to_upper) — вида не существует в принципе (D410) |
consume @into_X() | потребляющая передача владения (into_str, into_raw) — D131; универсального v.into() НЕТ (D73-ретракция) |
@hash(), @clone(), @iter(), @next() | стандартные методы |
Оси семантические (вид / трансформация / потребление) — следуй им; as_-префикс упразднён (D410).
try_* / failable pair convention (D30 §2, Plan 108):
Когда операция может завершиться с ошибкой, определяются две формы:
| Форма | Сигнатура | Семантика |
|---|---|---|
try_op(...) | -> Result[T, E] | возвращает результат без эффектов; вызывающий сам обрабатывает ошибку |
op(...) | Fail[E] -> T | unwrap-обёртка через !!; кидает E через эффект при провале |
Правило реализации: op реализуется как Nova-body через try_op:
// Примитив — только эта функция знает как читать байт:
export external fn ReadBuffer mut @try_read_byte() -> Result[u8, ReadBufferError]
// Обёртка — один лайнер на Nova, без дублирования C-логики:
export fn ReadBuffer mut @read_byte() Fail[ReadBufferError] -> u8 => @try_read_byte()!!
Зачем try_* первичен:
- C-логика живёт в одном месте (
try_*),*= тонкая обёртка - Нет дублирования кода ошибок между парами
- Вызывающий выбирает стиль:
op()(throw-style) илиtry_op()(result-style)
Применяется везде: ReadBuffer, WriteBuffer, I/O, парсинг, преобразования типов.
Полные слова, не сокращения
Имена методов, типов, параметров и полей — полные слова, не сокращения. Приоритет — читаемость, а не количество символов.
fn ReadBuffer @position() -> int // не @pos()
fn copy_into(destination []u8) -> () // не dest
fn parse(input str) -> Result[T, E] // не buf, не val
Запрещены ad-hoc сокращения (mainstream-precedent): pos,
dest, src, buf, val, tmp, cnt, idx (кроме mainstream-исключений
ниже), arr, len (кроме mainstream-исключения), msg (кроме Error.msg
field — закреплено D26), cfg, ctx.
Mainstream-исключения (Rust/Go/Swift convention — слишком устоявшиеся формы, чтобы менять):
| Сокращение | Где разрешено | Прецеденты |
|---|---|---|
len | длина коллекции (s.len(), arr.len(); method-only по D117) | Rust, Go |
cap | capacity-свойство коллекции (arr.cap(), mut arr.cap(n); read/write property-pair, D117 AMEND 2026-07-06) — дублирующий capacity() РЕТРАКТИРОВАН [M-unwrap-twins-retraction] 2026-07-07 | Go |
iter | итератор (coll.iter(), Iterator) | Rust |
idx | index — только в локальных переменных (for idx in ...) | Rust convention |
Ровно четыре исключения, никаких других. Остальные — full word:
length если не коллекция-len, capacity если не свойство-cap,
iterator если не protocol-имя, index если параметр или поле.
Operator-overloading имена (D46)
— @plus, @rem, @neg, @shl, … — фиксированы и не подчиняются
правилу полных слов. Это исторически зацементированная convention из
Rust/C++/Swift; менять @plus → @addition бессмысленно.
Acronyms работают по правилу выше (PascalCase в типах, snake_case
в методах: JsonParser, parse_json). К full-word правилу не относятся.
Зачем строго:
- AI-friendly. LLM не должна угадывать когда
posэтоposition, а когдаposix. Один canonical full word — однозначность. - Code review consistency. Reviewer видит
destи спрашивает «destination or destruct?» — лишний cycle. Full word убирает класс багов. - Прецедент Swift API Guidelines. Swift строго запрещает abbreviations, и это даёт API surface, которую читать как естественный язык.
Leading underscore: «параметр / биндинг намеренно не используется»
Конвенция (Plan 110.7.3.a, 2026-06-01): локальные биндинги и
параметры с префиксом _ явно сигналят compiler’у «эта переменная
объявлена для интерфейсной совместимости, но не нужна телу». Это
подавляет W_UNUSED_PARAM / W_UNUSED_LOCAL warning’и без
необходимости комментариев.
| Применение | Пример | Семантика |
|---|---|---|
| Unused parameter | fn @cleanup(_outcome ScopeOutcome) -> () | param required by protocol, тело его не читает |
| Unused let-binding | ro _ = expensive_compute() | side-effect важен, value irrelevant |
| Unused pattern binding | match v { Some(_x) => 0, None => 1 } | wildcard с именем для diagnostic, не reading |
| Discard tuple element | ro (a, _b) = pair() | первый нужен, второй — нет |
Правило компилятора:
- Имя начинается с
_(включая чистый_) → unused-warning suppressed. - Любое другое имя → warning fires если binding не читается.
_(одиночное подчёркивание) — традиционная «throwaway» форма; допустимо использовать многократно в одном scope (каждое — fresh binding).
Prior art:
- Rust:
let _x = compute()— same convention. - Swift:
_parameter labels — call-site suppression. - Go:
_blank identifier — same purpose, syntax level. - Python:
_var— informal convention, no enforcement.
Запрещено: _ префикс на public exports — это signals «private
to module», и leading-underscore tied к unused-suppression cleanly
разделимо только для local / private bindings.
Типы ошибок: Parse<TypeName>Error, <Operation><Domain>Error
Имена ошибок в публичных API должны включать тип / домен который породил ошибку, а не быть generic-словом:
| Стиль | Пример | Прецедент |
|---|---|---|
Parse<TypeName>Error | ParseIntError, ParseComplexError, ParseUrlError | Rust std, num-complex |
<Domain>Error | DbError, HttpError, RepoError | стандартный backend-стиль |
<Operation>Error | OverflowError, TransferError | для конкретной операции, не типа |
Не использовать generic-имена:
| Плохо | Почему | Лучше |
|---|---|---|
ParseError | коллизии: URL/JSON/datetime/complex/… | ParseUrlError, ParseComplexError, … |
Error (как пользовательский тип) | конфликт с prelude Error (D65) | конкретное имя |
Exception, Failure | пустые слова без домена | по операции / домену |
ValueError, TypeError | заимствование из Python — слишком общо | по операции / домену |
Вариантам внутри sum-типа доменный префикс не нужен — они уже живут в namespace своего типа:
type ParseComplexError enum InvalidFormat | NotANumber
throw InvalidFormat // имя варианта без префикса
throw ParseComplexError.InvalidFormat // полная форма (если ambiguous)
Это согласовано с D65 lookup’ом: throw InvalidFormat находит
активный Fail[ParseComplexError] handler по типу варианта.
Видимость полей record/tuple — через priv keyword (Plan 124, D220);
default = public. Convention _prefix для conventionally-private
полей отменена 2026-06-02 в пользу compile-time priv
(07-modules.md → D47 amend). Для функций/методов
видимость через export / приватно (07-modules.md → D47).
Зарезервированные имена для operator overloading: @plus, @minus,
@times, @div, @rem, @neg, @or, @and, @xor, @shl,
@shr, @eq, @lt, @le, @gt, @ge, @not, @get, @set —
D46.
Test-имена — строки естественного языка: test "insert and get" { ... }.
Длина имени типа: минимум 2 символа (D30 §naming, Plan 167)
Запрещены однобуквенные имена типов (type T, type S, type K, …).
Таблица в D30 закрепляет T, K, V, E, … как конвенцию для
generic-параметров (fn[T] ...). Если объявить type T { ... }, компилятор
не может отличить «named type T» от «typevar T» в generic context —
это источник E_PREFIX_SHADOWS_NAMED_TYPE. Запрет однобуквенных имён
делает пространства имён непересекающимися.
type T { ... } // ✗ E_TYPE_NAME_TOO_SHORT — переименуй в TVal, TNode, ...
type S { ... } // ✗ E_TYPE_NAME_TOO_SHORT
type Db effect { } // ✓ — 2 символа
type Ok { } // ✓ — 2 символа
Error: E_TYPE_NAME_TOO_SHORT (Plan 167, D30 §naming).
Почему
- Одно правило без исключений для акронимов — программисту и LLM не помнить «2 буквы UPPER, 3+ Pascal».
- Composability —
HttpClient,JsonParserчитаются без «плотностей» из заглавных. СравниHTTPClient,JSONParser. - AI-friendly. LLM плохо угадывает «сколько букв в акрониме» — единое правило.
- Прецедент. Swift API Guidelines, современный .NET, Rust.
Что отвергнуто
- Java/C# до 2010-х (UPPERCASE для коротких акронимов) — каша
на стыке (
parseXMLForJSONFromHTTPResponse). - snake_case для всего (Python) — типы и значения визуально не отличаются.
- camelCase для функций (Java/JS) —
to_strчитается лучшеtoStr; границы слов чётче.
Связь
- 07-modules.md → D47 —
export/ приватно; стиль не зависит от видимости. - D33 —
SCREAMING_SNAKE_CASEдляconst. - D46 — зарезервированные имена.
D33. Три оси immutability — ro/mut/consume + const + per-field freeze
Plan 114 rewrite (2026-05-31): эта секция полностью переписана. Старая формулировка («
constvslet— compile-time vs runtime») декларировала три оси, но одна была fake: ось «const= compile-time,let= runtime» не соответствовала реальности после Plan 14 Ф.2 (расширилconstна non-constexpr RHS через lazy-initnova_const_<name>()static getter). Plan 114 Ф.9 narrow’итconstобратно до strict constexpr-only, делая ось «hard compile-time guarantee» правдивой; Ф.10 generalize’итconstна 3 позиции;letretracted. Полный дизайн см. D184.
Что
Nova V2 имеет три ортогональные оси, все три реальные:
| Конструкция | Что фиксирует | Позиции | Решает |
|---|---|---|---|
ro x / mut x / consume x | binding mutability + ownership | module-level (только ro) + scope (все три) | можно ли переприсвоить переменную; кто owns |
const X = … | hard compile-time guarantee (strict constexpr) | module-level + scope-local + record-field (associated const) | известно ли значение при компиляции; compile-error если не |
ro field T / mut field T / field ro T | per-field freeze | внутри type X { … } | можно ли мутировать конкретное поле в record’е |
Правило
// Ось 1: binding mutability + ownership
ro x = 5 // immutable binding
mut counter = 0 // mutable binding
counter = counter + 1 // OK — mut
consume sb = StringBuilder.new() // owned binding (Plan 73.1)
// Ось 2: const — hard compile-time guarantee
const MAX_PAYLOAD = 4096 // ✓ literal
const TIMEOUT_SEC = 60 * 5 // ✓ constexpr arithmetic
const ORIGIN Point = { x: 0.0, y: 0.0 } // ✓ constexpr record-literal
const COMPUTED = make_point(7, 14) // ✗ E_CONST_NOT_CONSTEXPR
// hint: «use `ro` for lazy-init»
// Module-level non-constexpr → ro (заменяет старый let X = …)
ro NOW = Time.now()
ro COMPUTED Point = make_point(7.0, 14.0)
// Ось 3: per-field freeze
type Account {
ro id u64 // never-mut, даже у `mut acc`
balance int // default — mut если binding mut
mut log_count int // always-mut, даже у `ro acc`
}
const требует (strict constexpr-only — Plan 114 Ф.9):
- Литералы любого primitive-типа.
- Арифметика/bitwise/comparison над constexpr операндами.
- Record-литерал из constexpr-полей.
- Sum-type конструктор из constexpr args.
- Ссылка на другой
const. - Вызов
const fnс constexpr args (Plan 114 Ф.11; см. D199).
Не runtime call, не effect, не allocation. Для lazy-init
runtime-value используется ro X = … на module-level (заменяет старый
let X = … host).
Strict module-level partition
На module-level между const и ro — обязательное разделение по
constexpr-eligibility:
| RHS | Keyword |
|---|---|
Constexpr-eligible (literal, arithmetic, record-литерал, const fn call) | const X = … (E_RO_FOR_CONSTEXPR_PREFER_CONST иначе) |
| Non-constexpr (runtime call, effect, allocation) | ro X = … (E_CONST_NOT_CONSTEXPR иначе) |
Compiler определяет «constexpr» точно — user не выбирает между const/ro,
выбирает RHS, keyword следует. Codemod auto-converts в обе стороны.
Scope-level — без strict-правила. Внутри fn body ro x = 5 и const x = 5 оба валидны; разница только в гарантиях (const = строго constexpr
- inlined;
ro= runtime immutable binding).
Статус реализации (обе стороны — implemented). Обратное направление
(E_CONST_NOT_CONSTEXPR на не-constexpr const X) реализовано в Plan 114.4
Ф.1. Прямое направление (E_RO_FOR_CONSTEXPR_PREFER_CONST на constexpr-eligible
module-level ro X) реализовано в Plan 148 Ф.3 (аменд D199 ниже). Оба
направления используют один и тот же предикат constexpr-eligibility
(check_const_constexpr_ex), поэтому не могут разойтись в определении
«constexpr». Прямое правило применяется только к module-level binding с
одним именованным binder’ом (ro NAME = …; UPPER_CASE binder парсится как
single-segment unit-variant pattern и трактуется как имя); destructuring
(ro (a, b) = …) и scope-local ro — не затрагиваются.
Почему
- Compile-time гарантия (восстановлена).
constтеперь делает то что обещает — hard constexpr. После Plan 14 Ф.2 это было размыто; Plan 114 Ф.9 narrow’ит обратно. - Размеры массивов.
[N]T(D27) требуютconst N(теперь N может быть scope-local — Plan 114 Ф.10). - Associated constants.
type T { const X = … }— namespace-bound constexpr (Javastatic final, Rustimpl T { const X }, Kotlincompanion const val). См. D200. - AI-first. LLM, видя
const X = compute(...)→ compile error E_CONST_NOT_CONSTEXPR, получает явный сигнал «используйro».
Что отвергнуто
let/let mut— retracted в Plan 114 (D184). Сейчас replaced тройкой ro/mut/consume.:=(Go) — дублирует binding declaration; источник shadowing-багов.final(Java) — лишнее ключевое слово.- Без разделения — массивы
[N]Tпотребуют литералов всюду; comptime станет несовместимым.
Сравнение с mainstream — см. D184 §«Сравнение с mainstream»
Связь
- D27 —
const Nдля[N]T(Plan 114 Ф.10: any visible scope). - D30 —
SCREAMING_SNAKE_CASEдляconst. - D32 — default immutable (
roось 1). - D36, D175, D176 —
ro/mutfield, ось 3. - D102 — default-param values reference
const. - D184 — master keyword refresh decision (Plan 114).
- D199 —
const fncomptime evaluable. - D200 — associated constants.
- 07-modules.md —
export constэкспортирует.
D33-LEGACY (archived). const vs let — compile-time vs runtime
⚠ Эта секция — historical record для legacy-codebase reference. Plan 114 retracted
letkeyword (D184); design rewritten выше.
Что (archived)
const — для compile-time констант, известных при компиляции.
let — для runtime значений (immutable binding); let mut —
mutable. Это два разных ключевых слова, не сахар.
Правило
// const — compile-time
const MAX_PAYLOAD = 4096
const TIMEOUT_SEC = 60 * 5 // арифметика над литералами
const GREETING = "hello"
// let — runtime
ro now = Time.now()
ro user = Db.find(user_id) ?? throw UserNotFound(user_id)
// let mut
mut counter = 0
counter += 1
const требует:
- Compile-time computable: литералы, арифметика, конструкторы record/sum-type из const-значений.
- Не runtime-вызовы, эффекты, ссылки на не-const.
const fn (compile-time функции) — ✅ реализовано в Plan 114.4.2 (D199):
функция с const-params + -> const T return вычисляется компилятором,
call sites заменяются литералом в AST. См. D199.
const NOW = Time.now() остаётся ошибкой (Time.now() — runtime call,
не const fn).
const живёт в data-segment (zero-cost). let-объекты — в managed
heap (или на стеке через escape analysis).
Почему
- Compile-time гарантия.
const— программист уверен, нет runtime-зависимостей. - Размеры массивов.
[N]T(D27) требуютconst Nдля имени. constявно говорит «в data-segment», не нужно угадывать.- AI-first. LLM, видя
const X = compute(...)→ compile error, получает явный сигнал «используйlet».
Что отвергнуто
:=(Go) — дублируетlet; источник shadowing-багов в Go.final(Java) — лишнее ключевое слово рядом сlet.- Без разделения — массивы
[N]Tпотребуют литералов всюду; comptime станет несовместимым.
Сравнение с readonly / mut field — три оси immutability
Nova имеет три разных keyword’а связанных с immutability — let,
const, readonly/mut field. Они не конкурируют, потому что
работают на разных уровнях программы:
| Конструкция | Что фиксирует | Где живёт | Решает |
|---|---|---|---|
let x / let mut x | binding | в функции / scope | можно ли переприсвоить переменную |
const X = ... | compile-time placement | top-level или scope | известно ли значение при компиляции |
readonly field T | поле record’а never-mut | внутри type X { ... } (D36) | можно ли мутировать поле даже у let mut binding’а |
mut field T | поле record’а always-mut | внутри type X { ... } (D36) | можно ли мутировать поле даже у let binding’а |
let / let mut — про binding
ro x = 5 // binding x не переприсваивается
mut y = 0 // binding y переприсваивается
y = y + 1
Default immutable (D32) — let без префикса всегда
immutable. let mut — явный opt-in в mutable, аналогично Rust
let mut, Swift var, Kotlin var. Программист видит let mut —
знает что переменная меняется.
const — про compile-time
const MAX = 4096 // compile-time, в data-segment
ro limit = compute_limit() // runtime, в heap/stack
Оба immutable. Разница — const накладывает требование
compile-time computability (литералы + арифметика над ними +
const-record’ы). let принимает любое runtime-выражение.
const нужен для:
- Размеров фиксированных массивов:
[N]T(D27) требуетconst N. - Compile-time оптимизаций (свёртка, размещение в data-segment).
- Семантической декларации «это всегда константа», не «immutable до scope-exit».
readonly / mut field — про поле record’а
type Account {
ro id u64 // поле never-mut, даже у `let mut acc`
balance money // поле default — mut если binding mut
mut log_count int // поле always-mut, даже у `let acc`
}
mut acc = Account { id: 1, balance: 100, log_count: 0 }
acc.balance = 200 // OK — поле default + binding mut
acc.id = 999 // ERR — id ro
acc.log_count += 1 // OK — log_count mut
readonly / mut per-field — это freeze/unfreeze конкретного
поля относительно дефолта. Они не пересекаются с let/let mut:
binding управляет «можно ли модифицировать переменную», поле
управляет «можно ли модифицировать конкретное поле в записи».
Пример где они комбинируются:
| binding | field declaration | можно acc.field = ... |
|---|---|---|
let acc | field T (default) | ❌ — binding immutable |
let acc | mut field T | ✅ — поле always-mut |
let acc | readonly field T | ❌ |
let mut acc | field T (default) | ✅ |
let mut acc | mut field T | ✅ |
let mut acc | readonly field T | ❌ — readonly сильнее |
Почему три, а не одно
Альтернативы и почему они хуже:
-
Только
let/let mutбезconst— массивы[N]Tтребовали бы compile-time выводимости изlet N = 5. Компилятор должен проводить escape-analysis на каждыйlet, чтобы понять const-eligible. Программист не видит явно «это compile-time», а получает компилятор-error при первом нарушении. AI-unfriendly. -
Только
let/let mutбезreadonly/mut field— потеря per-field freeze. Альтернатива — newtype wrappers (type AccountId(u64)для каждого immutable поля), что ведёт к verbose-коду и потере ergonomics (acc.id.value()вместоacc.id). Cell/RefCell-style wrappers (как в Rust) ещё хуже для AI-кодинга. -
Только
const/readonly(безlet/let mut) — теряем обычные mutable переменные в функциях. Можно через field record’а (тип-обёрткуCounter { mut value int }), но это противоестественно для локальных счётчиков.
Это три разные оси ответственности, каждая решает свою задачу:
let/let mut— binding mutability (можно ли переприсвоить).const— compile-time vs runtime placement.readonly/mutfield — per-field freeze в record’е.
Связь
- D27 —
constдля размеров фиксированных массивов. - D30 —
SCREAMING_SNAKE_CASEдляconst. - D32 — default immutable bindings;
mutдля переменных и параметров. - D36 —
readonly/mutмодификаторы полей record’а; per-field freeze. - 07-modules.md —
export constэкспортирует.
D34. Pattern-bind в if/while conditions — unified grammar с match arms
Status: active (Rust 1:1, 2026-05-27); amended Plan 114 D184 (2026-05-31): drop outer
letkeyword; identifier-pattern требуетro/mut; constructor/destructure pattern bare = immutable,mutinside;consumeзапрещён в conditions; outer-mutзапрещён.
Что
Синтаксис if pattern = expr { ... } и while pattern = expr { ... }
— pattern matching прямо в условии с локальным binding в scope блока.
Guard-условие через && (Plan 106, D34 amend 2026-06-17).
Pattern grammar унифицирована с match-arm patterns: те же правила
(mut inside Some(mut x), bare = immutable) работают в обеих позициях.
Правило
// Constructor / destructure pattern — bare bindings default immutable
if Some(user) = cache.get(key) { process(user) }
if Some(mut buf) = pool.try_take() { buf.fill(0) } // mut inside pattern
if (a, b) = pair { use(a, b) }
if { name, age } = user_opt { greet(name, age) }
while Some(item) = queue.pop() { handle(item) }
while Some(mut line) = reader.read_line() { line.trim_in_place() }
// Identifier pattern — REQUIRES `ro` or `mut` (footgun protection)
if ro user = compute_user() { use(user) } // ✓ explicit ro
if mut counter = init() { counter += 1; … } // ✓ explicit mut
if user = compute_user() { ... } // ✗ E_AMBIGUOUS_IDENT_PATTERN
// Guard через && (Plan 106, D34 amend 2026-06-17)
if Some(x) = cache.get(key) && x > 5 { use(x) }
if ro Some(user) = db.find(id) && user.is_active { process(user) }
while Some(item) = queue.pop() && item.valid { handle(item) }
// else-if
if Some(a) = lookup_a() {
use(a)
} else if Some(b) = lookup_b() {
use(b) // a НЕ доступна
}
Правила:
- Constructor / destructure pattern — bare bindings inside pattern
default immutable.
mutexplicit когда нужно (Some(mut x),(mut a, b)). Consistent с match arms. - Identifier pattern (
if NAME = expr) — обязательноroилиmut. ИначеE_AMBIGUOUS_IDENT_PATTERN(footgun protection: bareif x = compute()визуально неотличимо от assignment). consumeзапрещён в conditions —E_CONSUME_IN_CONDITION.- Outer
mutудалён —if mut Some(x)→ useif Some(mut x)(mut moves inside pattern). Единое правило с match. - Guard-выражение: после scrutinee допустимо
&&+ bool-expr; биндинги паттерна видны в guard. Аналог Rust let-chains (RFC 2497), выбрано&&вместо запятой (Swift) за очевидность семантики и знакомость Rust-аудитории. else if— корректно для всех форм.
Грамматика:
if-expr := "if" if-cond block ("else" (if-expr | block))?
while-expr := "while" if-cond block
if-cond := cond-pattern "=" expr ("&&" expr)? // guard: && expr после scrutinee
| expr
cond-pattern := ("ro" | "mut") IDENT type_opt
| constructor-pattern // Some(...) / None / etc.
| tuple-pattern // (a, b)
| record-pattern // { name, age }
Скоуп: связанные имена доступны в guard-выражении и в теле блока.
? работает: if Some(user) = Db.find(id)? { ... } пробрасывает ошибку
наверх; внутрь блока заходим только при успехе.
Почему
- «Получить и использовать если есть» без полного
match-блока. - Unified grammar с match arms — единое правило (
Some(mut x)), а не два разных (Rustif let Some(mut x)vs matchSome(mut x)). - Footgun protection — identifier-pattern требует keyword’а (Plan 114 D184 §«identifier-pattern protection»).
- Условные циклы — итерация пока паттерн совпадает.
- Guard
&&— «получить и проверить» без вложенногоif.
Что отвергнуто
- Go-стиль
;-разделитель — нарушает D17 «один разделитель — запятая». :=оператор — shadowing-проблемы Go.- Smart-cast (Kotlin) — магия в типе, AI-first против.
if let(Rust-style outerlet) — Plan 114 retracted в пользу unified pattern grammar с match arms.- Outer
mutв pattern position (if mut Some(x)) — Plan 114 retracted; mut goes inside pattern (if Some(mut x)). - Запятая-chain (Swift-стиль) — отвергнута в пользу
&&(Plan 106); amend 2026-06-17: добавлен&&guard; запятая-chain отвергнута.
Связь
- D33 —
ro/mutbinding mutability. - D184 — master keyword refresh (Plan 114).
- 02-types.md → D17 — pattern matching в
match(shared grammar). - Plan 106 — chain syntax.
D35. Методы инстанса через @, self отменён
Что
Методы инстанса объявляются как fn Type @method(...) с неявным
self. Поля self — через @field. Мутирующий метод —
fn Type mut @method(...). Конструкторы и static — через точку
fn Type.name(...). Ключевое слово self отменено.
Правило
type Account {
ro owner str
_balance money
}
// конструктор / static — через точку, без @
fn Account.new(owner str) -> Account =>
Account { _balance: 0, owner }
// метод инстанса — через пробел и @, неявный self
fn Account @balance() -> money => @_balance
fn Account @summary() -> str => "${@owner}: ${@_balance}"
// мутирующий — mut перед @name
fn Account mut @deposit(amount money) =>
@_balance += amount
Грамматика:
free-fn := identifier "(" params ")" effects? ("->" type)? "=>" body
static-method := Type "." identifier "(" params ")" ...
instance-method := Type ("mut")? "@" identifier "(" params ")" ...
После имени типа: . → static, @ или mut @ → instance.
Receiver — любой тип, включая примитивы
Receiver-тип может быть любым именованным типом: record, sum, newtype,
unit-тип, protocol — и встроенный примитив (int, str, bool,
f64, u8, …). Это естественное следствие того, что в Nova
примитивы — обычные типы (D30, D32), просто с lowercase-именами и
особым представлением в runtime.
// Static method on a primitive — `str` is a regular type.
fn str.from(i int) -> Self => /* ... */
// Instance method on a primitive — used via `value.method()`.
fn int @to_hex() -> str => /* ... */
fn f64 @round() -> int => /* ... */
ro s = str.from(42) // static via D35
ro h = (255).to_hex() // instance, parens around literal
ro r = 3.7.round() // chained on numeric literal
Применение: From[X] для str (D73) — основной механизм
строковой конверсии. Также int.parse(s str), bool.from(n int)
и другие фабрики, не требующие отдельного wrapper-типа.
Ограничения: примитивы — закрытые типы, программист не может
добавить новые поля (нет type str { ... } для существующего
str). Только методы. Это согласовано с тем, что extension functions
в Nova не вводятся (D46): метод определяется один раз в модуле,
владеющем типом-receiver. Для примитивов это stdlib: fn int.method
определяется только в stdlib-модулях, пользовательский код может
определять методы только на собственных типах.
В теле метода @field — единственная форма доступа к self-полю.
@.field невалидно. @ без поля — значение текущего инстанса
(аналог self):
fn Account @copy() -> Account => @
fn Account @send(ch Channel[Account]) => ch.send(@)
Вызов методов — скобки обязательны:
ro acc = Account.new("alice")
acc.deposit(100)
ro bal = acc.balance() // getter, обязательные ()
Unbound method value и lambda (bound method value удалён в Plan 132):
// Unbound: fn-pointer, self передаётся явно.
ro g = Account.@balance // unbound: fn(Account) -> money
ro v = g(acc) // 100
// Closure захватывает receiver — замена bound-форме.
ro f = || acc.balance() // lambda: fn() -> money
ro v2 = f() // 100
Правило
@nameв теле метода:@nameбез()= полеname;@name()= вызов методаname(включая cross-method вызов:@len()из@call_len()корректно эмитируетNova_T_method_len(nova_self, ...)). Поле и метод с одним именем на одном типе — легально (нет коллизии).REMOVED (Plan 132, 2026-06-09):
obj.@method(bound method value). Замена: лямбда|| obj.method()или unboundType.@method.
Generic’и: [T] после имени типа (fn Vec[T] @len()) и/или после
@name (fn Vec[T] @map[U](f T -> U)).
Почему
- Минимум строк.
fn Account.deposit(mut self, ...)→fn Account mut @deposit(...)экономит 6-9 символов на метод. - Один смысл
@— «принадлежит self». В сигнатуре@method, в теле@field. - Чёткое разделение. Точка = static (
Account.new),@= instance. Программист и LLM видят роль из синтаксиса. - Скобки обязательны —
acc.balance()явно вызов, не поле. Property-механизмы (C#/Kotlin) делают это невидимым.
Что отвергнуто
fn Type.method(self, ...)— повторяющийсяselfв каждом методе и каждом обращении к полю.- Property (
property balance { get; set }) — невидимое «поле или вызов?»; известный источник путаницы в C#. @как параметр (fn deposit(mut @, ...)) —@приобретает два смысла.fn mut @Type.method—mutна типе vs на binding’е, разные смыслы.fn Type new(...)без точки — расходится с namespace path.
Связь
- D32 (если есть) / 05-memory.md —
mutсемантика mutable-binding’а. - D37
—
@field/@Nдля self. - D38 — generic на типе и методе.
- D46 — operator overloading
через
@-методы. - 01-philosophy.md → D1 — методы как часть
парадигмы
protocols + data.
Перегрузка методов
Полная семантика перегрузки методов (по типу аргумента, arity, mangling, bootstrap-status, ambiguity, disambiguation) — в D84. Здесь лишь напоминание: метод может быть перегружен несколькими сигнатурами на одном receiver-типе, резолв выполняется по статическим типам аргументов.
Method values (Plan 11 Ф.4)
Методы — first-class values: можно сохранить в переменную, передать в HOF, вернуть из функции. Три формы:
type Account { balance int }
fn Account.new(b int) -> Self => Self { balance: b }
fn Account @get() -> int => @balance
fn Account @add(n int) -> int => @balance + n
ro acc = Account.new(42)
// 1. Closure capturing receiver (replaces bound method value removed in Plan 132).
ro f = || acc.get() // тип: fn() -> int
ro g = fn(n int) -> int => acc.add(n) // тип: fn(int) -> int
ro v = f() // 42
ro r = g(10) // 52
// 2. Unbound method value: self передаётся явно как первый аргумент.
// Тип: fn(Receiver, <params>) -> R
ro h = Account.@add // тип: fn(Account, int) -> int
ro r2 = h(acc, 10) // 52
// 3. Static method value: обычная свободная функция.
// Тип: fn(<params>) -> R
ro mk = Account.new // тип: fn(int) -> Self
ro acc2 = mk(7)
Семантика
- Lambda capturing receiver — lambda closes over the receiver variable. Subsequent calls use the captured variable.
- Unbound — fn pointer без env’а. Caller обязан передать receiver как первый аргумент.
- Static — fn pointer без receiver’а вообще.
Использование в HOF
ro nums = [1, 2, 3]
ro negated = nums.map(int.@neg) // unbound: применяет @neg к каждому
ro total = nums.fold(0, |n| acc.add(n)) // closure-light: добавляет каждый num к acc
Disambiguation для overloaded methods
Если у метода несколько overload’ов, используется lambda с явными типами аргументов:
fn Buffer mut @write(s str) -> ()
fn Buffer mut @write(b []u8) -> ()
ro buf = Buffer.new()
ro f1 = fn(s str) -> () => buf.write(s) // выбор по типу аргумента
ro f2 = fn(b []u8) -> () => buf.write(b)
Тип аргумента в lambda однозначно определяет overload.
C-runtime представление
Lambda (closure) и unbound — оба используют generic NovaClosBase layout:
typedef struct { void* fn; void* env; } NovaClosBase;
fn указывает на сгенерированный wrapper, env — указатель на
struct с captured receiver (для lambda) или dummy struct (для unbound).
Call-site: cast fn к нужной сигнатуре, передача env + args.
Static method values — bare fn pointer (без env’а) — но в bootstrap для единообразия тоже оборачиваются в NovaClosBase.
Note: Bound method value (
obj.@method) removed in Plan 132. Lambda|| obj.method()is the explicit replacement.
Self в expression position (D66 расширение, Plan 11 Ф.4.5)
Self ранее работал только в type position (return type, parameter
type). Plan 11 Ф.4.5 добавляет expression position:
type Account { balance int }
fn Account.with_initial(amount int) -> Self =>
Self { balance: amount } // record literal
fn Account.new() -> Self =>
Self.with_initial(0) // call current type's static
Резолюция: Self в expression context резолвится в имя текущего
receiver-типа из метода (тот же current_receiver_type что для
type-position). Полезно для default → parameterized constructor
chain’ов и DRY.
Прецеденты: Rust impl Foo { fn make() -> Self { Self::new(2) } },
Swift Self.method(). D66 расширяется этим Plan’ом 11.
D37. Доступ к полям: .name для record, .N для позиционных и кортежей
Что
Доступ к полю / элементу — через точку:
obj.name— поле record по имени;obj.0,obj.1— поле позиционной структуры или кортежа по индексу (0-based);@name,@0,@1— то же внутри методов инстанса для self.
Правило
// record — доступ по имени
ro u = User { id: 1, name: "alice" }
println(u.name)
// позиционная структура — по индексу
type Point(f64, f64)
ro p = Point(1.0, 2.0)
println(p.0) // 1.0
println(p.1) // 2.0
// кортежи — то же
ro pair = (1, "alice")
println(pair.0)
println(pair.1)
Внутри методов:
fn Point @magnitude() -> f64 =>
math.sqrt(@0 * @0 + @1 * @1)
fn Account @summary() -> str =>
"${@owner}: ${@balance}"
Mutation работает по правилам 05-memory.md (mut binding +
поле без readonly):
mut p = Point(1.0, 2.0)
p.0 = 5.0 // ок
Pattern matching как альтернатива:
match p {
Point(x, y) => x + y
}
ro Point(x, y) = p // деструктуризация
Парсер: .N после идентификатора или ) — field access. После
числового литерала точка — только decimal. 1.foo — ошибка.
Почему
- Точечный доступ для одного поля без полной деструктуризации.
.0/.1— стандарт Rust/Swift, AI-friendly.- Compile-time проверка границ (в отличие от runtime
obj[i]).
Что отвергнуто
- Только pattern matching — многословно для простого доступа.
- Аксессоры (
fst/snd) — не масштабируются для 3+ кортежей. obj[0](TS array-style) — конфликт с runtime-индексацией массивов.
Связь
- 02-types.md → D17 — позиционные структуры
(
type Point(f64, f64)) объявляются через(). - D35 —
@name/@Nвнутри методов.
D38. Создание массивов и turbofish для дженериков
Что
Пустые массивы — литералом с annotation или static-методом на типе
массива ([]T.with_capacity(n)). Когда inference не справляется —
turbofish через те же [T] после имени, без Rust’овского ::.
Правило
Создание массивов:
// 1) литерал + annotation
mut buckets []Slot[K, V] = []
ro xs []int = [1, 2, 3]
// 2) inference из контекста
fn first(xs []int) -> Option[int] => ...
ro result = first([]) // [] выводится из аргумента
// 3) static-методы
ro buckets = []Slot[K, V].with_capacity(cap)
ro empty = []int.new()
ro zeros = []u8.filled(0, 1024)
Turbofish — те же [T], без :::
fn parse[T](s str) -> Result[T, ParseError] => ...
ro n = parse[int]("42")? // в Result-возвращающей функции
ro c = Cache[str, int].new()
ro buckets = []Slot[K, V].with_capacity(16)
ro result = m.@get[int]("key")
Грамматика — generic-application:
generic-application := identifier "[" type ("," type)* "]"
Работает для функций, static-методов, конструкторов, instance-методов.
Почему
- Парсер однозначен (D16) —
::не нужен. Rust сами признают::<>ошибкой дизайна. - Static-методы на типе массива — тип явный, pre-allocation доступна.
- Один синтаксис
[T]— везде, без специальных операторов.
Что отвергнуто
- Rust
::<T>— нужен только из-за<T>-ambiguity, у Nova её нет. - Глобальный
make[T](n)(Go) — не вписывается. Vec[T].new()—[]Tэто встроенный синтаксис, не отдельный типVec.
Связь
Эволюция
D16 уточнён: [T] сам по себе не
является типом — только generic-применение к именованной сущности.
Bootstrap (2026-05-07): turbofish реализован в codegen-парсере.
Активируется в expression-position через peek-disambiguation: после
Ident[T1, T2, ...] смотрим post-] token; если это ( (call),
.IDENT( (method-call) или ? (Try) — это turbofish-узел
(ExprKind::TurboFish { base, type_args }); иначе — обычный
Index-доступ. Параллельно с этим, multi-arg внутри [...] —
однозначно turbofish (Index не имеет comma). Bootstrap-codegen
прозрачно делегирует TurboFish в base (monomorphization идёт по
call-site / receiver-type), но AST сохраняет type_args для будущих
этапов inference. Тесты — nova_tests/types/generics.nv.
Plan 98 (закрыт 2026-05-23): type-argument inference расширена на
generic-параметризованные типы в позиции param. До Plan 98
infer_type_param_binding (emit_c.rs) выводил T только из голого
T и []T — Option[T] / Result[T,E] / пользовательские
Box[T]/HashMap[K,V] молча игнорировались → каждый generic-helper,
принимающий generic-тип, требовал turbofish (check[int](a)
вместо естественного check(a)). Хуже Rust/Go/TS, где это базовая
unification. Plan 98 конвертировал функцию из associated fn в метод
&self + добавил три рекурсивные ветки: Option[T] (recovery из
NovaOpt_<sani> через novaopt_value_types), Result[T,E]
(novares_ok_err), user-generic (через generic_type_instance_info).
Граница (known limitation): []Option[T] / []Result[T,E]
(массив generic-элементов) пока НЕ выводится — codegen эрейзит
element type в receiver_type_c_ident (NovaArray_nova_int* для
не-примитивов), теряя generic-инфу до inference; отдельный gap, не
scope Plan 98. Тесты — nova_tests/plan98/.
Built-in API для []T (Plan 17 Ф.1, закрывает Q-array-api)
[]T — встроенный тип, не запись stdlib (Vec[T] нет). Граница
между built-in API (компилятор знает напрямую) и stdlib
extensions (методы добавлены через fn []T @method по D35) —
зафиксирована ниже.
Built-in API — известно компилятору:
| Категория | API | Семантика |
|---|---|---|
| длина | xs.len(), xs.is_empty() | len() — method-call, zero-cost lowering в arr->len (O(1)); is_empty() ≡ len() == 0 (D117) |
| capacity | xs.cap(), mut xs.cap(n) | размер выделенного storage’а; len() ≤ cap(). Канон cap() (D117 AMEND 2026-07-06); дублирующий .capacity() РЕТРАКТИРОВАН [M-unwrap-twins-retraction] 2026-07-07 |
| доступ | xs[i], xs.get(i) | [i] — panic при out-of-bounds (D13); get(i) → Option[T] |
| мутация | mut xs.push(v), mut xs.pop() -> Option[T] | push grow при len() == cap() |
| итерация | xs.iter() -> Iter[T], for x in xs { ... } | for — sugar над .iter().next() (D58) |
| создание | []T.new(), []T.new().cap(n), []T.filled(v T, n int) | static-функции на типе; with_capacity удалён (D372) — .new().cap(n) |
xs.cap() — присутствует, но не часть стабильного API для
прикладного кода (detail of representation D32). Использование — для
оптимизации pre-allocation; при изменениях representation может
исчезнуть.
Field-access form (xs.len, xs.cap, xs.is_empty без скобок) —
запрещена (D117).
Compiler выдаёт E_SIZE_ACCESSOR_FIELD. Для bare .cap — diagnostic
подсказывает append () (canonical form — .cap()).
Stdlib extensions (std/collections/vec.nv через D35) — то, что
пишется как обычный пользовательский метод:
| Метод | Что делает |
|---|---|
xs.map[U](f fn(T) -> U) -> []U | каждый элемент через f |
xs.filter(pred fn(T) -> bool) -> []T | оставить совпадения |
xs.fold[Acc](init Acc, f fn(Acc, T) -> Acc) -> Acc | свёртка слева |
xs.any(pred), xs.all(pred) | bool-предикаты |
xs.first(), xs.last() | Option[T] head/tail |
Расширяется по необходимости (contains, index_of, reverse,
sort, zip, take, drop, unique, enumerate — добавляются по
запросу use-case’ов; формальный D-block не нужен, любой fn []T @method валиден по D35).
Слайсинг xs[a..b] — реализовано Plan 96 (см. D144).
Поддержаны 5 форм Range: a..b, a..=b, a.., ..b, .. (Rust
RangeBounds parity). Возвращает sub-slice view (cap == len, push →
realloc → silent detach). OOB → panic (D13).
Embed use []T — допустим по D39 (имя поля обязательно):
type Holder[T] {
use data []T
extra str
}
ro h = Holder[int] { data: [1, 2, 3], extra: "info" }
ro n = h.len() // прокси к data.len() (D117 method-only)
h.push(42) // прокси к data.push
Подробно — Plan 17 Ф.1, Q-array-api (closed), 02-types.md → D39 (use-delegation).
D40. Тело функции: => для одного выражения, {} для блока
Что
Два взаимоисключающих способа задать тело именованной функции:
=> expr (ровно одно выражение) или { stmt; ...; expr } (блок).
Общий закон: => и {} не сочетаются. Распространяется на fn
(named и closure-full), handler-method.
Closure-light (|x| body) — отдельная грамматика
(D22): тело — bare expression ИЛИ
block, без =>. D40 к ней не применяется.
Единственное исключение — match-arm (D19):
arm может быть pattern => expr или pattern => { block } (Rust-стиль).
Причина исключения — => гарантирован как маркер «начало результата»
после pattern’а с возможным if-guard’ом, поэтому терять его в блок-форме
нельзя.
Indentation не значим.
Правило
fn-decl = 'fn' name '(' params ')' [effects] ['->' type] body
closure-full = 'fn' '(' params ')' [effects] ['->' type] body
body = '=>' expression | block
block = '{' { statement } [ expression ] '}'
closure-light = '|' params? '|' (expression | block) // без =>
match-arm = pattern [ guard ] '=>' ( expression | block ) // исключение
Везде, где есть => (named fn, closure-full, handler-method), после
него идёт ровно одно выражение. Ни fn f() => { ... }, ни
fn f() { => x }, ни fn(x) => { stmt; expr } — запрещены.
Closure-light => вообще не использует.
Симметрия по контекстам:
| Контекст | => expr | { block } | => { block } |
|---|---|---|---|
fn name(...) (named fn) | ✅ | ✅ | ❌ |
fn(...) (closure-full) | ✅ | ✅ | ❌ |
|...| (closure-light) | ❌ (нет =>) | ✅ | — |
| Match-arm | ✅ | — | ✅ (D19) |
| Handler-method | ✅ | ✅ (без =>) | ❌ |
Если нужно несколько statement’ов:
- для
fn(named) и closure-full — блок-форма{ stmt; ...; expr }; - для closure-light — block-форма прямо в
|x| { stmt; expr }(D22); - для match-arm —
pattern => { stmt; expr }(D19); - для handler-method — блок-форма без
=>:op(p) { stmt; expr }(04-effects.md → D31).
// expression-body
fn double(x int) -> int => x * 2
fn HashMap[K, V].new() -> HashMap[K, V] =>
HashMap[K, V].with_capacity(16) // одно выражение, перенесённое
// block-body
fn next_pow2(n int) -> int {
if n <= 1 { return 1 }
mut p = 1
while p < n { p *= 2 }
p
}
Многострочный match/if — это одно выражение, поэтому => match {...}
и => if {...} else {...} остаются легальными:
fn classify(n int) -> str => match n {
0 => "zero"
n if n > 0 => "positive"
_ => "negative"
}
fn abs(x int) -> int => if x < 0 { -x } else { x }
Граница: появилось ли что-то кроме самого выражения (statement,
let, return, for, while)? Тогда нужен { block }.
// НЕ ОК — `let` это statement, `=>` ожидает одно выражение
fn area(r f64) -> f64 =>
ro pi = 3.14
pi * r * r
// ОК — блок-форма
fn area(r f64) -> f64 {
ro pi = 3.14
pi * r * r
}
Почему
- Один общий закон.
=>означает «ровно одно выражение после» для лямбд, телаfn, handler-method. Match-arm — единственное исключение, оправданное необходимостью гарантированного маркера «начало результата» после pattern’а с возможнымif-guard’ом (D19). - Indentation-significant грамматика ломает copy-paste, плохо переживает auto-format (Python-стиль отвергнут).
- Парсер сложнее при значимых отступах.
- AI-инструменты часто переформатируют код — невидимая разница становится багом.
- Явные
{}— ноль двусмысленности для форматера, линтера, LSP. - Граница
fnvs лямбда видна по форме. Блок-тело может иметь толькоfn name(...) { ... }, trailing-block и handler-method. Лямбда — никогда.
Что отвергнуто
=> indented-block(F#/OCaml/Python-стиль) — indentation-significant.- Только
{}для всех тел — теряется компактная expression-body. {}после=>(Kotlin/JS-стиль(x) => { ... }) — два маркера для одного, размывает границу «выражение vs блок».- Сочетание
=>и{}для лямбд при запрете дляfn— непоследовательно: общий закон должен работать одинаково для всех «безымянных» и «именованных» функций. Match-arm имеет особую природу (всегда требует=>как маркер) и потому делает исключение.
Связь
- D22 — closure-light
|x|имеет отдельную грамматику (bare expr или block, без=>); closure-fullfn(...)подчиняется D40 как named fn. - D19 — match-arm:
pattern => exprилиpattern => { block }(единственное исключение из правила «=>и{}не сочетаются»). - D23 — guard-clauses
через
returnтребуют блок-формы. - D43 — trailing-block (без
params) —
f(args) { block }; trailing-fn (с params) —f(args) fn(p) body. - 04-effects.md → D31 — handler-method
имеет две формы (
=> exprили{ block }), какfn. - D45 — inference работает только на expression-body.
- D49 —
{}правит newline-разделители.
Эволюция
Ревизия (2026-05-10): правило «=> и {} не сочетаются» больше не
применяется к closure-light (|x|), у которой своя грамматика без =>.
Изначально правило покрывало «лямбды» как единый класс; после
перехода на two-level closure (D22)
«лямбды» расщепились на closure-light (отдельная грамматика) и
closure-full (fn(...), подчиняется D40 как named fn).
D43. Trailing: { block } без params, fn(p) body с params
Что
Если последний параметр функции — функционального типа, аргумент-функция
может быть вынесен за () вызова в одну из двух форм:
- trailing-block —
f(args) { block }— для callback’ов без параметров (DSL-форма:with_timeout,retry,transaction). - trailing-fn —
f(args) fn(params) body— для callback’ов с параметрами. Синтаксис идентичен closure-full (D22) без имени.
Скобки () вызова всегда обязательны; trailing-форма должна начинаться
на той же строке, что ).
|...| (closure-light) в trailing-position запрещён — для
callback’ов с params используется fn(...), иначе ambiguity с
binary |. Closure-light с параметрами передаётся через args:
f(|x| body).
Правило
// trailing-block — без параметров (DSL)
with_timeout(2.seconds) {
Db.exec(sql`UPDATE counters SET v = v + 1`)
}
retry(3) {
Net.get(url)
}
transaction(db) { ... }
// trailing-fn — с параметрами; обе формы тела
list.filter() fn(x) => x > 0 // expr-body
list.fold(0) fn(acc, x) { acc + x } // block-body
list.map() fn(s str) Fail -> int { parse(s)? } // typed + effects
// closure-light — в args, не в trailing
list.filter(|x| x > 0)
list.fold(0, |acc, x| acc + x)
Грамматика:
call = primary '(' args ')' [ trailing ]
trailing = trailing-block | trailing-fn
trailing-block = '{' block-body '}'
trailing-fn = 'fn' '(' params ')' [ effects ] [ '->' type ] body
body = '=>' expression | block
block-body = { statement } [ expression ]
Trailing-fn идентична closure-full (D22).
Параметры пишутся как у named fn — (x int, y int), типы опциональны
если выводятся из ожидаемой сигнатуры callee.
Правила:
()обязательны — trailing должен следовать сразу после).- На той же строке — для trailing-block
{сразу после); для trailing-fnfnсразу после). Перенос строки между ними запрещён. - Тип последнего параметра — функциональный. Иначе type error.
- Один trailing на вызов.
|...|(closure-light) в trailing-position запрещён — пишетсяfn(...)или передаётся через args вызова.- Trailing-block — без параметров. Если callback требует параметры
— использовать trailing-fn (
fn(p) ...) или закрытие в args. - Implicit
itзапрещён — параметр всегда именован. - Method chain — те же правила:
list.filter() fn(x) => x > 0.
spawn— исключение.spawn— keyword-конструкция, не вызов функции, поэтому не подчиняется D43. Его синтаксис:spawn expr, гдеexpr— любое выражение: вызов функции (spawn foo()), блок (spawn { body }), и т.д.spawn() { body }— запрещено (пустые скобки без смысла вводят в заблуждение).
Дисамбигуация с record-литералом:
ro u = User { name: "alice" } // record (имя типа, без ())
fn_call(arg) { name: "alice" } // trailing-block (после `)`)
fn_call(arg) fn(x) => x.value // trailing-fn
fn_call(arg, User { name: "a" }) // record внутри args
Многие language primitives становятся обычными функциями stdlib:
fn with_timeout[T](dur Duration, body fn() -> T) Fail -> T
fn transaction[T](db mut Db, body fn() Db Fail -> T) Db Fail -> T
fn retry[T](attempts int, body fn() Fail -> T) Fail -> T
Keyword-блоки остаются (без ()): with X = h { ... },
parallel for x in xs { ... }, region { ... }, match/if/for/while.
Различие с trailing — наличие ().
Почему
()обязательны — локальный парсер без type-directed parsing. Kotlin/Swift вынуждены смотреть на тип, чтобы различить trailing и record-литерал.- trailing-fn = closure-full без имени. Симметрия — программист
учит одну грамматику параметров. Парсер коммитится за
fn-keyword после), никаких ambiguity. - Closure-light не в trailing.
func() |x| bodyсоздавал ambiguity с binary|в expression-position. Запрет даёт парсеру мгновенный ответ:|...|→ closure-light в args;fn(...)после)→ trailing-fn;{...}после)→ trailing-block. - Trailing-block — DSL-ниша. Для
with_timeout/retry/transactionнет параметров callback’а, и{ block }визуально маркирует «здесь начинается тело DSL’а». - Не closure-литерал внутри
(). Closure-light с params передаётся через args (f(|x| ...)), trailing — для последнего функционального параметра. Программист выбирает по форме (длина тела, наличиеlet’ов).
Что отвергнуто
- Опциональные
()(Kotlin) — нет локального способа развести с record-литералами. ()опционально в method chain — лишнее исключение.- Implicit
it— нелокальный reasoning. do { body }keyword — лишнее ключевое слово.- Indentation-significant — конфликт с D40.
- Trailing-block = лямбда (до 2026-05) — переклассифицировано в самостоятельную грамматику.
- Trailing-block с параметрами через
{ x => body }(до 2026-05-10) — заменено на trailing-fn (fn(x) ...) для симметрии с closure-full. - Trailing closure через
|x|—func(args) |x| bodyсоздавал ambiguity с binary|в expression-position;fn(...)решает за один токен.
Связь
- D22 — closure-light в args через
|x|; trailing-fn идентична closure-full без имени. - D40 —
trailing-fn body подчиняется правилу
=>↔{}как named fn; trailing-block — block-only (без=>). - 04-effects.md — handler-блоки
with X = h { ... }— keyword-блок, не trailing. - 06-concurrency.md —
parallel for,supervised,race,select— keyword-блоки.
Эволюция
Ревизия (2026-05): переименование «trailing-lambda» → «trailing-block».
Раньше форма f(args) { params => body } называлась лямбдой и
конфликтовала с правилом «лямбда = одно выражение». Тогда же
переклассифицировано в самостоятельную грамматику.
Ревизия (2026-05-10): trailing расщеплён на trailing-block (без
params, для DSL) и trailing-fn (с params, через fn(...)). Старая
форма f(args) { x => body } отменена. Триггер — переход closure
на two-level (|x| + fn(...), D22);
старая форма с => внутри {} после ) создавала путаницу с новым
правилом «=> не используется в closure-light». Симметрия trailing-fn
↔ closure-full даёт парсеру и программисту одно правило вместо двух.
Migration: ~10 примеров trailing с params в spec/.
D44. Числовые литералы
Что
Полный набор числовых форм; _ как разделитель между цифрами;
default — int для целых, f64 для дробных. Type-suffixes
(100u32, 1.5f32) отвергнуты — type через annotation или as-cast.
Правило
// целые: десятичные / hex / binary / octal
1
1_000_000_000
0xFF 0xFF_FF_FF_FF
0b1010_0001
0o755
// float
1.5 1_234.567_89
1e10 1.5e-3 1_000.5e6
// type через cast или аннотацию
ro x i32 = 100
100 as u8
0xFF as u32
Default-типы: int (= i64 на bootstrap per D129;
future-arch may become platform-pointer-width signed) для целого,
f64 для дробного. Контекст (annotation, тип параметра, тип поля)
переопределяет с hard compile-time range-check (см. D227
для range-policy, no narrow-fallback, negative-в-unsigned правил):
ro x u8 = 200 // 200 это u8
fn write(b u8) -> () => ...
write(0xFF) // 0xFF это u8
ro arr []f32 = [1.0, 2.0] // annotation-контекст: литералы → f32
ro v = Vec[f32].from([1.0, 2.0]) // param-тип `from(arr []f32)` → литералы → f32
ro w = Vec[f32].of(1.0, 2.0) // вариадик `of(...args []f32)` → то же
Impl-note (Plan 154.1, 2026-06-14): приведение числового литерала к элементному float-типу из контекста для array-литералов (
[]f32/Vec[f32]) было codegen-дырой — литералы строили[]f64, чьи биты реинтерпретировались как f32 → мусор. Исправлено:try_emit_typed_vec_literalберёт float-hint когда ВСЕ элементы — числовые литералы (FloatLit/IntLit); turbofish-static арг-array- литерал эмитится с param-C-типом. Гард: только всё-литеральные массивы. Переменная f64 в[]f32-контексте (Vec[f32].from([f64_var])) — НЕ сужается молча: это неявное сужение f64→f32, запрещённое для не-литералов (какas-cast narrowing, D54) → громкая ошибкаE_ARRAY_ELEM_NARROW(нужен явныйas f32на элементе). Маркер[M-154.1-f32-literal-coercion]✅ (pos+neg тестыplan154_1).
Разделитель _ — только между цифрами. Запрещено: в начале
(_1), в конце (1_), подряд (1__0), сразу после префикса
(0x_FF), вокруг точки (1_.5), вокруг e (1_e10).
Regex:
decimal-int = [0-9] (_? [0-9])*
hex-int = "0x" [0-9a-fA-F] (_? [0-9a-fA-F])*
binary-int = "0b" [01] (_? [01])*
octal-int = "0o" [0-7] (_? [0-7])*
float = decimal-int "." decimal-int (("e"|"E") ("+"|"-")? decimal-int)?
| decimal-int ("e"|"E") ("+"|"-")? decimal-int
Почему
- Без suffixes — меньше шума.
100u32,0xFFu8,1.5f32хуже100 as u32.let x u32 = 100уже работает через inference. - Тренд новых языков (Swift, Go, Zig) — без суффиксов.
- AI-friendly — меньше форм записи.
intплатформенно — компромисс между Rust (фиксированный) и Python (bigint)._строгий regex запрещает мусор (1__0,_1).
Что отвергнуто
- Type-suffixes (
100u32,1.5f32) — шум, дублирование с annotation, прецедент новых языков против. - Свободные
_— хочется без1__0и_1. 'как разделитель (C++14) — экзотический выбор,_стандарт.
Связь
- D27 — литералы
длин массивов берут тип
int. - D33 — литералы в
const. - D40 — литералы в expression-body.
Строковые литералы и интерполяция ${expr}
Строковый литерал "..." хранит UTF-8 байты (тип str). Внутри
литерала разрешена интерполяция через ${expr} (D-string-interp,
закрыт в Plan 17 Ф.1):
ro name = "alice"
ro age = 30
ro s = "Hello, ${name}, you are ${age}" // → "Hello, alice, you are 30"
Семантика — sugar над + и str.from(...) (D73 [Into]). Литерал
с N интерполяциями развёртывается в N+1 литеральных частей и N
выражений:
"a${x}b${y}c"
// = "a" + str.from(x) + "b" + str.from(y) + "c"
Каждое выражение ${expr} должно иметь тип, удовлетворяющий
Into[str] (через D73 это автоматически верно для int, f64,
bool, str, char, Option[T] где T: Into[str], и любых
user-типов с реализованным From[Self] for str или Into[str]).
Escape для буквального ${ — обратный слэш: "price: \${value}"
печатает ${value} без интерполяции.
Multi-line работает через обычные newlines в литерале (\n или
сырой newline между "..."); tag-форма (D48) для raw-строк отдельная.
Пустое выражение "${}" — compile error.
// Что разрешено
ro v = "x = ${1 + 2}" // sub-expression — ok
ro v = "user = ${user.name()}" // method call — ok
ro v = "${a}${b}" // соседние интерполяции — ok
ro v = "literal \${name}" // escape — буквальное "${name}"
// Что НЕ работает
ro v = "${}" // ✗ пустое выражение
ro v = "${ro x = 1; x}" // ✗ statement, не выражение
Bootstrap status (2026-05-08): ✅ реализовано в lexer/parser/codegen (Plan 17 Ф.4):
- Lexer видит
\$как escape — сохраняет sentinel-байт\x01$(SOH+$), чтобы парсер мог отличить literal-${от interpolation-${. - Parser разворачивает
TokenKind::Str(s)в expression-position вExprKind::InterpolatedStr { parts: Vec<InterpStrPart> }. Каждое${expr}парсится через sub-Lexer + sub-Parser; balanced{}внутри expr поддерживается. Пустое${}— compile error. - Codegen эмитит цепочку StringBuilder с pre-size estimate:
Nova_StringBuilder_static_with_capacity(N)→Nova_StringBuilder_method_append_str(...)per fragment →Nova_StringBuilder_method_into(sb). Одна аллокация на итоговый buffer; нет O(N²) от цепочки+. Per-fragment dispatch по типу:nova_strpass-through,nova_bool→nova_bool_to_str,nova_f64→nova_f64_to_str,CharLit→nova_char_to_str(UTF-8 encode), user-тип с@into() -> str(D73) —Nova_T_method_into, fallbacknova_int_to_str. - Interp (для тестов и
nova run) — обычная конкатенация черезformat!("{}", value). - Const-инициализатор: интерполяция запрещена (требует runtime StringBuilder); compile error «not allowed in const initialiser».
Тесты — nova_tests/types/string_interpolation.nv (13 тестов, все
PASS): int / negative int / str / bool / f64 / char-литерал /
multi-interpolation / expression в ${} / escape \${ / большие
строки через StringBuilder.
В tag\…`-литералах ([D48](#)) tag-функция получает части и аргументы раздельно — для них интерполяция работает по той же грамматике ${expr}`, но обработка идёт user-функцией.
Связь: D48 (tagged templates —
raw-строки tag\…`без интерполяции по такой же грамматике${expr}, но обработка зависит от tag-функции), [08-runtime.md → D73](/spec/decisions/runtime/#d73) (str.fromчерезFrom/Into), [08-runtime.md → D26](/spec/decisions/runtime/#d26) (str` тип
- конкатенация).
Format spec extension (Plan 91.14 D229)
${expr:SPEC} — Plan 91.14 D229 extension к base interp-string grammar. V1 supports :? (debug format via Debug.@debug). См. D229 для подробного описания + 3 новых error codes (E_FORMAT_SPEC_UNKNOWN / E_FORMAT_SPEC_EMPTY / E_FORMAT_SPEC_TRAILING). Future extensions (:hex, :pad-N, :.3) — [M-91.14-format-dsl-extensions] followup.
D45. Inferred return type для expression-body
Что
В expression-body (=> expr) тип возврата -> T опционален —
выводится из тела. В block-body ({ ... }) -> T обязателен,
если тип не unit.
Правило
// expression-body — -> T опционален
fn double(x int) => x * 2 // -> int выведен
fn Duration @as_nanos() => @nanos // -> i64 выведен
fn Duration @is_zero() => @nanos == 0 // -> bool выведен
fn HashMap[K, V] @len() => @count // -> int выведен
// block-body — -> T обязателен
fn next_pow2(n int) -> int {
if n <= 1 { return 1 }
mut p = 1
while p < n { p *= 2 }
p
}
fn process() { // -> () можно опускать
Log.info("hello")
}
Inference локальный (по одной функции, одному выражению), не Hindley-Milner:
- литерал → его тип;
@field→ тип поля; - вызов → тип возврата вызываемого; record-литерал
T { ... }→T; - match/if-else → unification веток.
Style-guide:
exportфункции — писать-> Tявно (линтер предупреждает).- Сложные match’и — писать явно.
- Generic-функции — связь параметра с возвратом полезно видеть.
- Простые геттеры/предикаты/конструкторы — опускать.
Почему
- Compact form для тривиальных методов — getters, predicates.
- Локальный inference — дёшев, прозрачен, не масштабирует на весь модуль.
- Граница совпадает с D40 — где
=>, там и inference; где{}, там типы обязательны. - Прецедент Kotlin.
Что отвергнуто
- Inference в block-body — теряется явный контракт; диф большой функции мог бы молча менять тип возврата.
- Полный inference (Haskell) — public API теряет явный контракт.
-> Tобязателен везде — шум для тривиальных одностроек.
Связь
- D40 — граница применимости.
- D20 —
-> ()опускается всегда. - 07-modules.md → D47 —
exportфункции и линтер.
Реализация (Plan 55 Ф.3, 2026-05-16)
Bootstrap-codegen (compiler-codegen/src/codegen/emit_c.rs::return_type_c)
реализует только Expr-body inference (FnBody::Expr) — Block-body
без аннотации → nova_unit (как раньше; см. «Что отвергнуто» выше).
Inference при registration call-site signatures (free fn + method)
делегируется в return_type_c. Это гарантирует что caller’ы видят
правильный return type до emit_fn собственно body.
Edge-case: если body Expr возвращает void* или unknown — fallback
на nova_unit (safety).
D46. Перегрузка операторов через @-методы
Что
Стандартные операторы автоматически вызывают instance-методы с фиксированными именами. Если у типа есть метод нужного имени — оператор работает. Custom-операторы запрещены.
Правило
fn Duration @plus(other Duration) -> Duration =>
Duration { nanos: @nanos + other.nanos }
fn Duration @times(n i64) -> Duration =>
Duration { nanos: @nanos * n }
ro total = 1.hour() + 30.minutes() // вызывает @plus
ro triple = 5.seconds() * 3 // вызывает @times
Mapping:
| Оператор | Метод | Возврат |
|---|---|---|
a + b | @plus(b) | свободный |
a - b | @minus(b) | свободный |
-a | @neg() | обычно Self |
a * b | @times(b) | свободный |
a / b | @div(b) | свободный |
a % b | @rem(b) | свободный |
a | b, a & b, a ^ b | @or / @and / @xor | свободный |
a << n, a >> n | @shl / @shr | свободный |
a == b, a != b | @equal(b) через протокол Equal (!= выводится) | bool |
a < b, <=, >, >= | @compare(b) через протокол Compare (< 0, > 0, == 0) | bool |
!a | @not() | обычно bool или Self |
a[i] (read), a[i] = v | @get(i) / @set(i, v) | свободный / () |
Правила:
- Только методы инстанса — привязка к первому операнду.
&&,||не перегружаются — short-circuit предсказуем.!=выводится из@equal— отдельно объявлять не надо.- Custom-операторы запрещены (
:+,>>=и т.п.) — фиксированный набор символов. - Никаких protocol/trait — структурное соответствие по имени.
- Type coercion нет —
Duration + 30ошибка, нуженDuration + 30.seconds(). - Overloading методов по типу аргумента разрешён, если сигнатуры различимы:
fn Vector @times(s f64) -> Vector => // умножение на скаляр
Vector { x: @x * s, y: @y * s }
fn Vector @times(other Vector) -> f64 => // dot product
@x * other.x + @y * other.y
Примечание (Plan 91.8b, 2026-06-17):
@eq/@lt/@le/@gt/@ge— УДАЛЕНЫ как operator-dispatch имена. ИспользуйEqual.@equal/Compare.@compare. Подробнее: D363.
Почему
- Просто и предсказуемо — структурное matching по имени, без trait-механики.
- Закрытый набор операторов — Scala-style символьные методы
(
:+,<>) известны как источник нечитаемости. &&/||фиксированы — short-circuit семантика.- Прецедент Kotlin — фиксированные имена методов.
Что отвергнуто
- Через
protocol/trait(Rustimpl Add, Swift) — избыточно. - Custom-операторы (Scala/C++) — нечитаемый код.
- Свободные функции (
fn plus(a, b)) для операторов — unification-ambiguity при резолвеa + b. Overloading свободных функций по типам аргументов сам по себе разрешён (D84), но привязка операторов к receiver-методам (@plus/@times) однозначнее: компилятор знает, где искать реализацию. - Перегрузка
&&/||— нарушает short-circuit. - Auto-derive
@equal/@compare— отдельный механизм, не часть D46 (см. D363).
Связь
- D35 — те же
@-методы. - D45 — методы операторов имеют inferred return при expression-body.
- 02-types.md — отсутствие trait/impl.
Эволюция
Закрывает Q16 (bitflags): type Permission(int) с @or/@and/@not
для |/&/!.
D363. Operator dispatch via protocols — замена magic methods (Plan 91.8b)
Status: active (2026-06-17, Plan 91.8b).
Что
Операторы сравнения диспатчятся через протоколы:
| Оператор | Диспатч | Требование |
|---|---|---|
a == b | a.@equal(b) | тип реализует Equal |
a != b | !a.@equal(b) | тип реализует Equal |
a < b | a.@compare(b) < 0 | тип реализует Compare |
a <= b | a.@compare(b) <= 0 | тип реализует Compare |
a > b | a.@compare(b) > 0 | тип реализует Compare |
a >= b | a.@compare(b) >= 0 | тип реализует Compare |
Примитивы (int, bool, f64, char) — встроенный C-диспатч, без вызова метода.
Что удалено
@eq, @lt, @le, @gt, @ge как operator-dispatch имена — удалены из компилятора (Plan 91.8b).
Связь
- D46 — operator overloading base
- D109 — Equal/Compare протоколы
- Plan 91.8b
D48. Tagged template literals
Что
Литералы вида tag`raw_text` — синтаксический сахар над вызовом
функции tag, получающей сегменты текста и интерполированные значения
раздельно.
Правило
ro j = json`{"name": "alice"}`
ro q = sql`SELECT * FROM users WHERE id = ${user_id}`
ro h = html`<div>${escape(name)}</div>`
ro r = regex`\d{3}-\d{4}`
ro b = bytes`deadbeef`
Грамматика:
tagged-template = identifier '`' template-body '`'
template-body = ( raw-char | escape-seq | interpolation )*
escape-seq = '\\' ( '`' | '\\' | '${' | 'n' | 't' | ... )
interpolation = '${' expression '}'
Desugar:
sql`SELECT * FROM users WHERE id = ${user_id} AND name = ${name}`
// эквивалентно
sql(
["SELECT * FROM users WHERE id = ", " AND name = ", ""],
[user_id, name]
)
Tag-функция получает parts []str (сегменты, длина = args.len() + 1)
и args []T. Сигнатура:
fn tag_name(parts []str, args []T) -> ResultType => ...
Стандартные теги stdlib MVP: json, sql, regex, bytes. html,
css, graphql — user-space.
Compile-time validation через @comptime — для тегов без интерполяций
(пустой args); если функция помечена, литерал проверяется при
компиляции (некорректный JSON → compile error). В MVP @comptime
откладывается на v2.
Multiline и raw escapes естественны:
ro r = regex`\d+\.\d+` // не нужно дважды экранировать
ro q = sql`
SELECT id, name
FROM users
WHERE created_at > ${cutoff}
`
Почему
- Типобезопасная интерполяция — главное преимущество. Tag получает raw parts и args отдельно, сама эскейпит / передаёт через prepared statement (защита от SQL injection).
- User-defined теги — обычные функции, любое имя.
- Compile-time валидация через
@comptime— JSON/regex/SQL без runtime-парсинга. - Прецедент JavaScript по синтаксису, Scala/Rust по compile-time.
Что отвергнуто
s"..."/r"..."(Scala) — ограничивает имя одним символом, нет user-defined.tag.raw("...") + tag.interp("...", args)— слишком многословно.- Macros (Rust
sql!) — требует механизма макросов. - Implicit tag — ambiguity со строками.
Связь
- D33 —
@comptime-теги без интерполяций могут бытьconst. - D27 —
partsиargs— обычные[]T. - D40 — tag-функции обычные.
- 09-tooling.md → D24 —
requiresдля валидации parts/args.
D49. Statement separator и парсинг выражений
Что
Перенос строки — основной разделитель statement’ов. ; —
опциональный, нужен только при нескольких statement’ах на одной строке.
Правило
ro x = 1 // newline разделяет
ro y = 2
foo(x, y)
ro a = 1; ro b = 2; foo(a, b) // ; для одной строки (редко)
Лексер игнорирует NEWLINE, если statement очевидно продолжается:
-
После висящего бинарного оператора в конце предыдущей строки:
ro total = a + b + c -
Внутри открытых
(,[,{— newlines игнорируются. -
Перед
.(method chain) и перед?(error propagation):ro r = list .filter(|x| x > 0) .map(|x| x * 2) .sum() -
После
,в списках. -
Перед
else/else if— продолжениеif-выражения:ro label = if s is Origin { "at-origin" } else if s is Circle { "circle" } else { "square" }Без этого правила multi-line
if/elseприходится писать через повторное присваиваниеlet mut x = default; if ... { x = ... }. -
Перед
||/&&/or/and— продолжение boolean expression:fn is_alnum(c char) -> bool { (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }Это исключение из общего правила «бинарные операторы — в конце предыдущей строки» (Go-стиль).
||и&&часто пишут leading’ом для читаемости; обе формы допустимы. Реализовано через look-ahead вparse_or/parse_and.
Бинарные операторы — в конце предыдущей строки (Go-стиль) для
большинства операторов (+, -, *, и т.п.). Исключения
зафиксированы в правилах 5 и 6 выше: else/else if и
||/&&/or/and — leading-форма допустима. + в начале новой
строки воспринимается как унарный.
Compound-assignment
Compound-операторы — синтаксический сахар:
| Оператор | Десахар |
|---|---|
a += e | a = a + e |
a -= e | a = a - e |
a *= e | a = a * e |
a /= e | a = a / e |
Target обязан быть lvalue — одна из трёх форм:
// 1) Локальная mut-переменная
mut n = 0
n += 1 // ✅
// 2) @field на self в методе (D35)
fn Counter mut @inc() -> () {
@value += 1 // ✅
}
// 3) Element массива/индексируемой коллекции
mut xs = [10, 20, 30]
xs[0] += 5 // ✅
Compound-assign — это statement, не expression. После => в
match-arm или в expression-body функции его нельзя писать без
обёртки в { ... }:
match c {
Some('\n') => { @line += 1; @col = 1 } // ✅ блок
Some(_) => { @col += 1 } // ✅ блок
None => ()
}
// ❌ парсер не поймёт `+=` в expression-position arm:
// Some(_) => @col += 1
Правая часть compound-assign — обычное выражение (любое допустимое в
RHS обычного =). Type-check соответствует базовому оператору:
a += e валидно ⇔ a + e валидно и его тип присваиваем a.
Перегрузка через @plus/@minus/@times/@div (D46)
работает прозрачно — compound на user-типе с @plus десахарится в
a = a.plus(e).
Edge cases:
ro x = foo
(arg) // ❌ два statement'а: foo и (arg)
ro x = foo(arg) // ✅ одна строка
ro x = foo( // ✅ открытая ( игнорирует newline
arg
)
Trailing-block: ) и { на одной строке (D43).
Match-arms — , или \n оба разделяют:
match x {
Some(v) => v * 2 // newline разделяет
None => 0
}
match x {
Some(v) => v * 2, // запятые тоже работают
None => 0,
}
Пустые ; запрещены — всегда баг.
Иерархия приоритетов (от низкого к высокому):
| Уровень | Операторы | Ассоциативность |
|---|---|---|
| 1 | =, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= | right |
| 2 | .., ..= (range) | non-associative |
| 3 | || | left |
| 4 | && | left |
| 5 | ==, != | left |
| 6 | <, <=, >, >= | left |
| 7 | | (bitwise or) | left |
| 8 | ^ (bitwise xor) | left |
| 9 | & (bitwise and) | left |
| 10 | <<, >> | left |
| 11 | +, - (binary) | left |
| 12 | *, /, % | left |
| 13 | as (cast) | left |
| 14 | !, - (unary) | right |
| 15 | ?, (), [], . | left |
Грамматика (упрощённо):
program = statement*
block = '{' statement* '}'
statement = ( decl | expr ) statement-end
statement-end = ';' | NEWLINE | look-ahead '}'
postfix-expr = primary ( '.' name | '[' expr ']' | '(' args ')' | '?' )*
primary = literal | identifier | '(' expr ')' | block | if | match | ...
Block-expressions в statement-позиции (Plan 148 Ф.2 clarification)
statement = ( decl | expr ) statement-end — в statement-позиции expr
парсится полной postfix-expr грамматикой, без отдельной
«block-statement» ветки. Это значит, что block-формы выражений
({ ... }, if, match, unsafe { ... }) в начале statement’а
принимают постфикс (.method() / [i] / .field) напрямую, без
обёртки в (…):
fn Vec[T Display] @display(mut sb StringBuilder) -> () {
sb.append("Vec[")
for i in 0..@len {
if i > 0 { sb.append(", ") }
unsafe { @data[i] }.display(sb) // ✅ постфикс на unsafe-блоке
} // без `(unsafe { … })`
sb.append("]")
}
ro d = if cond { mk_a() } else { mk_b() }.doubled() // ✅ постфикс на if
ro n = match tag { 1 => mk(99), _ => mk(0) }.field // ✅ постфикс на match
Граница с bare { ... } блоком: если блок открывает statement и за
ним нет постфикса, он остаётся обычным side-effecting
expression-statement (его значение, если не trailing, отбрасывается) —
никакого специального «block vs expression» форка в парсере нет; разбор
единообразен. Постфикс привязывается ровно тогда, когда он синтаксически
присутствует сразу после } (с учётом newline-tolerance правила 3 для
ведущего .). Ранее unsafe { … }-форма документировалась как
требующая (…) для постфикса — это устранено (закрывает backlog-маркер
[M-138-unsafe-block-postfix-stmt]).
Почему
- Современный тренд (Go/Kotlin/Swift/TS): newline-разделитель, меньше шума.
- Простые правила вместо JS ASI — JavaScript ASI известный
источник багов (
return\n{...}возвращает undefined). Nova строит на «висящий оператор», «незакрытая скобка», «.method/?». - Бинарный оператор в конце — Go-практика, иначе унарный парсинг ломает выражение.
Что отвергнуто
- Обязательный
;(Rust/C) — лишний шум. - Indentation-significant блоки — конфликт с D40.
- JS ASI с edge cases — известный источник багов.
- Перенос оператора в начало строки — унарный/бинарный конфликт.
Связь
- D40 — внутри
{}newlines разделяют statement’ы. - D43 —
)и{на одной строке как частный случай. - D45 — последнее выражение блока становится возвратом через newline-разделитель.
- 04-effects.md — handler-литералы используют те же
правила внутри
{...}.
D54. Операторы as и is
Что
Два оператора с разной семантикой:
as— compile-time конвертация значения между совместимыми типами (numeric cast, newtype ↔ underlying, sum → int). Возвращает значение целевого типа. Если конвертация невозможна по правилам типов — ошибка компиляции.is— runtime type-check для значений типаany. Возвращаетbool. Также используется как pattern вmatchиifдля биндинга и smart cast’а.
as — про «сделай этим типом» (статически). is — про
«проверь, какой это тип сейчас» (runtime).
Правило
as — compile-time конвертация
as работает в позиции выражения: <expr> as <type>. Возвращает
значение целевого типа.
Numeric cast (см. D44):
ro n = 100 as u32 // литерал → u32
ro big = 0xFF_FF as u16
ro x = 1.5 as i32 // f64 → i32 (truncate)
ro y = some_int as f64 // int → f64
Семантика narrowing-конверсий
Поведение as при потере точности зависит от пары source→target.
В отличие от C (где out-of-range float→int это UB), Nova даёт
defined behavior на любом входе:
| From → To | Семантика | Пример |
|---|---|---|
iN → iM (M < N) | wraparound (modulo 2^M) | 0x1_FFFF as i16 == -1 |
iN → uM | bit-pattern truncate | -1i32 as u16 == 65535 |
uN → uM (M < N) | wraparound | 0x1_FFFF as u16 == 0xFFFF |
uN → iM | bit-pattern, signed reinterpret | 0xFFFFu16 as i16 == -1 |
f64 → f32 | IEEE rounding | 1.1 as f32 ≈ 1.1 (с потерей) |
f → iN | saturation + NaN→0 | 70000.5 as i16 == 32767 |
f → uN | saturation + NaN→0 + neg→0 | -1.0 as u16 == 0 |
iN → f | exact (или nearest IEEE) | 123 as f64 == 123.0 |
| newtype ↔ underlying | identity | 42 as UserId reuses bits |
Float → integer — saturation, не UB. Out-of-range, NaN, ±Infinity дают defined значение, не зависящее от платформы:
- Out-of-range positive →
INT_MAX/UINT_MAX. - Out-of-range negative →
INT_MIN/0(для unsigned). - NaN →
0. +Infinity→INT_MAX/UINT_MAX.-Infinity→INT_MIN/0.
Если нужна проверка out-of-range — используйте
TryFrom:
ro n = f as i16 // saturation, infallible
ro n = i16.try_from(f)? // throws Fail[OutOfRangeError]
as остаётся pure (без Fail-эффекта). Throw-форма доступна через
D77 как explicit choice.
Прецеденты. Saturation для float→int согласован с Rust 1.45+
(RFC #2484 «sealed casts») — прямой аналог. C/C++ дают UB, Nova
улучшает. Swift делает trap (panic), нет pure as — Nova выбирает
saturation для совместимости с D54 «as это pure». Java делает
IEEE round + wraparound (defined, но не saturation).
Newtype ↔ underlying (см. 02-types.md → D52):
type UserId u64
ro u UserId = 42 as UserId // u64 → UserId
ro n u64 = u as u64 // UserId → u64
Sum → int (для sum’ов с числовыми discriminants, D52):
type ErrorCode enum NotFound = 404 | InternalError = 500
ro code = NotFound as int // 404
Запрещено:
any → T(x as intгдеx any) — нет статической конвертации. Используйтеis-pattern илиtry_as[T]()(см. ниже).- Произвольные типы без явного правила (
User as Account) — ошибка компиляции. - int → Sum через
as— type-небезопасно (число может не попасть в варианты). Только через pattern match (см. D52).
Запрещённые as-cast’ы для char/u8/bool
Prune as-cast’ов где seemingly-numeric mapping выражает unsafe
семантику. Программист должен использовать try_from (с
range-check’ом) или explicit comparison:
Запрещено через as | Альтернатива |
|---|---|
int as char, iN/uN as char | char.try_from(n)? (range 0..0x10FFFF, не surrogate) |
char as u8 | u8.try_from(c)? (fails если codepoint > 0xFF) |
int/u8/f64/etc as bool | n != 0 (или n != 0.0) |
str as int/i32/f64/bool/char | T.try_from(s)? (parse) |
int/f64/bool/char as str | str.from(v) (format) |
Исключение для char-литералов: 'A' as int, 'A' as u8
разрешены — программист видит codepoint буквально на
write-time, range-check не нужен.
Исключение для int-литералов → char: 0x41 as char, 65 as char
разрешены, если литерал — compile-time-known integer в валидном
Unicode-диапазоне U+0..=U+10FFFF исключая surrogate range
U+D800..=U+DFFF. Range-check выполняется статически в checker’е,
runtime Fail не нужен. Off-range литерал — compile error с указанием
конкретного codepoint (не generic suggestion). Для переменных типа
int правило прежнее — нужен char.try_from(n)?. Введено в Plan 14
Ф.7 (2026-05-09).
Исключение для unsafe { } блоков: внутри unsafe { } запрещённые
as-cast’ы для переменных разрешены без range-check. Программист берёт
ответственность за корректность значения. Основное применение — реализация
char.try_from и аналогичных stdlib-функций где range-check уже выполнен
явно перед cast’ом:
export fn char.try_from(cp int) -> Result[char, str] {
if cp < 0 || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF) {
Err("char.try_from: invalid codepoint")
} else {
Ok(unsafe { cp as char })
}
}
Прецеденты. Rust требует char::from_u32(n) (Result), не n as char. Swift Character.init(extendedGraphemeClusterLiteral) — нет
прямого n as Character. Kotlin n.toChar() существует но deprecated
для unsafe usage. Java (char)n — narrow с silent overflow (UB-class).
Nova выбирает Rust-стиль strict.
Bool-restrictions — то же из Rust/Swift/Kotlin: if cond требует
bool, n as bool — explicit ошибка с suggestion. Это закрывает
известный bug-class C/JavaScript/Python.
Strict if cond: bool / while cond: bool
if cond { ... }, while cond { ... }, cond1 && cond2,
cond1 || cond2 — cond обязан быть bool. C-стиль truthy-int
(if a где a: int) запрещён.
ro n int = 5
if n { ... } // ❌ compile error: cond must be bool
if n != 0 { ... } // ✅ explicit comparison
Прецеденты. Rust/Swift/Kotlin/Go (если игнорировать nil-check shortcut) — все требуют bool. Python/C/JavaScript разрешают truthy — известный bug-class.
is — runtime type-check
is работает в двух сценариях:
any → T— type-check для значений top-type’аany. Возвращаетbool(или используется как pattern в match).Sum → Variant— variant-check для sum-значений: «является ли это значение конкретным вариантом sum-типа?» (revision v2).
На остальных «обычных» типах (record без вариантов, primitives,
аносу́ты) is — ошибка компиляции: тип известен статически, проверка
бессмысленна.
Сценарий 1: any is T
Boolean-выражение:
fn dump(x any) Io -> () =>
if x is int { println("got int") }
if x is str { println("got str") }
Pattern в match:
match arg {
n is int => process_int(n) // биндинг + smart cast
s is str => process_str(s)
is bool => println("bool") // без биндинга
_ => throw UnsupportedType
}
Pattern-форма: <binding> is <type> или is <type> (без биндинга).
Smart cast в if:
fn process(x any) -> str =>
if x is str {
x.upper() // x здесь имеет тип str автоматически
} else if x is int {
str.from(x) // x здесь int (D73)
} else {
"unknown"
}
После if x is T { ... } внутри блока компилятор автоматически
уточняет тип переменной до T (Kotlin smart cast). Работает если
переменная не переприсваивается в блоке.
Сценарий 2: <sum> is <Variant>
is работает на любом sum-значении, проверяя соответствие конкретному
варианту:
type Shape enum Circle { radius f64 } | Square { side f64 } | Origin
ro s Shape = Circle { radius: 1.0 }
if s is Circle { println("circular") } // ✅ true
if s is Square { println("squarish") } // ✅ false
if s is Origin { println("at origin") } // ✅ unit-вариант
// Также для prelude sum-типов:
ro r Result[int, str] = Ok(42)
if r is Ok { println("happy path") } // ✅
if r is Err { handle_error() } // ✅
ro opt Option[User] = Some(u)
if opt is Some { ... }
if opt is None { ... }
Без биндинга — is это просто bool. Для извлечения значения
из варианта используется if let (D34), который комбинирует check
и binding в одном выражении:
// Без биндинга — только yes/no:
if r is Ok { println("ok") }
// С биндингом — if let:
if Ok(n) = r { use(n) }
Это даёт чёткое разделение:
is= «yes/no» (короткий guard).if let= «yes + extract» (binding form).
Поэтому is не поддерживает binding-форму на sum-типах —
r is Ok(n) ошибка, нужно if let Ok(n) = r. Это согласовано
с D9 «один очевидный путь»: одна форма для одной задачи.
Реализация: компилятор знает теги вариантов и эмитит
runtime-проверку tag’а sum-struct’а (shape->tag == NOVA_TAG_Shape_Circle).
Стоимость — одно сравнение integer’ов.
На не-sum / не-any — ошибка компиляции:
type User { id u64 }
fn process(x User) -> () =>
if x is int { ... } // ОШИБКА: User — record, не sum и не any
Методы на any для extraction (комплементарные is)
Для if let-стиля и работы через эффект Fail:
// Опциональный cast — Option[T]
fn any.try_as[T](x any) -> Option[T] =>
// runtime-проверка тэга, Some если совпал, None иначе
// Cast через Fail — для строгих случаев
fn any.as[T](x any) Fail[TypeMismatch] -> T =>
// throw TypeMismatch если тег не совпал
Использование:
// if let
if Some(n) = arg.try_as[int]() {
process_int(n)
}
// ?-стиль
ro n int = arg.as[int]?
Три инструмента под разные сценарии:
| Способ | Когда применять |
|---|---|
match { is T => ... } | несколько вариантов, exhaustive обработка |
if let Some(n) = x.try_as[T]() | один-два типа, mostly happy path |
let n = x.as[T]? | один тип, ожидается этот тип; несовпадение — ошибка |
Почему
Раздельные as и is — два разных вопроса
as — «как сделать значение типа T» (compile-time, статически
решаемая задача). is — «какой тип у значения сейчас» (runtime,
нужен для top-type extraction).
В языках, использующих один оператор для обоих (Swift as/as?/as!,
C++ static_cast/dynamic_cast), программист путается. В Nova
разделение явное — два keyword’а с непересекающимися ролями.
is для any и sum-типов — без overhead на остальных типах
is работает там, где runtime-tag уже есть структурно:
any-значения содержат tag дискриминирующий конкретный тип (boxing-цена для top-type — обязательная).- Sum-типы содержат tag дискриминирующий вариант (это часть
layout’а sum-struct’а —
tag + payload).
Для record/primitives/protocol — tag’а нет, и is ошибка компиляции:
тип уже известен статически, проверка бессмысленна.
В Kotlin/C# is T работает на любом типе через RTTI (Runtime
Type Information) — каждое значение несёт type-tag. Это глобальный
overhead. Nova избегает этого: is использует существующие теги
(any-boxing, sum-discriminant), не добавляет новых. Поэтому стоимость
is localized.
Sum-вариант check vs match:
// Короткая форма для yes/no:
if shape is Circle { return "round" }
// Полная форма с biding'ом:
if Circle(r) = shape { use(r) }
// Exhaustive обработка:
match shape {
Circle(r) => ...
Square(s) => ...
Origin => ...
}
Каждая форма для своего сценария: is — guard, if let — guard +
extract, match — exhaustive multi-way.
Smart cast — стандартная эргономика
if x is T { x.method_of_T() } без явного re-binding — фича Kotlin,
TypeScript narrowing, C# pattern matching, Swift binding-pattern. Все
сообщества любят smart cast, и этого не избегают.
Прецеденты ключевых слов
as: Rust, Swift, C#, Kotlin, TS — для cast (numeric и иначе). Nova берёт это значение.is: C# (x is T), Kotlin (x is T), TS (typeof/instanceof, но неis—isв TS это type predicate). F# использует:?, что менее красиво. Nova берёт C#/Kotlin-стиль.
Что отвергнуто
- Один оператор для cast и type-check (Swift
as?/as!). Усложняет mental model, путает пользователя. is Tдля любого типа без tag’а (Kotlin-style RTTI). Требует runtime-tag на всех значениях — глобальный overhead. Nova ограничена типами, у которых tag уже есть структурно (any-boxing, sum-discriminant). Для record/primitives — compile error.is Variant(binding)с биндингом на sum-типах. Дублируетif let Variant(binding) = expr(D34). Чтобы избежать двух форм для одной задачи —isбез binding,if letс binding.x.is[int]()метод вместо оператора. Менее читаемо в условиях (if x.is[int]()-запись хужеif x is int). Operator проще.asдляany → Tбез runtime-проверки. Type-небезопасно (программист может написатьx as intдляx anyбез гарантии). Используйтеisилиtry_as[T].- Implicit cast между типами без
as. Все конвертации явные. - Flow-sensitive narrowing на
!isв MVP. Дляif !(x is T) { return }после блокаxне уточняется автоматически. Можно расширить позже.
Цена
- Два keyword’а в синтаксисе языка вместо одного.
isранее не использовался — теперь зарезервирован. - Runtime-tag для
any-значений — стоимость в реализации (memory overhead на boxing). - Smart cast требует поддержки в type-checker — переменная имеет разный тип в разных ветках одной функции. Усложняет реализацию.
try_as[T]()иas[T]?— два метода stdlib наanyповерх оператораis. Нужно зафиксировать в prelude (D26).
Связь
- 02-types.md → D52 — newtype, sum, discriminants —
типы, для которых
asопределён. - 02-types.md → D53 —
anyкак пустой protocol-тип, для которого работаетis. - D44 — numeric
as-cast (100 as u32) как частный случай D54. - D34 —
if let Some(n) = x.try_as[T]()используетif let-форму. - D19 —
=>в match-arms,is-pattern наследует ту же стрелку. - 08-runtime.md → D26 —
try_asиasметоды наanyв prelude.
Открытые вопросы
- Flow-sensitive narrowing на
!is— можно ли послеif !(x is T) { return }уточнять тип в продолжении функции? Отложено. isдля protocol-types (runtime structural check) — дорого, не входит в MVP.isдля error/cancel-detection вResult[T, E].r is Errработает (variant check), но иногда хочется проверить конкретный payload —r is Err(NotFound). Сейчас это не поддерживается (binding запрещён), нужноif let Err(NotFound) = r.
Эволюция
v1: is работал только для any-значений. Sum-варианты
проверялись через match или if let — короткой is-формы не было.
Это вынуждало писать convention @is_circle() методы для часто
проверяемых вариантов, что засоряет API типов.
v2 (текущая, 2026-05-06): is расширен на sum-варианты —
shape is Circle работает. Cтоимость localized: tag для sum уже
есть в layout’е, никакого нового runtime-overhead’а. Биндинг-форма
не добавлена — это работа if let (D34); чёткое разделение
ролей: is = yes/no, if let = yes + extract.
Это убрало нужду в @is_X convention’ах из syntax.md.
Эволюция
До D54 as использовался без формального D-решения (упоминался в
D44, D52). D54 фиксирует семантику явно: as — compile-time
конвертация; is — runtime type-check. Закрывает Q-any-extract
(извлечение типа из any-значения).
Реализация is/try_as на any (Plan 174.3, 2026-07-04)
v1 any-downcast (был спроектирован, но не реализован в codegen —
any не имел рабочего value-представления) теперь реализован поверх
type_id-реестра Plan 61.
Runtime-представление any (D53). any — тип-стёртый void*,
указывающий на heap-boxed NovaAny:
typedef struct { NovaTypeId type_id; const char* name; } NovaTypeInfo;
typedef struct { const NovaTypeInfo* info; void* data; } NovaAny;
info — per-type статический NovaTypeInfo (несёт type_id из реестра
Plan 61 + имя для Display/диагностик); data — отдельная GC-allocation с
копией payload. И box, и payload сканируются консервативным GC. any
остаётся void* в ABI (нулевой blast-radius на erased-void*-код:
print/println, generic-параметры).
Операции:
- upcast
T → any(boxing):nova_any_box(&NOVA_TYPEINFO_<T>, &v, sizeof(T)). Явныйv as anyи неявный по D53-supertype — аргумент кany-параметру,ro x any = <concrete>,fn … -> any => <concrete>— боксируются автоматически. x is T(x: any):nova_any_is(x, NOVA_TID_<T>)— сравнениеtype_id.x.try_as[T]() -> Option[T]: при совпаденииtype_id—Some(*(T*)nova_any_data(x)), иначеNone.- flow-narrowing
if x is T { … }: внутри блокаxуточняется доT(Kotlin smart-cast) — payload-deref, aliased через#define.
Диагностика [E_IS_NON_ANY] (чекер): is на операнде, чей тип статически
известен как не-any и не-sum (record / примитив) — ошибка компиляции
(проверка бессмысленна). Чистый span+код, не CC-FAIL.
Отложено (followup [M-174.3-*]): форма x.as[T]? (Fail-downcast —
парсер не принимает .as member, as — ключевое слово); match { n is T => … }
pattern-форма (реализована операторная/if-форма + try_as); гетерогенные
[]any + Display (Ф.3).
D58. Range-литерал, Iter[T] protocol, for x in c implicit iter
Amend (Plan 138): правило
for x in cизменено —iter()всегда первым. Итераторы обязаны реализовыватьiter() -> Self(trivial).Amend (Plan 152.1 / D250, 2026-06-13):
for c in s(s: str) →charчерезstr @iter() => @as_chars()(CharsIter, codepoint-итератор). Default-единица итерации строки — codepoint (как Gofor range, Swiftfor c in s). Codegen: str’s C-типnova_str(lowercase, lang-item) маппится в for-in на ключ “str” → Case 2 синтезируетs.iter(). Байтовая итерация — явноfor b in s.as_bytes().
Что
Три связанных правила, объединённых одним D-блоком, потому что они взаимно поддерживают друг друга:
a..bиa..=b— литералы Range в любой expression-позиции (не только вfor). Open-ended формыa..,..b,..=b,..— расширение Plan 96 (D144): только в slice- context (arr[range]). В materialize / for-loop / quantifier / parallel-for — compile-error (нужна bounded форма).Iter[T]— структурный protocol в prelude (D26):protocol { mut next() -> Option[T] }. Любой тип с таким методом — итератор.for x in c— всегда черезiter(). Десугаринг:{ mut _it = c.iter(); loop { match _it.next() { Some(x) => body, None => break } } }. Итераторы реализуютiter() -> Self(trivial) — единая точка входа.
Правило
Range-литералы
ro r1 = 0..5 // Range { start: 0, end: 5 } half-open [0, 5)
ro r2 = 0..=5 // Range { start: 0, end: 6 } нормализован компилятором
ro r Range = 1..10 // в ro-binding'е работает
fn count(r Range) -> int => r.end - r.start
count(0..100) // в позиции аргумента работает
ro ranges []Range = [0..5, 10..20, 100..200] // в массиве
a..b — синтаксический сахар, разворачивается компилятором в
Range { start: a, end: b }. a..=b → Range { start: a, end: b+1 }
(нормализуется, inclusive не хранится).
Конструкторов Range.exclusive/Range.inclusive нет — лишний API.
Range — value record (Plan 138 Ф.0.3):
export type Range value {
ro start int
ro end int // half-open: end НЕ включён
}
Stack-allocated, 16 bytes. Методы: @iter(), @contains(x), @len(), @is_empty().
Next[T] + Iter[I] протоколы (D241+D242)
Конвенция: имя протокола = магический метод.
// Итератор — умеет выдавать следующий элемент.
export type Next[T] protocol {
mut @next() -> Option[T]
}
// Источник итератора — умеет превращаться в итератор.
// I — конкретный тип итератора (реализует Next[T] для некоторого T).
export type Iter[I] protocol {
@iter() -> I
}
Итераторы реализуют оба протокола: Next[T] + Iter[Self] (trivial => self).
Коллекции реализуют только Iter[SomeIter].
// Итераторы:
fn RangeIter mut @next() -> Option[int] => ...
fn RangeIter @iter() -> RangeIter => self // Iter[RangeIter]
fn VecIter[T] mut @next() -> Option[T] => ...
fn VecIter[T] @iter() -> VecIter[T] => self // Iter[VecIter[T]]
// Коллекции — только @iter():
fn Range @iter() -> RangeIter => ... // Iter[RangeIter]
fn Vec[T] @iter() -> VecIter[T] => ... // Iter[VecIter[T]]
Generic bound для «принять любой iterable»:
fn collect[C Iter[I], I Next[T], T](c C) -> Vec[T] {
mut result = Vec.new()
for x in c { result.push(x) }
result
}
for x in c — implicit iter
for-loop принимает любое выражение справа от in, разворачиваясь
по правилу:
for x in c { body }
десугарится компилятором двухфазно (как Rust IntoIterator + Iterator):
Фаза 1: mut _it = c.iter() // всегда вызвать iter() один раз
Фаза 2: loop { match _it.next() { Some(x) => body, None => break } }
Правило:
- Если
cимеетiter()— вызвать, получить итератор, перейти к фазе 2. - Если
cне имеетiter()но имеетnext()— fallback: использовать напрямую (backward compat; рекомендуется добавитьiter() -> Self). - Иначе — ошибка компиляции.
Итераторы реализуют iter() -> Self — поэтому for x in iter_obj тоже
работает через единый путь: iter_obj.iter() возвращает тот же объект,
фаза 2 вызывает next(). Нет бесконечного цикла: компилятор вызывает
iter() ровно один раз (фаза 1), дальше только next().
ro v []int = [1, 2, 3]
for x in v { ... } // v.iter() → VecIter → next()
ro r = 0..5
for x in r { ... } // r.iter() → RangeIter → next()
for x in 0..5 { ... } // то же
mut it = v.iter()
for x in it { ... } // it.iter() → self → next() (trivial)
Почему
- Range как expression — естественно. В for-loop
0..nуже работает. Расширение на любую expression-позицию устраняет асимметрию: «range можно в for, но не в let». Прецедент Rust, F#, Haskell, Scala. Iter[T]как protocol — fits structural typing. Никакого специального механизма, обычный protocol с одним методом. Прецедент RustIterator-trait, OCamlSeq.t, Python__iter__.for x in cбез.iter()— стандарт mainstream. Kotlin, Swift, Python, C#, Rust (черезIntoIterator) — везде sugar. Только Go требуетrange-keyword.- AI-friendly.
for x in cкороче, чемfor x in c.iter(). Меньше boilerplate, меньше ошибок «забыл.iter()».
Что отвергнуто
- Range только в for-loop (текущая ситуация до D58). Ограничивает использование — нельзя передать range как аргумент, сохранить в переменную.
Rangeкак примитив языка (без Range-типа в stdlib). Полезно, но изоляция от системы типов хуже — нельзя добавить методы, написать функцию, принимающую Range.for x in cстрогое — только Iter[T] (без implicititer()сахара). Программист пишетfor x in v.iter()каждый раз, избыточно.for-inчерез специальный keyword (Gorange). Лишний синтаксис, нет преимущества над implicit iter через protocol.
Цена
- Range type в prelude. Расширение D26 (prelude растёт).
a..bкак expression. Парсер должен пониматьa..bв любой expression-позиции, не только в for. Лёгкая правка грамматики.for-in-сахар. Компилятор делает desugaringfor x in c→ выборc.iter()vs использованиеcнапрямую. Простое правило, но требует type-resolution.Iter[T]имя. Короткое, но конфликтует с потенциальными user-defined type’амиIter. Согласовано с D30 (типы PascalCase).
Связь
- 02-types.md → D42, D53
—
Iter[T]как обычный protocol через структурную типизацию. - D38 —
0..nкак range-выражение в существующем синтаксисе for-loop. - 08-runtime.md → D26 —
Iter[T],Range,RangeIterв prelude. - 02-types.md → D355 — blanket methods на
Next[T]implementors:fn[I Next[T]] I @mдиспетчируется на любойCimplNext[T]; ≤1 impl инвариант (D355 §4, ex-D282). Cross-ref: D241+D242 (Plan 161 Ф.4).
Открытые вопросы
- Reverse range (
5..0или(0..5).reverse()) — что значит range сstart > end? Пустой? Идущий назад? — открытый Q-range-extras. (0..5).step(n)— step-итерация. Q-range-extras.collect[Out]()generic-collection-construction — требует bound’ов (Q-bounds) и static-method-protocol. Q-collect-mechanism.- Type-as-value (передача типа как значения,
xs.collect([]int)) — отдельный вопрос Q-type-as-value. — ✅ RESOLVED Plan 108.4 (2026-06-09).@-префикс в protocol-методах@обязателен перед instance-методами в protocol declarations.ro/mut/consumeprefix перед@для receiver mutability. Default =ro. Type-checker enforces impl match. См. D209.Static-метод в protocol через— ✅ RESOLVED Plan 97 (2026-05-23). Leading-точка.method()-префикс.method(args) -> Retвprotocol {}теле помечает метод статическим (симметрично D35fn Type.name); реализация ожидается черезfn Type.method(...).From/TryFromобновлены под новый синтаксис (.from(t T) -> Self/.try_from(t T) -> Result[Self,E]). Hard-enforcement static↔instance mismatch — followup.
⚠️ D58 AMENDED by Plan 108.4 (2026-06-09) — Protocol declaration of
Iter[T]/Iterable[T]now usesmut @next() -> Option[T](explicit@
mutreceiver). The@-prefix is required for all protocol instance-methods (D209). Use-site structural conformance (for-in loop,[T Iterable[U]]bound) checks receiver_mut: a type that declares@next()(ro) does NOT satisfyIterable[T]. Bootstrap parser limitation comment instd/prelude/collections.nvhas been removed. All existing implementers (Counter,RangeIter,VecIter, etc.) already usedmut @next()— no implementer changes required.
D59. Array, tuple и позиционные partial patterns
Что
Pattern matching на массивах ([]T), кортежах ((A, B)) и
позиционных конструкторах sum (Cons(T, T')). Покрывает разрозненные
фичи, которые уже использовались в examples ([], [r],
[_, ..], Cons(..)), но не были формально зафиксированы.
.. (rest-pattern) — единый маркер «остальные элементы игнорируются»
во всех трёх контекстах: record ({ field, .. } — D17/D52),
позиционные конструкторы (Cons(..), Click(x, ..)), массивы
([head, ..], [.., last], [a, .., z]).
Правило
Array patterns
match xs {
[] => "empty" // пустой массив
[x] => "one: ${x}" // ровно 1 элемент, bind в x
[a, b] => "two: ${a}, ${b}" // ровно 2
[a, b, c] => "three: ..." // ровно 3
[head, ..] => "first: ${head}" // ≥1, bind первого
[.., last] => "last: ${last}" // ≥1, bind последнего
[a, .., z] => "first/last: ${a}, ${z}" // ≥2, bind первого+последнего
[_, ..] => "non-empty" // ≥1, без bind
[_, _, third]=> "exactly third" // ровно 3, bind третьего
_ => "other" // wildcard
}
Правила:
- Ровные позиции (
[a, b],[a, b, c]) — соответствуют точной длине. ..rest-pattern — означает «0 или больше элементов». Допустим в позициях:[items, ..]— head + остальное.[.., items]— остальное + last.[a, .., z]— head + middle (игнорируется) + last.
..itemsс биндингом — biind остатка как массива:match xs { [head, ..rest] => process(head, rest) // rest : []T [.., last] => last // без bind остального }_placeholder — игнорировать один элемент, точно как в record.- Не более одного
..в массиве-pattern — иначе ambiguous (Rust то же правило).
Tuple patterns
ro p = (1, "alice", true)
match p {
(1, _, true) => "first variant"
(n, name, _) => "n=${n}, name=${name}"
_ => "other"
}
ro (a, b, c) = (1, 2, 3) // destructuring ro
ro (x, _, z) = (1, 2, 3) // ignore middle
Правила:
- Tuple-pattern соответствует точно — длина фиксирована типом.
..в tuple запрещён (длина известна на этапе типизации,..не нужен).- Деструктуризация в
letчерез tuple-pattern — поддерживается.
Positional sum-variant partial-pattern
type LinkedList[T] | Empty | Cons(T, LinkedList[T])
match list {
Empty => "nil"
Cons(h, _) => "head only" // явный _ для tail
Cons(..) => "non-empty" // partial: оба поля игнорируются
Cons(h, ..) => "head: ${h}" // bind первого, остальное ..
}
type Event enum Click(int, int) | Move(int, int, int) | Idle
match event {
Idle => "idle"
Click(..) => "click"
Move(x, ..) => "move at x=${x}"
Move(.., z) => "move with z=${z}"
_ => "other"
}
Правила:
..в позиционном конструкторе работает так же, как в массиве: head/tail/middle-rest.- Один
..на конструктор. - Согласовано с D17/D52 partial-pattern для record-форм.
Почему
- Используется в examples.
effect-density/repository.nv,orm_demo.nv,stdlib_linkedlist.nvуже активно применяют[],[r],[_, ..],Cons(..). Без формализации парсер не знает грамматику, LLM не знает правила, code review не имеет опоры. - Прецедент Rust. Array/tuple/sum-positional patterns в Rust
имеют точно такой синтаксис (
[],[head, ..],[.., tail],Variant(..)). Программисты с Rust-фоном узнают мгновенно. - Единый
..для всех partial-форм. Record (D17/D52), позиционный sum, массив — везде..означает «остальное игнорируется». Один концепт. - Tuple destructuring в
let— стандартная фича современных языков (Rust/Swift/Kotlin/Python).
Что отвергнуто
Cons(_, _)как единственная форма для позиционного sum. Шумно для конструкторов с 3+ полями (Move(_, _, _)). С..→Move(..).- Cons-list pattern (
head :: tail) для массивов, как в Scala/OCaml. Nova не имеет cons-семантики массивов —[]Tэто slice, не linked list. Используем bracket-syntax. - Multiple
..в одном pattern ([a, .., b, .., c]). Ambiguous — какое..сколько элементов берёт? Запрещено. ..в tuple-pattern. Длина tuple фиксирована,..не несёт информации. Запрещено для строгости.- Slice-binding
[head, ..rest]с типомrest : []T— частично отложено. Bind через..items(без значения по умолчанию) поддерживается. Расширения вроде[a, b, ..rest, c, d](rest в середине с bind) — не в MVP.
Цена
- Парсер усложняется — три новых формы pattern (array, tuple, positional-rest). Стандартное расширение, прецедент Rust.
- Exhaustiveness check для массивов сложнее. Длина
динамическая, компилятор не может проверить «все случаи покрыты»
как для sum-вариантов. Wildcard
_обязателен в array-match, если не покрыты все возможные длины (которых бесконечно). Это как в Rust. ..itemsslice-binding требует runtime-аллокации сегмента массива (rest : []T). В zero-copy случае —restэто slice (start, len). Согласовано с D32 (slice-семантика).
Связь
- D17, D52 — partial-pattern
..для record-форм. D59 расширяет на массивы и позиционные конструкторы. - D27 —
[]Tкак тип, на котором работают array-patterns. - D34 —
pattern-bind в условиях; array/tuple-patterns доступны и в
if let/while let. - Закрывает Q-positional-partial-pattern.
Открытые вопросы
[a, b, ..rest, c]— rest в середине с bind. Не в MVP.- Slice-bind на массиве с
[]int.alloc(...)vs zero-copy slice — деталь runtime, не дизайн. - String-as-array patterns (
match s { "hello" => ..., _ => ... }— strings как массивы char) — отдельный вопрос Q-string-patterns.
D60. Spread ...x в литералах: массив и record
Что
Оператор ... (три точки) внутри array- и record-литералов
вставляет элементы/поля из существующего значения. Двойственная
к D59 partial-pattern: D59 разбирает, D60 строит.
ro arr1 = [1, 2, 3]
ro arr2 = [0, ...arr1, 4] // [0, 1, 2, 3, 4]
ro user1 = User { id: 1, name: "alice", email: "a@x.com" }
ro user2 = { ...user1, name: "bob" } // copy + override name
Правило
Array spread
ro a = [1, 2, 3]
ro b = [4, 5]
ro c = [...a, ...b] // [1, 2, 3, 4, 5]
ro d = [0, ...a, ...b, 6] // [0, 1, 2, 3, 4, 5, 6]
ro e = [...a] // копия (не reference)
Правила:
- Источник
...srcдолжен быть[]T, гдеTсовпадает с типом элементов целевого массива. - Несколько spread’ов в одном литерале разрешены:
[...a, ...b, ...c]. - Смешивание spread и обычных элементов — в любом порядке:
[1, ...a, 2, ...b, 3]. - Стоимость: O(total length) — концептуально concatenation. Компилятор может оптимизировать (пред-аллокация по сумме длин).
Record spread
type User { id u64, name str, email str, role str }
ro alice User = { id: 1, name: "alice", email: "a@x.com", role: "user" }
// Override одного поля:
ro alice2 = { ...alice, name: "ALICE" }
// Override нескольких:
ro admin_alice = { ...alice, role: "admin", email: "admin@x.com" }
// Все поля из spread — то же значение:
ro copy = { ...alice } // эквивалентно alice (но новый record)
Правила:
- Источник
...srcдолжен быть того же типа, что и target (или иметь совпадающее множество полей). - Override: явные
field: valueпосле...srcперезаписывают значения из spread. Порядок в литерале — left-to-right.ro r = { ...src, name: "new", ...override, id: 99 } // ↑ ↑ ↑ ↑ // src.все override("name") override.все override("id"=99) - Все required-поля должны быть покрыты — компилятор проверяет. Если spread + явные не дают полного покрытия — ошибка.
- Один spread на record-литерал в MVP.
{ ...a, ...b }— отложено (нужны правила приоритета). - Тип источника: в MVP — строго тот же тип, что target. В будущем — может быть подтип/совпадение по полям (требует structural-subtyping, Q-anonymous-union).
Совместимость с D52 literal coercion
type User { id u64, name str }
ro u User = { id: 1, name: "alice" } // D52 record-coercion
ro u2 User = { ...u, name: "bob" } // D60 spread + D52 coercion
ro u3 User = { ...u } // полный copy через spread
В позиции с явным целевым типом spread работает с D52-coercion: имя типа подразумевается из аннотации.
Совместимость с D17/D52 field punning
ro name = "bob"
ro u User = { ...other, name } // shorthand + spread
Field punning (D52) работает после spread — если имя поля совпадает с переменной в scope, shorthand обязателен.
Почему
-
Immutable update. В функциональном стиле (доминирующем в Nova:
mutчерез эффект, GC по умолчанию) immutable-обновление record — частая операция. Без spread:ro u2 = User { id: u.id, name: "bob", email: u.email, role: u.role }С spread:
{ ...u, name: "bob" }. Краткость + защита от ошибок (если вUserдобавилось поле, программист не должен обновлять каждый use-site). -
Concatenation массивов.
[head, ...rest]— элегантнее[head].concat(rest)или ручного цикла. -
Прецедент TypeScript.
...spreadмассово используется в современном TS/JS. Программисты знают. -
Симметрия с D59 partial-pattern. D59 разбирает значение через
.., D60 строит через.... Концептуально — две стороны одной медали. Разные токены (..vs...) убирают синтаксическую путаницу. -
AI-friendly. LLM генерирует
{ ...other, name: "bob" }— очевидное намерение, нет boilerplate.
Что отвергнуто
..(две точки) для spread (Rust struct-update style). Конфликт с range-литералом (D58) и rest-pattern (D59). Парсер мог бы различать по контексту, но...(три точки) однозначен и согласован с TS-прецедентом.*arr/**obj(Python-style). Два разных оператора для array vs record — лишнее. Один...для всего.{ src with name = "bob" }(OCaml-stylewith-keyword). Новый keyword, менее знакомый, не симметричен с array-spread.- Multiple record-spread
{ ...a, ...b }в MVP. Семантика «правый перезаписывает» интуитивна, но требует продумать edge-cases (что если поле есть в обоих и target требует один тип — компилятор должен проверить). Отложено до measured-need. - Spread в pattern-position (
match xs { [1, ...rest, 5] => ... }). D59 уже даёт[head, ..rest]через две точки — отдельный механизм для destructuring....остаётся только для construction. - Spread с подтипом. В MVP target и source строго одного типа. Расширение — Q-spread-subtype.
Цена
- Парсер расширяется —
...exprв array/record литералах. Стандартное расширение, прецедент TS. - Type-checker проверяет покрытие required-полей при spread в record. Не сложнее, чем уже есть для D55 literal coercion.
- Runtime cost array-spread — O(total length). Программист знает (концептуально concat).
- Runtime cost record-spread — O(field count) копирование полей. Минимально, по аналогии с обычным record-литералом.
Связь
- D52 — record-coercion. D60 расширяет: spread в позиции с явным типом тоже coerce’ится.
- D17/D52 field punning —
{ ...src, name }shorthand работает после spread. - D58 —
..(две точки) для range. D60 использует...(три точки) для spread — разные токены, нет конфликта. - D59 —
partial-pattern
..в destructuring. D60 — spread...в construction. Двойственные операции, разные синтаксисы. - D27 —
[]Tкак тип, на котором работает array-spread.
Открытые вопросы
- Multiple record-spread (
{ ...a, ...b, ... }) — отложено. - Spread с подтипом/совпадением полей — Q-spread-subtype.
- Spread в tagged template literal args — нет в MVP, не нужен.
- Tuple-spread (
(1, ...t, 5)) — длина tuple фиксирована типом, spread даёт компилятору всю информацию. Не вводится в MVP за ненадобностью.
D69. Variadic-параметры через ...items []T
Что
Последний параметр функции может быть помечен префиксом ... —
параметр объявляет, что на call site его можно вызвать одним из
двух способов:
- Через spread существующего массива:
f(...arr). - Через отдельные элементы:
f(a, b, c)— компилятор соберёт их в[]T.
Тип параметра — обычный []T. Внутри функции items это []T,
никакой специальной семантики.
Правило
Декларация
fn print[T](...items []T) Io -> () {
for x in items { // items: []T внутри функции
Io.write(str.from(x))
}
}
fn fmt(template str, ...args []str) -> str {
// template — обычный параметр; args — variadic []str
...
}
Грамматика:
param = [ '...' ] name type
... допустим только перед последним параметром. Тип после ...
обязан быть []T (или []Type любой формы) — не element type.
Call site
// Способ 1: spread массива
ro names = ["alice", "bob"]
print(...names) // эквивалентно print("alice", "bob")
// Способ 2: отдельные элементы
print("alice", "bob") // компилятор собирает в ["alice", "bob"]
// Микс — spread в любой позиции после обычных аргументов
print("prefix", ...names, "suffix")
// ↑ ↑ ↑
// обычный spread обычный
// → результат: ["prefix", "alice", "bob", "suffix"]
Spread на call site можно использовать только для variadic-параметра.
Для обычного items []T параметра spread не разрешён —
программист передаёт массив явно: f(["a", "b"]).
Семантика
...items []Tв декларации — это синтаксический marker, не новый тип. Типitemsэто[]T.- На call site spread
...arrразворачиваетarr: []Tв позиционные аргументы. - Без spread’а: компилятор собирает все аргументы в
[]Tнеявно (compile-time, zero overhead). - Только последний параметр может быть variadic — упрощает парсинг и неоднозначности.
- Type checking: каждый аргумент проверяется против element type
T; spread-выражение должно иметь тип[]T.
Generic-variadic
fn first[T](...items []T) -> Option[T] {
if items.len() == 0 { None } else { Some(items[0]) }
}
first(1, 2, 3) // T = int
first("a", "b") // T = str
first(...["x", "y"]) // T = str через spread
T выводится из элементов или spread-массива.
Heterogeneous-variadic через any
Когда нужен print("count=", 42, " items") (разные типы):
fn print(...items []any) Io -> ()
any — top-type из D54. Каждый элемент конвертируется в
строку через str.from(v) (D73). Это разрешает
print принимать смешанные типы без T-параметра.
Что НЕ делается
- Variadic не последним параметром (
fn f(...xs []int, last str)). Усложняет грамматику без выгоды; в крайнем случае программист переставляет параметры. - Несколько variadic-параметров — нет смысла.
- Keyword args (Python
**kwargs) — отдельная фича, не нужна для variadic use-case. - Postfix-синтаксис как в Go (
items ...string). Префикс...единый для всех spread’ов в Nova (D60 для массивов, D69 для variadic) — symmetric. - Element-type как в Go (
...items T). Декларация показала бы «items: T» с magic-преобразованием в []T. Nova предпочитает явный array-type без скрытой обёртки.
Почему
- D60 symmetry. В литералах массивов уже используется prefix
...arrдля spread. Variadic-call-spreadf(...arr)— та же форма. - D40 «один способ». Нет «двух типов в одной декларации»
(element vs array как в Go). Тип параметра =
[]T, конец. - TypeScript-прецедент. Самый популярный variadic-синтаксис в современных языках, LLM знает.
- AI-friendly. Сигнатура
(...items []T)сразу показывает:...→ variadic;[]T→ точный тип параметра;- element type выводится естественно.
- Минимальные изменения грамматики. Парсер уже распознаёт
...в spread-литералах (D60). Расширение на параметры функции — маленькое дополнение.
Что отвергнуто
- Без variadic вообще (всегда явный
f([a, b, c])). Отвергнуто: частые отладочныеprint(...)стали бы шумнее. Variadic — конкретное улучшение DX. - Macro-style (
println!-как-в-Rust). Отвергнуто: у Nova нет macro-системы; добавлять её только ради variadic — overkill. - Variadic через Java-style autoboxing (
Object...). Отвергнуто: no implicit boxing в Nova; используемanyявно.
Связь
- D60 — spread
...arrв литералах массивов и record’ов; D69 распространяет на параметры функций. - D54 —
anyдля heterogeneous-variadic. - D27 —
[]Tкак тип параметра. - 08-runtime.md → D26 —
print/printlnтеперь имеют сигнатуруfn print(...items []any) Io -> ().
Эволюция
Bootstrap-stdlib изначально имел print как Native-функцию принимающую
переменное число аргументов (Rust-side &[Value]), но в спеке D26
определял fn print(s str) — fixed arity 1. Это был drift между
implementation и spec.
D69 фиксирует variadic как полноценную фичу языка и приводит сигнатуру
print к fn print(...items []any) Io -> ().
D83. Keywords строго запрещены как identifier’ы
Что
Зарезервированные слова языка (fn, type, let, mut, if, for,
while, in, match, use, import, export, и др.) не могут
использоваться как имена переменных, полей, параметров, типов,
методов, импортов или любых других user-defined identifier’ов.
Никаких escape-механизмов не предусмотрено.
Закрывает Q-keywords-as-fields вариантом 1 (строгий запрет).
Правило
Полный список зарезервированных слов
Декларации: module, import, use, export, external, fn,
type, protocol, effect, handler, alias.
Bindings: let, const, mut, readonly.
Control flow: if, else, match, for, while, loop, in,
return, break, continue.
Effects/concurrency: with, throw, interrupt, forbid,
realtime, spawn, supervised, parallel, detach, blocking,
select.
Cleanup (D90): defer, errdefer.
Operators (как слова): as, is, and, or, not.
Литералы: true, false.
Test: test.
Special: Self (D66), _ (wildcard / discard).
Что запрещено
// все следующие — compile error «expected identifier, got keyword `X`»
ro if = 5 // ✗
mut while = 0 // ✗
type Queue[T] {
in []T // ✗ — «expected identifier, got `in`»
}
fn process(match int) -> int => // ✗ — параметр не может быть `match`
match * 2
fn export() -> int // ✗ — `export` зарезервировано
import std.use // ✗ — `use` в module path
Что разрешено
Зарезервированные identifier’ы (D26 prelude — Self, any,
never, Option, Some, None, Result, Ok, Err, Error,
int, f64, etc.) — это обычные имена в prelude scope, не
keyword’ы. Программист может переопределить локально (см.
overview.md «Зарезервированные identifier’ы»),
но это анти-паттерн (lint выдаёт warning).
ro int_array []int = [1, 2, 3] // ✓ — `int_array` обычный identifier
fn shadow() {
ro int = "string" // ⚠️ shadow's prelude name (warning, не error)
println(int)
}
Контекстуальные keywords — отвергнуто
Альтернатива из Swift/C# (async, var, dynamic контекстные —
keyword только в специфичных позициях, иначе обычные identifier’ы)
не принимается в Nova. Все keyword’ы — глобально зарезервированы.
Escape-механизм (r#identifier, `identifier`) — отвергнуто
Альтернативы:
- Rust-style
r#fn— raw identifier черезr#префикс. - C#-style
@class— verbatim identifier. - Swift/Kotlin
`class`— backticks.
В Nova сейчас не предусмотрены. Программист переименовывает поле/переменную если оно конфликтует с keyword.
Когда может появиться: если накопится боль FFI с C-библиотеками
у которых функция называется match, или ORM/JSON-данные с keyword-
полями. До v1.0 — не вводим, после v1.0 — отдельный D-decision
(вероятно r#identifier Rust-style).
Backtick’и `...` в Nova уже заняты для tagged template
literals (D48 raw strings) — Swift-style `identifier` создаст
конфликт.
Почему
-
Простота парсера. Один-проход рекурсивного спуска, никакого lookahead’а для разрешения «keyword vs identifier».
-
AI-friendly. LLM никогда не путается между keyword и identifier. Никаких escape-форм для запоминания.
-
Читаемость. Программист видит
if— control flow. Видитclass— class. Никакихifкак имени переменной. -
Прецедент мейнстрима. Java, Go, C, Python — все строго запрещают. Default ожидание программиста.
-
Future-proof по версии. Без escape — добавление нового keyword’а это явный breaking change, программист видит compile error и переименовывает (как Rust 2018/2021 editions).
Что отвергнуто
-
Контекстуальные keywords (Swift/C# style). Сложнее парсер, AI-unfriendly. Прецедент Swift: contextual keywords постепенно становятся глобальными.
-
r#identifier(Rust-style). Полезен для FFI, но не приоритет в bootstrap’е. Можно добавить позже без breaking change. -
@identifier(C#-style). В Nova@занято (D35 self-method/field). -
`identifier`(Swift/Kotlin). Backtick’и заняты для raw strings (D48). Конфликт. -
Только-в-полях ослабление (например
mut in []Tразрешено посколькуinконтекстный дляfor x in iter). Отвергнуто — специальное правило для одного keyword’а нарушает D9.
Связь
- Q-keywords-as-fields — закрывается этим D-decision.
- D29 — module/import grammar.
- D30 — naming convention. D83 — жёсткое правило поверх D30.
- D48 — backtick’и заняты.
- D26 — prelude names — это identifier’ы, не keyword’ы.
Цена
- Sweep
std/collections/queue.nv— полеin []Tпереименовать вinputилиinputs. - Будущая FFI работа будет требовать обёртки если C-функция называется так же как Nova-keyword. Не блокер.
Эволюция
До D83 вопрос был open в Q-keywords-as-fields с тремя вариантами. D83 закрывает вопрос окончательно — Java/Go/C/Python style строгий запрет, без escape.
Если когда-либо в будущем (v1.0+) накопится FFI-боль — отдельный
D-decision вводящий r#identifier Rust-style. До v1.0 — строгий
запрет без escape.
D88. Default-значения generic-параметров
Что
Generic-параметры могут иметь default-значение через [T = Default]
или с bound’ом [T Bound = Default]. Default используется когда
компилятор не может вывести параметр из аргументов и программист не
указал его явно.
Закрывает Q-default-generic.
Триггер принятия — D87 (Effect[E, IRT = never]).
Правило
Базовый синтаксис
type Complex[T = f64] {
re T
im T
}
// Старые вызовы продолжают работать без [T]:
ro z = Complex.from(2.0) // T выводится как f64 (из default + arg)
ro z Complex = Complex.new(1.0, 2.0) // тип Complex без скобок ≡ Complex[f64]
// Новые — с явным параметром:
ro z32 Complex[f32] = Complex.new(1.0_f32, 2.0_f32)
С bound’ом
fn run[T Numeric = int](a T) -> T => a + 1
run(5) // T = int (вывод из аргумента)
run(5.0) // T = f64 (вывод из аргумента)
run[i64](5) // T = i64 (явно)
Грамматика для одного параметра: name [bound] [= default].
Семантика
| Случай | Что происходит |
|---|---|
Аргументы дают информацию о T | Inference побеждает default |
Аргументов нет / T не выводится / нет явной аннотации | Используется default |
Программист указал [T_value] явно | Default игнорируется |
fn first[T = int](xs []T) -> Option[T] { ... }
first([1, 2, 3]) // T = int (вывод из []int)
first[]([]) // ERROR: empty array, T не выводится
// default не применяется (тип элемента
// не из argument-type)
first[str]([]) // T = str (явно)
Несколько параметров
Параметры с default’ом должны идти после обязательных:
type HashMap[K, V, S = DefaultHasher] { ... } // ✅
type Bad[T = f64, U] { ... } // ❌ обязательный после default'а
Все default’ы могут быть опущены частично:
ro m HashMap[str, int] = ... // S = DefaultHasher
ro m HashMap[str, int, FxHasher] = ... // S явно
Default — это тип, не выражение
type X[T = f64] { ... } // ✅ default = тип
type Y[N = 10] { ... } // ❌ const-generic — отдельная фича, не входит
В D88 default — только тип. Const-generic (значения как параметры типа) — отдельная задача, не покрывается.
Default через bound
type Sorted[T Ord = int] { ... } // T должен реализовать Ord; если не указан — int
fn sort[T Ord = int](xs []T) -> []T => ...
Default-тип должен удовлетворять bound’у — компилятор проверяет это при объявлении.
Почему
- Backward-compat. Добавление generic к существующему типу/функции
= breaking change без default’ов. С default’ами — ноль ломаний:
// Раньше: type Complex { re f64, im f64 } // Теперь generic, но старый код работает: type Complex[T = f64] { re T, im T } ro z = Complex.from(2.0) // ← без правок - Default — не выбор для программиста. Это сокращённая запись, не два пути с разной семантикой. Нарушения D9 «один очевидный путь» нет — программист либо не пишет параметр (получает default), либо пишет (получает явное значение).
- Прецеденты: Rust (
Vec<T, A: Allocator = Global>), C++ (template<typename T = int>), TypeScript (Foo<T = string>). - Realistic consumer. D87
Effect[E, IRT = never]— главный практический use-case в Nova prelude.
Что отвергнуто
[T default int]keyword-форма — длиннее, без выгоды.- Const-generic в default’е (
[N = 10]) — отдельная фича, отложена. - Forward-references в default’е (
[T = SelfType]) — запрет: тип должен быть уже объявлен в момент парсинга generic-списка. - Default-параметры функции (
fn f(x int = 0)) — отдельная задача и отвергнута (history/rejected.md) в пользу опции-record + spread. D88 касается только generic-параметров типа.
Связь
- D16 — синтаксис
[T]. - D72 — generic bounds (
[T Hash]); D88 расширяет до[T Hash = SomeDefault]. - D52 — newtype/alias; D88 дополняет alias-механику (alias для конкретной инстанции, default — для самой частой).
- D87 —
Effect[E, IRT = never]главный consumer.
Эволюция
Зафиксировано 2026-05-10. Раньше — открытый вопрос
Q-default-generic, помечен
DEFERRED до появления реального consumer’а. Триггер — D87
параметризация Handler interrupt-типом.
Migration: ~10 примеров Effect[E] в spec/, где требуется
Effect[E, IRT] для interrupt-делающих handler’ов. См.
D87 миграция.
D90. defer и errdefer — scope-level cleanup statement
Закрывает Q20 «Нужен ли defer?».
⚠️ Амендмент (D189 ретракт + D314 defer-kernel):
errdefer(иokdefer/defer |result|) РЕТРАКТНУТЫ (D189); парсер отвергает их[D189-removed-*]. Замена — outcome-несущая формаdefer(o ScopeOutcome) { … }(D314, Plan 173 Ф.2): тело получает исходSuccess | Failure(reason) | Panic(msg);errdefer{b}≡defer(o){ match o { Failure(_)|Panic(_) => b, Success => () } }. Bounded lookahead различаетdefer(o ScopeOutcome){…}отdefer (expr);defer(o T)сT ≠ ScopeOutcome→[E_DEFER_OUTCOME_TYPE], лишние токены →[E_DEFER_OUTCOME_ARITY]. Секции ниже проerrdefer— historical. Плейнdefer(п.1) без изменений (byte-identical).
Что
Два keyword-statement’а для отложенного выполнения при выходе из текущего scope’а:
defer <body>— выполнить<body>при любом exit’е из enclosing scope (normal flow,return,throw,interrupt, panic).errdefer <body>— выполнить<body>только при exit’е через ошибку (throw/panic). При normal exit илиreturnerrdeferне выполняется.
Назначение — детерминированный cleanup (close, unlock, rollback) в языке без RAII-destructor’ов (D6 managed heap — нет detrministic destruction; см. цена D6).
Правило
Грамматика
statement = ...
| 'defer' body
| 'errdefer' body
body = expression
| block // { stmt1; stmt2; ... }
body — обычное выражение или block. Никаких params, никаких
=> — это statement, не closure.
Примеры
Простой defer:
fn read_config(path str) Fs Fail -> Config {
ro file = Fs.open(path)
defer file.close() // выполнится на exit из fn
ro raw = file.read_all()
Config.parse(raw)
}
Block-form:
fn process() Db Log -> () {
defer {
Log.info("done processing")
Metrics.record_completion()
}
Db.exec(...)
}
Несколько defer — LIFO (последний defer’нутый — первый выполнится):
fn nested() Fs -> () {
defer println("3") // выполнится последним
defer println("2")
defer println("1") // выполнится первым
// exit prints: 1, 2, 3
}
Scope-level (не function-level):
fn process() Fs Log -> () {
ro log_file = Fs.open("app.log")
defer log_file.close() // выход из fn
if condition {
ro temp = Fs.create_temp()
defer temp.cleanup() // выход из if-блока
write_to(temp)
} // <- здесь выполняется temp.cleanup()
// <- здесь выполняется log_file.close() при exit из fn
}
errdefer — откат при ошибке:
fn create_user(data UserData) Fail[Db] Db -> User {
ro user = Db.insert_user(data)
errdefer Db.delete_user(user.id) // откат если что-то дальше упадёт
ro profile = Db.insert_profile(user, data)
errdefer Db.delete_profile(profile.id)
Db.send_welcome(user.email) // если throw — оба delete сработают
// в LIFO порядке (delete_profile, потом delete_user)
user // normal exit — errdefer'ы НЕ выполняются
}
Комбинированно — defer + errdefer:
fn transaction() Fail Db -> Receipt {
Db.begin()
defer Log.info("transaction finished") // ВСЕГДА
errdefer Db.rollback() // только при throw
ro r = do_work()
Db.commit()
r
}
// normal exit: Db.commit() → Log.info(...)
// throw exit: Db.rollback() → Log.info(...)
Семантика
1. Scope-level. defer/errdefer привязаны к enclosing
block (function body, if/else branch, for body, with-block,
supervised-body, etc.). Выполняются при exit’е именно этого scope’а.
2. LIFO order. Несколько defer’ов выполняются в обратном
порядке регистрации (последний defer — первый выполняется).
3. Eager argument evaluation. Аргументы defer-выражения
вычисляются в момент defer, тело — откладывается:
ro i = 5
defer println(i) // i = 5 захвачено сейчас
ro i_new = 100 // другая переменная (immutable)
// exit prints: 5
Для mut-переменной с теми же captures-правилами:
mut counter = 0
defer println(counter) // counter — захвачен по reference (как closure)
counter = 42
// exit prints: 42
Это симметрично closure-семантике D32 (managed heap, mut-captures through reference).
4. Defer body — Fail-allowed с composition (amended by D158,
Plan 100.4.1, 2026-05-23). Тело defer/errdefer может иметь
Fail[E]-эффект; cleanup-failure композируется с propagating error через
Plan 49 multi-error infrastructure. Enclosing fn-sig обязан declare
Fail[E'] с совместимым E ⊆ E'.
fn process() Fail[CommitErr] -> () {
consume tx = begin()
defer { tx.commit() } // ✅ Fail[CommitErr] body
do_work()? // throws WorkErr
// composite: { primary: WorkErr, suppressed: [CommitErr] }
}
Если defer body имеет Fail[E], но enclosing fn-sig не declares Fail —
compile error D158-defer-fail-not-in-sig. Это force’ит explicit
visibility cleanup-fail в API.
Backward-compat: handler-wrap pattern продолжает работать как opt-in shorthand для silent-suppress:
defer {
with Fail = handler {
fail(e) { Log.error("cleanup failed: ${e}"); interrupt () }
} {
risky_cleanup() // Fail caught в inner with
}
}
Подробно — composition rules, MultiError API, diagnostic format — D158.
Historical (pre-D158, Plan 20 Ред. 1): body было infallible —
любой Fail[E] в defer body выдавал compile error. Programmer обязан
был ручной handler-wrap. D158 (Plan 100.4.1) снял это ограничение,
сохранив compile-time visibility через required fn-sig Fail[E']
declaration. Скрытого поглощения ошибок по-прежнему нет: cleanup-fail
видна either как composite-error caller’у, либо через explicit handler-
wrap внутри defer.
5. Defer body — suspend allowed (amended by D159, Plan 100.4.2,
2026-05-23). В теле defer/errdefer разрешены suspend-операции:
Time.sleep, Net.*, Fs.*, Db.*, Channel.recv — для production
graceful cleanup (socket close с FIN+ACK, DB drain, async commit).
Запрещены только AST-level concurrency constructs: spawn,
parallel for, supervised, detach, blocking — они leak supervised
hierarchy (новый fiber переживает scope cleanup’а). Это compile error
E (D159-spawn-in-defer).
Cancel-safe semantics (D159): runtime обеспечивает что cleanup
completes-then-propagates cancel. Programmer должен использовать
Time.timeout(d) { ... } (Plan 22) для bounded cleanup.
Historical (pre-D159, Plan 20 Ред. 1): body было no-suspend —
любая suspend operation в defer выдавала compile error. Programmer
обязан был ручной with Time.timeout обёртка. D159 (Plan 100.4.2)
снял ограничение для production-grade async cleanup.
6. Top-level return / break / continue / interrupt в defer-body —
запрещены (Вариант 3 — Plan 20 Ф.3 revised). Нельзя hijack scope-exit
окружающей функции/цикла через defer — defer сам часть exit-процесса.
Локальный control разрешён, только внутри вложенных конструкций:
return— разрешён внутри nested fn-литерала в defer body (returnлокален к этому fn-литералу, не к enclosing fn).break/continue— разрешены внутри nested loop (for/while/loop) в defer body (локальны к этому loop’у, не к enclosing).interrupt— всегда запрещён на любом уровне (hijack scope-exit с-effect-block’а; не failable cleanup).throw/?/!!— разрешены (D158, Plan 100.4.1) если enclosing fn-sig объявляетFail[E]; cleanup-fail композируется через Plan 49 multi-error (см. пункт 4 и D158).
defer {
for x in items {
if x.bad { break } // ✅ local break в nested loop
}
return 0 // ❌ top-level return — hijack scope exit
}
defer {
ro cleanup_fn = || {
if early_done { return } // ✅ local return в nested fn-literal
do_more()
}
cleanup_fn()
}
Type-check: DeferBodyCtx { loop_depth, fn_depth } инкрементируется
при заходе в nested loop/fn-literal; проверка > 0 на каждом
return/break/continue.
7. errdefer запускается на:
throw err(любойFail[E]).panic(msg)— пока fiber не умер.interrupt v— нет, это normal control flow (с точки зрения errdefer scope’а — exit «успешный»).exit(code, msg)— нет, exit гасит процесс без cleanup’ов (D13).
8. defer запускается на:
- Normal exit (последнее выражение block’а вычислено).
return.throw err.panic(msg)— пока fiber не умер.interrupt v— да (exit scope’а, неважно как).exit(code, msg)— нет (D13: exit без cleanup’ов).
Почему
Зачем нужен defer в Nova
В Nova нет deterministic destructor’ов (D6:
managed heap + GC). RAII Rust/C++ невозможен. Без defer resource
cleanup (file.close, unlock, rollback) пишется через handler-блоки
с copy-pasted error-paths:
// Без defer — verbose:
fn create_user(data UserData) Fail Db -> User {
ro user = Db.insert_user(data)
mut profile_id Option[int] = None
with Fail = effect Fail {
fail(e) {
if Some(pid) = profile_id { Db.delete_profile(pid) }
Db.delete_user(user.id)
throw e
}
} {
ro profile = Db.insert_profile(user, data)
profile_id = Some(profile.id)
Db.send_welcome(user.email)
}
user
}
Десятки строк boilerplate. С defer/errdefer — 6 строк
(см. пример выше). Это значительная экономия.
Прецеденты
| Язык | Конструкция | Scope-level? | errdefer? |
|---|---|---|---|
| Go | defer expr | function-level | нет |
| Swift | defer { body } | scope-level | нет |
| Zig | defer expr; errdefer expr | scope-level | да |
| D | scope(exit/success/failure) expr | scope-level | да + extra |
Nova берёт Zig-style: scope-level + errdefer. Не function-level
(Go), потому что Nova имеет вложенные scope’ы с богатой семантикой
(if, for, with, supervised) — function-level
ограничивал бы. Не D-style scope(success) — редко нужно, можно
писать обычным кодом перед exit’ом.
Почему scope-level, не function-level
Function-level (Go) накапливает все defer’ы в стеке функции:
func f() {
if cond {
temp := create()
defer temp.cleanup() // выполнится в КОНЦЕ func, не на exit if
}
long_running_work() // temp висит всё это время
}
В Nova scope-level позволяет локальный cleanup, что часто естественнее.
Почему eager argument evaluation
Если бы аргументы вычислялись lazy:
mut i = 0
defer println(i)
i = 42
// exit: print 42 (хотел печатать 0?)
Это regular для closure-семантики, но сюрприз для programmer’а ожидающего «defer фиксирует значение тогда же».
Eager arguments + lazy closures (через captures) — баланс. Это путь Go (которому 15 лет программистской практики симпатизируют).
Почему failable body + composition (а не infallible — historical)
Plan 20 Ред. 1 (2026-05-11) выбрал infallible body. D158 (Plan 100.4.1, 2026-05-23) revised к failable + composition. Аргументы.
Допустим, defer-body может падать:
fn process() Fail[CommitErr] -> () {
consume tx = begin()
defer { tx.commit() } // commit may fail
do_work()? // throws WorkErr
// exit: WorkErr propagating → defer fires → commit throws CommitErr → ???
}
Языки решают по-разному:
- Rust: panic-in-Drop =
abort()процесса. Безопасно, но programming совершенно непрактичен —tx.rollback()который может fail = abort. - Go: defer возвращает error через named return — manual handling, легко пропустить. На практике все игнорируют.
- TS (ES2024) / Java:
Symbol.dispose/close()throws → compositeSuppressedError/addSuppressed()chain. Структурированно, caller видит весь chain.
Nova D158 выбрал TS/Java-подход: composition через MultiError chain.
Plan 49 multi-error infrastructure уже даёт kinded throws + typed payload;
D158 добавляет nv_compose_suppressed для chain append’а и MultiError
prelude type для caller-side inspection.
Visibility сохранена через fn-sig: enclosing fn-sig обязан declare
Fail[E'] где E ⊆ E' для defer body. Без этого — compile error
D158-defer-fail-not-in-sig. Это сильнее Go/TS (которые не enforce’ят
visibility в сигнатуре), сравнимо с Java checked exceptions, но без
их verbosity — Fail[E] уже часть base effect-system.
Backward-compat: handler-wrap pattern сохраняется как opt-in shorthand для silent suppress (см. пункт 4 example).
Почему suspend allowed (а не no-suspend — historical)
Plan 20 Ред. 1 (2026-05-11) запретил suspend в defer body argument’ируя
«cleanup быстрый». D159 (Plan 100.4.2, 2026-05-23) revised: production
cleanup ОБЯЗАН suspend — graceful socket close с FIN+ACK, DB drain через
Channel.recv, async transaction commit. Без suspend programmer вынужден
делать leak-y fire-and-forget cleanup.
D159 решение: suspend allowed, но:
spawn/parallel for/supervised/detach/blocking— запрещены (leak supervised hierarchy: новый fiber переживает scope cleanup’а).- Programmer отвечает за bounded cleanup через
Time.timeout(d) { ... }(Plan 22 sleep-libuv-integration). - Runtime обеспечивает cancel-safe semantics: cleanup completes
before cancel-propagation (production-grade — Plan 100.4.2 followup
[M-100.4.2-cancel-shielding]для full runtime enforcement; в bootstrap defer runs after throw, cancel-as-throw тоже triggers cleanup).
Что отвергнуто
- Function-level defer (Go-style) — слабее scope-level, ограничивает локальный cleanup.
successdefer(Dscope(success)) — редкий case, обычный код перед exit покрывает.deferбезerrdefer—errdeferкритичен для transactions, без него boilerplate тот же что и безdefer. Включаем сразу.- Lazy argument evaluation — surprise factor, eager — стандарт Go/Swift/Zig/D.
- Failable defer body banned-as-such — first revision (Plan 20)
запретила Fail в defer body absolutely. Revised D158 (Plan 100.4.1):
failable body разрешён с composition через Plan 49 multi-error chain
(
MultiError); fn-sig обязан declareFail[E']. См. пункт 4. defer return X— нельзя hijack exit-значение через defer.recover(Go) — поглощение panic из defer. Сложная семантика, не нужно в Nova (panic — смерть fiber’а, D13).
Связь
- D6 — managed heap без RAII, мотивирует
потребность в
defer. - D13 —
panic/exitсемантика.deferвыполняется при panic пока fiber жив; не выполняется приexit(D13: exit гасит процесс без cleanup’ов). - D22 — closure семантика; defer использует те же mut-capture правила.
- D32 — managed-heap captures, base для defer captures.
- D85 —
?/!!; в теле defer запрещены (требуютFail, defer body infallible). - D91 — Channel revision; defer
tx.close()— main use-case для defer в concurrency. - Q20 — закрыто этим D-блоком.
Bootstrap-status
-
✅ Реализовано (Plan 20, 2026-05-11). Все 7 фаз закрыты:
- Ф.1 Лексер: keyword’ы
defer/errdefer(commit 75673d7). - Ф.2 Парсер + AST:
Stmt::Defer { body },Stmt::ErrDefer { body }(commit 380b457). - Ф.3 Type-checker constraints (revised: Вариант 3, local control
разрешён, commit fdb53be + 3faf9f0):
throw/?/!!/interrupt/suspend-effects — всегда запрещены.return/break/continue— запрещены только на top-level defer body; внутри nested fn-литерала/loop — разрешены.
- Ф.4 Codegen: per-scope DeferScope с активационными флагами; NovaFailFrame setjmp wrapper для errdefer throw-path с longjmp re-throw; integration во все emit_block_* paths; early-exit cleanup для return/break/continue (commits 94151c3 + b058968).
- Ф.5 Interp: per-scope defer-stack, LIFO invocation, errdefer skip non-error exit (commit c96f7f3).
- Ф.6 Positive-тесты: defer_basic.nv, errdefer_basic.nv, errdefer_throw.nv (interrupt handler).
- Ф.7 Spec uplift: текущий блок.
- Ф.8 Production-grade hardening (2026-05-11, commits e04ca85d
- 61af5af4 + 007bb9ba + d913aa08 + 33c1e050):
- (1) Type-check enforcement D61 §1430-1434: handler-method для
эффект-операции с return type
neverОБЯЗАН закончитьсяinterrupt/throw/panic/exit. Static analysis вcheck_handler_never_ops+ helpers (expr_diverges,block_diverges). Покрывает Fail.fail + user-defined effects с never-методами. - (2) Defer/errdefer на interrupt-path: codegen эмитит local
NovaInterruptFrame setjmp wrapper аналогично fail-frame.
На interrupt — invoke только
defer(skiperrdefer— это handled exit), pop interrupt-frame, re-interrupt с тем же value. - (3) Loop/branch body defer integration: while/loop/while-let/ for-in-array/for-in-iter/else-branch/match-arm — все эмитят defer scope (раньше только for-range body был покрыт).
- (4) D65 правило 3 (re-throw): NovaVtable_Fail.prev = outer handler; Nova_Fail_fail на время handler-body invocation swap’ает _nova_handler_Fail = current->prev, восстанавливает после. Throw в handler-body dispatch’ится на outer (skip current frame — нет infinite recursion).
Ф.8 positive-тесты:
syntax/defer_in_blocks.nv(9 кейсов) — defer внутри while/loop/for-in-array body, else-branch, match-arm-block, nested defer scopes (LIFO между inner/outer).syntax/errdefer_rethrow.nv(3 кейса) — re-throw из inner handler → outer (1-level и 3-level); errdefer + outer interrupt → errdefer корректно skip.syntax/defer_on_interrupt.nv(4 кейса) — defer fires на interrupt-path; errdefer skip; defer+errdefer combo; LIFO для multiple defer’ов.
Ф.8 negative-тест:
negative_capability/fail_handler_no_exit_rejected.nv— handlerfail()без exit-control → compile error.
Все 12 positive + 6 negative defer-relevant тестов PASS. 10/10 effects + 17/17 concurrency без регрессий после Ф.8.
- Ф.1 Лексер: keyword’ы
Известные ограничения
- Suspend (Db/Net/Fs/Time/spawn) в defer body — compile error (Ф.3). Это spec-compliant strict ограничение, не gap.
exit(code, msg)не запускает defer’ы (D13: exit гасит процесс без cleanup’ов) — by design.- Cleanup на
panic(msg)— для bootstrap’а purposefully простой: если fiber жив, defer тоже срабатывает через fail-frame longjmp-path (panic dispatch’ится через nova_throw).
D102. Именованные аргументы и значения параметров по умолчанию
Status: active (spec). Базовая реализация — Plan 46 (закрыт). Ревизия «дефолт → keyword-only» (2026-05-15) — Plan 50. 2026-06-01 D199 amend: default-value expression может вызывать
const fn(D199) — call-site replaced литералом во время компиляции, default остаётсяExpr::IntLit/StrLit/...после Plan 114.4.2 Ф.3. Plan 114.4.2 fixtureconst_fn_used_in_const_ok.nv— proof-of-concept с module-levelconst; default param сценарий — followup-сценарий (parser + checker уже совместимы).
Что
Параметр функции может иметь значение по умолчанию; на месте вызова аргумент может передаваться по имени. Ключевое правило: параметр с дефолтом передаётся только по имени, позиционно — нельзя.
fn connect(host str, port int = 8080, tls bool = false) -> Conn
connect("localhost") // ок — обязательный позиционно
connect("localhost", port: 9000) // ок — дефолтный по имени
connect("localhost", tls: true, port: 80) // ок — именованные переставимы
connect("localhost", 9000) // ОШИБКА — port с дефолтом, только по имени
connect("localhost", 9000, true) // ОШИБКА — нечитаемые позиционные флаги
Ментальная модель одной строкой: обязательный параметр — позиционно, опциональный — по имени.
Это общая фича языка, не спецсинтаксис. supervised(cancel: tok)
(D75) — обычный именованный аргумент.
Правило — объявление
fn f(required int, opt int = 0, flag bool = false)
// ^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^
// без дефолта с дефолтом с дефолтом
- Параметры с дефолтом идут после параметров без дефолта.
fn f(x int = 0, y int)— compile error. - Default-выражение вычисляется на месте вызова, каждый вызов
заново (не Python-style def-time). Может ссылаться на
предшествующие параметры и module-level
const:fn slice(xs []int, from int = 0, to int = xs.len()) - Variadic-параметр (D69) остаётся последним и не может иметь дефолта (его дефолт — пустой пакет). Параметры до variadic могут иметь дефолты.
Правило — вызов
// fn f(required int, opt int = 0, flag bool = false)
f(1) // opt, flag опущены → дефолты
f(1, opt: 5) // дефолтный по имени
f(1, flag: true, opt: 5) // именованные переставимы
f(required: 1, opt: 5) // обязательный тоже можно по имени
f(1, 5) // ОШИБКА — opt с дефолтом, позиционно нельзя
f(opt: 5, 1) // ОШИБКА — позиционный после именованного
- Параметр с дефолтом — keyword-only. Передаётся только по имени; позиционно — compile error. (Исключение — trailing-форма для последнего функционального параметра, см. «Взаимодействие».)
- Параметр без дефолта связывается позиционно или по имени.
- Позиционные аргументы идут первыми, связываются слева направо.
Именованный аргумент не может предшествовать позиционному —
f(opt: 5, 1)— compile error. - Именованные аргументы переставимы между собой.
- Каждый параметр связывается ровно один раз. Передать параметр
и позиционно, и по имени — compile error (
f(1, required: 2)). - Параметр с дефолтом можно опустить; параметр без дефолта — обязателен (позиционно или по имени).
- Имя в
name: expr— это имя параметра callee, не выражение.
Грамматика
param = ident type [ '=' expr ]
params = param { ',' param } [ ',' '...' ident '[]' type ]
call-args = [ pos-args ] [ ',' named-args ] | named-args
pos-args = expr { ',' expr }
named-args = named-arg { ',' named-arg }
named-arg = ident ':' expr
Внутри (...) вызова ident ':' expr всегда именованный аргумент
— коллизии с record-литералом нет (record-литерал — Имя { ... } в
фигурных скобках, D43).
f(User { name: "a" }) — позиционный аргумент-record.
Взаимодействие
- D43 trailing-block / trailing-fn. Trailing-форма связывается с
последним функциональным параметром. Trailing-форма синтаксически
отлична от позиционного аргумента в
(...), поэтому остаётся допустимой даже если этот параметр имеет дефолт — это не «позиционный аргумент дефолтного параметра». Передать тот же параметр и trailing-формой, и именованным аргументом нельзя (правило 5, «связан дважды»). - D69 variadic. Именованные аргументы — только для параметров
до variadic. После
...itemsименованных аргументов нет. - Overloading отсутствует — в Nova нет перегрузки функций, поэтому разрешение «какой параметр» однозначно по имени, без type-directed resolution.
@-методы / protocol-методы — именованные аргументы работают одинаково для свободных функций и методов.
Почему
- Нечитаемые флаги — compile error, а не «нежелательно».
connect("h", false, true)— позиционныеbool/int-флаги нечитаемы и это классическая ошибка LLM-генерации. Правило «дефолт → keyword-only» превращает её из стиль-замечания в ошибку компиляции. Для AI-first языка перевод целого класса багов в compile error — прямо по миссии. - Одно правило, обучаемая граница. «Обязательный — позиционно,
опциональный — по имени». Не нужно решать на каждом вызове, называть
или нет; не нужна система двух имён, как в Swift (
_+ label). Опциональные параметры — это как раз те, чей порядок не запоминается. - Убирает builder/option-struct boilerplate для простых случаев «функция с несколькими опциональными настройками».
- Включает
supervised(cancel: tok)— синтаксис structured concurrency (D75) опирается на эту фичу. - Call-site evaluation дефолтов — нет Python-гочи с разделяемым mutable-дефолтом.
Что отвергнуто
- Spread аргументов в вызов —
f(...record)(record → именованные) иf(..array)(массив → позиционные). Причины: два разных оператора несогласованны;...уже занят variadic (D69) и spread-в-литералах (D60); позиционный spread тихо ломается при перестановке параметров callee; call-site становится непрозрачным. Бандл связанных параметров выражается option-struct’ом (fn f(host str, opts Opts = Opts{})) или именованными аргументами. - Python-style def-time вычисление дефолта — mutable-default гоча.
- Все параметры обязательно-именованные (Swift-style, имя
обязательно на call-site для каждого параметра) — лишняя церемония
для унарных и math-функций (
abs(x: -5),add(left: a, right: b)), и делает имя каждого параметра жёстким API. Keyword-only применяется только к параметрам с дефолтом — обязательные остаются позиционными. - Исключение «если дефолтный параметр один — разрешить позиционно» —
отвергнуто: количество дефолтов не показатель риска (один
bool-флаг так же нечитаем, как один из трёх); добавление второго дефолтного параметра тихо ломало бы существующие позиционные вызовы (рефакторинг-ловушка); теряется простота «одного правила». - Per-параметр opt-in в позиционность (Swift
_) — добавляет сложность на декларации; пока не нужно. Если math-функции начнут раздражать многословием — вернуться к этому отдельным решением. - Позиционный аргумент после именованного — неоднозначно, запрещён.
Эволюция
Ревизия (2026-05-15): добавлено правило «параметр с дефолтом —
keyword-only на месте вызова». Раньше дефолтный параметр можно было
передать и позиционно. Триггер — позиционные bool/int-флаги
(connect("h", false, true)) остаются нечитаемыми и частой ошибкой
LLM-генерации даже при наличии именованных аргументов; правило делает
их compile error. Рассмотрены и отвергнуты: обязательные имена для
всех параметров (Swift-style) и исключение для «одного дефолта»
(см. «Что отвергнуто»). Реализация ревизии — Plan 50;
существующие call-site’ы из Plan 46 с позиционными дефолтными
аргументами требуют миграции.
Связь
- D69 — variadic-параметры; variadic несовместим с дефолтом, остаётся последним.
- D60 — spread
...xв литералах; spread-в-вызов (отвергнут здесь) — другая операция. - D43 — trailing closure связывается с последним функциональным параметром.
- D75 —
supervised(cancel: tok)использует именованный аргумент; ревизия D75 зависит от D102. - Plan 46 — базовая реализация (named args + дефолты), закрыт.
- Plan 50 — реализация ревизии «дефолт → keyword-only».
D108. Map-литерал [k: v]
Status: active (spec). Реализация — Plan 52. (Номера D104-D107 зарезервированы Plan 45.)
Что
Map-литерал [k: v, ...] конструирует HashMap[K, V]. Ключи и
значения — выражения, вычисляются в рантайме.
ro m HashMap[int, str] = [1: "a", 2: "b"]
ro m = [1: "a", 2: "b"] // K, V выводятся из литерала
ro a = 10
ro m HashMap[int, str] = [a: "x", a + 1: "y"] // ключи — выражения
ro m HashMap[str, bool] = ["has space": true] // не-идентификаторный str-ключ
ro empty HashMap[int, str] = [] // пустой — тип из контекста
Дополняет map-coercion {field: v} (02-types.md → D55):
{...}— ключи это статические имена-идентификаторы →HashMap[str, V].[k: v]— ключи это выражения (int, переменная, не-идентификаторная строка, computed) →HashMap[K, V].
Правило — синтаксис и парсинг
collection-literal = '[' ( map-body | array-body | (empty) ) ']'
map-body = expr ':' expr { ',' expr ':' expr } [ ',' ]
array-body = expr { ',' expr } [ ',' ] // D27/D38
Парсинг локальный, без type-directed:
- После
[парсим первое выражение. - Следующий токен
:→ это map-литерал, дальше парыexpr : expr. - Следующий токен
,или]→ это array-литерал (D27/D38). [](пусто) → array-или-map, разрешается на type-check по ожидаемому типу — ровно как уже работает пустой массив (D38).
Внутри [...] слева от : — выражение, не имя. Коллизии нет: в
[] вообще нет понятия «имя поля» (в отличие от record-литерала
{}). Первый : вне вложенных ()/[]/{} — разделитель пары.
Правило — типы и coercion
- Тип литерала —
HashMap[K, V];K/Vвыводятся из ключей/значений либо из ожидаемого типа. - Key-позиция — D55 «known-target-type position» с ожидаемым типом
K; value-позиция — с ожидаемымV. Значит sum-/record-/map-coercion (D55) композируются на ключах и значениях:ro m HashMap[str, JsonValue] = ["name": "alice", "age": 30.0] // значения: "alice" → Str(...), 30.0 → Num(...) - Все ключи унифицируются в один
K, все значения — в одинV.
Правило — порядок вычисления
Порядок вычисления зафиксирован нормативно — это улучшение над Go, spec которого оставляет порядок вычисления map-literal expressions неспецифицированным:
[k1: v1, k2: v2, ...]— пары вычисляются слева направо; внутри каждой пары — сначала ключ, потом значение. Итоговый порядок side-effect’ов:k1, v1, k2, v2, ....- Этот порядок observable — побочные эффекты в ключах/значениях наблюдаемы именно в нём.
Правило — порядок итерации
HashMap создаваемый литералом — unordered, как Go и Rust. Порядок
итерации не специфицирован и может рандомизироваться между запусками
программы (Go-стиль, защищает от случайной зависимости от порядка) либо
быть устойчивым в пределах процесса (Rust-стиль, per-instance random
seed). Конкретная политика — деталь реализации stdlib и может меняться
в будущем (например, при переходе на swisstable-implementation).
Это намеренное проектное решение — без него users пишут fragile
тесты («первый элемент в map это X»), которые ломаются при изменении
load-factor или hash-seed. Если требуется детерминированный порядок —
используйте OrderedMap (insertion-order, отдельный тип через
FromPairs протокол, Plan 52.1) или явный sort после .entries().
Сравнение:
- Go: random per-iteration (агрессивно ломает reliance) — мы можем выбрать то же
- Rust: random per-instance (стабилен в пределах HashMap, но между HashMap’ами разный)
- TS
Map: preserves insertion (но это другая структура — мы для этого даёмOrderedMap)
Правило — десугаринг
Map-литерал десугарится сразу в вызовы методов, без промежуточного массива пар:
[k1: v1, k2: v2]
// →
{
mut _m0 = HashMap[K, V].with_capacity(2)
ro _ = _m0.insert(k1, v1)
ro _ = _m0.insert(k2, v2)
_m0
}
- Пустой (
[]в map-позиции) →HashMap[K, V].new(). - Ноль промежуточных объектов на куче — только сам
HashMap(подход Rustvec![]: преаллокация + вставки). with_capacity(n)несёт контракт «nвставок без rehash» — аргумент это entry-count, не bucket-count (см. Plan 52).@insertвозвращаетOption[V](старое значение); в десугаринге возврат всегда явно отбрасывается черезlet _ = ....- Temp-переменная —
_m0,_m1, … (per-scope счётчик): valid ISO C11, без$; вложенные литералы ([1: [10: "x"]]) не конфликтуют именами. - Дубликаты ключей — last-wins, естественно из семантики
@insert. Если два ключа — одинаковые compile-time константы (int/str/bool literal илиconst), компилятор выдаёт lint-предупреждение «duplicate key — second entry overwrites first» (паритет сgo vetиtsc). Произвольные выражения не проверяются. - Plan 52 Ф.23 — расширяемость через
#from_pairsattribute. Десугаринг по умолчанию вызываетHashMap, но если expected type помечен#from_pairs, target меняется на этот тип. User-типы получают support литерала добавив#from_pairs+ протокол:static with_capacity(n int) -> Self— предаллоцировать подnзаписей;mut @insert_new(key K, val V) -> ()— вставить новую запись (без возврата; вызывается только если перед парой не было spread);mut @insert(key K, val V) -> Option[V]— вставить с override (возвращает старое значение; вызывается для пар после spread и в самом spread-цикле — т.к. ключ мог уже присутствовать). Итого:#from_pairsобязан реализовывать оба метода. ПолныйFromPairs[K, V]протокол (с bound-check через Plan 15) — future generalization, не в bootstrap.
HashMap.from(arr)остаётся как обычный метод для рантайм-массива пар; литерал через него не идёт.
Правило — NaN как ключ (документированный footgun)
Если K — float (f64/f32) и реализует Hash, то [f64.NAN: "x"]
синтаксически валиден. Но по IEEE 754 NaN != NaN, поэтому вставленный
NaN-ключ невозможно найти обратно — @get(f64.NAN) всегда вернёт
None. Rust решает радикально (f64 не реализует Hash + Eq); Go и TS
документируют, но не предотвращают. Nova документирует и предупреждает:
если ключевое выражение — константа f64.NAN / f32.NAN, компилятор
эмитит warning «NaN as map key — inserted key can never be found». Runtime-
проверку не вводим (дорого для non-NaN случаев).
Почему [], а не {}
{...} — это record-литерал (D17/D55).
{ ident: x } неустранимо неоднозначен: ident — имя поля record’а
или выражение-ключ? Различить можно только type-directed parsing
(Nova отвергает, D43)
или JS-гочей ({a:1} — ключ это строка "a", не переменная). Внутри
[...] понятия «имя поля» нет — [a: x] однозначно: a — выражение.
Прецедент — Swift (словари на [], не {}).
{field: v} всё равно даёт str-keyed map — через map-coercion (D55),
для подмножества «ключи это статические идентификаторы». Это не
TIMTOWTDI: {} и [] покрывают разные случаи (имя vs выражение).
Что отвергнуто
- Map-литерал на
{}({1: "a"},{[expr]: v}) —1не имя поля,{}пришлось бы парсить тремя способами (блок / record / map) с различием по «идентификатор ключ или нет», что молча меняет семантику ({x: v}record vs{x(): v}map). Фрагильно. - Десугаринг через
HashMap.from([(k,v),...])— строит промежуточный[](K,V)массив + tuple’ы на куче только ради инициализации. Десугарим сразу вwith_capacity+@insert. [:]как токен пустой мапы (Swift-style) — лишний спецтокен;[]+ ожидаемый тип уже однозначно даёт пустую мапу.- Map-литерал как compiler builtin —
HashMapостаётся stdlib-типом на Nova; литерал — чистый сахар, компилятор знает только именаHashMap/with_capacity/@insert, не реализацию.
Связь
- D27 /
D38 — array-литерал
на
[]; map-литерал делит с ним скобки, разводится по:. - D55
— map-coercion (
{field: v}); key/value-позиции литерала — D55 known-target-type positions. - D17 — record-литерал
{...}, с которым[]намеренно не конфликтует. - Plan 52 — реализация D108 + ревизии D55 (map-coercion).
Spread в map-литерале (Plan 55 followup, 2026-05-16)
...m внутри map-литерала разворачивает другую map того же типа:
ro defaults HashMap[str, int] = ["a": 1, "b": 2]
ro m HashMap[str, int] = [...defaults, "c": 3] // {a:1, b:2, c:3}
ro m HashMap[str, int] = [...defaults, "a": 100] // {a:100, b:2} (override)
ro m HashMap[str, int] = [...a, ...b] // merge two maps
Семантика «right-most wins»: при duplicate keys позже встретившаяся
запись побеждает (как JS object spread, Python {**a, **b}).
Парсер использует lookahead для disambiguation: [...x, y, z]
рассматривается как array, [...x, k: v] — как map. Edge case
[...x] (только spread без pairs) — type-directed: если expected тип
помечен #from_pairs (HashMap), интерпретируется как map.
Status (bootstrap): parser + desugar + annotator готовы; codegen
для [...src] с non-empty src блокирован orthogonal
[M-mono-tuple-element-types] (Plan 56 scope). Эффективно работает
spread пустых map’ов + pair-only литералов.
Mono invariants (Plan 55 Ф.4, 2026-05-16)
Codegen (emit_c.rs) при monomorphization сохраняет следующие
invariants:
current_fn_return_tysave/restore вemit_fnчерезmem::replace+ restore в конце. Это предотвращает leak prior return type в recursive emit (mono’d transitively’d deps).- Protocol-method return-type whitelist — для well-known
protocol methods (
eq/ne/lt/le/gt/ge/is_*→bool;hash→int) infer возвращает stable тип до fallback наfn_ret_<m>lookup (который может содержать stale из другой fn). - Placeholder mono skip —
register_mono_method_instance+drain_generic_type_worklistотвергают type_subst содержащийNova_<G>*placeholders (G ∈ fn.generics). Это предотвращает broken erased generic emit для recursive generic calls (e.g.HashMap[K,V].with_capacityвнутриHashMap.clone()body). current_type_substsave/restore в local scope — каждая recursive mono call имеет свой subst stack, не leak глобально.- Pattern::Record bindings —
collect_pattern_inner_bindingsдля record-form variant patterns (Slot.Occupied { key: k }) используетrecord_variant_field_typesmap с lookup mono’d sum_name first, fallback на base. Это предотвращает leak stale var_types между mono’d instances.
Полное описание — Plan 55 Ф.0-Ф.6.
D104. Синтаксис doc-comment’ов — /// outer, //! inner
Status: active (spec). Реализация — Plan 45 Ф.1.
Cross-refs: D101 (
#doc "..."module-attr сосуществует с//!); D105 (#doc(...)типизированные атрибуты делят namespace#doc); D106 (code-блоки внутри doc-comment’ов).
Что
Два префикса doc-comment’ов:
///— внешний doc-comment (outer): привязывается к следующей декларации (function, type, constant, effect, handler, protocol).//!— внутренний doc-comment (inner): привязывается к окружающему модулю/файлу. Допустим только в начале файла (после строкиmodule Xи любых строкimport), до первой декларации.
Голое // остаётся обычным комментарием (doc-token не эмитится).
//! Краткое описание модуля.
//!
//! Подробное описание того, что предоставляет модуль, включая
//! примеры, охватывающие несколько items.
module std.example
import std.io
/// Возвращает модуль числа `x`.
///
/// # Examples
///
/// ```nova
/// assert(abs(-5) == 5)
/// ```
fn abs(x int) -> int =>
if x < 0 { -x } else { x }
Правила
-
Outer (
///) — привязывается к следующей декларации в порядке исходника. Подряд идущие///строки сливаются в один doc-блок. Пустая///строка не разрывает блок (становится пустой строкой в content); не-doc строка завершает блок. -
Inner (
//!) — допустим только в начале модуля: после строкиmodule <path>и любыхimportstatement’ов, но до первой декларации item’а. Подряд идущие//!строки сливаются. -
////(четыре или больше слэшей) — обычный комментарий, не doc-comment. Это копирует поведение rustdoc и предотвращает случайное doc-promotion для идиомы section-divider’ов (//// SECTION). -
Multi-line merging — подряд идущие
///(или//!) строки без разделяющих blank-строк или других токенов конкатенируются с\n-разделителями. С каждой строки снимается префикс///(или//!) плюс ровно один опциональный ведущий пробел:/// Первая строка. /// /// Третья строка (после пустой doc-строки).даёт content
"Первая строка.\n\nТретья строка (после пустой doc-строки).". -
Indentation stripping — когда doc-блок занимает несколько строк, общий leading whitespace (после префикса
//////!+ одного опционального пробела) снимается единообразно с каждой непустой строки. Это нормализует индентацию markdown:/// Indented doc: /// inner detailдаёт
"Indented doc:\n inner detail"(четырёхпробельный внешний отступ снят равномерно; двухпробельный внутренний — сохранён). -
Doc не допускается на
module,import,letна module-scope,test-блоке. Документация уровня модуля — через//!(inner doc) или через#doc "..."module-attr (D101); уtest-блока doc- convention нет (если нужен комментарий — обычный//). -
Пустой doc-блок (
///за которым blank line или///\n) — warning, обрабатывается как отсутствие документации. Style guide запрещает пустые doc-блоки кроме явных случаев#hide_doc(D105).
Position rules — примеры
//! ok: в начале модуля, после module + imports.
module foo
import bar
//! WARNING: //! после первого item — отбрасывается с warning'ом.
/// ok: outer doc на item ниже.
fn baz() -> int => 1
/// orphan outer doc — warning: за ним нет item'а.
fn outer() -> int {
//! ERROR: //! внутри тела функции недопустим.
/// ERROR: outer doc на let-statement не поддерживается.
ro x = 1
x
}
Кодировка и escapes
- Content doc-comment’а — сырой текст (CommonMark markdown слой применяется позже, в D106 / Plan 45 Ф.5).
- На уровне лексера escape-последовательности не интерпретируются. Backslash’ы, backtick’и и пр. — часть raw content.
- Только UTF-8. BOM в начале файла снимается перед doc-recognition.
- Trailing whitespace на каждой строке сохраняется (от него зависит markdown line-break семантика).
Грамматика на уровне лексера
doc-outer-line = "///" [content-char ...] NEWLINE
doc-inner-line = "//!" [content-char ...] NEWLINE
doc-block-outer = doc-outer-line { doc-outer-line }
doc-block-inner = doc-inner-line { doc-inner-line }
content-char = любой символ, кроме NEWLINE; при этом строка НЕ
ДОЛЖНА начинаться с `/` сразу после префикса (т.е.
`////` — обычный комментарий, а не doc-prefix + лишний
слэш).
Сосуществование с #doc "..." (D101)
D101 определяет атрибут
module-level #doc "...", который может стоять перед строкой
module X в _module.nv и пропагируется на все peer-файлы. Это
комплементарно к //!:
#doc "..."— для коротких summary модуля, особенно в folder-module’ах с_module.nv.//!— для длинной документации модуля в одном каноническом файле, включая markdown-тело и# Examples-секции.
Модуль может иметь оба одновременно. Если оба присутствуют:
- Текст
#docстановится module summary (первое предложение). - Тело
//!добавляется как module description.
nova doc склеивает их; конфликта нет, но lint redundant-module-doc
предупреждает, если оба содержат идентичный текст.
Почему
///+//!— копирует rustdoc-конвенцию, знакомую широкому developer-сообществу. Заимствование устоявшейся конвенции снижает friction для новичков и AI-ассистентов.////отвергнут как doc — сохраняет идиому headings-as-comment’ы (//// SECTION) без случайного doc-promotion. rustdoc сделал этот выбор; мы повторяем.- Никаких
/** ... */-style блочных doc-comment’ов — в Nova вообще нет блочных комментариев (только//line по существующей языковой конвенции). Добавлять блочные doc-comment’ы только ради документации — вводить новый синтаксис комментариев для одной цели. - Английский как рекомендованная convention — для широкого охвата и AI/LLM-consumption Plan 45 §11.5 рекомендует писать doc-content на английском. Однако технически lexer/codegen не ограничивают язык — content treated как opaque UTF-8 text, и при необходимости разработчик/команда выбирает язык под свою аудиторию.
Что отвергнуто
///для inner-doc через position next-line — неоднозначно с привязкой к следующему item’у. Отвергнуто;//!однозначно inner.//* ... */-блочные doc-comment’ы — добавляет вариант синтаксиса комментариев для одной цели; line-форма покрывает все случаи одним правилом.- Авто-promotion
//обычных комментариев в doc, когда они предшествуют exported item — неявно и неожиданно. Doc-promotion обязан быть явным (///). - Doc на
import— import’ы не часть public API surface, в output’е не рендерятся.
Связь
- D101 — module-level
#docattribute; правила сосуществования выше. - D105 — типизированные doc-
атрибуты, включая
#doc(summary = "..."). - D106 — code-блоки внутри doc-comment’ов являются doc-test’ами.
- D107 — JSON output включает сырой doc-content плюс распарсенную структуру.
- Plan 45 — реализация; §11.5 style guide.
D117. Size-like accessors require call syntax
Status: active (spec). Реализация — Plan 60. (Номера D112–D116 заняты другими планами 33.x.)
AMEND (2026-07-06, решение владельца) — методы-свойства как ОБЩИЙ канон доступа к полям. Правило D117 обобщается со «size-подобных» на ВСЕ поля публичной поверхности типа: канонический доступ — одноимённые методы-свойства через перегрузку по арности (D84): чтение
@x() -> T, записьmut @x(v T) -> @(беглая; возврат приёмника автоматический — D409). Парыget_x/set_x— НЕ канон (в std их 0 — норма уже соблюдена). Прецедент пары:@cap()/@cap(n)(vec/core.nv, исходный D117-амендмент про write-setter).with_x(v)— ДРУГАЯ операция (копия с заменой поля, не мутация; см. nv-coding-style «Именование»). Весь новый код std пишется в этой парадигме.AMEND-2 (2026-07-06, решение владельца) — сеттер возвращает
@. Метод установки свойства по умолчанию объявляется-> @(беглая форма): это бесплатно (возврат приёмника автоматический — D409, в теле ни строки) и любая последовательность установок становится цепочкой.-> ()у сеттера — отступление, допустимое только с обоснованием; установка, которая может отказать (валидация), — не сеттер-свойство, а операция сResult/эффектом.
Что
Для любого типа T методы, возвращающие размер/cardinality/
capacity (len, cap, byte_len, is_empty, плюс будущие
count, size если они появятся как built-in convention), вызываются
только через method-call с круглыми скобками: t.method().
Запись t.method (без скобок, без @) — это обращение к полю
(если поле public), либо compile error если поле не существует.
Bound method value (t.@method) удалён в Plan 132 — используй
лямбду || t.method() или unbound Type.@method.
В подавляющем большинстве случаев голое v.len — user error
(забытые скобки).
Правило
ro v = [1, 2, 3]
ro n = v.len() // ✓ корректно
ro m = v.len // ✗ error E_SIZE_ACCESSOR_FIELD
ro z = v.is_empty() // ✓
ro c = v.cap() // ✓ канон (D117 AMEND 2026-07-06 — Go-style короткое имя;
// `.capacity()` был дублирующим alias, РЕТРАКТИРОВАН
// [M-unwrap-twins-retraction] 2026-07-07)
Что попадает под D117 (по conventional имени):
| Имя | Где |
|---|---|
len | любая коллекция |
cap | любая коллекция (включая []T, HashMap, StringBuilder, WriteBuffer, etc.) |
byte_len | строкоподобная поверхность (str, StringBuilder) — длина в байтах UTF-8 |
is_empty | любая коллекция |
count, size | если когда-нибудь добавятся как built-in convention |
Имя cap — канон (D117 AMEND 2026-07-06). Прежний дублирующий
capacity accessor РЕТРАКТИРОВАН на всех носителях
([M-unwrap-twins-retraction], 2026-07-07) — diagnostic при попытке
field-access t.cap (без скобок) подсказывает rename на .cap().
Diagnostic при нарушении
error[E_SIZE_ACCESSOR_FIELD]: size-like accessor `len` is method-only
(Plan 60 / D117)
--> file.nv:42:23
|
42 | println("${vec.len}")
| ^^^ help: append `()` — use `.len()` method call
|
= note: bare `.len` without `()` — missing parens (bound method value
syntax was removed in Plan 132; use `.len()` to call)
Для bare .cap (без скобок):
= help: append `()` — use `.cap()` method call (D117 AMEND 2026-07-06)
Почему
- Predictable cost. Nova сознательно отвергает TS/Swift-style
computed properties (без скобок) — это спрятало бы O(n) операции
за field-syntax (например,
s.lenдля UTF-8 string требует codepoint count, O(n)). Скобки везде = «здесь происходит вычисление, возможно дорогое». - Consistency. Без D117 — built-in коллекции (
[]T,str) дают.lenfield-style, а user-defined (HashMap,Set) —.len()method-style. Это паттерн Java (arr.lengthfield vslist.size()method), worst-of-both: программист и LLM не могут запомнить «для какого типа какая форма». - AI-friendly. D117 — explicit spec’ed contract. LLM, читающий
spec, имеет однозначный сигнал. Rust имеет тот же result, но
через implicit convention (rustc не выдаёт error если вы определите
публичное поле
len— Nova выдаёт). - Internal C-поля сохранены.
arr->len/arr->capв C-runtime остаются — это implementation detail.arr.len()lowers в zero-cost(arr->len); никакого function-call overhead.
Соответствие state-of-the-art
| Language | Array size | String size | Map size | Inconsistency? |
|---|---|---|---|---|
| Rust | vec.len() method | s.len() method | map.len() method | none |
| Go | len(slice) builtin | len(s) builtin | len(m) builtin | none (но top-level fn) |
| TS | arr.length property | s.length property | map.size property | none (но field) |
| Swift | arr.count property | s.count property | dict.count property | none (но field) |
| Java | arr.length field | s.length() method | m.size() method | inconsistent |
| Python | len(arr) builtin | len(s) builtin | len(m) builtin | none |
| Nova | arr.len()/arr.cap() method | s.len()/s.byte_len() method | map.len()/map.cap() method | none (D117) |
Nova = Rust паритет, + explicit D-block (Rust полагается на convention без compiler enforcement).
Что отвергнуто
- Field-style для всех типов — невыразимо для user-types
(encapsulation: HashMap внутри
_count+ invariant’ы). - TS/Swift-style property (no parens) — противоречит D14 «скобки обязательны для вызова» и главное — спрячет O(n) операции за field-syntax.
len(x)builtin (Go-style) — global-function-namespace конфликт с user-types; не работает с method-chainingvec.map(f).len().СУПЕРСЕДЕД D117 AMEND (2026-07-06):cap()(Go naming) — отвергнуто; полное словоcapacity()cap()стал каноном — precedent-пара read/write свойства@cap()/@cap(n)(см. AMEND-блок выше);capacity()был оставлен как alias, затем сам ретрактирован как дубль ([M-unwrap-twins-retraction], 2026-07-07). Историческая причина отказа (D29 explicitness) уступила единообразию read/write-пары одним именем.- Allow bare
.lenкак warning, не error — отвергнуто для bootstrap; method-value form требует явного intent (Plan 11 syntax).
Amend (Plan 139.2 Ф.1): self-field carve-out для declaring type-method
E_SIZE_ACCESSOR_FIELD нацелен на внешних caller’ов (s.len,
vec.len без скобок — забытые скобки / попытка обойти O(n)-cost
сигнал). Метод самого типа имеет полное право читать своё backing-
поле напрямую — это implementation detail (см. «Почему» §4: внутренние
C-поля сохранены). Для generic-типов (Vec[T] @len() => @len) это уже
работало de-facto: при эмите generic-шаблона nova_self-тип ещё не
concrete, поэтому guard не срабатывал.
Когда str стал concrete value-record lang-item (Plan 139.1,
type str value priv { ptr *u8, len int }), его собственный
@len/@byte_len Nova-body (=> @len) начал спотыкаться о D117:
infer_expr_c_type(SelfAccess) == "nova_str" + name == "len". Carve-
out: bare @len field-read разрешён iff (a) obj — SelfAccess,
(b) enclosing receiver — str, (c) name — реально
объявленное поле len (НЕ is_empty/cap/byte_len/capacity —
они не поля str, остаются method-only даже на @). Внешний s.len
(obj — Ident, не SelfAccess) по-прежнему E_SIZE_ACCESSOR_FIELD
(regression-проверено: plan60/f3_str_field_rejected,
plan139/neg_t0_str_len_field — PASS как negatives).
Это разблокировало миграцию str @len/@byte_at external-C →
Nova-body (Plan 139.2 Ф.1): @len() => @len (O(1) byte-len, бывший
nova_str_byte_len), @byte_at(i) = bounds-check + unsafe { @ptr[i] }
(бывший nova_str_byte_at, идентичная OOB-паника).
Связь
- D32 — array layout
(ptr, len, cap); D117 скрывает эти поля от user-language. - D26 — prelude API; D117 добавляет методы
[]T.len(),[]T.cap(),[]T.is_empty(),str.is_empty()в список prelude-API. - D38 — built-in
API для
[]T; D117 amend’ит таблицу (раздел “Built-in API”). - Plan 11 —
method-value semantics; bound form (
x.@len) removed in Plan 132; unboundType.@methodand lambda remain. - Plan 37 — refine arg-position vs non-arg method-value disambiguation (post-Plan 60 follow-up).
- Plan 45 — stdlib doc-comments
обновлены на consistent
.len()form. - Plan 56 — bound-K vtable dispatch для size-accessors на erased generics.
D126. external type — opaque типы без body
Status: 🔴 RETRACTED 2026-06-01 (Plan 91.12 V2) для plain form (
external type Xбезconsume).Retract rationale:
Все 5 stdlib D126 типов мигрированы на более чистые альтернативы:
Тип Migration Pattern StringBuilderPlan 109 (D179) Pure Nova consume record { mut buf []u8 }WriteBufferPlan 91.12 V1 Pure Nova record { mut buf []u8 }(consume @into)ReadBufferPlan 91.12 V1 Pure Nova cursor { ro data, mut pos }OnceCell[T]Plan 91.12 V2 Tuple-newtype type OnceCell[T](ptr)(Plan 115 D214)Lazy[T]Plan 91.12 V2 Tuple-newtype type Lazy[T](ptr)(Plan 115 D214)CondvarPlan 91.12 V2 Tuple-newtype type Condvar(ptr)(Plan 115 D214)Plain
external type Xdeclarations в любом модуле теперь — hard error [E_EXTERNAL_TYPE_RETRACTED]. Type-checker emit’ит diagnostic с migration hint:[E_EXTERNAL_TYPE_RETRACTED] `external type` (D126) retracted by Plan 91.12 V2 (2026-06-01). Replace `external type X` with `type X(ptr)` (tuple-newtype opaque-handle pattern, Plan 115 D214). C runtime backing preserved через `external fn` методы — ABI unchanged. Migration guide: docs/migration/d126-to-tuple-newtype.md. For FFI opaque consume-types оставайся на `external type X consume` (D163, supported).D163 (FFI opaque consume-types) сохраняется:
external type X consumeостаётся allowed для FFI resource handles (File consume,Mutex consume, etc) — это by-design (D163), Plan 100.5. Только plain (non-consume) form retracted.Историческая справка: D126 был bridge bootstrap для opaque runtime types (Plan 62.D.bis, 2026-05-18). Через год эксплуатации стало ясно, что эта форма не нужна:
- Для пользовательских FFI handles → D214
type X(ptr)tuple-newtype (Plan 115, 2026-06-01) даёт лучший type-safety + zero-overhead opaque-pointer wrap.- Для stdlib runtime-backed generic types → тот же D214 паттерн + compiler special-case routing к existing emit_*_instance helpers (Plan 91.12 V2 §«codegen routing»).
- Для FFI resource handles с auto-cleanup → D163
external type X consume(Plan 100.5) — уже отдельная форма с правильной семантикой.Реализация:
- Plan 62.D.bis (2026-05-18) — D126 introduce.
- Plan 109 D179 (~2026-05-28) — StringBuilder pure Nova migration.
- Plan 91.12 V1 (2026-06-01) — WriteBuffer/ReadBuffer pure Nova.
- Plan 91.12 V2 (2026-06-01) — OnceCell/Lazy/Condvar tuple-newtype + formal D126 retract notice (this §).
Cross-ref: D214 —
ptrtype + tuple-newtype opaque-handle (Plan 115), D163 — FFI consume integration (Plan 100.5).
Legacy reference (для historical clarity — больше не применяется к новому коду; type-checker emit’ит E_EXTERNAL_TYPE_RETRACTED):
Что
external type X [Generics] — модификатор type-декларации, означающий
что тип реализован в runtime (C-коде nova_rt/), а Nova-уровневая
декларация даёт только имя + optional generic параметры. Тело
(variants/fields/protocol/effect/alias/newtype) отсутствует —
type «opaque». Аналог D82
external fn, но для типов.
external применяется к типам через D126; к функциям — через
D82.
Один и тот же keyword, два валидных позиционирования
(external fn ... / external type ...).
Правило
Грамматика
type-decl = ['export'] ['external'] 'type' name [generic-params] [body]
Порядок modifiers строгий: export первым, external вторым. Body у
external type должен отсутствовать (никаких { ... },
| variant, effect { ... }, protocol { ... }, alias TYPE, или
newtype TYPE), иначе compile error «external type cannot have a body».
Примеры
// Public external (built-in, Plan 62.D.bis в std/prelude/collections.nv)
export external type StringBuilder
export external type WriteBuffer
export external type ReadBuffer
// Generic external (future Channel use-case)
export external type Channel[T]
// Two-param generic external (future Region use-case)
export external type Region[T, Capability]
// Private external (внутри runtime module'а)
external type Nova_intrinsic_buffer
Связь с D26 prelude
Built-in opaque-типы из D26
(StringBuilder, WriteBuffer, ReadBuffer) объявляются через
external type в std/prelude/collections.nv (Plan 62.D.bis,
2026-05-18). Раньше (Plan 04) типы были «known-by-name» без formal
declaration; D126 даёт canonical source-of-truth + nova doc surface +
eligible для type-annotations / cross-file resolve.
// std/prelude/collections.nv
module std.prelude.collections
export external type StringBuilder
export external type WriteBuffer
export external type ReadBuffer
// + Iter[T] protocol (D58)
Methods на opaque-типах объявляются отдельно через external fn
(D82)
в std/runtime/<type>.nv:
// std/runtime/string_builder.nv
module std.runtime.string_builder
export external fn StringBuilder.new() -> Self
export external fn StringBuilder mut @append(s str) -> Self
export external fn StringBuilder @into() -> str
// ... 11 more methods
Связь декларация ↔ methods — по receiver-type name (StringBuilder).
Нет syntactic block’а, объединяющего type-decl с methods (по
D52 это правильно — methods orthogonal к
declarations, free-fn-style).
Связь с D5/D47 видимостью
export external type — публичный: имя видно из других модулей.
external type без export — модуль-private. Те же правила, что для
обычных type-декл. external ортогонален export.
Связь с D52 kind-tokens
D52 фиксирует kind-tokens type / protocol /
effect. D126 не добавляет нового kind-token’а — external это
модификатор перед type (mirror D82 для fn), не отдельный kind.
В AST это кодируется через TypeDeclKind::Opaque (новый variant,
Plan 62.D.bis Ф.1), параллельный existing Record / Sum / Effect /
Protocol / Alias / Newtype. С точки зрения user’а — external type X это specialised type-declaration формы, не отдельный kind.
Связь с будущим FFI
external type — для типов, реализованных в Nova-runtime
(nova_rt/*.h/.c). Для типов, импортируемых из сторонних
C-библиотек (libuv handles, OS-libs), будет отдельный keyword
extern("C") type (Q-ffi, не реализуется сейчас). Семантика разная:
| Keyword | Реализация | C-name | Разрешён программисту |
|---|---|---|---|
external type | Nova-runtime (nova_rt/) | Nova_<Name>* mangled | нет (только в std.runtime.* / std.prelude.*) |
extern("C") type (TBD) | сторонний C/lib | as-is | да (FFI) |
Restriction: только std.*-whitelist
Программистский Nova-код не пишет external type. Этот keyword —
экспозиционный: только модули в std.runtime.* и std.prelude.*
имеют право его использовать. Компилятор отклоняет external type
в любом другом namespace’е:
error: `external type` is only allowed in `std.runtime.*` / `std.prelude.*`
modules (this module is `myapp.foo`); for FFI to external C libraries
a future `extern("C") type` keyword will be added (Q-ffi)
Whitelist реализуется через manifest::is_stdlib_runtime_module || is_prelude_self_module (тот же check что для external fn per D82).
Mangling и codegen
external type X не эмитит struct definition в C output —
определение живёт в runtime header (nova_rt/<x>.h):
// nova_rt/string_builder.h
typedef struct {
char* data;
size_t len;
size_t cap;
} Nova_StringBuilder;
Codegen reference на external type X использует mangling Nova_X*
(pointer, opaque). Это идентично mangling user-defined record-типов
(type Foo { ... } → Nova_Foo*), что обеспечивает consistency.
| Nova-form | C-name |
|---|---|
let sb StringBuilder = ... | Nova_StringBuilder* sb = ... |
fn f(sb StringBuilder) | void f(Nova_StringBuilder* sb) |
external type Channel[T] | Nova_Channel* (T erased в bootstrap) |
emit_type_decl skip’ает emission для TypeDeclKind::Opaque.
Forward-declarations (typedef struct Nova_X Nova_X;) skip’аются
через BUILTIN_RUNTIME_TYPES skip-list — runtime header сам
предоставляет.
Validation
Аналогично D82,
компилятор validate’ит что декларированный external type реально
существует в runtime (через BUILTIN_RUNTIME_TYPES list + at-emit-
time check). Если user добавит external type FooBar, но
nova_rt/foo_bar.h отсутствует → C-toolchain ошибётся при линковке
с undefined reference to Nova_FooBar при первом методе.
Полная Nova-side validation (компилятор знает все runtime-implemented
типы и заранее ошибётся «type ‘FooBar’ not implemented in runtime»)
— требует registry runtime types, который сейчас живёт неявно в
BUILTIN_RUNTIME_TYPES. Q-codegen-runtime-types-registry — отдельная
задача аналогично D82 builtins.nv validation; bootstrap relies на
list maintenance.
Почему
Зачем нужен external type
-
Source-of-truth для
nova doc. Программист (и AI) видит формальную декларацию типа в одном месте —nova doc std.prelude.collectionsпокажет StringBuilder/WriteBuffer/ ReadBuffer как canonical API. Раньше (Plan 04, до Plan 62.D.bis) типы существовали только как bare-name строки в D26 spec’е — не visible в tooling. -
Eligibility для cross-file resolve. После formal declaration типы участвуют в R26+R27 resolve (Plan 35). User-код может писать
import std.prelude.collections.{StringBuilder}или полагаться на auto-import через prelude. -
D29 W_PRELUDE_SHADOW работает. User declaration
type StringBuilder { ... }теперь генерирует warning (mirror Plan 62.A behavior для Option/Result). Раньше silent shadow. -
Symmetry с D82
external fn. Если методы opaque-типа объявляются черезexternal fn, сам тип должен иметь parallel form. Без D126 semantic asymmetry: methods are first-class, type itself isn’t. -
Future-proof для opaque user-types (Channel, Region, mmap’ed buffers). Когда возникнет use-case, mechanism уже есть — нужно только relax whitelist (или ввести
extern("C") typeдля FFI).
Почему не opaque type
- Один keyword (
external) для двух concepts (fnиtype) — снижает cognitive load. Прецедент: OCamlexternal, Dartexternal, Kotlinexternal— все используют один keyword для функций и (когда уместно) типов. opaqueподразумевает abstraction-from-user-code, а semantic нужный здесь — implementation-elsewhere.externalточнее семантически.
Почему не #external attribute
- Per D82
уже decision: «Атрибуты в Nova зарезервированы для тестов /
dev-tools (Q-attributes). Modifier-форма единообразна с
export/mut». D126 follows тот же principle. #externalдублировал бы syntax:#external type Xvsexternal type X. Choose one — modifier form for consistency.
Почему restrict scope
- Bootstrap MVP — программист не должен объявлять opaque types произвольно. Runtime backing — это compiler-versioned artefact, не user-extensible (см. D82 same argument). User-extensibility опасна: declaration без runtime impl приведёт к undefined-reference C errors.
- Future relaxation требует либо:
- Plugin mechanism (compiler plugin defines runtime — too heavy для bootstrap).
- FFI keyword
extern("C") type(Q-ffi) — для внешних libs, не Nova-runtime.
Что отвергнуто
- Bare-name
type X(no modifier, no body) — parser ambiguity с newtype branch (type X SomeType). opaque type X— separate keyword без явного gain.#externalattribute — modifier consistency lost.type X { _ runtime }body — magic body, parsing complexity.- Auto-discovery по runtime header presence — magic, debugging
nightmare. Explicit
externalлучше. - Включить methods в декларацию типа (
external type X { fn @method ... }): per D52 + Plan 11, methods orthogonal к type-decl (free-fn-style). Не ломаем consistency только для opaque types.
Связь
- D5 / D47 —
exportmodifier;external— ортогональный второй modifier. - D26 — prelude содержит
StringBuilder/WriteBuffer/ReadBuffer; декларации типов — через D126
в
std/prelude/collections.nv. - D30 — naming convention;
external— full word. - D52 — kind-tokens (
type/effect/protocol); D126 не добавляет нового kind-token’а —externalэто modifier. - D82
—
external fn; D126 — type-analog того же принципа. Один keywordexternal, два valid позиционирования.
Эволюция
До Plan 62.D.bis (2026-05-18) типы StringBuilder/WriteBuffer/ReadBuffer
существовали как «known-by-name» (D26 prose-only), без formal Nova-
side declaration. D82 (2026-05-08, Plan 04) явно отложил external type как «not yet — built-in only».
Plan 62 main (2026-05-18) выявил это как последний «known-by-name» hole в D26 visible prelude (все остальные items мигрированы 62.A– 62.F). Plan 62.D.bis закрывает.
D126 numbering — выбран чтобы продолжить chronology D124/D125 (Plan
62.F.bis, 2026-05-18); ставит этот D-block в 03-syntax.md (syntax-
extension), отдельно от runtime-side D82 в 08-runtime.md.
Bootstrap status (2026-05-18)
- ✅ Lexer:
KwExternaltoken уже существует (Plan 04 Этап 2). - ✅ Parser: relax
externalcheck наKwType(Plan 62.D.bis Ф.1). - ✅ AST:
TypeDeclKind::Opaquevariant добавлен (Plan 62.D.bis Ф.1). - ✅ Type-checker: whitelist enforcement (
std.runtime.*/std.prelude.*) — Plan 62.D.bis Ф.1. - ✅ Codegen: skip
emit_type_declдля Opaque kind (Plan 62.D.bis Ф.1). - ✅
std/prelude/collections.nv: добавлены 3 declarations (Plan 62.D.bis Ф.2). - ✅
std/prelude.nvfacade: re-export (Plan 62.D.bis Ф.2). - ⏳ Validation: формальная registry runtime-types — deferred
(Q-codegen-runtime-types-registry), bootstrap полагается на
BUILTIN_RUNTIME_TYPESlist maintenance.
D132. -> @ — fluent-return (метод возвращает receiver)
Plan 77. Принято 2026-05-21 (вариант B обсуждения Plan 73).
Что
Тип возврата -> @ означает: метод возвращает сам receiver.
Тип результата — receiver-тип (эквивалент Self), плюс гарантия, что
возвращается именно тот объект, на котором метод вызван.
fn StringBuilder mut @append(s str) -> @ // вернёт сам StringBuilder
fn Counter mut @bump() -> @ { @n = @n + 1; @ }
Зачем — Self отвечает «какой тип», @ отвечает «какой объект»
Self (D66) — referential тип: «тот же тип, что
у receiver’а». Метод @m() -> Self может вернуть и новый объект
того же типа (@clone() -> Self — копия). Builder-/fluent-методам
нужно строго «тот же объект» — для chaining (sb.append("a") .append("b")) и для проверяемых инвариантов.
-> @ даёт это явно: @ в позиции return-type — value-level двойник
type-level Self, консистентно с @ = receiver везде в Nova.
Правила
- Только instance-метод.
-> @требует@-receiver’а; на static-методе (Type.method) и свободной функции — parse error. - Тело обязано вернуть
@. Non-external метод с-> @: тело завершается выражением@. Иначе compile error — иначе гарантия-> @была бы ложной. external fn ... -> @— C-реализация по контракту runtime’а возвращает receiver (напр.Nova_StringBuilder_method_append→return b).- Тип результата для type-checker / codegen — receiver-тип (как
Self).
Что это разблокирует
- Sound builder-chain alias в consume-checker (D131):
let sb2 = sb.append("x")— разappendобъявлен-> @,sb2гарантированно алиасsb; use-after-consume через chain ловится. - Самодокументируемые fluent-API — fluent виден из сигнатуры (важно для AI-first: локальность контекста).
Сравнение
Rust выражает «возвращает receiver» через &mut self -> &mut Self
(заём) либо self -> Self (move) — точно, но ценой borrow-checker /
ownership-модели. Go сознательно отказался от builder-chaining
(b.WriteString(...) отдельными statement’ами). TS this-тип — как
наш Self, «тот же тип», без гарантии объекта. -> @ даёт
Rust-уровень точности без borrows / lifetimes — поверх GC.
Поправка (Plan 91 Ф.2.6, 2026-05-28) — wrapper-метод и инверсная проверка
Правило 1 (уточнение). Тело -> @ метода обязано завершаться
выражением, которое статически гарантированно возвращает receiver:
- Bare
@— всегда OK. - Вызов другого метода того же типа, объявленного
-> @, на@(@write(),@append(s)) — OK, поскольку он гарантированно вернёт сам receiver. if/else, где все ветки удовлетворяют условиям выше — OK.- Всё прочее — compile error (D132).
fn Buf mut @write() -> @ { @n = @n + 1; @ } // ✅ bare @
fn Buf mut @push() -> @ => @write() // ✅ делегирует в -> @ метод
Правило 2 (инверсное, новое). Если метод объявлен -> Self, но
все пути тела статически возвращают receiver (@ или вызов -> @
метода), это compile error:
error[E_FLUENT_SELF]: метод `step` объявлен `-> Self`, но все пути
возвращают сам receiver (`@`). Используйте `-> @`.
Рационал: -> Self и -> @ — разные семантики. -> @ = «возвращает
тот же объект» (гарантия aliasing). -> Self = «возвращает значение
того же типа» (может быть копия/новый). Объявить -> Self там, где
тело делает только -> @ семантику — это дезинформация для
type-checker’а (нарушает consume-aliasing D131).
Связь
- D131 —
consume; главный потребитель-> @(builder-chain alias). - D66 —
Self(referential тип);-> @— его value-level уточнение «именно receiver». - D35 — методы инстанса.
D143. Static-метод в protocol {} через leading-точку
Plan 97. Принято 2026-05-23. Закрывает
Q-static-method-protocol(был в D58 разделе открытых вопросов).
Что
В теле protocol {} метод объявленный с leading-точкой
(.method(args) -> Ret) — статический (D35: ожидаемая реализация
fn Type.method(...)); метод без префикса (method(args) -> Ret) —
instance (ожидаемая реализация fn Type @method(...)).
type From[T] protocol {
.from(t T) -> Self // static — Type.from(v)
}
type Hash protocol {
hash() -> u64 // instance — value.hash()
}
type Builder[T] protocol {
.new() -> Self // static — Type.new()
@push(item T) -> @ // instance, mutating, fluent return (D132)
}
Правило
Синтаксическое различение
protocol-method := [ "#pure" ] [ "." | "@" ]? ident generics? "(" params? ")" effects? ret? contracts?
.name(...)— static-метод (симметрично D35fn Type.name).@name(...)— instance-метод (явный маркер, симметрично D35fn Type @name). Bare-имяname(...)остаётся instance по умолчанию (backwards-compat).- Static + instance с одинаковым именем в одном протоколе —
запрещены (parse error «duplicate method
fooin protocol»).
Matching типа против протокола
При проверке «тип T удовлетворяет protocol P»:
- Для
is_static = trueметодаP— ищетсяfn T.method(...)(D35 static-форма, регистрируется компилятором среди статиков). - Для
is_static = false(instance) — ищетсяfn T @method(...)(D35 instance-форма, регистрируется среди методов receiver-типа).
Несовпадение static/instance — compile error «type T does not
satisfy P: method foo declared .foo (static) but T provides
instance @foo» (либо обратное). Это hardening аналогичный Plan 79;
вводится постепенно — на момент Plan 97 Ф.1 matching остаётся
структурно ленивым (см. [M-protocol-static-enforcement-deferred]).
Backwards-compat
Все существующие протоколы (Iter/Hash/Equal/Compare/
Display/Into/TryInto) написаны bare → остаются instance без
изменений. Меняются только From/TryFrom в std/prelude/protocols.nv
(их методы .from/.try_from — static).
Почему
- D35 симметрия: реализация
fn Type.name(...)— статический метод (точка); реализацияfn Type @name(...)— instance (@). Декларация в протоколе должна те же маркеры использовать; без этогоFrom.from(t T) -> Selfнеотличимо от instance, что противоречит D35. - Самодокументированность прелюдии:
From[T] protocol { .from(t T) -> Self }сразу читается «статический фабричный метод»; без точки — неоднозначно. - Spec hint:
D58раздел открытых вопросов уже предложил именно.method()-префикс (см.Q-static-method-protocolдо резолва); Plan 97 этот hint реализует. - Bare = instance (а не «требовать
@явно») — backwards-compat: существующие протоколы не переписываются. Явный@-префикс — Q-openQ-protocol-method-prefix(followup, не блокер).
Что отвергнуто
static method(...)keyword — отвергнут (нетstaticв Nova, противоречит D35 «точка для static»).[static]атрибут — несимметричен D35 и громоздок.- Инференция static из «возвращает Self без self-параметра» —
фрагильно (
fn into() -> Uтоже без явного self-параметра, но instance). @methodобязательный для instance — отвергнут ради backwards- compat. Может вернуться как optional symmetry-маркер (Q-open).
Связь
- D35 — реализация: static через
., instance через@. D143 — декларация в протоколе через те же маркеры. - D58 — раздел открытых вопросов;
Q-static-method-protocolзакрывается этим D-блоком. - D53 — protocol declaration (контейнер для D143).
- D142 — symmetry effect/protocol declaration ↔ literal (соседний D-блок Plan 97).
- D77 —
From/TryFrom4-way auto-derive (главные потребители static в протоколах). - Plan 97 Ф.1 —
имплементация (parser + AST
is_static).
D158. Failable cleanup body — Fail effect разрешён в defer/errdefer
Plan 100.4.1. Принято 2026-05-23 (proposed; implementation pending). Amend D90 §4 — снимает ограничение «defer body INFALLIBLE».
АМЕНДМЕНТ 2026-07-06 (Plan 173 Ф.4, решение владельца — модель Б). Ранняя редакция D158 (ниже, в §«Правила composition» и §«MultiError API») заворачивала обе ошибки в конверт
Err(MultiError { primary, suppressed })и отдавала его как единыйFail[MultiError]. Пересмотрено: primary отдаётся ловящему КАК ЕСТЬ (типизированная ловляwith Fail[Primary]работает, эффект НЕ становитсяFail[MultiError]); подавленные лежат «в кармане» и достаются свободным аксессоромsuppressed() -> []anyПОСЛЕ ловли. Нормативная модель доставки — §«[Модель доставки — вариант Б] (#d158-модель-доставки—вариант-б)» ниже; конверт-модель отвергнута (§«Что отвергнуто»). Материализация подавленных реализована end-to-end (Plan 173 Ф.4 #6/#7): runtime suppressed-chainNovaErrorChain→suppressed()черезnova_failframe_suppressed_count/_at.
Что
defer { ... } и errdefer { ... } body теперь может содержать Fail-
effect (вызов failable consume-метода / любой Fail-action). Cleanup-fail
композируется с propagating error через D85 /
D118 multi-error infrastructure: каждая ошибка
сохраняется в chain (primary + suppressed), caller получает composite
через MultiError.
fn process() Fail[Err] -> () {
consume tx = begin()
defer { tx.commit() } // commit may fail — теперь валидно
do_work() // may throw Err1
// Если do_work fails:
// 1. unwinding starts
// 2. defer fires — tx.commit() fails Err2
// 3. composite: { primary: Err1, suppressed: [Err2] }
// 4. caller получает composite через Fail[Err]
}
Зачем
D90 §4 (Plan 20) запретил Fail-effect в defer-body как защита от
тихого поглощения ошибок. Это работало для simple cleanup (log,
mutex.unlock), но блокирует production resource-management:
Transaction.commit()/.rollback()— failable (network drop, deadlock, constraint violation).File.close()— может fail (disk error).Socket.shutdown()— может fail.Connection.disconnect()— может fail.
Без D158 каждый такой cleanup — 6-строчный handler-wrap, что не production-grade ergonomics. D158 force’ит explicit Fail в fn-sig (compile-time visibility), а composition handles runtime.
Изменение D90 §4
БЫЛО: defer body не должно иметь Fail effect; обернуть в handler.
СТАЛО: defer body может иметь Fail effect; ошибка композируется через
Plan 49 multi-error. Enclosing fn-sig ОБЯЗАН declare Fail[E].
Правила composition (3 сценария)
A. Defer-fail на normal exit:
fn process() Fail[Err] -> () {
consume tx = begin()
defer { tx.commit() } // may fail
do_work() // success
}
// Exit: defer fires; commit может throw — caller получает Fail.
B. Defer-fail во время error-propagation:
fn process() Fail[Err] -> () {
consume tx = begin()
defer { tx.commit() }
do_work()? // throws Err1
// defer fires during unwinding:
// tx.commit() fails CommitErr → composite
// { primary: Err1, suppressed: [CommitErr] }
}
C. Multiple defers, each can fail — детально в D161 (Plan 100.4.4 multi-defer accumulation).
Модель доставки — вариант Б
Нормативно (амендмент 2026-07-06, решение владельца — Plan 173 Ф.4 §6/#1). Перекрывает §«Правила composition» и §«MultiError API» выше в части доставки композита ловящему (правила накопления цепочки — в силе).
Cleanup-fail во время propagation НЕ подменяет primary и НЕ меняет эффект:
- primary — единственный носитель эффекта. Тело бросило
Primary→ наружу летитPrimary.with Fail[Primary]ловит;e=Primary(type_id/payload сохранены,e is Primaryистинно); эффект остаётсяFail[Primary], не превращается вFail[MultiError]. - подавленные — вне эффекта, «в кармане». Каждый cleanup-fail во время
unwind’а копится в thread-local suppressed-chain (
NovaErrorChainна fail-frame, LIFO); в момент ловли снимок цепочки оседает в кармане (_nova_last_error). Ловящий достаёт их после перехвата свободным аксессоромsuppressed() -> []any. - если упал только cleanup (тело успешно) — cleanup-ошибка становится
primary (обычный
Fail), карман пуст.
fn process() Fail[WorkErr] -> () {
consume tx = begin()
defer { tx.commit() } // может кинуть CommitErr
do_work()? // кидает WorkErr (primary)
}
// Ловим primary типизированно; подавленные — из кармана ПОСЛЕ ловли:
mut recovered = default_result()
with Fail[WorkErr] = effect Fail[WorkErr] {
fail(e) { interrupt log_and_default(e) } // e: WorkErr — типизированно
} {
process()
}
for s in suppressed() { // == [CommitErr]
if s is CommitErr { Log.warn("commit failed during rollback") }
}
suppressed() -> []any — свободный аксессор (std/prelude/runtime.nv):
возвращает подавленные последней пойманной ошибки в хронологическом порядке
аварий (первая-упавшая первой); каждый элемент — any, сужается через
is T / .try_as[T]() (D54 / D53).
Пустой массив = cleanup не падал. Карман сбрасывается на каждый свежий
throw и заполняется в момент ловли, поэтому не течёт между несвязанными
ловлями (инвариант Plan 173 Ф.4 #7).
Тип MultiError (std/prelude/errors.nv, методы @primary/@suppressed/
@walk/@find_first_panic) остаётся как явная value-обёртка для кода,
который хочет собрать primary+suppressed в одно значение (лог-агрегация,
walk, panic-detection). Он НЕ конверт эффекта и НЕ строится компилятором
автоматически — это опциональная надстройка над suppressed().
Что отвергнуто
Конверт-модель Err(MultiError { primary, suppressed }) как Fail[MultiError]
— ОТВЕРГНУТА (владелец, 2026-07-06). Причина: заворачивание primary в
MultiError ломает типизированную ловлю — with Fail[Primary] перестал бы
ловить (эффект стал бы Fail[MultiError]), а весь смысл typed-errors в том,
что root cause ловится по своему типу. Композит-как-значение остаётся доступен
через явный тип MultiError для тех, кому нужна агрегация, но по умолчанию
эффект несёт только primary, а подавленные — вне канала эффекта.
(Историческая иллюстрация отвергнутой формы — деструктуринг
Err(MultiError { primary, suppressed }) в §«MultiError API» выше — читать
как «НЕ так»; актуальная форма — §«Модель доставки — вариант Б».)
Compile-time visibility — fn-sig обязан Fail[E]
fn process() -> () { // ❌ нет Fail[E]
defer { tx.commit() } // ❌ Fail[CommitErr] body
}
// E (D158-defer-fail-not-in-sig): add `Fail[CommitErr]` к fn-sig.
Force’ит explicit visibility в API.
Diagnostic format
error: composite error during scope exit
primary error:
Err1 ("operation failed") at do_work (process.nv:12)
suppressed during defer LIFO (in order of firing):
[1] CommitErr ("network timeout") at tx.commit() in defer (process.nv:14)
[2] Err3 ("disk full") at tx1.commit() in defer (process.nv:13)
Сравнение
| Capability | Go | Rust | TS (ES2024) | Java | Nova D158 |
|---|---|---|---|---|---|
| Cleanup body может fail | ✅ (return err) | ❌ panic-in-Drop = abort | ✅ Symbol.dispose throws | ✅ AutoCloseable.close throws | ✅ Plan 49 composition |
| Error composition при cleanup-fail-mid-error | ⚠️ manual | ❌ abort | ✅ SuppressedError chain | ✅ addSuppressed | ✅ MultiError tree |
| Visibility в сигнатуре | ⚠️ method-by-method | n/a | ⚠️ TS types | ⚠️ throws-list | ✅ Fail[E] effect |
Nova matches Java/TS на composition; превосходит Rust (no
double-panic-abort) + Go (нет manual defer error-handling).
Backward-compat
Existing handler-wrap код продолжает работать. D158 — расширение capabilities, не breaking change.
Связь
- D90 §4 — amend’аем.
- D85, D118 — composition infrastructure.
- D131, D133 — consume foundation.
- D159, D160, D161, D162 — sibling sub-sub-plans Plan 100.4 family.
D159. Async/suspend в cleanup body — cancel-safe
Plan 100.4.2. Принято 2026-05-23 (proposed). Amend D90 §5 — снимает «no-suspend».
Что
defer/errdefer body теперь может содержать suspend-операции
(Time.sleep, Channel.recv, Net.*, Fs.*). Cancel-safe
semantics: cleanup completes-then-cancel-propagates (runtime shield’ит
cleanup от cancel signal до его завершения).
fn process() -> () {
consume socket = open_socket()
defer { socket.graceful_close() } // includes Net.* — теперь валидно
do_io()
}
// Exit + pending cancel:
// 1. graceful_close может suspend (FIN+ACK).
// 2. cleanup completes (shielded).
// 3. cancel propagates AFTER cleanup.
Запрещено
spawn / parallel for в defer body — error E (D159-spawn-in-defer).
Создание новых fiber’ов в cleanup → leak supervised hierarchy.
Изменение D90 §5
БЫЛО: defer body NO-SUSPEND (Time.sleep, Channel.recv, Net.* запрещены).
СТАЛО: suspend разрешён; cancel-safe (cleanup completes-then-propagates);
spawn/parallel for остаются запрещены.
Time.timeout для bounded cleanup
defer {
with Time.timeout(5_s) {
socket.graceful_close() // если >5s — abort
}
}
(Полная реализация Plan 22 libuv async — already ✅.)
Сравнение
| Capability | Rust | TS | Kotlin | Nova D159 |
|---|---|---|---|---|
| Async cleanup body | ⏳ Rust 2024+ work-in-progress | ✅ await using | ✅ coroutine use{} | ✅ defer body suspend |
| Cancel-safe (cleanup completes first) | ⚠️ manual shielded | ✅ AbortSignal | ✅ withContext(NonCancellable) | ✅ shield-by-default |
Связь
- D90 §5 — amend’аем.
- D158 — failable cleanup (parallel).
- D85 — cancel-routing foundation.
- Plan 22 ✅ — async foundation.
D160. okdefer + reason-aware defer |result|
Plan 100.4.3. Принято 2026-05-23 (proposed). Статус: RETRACTED by D189 (Plan 110.5.7 hard cutover, 2026-05-31). Replaced by
consume X = ... { body }scope-block сmatch outcome { Success/ Failure(_)/Panic(_) }вcleanupmethod (D188).Новые scope-level statements; complement к D90 defer/errdefer family.
Что
Два новых construct’а:
-
okdefer { ... }— complement кerrdefer. Выполняется только на success-path (normal exit /return expr); skipped при throw/panic/interrupt. Симметризует defer-family. -
defer |result| { ... }— reason-aware форма. Body имеет доступ к exit-reason через patternresult(Ok(value)/Err(e)/Panic(m)).
Использование
consume tx = begin()
errdefer { tx.rollback() } // error path → rollback
okdefer { tx.commit() } // success path → commit
do_work()?
// На обоих paths tx covered — exhaustive coverage.
defer |result| {
match result {
Ok(value) => Log.info("success: ${value}"),
Err(e) => Log.error("failed: ${e}"),
Panic(m) => Log.fatal("panic: ${m}"),
}
}
Триггерные правила
| Exit-path | defer | errdefer | okdefer |
|---|---|---|---|
| Normal end-of-scope | ✅ | ❌ | ✅ |
return expr (без error) | ✅ | ❌ | ✅ |
throw err / expr? / expr!! | ✅ | ✅ | ❌ |
panic(msg) | ✅ | ✅ | ❌ |
interrupt v (после D162 amend) | ✅ | ✅ | ❌ |
exit(code) | ❌ | ❌ | ❌ |
okdefer + errdefer — exhaustive (один и только один срабатывает при non-exit() exit’е).
Exit-path определяется в start, НЕ retro-fires
Если okdefer { tx.commit() } запустился (success-path) и commit()
fail’ит — exit-path остаётся success. errdefer того же scope’а
НЕ fires ретро-активно. Failure okdefer’а propagates через D158/
D161 multi-error composition (composite { primary: cleanup-fail }).
consume tx = begin()
errdefer { tx.rollback() }
okdefer { tx.commit() }
do_work()
// normal exit → exit-path = SUCCESS
// okdefer fires → commit fails Err1
// errdefer SKIPPED (success exit-path не retro-changes на error)
// Err1 propagates через D158 composition
Почему так: (1) tx уже Consumed через commit (failed or not) — rollback on Consumed = error; (2) commit-failure не означает «rollback safe» (may have partial DB state); (3) Предсказуемая семантика: exit-path fixed at start.
Если programmer хочет «rollback-if-commit-fails»:
okdefer {
with Fail = handler {
fail(e) {
tx.rollback()?
throw e
}
} {
tx.commit()
}
}
Mixed LIFO
defer A
okdefer B
errdefer C
okdefer D
defer E
- Normal exit LIFO:
E → D → B → A(defer + okdefer; errdefer skipped). - Error exit LIFO:
E → C → A(defer + errdefer; okdefer skipped).
Сравнение
Unique среди GC-языков — никто не имеет success-only cleanup distinction:
| Capability | Go | Rust | TS | Kotlin | Nova D160 |
|---|---|---|---|---|---|
| Success-only cleanup | ❌ | ❌ | ❌ | ❌ | ✅ okdefer |
| Reason-aware cleanup | ❌ | ❌ | ❌ | ⚠️ try-finally manual | ✅ defer |result| |
| Symmetric defer family | ❌ | ❌ | ❌ | ❌ | ✅ defer + errdefer + okdefer |
Связь
- D90 — defer/errdefer foundation.
- D158 — failable body может Fail в okdefer тоже.
- D159 — suspend body тоже.
- D162 — consume-integration uses okdefer для commit-on-success.
D161. Multi-defer LIFO error accumulation + panic-in-defer composition
Plan 100.4.4. Принято 2026-05-23 (proposed). Extends D158 composition на multi-defer + panic. Amend D90 §«panic».
Что
- Multi-defer LIFO continues после partial failure. Если defer N fail’ит → defer N-1 still runs (все N attempted; errors accumulate в Plan 49 multi-error chain). Превосходит Rust уверенно (no abort + all cleanups attempted).
- Panic в defer body композируется с propagating через Plan 49 multi-error — нет Rust-style double-panic-abort.
LIFO с partial failure
fn process() Fail[MultiErr] -> () {
defer A_runs // fail E_a
defer B_runs // fail E_b
defer C_runs // success
body // fail E_main
}
// Exit semantics:
// 1. body throws E_main
// 2. C_runs — success; no contribution
// 3. B_runs — fails E_b; suppressed
// 4. A_runs — fails E_a; suppressed (LIFO continues!)
// 5. caller получает MultiError {
// primary: E_main,
// suppressed: [E_b, E_a] // LIFO order: first to fail = first
// }
Panic-in-defer composition
fn process() Fail[Err] -> () {
defer { panic("cleanup broken") }
do_fails()? // throws Err1
}
// Exit:
// 1. body throws Err1
// 2. unwinding starts
// 3. defer fires — panic("cleanup broken")
// 4. panic composes с Err1 → composite { primary: Err1, suppressed: [Panic("cleanup broken")] }
// 5. propagation continues with composed error
Никаких abort’ов. Plan 49 multi-error already supports panic-as- throw; D161 расширяет composition на panic.
Defer-stack runtime structure
for entry in stack.reverse() {
let result = run_defer_body(entry)
match result {
Ok(()) => continue
Err(e) => { propagating = compose(propagating, e); continue }
Panic(m) => { propagating = compose(propagating, Panic(m)); continue }
}
}
throw propagating
LIFO walk completes даже при ошибках. Rust does NOT do this.
Diagnostic — chain visibility
error: composite error during scope exit
primary error:
Err1 ("operation failed") at do_work (process.nv:12)
suppressed during defer LIFO:
[1] Err_B ("cleanup B failed") at B_cleanup() in defer (process.nv:10)
[2] Err_A ("cleanup A failed") at A_cleanup() in defer (process.nv:8)
[3] Panic("cleanup C broken") at panic() in defer (process.nv:11)
Сравнение
| Capability | Go | Rust | TS | Kotlin | Java | Nova D161 |
|---|---|---|---|---|---|---|
| Multi-cleanup LIFO continues после partial fail | ⚠️ defer continues errors lost | ❌ first-panic-abort | ✅ SuppressedError | ⚠️ partial | ✅ addSuppressed | ✅ Plan 49 multi-error |
| Panic в cleanup body | ✅ recover() | ❌ double-panic-abort | ⚠️ SuppressedError | ⚠️ try-catch | ⚠️ silent if not addSuppressed | ✅ composition + no abort |
| All N cleanups attempted | ⚠️ depends | ❌ first-Drop-only-tries | ⚠️ depends | ✅ try-finally chain | ✅ try-with-resources | ✅ guaranteed |
Nova превосходит Rust уверенно (no double-panic-abort + all cleanups attempted) + matches TS/Java на composition + превосходит на visibility (effect-typed).
Связь
- D90 §«panic» — amend’аем.
- D158 — failable cleanup foundation.
- D85 — multi-error composition.
- D162 — consume-integration uses D161 для multi-consume failures.
D162. Consume-integration final — check_consume + defer-family + cancel
Plan 100.4.5. Принято 2026-05-23 (proposed). Amend D90 §7 (
interrupttriggers errdefer). Финал Plan 100.4 umbrella.
Что
check_consume pass (D133) распознаёт defer/errdefer/okdefer
как покрывающие consume-vars на соответствующих exit-paths:
| Statement | Покрывает consume на path’е |
|---|---|
defer { tx.commit() } | все exit-paths (success, error, panic, interrupt) |
errdefer { tx.rollback() } | error-paths (throw, panic, interrupt — amend D90 §7) |
okdefer { tx.commit() } | success-path (normal exit, return) |
Amend D90 §7
БЫЛО: errdefer triggers on throw + panic; NOT on interrupt.
СТАЛО: errdefer triggers on throw + panic + INTERRUPT (за исключением exit()).
Логика: errdefer = «exit без normal completion». throw/panic/interrupt — все «abnormal» exits относительно success-path. Backward-compat impact — handler-flow user-code: errdefer’ы now fire on interrupt. Plan 100.4.5 Ф.0 GATE audit’ит existing fixtures.
Multiple defers на одну consume-var
consume tx = begin()
errdefer { tx.rollback() } // error path
okdefer { tx.commit() } // success path
do_work()?
// tx covered: error (errdefer) + success (okdefer) = exhaustive
Double-cover — error
consume tx = begin()
okdefer { tx.commit() }
tx.commit() // ❌ E (D162-double-cover):
// okdefer already commits.
Partial coverage — error
consume tx = begin()
errdefer { tx.rollback() }
do_work()?
// ❌ E (D162-not-consumed-on-path): success path tx Live.
// Suggest: добавить `okdefer { tx.commit() }` или explicit `tx.commit()`.
Exit-path fixed at start (НЕ retro-fire)
См. D160 §«Exit-path определяется в start, НЕ retro-fires» — если okdefer fail’ит на success-path, errdefer не fires дополнительно. Failure composes через D158/D161 multi-error composition. Exit-path определяется в начале unwinding’а и не меняется по ходу defer-execution.
Supervised cancel + consume cleanup
supervised(cancel: tok) {
spawn {
consume tx = begin()
errdefer { tx.rollback() } // покрывает cancel-path после D90 §7 amend
long_op() // may cancel
tx.commit()
}
}
// На cancel: errdefer fires → tx.rollback() runs (cancel-shielded по D159);
// rollback completes; fiber dies; supervised continues unwinding.
Async-await preservation
fn process() Fail Async -> () {
consume tx = begin()
errdefer { tx.rollback() }
await long_async_op() // suspend; may cancel
tx.commit()
}
// Pre-await: tx Live, errdefer registered.
// Post-await: tx still Live.
// Cancel-during-await: errdefer fires → tx Consumed via rollback.
Canonical Transaction lifecycle
fn process_order(data Data) Fail[OrderErr] Db -> Receipt {
consume tx = Db.begin()
errdefer { tx.rollback()? } // failable rollback (D158)
okdefer { tx.commit()? } // failable commit (D158)
ro order = Db.insert(data)?
ro receipt = Db.notify(order)?
return receipt // okdefer fires → commit
}
// Error: errdefer fires → rollback (composite если rollback fails)
// Success: okdefer fires → commit (throw если commit fails)
Связь
- D90 §7 — amend’аем.
- D131, D133 — consume foundation.
- D158, D159, D160, D161 — precondition (Plan 100.4 family).
- Plan 49 — cancel-routing.
- Plan 47 — supervised.
D184. Keyword refresh: ro/mut/consume bindings, const narrowed + generalized, no let, readonly → ro
📌 Полная модель мутабельности — D246 (3 оси: L1 binding / L2 content-view / L3 pointee). Этот D-блок описывает только синтаксис binding-ключевых слов (
ro/mut/consume) и переименованиеreadonly→ro. Для семантики, дефолтов и error-кодов читай D246.
🔗 Amend — D347 (Plan 181): повторное
ro/mut/consume x = …в ОДНОМ scope легально (new binding; тип может меняться). Правила R1–R7 — см. D347.
Статус: 🆕 draft (Plan 114 Ф.0; финализируется в Ф.8).
Что
Plan 114 фиксирует единую keyword-поверхность для четырёх ортогональных осей immutability в Nova V2:
| Ось | Keyword’ы | Позиции |
|---|---|---|
| Binding mutability + ownership | ro (immutable), mut (mutable), consume (owned) | scope; ro также module-level |
| Hard compile-time guarantee | const (strict constexpr) | module-level + scope-local + record-field (associated const) |
| Per-field freeze | ro field T / mut field T / field ro T | внутри type X { … } |
| Comptime evaluable functions | const fn (с const params и -> const T return) | top-level fn-declaration |
let retracted. readonly retracted (rename → ro). const keyword
сохранён, semantics narrowed (strict constexpr-only) + generalized (работает
в трёх позициях с единой semantics).
Правило: binding statements
ro x = 5 // immutable binding
mut counter = 0 // mutable binding
consume sb = StringBuilder.new() // owned binding (Plan 73.1)
ro x int = 5 // с явным типом (Plan 70 prefix-form)
ro (a, b) = pair // destructuring tuple — оба immutable
mut (lo, hi) = bounds // destructuring tuple — оба mutable
ro { name, age } = user // destructuring record
ro/mut— statement-leading keyword’ы в любой statement-позиции (top of fn body, top of block, body for/while/match arm).=обязателен. Barero x/mut xбез init =E_BINDING_REQUIRES_INIT.- Destructure-pattern: leading keyword распространяется на все имена.
Per-element granularity (
(ro a, mut b)) не вводится — destructure + reassign если нужна асимметрия. mut X = exprна module-level →E_MUT_AT_MODULE_LEVEL.consume X = exprна module-level →E_CONSUME_AT_MODULE_LEVEL.ro X = exprвалиден на module-level (заменяет старыйlet X = …host для non-constexpr lazy-init).
Правило: pattern-bind в условиях
if Pat = expr / while Pat = expr без outer keyword. Pattern grammar
унифицирована с match arm:
// Constructor / destructure pattern — bare bindings default immutable
if Some(user) = cache.get(key) { use(user) }
if Some(mut buf) = pool.try_take() { buf.fill(0) } // mut inside pattern
if (a, b) = pair { use(a, b) }
if { name, age } = user_opt { greet(name, age) }
while Some(item) = queue.pop() { handle(item) }
while Some(mut line) = reader.read_line() { line.trim_in_place() }
// Identifier pattern — REQUIRES `ro`/`mut` (footgun protection)
if ro user = compute() { use(user) }
if mut counter = init() { counter += 1; … }
if user = compute() { … } // E_AMBIGUOUS_IDENT_PATTERN
// Chains (Plan 106)
if Some(user) = lookup(id), user.is_active {
process(user)
}
// else if
if Some(a) = lookup_a() {
use(a)
} else if Some(b) = lookup_b() {
use(b)
}
- Constructor / destructure pattern: bare bindings default immutable;
mutexplicit inside (Some(mut x),(mut a, b)). - Identifier pattern (
if NAME = expr): обязательноro/mut— иначеE_AMBIGUOUS_IDENT_PATTERN(визуально неотличимо от assignment). consumeзапрещён в conditions —E_CONSUME_IN_CONDITION.mutoutside pattern удалён:if mut Some(buf) = e→ useif Some(mut buf) = e(E_OUTER_MUT_IN_CONDITION).- Chains (Plan 106) переиспользуют тот же
if_cond. - Pattern grammar shared между match arm и if/while condition.
Правило: readonly → ro (keyword rename, все позиции)
| Позиция | Было | Стало |
|---|---|---|
| Field default-immutable | readonly id u64 | ro id u64 |
| Field type-modifier (mutable ref, ro content) | field readonly T | field ro T |
| Field always-mut, ro content | mut field readonly T | mut field ro T |
| Param explicit ro (synonym default) | fn f(readonly b T) | fn f(ro b T) |
| Return-type | -> readonly []u8 | -> ro []u8 |
| Binding type-position | ro view readonly []u8 = … | ro view ro []u8 = … |
Error codes сохраняются (stable API): E_READONLY_FIELD, E_READONLY_CONTENT,
E_READONLY_COERCE, E_PARAM_NOT_MUT. Terminology в текстах diagnostic’ов
обновляется (ro вместо readonly).
ro view ro []u8 — не tautology: первое ro фиксирует «нельзя
view = …» (binding), второе — «нельзя view[0] = …» (content).
Правило: const narrow → strict constexpr-only (Ф.9)
const X = expr принимает только constexpr-eligible RHS:
- Литералы любого primitive-типа.
- Арифметика/bitwise/comparison над constexpr операндами.
- Record-литерал из constexpr-полей.
- Sum-type конструктор из constexpr args.
- Ссылка на другой
const(любая позиция). - Вызов
const fnс constexpr args (Ф.11).
Не runtime call, не effect, не allocation, не ссылка на
runtime ro.
Errors:
E_CONST_NOT_CONSTEXPR— RHS не constexpr-eligible.E_CONST_REFERS_NON_CONSTEXPR— RHS ссылается на runtime binding.E_CONST_EFFECT_IN_INIT— effect call в RHS.
// ✓ constexpr
const MAX_PAYLOAD = 4096
const TIMEOUT_SEC = 60 * 5
const GREETING = "hello"
const ORIGIN Point = { x: 0.0, y: 0.0 }
// Lazy-init non-constexpr → теперь `ro`
ro COMPUTED Point = make_point(7.0, 14.0)
ro NOW = Time.now()
// ✗ E_CONST_NOT_CONSTEXPR
const COMPUTED Point = make_point(7.0, 14.0)
Strict module-level partition. На module-level между const и ro
не выбор, а обязательное разделение по constexpr-eligibility:
ro MAX = 4096 // ✗ E_RO_FOR_CONSTEXPR_PREFER_CONST
const COMPUTED = make_point(7, 14) // ✗ E_CONST_NOT_CONSTEXPR
Scope-level — без strict-правила (ro x = 5 и const x = 5 оба валидны,
разница в гарантиях).
Amend (Plan 148 Ф.3, 2026-06-12): forward direction implemented
Прямое направление partition E_RO_FOR_CONSTEXPR_PREFER_CONST (раньше
spec-only — маркер [M-114.4-strict-partition]) теперь enforced в checker’е
(types/mod.rs::check_ro_module_partition, вызывается из
TypeCheckCtx::check_module по Item::Let-арму). Зеркало
E_CONST_NOT_CONSTEXPR: оба используют один предикат
check_const_constexpr_ex, что гарантирует согласованность определения
«constexpr».
Семантика (точная):
- Срабатывает только на module-level
robinding (post-D184Item::Letна module-level всегда происходит изro—let/mut/consumeотвергнуты парсером); внутри fn body / block (scope-local) правило не применяется. - Срабатывает только на single-name binder:
Pattern::Identлибо single-segment unitPattern::Variant(UPPER_CASEro MAX = …парсится какVariant{path:["MAX"],kind:Unit}— в позицииro X = …это всегда свежее имя, не match). Destructuring (ro (a, b) = …, record/array pattern) и multi-segment / sub-pattern variant — оставлены как есть (уconstнет destructuring-формы). ghost ro X = …— spec-only binding, не затрагивается.- Срабатывает только когда весь RHS constexpr-eligible (тот же предикат,
что и
E_CONST_NOT_CONSTEXPR). Runtime call / effect / allocation / ссылка на другойro→ RHS не constexpr →roкорректен, ошибки нет.
ro MAX int = 4096 // ✗ E_RO_FOR_CONSTEXPR_PREFER_CONST → const MAX
ro DERIVED int = BASE + 24 // ✗ (BASE const, арифметика над const → constexpr)
ro ORIGIN Point = { x: 0.0, y: 0.0 } // ✗ (record-литерал из constexpr полей)
ro COMPUTED int = make() // ✓ (runtime call — `ro` корректен)
fn f() { ro x = 4096 } // ✓ (scope-local — partition не применяется)
Codegen module-level runtime
ro X = expr()(non-constexpr) пока не lowered (pre-existing gap, не часть этого правила); checker корректно его принимает (не флагает), что и проверяется на уровнеcheck.
Правило: const generalization (Ф.10)
const валиден в трёх позициях с единой semantics (strict constexpr):
// 1. Module-level (как сегодня)
const MAX = 4096
// 2. Scope-local (внутри fn body / block)
fn parse_header(data ro []u8) -> Header {
const HEADER_SIZE = 16
ro buf [HEADER_SIZE]u8 = ...
...
}
// 3. Record-field — associated constant
type Config {
const VERSION int = 2 // не в instance layout
const MAX_PEERS int = 1024
name str // instance field
timeout Duration
}
Config.VERSION // ✓ 2 (namespace access)
ro c = Config { name: "alice", timeout: SECOND }
c.VERSION // ✗ E_CONST_INSTANCE_ACCESS
Sum-type assoc const и generic-type assoc const (T-independent + T-dependent с per-monomorphization codegen) — детали в D200.
Modifier-conflicts:
mut const/const mut→E_CONST_MUT_CONFLICT.ro const/const ro→E_CONST_RO_REDUNDANT.consume const→E_CONST_CONSUME_CONFLICT.export const— ✓ (module-level и record-field).
Правило: const fn (Ф.11)
fn calc(const a int, const b char) -> const int {
const c = b as int
a + c * 10
}
const RESULT = calc(5, 'A') // ✓ comptime → 655
ro buf [calc(2, '0')]u8 = ... // ✓ array size 482
V1: all-or-nothing const params/return; body subset (literals, arithmetic,
as-casts, const-references, local const, final expression, calls на
другие const fn); no if/match/loop/mut/consume/effect/alloc/recursion/
generic в V1. Детали — D199.
Правило: Return-type defaults + @-inheritance (D176 amend)
Асимметрия с параметрами намеренная:
- Param default =
ro(defensive — callee без права мутации). - Return default = mutable (permissive — caller owns).
fn make_buf(n int) -> []u8 // -> mutable []u8 by default
fn read_view(s str) -> ro []u8 // explicit ro в возврате
Pointer-returns (D216 amend, Plan 138.5). Для возвращаемого
указателя return-mut-default ставит мутируемость pointee (target),
не самого указателя: -> *T (= -> *ro T) возвращает указатель на
read-only target; чтобы вернуть writable target — explicit -> *mut T.
Никакого «outer pointer-mut в типе» нет (retracted). Перепривязываемость
самого результата (p = other_ptr) — это bind-site (ro/mut, D36),
а не часть возвращаемого типа.
fn alloc_cell() -> *mut int // writable target (pointee mut)
fn peek_head(buf []u8) -> *u8 // ≡ -> *ro u8 — ro target
ro p *mut int = alloc_cell() // p фиксирован (binding ro), target writable
mut q *u8 = peek_head(buf) // q reassignable (binding mut), target ro
Это снимает прежнюю двусмысленность «двух mut» в return-позиции (см. D216 §V2.6/§V3.3): в типе — только pointee-mut (постфикс), reassignability — только binding.
-> @ (self-return, D181) наследует мутируемость от receiver:
| Receiver | Return -> @ |
|---|---|
fn T @method() -> @ (implicit ro receiver) | ro @ |
fn T mut @method() -> @ | mut @ |
fn T consume @method() -> @ | E_CONSUME_RECEIVER_RETURNS_AT |
-> @ без receiver-method context → E_AT_RETURN_OUTSIDE_METHOD.
Grammar (precise diff)
// Старое (retracted)
binding_stmt ::= "let" "mut"? IDENT type_opt "=" expr
| "consume" IDENT type_opt "=" expr
if_let_stmt ::= "if" "let" pattern "=" expr block ("else" else_branch)?
while_let_stmt ::= "while" "let" pattern "=" expr block
field_decl ::= ("readonly" | "mut")? "field"? IDENT type
type_modifier ::= "readonly" type
param_decl ::= ("mut" | "readonly" | "consume")? IDENT type
// Новое
binding_stmt ::= ("ro" | "mut" | "consume") bind_lhs "=" expr
| const_decl
bind_lhs ::= IDENT type_opt
| "(" bind_lhs ("," bind_lhs)* ")"
| "{" IDENT ("," IDENT)* "}"
const_decl ::= "export"? "const" IDENT type_opt "=" expr
module_item ::= ...
| "export"? "ro" IDENT type_opt "=" expr
| const_decl
if_stmt ::= "if" if_cond ("," if_cond)* block ("else" else_branch)?
while_stmt ::= "while" if_cond block
if_cond ::= cond_pattern "=" expr
| bool_expr
cond_pattern ::= ("ro" | "mut") IDENT type_opt
| constructor_pattern
| tuple_pattern
| record_pattern
constructor_pattern ::= TYPE_PATH "(" pattern_arg ("," pattern_arg)* ")"
| TYPE_PATH
pattern_arg ::= "mut"? IDENT type_opt
field_decl ::= ("ro" | "mut")? "field"? IDENT type
| "mut" "field"? IDENT "ro" type
| "field"? IDENT "ro" type
| const_decl
type_modifier ::= "ro" type
param_decl ::= ("mut" | "ro" | "consume" | "const")? IDENT type
fn_return ::= "->" "const"? type
Tokenizer изменения: новый keyword token KW_RO; KW_LET/KW_READONLY
сохранены как recognized-but-deprecated (parser отвергает с
E_KW_REMOVED_LET / E_KW_REMOVED_READONLY).
Сравнение с mainstream
| Язык | Immutable | Mutable | Pattern-bind-in-cond | Strength |
|---|---|---|---|---|
| Go | const X = … (comp-time) / нет immutable runtime | var X / X := | if v, ok := m[k]; ok | walrus := cond compact |
| Rust | let x = … | let mut x = … | if let Some(x) = e | let повсюду, mut явный |
| TypeScript | const x = … | let x = … | if (e !== null) const x = e | const/let несимметрия |
| Kotlin | val x = … | var x = … | if (e is X) /* smart cast */ | symmetric pair |
| Java | final var x = … | var x = … | if (e instanceof X x) | final verbose |
| Swift | let x = … | var x = … | if case let .some(x) = e | symmetric pair |
| Nova V1 (was) | let x = … | let mut x = … | if let Some(x) = e | Rust-clone |
| Nova V2 | ro x = … | mut x = … | if ro x = … / if Some(x) = e | symmetric ro/mut/consume triad + ortho const + pattern-grammar unification |
Acceptance
См. Plan 114 A1-A16.
Связь
- D27 —
[N]Tsize, small wording-update «const Nfrom any visible scope». - D30 — naming convention SCREAMING_SNAKE_CASE для
const. - D32 — default immutable amend.
- D33 — three immutability axes (rewritten Ф.8).
- D34 —
if/whilepattern grammar amend. - D36 — field modifiers amend.
- D102 — default-param-values reference
const(compat). - D175 —
ro fieldfull freeze (rename). - D176 —
ro Ttype-modifier + return defaults +@-inheritance (rename + Plan 114 раздел). - D180 —
consumebinding (cross-ref). - D199 —
const fncomptime evaluable functions (Plan 114.4 Ф.3). - D200 — associated constants (Plan 114.4 Ф.2).
- D201 —
#cancel_safeFFI attestation (Plan 110.7.3.a). - Plan 114 — master plan.
D199. const fn — comptime evaluable functions
Plan 114.4.2 (extracted from Plan 114.4 Ф.3 safety hatch). Status: ✅ ACTIVE (2026-06-01) — V1 implementation landed: parser (const params +
-> const T+ all-or-nothing + modifier-conflicts
- effect-list/generic/external reject) + body checker (whitelist + 7 error codes + call-graph cycle detection) + comptime evaluator subsystem (env-based interp + memoization + overflow/div-zero) + AST rewriter (call-site → literal replacement + codegen drop) + 22 fixtures (8 NEG parser + 6 POS + 8 NEG checker/eval/external). См. Plan 114.4.2 closure.
Что
const fn — функция, вычисляемая компилятором во время компиляции.
Параметры с модификатором const требуют constexpr-eligible args на call
site; -> const T return type гарантирует constexpr-eligible результат.
Компилятор evaluate’ит body во время компиляции и inline’ит результат
литералом на каждый call site.
fn calc(const a int, const b char) -> const int {
const c = b as int
a + c * 10
}
const RESULT = calc(5, 'A') // ✓ comptime → 655
ro buf [calc(2, '0')]u8 = ... // ✓ array size 482
fn open(n int = calc(3, ' ')) { ... } // ✓ default param 323
Правила V1
-
All-or-nothing — если хоть один param объявлен
constИЛИ return-> const T, то ВСЕ params обязаны бытьconstИ return обязан бытьconst. Mixed →E_CONST_FN_PARTIAL_CONSTNESS. -
Allowed body (V1 subset):
- Литералы и арифметика над const.
as-casts между primitive-типами.- Ссылки на const-параметры и local
const-bindings. - Локальные
const c = exprdeclarations. - Final expression (последний statement — expression).
- Вызовы других
const fnс constexpr args.
-
Forbidden body (V1):
if/else/match/for/while/loop→E_CONST_FN_CONTROL_FLOW.mut/consumebindings →E_CONST_FN_MUT_BINDING.- Effects (calls на non-const fn, runtime calls) →
E_CONST_FN_EFFECT_IN_BODY. - Allocations →
E_CONST_FN_ALLOCATION. - Generic type params →
E_CONST_FN_GENERIC. - Recursion →
E_CONST_FN_RECURSION(V1).
-
Effect-list запрещён в declaration:
fn calc(const a int) Log -> const int { … }→E_CONST_FN_EFFECT_IN_SIGNATURE. -
Call-site rules: все args обязаны быть constexpr-evaluable.
E_CONST_FN_NON_CONST_ARGиначе. Result inline’ится литералом. -
First-class запрещено в V1:
ro f = calc→E_CONST_FN_FIRST_CLASS. -
Codegen.
const fnНЕ emit’ится в C-output. Все call sites replaced литералом. Deadconst fn— silently dropped. -
Modifier-conflicts:
mut const a int→E_CONST_PARAM_MOD_CONFLICT.
Comptime evaluator
Environment-based interpreter:
- Param env + local const env — отдельные scope’ы.
- Sequential statements — выполнение по одному.
- Final expression — последнее выражение возвращает результат.
- Recursion-limit V1=1 — no recursion (checker rejects).
- Memoization:
(fn_id, arg_tuple) → resultcache per compilation.
Errors на evaluator-side:
E_CONST_FN_EVAL_OVERFLOW— arithmetic overflow.E_CONST_FN_DIV_ZERO— division by zero.
Расширяет existing Plan 14 Ф.2 constexpr-engine на fn-вызовы.
Сравнение с mainstream
| Язык | Синтаксис | Body subset | Mixed const/runtime |
|---|---|---|---|
| Rust | const fn factorial(n: u32) -> u32 | Subset; recursion OK | Нет |
| C++ | constexpr fn factorial(int n) | Subset; recursion OK | Нет |
| Zig | fn factorial(comptime n: u32) u32 | Full Zig | Yes |
| Nova V1 | fn factorial(const n int) -> const int { … } | V1 subset | Нет в V1 |
Cross-ref
- D184 — Plan 114 master keyword refresh.
- D33 — three immutability axes.
- D102 — default-param-values.
- D200 — assoc const.
- D27 —
[N]Tarrays.
Acceptance
См. Plan 114.4.2 A14-A18 (T3 series), 22/22 fixtures PASS на release nova-cli.
Implementation notes (2026-06-01)
- Parser (
compiler-codegen/src/parser/mod.rs):Param.is_const: boolFnDecl.return_is_const: boolAST extensions. All-or-nothing check- modifier-conflicts + effect-list / generic / external reject
выполняются в
parse_fnпосле params + return parsing.
- Body checker (
compiler-codegen/src/types/mod.rs::check_const_fn_decl): whitelist (literal / arith / as-cast / Ident param/local / direct const-fn-call), blacklist (7 error codes). Stmt::Const внутри body binds local const env. Call-graph DFS (WHITE/GRAY/BLACK) detects direct + mutual recursion. - Comptime evaluator (
compiler-codegen/src/const_fn_eval.rs):ConstValueenum (Int/Float/Bool/Str/Char/Unit с manual Hash viato_bitsдля floats) +ConstFnEvaluator(OwnedEvaluator variant для rewrite borrow flow). Memoization(fn_name, args) → resultper compilation. Checked arithmetic — overflow / div-zero explicit errors. - AST rewriter (
rewrite_const_fn_calls): walks все expressions (включая Match arms / loops / closures / spawn / supervised / etc.), заменяет Call(const_fn) на literal Expr. После walk — retain’ит filter из items + peer_files.items_here removing const fn declarations. - Pipeline placement: после
types::check_module, доannotate_map_literals/desugar_module. Single pass через module → fail-fast на первой error.
V2 extensions (Plan 114.4.3, 2026-06-01)
V2 значительно расширяет const fn surface, закрывая 5 followup markers из Plan 114.4.2 followup chain.
Ф.1 — control flow в body:
if/else/else ifexpressions allowed; cond + branches validated.matchexpressions allowed; arms with literal patterns + wildcard + ident-bind + Or-alternation (V2.0 subset; record/sum patterns → followup[M-114.4.3-pattern-record-sum]).for/while/loop/if letостаются rejected V2.0 (followup[M-114.4.3-loops]); workaround: use recursion.- New errors:
E_CONST_FN_MATCH_EXHAUSTIVE(uncovered scrutinee value),E_CONST_FN_PATTERN_NOT_SUPPORTED.
Ф.2 — recursion:
- Direct AND mutual recursion allowed.
- Evaluator depth limit: 256 (V1: 64). Reaching → new error
E_CONST_FN_EVAL_DEPTH_EXCEEDED. - Memoization (V1) provides O(n) Fibonacci behavior.
- V1 cycle detection retained but downgraded to informational (no error).
Ф.3 — mixed-args:
- Allow ANY mix const/runtime params + const/runtime return.
- New fn classification:
- Fully-const fn: all params const + const return. V1 behavior — evaluator inlines + dropped из codegen.
- Mixed fn: any const surface, не fully-const. Stays в codegen как обычная runtime fn; const params validated at call-sites (must receive constexpr literal).
- Effect-list reject relaxed: fully-const only (mixed fn body может effects).
- Marker
[M-114.4.2-runtime-return]покрыт (частный случай: all-const params + runtime return).
Ф.4 — generic const fn:
fn[T] foo(const a int) -> const int { ... }allowed для T-independent body.- T reflection (size_of[T], T.field) rejected V2.0 — followup
[M-114.4.3-t-reflection]для V3. - Per-T monomorphization happens через standard generic pipeline.
Ф.5 — first-class const fn alias:
const ALIAS = const_fn_nameallowed.ALIAS(args)resolves через alias map в rewriter.- Alias-to-alias chains supported (depth-10 iterative pass).
- Alias
constdecls dropped из codegen (no runtime storage). - Out-of-scope V2.0:
ro f = const_fnruntime binding silent allow (enforcement → followup[M-114.4.3-runtime-let-enforcement]); HOF pass fails at link-time с unfriendly error (followup[M-114.4.3-friendly-hof-error]).
V3 extensions (Plan 114.4.4, 2026-06-01)
V3 расширяет const fn surface с usability + control flow:
Ф.1 (#fn_eval_max_depth(N) attribute):
Per-fn override const fn evaluator recursion depth limit.
Syntax:
#fn_eval_max_depth(N)
fn deep_recursive(const x int) -> const int =>
if x <= 0 { 0 } else { x + deep_recursive(x - 1) }
N— int literal в диапазоне1..=65535(parser-enforced; out-of-range → compile error).- Default (без attribute) — 256 (constant
MAX_EVAL_DEPTHвcompiler-codegen/src/const_fn_eval.rs). - При reach limit →
E_CONST_FN_EVAL_DEPTH_EXCEEDEDс error message упоминающим attribute как способ override.
Семантика:
- Override применяется на eval_call_inner — depth check выполняется ПЕРЕД memoization lookup (защита от runaway recursion даже когда memo cache effective).
- Attribute lookup происходит per-call-site через
FnDecl.fn_eval_max_depth: Option<u32>— каждая const fn имеет собственный override. - Memoization работает независимо: повторные calls с identical args кэшируются вне зависимости от depth limit.
Use cases:
- Deep recursion:
#fn_eval_max_depth(1024)для factorial / fibonacci с большими аргументами (caveat below). - Conservative limit:
#fn_eval_max_depth(10)для protect against user-induced infinite recursion в API-design.
Caveat: Rust thread call stack (~8 MB) limits actual deep recursion
независимо от override. Practical limit N <= ~150-200 без stack
overflow в evaluator. Real production deep recursion — followup
[M-114.4.4-iterative-evaluator] V4 (rewrite evaluator on iterative
form с explicit stack).
Cross-ref: MAX_LOOP_ITERATIONS (Ф.3 loops, default 10_000) —
аналогичный guard для iteration-based termination; configurable
attribute #fn_eval_max_iterations(N) — V4 followup.
Ф.2 (friendly UX errors):
ro f = const_fnruntime binding →E_CONST_FN_FIRST_CLASSс actionable suggestions (alias OR lambda wrap).map(arr, const_fn)runtime HOF →E_CONST_FN_FIRST_CLASS_RUNTIME_HOFс suggestion|args| const_fn(args).- Walker validate_const_fn_runtime_uses runs after rewriter.
Ф.3 (loops в body):
for x in 0..n { body }allowed — literal Range iter only V3.0 (followup[M-114.4.4-for-iter-array]для array iter).while cond { body }allowed.loop { body }allowed (mustbreakto exit).break/continueworking — propagate through nested if/blocks.mutlet bindings allowed (для accumulator pattern).assignmentallowed.MAX_LOOP_ITERATIONS = 10_000— anti-infinite-loop guard. New errorE_CONST_FN_EVAL_ITERATIONS_EXCEEDED.
Extracted V3 followups (Plan 114.4.4.1-5):
- Plan 114.4.4.1 — record/sum patterns в match (V2.1; ConstValue extension needed).
- Plan 114.4.4.2 — t-reflection (size_of[T]/align_of[T]; type layout integration).
- Plan 114.4.4.3 — runtime HOF (trampoline ABI design).
- Plan 114.4.4.4 — closure-returning const fn.
- Plan 114.4.4.5 — true per-const-arg monomorphization.
V4 extensions (Plan 114.4.4 finish session, 2026-06-02)
Ф.4 — Record/sum/tuple patterns в match:
Pattern V2.0 subset → V4 расширен с structured destructuring:
(a, b)— tuple pattern.Variant,Variant(p1, p2),Cons(h, ..)— sum-type destructuring.{ field: pat }или{ field }(shorthand) — record destructuring.pat as name— binding pattern.
ConstValue extended с Tuple(Vec<ConstValue>), Variant(String, Vec<ConstValue>), Record(Vec<(String, ConstValue)>) variants.
Variant constructor recognition heuristic: Call(Ident(Name), args) где
Name starts с uppercase letter и НЕ const fn → constructed as
ConstValue::Variant. Lowercase Idents trigger const fn lookup.
Ф.5 — Type reflection (size_of/align_of):
Built-in intrinsics evaluating at compile time:
const SIZE_INT = size_of[int]() // 8
const SIZE_BOOL = size_of[bool]() // 1
const ALIGN_F64 = align_of[f64]() // 8
fn buf_size(const count int) -> const int =>
count * size_of[int]() // works в const fn body
V4.0 surface (primitive types only — hardcoded per default 64-bit ABI):
int/i64/u64/f64→ 8 bytes.i32/u32/f32→ 4 bytes.i16/u16→ 2 bytes.i8/u8/bool→ 1 byte.char→ 4 bytes (u32 codepoint per Plan Q-char-literals).str→ 16 bytes (pointer + length per Plan 26 prelude).
Records / sum-types / generic types → V4 followup
[M-114.4.4-record-reflection] (Plan 114.4.4.2 covers it).
size_of/align_of recognized as built-in identifiers в name resolution
(special-cased в is_known); replaced литералом в rewriter pass до
codegen.
V4.1 extensions (Plan 114.4.4 V4.1 session, 2026-06-02)
Mono-specialization — per-const-arg true monomorphization (Plan 114.4.4.5):
V3 baseline (Plan 114.4.3 Ф.3): mixed const fn (e.g. fn scale(const factor int, x int) -> int) compiled as regular runtime fn — const
param factor purely informational. V4.1 lands true monomorphization:
fn scale(const factor int, x int) -> int => x * factor
ro a = scale(3, x) // generates fn `scale__cst_0(x) => x * 3`
ro b = scale(3, y) // reuses `scale__cst_0` (same const arg)
ro c = scale(10, z) // generates fn `scale__cst_1(x) => x * 10`
Semantics:
- Each unique (mixed_fn_name, const_args_tuple) tuple → отдельная
specialized C fn
<orig>__cst_<idx>где const params substituted с literal values в body, dropped из signature. - Per-compilation cache deduplicates: identical (fn, const_args) reuses spec name.
- Mixed fn original AST kept as template (used для cloning); codegen emits both original (mostly dead) + all specializations.
Implementation: New module compiler-codegen/src/const_fn_mono.rs.
Pipeline placement: AFTER rewrite_const_fn_calls (fully-const fns
already dropped + size_of replaced). Mono pass walks all call sites,
generates spec FnDecls с literal substitution в body, rewrites Call
sites с new spec names + drops const args.
Use cases: Performance optimization для hot loops с known const parameters (loop unrolling, branch elimination, SSE-like vectorization). Const generics-style API design (Rust analog).
V4.2 extensions (Plan 114.4.4.3 V4.2 session, 2026-06-02)
Runtime trampoline для first-class const fn use (Plan 114.4.4.3):
V3 baseline (Plan 114.4.4 Ф.2): const fn name использованное в non-callee
position (ro f = const_fn, apply(const_fn, x)) → friendly errors
E_CONST_FN_FIRST_CLASS / E_CONST_FN_FIRST_CLASS_RUNTIME_HOF с
suggestion обернуть в lambda. V4.2 supersedes этот ограничивающий
поведение автоматической генерацией runtime trampoline:
fn double(const x int) -> const int => x * 2
fn apply(f fn(int) -> int, x int) -> int => f(x)
test "HOF use" {
ro r = apply(double, 5) // V3: error. V4.2: compiler emits
// `double__trampoline(int x) -> int { x * 2 }`
// и переписывает `double` → `double__trampoline`.
assert(r == 10)
}
Semantics:
- Для каждого fully-const fn
f, используемого в non-callee position (Call.args, Let.value, Assign.value, Return.value), компилятор генерирует runtime trampoline fn с именем<f>__trampoline. - Trampoline — клон оригинала с demoted modifiers:
constпараметры становятся обычными runtime,-> const T→-> T. Body unchanged (т.к. body fully-const fn состоит из выражений валидных и в runtime). - Транзитивный вызов: если body trampoline вызывает другой fully-const
fn, тот fn тоже добавляется в trampoline-set, и call внутри body
переписывается на
<other>__trampoline. Fixed-point reachability. size_of[T]()/align_of[T]()intrinsics в trampoline body substituted с literal Int при генерации body (V4.0 primitive limit).- Alias resolution:
const ALIAS = const_fn; apply(ALIAS, x)→ alias resolved кconst_fn, который trampolines. - Original fully-const fn декларация всё ещё dropped из codegen pass (call sites уже inlined литералами); trampoline имеет распознаваемый суффикс и переживает retain step.
Implementation: New module compiler-codegen/src/const_fn_trampoline.rs.
Pipeline placement: внутри rewrite_const_fn_calls, ПОСЛЕ main walker
(inlining + intrinsic eval), ДО validate + retain.
V4.2 limitations:
- Generic const fn rejected (
E_CONST_FN_TRAMPOLINE_GENERIC) — trampoline body нужны concrete types для intrinsic substitution. Followup[M-114.4.4-trampoline-generics]. - Closure literals в body — Plan 114.4.4.4 territory.
V4.3 extensions (Plan 114.4.4.4 V4.3 session, 2026-06-02)
Closure-returning const fn — comptime closure specialization (Plan 114.4.4.4):
V3 baseline (Plan 114.4.4 Ф.1): closure literals в const fn body отвергались
с E_CONST_FN_EFFECT_IN_BODY. V4.3 разрешает специальную форму — const
fn чьё body — single closure literal:
fn make_adder(const n int) -> const fn(int) -> int =>
|x| x + n // captures const param n
test {
ro adder5 = make_adder(5) // ⇒ Ident("make_adder__closure_0")
assert(adder5(3) == 8) // calls specialized fn (x int) -> int { x + 5 }
ro m3 = make_mul(3) // distinct spec __closure_1
ro m3_again = make_mul(3) // reuses __closure_1 (memoized)
}
Semantics:
- Detect: const fn whose body is
FnBody::Expr(Lambda | ClosureLight | ClosureFull). - At each Call site
host_fn(LITERAL_ARGS):- Memoize per (host_fn_name, const_args_tuple); identical args reuse spec.
- Generate specialized top-level fn
<host>__closure_<idx>where:- Params = closure params (типы выведены: Lambda/ClosureFull explicit
annotations OR host fn’s
-> const fn(T1, ..) -> Rdeclaration для ClosureLight untyped). - Body = closure body с host’s const params substituted с literal values.
- Return type = closure’s explicit annotation OR host’s declared
R.
- Params = closure params (типы выведены: Lambda/ClosureFull explicit
annotations OR host fn’s
- Replace Call expression с
Ident(spec_name)— Nova принимает bare fn name как fn pointer.
Body validation extension: check_const_fn_decl детектирует closure-at-top-level
case и расширяет scope (host const params + closure params) при validation
closure body — body validated по обычным V1 rules (literals/arithmetic/control
flow/calls к другим const fn) с extended ident scope.
Implementation: New module compiler-codegen/src/const_fn_closure.rs.
Pipeline placement: внутри rewrite_const_fn_calls ДО main walker
(чтобы calls к closure-returning fns были already rewritten к Idents и
walker не пытался eval_call их через interpreter, который не умеет
closure ConstValue).
V4.3 limitations:
- First-class use of closure-returning fn name (
ro f = make_adderбез immediate call) rejected: each call produces distinct specialized closure → no single trampoline. Friendly errorE_CONST_FN_CLOSURE_FIRST_CLASS. - Untyped closure
|x| bodyrequires host’s-> const fn(T) -> Rdeclaration для type inference (Lambda/ClosureFull с explicit types работают независимо). - Closure param arity must match host’s
fn(..)declaration arity (E_CONST_FN_CLOSURE_ARITY). - Generic closure-returning const fn — V2 followup
[M-114.4.4-closure-generic].
V3+V4+V4.1+V4.2+V4.3 acceptance — A27-A35 (landed)
| # | Критерий | Verification |
|---|---|---|
| A27 | #fn_eval_max_depth(N) override работает | Ф.1 fixtures |
| A28 | Runtime-let ro f = const_fn через trampoline | runtime_hof_let_binding_ok (V4.2) |
| A29 | HOF passing через trampoline | runtime_hof_arg_ok (V4.2) |
| A30 | Loops + mut/assign/break/continue работают | Ф.3 fixtures |
| A31 | Record/sum/tuple patterns в match destructure | Ф.4 fixtures |
| A32 | size_of[T]() / align_of[T]() для primitives | Ф.5 fixtures |
| A33 | Mixed const fn per-arg monomorphization | mono_specialization_ok fixture |
| A34 | Const fn first-class use через runtime trampoline | runtime_hof_*_ok fixtures (5 шт.) |
| A35 | Closure-returning const fn — comptime specialization | closure_from_const_fn_*_ok fixtures (4 шт.) |
V4.4 — rename sizeof → size_of для Rust-style consistency (2026-06-02)
Status: ✅ LANDED.
Plan 114.4.4 Ф.5 V4 originally shipped sizeof[T]() (без подчёркивания) +
align_of[T]() (с подчёркиванием) — inconsistent. Following Rust’s
std::mem::size_of::<T>() / align_of::<T>() naming, оба intrinsic
теперь имеют consistent <verb>_of form:
- ❌
sizeof[T]()(V4.0/V4.4 Ф.1) → ✅size_of[T]()(V4.4 rename). - ✅
align_of[T]()(unchanged).
Migration: Bootstrap-stage breaking change — no deprecation shim shipped (Nova не имеет внешних пользователей yet). All fixtures
- spec/docs updated atomically с rename. Future code must use
size_of[T]().
Implementation: Single-token rename в parser/types/eval/trampoline
recognition tables. Fixture files renamed sizeof_*.nv → size_of_*.nv.
User-facing docs: см. docs/size-of-align-of.md — concept doc с детальным explanation: что возвращает, зачем нужно (CPU memory alignment), layout semantics composite types, padding edge cases, Rust comparison, V4.4 limitations.
V4.4 extensions (Plan 114.4.4 V4.4 session, 2026-06-02)
Ф.1 — size_of/align_of для composite types (closes [M-114.4.4-trampoline-record-reflection]):
type_size_or_align расширен от primitives-only до композитных типов
БЕЗ TypeDecl lookup:
- Tuples
(T1, T2, ..)— C struct-style layout с natural alignment + tail-pad. - FixedArray
[N]T—N * size_of(T), element alignment. - Array
[]T— slice ABI = 16 bytes (pointer + length), align 8. - Unit
()— size 0, align 1. - Readonly
readonly T— same layout as T. - Primitive table теперь (size, align) tuples —
str = (16, 8)для slice ABI.
Still V2 (требует TypeDecl access):
- Named user-defined records/sum-types → followup
[M-114.4.4-trampoline-named-types]. - Generic instantiations (
Option[int]etc) — same followup.
Ф.2 — closure-returning const fn captures outer const locals (closes [M-114.4.4-closure-captures-outer]):
Расширяет V4.3 closure-from-const-fn для body форма
FnBody::Block { stmts: all Stmt::Const, trailing: closure_literal }:
fn make_thing(const base int) -> const fn(int) -> int {
const offset = base + 10
fn(x int) -> int => x + offset
}
ro t5 = make_thing(5) // spec body: x + 15 (offset evaluated)
При специализации: каждый outer const eval’ится в порядке declaration с running subst map (host params + prior outer consts); результаты добавляются в map, потом specialize closure body с full subst.
Parser note: |x| body после const x = ... Stmt парсится как
binary OR. V4.4 Ф.2 workaround — explicit fn(x T) -> R => body
syntax (ClosureFull). Followup [M-114.4.4-closure-light-after-const-stmt-parser].
Ф.3 — generic const fn first-class use (DEFERRED, design pending):
Goal: apply_i(id, 7) где fn id[T](const x T) -> const T => x —
should generate id__trampoline_int per generic instantiation.
Blocker: requires one of:
- Parser support for TurboFish-as-fn-value (
id[int]без postfix continuation — currently parsed как Index expression). - HOF context type inference — look up callee’s expected fn-type and infer generic type args. Requires fn signature registry access at trampoline pass time (cross-cutting design).
- Explicit type annotation:
ro f fn(int) -> int = id— requires type-checker integration to read annotation at trampoline pass time.
V4.4 Ф.3 не shipped — design question (1/2/3) ждёт user input.
Tracked: [M-114.4.4-trampoline-generics].
Ф.4 — generic closure-returning const fn (DEFERRED, same blocker as Ф.3):
Same design challenge — generic instantiation requires same parser/type-inference
infrastructure as Ф.3. Will be unblocked together. Tracked:
[M-114.4.4-closure-generic].
V3+V4+V4.1+V4.2+V4.3+V4.4 acceptance — A36-A37 added
| # | Критерий | Verification |
|---|---|---|
| … A27-A35 … | (см. выше) | (см. выше) |
| A36 | size_of/align_of для tuples/arrays/Unit/Readonly | size_of_tuple_ok / size_of_fixed_array_ok / size_of_nested_composite_ok (V4.4 Ф.1) |
| A37 | Closure-returning const fn с outer const captures | closure_outer_const_ok / closure_outer_chained_ok (V4.4 Ф.2) |
| A38 | Padding/alignment edge cases для tuples (layout semantics) | size_of_padding_ok (V4.4 Ф.1 docs) — 7 edge cases: inner/tail-pad, big gaps, no-pad uniform, multi-step alignment, unit, mixed sizes |
| A39 | Generic const fn first-class via HOF inference | trampoline_generic_inferred_ok / _two_types_ok / _no_context_neg (V4.5 Ф.3) |
| A40 | Generic closure-returning const fn via TurboFish | closure_generic_ok / closure_generic_const_arg_ok / _no_turbofish_neg (V4.5 Ф.4) |
| A41 | size_of/align_of для user records + sums | size_of_record_ok / size_of_sum_ok (V4.6 M1) |
| A42 | ClosureLight |x| после Stmt::Const parses correctly | closure_outer_pipe_ok (V4.6 M2) |
| A43 | Closure-generic HOF inference without TurboFish | closure_generic_hof_inferred_ok (V4.6 M3) |
| A44 | Composite concrete types (Tuple/Array) в generic instantiation | trampoline_generic_tuple_concrete_ok / closure_generic_tuple_turbofish_ok (V4.6 M4) |
V4.6 extensions (Plan 114.4.4 V4.6 final-followups session, 2026-06-03)
M1 — size_of/align_of для user-defined records + sums (closes [M-114.4.4-trampoline-named-types]):
V4.4 Ф.1 расширил layout computation до composite ABI types но всё ещё ограничивался primitive Named types. M1 finalizes к user-defined Named:
- Records
type T { f1, f2 }— C struct layout, natural alignment + tail-pad to max-field-align. - NamedTuple
type T(f1, f2)(Plan 120 forward-compat) — same as Record. - Sum-types
type Color | Red | Green | Blue— tag (i32 = 4 bytes, align 4) + max-variant payload, align = max(4, max_payload_align). - Variants: Unit (size 0), Tuple (sum + padding), Record (struct layout).
- Newtype / Alias — transparent.
- Opaque/Effect/Protocol — None (no concrete layout).
Implementation via TypeDecl registry: type_size_or_align_resolved accepts
registry, backward-compat wrapper uses thread-local via TypeDeclRegistryGuard
(RAII install/clear). Registry built at start of rewrite_const_fn_calls.
M2 — Parser disambiguation |x| after Stmt::Const (closes [M-114.4.4-closure-light-after-const-stmt-parser]):
V4.4 Ф.2 documented parser ambiguity workaround. M2 implements lookahead
disambiguation в parse_bit_or:
fn make_thing(const base int) -> const fn(int) -> int {
const offset = base + 10
|x| x + offset // V4.6 M2: parses correctly as ClosureLight (was binary OR)
}
looks_like_closure_light_params lookahead helper peeks pattern
|ident<,ident>*|. If detected, bit-or continuation breaks; ClosureLight
starts as new expression.
M3 — Closure-generic HOF inference (closes [M-114.4.4-closure-generic-hof-inference]):
V4.5 Ф.4 required explicit TurboFish at call site. M3 extends к HOF context where TurboFish becomes optional:
fn make_id[T]() -> const fn(T) -> T => fn(x T) -> T => x
fn apply_i(f fn(int) -> int, x int) -> int => f(x)
test {
ro r = apply_i(make_id(), 7) // T=int inferred from apply_i's f param
}
GenericClosureHofCtx pre-pass walks module mutably, at each Call site
checks args; for bare Call(generic_closure_fn, [], None) без TurboFish,
matches host’s return-type Func against expected param Func via
unify_simple. Wraps Ident в TurboFish; existing rewriter handles rest.
M4 — Composite concrete types (closes [M-114.4.4-trampoline-complex-concrete]):
V4.5 ограничивался simple Named concrete types (int, str). M4 расширяет
к composite TypeRef в both trampoline and closure paths:
- Tuple
(int, int)→ mangletup2_int_int - Array
[]int→arr_int - FixedArray
[4]int→fixarr4_int - Named-with-generics
Option[int]→Option_int - Unit
()→unit - Readonly
readonly T→ro_<m> - Func type →
fn<n>_<params>_ret_<ret>
GenericInst.concrete теперь Vec<TypeRef>. Hash/Eq via mangled_args()
stable serialization. mangle_type_ref pub helper exported для cross-module
use. Closure spec value: (spec_name, Vec<TypeRef>) — TypeRefs retained
для substitution.
🎯 Plan 114.4.4 family COMPLETE — V3-V4.6 (всё закрыто)
- ✅ V3 (Ф.1-Ф.5) + V4 (Ф.1-Ф.5) — base.
- ✅ V4.1 mono-specialization (Plan 114.4.4.5).
- ✅ V4.2 runtime trampoline (Plan 114.4.4.3).
- ✅ V4.3 closure-from-const-fn (Plan 114.4.4.4).
- ✅ V4.4 Ф.1+Ф.2 composite-sizeof + outer-captures (Plan 114.4.4.6).
- ✅ V4.5 Ф.3+Ф.4 generic trampoline + generic closure.
- ✅ V4.6 M1+M2+M3+M4 (Plan 114.4.4.7).
Cumulative: 15 markers [M-114.4.4-*] все закрыты, A1-A44 acceptance,
70/70 fixtures PASS на release nova-cli.
V4.5 extensions (Plan 114.4.4 V4.5 followups session, 2026-06-02)
Ф.3 — Generic const fn first-class через HOF type inference (closes [M-114.4.4-trampoline-generics]):
V4.2 baseline отвергал generic const fn как first-class value. V4.5 Ф.3 снимает ограничение: при passing generic const fn как arg в HOF callee, компилятор infers concrete generic types из callee’s expected param fn-type:
fn id[T](const x T) -> const T => x
fn apply_i(f fn(int) -> int, x int) -> int => f(x)
test {
ro r = apply_i(id, 7) // inferred T=int → id__trampoline_int generated
assert(r == 7)
}
Semantics:
- Build fn signature registry от non-const fns в модуле.
- На каждом Call.args site проверить: arg = Ident matching generic const fn AND callee’s expected param[i].ty = TypeRef::Func.
- Unification: structural recursion (Named/Tuple/Array/FixedArray/Func/ Unit/Readonly), bind generic params к concrete types.
- Generate specialized monomorph FnDecl с substituted TypeRef positions
- dropped generics + demoted const modifiers.
- Mangled name:
<orig>__trampoline_<T1>_<T2>... - In-place Ident rewrite at each Call.args site (multiple instantiations same name not disambiguable from later rewriter pass).
V4.5 Ф.3 limitations:
- Inference только в Call.args position. Let/Assign/Return positions require type annotation infrastructure (V2 followup).
- Concrete TurboFish args должны быть simple Named (V2 followup для nested generics).
- Multiple generic params supported (independent inference per position).
Ф.4 — Generic closure-returning const fn via TurboFish (closes [M-114.4.4-closure-generic]):
fn make_id[T]() -> const fn(T) -> T => fn(x T) -> T => x
test {
ro id_i = make_id[int]() // make_id__closure_int_0
ro id_b = make_id[bool]() // make_id__closure_bool_1 — distinct
}
Semantics:
- Detect closure-returning const fns с non-empty generics.
- Call rewriter accepts BOTH
Ident(name)(args)ANDIdent(name)[T1,..](args)(TurboFish form). - Spec key extended:
(name, const_args, type_args_mangle). - generate_closure_spec substitutes generic types в host return signature,
closure params, closure body via shared
subst_type_ref_pubhelper (exposed from const_fn_trampoline.rs). - Mangled name:
<orig>__closure_<T1>_<T2>_<idx>.
V4.5 Ф.4 limitations:
- TurboFish required at call site (no HOF inference для closure-returning).
Followup
[M-114.4.4-closure-generic-hof-inference]. - Concrete TurboFish args must be simple Named.
- TurboFish arity must match host’s generics.
🎯 Plan 114.4.4 family complete (umbrella + all V4.x sub-plans):
- ✅ V3 (Ф.1-Ф.5) + V4 + V4.1 mono + V4.2 trampoline + V4.3 closure + V4.4 Ф.1 composite-sizeof + V4.4 Ф.2 outer-captures + V4.5 Ф.3 generic-trampoline + V4.5 Ф.4 generic-closure.
- All
[M-114.4.4-*]markers closed (11 cumulative). - A1-A40 acceptance criteria.
🎯 Plan 114.4.4 family extended status:
- ✅ V3/V4/V4.1/V4.2/V4.3 phases (А1-А35) — landed earlier sessions.
- ✅ V4.4 Ф.1 + Ф.2 (А36-А37) — этот session.
- 🟡 V4.4 Ф.3/Ф.4 generics — design-blocked, tracked в
[M-114.4.4-trampoline-generics]+[M-114.4.4-closure-generic].
V2 acceptance — A19-A26
| # | Критерий | Verification |
|---|---|---|
| A19 | if/match в body парсятся + evaluate + inline literal | T4.1 positives (3 fixtures) |
| A20 | Loops reject с pointer на V2.1 followup | T4.1 negatives |
| A21 | Recursion (direct + mutual) с depth-limit + memo | T4.2 (4 fixtures) |
| A22 | Termination — runtime depth-limit enforcement | T4.2 negative |
| A23 | Mixed const+runtime params + monomorphization | T4.3 positives |
| A24 | Mixed signature constraints enforced | T4.3 negative |
| A25 | Generic const fn (T-independent) + per-T mono | T4.4 |
| A26 | First-class alias resolution + drop из codegen | T4.5 |
См. Plan 114.4.3 closure для full T4 series listing.
D409. -> @: автоматический возврат приёмника (2026-07-06)
Решение владельца. Амендит D181 (беглые цепочки
-> @) и D132 (ретракция требования «тело обязано завершаться@»). Синтаксис тел методов. ✅ IMPLEMENTED 2026-07-06.
Что
Метод с возвратом -> @ НЕ пишет возврат приёмника — он единственно
автоматический:
- отсутствие хвостового выражения в теле → неявный
return @; - голый
return(без значения) в таком теле →return @; - хвостовое выражение, само не являющееся приёмником (например, вызов
НЕ-fluent метода), — discard’ится как statement, затем неявный
return @; - explicit-формы запрещены (амендмент владельца, усиление изначальной
редакции: обратная совместимость снята, все существующие тела мигрированы):
явный
@в хвосте,return @,=> @→E_EXPLICIT_SELF_RETURN. Исключение — делегация в другой-> @метод (=> @write()/return @other_fluent()): это не self-return, а обычный вызов.
export fn Vec[T] mut @reserve(additional int) -> @ {
ro needed = @len + additional
if needed <= @cap { return } \ = return @
_grow(needed)
} \ конец тела = return @
Правило
- Работает ТОЛЬКО для формы возврата
-> @. Обычные-> T— без изменений. - Вернуть в
-> @-методе что-либо, кроме приёмника, — ошибка (как и раньше). - Explicit
@/return @/=> @в-> @-теле →E_EXPLICIT_SELF_RETURN. - Чекер: тело/ветки без значения — легальны (
check_fluent_return); лоуэринг (self_return_lower.rs, AST-pass ПОСЛЕ check) подставляет возврат приёмника на каждом выходе без явного значения — codegen не меняется, переиспользуется существующая эмиссия manual-формы.
Почему
-> @ уже однозначно фиксирует, ЧТО возвращается, — тело должно лишь сделать
работу. Ручной @/return @ — шум и источник рассинхрона (забытый @ в одной
из веток). После D409 класс «забыл вернуть приёмник» невозможен по построению;
запрет explicit-форм убирает двоестилие (единственный способ написать —
правильный).
D347. Same-scope re-binding (ro/mut/consume x = ... повторно; тип может меняться)
Plan 181. Амендит D184 (binding-грамматика). Cross-ref: D34 (отличие от отвергнутого
:=), D90 §3 (defer eager), D131/D133/D180 (consume), D22 (замыкания), D217 (field-cache порядок), D274 (interp), D297 (LSP rename — followup).
Что. Повторное объявление того же имени в ОДНОМ scope полной binding-формой
(ro/mut/consume x = ...) — легально. Каждый rebind = новая переменная: свой
тип (может отличаться), своя мутабельность. Старое значение недоступно по имени ниже по
тексту (живёт до конца scope только для уже созданных захватов). Статус-кво до Plan 181
был дырой: фронтенд принимал (shadowing-типизация), бэкенд эмитил оба под ОДНИМ C-именем →
clang redefinition (ошибка в .c, не в .nv).
ro input = read_line()
ro input = input.trim() \ тот же тип — pipeline (R5-тихо)
ro input = parse_request(input)? \ str → Request; str-версия НЕДОСТУПНА ниже (R1)
mut work = work \ «разморозка» readonly→mut (Rust: let mut x = x)
Правило (R1–R7).
- R1 Явность. Rebind — только полной формой
ro/mut/consume x = .... Голоеx = vостаётся мутацией существующегоmut-биндинга (грамматика D184 не меняется). Тип и мутабельность новой переменной независимы от старой. - R2 Consume-звучность (hard error
E_REBIND_LIVE_CONSUME). Затенение биндинга с непотреблённым consume-обязательством (D131/D133/D180) в ТОМ ЖЕ scope — ошибка на месте rebind («потребите/переименуйте»). Nova-эксклюзив: Rust-футган «затенённый guard живёт до конца scope» становится compile error (guard’ы Nova = consume-типы, D174). Ловит тихую same-scope double-consume-shadow утечку (B2). Граница: nested-scope блок-затенение того же класса (consume tx=…; { consume tx=… } tx.commit()— и в теле if/for/match/while-let) R2 НЕ ловит: alpha-rename по R7 не уникализирует cross-scope,rebind_shadowsдля него пуст, а consume-obligations ключуются по имени → один commit гасит оба. Это pre-existing дыра consume-чекера (идентична на baseline до Plan 181, НЕ регрессия), см.[M-consume-nested-scope-shadow-leak]— территория D131/D133. - R3 Нерекурсивность. RHS rebind’а видит старый биндинг:
ro x = x + 1читает прежнийx. Haskell-грабли (let x = f x=<<loop>>) исключены. - R4 Захваты на момент создания. Замыкание/
deferвидят биндинг, живой в точке создания замыкания / регистрации defer (D22 env-снапшот, D90 §3 eager). Rebind ниже по тексту НЕ меняет захваченное. - R5 Lint
W_SHADOW_UNRELATED(warn): новое значение не использует старое И старый биндинг ещё жив. Pipelinero x = f(x)— тихо. Подавление#allow(shadow). - R6 Параметры. Затенять параметр можно (
fn f(x int) { ro x = x + 1 }). - R7 Cross-scope без изменений. Блочное затенение работает как прежде.
Почему. (1) Pipeline-идиома (unwrap/transform/validate под одним именем) убирает
input2/input3-мусор; давление в сторону immutable ro. (2) Прецедент: Rust / OCaml /
F# (в функциях) / Elixir — rebind идиоматичен десятилетиями; ФП-норма. (3) Реализация
дешёвая — один alpha-renaming pass после parse уникализирует 2-й+ same-scope биндинг в
x__s1, x__s2, …, original-имя в side-channel для диагностик; весь остальной компилятор
(consume-checker, codegen, замыкания, verify) видит уже уникальные имена. Pass попутно
чинит 3 смежных бага: false-positive D133 при rebind ПОТРЕБЛЁННого consume; тихую
double-consume утечку (obligations по имени); расхождение чекер↔codegen на ro x = x + 1.
Отвергнуто. (a) Source-SSA / := (Go-модель) — D34 отверг := за «shadowing-баги
Go»: у := дефект — СЛУЧАЙНОЕ затенение из-за смешанного decl/assign-оператора + cross-
scope протечки (класс err-багов). У D347 иначе: ЯВНОЕ ro/mut/consume +
same-scope (старое имя недоступно ниже) + R2/R5 guard’ы. Инверсия мейнстрима (Java/C#/TS
запретили безопасное same-scope, разрешили опасное cross-scope): Nova — same-scope
свободно с правилами, cross-scope как есть. (b) Запрет (E_DUPLICATE_LOCAL) —
рассмотрен как fallback; отвергнут: фича обоснована, а alpha-pass дёшев и чинит B1/B2/B3
попутно. (c) Erlang/Zig-строгость («имя = одна вещь») — порождает State1/State2-класс
багов, отвергнута. (d) flow-narrowing вместо rebind (TS/Kotlin/Swift) — покрывает
«тот же объект, уточнённый тип», но НЕ «трансформация с потерей старого» (ro s = parse(s)).
Таблица проб (эмпирика 2026-07-02, 11 проб) — целевое поведение после Plan 181.
| Проба | Форма | Поведение |
|---|---|---|
| p01 | ro x=1; ro x=2 same type | POS |
| p02 | ro x=1; ro x="s" смена типа | POS (str-версия ниже) |
| p03/p04 | mut x=1; ro x=2 / ro x=1; mut x=x | POS (разморозка) |
| p05 | вложенный блок { ro x=2 } | POS (inner≠outer, R7) |
| p06 | closure + rebind | POS runtime (f()==старое, R4) |
| p07 | for i { ro x=i } | POS (fresh на итерацию) |
| p08 | consume sb=…; sb.append(); ro sb=5 | NEG E_REBIND_LIVE_CONSUME (R2) |
| p08b | consume ПОТРЕБЛЁН into_str() → ro sb=5 | POS (B1 fixed) |
| p08c | consume tx=…; consume tx=… (same scope) | NEG E_REBIND_LIVE_CONSUME (B2 same-scope leak; nested-scope — pre-existing gap) |
| p09 | fn f(x){ ro x=x+1 } param-shadow | POS (R6) |
| p10 | ro x=1; ro x=x+1 | POS x==2 (R3, B3 fixed) |
Связь / границы. Amend D184 (binding), D90 §3 (defer), D131/D133/D180 (R2), D22/D90
(R4), D34 (отличие от :=). Alpha-pass врезан ДО канала resolved_types (172.1 M0). LSP
rename (D297 V1) переименует оба одноимённых биндинга — pre-existing долг (уже сломан для
nested shadow), честный фикс = V2 symbol table. Destructuring-rebind (ro (a,b)=…
повторно) — tuple-дубли уникализируются, R5-lint на них V2. Rebind САМОГО pattern-bound
имени внутри matching-ветки (if Some(u)={ ro u=… }) — вне scope (pre-existing
checker-overflow), см. [M-181-pattern-var-rebind]. Nested-scope double-consume-shadow
утечка (R2 ловит только same-scope) — pre-existing gap consume-чекера, см.
[M-consume-nested-scope-shadow-leak].
D188. Cleanup[E] protocol + consume X = expr { body } scope-block
Plan 110. Принято 2026-05-31. Статус: ACTIVE (Plan 110.1+110.2 +110.4+110.5 landed 2026-06-01; Plan 110.9 V1.1 partial 2026-06-03 — M-110.9.2/110.9.4/110.9.5 closed, M-110.9.1/110.9.3 deferred). Radical simplification cleanup-семейства: один keyword
consume+ один protocolCleanup[E]покрывают ~95% cleanup use-cases, оставляяdefer { }для оставшихся 5%. Amends D90, D158, D161, D162. Retracts D160.Plan 110.9 V1.1 partial closure (2026-06-03):
- ✅ M-110.9.4:
W_FFI_CANCEL_UNSAFElint enforcement (was parser-only).- ✅ M-110.9.5:
cleanupstrict signature check (return Unit + effects only Fail[E] + no generics; D188-malformed-on-exit extended).- ✅ M-110.9.2: WithExitTimeout Level 1 per-type protocol — codegen emits
Nova_<T>_method_exit_timeout_ms(binding)lookup before Application/hardcoded fallback (D192 Level 1 enabled).- 🟡 M-110.9.1 (typed throw codegen), M-110.9.3 (finalizer LIFO) — deferred to focused dev-session.
Plan 110.9 V1.1 ✅ COMPLETE (2026-06-03):
- ✅ M-110.9.1: typed CleanupTimeoutError throw codegen (1377c611a57). Static
_nova_throw_cleanup_timeout_implemitted, fn pointer assigned в main; runtimenv_shield_check_deadlinealways takes typed path.- ✅ M-110.9.3: Application register_finalizer LIFO runtime (86028e74aac). Three-layer: NovaFinalizerStack TLS + with-Application prologue/epilogue + method dispatcher intercept. D195 R1+R2+R8 integrated.
Plan 110.9.5.a ✅ closed (2026-06-05):
- Removed pragmatic accept-both в
is_unit_tr(types/mod.rs:2256). Recon (Workflowwf_ccdccc85-007) confirmed parser ALREADY emits canonicalTypeRef::Unit(Span)для()(parser/mod.rs:5031); accept-both was defensive code for non-existent variance. Now strict: onlyTypeRef::UnitandTypeRef::Readonly(Unit). Aligns с peeris_unit_or_none()at line 9137.Plan 110 — M-110-deadline-fire-fixture ✅ closed (2026-06-05):
- E2E fixture
deadline_fire_e2e_v1_1.nv— verifies full pipeline: Level 1exit_timeout_ms()(1ms) + cleanup Time.sleep(50) + Fail[CleanupTimeoutError] propagation. Unblocked после 110.9.2+110.9.1 landed earlier. Compile-success verifies all 6 codegen splices.🎯 Plan 110 family полностью завершена. 9 sub-plans + 12 D-blocks ACTIVE + 76 fixtures PASS (74 baseline + 2 new V1.1.a closures).
Что
Вводятся два связанных языковых элемента:
Cleanup[E]protocol — контракт ресурсов, требующих cleanup.consume X = expr { body }— scope-block, гарантирующий exactly-once вызовcleanupпри выходе изbody(success, throw, panic, cancel).
type Cleanup[E] protocol {
cleanup(outcome ScopeOutcome) Fail[E] -> ()
}
type ScopeOutcome
| Success
| Failure(any)
| Panic(str)
E— тип ошибок, которыеcleanupресурса сам может throw (commit failure, flush failure). Если ресурс infallible —E = Never(см. D194).ScopeOutcome— type-erased (Python__exit__pattern): ресурс не знает body error type.Failure(any)хранит throw/cancel-payload как существенно динамическое значение; route’ит черезif err is T(D85 auto-narrowing).Panic(str)отдельно — это bug, не recoverable error.
Syntax
consume IDENT = EXPR { BODY }
- Parser lookahead на
{послеEXPRрешает между: IDENT— single name. Destructure (consume (a, b) = ...) не разрешается для scope-block (один resource = один cleanup).EXPRдолжен statically resolve к типуCleanup[E]для некоторогоE(см. D196).BODY— block;IDENTдоступен внутри какrobinding (нельзя reassign, можно вызывать методы, mutating через interior mutability разрешено).
Desugaring
consume tx = init() { body } развёрнуто codegen’ом эквивалентно:
{
ro _tx = init() // R1: throws → exit
ro _timeout = nv_resolve_exit_timeout(_tx) // R4 (D192 3-level)
ro _outcome = nv_run_body_capturing { body } // captures Success/Failure(e)/Panic(m)
nv_enter_cancel_shield(deadline: _timeout) // R3
match _outcome {
Success => _tx.cleanup(Success)
Failure(e) => { _tx.cleanup(Failure(e)); throw e }
Panic(m) => { _tx.cleanup(Panic(m)); nv_resume_panic(m) }
}
nv_leave_cancel_shield()
}
Где nv_run_body_capturing { body } — codegen-emitted construct:
- normal exit →
Success throw e/?propagation / cancel-as-throw (D90 §7 amend) →Failure(e)panic(m)→Panic(m)exit(code)— НЕ captures; process exit’ы напрямую (handler.cleanup не runs).
Правила (R1-R6)
R1 — Partial construction safety
Если init() throws до scope-entry — cleanup не вызывается. Codegen
эмитит установку _outcome/shield только после успешного завершения
init(). Пример: если db.begin() throws, никакого tx.cleanup(...) не
будет (некому).
Это согласовано с D195 §boot-order для Application
handler’а.
R2 — Exactly-once
cleanup для данной consume-binding гарантированно вызывается ровно один
раз на любом exit-path (включая return, throw, panic, cancel).
Реализуется через runtime counter _consume_count в desugared coode: codegen
- runtime инкрементируют при invocation, runtime panic’ит при ≥ 2.
Double-invocation invariant нарушается только если programmer вручную
зовёт tx.cleanup(...) из body — это runtime error
D188-on-exit-double-invocation (linear types prevent double-consume в
большинстве случаев, но FFI/reflection обход возможен).
R3 — Cancel-shield by default
Внутри cleanup-path (tx.cleanup(...)) cancel-доставка автоматически
маскируется до завершения cleanup или превышения exit_timeout
(см. D192). Это default behavior; opt-out не предоставляется
(Rust scopeguard / C++23 lessons показывают что opt-in cancel-shield
большинство забывает).
Cancel остаётся pending в fiber->cancel_pending; доставляется после
nv_leave_cancel_shield(). Если cleanup body превысил timeout — текущий
suspend получает CleanupTimeoutError, дальше propagates через D161
R3b amend (2026-06-08, V2 refinement) [M-83.11-cancel-token-bound-race-2k]
proper fix: Plan 83.11 §11.6 ctx_pins[] ARRAY (not the token itself)
must be allocated uncollectable. Под high fiber-count (≥2k spawns в
supervised(cancel:)) ctx_pins[] array growth (16→32→64→…→1024+) triggers
many GC cycles. Pre-fix nova_alloc’d array lost root coverage под heavy
allocation pressure — Boehm conservative scanner could miss the pointer-
chain stack-scope → ctx_pins → tokens during Mark phase, reclaiming
tokens. Result: token memory reused for SpawnCtx, struct overlap at
offset 8 (_nova_worker_slot vs bound_scope) → panic «token already bound
to a live scope» on bind.
Fix shape:
nova_scope_pin_ctxallocatesctx_pins[]vianova_alloc_uncollectable(fibers.h:639).- On array growth, OLD array
nova_free_uncollectabled (tokens already copied to NEW array — no leak). - NovaCancelToken stays
nova_alloc(collectable) — array always alive keeps tokens reachable through standard Boehm pointer-chain scan.
Pre-V2 workaround (2026-06-05, now superseded): initially nova_cancel_token_new
itself switched к nova_alloc_uncollectable — что fixed crash но создавало
per-token leak. V2 V refinement moves uncollectable-ness к ctx_pins array
which is per-scope (~16-1024 entries × 8 bytes), and token stays collectable.
V3 refinement (2026-06-08) [M-83.11-ctx-pins-scope-cleanup] fix:
Cleanup hook added в nova_supervised_run_impl (~line 1907) после
nova_cancel_token_unbind, перед всеми re-throw paths. Frees ctx_pins[]
array via nova_free_uncollectable + resets ctx_pins/count/cap fields.
Runs on all exit paths (normal, error, interrupt, CANCEL). Token остаётся
reachable через caller’s stack ref (Boehm scans stack roots) даже после
free — array нужен был только для cross-worker pointer-chain reachability
which ends at scope exit. SpawnCtx entries уже cleaned up через their own
nova_spawn_pool_release lifecycle.
V3 trade-off: zero per-scope leak (was 128B-8KB tail в V2). Cleanup overhead = single nova_free_uncollectable + 3 field resets — negligible.
R3c amend (2026-06-05) [M-83.11-nested-supervised-cascade-drain-hang]
fix: nova_cancel_token_bind deferred-cancel propagation (вызывается
когда cancel_requested=true уже выставлен до bind) MUST run полную
cancel-hook chain — не только nova_sched_cancel_all_pending(q). Pre-fix
bug: cascade-cancel race (outer fiber вызывает outer_tok.cancel() BEFORE
main reaches outer’s bind, поскольку cascade triggered by inner supervised
которое blocks main thread first) — cancel cascades через linked[]
к inner_tok который УЖЕ bound, но outer’s deferred propagation на bind
only wakes pending-slot fibers, missing armed M:N worker-parked fibers +
ASYNC slots + driver-armed timers. Outer scope worker fibers stay parked
→ supervised_run hangs до watchdog. Fix: bind деferred-cancel calls full
chain — nova_sched_cancel_all_pending + nova_scope_cancel_wake_all +
nova_runtime_cancel_worker_fibers + _nova_cancel_via_driver (same
sequence as nova_cancel_token_cancel_reason).
composition.
R3a amend (2026-06-05) [M-110.x-cleanup-shield-deadline-underflow] codegen-layout invariant fix (supervised(cancel:) path):
Cancel-shield state — _nova_cancel_mask_count + _nova_cancel_deadline_ns
fields — lives в NovaSpawnCtxBase (runtime fibers.h). Codegen-emitted
per-fiber NovaSpawnCtx_<id> (spawn) и NovaDetachCtx_<id> (detach)
структуры MUST включать ВСЕ base fields в правильном порядке (включая
позже добавленные _nova_pool_size (Plan 83.6), _nova_cancel_mask_count
(Plan 110.2.1.a), _nova_cancel_deadline_ns (Plan 110.2.2.a)).
Why: runtime cast’ает mco_get_user_data(co) → NovaSpawnCtxBase* и
читает поля по фиксированному offset. Если codegen struct emit’ит ТОЛЬКО
первые N полей, allocation размер = sizeof(codegen_ctx) < sizeof(NovaSpawnCtxBase).
Runtime читает за пределами allocation → Boehm GC adjacent memory bytes
(garbage) → mask=garbage > 0 → nv_shield_check_deadline enters slow path
→ deadline=garbage → throws bogus CleanupTimeoutError с inflated
over_ms (720M+ ms over budget в 6-second tests — i64 underflow symptom).
Pre-fix bug history: codegen comment на emit_c.rs:6253 явно
утверждал «_nova_fiber_state MUST be last in NovaSpawnCtxBase prefix» —
stale assertion. Runtime добавил _nova_pool_size (Plan 83.6) +
_nova_cancel_mask_count + _nova_cancel_deadline_ns (Plan 110.2),
codegen не обновился. Layout mismatch latent до supervised(cancel:) +
sleep + cancel pattern — там fiber yields в check_deadline под обычным
обстоятельством, реально читая garbage.
Acceptance invariant: sizeof(codegen NovaSpawnCtx_
Cross-references: D196 R4 (consume{} prev_deadline save/restore — different bug); D196 R4b (cleanup exception safety — different bug); Plan 110 plan-doc.
R4 — Timeout resolution at scope-entry
exit_timeout определяется один раз при scope-entry через 3-level
fallback (см. D192):
WithExitTimeoutimpl ресурса (если есть);- Активный
Applicationeffect handler (см. D195); - Hardcoded fallback
Duration.seconds(5).
Кэшируется в локалке _timeout для use в nv_enter_cancel_shield.
Сохранение в локалке предотвращает race с асинхронным изменением handler’а
во время body execution.
R5 — LIFO composition
Вложенные consume {} блоки выходят в LIFO порядке (наружный позже
внутреннего). Если outer throws, inner.cleanup уже завершён. Если inner
throws, outer.cleanup получает Failure(inner_err) в outcome.
Mixed consume {} + defer { } LIFO — единый scope-stack per-fiber:
consume a = A.new() {
defer { cleanup_b() }
consume c = C.new() {
defer { cleanup_d() }
body
}
// exit: cleanup_d → c.cleanup → cleanup_b
}
// exit: a.cleanup
R6 — Memory ordering
Acquire-release semantics между body и cleanup:
- Все writes в
bodyhappen-beforecleanupreads (release on exit, acquire on entry). - Согласовано с D167 memory ordering.
- Reason: cleanup может flush/commit видимое состояние; должен видеть финальную семантику ресурса.
Typed error dispatch в cleanup
Resource решает что делать с body error через D85 auto-narrowing:
fn Transaction consume @cleanup(outcome ScopeOutcome) Fail[DbError] -> () {
match outcome {
Success => @commit()!!
Failure(err) => {
if err is DbError.Deadlock {
@retry_friendly_rollback()!! // err narrow'нут до DbError.Deadlock
} else if err is DbError {
@rollback_with_log(err.msg)!!
} else {
@rollback()!! // generic non-DB failure
}
}
Panic(_) => @rollback_emergency()
}
}
Никакого outcome.failure_as[T]() helper’а не предоставляется — is-
narrowing достаточен и идиоматичен (rejected alternative в D190).
Generic constraint
fn use_any[T Cleanup[E]](r T) Fail[E] -> () {
consume binding = r {
// binding : T
}
}
Generic bound [T Cleanup[E]] следует синтаксису D72. E может
быть concrete (Cleanup[IoError]) или generic param ([T Cleanup[E]]
с обоими свободными). Never special case: если E = Never в bound,
type-checker автоматически снимает требование Fail[E] у caller’а
(см. D194).
Что заменяется
| Старая форма | Новая форма |
|---|---|
consume tx = begin(); errdefer { rollback }; okdefer { commit } | consume tx = begin() { body } (Transaction impl Cleanup) |
defer |result| match { ... } | consume X = ... { } или with ResourceTrace = h { ... } (D185) |
consume X = ...; defer { X.close() } | consume X = ... { body } (если X impl Cleanup) |
См. D189 для прямого удаления.
Сравнение
| Capability | Java | Kotlin | Swift | C++23 | Rust | Go | TS | Python | Nova D188 |
|---|---|---|---|---|---|---|---|---|---|
| Cancel-shield by default | ❌ | ⚠️ opt-in | ⚠️ opt-in | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Exactly-once invariant | ⚠️ | ⚠️ | ✅ | ⚠️ | ⚠️ | ❌ | ❌ | ⚠️ | ✅ runtime |
| Partial-construction spec’d | ⚠️ stacked-using bug | ⚠️ | ⚠️ | ⚠️ | ⚠️ | n/a | ⚠️ | ⚠️ | ✅ R1 |
| Single keyword | ✅ try-w-r | ✅ .use | ❌ | ❌ | ❌ | ❌ | ⚠️ using | ✅ with | ✅ consume{} |
Связь
- D72 — generic bounds syntax.
- D85 —
isauto-narrowing. - D90 §7 — amend: cancel as
Failure(CancelError). - D158 — amend: ErrorKind discrimination retracted.
- D160 — retract в пользу D188.
- D161 — amend: MultiError без ErrorKind.
- D162 — amend: scope-block exhaustive by construction.
- D167 — memory ordering basis.
- D180 — raw
consume X = ...(без block). - D185 — Cleanup effect (telemetry).
- D189 — okdefer/errdefer/defer|r| removal.
- D190 — rejected alternatives.
- D191 — async cleanup.
- D192 — exit-timeout taxonomy + 3-level resolution.
- D193 — MultiError + cycle-safety.
- D194 —
Cleanup[Never]. - D195 — Application nesting.
- D196 — init type constraints.
- D197 — cleanup re-entrance.
- D198 — realtime + cleanup interaction.
- Plan 110.
D189. Прямое удаление okdefer + errdefer + defer |result|
Plan 110 Ф.9. Принято 2026-05-31. Статус: ACTIVE (Plan 110.5.7 hard cutover landed; parser rejects retracted forms с D189-removed-* errors). Fixture migration via deletion + behavior coverage preserved в plan110/ (Plan 110.5.5).
Что удаляется
| Construct | Origin | Replacement |
|---|---|---|
errdefer { ... } | D90 §2 | Move logic в Cleanup.cleanup через match outcome { Failure(_) => ... } или defer + flag для escape hatch |
okdefer { ... } | D160 | match outcome { Success => ... } |
defer |result| { ... } | D160 | match outcome { ... } в cleanup |
DeferResult[T, E] type | D160 | Заменён на ScopeOutcome (D188) |
DeferWithResult AST node | D160 | Удалён |
Parse errors (после Ф.5 удаления)
После Ф.5 парсер не принимает старые формы — каждая выдаёт parse error с suggestion на новую форму:
| Token | Error code | Hint |
|---|---|---|
okdefer | D189-removed-okdefer | Use consume X = ... { ... } with match outcome { Success => ... } |
errdefer | D189-removed-errdefer | Use consume X = ... { ... } with match outcome { Failure(_) => ... } |
defer |...| { ... } | D189-removed-defer-result | Use consume X = ... { ... } |
Auto-fix mappings
nova fix --simplify-cleanup применяется один раз перед удалением парсер-
поддержки (Plan 110 Ф.9.2):
-
Pattern: linear
consume + errdefer + okdefer// before: consume tx = db.begin() errdefer { tx.rollback() } okdefer { tx.commit()? } do_work()? // after: consume tx = db.begin() { do_work()? } // (предполагается Transaction impl Cleanup: cleanup Success → commit, Failure → rollback) -
Pattern:
errdeferбез ресурса (cleanup state)// before: errdefer { log.warn("operation failed") } risky()? // after: mut done = false defer { if !done { log.warn("operation failed") } } risky()? done = true -
Pattern:
defer |result|// before: defer |result| { match result { Ok(_) => Log.info("ok") Err(e) => Log.error("fail: ${e}") Panic(m) => Log.fatal("panic: ${m}") } } // after (using Cleanup effect — D185): with ResourceTrace = LogHandler.new(label: "operation") { body }
Rationale (почему hard cutover без migration window)
- Nova ещё pre-0.1; breaking change acceptable per project-philosophy.
- Кода на
.nvмало (стдлиб + tests + examples; ~десятки fixture’ов). - Auto-fix tool покрывает 100% паттернов (3 правила выше).
- Dual-syntax fallback запутывал бы users во время transition (Rust scope-bracket lessons).
- Spec maintenance cost dual-форм > value migration window.
Связь
- D90 §«errdefer» — retract.
- D160 — retract целиком.
- D158 — amend.
- D161 — amend.
- D162 — amend.
- D188 — replacement.
- Plan 110 Ф.9.
D190. Rejected alternative cleanup designs
Plan 110. Принято 2026-05-31. Статус: ACTIVE (pure documentation of rejected design choices; no impl required). Документирует rejected design choices для будущих ревизоров с rationale почему именно
Cleanup[E]+consume {}.
Drop-trait (Rust-style)
impl Drop for File { fn drop(&mut self) { self.close(); } }
Отвергнуто потому что:
- Implicit cleanup невидим в call-site — code review загромождён.
- Async-Drop unresolved (Rust open RFC с 2019); Nova first-class async.
drop()нельзя throw — Rust решает черезabort, Nova хочет typed propagation.- Order-of-drop magic (struct field order); programmer ошибки скрыты.
Priority-defer (defer priority=10 { ... })
Отвергнуто: LIFO достаточен для всех known patterns. Priority вводит новый axis сложности без killer use-case (ни один индустриальный язык не имеет).
module_finalizer { ... } keyword
Отвергнуто: добавление primitive для редкого паттерна. Достижимо через
Cleanup[Application] idiom (см. D195).
Two-method protocol (on_success / on_failure)
type Cleanup[E] protocol {
on_success() Fail[E] -> ()
on_failure(err any) Fail[E] -> ()
}
Отвергнуто:
- Resource’ы которые делают одинаковое cleanup в обоих случаях (Mutex, File) — дублирование кода.
- Panic-handling требует третий метод → 3 method protocol → readability страдает.
- Single
cleanup(outcome)с match — лучше структурирован, легче generic’и пишутся.
Generic ScopeOutcome[E]
type ScopeOutcome[E]
| Success
| Failure(E)
| Panic(str)
Отвергнуто: resource не знает body error type (transactionResource не
знает что body может throw OrderError). Type-erased Failure(any) —
canonical Python __exit__ pattern. Routing через if err is T (D85).
Отдельный Cancelled variant
type ScopeOutcome enum Success | Failure(any) | Cancelled(any) | Panic(str)
Отвергнуто: ни один из benchmark-языков (Java/Kotlin/Swift/C++/Rust) не выделяет cancel отдельно. Cancel — special case throw’а; semantics identical для resource cleanup. См. D90 §7 amend.
using / scoped keyword
using tx = db.begin() { ... }
scoped tx = db.begin() { ... }
Отвергнуто: re-use existing consume keyword снижает keyword count
на 1. consume уже описывает “linear, owned, single-use” semantics —
scope-block — естественное расширение.
outcome.failure_as[T]() -> Option[T] helper
match outcome {
Failure(_) => {
if dbErr = outcome.failure_as[DbError]() {
...
}
}
}
Отвергнуто: D85 is auto-narrowing уже даёт smart-cast; helper —
дублирование. Kotlin smart-cast precedent.
Связь
- D188 — accepted design.
- Plan 110 §«Rejected».
D191. Async cleanup — suspend в cleanup body
Plan 110 Ф.3. Принято 2026-05-31. Статус: ACTIVE (Plan 110.2.1.a +110.2.2.a landed 2026-06-01). Расширяет D159 async cleanup на
Cleanup.cleanup.
Что разрешено
cleanup body может содержать suspend-операции:
Time.sleep(d)— для retry с backoff.Net.*— для grace-close TCP socket.Db.*— для commit/rollback с round-trip.await fut— для произвольногоFuture[T].
Что запрещено
spawn { ... }— fire-and-forget fiber внутри cleanup нарушает exactly- once и cancel propagation (D159 правило).parallel { ... }— concurrent cleanup-tasks непредсказуемы.supervised { ... }— supervisor-frame inside cleanup-frame double-nested cancel routing (Plan 83.10 lessons).
Эти запреты эмитятся checker’ом как E_CLEANUP_FORBIDDEN_OPERATION с
suggestion переписать как sequential await.
Cancel-shield пробрасывается через suspend
Внутри cleanup cancel доставка отложена до exit_timeout (R3 D188).
На каждом suspend-point runtime проверяет deadline:
fn TcpStream consume @cleanup(outcome ScopeOutcome) Fail[IoError] -> () {
@send_eof()? // suspend ok; cancel masked
@wait_for_ack(timeout: 1.s())? // suspend ok; deadline check
@close()? // suspend ok; cancel masked
// если cumulative time > exit_timeout → CleanupTimeoutError throws здесь
}
Timeout exceedance
Если cumulative cleanup-suspend-time превысил _timeout (computed at
scope-entry per D192 3-level resolution):
- Текущий active suspend получает
CleanupTimeoutError. - Эта ошибка propagates через
cleanup’s normal error path (?/!!). - Если
cleanupthrows —MultiError.suppressed.push(CleanupTimeoutError)composed с primary error. - Cancel доставка снимается (shield off), cancel re-raises после exit.
Realtime context
В #realtime fn (D172) — _timeout = Duration.zero (D198). Любой suspend
в cleanup запрещён checker’ом через D172 правила (parking ops not
allowed in #realtime). Это compile error, не runtime.
Связь
- D90 §7 — cancel/throw routing.
- D158 — failable cleanup base.
- D159 — async cleanup base; этот D191 — amend для Cleanup.
- D172 —
#realtimeparking ban. - D188 §R3 — cancel-shield.
- D192 — 3-level timeout resolution.
- Plan 100.4.2.
D192. exit_timeout taxonomy + 3-level resolution
Plan 110 Ф.3. Принято 2026-05-31. Статус: ACTIVE (Plan 110.2.3
- 110.4.6.a Level-2 Application landed 2026-06-01). Определяет как ресурс получает свой cleanup deadline.
Taxonomy Duration значений
| Value | Семантика | Diagnostic |
|---|---|---|
Duration.zero | Sync-only cleanup; любой suspend → runtime error | D192-zero-timeout-suspend (runtime) |
Duration.positive(d) | Normal timeout; deadline = now + d | — |
Duration.MAX | Без timeout; cleanup ждёт неограниченно | D192-infinite-timeout-warn (compile warn) |
Duration.negative | Invalid; runtime panic при resolve | D192-negative-timeout (runtime panic) |
Duration.zero использует #realtime (D198). Realtime-context
автоматически устанавливает zero без 3-level resolution.
3-level resolution (от ближайшего к дальнему)
При входе в consume X = ... { body } runtime resolves timeout один раз:
Level 1 — WithExitTimeout impl
type WithExitTimeout protocol {
exit_timeout() -> Duration
}
fn Transaction @exit_timeout() -> Duration => 30.s()
- Structural match — ресурс не обязан явно объявлять impl, достаточно иметь метод правильной signature.
- Если method присутствует — runtime зовёт его при scope-entry, result кэшируется в локалке.
- НЕ часть Cleanup protocol (опционально); Mutex/Sem/Lock не нужны.
Level 2 — Application effect handler
with Application = Application.handler(default_exit_timeout: 10.s()) {
run_server() // все consume{} получают 10s
}
- Если активен
Applicationeffect handler и ресурс НЕ имеетWithExitTimeoutimpl — runtime вызываетApplication.default_exit_timeout(). - См. D195.
- Nested Application handlers: inner handler побеждает (effect-stack semantics).
Level 3 — hardcoded fallback
Duration.seconds(5) если ни Level 1 ни Level 2 не сработали. Конечная
safety net.
Implementation: единая runtime функция
// runtime emit:
nv_duration_t nv_resolve_exit_timeout(nv_typeid_t type, void* obj) {
// 1) check WithExitTimeout via vtable lookup
if (nv_type_has_method(type, "exit_timeout")) {
return nv_call_method_exit_timeout(type, obj);
}
// 2) check Application effect
nv_handler_t* app = nv_effect_lookup("Application");
if (app) {
return nv_call_application_default_exit_timeout(app);
}
// 3) hardcoded
return nv_duration_seconds(5);
}
Codegen вызывает nv_resolve_exit_timeout один раз per scope, результат
кэшируется в локалке. Преимущества vs per-callsite codegen:
- меньше binary size;
- единая точка модификации;
- проще для inlining в VM/JIT.
Per-instance конфигурация — library pattern
fn Db.connect(url str, exit_timeout Duration = 30.s()) -> Db => ...
fn Db @begin() -> Transaction => Transaction {
exit_timeout_value: @config.exit_timeout, ...
}
fn Transaction @exit_timeout() -> Duration => @exit_timeout_value
Когда Db.connect(url, exit_timeout: 60.s()) — все транзакции через этот
Db унаследуют 60s, потому что Transaction.exit_timeout() структурно
satisfies WithExitTimeout. Это library pattern, не language feature.
Что НЕ делаем
- ❌ Нет
exit_timeout()вCleanup— оптимизация для infallible cleanup (MutexGuard). - ❌ Нет scope-level override через
with X = Y { }— этот syntax только для effect-handlers. - ❌ Нет global mutable setting через прямой setter — конфиг через
Applicationeffect handler.
Связь
- D188 §R4 — resolution at scope-entry.
- D194 —
Cleanup[Never]hot-path opt. - D195 —
Applicationeffect. - D198 — realtime bypass.
- Plan 110 Ф.3.
D193. MultiError — iteration + cycle-safety + depth-limit
Plan 110 Ф.6. Принято 2026-05-31. Статус: ACTIVE (MultiError API
Structure
type MultiError {
ro primary any
ro suppressed []any
}
primary — первая ошибка цепочки (chronologically first failure).
suppressed — последующие ошибки, добавленные через compose.
any (не Error) — потому что MultiError может composit’ить CancelError,
CleanupTimeoutError, DbError, panic-string и пр. Type-erased.
API
fn MultiError @primary() -> any => @primary
fn MultiError @suppressed() -> []any => @suppressed
fn MultiError @walk() -> Iter[any] {
// returns: primary, then suppressed in LIFO order
}
fn MultiError @fmt_chain() -> str {
// formatted: "primary: X\n suppressed: Y\n suppressed: Z"
}
fn MultiError @find_first_panic() -> Option[str] {
// первый panic-string в chain (primary or suppressed); None если none
}
Cycle-safety (Java JDK-8287921 lesson)
В Java HotSpot обнаружили deadlock когда Throwable.addSuppressed(this) —
self-suppression создавала self-reference cycle. Nova избегает через
identity-check в compose operation:
void nv_compose_error(nv_multi_err_t* m, void* secondary) {
// identity check: ignore self
if (secondary == m->primary) return;
// dedup: ignore if already in suppressed
for (size_t i = 0; i < m->suppressed_len; i++) {
if (m->suppressed[i] == secondary) return;
}
nv_multi_err_push(m, secondary);
}
Depth limit
Runtime invariant: suppressed.len <= 256. Если cleanup-cascade глубже —
очередная compose добавляет sentinel entry MultiErrorTruncated { depth }
и дальше silently ignores дальнейшие composes.
256 выбран как:
- порядок MAX_DEFER_DEPTH (D193);
- достаточно для всех reasonable cleanup-cascades (10 levels nesting × 25 resources per level);
- protects from O(N²) compose-чейнов с pathological recursion.
Concrete error types в prelude
type CancelError { reason str }
type CleanupTimeoutError { duration Duration }
type MultiErrorTruncated { depth int }
Эти типы emerge из D90 §7 amend (CancelError) и D192 deadline enforcement (CleanupTimeoutError).
Что удаляется
ErrorKindenum (D158) — типизация через прямойif err is T.DeferResult[T, E](D160) — заменёнScopeOutcome.- Raw
MultiError.suppressed.push(...)— должен идти черезnv_compose_errorс cycle-check (compile error D193-direct-mutation).
Сравнение
| Capability | Java | Kotlin | Swift | C++23 | Rust | TS | Nova D193 |
|---|---|---|---|---|---|---|---|
| Iterable walk | ✅ getSuppressed | ✅ | ✅ | ⚠️ stdexception_ptr | n/a | ✅ AggregateError | ✅ walk() |
| Cycle-safety spec’d | ⚠️ JDK-8287921 bug | ⚠️ inherit | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ identity-check |
| Depth-limit explicit | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ 256 |
| Fast panic finder | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ find_first_panic |
Связь
- D90 §7 — CancelError.
- D158 — amend: ErrorKind retracted.
- D161 — amend: MultiError structure.
- D188 §R3 — cancel-shield exit composition.
- D191 — async cleanup compose path.
- D192 — CleanupTimeoutError.
D194. Cleanup[Never] — infallible cleanup + hot-path elision
Plan 110. Принято 2026-05-31. Статус: ACTIVE — с оговоркой (Plan 173 Ф.2.D194, 2026-07-03). Реализована caller-relaxation (type-checker снимает требование
Fail[E]дляCleanup[Never]-биндинга — см. §Caller relaxation, живо). §perf hot-path elision (§Hot-path optimization ниже) НЕ реализована — см. врезку в конце секции. Special-case для resource’ов которые гарантированно не fail в cleanup (Mutex, Sem, Lock).
Что
Resource-типы которые гарантированно не fail в cleanup используют
Cleanup[Never]:
fn MutexGuard consume @cleanup(outcome ScopeOutcome) -> () => @release()
// ^^^^ no Fail[E]
Fail[Never] — empty effect set, эквивалентно «не throws».
Caller relaxation
Type-checker special-case: если binding имеет тип Cleanup[Never],
требование Fail[E] у caller’а снимается:
fn use_mutex() -> () { // нет Fail[E]
consume _l = mu.acquire() { // MutexGuard: Cleanup[Never] — ОК
do_work()
}
}
Это аналог Rust Result<T, !> или Haskell IO () без bracket throws.
Делает API для locks/permits эргономичным.
Generic Never special case
fn use_any[T Cleanup[Never]](r T) -> () { // нет Fail[E]
consume binding = r { do_work() }
}
Generic с [T Cleanup[Never]] тоже не требует Fail[E] у caller’а
(см. D188 generic constraint).
Hot-path optimization (D194 §perf) — ⚠ НЕ РЕАЛИЗОВАНА (аспирационно)
⚠ Статус (Plan 173 Ф.2.D194, 2026-07-03): эта оптимизация НЕ реализована в codegen. Де-риск Ф.2 (2 агента независимо + firsthand-разбор) установил: единственная эмитящая
ConsumeScope-ветка (emit_c.rs, ~19816-20101) эмитит ПОЛНЫЙ frame-bearing путь БЕЗУСЛОВНО для ВСЕХ resource-типов (вкл.Cleanup[Never]): shield-пара (nv_consume_enter_shield/nv_consume_leave_shield), 3-level timeout, ≥2 setjmp-кадра (body + on_exit), полный outcome. Effect-row-inspection для Never-элизии в codegen = 0. Прежний текст «Disasm-verified в T2.9» — артефакт отсутствует (docs/plans/artifacts/ 173-disasm-baseline/), премиса дрейфанула. Ф.2 (defer-kernel unification) acceptance = PARITY (lowered consume/defer(o) НЕ увеличивает кадры/shield/outcome vs дорефакторный frame-bearing вывод), а НЕ «сохранить существующую элизию» (её нет). Генуинная §perf-элизия — отдельный deferred followup[M-173-d194-perf-elision](перф-оптимизация вне периметра unification; требует own disasm-guard artifact).
Целевая (будущая) форма — codegen detect’ит case когда:
- Binding имеет тип
Cleanup[Never]И - Type не satisfies
WithExitTimeout(нет customexit_timeout()method).
Тогда codegen мог бы eliminate:
- Cancel-shield setup/teardown (на throw на cleanup-path → нет MultiError compose).
- Timeout resolution (5s hardcoded не нужен — release инстант).
outcomeconstruction (Mutex не различает Success/Failure/Panic).
// regular case (СЕЙЧАС — для ВСЕХ типов, вкл. Never):
nv_consume_enter_shield(timeout);
... body (fail-frame) ...
nv_call_on_exit(tx, outcome); // on_exit-frame
nv_consume_leave_shield(prev);
// elided case (Cleanup[Never] + no WithExitTimeout) — ЦЕЛЬ [M-173-d194-perf-elision], НЕ реализовано:
... body ...
nv_call_release(mu);
Critical для hot-paths: lock contention, high-frequency permits, fast
mutex/atomic patterns — мотивация для будущего [M-173-d194-perf-elision].
Когда elision НЕ применяется
Cleanup[Never]+WithExitTimeoutimpl → timeout resolution не нужен (5s default), но shield нужен для potentialawait exit_timeout()call.Cleanup[E]гдеE != Never→ cleanup может throw, shield нужен.- Body содержит cancel-points даже если
E = Never— shield нужен для cancel-routing.
Связь
- D188 — Cleanup base.
- D192 — WithExitTimeout protocol.
- Plan 110 Ф.2.8.
D196. Init type constraints для consume X = expr { body }
Plan 110 Ф.2.9. Принято 2026-05-31. Статус: ACTIVE (Plan 110.1.2 + 110.1.3 + refine landed; forms 1-3, 5 implemented в type-checker; form 4 method-chain deep recursion partial). Определяет какие expression’ы могут служить init для consume scope-block.
Правило
expr после = должен statically resolve к типу implementing
Cleanup[E] для какого-то E. Type-checker проверяет в Ф.1.5 (после
type inference body init expression’а).
Acceptable init forms
1. Прямой Cleanup
consume tx = db.begin() { ... } // db.begin() : Transaction (Cleanup[DbError])
2. Result/Option unwrap через ? / !!
consume tx = db.try_begin()? { ... } // try_begin() : Result[Transaction, DbError]
// после `?` → Transaction
consume tx = maybe_tx()!! { ... } // maybe_tx() : Option[Transaction]
// после `!!` → Transaction
3. Conditional (if/match)
consume tx = if cond { open_a() } else { open_b() } { ... }
- Обе ветки должны возвращать совместимый Cleanup type.
- Если a и b возвращают разные Cleanup типы →
D196-divergent-consumable.
4. Method chain
consume tx = db.with_config(cfg).begin() { ... }
Финальный return type должен быть Cleanup.
Rejected init forms
Wrapped без unwrap
consume tx = maybe_tx() { ... } // maybe_tx() : Option[Transaction]
// → D196-wrapped-init-needs-unwrap
Suggestion: «use consume tx = maybe_tx()!! { ... } или distinguish None
сначала через if Some(tx) = maybe_tx() { consume tx = tx { ... } }».
Non-Cleanup
consume x = 42 { ... } // int не Cleanup
// → D188-not-consumable
Memory ordering для acquisition
init evaluation полностью завершается до scope-entry (acquire
semantics). nv_consume_enter имеет implicit memory fence перед
nv_run_body_capturing. Cleanup видит финальное состояние ресурса
(см. D188 §R6).
Связь
D197. Cleanup re-entrance — nested consume {} inside cleanup
Plan 110 Ф.2.12. Принято 2026-05-31. Статус: ACTIVE (Plan 110.1.8 landed; codegen handles re-entrance correctly through nested scope-blocks). Разрешает вложенные consume scope-block’и внутри
cleanup.
Правило
cleanup body может содержать вложенные consume {} блоки:
fn Connection consume @cleanup(outcome ScopeOutcome) Fail[IoError] -> () {
// closing the connection requires acquiring lock
consume _l = @cleanup_mutex.acquire() {
@do_close()?
}
}
Семантика
-
Outer cancel-shield остаётся активен на время всей outer
cleanupbody. Inner consume{} наследует масштабы shield (nested mask). -
Inner consume{} создаёт свой shield с своим timeout. Cancel доставка остаётся глобально pending до выхода outer cleanup.
R4 amend (2026-06-05) [M-110.x-cleanup-shield-deadline-underflow] fix: Inner shield’s deadline shadows outer’s. На inner leave outer’s deadline должна быть restored (через prev_deadline save+restore pattern в
nv_consume_enter_shieldreturning prev /nv_consume_leave_shieldaccepting prev arg). Pre-fix bug:nv_consume_leave_shield()cleared deadline только when mask reached 0 — left inner’s stale (shorter) deadline visible к outer body, producing inflated/bogus CleanupTimeoutError fires (appeared as i64 underflow в reports). Codegen threads prev_deadline через local var per consume-block.R4b amend (2026-06-05) [M-110.x-on-exit-throws-leaks-shield] fix:
nv_consume_leave_shield(prev_deadline)MUST execute on every exit path from a consume-scope — success, body throw, body panic, cleanup throw, cleanup panic. Codegen wraps the cleanup C-call в ownNovaFailFramesetjmp; the catch branch setson_exit_threwkind (0=ok, 1=throw, 2=panic). After the wrap,nv_consume_leave_shieldis called unconditionally before any re-throw/re-panic decision. Pre-fix bug: cleanup C-call ran outside any setjmp; if cleanup body threw, longjmp routed to caller’s frame, skipping leave_shield → mask stuck (cancel deferral leaks into caller fiber) + deadline never restored (same shadow bug as R4 but triggered via exception path). Re-throw composition follows D193 R5 + D197 R3:- body panic OR cleanup panic →
nv_panicpropagates (body’s panic wins if both panic). - body throw + cleanup throw → body=primary, cleanup composed как
suppressed через
nv_compose_suppressed(&body_frame, on_exit_*)→nova_rethrow_with_suppressed(&body_frame). - only body throw →
nova_rethrow_with_suppressed(&body_frame). - only cleanup throw →
nova_rethrow_with_suppressed(&on_exit_frame). - both clean → fall through.
ResourceTrace observability hook (
Nova_ResourceTrace_on_resource_exit) fires only когда cleanup succeeded — preserves existing post-fix behavior.
- body panic OR cleanup panic →
-
Inner
cleanupошибки compose в локальный MultiError. Если он throws — outercleanupполучает это в propagation:- outer.cleanup started → outcome = Failure(orig)
- inner.cleanup failed → MultiError { primary: orig, suppressed: [inner_err] }
- outer cleanup completes; outer body re-throws с composed.
-
Depth limit 256 (same as MultiError D193). При превышении — runtime error
D197-cleanup-reentrance-depth-exceededcomposes в MultiError; cleanup продолжает разворачиваться с этой ошибкой как «…truncated» entry. -
Запрещено: re-entrance с тем же ресурсом — linear types prevent (D131 use-after-consume). Программер пытающийся
consume X = X { ... }внутриX.cleanupполучает compile error до reaching this rule.
Use case
Connection close требующий lock acquisition; Database flush требующий internal transaction; HTTP keep-alive close требующий buffer flush. Все эти паттерны — composable resources с inner cleanup.
Связь
- D131 — linear types.
- D188 §R5 — LIFO composition base.
- D192 — timeout per-scope.
- D193 — MultiError + depth limit.
- Plan 110 Ф.2.12.
D198. #realtime + cleanup-timeout interaction
Plan 110 Ф.3.6. Принято 2026-05-31. Статус: ACTIVE (codegen
in_realtimecheck emits 0-timeout, 2026-05-31). Cross-ref D172.
Семантика #realtime (recap)
#realtime на функции — гарантия callee:
- Внутри
#realtimefn body: можно вызывать только другие#realtimefns или#realtime-annotated primitive operations. - Parking ops, allocations, GC pauses запрещены.
- Никаких ограничений на caller: обычная fn свободно может вызвать
#realtimefn. Атрибут описывает свойство callee.
См. D172 полную семантику attribute.
Правило для cleanup
Codegen смотрит на enclosing function где находится consume {}:
// в обычной fn:
fn foo() Fail[E] -> () {
consume r = expr { body }
// codegen: let _timeout = nv_resolve_exit_timeout(r); // WithExitTimeout / App / 5s
}
// в #realtime fn:
#realtime
fn bar() -> () {
consume r = expr { body }
// codegen: let _timeout = Duration.zero(); // hardcoded
}
#realtime контекст полностью bypass’ит 3-level resolution —
runtime functions для timeout lookup не вызываются вовсе.
Следствия (автоматические из правила #realtime)
-
cleanupметод ресурса должен быть#realtime, иначе compile error внутриbarbody (нельзя вызвать non-#realtimefn из#realtime). Это значит resource-тип используемый в realtime- context уже спроектирован для него (MutexGuard.release, atomic ops). -
WithExitTimeoutimpl ресурса не вызывается — потому чтоnv_resolve_exit_timeoutне вызывается. -
Applicationeffect не запрашивается — same reason. -
Suspend в
cleanupневозможен — D172 body restriction (parking ban), не через нашу new проверку.
Что НЕ делаем
- ❌ Compile-time heuristic «попытается ли Application override» — не нужно;
правило
#realtimebody уже всё ограничивает. - ❌ Runtime fallback к Application в realtime — codegen эмитит zero напрямую.
- ❌ Дополнительные constraints на caller — не нужно, атрибут это callee promise.
Diagnostic
D198-realtime-application-override: warning если статически detect’имо
что #realtime fn внутри with Application = handler(default_exit_timeout: non-zero) scope’е. Application timeout будет ignored — warn user. Это
heuristic detection (не точная analysis); warning, не error.
Связь
- D172 —
#realtimeattribute model. - D188 §R4 — timeout resolution baseline.
- D191 — async cleanup parking restrictions.
- D192 — 3-level resolution bypassed.
- D194 —
Cleanup[Never]typical realtime pattern. - Plan 103.6.
- Plan 113.
D201. #cancel_safe — attestation на FFI safety inside cleanup
Plan 110.7.3.a. Принято 2026-06-01. Cross-ref D188 §R3 (cancel-shield), D192 (exit_timeout).
Что
#cancel_safe — fn-level attribute который аттестует, что функция
безопасна для вызова из Cleanup.cleanup body под активным
cancel-shield’ом (D188 R3).
#cancel_safe
external fn sqlite3_close(handle int) -> int
#cancel_safe
fn local_cleanup_helper(state State) -> () { ... }
Зачем
Когда consume X = expr { body } выходит, runtime поднимает
cancel-shield (mask_count++). Внешние cancel’ы откладываются до
leave_shield. Если cleanup вызывает C-функцию, которая:
- Блокируется неограниченно (например, classic POSIX
read(fd)на TTY-устройстве безO_NONBLOCK) — fiber виснет на C-стэке, shield deadline сгорит впустую. - Не идемпотентна при повторе — если cancel в итоге сработает и unwind рестартанёт cleanup, partial-effect C-state может оставить garbage.
- Требует Nova fail-frame state — например читает
_nova_fail_top— это нестабильно через FFI boundary.
#cancel_safe — обещание разработчика, что вызываемая функция отвечает
трём требованиям:
-
Bounded completion time. Функция завершится за разумное время даже под cancel-shield’ом — то есть не может зависеть от внешнего cancel для пробуждения / завершения (внешний cancel игнорируется shield’ом). Конкретно:
- Никаких
read()/recv()/poll()без timeout’а на файл-дескрипторах которые могут никогда не получить данные. - Никаких
pthread_cond_wait/ event-loop wait’ов без timeout’а. - Никаких busy-loop’ов которые ожидают «снаружи что-то изменится».
Антипаттерн: C-функция «жди новой записи в очереди пока не отменят» —
под shield’ом отмена не прилетит → fiber висит до exit_timeout.
Хороший паттерн: C-функция делает свою работу sync’но (
fclose,sqlite3_close,free) и возвращается.
- Никаких
-
Idempotent для cleanup семантики. D188 R2 «exactly-once» гарантия требует чтобы partial-effect cleanup был safe для рестарта (multi-cancel / multi-throw scenarios — компилятор не дублирует, но если повтор в коде → должен быть OK).
-
Не зависит от Nova fail-frame TLS state. Внутри Nova fail-frame chain (
_nova_fail_topTLS pointer) — это runtime mechanism Nova для throw routing. C-код не должен:- Читать
_nova_fail_top/_nova_active_scope/ другие internal TLS-переменные runtime’а. - Вызывать
nova_throw_*/nova_fail_push/popнапрямую. - Полагаться на Nova handler stack или ScopeOutcome. Причина: shield’ом fail-frame в strange mid-unwinding state; C-кода таких допущений делать не должен. C-код возвращает int код ошибки — Nova-обёртка (caller) сама конвертирует в throw если надо.
- Читать
Lint
При вызове FFI fn БЕЗ #cancel_safe из cleanup body — компилятор
выдаёт W_FFI_CANCEL_UNSAFE warning с suggestion:
- Добавить
#cancel_safeк декларации FFI fn если действительно safe. - Обернуть call в sync-only wrapper если cancel-safety не гарантирована.
Внутри тела обычной Nova fn (не FFI) — #cancel_safe не требуется;
весь Nova-код cancel-safe by construction (cancel routed через
nova_throw_cancel + fail-frame).
Что НЕ делает #cancel_safe
- Не меняет codegen вызова — это статическая аттестация.
- Не отключает cancel-shield (это всегда активно under ConsumeScope).
- Не предоставляет runtime check на cancel-safety — только compile-time warn’ит на отсутствующую аттестацию.
Связь
- D188 §R3 — cancel-shield механизм.
- D192 — exit_timeout taxonomy.
- Plan 110.7 — FFI Cleanup integration.
- Plan 100.5 — general FFI rules.
- Followup [M-110.7.3-w-ffi-cancel-unsafe-lint] — runtime lint enforcement (currently parser stores attribute, lint check pending).
D227. Numeric literal inference — default int, context coercion, hard range-check
Принято 2026-06-03. Уточняет и амендит D44 §«default-типы»; закрывает Rust-style narrow-fallback question. Связан с D129 (
int = i64) и D226 (signed indexing).
Что
Целочисленный литерал без context-аннотации имеет тип int
(D129 alias i64). В позиции с явным числовым
типом (annotation, parameter type, field type, generic constraint)
литерал coerce’ится в этот тип с compile-time range-check.
Rust-style fallback «default to i32 when value fits» отвергнут.
Правило
-
Default без context. Голый литерал
42,0xFF,1_000_000_000в позиции без typed target →int(=i64per D129).ro x = 42 // x: int ro y = 0xFF // y: int (= 255) ro z = 3_000_000_000 // z: int (out of i32 range — fine, int = i64) -
Context coercion. В позиции с явным numeric target литерал принимает этот тип без cast:
ro a i32 = 100 // a: i32 ro b u8 = 0xFF // b: u8 fn write(b u8) -> () => ... write(200) // 200: u8 (coerce'ится из context) ro arr []f32 = [1.0, 2.0] // элементы: f32Working positions:
let X T = …, function-call argument, field initializer, return statement в fn с явным return-type, generic instantiation argument когда тип фиксирован, array/record literal в typed context (см. D55). -
Range-check на compile time. Если литерал не помещается в target type — hard compile error, не silent truncation:
ro a i32 = 3_000_000_000 // ✗ E_LIT_OUT_OF_RANGE: 3000000000 > i32.MAX (2147483647) ro b u8 = 300 // ✗ E_LIT_OUT_OF_RANGE: 300 > u8.MAX (255) ro c u8 = -1 // ✗ E_LIT_OUT_OF_RANGE: -1 < u8.MIN (0) ro d i32 = 100 + 50 // ok если оба операнда — литералы, evaluator проверяет sumЭто отличие от Rust, где
let a: i32 = 3_000_000_000тоже error, ноlet x = 3_000_000_000→ silenti64(а в Nova →intper Rule 1). И отличие от C/Java, где silent truncation. -
Без type-suffix. D44 подтверждается:
100u32,1.5f32и прочие suffix-формы остаются rejected. Тип выбирается annotation’ом илиas-cast:100u32 // ✗ syntax error (D44) 100 as u32 // ok let x u32 = 100 // ok (Rule 2) -
Floating-point параллельно. Дробный литерал без context →
f64(D44); с context →f32илиf64с range-check (overflow при exponent overflow тоже compile error). -
Negative literal в unsigned context.
-1,-200etc. в позиции любого unsigned-типа —u8/u16/u32/u64И wideuint— hard error (E_LIT_OUT_OF_RANGE, напр.-1 < uint.MIN (0)), не wrap. Для wrap-семантики используется(-1) as u32(D54 saturation rules apply).Amend 2026-06-20 (Plan 172.1,
[M-172.1-U5.2-d227-neg-uint]). Уточнение для wide-defaultuint. Правило «widest int» (Rule 1:int/uintнесут любой литерал без верхнего range-check) относится ТОЛЬКО к верхней границе. Нижняя граница (floor 0) для unsigned проверяется ВСЕГДА, включаяuint: отрицательный литерал вuint— sign-domain ошибка, как и вu64. До 2026-06-20 impl имел дыру —uint = -1молча принимался (wrap →u64.MAX), тогда какu64 = -1корректно ругался. Fix (impl→spec conformance) ключится на общем свойствеsigned == false, не на имениuint(§3 общий механизм). Регресс —detect172/d227(pos: положительныйuint/large-hex OK; neg:uint = -1/-5→E_LIT_OUT_OF_RANGE). -
Pointer-typed positions. Литералы не coerce’ятся в pointer type автоматически (в отличие от numeric Rule 2). Требуется явный
as-cast:ro p *() = 0 // ✗ E_LIT_PTR_NO_COERCE ro p *() = 0 as *() // ok — explicit cast (Plan 134 / D214) ro h *() = 0x1000 as *() // ok — opaque handle from integer ro q *u8 = some_ptr // ok — pointer-to-pointer, no literal(
ptrв type position удалён Plan 134, 2026-06-09 — используй*().)В pointer arithmetic offset — обычный
intper Rule 1, scaled bysizeof(T)runtime (D216 §6):unsafe { ro p2 = some_ptr + 1 // 1: int (Rule 1), p2: *unsafe T ro p3 = some_ptr + offset // offset: int }null ptrliteral retracted (Plan 118 A23, 2026-06-02 D214 amend;ptrremoved Plan 134 2026-06-09) — стандартный паттерн:(0 as *()). Для nullable pointers —Option[*T](NPO codegen per D216 §7),Noneэто constructor, не литерал.
Почему
Industry baseline (2026-06).
| Язык | Default 42 | Coerce в context? | Range-check на compile? | Suffix? |
|---|---|---|---|---|
| Rust | i32 (fallback) | Нет — strict | Да (на typed binding) | Да (42i64) |
| Swift | Int (word) | Да | Да | Нет |
| Go | untyped const → int | Да | Да | Нет |
| Java | int (i32) | Implicit widen | Да (overflow → error при literal) | Да (42L) |
| Kotlin | Int (i32) | Implicit widen | Да | Да (42L) |
| C# | int (i32) | Implicit widen | Да (unchecked opt-in) | Да (42L) |
| Zig | comptime_int (∞) | Yes (coerce at use) | Yes | Нет |
| Nim | int (word) | Yes | Yes | Нет |
| Nova | int (= i64) | Yes | Yes — hard error | Нет |
Nova model = Zig/Swift гибрид (wide default + context coerce) без Rust-style narrow-fallback и без Java/C# silent widening / suffix.
Конкретные обоснования:
-
Никакого
as i32шума.arr[i]гдеiиндекс — работает без cast потому чтоint = i64= natural index type per D226. Rustarr[x as usize]— постоянная ceremony — заслужено критикуется. -
Predictability. «
42это всегда тот же тип» — правило, которое человек / LLM может удержать без edge cases. Rust42→i32если default, →usizeесли вVec::with_capacity(42), →u8если field — три разных типа по 5 правилам fallback. Slop. -
Compile-error overflow > silent wrap.
ro b u8 = 300ошибка на 3 декларации раньше, чемbиспользован — surface area ошибки минимальна. Plan 33.8 runtimeintoverflow → panic; literal overflow надо catch’ить на компиляции, не runtime. -
AI-first (D10). LLM пишет числа без suffix’а. Default = «общий int», context coerce’ит — LLM не должен гадать ширину.
-
Generic instantiation hygiene.
Vec.new()потомpush(42)→Vec[int]. Без narrow-fallbackVec[i32]инстанциация не возникает «случайно» — каждое[i32]появляется только из явной аннотации. Меньше mangled symbols в binary. -
Refactor safety. Изменить
42на3_000_000_000—intспокойно держит. В Rust меняет типi32→i64со cascade-effect на все use sites.
Что отвергнуто
-
Rust-style narrow-fallback (
42→i32если влезает). Создаёт 3 разных типа для одного литерала в зависимости от контекста; cast-hell с usize-индексами (у нас был быint-индексами); refactor-hostile. Главный design-debt Rust в области numeric ergonomics. -
Default =
i32(Java/Kotlin/C# stance). Раскрыто в research 2026-06-03: overflow на 2.1B в современном коде вероятен (file sizes, timestamps, counters, hashes), Plan 33.8 panic’ает, D226 индексы ломаются. Java team регретит, ловить ту же грабельку — без выгоды. -
Bigint-default (Python stance). Performance regression vs
i64; runtime arbitrary-precision support увеличивает runtime complexity. Plan 33.7 BitVec/sized integers покрывает domain, где точная ширина нужна. Bigint — future stdlib typeBigInt, не primitive default. -
Type-suffix (
42i64,100u8). Подтверждено D44. Annotationlet x i64 = 42уже работает; suffix дублирует. -
Silent truncation на overflow. C/Java behaviour. Hidden bug source; не AI-friendly; не aligned с Plan 33.8 soundness philosophy.
Связь
- D44 — базовый numeric literal grammar (этот D227 amend’ит default-types раздел).
- D54 —
as-cast saturation / narrowing semantics (runtime conversion path). - D55 — literal coercion в typed positions (parallel rule for record/sum constructors).
- D129 —
int = i64alias (bootstrap invariant). - D130 —
uint = u64alias. - D226 — signed indexing convention (
intдля len/capacity/index, дополняется правилом «литерал в индексной позиции = int by Rule 1»). См. также D226 §7 «Pointer interactions» — matrix offset/diff/FFI типов. - D214 — opaque pointer type (
ptrremoved Plan 134; use*()) + null retract →(0 as *())(motivates Rule 7). - D216 —
*Ttyped pointer family + arithmetic semantics (motivates Rule 7 «offset = int per Rule 1» pattern). - Plan 33.8 Ф.1 — runtime
intoverflow → panic (motivates compile-time literal range-check).
Эволюция
- D44 (2025-Q4): зафиксировал «default int, context переопределяет»
- reject suffix. Не специфицировал: range-check policy, narrow-fallback policy, behaviour negative-в-unsigned.
- D129 (2026-05-19):
int = i64alias на bootstrap. D44 line «intплатформенно-зависимая ширина» становится drift. - D226 (2026-06-03): signed indexing convention; внутренне предполагает Rule 1 («голый литерал = int»), но не формализует.
- D227 (2026-06-03, этот блок): закрывает три пробела — no narrow-fallback, hard range-check, negative-в-unsigned; amend D44 default-type line (см. inline amend в D44 выше).
Acceptance criteria
- D227 spec block формализует 4 правила + industry baseline + rejected alternatives
- D44 §«default-типы» amend cross-refs D129 + D227
- Compiler error code
E_LIT_OUT_OF_RANGE— landed Plan 142 Ф.1 (commitd6b209b8e63). Emitted при context-coercion целочисленного литерала к sized-int вtypes/mod.rs(assignable()IntLit-арм +Unary{Neg, IntLit}-арм для negative-в-unsigned Rule 6); сообщение[E_LIT_OUT_OF_RANGE] <val> > <T>.MAX (<max>)/< <T>.MIN (<min>). - Test corpus — landed Plan 142 Ф.1 в
nova_tests/plan142/(неnova_tests/types/literal_range_*как изначально именовалось в этом блоке): 8 NEG (neg_u8_300,neg_u8_minus1,neg_i32_3b,neg_u16_70000,neg_i8_200,neg_u8_hex_1ff,neg_u32_4b,neg_arg_u8— call-arg path) + 2 POS (pos_boundaries— все 8 sized MIN/MAX exactly in-range, включая boundary127/-128для i8;pos_wide_int— Rule 1 defaultint). 10/0 PASS на релизномnova.
Открытые вопросы (scoped)
- Alias / newtype над sized-int.
assignable()range-check’ит только прямой Named sized-int (+Readonly/Mut/Unsafewrappers). Литерал в позиции alias’а / newtype над sized-int (напр.type Age = u8; ro a Age = 300) не проверяется — чтобы не печатать неверное имя типа в диагностике (требуется резолв черезself.types, недоступный из free-fn coercion-сайта). Скоуп для будущего sub-plan если alias-coverage понадобится. - Float range-check (Rule 5). Compile-time overflow дробного
литерала при coercion к
f32(exponent overflow) не реализован — Plan 142 Ф.1 scope был integer-only (plan §43 «все 8 sized-int»; floats не перечислены). Rule 5 остаётся spec-only до отдельного enforcement-плана.
D236 — Tuple destructuring assignment
Syntax:
(lhs_0, lhs_1, ..., lhs_N) = (rhs_0, rhs_1, ..., rhs_N)
Semantics:
- RHS is evaluated completely before any LHS assignment begins.
- Each lhs_i must be a mutable lvalue: mut local binding, @field, arr[i], or chain.
- LHS and RHS element counts must match exactly.
- Consume types on lhs are banned in V1 ([M-136-consume-tuple-assign]).
Codegen V1 — conservative tmp:
- Build lhs_names = set of root ident names from all lhs elements.
- For each rhs_i that reads any ident in lhs_names: emit C tmp variable.
- Assign lhs_i from tmp (or direct rhs_i if independent).
Example: (a, b) = (b, a) generates:
{ nova_int _nv_ta_0 = b; nova_int _nv_ta_1 = a; a = _nv_ta_0; b = _nv_ta_1; }
Codegen V2 (Plan 136.1 — ACTIVE): pure permutation (all rhs[i] lvalue-equal to some lhs[j], mapping bijective) -> cycle-decomposition, 1 tmp/cycle. Otherwise: V1 fallback.
Error codes:
- E_TUPLE_ASSIGN_ARITY_MISMATCH lhs.len() != rhs.len()
- E_TUPLE_ASSIGN_LHS_NOT_MUT lhs element is not a mutable lvalue
- E_TUPLE_ASSIGN_CONSUME_TYPE lhs element has consume type (V1 ban)
Acceptance criteria:
- A1: (a, b) = (b, a) compiles and executes correctly
- A2: (a, b, c) = (b, c, a) rotate works correctly
- A3: independent rhs (no lhs overlap) — 0 tmp in C
- A4: E_TUPLE_ASSIGN_ARITY_MISMATCH fires on arity mismatch
- A5: E_TUPLE_ASSIGN_LHS_NOT_MUT fires on ro-binding lhs
- A6: 0 regressions in existing tests
Followup markers:
- [M-136-cycle-decomp] V2 codegen: cycle-decomposition
- [M-136-nested-tuple-lhs] nested tuple lhs ((a,b),c) = …
- [M-136-consume-tuple-assign] consume types in tuple-assign
Related: Plan 53 (let destructuring), Plan 59 (tuple mono), Plan 120 (named tuples), D215 Added: 2026-06-09 Status: ACTIVE
D238. Index[K, V] protocol — a[key] magic
AMENDED (Plan 152.1 / D249, 2026-06-13): RETRACT
str | int | char.strНЕ реализуетIndex[int, char]—s[i](int) →E_STR_NO_INT_INDEX(codepoint-index O(n) под видом O(1)).strреализует ТОЛЬКОIndex[Range, str](byte-range slice, contract-bounds,slice.nv). Элементный доступ — через линзыas_bytes()[i]/for c in as_chars()(positionalas_chars().nth(i)retracted — D260-амендмент). См. D249/D250.
Что
@index(key K) -> V — протокол для a[key] доступа на user-типах.
K — тип ключа (int для массивов, Range для slicing, str для maps).
V — тип возвращаемого значения.
Семантика
a[key] десугаринг в a.index(key) для любого типа реализующего
Index[K, V]. Паникует при invalid key (OOB, missing key etc.).
Builtin пути ([]T, str, NovaArray_*) продолжают работать через
compiler built-in dispatch. User-типы реализуют через @index метод.
Примеры
// Vec[T] implements Index[int, T]:
assert(v[i]) // → v.index(i)
// Vec[T] implements Index[Range, Vec[T]]:
assert(v[2..5].len() == 3) // → v.index(Range { start: 2, end: 5 })
// Custom map implements Index[K, V]:
type MyMap[K, V] { ... }
fn MyMap[K, V] @index(key K) -> V { ... }
assert(mymap["hello"]) // → mymap.index("hello")
Стандартные реализации
| Тип | K | V |
|---|---|---|
Vec[T] | int | T |
Vec[T] | Range | Vec[T] (zero-copy view) |
str | int | char (panic OOB) |
str | Range | str (byte-range view, panic OOB) |
Slicing: Index[Range]
v[a..b] и v[..] — механизм среза (slice) в Nova. Нет
отдельного slice-протокола: Range — просто другой тип ключа K в Index[K, V].
Компилятор опускает a[key] в a.index(key). Для a[range] ключ —
значение Range. Пример:
mut v []int = [10, 20, 30, 40, 50] // []int = Vec[int] (D239)
v[1..4] // → v.index(Range { start: 1, end: 4 }) // [20, 30, 40]
v[1..4][0] // → [20] (цепочка: сначала срез, потом скалярный index)
v.get(1..4) // → Option[Vec[int]] (safe-версия, None если OOB)
Open-ended bounds (v[1..], v[..3], v[..]) — компилятор подставляет
0 или arr.len() перед вызовом @index, так что пользовательские
реализации всегда получают полностью заполненный Range:
v[2..] // → v.index(Range { start: 2, end: v.len() })
v[..3] // → v.index(Range { start: 0, end: 3 })
v[..] // → v.index(Range { start: 0, end: v.len() }) // полная копия/view
Open-ended формы допустимы только в index-context (a[range]).
В for-loop / материализации — compile-error (нужна bounded форма), см. D58.
Zero-copy semantics (для Vec[T]): v[a..b] возвращает новый
Vec[T]-заголовок (pointer + len + cap), указывающий внутрь того же
GC-tracked буфера. Мутации view (push) вызывают realloc → silent detach
от родителя. Backing-буфер защищён GC_set_all_interior_pointers.
str реализует Index[Range, str] — slice по байтам (panic при
разрезании посередине кодпоинта не предусмотрен в V1; планируется D239+).
Почему
- Устраняет compiler magic для
a[i]— пользователь может реализовать indexing для своего типа (Map, Matrix, etc.) - Нет отдельного
Slice-протокола — Range как K достаточен; перегрузка по типу ключа обеспечивает и скалярный, и range доступ - Выравнивает с Python
__getitem__, C++operator[], RustIndex - Совместно с
MutIndex[K, V](D240) покрывает полный read/write API
Что отвергнуто
@get(key)вместо@index(key)— имяgetзанятоOption-возвращающей версией (safe access).@index— panic-версия, явно иная семантика.[T Index[K, V]]обязательный bound — структурная типизация достаточна, никакихimpl-блоков.- Отдельный
Slice[T]протокол — излишен:Index[Range, Vec[T]]выражает то же самое через обычный перегруженный@index.
Связь
- D58 —
for x in cimplicit iter (аналогичный sugar-паттерн); open-ended bounds в for-loop запрещены - D239 —
[]Tкак сахар надVec[T];v[a..b]возможен потому что[]T = Vec[T]реализуетIndex[Range, Vec[T]] - D240 —
MutIndex[K, V]write magic - Plan 138 —
[]Tsugar overVec[T]+ Index protocol
Added: 2026-06-10 Status: ACTIVE
D240. MutIndex[K, V] protocol — a[key] = val magic
Что
mut @index(key K, val V) — протокол для a[key] = val записи.
Read-only типы реализуют только Index[K, V].
Мутабельные типы реализуют оба.
Семантика
a[key] = val десугаринг в a.@index(key, val) (write-overload @index
с mut-receiver) для типов реализующих MutIndex[K, V]. Паникует при invalid
key (OOB, etc.). Запись-@index отличается от чтения-@index арностью
(2 аргумента vs 1) и mut-receiver’ом.
Примеры
// Vec[T] implements MutIndex[int, T]:
mut v []int = [1, 2, 3]
v[0] = 99 // → v.@index(0, 99)
assert(v[0] == 99)
Стандартные реализации
| Тип | K | V |
|---|---|---|
Vec[T] | int | T |
Followup
[M-138-index-set]— compiler dispatch дляa[key] = valчерез write-overloadmut @index(key, val)(пока LHS assignment через builtin path — codegen инлайнитv[i] = valнапрямую, см. emit_c.rsStmt::Assign+ExprKind::Index; протокол-метод declared для конформанса MutIndex D240)
Связь
- D238 —
Index[K, V]read magic (парный протокол) - Plan 138 —
[]Tsugar overVec[T]+ Index protocol
Added: 2026-06-10 Status: ACTIVE
D239. []T как сахар над Vec[T]
⚠ ДУБЛЬ-ЗЕРКАЛО (помечено 2026-07-03): канонический блок D239 — 02-types.md#d239 (полная метадата/typed-storage/§amend). Этот блок — краткое зеркало (Plan 138); при расхождении канон = 02-types.
Что
[]T — синтаксический псевдоним Vec[T]. После миграции (Plan 138 Ф.5)
компилятор разворачивает любое []T в Vec[T] на уровне type resolution.
// Эти объявления эквивалентны:
mut a []int = [1, 2, 3]
mut b Vec[int] = [1, 2, 3]
// Литерал desugars в Vec[T]:
[1, 2, 3] → Vec[int].with_capacity(3); push 1; push 2; push 3
// []T.new() = Vec[T].new()
mut v []int = []int.new()
Зачем
До D239 []T — встроенный C-макро тип (NovaArray_T), Vec[T] — чистая
Nova-реализация с тем же layout ({ data *T, len int, cap int }, 24 байта).
Объединение:
- Закрывает typed-storage gap:
[]Option[int],[]Recordполучают правильное typed хранение вместо int64-erasure - Убирает дублирование логики между
[]TиVec[T] v[a..b]работает потому чтоVec[T]реализуетIndex[Range, Vec[T]](D238)- Все методы
Vec[T](push,pop,len,get,iterи т.д.) доступны через[]T-синтаксис
Статус
Plan 138 Ф.1-Ф.4 (2026-06-10): D239 частично активен —
[]T → Vec[T]flip работает в единицах компиляции, которые явно импортируютVec(т.е. имеютVec-шаблон вgeneric_type_templates). Примитивные единицы безVec-импорта продолжают использоватьNovaArray_TC-backing.Plan 138.2 [M-138.1-vec-in-prelude]: сделать
Vecчастью prelude — тогда D239 включится для всех единиц иNovaArray_Tможно будет убрать.
Правила
[]T=Vec[T]на уровне типов:fn f(a []int)принимаетVec[int]- Литерал
[e1, e2, ...]в позиции[]TстроитVec[T] - Spread-литерал
[...xs]→ итерация с push (D60) []T.new()иVec[T].new()— одно и то же
Связь
- D238 —
Index[K, V];v[a..b]черезIndex[Range, Vec[T]] - D240 —
MutIndex[K, V];v[i] = xчерезMutIndex[int, T] - D27 — синтаксис
[]T(формат записи сохраняется, семантика меняется) - D144 (02-types.md) — migration path: «
[]Tmay become sugar overVec[T]» — закрывается D239 - Plan 138 — полный план миграции
Added: 2026-06-10 Status: ACTIVE (partial — gated on Vec-in-prelude)
D241. Канонический порядок модификаторов type-декларации (scope-adjacency)
Что
Модификаторы в объявлении типа (export type NAME <mods> { fields }) имеют
единственный канонический порядок — по scope (область действия), от широкой к
узкой слева направо. Несколько эквивалентных порядков (order-independence) запрещены
(«one canonical syntax» — Nova не допускает разных написаний одного и того же).
export type Point value priv { x f64, y f64 } // ✓ канон
type Point priv value { ... } // ✗ E_MODIFIER_ORDER → fix-it «value priv»
| Позиция | Модификатор | Scope |
|---|---|---|
до type | export | видимость типа в модуле (широчайший) |
| после имени, левее | value | аллокация/представление всего типа (stack vs heap) |
после имени, вплотную к { | priv | дефолт видимости полей в {…} |
{ … } | — | сами поля |
Зачем
Scope-adjacency: каждый модификатор стоит рядом с тем, что определяет.
privзадаёт дефолт видимости полей внутри{…}→ квалифицирует блок{…}→ вплотную к{.value— свойство всего типа (представление/копирование) → type-level → левее.export— видимость самого типа → широчайший scope → передtype.
Чтение монотонно сужает scope: модуль → тип → поля → сами поля.
Выбор value priv (не priv value) обоснован по существу (scope-adjacency), а не
«так уже принято»: priv примыкает к полям, которыми управляет; value остаётся на уровне
типа. Контраргумент «видимость первой» (как pub/priv в member-декларациях) не побеждает:
видимость типа уже выражена export слева, а type-level priv — это дефолт полей,
иная сущность, принадлежащая блоку {…}.
Правило обобщается на все будущие type-модификаторы: сортировать по scope (type-level → field-default), не вводя произвольных синонимичных порядков.
Статус
РЕШЕНО 2026-06-11; ENFORCEMENT РЕАЛИЗОВАН 2026-06-12 (Plan 148 Ф.1,
[M-138-canonical-modifier-order]CLOSED). Order-independence (намеренный артефакт Plan 124.8) RETIRED. Parser (parser/mod.rs::parse_type_decl, modifier-loop) присваивает каждому модификатору canonical rank, проверяет монотонное возрастание rank’ов в порядке появления и при инверсии эмититE_MODIFIER_ORDERс machine-applicable fix-it (переписывает modifier-регион в канон).nova_tests/plan124_8/modifier_order_independence_ok.nvФЛИПНУТ в negative (EXPECT_COMPILE_ERROR E_MODIFIER_ORDER); positive-канон + neg-инверсии вnova_tests/plan148/mo_*.
Правила
- Канон:
export(доtype) → type-level mods (value) → type-level ownership (consume) → field-default mods (priv) →{ fields }. - Canonical ranks (по scope, широкий→узкий):
value=0 (представление/аллокация всего типа) →consume=1 (must-consume обязательство всего типа) →priv=2 (дефолт видимости полей).export— отдельным keyword’ом доtype, в rank-набор не входит (всегда левее имени). - Out-of-canon (любая инверсия rank’ов в порядке появления, напр.
priv value,priv consume,consume value) →E_MODIFIER_ORDERс fix-it (переписать modifier-регион в rank-отсортированный канон). - 0 или 1 модификатор — всегда канон (нечего переставлять); проверка фактически применяется к ≥2 модификаторам.
- Правило по scope обобщается на любые новые модификаторы: новому модификатору присваивается rank по его scope (type-level → field-default) — он автоматически попадает в проверку монотонности, без введения синонимичных порядков.
Связь
- type-level
priv {}flip (02-types.md, Plan 124) — field-default visibility (privмодификатор) - value-records (Plan 124.8) —
valueмодификатор (stack-аллокация) - D239 —
[]T ≡ Vec[T]; Plan 138 family - D124 / D220 (02-types.md) —
privfield-default visibility (rank 2 модификатор); D241 фиксирует его позицию в каноне (amend D124’s order-independence allowance). - Plan 124.8 —
valueмодификатор + (ретированный) order-independence test [M-138-canonical-modifier-order]— enforcement followup → CLOSED Plan 148 Ф.1
Added: 2026-06-11 Status: IMPLEMENTED 2026-06-12 (Plan 148 Ф.1; enforcement в parser, [M-138-canonical-modifier-order] closed)
D262. Slice-op surface на []T-view модели — без нового типа
AMEND (2026-07-06, D410):
@as_slice()/mut @as_slice()переименованы в@slice()/mut @slice()— префиксas_упразднён (см. D410). Ниже — исторический текст с прежним именем.
Минорное решение (Plan 153.4). Фиксирует, что весь slice-операционный поверхностный API (
split_at/split_first/split_last/first_n/last_n/as_slice+ ленивыеchunks/windows) строится на уже принятой[]T-view модели D238/D239 + Plan 96 (D-single-type, D-cap-len) и НЕ вводит отдельныйSlice[T]-тип. Новых типов/протоколов нет — это подтверждение едино-типной модели + конкретная сигнатурная поверхность.
Что
Slice-операции (разбиение, префикс/суффикс, whole-view) на Vec[T] возвращают
view того же типа []T ≡ Vec[T] ({ data: @data + start, len, cap: len },
cap == len), указывающий внутрь того же GC-tracked буфера. Нет Slice[T],
нет &[T], нет borrow-lifetime — view это просто Vec-заголовок (D238 уже
зафиксировал это для v[a..b]; D262 распространяет на named-методы).
| Метод | Сигнатура | Контракт |
|---|---|---|
@split_at(i) | -> (Self, Self) | requires 0 <= i <= len (OOB → panic, НЕ clamp) |
@split_first() | -> Option[(T, Self)] | пусто → None |
@split_last() | -> Option[(T, Self)] | пусто → None |
@first_n(n) | -> Self | CLAMP (n>len→весь, n<=0→пусто) |
@last_n(n) | -> Self | CLAMP (как first_n) |
@as_slice() | -> Self | ro whole-view (Vec-side аналог str.as_bytes()) |
mut @as_slice() | -> mut Self | write-through whole-view (recv-mut overload) |
@chunks(n)/@chunks_exact(n)/@rchunks(n)/@windows(n) | -> BoxIter[Self] (LAZY, yield []T-view’ы) | requires n > 0; в std/collections/vec_lazy.nv (Plan 153.4-B, ✅ IMPLEMENTED) |
Семантика
- Same-type, zero-copy. Каждый view алиасит родительский буфер; никакой
внешней аллокации. Owning-копия —
clone()/to_vec(), никогда не view. split_at— контракт (panic),first_n/last_n— clamp.split_atобязан держать инвариантlen(left) + len(right) == len; silent clamp скрыл бы баг вызывающего и сломал инвариант →requires-violation panic. Уfirst_n/last_nсемантика «взять до N» — clamp естественен (зеркало Rust[..n.min(len)]), не сюрпризит «не больше n».- Mut-view через receiver-mut overload.
mut @as_slice()выбирается наmut-bound получателе и даёт write-through view; имя —as_slice(overload по получателю, как@as_ptr/mut @as_ptr), НЕas_mut_slice(D247 / Plan 135 accessor-конвенция). - Detach-on-resize (D238/Plan 96, Go-модель GC-safe). View имеет
cap == len, поэтому первая реаллоцирующая мутация (push/reserve/insertприcap==len) уезжает в свежий буфер и view молча детачится — родительский backing не перезаписывается (Go shared-backing footgun устранён без borrow-checker). До точки detach mut-view пишет сквозь в родителя. Точка detach предсказуема через точную ёмкость (with_capacity/@cap(n), 153.1 — без pow2-округления).
Почему
- Никакого нового типа.
[]T ≡ Vec[T](D239) +Index[Range, Vec[T]](D238) уже выражают slice; named slice-методы — лишь эргономика поверх той же модели. ОтдельныйSlice[T]потребовал бы borrow-lifetime инфраструктуры, которой в Nova нет и не нужно (detach-on-resize заменяет borrow-checker). - Eager без внешней аллокации для view-заголовков, lazy для chunks/windows.
split_at/first_n/… возвращают view’ы БЕЗ аллокации (просто заголовки), их отдаём eager.chunks/windowsв eager-форме аллоцировали бы[][]T-Vec — это расходится с ленивым каноном (Q-iterator-laziness), поэтому они ленивые итераторы-> BoxIter[Self]поверх инфры Plan 153.2 (Plan 153.4-B, реализованы вstd/collections/vec_lazy.nv):collect()материализует[][]Tтолько по требованию,chunks(n).map/fold/count/for_each— без внешней аллокации вовсе.
Что отвергнуто
- Отдельный
Slice[T]/&[T]тип — избыточен на single-type модели Plan 96; view этоVec-заголовок сcap == len. as_mut_sliceкак отдельное имя — мут-view = receiver-mut overload@as_slice(D247/Plan 135), одно имя, диспатч по mut-получателю.- Eager
chunks/windows→[][]T— аллоцировал бы внешний Vec, расходится с ленивым каноном; реализованы ленивыми-> BoxIter[Self](Plan 153.4-B).
Связь
- D238 —
Index[K, V];v[a..b]черезIndex[Range, Vec[T]](та же view-модель) - D239 —
[]T ≡ Vec[T](нет отдельного slice-типа) - D247 / Plan 135 — accessor-конвенция: мут-вариант = receiver-mut overload (
mut @as_ptr) - Plan 96 — D-single-type (
[]T-view того же типа) + D-cap-len + detach-on-resize - Plan 153.4-A — eager view-заголовки (
std/collections/vec/views.nv) - Plan 153.4-B — ленивые
chunks/chunks_exact/rchunks/windows-> BoxIter[Self](std/collections/vec_lazy.nv, поверх Plan 153.2);[M-153.4-chunks-windows-lazy]✅ CLOSED - D260 / Plan 153.2 —
BoxIter[T]ленивая итератор-инфра (база для chunks/windows)
Added: 2026-06-14 Status: ✅ IMPLEMENTED ЦЕЛИКОМ (Plan 153.4-A eager-views
std/collections/vec/views.nv, 2026-06-14; Plan 153.4-B lazy chunks/chunks_exact/
rchunks/windows std/collections/vec_lazy.nv, 2026-06-15)
D249. Строковая координатная модель: линзы вместо плоских методов
Added: 2026-06-13 Status: IMPLEMENTED 2026-06-13 (Plan 152.1 Ф.1/Ф.4)
str хранится как UTF-8 (ptr *ro u8, len int) (Plan 139). Доступ — через
линзы-представления, не плоские методы; единицы координат — байтовые.
strНЕ индексируется целым —s[i](int) →E_STR_NO_INT_INDEX(checker,types/mod.rsIndex-арм). Fix-it: байтs.as_bytes()[i](O(1)), codepoint черезfor c in s.as_chars()/.indices()(O(n); positionalas_chars().nth(i)retracted — D260-амендмент, same O(n)-as-O(1) footgun), срезs[a..b]. Codepoint-index на UTF-8 = O(n)-ложь под видом O(1) → запрещён (прецедент Rust/Swift).str[a..b]— единственныйstr[..]: byte-range zero-copy view (Index[Range,str],slice.nv), O(1). Контрактrequires 0<=start && start<=end && end<=byte_len()(140.2-элидируемый) + рантайм-паника при рассечении codepoint-границы (R-UTF8). Дискриминация int-vs-Range — по типу индекса (Range-var тоже допускается). Codegen лоуэритs[a..b]inline в(nova_str){.ptr=…+from,.len}.@as_bytes() -> ro []u8— байтовый слой бесплатно (Vec[u8]-view, D239):[i]/len()/итерация O(1).@byte_at(i)ретайрнут →as_bytes()[i].@as_chars() -> CharsIter(D250) — codepoint-слой (поток, O(n)). Плоские@char_at/@char_len/@get(int)ретайрнуты →for c in as_chars()/.count()(positionalas_chars().nth(i)— сам тоже ретрактирован, D260-амендмент).@find/@rfind→ байт-offset (композируется сs[k..]за O(1)).for c in s→char(=s.as_chars(), D58 amend).- Бэар
@len()ретайрнут →E_STR_NO_LEN.@byte_len()(O(1), читает priv-поле@len) — единственный length-метод наstr; codepoint-длина —as_chars().count(). Полеlen(storage) остаётся. Ретайрнутые с targetedE_STR_NO_LEN+fix-it (checker, НЕ-self receiver; self-@lenfield-read изъят). - Инвариант R-UTF8 (D26 AMEND): значение
strвсегда валидный UTF-8 →as_chars()-decode тотален.
Отвергнуто: str[i]→u8 (Go) / str[i]→char (Python, прежняя «школа B»);
индексация строки провоцирует for i in 0..len-антипаттерн O(n²). Дефолт-grapheme
(Swift) — Unicode-data в библиотеку (Plan 152.4). Q-string-indexing/Q-string-len —
закрыты этим (open-questions.md). Полный план — Plan 152.1.
D250. CharsIter (codepoint-итератор) + конвенция as_/to_
Added: 2026-06-13 Status: IMPLEMENTED 2026-06-13 (Plan 152.1 Ф.3)
as_chars() — decoding lens (codepoint’ы вычисляются на проходе, не лежат
массивом), поэтому честный примитив — итератор-поток, не коллекция (View с
at/len → for i in 0..len{at(i)} = O(n²) footgun, нарушает «no hidden O(n)»).
Прецедент: Rust str::chars().
type CharsIter value priv { buf str, pos int } // borrows str; GC видит ptr через buf
| Метод | Смысл | Сложность |
|---|---|---|
mut @next() -> Option[char] | декод codepoint в buf[pos], pos += step (Next[char]) | аморт. O(1) |
@iter() -> CharsIter => @ | Iter (self-iterator, D58 for c in s) | O(1) |
@count() -> int | число codepoint’ов от pos (терминатор) | O(n) |
@nth(i int) | ⛔ ретрактирован (2026-07-06, решение владельца) — это воссозданный запрещённый s[i] по codepoint (E_STR_NO_INT_INDEX): не-mut приёмник сканировал с нуля каждый вызов → O(n²) в циклах. Целевая итерация: for-in + enumerate/skip; миграция [M-d73-d77-retraction-migration]-волной | — |
@is_empty() -> bool | pos >= buf.byte_len() | O(1) |
- НЕ реализует
Index[int,char], нет позиционныхat/len(вариант C). Наstrнетchar_len/char_at— это и есть минимализм.CharsIter[i]→ CC-FAIL (не индексируем). str @as_chars() -> CharsIter(ленивый, borrows).str @iter() => @as_chars()→for c in sдекодит codepoint’ы. codepoint-count =as_chars().count(); N-й элемент — через целевую итерацию (for/.indices()), не позиционный доступ (.nth(i)ретрактирован).- Конвенция:
as_<repr>()= линза/итератор-вью (borrows, zero-copy);to_<repr>()= owned копия (alloc).as_bytes/as_charsvsto_bytes/to_chars. - GC/lifetime:
CharsIter.buf(str) держитptr→ conservative GC видит буфер живым;strиммутабелен (R8). - Codegen-foundation (Plan 152.1 Ф.3): value-record итератор — первый общий
value-record с методами; потребовал фиксов value-record ABI в
emit_c.rs(for-inNovaValue_*strip + by-pointer&itдляmut @next; bare-@self deref для self-return;nova_type_name_from_c/recv-dispatch +prepare_method_recv) — заодно закрыл класс[M-138.2-vec-self-return]для value-records.
D251. Полный str-surface (паритет Go/Rust/TS/Kotlin/Java)
Added: 2026-06-13 Status: IMPLEMENTED 2026-06-13 (Plan 152.2 Ф.1-Ф.3)
Прод-поверхность str поверх координатной модели D249/D250. Все позиции/длины —
байтовые (или явный char через линзу); поиск/нарезка — byte-offset; нарезка/trim/
strip/split возвращают zero-copy sub-views (@[a..b]).
- split-семейство (search.nv, byte-scan + zero-copy views):
@split(sep),@splitn(n, sep)/@rsplitn(n, sep)(≤ n частей, последняя = остаток; reverse для rsplit*),@rsplit(sep)(справа, обратный порядок),@split_once(sep)/@rsplit_once(sep)→Option[(str, str)],@split_terminator(sep)(без пустого хвоста),@split_whitespace()(ASCII-WS runs; non-ASCII →[M-152-ws-unicode], 152.4),@lines()(\n/\r\n, без терминатора). - trim/strip (transform.nv, zero-copy views):
@trim/@trim_start/@trim_end(ASCII-WS ≤0x20; Unicode-WS — Phase B);@trim_matches(c char)/@trim_start_matches/@trim_end_matches(codepoint-pattern, multibyte-safe черезstr.from(c));@strip_prefix(p)/@strip_suffix(s)→Option[str]. - search-доп (search.nv):
@match_indices(needle) -> []int(byte-offset’ы),@matches(needle) -> []str(views);@is_char_boundary(idx) -> bool(core.nv, O(1), Rust-parity — для безопасной нарезки без паники). - transform (transform.nv):
@replace(все),@replacen(from, to, n)(первые n),@repeat,@pad_left/@pad_right/@pad_center— ширина в CODEPOINT’ах (as_chars().count(), не байты:"é".pad_left(3,'·') == "··é"). - slice/owned-линзы:
s[a..b](panic) /@get(a..b) -> Option[str](None при OOB/не-границе) — byte-range, zero-copy (D249/slice.nv);@to_bytes() -> []u8/@to_chars() -> []char(owned, alloc).
Аллокации (для не-zero-copy путей: replace/repeat/pad) — через _buffer/StringBuilder
(Plan 152.0). codepoint-семантика поиска/split (прежняя школа B) переведена на байт.
Полный план — Plan 152.2.
D252. char-тип API — классификация / case / digit
Added: 2026-06-13 Status: 152.3a (ASCII) IMPLEMENTED 2026-06-13 (Plan 152.3a); 152.3b (Unicode) IMPLEMENTED 2026-06-15 (Plan 152.3b)
char (u32 codepoint) получает прод-API уровня Rust char / Java Character / Go
unicode. Двухуровнево: ASCII-core (в ядре, без таблиц) + Unicode-aware (делегат
в std/unicode, Phase B).
152.3a — ASCII-core (Фаза A, IMPLEMENTED)
В std/runtime/defaults.nv (рядом с char @compare — Nova-body методы на builtin
char эмитятся только из disk-loaded prelude-модуля, НЕ из embedded char.nv,
который парсится лишь для checker’а). Все предикаты — через char-сравнения (codepoint
order), ноль Unicode-таблиц; семантика == Rust char::is_ascii_*/to_digit:
@is_ascii/@is_ascii_digit/@is_ascii_alphabetic/@is_ascii_alphanumeric/@is_ascii_whitespace(space\t\n\r\x0C, без \x0B) /@is_ascii_uppercase/@is_ascii_lowercase/@is_ascii_hexdigit.@to_ascii_uppercase/@to_ascii_lowercase(a-z↔A-Z черезchar.try_from(@ as int ±32)).@to_digit(radix) -> Option[int](2..=36; None вне диапазона/не-цифра/значение≥radix).@len_utf8() -> int(1-4);@encode_utf8() -> str(=str.from(@)).
Ad-hoc is_digit/is_hexdigit (json.nv) консолидированы на эти методы.
152.3b — Unicode-aware (Фаза B, IMPLEMENTED — делегат в std.unicode)
Opt-in char-методы (доступны ТОЛЬКО под import std.unicode, как str @as_graphemes — за размер таблиц платит импортирующий, НЕ prelude). Реализованы в
std/unicode/category.nv char-receiver-обёртками над
code-point-предикатами того же модуля; 1:1 с UCD 16.0, семантика == Rust char:
@is_alphabetic/@is_numeric/@is_alphanumeric/@is_whitespace/@is_uppercase/@is_lowercase/@is_control— бинарные предикаты.@general_category() -> GeneralCategory— UCD General_Category (TR44 Table 12), все 30 значений (Lu|Ll|Lt|Lm|Lo|Mn|Mc|Me|Nd|Nl|No|Pc|Pd|Ps|Pe|Pi|Pf|Po|Sm|Sc|Sk| So|Zs|Zl|Zp|Cc|Cf|Cs|Co|Cn);Cn(not assigned) — дефолт для code point’а внеUnicodeData.txt. Объявлен какexport type GeneralCategoryвcategory.nv.@to_uppercase() -> str/@to_lowercase() -> str— полное per-code-point case-mapping, возвращаютstr(НЕ одинchar): результат может быть multi-code-point (ß→"SS", fi→"FI", İ→"i"+◌̇).CharsViewНЕ существует; возвратstr— финальное решение (см. ниже). Final_Sigma — string-level контекст-правило, поэтому одиночная Σ (U+03A3) lowercase’ится в σ (non-final form) — корректный context-free ответ. Переиспользуетupper_one/lower_oneизcase.nv(152.4.4).
Делегация (под капотом). Предикаты делегируют в std.unicode code-point-функции
(general_category/is_alphabetic/is_numeric/…), которые читают новую таблицу
std/unicode/category_data.nv: General_Category
(UnicodeData.txt поле 2) + бинарные Alphabetic (DerivedCoreProperties.txt) и
White_Space (PropList.txt) из UCD 16.0. is_numeric выводится из GC (Nd|Nl|No, как
Rust), отдельной таблицы не требует. Таблица сгенерирована build-time-инструментом
nova-codegen unicode (range-таблицы binary-search, как word_data.nv); --check —
CI-guard. Char-методы остаются вне prelude: программа без import std.unicode их
не видит (pin’ит negative-фикстура).
AMEND (Plan 159 Ф.4 / Plan 169.2.1, D308, 2026-06-19): char-@методы фактически
доступны без явного import std.unicode — резолвер инжектит std.unicode в
entry-группу, увидев method-call-селектор expr.is_alphabetic() (тег @method:),
а Plan 159 Ф.1 DCE срезает неиспользуемые таблицы. Free-функции же
(general_category(0x41) и т.п.) остаются строго opt-in под import std.unicode
(@method:-тег различает форму вызова) — это и есть то, что pin’ит negative-фикстура
plan152_3/neg/n_char_unicode_opt_in.nv.
Plan 162 Ф.4 временно хостил @методы в prelude.core (через core.nv import std.unicode); Plan 169.2.1 D308 вернул их в category.nv и
восстановил resolver-инъекцию, чтобы core не тянул unicode (фикс частичного
#prelude(core, …)). См. D308.
@to_uppercase() возврат str, НЕ итератор. Возможные формы возврата были str
(материализованный) vs CharsView/итератор (как Rust char::to_uppercase() →
ToUppercase: Iterator<Item=char>). Решение — str: (1) CharsView для char-case
в Nova не существует и не нужен (расширение 1–3 cp — крошечное), (2) симметрия со
string-level to_uppercase(s) -> str (case.nv), (3) str напрямую конкатенируется/
сравнивается без сбора. См. Plan 152.3.
D253. std/unicode — нормализация (UAX #15) + grapheme/word/sentence-сегментация (UAX #29) + case folding/mapping/title-casing
Added: 2026-06-14 Status: 152.4.1+152.4.2 (нормализация) + 152.4.3 (graphemes) + 152.4.4 (case folding/mapping) + 152.4.5 (word-сегментация + title-casing) + 152.4.6 (sentence-сегментация) IMPLEMENTED 2026-06-14; collation = D254 (152.5b DUCET, тоже IMPLEMENTED 2026-06-14)
Полная Unicode-нормализация — отдельный opt-in модуль std/unicode (модуль
std.unicode, folder-module), импортируется явно; НЕ prelude (за размер таблиц
платит импортирующий, criterion A6). Ядро (152.1–152.3a: байты/codepoint/ASCII)
остаётся prelude-доступным и собирается без std/unicode.
Что в ядре vs библиотеке
Ядро: байты, codepoint decode/encode, ASCII case/classification, byte-Compare.
std/unicode: нормализация + grapheme/word/sentence-сегментация + case
folding/mapping/title-casing (здесь, реализовано); collation (152.5b) — Phase B.
API (152.4.2, реализовано)
Free-function форма (D253): normalize_nfc/nfd/nfkc/nfkd(s str) -> str.
- NFD = canonical decomposition + canonical ordering (стабильная сортировка combining-марок по CCC, starter’ы — барьеры).
- NFC = NFD + canonical composition (blocking-rule UAX #15).
- NFKD/NFKC = compatibility decomposition вместо canonical. Hangul декомпозируется/композируется алгоритмически (UAX #15 §Hangul, L+V и LV+T), не по таблицам.
Данные (152.4.1, Q-unicode-data)
Таблицы генерируются build-time-инструментом nova-codegen unicode из UCD
(UnicodeData.txt, CompositionExclusions.txt, DerivedNormalizationProps.txt) в
std/unicode/norm_data.nv — компактные ;-строки (NFD/NFKD full decomp, CCC,
canonical composition), пин к UNICODE_VERSION (16.0). Парсятся лениво в
HashMap при первом вызове (module-level ro lazy-static, D199). Composition-ключ —
упакованный int (a<<21)|b (tuple-key HashMap — codegen-gap). Без ICU/ОС;
--check — CI-guard. Прецедент: Rust unicode-* (codegen), Go maketables.
API grapheme-сегментации (152.4.3, реализовано)
Третья линза str.@as_graphemes() -> GraphemesIter (симметрична
as_bytes/as_chars): extended grapheme clusters (UAX #29) — то, что человек
видит как один символ (é = e+◌́; 🇺🇸 = 2 RI; 👨👩👧 = ZWJ-emoji — каждый 1 grapheme).
GraphemesIter (value priv {buf,pos}, как CharsIter) реализует Next[str]
(mut next() -> Option[str], срез-кластер) + iter/count/is_empty; decoding
lens (поток, O(n) count, нет позиционного кэша — I2). Правила GB1-GB13 + GB9c
(Indic Conjunct Break, U15.1) над Grapheme_Cluster_Break + Extended_Pictographic +
Indic_Conjunct_Break (range-таблицы binary-search, grapheme_data.nv из
GraphemeBreakProperty.txt + emoji-data.txt + DerivedCoreProperties.txt). Hangul
L/V/T — через GCB-категории.
API case folding + case mapping (152.4.4, реализовано)
Free-function форма: fold_case(s) -> str, to_uppercase(s) -> str,
to_lowercase(s) -> str — все locale-independent (D253: без локали).
fold_case— полное case folding (CaseFolding.txt status C+F) для caseless matching (Σ/σ/ς→σ, ß→“ss”, K-Kelvin→k). Идемпотентно. НЕ нормализация — для полного caseless-сравнения канонически-эквивалентного текста сначала normalize (UAX #15), затем fold.to_uppercase/to_lowercase— полное Unicode case mapping, multi-codepoint (ß→SS, fi→FI, İ→“i”+◌̇). SpecialCasing.txt unconditional + UnicodeData simple.- Final_Sigma — единственное context-правило языко-нейтрального подмножества:
Σ→ς в конце слова (preceded-by-cased & not-followed-by-cased, скип
Case_Ignorable), иначе σ. Реализовано в
case.nvчерезCased/Case_Ignorablerange-таблицы. - Исключено (по дизайну, не упрощение): locale tailoring (tr/az/lt
SpecialCasing + Turkic fold status T); title-casing (нужны UAX #29 word
boundaries —
[M-152-word-boundaries]).
Данные case (152.4.4)
std/unicode/case_data.nv (nova-codegen unicode из CaseFolding.txt +
SpecialCasing.txt + UnicodeData.txt[12,13,14] + DerivedCoreProperties.txt
Cased/Case_Ignorable): FOLD/LOWER/UPPER/TITLE maps (cp:m1,m2;..) +
CASED/CASE_IGNORABLE ranges. Ленивые ro HashMap/range-таблицы.
API word-сегментация + title-casing (152.4.5, реализовано)
Четвёртая линза str.@as_words() -> WordsIter: UAX #29 word boundaries (WB1-WB16) —
итерирует ВСЕ сегменты (слова, пробелы, пунктуация), как видит человек. O(1) на
создание (forward state-machine, lazy — границы не материализуются; conditional
lookahead для WB6/7b/12 только когда нужен). WordsIter (value priv {buf, pos, n, prev_imm_cat, prev_eff_cat, prev_prev_eff_cat, ri_run}) реализует
Next[str] + iter/count/is_empty. Данные: std/unicode/word_data.nv
(WB-категории range-таблица из WordBreakProperty.txt;
Extended_Pictographic для WB3c переиспользуется из grapheme_data.nv).
to_titlecase(s) -> str (locale-independent): титулкейс первой cased-буквы каждого
слова (UAX #29) + lowercase остального (с Final_Sigma). Использует TITLE-маппинг
(не upper — dž→Dž, не DŽ).
API sentence-сегментация (152.4.6, реализовано)
Пятая линза str.@as_sentences() -> SentencesIter: UAX #29 sentence boundaries
(SB1-SB11 + SB998) — итерирует сегменты-предложения (предложение + хвостовой
whitespace/терминатор). O(1) на создание (forward state-machine, lazy; SB8
forward-lookahead ограничен следующим blocker/Lower per-char, O(1) amortised).
SentencesIter (value priv {buf, pos, n, prev_imm, term, phase, peb, ppeb, sb8_lower})
реализует Next[str] + iter/count (remaining, позиционный)/
is_empty. Важно: sentence-правила по умолчанию НЕ ломают (SB998 = ×) —
противоположно grapheme/word (по умолчанию ломают). Данные:
std/unicode/sentence_data.nv (SB-категории range-таблица из
SentenceBreakProperty.txt: 14 категорий CR/LF/Extend/Sep/Format/Sp/Lower/Upper/
OLetter/Numeric/ATerm/SContinue/STerm/Close).
- SB6 ATerm × Numeric (
3.4— одно предложение); SB7 (Upper|Lower) ATerm × Upper (U.S.A); SB8 ATerm Close* Sp* × (¬…)* Lower (resp. leaders— строчная буква после.подавляет разрыв ⇒ одно предложение); SB8a (STerm|ATerm) Close* Sp* × (SContinue|STerm|ATerm); SB9-SB11 — терминатор + Close*/Sp* + опц. ParaSep ⇒ разрыв. - По дизайну (не упрощение): дефолтный UAX #29 без словаря аббревиатур —
Mr. Smithломается послеMr.(заглавнаяS⇒ SB8 не срабатывает, SB11 ломает), как в эталонномSentenceBreakTest.txt. Это поведение UAX #29, не баг.
Conformance
- UAX #15:
NormalizationTest.txt— фикстураnormalization_conformance.nv(полный 19965/19965 verified out-of-band; коммит — сэмпл). - UAX #29:
GraphemeBreakTest.txt— фикстураgrapheme_conformance.nv(полный 1093/1093, content-checked). - Case (152.4.4):
case_conformance.nv— breadth по всем mapped codepoints (полный 2981/2981 verified out-of-band; коммит — uniform-spread 1500). UCD-derived expected → проверяет рантайм-парсер+lookup+emission, НЕ выборку (self-referential). Выборку (no-locale: Turkic-excl, field-index, 3-cp, Final_Sigma+case-ignorable, title≠upper) пиннит independent hand-oracle вcase.nv/words.nv. - Word (152.4.5):
word_conformance.nv— UAX #29 изWordBreakTest.txt(полный 1826/1826 content-checked verified out-of-band; коммит — uniform-spread 1500). Independent UCD oracle (boundaries заданы в тест-файле, не выводятся из генератора). - Sentence (152.4.6):
sentence_conformance.nv— UAX #29 изSentenceBreakTest.txt(полный 512/512 content-checked; коммит = весь тест-файл, т.к. < лимита 1500). Independent UCD oracle (boundaries заданы в тест-файле). Все —nova-codegen unicode --emit-conformance.
См. Plan 152.4, Q-unicode-data
(open-questions.md). Phase B остаток: collation CLDR-tailoring + eq_ignore_case
([M-152-collation-tailoring]); DUCET-collation (152.5b, D254) IMPLEMENTED.
[M-152-case-fold] + [M-152-word-boundaries] + [M-152-sentence-boundaries] ЗАКРЫТЫ.
AMEND D250 §«линзы» — as_graphemes 3-я, as_words 4-я, as_sentences 5-я линза.
AMEND D253 V2 (Plan 91.18, 2026-06-19):
*Viewполностью переименованы в*Iter:GraphemesView→GraphemesIter,WordsView→WordsIter,SentencesView→SentencesIter. Везде единый суффикс Iter.WordsIter+SentencesIterстали ленивыми forward state-machine (O(1) create).as_words()/as_sentences()— O(1) конструкторы.str @to_nfc/nfd/nfkc/nfkd()добавлены как методы (тонкие делегаты); free-fnnormalize_*остаются internal helpers.str @fold_case()/@to_upper()/@to_lower()/@to_title()добавлены как методы (подimport std.unicode); bareto_upper/to_lower= Unicode (таблицы),to_ascii_upper/to_ascii_lower= ASCII-only (всегда доступны).CharIndicesIterдобавлен в chars.nv:s.as_chars().indices() -> CharIndicesIter,@next() -> Option[(int, char)]— byte-offset + char.
D254. Модель сравнения строк — byte-Ord дефолт + явный collation
Added: 2026-06-13 Status: 152.5a (core) IMPLEMENTED 2026-06-13; 152.5b DUCET-collation (UCA/UTS #10, Shifted + S2.1) IMPLEMENTED 2026-06-14; остаётся eq_ignore_case + CLDR-tailoring ([M-152-collation-tailoring])
Дефолтное сравнение str — byte-lexicographic (быстрое, детерминированное,
locale-НЕзависимое, как Rust/Go). Locale-aware collation — отдельный явный opt-in
слой; str никогда не делает collation молча.
152.5a — Ядро (Фаза A, IMPLEMENTED)
@compare(other) -> int(Compare/Equal/Hash) — byte-lexicographic (D178): memcmp-style на@as_bytes()+ length-tiebreak. ДефолтOrd; сортировка детерминирована, locale-независима.@eq_ignore_ascii_case(other) -> bool(str + char) — ASCII case-insensitive eq (только A-Z/a-z складываются, БЕЗ Unicode-таблиц; длины должны совпадать). Ruststr/char::eq_ignore_ascii_case. Не-ASCII (é/É) — байт-различны (не фолдятся).
152.5b — Unicode collation (IMPLEMENTED 2026-06-14) + locale (Phase B)
- DUCET collation (UCA/UTS #10) —
std/unicode/collate.nv, opt-in:collate_compare(a,b)->int,collate_sort_key(s)->Vec[u32],collate_eq,Collator.order/Collator.key/Collator.same(bodyless namespace — DUCET stateless, amend 91.18 2026-06-19: было instanceCollator.root().@order). Алгоритм: NFD → collation elements (longest-match contractions + S2.1 discontiguous + implicit-веса) → multi-level sort key (Shifted variable-weighting) → лексикографическое сравнение.collate_data.nvиз DUCETallkeys.txt(nova-codegen unicode). Conformance:CollationTest_SHIFTED.txt(independent oracle, 50000/50000 spread out-of-band; коммит 1500; полный — slow-lane Plan 156). НЕ prelude —strOrd остаётся byte-lex. - eq_ignore_case (тонкий враппер над
fold_case) + CLDR locale-tailoring — остаются Phase B →[M-152-collation-tailoring]. Аналог: Rustunicode-collationDUCET-mode / ICU root collator (без tailoring).
AMEND D254 V2 (Plan 91.18, 2026-06-19):
collate_sort_keyвозвращаетVec[u32](не[]intкак было в doc — код всегда был правильным, исправлена только документация).Collator.strengthудалён как dead field (писался только вroot(), никогда не читался).Collatorхранит_tag inttype-private placeholder для future tailoring.STRENGTH_QUATERNARYконстанта удалена.- split("") policy (отменяет Plan 91.15 Ф.4): пустой sep/паттерн = определённый
край (не паника) во всём search/split/replace surface.
"hi".split("")==["hi"],find("")==Some(0),rfind("")==Some(2),contains("")==true. Единое правило «пустой паттерн — определённый результат» заменяет «пустой паттерн → паника». - Collation intentionally uses free-fn + Collator (not str @compare/@equal) — D254
design:
strникогда не делает collation молча; asymmetry is by design.
D-R4 ✅ DONE (2026-06-13) — декомиссия str-operator C-lowering
==/!=/</<=/>/>=/+ для str теперь синтезируются из Nova-body методов
(emit_c.rs nova_str BinOp arm): ==/!= → Nova_str_method_eq, </<=/>/>=
→ Nova_str_method_compare(l,r) OP 0, + → Nova_str_method_concat. Хардкод C
nova_str_lt/le/gt/ge в operator-lowering снят; реестр str (runtime_registry.rs
str_method_to_rt) теперь содержит только@hash(SipHash crypto-seed, неустраним). Method-forms.eq(t)/s.lt(t)falls through к тем же Nova-body.
- Nova-body на RawMem-примитивах:
@eq= length-first +RawMem.compare(memcmp);@compare=RawMem.compare+ length-tiebreak;@concat=with_capacity+ 2×Vec.append(memcpy) + steal. Те же примитивы, что и в снятых C-функциях. - Бенч-паритет подтверждён: 5M сравнений + 200k concat — before (C-операторы, ae465fce) 23.5–28.2s vs after (Nova-body) 22.6–29.2s; дельта в пределах compile-шума (after-лучший быстрее before-лучшего) → регрессии нет.
- Остаток C (codegen-внутренние, НЕ операторы):
nova_str_eq— структурная eq полей вemit_field_eq(HashMap-ключи/record-==);nova_str_concat— cancel-marker ScopeOutcome + concat-аккумулятор. Это codegen-примитивы, не user-facing str-операторы; reroute создал бы method-emission reachability-связь в concurrency/collections codegen без пользовательской выгоды. - 0 регрессий (str/plan137/plan152_*/plan91_13/plan131/plan138 + reachability-проба
чистых операторов). Закрывает
[M-139.1-operator-lowered-methods].
См. Plan 152.5, Q-string-collation (open-questions.md).
D255. UTF-16 / code-point interop
Added: 2026-06-13 Status: IMPLEMENTED 2026-06-13 (Plan 152.6)
Для FFI/JSON/протоколов (Windows API, JS-interop, JSON \uXXXX) нужны конверсии в/из
UTF-16 и доступ к сырым int-codepoint’ам. Дополняет байтовый слой
(from_bytes_*/to_bytes/as_bytes) и codepoint-линзу (as_chars). Размещено в
std/encoding/utf16 (модуль encoding.utf16) — импортируется явно, НЕ prelude
(FFI/протокол-концерн). str-методы — тонкие делегаты в этот модуль (резолвятся через
dot-call при import std.encoding.utf16).
API
str @encode_utf16() -> []u16— UTF-16 code units. BMP-codepoint → одинu16; supplementary (cp > 0xFFFF) → surrogate-пара (дваu16). Аналог Ruststr::encode_utf16.str.from_utf16(units []u16) -> Result[str, Utf16Error]— checked decode. Валидирует surrogate-пары; lone high/low или усечённая пара →Err. На выходе — валидный UTF-8 (R-UTF8, D26 AMEND). Двухпроходно: валидация в[]int, затем построение черезStringBuilder(consume один раз на успешном пути).str @code_points() -> []int— сырые int-codepoint’ы (безchar-обёртки), для низкоуровневых протоколов/таблиц. Значения ==as_chars()как int.Utf16Error|LoneHighSurrogate(int)|LoneLowSurrogate(int)|TruncatedSurrogatePair(int)— хранит проблемный code unit.- surrogate-помощники (свободные fn, контракты Plan 140):
is_high_surrogate,is_low_surrogate,decode_surrogate_pair(hi, lo)(requires is_high_surrogate(hi) && is_low_surrogate(lo),ensures result ∈ [0x10000, 0x10FFFF]).
Roundtrip-инвариант: from_utf16(s.encode_utf16()) == Ok(s) на ASCII/BMP/supplementary.
См. Plan 152.6.
D258. Формат-спеки в интерполяции — Rust-style mini-language
Added: 2026-06-14 Status: B1 (format mini-language) IMPLEMENTED 2026-06-14 (Plan 152.7-B); B2 (Write-sink обобщение) — отложено в 152.7.1 (breaking). AMEND D229/D44/D183.
Расширяет интерполяцию ${expr:SPEC} (база — D229, Plan 91.14, поддерживала только
:? Debug) до полного Rust-style mini-language. Синтаксис ${...} не меняется.
Грамматика спека (после :)
[[fill]align][sign][#][0][width][.precision][type]
- align
<(left) /^(center) />(right); опц. fill-char перед align (${x:*^10}→ центрирование с заполнением*). Дефолт-выравнивание: числа вправо, прочее влево. - sign
+— всегда печатать знак для чисел (${n:+}). #alternate — radix-префикс для целых (0x/0o/0b). Префикс всегда строчный даже для:#X→0xFF(как Rust;upperвлияет только на цифры).0zero-pad — нули между знаком/префиксом и цифрами (sign-aware:${n:08x},${n:#08x}→0x0000ff).- width — мин. ширина поля (в Unicode-скаляр-ширине).
.precision— для f64 = число знаков после точки; для str = усечение.- type —
x/X(hex),b(binary),o(octal),?(Debug, из D229). Дефолт — Display.
Семантика выровнена с Rust format!. Прецедент: Rust mini-language, Python f-string.
Диагностика (compile-time)
E_FORMAT_SPEC_UNKNOWN— неизвестный type-char (с fix-it: список поддерживаемых).E_BAD_FORMAT_SPEC— структурно невалидный спек (напр..без цифр precision).E_FORMAT_SPEC_EMPTY/E_FORMAT_SPEC_TRAILING— из D229 (пустой спек / мусор). Невалидный спек — всегда compile-error, никогда не молчаливый pass.
Реализация
AST FormatSpec (compiler-codegen/src/ast/format_spec.rs, расширен от Plan 91.14) +
парсер (parser/mod.rs) + codegen (emit_c.rs) → runtime-хелперы
compiler-codegen/nova_rt/conv.h (nova_fmt_int_body/_radix_body/_pad/
_radix_prefix/_f64_body + sign/prefix). Целые — nova_int (64-bit), negative hex =
two’s-complement (${-1:x} → ffffffffffffffff, как Rust).
Отложено (B2 → Plan 152.7.1, [M-152.7-write-sink])
Обобщение @display(mut sb StringBuilder) → @display(mut w Write) (форматтер в любой
sink: StringBuilder/WriteBuffer/stdout, direct-to-sink print без промежуточной str).
Breaking (меняет сигнатуры всех @display/@debug) → отдельный sub-plan.
См. Plan 152.7, D229.
D314. Единое ядро cleanup — defer как примитив (defer-kernel)
Plan 173 Ф.2 (defer-kernel unification). Статус: 🔨 SPEC-FIRST (2026-07-04, написан ДО codegen; реализация — под-атомы Ф.2, см. 173-f2-derisk-map.md). Закрывает несведённость трёх cleanup-поверхностей (
defer/Consumable.on_exit+consume{}/with Fail[E]) из двух непримирённых эпох (Plan 173 §1). Модель: MODEL 1 «defer— ядро» (sign-off 2026-06-20). Нормативы: §3a (completes-by-default, D192-ретракт) + §3b (no-restart-default). Амендит: D90, D188, D189, D194, D185 (эффектCleanup→ResourceTrace). Хаб: docs/idiom/error-and-cleanup-model.md (rewrite Ф.2.E).
Что
Nova имел ТРИ несведённые cleanup-поверхности. D314 сводит их к ОДНОМУ примитиву — defer — с
опциональным outcome-биндингом; протокол Cleanup[E] (ex-Consumable) / consume / @cleanup
(ex-@on_exit) низводятся до сахара над outcome-defer; весь unwind-ре-диспатч идёт через ОДНУ
runtime-точку (nova_scope_exit).
Правило
1. defer body (форма без изменений) — безусловный cleanup на ЛЮБОМ exit из enclosing scope
(normal / return / throw / panic / interrupt), кроме exit(N). LIFO. Добегание — §3a
(completes-by-default: щит элидится для sync-тел, cancel замаскирован для suspend-тел).
2. defer(o ScopeOutcome) { … } (НОВОЕ) — outcome-несущий block-defer; тело получает исход
ScopeOutcome (core.nv). Отображение exit-path → outcome:
| Exit-path | ScopeOutcome |
|---|---|
normal end-of-scope / return v | Success |
throw e / typed-throw | Failure(reason) |
cancel (structural) | Failure("cancel: " + reason) (D90 §7 marker) |
panic(msg) | Panic(msg) |
interrupt v | Failure(reason) (спека core.nv:130; выравнивает impl — consume-монолит СЕЙЧАС обходил on_exit на interrupt, defer-frame его оборачивает) |
Субсумирует три ретрактнутые формы (D189): errdefer{…} ≡ defer(o){ match o { Failure(_)|Panic(_) => …, Success => () } };
okdefer{…} ≡ defer(o){ match o { Success => …, _ => () } }; defer |r| {…} ≡ эта форма.
Zig-парность: errdefer |e| {…} ≡ defer(o){ match o { Failure(e) => use(e), _ => () } } — payload
e типизирован; ветка Panic различима И ВЫПОЛНЯЕТСЯ (Zig defer при panic не бежит). AST: опц.
поле на Stmt::Defer (не новый вариант — low blast). Parser: bounded lookahead defer (IDENT ScopeOutcome) с fallback на parse_expr для defer (expr); double-binding / non-ScopeOutcome тип → диаг.
3. consume X = e { body } → САХАР над outcome-defer:
{ ro X = e; defer(o) { X.@cleanup(o) }; body }. Протокол Cleanup[E] (ex-Consumable) остаётся как
protocol-сахар (тип инкапсулирует cleanup через @cleanup(o ScopeOutcome), ex-@on_exit). Разница
consume vs bare defer(o) — **must-consume (D133) + exactly-once (D188 R2) + partial-init (D188 R1)
- ResourceTrace-события**, НЕ «добежит/не добежит» (§3a). Consume-специфичная policy (cancel-shield поверх body+cleanup, 3-level timeout, ResourceTrace enter/exit) re-home’ится на consume-flavored defer-entry, не теряется при десугаре.
4. Централизованный ре-диспатч (nova_scope_exit) — ОДИН runtime-helper
nova_scope_exit(NovaFailFrame* primary, NovaScopeExitPolicy policy), policy ∈ {CATCH, TRANSPARENT};
читает primary->error_kind. РЕАЛИЗОВАН (Ф.2.C, effects.h static inline рядом с триадой). Таблица
приведена к факту (прежняя PANIC→nv_panic / CANCEL→nova_throw_cancel_reason устарела — оба сайта
фактически несли suppressed-chain через nova_rethrow_with_suppressed, а голый nv_panic его теряет;
§5-согласование выбрало канон, СОХРАНЯЮЩИЙ chain + reason):
error_kind | Транспорт |
|---|---|
PANIC | nova_rethrow_with_suppressed(primary) — kind=PANIC, suppressed-chain СОХРАНЁН (матчит post-B3-merge defer-kernel; голый nv_panic терял бы chain) |
CANCEL | nova_rethrow_with_suppressed(primary) — error_reason_ptr И chain СОХРАНЕНЫ (rethrow копирует frame->error_reason_ptr; эмпирически подтверждено §5, унифицирует с PANIC) |
USER / USER_TYPED | CATCH → return-to-caller (handler отработал; вызывающий ставит result=default; with-Fail); TRANSPARENT → nova_rethrow_with_suppressed (defer/consume) |
Success | no-op (sentinel; недостижим — frame инспектируется только после throw, у NovaThrowKind нет success-значения) |
Класс бага «кадр забыл kind» (дефект #1) исчезает по построению — единственная точка политики.
IN (роутятся через helper): with-Fail terminal (CATCH; site-пролог pop(fail)+restore(handlers)+ pop(interrupt) ПЕРЕД helper, флаг caught заменил per-kind dispatch), defer-kernel FAIL run-site
(TRANSPARENT), consume-flavored FAIL run-site (TRANSPARENT; consume физически слит в defer-kernel —
B3-merge, отдельного consume-терминала НЕТ). OUT (report/log-семьи, НЕ throw-транспорт; читают
error_kind как ПАРАМЕТР, не как policy-диспатч): fiber-report spawn-worker (nova_fiber_report_*_kinded
— форвардит kind в parent-scope queue; ре-диспатч на main flow через supervised_run first_error_kind),
detach LogAndDrop (D50 — log-to-stderr, не throw), test-frame. Compose ОСТАЁТСЯ в codegen (helper —
только single-frame transport): nv_compose_suppressed/suppressed-chain build (D158), panic-dominance
emit_fail_cleanup_compose (§4a), defer normal-exit cleanup-fail hand-rolled longjmp (переносит
compose-state comp_msg/comp_kind/... — НЕ single frame, поэтому helper к нему НЕ применим), а также
outcome-материализация ScopeOutcome из frame (assign_scope_outcome_from_frame — строит user-visible
o, не transport). grep-guard (пройден): error_kind == только внутри nova_scope_exit +
санкционированных compose/outcome-сайтов.
4a. Единое правило composition — panic-dominance (аменд Ф.2, 2026-07-04). Поскольку consume — САХАР
над defer(o) (§3), их compose ОБЯЗАН совпадать (иначе десугар лжёт). Единая таблица (тело упало И
cleanup упал), выводимая из D13 (panic = abort-class баг, строго СРОЧНЕЕ recoverable
throw) + D158/D161 (throw-composition) + D196 R3:
| body \ cleanup | cleanup Success | cleanup throw (USER/CANCEL) | cleanup PANIC |
|---|---|---|---|
| Success | — | cleanup throw = primary | cleanup panic propagates |
| throw | body throw = primary | body=primary, cleanup=suppressed ([D158]) | cleanup PANIC доминирует (nv_panic, body-throw подавлен) |
| panic ([D196] R3) | body panic доминирует | body panic доминирует, cleanup подавлен | body panic доминирует |
Ключевой пункт (устраняет расхождение impl): body-throw + cleanup-PANIC → panic доминирует
(D13: баг важнее recoverable-ошибки). Монолит consume это УЖЕ делал; defer-kernel же ошибочно клал
cleanup-panic в suppressed-chain как обычный throw (chain-compose не различал PANIC-kind) — это
непреднамеренный гэп defer, НЕ намеренное поведение. Унификация: defer-kernel тоже даёт
cleanup-PANIC доминировать. Это делает десугар consume→defer(o) behavior-preserving (обе
поверхности — одно правило) и разблокирует физическое слияние (B3-merge). Blast-radius: только
двойной-fault body-throw + cleanup-panic (редкий; cleanup-panic сам = баг) — мигрируется в том же
изменении (§7).
5. Hot-path (D194 амендмент — ПРЕМИСА ИСПРАВЛЕНА, §3.5): прежняя формулировка «Consumable[Never]
СЕЙЧАС элидит shield/timeout/outcome (disasm-verified T2.9)» не соответствует коду — ConsumeScope
эмитит полный frame-bearing путь безусловно (§perf-элизия НЕ реализована, Plan 110:695). Поэтому Ф.2
acceptance = PARITY (lowered consume/defer(o) даёт disasm ≡ текущему; НЕ регрессировать). Генуинная
§perf-элизия (ключ «sync-тело + cleanup Fail[Never] → прямой вызов без кадра») — ОТДЕЛЬНЫЙ followup
[M-173-d194-perf-elision], вне периметра unification. D194-спека приводится к факту (без «disasm-verified»
без живого артефакта). Disasm-guard targets (byte-identical): Mutex/Semaphore/atomic guards.
Renames (нормативны; порядок ОБЯЗАТЕЛЕН — коллизия имён)
Протокол Cleanup[E] и эффект Cleanup НЕ сосуществуют в prelude (один type-name namespace) →
duplicate-def ломает ВЕСЬ prelude. Порядок: (1) эффект Cleanup→ResourceTrace (D185; ops
on_resource_enter(label) / on_resource_exit(label, outcome), timeout из enter УБРАН) — ПЕРВЫМ,
освобождает имя. (2) протокол Consumable[E]→Cleanup[E] + (3) метод @on_exit→@cleanup(o ScopeOutcome) — вместе (после 1). ⚠ CleanupTimeoutError (errors.nv) — ДРУГОЕ имя (D192-scope, Ф.5),
НЕ трогать sed’ом.
Миграция (errdefer/okdefer → defer(o))
| Старое (ретракт D189) | Новое (D314) |
|---|---|
errdefer { rollback() } | defer(o){ match o { Failure(_) | Panic(_) => rollback(), Success => () } } |
okdefer { commit() } | defer(o){ match o { Success => commit(), _ => () } } |
errdefer |e| { log(e) } | defer(o){ match o { Failure(e) => log(e), _ => () } } |
defer |r| { … } | defer(o){ … } |
НЕ-цели
- call-site try-маркер (видимость эффекта — на уровне сигнатуры); HOF-эффект-полиморфизм (Swift
rethrows) — вне периметра;ScopeOutcome.Failure(any)типизированный payload — ✅ реализован Ф.4 #5 (2026-07-06): cleanup/defer(o)восстанавливает причину черезif err is T(D54/174.3), cancel — какFailure(CancelError{reason}); идентичность ошибки на interrupt-path переживает разрушенный stack-fail-frame через thread-local snapshot_nova_last_error. Генуинная §perf-элизия — followup[M-173-d194-perf-elision]. ИдиомаFail[E]→Result:with Fail[E] = |e| interrupt Err(e) { Ok(body) }(std-сахар D314 не вводит).
D410. Ретракция as_*/to_*-близнецов: голые имена-виды, копия на месте вызова (2026-07-06)
Status: accepted (решение владельца, 2026-07-06). Реализация — миграционная волна
[M-d410-as-to-migration](~380 мест) после волны []T/cap/http-props.
Что
Словарь префикса as_ упраздняется. Оси именования конверсий/доступов:
| Форма | Смысл | Примеры |
|---|---|---|
| голое существительное | O(1) вид/линза: заём, zero-copy, лениво | bytes(), chars(), graphemes(), words(), sentences(), slice(), ptr() |
.clone() / .collect() на месте вызова | явная копия/материализация вида | s.bytes().clone(), s.chars().collect() |
to_* | трансформация в новое владеющее значение — операция, у которой вида не существует в принципе | to_upper(), to_lower(), to_str(), to_ascii_upper() |
into_* | потребляющий финализатор (ось владения, без изменений) | into_str(), into_raw() |
Удаляются близнецы-копии (их смысл — вид + clone): to_bytes (≡ bytes().clone()),
to_chars (≡ chars().collect()). Переименовываются виды: as_bytes→bytes,
as_chars→chars, as_graphemes→graphemes, as_words→words, as_sentences→sentences, as_slice→slice
(вкл. recv-mut перегрузку mut @slice(), D262-амендмент), as_ptr→ptr (ложится в канон
методов-свойств D117: ptr — читатель поля). to_upper/to_str-семейство и into_* — без
изменений.
Почему
- Правило «два имени — выбери сам» не самообеспечивается: в std
to_bytes— 124 вызова против 67 уas_bytes— тяжёлая копия доминирует там, где хватило бы вида. Удаление близнецов делает неправильное невыразимым (дух нулевой толерантности, §4а compiler-conventions). as_charsбыл нашей девиацией от Rust-первоисточника: в Rust C-CONV ленивые итераторы — голые существительные (chars(),lines(),bytes()),as_только у видов-реинтерпретаций.[]T-вид Nova и random-access, и итерируем напрямую (D238/D239) — одинbytes()покрывает раздельные в Rustas_bytes(вид),bytes(итератор) иto_bytes(через.clone()).- Плата за копию видна в точке вызова (
.clone()читается глазами), а не спрятана в имени.
Прецеденты: Swift (голые виды .utf8, копия конструктором Array(s.utf8)) — целевая модель;
Rust C-CONV — источник старого правила (итераторы там уже без as_); C#/Kotlin
(AsSpan/ToArray, asSequence/toList) — таксономия, от которой уходим благодаря
[]T-модели. Правило §1 nv-coding-style переписано синхронно.
D411. Record-деструктуризация в биндингах ro/mut (2026-07-07)
Status: ✅ IMPLEMENTED 2026-07-07 (решение владельца, предложено на живом коде — паре
ro line = @line; ro col = @colв json-лексере). Парсер переиспользует match’евскую грамматику record-паттернов без изменений (parse_patternуже вызывался изparse_ro_mut_binding); irrefutable-проверка (Plan 53) и codegen-биндинг полей (emit_record_destructure) тоже уже существовали в конвейере — реализация D411 добавила НОВОЕ:[E_REFUTABLE_BINDING]код-тег на существующую refutability-диагностику,[E_RECORD_PATTERN_NEEDS_REST]— новая проверка «частичный список полей без..» (только дляro/mut-биндингов, не match/for), conformance-тесты, json-лексер как потребитель.[M-d411-record-binding-destructuring]ЗАКРЫТ.
Что
Record-паттерн (уже существующий в match-ветках) разрешается в irrefutable-позиции
биндинга — симметрично уже принятой кортежной деструктуризации (ro (a, b) = ...):
ro {line, col, ..} = @ \ снапшот части полей приёмника (`..` обязателен)
ro {line, col} = tp \ перечислены ВСЕ поля — `..` не нужен
mut {pos, ..} = lex \ mut-вариант: каждое введённое имя — mut-биндинг
ro {line: l0, col: c0, ..} = @ \ переименование — та же match-грамматика
Правило
- Грамматика паттерна — ровно match’евская (один паттерн-язык, ноль особых случаев):
shorthand-поля, переименование
поле: имя,..ОБЯЗАТЕЛЕН при частичном списке полей (честный сигнал «беру не всё»), без..— только полный список. - Позиция: после
ro/mutтокен{однозначен; конфликтов с блоком (не бывает слева от=) и D55-коэрцией литералов (та — справа) нет. - Irrefutability: паттерн обязан быть неопровержимым — записи и named-tuple да; варианты
сумм/литералы — НЕТ (
[E_REFUTABLE_BINDING], для нихmatch/if let). - Семантика — pattern-native, БЕЗ отдельного AST-десугара:
ro {a, b, ..} = eконцептуально ≡ro _tmp = e; ro a = _tmp.a; ro b = _tmp.b(eвычисляется один раз; копия для value-полей, разделяемая ссылка для кучевых), НО реализовано не переписыванием AST в серию синтетических биндингов, а тем, чтоPattern::Recordостаётся первоклассным узломLetDecl.patternчерез весь конвейер: чекер понимает его напрямую (irrefutability/priv/..-правило — все три ходят по pattern, не по десугар-форме), кодоген (emit_record_destructure) сам эмититT tmp = <e>;РОВНО один раз, затем биндит поля черезpattern_bind_typed. Архитектурное решение (не с нуля для D411 — унаследовано от Plan 53’slet-record-destructure, который эту инфраструктуру уже построил до retractionlet→ro/mut): нет нужды выдумывать hygienic tmp-имена на уровне Nova-исходника, когда C-кодоген уже даёт свежийtmpна своём уровне. - Вложенные паттерны — как в match (рекурсия по вложенным
Pattern::Record-полям). Ограничение реализации: record-паттерн, вложенный ВНУТРИ tuple-элемента биндинга (ro (a, {x, ..}) = pair), не проходит..-правило (нет type-resolution на уровне tuple-элемента в текущем чекер-пути) — редкий кейс, вне примеров D411 (json-лексер,TokenWithPos-снапшоты), при необходимости — отдельный follow-up.
Почему
Замыкает матрицу: паттерны записей уже есть (match), биндинги уже принимают паттерны (кортежи) — отсутствовала одна клетка. Частый мотив «снапшот полей» (лексер, диспатчеры) схлопывается в строку без новой концепции в языке.
D412. Hex-блоб литерал x"…" → []u8 и интринсик embed("path") (2026-07-07)
Статус: ПРИНЯТО (владелец 2026-07-07, «добро п.1, 2»). Реализация в очереди (маркер [M-hex-blob-embed-d412] в docs/plans/backlog-followups.md).
Решение
-
Hex-строковый литерал (форма D-стиля):
x"486900FF_486900FF"— компайл-тайм последовательность байт, тип[]u8.- Внутри кавычек допустимы hex-цифры и разделители:
_, пробел, перенос строки — разделители игнорируются (группировка на глаз читателя). - Нечётное число цифр = ошибка компиляции (E_HEX_BLOB_ODD). Не-hex символ =
ошибка (E_HEX_BLOB_CHAR). Пустой
x""легален → пустой[]u8. - Это НЕ числовой литерал: ведущие нули значимы (
x"00FF"= 2 байта), порядок байт = порядок записи (эндианность машины не участвует).
- Внутри кавычек допустимы hex-цифры и разделители:
-
embed("relative/path")— компайл-тайм интринсик: содержимое файла как[]u8.- Аргумент — ТОЛЬКО строковый литерал (путь известен на компиляции).
- Путь разрешается относительно файла-исходника (.nv), где стоит вызов —
модель Rust
include_bytes!; выход из дерева проекта наружу (..выше корня) — ошибка. Файл не найден = ошибка компиляции (E_EMBED_NOT_FOUND). - Встроенный файл — зависимость сборки: участвует в fingerprint пересборки.
- Интринсик класса C по природе (файловый ввод на этапе компиляции — как
include_bytes!/#embed/@embedFile), конвенции §3 не противоречит.
Материализация: указатель в статические данные
Оба источника эмитятся в C как static const uint8_t nova_blob_<n>[] = {…} в
секции статических данных (.rodata) — прецедент: str-литералы уже интернируются в
static const nova_str (одна копия на CU, см. interned_str_literals в emit_c).
ro-биндинг (ro img []u8 = x"…"/= embed("…")): нулевая копия —[]u8-вид сdata→ статический блоб,len == cap == N(модель D262-видов). Мутирующие методы на ro-биндинге отсекает чекер — записи в .rodata невыразимы.mut-биндинг / consume в мутацию: материализуется копией в GC-кучу в точке биндинга (память = обычный Vec-буфер, дальше живёт как любой[]u8).- GC: Boehm-консервативный сканер указатель вне своей кучи игнорирует — статический блоб не собирается и не двигается; interior-указатели легальны.
- Рост (
pushпосле копии) идёт обычным Vec-grow (alloc-new + memcpy) — статическая память никогда не realloc’ится.
Почему не голый 0x486900FF…
Числовой литерал стирает ведущие нулевые байты (0x00FF = 255), не определяет
нечётное число цифр, тянет эндианность-двусмысленность (число vs блоб — противо-
положные порядки байт в памяти) и делает вывод типа ro x = 0x48 контекстно-
зависимым. Кавычная форма снимает все четыре проблемы лексически.
Прецеденты: D (x"48 69" в языке), Rust (b"…" + include_bytes!), C23 (#embed),
Go (//go:embed), Zig (@embedFile).