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

  1. Install the compiler.
  2. Write and check a schema.
  3. Generate code for your target.
  4. Run the curated examples.
  5. 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 hello gives declarations a stable namespace.
  • priority @0 : u3 assigns 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 @varint uses unsigned LEB128 instead of fixed-width u32.

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 MessageFields input type plus createMessage object factory. Decode routes through the factory so decoded values carry their methods.
  • Go uses pointer-receiver methods.
  • Python uses dataclass methods and emits static Protocol conformance proofs under TYPE_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.

SurfaceDistributionEvidence in this repository
Compiler and CLIRust crates and release binariesWorkspace tests, corpus, project graphs, diagnostics, and compatibility checks
Editor diagnosticsSource build from current main (vexilc lsp over stdio)Full-document single-file synchronization, compiler diagnostics, and UTF-16 range tests
Rust generated codevexil-runtimeBroad golden, native compile, Clippy, and byte-vector coverage
TypeScript generated code@vexil-lang/runtimeNative type-check/build/tests and broad byte-vector coverage
Go generated codeversioned Go moduleNative compile and execution over a representative shared wire matrix
Python generated codevexil-runtime on PyPIStatic 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 vexilc 0.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

  1. Select the exact generated targets from the support matrix.
  2. Compile and round-trip the schemas your application will ship.
  3. Compare generated bytes across every participating language.
  4. Define framing, authentication, resource limits, and schema distribution at the application layer.
  5. Test rolling upgrades with vexilc compat and 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

