Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

mimas

mimas is a statically typed, embeddable scripting language for Rust. It carries over much of Rust's syntax and ergonomics, reshaping the rest to deliver what a scripting layer is good for: fast iteration, logic you can change without a rebuild, and a runtime that ships anywhere your program runs.

github version built with Rust license ci

Typed

Static typing with inference, user-defined types, exhaustive pattern matching, and more of the same features that empower you in Rust.

let area: float? = match my_shape {
    Shape::Circle(r) => r * r * std::math::PI,
    Shape::Rectangle(w, h) => w * h,
    _ => null,
};

Flexible

Rigorous, not rigid. Strong inference lets you focus on your goals, not your types. Writing is intuitive, and sub-second compile times keep you in motion.

let nums = [5, 3, 8, 1];
let big = for n in nums { if n > 4 collect n; };
print(f"found {big.len()}: {big}"); // -> found 2: [5, 8]

Fast

Stay fast. Static analysis fuels the VM, making it the fastest pure-Rust scripting language – competitive even with C++ runtimes like Luau. See the benchmarks.

physics scalar float throughput, tight loops

mimas1.05s
luau1.16s
fabricator2.97s
rune3.48s
koto7.89s
dyon8.68s
boa10.01s
steel12.30s
rhai14.46s
rustpython16.71s

mimas v0.1.0 · Luau (mlua) v0.11.4 · Rhai (perf) v1.25.1 · Rune v0.14.2 · Koto v0.16.1 · Dyon v0.51.0 · Steel v0.8.2 · Boa v0.21.1 · RustPython v0.5.0 · Fabricator git 60c32e1

Extendable

Share your Rust types and functions with the mimas macro, all while maintaining type safety. The macro alone is all you need for mimas to find it.

// rust
#[mimas]
struct User(String);
impl User {
    fn greet(self) {
        println!("Hello, {}!", self.0);
    }
}
// mimas
let user = User("mimas");
user.greet(); // Hello, mimas!

Robust

  • Guaranteed “Results” – mimas treats any panic as a bug, both in the compiler and the VM.
  • Tested top to bottom – over 1,500 tests cover every corner of the codebase. Even the tests are tested, thanks to cargo mutants.
  • Helpful diagnostics – bugs are caught at their source with clear reports powered by miette:
  error:  non-exhaustive match
   ╭─[tools/src/main.mim:3:1]
 23 match Color::random() {
 4       Color::Red => print("Red!"),
 5       Color::Blue => print("Blue!"),
 6 }
   · ─── missing pattern `Color::Green`
 7 │     
   ╰────
  ╰─▶   advice:  `Color::Green` defined here
         ╭─[tools/src/color.mim:6:5]
       5 │     Blue,
       6 │     Green,
         ·     ──┬──
         ·       ╰── this variant has no matching arm
       7 │ }
         ╰────

Why mimas?

mimas is a young side project. It is not ready to replace your daily drivers or be used in a professional capacity, but it is ready for experimentation and collaborative development. To that end, I’d like to share why I made it and what it seeks to achieve.

The gap to fill

Plenty of projects end up wanting two languages. You build the serious part – the engine, the core, the thing that has to be fast and correct – in something strict and heavy. Then, when you want to move, to describe behavior, try an idea, change something and see it a second later, you reach for a scripting layer on top.

Typically, this is where you have to make a trade. Scripting layers tend to commit hard to flexibility at the expense of safety. They’re nimble and easy, but your trivial mistakes materialize as crashes for end-users, not errors for your compiles.

This has been a constant headache throughout my career in game development, and one with real consequences; games have enormous performance demands while also needing to be remarkably stable. There isn’t much room to recover from an error mid-gameplay. You can wrap the unstable parts and pray, but catching an error you didn’t model just kicks the can to a later, much more difficult to diagnose error. In the end, it feels as though I’m spending more time untangling cryptic crashes than I am actually making games.

mimas is my detox from those frustrations: a fun, snappy programming experience without gnashing my teeth at a crash from a typo the compiler absolutely should have been capable of warning me about.

Getting there

The path towards such a language necessitates a series of compromises. We’re not going to produce some magical language that lets us program with our eyes closed while still fulfilling a borrow checker – instead, each part of the language needs to weigh the ease against safety. Will this frustrate me when I’m just trying to write something quick? Will this come back to haunt me at runtime? Each choice swings a little bit in one direction or the other; our ethos is to try to stay upright on that tightrope.

Ultimately, that has turned out to look a lot more like Rust than Python, just with certain pieces removed. Some are to increase flexibility (garbage collection instead of a borrow checker, no immutability beyond const declarations). Others are to reduce complexity, both for the compiler and the programmer (no references or pointers, no lifetimes to annotate). We start from safety and walk only as far away as we must to reach the flexibility we require.

A small, clear target

None of this is a new wish. Wanting expressiveness, safety, and a fast loop all at once is a familiar itch, and people wiser than me have poured real work into pieces of it, even from inside Rust. The point isn’t whether this project reaches that ideal, but that the chase is a worthwhile and rewarding place to spend time.

Thank you for stopping by to check mimas out! I’d gratefully welcome your thoughts and feedback over on our GitHub.

Gabe

No, but like, why ‘mimas’?

mimas is named after one of Saturn’s smallest moons – which is, in turn, named after a giant from Greek mythology, a metaphor I think suits the project.

Alternatively, the reason is that it was the coolest available name I could find on crates.io. Whichever you prefer.

Tour

This page covers the entire language with brief examples for those who want a quick tour. Links to the full reference are left at each section.

Variables

See Variables & Constants.

let x = 0;           // inferred as int
let y: float = 1.5;  // or annotate the type explicitly
x = 10;              // `let` bindings are reassignable
const MAX = 100;     // `const` is not, and must be compile-time known

let x = "a str";     // shadowing: reuse a name, even with a new type

There is no mut and no borrow checker – a managed runtime handles memory, so the binding model is just let (reassignable) and const (not).

Primitive types

See Primitive Types.

let i: int   = 1_000;       // 64-bit signed; underscores ignored; 0xff hex too
let f: float = 3.14;        // 64-bit IEEE-754
let b: bool  = true;
let s: str   = "hi";        // no char type; a single character is a 1-char str

// int + float promotes to float; there is no `as` cast -- use methods
let g: float = i.to_float();
let back: int = g.to_int(); // truncates toward zero

Integer arithmetic is checked: overflow is a runtime error, never a silent wrap.

Operators

See Operators.

// basic math
let foo = 5 + 4 - 3 * 2;
let promotes: float = 5 / 2; // / always yields a float
let divs: int = 5 ~/ 2;      // ~/ here results in 2, truncating
let rem: int = 5 % 2;        // % is the remainder 

// strs can also be added
let foob = "foo" + "bar";    // -> "foobar"

// logicals, which short-circuit
let a = true || false;
let b = true && a;
let c = !b;

// evaluations
let eq = 0 != 1 && 1 == 1;
let ltgt = 0 < 1 && 1 <= 1 && 1 >= 0 && 1 > 0;

// coalesce
let good: int = null ?? 0;

// bit operators
let and = a & b;
let or = a | b;
let xor = a ^ b;
let shift_right = 1 >> 1;
let shift_left = 1 << 1;

// Finally, every binary op has a compound form
a += 1;
a ~/= 2;
// etc

Strings

See Primitive Types.

let multi = "spans
multiple lines";                     // the newline becomes part of the value

let name = "world";
let msg = f"hi {name}, 1+1={1 + 1}"; // f-string interpolates any expression
let lit = f"{{literal braces}}";     // -> "{literal braces}"

Collections

See Collections.

// Array -- ordered, growable, single element type: [T]
let xs: [int] = [3, 1, 2];
xs.push(4);
let first = xs[0]; // 0-based, reading past the end faults at runtime

// Dictionary -- hash map of str keys to one value type: ~{V}
let scores: ~{int} = ~{ alice = 10, bob = 7 }; // keys are bare idents
let a: int? = scores["alice"]; // indexing yields V? (missing key -> null)
scores.insert("cy", 3);

// Tuple -- fixed-length, heterogeneous, indexed by position
let pair: (int, str) = (1, "one");
let one = pair.1;    // -> "one"
let (x, y) = (3, 4); // destructuring let

// `in` tests membership
0 in xs;            // array: contains the element
"alice" in scores;  // dict: has the key
"ell" in "hello";   // str: substring

Control flow

See Control Flow.

Everything here is an expressionif, match, blocks, and loops all yield values. Block braces are optional around a single expression.

if x == 0 {
    print("zero");
} else if x > 0 {
    print("positive");
} else {
    print("negative");
}

let sign = if x >= 0 1 else -1; // as an expression, braces optional
// an `if` with no `else` must yield () -- its body can't produce a value

let total = {     // blocks yield their final semicolon-free expression
    let a = 3;
    a + 5         // -> 8
};
// match -- first arm to fit wins. exhaustiveness is checked (Maranget-style).
// the below is to illustrate all patterns (and as such wouldn't compile).
let label = match value {
    0 => "zero",                  // literal
    1 | 2 | 3 => "small",         // or-pattern (multiple literals)
    n if n > 100 => "big",        // guard
    found? => found.name,         // null-bind: binds when value isn't null
    Point { x = 0, y } => f"{y}", // struct destructure (fields use `=`)
    Shape::Circle(r) => area(r),  // enum-variant destructure
    (a, b) => a + b,              // tuple destructure
    other => fallback(other),     // bare ident binds anything; `_` too
};

let code = match cmd {            // end with a bare `!` to promise exhaustiveness;
    "go" => 1,                    // reaching it is a runtime fault
    "stop" => 2,
    !
};
loop {                            // infinite until `break`
    if done() { break; }
    continue;
}
let answer = loop { break 42; }; // `loop` can `break value` -> 42

while ready() { work(); }
for item in xs { print(item); }  // iterates arrays, dicts, str, 0..n on int

// `collect` turns any loop into an array builder -- mimas's comprehension
let doubled = for n in xs collect n * 2;
let evens = for n in xs { 
    if n % 2 == 0 collect n;     // filter with `if`
}; 

Options

See Options.

A value that may be null is an option, written T?. null exists only where a ? invites it, so the compiler guarantees you never hit an unexpected null.

let maybe: int? = lookup(); // an int, or null
// let bad = maybe + 1;     // compile error: maybe may be null

let n = maybe ?? 0;         // ??  fallback when null
let len = name?.len();      // ?.  short-circuits to null on a null receiver
let cell = grid[1]?[0];     // ?[] the same, for indexing
let sure = maybe!;          // !   asserts non-null (faults if it was null)

let n? = maybe else return; // let/else: bind, or diverge and move on

T?? automatically flattens to T? – there is no option-of-an-option.

Results & errors

