Skip to content
ricardohbinPublic

About

my language, created with AI... zzz

Resources

Stars

7 stars

Watchers

0 watching

Forks

Repository files navigation

binZ

DISCLAIMER: only HUMANS.md and SOUL.md contains stuff created by human. You can see some real thoughts there. Ah, all the PR and commits are made by me too (the human, in the case :P)

A small, strongly typed, compiled language. C/C++/JavaScript-shaped syntax, Rust backend, bytecode artifact plus a stack VM that executes it.

Source files are .binz; compiled artifacts are .binzc.

This is the v0.1 scratch: primitives, functions as first-class values, C-like structs and pointers, fixed arrays, four heap containers, two maps, and a standard library reached through import binz/<module>.

Guiding rule: one way to do one thing

Every design decision below falls out of that rule.

Decision Why
Types are always written out — no inference one spelling for a declaration
No implicit conversions, ever (not even i32 → i64) cast<T>(x) is the only converter
cast<T>(x) is the only cast syntax no C casts, no constructor casts, no as
function declares a function — fn is rejected one keyword, spelled out
: introduces every type, including return types no -> in declarations
No -> operator; write (*p).field one way to reach through a pointer
Only while for loops for/do/foreach are the same loop
No +=, ++, -- x = x + 1
Conditions are parenthesised and must be bool no truthiness, no if x = 0 trap
Struct literals list every field, in declaration order — or none, as Point{}, for all defaults one shape per struct, and no literal that leaves the reader guessing which fields were skipped
const / var — no third form immutable by default in spirit
No null a *T always points at something
c[i] indexes every container one spelling for "the element at i", by position for a sequence and by key for a map
m[key] = value is the only way into a map it inserts when the key is new and overwrites when it is not
Modules are lowercase, members are camelCase, types are PascalCase one convention per kind of name, and a test in src/stdlib.rs holds the line
An as rename is camelCase it is two module names joined, and camelCase is how binZ joins words
A module name is always module.member no bare import, no wildcard — io.print reads the same in every file
The binding is the last path segment, always binz/io is io, @root/utils/math.binz is math
as only when two imports in a file are named the same — and then on every one of them a rename is never a second spelling; it exists only where there is no first one
A rename is the directory plus the file name — text/format.binz is textFormat not a choice: two people renaming the same clash write the same line
A file of the project is imported from @root one path for one file, wherever it is written — no ../
container.size(c) / string.size(s) / map.size(m) one size per type family, rather than one name resolving three ways
Sequences use find / erase, sets use contains / remove positions and keys are different questions, so they get different verbs
[T; N] copies, Vector<T> aliases one rule: frame storage is a value, heap storage is a handle
A test is tagged @test and named for what it asserts the tag says it is a test, so the name may not say it again
test.equal is the one assertion equal states what was expected; test.fail covers what equality cannot say
A stub replaces a module's function, written at the top of the test mocking without interfaces or an argument threaded through three layers
A call that can fail answers an Error, and code == 0 means it did not one error convention, and no truthiness: if (err.code != 0)
A tuple is Tuple<A, B>(a, b), and an Error in it is always element 0 no (a, b) shorthand, and the error is in the same place in every signature
Only an @json struct crosses the wire, and a field is its own key unless @field says otherwise @field("year") on year is refused — it would be a second spelling of the default

Install & use

cargo build --release

binz run   examples/tour.binz         # compile and execute
binz run   examples/containers.binz   # the container tour
binz run   examples/maps.binz         # the map tour
binz run   examples/stdlib.binz       # the standard library tour
binz run   examples/modules.binz      # a program split over three files
binz test  examples/orders.binz       # run the `@test` functions it can reach
binz build examples/tour.binz         # emit examples/tour.binzc
binz exec  examples/tour.binzc        # execute an artifact
binz dump  examples/tour.binzc        # disassemble
cargo test                            # end-to-end language tests

main must be function main(): i32, and its return value is the process exit code.

The language

Types

Primitives: i32, i64, f64, bool, str, and void (return type only).

Composites: *T (pointer), function(A, B): R (function), structs, [T; N] (fixed array), the heap containers Vector<T>, LinkedList<T>, Set<T> and SortedSet<T>, the maps HashMap<K, V> and SortedMap<K, V>, and tuples, Tuple<A, B, ...>.

