Vexil
Vexil is a schema language and toolchain for exact binary protocols. The schema defines the data model and its representation: bit widths, field ordinals, integer encodings, collection bounds, and evolution metadata are reviewable in one contract.
Pre-1.0: Vexil's components version independently. Read the support matrix and compatibility limits before adoption.
The defining choice
In many schema systems, a type describes a value while the codec decides how it is represented. Vexil makes representation part of the type contract:
message Reading {
channel @0 : u4
value @1 : u16
sequence @2 : u32 @varint
offset @3 : i32 @zigzag
}
channel is four bits. sequence uses unsigned LEB128. offset uses ZigZag
followed by LEB128. Those are language rules rather than conventions hidden in
application code.
What the toolchain provides
- a compiler with source-spanned diagnostics;
- Rust, TypeScript, Go, and Python code generation;
- deterministic canonical schema hashes using BLAKE3;
- compatibility classification for schema evolution;
- Rust, TypeScript, Go, and Python runtimes with different documented evidence levels;
- a conformance corpus and golden byte vectors.
The wire is not self-describing, and Vexil does not define transport, authentication, discovery, or compression. Applications own those layers.
A practical path
- Install the compiler.
- Write and check a schema.
- Generate code for your target.
- Run the curated examples.
- Review the support matrix for the exact target combination you intend to ship.
Installation
vexilc is the compiler and command-line entry point. The generated target
runtimes are installed separately by the applications that use them.
Install from crates.io
With Rust installed:
cargo install vexilc
Use a release binary
Tagged vexilc releases publish archives and installers for supported Linux,
macOS, and Windows targets on the repository's
Releases page.
Use an asset from the exact release you selected; a local build does not prove that a newer release artifact has been published.
Build this checkout
The workspace minimum supported Rust version is 1.94.
git clone https://github.com/vexil-lang/vexil
cd vexil
cargo build --release --bin vexilc
The binary is written under target/release/.
Verify
vexilc --version
vexilc --help
Then continue to Your First Schema.
Target runtime installation belongs in Generating Code and the target-specific runtime chapters. Check the support matrix before selecting a new cross-language combination.
Your First Schema
Create hello.vexil:
namespace hello
message Greeting {
priority @0 : u3
name @1 : string @limit(64)
count @2 : u32 @varint
}
Or ask the CLI for a starting file:
vexilc init hello
Check the contract
vexilc check hello.vexil
On success, vexilc prints the canonical BLAKE3 schema hash and exits with
status 0. On failure, it reports the source span and a diagnostic explaining the
rejected contract.
Read the schema as wire instructions
namespace hellogives declarations a stable namespace.priority @0 : u3assigns ordinal 0 and exactly three wire bits.name @1 : string @limit(64)uses a length-prefixed UTF-8 string with an application-visible bound.count @2 : u32 @varintuses unsigned LEB128 instead of fixed-widthu32.
Ordinals are durable wire identities. Reordering source lines does not reorder the encoded fields.
Inspect the hash
vexilc hash hello.vexil
Comments and formatting do not affect the hash. A change to the compiled contract does.
Next: Generating Code, or run the complete Quickstart.
Generating Code
Single file
# Rust (default target)
vexilc codegen hello.vexil --target rust --output hello.rs
# TypeScript
vexilc codegen hello.vexil --target typescript --output hello.ts
# Go
vexilc codegen hello.vexil --target go --output hello.go
# Python
vexilc codegen hello.vexil --target python --output hello.py
Default target is rust. Output goes to stdout if --output is omitted.
Rust and TypeScript generated paths have broad cross-language byte-vector coverage. Generated Go and Python are verified against a representative shared wire matrix; verify application-specific schemas and environments separately.
Traits are emitted as structural contracts in every target. Portable trait functions become mutable instance methods:
- Rust uses
&mut self. - TypeScript keeps message interfaces and adds a
MessageFieldsinput type pluscreateMessageobject factory. Decode routes through the factory so decoded values carry their methods. - Go uses pointer-receiver methods.
- Python uses dataclass methods and emits static
Protocolconformance proofs underTYPE_CHECKING.
Portable bodies support literals, parameters, immutable let bindings,
self.field, receiver-field assignment, return, and unary/binary operators.
Free calls, method calls, local reassignment, non-receiver assignment, and
target identifier collisions fail code generation before output is produced.
Impl bodies are generated source templates; they do not add runtime dispatch or
wire metadata.
Multi-file project
For schemas with imports, use the build subcommand:
vexilc build root.vexil --include ./schemas --output ./generated --target rust
This resolves all imports, compiles in topological order, and generates one file per namespace.
Project code generation resolves traits imported directly by name, through a
unique wildcard, or through one explicit alias qualifier such as
impl Contracts.Tagged<u64> for Event. A trait reachable only through another
schema's imports is not re-exported: the implementing schema must import it
explicitly. Imported impl declarations remain in their defining schema and are
not copied into consumers.
Watch mode
Auto-rebuild on save:
vexilc watch root.vexil --include ./schemas --output ./generated --target typescript
Changes to any .vexil file in the watched directories trigger a rebuild with 200ms debounce.
Using generated code
Rust
Add vexil-runtime to your Cargo.toml:
[dependencies]
vexil-runtime = "0.5"
#![allow(unused)] fn main() { use vexil_runtime::{BitWriter, BitReader, Pack, Unpack}; let greeting = Greeting { name: "world".to_string(), message: "hello".to_string(), count: 42, _unknown: Vec::new(), }; // Encode let mut w = BitWriter::new(); greeting.pack(&mut w).unwrap(); let bytes = w.finish(); // Decode let mut r = BitReader::new(&bytes); let decoded = Greeting::unpack(&mut r).unwrap(); }
TypeScript
Install @vexil-lang/runtime:
npm install @vexil-lang/runtime
import { BitWriter, BitReader } from '@vexil-lang/runtime';
import { encodeGreeting, decodeGreeting } from './hello';
const w = new BitWriter();
encodeGreeting(
{ name: 'world', message: 'hello', count: 42, _unknown: new Uint8Array(0) },
w,
);
const bytes = w.finish();
const r = new BitReader(bytes);
const decoded = decodeGreeting(r);
Go
import vexil "github.com/vexil-lang/vexil/packages/runtime-go"
greeting := &Greeting{
Name: "world",
Message: "hello",
Count: 42,
}
w := vexil.NewBitWriter()
greeting.Pack(w)
bytes := w.Finish()
r := vexil.NewBitReader(bytes)
var decoded Greeting
decoded.Unpack(r)
The versioned Go runtime module is available as github.com/vexil-lang/vexil/packages/runtime-go@v0.1.1. See the Go runtime page for installation details.
Python
Install the published runtime from PyPI:
python -m pip install vexil-runtime
When testing changes from this checkout, you can instead install
./packages/runtime-py into an isolated environment.
from hello import Greeting
encoded = Greeting(name="world", message="hello", count=42).encode()
decoded = Greeting.decode(encoded)
assert decoded.count == 42
See the support matrix before choosing a production target combination.
Choose a Target
All four generators consume the same compiled schema, but their native evidence is not identical. Choose a target based on what is verified today, then test the specific schemas and environments your application will ship.
| Surface | Distribution | Evidence in this repository |
|---|---|---|
| Compiler and CLI | Rust crates and release binaries | Workspace tests, corpus, project graphs, diagnostics, and compatibility checks |
| Editor diagnostics | Source build from current main (vexilc lsp over stdio) | Full-document single-file synchronization, compiler diagnostics, and UTF-16 range tests |
| Rust generated code | vexil-runtime | Broad golden, native compile, Clippy, and byte-vector coverage |
| TypeScript generated code | @vexil-lang/runtime | Native type-check/build/tests and broad byte-vector coverage |
| Go generated code | versioned Go module | Native compile and execution over a representative shared wire matrix |
| Python generated code | vexil-runtime on PyPI | Static checking and native execution over a representative shared wire matrix |
The curated cross-language example compares one readable fixture across all four targets. The generated-wire test suite covers a larger representative matrix.
Shared contract points
Every maintained target is expected to agree on:
- LSB-first bit packing and little-endian multi-byte scalars;
- LEB128 and ZigZag integer encodings;
- field and variant ordinals;
- canonical collection ordering;
- Result discriminants (
0 = Err,1 = Ok); - bounded preservation of unknown non-exhaustive union variants;
- canonical BLAKE3 schema hashes.
Differences in generated language API shape are target-specific. Differences in wire bytes for the same schema and value are defects.
Current boundaries
- Go and Python coverage is representative, not exhaustive.
- The language server is newer than the published
vexilc0.6.0 CLI and is available only from a current source build. It is diagnostics-only and single-file: it does not load imports or projects and does not advertise completion, navigation, hover, formatting, incremental synchronization, or a bundled editor extension. - The Python runtime is published on PyPI. Its generated-code evidence remains representative rather than exhaustive.
- No independent implementation or external security audit has been completed.
- The compiler does not promise unimplemented constraints, RPC, transport, encryption profiles, reflection, or a standard library for a named version.
Continue with Compatibility and Limits before deploying a new protocol.
Compatibility and Limits
Vexil's first wire-format generation is stabilizing and the language specification remains a draft. Repository tests provide substantial internal evidence, but the format has not been independently implemented or audited.
Before adopting Vexil
- Select the exact generated targets from the support matrix.
- Compile and round-trip the schemas your application will ship.
- Compare generated bytes across every participating language.
- Define framing, authentication, resource limits, and schema distribution at the application layer.
- Test rolling upgrades with
vexilc compatand real old/new peers.
What hashes and compatibility checks do not do
A matching schema hash says two peers compiled the same canonical contract. It does not authenticate the peer, negotiate a transport, validate business rules, or prove that the surrounding application uses the codec correctly.
vexilc compat classifies schema changes according to the specification. It
does not prove application-level compatibility, migration correctness, or safe
deployment order.
Adding a message field is classified as breaking. Message values are not internally length-delimited, so nested and aggregate decoders cannot infer the old value boundary safely. Prefer a new declaration and explicit migration over an EOF-based default.
Maintained detail
The repository's Compatibility and Current Limits page records verified behavior, adoption boundaries, unavailable capabilities, and the evidence level for performance claims.
For a hands-on path, continue to Project Evolution.
Types
Primitive types
| Type | Size | Description |
|---|---|---|
bool | 1 bit | True or false |
u8 -- u64 | 8--64 bits | Unsigned integers |
i8 -- i64 | 8--64 bits | Signed integers (two's complement) |
f32 | 32 bits | IEEE 754 single-precision float |
f64 | 64 bits | IEEE 754 double-precision float |
fixed32 | 32 bits | Q16.16 fixed-point (two's complement) |
fixed64 | 64 bits | Q32.32 fixed-point (two's complement) |
Fixed-point types (fixed32, fixed64) give the protocol an explicit scaled
integer representation. That avoids platform-dependent wire representation for
fractional values. Applications still choose their arithmetic, overflow, and
rounding policy.
The @varint annotation is valid on fixed32 and fixed64, encoding the raw i32/i64 as unsigned LEB128 for variable-length wire representation.
Sub-byte types
| Type | Size | Description |
|---|---|---|
u1 -- u7 | 1--7 bits | Unsigned sub-byte integers |
i2 -- i7 | 2--7 bits | Signed sub-byte integers |
Sub-byte fields are packed LSB-first within each byte. Use them when the wire contract needs widths smaller than a byte, rather than modelling a packed value through application-side masks.
Semantic types
| Type | Wire encoding | Description |
|---|---|---|
string | LEB128 length + UTF-8 | Text |
bytes | LEB128 length + raw | Binary data |
uuid | 16 bytes | UUID as raw bytes |
timestamp | 64-bit signed | Unix epoch (interpretation is application-defined) |
rgb | 3 bytes | Red, green, blue |
hash | 32 bytes | BLAKE3 hash |
Parameterized types
| Type | Description |
|---|---|
optional<T> | Presence bit + value |
array<T> | LEB128 count + elements |
array<T, N> | Fixed-size array (no count prefix, N elements) |
map<K, V> | LEB128 count + sorted key-value pairs |
result<T, E> | Boolean tag + ok or error value |
set<T> | LEB128 count + sorted unique elements |
Fixed-size arrays (array<T, N>) have no length prefix on the wire -- the size is part of the schema. N must be a compile-time constant.
Sets (set<T>) are unordered unique collections. Elements are sorted on encode for deterministic wire representation. Duplicates are silently deduplicated.
Geometric types
Graphics and simulation primitives with deterministic wire encoding:
| Type | Components | Description |
|---|---|---|
vec2<T> | x, y | 2D vector |
vec3<T> | x, y, z | 3D vector |
vec4<T> | x, y, z, w | 4D vector or homogeneous coordinate |
quat<T> | x, y, z, w | Quaternion rotation |
mat3<T> | 9 components | 3x3 matrix (column-major) |
mat4<T> | 16 components | 4x4 matrix (column-major) |
Valid element types: fixed32, fixed64, f32, f64.
Examples:
message Transform {
position @0 : vec3<fixed64> # deterministic simulation position
rotation @1 : quat<fixed64> # deterministic quaternion
gl_pos @2 : vec3<f32> # GPU-ready render position
model @3 : mat4<f32> # 4x4 transform matrix
}
Wire encoding: components written in order (x, y, z, w), no padding, no count prefix. Total size = N components x element size.
Inline bitfields
Anonymous flags for compact permission or storage bits:
message FileHeader {
perms @0 : bits { r, w, x, hidden, system }
}
Wire encoding: exactly N bits (one per named flag), LSB-first. The example above uses 5 bits.
Wire encoding
All types encode to a deterministic byte sequence. Fixed-size types pack at their natural bit width. Variable-length types (string, bytes, array, map, set) use LEB128 length prefixes.
The @varint annotation changes a fixed-width integer to unsigned LEB128 encoding. The @zigzag annotation uses ZigZag encoding for signed integers (small magnitudes use fewer bytes). The @delta annotation generates stateful encoder/decoder pairs that transmit field-level deltas.
See the language specification for complete encoding rules.
Messages
Messages are the primary data type in Vexil -- ordered, typed fields with explicit ordinals.
message SensorReading {
channel @0 : u4
kind @1 : SensorKind
value @2 : u16
sequence @3 : u32 @varint
}
Fields are encoded in ordinal order. Each field has a name, an ordinal (@N), and a type.
Field ordinals
Ordinals determine wire order. They must be unique within a message but do not need to be sequential. Gaps can reserve positions and make a schema easier to read, but filling a gap or appending a field still changes the message contract.
message Config {
name @0 : string
# @1 was removed
timeout @2 : u32
retries @3 : u8
}
Field annotations
Fields can carry encoding annotations:
message Packet {
sequence @0 : u32 @varint # LEB128 variable-length encoding
delta @1 : i32 @zigzag # ZigZag encoding for signed values
payload @2 : bytes
}
Wire encoding
Fields are packed in ordinal order with LSB-first bit packing. Sub-byte fields (like u4) pack tightly -- two u4 fields occupy a single byte. After all fields, the encoder flushes to a byte boundary.
Unknown fields
Generated message types carry target-specific storage for unknown bytes, but decoders do not populate it. The storage is empty after every decode. Vexil does not currently provide lossless unknown-field round-tripping.
Message values are not internally length-delimited. A decoder therefore cannot identify unknown trailing fields safely when the message is nested inside a parent or inline aggregate. Adding a field is classified as breaking. See schema evolution for the boundary and migration guidance.
See the language specification for the full normative reference.
Enums and Flags
Enums
Enums define a closed set of named variants with a fixed-width backing type.
enum Direction : u8 {
North @0
East @1
South @2
West @3
}
The backing type (: u8) determines the wire size. Variant ordinals (@N) are the values written to the wire.
Non-exhaustive enums
By default, enums are exhaustive -- receiving an unknown variant is an error. Use @non_exhaustive to allow future additions:
@non_exhaustive
enum Status : u8 {
Active @0
Inactive @1
}
A non-exhaustive enum can safely add variants in newer schema versions without breaking existing decoders.
Flags
Flags are bitmask types where each named bit occupies a specific position in a fixed-width integer.
flags Permissions : u8 {
Read @0
Write @1
Execute @2
}
Multiple flags can be set simultaneously. The ordinal (@N) is the bit position, not the value -- Read @0 means bit 0 (value 1), Write @1 means bit 1 (value 2), Execute @2 means bit 2 (value 4).
Flags encode as their backing type on the wire. A flags Permissions : u8 always occupies exactly 8 bits.
See the language specification for the full normative reference.
Unions
Unions represent a value that can be one of several typed variants. They are Vexil's tagged union / sum type.
union Shape {
Circle @0 : f32 # radius
Rectangle @1 : Dimensions
Point @2 # no payload
}
Wire encoding
A union encodes as a discriminant tag followed by the variant payload. The tag type is determined by the number of variants -- the compiler picks the smallest unsigned integer that fits.
Non-exhaustive unions
Like enums, unions can be marked @non_exhaustive to allow adding variants without breaking existing decoders:
@non_exhaustive
union Event {
Click @0 : ClickData
Scroll @1 : ScrollData
}
Variants with and without payloads
Variants can carry a payload type or be empty:
union Result {
Ok @0 : Data
Error @1 : string
Empty @2
}
See the language specification for the full normative reference.
Newtypes and Configs
Newtypes
A newtype wraps an existing type with a distinct name. On the wire, it encodes identically to the underlying type.
newtype UserId = u64
newtype Temperature = f32
Newtypes provide type safety in generated code -- a UserId and a raw u64 are different types even though they have the same wire representation.
Newtypes with annotations
newtype CompactId = u64 @varint
The annotation applies to the wire encoding of the underlying type.
Configs
Configs are compile-time constant declarations. They do not appear on the wire but are available in generated code as constants.
config MAX_PACKET_SIZE : u32 = 1500
config VERSION : string = "1.0.0"
Configs are useful for sharing magic numbers and version strings between schema and application code without encoding them in every message.
See the language specification for the full normative reference.
Type Aliases and Constants
Type Aliases
A type alias gives an existing type a new name. It's transparent — same wire encoding, same codegen, just a different name in the schema.
type UserId = u64
type Token = bytes
type DFixed = fixed64
type Labels = array<string>
type Lookup = map<string, optional<u32>>
UserId and u64 produce identical bytes. The alias exists only in the schema source, making fields more readable.
Rules
- The target must be a concrete type, not another alias
- Alias chains are rejected:
type A = u64thentype B = Awon't compile - Aliases can be imported:
import { UserId } from my.types
Constants
Constants are named compile-time values. They don't exist on the wire — they're resolved during compilation and disappear.
const MaxHealth : u32 = 100
const TickRate : u32 = 64
Where You Can Use Them
- Array sizes:
array<u8, MaxHealth> - Where clause bounds:
where value in 0..MaxHealth - Other constant expressions (see below)
Cross-References
Constants can reference each other with +, -, *, /:
const TicksPerSec : u32 = 64
const TickMs : u32 = 1000 / TicksPerSec # 15
const TwoSecTicks : u32 = TicksPerSec * 2 # 128
Division truncates toward zero (integer division). Division by zero and circular dependencies are caught at compile time — you'll get a diagnostic, not a runtime panic.
Supported Types
bool, u8–u64, i8–i64, f32, f64, fixed32, fixed64. Messages, enums, unions — none of those. Constants are values, not types.
Where Clauses
Where clauses add validation constraints to fields. They run automatically on encode and decode — if the data violates the constraint, you get an error and nothing goes on the wire.
Basic Syntax
message Player {
health @0 : u8 where value >= 0 && value <= 100
name @1 : string where len(value) >= 1 && len(value) <= 32
level @2 : u16 where value in 1..999
}
value refers to the field being validated. The expression must evaluate to a boolean.
Comparison Operators
==, !=, <, >, <=, >=. They work like you'd expect.
Logical Operators
&&, ||, !. Standard boolean logic.
message Request {
code @0 : u16 where value >= 100 && value < 600
flag @1 : u8 where value == 0 || value == 1 || value == 255
}
Range Expressions
# Inclusive: 0 to 100 (both endpoints included)
health @0 : u8 where value in 0..100
# Exclusive: 0 to 99 (upper bound excluded)
age @1 : u8 where value in 0..<100
Bounds can be constants:
const MaxLevel : u16 = 999
level @0 : u16 where value in 1..MaxLevel
len()
Returns the length of a string, bytes, array, map, or set:
message User {
username @0 : string where len(value) in 3..32
tags @1 : array<string> where len(value) <= 10
key @2 : bytes where len(value) == 32
}
Current boundaries
- Cross-field constraints:
where amount <= balancecannot reference other fields. This is backlog work, not a promise for a named release. - Regex:
where value matches "..."doesn't exist. Use a length check and validate regex in application code. - User-defined functions: you can only use the built-in operators and
len().
Error Behavior
On constraint violation:
- Encode: returns
EncodeError::ConstraintViolation, nothing written - Decode: returns
DecodeError::ConstraintViolation, partial data discarded
Invalid data never makes it onto the wire. That's the whole point.
Annotations
Annotations modify the behavior of types, fields, and declarations. They are prefixed with @.
Encoding annotations
These change how a field is encoded on the wire:
| Annotation | Applies to | Effect |
|---|---|---|
@varint | unsigned integers | LEB128 variable-length encoding |
@zigzag | signed integers | ZigZag encoding (small magnitudes use fewer bytes) |
@delta | numeric fields in arrays | Delta encoding (store differences, not absolute values) |
message Packet {
sequence @0 : u32 @varint
offset @1 : i32 @zigzag
}
Declaration annotations
| Annotation | Applies to | Effect |
|---|---|---|
@non_exhaustive | enum, union | Allows adding variants without breaking decoders |
@deprecated | fields, variants | Marks as deprecated in generated code |
@removed(ordinal, reason: "...") | declaration bodies | Reserves a removed ordinal; optional original type is history metadata |
@non_exhaustive
enum Status : u8 {
Active @0
@deprecated
Legacy @1
Suspended @2
}
Removed fields
When evolving a schema, use @removed to reserve the old ordinal and explain
the removal. An optional type records the old field's type for readers of the
schema:
message Config {
name @0 : string
@removed(1, reason: "migrated to timeout_ms") : u32
timeout_ms @2 : u64
}
The recorded type is metadata only. It causes no encoder or decoder operation, and a tombstone does not make field removal wire-compatible.
See the language specification for the full normative reference.
Imports
Vexil supports multi-file schemas with explicit imports. This allows you to split large schemas into reusable modules.
Basic imports
import common.types
namespace myapp.protocol
message Request {
id @0 : common.types.RequestId
action @1 : string
}
The imported namespace must be resolvable via the include paths passed to vexilc build.
Project compilation
When using imports, use vexilc build instead of vexilc codegen:
vexilc build root.vexil --include ./schemas --output ./generated --target rust
The compiler:
- Parses the root file and discovers imports
- Resolves each import against the include directories
- Compiles all schemas in topological order (dependencies first)
- Generates code for each schema with proper cross-file references
Diamond dependencies
If A imports B and C, and both B and C import D, the compiler deduplicates D. Each type is compiled exactly once, and generated code references the canonical location.
Generated imports
Each target language handles cross-file references idiomatically:
- Rust:
usestatements referencing sibling modules - TypeScript: relative
importstatements with barrelindex.tsfiles; each barrel exposes child modules as<child>Schemanamespace objects so per-schema metadata constants do not collide - Go: standard package imports
See the language specification for the full normative reference.
Schema Evolution
Vexil makes schema changes explicit and classifies whether existing message values can still be decoded safely. Because message values are not internally length-delimited, adding a field is a breaking change.
Compatible changes
These changes are safe (v1 and v2 can interoperate):
| Change | Classification |
|---|---|
Add a variant to @non_exhaustive enum/union | Minor |
| Add a new declaration | Minor |
Mark a field @deprecated | Patch |
| Rename a field (ordinal unchanged) | Patch |
Breaking changes
These changes require all peers to upgrade simultaneously:
| Change | Why |
|---|---|
| Add a field | Nested and aggregate message values have no old-schema boundary |
| Remove a field | Wire layout changes |
| Change a field's type | Wire encoding differs |
| Change a field's ordinal | Wire order changes |
Add/remove @varint, @zigzag, @delta | Encoding differs |
Detecting breaking changes
vexilc compat v1/schema.vexil v2/schema.vexil
Output:
✗ field "flags" added at @2 BREAKING (major)
✗ field "timeout" type u32 → optional<u32> BREAKING (major)
Result: BREAKING — requires major version bump
JSON output for CI integration:
vexilc compat v1.vexil v2.vexil --format json
The compat command exits with code 0 for compatible changes and code 1 for breaking changes, making it suitable for CI gates.
Why appending is breaking
A bounded top-level reader may be able to stop after its known fields, but that does not make the schema change generally compatible. A nested message is encoded directly beside its parent's following fields. A newer nested decoder cannot tell whether the next bytes contain its appended field or the parent's next field. Arrays of inline messages have the same problem between elements.
Do not treat end of input as a general evolution marker. It would turn some truncated required fields into defaults. Add a new declaration and migrate explicitly, or coordinate a major-version transition for the changed message.
Typed tombstones
When removing a field, use @removed to reserve its ordinal and document why
it disappeared. You can retain the original type as historical metadata:
message Config {
name @0 : string
@removed(1, reason: "migrated to timeout_ms") : u32
timeout_ms @2 : u64
}
The type after the tombstone is metadata only. Generated codecs do not read or write bytes for it, and changing it does not change the schema hash. Removing a field still changes the wire layout and remains a breaking change; the tombstone prevents accidental ordinal reuse rather than making old and new payloads interoperable.
See the language specification for the full normative reference.
Delta Encoding
Delta encoding is an annotation that instructs the encoder to write differences between consecutive values instead of absolute values. This is useful for time-series data where consecutive readings are close together.
message TimeSeries {
timestamps @0 : array<u64 @delta>
values @1 : array<i32 @delta @zigzag>
}
How it works
With @delta, the encoder writes:
- The first value as-is
- Each subsequent value as
current - previous
The decoder reverses the process, accumulating deltas to reconstruct absolute values.
When to use delta encoding
Delta encoding is most effective when:
- Values increase monotonically (timestamps, sequence numbers)
- Consecutive values are close together (sensor readings, coordinates)
- Combined with
@varintor@zigzag-- small deltas compress to fewer bytes
Combining annotations
Delta encoding composes with other encoding annotations:
message GpsTrack {
timestamps @0 : array<u64 @delta @varint> # monotonic, small deltas
latitudes @1 : array<i32 @delta @zigzag> # signed deltas near zero
longitudes @2 : array<i32 @delta @zigzag>
}
Note: Delta encoding support is currently specified but implementation may vary by backend. Check the limitations document for current status.
See the language specification for the full normative reference.
Compression Layering
Vexil messages are canonical, uncompressed bytes. Compression belongs outside the generated codec, so it does not change schema hashes, compatibility, or the meaning of a Vexil payload.
For an application protocol, use an explicit envelope:
- Encode one bounded Vexil message or a deliberately defined batch.
- Compress that complete frame or batch.
- Apply integrity protection or authenticated encryption to the compressed bytes when the protocol requires it.
- Carry the compression algorithm, dictionary identity, compressed length, decompressed limit, and schema identity in the surrounding protocol.
Do not infer compression from the payload. Reject unknown algorithms and dictionary identifiers, truncated streams, trailing compressed data when the profile forbids it, and output that exceeds the declared decompressed limit. Bound both compressed input and decompressed output before allocating. These limits are application or transport policy and are separate from Vexil's collection and recursion limits.
Compress before encryption; encrypted bytes are not usefully compressible, and compressing attacker-controlled and secret material together can create side channels. A real transport profile still needs its own threat model and rules for negotiation, authentication, replay, and failure handling.
The compressed .vxb form in vexil-store is a file-container feature. It does
not make ordinary generated wire messages compressed and should not be treated
as a transport profile.
vexilc
vexilc is the Vexil schema compiler. It validates schemas, generates code for multiple target languages, and provides tools for schema evolution and binary file inspection.
Usage
vexilc <subcommand> [args]
Subcommands
| Command | Description |
|---|---|
check | Validate a schema and print its hash |
codegen | Generate code for a single schema file |
build | Generate code for a multi-file project |
watch | Watch files and rebuild on changes |
compat | Compare schemas for breaking changes |
init | Create a new schema file |
hash | Print the BLAKE3 schema hash |
lsp | Publish compiler diagnostics to an editor (current source builds; not vexilc 0.6.0) |
pack | Encode a .vx text file to .vxb binary |
unpack | Decode a .vxb binary file to .vx text |
format | Format a .vx text file |
info | Inspect .vxb/.vxc file headers |
compile | Compile a schema to .vxc binary format |
Global options
| Option | Description |
|---|---|
-V, --version | Print version |
-h, --help | Print help |
Targets
The --target option (used by codegen, build, and watch) accepts:
rust(default)typescriptgopython
check
Validate a Vexil schema file and print its BLAKE3 hash.
Usage
vexilc check <file.vexil>
Example
$ vexilc check sensor.vexil
schema hash: a1b2c3d4e5f6...
If the schema has errors, they are printed with source spans and the command exits with code 1:
Error: unknown type `strin`
╭─[ sensor.vexil:4:18 ]
│
4 │ name @0 : strin
│ ──┬──
│ ╰── UnknownType
───╯
Exit codes
| Code | Meaning |
|---|---|
| 0 | Schema is valid |
| 1 | Schema has errors |
codegen
Generate code from a single Vexil schema file.
Usage
vexilc codegen <file.vexil> [--target <target>] [--output <path>]
Options
| Option | Default | Description |
|---|---|---|
--target <target> | rust | Code generation target: rust, typescript, go, or python |
--output <path> | stdout | Write output to a file instead of stdout |
Examples
# Generate Rust to stdout
vexilc codegen sensor.vexil
# Generate TypeScript to a file
vexilc codegen sensor.vexil --target typescript --output sensor.ts
# Generate Go
vexilc codegen sensor.vexil --target go --output sensor.go
# Generate Python
vexilc codegen sensor.vexil --target python --output sensor.py
Notes
- For schemas with imports, use
buildinstead - The generated code depends on the corresponding runtime library (
vexil-runtimefor Rust,@vexil-lang/runtimefor TypeScript,vexil-runtimeGo module, orvexil_runtimePython module) - Schema errors are reported before code generation begins
- Traits and the portable impl-function body subset are generated in every target; unsupported calls, assignments, types, and target-name collisions are reported before output
build
Generate code for a multi-file Vexil project with imports.
Usage
vexilc build <root.vexil> --include <dir> --output <dir> [--target <target>]
Options
| Option | Default | Description |
|---|---|---|
--include <dir> | (none) | Directory to search for imported schemas (can be repeated) |
--output <dir> | (required) | Output directory for generated code |
--target <target> | rust | Code generation target: rust, typescript, go, or python |
Vexil validates the complete generated path set before creating directories or
writing files. Rooted, traversing, non-portable, or case-colliding output paths
fail with a codegen-output-* diagnostic. A later filesystem I/O failure can
still leave files written earlier in the operation.
Example
vexilc build protocol.vexil \
--include ./schemas \
--output ./generated \
--target rust
Output:
wrote ./generated/common/types.rs
wrote ./generated/protocol.rs
build complete: 3 schemas compiled
How it works
- Parses the root schema file
- Discovers
importstatements and resolves them against--includedirectories - Compiles all schemas in topological order (dependencies before dependents)
- Generates one output file per namespace, with cross-file references handled by the backend
- Handles diamond dependencies by deduplicating shared imports
watch
Watch for file changes and automatically rebuild.
Usage
vexilc watch <root.vexil> [--include <dir>] [--output <dir>] [--target <target>]
Options
| Option | Default | Description |
|---|---|---|
--include <dir> | (none) | Additional directories to watch and search for imports |
--output <dir> | (none) | Output directory (if omitted, runs check only) |
--target <target> | rust | Code generation target |
Example
vexilc watch protocol.vexil --include ./schemas --output ./generated --target typescript
Output:
[watch] Initial build...
wrote ./generated/protocol.ts
build complete: 2 schemas compiled
[watch] Ready. Watching for changes...
Behavior
- Performs an initial build on startup
- Watches the root file's directory and all
--includedirectories recursively - Only reacts to
.vexilfile changes (creates and modifications) - Debounces rapid changes with a 200ms delay
- If
--outputis omitted, runscheckon each change instead of a full build
compat
Compare two schema versions and detect breaking changes.
Usage
vexilc compat <old.vexil> <new.vexil> [--format <human|json>]
Options
| Option | Default | Description |
|---|---|---|
--format <format> | human | Output format: human or json |
Example
$ vexilc compat v1/sensor.vexil v2/sensor.vexil
✗ field "flags" added at @2 BREAKING (major)
✗ field "timeout" type u32 → u64 BREAKING (major)
Result: BREAKING — requires major version bump
JSON output
$ vexilc compat v1.vexil v2.vexil --format json
{
"changes": [
{
"kind": "field_added",
"declaration": "SensorReading",
"field": "flags",
"detail": "field \"flags\" added at @2",
"classification": "major"
}
],
"result": "breaking",
"suggested_bump": "major"
}
Exit codes
| Code | Meaning |
|---|---|
| 0 | Compatible changes only |
| 1 | Breaking changes detected |
| 2 | Schema compilation error |
Detected changes
The compat checker detects field additions, removals, type changes, ordinal changes, renames, deprecations, encoding changes, variant additions/removals, declaration additions/removals, namespace changes, and flags bit changes.
init
Create a new Vexil schema file with a starter template.
Usage
vexilc init [name]
Example
$ vexilc init myapp
Created myapp.vexil
This creates myapp.vexil with a starter schema:
namespace myapp
message Hello {
name @0 : string
greeting @1 : string
count @2 : u32
}
Notes
- The command refuses to overwrite an existing file
- The name becomes both the filename and the namespace
hash
Print the BLAKE3 hash of a compiled schema.
Usage
vexilc hash <file.vexil>
Example
$ vexilc hash sensor.vexil
a1b2c3d4e5f67890... sensor.vexil
How it works
The hash is computed from the canonical form of the schema, not the raw source text. This means:
- Whitespace differences don't affect the hash
- Comment differences don't affect the hash
- Reordering declarations (without changing semantics) may or may not affect the hash, depending on the canonical form rules
Two schemas that describe the same types with the same encoding produce the same hash. This enables:
- Schema identity verification at connection time
- Content addressing for cached compilations
- Detecting when a schema has actually changed vs. just been reformatted
lsp
Start Vexil's diagnostics language server over standard input and output.
This command is available in current source builds. It is newer than the published
vexilc0.6.0 CLI and is not included incargo install vexilcyet.
Usage
vexilc lsp
Configure an editor or LSP client to launch vexilc with the single argument
lsp for Vexil documents. The process reserves stdout for Language Server
Protocol messages; operational errors are written to stderr.
Supported workflow
The server advertises UTF-16 positions and full-document text synchronization with open and close notifications. It compiles the editor's in-memory text on every open or full change, so diagnostics do not require the file to be saved. Published diagnostics include:
- an end-exclusive source range;
- error or warning severity;
- the compiler diagnostic code;
vexilcas the source;- the message and any attached notes or suggestions;
- the current document version after open or change.
Replacing invalid text with valid text clears the diagnostics. Closing a document also clears them.
Current boundary
This is a diagnostics-only, single-file server. It does not load imports or
projects, so imported names may remain unresolved in the editor even when a
saved project succeeds with vexilc check --include or vexilc build.
The server does not advertise incremental changes, completion, navigation, references, rename, hover, formatting, code actions, workspace indexing, or an editor extension. Unsupported requests receive the standard JSON-RPC method-not-found response.
Rust Runtime
The vexil-runtime crate provides the runtime support needed by Vexil-generated Rust code.
Installation
[dependencies]
vexil-runtime = "0.5"
Core types
BitWriter
Encodes data into a byte buffer with LSB-first bit packing.
#![allow(unused)] fn main() { use vexil_runtime::BitWriter; let mut w = BitWriter::new(); w.write_bits(0b1010, 4); // write 4 bits w.write_u8(255); // write a full byte w.write_leb128(12345); // write an unsigned LEB128 integer let bytes = w.finish(); // flush and return the byte buffer }
BitReader
Decodes data from a byte buffer with LSB-first bit packing.
#![allow(unused)] fn main() { use vexil_runtime::BitReader; let mut r = BitReader::new(&bytes); let nibble = r.read_bits(4)?; // read 4 bits let byte = r.read_u8()?; // read a full byte let value = r.read_leb128(10)?; // read an unsigned LEB128 integer }
Pack and Unpack traits
Generated message types implement Pack and Unpack:
#![allow(unused)] fn main() { use vexil_runtime::{BitWriter, BitReader, Pack, Unpack}; // Encode let mut w = BitWriter::new(); my_message.pack(&mut w)?; let bytes = w.finish(); // Decode let mut r = BitReader::new(&bytes); let decoded = MyMessage::unpack(&mut r)?; }
API documentation
Full API documentation is available on docs.rs/vexil-runtime.
Source
TypeScript Runtime
The @vexil-lang/runtime npm package provides the bit reader and writer used by
Vexil-generated TypeScript code.
Installation
npm install @vexil-lang/runtime
Zero dependencies.
Core types
BitWriter
import { BitWriter } from '@vexil-lang/runtime';
const w = new BitWriter();
w.writeBits(0b1010, 4); // write 4 bits
w.writeU8(255); // write a full byte
w.writeLeb128(12345); // write an unsigned LEB128 integer
const bytes = w.finish(); // flush and return Uint8Array
BitReader
import { BitReader } from '@vexil-lang/runtime';
const r = new BitReader(bytes);
const nibble = r.readBits(4); // read 4 bits
const byte = r.readU8(); // read a full byte
const value = r.readLeb128(); // read an unsigned LEB128 integer
Generated code usage
import { BitWriter, BitReader } from '@vexil-lang/runtime';
import { encodeMyMessage, decodeMyMessage } from './generated/my_message';
// Encode
const w = new BitWriter();
encodeMyMessage(myData, w);
const bytes = w.finish();
// Decode
const r = new BitReader(bytes);
const decoded = decodeMyMessage(r);
Compliance
Generated TypeScript has broad coverage against the same byte vectors as the Rust reference implementation. See the support matrix for the exact project-level claim.
Source
Go Runtime
The Go runtime provides BitWriter and BitReader for Vexil-generated Go code.
Installation
The Go runtime is available as the versioned module github.com/vexil-lang/vexil/packages/runtime-go@v0.1.1. It requires Go 1.22 or later.
go get github.com/vexil-lang/vexil/packages/runtime-go@v0.1.1
Generated Go and its runtime are exercised together against a representative shared wire matrix. This is not exhaustive for every schema or environment.
Core types
BitWriter
import vexil "github.com/vexil-lang/vexil/packages/runtime-go"
w := vexil.NewBitWriter()
w.WriteBits(0b1010, 4) // write 4 bits
w.WriteU8(255) // write a full byte
w.WriteLeb128(12345) // write an unsigned LEB128 integer
bytes := w.Finish() // flush and return byte slice
BitReader
r := vexil.NewBitReader(bytes)
nibble, err := r.ReadBits(4)
if err != nil {
return err
}
b, err := r.ReadU8()
if err != nil {
return err
}
value, err := r.ReadLeb128(10)
if err != nil {
return err
}
Generated code usage
Generated Go structs implement Pack and Unpack methods:
// Encode
w := vexil.NewBitWriter()
myMessage.Pack(w)
bytes := w.Finish()
// Decode
r := vexil.NewBitReader(bytes)
var decoded MyMessage
decoded.Unpack(r)
Source
Python Runtime
The Python runtime provides bit-level I/O for Vexil-generated Python code.
Install
Install the published runtime from PyPI:
python -m pip install vexil-runtime
It requires Python 3.10 or later.
To test the current checkout, install from the repository root:
python -m pip install ./packages/runtime-py
Compatibility
Generated Python and its runtime are exercised together against a representative shared wire matrix. This is not exhaustive for every schema or environment.
Source
Writing a Codegen Backend
Vexil exposes code generation as a Rust API. Use it when an application needs
to generate a built-in target without starting vexilc, or when you are
building a target that lives outside the Vexil workspace.
This API is not a CLI plugin system. vexilc selects Rust, TypeScript, Go, and
Python through a closed match in the binary. A third-party backend is called by
your Rust program unless you maintain a custom vexilc build.
Use a built-in backend
Each built-in backend crate exports a zero-sized backend value:
#![allow(unused)] fn main() { use vexil_codegen_rust::RustBackend; use vexil_lang::{CodegenBackend, CodegenError}; fn generate_rust(source: &str) -> Result<Option<String>, CodegenError> { let result = vexil_lang::compile(source); if result.has_errors() { for diagnostic in result.errors() { eprintln!("{}", diagnostic.message); } return Ok(None); } match result.compiled { Some(compiled) => RustBackend.generate(&compiled).map(Some), None => Ok(None), } } }
The equivalent values are TypeScriptBackend, GoBackend, and
PythonBackend from their respective vexil-codegen-* crates. Use the trait
method rather than the backend crate's convenience function when the caller
needs to select a backend dynamically.
Understand the two inputs
CompiledSchema is one resolved schema. Its declarations list contains only
types declared in that source file. Its registry can also contain imported
types, so iterating the complete registry would duplicate dependency output.
ProjectResult contains every compiled schema in dependency-first topological
order plus the combined diagnostics. Pass the complete project to
generate_project; the backend needs that context to produce imports and
module scaffolding.
Compilation can return IR alongside diagnostics. Treat any error-severity diagnostic as a stop condition. Generating from a result with errors can turn a useful compiler diagnostic into confusing or incomplete target output.
Implement the trait
The complete compiling example lives in the
CodegenBackend rustdoc.
Its shape is:
#![allow(unused)] fn main() { pub trait CodegenBackend { fn name(&self) -> &str; fn file_extension(&self) -> &str; fn generate(&self, schema: &CompiledSchema) -> Result<String, CodegenError>; fn generate_project( &self, project: &ProjectResult, ) -> Result<BTreeMap<PathBuf, String>, CodegenError>; } }
Before implementing it, answer these questions:
- Which Vexil declarations, resolved types, and annotations does the target support?
- How are authored names escaped, and what is the collision domain after escaping?
- Which runtime package or generated support code owns wire operations?
- How do imported types map to target-language imports?
- Which relative file path belongs to each namespace, and which barrel or module files are required?
- How will the backend reject unsupported constructs before emitting partial output?
- Which golden, native compiler, and wire-vector checks prove the result?
Output ownership and determinism
generate returns one source string. It does not choose a filename or write to
disk.
generate_project returns a BTreeMap<PathBuf, String>. Every path is relative
to the output directory chosen by the caller. Build that map with
ProjectOutputBuilder:
#![allow(unused)] fn main() { use vexil_lang::ProjectOutputBuilder; let mut output = ProjectOutputBuilder::new(); output.add("demo/generated.rs", "// generated source\n")?; let files = output.finish(); Ok::<(), vexil_lang::OutputPathError>(()) }
The builder accepts a conservative portable path grammar: one or more ASCII
components containing letters, digits, _, -, or ., separated by either
path separator. It rejects roots and drive prefixes, . and .., repeated,
mixed, or trailing separators, Windows device names, and case-insensitive
collisions. This is intentionally narrower than any one host filesystem.
Existing backend implementations remain valid and are not deprecated. A custom
backend may continue returning a raw map, while a caller can apply
validate_project_output and write the canonical map it returns. The function
consumes the original map and reconstructs every accepted path using the host's
separator. Validation prevents lexical path escape. It does not follow symlinks
or junctions and does not make a sequence of filesystem writes transactional.
For identical compiler input and backend configuration, return identical paths
and bytes. Sort any data derived from hash maps or sets before emitting it. The
BTreeMap stabilizes file iteration, but it cannot make each file's contents
deterministic for you.
The caller owns directory creation, generated-output writes, overwrite policy, atomic replacement, formatting, and cleanup of stale files. Keeping those operations outside the backend makes generation testable without generated-file side effects. A backend may still read an explicitly configured auxiliary resource such as a template.
Error model
Use the narrow shared variants when they describe the failure:
UnsupportedTypefor a resolved type the target cannot represent.MissingAnnotationwhen the target contract requires explicit schema metadata.Ioonly when the backend itself performs necessary I/O. Most backends do not need it because callers write returned files.BackendSpecificfor a typed target error such as an escaped-name collision or an unsupported target-language construct.
ProjectOutputBuilder::add returns OutputPathError directly. When a backend
uses ? from generate_project, Vexil places that error in the existing
CodegenError::BackendSpecific variant. A caller can downcast the boxed error
to OutputPathError and use its stable diagnostic_id.
Validate the whole input before expensive emission where practical. On error, return no project map. Do not make callers distinguish trustworthy files from partial files.
Project checklist
A project backend should test at least:
- a single schema with no imports;
- a direct import and a transitive import;
- a diamond dependency without duplicate output;
- authored aliases and target-name collisions;
- deterministic paths and file contents across repeated runs;
- an unsupported construct that returns an error and no partial project;
- target-native parsing or compilation of every generated file;
- applicable shared wire vectors when the backend emits codecs.
The built-in backends are useful implementation references, but the language specification, wire-format specification, corpus, and compliance vectors are the contract authorities.
Quickstart
The quickstart is the shortest complete Vexil path: check one schema, inspect its canonical hash, generate a Rust codec, encode exact bytes, and decode them.
cargo run --manifest-path examples/quickstart/Cargo.toml
The schema uses a four-bit channel, a compact enum, unsigned LEB128, and ZigZag encoding. The executable prints the complete schema hash and payload before verifying the round trip.
Read the guided example README for the file-by-file walkthrough and regeneration command.
Next: Project Evolution.
Project Evolution
Real protocols rarely remain in one file or one version. This example combines two workflows:
vexilc buildresolves imports and emits a Rust module tree;vexilc compatdistinguishes a compatible declaration addition from a breaking field-type change.
Run the complete path from the repository root:
python scripts/examples.py check project-evolution
The command expects the compatible comparison to succeed and the breaking comparison to exit with status 1. A failure path is part of the example rather than something the guide asks you to imagine.
Read the guided example README for the schema layout and expected reports.
Next: Cross-Language Interop.
Cross-Language Interop
The cross-language example asks Rust, TypeScript, Go, and Python to encode the same fixture from generated code. Each target decodes its payload and prints a machine-readable schema hash and hex value. The runner requires all four pairs to match.
python scripts/examples.py check cross-language
The fixture covers sub-byte integers, an enum, floats, a string, and optional coordinates. It is deliberately small enough to understand and strict enough to fail on a real representation difference.
This example is representative evidence. The maintained generated-wire matrix and target test suites cover more shapes, but no repository example establishes compatibility for every schema, runtime version, or environment.
Read the guided example README for prerequisites, generated files, and regeneration.
Next: Live Telemetry.
Live Telemetry
The flagship example streams local CPU and memory samples from Rust to a browser using generated stateful delta codecs.
The path has four contract boundaries:
- the schema marks
SystemSnapshotwith@delta; - the Rust encoder retains its previous numeric values;
- the TypeScript decoder applies the matching state transitions;
- reconnect resets the decoder before a new base frame.
Verify the codecs and reset path without binding a port:
python scripts/examples.py check live-telemetry
To run the dashboard, install its locked Node dependencies and start the Rust service as described in the guided example README.
Delta encoding is stateful representation, not general compression. Frame size depends on the sampled values and number of CPU cores.
Vexil 0.6.0
The vexilc 0.6.0 release caps a focused 0.x stabilization wave. It is not a
1.0 claim. The release establishes a clearer compiler-to-runtime path, explicit
package boundaries, and documentation that distinguishes broad evidence from
representative coverage.
Highlights
- Import version requirements are enforced as SemVer across direct and transitive project graphs.
- Concrete type aliases work for containers and imported named types while remaining transparent on the wire.
- Unsupported message invariants fail closed with a dedicated diagnostic.
- Result encoding retains the published
0 = Err,1 = Okcontract. - Unknown non-exhaustive union values preserve their discriminant and payload in Rust, TypeScript, Go, and Python.
- Generated-code checks compile and execute target output, including expanded Go and Python compliance coverage.
- A curated example path moves from a first schema through evolution, cross-language byte agreement, and stateful telemetry, with regeneration and execution checked in CI.
- Python
vexil-runtime0.1.0 is published on PyPI through Trusted Publishing.
Upgrade notes
Regenerate code with the matching vexilc release and run your target's native
test suite. If you use non-exhaustive unions, handle or retain the generated
unknown case. If a schema contains invariant, compilation now rejects it
instead of implying enforcement that did not exist.
Import requirements such as @ ^0.5.0 now have effect. Give imported schemas
a valid schema-level @version; a missing version produces a warning and a
mismatch produces an error.
Current boundaries
This release does not add a new wire format, cross-field constraints, regex constraints, invariant execution, RPC definitions, a standard library, a transport profile, or encryption. These remain separately decidable work and have no promised version number.
Go and Python have representative native and generated-code evidence rather than the broader coverage currently held by Rust and TypeScript.
Published components
Components version independently. This release wave published:
- compiler and Rust, TypeScript, Go, and Python generators at 0.5.0;
- Rust runtime and
vexilcat 0.6.0; vexil-storeat 0.5.0;- TypeScript runtime at 0.5.2;
- Go runtime module at 0.1.1; and
- Python runtime at 0.1.0.
See the GitHub release
for pre-built vexilc archives and checksums.
Development Setup
Prerequisites
- Rust 1.94 or later
- Node.js 22.12+ for TypeScript targets and examples
- Go 1.22+ for the Go runtime and interop example
- Python 3.10+ for Python runtime work and repository checks
Build the workspace
git clone https://github.com/vexil-lang/vexil
cd vexil
cargo build --workspace
Rust baseline
cargo fmt --all
cargo test --workspace
cargo clippy --workspace -- -D warnings
cargo fmt --all -- --check
There is no repository pre-commit hook. Run formatting explicitly; CI checks it without changing the contributor's staged files.
Target suites
cd packages/runtime-ts
npm ci
npm run build
npm test
cd ../runtime-go
go test ./...
cd ../runtime-py
python -m pytest
Return to the repository root before running the curated examples:
python scripts/examples.py check all
Generated output
Generator tests compare source against checked-in goldens. Regenerate only for an intentional output change:
UPDATE_GOLDEN=1 cargo test -p vexil-codegen-rust
UPDATE_GOLDEN=1 cargo test -p vexil-codegen-ts
UPDATE_GOLDEN=1 cargo test -p vexil-codegen-go
UPDATE_GOLDEN=1 cargo test -p vexil-codegen-py
Inspect every generated diff. A passing updated snapshot is not evidence that the new output is correct.
Documentation
python scripts/check-doc-links.py
cd docs/book && mdbook build
Benchmarks
crates/vexil-bench is excluded from the main workspace. Run its Criterion
benchmarks explicitly when performance is in scope:
cargo bench --manifest-path crates/vexil-bench/Cargo.toml
Read the root contribution guide for change boundaries, contract tests, and pull-request expectations.
Architecture
Vexil separates source-oriented compiler data from the resolved model consumed by checks and generators:
source -> lexer -> parser -> AST -> lowering -> IR -> checks -> CompiledSchema
The AST keeps syntax and spans for diagnostics. The IR resolves declarations,
types, and imports. A CompiledSchema represents one resolved schema;
ProjectResult contains a multi-file project in topological order.
Workspace boundaries
vexil-lang
├── vexil-codegen-rust
├── vexil-codegen-ts
├── vexil-codegen-go
├── vexil-codegen-py
├── vexil-store
└── vexilc
vexil-runtime Rust wire runtime
packages/runtime-ts TypeScript wire runtime
packages/runtime-go Go wire runtime
packages/runtime-py Python wire runtime
Generators implement vexil_lang::codegen::CodegenBackend. Each backend owns
its target's imports, names, file layout, and runtime bindings. The compiler
does not impose a shared host-language layout.
Contract flow
Language and wire specifications are authoritative. Corpus cases express accepted and rejected schemas; compliance vectors express exact bytes. Compiler and generator changes should update those contract fixtures when behavior changes, then run the target's native checks.
See the language specification, wire-format specification, and contributor guide.