See Results & Error Handling.

Two tiers of failure: an unrecoverable panic that halts the VM, and a recoverable result (T!) a caller can handle.

panic("unreachable");           // halts the VM immediately
todo("later");                  // panic's cousin for unfinished code (msg optional)

fn parse_port(s: str) -> int! { // `!` return type lets the fn `raise`
    let n = s.to_int();         // str.to_int() -> int?
    if n == null {
        raise "not a number";   // the raised value must be a str*
    }
    n!                          // a bare T auto-wraps as the Ok case
}

// unwrap with `!`: take the value, or fault hard if it raised
let p: int = parse_port("80")!;

// recover with `absolve`: handle the error string, keep running
let q: int = parse_port(input) absolve |e| {
    print(f"bad: {e}");
    8080
};

panic, todo, return, raise, and an endless loop {} are all never (!) typed, so they slot into any branch without disturbing its type.

*typed errors are coming

In 0.1.0 the error carried by a result is always a str. The plan is to move to an Error pact you can implement for your own types, so failures can carry structured data. For now, a descriptive message is the tool.

Functions & closures

See Functions & Closures.

fn square(n: int) -> int { n * n }   // body is a block; last expr returns
fn greet(name: str) { print(name); } // no `->`: returns ()

// optional params (with const-foldable defaults) follow the required ones
fn connect(host: str, port=8080) {}
connect("localhost");                // port defaults to 8080
connect("localhost", port=9090);     // name an optional to skip earlier ones

// closures: inline, anonymous, pipe-delimited. return type optionally inferred
let add = |a: int, b: int| a + b;
let now = || current_time();         // no args

fns live only at the file’s top level and capture nothing – they’re second-class (passable as arguments, but not bindable). Closures are first-class (bindable, storable) and capture their surrounding scope.

let g = add;       // ok -- closures are values
// let h = square; // error -- a fn isn't a value you can bind

Structs

See Structs.

struct Player { name: str, score: int }

let p = Player { 
    name = "ada",                // construct with `=`
    score = 0 
}; 
print(p.name);                   // read fields with `.`

impl Player {                    // a type may have many impl blocks
    const MAX = 100;             // associated const
    fn new(name: str) -> Self {  // associated fn (no self)
        Self { 
            name = name, 
            score = 0 
        }
    }
    fn won(self) -> bool {       // method (takes self)
        self.score >= Self::MAX
    }
}

let p = Player::new("ada");      // `::` reaches associated items
p.won();                         // `.` calls a method (= Player::won(p))

struct Vec2(float, float)        // tuple struct: positional fields, indexed
let v = Vec2(1.0, 2.0);
let vx = v.0;
struct Marker;                   // field-free marker type

Enums

See Enums.

enum Shape {
    Circle(float),         // tuple variant
    Rect(float, float),
    Labeled { text: str }, // struct variant
    Empty,                 // payload-free
}

let c = Shape::Circle(1.0);
let e = Shape::Empty;

impl Shape {
    fn area(self) -> float {
        match self {            // every variant must be covered
            Shape::Circle(r) => 3.14159 * r * r,
            Shape::Rect(w, h) => w * h,
            _ => 0.0,
        }
    }
}

Pacts

See Pacts.

A pact is mimas’s trait analogue: a named set of method, associated-function, and constant signatures a type satisfies with impl Pact for Type. It’s how you abstract over types without generics.

pact Draw {
    fn draw(self);
    fn describe() { print("a shape"); } // items may ship a default
}

struct Square;
impl Draw for Square {
    fn draw(self) {}                    // `describe` is inherited
}

fn render_all(items: [Draw]) {          // a pact stands in anywhere a type is expected
    for item in items { item.draw(); }
}

struct Widget {
    item: Named + Identified,           // require several pacts at once with `+`
}

Modules & scripts

See Modules and Scripts.

A plain file is a script – it runs top to bottom, no main required. A file that opens with module is a library: it organizes items and can’t run top-level code.

// colors.mim
module @; // name the module after the file (or: `module graphics::colors;`)

const INTERNAL = 0;          // private by default
pub const RED = 0xff0000;    // `pub` exposes it across modules
pub fn mix(a: int, b: int) -> int {
    // elided
    a
}
// main.mim -- a script
use colors;               // reach items via colors::RED
use colors::{ RED, mix }; // or pull names in directly (also `colors::*`)

print(mix(RED, 0x00ff00));

Extending with Rust

See Extension with Rust.

mimas is built to embed: share Rust types and functions with the #[mimas] macro and they’re type-checked like native ones.

#[mimas]
struct User(String);

#[mimas]
impl User {
    fn greet(self) { println!("Hello, {}!", self.0); }
}
let user = User("mimas");
user.greet(); // -> Hello, mimas!

Benchmarks

The core ethos of mimas is flexibility with safety. The upshot of the latter is a plethora of guarantees for the compiler and runtime, allowing it to optimize the bytecode very tightly.

The wider ecosystem of scripting options can be viewed against mimas on the home page. Here, we’ll narrow down to a few. Luau (through mlua) is the fastest option you can embed in Rust, though it’s a C++ runtime rather than a Rust one. Rune, like mimas, is written in pure Rust, and is the fastest of our options in that category. Rhai, another pure-Rust option, is feature-rich and the most popular in the space.

strings string formatting, builtin methods

mimas656ms
luau869ms
rune3.34s
rhai4.52s

physics struct field access, float math

mimas1.06s
luau1.15s
rune3.51s
rhai14.44s

mandelbrot scalar float throughput, tight loops

luau641ms
mimas940ms
rune1.52s
rhai7.20s

prime numbers array indexing, tight integer loops

luau544ms
mimas620ms
rune3.55s
rhai6.10s

fibonacci function-call overhead, recursion

luau737ms
mimas951ms
rune1.94s
rhai8.77s

eval enum match dispatch, recursion

luau622ms
mimas1.28s
rune1.88s
rhai70.73s

collections dict insert + lookup, string keys

luau360ms
mimas417ms
rune3.08s
rhai15.03s

mimas v0.1.0 · Rune v0.14.2 · Rhai (perf) v1.25.1 · Luau (mlua) v0.11.4 -- measured on a 2021 MacBook Pro M1 Max. Bars are scaled within each test. Results slower than 5x of the fastest result are faded and not factored into the scaling.

What these actually reflect

What is idiomatic in one language very well may not match another, but these tests are written to be as structurally similar as possible – the same approach taken by Are We Fast Yet?, which compares languages on equivalent code rather than language-specific tricks. The intention is to measure how the same practices perform in each language. They therefore measure the cost of the abstraction, not raw arithmetic throughput: read them as “what does modelling your data this way cost?”, not “which language computes faster”.

Why stop at bytecode?

The next step down from mimas’s current bytecode VM would be JIT using something like Cranelift. The problem is that JIT has to write fresh machine code into memory, and many of the platforms mimas cares about forbid exactly that. Game consoles (PlayStation, Xbox, Switch) disallow runtime-generated executable memory as a hard certification requirement; Apple’s platforms (iOS and its siblings) disallow it for third-party apps; sandboxes like the browser don’t offer it either.

This doesn’t mean that JIT (or anything beyond) is completely out of the question, but it would always exist as an optional feature, and likely won’t be a priority for a while.

Compile speed

The compiler is fast enough that you effectively won’t notice any compile times, running at about 400,000 lines per second. More timing information below.

programlinescompile
a small module~100< 1 ms
a project~10,000~23 ms
a large project~100,000~0.24 s

Measured with `mimas build` on the same M1 Max, over a representative collection of source (consts, structs, enums, functions, deeply-nested types) generated by the fodder project (`tools/fodder`).

Reproduce

You can calculate these tests on your own machine with benchmarks/compare.sh, or with cargo bench -p mimas to run our benchmarks through criterion.

Getting started

mimas can be used directly at the CLI or from within your rust project.

CLI Utility

To use mimas independently you can install its CLI utility.

cargo install mimas-cli

Mirroring cargo, you can use a check, build, and run command.

Note

mimas doesn’t “build” to any compiled files, it just runs off of scripts. The build command mostly exists for internal use to invoke the compiler!

mimas check my_script.mim # runs a script through the parsers/type checker
mimas check my_project    # you can also check a directory recursively for all .mim files

mimas run my_script.mim   # executes a script
mimas run my_project      # searches for the `main.mim` file and executes it

mimas my_script.mim       # you can also drop the "run" and just type "mimas"

Rust Projects

To embed into your Rust project, add mimas as a dependency to your Cargo.toml.

[dependencies]
mimas = "0.1.0"

Compiling and execution is simple. A full guide can be found here!

const SOURCE: &str = include_str!("my_script.mim");

let mut vm = mimas::compile_source(SOURCE).unwrap();
let _ = vm.run();

Language Reference

This is the complete reference for the mimas language in version 0.1.0. It describes the syntax and mechanics of the language itself – the parts you write in a .mim file. Extending mimas from Rust is covered separately in Extension with Rust.

Each page introduces one concept with short examples. Most are complete programs you can drop into a file and run; a few are fragments or deliberate errors, noted where they appear:

mimas example.mim
PageCovers
Variables & Constantslet, const, and shadowing
Primitive Typesint, float, str, and bool
OperatorsArithmetic, comparison, logic, and bit operations
Blocks & ScopeBlocks are expressions; what a scope can see
Unit & Never Types(), !, and what “no value” means
Control Flowif and if let, match, the three loops, and collect
CollectionsArrays, dictionaries, tuples, and the in operator
OptionsT?, null, and the operators that handle them
Results & Error HandlingT!, raise, and absolve
Functions & ClosuresDeclarations, defaults and named arguments, closures
User-Defined TypesStructs, enums, and impl blocks
PactsAbstracting over types without generics
Privacypub and the module boundary
ModulesDeclaring, nesting, and importing
ScriptsNo main; files run top to bottom
MemoryGarbage collection and its limits
Notable ExclusionsWhat mimas leaves out, and why

Variables & Constants

Local variables are introduced with let. A let binding can be reassigned.

let a: int = 0;
let b = 1;  // type inferred as int

a = 10;     // reassignment is fine

Inference does the heavy lifting

Type inference in mimas is strong. The only places you must write a type are function signatures and type declarations (struct, enum, pact). Everywhere else the compiler works it out from the value.

We still annotate freely throughout this book – let a: int = 0 – purely to make each example’s types obvious. You won’t need most of them in real code.

Reassignment

A let binding is reassignable, but its type is fixed at declaration. Assigning a value of a different type is an error – use shadowing (below) if you genuinely want a new type.

let count = 0;
count = 5;        // valid
count += 1;       // valid, count is now 6
count = "six";    // compile error: expected int, found str

Shadowing

A new let may reuse a name already in scope. The new binding shadows the old one and can even change the type. This is a fresh variable, not a mutation of the original.

