Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vox Documentation

Vox is a high-performance, data-flow compatible programming language, designed with modern syntax, optimized for vector processing and parallel execution.

Why Vox?

Many programs come with embedded expressions and node graphs to provide users with simple program-level control. Examples include Excel, Houdini, Blender Geometry Nodes, and Unreal Engine’s Blueprints. Traditionally for users to do the scripting each program has its own scripting language, and in recent years they seem to be converging on standard scripting languages like Python and Lua.

Python and Lua are great languages, but they both have issues. While Python is concise and modern, its flexibility comes at the cost of performance. Lua is fast, but its syntax is, in my humble opinion, not as user-friendly and far from modern.

From another perspective, both languages are designed for general-purpose programming, and they fail to capture the unique needs of node-based programming (NBP). In NBP, live previewing is often required, and caching internal states is common. Traditionally this is achieved either by interpreting the code and user-managing state info, or by re-compiling every time the code changes. Both are slow and inefficient. Additionally we can find that self-recursion, unbounded loops and side effects common in general-purpose programming are often not present. This prompts us to redesign the language, capturing the unique needs of NBP, and optimizing for it.

Thus Vox is born, a language designed for NBP scripting and execution. It features a modern syntax, optimized performance, and superior support node-based programming. With Vox, users can enjoy a seamless scripting experience while benefiting from the efficiency and capabilities tailored for node-based workflows.

Getting Started

Vox is currently built from source.

git clone https://github.com/RayZh-hs/Vox
cd Vox

Start the REPL with an embedded runtime:

cargo run -p vox-repl

Or start a shared runtime and attach the REPL to it:

cargo run -p vox-runtime -- --listen 127.0.0.1:4545
cargo run -p vox-repl -- --connect 127.0.0.1:4545

To attach to a specific remote session, append @name or @id:

cargo run -p vox-repl -- --connect 127.0.0.1:4545@shared --new

Language

This section is the user guide for writing Vox code.

Start with the overview for everyday syntax and file structure. Use the specification when you need exact grammar or edge-case rules.

Overview

Vox is a value-oriented language for reusable packages and executable scripts. It is pure by default, keeps side effects explicit, and aims to stay readable in small files.

This page is a quick guide. Use the specification for exact grammar and full semantic rules.

What A File Can Be

Reusable Vox files start with a package header:

  • package demo.math; for reusable code

Executable scripts may either start with a header or omit it:

  • script demo.main; for a named executable entrypoint
  • evil script demo.main; for a named executable entrypoint that may perform side effects
  • no header for an anonymous executable script

Packages may be imported by other Vox files. Scripts may declare param inputs and may end with one trailing expression that becomes the script result. Declarations inside scripts are local to that script. Anonymous scripts cannot be imported or compiled as libraries; they can only be executed directly.

Basic Declarations

package demo.math;

public import math;

val defaultScale = 2.0;

public fun clamp01(x: Float): Float {
    if (x < 0.0) {
        0.0
    } else if (x > 1.0) {
        1.0
    } else {
        x
    }
}

fun scale(x: Float, factor: Float = defaultScale): Float = x * factor;

Rules to remember:

  • val creates an immutable binding.
  • var allows local reassignment inside a block or script.
  • fun declares a function.
  • public exports a package declaration or re-exports an import.
  • declarations are private by default.
  • function parameters and return types use name: Type.
  • default argument values use =.
  • packages are order-independent declaration graphs with top-level val and fun declarations;
  • scripts execute in source order, except that script function headers are visible throughout the script.

The exact modules, types, and host functions available to import depend on the host application embedding Vox.

Expressions And Control Flow

Blocks return their last expression, so small functions often read naturally:

fun sum(values: List[Float]): Float {
    var total = 0.0;

    for (value in values) {
        total += value;
    }

    total
}

Common expression forms:

  • if is an expression.
  • return is available when an early exit is clearer.
  • lambdas use x -> x * 2 or (x: Float) -> x * 2.
  • tuples use (a, b).
  • lists use [1, 2, 3].
  • records use { name = "vox", version = 1 }.
  • value.with { field = next } copies an immutable value with selected changes; write field: Type = next when an explicit replacement type hint is useful.
  • value.fun(args) calls a function as a method — sugar for fun(value, args).
val i: Int = 1;

fun add(x: Int, y: Int): Int = x + y;

val result: Int = i.add(2);  // sugar for add(i, 2)

For external libraries, struct methods from trait implementations are also available via this syntax.

Method resolution order: fields, then methods, then qualified names. Defining more than one applicable method with the same name for a receiver type is a compile-time error. This includes conflicts between built-in methods, visible first-parameter functions, and trait methods implemented by the receiver.

Nullability

Nullable types use ?:

fun findUser(id: Int): { name: String }? {
    if (id == 1) {
        { name = "vox" }
    } else {
        null
    }
}

val name = findUser(2)?.name ?: "unknown";

Useful operators:

  • ?. accesses a nullable receiver safely.
  • ?: provides a fallback when the left side is null.
  • !! unwraps a nullable value and fails at runtime if it is null.

Effects And econ

Pure code is the default. Mark a function evil when it performs observable side effects such as I/O.

evil fun readText(path: String): String {
    host.readText(path)
}

fun cachedText(path: String): Econ[String] {
    econ[String] {
        readText(path)
    }
}

econ[T] { ... } is a built-in intrinsic that creates a pure handle to a cached snapshot of an effectful computation. Pure code can pass the handle around without re-running the effect.

Use snapshot.update() to refresh an Econ[T]. The call re-runs the original econ block, stores the new snapshot, and returns the refreshed T value.

Scripts

Scripts use the same declaration syntax as packages, plus param inputs and an optional trailing result expression. The script ...; header is optional for a pure script that is meant to be executed directly.

script demo.main;

param value: Float;
param factor: Float = 2.0;

fun scale(x: Float): Float = x * factor;

scale(value)

The same script can be written anonymously:

param value: Float;
param factor: Float = 2.0;

fun scale(x: Float): Float = x * factor;

scale(value)

Script values and statements are processed in source order:

script demo.counter;

var b = 1;
val a = b;
b = 2;

a

This script returns 1, because a receives the value of b at the point where a is declared. It is not a live alias to b.

Script functions are visible throughout the script:

script demo.functions;

val total = even(4) + odd(3);

fun even(value: Int): Int = value;
fun odd(value: Int): Int = value;

total

Use scripts for entrypoints and one-off execution. Use packages for code you want to import elsewhere.

Documentation Comments

Vox uses /// for documentation comments, similar to Rust. Doc comments annotate declarations and are shown in editor hover:

/// Computes the greatest common divisor of two integers.
/// Uses the Euclidean algorithm.
fun gcd(a: Int, b: Int): Int {
    if (b == 0) {
        a
    } else {
        gcd(b, a % b)
    }
}

/// The default scaling factor.
val defaultScale: Float = 2.0;   /// Applied to all coordinate-transforms.

Doc comments come in two forms:

  • Head docstrings: /// lines that appear directly before a declaration (val, var, fun, import, param). These describe the declaration they precede.
  • Body docstrings: /// inside a function body provide additional documentation for the function. A /// on the same line as a value declaration (after the ;) is a body docstring for that value.

A package or named script header may also be preceded by /// lines to document the module:

/// Geometry utilities for 2D and 3D coordinate transforms.
package geo.transform;

Important: Every /// comment must annotate either a val/var, fun, import, param, or a package/named script header. The language server will raise a warning if a doc comment is not attached to any declaration.

Vox Language Specification

Use this section when you need exact Vox syntax or precise semantic rules.

Chapters:

  1. Source Model
  2. Lexical Structure
  3. Types and Declarations
  4. Expressions
  5. Statements and Control Flow
  6. Effects and Execution

This specification describes the current Vox surface language.

Notation

Grammar examples use a lightweight EBNF style:

  • quoted text means a literal token;
  • A? means optional;
  • A* means zero or more;
  • A+ means one or more;
  • parentheses group sub-productions.

Unless a chapter says otherwise, whitespace and comments may appear between tokens.

Source Model

This chapter defines the file-level structure of Vox source code.

0. Language Tiers

Every compilation request selects a language tier. Tiers are cumulative:

  • tier 0 (inline) permits visible calls, operators, values, structs, traits, and inline conditionals;
  • tier 1 (eval) adds blocks, val, loops, when, and panic;
  • tier 2 (script) adds functions, var, and imports;
  • tier 3 (dev) adds package authoring, native trait definitions, and impl;
  • tier 4 (debug) adds private-surface and debugger-only access.

The compiler rejects syntax above the selected tier. Optimization metadata is attached to MIR bodies as tier_inline, tier_eval, tier_script, tier_dev, or tier_debug; a backend must not apply an attribute above the body’s tier.

1. Files

A Vox source file is exactly one of:

  • a package file;
  • a named script file;
  • an anonymous script file.

Package files and named script files start with a file header. Anonymous script files omit the header and begin directly with script top-level items or a script result expression.

2. Module Paths

A module path is a dot-separated sequence of identifiers.

ModulePath
  ::= Identifier ("." Identifier)*

Examples:

  • voxini.filters
  • std.file
  • demo.preview

3. Package Files

A package file declares reusable code.

PackageUnit
  ::= PackageHeader ";" TopLevelItem*

PackageHeader
  ::= "package" ModulePath

Package rules:

  • a package file must not contain a top-level trailing expression;
  • a package file may contain top-level val and fun declarations;
  • a package file must not contain top-level var declarations or assignment statements;
  • package declarations are order-independent;
  • package top-level declarations are not redefinable;
  • package values collide when they have the same name;
  • package functions collide when their callable signatures overlap. The current implementation has no overload set representation, so two package functions with the same name are a collision;
  • a package exports its public declarations and public imports;
  • a package may contain evil fun declarations.
  • native struct, trait, and impl declarations require tier 3.

4. Script Files

A script file declares an executable entrypoint. A named script carries an explicit module path. An anonymous script has no source-level module path.

ScriptUnit
  ::= NamedScriptUnit
   |  AnonymousScriptUnit

NamedScriptUnit
  ::= ScriptHeader ";" ScriptTopLevelItem* ScriptResult?

AnonymousScriptUnit
  ::= ScriptTopLevelItem* ScriptResult?

ScriptHeader
  ::= "script" ModulePath
   |  "evil" "script" ModulePath

ScriptResult
  ::= Expr

Script rules:

  • a script may declare param inputs;
  • a script may end with one top-level trailing expression;
  • the trailing expression, when present, is the script result;
  • declarations inside a script are script-local and are not importable;
  • anonymous scripts cannot be imported or compiled as libraries; they can only be executed directly;
  • anonymous scripts are pure scripts. Use evil script ModulePath; when the script entrypoint itself must be marked effectful;
  • script values and statements are processed in source order;
  • script value initializers and statements may reference only values already introduced earlier in the script;
  • script function headers are visible throughout the whole script, so functions may be mutually recursive without forward declarations;
  • script top-level values may be redefined. Later value definitions shadow earlier value definitions from that point onward;
  • script functions may be redefined. Because script function headers are visible throughout the script, a later colliding function declaration replaces the earlier active function for that script.

5. Top-Level Items

After the header, a package compilation unit may contain the following top-level items:

TopLevelItem
  ::= ImportDecl
   |  ValueDecl
   |  FunctionDecl

After the header, a script compilation unit may contain the following top-level items:

ScriptTopLevelItem
  ::= ImportDecl
   |  ParamDecl
   |  ValueDecl
   |  FunctionDecl
   |  ScriptStatement

ScriptStatement
  ::= AssignmentStatement
   |  CompoundAssignmentStatement
   |  ForStatement
   |  PanicStatement
   |  ExprStatement

6. Visibility

Vox has two visibility modifiers:

  • public
  • private

They are mutually exclusive.

If a declaration has no visibility modifier, it is private by default.

public and private may prefix any top-level declaration form that accepts visibility.

In packages:

  • public exports the declaration from the package;
  • private keeps it internal to the file.

In scripts:

  • declarations remain script-local regardless of visibility spelling;
  • public has no import/export effect.

7. Imports

An import declaration makes symbols from another package available in the current module. There are three import forms:

ImportDecl
  ::= "import" ModulePath ";"
   |  "import" ModulePath "as" Identifier ";"
   |  "import" ModulePath "." "(" ImportItem ("," ImportItem)* ","? ")" ";"

ImportItem
  ::= Identifier ImportTree?
   |  Identifier "as" Identifier ImportTree?

ImportTree
  ::= "." "(" ImportItem ("," ImportItem)* ","? ")"

7.1. Wildcard Import

A bare import binds the package under the final segment of its module path and makes all public symbols from the target package available. For example, import foo is referenced as foo.bar, while import foo.bar is referenced as bar. If a symbol name is provided by exactly one imported package, it may also be used unqualified. If multiple imports provide the same name, that symbol must be used with a qualified path.

import foo;         // foo provides bar
foo.bar();

import foo.bar;     // foo.bar provides baz and qux; it is bound as `bar`
bar.baz();
baz();              // ok: only foo.bar provides baz
foo.bar.qux();      // always works

7.2. Module Alias

The as keyword creates a local alias for the module path.

import foo.bar as other;
other.baz();        // equivalent to foo.bar.baz()

Module aliases may be combined with selective imports.

import foo.bar as other.(baz);   // alias + selective
other.baz();                     // ok

7.3. Selective Import

A parenthesised list after a . imports only the named symbols, with optional per-item aliasing. Import items may contain nested lists, which continue through the corresponding package path.

import foo.bar.(baz, goo as go);
baz();              // shorthand: equivalent to foo.bar.baz
go();               // aliased: equivalent to foo.bar.goo
foo.bar.goo();      // original name still works

import foo.(bar, nested.(baz, qux as renamed));
bar();              // imported from foo
baz();              // imported from foo.nested
renamed();          // imported from foo.nested.qux

7.4. Public Import Aliasing

When two import paths refer to the same underlying function implementation (e.g. a package re-exports another’s symbol under the same name), they are not considered a naming conflict for unqualified name resolution.

Lexical Structure

This chapter defines comments, identifiers, operators, and literals.

1. Whitespace

Whitespace separates tokens where needed.

Whitespace includes:

  • spaces;
  • horizontal tabs;
  • line feeds;
  • carriage returns.

Whitespace is otherwise insignificant.

2. Comments

Vox supports three comment forms:

LineComment
  ::= "//" <all characters up to line end>

DocComment
  ::= "///" <all characters up to line end>

BlockComment
  ::= "/*" <comment text> "*/"

Rules:

  • // introduces an ordinary line comment;
  • /// introduces a documentation comment;
  • /* ... */ introduces a block comment;
  • a documentation comment documents the declaration that immediately follows it;
  • comments may appear wherever whitespace may appear.

3. Identifiers

Vox identifiers are ASCII-only.

Identifier
  ::= IdentifierStart IdentifierContinue*

IdentifierStart
  ::= "_" | [a-zA-Z]

IdentifierContinue
  ::= "_" | [a-zA-Z0-9]

Examples of valid identifiers:

  • x
  • _tmp
  • Point2D

Examples of invalid identifiers:

  • 2d
  • blur-radius
  • with space

4. Keywords

The following words are reserved keywords:

  • as
  • break
  • continue
  • dyn
  • econ
  • else
  • evil
  • false
  • for
  • fun
  • if
  • import
  • in
  • is
  • null
  • package
  • panic
  • param
  • private
  • public
  • return
  • script
  • true
  • val
  • var
  • when
  • with

5. Operators and Punctuation

The language uses the following operators and punctuation:

( ) [ ] { }
, . : ; ? # -> => 
+ - * / % ! 
 = += -= *= /= %=
== != < <= > >=
&& || 
?. ?: !!
.. ..=

The => token is reserved. Its language meaning will be implemented later.

6. Literals

Literal
  ::= IntegerLiteral
   |  FloatLiteral
   |  StringLiteral
   |  InterpolatedStringLiteral
   |  BooleanLiteral
   |  NullLiteral
   |  ListLiteral
   |  TupleLiteral
   |  RecordLiteral

6.1 Numeric Literals

Digit
  ::= [0-9]

HexDigit
  ::= [0-9a-fA-F]

DigitSeq
  ::= Digit ("_"? Digit)*

IntegerLiteral
  ::= DigitSeq

FloatLiteral
  ::= DigitSeq "." DigitSeq ExponentPart?
   |  DigitSeq ExponentPart

ExponentPart
  ::= ["eE"] ["+-"]? DigitSeq

Rules:

  • numeric separators are permitted between digits;
  • exponent notation is permitted only for floating-point literals.

6.2 String Literals

StringLiteral
  ::= "\"" StringPart* "\""

InterpolatedStringLiteral
  ::= "\"" InterpolatedStringPart* "\""

StringPart
  ::= EscapeSequence
   |  StringChar

InterpolatedStringPart
  ::= EscapeSequence
   |  InterpolationSequence
   |  StringChar