Variables

const limit: i64 = 10;   // immutable
var   count: i64 = 0;    // mutable

The type annotation is mandatory. An integer literal takes the type it is assigned to (i32 when there is nothing to go on); a float literal is always f64, so const x: f64 = 3; is an error — write 3.0.

Blocks and scope

A bare { ... } is a statement, and it is a scope: every variable declared inside it is gone at the closing brace. while is the only loop, so this is how a loop counter is kept from outliving the loop.

{
    var i: i32 = 0;
    while (i <= 10) {
        io.print(cast<str>(i));
        i = i + 1;
    }
}
// `i` is not defined here

The frame slots a block used are handed back at the brace, so a block is a compile-time rule and costs nothing at runtime.

There is no shadowing. A name that is still alive in an enclosing scope cannot be declared again by an inner block, and that includes a parameter:

var i: i32 = 0;
{
    var i: i32 = 99;   // error: `i` is already declared in an enclosing
}                      //        scope, and binZ has no shadowing

The one-way rule again: with shadowing, i inside the braces names one of two variables and the reader has to count braces to know which. A name is free again once the block that owns it has closed, so two sibling blocks may both call their counter i.

Functions

Declared at the top level, in any order (forward and mutual recursion work).

function add(a: i64, b: i64): i64 {
    return a + b;
}

Functions are values, so they have a type and can be stored and passed:

function apply(op: function(i64, i64): i64, a: i64, b: i64): i64 {
    return op(a, b);
}

const f: function(i64, i64): i64 = add;
io.print(cast<str>(apply(f, 2, 3)));

The type of a function is written the same way, so function(i64, i64): i64 is a type as well as the start of a declaration. fn and -> are reserved purely so that writing them produces a diagnostic pointing at the one spelling:

error: binZ declares functions with `function`, not `fn`
error: binZ writes the return type after `:`, not `->`

A standard-library function is a value under exactly the same rule — see The standard library.

Parameters behave like var: they are mutable copies inside the function.

Structs and pointers

Structs are value types, laid out flat like in C. Nested structs are inlined; a struct that contains itself by value is rejected (use a pointer).

struct Point { x: i64, y: i64 }

var a: Point = Point { x: 1, y: 2 };
var b: Point = a;        // full copy, not an alias

A literal names every field or none. Point{} sets every field to its default: 0, 0.0, false, "", an empty container or map, and the same again for a nested struct and for each element of an array (every element gets a container of its own). A pointer or a function has no default, since there is no null, so a struct holding one has to be written out in full. Error{} is therefore "no error".

They are passed and returned by value too — a struct return is compiled with a hidden destination pointer, so no copy is left dangling.

&place takes an address, *p dereferences. You can only take the address of a var place, and there is no ->:

function shift(p: *Point, dx: i64): void {
    (*p).x = (*p).x + dx;
    return;
}

shift(&a, 10);

var pn: *i64 = &a.y;     // pointers to fields work
*pn = 99;

Tuples

A tuple is two or more values side by side: a value type, laid out flat and copied like a struct whose fields have no names. It is how a function answers more than one thing. The type is written out at the literal too — there is no (a, b) shorthand and no Tuple{}:

function divmod(a: i32, b: i32): Tuple<Error, i32, i32> {
    if (b == 0) {
        return Tuple<Error, i32, i32>(Error{ reason: "division by zero", stacktrace: "", code: 1 }, 0, 0);
    }
    return Tuple<Error, i32, i32>(Error{}, a / b, a % b);
}

const r: Tuple<Error, i32, i32> = divmod(7, 2);
if (r[0].code != 0) {
    io.print(r[0].reason);
}
io.print(cast<str>(r[1]));   // 3

t[0] reaches an element, with the same [] as every container — but the index is always a literal, because each element has its own type. On a var tuple, t[1] = x assigns one. A tuple holds at least two values (Tuple<i32> is just i32), never void, and nests: n[1][0].

An Error in a tuple is always element 0, and a tuple carries at most one. The compiler checks it wherever a tuple type is written — a return type, a parameter, a variable, a field — so Tuple<i32, Error> is refused with the order that works, Tuple<Error, i32>. A caller therefore always finds the error in r[0].