let a: int = 0;
let a: str = "hello!"; // valid -- `a` is now a str

Let else

let/else binds a pattern and runs an else block when the value doesn’t match. The else has to diverge – return, break, panic, and so on – so execution only continues past it when the binding succeeded.

let Shape::Circle(r) = shape else {
    return;
};
// `r` is in scope from here on

It accepts any pattern match does, including the ? null-bind for options:

let port? = lookup_port() else {
    panic("no port configured");
};

Unused bindings

Prefixing a name with _ marks it as intentionally unused.

let _scratch = compute();

No warnings yet

0.1.0 only reports errors, not warnings, so the _ prefix is a no-op for now – it documents intent for when unused-variable warnings land.

Constants

Constants are declared with const and cannot be reassigned.

const FOO: int = 0;
FOO = 1; // compile error: constants cannot be mutated after declaration

A constant’s value must be computable at compile time. Literals, operators, and references to other constants are all fair game; anything that requires running code at runtime – like a function call – is not.

const BAR = 0;
const FIZZ = BAR + 1;          // valid -- built from another const
const GREETING = "hello";      // valid -- a literal

const NOPE = some_call();      // compile error: constants must be known at compile time

Todo

Our constant folding could likely handle evaluating whether a function is fully knowable at compile time, but that will come after 0.1.0.

Primitive Types

mimas has four primitive types: int, float, bool, and str.

Integers

int is a 64-bit signed integer. Integer literals can be written in decimal or hexadecimal, and _ may be used anywhere as a visual separator (it is ignored).

let a: int = 0;
let b: int = 0xff;          // 255, in hex
let c: int = 1_000_000;     // underscores are just for readability

Integer arithmetic is checked: an operation that overflows the 64-bit range is a runtime error rather than silently wrapping around.

let big = 9223372036854775807; // i64::MAX
let oops = big + 1;            // runtime error: integer arithmetic overflowed

Floats

float is a 64-bit IEEE-754 floating-point number.

let a: float = 0.0;
let b: float = 1_000.000_1;

An int and a float can be combined in one expression, but the result is always a float – the int is promoted. There is no implicit float -> int.

let a: float = 1 + 0.1; // valid -- (int + float) => float
let b: int = 1 + 0.1;   // compile error: expected int, found float

Division always yields a float

The / operator always produces a float, even between two ints. When you want integer (truncating) division, use the ~/ operator.

let a: float = 7 / 2;  // 3.5
let b: int   = 7 ~/ 2; // 3

To convert deliberately between the two, use the conversion methods rather than a cast – mimas has no as.

let n: int = 42;
let f: float = n.to_float(); // 42.0
let back: int = f.to_int();  // 42 (truncates toward zero)

Booleans

bool is true or false. Comparisons and logical operators produce bools, and conditions in if, while, and friends must be bool.

let a: bool = true;
let b = 3 > 2;        // true
let c = a && !b;      // false

See Operators for the full set of comparison and logical operators.

Strings

