Q+ Language Guide
This guide introduces the Q+ language as implemented by the reference toolchain in Q+ Language 1.5.0-dev and described by the Q+ Language Specification v4.36. It covers the syntax, type system, composite types, functions, generics, traits, macros, FFI, error model, concurrency, the AOT backend, debugging, and the known boundaries of the current implementation.
Version 4.18 focuses on concurrency safety: the memory model, join and spawn semantics, the async scheduler's thread scope, and channel blocking/lifecycle rules are documented under Threads, channels, and thread pools and in specification §9.5.6.
The specification is the normative reference. This guide is a practical companion: it shows complete, runnable examples and calls out behavior that is easy to trip over. Where the specification and the current compiler differ, the difference is stated explicitly.
Every complete program in this guide was compiled and executed with the reference toolchain built by tools/build.sh. Run commands from the repository root so that use std.<module> resolves; from another directory, set QPLUS_ROOT to the repository root.
Contents
- A first program
- Program structure
- Values, variables, and types
- Operators
- Statements and control flow
- Functions and closures
- Structs
- Enums and match
- Arrays
- Strings
- Modules and the standard library
- Generics
- Traits
- Macros
- FFI: extern "C"
- Errors and exit codes
- Async tasks
- Threads, channels, and thread pools
- The AOT backend
- Debugging and observability
- Benchmarks
- Editor and package tooling
- Known boundaries
- Reliable patterns
- Next steps
A first program
fn main() -> i32 {
print("Hello, Q+!\n")
return 0
}
Compile and run it:
build/bqvm --timeout-ms 0 build/qboot.qbc hello.q -o hello.qbc
build/bqvm hello.qbc
The program has three parts:
fn main() -> i32declares the entry point. Every program needs exactly onemain.return 0endsmain; the returned integer becomes the process exit status.printis a language builtin. It writes its argument without adding a newline, which is why the example prints the"\n"suffix explicitly.
print accepts integers, floats, booleans, and strings, and works on both the bytecode VM and the ELF AOT backend. The standard library also offers println_int, println_str, println_float, and println_bool in std.io, but that module wraps VM service builtins, so programs that import it cannot be compiled by the ELF AOT backend (see The AOT backend).
On Linux, bqvm currently writes printed lines with CRLF (\r\n) endings; the specification records this as observed behavior.
Program structure
A Q+ source file is a sequence of top-level items:
| Item | Example |
|---|---|
| Module imports | use std.string |
| Function definitions | fn add(a: i32, b: i32) -> i32 { return a + b } |
| Async functions | async fn work(n: i32) -> i32 { return n * 2 } |
| Struct definitions | struct Point { x: i32, y: i32 } |
| Generic struct definitions | struct Box<T> { v: T } |
| Enum definitions | enum Color { Red, Green(i32) } |
| Trait definitions | trait Shape { fn area(self) -> i32 } |
| Inherent implementations | impl Point { fn sum(self: Point) -> i32 { ... } } |
| Trait implementations | impl Shape for Rect { fn area(self: Rect) -> i32 { ... } } |
| Macro definitions | macro square($x: expr) { $x * $x } |
| Global variables | let limit: i32 = 100 |
| External declarations | extern fn sys_env_get(key: string) -> string |
| C FFI declarations | extern "C" fn puts(s: string) -> i32 |
Imports are declared at the top of the file. The specification places extern declarations after all use declarations; follow that order.
Comments use // and run to the end of the line. Semicolons are optional statement separators.
Values, variables, and types
Declarations and assignment
let declares a variable. The type annotation is optional when the initializer determines the type:
fn main() -> i32 {
let count: i32 = 3
let label: string = "items"
let ratio: f64 = 0.75
let ready: bool = true
let inferred = 42 // inferred as i32
let message = "Q+" // inferred as string
count = count + 1 // assignment to an existing variable
print(count); print(" ")
print(label); print(" ")
print(ratio); print(" ")
print(ready); print(" ")
print(inferred); print(" ")
print(message); print("\n")
return 0
}
There is no const or mut keyword; a let binding is writable. Variables are function-local unless declared at the top level (see Globals).
Type names
| Type | Spelling | Description |
|---|---|---|
| Signed integer | i32, i64 | Wide tagged integers. Arithmetic inside an expression does not wrap or truncate. |
| Unsigned integer | u8, u16, u32, u64 | Ranges [0,255], [0,65535], [0,2^32-1], and the full 64-bit u64 range [0,2^64-1] on the VM (see Full u64 value range); AOT supports the full A5 u64 path (Stage1+2+3: constants/print/arithmetic/comparisons/casts/f64/containers/ABI/std.u64) with VM/AOT parity; documented loud residuals: type-1 i64 constants >2^60, u64->i64 beyond AOT tagged range, std.io sys_svc, AOT bool print parity, u64 bitwise.u64/containers) stay loud. |
| Signed small integer | i8, i16 | Ranges [-128,127], [-32768,32767]. |
| Float | f64 | Double-precision floating point. The specification describes the concept as float; the compiler and standard library use f64. |
| Boolean | bool | true or false. |
| String | string | UTF-8 byte sequence backed by a heap object. |
| Dynamic array | Array or T | Element type T; length is carried at runtime. |
| Fixed-size array annotation | T[N] | Element type T and a size annotation N. |
| Function value | fn or fn(T1, T2) -> R | Bare fn is a wildcard; a signature restricts parameters and result. |
| Struct | declared name | Nominal composite type; may be generic (Box<i32>). |
| Enum | declared name | Nominal tagged-union type. |
| Trait | declared name | Static contract only; not a value type. |
The lower-case word array is the specification's conceptual name for the array type; the compiler accepts Array or T as an annotation. Writing lower-case array as a type annotation is a type error ([TC11]).
Integer matrix and checked overflow
The small integer types are ordinary types with a range contract. Integer expressions are evaluated exactly in a tagged 64-bit carrier; a value is checked when it is written to a small-integer destination (a let initializer, assignment, return, call argument, struct construction argument, global initializer, or cast):
fn main() -> i32 {
let a: u8 = 200
let b: u16 = 60000
let c: u32 = 4000000000
let d: u64 = 18446744073709551615
let e: i8 = 0 - 100
let f: i16 = 0 - 30000
print(a); print(" "); print(b); print(" "); print(c); print(" ")
print(d); print(" "); print(e); print(" "); print(f); print("\n")
return 0
}
200 60000 4000000000 18446744073709551615 -100 -30000
A literal that cannot fit its annotated small-integer type is a compile-time error, not a silent truncation:
Error: integer literal out of range at line 2 type=u8 range=[0..255] [TC15]
=== Compilation aborted: type errors above ===
A literal above i64 max used where a non-u64 type is expected is also a compile-time [TC15] error (exit status 4); it is never silently truncated.
A value that overflows at runtime aborts with a classified runtime error:
Runtime error: overflow: integer overflow
Exit status is 1. Casts are checked conversions with the same guarantee:
fn main() -> i32 {
let a: u8 = u8(200)
let b: i8 = i8(0 - 100)
let c: u8 = u8(3.9)
print(a); print(" "); print(b); print(" "); print(c); print("\n")
return 0
}
200 -100 3
Arithmetic between different integer types is performed exactly and only checked at the write boundary, so let wide: i32 = 255 + 1 is valid and equal to 256. The i32(...)/i64(...)/small-integer casts convert floating-point values by truncating toward zero, and range-check integer conversions.
Full u64 value range (v4.29, A1)
On the VM, u64 supports the full 64-bit range [0, 18446744073709551615]. Full-width decimal literals are lexed exactly (no truncation) and carried as a dedicated unsigned tag. print uses unsigned decimal; comparisons are unsigned (including mixed comparisons with signed integers); and arithmetic in a u64 context uses unsigned wrap-around. Division and modulo by zero abort with Runtime error: division_by_zero: division by zero.
fn main() -> i32 {
let max: u64 = 18446744073709551615
let one: u64 = 1
let zero: u64 = 0
let wrap_up: u64 = max + one
let wrap_down: u64 = zero - one
print(max); print(" ")
print(wrap_up); print(" ")
print(wrap_down); print(" ")
print(max / 3); print(" ")
print(max % 10); print(" ")
print(max > one); print("\n")
return 0
}
18446744073709551615 0 18446744073709551615 6148914691236517205 5 true
Boundaries (the P3-A1 A1 milestone; A2–A5 are follow-ups):
- u64 type-context propagation currently covers
letinitialization expressions. Assignment, function parameters/returns, container elements, and comparison contexts are not yet uniformly propagated (A2); values outside a u64 context keep the signed INT-tagged checked semantics. - Casts, string conversions, checked/wrapping APIs for
u64areA3; container, struct, and ABI unification areA4. std.algwrapping helpers fori64/u64still wrap modulo 2^56 (they are independent of the u64 tag); this is theA3follow-up.- The ELF AOT backend does not implement the u64 opcodes/constants: AOT-building any u64 program fails loudly with
AOT_UNSUPPORTED_OPCODE: code=11, exit status 5, no ELF produced (A5).
Type inference
An unannotated let takes the type of its initializer. Inference is single-level and fixed at the declaration:
fn main() -> i32 {
let n = 5 // i32
let values = [1, 2, 3] // i32
let first = values[0] // i32
let name = "Q+"
print(n + first); print(" ")
print(name); print("\n")
return 0
}
Inference covers scalar literals, array literals with a uniform element type, enum and struct construction, the ternary operator, indexing, member access, function-call results, and function references. It does not flow across branches: the inferred type is fixed where the let is declared, and later assignments are checked against it. if and match are statement forms, so let x = if ... and let x = match ... are parse errors.
Globals
A top-level let declares a global variable:
struct Pair { a: i32, b: i32 }
let total: i32 = 10
let values: i32[3] = [1, 2, 3]
let pair: Pair = Pair()
fn main() -> i32 {
pair.a = 4
print(total + values[0] + values[1] + values[2] + pair.a); print("\n")
return 0
}
20
Global scalar variables may omit the initializer and default to zero. Global aggregates — structs, enums, and arrays — must be initialized, otherwise the compiler reports global aggregate requires initializer ([TC12], exit status 4).
Reference semantics
Structs and arrays have reference semantics. Assignment, argument passing, and nested member access share the same underlying object; there is no implicit copy or deep copy. Use an explicit copy (array_copy, or a manual field-by-field copy) when independent data is required.
struct Box { value: i32 }
fn set(p: Box, v: i32) {
p.value = v
}
fn bump(a: i32) {
a[0] = a[0] + 100
}
fn main() -> i32 {
let x: Box = Box()
x.value = 1
let y: Box = x
set(y, 7)
print(x.value); print(" ")
let a: i32[2] = [1, 2]
let b: i32[2] = a
b[0] = 99
print(a[0]); print(" ")
bump(a)
print(a[0]); print("\n")
return 0
}
7 99 199
The one exception is nested-function capture, which passes an implicit by-value snapshot (see Functions and closures).
Operators
Q+ expression operators are:
| Category | Operators |
|---|---|
| Arithmetic | + - * / % |
| Comparison | == != < > <= >= |
| Logical | && || ! |
| Bitwise | & | ^ << >> ~ |
| Conditional | cond ? a : b |
| String concatenation | + |
Integer division truncates toward zero, and % is the matching remainder. A division by zero aborts at runtime with Runtime error: division_by_zero: division by zero.
The precedence chain from highest to lowest:
arithmetic (+ - * / %)
> comparison (< > <= >=)
> bitwise and (&)
> bitwise xor (^)
> bitwise or (|)
> equality (== !=)
> logical and (&&)
> logical or (||)
> conditional (?:)
For example, c & 1 != 0 parses as (c & 1) != 0. Even though this is correct, an explicit intermediate variable is easier to read:
let lsb: i32 = c & 1
if lsb != 0 {
// ...
}
Boolean values must not be mixed with integers in bitwise operators. A boolean in a bitwise context contributes zero, so 3 & (5 < 10) evaluates to 0, not 1. Use if on the boolean directly instead.
Strings support + for concatenation and all comparison operators; comparison is byte-wise. The two-argument helper str_concat(a, b) from std.string is equivalent to a + b; there is no variadic form.
The conditional operator is the only expression form of conditional evaluation:
fn main() -> i32 {
let x: i32 = 7
let bigger: i32 = x > 5 ? 100 : 200
let low_bit: i32 = x & 1 != 0 ? 1 : 0
print(bigger); print(" "); print(low_bit); print("\n")
return 0
}
100 1
Small integers and signed wide integers are checked by default; unsigned u64 arithmetic in a u64 context wraps modulo 2^64 (see Full u64 value range). For explicit signed wrap-around, std.alg provides wrapping_add, wrapping_sub, and wrapping_mul helpers for u8, u16, u32, i8, i16, i32, i64, and u64 (for example wrapping_add_u8(255, 1) == 0); the i64/u64 helpers wrap modulo 2^56 (A3 follow-up). The default semantics remain checked.
Statements and control flow
The statement forms are:
letdeclarations and assignmentsif/else if/elsewhileloopsreturnmatch- expression statements (for example a function call)
fn main() -> i32 {
let i: i32 = 0
let sum: i32 = 0
while i < 5 {
if i % 2 == 0 {
sum = sum + i
} else if i == 3 {
sum = sum + 100
} else {
sum = sum + 1
}
i = i + 1
}
print(sum); print("\n")
return 0
}
107
if and match are statements, not expressions. They cannot initialize a variable or appear as a function argument, and attempting to do so is a parse error (exit status 2):
// does not compile: parse error (rc=2)
let x: i32 = if true { 1 } else { 2 }
// use the ternary operator instead
let y: i32 = true ? 1 : 2
The specification defines one loop form: while. The current compiler also accepts break, continue, and C-style for (init; cond; step) loops, and the standard library uses break/continue; these are not yet described in the v4.36 specification, so portable code that needs one documented loop construct should prefer while.
Functions and closures
Functions are declared with fn, typed parameters, and an optional return type:
fn add(a: i32, b: i32) -> i32 {
return a + b
}
- A function with no useful result omits the return type or declares
-> void. returnends a function early; functions may recurse.- A function declared inside another function is hoisted to module scope and can be called from other functions.
- A nested function may capture variables and parameters of its enclosing function.
fn factorial(n: i32) -> i32 {
if n <= 1 { return 1 }
return n * factorial(n - 1)
}
fn twice(x: i32) -> i32 {
return x * 2
}
fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
return f(x)
}
fn greet(name: string) {
print("hello "); print(name); print("\n")
}
fn main() -> i32 {
greet("Q+")
print(factorial(5)); print("\n")
let f: fn(i32) -> i32 = twice
print(apply(f, 21)); print("\n")
fn local(x: i32) -> i32 { return x + 1 }
print(local(41)); print("\n")
return 0
}
hello Q+
120
42
42
Closures: snapshot capture
A nested function can read an enclosing local or parameter. Captured values are passed to the nested function as implicit by-value arguments at each call site: this is snapshot semantics, not a shared environment.
fn main() -> i32 {
let x: i32 = 7
fn inner() -> i32 { return x }
print(inner()); print("\n")
return 0
}
7
Writing to a captured name inside the nested function only changes its local copy; the outer variable is unchanged:
fn main() -> i32 {
let x: i32 = 10
fn bump() -> i32 { x = x + 1; return x }
print(bump()); print(" "); print(x); print("\n")
return 0
}
11 10
Capture works with parameters and inside loops:
fn outer(base: i32) -> i32 {
let step: i32 = 3
fn add(a: i32) -> i32 { return a + step + base }
let i: i32 = 0
let s: i32 = 0
while i < 5 { s = add(s); i = i + 1 }
return s
}
fn main() -> i32 {
print(outer(2)); print("\n")
return 0
}
25
The boundary: a capturing nested function cannot be stored in a fn slot or otherwise escape its defining function. The implicit capture arguments only exist at the call sites the compiler can see, so taking its address is a loud type error. There are no heap environments, no delayed-call closures, and no capture across more than one nesting level.
Function values
Functions are first-class values:
struct Ops { f: fn(i32) -> i32 }
let gf: fn(i32) -> i32 = inc
fn inc(x: i32) -> i32 { return x + 1 }
fn apply(f: fn(i32) -> i32, x: i32) -> i32 { return f(x) }
fn main() -> i32 {
let o: Ops = Ops()
o.f = inc
print(gf(41)); print(" ")
print(o.f(41)); print(" ")
print(apply(inc, 41)); print("\n")
return 0
}
42 42 42
- A bare function name or
&nameproduces a function value. fnis a wildcard function type;fn(T1, T2) -> Ris a signature type.- Function-value slots can live in local and global
lets, struct fields, and parameters; signed slots are checked atlet/assignment/call sites. - A local
let f: fn = <function reference>refines the slot to the inferred signature; any reassignment downgrades it to the wildcard.
External declarations
extern declares a function implemented by the VM runtime rather than in Q+:
use std.io
extern fn sys_env_get(key: string) -> string
fn main() -> i32 {
println_str(sys_env_get("HOME"))
return 0
}
The standard library wraps the raw sys_* builtins in ordinary modules (std.env, std.io, std.file, and so on), so application code normally calls the standard-library functions instead of the raw declarations. The ELF AOT backend does not link sys_* builtins (see The AOT backend).
Structs
A struct is a named record with typed fields:
struct Point { x: i32, y: i32 }
impl Point {
fn sum(self: Point) -> i32 {
return self.x + self.y
}
}
fn main() -> i32 {
let p: Point = Point() // allocate a struct object
p.x = 3 // assign every field before reading it
p.y = 4
print(p.sum()); print("\n")
return 0
}
7
- Construct a struct with
TypeName()and assign fields afterwards. There is no brace-based struct literal syntax. TypeName()allocates the object but does not initialize its fields. Assign every field before reading it; reading an uninitialized field is a bug, and uninitialized string fields can abort at runtime.- Fields are accessed and assigned with
.. impl TypeName { ... }adds methods. A method receives the receiver as an explicit first parameter namedself, and calls use uniform method syntax:value.method(args).- Struct values have reference semantics (see Reference semantics).
struct Box<T> { v: T }declares a generic struct; see Generics.
Enums and match
An enum is a tagged union. Variants may carry payloads:
enum Color { Red, Green(i32), Blue(i32, i32) }
fn describe(c: Color) -> i32 {
match c {
Color.Red => { return 1 }
Color.Green(x) => { return x + 10 }
Color.Blue(a, b) => { return a + b }
}
return 0
}
fn main() -> i32 {
print(describe(Color.Red)); print(" ")
print(describe(Color.Green(5))); print(" ")
print(describe(Color.Blue(3, 4))); print("\n")
return 0
}
1 15 7
Construction and pattern rules:
- A payload-free variant is written
Color.Red; a variant with payload is constructed withColor.Green(5). - Enum constructors take their declared payload types; payload enum values must be passed as enum constructors, not bare integers.
matchis a statement. Each arm isPattern => statementorPattern => { statements }.- Patterns must be qualified with the enum type: write
Color.Red, notRed. _is a wildcard pattern.- Matching an enum without covering every variant requires a wildcard arm; otherwise the compiler reports a non-exhaustive match (
[TC13], exit status 4). - Payload variables bound in a pattern carry the variant's declared types and are type-checked in the arm body.
An enum may have impl methods:
enum Result2 { Ok(i32), Err(string) }
impl Result2 {
fn is_ok(self: Result2) -> bool {
match self {
Result2.Ok(_) => { return true }
_ => { return false }
}
}
}
fn main() -> i32 {
let good: Result2 = Result2.Ok(42)
let bad: Result2 = Result2.Err("e")
print(good.is_ok()); print(" "); print(bad.is_ok()); print("\n")
return 0
}
1 0
Result/Option are ordinary enums, not built-in exception types. The language has no exceptions; the standard library's std.result module provides a convenient Result encoding (see Errors and exit codes).
Arrays
Q+ has fixed-size annotations, dynamic arrays, and array literals:
use std.array
fn main() -> i32 {
let a: i32[3] = [10, 20, 30]
a[1] = 99
print(a[0] + a[1] + a[2]); print(" ")
let b: i32 = Array(4)
b[0] = 7
print(len(b)); print(" ")
let m: i32 = [[1, 2], [3, 4]]
print(m[1][0]); print(" ")
let copy: Array = array_copy(a)
copy[0] = 0
print(a[0]); print("\n")
return 0
}
139 4 3 10
- An array literal is written
[e0, e1, ...]. Array(n)allocates a dynamic array of lengthn; its type can be writtenArrayorT.len(value)returns the length of an array or string.- Arrays are indexed from zero; out-of-bounds access aborts at runtime with
Runtime error: index_out_of_bounds: .... - Nested arrays are declared and indexed recursively (
i32,m[i][j]); arrays of structs are supported (Point[2]). - Arrays have reference semantics;
array_copy(fromstd.array) creates an independent copy. - Small-integer array element assignment is range-checked when the element type is statically known.
std.arrayprovides helpers such asarray_copy,array_slice,array_sort,array_reverse,array_sum,array_contains, andarray_find.
A heterogeneous literal without an annotation (let a = [1, "two", 3]) becomes a wildcard array and its elements are no longer type-checked. See Known boundaries.
Strings
Strings are UTF-8 byte sequences with a rich standard-library module:
use std.string
fn main() -> i32 {
let a: string = "Q"
let b: string = "+"
let name: string = a + b
let s: string = "汉字a"
print(name); print(" ")
print(str_len(name)); print(" ")
print(str_char_len(s)); print(" ")
print(char_code("A", 0)); print(" ")
print(str_substr("abcdef", 1, 3)); print(" ")
print(str_char_at(s, 0)); print("\n")
return 0
}
Q+ 2 3 65 bcd 汉
- String literals use double quotes and support the usual escapes (
\n,\t,\"). +concatenates strings;str_concat(a, b)is a two-argument helper.==and!=compare strings, and<,>,<=,>=order them byte-wise.str_equals(a, b)returns an explicitbool.len(s)andstr_len(s)count bytes, not characters.str_char_len,str_char_at, andstr_char_substroperate on Unicode code points;str_substr,char_at, and indexing operate on byte positions.- Reading a byte position with
s[i]yields a one-byte string. Writing to a string index is not supported and aborts at runtime. - Useful
std.stringhelpers includestr_contains,str_starts_with,str_ends_with,str_index_of,str_trim,str_to_upper,str_to_lower,str_split_get,str_split_count,str_to_int,int_to_str, andstr_replace_all.
Modules and the standard library
Standard-library modules are imported by name:
use std.io
use std.math
use std.string
fn main() -> i32 {
println_float(sqrt(81.0))
println_int(str_to_int("123"))
println_str(int_to_str(456))
return 0
}
9.000000
123
456
use std.<module>loadsstdlib/std/<module>.q;use std::<module>is accepted as an equivalent form.- Place all
usedeclarations before other items and beforeexterndeclarations. - Import every standard-library module whose functions you call. Missing imports surface as
undefined functionerrors at code-generation time (exit status 3). - Relative
use std.<module>paths are resolved against the current working directory. Run programs from the repository root, or setQPLUS_ROOTto the repository root when running from elsewhere (the build and test scripts run from the tree root). - An unknown module is a loud compile error (exit status 2):
Error: unknown module in 'use' at line 1 (only std.<module> / std::<module> supported). - The module set is flat:
std.io,std.string,std.array,std.math,std.env,std.file,std.json,std.csv,std.http,std.datetime,std.alg,std.async,std.thread,std.panic,std.result, and many more — over 940 modules understdlib/std/. - This release has no external package manager inside the language tree;
qpmis a separate tool (see Editor and package tooling).
Generics
Generics are erased: a single copy of the code runs at runtime, while the type checker binds type parameters at the call site and substitutes them into the result type.
fn id<T>(x: T) -> T { return x }
fn main() -> i32 {
let n: i32 = id(7)
let s: string = id("ok")
print(n); print(" "); print(s); print("\n")
return 0
}
7 ok
Call-site checks enforce consistency: a parameter that appears twice must bind to the same concrete type, and the substituted return type participates in the surrounding checks:
fn max_of<T>(a: T, b: T) -> T { ... }
let x: i32 = max_of(1, "s")
[TC4] line 3 want=i32 got=string
=== Compilation aborted: type errors above ===
Generic structs are tracked for field typing even though code generation erases the parameter:
struct Box2<T> { v: T }
fn main() -> i32 {
let a: Box2<i32> = Box2(7)
let b: Box2<string> = Box2("hi")
print(a.v); print(" "); print(b.v); print("\n")
return 0
}
7 hi
Box2<i32>.v = "str" is a [TC2] error and Box2<i32>/Box2<string> values are not interchangeable. An unannotated let b = Box2(7) is inferred as a plain struct and loses field-type tracking (a documented boundary).
Traits
A trait is a static contract. impl Trait for Type must provide every method the trait declares, with compatible arity, parameter types, and return type.
trait Shape { fn area(self) -> i32 }
struct Rect { w: i32, h: i32 }
impl Shape for Rect {
fn area(self: Rect) -> i32 { return self.w * self.h }
}
fn main() -> i32 {
let r: Rect = Rect()
r.w = 6
r.h = 7
print(r.area()); print("\n")
return 0
}
42
- Dispatch is static: the concrete type is known at compile time.
- A trait name is not a value type.
dyn Traitand trait objects are explicit loud errors, and a trait used in a type position is[TC11]:
Error: `dyn Trait` trait objects are not supported (static dispatch only; use a concrete type or generic fn) at line 3 [TC11]
- Missing or mismatched trait methods are compile errors with source line numbers (exit status 2).
Macros
Q+ has declarative macros. A macro is declared with macro name($param: category) { ... } and invoked with name!(args...):
macro square($x: expr) { $x * $x }
fn main() -> i32 {
print(square!(5)); print(" "); print(square!(2 + 3)); print("\n")
return 0
}
25 11
- Parameter categories are
expr,stmt, andident; other categories are compile errors. A macro takes at most 8 parameters. - Expansion happens in the token stream before parsing, so macros introduce no runtime objects or opcodes; VM and AOT behavior are identical.
- Arguments are not automatically parenthesized.
square!(2 + 3)expands to2 + 3 * 2 + 3(= 11), matching C macro behavior; parenthesize arguments when in doubt. - Statement macros work as well:
macro twice($s: stmt) { $s; $s; }
twice!(print("ab"))
- Minimal hygiene: identifiers in the macro body that start with
__are renamed to unique generated names, so they do not clash with caller locals:
macro swap($a: expr, $b: expr) { let __t: i32 = $a; $a = $b; $b = __t; }
fn main() -> i32 {
let __t: i32 = 99
let x: i32 = 1
let y: i32 = 2
swap!(x, y)
print(__t); print(" "); print(x); print(" "); print(y); print("\n")
return 0
}
99 2 1
- Macro errors are loudly reported at the invocation line (unknown macro, wrong argument count/category, unknown
$param, duplicate or nested declarations, recursion deeper than 64): exit status 2. - Limitations: no
macro_rules!pattern matching or repetition, no procedure macros, top-level declarations only, no nested macro declarations.
FFI: extern "C"
Q+ supports a deliberately small, controlled C FFI:
extern "C" fn puts(s: string) -> i32
extern "C" fn strlen(s: string) -> i32
fn main() -> i32 {
puts("hello ffi")
print(strlen("abcd")); print("\n")
return 0
}
hello ffi
4
- Only the
"C"ABI string is accepted; other ABIs are compile errors. - The VM exposes a trusted shim whitelist (
puts,strlenin this release). Unknown extern names fail loudly at load time:Error: failed to load QBC: unsupported extern function: <name>(exit status 1), never a silent zero-length stub. - Safety rules: all
extern "C"calls are unsafe boundaries. Parameters are limited to scalars and a borrowed, read-onlystringview that is valid only for the call; callees must not retain or free it. Structs, arrays, and raw pointers are not supported, and the shim table is maintained in the VM binary. - The ELF AOT backend has no shim table, so any FFI program fails loudly at AOT compile time (
AOT_UNSUPPORTED_BUILTIN: FFI shim ..., exit status 5).
Errors and exit codes
Q+ has no exceptions and no try/catch. Errors fall into two groups.
Compile-time errors
The compiler proceeds through lexing/parsing, macro expansion, static type checking, and code generation. Each phase has its own exit status:
| Exit status | Phase | Typical output |
|---|---|---|
| 0 | Success | No errors; a .qbc file is written |
| 1 | I/O or argument error | Unreadable source, invalid QBC, unknown extern at load time |
| 2 | Lexical/syntax error (and macro errors) | Error: expected token 57 got 7 at line 3, Error: macro: ... |
| 3 | Semantic or code-generation error | Error: undefined function called at line 2 |
| 4 | Static type error | [TC1] line 5 want=i32 got=string, [TC15], [TC11] |
| 5 | AOT unsupported feature | AOT_UNSUPPORTED_BUILTIN: ..., AOT_UNSUPPORTED_OPCODE: ..., or AOT_UNSUPPORTED_I64_CONST: ..., written by the AOT tool; no ELF produced |
Type errors report the checker rule ([TC1] for let, [TC4] for call arguments, [TC15] for integer literal range, and so on), the source line, and the expected and actual types. Type checking runs before code generation; a type error produces no output file.
Runtime errors
The VM and the ELF AOT backend share one classified runtime-error model. A failure prints a Runtime error: <class>: <detail> line to stdout and exits with status 1:
| Class | Cause |
|---|---|
panic | panic() / panic_i32() from std.panic; prints PANIC: <msg> first |
overflow | checked small-integer boundary overflow |
division_by_zero / modulo_by_zero | integer or floating-point division/modulo by zero |
index_out_of_bounds | array index out of bounds |
stack_overflow | call stack overflow |
heap_exhausted / out_of_memory | heap object limit / allocation failure |
assert_failed | assert(cond) with a falsy condition |
timeout / ip_out_of_bounds / runtime_error | execution timeout, malformed QBC, other |
assert(cond)is a language builtin: a falsy condition aborts withRuntime error: assert_failed: assertion failed, exit status 1.std.panicprovidespanic(msg)andpanic_i32(msg, value):PANIC: <msg>on stdout, followed byRuntime error: panic: <msg>, exit status 1.std.resultprovides the library-levelResultconvention.Result.Ok(v)/Result.Err(msg),is_ok,is_err,result_unwrap,result_expect, and friends operate on tagged values; unwrapping anErrterminates throughpanicinstead of silently continuing. There are no language exceptions.- Concurrency violations are classified runtime errors too. A spawn-limit violation reports
Runtime error: runtime_error: thread: spawn limit exceeded (max 16 threads); a concurrent second join reportsthread: join already in progress on this tid; a worker executing async primitives reportsasync: tasks are not supported in worker threads; a blocked channel wait interrupted by the global execution budget reportsRuntime error: timeout: execution timeout exceeded. When the failure happens in a worker thread, the message and traceback are printed to stderr with a[thread <tid>]prefix and the process keeps running.
use std.result
fn main() -> i32 {
let r: i32 = Result.Ok(42)
print(is_ok(r)); print(" "); print(result_unwrap(r)); print("\n")
return 0
}
1 42
The integer returned by main also becomes the process exit status for normal returns.
Async tasks
The std.alg module provides a minimal cooperative task queue that works on both the VM and the ELF AOT backend:
use std.alg
async fn work(n: i32) -> i32 { return n * 2 }
fn task_a() -> i32 { return work(21) }
fn main() -> i32 {
let id: i32 = spawn(task_a)
let r: i32 = await id
print(r); print("\n")
return 0
}
42
async fn is a semantic marker (callable like a normal function); await expr is syntax sugar for await_task(expr). spawn(fn() -> i32) enqueues a task, await_task(id) runs queued tasks FIFO to completion, and yield_now() runs one queued task.
The std.async module provides stackful, suspending tasks on the VM (ucontext-based, each task with its own stack and saved interpreter state):
use std.async
fn t1() -> i32 { print("A1 "); yield_now(); print("A2 "); return 1 }
fn t2() -> i32 { print("B1 "); yield_now(); print("B2 "); return 2 }
fn main() -> i32 {
let a: i32 = spawn(t1)
let b: i32 = spawn(t2)
print("await1 ")
let ra: i32 = await a
print("ra="); print(ra); print(" ")
let rb: i32 = await b
print("rb="); print(rb); print("\n")
return 0
}
await1 B1 B2 A1 A2 ra=1 rb=2
std.async also provides cancellation, state inspection, timeouts, and real sleeping:
use std.async
fn work() -> i32 {
print("W1 "); task_sleep(100); print("W2 ")
return 1
}
fn main() -> i32 {
let t: i32 = spawn(work)
let r1: i32 = task_wait_timeout(t, 20)
let c: i32 = task_cancel(t)
let st: i32 = task_state(t)
let r2: i32 = await_task(t)
print("r1="); print(r1); print(" c="); print(c); print(" st="); print(st); print(" r2="); print(r2); print("\n")
return 0
}
W1 r1=-2 c=0 st=4 r2=-3
task_state(id): 0 = not started, 1 = running, 2 = suspended, 3 = done, 4 = cancelled, 5 = error.task_cancel(id)marks the task for cooperative cancellation at a yield/sleep resume point and propagates along creator/waiter edges; a cancelledawaitreturns-3.task_wait_timeout(id, ms)waits up tomsmilliseconds.-2means timeout (the task keeps running and can be waited on again);-3means cancelled;-1means the task failed.task_sleep(ms)suspends the current task for a real deadline; the executor resumes it from the ready scan, without busy-waiting.- Calls from the root task (
yield_now,task_sleep,task_cancelon root) are loud runtime errors.
Limits: one VM, at most 16 tasks, each task's operand stack up to 4096 values, no IO-ready queue, and no preemption (tasks yield cooperatively). std.alg and std.async both export same-named wrappers; do not import both in one file. The current compiler has no compiler-generated Future state machine.
The stackful task scheduler is main-thread-only. A worker thread that calls any async primitive (spawn, yield_now, task_wait, task_cancel, task_state, task_wait_timeout, task_sleep) fails loudly with async: tasks are not supported in worker threads (surfaced on stderr with the [thread <tid>] prefix); the VM never performs a cross-thread swapcontext. Use channels and thread primitives for cross-thread coordination.
Threads, channels, and thread pools
std.thread provides a pthread-backed model in which threads never share mutable memory: each thread runs an independent VM instance (copied read-only code/constants, fresh stack, heap, and globals). Communication is only through explicit channel messages.
use std.thread
fn work_a() -> i32 { return 40 }
fn work_b() -> i32 { return 2 }
fn dbl(x: i32) -> i32 { return x * 2 }
fn main() -> i32 {
let t1: i32 = thread_spawn(work_a)
let t2: i32 = thread_spawn(work_b)
let t3: i32 = thread_spawn_arg(dbl, 21)
let a: i32 = thread_join(t1)
let b: i32 = thread_join(t2)
let c: i32 = thread_join(t3)
print(a + b); print(" "); print(c); print("\n")
return 0
}
42 42
thread_spawn(f: fn() -> i32),thread_spawn_arg(f: fn(i32) -> i32, arg),thread_join(tid),thread_join_timeout(tid, ms),thread_last_error(tid).- A thread runtime error or panic is loud on stderr with a
[thread <tid>]prefix and traceback; the process keeps running.thread_joinreturns-1for abnormal termination, andthread_last_errorreports0(normal),1(runtime error), or2(panic), still queryable after join. thread_join_timeout(tid, ms)returns-2on timeout; the thread keeps running and can be joined later (a timeout does not consume the join).- Join state is synchronized. A concurrent second join of the same tid from another thread fails loudly with
thread: join already in progress on this tid; it does not hang and never double-joins. A repeated join from the same thread after a successful join is idempotent and returns the recorded result. - The thread limit is loud: attempting to create more than 16 threads aborts with
Runtime error: runtime_error: thread: spawn limit exceeded (max 16 threads)(exit status 1) instead of silently returning-1.
Channels are bounded queues:
use std.thread
fn prod_a() -> i32 { channel_send(1, 111); return 1 }
fn prod_b() -> i32 { channel_send(1, 222); return 2 }
fn main() -> i32 {
let ch: i32 = channel_new(4)
let t1: i32 = thread_spawn(prod_a)
let t2: i32 = thread_spawn(prod_b)
let v1: i32 = channel_recv(ch)
let v2: i32 = channel_recv(ch)
let a: i32 = thread_join(t1)
let b: i32 = thread_join(t2)
print(v1 + v2); print(" "); print(a + b); print("\n")
return 0
}
333 3
channel_new(cap),channel_send,channel_recvcarry scalari32messages;channel_send_i64/channel_recv_i64carryi64(full-width scalar);channel_send_str/channel_recv_strcarry strings.- String messages are copied: the sender's UTF-8 bytes are copied into the channel buffer (1 MiB limit), and the receiver allocates a fresh string in its own VM heap. No cross-thread pointers or reference counting are used.
channel_close(ch)wakes blocked senders/receivers. After close, sends fail loudly, receives may drain buffered messages, and then return-3(i32/i64) or an empty string (str);channel_is_closed(ch)distinguishes an empty message from a closed channel.- Timeout variants (
channel_send_timeout,channel_recv_timeout, and the_i64_/_str_forms) return-2on timeout and-3when closed. - Blocking
channel_send/channel_recvwait in 20 ms slices, so the global--timeout-msbudget and runtime shutdown can interrupt them. A blocked channel call that reaches the execution deadline aborts the program withRuntime error: timeout: execution timeout exceeded(exit status 1) instead of hanging. For precise per-call deadlines, use the*_timeoutAPIs. - Channel slots are not reclaimed after
channel_close, and undrained string messages are retained until process exit. Long-running programs should bound channel creation and drain channels before closing.
Thread pools run jobs on fixed worker threads, each job in its own child VM:
use std.thread
fn j1() -> i32 { return 10 }
fn j2() -> i32 { return 11 }
fn j3() -> i32 { return 12 }
fn j4() -> i32 { return 9 }
fn main() -> i32 {
let p: i32 = pool_new(2)
let a: i32 = pool_submit(p, j1)
let b: i32 = pool_submit(p, j2)
let c: i32 = pool_submit(p, j3)
let d: i32 = pool_submit(p, j4)
let r: i32 = pool_join(p)
let s: i32 = pool_job_result(p, a) + pool_job_result(p, b) + pool_job_result(p, c) + pool_job_result(p, d)
print(r); print(" "); print(s); print("\n")
let sh: i32 = pool_shutdown(p)
print(sh); print("\n")
return 0
}
0 42
0
pool_new(size)accepts 1..16 workers and allows up to 4 pools per process.pool_submit(pool, fn() -> i32)enqueues a job on a bounded queue (capacity 64; up to 256 jobs); a full queue blocks the submitter.pool_join(pool)returns0(all jobs succeeded),-1(some job failed), or-2(pool was shut down).pool_shutdown(pool)is idempotent;pool_job_result(pool, job)returns a completed result,-1for a failed job, or-2for an unfinished one, and is a loud error for an invalid job id.- Job errors are loud on stderr with a
[pool <id> job <n>]prefix and traceback; the process keeps running.
Caps: 16 threads (ids 1..15 usable), 4 pools, 16 workers per pool, 64 channels per process, channel capacity 64, 256 jobs per pool, 1 MiB per string message. There are no pool_submit_timeout jobs, no job arguments/closures, and no cross-thread structured cancellation of threads. The thread backend requires a host pthread implementation (Linux); the a separate product line kernel backend is separate.
Concurrency memory model and lifecycle (v4.18)
The P2-C10 safety fixes define the following model:
- Isolation. Each thread runs an independent VM with fresh stack, heap, and globals. Threads never share mutable Q+ memory; channels are the only data crossing point, and string messages are copied (see above).
- Snapshot before spawn.
thread_spawn*copies a read-only parent-VM snapshot in the spawning thread beforepthread_create. Workers read only that snapshot to build their child VM; they never read the live parent VM structure while the parent continues executing. - Serialized global state. Thread/channel/pool slot allocation and reuse, join state, the task boot function, and console output are serialized by process-level mutexes. Channel data paths use a per-channel mutex and condition variable. This makes concurrent joins and concurrent slot reuse deterministic instead of racy.
- Async scope. The stackful (ucontext) task scheduler belongs to the main thread only. Worker threads cannot run async tasks; they fail loudly and coordinate through channels instead.
- Teardown. On exit, the VM first shuts down concurrency (sets a shutdown flag, broadcasts all channel/pool conditions, joins all worker and pool threads, reclaims child VMs, and releases child heap objects). It only then frees the parent VM's functions, constants, and bytecode. A worker still running at shutdown observes the shutdown flag at its
bqvm_runcheckpoint and exits, so an unjoined worker cannot outlive the parent VM. - Loud failures. Concurrency violations are never silent: concurrent double-join, exceeding the thread limit, async calls from worker threads, and interrupted channel waits each produce a classified error (see Errors and exit codes).
The v4.18 fixes are covered in the reference suite by the concurrency security fixtures under src/tests/threads/security/; TSAN reports zero data races for the thread/channel cases, and ASAN reports zero errors for unjoined-worker teardown cases.
The AOT backend
The build produces build/cgtest.qbc (the AOT compiler) and build/cgtest_aot (the ELF backend). To build a native ELF from a compiled program:
build/bqvm --timeout-ms 0 build/qboot.qbc hello.q -o hello.qbc
build/cgtest_aot hello.qbc hello.elf
chmod +x hello.elf # the tool writes the file, not its executable bit
./hello.elf
The ELF AOT backend supports: control flow, arithmetic, calls, print, assert, checked integer bounds (INT_CHECK), casts (I2F/F2I/I2I), CALL_INDIRECT for function-value slots, and runtime errors with a stderr traceback (at <fn> (line <N>), up to 8 frames). Its stdout error line and exit status are byte-identical to the VM's. The cooperative task queue in std.alg is AOT-supported.
The backend is loud, never silent, about what it does not support:
| Feature | ELF AOT behavior |
|---|---|
Unknown or unmapped sys_* builtin (for example any program importing std.io) | Compile-time loud fail, AOT_UNSUPPORTED_BUILTIN, exit 5, no ELF written |
extern "C" FFI shim | Compile-time loud fail, exit 5, no ELF written |
std.panic / Result unwrap on Err (calls sys_abort) | Compile-time loud fail, exit 5, no ELF written |
| Exponent-notation float constants | Compile-time loud fail, AOT_UNSUPPORTED_OPCODE, exit 5 |
Integer constants outside [-2^60, 2^60-1] (including negative literals whose magnitude exceeds 2^60-1) | Compile-time loud fail, AOT_UNSUPPORTED_I64_CONST, exit 5, no ELF (v4.29) |
| Programs using unsupported u64 features (A5 Stage2/3: casts/bitwise/std.u64/full containers) | Loud fail, AOT_UNSUPPORTED_OPCODE (e.g. code=125), exit 5, no ELF; A5 Stage1 subset builds with VM/AOT parity |
Executed std.async task primitive | ELF builds; execution traps with Runtime error: runtime_error: AOT: async primitives unsupported (VM-only), exit 1 |
Executed std.thread/channel/pool primitive | ELF builds; execution traps with the same async-primitive message, exit 1 |
| Unused async/thread wrappers | Do not block the build (runtime trap provides call-path loudness; compile-time DCE is not implemented) |
Because importing std.io pulls in VM service builtins that the AOT backend cannot link, AOT-compatible programs should use the print builtin instead of std.io output helpers. Compile with the VM toolchain for programs that need std.io, std.thread, or other service-backed modules.
Debugging and observability
On a runtime failure, the VM prints the classified error line to stdout and a traceback to stderr:
Runtime error: division_by_zero: division by zero
Traceback (most recent call last):
at boom (line 1, pc=12)
at mid (line 2, pc=13)
at main (line 3, pc=14)
- At most 8 frames are printed.
- Source line numbers come from the QBC debug line table emitted by
qboot; old QBC files without the table still load (traceback showspconly). QPLUS_TRACEBACK=0disables the stderr traceback; the stdout error line is unchanged.- ELF AOT runtime errors print a traceback to stderr with function name and real source line (
at <fn> (line <N>)); stdout error lines and exit status match the VM. AOT panic/sys_abortpaths are compile-time loud failures and produce no ELF, so they have no AOT traceback. build/bqvm --helpprints all VM options;build/bqvm --versionprints the VM and minimal-runtime versions; theQ+VS Code extension surfaces compile, runtime, traceback, and AOT diagnostics in the Problems panel.
Benchmarks
bench/ contains 9 representative programs (recursion, loops, sieve, sorting, array allocation, strings, function calls, structs, integer math) plus bench/run.sh [reps]. The harness compiles each program, builds an AOT ELF, runs both backends (and two C reference kernels), verifies output checksums, and writes raw per-run timings plus environment/tool anchors to bench/results/raw-*.tsv.
bench/run.sh 3
The suite reports the VM/AOT difference for the machine it runs on. Benchmark numbers are machine-specific and are not comparable across machines; the raw files record the CPU, compiler, and tool hashes so results can be interpreted honestly. Nine of nine benchmark programs are VM/AOT checksum-consistent in this release, and the suite runs as part of tools/test.sh.
Editor and package tooling
- VS Code extension is a separate editor component: the release package's supported component is 0.6.0 (190 checks; syntax highlighting, snippets, formatter, build / build-AOT / run / test commands, compiler/runtime/AOT diagnostics, and an offline LSP with symbols, definition, references, rename, signature help, semantic tokens, hover/completion, code actions, inlay hints, document links, code lenses, and workspace folders). The extension sources are in
editors/vscode; package with itsscripts/package.sh; no Marketplace publication is bundled. An optional/dev LSP 0.7.0 (208 checks; Modules B qualified paths, OS SDK 0.1 module awareness, TC16/TC17 Explain) exists outside the release tar and is not a supported release component; 0.11.0 is the current optional/dev.vsix(0.9.0/0.10.0 earlier) (not in the tar, not Marketplace-published); 0.7.0/0.8.0 were earlier dev lines. qpmpackage manager is a separate tooling component, not part of this language tree. Current line: qpm 0.3.0-alpha.1, a single-file Python tool (stdlib only) with static registry support (index.jsonplus package tarballs), deterministicpublish, versions, dependency resolution, offline install cache, search/info/yank, andadd/install. It is useful for organizing external Q+ source packages; the language tree itself only ships and imports the standard library.
Known boundaries
This section summarizes the documented limits of the current implementation. The normative details are in specification §7.4 (type-checker coverage), §7.5 (function values/inference), §8 (semantics, integer matrix, error model), §9.5.6 (concurrency memory model and lifecycle), §9.5.7 (full u64), and §10.1 (AOT integer constant range).
Type-checker coverage and residual limits (spec §7.4)
The checker validates let initializers, assignments, return expressions, call arguments (including extern, impl methods, and method-call syntax), member access, indexing, conditions, arithmetic and string concatenation, unknown type names, match exhaustiveness (TC13/TC14), global initializers (TC12), enum constructor payloads, function-pointer signatures, erased-generic call sites, trait implementation contracts, and small-integer literal/boundary checks (TC15, INT_CHECK).
Intentional or still-open limitations:
- A bare
fnin a parameter position is a wildcard and cannot be checked against a signature. - A bare
fnstruct field remains a wildcard; local bare-fnslots refine from their initializer and downgrade to wildcard after reassignment. - Once a heterogeneous array literal becomes a wildcard
array, element accesses are no longer type-checked and the element type is not recovered. - String
==/!=are not type-checked (historical permissive behavior). - The checker is deliberately permissive about implicit numeric conversions and about
bool/numeric interchange in assignments. - Generic bounds and monomorphization are not implemented; code generation is fully erased.
dyntrait objects, default methods, and associated types are not implemented.u64supports the full 64-bit range on the VM (v4.29/A1), but uniform u64 contexts, casts, container/ABI propagation, checked/wrapping APIs, and full AOT u64 support are theA2–A5follow-ups. See Full u64 value range.- ELF AOT integer constants are exact only in
[-2^60, 2^60-1]; out-of-range constants fail loudly at AOT build time (AOT_UNSUPPORTED_I64_CONST, exit 5, no ELF). Runtime tagged arithmetic overflow beyond the AOT tagged range remains anA5residual. See The AOT backend.
Inference boundaries (spec §7.5)
Inference is single-level and fixed at the let declaration. It covers scalar and array literals, enum/struct construction, the ternary operator, indexing, member access, call results, and function references. Because if and match have no expression form, let x = if ... and let x = match ... are parse errors (exit status 2); the only expression-level conditional is ?:.
Heterogeneous array literals follow the documented contract:
- Without an annotation,
[1, "two", 3]degrades to a wildcardarray: no error, element accesses unchecked, value still usable at runtime. - With an annotation (
i32[3],T), elements are checked recursively. - Same-family numbers (
[1, 2.5]) fold to the first element's type. - Call arguments and returns check only top-level array compatibility.
Reference semantics (spec §8.1)
Structs and arrays are shared references; there is no implicit copy or deep copy. Nested-function capture is the exception: it is a by-value snapshot (see Functions and closures).
Integer semantics (spec §8.2)
i32/i64 are tagged wide integers: arithmetic does not wrap or truncate (2147483647 + 1 = 2147483648, 70000 * 70000 = 4900000000). The VM supports the full i64 constant range and the full u64 range (v4.29); u64 literals above i64 max require a u64 target and are otherwise [TC15]. Small integers are checked at their type boundaries and abort with overflow rather than truncating. The ELF AOT backend represents integer constants only in [-2^60, 2^60-1]: in-range constants are exact, out-of-range constants are a loud build error (AOT_UNSUPPORTED_I64_CONST), and runtime tagged arithmetic overflow is an A5 residual. Implementing 32-bit hash or bit-mixing algorithms requires explicit masking (for example & 0xFFFFFFFF) or the std.alg wrapping helpers.
Error model (spec §8.3)
Compile-time exit statuses are 0 (success), 1 (I/O), 2 (parse/macro), 3 (semantic/codegen), 4 (type), and 5 (AOT unsupported). Runtime failures abort with status 1 and a classified Runtime error: <class>: <detail> message; there is no runtime recovery or exception handling, and Result is a library convention. Concurrency violations follow the same rule: join/spawn/async-scope violations are classified runtime errors (or [thread <tid>]-prefixed stderr diagnostics when raised inside a worker), never silent failures. AOT output for the error line and exit status is byte-identical to the VM; AOT traceback is supported for non-panic runtime errors.
Concurrency boundaries (spec §9.5.6)
- The stackful async scheduler is main-thread-only; worker threads cannot run async tasks and fail loudly if they try.
- Threads share no mutable Q+ memory; only channel messages cross thread boundaries, and string messages are copied.
- A concurrent second join of the same thread is a loud error; a repeated join from the same thread is idempotent.
- Threads beyond the 16-slot limit are a loud error, not a silent
-1. - Blocking channel waits are interruptible by
--timeout-ms/shutdown, but the precise per-call deadline belongs to the*_timeoutAPIs. - Channel slots are not recycled after close; undrained string messages are retained until process exit. Long-running programs must bound and drain channels themselves.
- There is no cross-thread structured cancellation of threads, no thread pool submit timeout, and no shared-memory synchronization primitives beyond channels and the VM's internal mutexes.
Other hard facts
- A
boolused in a bitwise operation contributes0; do not mix booleans and integers bitwise. str_concataccepts exactly two arguments.- Strings are UTF-8 bytes: byte-position APIs (
str_substr,char_at,str_len) and code-point APIs (str_char_substr,str_char_at,str_char_len) are different; choose deliberately. read_linereads into a fixed 4 KB buffer; longer lines are truncated to 4095 bytes, and empty input or end-of-file yields an empty string.- On Linux,
bqvmcurrently emits CRLF line endings for printed lines. - Module resolution is working-directory-relative unless
QPLUS_ROOTis set. - The standard library module set and its function index are versioned with the language;
qpmpackages are a separate ecosystem and are not automatically importable asstd.*modules.
Reliable patterns
- Use
ifdirectly on abool. Writeif str_equals(a, b) { ... }, notif str_equals(a, b) == 1. - Import what you call. Add
use std.<module>for every standard-library module used in the file, and run from the repository root (or setQPLUS_ROOT). - Put
externafteruse. - Qualify enum patterns. Use
Color.Redinmatcharms; include_when not all variants are covered. - Use
?:for expression-level conditions and plainif/matchstatements elsewhere. - Construct enums with payload values. Pass
Result.Ok(5), not a bare integer. - Copy shared data explicitly. Structs and arrays are references; call
array_copywhen an independent array is needed. - Parenthesize macro arguments when they contain operators.
- Choose checked or wrapping arithmetic deliberately. Default small-integer arithmetic is checked; use
std.algwrapping helpers only where wrap-around is intended. - Keep AOT-compatible programs free of VM service modules. Use the
printbuiltin and avoidstd.io/std.thread/FFI when targeting ELF AOT; otherwise expect a loud rc=5 or a runtime trap. - Treat threads as isolated VMs. Share data only through channels; string messages are copied at the channel boundary.
- Guard division and indexing, because runtime errors cannot be caught.
Next steps
- QUICKSTART.en.md — build the toolchain and run a first program, including AOT.
- BUILDING.en.md — build internals, anchors, and regression suite details.
- docs/qplus_language_spec.md — the normative language specification (v4.36).
src/examples/— 193 example programs to study and modify.stdlib/std/— the standard library source; every module is readable Q+.bench/— benchmark suite and raw results.- FAQ.en.md — licensing, project relationship, and common questions.