Like a struct, a tuple cannot go into a heap container, has no ==, and is not something cast<str> formats. A struct holding a tuple field defaults element by element under T{}.

Containers

Five of them, split by one question: does the storage live in the frame or on the heap? (The two maps are keyed rather than positional, and get their own section below.)

Type Storage Assignment Element type
[T; N] frame, flat, N known at compile time copies anything, including structs and arrays
Vector<T> heap, contiguous aliases any one-slot value
LinkedList<T> heap, doubly linked nodes aliases any one-slot value
Set<T> heap, hashed, insertion order aliases i32 i64 f64 bool str
SortedSet<T> heap, sorted, ascending order aliases i32 i64 f64 bool str

A fixed array is a value like a struct: assigning, passing or returning one copies every element, and &a[i] is a real pointer into it. A heap container is a handle: copying it shares the storage, the way a *T would, so a function can fill a Vector<T> it was handed. const freezes the handle, not its contents. container.copy(c) is the one way to get independent storage, and it is deep — nested containers are copied too.

var scores: [i32; 5] = [7, 3, 9, 1, 5];   // every element, like a struct literal
var grid:   [[i32; 3]; 2] = [[0; 3]; 2];  // `[x; N]` repeats one value
scores[0] = 10;

var v: Vector<str> = Vector<str>{};       // Name<T>{ ... }, like a struct literal
container.push(v, "one");
v[0] = "ONE";

var seen: Set<i32> = Set<i32>{3, 1, 3};   // two elements
var ranked: SortedSet<i32> = SortedSet<i32>{40, 10};

A heap container may sit in a struct field or an array element, where it is still just a handle: copying the struct shares the container. For the same reason [Vector<i32>{}; 2] repeats one handle into both slots — write the elements out to get two containers.

c[i] reads an element of any of the five; it is assignable on the three sequences and rejected on a set, whose elements are its keys. An index is always an i32. Iteration is a while over container.size, and it is deterministic everywhere — a Set keeps insertion order, a SortedSet stays ascending:

var i: i32 = 0;
while (i < container.size(seen)) {
    io.print(cast<str>(seen[i]));
    i = i + 1;
}

The verbs live in binz/container, and each one belongs to exactly one shape:

Call Works on Result
container.size(c) every container i32
container.find(c, x) [T; N], Vector, LinkedList index, or -1
container.push(c, x) Vector, LinkedList void
container.pop(c) Vector, LinkedList the last element
container.insert(c, i, x) Vector, LinkedList void
container.erase(c, i) Vector, LinkedList the removed element
container.add(s, x) Set, SortedSet true if it was new
container.remove(s, x) Set, SortedSet true if it was there
container.contains(s, x) Set, SortedSet bool
container.clear(c) the four heap containers void
container.copy(c) the four heap containers a deep copy

erase takes a position and remove takes a key, so there is never a choice about which one a container wants. Applying the wrong one says so:

error: `container.push` is not defined for `Set<i32>`; use `container.add`
error: `container.contains` is not defined for `Vector<i32>`; use `container.find`
error: `container.push` is not defined for `[i32; 1]`; a fixed array never changes size, use `Vector<i32>`
error: `container.add` is not defined for `HashMap<str, i32>`; write `m[key] = value`
error: `container.size` is not defined for `HashMap<str, i32>`; a map answers `map.size`
error: `container.size` needs a container, found `str`; a `str` answers `string.size`

A str is not a container, and neither is a map: a str is sized and searched through binz/string, a map through binz/map. So size, find and contains each mean one thing per type family instead of one name covering three.

Out-of-range indexes, pop on an empty container and NaN used as a key all trap at runtime.

Maps

Two of them. Both are keyed by value, both are handles, and both answer the same six verbs in binz/map — they differ in one thing, the order map.keys hands back:

Type Storage map.keys order Cost
HashMap<K, V> heap, hash index over an insertion-ordered vector insertion read and insert O(1); map.remove is O(n)
SortedMap<K, V> heap, red-black tree ascending by key O(log n), including map.remove

A HashMap pays for its insertion order: the hash index stores positions into a vector, so erasing a key has to close the gap and reindex everything after it. If a workload removes keys in bulk, SortedMap is the faster of the two today, despite the O(log n).