Text is always str. mimas has no separate character type – a single character is just a one-character str. Strings use double quotes only ("), never single (').

let a: str = "hello!";
let b: str = "x"; // a one-character str, not a `char`

Any string literal may span multiple lines; the newline becomes part of the value.

let poem = "this string has
multiple lines!"; // -> "this string has\nmultiple lines!"

Interpolation (f-strings)

Prefix a literal with f to interpolate expressions inside { }. Any expression is allowed.

let name = "world";
let greeting = f"hello, {name}! 1 + 1 = {1 + 1}"; // -> "hello, world! 1 + 1 = 2"

To write a literal brace, double it:

let s = f"{{not interpolated}} but {1 + 1} is"; // -> "{not interpolated} but 2 is"

String methods chain

str has a wide set of built-in methods, and they chain:

let cleaned = "  Hello, World  ".trim().to_lower(); // -> "hello, world"

Operators

mimas’s operators will look familiar coming from Rust or C-family languages. This page is the full list.

Arithmetic

NameSymbolTypesBehavior
Add+int, float, strAdds two numbers, or concatenates two strs.
Subtract-int, floatSubtracts.
Multiply*int, floatMultiplies.
Divide/int, floatDivides. Always yields a float.
Truncating divide~/int, floatDivides and truncates toward zero, keeping the operand type.
Modulo%int, floatRemainder after division (sign follows the left operand).
let a = 7 / 2;   // 3.5  (float)
let b = 7 ~/ 2;  // 3    (int)
let c = 7 % 2;   // 1
let d = "ab" + "cd"; // "abcd"

int + float promotes; / always floats

Mixing an int and a float promotes the result to float. And / produces a float even between two ints – use ~/ for integer division. Integer arithmetic is checked, so overflow is a runtime error, never a silent wrap.

Comparison

Comparisons produce a bool. Ordering (<, <=, >, >=) is defined for numbers only; str supports equality but not ordering.

NameSymbolTypes
Equal==any matching pair
Not equal!=any matching pair
Less / less-or-equal< <=int, float
Greater / greater-or-equal> >=int, float
print(2 < 3);          // true
print("hi" == "hi");   // true
print("a" < "b");      // compile error: these values cannot be compared like numerals

Logical

NameSymbolBehavior
And&&true if both operands are true. Short-circuits.
Or||true if either operand is true. Short-circuits.
Not! (prefix)Negates a bool.
let ok = is_ready() && !is_locked();

Bitwise

Bitwise operators work on int.

NameSymbolBehavior
And&Set each bit where both bits are set.
Or|Set each bit where either bit is set.
Xor^Set each bit where the bits differ.
Shift left<<Shift bits left.
Shift right>>Shift bits right.
print(6 & 3);  // 2
print(6 | 1);  // 7
print(6 ^ 3);  // 5
print(1 << 4); // 16

Null coalescing

The ?? operator supplies a fallback when its left operand is null. It is short-circuiting – the right side is only evaluated when needed – and it belongs to the option family of operators.

let name: str? = null;
let shown = name ?? "anonymous"; // -> "anonymous"

Assignment

Every arithmetic, bitwise, and coalescing operator has a compound-assignment form that updates a binding in place.

a += 1;
a -= 1;
a *= 2;
a /= 2;
a %= 3;
a ~/= 2;
a &= 1;
a |= 1;
a ^= 1;
a ??= fallback; // assign only if `a` is currently null

Blocks & Scope

Like Rust and Zig, a block in mimas is an expression. Curly braces group statements, and if the last thing inside – before the closing brace – is a bare expression (no trailing ;), the block evaluates to it.

let a = {};          // -> ()  (no trailing expression)
let b = { 0 };       // -> 0
let c = {
    let x = 1;
    let y = 2;
    x + y            // no semicolon: this is the block's value
};                   // -> 3

Because blocks are expressions, the same rule powers if, match, and loops – they can all produce values.

Scope

mimas is lexically scoped: a block defines a scope. Code can read and reassign variables from any block that encloses it, but not the other way around. When a block ends, the variables it declared fall out of scope.

let a = 0;
{
    let b = a; // valid -- `a` is visible from the enclosing scope
}
let c = b;     // compile error: `b` is not defined out here

A let inside a block is a brand-new binding. It does not disturb a same-named variable in an outer scope – that’s shadowing. Reassignment (=, without let), on the other hand, reaches outward to the existing variable.

let a = 0;
let b = 0;
{
    a = 1;     // reassigns the outer `a`
    let b = 1; // new binding, shadows the outer `b` only inside this block
}
// -> a is 1, b is 0

The Unit & Never Types

Three things in mimas stand in for “no ordinary value here,” and they mean genuinely different things:

  • null – there could be a value, but right now there isn’t. It’s a real value you can hold and test, and it only appears behind an option (T?).
  • (), the unit type – truly nothing. The result of an expression that was never going to hand back a value.
  • !, the never type – a dead end. The “result” of an expression after which no further code can run.

null belongs to Optionals; this page covers the other two.

Unit – ()

Any expression that doesn’t produce a meaningful value evaluates to the unit type, written (). An empty block, a for loop, a function with no return – they all yield ().

let a: () = {};
let b: () = loop { break; };

Never – !

The never type, written !, is the type of an expression that diverges – one after which no code can run. It arises from return, panic, todo, and infinitely-running loop {}. Only the compiler can produce a !; you can never annotate a binding with it directly.

let a = loop {};       // `a` is `!` -- the loop never ends, so `a` is never assigned
panic("oh no!");       // `panic` diverges, so it is `!`

The useful part is that ! coerces into any type. Because a diverging branch can never actually supply a value, the compiler lets it stand in for whatever type the surrounding code expects. That’s why one arm of an if can bail out while the other still determines the type:

let a: int = if some_condition {
    0
} else {
    return;  // `return` is `!`, so it fits where an `int` is expected
};

The same applies to match: an arm that panics contributes !, which folds into the common type, so it doesn’t force the other arms to become optional.

Control Flow

mimas’s control-flow constructs – if, match, and the loops – are all expressions: each one evaluates to a value, not just steer execution. That property runs through everything in this section:

let label = if score >= 50 { "pass" } else { "fail" };

let kind = match tag {
    0 => "circle",
    _ => "other",
};

let first_even = for n in numbers { if n % 2 == 0 { break n; } };

The pages here cover if & if let, match, the loop / while / for family, and the collect operator for building arrays out of a loop.

If & If Let

An if expression tests a bool condition and runs its block when the condition holds. An optional else – including chained else if – handles the other case.

if ready {
    launch();
}

if ready {
    launch();
} else if waiting {
    hold();
} else {
    abort();
}

As an expression

Because if is an expression, it can produce a value. When you use it that way, an else is required – without one, the if might produce nothing, so the only value it’s allowed to have is ().

let tier: int = if vip {
    0
} else {
    1
};

let c = if vip { 0 }; // compile error: an `if` used as a value must have an `else`

Each branch must agree on a type – though a diverging branch (one that returns, breaks, or panics) contributes the never type and bows out of that agreement:

let port: int = if configured {
    configured_port()
} else {
    panic("no port configured"); // `!` -- doesn't fight the `int` from the other arm
};

If let

if let swaps the bool test for an option test. It evaluates an expression and, if the result is not null, binds the unwrapped value and runs the block. An else runs when the value was null.

if let port = lookup_port() {
    // runs only when `lookup_port()` was not null;
    // `port` is the non-null value in here
    connect(port);
} else {
    use_default();
}

This is the ergonomic way to “check and use” an optional in one step, instead of testing for null and then unwrapping separately. The same pattern-driven form exists for loops as while let.

Match

A match compares a value against a series of patterns and runs the first arm that fits. Each arm is pattern => expression, and arms are separated by commas.

match status {
    0 => print("idle"),
    1 => print("running"),
    2 => print("done"),
    _ => print("unknown"),
}

Like everything else in this section, match is an expression – every arm yields a value, and the whole match evaluates to it:

let label = match status {
    0 => "idle",
    1 => "running",
    _ => "unknown",
};

Patterns

mimas supports a wide range of patterns:

match value {
    0 => "zero",                 // literal
    1 | 2 | 3 => "small",        // multiple literals (an "or" pattern)
    n if n > 100 => "huge",      // a guard -- an extra boolean condition
    found? => found.label,       // null-bind: matches & binds when `value` is not null
    Point { x = 0, y } => y,     // struct destructure (note: `=`, like construction)
    Shape::Circle(r) => area(r), // enum-variant destructure
    (a, b) => a + b,             // tuple destructure
    other => fallback(other),    // a bare name binds anything (the catch-all)
}

A few things to keep in mind:

  • Struct and variant patterns mirror construction. Fields use = (Point { x = 0 }), not :. Write just the field name to bind it (Point { x, y } binds both x and y).
  • A bare identifier matches anything and binds the value to that name – this is your wildcard / default arm. _ works too when you don’t need the binding.
  • Guards (n if cond) add a runtime condition to an arm.

Exhaustiveness

mimas checks matches for exhaustiveness using the same Maranget-style analysis as Rust: the compiler proves that every possible value is covered.

enum Flag { On, Off }

match flag {
    Flag::On => {},
    // compile error: non-exhaustive match -- missing `Flag::Off`
}

Types with a finite shape – bool, enums, tuples, single-variant structs, options – can be exhausted by listing their cases. Open-ended types like int, float, and str can’t, so they always need a catch-all.

You have two ways to deliberately not enumerate every case.

A catch-all arm

A bare identifier (or _) at the end soaks up everything that’s left, which satisfies the exhaustiveness check:

match flag {
    Flag::On => do_thing(),
    _ => {},
}

The ! terminator

When you’re certain the remaining cases can’t occur, end the match with a bare !. It promises exhaustiveness and, if execution ever actually reaches it, raises a runtime error.

let n = match command {
    "go" => 1,
    "stop" => 2,
    ! // if it's neither, we made a promise we couldn't keep -- error at runtime
};

Because the ! arm has the never type, it doesn’t add to the match’s result type. The expression above is a plain int.

Guards don’t count toward exhaustiveness

An arm with a guard might not fire, so the compiler can’t treat it as covering its pattern. You’ll still need a catch-all even when a guarded arm “looks” total:

match flag {
    Flag::On => {},
    Flag::Off if quiet() => {}, // doesn't fully cover `Flag::Off`
    _ => {},                    // still required
}

Loop

loop is the simplest loop: it repeats its body forever until something stops it. Use break to exit and continue to skip to the next iteration.

loop {
    if done() {
        break;     // leave the loop
    } else {
        continue;  // jump straight to the next pass
    }
}

Loops are expressions

A loop evaluates to whatever value you break with, which makes it a clean way to retry until you get a result:

let answer: int = loop {
    let guess = next_guess();
    if is_valid(guess) {
        break guess; // the loop evaluates to this
    }
};

The result type depends on how the loop ends:

The loop ends withevaluates to
break valuethe value’s type, e.g. int
break with no value()
never breaks (loop {})!, the never type

```admonish note title=“break value is for loop only” Plain loop is the one form guaranteed to run, so it can hand back a T directly. while and for might never enter their body, so a value they break out becomes a T? instead – see those pages.

While

A while loop checks a bool condition before each pass and stops as soon as it fails.

let i = 0;
while i < 5 {
    i += 1;
}
// -> i is 5

Breaking a value

Because a while loop might never run – its condition could be false on the very first check – a value broken out of it is wrapped in an option. The loop yields null if it ends without a break value.

let found: int? = while has_next() {
    let x = next();
    if matches(x) {
        break x; // -> int?, because the loop might not have run at all
    }
};

While let

while let is to while what if let is to if: instead of a bool, it evaluates an expression each pass and keeps looping as long as the result is not null, binding the unwrapped value in the body.

while let job = next_job() {
    // runs as long as `next_job()` returns a value;
    // stops the first time it returns null
    process(job);
}

It’s the idiomatic way to drain a source that signals “nothing left” with null.

For

A for loop walks over the elements of something iterable, binding each one in turn.

let xs = [10, 20, 30];
for x in xs {
    print(x); // 10, then 20, then 30
}

What you can iterate

IterableEach binding is
[T] (array)an element, T
~{V} (dict)a (str, V) pair of key and value
streach character, as a one-character str
intthe numbers 0 up to (but not including) the value
for pair in ~{ x = 1, y = 2 } {
    print(pair); // [x, 1], then [y, 2]
}

for c in "hi" {
    print(c); // "h", then "i"
}

for i in 3 {
    print(i); // 0, 1, 2
}

Need the index?

Call .enumerate() on an array to pair each element with its position:

for pair in ["a", "b"].enumerate() {
    print(pair); // [0, a], then [1, b]
}

Tuples are intentionally not iterable: each position can hold a different type, so a single loop binding would have no consistent type. Reach into a tuple by index instead (t.0, t.1). See Tuples.

Breaking a value

Like while, a for loop isn’t guaranteed to run – the collection might be empty – so a value it breaks comes back as an option.

let first_big: int? = for n in numbers {
    if n > 100 {
        break n; // -> int?, since `numbers` could be empty
    }
};

A for loop that never breaks a value evaluates to (). To build a value out of every iteration instead of breaking once, use collect.

Mutation during iteration

Avoid mutating a collection while you are iterating over it. mimas does not catch this today – it has no borrow checker, and a purely-runtime check would either reject safe code or ambush you mid-loop, both of which cut against the language’s goals. A compile-time check is planned. Until then, mutating the collection you are looping over can behave unexpectedly; pushing to an array inside its own for, for instance, may not terminate.

When the compiler can prove a loop does not change the length of what it iterates, it computes that length once instead of on every pass. This is only an optimization, and the cost of missing it is negligible unless you run many thousands of iterations. If you do need to grow a collection as you walk it, iterate a copy or use a loop with the bounds handled yourself.

Collect

collect is a control-flow operator unique to mimas. It turns any loop into an array builder: each value you collect is appended to a running list, and when the loop finishes – whether it runs out of iterations or hits a break – the whole loop evaluates to that array.

Think of it as continue that carries a value: like continue, it moves on to the next iteration.

let numbers = [1, 5, 9, 2];

let doubled = for x in numbers collect x * 2;
// -> [2, 10, 18, 4]

Loop bodies don’t have to be blocks, so that last example fits on one line – close to a Python list comprehension:

// python:  doubled = [x * 2 for x in numbers]
let doubled = for x in numbers collect x * 2;

Add an if to filter which values get collected:

let evens = for x in numbers {
    if x % 2 == 0 {
        collect x;
    }
};
// -> [2]

It composes with every loop, including while let, which is handy for gathering results from a source until it’s exhausted:

let ages: [int] = while let person = next_person() {
    collect person.age;
};

‘collect’ and ‘break value’ don’t mix

A loop either builds a value with collect or breaks out with one, not both. Doing both in the same loop is a compile error, since the loop can’t be an array and a single value at once.

Collections

mimas has three built-in ways to group values: arrays (ordered, same-typed sequences), dictionaries (string-keyed maps), and tuples (fixed-size, mixed-type groups). The first two are growable containers with methods; the tuple is a lightweight structural type.

This section also covers the in operator for membership tests. For named product and sum types, see Structs and Enums.

Arrays

An array is an ordered, growable sequence of values that all share one type – like a list in Python or a Vec in Rust. Its type is written [T].

let xs: [int] = [0, 1, 2, 4];
let first = xs[0]; // 0 -- index with []

Indexing is zero-based, and reading past the end is a runtime error:

let xs = [1, 2, 3];
let oops = xs[9]; // runtime error: index out of bounds

Arrays carry a set of built-in methods for inspecting and growing them, such as len, push, and contains:

let xs = [3, 1, 2];
xs.push(4);       // xs is now [3, 1, 2, 4]
let n = xs.len(); // 4

To build a new array by transforming or filtering an existing one, use a collect loop:

let doubled = for x in [1, 2, 3] collect x * 2; // [2, 4, 6]

Dictionaries

A dictionary is a hash map from string keys to values of one type, written ~{V}. The leading ~ distinguishes a dict literal from a block, since both use braces.

In a literal, keys are written as bare identifiers and values follow an =. You read a value back with ["key"].

let scores: ~{int} = ~{
    alice = 10,
    bob = 7,
};
let top: int? = scores["alice"]; // 10

Keys are strings

The identifier syntax in a literal (alice = 10) is sugar – the key is the string "alice", and you index with a string (scores["alice"]). Dictionaries are plain hash maps, not an ADT; for fixed, named fields with mixed types, use a struct.

Indexing returns an option

Any key might be absent, so indexing a dict hands back an option: ~{V} yields V?, never a bare V. A present key gives the value, a missing one gives null, and you handle that null like any other option.

let scores: ~{int} = ~{ alice = 10 };

let a: int? = scores["alice"]; // 10
let b: int? = scores["zoe"];   // null

let shown = b ?? 0; // 0 -- fall back when absent

To reach through a nested dict, each dict hop you index past takes its own ? – see option chaining.

let users: ~{~{int}} = ~{ alice = ~{ score = 10 } };
let score: int? = users["alice"]?["score"]; // 10, or null if either key is absent

Iterating

Iterating a dict binds each entry as a (key, value) pair:

for pair in ~{ x = 1, y = 2 } {
    print(pair); // [x, 1], then [y, 2]
}

Dictionaries also carry built-in methods for membership and mutation, such as contains_key, insert, and remove.

Tuples

A tuple is a fixed-length, heterogeneous sequence: an anonymous product type where each position can hold a different type. Tuple types are written as a parenthesized list.

let pair: (int, int) = (0, 1);
let mixed: (float, str) = (0.1, "hello!");

Reach into a tuple by position with dot-and-index:

let mixed = (0.1, "hello!");
let x: float = mixed.0; // 0.1
let s: str   = mixed.1; // "hello!"

Because each position can be a different type, a tuple is not iterable – a single loop binding would have no consistent type to take. That’s why index access exists. To pull a tuple apart in one step, match on it:

let point = (3, 4);
let sum = match point {
    (a, b) => a + b,
};
// -> 7

You can also destructure a tuple straight into a let, binding each position at once:

let (x, y) = (3, 4); // x is 3, y is 4

Tuple vs. tuple struct

A tuple is anonymous and structural: (int, int) is just “two ints.” When you want that shape to carry a name and an identity – a Vec2 that isn’t interchangeable with every other (float, float) – reach for a tuple struct.

The in Operator

in tests for membership and evaluates to a bool. It works across the collection types and strings, adapting its meaning to each:

x in y where y is…tests whether…
[T] (array)the array contains the element x
~{V} (dict)the dict has the key x (a str)
strx is a substring of y
let nums = [0, 1, 2];
print(0 in nums);        // true

let dict = ~{ foo = 1 };
print("foo" in dict);    // true  -- checks keys

let text = "hello!";
print("ell" in text);    // true  -- substring
print("x" in text);      // false

For an array, x in xs is the operator form of xs.contains(x); use whichever reads better at the call site.

Options

When a value is allowed to be either some type or null, it’s an option. Mark a type as optional by suffixing it with ?.

let a: int = 0;
a = null; // compile error: `a` is int, never null

let b: int? = 0;
b = null; // valid

An option is mimas’s answer to the billion-dollar mistake: null only exists where a ? invites it, so the compiler can guarantee you never trip over an unexpected null access.

An option is not its inner type

int? and int are different types. You can’t use a possibly-null value where a definitely-present one is required – you have to deal with the null case first (unwrapping, below).

let a: int = 0;
let b: int? = null;
a = b; // compile error: `b` may be null

Comparing options

You can compare an option for equality against null, or against a value of its inner type:

let maybe: int? = null;

print(maybe == null); // true
print(maybe == 0);    // false
print(0 == maybe);    // false -- order doesn't matter

Ordering comparisons (<, >, and so on), on the other hand, need both sides to be non-null – there’s no sensible place for null on a number line. Unwrap or coalesce first.

let a: int = 0;
let b: int? = null;
print(a > b); // compile error: these values can't be compared like numerals

Creating options

Any type coerces into its option form when it meets a null. So an array literal with a null in it, or an if whose branches disagree about presence, infers an optional type automatically.

let a = [0, null, 1];               // a is [int?]
let b = ~{ x = null, y = 0 };       // b is ~{int?}
let c = if ready { 0 } else { null }; // c is int?

The coercion only kicks in when a fresh value is created. It won’t quietly punch a null into an existing non-optional slot:

let d = [0];
d[0] = null; // compile error: expected int, found null

Flattening

There is no “option of an option.” If a type would come out as T??, mimas automatically flattens it to T? – the same choice Kotlin makes.

fn maybe() -> int? { null }

// `maybe()` is already int?, and the other branch is null, so this would be int??
// -- mimas flattens it straight to int?.
let nested: int? = if ready { maybe() } else { null };

Option chaining

To reach through a value that might be null, you’d otherwise write a guard:

let len: int? = if name == null {
    null
} else {
    name.len()
};

The ?. operator collapses that into one expression. If the receiver is null, the whole chain short-circuits to null and the call is never made; otherwise it proceeds and re-wraps the result as an option.

let len: int? = name?.len();

The same works for indexing, with ?[ ]:

let grid: [[int]?] = [[0], null, [1]];
let cell: int? = grid[1]?[0]; // null -- the middle row is null, so we stop

Chaining past the first optional

A ? short-circuits past one option. Drilling further with a plain [i] or .field rides that same short-circuit – those don’t introduce a new null – so you don’t repeat ? to reach into an array or a struct field:

let grid: [[int]]? = null;

// one `?` for `grid`; the `[0]` and `[1]` array drills ride it.
let cell: int? = grid?[0][1]; // null if `grid` is null

A dict index is different: the key might miss, so each dict you reach through spends its own ?. With a ~{~{[int]}}, both dict hops are options:

let f: ~{~{[int]}} = ~{ a = ~{ b = [0] } };

let x: int? = f["a"]?["b"]?[0]; // two dicts -> two `?`, then the array drill rides
// f["a"]?["b"][0]  // compile error: the `["b"]` miss isn't handled before `[0]`

The rule is one ? for each option you reach through. A trailing option is fine – you spend a ? to index past one, not to produce one:

let users: ~{~{int}} = ~{ alice = ~{ score = 10 } };
let score: int? = users["alice"]?["score"]; // `?` reaches through ["alice"]; the result is the option

A chain only rides a real ? token. A bare dict index that happens to be an option does not silently propagate – users["alice"]["score"] is a compile error, because the middle value may be null and you haven’t said how to handle it. Parentheses close a chain too, so (grid?[0])[1] stops at the paren and the outer [1] sees a plain option again.

Unwrapping

When a code path expects an option to actually hold a value, you unwrap it. Where ? poses the question of an option, a postfix ! asserts the answer: “this is not null.” If it turns out to be null, that’s a runtime error.

let port: int = lookup_port()!; // `port` is a plain int from here on

When you’d rather supply a fallback than risk a runtime error, reach for ??, which yields its right side when the left is null:

let port: int = lookup_port() ?? 8080; // the value if present, otherwise 8080

And to branch on presence while binding the unwrapped value, use if let:

if let port = lookup_port() {
    connect(port);
} else {
    use_default();
}

When you want the unwrapped value for the rest of the scope and would rather bail out than nest, a let/else keeps things flat:

let port? = lookup_port() else {
    panic("no port configured");
};
// `port` is a plain int from here on

Results & Error Handling

mimas has two tiers of failure: an unrecoverable panic that tears down the VM, and a recoverable result that a caller can inspect and handle.

Panicking

The blunt instrument is panic. It immediately halts the VM with a message – use it for “this should never happen” situations.

panic("unreachable state");

todo is panic’s cousin for unfinished code; it halts the same way and even lets you omit the message.

fn not_done_yet() -> int {
    todo("implement me")
}

Since both diverge, they have the never type and slot into any expression – handy as a placeholder branch.

Results

For failure a caller can actually deal with, a function returns a result, written by suffixing its return type with !. Inside such a function, raise produces an error.

fn parse_port(text: str) -> int! { // the `!` lets this function `raise`
    let n = text.to_int();
    if n == null {
        raise "port must be a number";
    }
    n!
}

A few rules govern results:

  • raise is only legal inside a function whose return type is a result (T!). The value you raise must be a str – the only error type in 0.1.0.
  • A function returning T! can hand back a bare T; it’s automatically wrapped as the success case. (That’s why n! above – an int – is a valid int! return.)
  • raise itself has the never type, so it composes inside if and match arms without disturbing their result type.

```admonish todo title=“str errors today, typed errors later” In 0.1.0, the error carried by a result is always a str. The plan is to move to an Error pact you can implement for your own types, so failures can carry structured data. For now, a descriptive message is the tool.


## Handling a result

A result has to be dealt with before you can use the value inside it. There are two ways.

### Unwrap with `!`

The postfix `!` -- the same operator that unwraps an [option](./options.md#unwrapping) -- pulls the success value out of a result. If the result turned out to be a raised error, that becomes a runtime error and execution stops.

<pre><code class="language-mimas hljs"><span class="hljs-keyword">let</span> port: <span class="hljs-built_in">int</span> = <span class="hljs-title">parse_port</span>(<span class="hljs-string">&quot;</span><span class="hljs-string">8080</span><span class="hljs-string">&quot;</span>)<span class="hljs-keyword">!</span>; <span class="hljs-comment">// 8080 -- or a hard stop if it had raised</span></code></pre>

```admonish warning title="`!` does not propagate -- it unwraps or dies"
Coming from Rust, the `!` here is *not* the `?` operator. It doesn't bubble an error up to your caller; it asserts success and faults the VM if it's wrong. To actually *handle* a failure and keep running, use `absolve`.

Recover with absolve

absolve consumes a result and guarantees a plain value. You give it a closure that receives the error string; if the result raised, your closure runs and its value is used instead.

let port: int = parse_port(input) absolve |err| {
    print(f"bad config: {err}");
    8080 // fall back to a default
};

The closure’s return type must match the result’s inner type – absolve on an int! has to produce an int – so the overall expression is a guaranteed, non-failing value.

Functions & Closures

Functions are declared with fn. Parameters must be annotated; the return type follows -> and defaults to () when omitted.

fn greet(name: str) {
    print(f"hello, {name}!");
}

fn square(n: int) -> int {
    n * n
}

The body is a block, so its final bare expression is the return value. The annotated return type is enforced:

fn oops() {
    0 // compile error: expected (), found int
}

return exits early and, being never-typed, fits anywhere:

fn clamp_low(n: int) -> int {
    if n < 0 {
        return 0;
    }
    n
}

Optional parameters

A parameter with a default value becomes optional. Like a let, its type can be inferred from the default, so the annotation is optional too.

fn connect(host: str, port=8080) {
    // ...
}

connect("localhost");        // port defaults to 8080
connect("localhost", 9090);  // or pass it explicitly

Defaults must be const-foldable, and every optional parameter has to come after all the required ones.

fn bad(a: int, b=0, c: int) {} // compile error: a required parameter can't follow an optional one
fn good(a: int, c: int, b=0) {} // valid

Named arguments

Optional arguments may be passed by name – again, only once all the required positional arguments are supplied. Naming lets you skip over defaults you don’t care about and set one further along:

fn style(text: str, bold=false, italic=false, size=12) {}

style("hi", size=18, bold=true); // skip `italic`, set the other two

Closures

A closure is an inline, anonymous function written with pipes. Its return type is optional and inferred when left off.

let add = |a: int, b: int| -> int { a + b };
let double = |n: int| { n * 2 }; // return type inferred

let total = add(double(3), 4); // 10

A no-argument closure uses an empty pair of pipes:

let now = || current_time();

Note

Functions cannot currently be passed to native functions and called. This is a high priority to be added after v0.1.0.

Closures vs. functions

The two differ in two important ways.

First- vs. second-class. Closures are first-class: assign them to bindings, store them in arrays, pass them around freely. Functions are second-class – you can pass one as an argument, but you can’t bind or store it as a value.

let add = |a: int, b: int| -> int { a + b };
fn sub(a: int, b: int) -> int { a - b }

apply(add); // legal -- passing a closure
apply(sub); // legal -- passing a function as an argument is fine

let f = add; // legal
let g = sub; // compile error: a function isn't a value you can bind

Capturing. A closure can capture variables from the scope around it. A function cannot – it sees only its parameters and top-level items.

let greeting = "hello!";

let say = || print(greeting); // legal -- captures `greeting`

fn say_fn() {
    print(greeting); // compile error: `greeting` is undefined in here
}

Functions live at the top level

fn declarations are only allowed at the root of a file. Need a function-like value deeper inside another function or a block? That’s exactly what closures are for.

fn outer() {
    fn inner() {}      // compile error: no nested functions
    let inner = || {}; // do this instead
}

User-Defined Types

Beyond the built-in primitives and collections, mimas lets you define your own named types. There are two kinds, the algebraic product/sum pair:

  • Structs – product types. A struct holds several values at once: a Player has a name, a score, and a position.
  • Enums – sum types. An enum is exactly one of several shapes: a Shape is a circle, a rectangle, or a triangle, never more than one at a time.

Both gain behavior through impl blocks, and both can satisfy a pact to share an interface.

Structs

A struct is a named product type – it bundles several fields, each with its own type, under one name.

struct Player {
    name: str,
    score: int,
}

let p = Player {
    name = "ada",
    score = 0,
};

Construction uses = for each field (not :, which is reserved for type annotations). Read a field back with dot access:

print(p.name);  // "ada"
print(p.score); // 0

Methods and associated items

impl blocks attach behavior to a struct. A type can have any number of impl blocks, as long as the struct is in scope.

An impl can define associated constants, associated functions, and methods. A function becomes a method when its first parameter is self; Self refers to the struct being implemented.

struct Player {
    name: str,
    score: int,
}

impl Player {
    const MAX_SCORE = 100;

    // associated function -- no `self`
    fn new(name: str) -> Self {
        Self { name = name, score = 0 }
    }

    // method -- takes `self`
    fn is_winner(self) -> bool {
        self.score >= Self::MAX_SCORE
    }
}

let p = Player::new("ada");   // call an associated function with `::`
print(p.is_winner());         // call a method with `.`

Calling a method with dot syntax is sugar that passes the receiver in as self. These two lines are equivalent:

let won = p.is_winner();
let won = Player::is_winner(p);

Reaching items through a value

mimas has one deliberate split from Rust: dot access can also reach an associated item through a value, not just through the type name. This matters for pacts, where you have a value but not its concrete type name.

let p = Player::new("ada");
print(p.MAX_SCORE);      // 100 -- the associated const, reached through the value
print(Player::MAX_SCORE); // the same const, through the type

Tuple structs

A struct can use positional fields instead of named ones, making it a tuple struct. Read its fields by index, like a tuple.

struct Vec2(float, float)

let v = Vec2(1.0, 2.0);
let x = v.0; // 1.0
let y = v.1; // 2.0

A tuple struct’s bare name doubles as a constructor value: a function (fields...) -> Struct you can pass around – while the name still works as a type in annotations.

struct Wrap(int)

let make = Wrap;       // `make` is a function value, (int) -> Wrap
let w: Wrap = make(7); // `Wrap` is still usable as a type
print(w.0);            // 7

A field-free struct is written with empty braces or a bare struct Name; – useful as a marker type or a home for associated items and pact impls.

struct Marker;

Enums

An enum is a named sum type: a value is exactly one of several variants. Each variant has its own shape and can carry data, either as a tuple of types, as named fields, or as nothing at all.

enum Shape {
    Circle(float),           // tuple variant
    Rectangle(float, float), // tuple variant
    Labeled { text: str },   // struct variant
    Empty,                   // payload-free variant
}

let a = Shape::Circle(1.0);
let b = Shape::Rectangle(2.0, 3.0);
let c = Shape::Labeled { text = "square" };
let d = Shape::Empty;

Variants are namespaced under the enum with ::. A payload-free variant like Shape::Empty is used directly as a value; the others are constructed by supplying their data – positionally for tuple variants, with = for struct variants (mirroring struct construction).

An enum is always one of its variants

You can’t make a bare Shape(). There’s no such thing as an enum value that isn’t one specific variant. Construct through a variant, always.

Matching on variants

match is how you take an enum apart: each arm names a variant and binds its payload. Because the compiler knows the full variant list, it can check that you’ve covered them all.

fn area(s: Shape) -> float {
    match s {
        Shape::Circle(r) => 3.14159 * r * r,
        Shape::Rectangle(w, h) => w * h,
        Shape::Labeled { text } => 0.0,
        Shape::Empty => 0.0,
    }
}

Methods

Like structs, enums take impl blocks for associated items and methods. A method that dispatches on self is the idiomatic way to fold behavior into the type itself:

impl Shape {
    fn area(self) -> float {
        match self {
            Shape::Circle(r) => 3.14159 * r * r,
            Shape::Rectangle(w, h) => w * h,
            _ => 0.0,
        }
    }
}

let total = Shape::Circle(2.0).area(); // 12.56636

Pacts

mimas is built for embedded use, so Rust’s full trait machinery – generics, associated types, blanket impls, and the constraint solver behind them – is intentionally out of scope. Even so, abstracting over types is sometimes genuinely useful: without any way to do it, every function that wants to handle “anything with this behavior” has to be rewritten per concrete type.

It comes up most at the FFI line. A host project large enough to span several crates often leans on traits at its boundaries, and a mimas script needs some way to interoperate with at least the simplest of those patterns.

A pact fills that role. It’s a named set of method, associated-function, and constant signatures – a contract a type satisfies by writing impl PactName for TheType { ... } and supplying the listed items. Anywhere a type is expected, a pact can stand in, and the checker will accept any value whose type fulfills it.

pact Draw {
    fn draw(self);
}

struct Square;
impl Draw for Square {
    fn draw(self) { /* draw a square */ }
}

struct Circle;
impl Draw for Circle {
    fn draw(self) { /* draw a circle */ }
}

fn render_all(items: [Draw]) {
    for item in items {
        item.draw();
    }
}

Constants

A pact can require constants as well as methods.

pact Named {
    const NAME: str;
}

impl Named for Square {
    const NAME = "Square";
}

let n = Square::NAME; // "Square"

Default implementations

A pact item can ship with a default, which an implementer may use as-is or override.

pact Identified {
    const ID: int;

    fn describe() {
        print(f"my id is {Self::ID}");
    }
}

impl Identified for Square {
    const ID = 0;
    // `describe` is inherited
}

Square::describe(); // "my id is 0"

Reaching pact items through a value

Without generics, mimas can’t lean on Rust’s T::ITEM syntax to read an associated item off a constrained type. This is why dot access reaches associated items – given a value known only by its pact, you still need a way to get at its constants and methods, and . is it.

fn announce(thing: Identified) {
    let id = thing.ID;
    thing.describe();
}

Binding multiple pacts

An annotation can require several pacts at once with +:

struct Widget {
    item: Named + Identified,
}

To make a multi-pact bound optional or a result, wrap it in parentheses first so the ?/! applies to the whole thing:

struct Widget {
    item: (Named + Identified)?,
}

Privacy

Privacy in mimas is drawn at the module boundary. Everything is private to its own module by default; the pub keyword is what makes an item – or a struct field – reachable from other modules.

pub fn api() {}      // callable from other modules
fn helper() {}       // module-private

pub struct Config {
    pub name: str,   // readable from other modules
    secret: str,     // module-private field
}

Within a module, there are no walls

pub only matters when one module reaches into another. Inside a single module (including a plain script file), every item and field is freely accessible. The examples below assume the access is happening from a different module.

Private fields

From outside its module, a struct’s private fields are neither readable nor writable. That also means you can’t build the struct with a literal – you’d have to name fields you aren’t allowed to touch.

// in another module:
let c = Config { name = "x", secret = "y" }; // error: `Config::secret` is not accessible
let s = c.secret;                            // error: `Config::secret` is not accessible

The fix is to expose a public surface – a constructor and accessors – and keep the internals sealed:

impl Config {
    pub fn new(name: str) -> Self {
        Self { name = name, secret = generate() }
    }

    pub fn name(self) -> str {
        self.name
    }
}

// in another module:
let c = Config::new("x"); // ok
print(c.name());          // ok

This is the standard encapsulation move: callers get a stable, intentional interface, and you stay free to change the private guts.

Modules

A module is a named collection of items – functions, constants, types – that you can share across files. Modules are how a mimas project grows past a single script: they group related code and draw the privacy boundary that pub controls.

Declaring a module

A file becomes a module with a module declaration at the top. Name it explicitly, or use @ to take the file’s own name (so colors.mim becomes the colors module).

module colors;
// equivalently, in colors.mim:
module @;

const INTERNAL = 0; // private to this module
pub const RED = "#ff0000";
pub fn mix(a: str, b: str) -> str { /* ... */ }

Modules can nest by qualifying the path with :::

module graphics::colors;

Module files don’t run

A file that declares a module is a library, not a script – it can’t have top-level expressions to execute. That keeps “code that runs” and “code that’s organized for reuse” separate. See Scripts.

Using a module

Bring a module into scope with use. By default this makes the module available under its name, and you reach its public items with :: – the same accessor structs use.

use colors;

let r = colors::RED;
let m = colors::mix(colors::RED, "#00ff00");

let i = colors::INTERNAL; // error: `INTERNAL` is private to its module

You can also pull items directly into the current file. Import one item, several at once with braces, or everything with *:

use colors::RED;          // just `RED`
use colors::{ RED, mix }; // several names
use colors::*;            // every public item

let r = RED;
let m = mix(RED, "#00ff00");

Scripts

There is no implicit main function in mimas. Unlike Rust, a script just runs top to bottom – write whatever code you like at the file’s root and execute it directly, the way you would with Python or JavaScript.

// hello.mim
print("Hello, world!"); // runs the moment you execute the file
mimas run hello.mim

Top-level statements run in order, and top-level fns, structs, and similar declarations are available throughout the file regardless of where they’re declared.

print(greet("world"));      // works -- `greet` is visible across the whole file

fn greet(name: str) -> str {
    f"hello, {name}!"
}

Scripts run; modules organize

A file that opens with a module declaration is a library, not a script – it can’t carry top-level expressions to execute. This split keeps “the thing you run” distinct from “the code you structure for reuse.”

Memory

mimas is garbage collected. Memory is managed for you by a collector built on gc-arena: there is no manual allocation, no free, no pointers, and no lifetimes to thread through your code. You create values and use them, and the runtime reclaims what you no longer reach.

This is a deliberate trade: a forgiving runtime paired with a strict compiler. The static analysis mimas does is spent on correctness – types, exhaustiveness, null-safety – not on tracking who owns what. The goal is a language that feels like scripting to write while still catching real mistakes before you run.

Where the heavy lifting belongs

If a piece of your project needs ownership, lifetimes, or manual control over memory, that’s a sign it belongs on the Rust side of the boundary. mimas is the scripting layer; Rust is the place for the parts that demand that rigor. See Extension with Rust.

Running out of memory

The collector manages reclamation, not quotas. A script can exhaust the process’s memory, and when it does, the process aborts – there is no error a script (or its host) can catch at that point. If you embed mimas and run scripts you don’t control, account for this at the host level for now. We intend to offer a per-Vm memory budget that surfaces exhaustion as an ordinary fault, but it does not exist today.

Notable Exclusions

Anything not described in this reference can be assumed not to exist in mimas – the language is intentionally small. This page calls out a few absences that programmers coming from other languages are most likely to reach for, and why they’re left out.

Pointers & references

There are no pointers or references. They’re a sharp tool that invites whole categories of instability, and guarding against that safely would demand a lifetime system as involved as Rust’s. Like Python, mimas’s answer is not to have them. If part of your project genuinely needs that level of control, that’s a sign it belongs on the Rust side. (Memory covers the managed model that replaces them.)

Union types

Arbitrary unions (A | B) undercut type inference and blur the line between static and dynamic typing in ways that get unpleasant fast. Where you’d reach for a union to write code generic over several types, mimas points you at a pact instead. The only built-in “this or that” types are options and results, which are deliberate, well-behaved special cases.

Generics

mimas has no user-facing generics. Abstracting over types is the job of pacts, which cover the common cases without the inference cost and complexity that a full generic system brings to an embeddable language.

A borrow checker

There is no borrow checker – garbage collection removes the need for one. Trading manual memory rigor for a managed runtime is exactly what buys mimas its forgiving, script-like feel.

A let / mut split

There’s no mut. Without a borrow checker there’s little to gain from distinguishing mutable and immutable bindings, so the model is as simple as it gets: let is reassignable, const is not, and that’s all. See Variables & Constants.

Extension with Rust

mimas is designed to live inside a Rust program, and the boundary between the two is meant to be easy to cross: Rust can provide its types, functions, and systems, mimas takes it from there. This chapter covers that boundary – how to expose your Rust code to mimas, what happens to values as they cross, and how to handle the things that can go wrong.

The short version is that a single attribute is usually all it takes:

// rust
#[mimas]
fn roll(sides: i64) -> i64 {
    /* ... */
}
// mimas
print(roll(20));

Nothing about this is dynamic: the signature you write in Rust becomes the signature the mimas compiler enforces at every call site, so a script that misuses your API fails to compile rather than failing at runtime.

PageCovers
BasicsThe mimas macro: functions, constants, types, and modules
Working With TypesHow Rust types map to mimas types, and what each costs
Runtime ErrorsThe ways a native function can fail, recoverably or not
Fixtures & ContextHow to communicate data from the host into the native functions you provide
FreezeTaking Fixtures a step further and enabling mutable access to the host.
Advanced UsageRegistering by hand for the cases the macro can’t infer

Basics

Providing new types and functions to mimas is made easy by the mimas macro.

// rust
#[mimas]
const HELLO: &str = "Hello!";

#[mimas]
fn echo(message: &str) {
    println!("{message}");
}

#[mimas]
struct MyType(usize);

By default the macro will place its imported items into the prelude, meaning your mimas code can reach it from anywhere.

// mimas
let hello: str = HELLO;

Alternatively, you can dynamically create modules by specifying them to the macro.

// rust
#[mimas(entities)]
const ENTITY_COUNT: usize = 2;

#[mimas(entities::enemies)]
struct Monster {
    health: usize,
}
// mimas
print(entities::ENTITY_COUNT);

let monster = entities::enemies::Monster {
    health: 0,
};

Methods come along too: tag an impl block and everything inside attaches to the type. self, &self, and &mut self all work, as do associated functions like constructors and constants. Methods belong to their type rather than a module, so the impl form takes no module path.

// rust
#[mimas]
struct Player {
    name: String,
    health: i64,
}

#[mimas]
impl Player {
    const MAX_HEALTH: i64 = 100;

    fn new(name: String) -> Self {
        Player { name, health: Self::MAX_HEALTH }
    }

    fn damage(&mut self, amount: i64) {
        self.health -= amount;
    }
}
// mimas
let player = Player::new("Ferris");
player.damage(30);
print(player.health); // 70

Write-back mutation

Your Player doesn’t live inside the VM – its fields do. Calling a method copies the fields out into a real Player, runs your Rust, and (for &mut self) writes the fields back into the same instance when it returns.

Magic is dangerous!

We use the inventory crate to automatically make your mimas tagged items visible to mimas. For most projects (where their API will be defined in the same crate that runs mimas) this carries no complications. However, if you ever wish for your API declarations to cross crates, you need to do one of the following:

  1. Pin the crate holding them to a single codegen unit in your root Cargo.toml, and make sure your binary actually uses that crate (calling any one function from it is enough):

    [profile.dev.package.my-api]
    codegen-units = 1
    [profile.release.package.my-api]
    codegen-units = 1
    
  2. Skip the magic for those items and register them by hand: write them with #[native] and add them through an installer function. We go over this in depth on the Advanced Usage page.

Without one of these, the linker is free to drop library code it sees no direct use for, and your items can silently vanish – sometimes only in certain builds.

Working With Types

Our goal is to allow you to work naturally while in Rust without having to think too much about how mimas itself works under the hood. Types that you use on your mimas-tagged Rust code can be marshalled into their mimas counterparts.

The ’gc lifetime

You’ll often see the 'gc lifetime showing up in native type signatures – this is the lifetime tied to our garbage collector. Anything that lives within the Vm is tied to this lifetime.

You don’t need to hand-write the lifetime on your function headers – the macro will insert it if it is missing.

The conversion table

Rust typemimas typeNotes
boolbool
i8 through i64, isize, u8 through u64, usizeintRange-checked at the boundary (see below)
f32, f64floatf32 narrows silently (see below)
String, &str, Strstr
()()() is just null at runtime
Option<T>T?None and null convert to one another. Trailing Option parameters may be omitted at the call site
Raisable<T>T!See Runtime Errors
Vec<T>[T]Copied in and out (see below)
HashMap<String, T>~{T}Copied in and out
Tuples, up to 8 elements(A, B, ...)Runtime shape is an array
A #[mimas] struct or enumThat type
anon::T (and U, V, W)A type linked per call siteSee below
Array, DictThe corresponding valueZero-copy handles to the live value.
ValAny valueRemoves type analysis on anything it touches. Use with caution.

Integers are range-checked at the boundary

A mimas int is an i64. Declaring any other integer type makes the conversion itself the validation: an argument that does not fit will fault at the call site with a normal runtime error.

#[native]
fn nth(items: &[anon::T<'gc>], index: usize) -> anon::T<'gc> { /* ... */ }
x expected usize, received Int(-2)

This means a usize parameter is a free “must be a non-negative integer” check. When you want a friendlier (or recoverable) error instead, take i64 and validate it yourself – see Runtime Errors.

f32 silently rounds

The one asymmetry is f32: a mimas float is an f64, and converting to f32 rounds silently rather than erroring. Declare f32 only when that loss is acceptable.

Collections

There are a few different ways to pass arrays and dictionaries into a function.

Parameter shapeAccess
&[Val], &Vec<Val>, &mut Vec<Val>The live array, untyped elements
&[anon::T<'gc>], &Vec<anon::T<'gc>>, &mut Vec<anon::T<'gc>>The live array, elements typed to the receiver
&DictMap<'gc>, &mut DictMap<'gc>The live dict, untyped values
&DictMap<'gc, anon::T<'gc>>, &mut DictMap<'gc, anon::T<'gc>>The live dict, values typed to the receiver
&[i64], &Vec<String>, etc.A fresh, read-only Vec<T>
&mut Vec<i64>, etc.compile error – see below

Allocation costs

Taking an owned Vec or HashMap will create a clone of your runtime data. However, simply taking a reference is not enough to guarantee zero-cost references. If your type signature will require marshalling the inner values of the collection then a clone is required to create the correct shape you ask for. This is why &mut Vec<i64> is made into a compile error – the Vec you’d be mutating is one that was just minted for you and will be dropped after the call.

Marking your inner values as raw Val or an Anon will always allow for actual references.

Anonymous types

mimas has no generics, but native signatures still need to express things like “the value pushed must match the array’s element type.” The Anon type allows you to write generic code while keeping your type safety.

The structure of this type is just Anon<'gc, const N: u32>, but rather than writing it out it is more common to use the shorthands available in vm::anon: T, U, V, and W – aliases for slots 0 through 3. The explicit form works in any parameter position too (including the borrow shapes above), so a signature that somehow needs more than four independent slots can write anon::Anon<'gc, 4> and up. Within documentation we always write with a qualified usage (anon::T<'gc>) to make it clear that they are not true generics.

Anons are local to their call

An anon::T you use in one function has zero relation to an anon::T in another. Each time the solver encounters them it creates a freshly minted type ID that will only be mapped to the current call.

#[native]
fn push<'gc>(arr: &mut Vec<anon::T<'gc>>, val: anon::T<'gc>) { /* ... */ }

Calling [1, 2].push(x) constrains x to int; calling it on [str] constrains x to str. U, V, and W are additional independent slots.

Val is the opposite end of the spectrum: it accepts any value with no constraint, and the checker learns nothing from it. In practice you should just avoid it – anon::T accepts everything Val does while keeping the checker engaged.

Tread carefully with Val!

mimas uses all of the information the solver learns to structure its bytecode. Since Val is effectively a cheatcode for an Any type, you can easily trick mimas into running code it expects to be impossible. This will lead either to silent failures or panics, but this is not guaranteed – in the future it may lead to UB.

As an example, this successfully compiles and executes:

// rust
#[mimas]
fn uh_oh(a: &mut Vec<Val<'gc>>, b: Val<'gc>) {
    a.push(b);
}
// mimas
let a = [0, 1, 2];
uh_oh(a, "not good...");
print(a); // > [0, 1, 2, "not good..."]

Congrats, you’ve made mimas a dynamic language. Don’t try this at home!

Runtime Errors

There are three different methods of raising errors at runtime. The first one is just to panic, which doesn’t really count, so we’ll skip past that one!

Unrecoverable errors

Using an ordinary Result as the return type of your function will raise the error immediatly within the VM if the error case is hit.

// rust
#[mimas]
fn oops() -> Result<(), RtErr> {
    Err(RtErr::Custom("oops!"));
}
// mimas
oops(); // runtime error: oops!

The standard library avoids using these in favor of relying on the type system or handing back raisable errors, but hey, you do you.

Raisable errors

To return an actual mimas Result, you use Raisable.

// rust
#[mimas]
fn test(b: bool) -> Raisable<()> {
    if b {
        Raisable::Ok(())
    } else {
        Raisable::Raised("not true!");
    }
}
// mimas
test(false) absolve |e| print(e); // > not true!

Raisable has a blanket implementation on Result<T, E> (so long as E is Display).

#[mimas]
fn load_file(path: &str) -> Raisable<String> {
    std::fs::read_to_string(path).into()
}

Fixtures & Context

All mimas tagged functions have access to Ctx, a handle into the current execution. To use it, declare it as the first argument of your function.

use vm::Ctx;

#[mimas]
fn with_ctx(ctx: Ctx) {
    // makes a new array within the GC
    let _array = ctx.new_array(vec![]);

    // intern a string to the GC
    ctx.intern("foobar");
}

Because of the automatic marshalling of values it’s rare to need access to Ctx for the purposes shown above. You’re more likely to use it for accessing fixtures: per-Vm storage that lets your mimas code reach important parts of your program.

Consider a scenario where mimas needs access to data that is collected at bootup of its host program.

// rust
#[mimas]
struct Request(String);

fn main() {
    let user_request = Request(std::env::args().nth(1).unwrap());
    let vm = mimas::compile_source(some_source).unwrap();
}

#[mimas]
fn process_request() {
    // how do I get that request?
}

To achieve this we can use a fixture: a value the Vm owns that both sides – the host outside, and your native functions inside – can reach.

Fixtures have two rules that shape how you use them:

  1. You never hand the Vm a value. A fixture is built by the Vm, in place, via Default, the first time either side asks for it. There is no insert or replace – just vm.fixture::<T>() from the host and ctx.fixture::<T>() from a native, both returning a reference to the same instance.
  2. You only ever get a shared reference. To put data into a fixture after the fact, the fixture type itself provides interior mutability – a RefCell and a setter method.

Which means our Request doesn’t become the fixture directly; instead we give the Vm a slot that holds one:

// rust
use std::cell::RefCell;

#[mimas]
#[derive(Clone)]
struct Request(String);

#[derive(Default)]
struct CurrentRequest(RefCell<Option<Request>>);

impl CurrentRequest {
    fn set(&self, request: Request) {
        *self.0.borrow_mut() = Some(request);
    }

    fn get(&self) -> Option<Request> {
        self.0.borrow().clone()
    }
}

fn main() {
    let user_request = Request(std::env::args().nth(1).unwrap());
    let mut vm = mimas::compile_source(some_source).unwrap();

    // first access creates the (empty) fixture; `set` fills it
    vm.fixture::<CurrentRequest>().set(user_request);
    vm.run().unwrap();
}

#[mimas]
fn process_request(ctx: Ctx) -> Option<Request> {
    // here it is!
    ctx.fixture::<CurrentRequest>().get()
}
// mimas
let request = process_request()!;
print(request);

A few things worth noticing:

  • vm.fixture and ctx.fixture reach the same slot – the host fills it before run, the native reads it during.
  • process_request returns Option<Request>, which mimas sees as Request? – the script decides what to do when no request was installed (here it just unwraps with !).
  • This is exactly how the standard library’s std::sys::arg works: the CLI fills a ScriptArgs fixture at startup, and the native reads it back per call.

What about &mut state?

A fixture must be 'static – it can hold owned data, but not a borrow of your program’s state. Lending borrows into the Vm for the duration of a run requires something else: a FreezeCell fixture. That’s what comes next!

Freeze

Fixtures must own their data. The Vm holds them for its whole life, so everything inside must be 'static. This is directly against a very common pattern for a scripting layer: getting mutable access to the wider program in the middle of a frame. That’s what the Freeze trait is for.

A FreezeCell is an empty slot that holds a borrow for the duration of a single closure and takes it back the moment that closure ends. While the borrow is in the cell, natives can reach it. The instant your frame is over, it’s gone.

Let’s wire up an example similar to what we did on the Fixtures page, only this time, let’s say that our script needs to not just read information from the host but write it too.

// rust
use mimas::vm::freeze::{Freeze, FreezeCell};

struct Database(Vec<String>);

type DatabaseCell = FreezeCell<Freeze![&'freeze mut Database]>;

Freeze![&'freeze mut Database] reads as “this cell holds a &mut Database for some lifetime we’ll call 'freeze.” The cell itself is 'static and starts empty, which is exactly what makes it a legal fixture.

// rust
fn main() {
    let mut database = Database(Vec::new());
    let mut vm = mimas::compile_source(SOURCE).unwrap();
    vm.run().unwrap();

    let cell = vm.fixture::<DatabaseCell>();
    cell.freeze(&mut database, || vm.call_fn("call_me").unwrap());

    assert_eq!(database.0.first(), Some(&"hello!".to_string()));
}

Now any native can add to the database, and our script can use it like anything else:

// rust
#[mimas]
fn add_entry(ctx: Ctx, entry: String) {
    ctx.fixture::<DatabaseCell>()
        .with_mut(|database| {
            database.0.push(entry);
        })
        .unwrap();
}
// mimas
fn call_me() {
    add_entry("hello!");
}

A few things worth noticing:

  • The fixture is the cell, not the Database. The Database never enters the Vm and never needs Default – the cell is built empty on first access, which is exactly the state it should be in between frames.
  • with_mut returns a Result because the cell might be empty: a native that runs outside any freeze scope gets an error instead of stale data. add_entry just unwraps it, which is a plain panic of our own choosing; if some of your natives can legitimately run outside a frame, surface a runtime error instead.
  • vm.fixture hands back a handle that is deliberately not borrow-tied to the Vm – that’s what lets us hold cell while calling vm.call_fn (a &mut borrow) inside the closure. The cost of that freedom is one rule: drop the handle before the Vm. Declaring it after the vm, like above, gets you that for free.
  • Need to lend more than one thing per frame? Cells nest: a.freeze(x, || b.freeze(y, || ...)).

Info

The freeze pattern comes from Catherine West: it originates in piccolo’s freeze module, and our implementation follows the slimmer form another of her projects, fabricator.

A complete, runnable example of this pattern lives at examples/game-loop.

Advanced Usage

The mimas macro has two jobs: it translates your item into a format that can satisfy the type requirements of API installation, and it automatically performs that installation for you. In this section we’ll break down how to do the first part without the second so that you can handle installation manually.

The first reason you may want to approach things this way is if you are using multiple crates to build/use your API – see why here.

The second reason is to have more flexibility in how your item is installed. Some registrations can’t be inferred from the item alone: methods on the core types, function overloads, and custom names all require manual control.

The native macro

#[native] is the conversion half of #[mimas]. It rewrites your function into the shape the VM can call – everything from Working With Types applies, including Ctx, the borrow shapes, and anon, but no registration happens; the function just sits there waiting for you to install it.

// rust
use mimas::native;

#[native]
fn shout(s: &str) -> String {
    format!("{}!", s.to_uppercase())
}

Installer functions

Installation happens through an installer: a function handed an Api that registers everything your program offers. You pass it to the Vm at compile time, and the usual pattern is one installer that pulls in the standard library and then your own:

// rust
use mimas::Api;

fn installer(api: &mut Api) {
    mimas::library::std(api);
    my_library(api);
}

fn my_library(api: &mut Api) {
    api.add_method(shout);
    // ... the rest of your program's API
}

fn main() {
    let mut vm = mimas::Vm::compile(r#"print("hey".shout());"#, installer).unwrap();
    vm.run().unwrap(); // > HEY!
}

A few things worth noticing:

  • The standard library only arrives if you ask for it – that’s the mimas::library::std(api) line, and you almost always want it first. (mimas::compile_source is nothing more than Vm::compile with library::std alone as the installer.)
  • Going manual doesn’t turn the magic off. Any mimas-tagged items that made it into your binary still install themselves, interleaved with your installer: tagged types first, then your installer, then tagged functions. That ordering means your installer can reference #[mimas] types, and #[mimas] functions can reference yours – the two styles mix freely.

The Api surface

Everything an installer can do:

CallInstallsmimas name
api.add(f)A function in the preludeThe Rust ident
api.add_named("name", f)A function in the prelude"name"
api.add_method(f)A method on its first parameter’s typeThe Rust ident
api.add_method_named("name", f)A method, overloadable"name"
api.add_assoc(ty, f)An associated function on tyThe Rust ident
api.add_adt::<T>()A derived struct or enumThe type’s name
api.constant("NAME", ty, value, doc)A constant in the prelude"NAME"
api.assoc_constant(ty, "NAME", ...)An associated constant on ty"NAME"
api.module("a::b").add(f)A function, type, or constant inside a moduleAs above

api.module returns a scoped handle, so module placement is one extra call rather than a different API: api.module("game").add(spawn) is the manual spelling of #[mimas(game)]. Methods and associated functions take no module – they belong to their receiver.

Struct/Enum Conversion

#[mimas] on a type is just an alias for our derive macros, MimasStruct and MimasEnum, plus an automatic add_adt. Done by hand:

// rust
use mimas::{MimasStruct, native};

#[derive(MimasStruct)]
struct Player {
    name: String,
    health: i64,
}

#[native]
fn spawn(name: String) -> Player {
    Player { name, health: 100 }
}

fn installer(api: &mut Api) {
    mimas::library::std(api);
    api.add_adt::<Player>();
    api.add(spawn);
}

Order matters

A type must be registered before anything that mentions it: add_adt::<Player>() has to run before add(spawn), or installation panics. The macro’s deferred registration handles this ordering for you (types always install first); by hand, the ordering is yours to keep.

Methods on core types

api.add_method attaches a function to whatever type its first parameter is – that parameter becomes the receiver. This is the only way to put methods on the core types, and it’s how the entire standard library is built:

// rust
#[native]
fn abs(n: i64) -> i64 {
    n.abs()
}

api.add_method(abs);
// mimas
print((-5).abs()); // > 5

The receiver follows the conversion table like any other parameter, so the borrow shapes pick which type you’re attaching to: a first parameter of &[anon::T<'gc>] makes an array method, &DictMap<'gc> a dict method, and a #[derive(MimasStruct)] type works too (though for your own types, #[mimas] impl is the comfortable path).

Associated functions

Associated functions have no self parameter to introspect, so you name the receiver yourself with a Ty:

// rust
use mimas::Ty;

#[native]
fn roll(sides: i64) -> i64 { /* ... */ }

api.add_assoc(Ty::Int, roll);
// mimas
print(int::roll(20));

For your own registered types, api.ty_of::<Player>() hands back the Ty to pass – just make sure add_adt has already run. That said, you’ll rarely need it: if your goal is associated functions on your own type, tagging an impl block gets you there without any of this.

Overloading

Registering multiple functions under one method name with add_method_named creates an overload, dispatched by the shape of the receiver. This is, for example, how max works on both [int] and [float]:

// rust
#[native]
fn max_int(arr: &[i64]) -> Option<i64> {
    arr.iter().max().copied()
}

#[native]
fn max_float(arr: &[f64]) -> Option<f64> {
    arr.iter().copied().reduce(f64::max)
}

api.add_method_named("max", max_int);
api.add_method_named("max", max_float);
// mimas
print([1, 5, 3].max()!);     // > 5
print([1.0, 2.5].max()!);    // > 2.5

At each call site the compiler unifies the actual receiver against every candidate’s receiver shape and requires exactly one winner: no match is an ordinary type mismatch, more than one is an ambiguity error. That’s why the typed-element shapes matter here – &[i64] registers the precise shape [int], which is what gives the compiler something to choose by. An &[anon::T<'gc>] candidate matches every array, so it can’t share a name with anything else.