TypeSizeDescription
bool1 bitTrue or false
u8 -- u648--64 bitsUnsigned integers
i8 -- i648--64 bitsSigned integers (two's complement)
f3232 bitsIEEE 754 single-precision float
f6464 bitsIEEE 754 double-precision float
fixed3232 bitsQ16.16 fixed-point (two's complement)
fixed6464 bitsQ32.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

TypeSizeDescription
u1 -- u71--7 bitsUnsigned sub-byte integers
i2 -- i72--7 bitsSigned 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

TypeWire encodingDescription
stringLEB128 length + UTF-8Text
bytesLEB128 length + rawBinary data
uuid16 bytesUUID as raw bytes
timestamp64-bit signedUnix epoch (interpretation is application-defined)
rgb3 bytesRed, green, blue
hash32 bytesBLAKE3 hash

Parameterized types

TypeDescription
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:

TypeComponentsDescription
vec2<T>x, y2D vector
vec3<T>x, y, z3D vector
vec4<T>x, y, z, w4D vector or homogeneous coordinate
quat<T>x, y, z, wQuaternion rotation
mat3<T>9 components3x3 matrix (column-major)
mat4<T>16 components4x4 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 = u64 then type B = A won'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, u8u64, i8i64, 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 <= balance cannot 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:

AnnotationApplies toEffect
@varintunsigned integersLEB128 variable-length encoding
@zigzagsigned integersZigZag encoding (small magnitudes use fewer bytes)
@deltanumeric fields in arraysDelta encoding (store differences, not absolute values)
message Packet {
    sequence @0 : u32 @varint
    offset   @1 : i32 @zigzag
}

Declaration annotations

AnnotationApplies toEffect
@non_exhaustiveenum, unionAllows adding variants without breaking decoders
@deprecatedfields, variantsMarks as deprecated in generated code
@removed(ordinal, reason: "...")declaration bodiesReserves 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:

  1. Parses the root file and discovers imports
  2. Resolves each import against the include directories
  3. Compiles all schemas in topological order (dependencies first)
  4. 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: use statements referencing sibling modules
  • TypeScript: relative import statements with barrel index.ts files; each barrel exposes child modules as <child>Schema namespace 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):

ChangeClassification
Add a variant to @non_exhaustive enum/unionMinor
Add a new declarationMinor
Mark a field @deprecatedPatch
Rename a field (ordinal unchanged)Patch

Breaking changes

These changes require all peers to upgrade simultaneously:

ChangeWhy
Add a fieldNested and aggregate message values have no old-schema boundary
Remove a fieldWire layout changes
Change a field's typeWire encoding differs
Change a field's ordinalWire order changes
Add/remove @varint, @zigzag, @deltaEncoding 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:

  1. The first value as-is
  2. 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 @varint or @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:

  1. Encode one bounded Vexil message or a deliberately defined batch.
  2. Compress that complete frame or batch.
  3. Apply integrity protection or authenticated encryption to the compressed bytes when the protocol requires it.
  4. 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

CommandDescription
checkValidate a schema and print its hash
codegenGenerate code for a single schema file
buildGenerate code for a multi-file project
watchWatch files and rebuild on changes
compatCompare schemas for breaking changes
initCreate a new schema file
hashPrint the BLAKE3 schema hash
lspPublish compiler diagnostics to an editor (current source builds; not vexilc 0.6.0)
packEncode a .vx text file to .vxb binary
unpackDecode a .vxb binary file to .vx text
formatFormat a .vx text file
infoInspect .vxb/.vxc file headers
compileCompile a schema to .vxc binary format

Global options

OptionDescription
-V, --versionPrint version
-h, --helpPrint help

Targets

The --target option (used by codegen, build, and watch) accepts:

  • rust (default)
  • typescript
  • go
  • python

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

CodeMeaning
0Schema is valid
1Schema has errors

codegen

Generate code from a single Vexil schema file.

Usage

vexilc codegen <file.vexil> [--target <target>] [--output <path>]

Options

OptionDefaultDescription
--target <target>rustCode generation target: rust, typescript, go, or python
--output <path>stdoutWrite 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 build instead
  • The generated code depends on the corresponding runtime library (vexil-runtime for Rust, @vexil-lang/runtime for TypeScript, vexil-runtime Go module, or vexil_runtime Python 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

OptionDefaultDescription
--include <dir>(none)Directory to search for imported schemas (can be repeated)
--output <dir>(required)Output directory for generated code
--target <target>rustCode 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

  1. Parses the root schema file
  2. Discovers import statements and resolves them against --include directories
  3. Compiles all schemas in topological order (dependencies before dependents)
  4. Generates one output file per namespace, with cross-file references handled by the backend
  5. 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

OptionDefaultDescription
--include <dir>(none)Additional directories to watch and search for imports
--output <dir>(none)Output directory (if omitted, runs check only)
--target <target>rustCode 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 --include directories recursively
  • Only reacts to .vexil file changes (creates and modifications)
  • Debounces rapid changes with a 200ms delay
  • If --output is omitted, runs check on 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

OptionDefaultDescription
--format <format>humanOutput 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

CodeMeaning
0Compatible changes only
1Breaking changes detected
2Schema 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 vexilc 0.6.0 CLI and is not included in cargo install vexilc yet.

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;
  • vexilc as 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

crates/vexil-runtime/

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

packages/runtime-ts/

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

packages/runtime-go/

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

packages/runtime-py/

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:

  1. Which Vexil declarations, resolved types, and annotations does the target support?
  2. How are authored names escaped, and what is the collision domain after escaping?
  3. Which runtime package or generated support code owns wire operations?
  4. How do imported types map to target-language imports?
  5. Which relative file path belongs to each namespace, and which barrel or module files are required?
  6. How will the backend reject unsupported constructs before emitting partial output?
  7. 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:

  • UnsupportedType for a resolved type the target cannot represent.
  • MissingAnnotation when the target contract requires explicit schema metadata.
  • Io only when the backend itself performs necessary I/O. Most backends do not need it because callers write returned files.
  • BackendSpecific for 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 build resolves imports and emits a Rust module tree;
  • vexilc compat distinguishes 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:

  1. the schema marks SystemSnapshot with @delta;
  2. the Rust encoder retains its previous numeric values;
  3. the TypeScript decoder applies the matching state transitions;
  4. 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 = Ok contract.
  • 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-runtime 0.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 vexilc at 0.6.0;
  • vexil-store at 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.