A key is i32, i64, f64, bool or str; a value is any one-slot value. These are the only two types with two type arguments, and the only two subscripted by something other than an i32.

var ages: HashMap<str, i32> = HashMap<str, i32>{"ana": 31};  // key: value
ages["bruno"] = 27;                       // inserts
ages["ana"] = 32;                         // overwrites

var stock: SortedMap<str, i32> = SortedMap<str, i32>{"pear": 3, "apple": 12};
stock["fig"] = 7;                         // same spelling, ordered storage

m[key] = value is the only way in, so there is no add / insert / put to choose between. Reading a key that is not there traps — binZ has no null, so map.contains(m, k) is the guard. Walking a map is map.keys(m), which hands back a Vector<K>; there is deliberately no values, because m[k] already answers that:

const who: Vector<str> = map.keys(ages);
var i: i32 = 0;
while (i < container.size(who)) {       // the Vector is a container again
    io.print(who[i] + " = " + cast<str>(ages[who[i]]));
    i = i + 1;
}
Call Result
map.size(m) i32
map.contains(m, k) bool
map.remove(m, k) true if the key was there
map.keys(m) a Vector<K>
map.clear(m) void
map.copy(m) a deep copy

A map answers remove and never erase, the same split the sets use: erase takes a position and a map has none. Every other verb names the line that works:

error: `binz/map` has no `add`: write `m[key] = value`
error: `binz/map` has no `find`: a map is keyed by value; use `map.contains`
error: `binz/map` has no `values`: read `m[key]` while walking `map.keys(m)`
error: `map.size` needs a map, found `Vector<i32>`; a container answers `container.size`

Reading an absent key traps, and so does NaN used as a key — a SortedMap has to compare its keys, so it rejects NaN for exactly the reason a SortedSet does.

Operators

+ - * / on numbers, % on integers, + also concatenates str. == != < <= > >= compare two values of the same type and yield bool. && || ! are boolean-only; && and || short-circuit. Both sides of a binary operator must already have the same type — there is no promotion.

Casting

cast<i64>(x)     // between i32 / i64 / f64 (f64 -> int truncates)
cast<str>(x)     // any primitive to str; this is how you format for io.print

Anything else — str to a number, pointer casts, struct casts — is rejected.

Control flow

if (cond) { ... } else if (other) { ... } else { ... }
while (cond) { ... }
return expr;   // `return;` in a void function

A function that does not return void must return on every path.

The standard library

Nothing is in scope by default. A module is made visible with import, and every one of its members is then reached through the last segment of the path — always, with no way to rename it:

import binz/io;
import binz/string;
import binz/container;

function main(): i32 {
    io.print(string.upper("binz"));
    return 0;
}

A module name is one lowercase word, a member is camelCase (startsWith, canParse), and a type is PascalCase (Vector<T>, HashMap<K, V>) — one convention per kind of name, so nothing has to be looked up.

That is the whole mechanism. There is no bare import, no wildcard, no second spelling — io.print reads identically in every file that uses it, and a reader never has to look up where a name came from. Every import goes at the top of the file, one module per line, before the first struct or function.

An import owns its binding for the whole file: once binz/io is in scope, nothing else in that file may be called io. Conversely, a module member never takes a name away from the program — add and find are ordinary words, and container.add does not stop you writing your own function add(...).

Module Covers
binz/io print
binz/string size at slice find contains startsWith endsWith upper lower trim repeat replace split join
binz/int parse canParse abs min max
binz/float parse canParse abs min max floor ceil round sqrt pow isNan
binz/container the eleven container verbs, above
binz/map the six map verbs, above
binz/random f64 i32 — below
binz/test equal calls fail, reachable only from a test — below
binz/json parse stringify, over any @json struct — below

Which members are values

A standard-library function is a first-class value exactly when its type can be written down in binZ. Nothing else distinguishes the two groups:

const shout: function(str): str = string.upper;   // fine
const say:   function(str): void = io.print;      // fine
const n:     function(str): i32  = container.size;
// error: `container.size` is generic over the type it is given, which binZ
// cannot write in a signature, so it is not a value; call it directly

