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.
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,
};
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]
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
| mimas | 1.05s |
|---|---|
| luau | 1.16s |
| fabricator | 2.97s |
| rune | 3.48s |
| koto | 7.89s |
| dyon | 8.68s |
| boa | 10.01s |
| steel | 12.30s |
| rhai | 14.46s |
| rustpython | 16.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
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!
- 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] 2 │ 3 │ ╭─▶ 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
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
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 expression – if, 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
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.
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
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
| mimas | 656ms |
|---|---|
| luau | 869ms |
| rune | 3.34s |
| rhai | 4.52s |
physics struct field access, float math
| mimas | 1.06s |
|---|---|
| luau | 1.15s |
| rune | 3.51s |
| rhai | 14.44s |
mandelbrot scalar float throughput, tight loops
| luau | 641ms |
|---|---|
| mimas | 940ms |
| rune | 1.52s |
| rhai | 7.20s |
prime numbers array indexing, tight integer loops
| luau | 544ms |
|---|---|
| mimas | 620ms |
| rune | 3.55s |
| rhai | 6.10s |
fibonacci function-call overhead, recursion
| luau | 737ms |
|---|---|
| mimas | 951ms |
| rune | 1.94s |
| rhai | 8.77s |
eval enum match dispatch, recursion
| luau | 622ms |
|---|---|
| mimas | 1.28s |
| rune | 1.88s |
| rhai | 70.73s |
collections dict insert + lookup, string keys
| luau | 360ms |
|---|---|
| mimas | 417ms |
| rune | 3.08s |
| rhai | 15.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 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”.
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.
| program | lines | compile |
|---|---|---|
| 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.
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
| Page | Covers |
|---|---|
| Variables & Constants | let, const, and shadowing |
| Primitive Types | int, float, str, and bool |
| Operators | Arithmetic, comparison, logic, and bit operations |
| Blocks & Scope | Blocks are expressions; what a scope can see |
| Unit & Never Types | (), !, and what “no value” means |
| Control Flow | if and if let, match, the three loops, and collect |
| Collections | Arrays, dictionaries, tuples, and the in operator |
| Options | T?, null, and the operators that handle them |
| Results & Error Handling | T!, raise, and absolve |
| Functions & Closures | Declarations, defaults and named arguments, closures |
| User-Defined Types | Structs, enums, and impl blocks |
| Pacts | Abstracting over types without generics |
| Privacy | pub and the module boundary |
| Modules | Declaring, nesting, and importing |
| Scripts | No main; files run top to bottom |
| Memory | Garbage collection and its limits |
| Notable Exclusions | What 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
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();
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
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
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"
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
| Name | Symbol | Types | Behavior |
|---|---|---|---|
| Add | + | int, float, str | Adds two numbers, or concatenates two strs. |
| Subtract | - | int, float | Subtracts. |
| Multiply | * | int, float | Multiplies. |
| Divide | / | int, float | Divides. Always yields a float. |
| Truncating divide | ~/ | int, float | Divides and truncates toward zero, keeping the operand type. |
| Modulo | % | int, float | Remainder 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"
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.
| Name | Symbol | Types |
|---|---|---|
| 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
| Name | Symbol | Behavior |
|---|---|---|
| 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.
| Name | Symbol | Behavior |
|---|---|---|
| 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 bothxandy). - 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.
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 with | evaluates to |
|---|---|
break value | the 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
| Iterable | Each binding is |
|---|---|
[T] (array) | an element, T |
~{V} (dict) | a (str, V) pair of key and value |
str | each character, as a one-character str |
int | the 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
}
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.
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;
};
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
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
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) |
str | x 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:
raiseis only legal inside a function whose return type is a result (T!). The value you raise must be astr– the only error type in0.1.0.- A function returning
T!can hand back a bareT; it’s automatically wrapped as the success case. (That’s whyn!above – anint– is a validint!return.) raiseitself has the never type, so it composes insideifandmatcharms 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">"</span><span class="hljs-string">8080</span><span class="hljs-string">"</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();
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
}
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
Playerhas a name, a score, and a position. - Enums – sum types. An enum is exactly one of several shapes: a
Shapeis 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).
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
}
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;
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}!"
}
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.
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.
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.
| Page | Covers |
|---|---|
| Basics | The mimas macro: functions, constants, types, and modules |
| Working With Types | How Rust types map to mimas types, and what each costs |
| Runtime Errors | The ways a native function can fail, recoverably or not |
| Fixtures & Context | How to communicate data from the host into the native functions you provide |
| Freeze | Taking Fixtures a step further and enabling mutable access to the host. |
| Advanced Usage | Registering 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
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.
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:
-
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 -
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.
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 type | mimas type | Notes |
|---|---|---|
bool | bool | |
i8 through i64, isize, u8 through u64, usize | int | Range-checked at the boundary (see below) |
f32, f64 | float | f32 narrows silently (see below) |
String, &str, Str | str | |
() | () | () 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 enum | That type | |
anon::T (and U, V, W) | A type linked per call site | See below |
Array, Dict | The corresponding value | Zero-copy handles to the live value. |
Val | Any value | Removes 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.
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 shape | Access |
|---|---|
&[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 |
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.
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.
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:
- 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 – justvm.fixture::<T>()from the host andctx.fixture::<T>()from a native, both returning a reference to the same instance. - You only ever get a shared reference. To put data into a fixture after the fact, the fixture type itself provides interior mutability – a
RefCelland 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.fixtureandctx.fixturereach the same slot – the host fills it beforerun, the native reads it during.process_requestreturnsOption<Request>, which mimas sees asRequest?– 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::argworks: the CLI fills aScriptArgsfixture at startup, and the native reads it back per call.
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. TheDatabasenever enters the Vm and never needsDefault– the cell is built empty on first access, which is exactly the state it should be in between frames. with_mutreturns aResultbecause the cell might be empty: a native that runs outside any freeze scope gets an error instead of stale data.add_entryjustunwraps 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.fixturehands back a handle that is deliberately not borrow-tied to the Vm – that’s what lets us holdcellwhile callingvm.call_fn(a&mutborrow) inside the closure. The cost of that freedom is one rule: drop the handle before the Vm. Declaring it after thevm, like above, gets you that for free.- Need to lend more than one thing per frame? Cells nest:
a.freeze(x, || b.freeze(y, || ...)).
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_sourceis nothing more thanVm::compilewithlibrary::stdalone 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:
| Call | Installs | mimas name |
|---|---|---|
api.add(f) | A function in the prelude | The Rust ident |
api.add_named("name", f) | A function in the prelude | "name" |
api.add_method(f) | A method on its first parameter’s type | The Rust ident |
api.add_method_named("name", f) | A method, overloadable | "name" |
api.add_assoc(ty, f) | An associated function on ty | The Rust ident |
api.add_adt::<T>() | A derived struct or enum | The 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 module | As 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);
}
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.