StringChar
  ::= any Unicode scalar value except `"`, `\`, `$`, LF, CR

InterpolationSequence
  ::= "$" Identifier
   |  "${" Expr "}"

EscapeSequence
  ::= "\\" (
          "\""
        | "\\"
        | "$"
        | "n"
        | "r"
        | "t"
        | UnicodeEscape
      )

UnicodeEscape
  ::= "u" "{" HexDigit HexDigit? HexDigit? HexDigit? HexDigit? HexDigit? "}"

Rules:

  • raw string literals are not part of Vox;
  • a string that contains interpolation uses the interpolated form;
  • both plain and interpolated string literals produce values of type String.

6.3 Boolean and Null Literals

BooleanLiteral
  ::= "true" | "false"

NullLiteral
  ::= "null"

6.4 Collection and Aggregate Literals

ListLiteral
  ::= "[" (Expr ("," Expr)* ","?)? "]"

TupleLiteral
  ::= "(" ")"
   |  "(" Expr "," ")"
   |  "(" Expr "," Expr ("," Expr)* ","? ")"

ParenExpr
  ::= "(" Expr ")"

RecordLiteral
  ::= "{" "}"
   |  "{" RecordFieldInit "," "}"
   |  "{" RecordFieldInit ("," RecordFieldInit)+ ","? "}"

RecordFieldInit
  ::= Identifier "=" Expr
   |  Identifier ":" Type "=" Expr

Rules:

  • () is the unit literal;
  • {} is also a unit literal;
  • a single parenthesized expression without a comma is not a tuple literal;
  • a single braced field without a comma is not a record literal;
  • record literal keys are constant identifier names;
  • name = expr initializes a field from a value expression;
  • name: Type = expr initializes a field from a value expression with an explicit field type annotation.

7. Statement Terminators

Declarations and simple statements end with ;.

A block expression does not require a trailing semicolon after its closing }.

The final trailing expression in a block or script does not use ;.

Types and Declarations

This chapter defines Vox type syntax and top-level declarations.

1. Types

Type
  ::= FunctionType

FunctionType
  ::= NullableType
   |  "(" TypeList? ")" "->" Type

TypeList
  ::= Type ("," Type)* ","?

NullableType
  ::= PrimaryType ("?")?

PrimaryType
  ::= NamedType
   |  DynType
   |  GroupedType
   |  TupleType
   |  RecordType

GroupedType
  ::= "(" Type ")"

NamedType
  ::= QualifiedIdentifier TypeArgumentClause?

QualifiedIdentifier
  ::= Identifier ("." Identifier)*

TypeArgumentClause
  ::= "[" Type ("," Type)* ","? "]"

DynType
  ::= "dyn" QualifiedIdentifier

TupleType
  ::= "(" ")"
   |  "(" Type "," ")"
   |  "(" Type "," Type ("," Type)* ","? ")"

RecordType
  ::= "{" (RecordTypeField ("," RecordTypeField)* ","?)? "}"

RecordTypeField
  ::= Identifier ":" Type

Rules:

  • T? denotes the nullable form of T;
  • () is the unit type;
  • Unit is equivalent to the zero-element tuple type ();
  • {} is also equivalent to Unit in type positions;
  • (A, B) -> C denotes a function that takes A and B and returns C;
  • function types are right-associative;
  • record types are structural and anonymous;
  • a record literal may appear either where its type is inferred or where an explicit record type annotation is present.

2. Predefined Types

The following predefined scalar types are available:

  • Int
  • Float
  • Bool
  • String
  • Unit

The following type forms are built into the language:

  • nullable types;
  • tuple types;
  • record types;
  • function types;
  • dynamic trait types introduced by dyn.

List[T] is a predefined generic type constructor.

Econ[T] is a predefined generic type constructor. Econ[T] values expose update(self: Econ[T]) -> T as a built-in effectful method.

3. Generic Parameter Clauses

GenericParameterClause
  ::= "[" GenericParameter ("," GenericParameter)* ","? "]"

GenericParameter
  ::= TypeParameter ":" TraitBound

TypeParameter
  ::= Identifier

TraitBound
  ::= Identifier

Rules:

  • each generic parameter has exactly one trait bound;
  • bounds are named trait constraints;
  • user-authored trait declarations are not available in Vox.

Examples:

fun mix[T: Numeric](a: T, b: T, t: Float): T = a;
fun pair[A: Show, B: Show](a: A, b: B): (A, B) = (a, b);

4. Native Structs and Traits

Native structs and traits are available at their declared tier. Struct fields and methods are public by default and accept private for debug-only access.

StructDecl ::= VisibilityModifier? "struct" Identifier "{" StructMember* "}"
StructMember ::= VisibilityModifier? ("val" | "var") Identifier ":" Type ";"
               | VisibilityModifier? "struct"? EvilModifier? "fun" Identifier
                 "(" ParameterList? ")" ReturnTypeAnnotation? FunctionBody
TraitDecl ::= VisibilityModifier? "trait" Identifier "{" TraitMember* "}"
ImplDecl ::= "impl" QualifiedIdentifier "for" QualifiedIdentifier
             (";" | "{" FunctionDecl* "}")

An instance method receives an implicit self parameter. struct fun declares an associated function and does not receive self. A trait implementation must provide every public trait field and method, either in the struct, in the implementation block, or through a visible function with the same receiver.

5. Imports

ImportDecl
  ::= VisibilityModifier? "import" ModulePath ";"

Rules:

  • an import makes the package available for qualified access under the final module-path segment (for example, import foo.bar; bar.baz()); import foo; remains addressable as foo.bar();
  • public import re-exports the imported package from a package file;
  • private import is permitted but equivalent to an omitted visibility modifier.

Selective imports, nested selective imports, and explicit module/item aliasing are supported by the frontend and runtime. An explicit module alias replaces the implicit final segment binding.

6. Script Parameters

ParamDecl
  ::= "param" Identifier ":" Type DefaultValue? ";"

DefaultValue
  ::= "=" Expr

Rules:

  • param is valid only in scripts;
  • script parameters define the script entrypoint inputs;
  • a parameter with a default value may be omitted by the caller.

7. Value Declarations

ValueDecl
  ::= VisibilityModifier? ImmutableValueDecl
   |  VisibilityModifier? MutableValueDecl

ImmutableValueDecl
  ::= "val" Identifier TypeAnnotation? "=" Expr ";"

MutableValueDecl
  ::= "var" Identifier TypeAnnotation? "=" Expr ";"

TypeAnnotation
  ::= ":" Type

Rules:

  • val declares an immutable binding;
  • var declares a reassignable binding;
  • an omitted type annotation is inferred from the initializer;
  • package top-level value declarations must use val;
  • script top-level and local value declarations may use either val or var.

8. Function Declarations

FunctionDecl
  ::= VisibilityModifier? EvilModifier? "fun" Identifier GenericParameterClause?
      "(" ParameterList? ")" ReturnTypeAnnotation? FunctionBody

EvilModifier
  ::= "evil"

ParameterList
  ::= Parameter ("," Parameter)* ","?

Parameter
  ::= Identifier ":" Type DefaultValue?

ReturnTypeAnnotation
  ::= ":" Type

FunctionBody
  ::= "=" Expr ";"
   |  BlockExpr

Rules:

  • a function is pure unless it is marked evil;
  • parameters are ordered from left to right;
  • default parameter values are part of the function signature;
  • an expression body and a block body are semantically equivalent;
  • package functions are order-independent and may not collide with another package function whose callable signature can match the same call;
  • script function headers are visible throughout the whole script, including before the function body appears in source order.

9. Visibility Modifiers

VisibilityModifier
  ::= "public"
   |  "private"

If a declaration omits visibility, it is private.

Expressions

This chapter defines Vox expressions and operator precedence.

1. Overview

Vox is expression-oriented. Most constructs produce values.

The following constructs are expressions:

  • literals;
  • name references;
  • calls;
  • built-in intrinsic forms;
  • indexing;
  • field access;
  • receiver-call sugar;
  • unary and binary operator expressions;
  • if expressions;
  • when expressions;
  • for expressions (iterator, condition, and statement-condition forms);
  • lambda expressions;
  • block expressions.

2. Expression Grammar

Expr
  ::= LambdaExpr
   |  CoalesceExpr

LambdaExpr
  ::= LambdaParameters "->" LambdaBody

LambdaParameters
  ::= Identifier
   |  "(" LambdaParameterList? ")"

LambdaParameterList
  ::= LambdaParameter ("," LambdaParameter)* ","?

LambdaParameter
  ::= Identifier TypeAnnotation?

LambdaBody
  ::= Expr
   |  BlockExpr

CoalesceExpr
  ::= RangeExpr ("?:" CoalesceExpr)?

RangeExpr
  ::= OrExpr RangeSuffix?
   |  PrefixRangeExpr

RangeSuffix
  ::= ".." OrExpr?
   |  "..=" OrExpr

PrefixRangeExpr
  ::= ".." OrExpr?
   |  "..=" OrExpr

OrExpr
  ::= AndExpr ("||" AndExpr)*

AndExpr
  ::= EqualityExpr ("&&" EqualityExpr)*

EqualityExpr
  ::= ComparisonExpr (EqualityOp ComparisonExpr)*

EqualityOp
  ::= "==" | "!="

ComparisonExpr
  ::= AdditiveExpr (ComparisonOp AdditiveExpr)*

ComparisonOp
  ::= "<" | "<=" | ">" | ">="

AdditiveExpr
  ::= MultiplicativeExpr (AdditiveOp MultiplicativeExpr)*

AdditiveOp
  ::= "+" | "-"

MultiplicativeExpr
  ::= UnaryExpr (MultiplicativeOp UnaryExpr)*

MultiplicativeOp
  ::= "*" | "/" | "%"

UnaryExpr
  ::= UnaryOp UnaryExpr
   |  PostfixExpr

UnaryOp
  ::= "-" | "!"

PostfixExpr
  ::= PrimaryExpr PostfixOp*

PostfixOp
  ::= CallSuffix
   |  WithSuffix
   |  IndexSuffix
   |  FieldSuffix
   |  SafeFieldSuffix
   |  NonNullSuffix
   |  ReceiverCallSuffix

3. Primary Expressions

PrimaryExpr
  ::= Literal
   |  QualifiedIdentifier
   |  ParenExpr
   |  ForExpr
   |  IfExpr
   |  WhenExpr
   |  BlockExpr
   |  EconExpr

ParenExpr is defined in Chapter 2.

4. Postfix Forms

CallSuffix
  ::= "(" ArgumentList? ")"

ArgumentList
  ::= Argument ("," Argument)* ","?

Argument
  ::= Expr
   |  Identifier "=" Expr

IndexSuffix
  ::= "[" Expr "]"

FieldSuffix
  ::= "." Identifier

SafeFieldSuffix
  ::= "?." Identifier

NonNullSuffix
  ::= "!!"

ReceiverCallSuffix
  ::= ".(" QualifiedIdentifier ")" "(" ArgumentList? ")"

WithSuffix
  ::= "." "with" "{" UpdateAssignmentList "}"

UpdateAssignmentList
  ::= UpdateAssignment ("," UpdateAssignment)* ","?

UpdateAssignment
  ::= UpdatePath UpdateTypeHint? "=" Expr

UpdateTypeHint
  ::= ":" Type

UpdatePath
  ::= UpdatePathSegment ("." UpdatePathSegment)*

UpdatePathSegment
  ::= Identifier
   |  "#" IntegerLiteral

Rules:

  • arguments may be positional or named;
  • named arguments use Identifier "=" Expr;
  • value.with { ... } copies value and applies one or more updates;
  • update paths may select nested fields and use #index for tuple and list positions;
  • an optional : Type hint must match the selected source type and checks the replacement against that type;
  • a?.b performs nullable-safe field access;
  • a!! asserts that a is non-null;
  • value.(pkg.fun)(x, y) is sugar for pkg.fun(value, x, y).

4.1 Method Calls

When a postfix .identifier is immediately followed by a call suffix (i.e., value.fun(args)), the compiler resolves fun as a method if a function named fun exists whose first parameter type is assignable from the type of value. The call is then rewritten to fun(value, args).

Resolution order for a.b lookup:

  1. record field access — if a is a record type and has a field b;
  2. method resolution — if a function b exists whose first parameter matches the type of a;
  3. qualified name resolution — if a.b is a valid qualified name (e.g., an imported package member).

For external libraries, struct types expose trait methods as methods when the struct implements the corresponding trait. These are resolved through the package manifest’s trait_impls during method resolution.

For any receiver type, a method name may have only one applicable definition. An inherent or built-in method, a visible function whose first parameter is that receiver type, and a trait method implemented by that receiver therefore cannot overlap. Vox rejects the conflicting declaration or import as a method guard violation before resolving calls. Defining two functions with the same name and indistinguishable parameter types is also a compile-time error.

5. if Expressions

IfExpr
  ::= "if" "(" Expr ")" BlockExpr
      ("else" "if" "(" Expr ")" BlockExpr)*
      ("else" BlockExpr)?

Rules:

  • if is an expression as well as a statement: if it appears at the head of a statement it is parsed as a statement. To use if as an expression in that position, wrap it in parentheses: (if (cond) { a } else { b }).
  • each branch produces a value;
  • the overall type is the common type of the branch results.

6. when Expressions

when is used for type-based dispatch.

WhenExpr
  ::= "when" "(" Expr ")" "{" TypeWhenArm+ ElseArm? "}"

TypeWhenArm
  ::= "is" Type Binding? "->" (InlineExpr ";" | BlockExpr)

Binding
  ::= "as" Identifier

ElseArm
  ::= "else" "->" Expr ";"

InlineExpr
  ::= Expr

Rules:

  • each is arm tests the subject against a type;
  • as Identifier binds the refined subject value inside that arm;
  • when does not support range matching or general pattern matching;
  • an inline arm ends with ;;
  • a block arm does not use ; after its closing };
  • else is optional;
  • at the head of a statement position, when is parsed as a BlockStatement and does not require a trailing ;. To use when as a trailing expression, wrap it in parentheses.

7. Block Expressions

BlockExpr
  ::= "{" BlockItem* TrailingExpr? "}"

TrailingExpr
  ::= Expr

A block evaluates to:

  • the value of its trailing expression, if present; or
  • the unit value (), otherwise.

{} is also a valid unit literal. It is equivalent to ().

8. Range Expressions

Range expressions use standard half-open and closed forms.

The range forms are:

RangeExpr
  ::= OrExpr ".." OrExpr
   |  OrExpr ".."
   |  ".." OrExpr
   |  ".."
   |  OrExpr "..=" OrExpr
   |  "..=" OrExpr

Range meanings:

  • start..end: inclusive lower bound, exclusive upper bound;
  • start..: inclusive lower bound with no upper bound;
  • ..end: exclusive upper bound with no lower bound;
  • ..: unbounded range;
  • start..=end: inclusive lower bound, inclusive upper bound;
  • ..=end: inclusive upper bound with no lower bound.

9. Nullability Operators

?., ?:, and !! have the following semantics:

  • a?.b evaluates to null when a is null, otherwise it evaluates to a.b;
  • a ?: b evaluates to a when a is non-null, otherwise to b;
  • a!! evaluates to a when a is non-null and fails at runtime when a is null.

10. Precedence and Associativity

From highest precedence to lowest, Vox expressions are parsed in this order:

  1. postfix forms: calls, indexing, field access, safe field access, !!, and receiver-call sugar;
  2. unary - and !;
  3. multiplicative *, /, %;
  4. additive +, -;
  5. comparison <, <=, >, >=;
  6. equality ==, !=;
  7. logical &&;
  8. logical ||;
  9. ranges .., ..=;
  10. null coalescing ?:;
  11. lambda ->.

Associativity rules:

  • postfix operators associate left to right;
  • multiplicative and additive operators associate left to right;
  • comparison and equality operators associate left to right;
  • && and || associate left to right;
  • ?: associates right to left;
  • function types and lambdas associate right to left.

Statements and Control Flow

This chapter defines the statement forms used in Vox block expressions, as well as the limited statement forms permitted at script top level.

1. Statement Contexts

Statements may appear inside block expressions.

Script files may also use statements at top level, while package files may not, except for public and private, non-mutable value declarations.

2. Block Items

A block body is a sequence of block items followed optionally by a trailing expression.

BlockItem
  ::= LocalValueDecl
   |  AssignmentStatement
   |  CompoundAssignmentStatement
   |  TerminationStatement
   |  BlockStatement
   |  ExprStatement
BlockStatement
  ::= IfExpr
   |  WhenExpr
   |  ForExpr
ExprStatement
  ::= Expr ";"

A BlockStatement is a block-like expression, such as if, when, or for, used in statement position. It is consumed as a statement without a trailing semicolon.

All other expressions in statement position require a trailing semicolon.

The final expression in a block may be written without a semicolon. This is the block’s trailing expression.

To use a block-like expression as the trailing expression of a block in a position parsed as a statement, wrap it in parentheses:

fun describe(x: Int?): String {
    if (x == null) { return "none"; }

    (if (x > 0) { "positive" } else { "non-positive" })
}

3. Local Value Declarations

LocalValueDecl
  ::= "val" Identifier TypeAnnotation? "=" Expr ";"
   |  "var" Identifier TypeAnnotation? "=" Expr ";"

val introduces an immutable local binding.

var introduces a local binding that may be reassigned.

4. Assignment Statements

AssignmentStatement
  ::= Identifier "=" Expr ";"

Assignment rules:

  • assignment is valid only for a previously declared var;
  • assignment targets are identifiers only;
  • field assignment and indexed assignment are not part of Vox;
  • in scripts, top-level assignment may target a previously declared script top-level var.

5. Compound Assignment Statements

CompoundAssignmentStatement
  ::= Identifier CompoundAssignmentOp Expr ";"

CompoundAssignmentOp
  ::= "+="
   |  "-="
   |  "*="
   |  "/="
   |  "%="

Compound assignment rules:

  • compound assignment is valid only for a previously declared var;
  • compound assignment is not valid for val.

6. for Expressions

for is an expression that evaluates to the unit value ().

ForExpr
  ::= "for" "(" ForInitSemi? (Expr | Pattern "in" Expr) ")" BlockExpr

ForInitSemi
  ::= (LocalValueDecl | AssignmentStatement | CompoundAssignmentStatement | Expr) ";"

Pattern
  ::= Identifier

A for loop consists of an optional initializer (terminated by ;), a header expression, and a block body. The header expression is either a loop condition (Expr) or an iterator (Pattern "in" Expr).

6.1 Condition-based Forms

for (condition) {
    ...
}

for (var i = 0; i < 10) {
    ...
}

for (condition) block evaluates condition before each iteration. If true, executes the body; otherwise exits (a while-style loop).

for (init; condition) block executes init once, then evaluates condition before each iteration. The initializer may be a local declaration, assignment, compound assignment, or expression. Its scope is shared with the loop body.

6.2 Iterator-based Forms

for (item in items) {
    ...
}

for (val low = 0; x in 0..10) {
    ...
}

for (pattern in iterable) block iterates over a list or integer range, binding each element to pattern per iteration.

for (init; pattern in iterable) block executes init once, then iterates. The initializer scope is shared with the loop body.

6.3 Rules

  • parentheses around the loop header are required;
  • the pattern must be a single identifier;
  • the loop body is always a block expression;
  • break and continue are valid only inside a for loop body.

7. Termination Statements

TerminationStatement
  ::= ReturnStatement
   |  PanicStatement
   |  BreakStatement
   |  ContinueStatement

A termination statement stops normal execution of the current control-flow path.

After a termination statement, no later code in the same block is executed on that path. Control-flow analysis treats such code as dead code, and optimizers may remove it.

7.1. return Statements

ReturnStatement
  ::= "return" Expr? ";"

return exits the innermost enclosing function.

Rules:

  • return; returns the unit value ();
  • return expr; returns the value of expr.

7.2. panic Statements

PanicStatement
  ::= "panic" StringLiteral ";"

panic raises an unrecoverable error with the given message.

The panic message is passed to the host.

7.3. break Statements

BreakStatement
  ::= "break" ";"

break exits the innermost enclosing for loop immediately.

Control resumes after the loop.

break is only valid inside a for loop body.

7.4. continue Statements

ContinueStatement
  ::= "continue" ";"

continue skips the rest of the current iteration.

For conditional for loops, control jumps to the next condition check.

For iterable for loops, control jumps to the next element.

continue is only valid inside a for loop body.

Effects and Execution

This chapter defines purity, evil, econ, and the source-level execution rules visible to Vox users.

1. Purity

Vox is pure by default.

A pure computation:

  • may be cached;
  • may be shared;
  • may not perform observable side effects.

A computation that performs observable effects must be marked evil.

2. evil Functions

A function declaration may be prefixed with evil.

EvilFunctionDecl
  ::= VisibilityModifier? "evil" "fun" Identifier GenericParameterClause?
      "(" ParameterList? ")" ReturnTypeAnnotation? FunctionBody

Rules:

  • an evil fun may perform host-visible effects such as I/O;
  • purity is contagious: a function that directly performs or depends on an effectful computation is evil;
  • pure code may call only pure computations unless the effect is mediated by econ;
  • an effectful operation must be executed again when its containing computation is evaluated, even if its explicit Vox arguments are unchanged;
  • marking a function evil does not make every operation inside it uncached. Pure subcomputations inside an evil function may still be cached, shared, folded, or removed according to their own pure inputs and demand.

3. evil Scripts

A script header may be either:

  • script path.to.module; or
  • evil script path.to.module.

An evil script marks the script entrypoint as effectful.

Scripts that omit the header are anonymous pure scripts. They are executable directly, but they cannot be imported or compiled as libraries. Use a named evil script header when the script entrypoint itself must be effectful.

4. econ

econ is a built-in intrinsic that creates a pure handle to a cached snapshot of an effectful computation.

EconExpr
  ::= "econ" "[" Type "]" BlockExpr

Example:

fun loadConfig(path: String): Econ[String] {
    econ[String] {
        readFile(path)
    }
}

Semantics:

  • constructing or refreshing an econ snapshot is effectful;
  • reading from an existing snapshot is pure;
  • pure computations that depend on an Econ[T] depend on the snapshot version, not on re-running the effect.

Econ[T] exposes one built-in method:

evil fun update(self: Econ[T]): T

snapshot.update() re-runs the block captured by the original econ[T] expression, stores the refreshed snapshot in snapshot, and returns the new value.

5. Evaluation Model

Package top-level values and function bodies are evaluated on demand.

Scripts are evaluated in source order. A script top-level val binds the value produced at that point in execution. It does not create a live alias to another binding.

Pure results may be cached.

Effectful computations are never treated as pure cached results.

Pure computations inside an effectful computation remain pure. If an evil operation produces the same value as in a previous evaluation, downstream pure work may reuse cached results for that value.

When a package artifact changes, cached package values from the previous artifact may be discarded. Precise dependency invalidation is an implementation optimization, not a user-visible semantic rule.

6. Value Semantics

Vox uses value semantics.

Consequences:

  • passing a value behaves as passing an independent value;
  • Vox has no user-visible reference syntax such as & or mut&;
  • host values exposed to pure Vox code must behave immutably.

7. Local Mutation

var and loop reassignment are local execution conveniences only.

They do not create mutable shared objects or mutable references.

Top-level var is valid in scripts because the script top level is an execution-local scope. Top-level var is not valid in packages.

System

This section groups the implementation reference for the main Vox components.

These documents describe responsibilities and boundaries. They are not the normative language specification.

Compiler

vox-compiler turns Vox source into compiled artifacts that vox-runtime can load and execute. It also serves as a standalone CLI tool for producing wasm and .voxlib files.

CLI Usage

vox-compiler [OPTIONS] FILE

Compile a .vox source file. The output format is chosen automatically: scripts produce raw wasm, and packages produce .voxlib artifacts.

FlagDescription
--mount PATHMount a library directory, .vox, or .voxlib file as a dependency (repeatable)
-o OUTPUTOutput file path (default: input stem with .wasm or .voxlib)
--packageForce .voxlib output even for script sources
-h, --helpShow help message

Examples

# Compile a script to wasm
vox-compiler hello.vox                    # → hello.wasm

# Compile a script with library dependencies
vox-compiler --mount ./lib/ app.vox       # → app.wasm

# Compile a package to .voxlib (auto-detected)
vox-compiler mylib.vox                    # → mylib.voxlib

# Force .voxlib output
vox-compiler --package script.vox         # → script.voxlib

# Custom output path
vox-compiler -o out.wasm hello.vox

Auto-detection

The compiler inspects the source to decide the output format:

  • Sources whose first non-comment token is package → .voxlib (compiled via compile_to_voxlib)
  • All other sources → raw wasm bytes

Use --package to override auto-detection and force .voxlib output.

Library Mounting

--mount PATH supports:

  • .voxlib files: decoded and registered in the compilation host registry; their manifests provide dependency surfaces during compilation.
  • Directories: scanned for .voxlib files (non-recursive).
  • .vox files: not supported directly; compile to .voxlib first.

Package output uses the universal Voxlib package ABI. Public functions and values are emitted as stable wasm exports that the runtime validates and invokes after mounting.

Pipeline

Compilation runs through these phases:

  1. Parse source into the frontend syntax tree.
  2. Analyze the source: resolve imports, declarations, names, calls, types, purity, captures, and script parameters.
  3. Lower executable bodies into MIR.
  4. Analyze and optimize MIR according to the requested optimization level.
  5. Produce a compiled artifact for the runtime.

The current runtime still executes scripts through the tree-walk path. MIR is compiled into the artifact as the optimization and backend handoff layer, so it can be inspected and later lowered to wasm without changing source semantics.

Compilation Result

A successful compilation produces:

  • the parsed frontend representation;
  • a compiled artifact with module identity, parameter metadata, purity, and optimization rankings;
  • MIR for executable script, function, and initializer bodies;
  • an executable plan carrying MIR inspection text and optimization metadata;
  • for scripts, tree-walk data for the current interpreter.

If compilation fails, the compiler returns diagnostics instead of an artifact.

Optimization Levels

NOpt keeps compilation conservative:

  • preserve source-shaped MIR;
  • run required control-flow cleanup;
  • compute binding versions and lifetimes;
  • avoid expensive demand analysis.

IOpt is the default for interactive work:

  • keep stable body, binding, block, and value identities where possible;
  • cache active pure values and lowered body metadata;
  • fold cheap constants;
  • simplify local control flow.

SOpt is for sealed execution:

  • run full lifetime and demand analysis;
  • remove dead pure computations;
  • prune unused tuple slots and record fields when demand proves they are unused;
  • allow copy-on-write and slot reuse when a value’s lifetime has ended;
  • prepare compact MIR for backend lowering.

Runtime callers can choose the default optimization level when loading a script, attach per-object optimization overrides for functions, set a connection or session default, or request a one-off run override. REPL sessions expose these controls through :opt get, :opt set, and :opt dump.

When a function override requests a stronger mode than the module default, the compiler runs the artifact’s optimization pipeline at the strongest requested mode and records the per-object requested mode and rank separately.

Runtime Contract

The compiled artifact records the requested module optimization level, per-function override metadata, and the rank chosen for each executable body. The runtime executes the best representation it supports:

  • tree-walk execution remains the fallback for scripts;
  • MIR metadata is available for inspection, optimization accounting, and future backend lowering;
  • wasm lowering will consume optimized MIR directly.

Optimization must not change externally visible behavior. Pure work may be cached, shared, folded, or removed when unused. Evil calls and other observable effects remain ordered and are never removed only because their result is unused.

MIR

MIR is the middle intermediate representation used between semantic analysis and backend lowering.

It is source-facing enough to inspect, but executable enough for optimization. Source names are resolved before MIR. Rebindings are split into explicit binding versions. Runtime values are represented by SSA-style value ids. Lifetimes, escapes, materialization demand, copy-on-write eligibility, and slot reuse are computed on MIR.

Module

A MIR module contains executable bodies for:

  • the script entry body;
  • package value initializers;
  • top-level functions;
  • lambda bodies.
MirModule
  module: ModulePath
  kind: script | evil script | package
  optimization: NOpt | IOpt | SOpt
  bodies: [MirBody]

Body

Each body is a control-flow graph.

MirBody
  id: BodyId
  name: String
  kind: script_entry | value_initializer | function | lambda
  purity: pure | evil
  rank: baseline | interactive | sealed-ownership | sealed-demand | sealed-materialization
  params: [ValueId]
  captures: [Capture]
  bindings: [Binding]
  values: [Value]
  blocks: [Block]
  analyses: AnalysisSummary

Blocks use block parameters as phi nodes. A predecessor passes the values needed by the successor through jump or branch.

Block
  id: BlockId
  params: [ValueId]
  ops: [Op]
  term: Terminator

Binding Nodes

BindingId identifies a source declaration. Two declarations with the same source name always have different ids.

Binding
  id: BindingId
  name: String
  mutability: val | var
  scope_depth: u32
  declared_type: Type?
  span: TextSpan
  capture: local | captured | noncapturable
  versions: [VersionId]

VersionId identifies the value currently held by a binding at a point in the body. A val usually has one version. A var has one version for its initializer and one for each assignment.

Version
  id: VersionId
  binding: BindingId
  value: ValueId
  source: initializer | assignment | compound_assignment | join | loop

The executable graph uses ValueId operands, not source names. Bindings remain for inspection, diagnostics, and optimization explanations.

Value Nodes

Value
  id: ValueId
  type: Type?
  def: parameter | capture | block_param | op | literal | unit
  binding_version: VersionId?
  uses: [Use]
  lifetime: Lifetime
  escape: Escape
  demand: Demand
  storage: Storage

Lifetime records first definition, last use, live-in blocks, live-out blocks, and whether storage can be reused after the last use.

Escape records whether the value is returned, captured, stored in econ, passed to an evil call, or passed to an unknown host boundary.

Demand records whether the full value is needed or only selected tuple slots or record fields are demanded.

Storage records the current slot assignment and copy-on-write status:

  • fresh: new storage is required;
  • reuse(ValueId): the value can reuse storage from an ended lifetime;
  • cow(ValueId): the value can share storage until a write forces a copy;
  • virtual: no materialized storage is required.

Operation Nodes

Op
  result: ValueId?
  kind: OpKind
  args: [ValueId]
  span: TextSpan?

Operation kinds:

  • literal(value);
  • unary(op);
  • binary(op);
  • tuple(shape);
  • record(shape);
  • list;
  • project(field | slot);
  • index;
  • updated(path);
  • call(callee, purity);
  • econ(type);
  • non_null;
  • safe_project(field);
  • type_test(type);
  • type_refine(type);
  • iterator;
  • iterator_next;
  • cache_get(key);
  • cache_put(key).

Short-circuit &&, ||, and ?: lower to branches and joins unless a prior analysis proves eager evaluation is equivalent.

Terminator Nodes

Terminator
  jump(target, args)
  branch(condition, then_target, then_args, else_target, else_args)
  return(value)
  panic(message)
  unreachable

if, when, loops, early return, and panic are represented with blocks and terminators. Branch result values flow through join block parameters.

Lowering Semantics

The lowering environment maps each visible source name to its current binding version.

Declaration lowering:

  1. Lower the initializer.
  2. Create a new BindingId.
  3. Create version zero for the binding.
  4. Map the source name to that version in the current lexical scope.

Assignment lowering:

  1. Resolve the target name to a mutable binding.
  2. Lower the right-hand expression.
  3. Create a new version for the same binding.
  4. Update the environment so later references use the new version.

Compound assignment lowering:

  1. Resolve the target name to the current value.
  2. Lower the right-hand expression.
  3. Emit the matching binary operation.
  4. Create a new version for the target binding.

Shadowing lowering creates a new BindingId and restores the previous name mapping when the lexical scope exits.

Branch joins compare the binding versions leaving each branch. If a binding has different outgoing versions, the join block receives a block parameter and the environment maps that binding to a join-created version.

Loops use loop header block parameters for loop-carried mutable bindings. The loop pattern is a fresh binding in the loop body.

when lowers the subject once, then emits type tests. An as binding is a fresh binding whose value is the refined subject inside that arm.

Text Format

MIR text is an inspection format, not the executable storage format. It keeps the surface close to Vox while exposing compiler facts.

Example source:

var x = 1;
x = x + 2;
{
    val x = "local";
    x
}
x

Example MIR text:

body @script_entry pure rank=interactive {
  binding %b0 var x scope=0 versions=[%v0,%v1]
  binding %b1 val x scope=1 versions=[%v2]

  block %bb0:
    %0 = literal 1
    bind %v0 -> %b0 = %0
    %1 = use %v0
    %2 = literal 2
    %3 = binary add %1, %2
    bind %v1 -> %b0 = %3 lifetime(%0..%3, reusable)
    %4 = literal "local"
    bind %v2 -> %b1 = %4 lifetime(%4..%5, reusable)
    %5 = use %v2
    drop %5
    %6 = use %v1 lifetime(%3..return, escapes=return)
    return %6
}

The text format includes:

  • body kind, purity, and optimization rank;
  • binding declarations and version lists;
  • block labels;
  • value-producing operations;
  • bind statements showing VersionId creation;
  • lifetime summaries;
  • escape and demand summaries when non-default;
  • storage summaries when slot reuse, copy-on-write, or virtualization applies.

Optimization Passes

Optimization is extensible through ordered MIR passes. A pass receives a mutable body plus analysis facts and returns whether it changed the body.

Required built-in passes:

  • control-flow cleanup;
  • def-use construction;
  • lifetime analysis;
  • active value caching for IOpt;
  • demand analysis for tuple and record projections;
  • function result culling for unused tuple slots and record fields;
  • copy-on-write marking;
  • storage slot reuse;
  • sealed compaction for SOpt.

Custom pass groups can be installed around the built-in groups:

  • before cleanup;
  • after analysis;
  • before sealed compaction;
  • before backend lowering.

Custom passes must preserve Vox behavior. Passes that move, remove, or share operations must respect evil call ordering and escape facts.

Backend Contract

MIR does not encode wasm layout.

The wasm backend chooses local allocation, stack use, memory layout, imported function ABI, runtime helper calls, and final instruction order. MIR supplies typed values, explicit control flow, effect metadata, lifetimes, demand, and storage reuse facts.

Runtime

vox-runtime is the long-lived execution service for Vox.

It owns:

  • mounted host libraries;
  • compiled script artifacts;
  • runtime handles for large values;
  • interactive sessions.

It can run in-process through EmbeddedRunner or as a TCP server through the vox-runtime binary.

File-backed .voxlib packages use the versioned Voxlib package ABI. Their wasm implementations are validated, retained, and used for imported functions and package values.

Start the Runtime

Run a shared runtime server with:

cargo run -p vox-runtime -- --listen 127.0.0.1:4545

--listen is optional. If omitted, the server listens on 127.0.0.1:4545.

The runtime prints the address it bound to and then waits for client connections.

Connect a Client

The REPL can connect to that runtime with:

cargo run -p vox-repl -- --connect 127.0.0.1:4545

Programs can also connect directly through RemoteRunner.

To attach to a specific session from the REPL:

cargo run -p vox-repl -- --connect 127.0.0.1:4545@shared
cargo run -p vox-repl -- --connect 127.0.0.1:4545@12

Use --new with a named target to create it when missing:

cargo run -p vox-repl -- --connect 127.0.0.1:4545@shared --new

Runtime and Session

The runtime and the session are different objects.

  • The runtime is the shared process. It stores libraries, compiled artifacts, live handles, and caches.
  • A session is an interactive workspace inside that runtime. It stores imports, definitions, the last-value binding behind $, and any handles retained by those bindings.

When a client opens a session, all later evaluation happens inside that session.

Session Kinds

The runtime supports two kinds of sessions:

  • Anonymous session: always creates a new interactive workspace.
  • Named session: reopens the same interactive workspace when another client uses the same name.

Named sessions are how multiple clients share one interactive environment.

Sessions also have two lifecycle states:

  • attached: one or more client endpoints are currently using the session;
  • reserved: the session is kept even when the attached endpoint count reaches zero.

An unreserved session is recycled as soon as its attached endpoint count drops to zero.

Programmatic Session Use

Embedded use:

#![allow(unused)]
fn main() {
use vox_runtime::{EmbeddedRunner, InteractiveSession};

let runner = EmbeddedRunner::default();
let mut session = InteractiveSession::new(runner)?;
session.eval("val answer = 42;")?;
}

Remote use with a shared named session:

#![allow(unused)]
fn main() {
use vox_runtime::{InteractiveSession, RemoteRunner};

let runner = RemoteRunner::connect("127.0.0.1:4545")?;
let mut session = InteractiveSession::named(runner, "shared")?;
session.eval("val answer = 42;")?;
}

If another client opens "shared" on the same runtime, it sees the same interactive state.

What Is Shared

Clients attached to the same runtime share:

  • mounted libraries;
  • compiled artifacts;
  • the runtime handle store;
  • runtime-wide caches;
  • any interactive state inside the same named session.

Clients in different sessions do not share:

  • bindings;
  • function definitions entered interactively;
  • the $ value;
  • :reset effects.

Sharing Data

There are two supported ways to share data today.

1. Share one named session

If multiple clients need the same bindings and definitions, they must attach to the same named session. This is the direct sharing model.

Anonymous sessions can also be shared by id while they are still live, or after they have been marked as reserved.

2. Copy source state between sessions

If the sessions must stay separate, copy the session source with snapshot and restore operations. In the REPL this is exposed as :snapshot and :restore.

This copies source-defined interactive state. It does not move a live session binding from one session to another inside the runtime.

Handles

Large values cross the runtime boundary as handles instead of full serialized payloads.

Clients can:

  • receive a handle as an evaluation result;
  • inspect a handle summary;
  • fetch serializable handle data eagerly with get_handle_data();
  • fetch serializable handle data in chunks with read_handle_data();
  • retain or release a handle through the runner API.

Only pure-serializable handles support data fetches. Opaque handles such as functions still require handle-based use.

Session Management

At the API and protocol level, clients can:

  • create anonymous sessions;
  • attach to sessions by id;
  • attach to sessions by name;
  • create named sessions on demand;
  • list live sessions;
  • mark a session as reserved or unreserved.

The REPL exposes these through --connect host:port@session, --new, and the :session command family.

Protocol

The runtime protocol is the binary attach boundary for external instances such as REPL clients, editors, batch workers, and test harnesses.

This document defines the wire format closely enough to implement both ends of the connection without an additional schema layer.

Scope

The protocol exists to:

  • attach one client instance to a long-lived vox-runtime;
  • open or attach that client to a runtime-managed interactive session;
  • load, reload, run, and unload script artifacts for that instance;
  • move arguments and results across the boundary;
  • manage runtime-owned handles for large values;
  • expose runtime cache and Econ maintenance operations.

The protocol does not model REPL history, completion menus, or client-side synthetic source assembly. Those stay in the client.

The transport connection and the interactive session are distinct concepts. A connection is the ordered byte stream used by one attached client. A session is the shareable interactive environment that may later be revisited or shared with other clients while it still has attached endpoints or has been marked as reserved.

Connection Model

  • transport: any ordered byte stream such as a Unix socket or TCP connection;
  • endianness: little-endian for all fixed-width integers and floats;
  • lifetime: one connection equals one attached client instance, not the entire lifetime of a shared interactive session;
  • concurrency: the client may pipeline requests and match responses by request_id;
  • isolation: interactive bindings are scoped to a runtime session rather than ambiently shared across all clients;
  • sharing: library mounts, caches, Econ state, and handle storage are owned by the runtime and may be shared across connections;
  • disconnect: dropping the connection releases connection-owned references; when a session reaches zero attached endpoints, the runtime may recycle it unless that session is reserved.

The first frame on every connection must be HELLO.

IPC Model

The runtime protocol is the IPC surface for Vox tools that share one runtime.

Normal same-runtime transfer uses these methods:

  • inline copy for small serializable values;
  • handle passing for large or opaque runtime-owned values;
  • callable references for functions, compiled entry points, and retained closures that the runtime can represent safely;
  • automatic runtime cache reuse instead of explicit client-to-client cache copy.

Cross-runtime movement is different from same-runtime IPC. It uses explicit export/import operations and versioned bundles rather than raw handle reuse.

Frame Format

Every message begins with this fixed 24-byte header:

offset  size  field
0       4     magic      = 0x56585254  // "VXRT"
4       2     version
6       1     kind
7       1     opcode
8       4     flags
12      4     request_id
16      4     target_id
20      4     payload_len

Rules:

  • version is 0 on the initial HELLO request and the selected protocol version on every later frame;
  • request_id is chosen by the client for requests and copied by the server into the matching response;
  • target_id is 0 when the opcode does not act on an existing object;
  • payload_len may be 0;
  • after the header, exactly payload_len bytes follow.

kind values:

  • 0: request
  • 1: success response
  • 2: error response
  • 3: event

flags are a bitset:

  • 0x0000_0001: payload contains diagnostics
  • 0x0000_0002: payload contains an inline value
  • 0x0000_0004: payload contains a handle result

All other bits are reserved and must be sent as 0.

Opcodes

opcode is a one-byte enum:

  • 0x01: HELLO
  • 0x02: PING
  • 0x03: OPEN_SESSION
  • 0x04: EVALUATE_SESSION
  • 0x05: DROP_SESSION_ITEM
  • 0x06: RESET_SESSION
  • 0x07: SNAPSHOT_SESSION
  • 0x08: RESTORE_SESSION
  • 0x09: RUN_SESSION_SCRIPT
  • 0x0a: SET_SESSION_XOPT
  • 0x0b: CLOSE_SESSION
  • 0x0c: LIST_SESSIONS
  • 0x0d: SET_SESSION_RESERVED
  • 0x0e: SET_SESSION_OPT
  • 0x0f: GET_SESSION_OPT
  • 0x14: DUMP_SESSION_OPT
  • 0x10: MOUNT_LIBRARY
  • 0x11: UNMOUNT_LIBRARY
  • 0x20: LOAD_SCRIPT
  • 0x21: RELOAD_SCRIPT
  • 0x22: UNLOAD_SCRIPT
  • 0x23: SET_XOPT
  • 0x24: RUN_SCRIPT
  • 0x25: GET_OPT
  • 0x26: DUMP_OPT
  • 0x30: RETAIN_HANDLE
  • 0x31: DESCRIBE_HANDLE
  • 0x32: RELEASE_HANDLE
  • 0x33: READ_HANDLE_DATA
  • 0x40: REFRESH_ECON
  • 0x41: CACHE_STATS
  • 0x42: CLEAR_CACHE
  • 0x7f: SHUTDOWN

The server must reject unknown opcodes with ERR_UNSUPPORTED_OPCODE.

Primitive Encodings

The protocol uses only these primitive encodings:

  • u8, u16, u32, u64
  • i64
  • f64
  • bytes: u32 len followed by len raw bytes
  • string: bytes containing UTF-8

There is no map or self-describing object envelope at the frame level.

Optimization modes use these u8 values:

  • 0: NOpt
  • 1: IOpt
  • 2: SOpt

Optimization settings are encoded as:

u8 default_xopt      // 0=NOpt, 1=IOpt, 2=SOpt
u8 reserved[3]
u32 override_count
repeated override_count:
  string object      // function name; "module" is reserved for the module
  u8 object_xopt     // 0=NOpt, 1=IOpt, 2=SOpt
  u8 reserved[3]

Optimization status rows are encoded as:

u32 status_count
repeated status_count:
  string object
  u8 requested_xopt  // 0=NOpt, 1=IOpt, 2=SOpt
  u8 rank            // 0=pending, 1=baseline, 2=interactive,
                     // 3=sealed-ownership, 4=sealed-demand,
                     // 5=sealed-materialization
  u8 has_artifact
  u8 mir_available
  u8 wasm_available
  u32 artifact_id    // present only when has_artifact != 0

Optimization dump kinds use these u8 values:

  • 0: MIR text
  • 1: wasm bytes rendered as diagnostic text

Value Encoding

Arguments and inline results use the Value encoding below:

tag: u8
payload: tag-specific

Tags:

  • 0x00: null
  • 0x01: bool followed by u8 (0 or 1)
  • 0x02: int followed by i64
  • 0x03: float followed by f64
  • 0x04: string
  • 0x05: tuple followed by u32 count, then count encoded values
  • 0x06: record followed by u32 field_count, then repeated string name plus encoded value
  • 0x07: handle followed by u32 handle_id

Encoding rules:

  • values smaller than the negotiated inline limit are sent inline;
  • large host values must be returned as handle;
  • a client may send a previously received handle value back as an argument;
  • when a result does not fit the inline limit, the runtime prefers returning a handle over copying the value into the response.
  • inline values are copy-transferred, not shared by later mutation.

The protocol deliberately avoids textual field names outside inline records.

Serialized Handle Data Encoding

When a handle exposes pure-serializable data, READ_HANDLE_DATA returns bytes encoded with the same primitive style as Value, but with one extra tag for lists and without nested handles.

Tags:

  • 0x00: null
  • 0x01: bool
  • 0x02: int
  • 0x03: float
  • 0x04: string
  • 0x05: tuple
  • 0x06: record
  • 0x08: list

Rules:

  • nested handle values are not valid inside serialized handle data;
  • opaque handles such as functions must reject READ_HANDLE_DATA;
  • clients may fetch the full byte stream eagerly or page it in chunks.

Function transfer rules:

  • functions normally cross the process boundary as callable references, not raw executable blobs;
  • top-level functions and compiled script entry points are addressable by runtime-issued callable ids or by symbol plus revision metadata;
  • closures may only be transferred when the runtime can retain their captured environment safely as a runtime-owned callable object.

Diagnostics

Compilation and runtime failures may carry diagnostics. A diagnostic block is:

u32 count
repeat count times:
  u8 severity        // 0=error, 1=warning, 2=note
  string code
  string message
  string source_name // empty when unavailable
  u32 start_byte
  u32 end_byte

Diagnostics are optional on success and recommended on compile failures.

Handshake

HELLO is mandatory and must be the first request.

The HELLO request must use header version = 0. The HELLO response must return the selected version both in the header and in the payload.

Request payload:

u16 min_version
u16 max_version
u32 client_caps
u32 max_inline_value_bytes

Response payload:

u16 selected_version
u16 reserved
u32 server_caps
u32 instance_id
u32 max_payload_bytes
u32 max_inline_value_bytes

Handshake rules:

  • the server selects one version within the requested range;
  • if there is no overlap, the server replies with ERR_VERSION_MISMATCH;
  • instance_id identifies the attached instance in logs and metrics only;
  • both sides must honor the smaller of the client and server inline limits.

Operation Payloads

This section defines the exact payload for each opcode. target_id in the frame header identifies the object being acted on when required.

PING

Request payload: empty.

Success response payload:

u64 runtime_uptime_ms

OPEN_SESSION

target_id must be 0.

Request payload:

u8 open_mode         // 0=attach, 1=create, 2=attach_or_create
u8 selector_kind     // 0=anonymous, 1=session_id, 2=session_name
u8 reserved[2]
u32 session_id       // present only when selector_kind = 1
string session_name  // present only when selector_kind = 2

Success response payload:

u32 session_id

Rules:

  • selector_kind = 0 with open_mode = create opens a fresh anonymous session;
  • selector_kind = 1 attaches to an existing session by id;
  • selector_kind = 2 with open_mode = attach attaches to an existing named session;
  • selector_kind = 2 with open_mode = create creates a fresh named session and fails if that name already exists;
  • selector_kind = 2 with open_mode = attach_or_create reattaches when the name already exists or creates the named session otherwise;
  • session ids are runtime-issued and may be reused on later requests across multiple connections attached to the same runtime instance.

CLOSE_SESSION

target_id is session_id.

Request payload: empty.

Success response payload: empty.

Closing a session endpoint decrements that session’s attached-endpoint count on the current connection.

LIST_SESSIONS

target_id must be 0.

Request payload: empty.

Success response payload:

u32 session_count
SessionSummary sessions[session_count]

Where each SessionSummary is:

u32 session_id
u8 has_name
u8 reserved
u8 reserved_bytes[2]
u64 attached_endpoints
string session_name  // present only when has_name = 1

SET_SESSION_RESERVED

target_id is session_id.

Request payload:

u8 reserved          // 0=false, 1=true
u8 reserved_bytes[3]

Success response payload: empty.

EVALUATE_SESSION

target_id is session_id.

Request payload:

string submission

Success response payload:

  • empty when the submission only changes session state and yields no result;
  • otherwise the same result encoding used by RUN_SCRIPT.

DROP_SESSION_ITEM

target_id is session_id.

Request payload:

string name

Success response payload:

u8 removed           // 0=false, 1=true

RESET_SESSION

target_id is session_id.

Request payload: empty.

Success response payload: empty.

SNAPSHOT_SESSION

target_id is session_id.

Request payload: empty.

Success response payload:

string snapshot_source

RESTORE_SESSION

target_id is session_id.

Request payload:

string label
string snapshot_source

Success response payload: empty.

RUN_SESSION_SCRIPT

target_id is session_id.

Request payload:

string logical_path
string source_text

Success response payload:

  • the same result encoding used by RUN_SCRIPT.

SET_SESSION_XOPT

target_id is session_id.

Compatibility command for setting only the session default optimization mode. New clients use SET_SESSION_OPT.

Request payload:

u8 default_xopt      // 0=NOpt, 1=IOpt, 2=SOpt
u8 reserved[3]

Success response payload: empty.

SET_SESSION_OPT

target_id is session_id.

Request payload:

u8 xopt             // 0=NOpt, 1=IOpt, 2=SOpt
u8 reserved[3]
u32 object_count
string objects[object_count]

If object_count is 0, the runtime updates the session default mode. If objects are supplied, module updates the module/default mode and function names update per-function overrides. The session recompiles its active artifact when one exists.

Success response payload: empty.

GET_SESSION_OPT

target_id is session_id.

Request payload:

u8 has_object
u8 reserved[3]
string object       // present only when has_object != 0

Success response payload:

OptimizationStatus[]

With no object, the response includes the module and all known function objects.

DUMP_SESSION_OPT

target_id is session_id.

Request payload:

u8 dump_kind        // 0=MIR, 1=wasm
u8 reserved[3]
string object       // empty or "module" for the module

Success response payload:

u8 present
u8 reserved[3]
u8 dump_kind        // present only when present != 0
u8 reserved[3]
string object       // present only when present != 0
string text         // present only when present != 0

MOUNT_LIBRARY

Request payload:

u8 source_kind       // 0=filesystem path, 1=manifest bytes, 2=bundle bytes
u8 reserved[3]
bytes source

Source kind 2 contains the complete .voxlib file. It is the portable mount form used by remote runners; the server validates and retains its manifest, wasm implementation, and metadata. Source kind 1 registers a manifest for a host implementation already available inside the runtime process. Source kind 0 is reserved and is not accepted by the current server.

Success response payload:

u32 library_id
u64 library_revision

UNMOUNT_LIBRARY

target_id is library_id.

Request payload: empty.

Success response payload: empty.

LOAD_SCRIPT

target_id must be 0.

Request payload:

u8 source_kind       // 0=source text, 1=precompiled artifact
u8 default_xopt      // 0=NOpt, 1=IOpt, 2=SOpt
u8 reserved[2]
OptimizationSettings optimization_settings
string logical_path
bytes source

default_xopt and optimization_settings.default_xopt must match. The duplicated byte keeps the module/default mode visible in the fixed part of the payload while allowing per-object overrides to travel with the same request.

Success response payload:

u32 script_id
u64 script_revision
u32 parameter_count
u8 result_is_handle_capable

If compilation produces diagnostics but still yields a runnable artifact, the server may return success with the diagnostics flag set.

RELOAD_SCRIPT

target_id is script_id.

Request payload matches LOAD_SCRIPT.

Success response payload:

u64 script_revision
u32 parameter_count
u8 result_is_handle_capable

UNLOAD_SCRIPT

target_id is script_id.

Request payload: empty.

Success response payload: empty.

SET_XOPT

target_id must be 0.

Request payload:

u8 default_xopt      // 0=NOpt, 1=IOpt, 2=SOpt
u8 reserved[3]

Success response payload: empty.

Current runtime behavior:

  • SET_XOPT updates the connection default used by later LOAD_SCRIPT and RELOAD_SCRIPT requests;
  • RUN_SCRIPT may use xopt_override = 255 to execute with the artifact’s compiled optimization level, or 0, 1, or 2 for a one-off run override. The tree-walk fallback preserves source behavior for all modes.

RUN_SCRIPT

target_id is script_id.

Request payload:

u8 xopt_override     // 255=use script default, else 0/1/2
u8 reserved[3]
u32 arg_count
Value args[arg_count]

Success response payload:

  • when the result is inline: one encoded Value;
  • when the result is large: u32 handle_id.

The server must return exactly one result value.

GET_OPT

target_id is script_id.

Request payload:

OptimizationSettings optimization_settings

Success response payload:

OptimizationStatus[]

This lets clients query the optimization state of a loaded artifact using the same metadata they submitted at compile time.

DUMP_OPT

target_id is script_id.

Request payload:

u8 dump_kind        // 0=MIR, 1=wasm
u8 reserved[3]
string object       // empty or "module" for the module; function name for MIR

Success response payload:

u8 present
u8 reserved[3]
u8 dump_kind        // present only when present != 0
u8 reserved[3]
string object       // present only when present != 0
string text         // present only when present != 0

MIR dumps are available for the module and function bodies when the artifact contains MIR. Wasm dumps are currently module-level only.

RETAIN_HANDLE

target_id is handle_id.

Request payload:

u32 extra_refs

Success response payload:

u32 handle_id
u32 retained_refs

DESCRIBE_HANDLE

target_id is handle_id.

Request payload: empty.

Success response payload:

u32 handle_id
string type_name
u64 approx_size_bytes
u32 ref_count
u32 handle_flags
string summary

handle_flags currently uses:

  • 0x0000_0001: pure-serializable
  • 0x0000_0002: externally pinned

RELEASE_HANDLE

target_id is handle_id.

Request payload:

u32 release_refs     // usually 1

Success response payload:

u32 remaining_refs

READ_HANDLE_DATA

target_id is handle_id.

This operation is valid only for pure-serializable handles.

Request payload:

u64 offset
u32 max_bytes

Success response payload:

u64 total_bytes
bytes chunk

chunk contains a slice of the serialized handle-data byte stream beginning at offset.

REFRESH_ECON

Request payload:

string econ_key

Success response payload:

u64 econ_version
u64 invalidated_cache_entries

CACHE_STATS

Request payload: empty.

Success response payload:

u64 artifact_entries
u64 pure_cache_entries
u64 pure_cache_bytes
u64 live_handles

CLEAR_CACHE

Request payload:

u8 scope             // 0=all, 1=artifacts, 2=pure-cache
u8 reserved[3]

Success response payload:

u64 cleared_entries

SHUTDOWN

Request payload: empty.

Success response payload: empty.

Only privileged clients may issue this opcode.

Error Model

An error response uses kind = 2 and this payload:

u32 error_code
string message
optional diagnostic block

Recommended error codes:

  • 1: ERR_VERSION_MISMATCH
  • 2: ERR_BAD_FRAME
  • 3: ERR_UNSUPPORTED_OPCODE
  • 4: ERR_UNKNOWN_LIBRARY
  • 5: ERR_UNKNOWN_SCRIPT
  • 6: ERR_UNKNOWN_HANDLE
  • 7: ERR_COMPILE_FAILED
  • 8: ERR_RUNTIME_FAILED
  • 9: ERR_BAD_ARGUMENT
  • 10: ERR_PERMISSION_DENIED

ERR_BAD_FRAME is fatal to the connection.

Events

Events are optional and never replace the required response to a request.

If implemented, supported events are:

  • 0x80: HANDLE_DROPPED with payload u32 handle_id
  • 0x81: ECON_INVALIDATED with payload string econ_key plus u64 version

Clients must ignore unknown event opcodes.

Performance Rules

  • keep the header fixed-width and branch-light to parse;
  • do not use JSON, text keys, or per-message schema negotiation;
  • prefer integer ids and handle passing over value copying;
  • allow request pipelining on one connection;
  • keep script artifacts connection-local while keeping durable interactive state session-local.

REPL

vox-repl is the interactive frontend for evaluating Vox code.

It can either:

  • run with an embedded runtime in the same process;
  • connect to a separate vox-runtime server.

CLI Arguments

vox-repl [OPTIONS] [SCRIPT] [-- SCRIPT_ARGS...]

When run without arguments, vox-repl starts an interactive REPL session. When given a script file, it runs the file and prints the trailing expression value to stderr.

FlagDescription
-i, --interactiveDrop into REPL after running the script
-s, --silentSuppress stderr output of trailing expressions
--connect ADDRConnect to a remote runtime (host:port[@session])
--newCreate session if missing (requires --connect)
-h, --helpShow help message

Script arguments after -- are converted to Vox values and passed as positional parameters to the script:

InputVox type
Integer literalInt
Float literalFloat
true / falseBool
nullNull
Everything elseString

Examples

# Run a script, printing its result to stderr
vox-repl hello.vox

# Run a script, then drop into the REPL
vox-repl -i hello.vox

# Run silently (no trailing expression output), then REPL
vox-repl -i -s hello.vox

# Pass arguments to a parameterised script
vox-repl greet.vox -- "Alice" 42

# Connect to a remote runtime
vox-repl --connect 127.0.0.1:4545@shared

What the REPL Owns

The REPL owns terminal interaction only:

  • line editing;
  • history;
  • completion UI;
  • command parsing;
  • snapshot files.

The runtime owns execution and interactive state:

  • bindings;
  • functions;
  • imports;
  • the $ last value;
  • retained handles.

Current Session Behavior

Every ReplSession opens one runtime session and evaluates all user input inside it.

The current CLI behavior is:

  • embedded mode opens a fresh anonymous session;
  • --connect host:port opens a fresh anonymous session on the remote runtime;
  • --connect host:port@name attaches to an existing named session;
  • --connect host:port@id attaches to an existing session by id;
  • --connect host:port@name --new attaches to that named session or creates it if it does not exist.

Session ids are numeric. If the token after @ is all digits, the REPL treats it as a session id.

Entering Code

Any line that does not start with : is treated as Vox code.

Examples:

>>> val x = 4;
>>> x + 1
5
>>> :type x
Int

The REPL preserves prior successful state when a later submission fails.

Interactive submissions follow script top-level rules. Values and statements are processed in submission order. Function headers from the current submission are visible throughout that submission, and function headers from earlier successful submissions remain visible in later submissions. When a later submission redefines a function, that function becomes the active visible definition for the session.

In practice this means:

  • value references must already resolve inside the current session or earlier in the same submitted chunk;
  • functions may call each other when they are entered in the same compilation chunk;
  • a value initializer such as val x = foo() + bar(); may use functions from earlier chunks or functions declared in the current chunk;
  • if a function signature changes, direct function callers must be resubmitted in the same chunk.

Assignments entered at the prompt are statements:

>>> var a = 1;
>>> a = 2;
>>> a
2

For coupled changes, prefer :chunk, :edit, :run, or an external file.

REPL Commands

  • :help shows the command list.
  • :quit exits the REPL.
  • :reset clears the current interactive session state.
  • :clear clears the terminal screen.
  • :env prints visible imports, bindings, and functions.
  • :chunk opens an editor for a new multi-definition chunk.
  • :edit <symbol>... opens stored definitions together for resubmission as one chunk.
  • :snapshot <name> saves the current session source to a local snapshot file.
  • :restore <name> replaces the current session state with a snapshot file.
  • :run <file> runs a Vox script file in the current session context.
  • :show <handle> prints a lightweight summary for a handle id.
  • :type <expr> prints the inferred type of an expression.
  • :handles lists live handles visible through the runtime.
  • :drop <name> removes a binding or definition from the session.
  • :opt get [object] prints optimization state for the module and functions.
  • :opt set <mode> [object]... sets the session default mode, or forces the mode for module and named functions. Modes are NOpt, IOpt, and SOpt.
  • :opt dump [object] prints a MIR dump for module or a function when an optimized artifact exists. Prefix the object with wasm: to dump module wasm bytes, for example :opt dump wasm:module.
  • :session connect <id-or-name> switches to an existing session.
  • :session new [name] creates a fresh anonymous or named session and switches to it.
  • :session reserve toggles whether the current session is kept when its endpoint count reaches zero.
  • :session list shows all live sessions, their ids, attachment counts, and reserve status.

Shorthands and Editing

  • $ refers to the last evaluated value.
  • Arrow keys move through input history.
  • Tab completes commands, snapshot names, handles, and visible symbols.
  • Ctrl+C interrupts the current input line.
  • Ctrl+D exits the REPL.

:chunk and :edit choose an editor as follows:

  • if VOX_EDITOR=builtin, use the builtin multiline editor;
  • otherwise if VOX_EDITOR is set, run that command;
  • otherwise if EDITOR is set, run that command;
  • otherwise fall back to the builtin multiline editor.

The builtin editor is a simple replacement editor:

  • it shows the current chunk, if any;
  • it asks for the full replacement chunk;
  • .submit commits the chunk;
  • .cancel abandons it.

:opt dump prints the dump to the REPL by default. If VOX_VIEWER is set, the REPL writes the dump to a temporary read-only text file and runs that viewer command with the file path.

Snapshot Files

Snapshots are stored locally by the REPL process:

  • Unix-like systems: /tmp/vox-repl/snapshots
  • Windows: %APPDATA%\\vox-repl\\snapshots

:snapshot writes <name>.vox.

:restore loads that file and replaces the current session state.

This is the current user-facing way to move source-defined interactive state from one session to another.

Sharing Data

Different users often mean different things by “share”, so the rules are:

  • Same session: shared bindings, shared functions, shared $, shared retained handles.
  • Different sessions on the same runtime: separate interactive state.
  • Different runtimes: completely separate state.
  • A session with zero attached endpoints is recycled unless it has been marked as reserved.

From the REPL CLI, the practical sharing options are:

  • attach multiple REPLs or tools to the same reserved or still-live session by name or id;
  • use :session list to discover session ids and names;
  • use snapshot and restore to copy session source between separate sessions.

There is no REPL command that directly copies a live binding or handle from one session into another session.

LSP

vox-lsp is a Language Server Protocol server that provides IDE features for Vox source files.

Starting the Server

Build and run the server:

cargo build -p vox-lsp
./target/debug/vox-lsp

The server communicates over stdio using JSON-RPC. Editors with LSP support can launch it as a language server for .vox files.

What the LSP Owns

The LSP server owns editor integration:

  • document synchronization (open, change, close);
  • parsing and diagnostic publishing;
  • text position mapping (byte offsets to line/column).

The compiler (vox-compiler) owns analysis:

  • lexing and parsing;
  • diagnostic collection.

Current Capabilities

When the server receives a document:

  1. Runs the Vox lexer and parser on the source text.
  2. Converts any parse errors into editor diagnostics with source positions.
  3. Treats files without a package, script, or evil script header as anonymous executable scripts, so the editor does not report a missing-header error for directly executable script files.

Planned

  • semantic tokens from the lexer;
  • go-to-definition using the AST;
  • document symbols for functions, values, and records;
  • hover with inferred types via vox-runtime analysis;
  • completions adapted from vox-repl completion logic;
  • signature help at call sites.

Editor Integration

VS Code

Create a .vscode/extensions/vox/ directory with a package.json that declares the Vox language and launches vox-lsp as the language server.

Minimal package.json:

{
  "name": "vox-lang",
  "contributes": {
    "languages": [
      {
        "id": "vox",
        "extensions": [".vox"],
        "aliases": ["Vox"]
      }
    ],
    "grammars": [
      {
        "language": "vox",
        "scopeName": "source.vox",
        "path": "./syntaxes/vox.tmLanguage.json"
      }
    ]
  },
  "main": "./out/extension.js",
  "activationEvents": ["onLanguage:vox"]
}

The extension (extension.js) spawns the vox-lsp binary and connects its stdio as the LSP transport:

const { LanguageClient } = require("vscode-languageclient/node");
const client = new LanguageClient("vox-lsp", "Vox", {
  command: "vox-lsp",
  args: [],
});
client.start();

Other editors (Neovim, Helix, Emacs) can be configured to launch vox-lsp using their native LSP client support.

Architecture

Editor (VS Code / Neovim / ...)
    │  LSP (JSON-RPC over stdio)
    ▼
vox-lsp ── vox-compiler (parse, diagnostics)
        ── vox-core (source model, text spans)

The LSP server is stateless across documents. Each document is parsed independently on open or change. The server does not require a running vox-runtime.

Language Integration

This section covers host-language integration points for Vox.

Use it when you need to embed the runtime, export host libraries, or understand the host metadata that Vox consumes.

Rust

Rust is the first host-language integration target for Vox.

This section covers the practical Rust-facing APIs:

  1. Embedding the Runtime
  2. External Library Creation

Use vox_runtime for embedding or connecting to a runtime. Use voxlib-sdk for authoring Rust-backed .voxlib packages with VoxExport, vox_fn, vox_trait, vox_trait_impl, and ExternalLibrary.

Embedding the Runtime

This page shows how Rust code uses the Vox runtime directly.

The important split is:

  • RuntimeRunner gives you a transport-neutral way to talk to a runtime;
  • InteractiveSession gives you an interactive workspace inside that runtime.

The same session API works with both embedded and remote runners.

Embedded Use

Create a runtime in-process and open a fresh anonymous session:

#![allow(unused)]
fn main() {
use vox_runtime::{EmbeddedRunner, InteractiveSession};

let runner = EmbeddedRunner::default();
let mut session = InteractiveSession::new(runner.clone())?;

session.eval("val answer = 42;")?;
let value = session.eval("answer")?;
}

Use this when one program owns the runtime and does not need a separate server process.

Remote Use

Connect to a long-lived runtime server:

#![allow(unused)]
fn main() {
use vox_runtime::{InteractiveSession, RemoteRunner};

let runner = RemoteRunner::connect("127.0.0.1:4545")?;
let mut session = InteractiveSession::new(runner)?;
}

This opens a fresh anonymous session on that runtime.

Named Sessions

Named sessions are the mechanism for shared interactive state.

#![allow(unused)]
fn main() {
use vox_runtime::{InteractiveSession, RemoteRunner, SessionSelector};

let runner = RemoteRunner::connect("127.0.0.1:4545")?;
let mut shared = InteractiveSession::named(runner, "shared")?;

shared.eval("val answer = 42;")?;
}

If another client opens "shared" on the same runtime, it attaches to the same interactive workspace and can read or extend those bindings.

To attach to an existing session by id instead of by name:

#![allow(unused)]
fn main() {
use vox_core::ids::SessionId;
use vox_runtime::{InteractiveSession, RemoteRunner, SessionSelector};

let runner = RemoteRunner::connect("127.0.0.1:4545")?;
let mut session = InteractiveSession::attach(runner, SessionSelector::Id(SessionId(12)))?;
}

Session Rules

  • InteractiveSession::new(...) creates a fresh anonymous session.
  • InteractiveSession::named(..., "name") reopens the same session when the name already exists.
  • InteractiveSession::attach(..., selector) attaches to an existing session and fails when it does not exist.
  • InteractiveSession::create_named(..., "name") creates a fresh named session and fails when the name already exists.
  • Different session names are isolated from one another.
  • Sessions on the same runtime still share runtime-level resources such as mounted libraries and live handles.
  • An unreserved session is recycled when its attached endpoint count reaches zero.

Sharing Data

Choose the sharing model based on what you need:

  • Shared interactive workspace: use one named session.
  • Isolated workspaces with copied source state: export session source with snapshot_source() and import it with restore_snapshot_source().
  • Handle-backed large values: keep the handle, then fetch serializable data later with get_handle_data() or stream it in chunks with read_handle_data().

There is currently no higher-level API that copies a live binding directly from one session into another separate session.

Reading Handle-Backed Data

Large serializable values may cross the runtime boundary as handles. You can materialize the value later without re-running the original submission.

#![allow(unused)]
fn main() {
use vox_core::value::HandleData;
use vox_core::value::RuntimeValue;
use vox_runtime::{EmbeddedRunner, InteractiveSession};

let runner = EmbeddedRunner::default();
let mut session = InteractiveSession::new(runner)?;

let result = session.eval("[40, 41, 42]")?.expect("list result");
let RuntimeValue::Handle(handle) = result else {
    panic!("expected a handle-backed list");
};

let data = session.get_handle_data(handle)?;
assert_eq!(
    data,
    HandleData::List(vec![
        HandleData::Int(40),
        HandleData::Int(41),
        HandleData::Int(42),
    ])
);
}

For very large payloads, read incrementally:

#![allow(unused)]
fn main() {
let first_chunk = session.read_handle_data(handle, 0, 64 * 1024)?;
}

get_handle_data() is the eager convenience API. read_handle_data() is the chunked API that remote clients can use to stay within protocol payload limits.

External Library Creation

Rust host integration is intentionally small:

  • declare the package once;
  • mark Rust structs, traits, and functions as Vox-exportable once;
  • let the library collect everything automatically.

Rust .voxlib authoring lives in voxlib-sdk. The lower-level vox_core crate only contains language-neutral Vox manifest, type, value, and .voxlib encoding types.

An external library is the Rust-side description of one Vox package. In Rust, the builder type for that concept is ExternalLibrary. Ordinary library authors do not build PackageManifest, TypeSpec, FunctionSpec, or VoxType by hand.

The Model

ExternalLibrary is the Rust builder for one external library, which in turn owns one Vox package.

The package contents are built from two sources:

  • Rust items marked for Vox export;
  • referenced structs and traits reachable from those exported functions and trait methods.

That means:

  • structs, traits, and functions are opt-in at the item definition site;
  • there are no export_type::<T>() or export_trait::<T>() calls in normal usage;
  • there are no .function(...) calls in normal usage;
  • unused exported items stay out of the final library.

1. Mark exported structs once

Use a Vox SDK derive on each Rust struct that is visible to Vox:

#![allow(unused)]
fn main() {
use voxlib_sdk::{VoxExport, external_library::ExternalLibrary};

#[derive(VoxExport)]
struct Image {
    width: i64,
    height: i64,
}
}

This marks Image as eligible for export. It does not export anything by itself yet. The type is pulled into the package automatically when an exported function or trait method uses it.

The optional name override controls the public Vox name. If omitted, Vox uses the Rust struct name.

2. Mark exported traits once

Rust traits cannot use derive, so Vox uses a trait attribute for the same purpose: mark the trait once and let the external library include it automatically when it is referenced.

#![allow(unused)]
fn main() {
#[vox_trait]
trait Filter {
    #[vox(lowered_by = filter_apply, pure = true)]
    fn apply(&self, input: Image) -> Image;
}
}

This contributes trait metadata:

  • the Vox trait name;
  • the method signature;
  • the lowered function symbol that implements the runtime boundary.

Like structs, traits are not registered manually with the ExternalLibrary builder.

The optional trait name override controls the public Vox trait name. If omitted, Vox uses the Rust trait name.

3. Mark exported functions once

Functions follow the same rule: mark them once on the Rust item and let the external library include them automatically.

#![allow(unused)]
fn main() {
#[vox_fn(pure = true)]
fn blur(input: Image, #[vox(default)] radius: f64) -> Image {
    todo!()
}

#[vox_fn(name = "filter_apply", pure = true)]
fn filter_apply(filter: &dyn Filter, input: Image) -> Image {
    filter.apply(input)
}
}

This marks each function as part of the Vox package surface.

The optional function name override controls the public Vox function name. If omitted, Vox uses the Rust function name.

pure is a boolean attribute: true means the function is pure, and false means it is effectful.

4. Build the package once

#![allow(unused)]
fn main() {
    let (manifest, _metadata) = ExternalLibrary::new("image")?
        .build()?;
}

That is the full external library declaration.

From the exported Rust items, the external library collects:

  • image.blur
  • image.filter_apply
  • image.Image
  • image.Filter
  • the Filter.apply method metadata

No separate type or trait registration step is needed. No separate function registration step is needed either.

Docstrings

Every Vox-exported item can carry a docstring. These are collected at build time and attached to the voxlib as metadata after the wasm module. Libraries with metadata are called annotated libraries, and they are the preferred way to publish external Vox libraries, since tooling and IDEs can display the documentation from the voxlib file itself.

Docstrings are sourced from two places, in priority order:

  1. An explicit #[vox(doc = "...")] override on the item.
  2. Rust /// doc comments on the item, if no explicit override is given.

All three macro forms support docstrings:

#![allow(unused)]
fn main() {
/// A pixel buffer with integer dimensions.
#[derive(VoxExport)]
struct Image {
    width: i64,
    height: i64,
}

/// Applies a Gaussian blur to the image.
#[vox_fn(pure = true)]
fn blur(input: Image, #[vox(default)] radius: f64) -> Image {
    todo!()
}

/// Describes a compositional image filter.
#[vox_trait]
trait Filter {
    /// Applies this filter to an input image.
    #[vox(lowered_by = filter_apply, pure = true)]
    fn apply(&self, input: Image) -> Image;
}
}

When ExternalLibrary::build() is called, the collected docstrings are returned alongside the manifest. ExternalLibrary::generate() writes them into the .voxlib file automatically.

To override a docstring (e.g. when the Rust doc comment is intended for Rust consumers but the Vox docstring differs), use the explicit form:

#![allow(unused)]
fn main() {
#[vox_fn(pure = true, doc = "Blurs an image with a Gaussian kernel.")]
fn blur(input: Image, #[vox(default)] radius: f64) -> Image {
    todo!()
}
}

End-to-End Example

#![allow(unused)]
fn main() {
use voxlib_sdk::{VoxExport, external_library::ExternalLibrary, vox_fn, vox_trait};

#[derive(VoxExport)]
struct Image {
    width: i64,
    height: i64,
}

#[vox_trait]
trait Filter {
    #[vox(lowered_by = filter_apply, pure = true)]
    fn apply(&self, input: Image) -> Image;
}

#[vox_fn(pure = true)]
fn blur(input: Image, #[vox(default)] radius: f64) -> Image {
    todo!()
}

#[vox_fn(name = "filter_apply", pure = true)]
fn filter_apply(filter: &dyn Filter, input: Image) -> Image {
    filter.apply(input)
}

let (manifest, _metadata) = ExternalLibrary::new("image")?.build()?;
}

The user overhead is intentionally small:

  • one derive per exported struct;
  • one attribute per exported trait;
  • one attribute per exported function;
  • one package name at the ExternalLibrary root.

Generated Artifact Format

ExternalLibrary::generate(wasm_bytes) produces a single .voxlib file containing:

  • a header (magic, version, reserved);
  • the package manifest (types, traits, functions, trait impls);
  • the embedded wasm module;
  • optionally, an annotated metadata section at the end.

The supplied module must implement the universal Voxlib package ABI. Generation rejects arbitrary wasm that does not provide the imports and exports declared by the manifest. This is the same ABI emitted for Vox source packages and validated by the runtime when mounted.

The resulting GeneratedExternalLibrary can be written to disk via write_to_dir(dir).

For tooling or codegen that needs raw bytes without wasm, use ExternalLibrary::build() which returns (PackageManifest, Vec<u8>) — the manifest plus serialized docstring metadata.

Exported Names

By default, Vox exports each declaration under its Rust name:

  • structs use the Rust struct name;
  • traits use the Rust trait name;
  • trait methods use the Rust method name;
  • functions use the Rust function name.

When a public Vox name needs to differ from the Rust item name, use the same name = "..." override on each exported declaration kind:

  • structs via #[vox(name = "...")] next to #[derive(VoxExport)];
  • traits via #[vox_trait(name = "...")];
  • trait methods via #[vox(name = "...", lowered_by = ...)];
  • functions via #[vox_fn(name = "...")].

For example:

#![allow(unused)]
fn main() {
#[derive(VoxExport)]
#[vox(name = "Bitmap")]
struct Image {
    width: i64,
    height: i64,
}

#[vox_trait(name = "PixelFilter")]
trait Filter {
    #[vox(name = "run", lowered_by = filter_apply, pure = true)]
    fn apply(&self, input: Image) -> Image;
}

#[vox_fn(name = "gaussian_blur", pure = true)]
fn blur(input: Image, #[vox(default)] radius: f64) -> Image {
    todo!()
}
}

Automatic Inclusion Rules

ExternalLibrary includes an exported Rust struct automatically when:

  • an exported function parameter uses it;
  • an exported function return type uses it;
  • an exported trait method uses it;
  • it appears inside a supported container such as Option<T> or Vec<T>.

ExternalLibrary includes an exported Rust trait automatically when:

  • an exported function parameter uses dyn Trait;
  • an exported function return type uses dyn Trait;
  • the trait itself declares exported methods.

ExternalLibrary includes an exported Rust function automatically when:

  • it is marked with #[vox_fn(...)];
  • it is a lowered function referenced by an exported trait method;
  • it belongs to the package being built.

ExternalLibrary follows these references transitively until the reachable package metadata is complete.

Trait Methods and Lowered Functions

Traits describe the Vox-facing method surface. Ordinary functions are still the runtime entry points.

For each exported trait method:

  • the Vox method name defaults to the Rust method name and may be overridden with name = "...";
  • the runtime executes the lowered free function named by #[vox(lowered_by = ...)];
  • that lowered function is included automatically when it is marked with #[vox_fn(...)].

The two method fields serve different roles:

  • name controls the public Vox method name;
  • lowered_by names the Rust free function that implements the runtime boundary.

This keeps the API simple:

  • traits stay declarative;
  • functions stay executable;
  • the runtime only needs one callable host boundary.

Type Mapping

Rust types map to Vox types automatically for the common cases:

  • i64, i32, u32, usize -> Int
  • f64, f32 -> Float
  • bool -> Bool
  • String, &str -> String
  • Option<T> -> T?
  • Vec<T> -> List[T]
  • (A, B, ...) -> tuple
  • exported Rust structs -> qualified named Vox types
  • exported Rust traits behind dyn -> qualified dynamic trait types

Most users stay entirely in ordinary Rust signatures and let Vox infer the manifest types.

When a host function needs to return multiple named values, prefer a record return type instead of inventing a temporary exported struct. Structs remain single named values in the Vox type system; records are the anonymous product type for “these named fields together”.

For the common path, expose the Rust function normally and override only the Vox-facing return type when Rust cannot express the anonymous record directly:

#![allow(unused)]
fn main() {
#[vox_fn(
    pure = true,
    return_type = "{ shadows: Int, mids: Int, highlights: Int }"
)]
fn histogram(image: Image) -> HistogramSummary {
    todo!()
}
}

In low-level manifest code, the equivalent shape is VoxType::Record(...).

API Summary

  • ExternalLibrary::new(package) starts one package export session.
  • #[derive(VoxExport)] marks a Rust struct as exportable metadata.
  • #[vox(name = "...")] optionally overrides the exported name of a struct or trait method.
  • #[vox(doc = "...")] optionally overrides the documented description of any exported item.
  • #[vox_trait(...)] marks a Rust trait as exportable metadata and may override its exported name.
  • #[vox_fn(...)] describes one exported function and may override its exported name, purity, Vox return type, or docstring.
  • ExternalLibrary::build() returns (PackageManifest, Vec<u8>) — the package manifest and serialized docstring metadata.
  • ExternalLibrary::generate(wasm_bytes) produces a GeneratedExternalLibrary containing the complete .voxlib bytes, which can be written to disk with write_to_dir(dir).

Advanced Use

Low-level manifest construction still exists for tooling, code generation, and unusual edge cases. It is not the normal library-author workflow.

If you are writing an external library by hand, the intended rule is:

  • annotate structs, traits, and functions once;
  • let ExternalLibrary infer the package.