container.size answers for five different types, map.keys for two and int.max for two, and binZ has no user-written generics yet, so those have no type to hold. They are resolved at the call site instead.

binz/string

str is a sequence of characters, and every position the module hands out is a character index, so size, at, slice and find agree on text that is not ASCII:

const s: str = "maçã";
string.size(s);            // 4
string.at(s, 2);           // "ç"  -- there is no char type, so a 1-char str
string.slice(s, 0, 2);     // "ma" -- half-open, like every range in binZ
string.find(s, "çã");      // 2, or -1 -- same convention as container.find

string.split hands back a Vector<str> and string.join takes one, so the container module takes over the moment there is more than one string:

const parts: Vector<str> = string.split("a,b,c", ",");
string.join(parts, " | ");                 // "a | b | c"
container.size(parts);                     // 3

Formatting a value as text is still cast<str>(x), not a string function — cast is the one converter and this module does not duplicate it.

binz/int and binz/float

There is no null and no optional, so parse traps on input it cannot read and canParse is the guard — the same shape as container.contains in front of a HashMap read:

if (int.canParse(text)) {
    const n: i32 = cast<i32>(int.parse(text));
}

int.parse answers in the widest integer, i64, and cast<i32> narrows it, because cast is already the one converter. abs, min and max are generic over i32 and i64 and answer in the width they were handed, so neither width is pushed through a cast to use them. Handing one an f64 says where to go instead:

error: `int.abs` needs an `i32` or an `i64`, found `f64`; an `f64` answers `float.abs`

binz/random

A member is named for the type it answers, because that is the whole of what it is:

import binz/random;

const r: f64 = random.f64();       // 0.0 <= r < 1.0, never 1.0
const d: i32 = random.i32(1, 6);   // a die -- both ends included

random.i32 traps on a backwards range, since an empty range has no value to answer with and binZ has no null. Both members are monomorphic, so both are ordinary function values.

The generator is seeded from the operating system once per process and there is no way to set the seed. Code that has to be predictable does not replay a random number — it stubs the module function that reads one. The standard library is not stubbable, so the seam is always your own function:

// rates.binz -- the dependency a test cannot predict
function lookup(country: str): f64 {
    return random.f64();
}
@test function appliesTheRate(): void {
    stub rates.lookup(country: str): f64 { return 0.5; }

    test.equal(total(100.0, "br"), 50.0);
}

Without that stub, all a test can say about total(100.0, "br") is that it lands between 0.0 and 100.0 — which is what examples/orders.binz shows side by side.

binz/json

A struct is read from and written to JSON only when it is tagged @json. A field is written under its own name; @field gives it another one, and that is all it does:

import binz/io;
import binz/json;

@json
struct Point {
    @field("some_field")
    someField: str,
    year: i64
}

function main(): i32 {
    var p: Point = Point{};          // every field at its default
    var err: Error = json.parse("{\"some_field\": \"x\", \"year\": 2026}", &p);
    if (err.code != 0) {
        io.print(err.reason);
        return 1;
    }

    var text: str = "";
    err = json.stringify(p, &text);   // {"some_field":"x","year":2026}
    return 0;
}

Both calls answer an Error, the one struct binZ declares itself — every file can name it, and it crosses module boundaries, so your own functions can answer one too:

struct Error {
    reason: str,       // "`$.year`: expected an integer, found a string"
    stacktrace: str,   // "at load\nat main" -- innermost first
    code: i32,         // 0 when nothing went wrong
}

On success code is 0 and the two strings are "". There is no null and no truthiness, so the check is always err.code != 0; if (err) is refused with that line. A failed call leaves its destination untouched: the value is read in full before a single slot is written.

code Meaning
0 success
1 the text is not JSON (the reason gives line and column)
2 a value is the wrong type for its field — including 1.5 for an integer, a number too large for its field, and null
3 a field of the struct has no key in the object
4 an object names the same key twice
5 json.stringify met NaN or an infinity, which JSON cannot spell

