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 entrypointevil 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:
valcreates an immutable binding.varallows local reassignment inside a block or script.fundeclares a function.publicexports 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
valandfundeclarations; - 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:
ifis an expression.returnis available when an early exit is clearer.- lambdas use
x -> x * 2or(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; writefield: Type = nextwhen an explicit replacement type hint is useful.value.fun(args)calls a function as a method — sugar forfun(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 isnull.!!unwraps a nullable value and fails at runtime if it isnull.
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:
- Source Model
- Lexical Structure
- Types and Declarations
- Expressions
- Statements and Control Flow
- 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, andpanic; - tier 2 (
script) adds functions,var, and imports; - tier 3 (
dev) adds package authoring, native trait definitions, andimpl; - 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.filtersstd.filedemo.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
valandfundeclarations; - a package file must not contain top-level
vardeclarations 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
publicdeclarations andpublic imports; - a package may contain
evil fundeclarations. - native
struct,trait, andimpldeclarations 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
paraminputs; - 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:
publicprivate
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:
publicexports the declaration from the package;privatekeeps it internal to the file.
In scripts:
- declarations remain script-local regardless of visibility spelling;
publichas 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_tmpPoint2D
Examples of invalid identifiers:
2dblur-radiuswith space
4. Keywords
The following words are reserved keywords:
asbreakcontinuedyneconelseevilfalseforfunifimportinisnullpackagepanicparamprivatepublicreturnscripttruevalvarwhenwith
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 = exprinitializes a field from a value expression;name: Type = exprinitializes 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 ofT;()is the unit type;Unitis equivalent to the zero-element tuple type();{}is also equivalent toUnitin type positions;(A, B) -> Cdenotes a function that takesAandBand returnsC;- 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:
IntFloatBoolStringUnit
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 asfoo.bar(); public importre-exports the imported package from a package file;private importis 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:
paramis 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:
valdeclares an immutable binding;vardeclares 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
valorvar.
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;
ifexpressions;whenexpressions;forexpressions (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 { ... }copiesvalueand applies one or more updates;- update paths may select nested fields and use
#indexfor tuple and list positions; - an optional
: Typehint must match the selected source type and checks the replacement against that type; a?.bperforms nullable-safe field access;a!!asserts thatais non-null;value.(pkg.fun)(x, y)is sugar forpkg.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:
- record field access — if
ais a record type and has a fieldb; - method resolution — if a function
bexists whose first parameter matches the type ofa; - qualified name resolution — if
a.bis 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:
ifis an expression as well as a statement: if it appears at the head of a statement it is parsed as a statement. To useifas 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
isarm tests the subject against a type; as Identifierbinds the refined subject value inside that arm;whendoes not support range matching or general pattern matching;- an inline arm ends with
;; - a block arm does not use
;after its closing}; elseis optional;- at the head of a statement position,
whenis parsed as aBlockStatementand does not require a trailing;. To usewhenas 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?.bevaluates tonullwhenaisnull, otherwise it evaluates toa.b;a ?: bevaluates toawhenais non-null, otherwise tob;a!!evaluates toawhenais non-null and fails at runtime whenaisnull.
10. Precedence and Associativity
From highest precedence to lowest, Vox expressions are parsed in this order:
- postfix forms: calls, indexing, field access, safe field access,
!!, and receiver-call sugar; - unary
-and!; - multiplicative
*,/,%; - additive
+,-; - comparison
<,<=,>,>=; - equality
==,!=; - logical
&&; - logical
||; - ranges
..,..=; - null coalescing
?:; - 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;
breakandcontinueare valid only inside aforloop 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 ofexpr.
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 funmay 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
evildoes 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; orevil 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
econsnapshot 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
&ormut&; - 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.
| Flag | Description |
|---|---|
--mount PATH | Mount a library directory, .vox, or .voxlib file as a dependency (repeatable) |
-o OUTPUT | Output file path (default: input stem with .wasm or .voxlib) |
--package | Force .voxlib output even for script sources |
-h, --help | Show 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 viacompile_to_voxlib) - All other sources → raw wasm bytes
Use --package to override auto-detection and force .voxlib output.
Library Mounting
--mount PATH supports:
.voxlibfiles: decoded and registered in the compilation host registry; their manifests provide dependency surfaces during compilation.- Directories: scanned for
.voxlibfiles (non-recursive). .voxfiles: not supported directly; compile to.voxlibfirst.
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:
- Parse source into the frontend syntax tree.
- Analyze the source: resolve imports, declarations, names, calls, types, purity, captures, and script parameters.
- Lower executable bodies into MIR.
- Analyze and optimize MIR according to the requested optimization level.
- 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:
- Lower the initializer.
- Create a new
BindingId. - Create version zero for the binding.
- Map the source name to that version in the current lexical scope.
Assignment lowering:
- Resolve the target name to a mutable binding.
- Lower the right-hand expression.
- Create a new version for the same binding.
- Update the environment so later references use the new version.
Compound assignment lowering:
- Resolve the target name to the current value.
- Lower the right-hand expression.
- Emit the matching
binaryoperation. - 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
VersionIdcreation; - 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; :reseteffects.
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
Econmaintenance 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,
Econstate, 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:
versionis0on the initialHELLOrequest and the selected protocol version on every later frame;request_idis chosen by the client for requests and copied by the server into the matching response;target_idis0when the opcode does not act on an existing object;payload_lenmay be0;- after the header, exactly
payload_lenbytes follow.
kind values:
0: request1: success response2: error response3: event
flags are a bitset:
0x0000_0001: payload contains diagnostics0x0000_0002: payload contains an inline value0x0000_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:HELLO0x02:PING0x03:OPEN_SESSION0x04:EVALUATE_SESSION0x05:DROP_SESSION_ITEM0x06:RESET_SESSION0x07:SNAPSHOT_SESSION0x08:RESTORE_SESSION0x09:RUN_SESSION_SCRIPT0x0a:SET_SESSION_XOPT0x0b:CLOSE_SESSION0x0c:LIST_SESSIONS0x0d:SET_SESSION_RESERVED0x0e:SET_SESSION_OPT0x0f:GET_SESSION_OPT0x14:DUMP_SESSION_OPT0x10:MOUNT_LIBRARY0x11:UNMOUNT_LIBRARY0x20:LOAD_SCRIPT0x21:RELOAD_SCRIPT0x22:UNLOAD_SCRIPT0x23:SET_XOPT0x24:RUN_SCRIPT0x25:GET_OPT0x26:DUMP_OPT0x30:RETAIN_HANDLE0x31:DESCRIBE_HANDLE0x32:RELEASE_HANDLE0x33:READ_HANDLE_DATA0x40:REFRESH_ECON0x41:CACHE_STATS0x42:CLEAR_CACHE0x7f:SHUTDOWN
The server must reject unknown opcodes with ERR_UNSUPPORTED_OPCODE.
Primitive Encodings
The protocol uses only these primitive encodings:
u8,u16,u32,u64i64f64bytes:u32 lenfollowed bylenraw bytesstring:bytescontaining UTF-8
There is no map or self-describing object envelope at the frame level.
Optimization modes use these u8 values:
0:NOpt1:IOpt2: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 text1: wasm bytes rendered as diagnostic text
Value Encoding
Arguments and inline results use the Value encoding below:
tag: u8
payload: tag-specific
Tags:
0x00:null0x01:boolfollowed byu8(0or1)0x02:intfollowed byi640x03:floatfollowed byf640x04:string0x05:tuplefollowed byu32 count, thencountencoded values0x06:recordfollowed byu32 field_count, then repeatedstring nameplus encoded value0x07:handlefollowed byu32 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
handlevalue back as an argument; - when a result does not fit the inline limit, the runtime prefers
returning a
handleover 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:null0x01:bool0x02:int0x03:float0x04:string0x05:tuple0x06:record0x08:list
Rules:
- nested
handlevalues 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_ididentifies 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 = 0withopen_mode = createopens a fresh anonymous session;selector_kind = 1attaches to an existing session by id;selector_kind = 2withopen_mode = attachattaches to an existing named session;selector_kind = 2withopen_mode = createcreates a fresh named session and fails if that name already exists;selector_kind = 2withopen_mode = attach_or_createreattaches 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_XOPTupdates the connection default used by laterLOAD_SCRIPTandRELOAD_SCRIPTrequests;RUN_SCRIPTmay usexopt_override = 255to execute with the artifact’s compiled optimization level, or0,1, or2for 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-serializable0x0000_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_MISMATCH2:ERR_BAD_FRAME3:ERR_UNSUPPORTED_OPCODE4:ERR_UNKNOWN_LIBRARY5:ERR_UNKNOWN_SCRIPT6:ERR_UNKNOWN_HANDLE7:ERR_COMPILE_FAILED8:ERR_RUNTIME_FAILED9:ERR_BAD_ARGUMENT10: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_DROPPEDwith payloadu32 handle_id0x81:ECON_INVALIDATEDwith payloadstring econ_keyplusu64 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-runtimeserver.
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.
| Flag | Description |
|---|---|
-i, --interactive | Drop into REPL after running the script |
-s, --silent | Suppress stderr output of trailing expressions |
--connect ADDR | Connect to a remote runtime (host:port[@session]) |
--new | Create session if missing (requires --connect) |
-h, --help | Show help message |
Script arguments after -- are converted to Vox values and passed as
positional parameters to the script:
| Input | Vox type |
|---|---|
| Integer literal | Int |
| Float literal | Float |
true / false | Bool |
null | Null |
| Everything else | String |
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:portopens a fresh anonymous session on the remote runtime;--connect host:port@nameattaches to an existing named session;--connect host:port@idattaches to an existing session by id;--connect host:port@name --newattaches 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
:helpshows the command list.:quitexits the REPL.:resetclears the current interactive session state.:clearclears the terminal screen.:envprints visible imports, bindings, and functions.:chunkopens 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.:handleslists 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 formoduleand named functions. Modes areNOpt,IOpt, andSOpt.:opt dump [object]prints a MIR dump formoduleor a function when an optimized artifact exists. Prefix the object withwasm: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 reservetoggles whether the current session is kept when its endpoint count reaches zero.:session listshows 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.
Tabcompletes commands, snapshot names, handles, and visible symbols.Ctrl+Cinterrupts the current input line.Ctrl+Dexits the REPL.
:chunk and :edit choose an editor as follows:
- if
VOX_EDITOR=builtin, use the builtin multiline editor; - otherwise if
VOX_EDITORis set, run that command; - otherwise if
EDITORis 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;
.submitcommits the chunk;.cancelabandons 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 listto 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:
- Runs the Vox lexer and parser on the source text.
- Converts any parse errors into editor diagnostics with source positions.
- Treats files without a
package,script, orevil scriptheader 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-runtimeanalysis; - completions adapted from
vox-replcompletion 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:
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:
RuntimeRunnergives you a transport-neutral way to talk to a runtime;InteractiveSessiongives 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 withrestore_snapshot_source(). - Handle-backed large values: keep the handle, then fetch serializable data
later with
get_handle_data()or stream it in chunks withread_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>()orexport_trait::<T>()calls in normal usage; - there are no
.function(...)calls in normal usage; - unused exported items stay out of the final library.
Recommended Workflow
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.blurimage.filter_applyimage.Imageimage.Filter- the
Filter.applymethod 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:
- An explicit
#[vox(doc = "...")]override on the item. - 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
ExternalLibraryroot.
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>orVec<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:
namecontrols the public Vox method name;lowered_bynames 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->Intf64,f32->Floatbool->BoolString,&str->StringOption<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 aGeneratedExternalLibrarycontaining the complete.voxlibbytes, which can be written to disk withwrite_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
ExternalLibraryinfer the package.