Full Language Specification
A full reference of violet
Status: Design phase. Bootstrap compiler in progress (Zig). Items marked [TBD] are accepted in principle but not yet formally specified or implemented.
Introduction
Violet is a systems programming language that combines:
- Zig’s manual allocation philosophy, comptime metaprogramming, fast compile times, and simplicity
- Rust’s interfaces (traits), generics, affine type system, and operator overloading
- Pascal’s readability, keyword conventions, and syntactic clarity
Violet targets embedded systems, operating systems, games, servers, and any domain where control over memory and performance is essential. It provides memory safety without a borrow checker through a combination of linear types, scope-tagged pointers, and explicit allocator discipline.
-- Hello, World
const process = import("std/process").Context;
const debug = import("std/debug");
fn main(ctx: process.Context): !void {
debug.print("Hello, World!\n");
}
Design Philosophy
Explicit over implicit. Every allocation is visible. Every suspension point is visible. No hidden control flow.
Manual allocation, compiler-verified discipline. You choose when and where to allocate. The compiler ensures you clean up.
No borrow checker, meaningful safety. Linear types, scope tags, and unsafe boundaries catch the most dangerous bugs without the cognitive overhead of lifetime annotations.
One way to do things. One async runtime, one allocator interface, one derive mechanism. Ecosystem cohesion over infinite flexibility.
Pascal heritage, modern execution. Keywords like and, or, not, case, repeat...until and nil alongside {} blocks, generics, and a Zig-style build system.
Toolchain
The Violet toolchain is a single binary: violet.
violet build -- debug build (Cranelift backend) [TBD]
violet build --mode=ReleaseSafe -- release build (LLVM backend) [TBD]
violet build --mode=ReleaseUnsafe -- maximum performance, reduced safety [TBD]
violet run -- build and run
violet test -- run @test functions
violet fmt -- format source files
violet expand src/main.vi -- show @expand macro output [TBD]
violet expand --type Foo src/main.vi -- expand only Foo's derives [TBD]
violet pkg add arena-alloc -- add a palette dependency
violet pkg remove arena-alloc
violet pkg update
violet pkg build --mode=ReleaseSafe
violet pkg publish -- publish to palette registry [TBD]
Backend Split
| Mode | Backend | Purpose |
|---|---|---|
| Debug | Cranelift [TBD] / C backend | Fast iteration, sub-second rebuilds |
| ReleaseSafe | LLVM | Optimized, all checks active |
| ReleaseUnsafe | LLVM | Maximum performance, reduced checks |
Linkers: mold (default on Linux), wild for incremental linking [TBD], lld on other platforms.
File Extensions
.vi— Violet source filepalette.kdl— package manifestpalette.lock.kdl— lockfile (generated, commit to VCS)build.vi— build script (a normal Violet program)
Build Instructions
Violet’s bootstrap compiler is written in Zig. You need Zig 0.14+ to build it.
Prerequisites
# Install Zig
# Linux / macOS: https://ziglang.org/download/
# Windows: winget install zig.zig
zig version # should print 0.14.x or later
Clone and Build
git clone https://codeberg.org/violet-lang/violet
cd violet
zig build
Linux
zig build -Doptimize=ReleaseSafe
./zig-out/bin/violet --version
macOS
zig build -Doptimize=ReleaseSafe
./zig-out/bin/violet --version
Windows
zig build -Doptimize=ReleaseSafe
.\zig-out\bin\violet.exe --version
Running Tests
zig build test
Syntax Reference
Comments
-- line comment (Pascal style)
(* block comment
can span multiple lines *)
/// doc comment for the following declaration
fn add(a: i32, b: i32): i32 {
return a + b;
}
Variables and Constants
var x: i32 = 5; -- mutable binding
const max: usize = 1024; -- immutable binding
x := 10; -- reassignment uses :=
x += 1; -- compound assignment (shorthand for x := x + 1)
if x = 10 { -- = in expression position is equality comparison
do_thing();
}
Rationale: := for reassignment eliminates the == vs = confusion. = is used consistently for initialization and comparison.
Functions
-- basic function
fn add(a: i32, b: i32): i32 {
return a + b;
}
-- void function (no return type)
fn greet(name: []const u8) {
debug.print("hello {}", .{name});
}
-- failable function
fn parse(s: []const u8): ParseError!u32 {
if s.len = 0 { return error.UnexpectedEof; }
-- ...
}
-- static method (associated function, no receiver)
impl Point {
static fn new(x: f32, y: f32): Self {
return .{ .x = x, .y = y };
}
}
-- instance method with mutable receiver
impl Point {
fn translate(*self, dx: f32, dy: f32) {
self.x += dx;
self.y += dy;
}
}
-- instance method with immutable receiver
impl Point {
fn len(*const self): f32 {
return #sqrt(self.x * self.x + self.y * self.y);
}
}
-- consuming method (moves self)
impl String {
fn intoSlice(self): []u8 {
-- self is consumed
}
}
Self parameter forms:
*self— mutable receiver (*Self)*const self— immutable receiver (*const Self)self— consuming receiver, moves the value
Rule: Inside impl blocks, every fn must either be static or have a self parameter. A non-static fn without self is a compile error.
Types
-- struct
type Point = struct {
x: f32,
y: f32,
};
-- packed struct (no padding, C-compatible layout)
type Flags = packed struct {
active: u1,
mode: u3,
priority: u4,
};
-- C-compatible layout
type CVec3 = extern struct {
x: f32,
y: f32,
z: f32,
};
-- enum (fieldless variants only, always Copy)
type Color = enum {
Red,
Green,
Blue,
};
-- union (tagged variants with optional data)
type Shape = union {
Circle { radius: f32 },
Rect { w: f32, h: f32 },
Point, -- unit variant
};
-- error type with payload
type ParseError = error {
InvalidChar { ch: u8, pos: usize },
UnexpectedEof,
TooLong { max: usize, got: usize },
};
-- type alias
type Meters = f32;
Struct Initialization
var p = Point { .x = 1.0, .y = 2.0 };
var p: Point = .{ .x = 1.0, .y = 2.0 }; -- anonymous, type inferred
-- with field defaults
type Config = struct {
host: []const u8 = "localhost",
port: u16 = 8080,
};
var cfg = Config { .host = "example.com" }; -- port uses default
Control Flow
-- if statement
if x > 0 {
positive();
} else if x < 0 {
negative();
} else {
zero();
}
-- if expression (then...else)
var abs = if x >= 0 then x else -x;
-- case (pattern matching)
case shape {
.Circle { radius } => debug.print("r={}", .{radius}),
.Rect { w, h } => debug.print("{}x{}", .{w, h}),
_ => debug.print("unknown"),
}
-- case as expression
var n = case opt {
.Some { value } => value,
.None => 0,
};
-- for loop
for item in collection {
process(item);
}
-- for with index
for item, i in collection {
debug.print("[{}] {}", .{i, item});
}
-- multi-sequence for
for x, y in xs, ys {
debug.print("{} {}", .{x, y});
}
-- while
while condition {
step();
}
-- infinite loop with break-value
var result = loop {
var x = try next();
if x.valid { break x; }
};
-- post-condition loop (Pascal heritage)
repeat {
input := try readLine();
} until input != "";
Logical and Bitwise Operators
-- logical (word operators, Pascal style)
if x > 0 and y > 0 { }
if not valid { }
if a or b { }
if a xor b { }
-- bitwise (symbol operators)
var flags = a & b; -- AND
var mask = a | b; -- OR
var inv = ~a; -- NOT
var bits = a ^ b; -- XOR
var shift = a << 2;
Rationale: Word operators for logic, symbol operators for bitwise. No ambiguity between & (bitwise AND) and and (logical AND).
Type Operators
-- type check
if shape is *Circle {
shape.draw();
}
-- safe downcast (panics on failure)
var circle = shape as *Circle;
-- safe optional cast (returns ?*Circle)
var circle = shape as? *Circle;
-- numeric cast
var y: i64 = x as i64;
var z: ?u8 = x as? u8; -- checked narrowing, .None if doesn't fit
Nil
var p: *i32 = nil; -- raw pointers only
if p = nil { #panic("null"); }
var x: ?i32 = .None; -- optionals use .None, never nil
Defer and Errdefer
fn example(alloc: Allocator): !void {
var file = try fs.open("data.txt");
defer file.deinit(); -- runs on any exit
var list = List[u8]::new();
errdefer list.deinit(); -- runs only on error exit
try file.readAll(alloc, &list);
return list.intoSlice(); -- success: errdefer does NOT run
}
Visibility
pub type ExportedType = struct { ... }; -- public
pkg type PackageType = struct { ... }; -- package-visible
type PrivateType = struct { ... }; -- file-private (default)
pub type Config = struct {
pub host: []const u8, -- public field
pub readonly port: u16, -- publicly readable, privately writable
timeout: Duration, -- private field
};
Unsafe
unsafe {
var p = raw_ptr as *MyStruct;
p.*.doThing();
}
unsafe fn rawAccess(ptr: *u8): u8 {
return ptr.*;
}
What requires unsafe:
- Raw pointer dereference from unsafe source
- Calling
externfunctions - Pointer casts via
asbetween pointer types nildereference#asminline assembly#ptrAdd,#uncheckedIndex,#bitCast- Manually implementing
Send/Syncviaunsafe provide
What unsafe does NOT disable:
- Linear type checking
- Scope tag checking
- Bounds checking (use
#uncheckedIndexexplicitly to opt out) - Nil checks (compiler still inserts them unless in
ReleaseUnsafemode)
Type System
Primitive Types
| Type | Description |
|---|---|
i8, i16, i32, i64, i128 | Signed integers |
u8, u16, u32, u64, u128 | Unsigned integers |
isize, usize | Pointer-sized signed/unsigned |
f32, f64 | IEEE 754 floats |
bool | Boolean |
void | No value |
noreturn | Diverging (never returns) |
anyopaque | Type-erased pointer target |
char | Unicode scalar value (restricted u32) |
Arbitrary-width integers [TBD]: u1, u3, u7 etc. (Zig-style)
Integer Arithmetic
-- always panics on overflow (Option A, settled)
x + y
x - y
x * y
-- wrapping (always defined)
x +% y
x -% y
x *% y
-- saturating (always defined)
x +| y
x -| y
x *| y
-- integer division and modulo [TBD: div/mod keywords vs / %]
x / y -- or: x div y (Pascal style, under debate)
x % y -- or: x mod y
Pointers and Slices
*T -- mutable pointer
*const T -- immutable pointer
[*c]T -- C pointer (opaque provenance)
*anyopaque -- type-erased pointer
*s T -- scope-tagged pointer [TBD: full rules]
[]T -- mutable slice (pointer + length)
[]const T -- immutable slice
[]s T -- scope-tagged slice [TBD]
-- dereference
p.* -- explicit postfix dereference (never prefix *)
Optionals
?T -- optional, sugar for Option[T]
var x: ?i32 = 5;
var y: ?i32 = .None;
-- unwrap patterns
var n = x orelse 0; -- default value
var n = x orelse return error.Missing; -- early return
var n = x?; -- propagate None upward
if x |val| { use(val); } -- conditional unwrap
-- adaptor methods
x.isSome()
x.isNone()
x.unwrapOr(default)
x.andThen(|v| someOp(v))
x.map(|v| transform(v))
Error Unions
!T -- inferred error set
E!T -- named error union (E is an error type)
-- example
fn parse(s: []const u8): ParseError!u32 { ... }
-- propagation
var n = try parse(input); -- propagates if error types match
var n = parse(input) catch wrap; -- auto-wraps into caller's error type
-- handling
var n = parse(input) catch |e| {
case e {
.InvalidChar { ch, pos } => { ... },
.UnexpectedEof => { ... },
}
};
wrap in error types:
type AppError = error {
wrap ParseError, -- generates: ParseError { inner: ParseError }
wrap ConnectError,
Custom { code: u32 },
};
Generics
-- generic function with bounds
fn sum[T: Add[T] + Default](items: []T): T {
var result = T::default();
for item in items { result += item; }
return result;
}
-- generic type
type List[T] = struct {
ptr: [*]T,
len: usize,
cap: usize,
};
-- generic impl block
impl[T] List[T] {
static fn new(): Self { ... }
fn push(*self, alloc: Allocator, val: T): !void { ... }
}
-- requires clause for complex bounds
fn process[T, U](a: T, b: U): U
requires T: Serialize + Clone,
U: Deserialize,
{
...
}
Comptime Value Parameters
type FixedList[T, const N: usize] = struct {
data: [N]T,
len: usize,
};
var arr: FixedList[i32, 16] = FixedList[i32, 16]::new();
Ownership and Move Semantics
interface Copy {} -- marker: trivially copyable (derive for POD types)
interface Clone {
fn clone(*const self, alloc: Allocator): !Self;
}
interface NoCopy {} -- opt-out of auto-derived Copy
interface NoClone {} -- opt-out of Clone, implies NoCopy
Rules:
- Non-
Copytypes move on assignment — use it once, compiler tracks it Copytypes are bitwise-copied silentlyCloneis always explicit and always shows an allocator- Using a moved value is a compile error
var f = File::open("x.txt");
consume(f); -- f moved
consume(f); -- compile error: f was moved
Linear Types
Types that implement Destroy are linear — they must be explicitly consumed or the compiler errors.
interface Destroy {
type Args;
fn deinit(*self, args: Self::Args): void;
}
provide Destroy for List[T] {
type Args = Allocator;
fn deinit(*self, alloc: Allocator) {
alloc.free(self.ptr[0..self.cap]);
}
}
-- usage
fn example(alloc: Allocator): !void {
var list = List[i32]::new();
defer list.deinit(alloc); -- must consume or compiler errors
try list.push(alloc, 42);
}
Two-tier cleanup:
interface AsyncClose {
type Args;
fn close(*self, io: *Io, args: Self::Args): !void;
}
-- deinit: sync, no scheduler, always available (force-close)
-- close: async, graceful shutdown, scheduler required
Smart Pointers and Deref
interface Deref[T] {
fn deref(*const self): *const T;
fn derefMut(*self): *T;
}
-- .* desugars to .deref() or .derefMut() based on context
var rc: Rc[List[i32]] = ...;
rc.*.push(alloc, 5); -- explicit dereference, one layer
Standard smart pointers:
Box[T]— unique ownership, heap allocated, implementsDestroyRc[T]— single-threaded reference countingArc[T]— atomic reference counting, thread-safeCell[T]— interior mutability forCopytypes [TBD]RefCell[T]— runtime borrow checking, single-threaded [TBD]
dyn — Dynamic Dispatch
dyn interface Drawable {
fn draw(*self, ctx: *Context): void;
fn bounds(*const self): Rect;
}
var d: *dyn Drawable = &my_circle;
d.*.draw(ctx);
-- heap allocated dyn
var d = alloc.create(Drawable, my_circle);
defer alloc.destroy(d);
Object safety rules (dyn interface):
- No
static fn - No generic methods
- No methods returning
Selfby value - Associated types must be pinned at use site
Memory Model
Explicit Allocators
Every allocation takes an explicit Allocator parameter. No hidden allocation ever.
fn makeList(alloc: Allocator): !List[i32] {
var l = List[i32]::new();
errdefer l.deinit(alloc);
try l.push(alloc, 1);
try l.push(alloc, 2);
return l;
}
using — Implicit Allocator Threading
fn process(alloc: Allocator, data: []const u8): !Report {
using alloc; -- alloc is implicit for this scope
var tokens = try tokenize(data); -- alloc threaded implicitly
errdefer tokens.deinit(); -- alloc implicit in deinit
var tree = try parse(tokens.asSlice());
errdefer tree.deinit();
return try analyze(tree);
}
ctx parameter modifier:
-- declaration: marks parameter as implicitly suppliable
fn tokenize(input: []const u8, ctx alloc: Allocator): ![]Token { ... }
-- call site: alloc comes from `using alloc` in scope
using gpa;
var tokens = try tokenize(source);
-- explicit override
var tokens = try tokenize(source, .alloc = arena);
Rules:
usingis lexical — applies to the rest of the current block- Two
usingvalues of the same type in the same scope → compile error - Inner scope
usingshadows outer ctxparameters are NOT allowed onextern("C")functions
Blessed types for ctx: Allocator. *Scheduler is explicitly excluded — schedulers must always be explicit because suspension affects correctness (lock-safety, ordering).
Scratch Allocations
scratch(alloc, 4096) {
-- temporary arena, freed at block exit
-- using established implicitly
var parsed = try parseHeaders(req.raw);
var validated = try validate(parsed);
-- all scratch data freed here
}
Scope-Tagged Pointers [TBD]
scope s {
var x: i32 = 5;
var p: *s i32 = &x; -- pointer tagged with scope s
use(p.*);
-- p cannot escape this block
}
-- p is dead, cannot be used
-- coercion: outer scope tag can be used in inner scope
scope s {
var x: i32 = 5;
var p_outer: *s i32 = &x;
scope t {
var p_temp: *t i32 = p_outer; -- valid: s outlives t
-- p_outer := &y; -- invalid: y is scoped to t
}
}
Rules:
*s Tcannot escape scopes- Outer scope pointers can coerce to inner scope (safe)
- Inner scope pointers cannot escape to outer (compile error)
- Tags are inferred locally, required at API boundaries
Escaping a scope:
- Copy
Tout by value (for Copy types) - Clone using an outer allocator (for Clone types)
- Move an owned value out if it has no scope-tagged fields
UB Policy
Outside unsafe:
| Operation | Behavior |
|---|---|
Integer overflow (plain +) | Always panic |
| Nil dereference | Always panic |
| Out-of-bounds access | Always panic |
Uninitialized read (#undefined) | Compile error if statically detectable; panic in debug (poisoned memory) |
| Data races | Prevented by type system |
| Pointer arithmetic | Not available |
Inside unsafe:
| Operation | Behavior |
|---|---|
| Integer overflow | Same (panics) unless +% or `+ |
| Nil dereference | UB — compiler assumes non-null |
| Out-of-bounds | Panics by default; #uncheckedIndex for UB opt-in |
| Uninitialized read | UB in ReleaseUnsafe, poisoned in Debug |
| Data races | UB — programmer asserts synchronization |
| Pointer arithmetic | #ptrAdd available, UB if out of allocation |
Build Modes:
| Mode | Nil check | Bounds check | Overflow check | Uninitialized |
|---|---|---|---|---|
Debug | Panic | Panic | Panic | Poisoned (0xAA) |
ReleaseSafe | Panic | Panic | Panic | Undefined |
ReleaseUnsafe | UB inside unsafe | #uncheckedIndex UB | Same | UB |
Error Handling
-- define error type with payload
type FileError = error {
NotFound { path: []const u8 },
PermissionDenied,
Io { code: i32 },
};
-- return error
fn openFile(path: []const u8): FileError!File {
if not pathExists(path) {
return error.NotFound { .path = path };
}
-- ...
}
-- propagate (exact type match)
fn readConfig(path: []const u8): FileError!Config {
var f = try openFile(path);
defer f.deinit();
return try parseConfig(f);
}
-- wrap into outer error type
type AppError = error {
wrap FileError,
wrap ParseError,
Custom { msg: []const u8 },
};
fn run(): AppError!void {
var cfg = openFile("config.toml") catch wrap; -- auto-wraps FileError
-- ...
}
-- handle explicitly
var result = openFile("x.txt") catch |e| {
case e {
.NotFound { path } => {
debug.print("not found: {}", .{path});
return error.Custom { .msg = "file missing" };
},
_ => return e as AppError,
}
};
errdefer — cleanup only on error:
fn process(alloc: Allocator): ![]u8 {
var buf = try alloc.alloc(u8, 1024);
errdefer alloc.free(buf); -- frees buf only if we return an error
try doWork(buf);
return buf; -- success: errdefer does NOT run
}
Generics and Comptime
Comptime Parameters
fn printFields(comptime T: type, val: *const T): void {
inline for (#typeInfo(T).fields) |field| {
debug.print("{}: {}", .{field.name, #fieldGet(val.*, field.name)});
}
}
inline if and inline for
fn serialize[T](val: *const T): []u8 {
inline if #typeInfo(T).repr = .Int {
return serializeInt(val.*);
} else inline if #typeInfo(T).repr = .Struct {
return serializeStruct(T, val);
} else {
#compileError("cannot serialize {}", .{#typeName(T)});
}
}
inline for generates code at compile time:
inline for (fields) |field| {
-- each iteration generates separate code
}
inline for (field, i) in fields {
inline if i > 0 { writer.writeStr(", "); }
writer.writeStr(field.name);
}
Comptime Type Functions
fn Pair(comptime A: type, comptime B: type): type {
return struct {
first: A,
second: B,
fn swap(*const self): Pair(B, A) {
return .{ .first = self.second, .second = self.first };
}
};
}
var p = Pair(i32, f32) { .first = 1, .second = 2.0 };
TypeInfo
type TypeInfo = struct {
ident: []const u8,
size: usize,
align: usize,
repr: TypeRepr,
};
type TypeRepr = union {
Struct { fields: []FieldInfo, packed: bool, extern_: bool },
Enum { variants: []VariantInfo, tag_type: *TypeInfo },
Union { fields: []FieldInfo },
Error { variants: []ErrorVariantInfo },
Int { bits: u8, signed: bool },
Float { bits: u8 },
Pointer { inner: *TypeInfo, mutable: bool },
Slice { inner: *TypeInfo },
Bool,
Void,
NoReturn,
};
Comptime Sandbox
Comptime code is sandboxed — it can only:
- Read type information via
#typeInfo - Read palette-local files via
#embedFile - Read build-time env vars via
#readEnv(root palette only) - Perform arithmetic and string manipulation
Comptime code cannot:
- Write files
- Make network requests
- Execute processes
- Read files outside the palette root
Interfaces
Declaration
interface Drawable {
fn draw(*self, ctx: *Context): void;
fn bounds(*const self): Rect;
}
-- interface with requirements
interface Car requires Vehicle, Mechanical {
fn drive(*self): void;
}
-- dyn-safe interface
dyn interface Runnable {
fn run(*self): void;
}
Implementation
provide Drawable for Circle {
fn draw(*self, ctx: *Context): void {
fill_circle(ctx, self.center, self.radius);
}
fn bounds(*const self): Rect {
return Rect::fromCircle(self.*);
}
}
Generic Implementations
-- specific generic type
provide[T] Clone for List[T] {
fn clone(*const self, alloc: Allocator): !Self { ... }
}
-- blanket implementation
provide[T: LuaType] Destroy for T {
fn deinit(*self, _: void) { ... }
}
-- specialize to override blanket
specialize provide Destroy for LuaFunction {
fn deinit(*self, _: void) { ... }
}
Operator Overloading
operator (+) = Add;
operator (-) = Sub;
operator (*) = Mul;
operator (/) = Div;
operator ([]) = Index;
operator (=) = Eq;
interface Add[T] {
fn add(a: T, b: T): T;
}
provide Add[Vec2] for Vec2 {
fn add(a: Vec2, b: Vec2): Vec2 {
return .{ .x = a.x + b.x, .y = a.y + b.y };
}
}
var c = a + b; -- desugars to Add[Vec2].add(a, b)
Index Interface
interface Index {
type Value;
type Idx;
fn getIndex(*const self, Self::Idx): !*const Self::Value;
fn getIndexMut(*self, Self::Idx): !*Self::Value;
unsafe fn getIndexUnchecked(*const self, Self::Idx): *const Self::Value;
unsafe fn getIndexUncheckedMut(*self, Self::Idx): *Self::Value;
fn setIndex(*self, Self::Idx, Self::Value): !void;
unsafe fn setIndexUnchecked(*self, Self::Idx, Self::Value): void;
}
-- arr[i] desugars to arr.getIndex(i)?
-- unsafe { arr.getIndexUnchecked(i) } for unchecked access
HashMap does not implement Index — key absence is a normal case, not an error. Use .get(), .getMut(), .set(), .remove(), .entry().
Naming: @expand(Interface) calls Interface::generate(T). Conflicts: manual provide wins over expanded, duplicate expands are a compile error.
Closures
-- [capture_list](params): ReturnType { body }
var offset: i32 = 5;
var add = [offset](x: i32): i32 { return x + offset; };
-- move capture
var data = loadData(alloc);
var f = [move data](_: i32): void { process(data); };
-- inferred types from context
io.scope([](s: *IoScope) {
try s.spawn([move data](_: *IoScope) {
process(data);
});
});
-- named functions coerce to closure type
fn myTask(s: *IoScope): !void { ... }
try io.scope(myTask);
-- _ for unused params (but not ALL params)
[](s: *IoScope, _: Event) { use(s); }
Capture rules:
- Capture list is always explicit
move x— movesxinto the closurex(no move) — copies ifCopy, else moves- Reference captures are not supported (would require lifetimes)
Thread safety: Closures passed to io.spawn must be Send — all captured values must be Send. Non-Send captures (e.g., Rc[T]) are a compile error at the spawn site.
Async and Concurrency
The Io Interface
Violet’s async model uses an explicit *Io parameter rather than async/await keywords. Functions that take *Io may suspend; functions without it are provably synchronous.
interface Io {
fn waitFd(*self, fd: Fd, event: IoEvent): !void;
fn sleep(*self, dur: Duration): !void;
fn yield_(*self): void;
fn spawnBlocking(*self, alloc: Allocator, work: BoxedBlockingTask): !void;
fn scope(*self, alloc: Allocator, body: BoxedScopeTask): !void;
fn spawn(*self, alloc: Allocator, task: BoxedTask): !TaskHandle;
fn spawnDetached(*self, alloc: Allocator, task: BoxedTask): !void;
fn waker(*self): Waker;
fn handle(*self, alloc: Allocator): IoHandle;
fn run(*self): !void;
fn shutdown(*self): void;
}
interface IoScope: Io {
fn spawn(*self, alloc: Allocator, task: BoxedTask): !void;
}
Why explicit *Io instead of async fn:
- Suspension is visible at every call site — critical for lock safety
- No function coloring at the syntax level
- Multiple schedulers in one program are possible
- Library code is scheduler-agnostic
Structured Concurrency (Recommended)
fn serveRequests(io: *Io, alloc: Allocator): !void {
using alloc;
try io.scope([](s: *IoScope) {
while true {
var conn = try acceptConnection(io);
try s.spawn([move conn](_: *IoScope) {
try handleConnection(io, conn);
});
}
});
-- all connection tasks joined before returning
}
Inside io.scope, use s for everything: IoScope extends Io, so s.waitFd, s.sleep, s.spawn all work through the scope.
Manual Task Management
-- spawn with handle (must join or detach)
var handle = try io.spawn(alloc, [](s: *IoScope) {
try doWork(io);
});
-- ... do other work ...
try handle.join(); -- wait for completion
-- or:
handle.detach(); -- convert to daemon task (cancelled on shutdown)
-- fire and forget daemon
try io.spawnDetached(alloc, [](s: *IoScope) {
while true {
try emitMetrics(io);
try s.sleep(Duration::seconds(30));
}
});
TaskHandle is linear — you must join, detach, or awaitCancelled. Dropping without one is a compile error (asserts in debug).
Cancellation
Tasks are cancelled cooperatively — error.Cancelled is returned at suspension points. errdefer handles cleanup:
fn worker(io: *Io, alloc: Allocator): !void {
var list = List[u8]::new();
errdefer list.deinit(); -- runs if cancelled or any other error
try io.waitFd(fd, .Read); -- returns error.Cancelled if cancelled
try process(&list);
list.deinit();
}
CPU-Bound Work
fn heavyWork(io: *Io, alloc: Allocator): !u64 {
return try io.spawnBlocking(alloc, [](_: void): u64 {
-- runs on thread pool, Violet task suspends
return computeResult();
});
}
Waker and Cross-Scheduler Communication
type Waker = struct {
ptr: *anyopaque,
vtable: *const WakerVTable,
};
-- Waker holds Weak reference to scheduler
-- wake() is a no-op if scheduler has been destroyed
#currentScheduler [TBD]
fn downloadEasy(url: []u8): ![]u8 {
var io = #currentScheduler(); -- thread-local escape hatch
var conn = try tcp.connect(io, url);
return try conn.readAll(alloc);
}
Use #currentScheduler only in application code, not library code. Libraries should take explicit *Io.
Thread Safety: Send and Sync [TBD]
interface Send {} -- safe to transfer to another thread
interface Sync {} -- safe to share reference across threads
-- auto-derived by compiler from field types
-- unsafe provide for manual assertion
unsafe provide Send for MyRawHandle {}
| Type | Send | Sync |
|---|---|---|
| Primitives | ✓ | ✓ |
*T, *const T | ✗ | ✗ |
Rc[T] | ✗ | ✗ |
Arc[T: Send] | ✓ | ✓ |
Mutex[T: Send] | ✓ | ✓ |
Cell[T: Send] | ✓ | ✗ |
C FFI
Importing C Headers
const c = #cImport({
#cInclude("stdio.h");
#cInclude("mylib.h");
#cDefine("MY_FEATURE", "1");
});
unsafe {
c.printf("hello %d\n", 42);
}
Extern Functions
extern fn malloc(size: usize): [*c]u8;
extern fn free(ptr: [*c]u8): void;
extern("stdcall") fn winFunc(hwnd: *anyopaque): i32;
All extern calls require unsafe.
Exporting to C
export fn violetAdd(a: i32, b: i32): i32 {
return a + b;
}
export("my_lib_add") fn add(a: i32, b: i32): i32 {
return a + b;
}
Generate C header: violet build --emit-header mylib.h
C-Compatible Types
type WireHeader = extern packed struct {
magic: u16,
version: u8,
flags: u8,
length: u32,
};
type Color = extern enum(u32) {
Red = 0,
Green = 1,
Blue = 2,
};
FFI Callbacks
The user_data: *anyopaque pattern:
const ffi = import("std/ffi");
type AppCtx = struct {
alloc: Allocator,
io: *Io,
};
extern("C") fn myCallback(data: *u8, user_data: *anyopaque): void {
unsafe {
var ctx = ffi.restore(*AppCtx, user_data);
using ctx.alloc;
doWork(ctx.io, data);
}
}
-- registration
var ctx = AppCtx { .alloc = gpa, .io = io };
unsafe {
c.register_callback(myCallback, &ctx as *anyopaque);
}
ctx parameters are not allowed on extern("C") functions — compile error.
std.c — C Standard Library Bindings
const c = import("std/c");
c.int, c.uint, c.size_t, c.char
c.malloc, c.free, c.memset, c.strlen
c.ptr = *anyopaque -- void*
Disable std.c in build.vi to avoid linking libc:
-- build.vi
exe.libc(.none); -- no libc, embedded targets
exe.libc(.musl); -- bundle musl (self-contained binary)
exe.libc(.system); -- link system libc (default)
Inline Assembly
unsafe {
asm("add {a}, {b}")
.input("a", x)
.input("b", y)
.output("out", &result)
.clobber("cc")
.execute();
}
-- architecture-specific
const template = inline if #targetArch() = "x86_64" {
"add %[a], %[b]"
} else if #targetArch() = "aarch64" {
"add %[out], %[a], %[b]"
} else {
#compileError("unsupported architecture");
};
Standard Library
Violet’s standard library is split between core (always available, no OS) and std (requires OS).
core — Always Available
| Module | Contents |
|---|---|
core/allocator | Allocator fat-pointer type, AllocatorVTable |
core/option | Option[T], ?T sugar |
core/result | Error union support |
core/slice | Slice operations |
core/mem | memcpy, memset, basic memory |
core/types | Copy, Clone, NoCopy, NoClone, Send, Sync, Destroy, AsyncClose |
core/fmt | Basic formatting |
std Modules
| Module | Contents |
|---|---|
std/alloc | GPA, ArenaAllocator, FixedBufferAllocator |
std/collections | List[T], HashMap[K,V], BTreeMap[K,V], RingBuffer[T] |
std/string | String, StringView, char utilities |
std/io | Io interface, file I/O, buffering |
std/fs | Filesystem operations |
std/net | TCP/UDP sockets, DNS |
std/async | IoUringScheduler, EpollScheduler, KqueueScheduler |
std/sync | Mutex[T], Arc[T], Channel[T], atomics |
std/process | Context, args, env, signals |
std/fmt | String formatting, printing |
std/math | Numeric utilities, PRNG |
std/serial | JSON, KDL, MessagePack |
std/compress | Deflate, zlib |
std/crypto | SHA-256, Blake3, ChaCha20 (primitives only) |
std/test | Test framework |
std/log | Structured logging |
std/time | Clocks, Duration, timers |
std/unicode | Grapheme clusters, normalization |
std/c | C standard library bindings (disablable) |
std/ffi | ffi.restore, FFI utilities |
std/build | Build system API |
String Types
type char = u32; -- Unicode scalar value (restricted)
type String = struct { buf: List[u8] }; -- owned, UTF-8 validated
type StringView = []const u8; -- borrowed, UTF-8 by convention
-- construction
var s = try String::from(alloc, "hello");
var s = try String::fromUtf8(alloc, bytes); -- validates UTF-8
-- iteration
for b in s.bytes() { -- b: u8 }
for c in s.codepoints() { -- c: char }
-- for x in s { } -- compile error: ambiguous, use .bytes() or .codepoints()
-- grapheme clusters (library)
const unicode = import("std/unicode");
for g in unicode.graphemes(s.view()) { -- g: StringView }
Collections
List[T] — dynamic array
var list = List[i32]::new();
defer list.deinit(alloc);
try list.push(alloc, 42);
var x = try list.get(0);
var n = list.len();
var slice = list.asSlice();
HashMap[K, V] — open-addressing hash map (does NOT implement Index)
var map = HashMap[String, i32]::new();
defer map.deinit(alloc);
try map.set(alloc, "key", 42);
var val = map.get("key"); -- ?*const i32
var val = map.getOrInsert(alloc, "key", 0);
-- entry API (avoids double-lookup)
var e = try map.entry(alloc, "key");
case e {
.Occupied { val } => val.* += 1,
.Vacant { slot } => try slot.insert(alloc, 1),
}
Process Context
fn main(ctx: process.Context): !void {
using ctx.alloc;
for arg in ctx.args {
debug.print("{}", .{arg});
}
var home = ctx.env.get("HOME") orelse "/tmp";
ctx.exit(0);
}
-- simplified (no context needed)
fn main(): void {
debug.print("hello\n");
}
Package System
Manifest — palette.kdl
package {
name "myapp"
version "0.1.0"
authors "Developer <dev@example.com>"
license "MIT"
}
deps {
dep "std-io" version="1.2.0"
dep "arena-alloc" version=">=0.4.0"
dep "mylib" git="https://codeberg.org/user/mylib" rev="abc123"
dep "locallib" path="../locallib"
}
targets {
bin "myapp" root="src/main.vi"
lib "mylib" root="src/lib.vi"
}
“Palette” — Violet’s name for a package. A palette is a collection of colors; your dependencies are the colors in your project’s palette.
Build Script — build.vi
const build = import("std/build");
fn main(b: *build.Builder) {
var opts = b.options();
var mode = opts.enum("mode", build.BuildMode, .Debug);
var exe = b.addExecutable(.{
.name = "myapp",
.root = "src/main.vi",
.mode = mode,
});
exe.linkSystemLib("z");
exe.libc(.musl);
exe.addIncludePath("vendor/mylib/include");
b.install(exe);
}
CLI Commands
violet pkg add arena-alloc # add dependency
violet pkg add arena-alloc@0.4.0 # pinned version
violet pkg remove arena-alloc
violet pkg update
violet pkg publish # publish to registry [TBD]
Tutorials
Tutorial 1: Basic Operations
const debug = import("std/debug");
const fmt = import("std/fmt");
fn main(): void {
-- integers
var x: i32 = 42;
var y: i32 = x + 8;
debug.print("y = {}\n", .{y});
-- strings
const greeting: []const u8 = "Hello, Violet!";
debug.print("{}\n", .{greeting});
-- optionals
var maybe: ?i32 = 5;
if maybe |val| {
debug.print("got {}\n", .{val});
}
var n = maybe orelse 0;
-- pattern matching
var result = compute(x);
case result {
.Ok { value } => debug.print("ok: {}\n", .{value}),
.Err { msg } => debug.print("err: {}\n", .{msg}),
}
}
type Result = union {
Ok { value: i32 },
Err { msg: []const u8 },
};
fn compute(x: i32): Result {
if x > 0 {
return .Ok { .value = x * 2 };
}
return .Err { .msg = "negative input" };
}
Tutorial 2: Working With Allocators
const mem = import("std/mem");
const alloc = import("std/alloc");
fn main(ctx: process.Context): !void {
-- create a general-purpose allocator
var gpa = alloc.GPA::new();
defer gpa.deinit();
var allocator = gpa.allocator();
-- allocate a list
var numbers = List[i32]::new();
defer numbers.deinit(allocator);
try numbers.push(allocator, 1);
try numbers.push(allocator, 2);
try numbers.push(allocator, 3);
for n in numbers.asSlice() {
debug.print("{}\n", .{n.*});
}
-- scratch allocation (temporary arena)
scratch(allocator, 4096) {
var temp = try buildTempData();
process(temp);
-- temp freed here
}
}
Tutorial 3: Error Handling
type AppError = error {
wrap IoError,
wrap ParseError,
Config { msg: []const u8 },
};
fn loadConfig(alloc: Allocator, path: []const u8): AppError!Config {
-- open file, errors auto-wrapped
var f = openFile(path) catch wrap;
defer f.deinit();
var contents = try f.readAll(alloc);
defer alloc.free(contents);
-- parse, errors auto-wrapped
return parseConfig(contents) catch wrap;
}
fn main(ctx: process.Context): !void {
using ctx.alloc;
var cfg = loadConfig("config.toml") catch |e| {
case e {
.IoError { inner } =>
debug.print("IO error: {}\n", .{inner}),
.ParseError { inner } =>
debug.print("Parse error: {}\n", .{inner}),
.Config { msg } =>
debug.print("Config error: {}\n", .{msg}),
}
return error.Config { .msg = "failed to load" };
};
run(cfg);
}
Tutorial 4: Interfaces and Polymorphism
-- define an interface
dyn interface Animal {
fn speak(*const self): []const u8;
fn name(*const self): []const u8;
}
type Dog = struct { name_: []const u8 };
type Cat = struct { name_: []const u8 };
provide Animal for Dog {
fn speak(*const self): []const u8 { return "woof"; }
fn name(*const self): []const u8 { return self.name_; }
}
provide Animal for Cat {
fn speak(*const self): []const u8 { return "meow"; }
fn name(*const self): []const u8 { return self.name_; }
}
-- static dispatch (generic)
fn makeNoise[A: Animal](animal: *const A) {
debug.print("{} says {}\n", .{animal.name(), animal.speak()});
}
-- dynamic dispatch (runtime polymorphism)
fn makeNoiseAll(animals: [](*dyn Animal)) {
for a in animals {
debug.print("{} says {}\n", .{a.*.name(), a.*.speak()});
}
}
fn main(): void {
var dog = Dog { .name_ = "Rex" };
var cat = Cat { .name_ = "Whiskers" };
makeNoise(&dog); -- static, no overhead
makeNoise(&cat);
var animals = []*dyn Animal{ &dog, &cat };
makeNoiseAll(&animals);
}
Tutorial 5: Asynchronous Programming
const io_mod = import("std/async");
const net = import("std/net");
fn main(ctx: process.Context): !void {
using ctx.alloc;
var io = try io_mod.IoUringScheduler::new(ctx.alloc, 256);
defer io.deinit(ctx.alloc);
try io.run();
}
fn serverMain(io: *Io, alloc: Allocator): !void {
using alloc;
var listener = try net.TcpListener::bind(io, "0.0.0.0:8080");
defer listener.deinit();
debug.print("Listening on :8080\n");
-- structured concurrency: all connections joined on exit
try io.scope([](s: *IoScope) {
while true {
var conn = try listener.accept(io);
try s.spawn([move conn](_: *IoScope) {
handleConnection(io, conn) catch |e| {
debug.print("connection error: {}\n", .{e});
};
});
}
});
}
fn handleConnection(io: *Io, conn: net.TcpConn): !void {
defer conn.deinit();
var buf: [4096]u8 = #undefined;
var n = try conn.read(io, &buf);
try conn.write(io, buf[0..n]);
}
Tutorial 6: Concurrency With Channels
const sync = import("std/sync");
fn main(ctx: process.Context): !void {
using ctx.alloc;
var io = try io_mod.IoUringScheduler::new(alloc, 256);
defer io.deinit(alloc);
var ch = try sync.Channel[i32]::new(alloc, 32);
defer ch.deinit(alloc);
try io.scope([](s: *IoScope) {
-- producer
try s.spawn([move ch.sender()](_: *IoScope) {
for i in 0..10 {
try ch.send(i);
}
ch.close();
});
-- consumer
try s.spawn([move ch.receiver()](_: *IoScope) {
while ch.recv() |val| {
debug.print("got {}\n", .{val});
}
});
});
}
Tutorial 7: C FFI
const c = import("std/c");
const ffi = import("std/ffi");
-- calling C functions
fn countBytes(data: []const u8): usize {
unsafe {
return c.strlen(data.ptr);
}
}
-- C callback with context
type SqliteCtx = struct {
alloc: Allocator,
results: *List[[]const u8],
};
extern("C") fn sqliteCallback(
user_data: *anyopaque,
col_count: c.int,
col_values: [*c][*c]const u8,
_: [*c][*c]const u8,
): c.int {
unsafe {
var ctx = ffi.restore(*SqliteCtx, user_data);
var i: usize = 0;
while i < col_count as usize {
var val: []const u8 = c.ptrToSlice(col_values[i]);
ctx.results.push(ctx.alloc, val) catch return 1;
i += 1;
}
}
return 0;
}
-- importing C headers
const sqlite = #cImport({
#cInclude("sqlite3.h");
});
fn query(db: *sqlite.sqlite3, sql: []const u8, alloc: Allocator): !List[[]const u8] {
var results = List[[]const u8]::new();
errdefer results.deinit(alloc);
var ctx = SqliteCtx { .alloc = alloc, .results = &results };
unsafe {
var rc = sqlite.sqlite3_exec(
db,
sql.ptr,
sqliteCallback,
&ctx as *anyopaque,
nil,
);
if rc != sqlite.SQLITE_OK {
return error.SqliteError { .code = rc };
}
}
return results;
}
Tutorial 8: HTTP Server [TBD]
-- Note: std/http is a companion palette, not in std
-- violet pkg add violet-http
const http = import("violet-http");
fn main(ctx: process.Context): !void {
using ctx.alloc;
var io = try io_mod.IoUringScheduler::new(alloc, 1024);
defer io.deinit(alloc);
var server = try http.Server::new(io, alloc, .{
.addr = "0.0.0.0",
.port = 8080,
});
defer server.deinit(alloc);
try server.get("/", handleRoot);
try server.get("/health", handleHealth);
try server.post("/echo", handleEcho);
debug.print("Serving on :8080\n");
try server.run(io);
}
fn handleRoot(req: *http.Request, res: *http.Response): !void {
try res.status(200);
try res.body("Hello from Violet!");
}
fn handleHealth(_: *http.Request, res: *http.Response): !void {
try res.json(.{ .status = "ok" });
}
fn handleEcho(req: *http.Request, res: *http.Response): !void {
var body = try req.readBody(req.alloc);
defer req.alloc.free(body);
try res.status(200);
try res.body(body);
}
Incomplete / TBD
The following are accepted in principle but not yet formally specified or implemented.
Grammar
- Full EBNF incorporating all accepted constructs
- Scope-tagged pointer syntax (
*s T,[]s T,[]_) ctxparameter modifierunsafe fnin interface declarationsrequires T: not Interfacein expand fn- Blanket and specialize provide syntax
- Loop labels and
break label inline formulti-sequence with index variable- Anonymous scope elision
[]_ - Multi-dimensional array syntax and indexing
Semantics
- Scope tag propagation rules through generics
usingresolution formal rulesctxC ABI ordering rules- Linear type checker + errdefer interaction
- Definite assignment analysis for
#undefined - Blanket impl conflict detection
dyn interfaceobject safety formal enumerationerror.Cancelledpropagation rules
Features
SendandSyncmarker interfaces (direction settled, not implemented)Cell[T]andRefCell[T]Box[T]divandmodinteger operators (vs/and%)charas restricted u32 (settled, not in grammar)- Block comment syntax
(* *)(settled, not in grammar) - Cranelift debug backend
- Wild incremental linker integration
- ARMv7 via Cwerg backend
- WASM target (low priority)
violet expandtoolviolet pkg publishand registry
Standard Library
std/http(companion palette)std/tls(companion palette)std/postgres,std/sqlite(companion palettes)std/regex(companion palette)- Full
std/unicode(grapheme clusters, normalization) - Full
std/crypto(full suite, primitives done) std/compress(deflate done, zstd companion palette)
Tooling
- Formatter rules
- LSP implementation
- Test framework details
- Doc comment markup and generation
- Error message philosophy and templates
Appendix: Intrinsics Reference
| Intrinsic | Description |
|---|---|
#panic(msg, .{args}) | Unconditional panic, noreturn |
#assert(cond) | Panic if false, stripped in release |
#assert(cond, msg) | With message |
#unreachable() | UB in release, panic in debug |
#trap() | Hardware trap instruction |
#setPanicHandler(fn) | Compile-time panic handler override |
#typeInfo(T) | Comptime type information |
#typeName(T) | Comptime type name string |
#typeHash(T) | Comptime type identity hash |
#sizeOf(T) | Byte size of T |
#alignOf(T) | Alignment of T |
#field(val, name) | Access field by comptime name |
#fieldGet(val, name) | Get field value by name |
#fieldSet(val, name, v) | Set field value by name |
#implements(T, I) | Comptime interface check |
#ident(a, b) | Concatenate strings into identifier |
#undefined | Explicit uninitialized value |
#ptrAdd(ptr, n) | Unsafe pointer arithmetic |
#bitCast(val) | Bit-level reinterpretation |
#uncheckedIndex(s, i) | Unchecked index, unsafe |
#memcpy(dst, src, n) | Raw memory copy |
#embedFile("path") | Embed file as []const u8 |
#embedDir("path") | Embed directory listing |
#readEnv("VAR") | Build-time env var (root palette) |
#compileTime() | Build timestamp string |
#gitRevision() | Git revision string |
#paletteVersion() | Version from palette.kdl |
#currentScheduler() | Thread-local scheduler escape hatch |
#structFrom(T, f, m) | Generate struct from fields |
#matchUnion(v, vs, h) | Generate case over union variants |
#targetArch() | Comptime target architecture string |
#compileError(msg, .{}) | Emit compile error |
#asm(...) | Inline assembly (see FFI tutorial) |
Violet is designed and documented in this specification. The language is in active design and bootstrap compiler development. Contributions and feedback are welcome at https://gitlab.com/violet-lang