Rules, each one enforced at compile time where it can be:

  • A key in the JSON that the struct does not declare is skipped. A field of the struct with no key in the JSON is an error (3) — without null there is nothing to leave in it.
  • A field of an @json struct is bool, i32, i64, f64, str, another @json struct, [T; N] (a JSON array of exactly N) or Vector<T>. A pointer, a function, a set, a list or a map is refused where the struct is declared.
  • @field belongs only inside an @json struct, may not repeat the field's own name, and two fields may not end up on one key.
  • The top of the document is an object, because the value is a struct.
  • json.stringify writes compact JSON with keys in declaration order. There is one spelling of a value, so there is no pretty-printer.
  • Numbers keep every digit: an i64 field reads 9007199254740993 exactly.

When a name is missing

Every diagnostic carries the line the program should have written:

error: `print` is in `binz/io`; write `io.print` after `import binz/io;`
error: `io` is not imported; add `import binz/io;` at the top of the file
error: `binz/string` has no `push`; it is in `binz/container`
error: `binz/map` has no `values`: read `m[key]` while walking `map.keys(m)`
error: there is no module `binz/json`; binZ has io, string, int, float, container, map
error: cannot read `@root/nope.binz`: No such file or directory
error: `@root/math.binz` has no function `cube`
error: `math` is the imported module `@root/math.binz`, so it cannot also be a variable

A verb a module refuses on purpose gets the line to write instead, rather than a pointer at another module that would refuse it too. A program is a graph of files, so every diagnostic names the file its span belongs to — which is not always the one on the command line.

len was the spelling before the standard library had modules, so it gets its own:

error: `len` is now `size`, in `binz/string`, `binz/container` or `binz/map`;
write `string.size`, `container.size` or `map.size` after
`import binz/string; import binz/container; import binz/map;`

Local modules

A program can be more than one file. A file of this project is imported from @root, the directory of the file handed to binz:

// examples/modules.binz
import binz/io;
import @root/modules/geometry.binz;

function main(): i32 {
    io.print(cast<str>(geometry.square(7)));
    return 0;
}

@root is the anchor because a path should mean the same thing wherever it is written. There is no ../, no path relative to the importing file, and no way to reach outside the root — one file, one spelling.

Everything else is the rule already in force for binz/io. The binding is the file's own name, always, with no way to rename it: geometry.binz binds geometry. That is why a module file is named with one lowercase word — the name is not decoration, it is the identifier the importing file will type, so some-module.binz is refused where it is written:

error: `-` cannot appear in a module file name: the name is the binding, the
`math` you would write in `math.square`, so it is one lowercase word

A module is an ordinary .binz file, with three consequences:

  • It exports every function it defines, and nothing else. There is no export keyword and no pub — a module's surface is what you can read at the top of it. Its structs stay inside it, including in the signature of an exported function, which the importing file would have no way to write down.
  • It imports what it uses. The file that imports it is not a scope, so a module that calls io.print writes import binz/io; itself. A module may import other local modules, including ones it shares with the entry file: a file reached twice through the graph is still compiled once.
  • It does not define main. main is the program; a module is a library.

Names are per file. Two files may both define add, and neither shadows the other — add is this file's, alpha.add is the other's. A module function is an ordinary value, by the same rule that makes io.print one: its type is writable in binZ.

const f: function(i32): i32 = geometry.square;

An import cycle is an error, and names the whole chain:

error: import cycle: @root/a.binz -> @root/b.binz -> @root/a.binz

as, only where it is forced

Two directories may hold two files of the same name. That is the one situation as exists for — and then every one of them is renamed, so a name is never the default for one import and a rename for another:

import @root/modules/text/format.binz   as textFormat;
import @root/modules/number/format.binz as numberFormat;

The rename is not a choice either. It is the module's directory and its own name, joined the way binZ joins words everywhere else — text/format.binz is textFormat and nothing else. Two people renaming the same clash write the same line, and the diagnostic names the spelling rather than asking you to invent one:

error: `@root/modules/text/format.binz` and `@root/modules/number/format.binz`
are named `format`; when two or more imports in a file are named the same,
every one of them is renamed -- write
`import @root/modules/text/format.binz as textFormat;`

error: the rename of `@root/modules/text/format.binz` is `textFormat`, not
`textformat1`: a rename is the directory and the file name joined, so it is
not a choice either

Two contested imports always differ in the directory — two files of the same name in one directory are one file — so the rename is unique without looking at what else the file imports. A file directly under the anchor borrows the anchor's name: @root/format.binz is rootFormat.

Outside that situation as is refused, because a module would then have two spellings — the bare name and the rename — which is the thing the whole import design exists to avoid:

error: `as` renames a module only when two or more imports in a file are named
the same; `@root/utils/math.binz` is the only `math` in this file, so it is
imported as `math`

No two standard library modules are named the same, so as is never legal on one: io.print reads identically in every file of every program. A rename always carries a capital at the join, so it can never collide with a module name — those are one lowercase word.

A clash is per file. The same module is math in a file that imports only it, and renamed in a file that imports both.

Tests

A test lives in the file it tests, tagged @test, and binz test is the only thing that runs one:

import binz/test;

function double(n: i32): i32 {
    return n + n;
}

@test function doublesPositives(): void {
    test.equal(double(21), 42);
}
binz test examples/orders.binz

binz test compiles the file it is given and everything that file imports, and runs every @test in the graph — so pointing it at the entry file runs the project. main is neither required nor run, which is also what lets a module be tested on its own: binz test src/math.binz works, and a module still may not define main.

binz build and binz run do not compile a test at all. A test is not in the artifact, weighs nothing, and cannot be called — by its own file or by one importing it. The cost is the other half of that: a test that no longer compiles stops binz test, and nothing else.

A test takes no arguments and answers void. It reports by failing, and there is no caller to read a result. A test name may not start with test — the tag above it already says that:

error: a test name cannot start with `test`: the `@test` tag above it already
says that, so write `sumsPositives`

Asserting

Written Means
test.equal(actual, expected) fails unless the two are ==, printing both
test.fail(message) fails outright — for what equality cannot state
test.calls(math.add) how many times that function was called in this test

test.equal compares whatever == compares, and both sides are one type: binZ converts nothing on its own here either. A failure prints both sides with cast<str>, the one formatter the language has:

  FAIL  doublesNegatives
        expected `0`, found `-2`

There is deliberately no notEqual, no assert(condition) and no check — test.equal(x > 10, true) states an expectation in the one spelling there is. binz/test is reachable only from a test body or a stub body; a program never runs one of these, so outside a test there is nothing for them to answer to.

Stubbing a module

stub replaces a function of an imported module for the length of one test:

import binz/test;
import @root/rates.binz;

function total(amount: f64, country: str): f64 {
    return amount * rates.lookup(country);
}

@test function appliesTheRate(): void {
    stub rates.lookup(country: str): f64 { return 0.5; }

    test.equal(total(100.0, "br"), 50.0);
    test.equal(test.calls(rates.lookup), 1);
}

It replaces the function, not the call. Every path that reaches rates.lookup lands in the body written here, however deep in the import graph the call was written — which is the point: no interface, no dependency passed in, nothing threaded through three layers to reach the one place that has to lie. It is undone when the test ends, because each test runs in a machine of its own.

The rules, and what each one is for:

Rule Why
Every stub goes at the top of the test, before its first statement what a test replaced is read once, not hunted for
The signature is written out and must match the real one a stub that has drifted from what it fakes is the one bug a test library must not hide
One function, one stub per test no guessing which body wins
Only a module of this project — never binz/io io.print means the same thing in every program
Only inside an @test function outside a test there is no length to replace it for
A stub cannot call the function it replaces the call would land back in the stub — there is no calling through

Since binZ has no globals and no closures, a stub body cannot record anything — which is why counting is the library's job. test.calls answers how many times a function of the program was called during this test, stubbed or not, and it is asserted with the same test.equal as everything else. A standard library call has no function id to count, so test.calls(io.print) is refused rather than answering zero forever.

Implementation

src/lexer.rs      source -> tokens
src/loader.rs     entry file -> the graph of files it imports
src/parser.rs     tokens -> AST
src/compiler.rs   AST -> type check + bytecode, in a single pass
src/stdlib.rs     the module table: what binz/io, binz/string, ... contain
src/bytecode.rs   instruction set, .binzc serialization, disassembler
src/vm.rs         stack VM
src/obj.rs        heap storage: Vector / LinkedList / Set / SortedSet / HashMap / SortedMap
src/json.rs       binz/json: an RFC 8259 reader and a writer, driven by the artifact's schemas

The compiler is one pass: because binZ has no inference beyond literal typing, a type hint threaded downwards is enough to check and emit at the same time.

The standard library is compiled in, not written in binZ, and src/stdlib.rs is its whole table. binz/random is the one member with state: an xorshift64* word in the VM, seeded from the platform through RandomState, so the language still has no dependencies. A monomorphic member is a native: its index is a bytecode operand, it is pushed as an ordinary function value, and the VM executes it in call_native. A generic member is a form: the compiler resolves it against the type of its first argument and emits OP_BUILTIN. That split is why io.print is a value and container.size is not — it is the one difference between the two tables.

The VM has two stacks. mem holds call frames and is addressable at slot granularity — that is what a *T actually points at, which is why &local costs nothing. stack holds operands. Struct values are represented by the address of their storage; assignment, argument passing and returns emit an explicit copy of N slots, which is what gives structs value semantics. A fixed array is the same kind of value, so [T; N] costs N * sizeof(T) slots in the frame and indexing it is one bounds-checked address computation.

The five heap containers are the only things outside that model: each is one slot holding a reference-counted handle, and storage is freed when the last handle goes. LinkedList<T> really is a doubly linked list, held in a node arena, and l[i] walks from whichever end is closer — indexing a list is O(n), and that is the honest cost. Set<T> is a hash index over an insertion-ordered vector; SortedSet<T> is a sorted vector searched by bisection; HashMap<K, V> is the same shape as Set<T>, a hash index over an insertion-ordered vector of pairs; and SortedMap<K, V> is a genuine red-black tree over a node arena, with -1 for nil rather than a sentinel node, so deletion carries the parent explicitly where CLRS reads nil.parent. Both maps therefore make map.keys(m) deterministic across runs. get and set share the c[i] opcodes: the VM reads the value on the stack as a position for a sequence and as a key for a map.

The red-black invariants are checked directly rather than trusted: the unit tests in src/obj.rs walk the tree after every step of 3000 randomised inserts and deletes, asserting the root is black, no red node has a red child, every path carries the same black height, parent links agree, and the in-order walk is ascending — against a plain Vec kept as the oracle.

binz test is the same compiler in a second mode. @test functions and the stub bodies inside them are the only difference: in run mode they are never declared, so they cannot be reached and cannot be emitted. A stub costs one instruction, OP_STUB, which pops a function value and records a redirect — every call already goes through a function id, so redirecting one is what makes a stub reach calls written anywhere in the graph. The redirect table is allocated only when a stub installs one, and the call counts only in a test run, so an ordinary program pays nothing for either existing.

binz/json needs what the bytecode does not carry: the shape of a struct. So every @json struct a json.* call reaches gets a schema in the artifact — its keys, slot offsets and field types — and the call pushes the schema's index as its last argument before OP_BUILTIN. The Error is written into a temporary the call reserves, the same way a function returning a struct writes into its caller's. Parsing builds a tree first and only then fills the destination, which is why a failure leaves it untouched; nesting past 512 levels is refused rather than recursed into.

Runtime traps: division/remainder by zero, integer overflow, an index out of range, a map key that is not present, pop on an empty container, NaN used as a key, and dereferencing a pointer into a frame that has already been popped.

Artifact format

Little-endian: "BINZ" magic, version, string pool, function table (name, parameter slot sizes, frame size, sret flag, code), entry index, test table, JSON schema table. The entry index is optional and the test table is empty in everything binz build writes: both exist because a binz test compile has no main and carries the tests it found.

Not in v0.1

Slices, user-written generics, closures, exported types (a module exports its functions only), methods, enums/unions, bitwise operators, and unsigned integers. binz/json reads into structs only: no JSON object into a HashMap<str, V>, no optional fields. The standard library is a scratch: no file or process I/O beyond io.print, no time. binz/random cannot be seeded, so a failing random case cannot be replayed. A test run has no setup, no teardown and no filter — binz test <file> runs everything it can reach. cast<str> formats scalars only, so a container is printed by iterating it. Heap containers cannot hold structs, and their elements have no address. Dangling pointers are detectable but not prevented — pointer lifetimes are C-like, not borrow-checked.

About

my language, created with AI... zzz

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages