Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

mimas

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

mimas repl

Runs with the mimas standard library with certain elements removed (i.e.: std::fs)

github version built with Rust license ci

Typed

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

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

Flexible

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

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

Fast

Stay fast. Static analysis fuels the VM, putting mimas at the front of the pure-Rust scripting runtimes. See the benchmarks for more.

physics struct field access, float math

mimas1.01s
fabricator3.13s
rune3.33s
boa5.00s
koto8.12s
dyon8.98s
steel9.83s
rhai13.83s
rustpython15.34s

Pure-Rust runtimes, measured on an AMD Ryzen 7 9800X3D running Linux. mimas v0.3.0 · Rhai (perf) v1.26.0 · Rune v0.14.2 · Koto v0.16.1 · Dyon v0.51.2 · Steel v0.8.3 · Boa v0.22.0 · RustPython v0.5.0 · Fabricator git cef73ca

Extendable

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

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

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

Robust

  • Guaranteed “Results” – mimas treats any panic as a bug, both in the compiler and the VM.
  • Tested top to bottom – over 2,200 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

No, but like, why ‘mimas’?

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

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

Tour

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

Variables

See Variables & Constants.

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

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

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

Primitive types

See Primitive Types.

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

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

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

Operators

See Operators.

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

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

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

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

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

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

// Finally, every binary op has a compound form
let count = 10;
count += 1;
count ~/= 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

See Results & Error Handling.

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

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

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

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

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

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

*typed errors are coming

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

Functions & closures

See Functions & Closures.

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

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

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

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

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

Structs

See Structs.

struct Player { name: str, score: int }

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

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

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

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

Enums

See Enums.

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

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

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

Pacts

See Pacts.

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

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

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

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

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

Modules & scripts

See Modules and Scripts.

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

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

const INTERNAL = 0;          // private by default
pub const RED = 0xff0000;    // `pub` exposes it across modules
pub fn mix(a: int, b: int) -> int {
    // elided
    a
}
// game.mim -- a script, which sees the modules beside it
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 home page measures mimas against the other pure-Rust scripting runtimes. Here we’ll narrow down with the intention of showing the most likely alternative options one would use against mimas.

Rune and Rhai are the pure-Rust points of comparison: Rune is the quickest of them after mimas, and Rhai is feature-rich and by far the most widely used in the space. Luau (through mlua) is is the quickest thing you can embed in a Rust program, full stop. However, unlike the others, it is written in C++.

strings string formatting, builtin methods

mimas586ms
luau852ms
rune2.84s
rhai4.19s

physics struct field access, float math

mimas1.01s
luau1.19s
rune3.33s
rhai13.83s

mandelbrot scalar float throughput, tight loops

luau656ms
mimas961ms
rune1.62s
rhai7.72s

prime numbers array indexing, tight integer loops

luau548ms
mimas624ms
rune3.28s
rhai5.88s

fibonacci function-call overhead, recursion

luau748ms
mimas943ms
rune2.00s
rhai7.63s

eval enum match dispatch, recursion

luau627ms
mimas1.26s
rune1.86s
rhai64.26s

collections dict insert + lookup, string keys

luau356ms
mimas416ms
rune2.87s
rhai3.37s

mimas v0.3.0 · Rune v0.14.2 · Rhai (perf) v1.26.0 · Luau (mlua) v0.11.4 -- measured on an AMD Ryzen 7 9800X3D running Linux. Bars are scaled within each test. Results slower than 5x of the fastest result are faded and not factored into the scaling.

What these actually reflect

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

Why stop at bytecode?

The next step down from mimas’s current bytecode VM would be JIT compilation using something like Cranelift. A JIT has to write fresh machine code into memory at runtime, 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.

Pulley remains an option that could improve performance further, which will be later explored.

Compile speed

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

programlinescompile
a small module~100< 1 ms
a project~10,000~16 ms
a large project~100,000~0.20 s

Measured with `mimas build` on the same machine, 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 (which needs hyperfine), and the compile-speed table with benchmarks/compile-bench.sh. cargo bench -p mimas runs our own 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

mimas check my_script.mim   # parse + type-check
mimas run my_script.mim     # execute (or just `mimas my_script.mim`)
mimas run my_project        # runs the project's one script, with its modules
mimas my_project            # running with no subcommand defaults to `run`
mimas repl                  # runs the repl
mimas                       # also runs the repl

Rust Projects

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

[dependencies]
mimas = "0.3.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 Server

mimas ships a language server to use in the editor of your choice. It is still very young but offers diagnostics, hover, navigation, rename, outlines, and inlay hints for scripting use. Scripts that use a Rust host’s functions and types are checked against that host’s API once it has run – see Host APIs.

What it supports

Diagnostics

Syntax and type errors are all reported inline in your editor. The lexer and parser are resilient to errors and will offer a full report of any syntax errors your code contains. The same is not true of today’s solver, which halts the first time it encounters an error. This means that you can get multiple syntax errors at once, but can only get one type error at a time (the same is true of the compiler however you use it). This will be improved in future releases.

Hover

Hovering a name shows what it is and what type it has. If its declaration has a /// doc comment, that shows underneath. For a function or type a Rust host registers, that’s the /// on the Rust item.

You hoverYou get
a local, parameter, or fieldx: int
a function or methodthe owner on one line, the declaration on the next
a struct or enumits qualified name, like one::two::Foo
a constantconst SCALE: int
a modulemodule `fs`
anything elsethe expression’s type

Functions carry where they came from, which matters once a project has modules:

// hovering `bar` anywhere it is used or declared
one::two::Foo
fn bar(self, fizz: int) -> Self

Go to definition

Any name that resolves to a declaration in your project jumps to it, across files. That covers locals, parameters, struct fields, functions, methods, structs, enums, and constants.

Find references

The same names work in reverse: ask for a declaration’s references and you’ll get every use of it in the project, wherever it lives. Resting your cursor on a name also highlights its other uses in the file you’re reading.

Rename

Renaming rewrites a name everywhere it appears in the project. Renaming a member of a pact carries every implementation with it, so the two can’t drift apart.

Outline

Your editor’s outline, breadcrumbs, and symbol search list what a file declares – types with their fields and variants, impl blocks with their methods, and a script’s top-level bindings. This is the one feature that only needs the syntax tree, so it keeps working while the rest of the file doesn’t.

Inlay hints

A let written without an annotation shows the type that was inferred for it.

let count = items.len(); // your editor can render this as `let count: int = ...`

Projects

Each file is checked as part of its project, the same one mimas run and mimas check use, so a script sees every module in it.

What it does not support

  • The solver is not resilient. This means that any error will cut off type information and you’ll be left with only that error in the editor. Everything but diagnostics and the outline goes quiet until the project checks cleanly again, which you’ll notice most while mid-keystroke.
  • No completion, formatting, signature help, or code actions. These are the obvious next steps, but none of them exist today.
  • Navigation stops at the language boundary. Natives from the standard library or a host, builtin types like int, and module names have no mimas source to jump to, so nothing happens. We will support navigating to the Rust definition in the future.

Setting it up

The server

The VS Code extension bundles the server, so skip this step if that’s your editor. Otherwise, install the lsp via cargo:

cargo install mimas-lsp

That puts a mimas-lsp binary on your PATH, which is where editors will look for it by default.

The extension

Install mimas from the Visual Studio Code marketplace. Each release also attaches the extension’s .vsix files to its GitHub release, which you can install by opening the command palette and selecting “Install from VSIX”. Its source lives in tools/vscode.

Other editors

Any LSP client works. The server speaks the protocol over stdio, takes no arguments, and wants to be started for the mimas language on .mim files. In Neovim, for example:

vim.lsp.config.mimas = {
    cmd = { "mimas-lsp" },
    filetypes = { "mimas" },
    root_markers = { "Cargo.toml", ".git" },
}
vim.lsp.enable("mimas")

Host APIs

When a Rust host embeds mimas, its scripts use the functions, types, and constants it registers. The server checks them against the manifest the host writes, found the way Cargo Projects describes. Until the host has written one, or when it’s from another version of mimas, a warning at the top of every file in the project says so and the server falls back to the standard library alone. After you change the host’s API, run it again, and diagnostics pick up the change on your next edit in that project.

In VS Code, the mimas.apiPath setting points the server at one file for every script (relative to the workspace folder), or turns it off with off, which leaves only the standard library. The mimas: Select Host API Manifest command sets it with a file dialog, and mimas: Restart Language Server restarts the server, which also happens on its own when the setting changes. Other editors pass the same value as apiPath in the server’s initialization options, where a relative path starts from the folder the editor starts the server in. In Neovim, add it to the config above:

init_options = { apiPath = "path/to/api.json" },

Language Reference

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

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

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

Variables & Constants

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

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

a = 10;     // reassignment is fine

Inference does the heavy lifting

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

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

Reassignment

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

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

Shadowing

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

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

Let else

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

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

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

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

Unused bindings

Prefixing a name with _ marks it as intentionally unused.

let _scratch = compute();

No warnings yet

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

Constants

Constants are declared with const and cannot be reassigned.

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

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

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

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

Todo

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

Primitive Types

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

Integers

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

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

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

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

Floats

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

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

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

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

Division always yields a float

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

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

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

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

Booleans

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

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

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

Strings

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

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

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

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

Index a string with an int to read the character at that position. Positions count characters rather than bytes, so they line up with len() and with for c in s. Reading past the end is a runtime error, and since strings are immutable, assigning into an index is a compile error.

let word = "café";
let first = word[0];              // -> "c"
let last = word[word.len() - 1];  // -> "é"
word[0] = "C";                    // compile error: strings can't be assigned into

Indexing a string that is entirely ASCII is constant-time; a string holding any other characters is scanned from the start.

Interpolation (f-strings)

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

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

To write a literal brace, double it:

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

String methods chain

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

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

Operators

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

Arithmetic

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

int + float promotes; / always floats

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

Comparison

Comparisons produce a bool. Ordering (<, <=, >, >=) is defined for numbers and for str. Strings are ordered by their UTF-8 bytes (relying on ordinary Rust string ordering comparisons).

NameSymbolTypes
Equal==any matching pair
Not equal!=any matching pair
Less / less-or-equal< <=int, float, str
Greater / greater-or-equal> >=int, float, str
print(2 < 3);            // true
print("hi" == "hi");     // true
print("apple" < "pear"); // true
print("Zoo" < "apple");  // true -- uppercase sorts first
print("a" < 1);          // compile error: these types cannot be compared

Logical

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

Bitwise

Bitwise operators work on int.

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

Null coalescing

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

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

Assignment

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

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

Blocks & Scope

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

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

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

Scope

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

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

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

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

The Unit & Never Types

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

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

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

Unit – ()

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

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

Never – !

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

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

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

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

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

Control Flow

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

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

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

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

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

If & If Let

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

if ready {
    launch();
}

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

As an expression

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

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

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

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

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

If let

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

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

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

Match

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

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

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

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

Patterns

mimas supports a wide range of patterns:

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

A few things to keep in mind:

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

Exhaustiveness

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

enum Flag { On, Off }

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

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

You have two ways to deliberately not enumerate every case.

A catch-all arm

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

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

The ! terminator

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

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

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

Guards don’t count toward exhaustiveness

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

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

Loop

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

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

Loops are expressions

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

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

The result type depends on how the loop ends:

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

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

While

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

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

Breaking a value

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

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

While let

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

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

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

For

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

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

What you can iterate

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

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

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

Need the index?

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

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

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

Breaking a value

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

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

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

Mutation during iteration

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

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

Collect

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

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

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

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

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

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

Add an if to filter which values get collected:

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

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

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

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

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

Collections

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

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

Arrays

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

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

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

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

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

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

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

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

Dictionaries

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

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

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

Keys are strings

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

Indexing returns an option

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

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

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

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

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

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

Iterating

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

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

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

Tuples

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

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

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

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

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

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

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

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

Tuple vs. tuple struct

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

The in Operator

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

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

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

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

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

Options

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

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

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

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

An option is not its inner type

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

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

Comparing options

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

let maybe: int? = null;

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

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

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

Creating options

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

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

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

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

Flattening

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

fn maybe() -> int? { null }

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

Option chaining

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

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

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 with a ? on the binding to ask for the unwrap:

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"); // runtime error: halts the VM

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

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

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

Results

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

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

A few rules govern results:

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

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


## Handling a result

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

### Unwrap with `!`

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

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

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

Recover with absolve

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

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

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

Functions & Closures

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

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

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

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

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

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();

Handing a function to the host

A native can take a function or a closure, keep it, and call it later. A host that offers one registers whatever it wants run, rather than the script having to declare functions under names the host already knows.

fn on_tick(n: int) { print(f"tick {n}"); }

every_tick(on_tick);                  // a function by name
every_tick(Counter::bump);            // a method, `self` filled like any parameter
every_tick(|n: int| { print(n); });   // or a closure

Whether a native does anything with a function you give it is up to the host. Writing that side is covered in Function Values.

Closures vs. functions

The two differ in two important ways.

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

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

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

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

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

let greeting = "hello!";

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

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

Functions live at the top level

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

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

User-Defined Types

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

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

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

Structs

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

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

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

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

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

Methods and associated items

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

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

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

impl Player {
    const MAX_SCORE = 100;

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

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

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

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

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

Reaching items through a value

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

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

Tuple structs

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

struct Vec2(float, float)

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

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

struct Wrap(int)

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

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

struct Marker;

Enums

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

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

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

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

An enum is always one of its variants

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

Matching on variants

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

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

Methods

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

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

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

Pacts

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

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

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

pact Draw {
    fn draw(self);
}

struct Square;
impl Draw for Square {
    fn draw(self) { print("a square"); }
}

struct Circle;
impl Draw for Circle {
    fn draw(self) { print("a circle"); }
}

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

render_all([Square, Circle]);

A collection literal holding several types settles on the pacts they share, which is how you build “a list of things that implement Draw” without generics. If the elements have no pact in common, it’s an ordinary type mismatch.

Constants

A pact can require constants as well as methods.

pact Named {
    const NAME: str;
}

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

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

Constants are read off a concrete type. Unlike methods, they don’t dispatch – see Limitations below.

Default implementations

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

pact Greet {
    fn name(self) -> str;

    fn hello(self) -> str {
        f"hi, {self.name()}"
    }
}

struct Dog { tag: str }
impl Greet for Dog {
    fn name(self) -> str { self.tag }
    // `hello` is inherited
}

print(Dog { tag = "rex" }.hello()); // "hi, rex"

A default body can call the pact’s other methods through self, including ones the implementer supplies. An impl that defines the method itself overrides the default.

Self

Inside a pact Self is the implementing type. A method that works on two values of the same type spells the second one Self, and each implementer puts its own type there:

pact Plus {
    fn plus(self, other: Self) -> Self;
}

struct V { x: int }
impl Plus for V {
    fn plus(self, other: V) -> V {
        V { x = self.x + other.x }
    }
}

struct W { y: int }
impl Plus for W {
    fn plus(self, other: W) -> W {
        W { y = self.y + other.y }
    }
}

V { x = 1 }.plus(V { x = 2 }); // V { x = 3 }
V { x = 1 }.plus(W { y = 2 }); // compile error: expected Self but found W

Self is not the same as writing the pact’s name. other: Plus means any implementer at all; other: Self means the receiver’s own type, and an impl has to name exactly that type – impl Plus for V can’t take other: W. Inside a default body, self has type Self, so it can be passed wherever the pact asks for Self, and also wherever it asks for the pact itself, since a Self is always an implementer.

Reaching pact methods through a value

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

fn announce(thing: Greet) {
    print(thing.hello());
}

Binding multiple pacts

An annotation can require several pacts at once with +:

struct Widget {
    item: Named + Greet,
}

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 + Greet)?,
}

Limitations

Pacts are the youngest part of the language. Each of the following is rejected with a clear error rather than silently misbehaving.

Constants don’t dispatch. A pact constant can only be read off a concrete type. Reaching one through a pact-typed value has no answer at runtime – every impl declares its own value, and the receiver’s concrete type isn’t known:

fn tag_of(thing: Named) -> str {
    thing.NAME // compile error: pact constant `NAME` can't be reached through `Named`
}

The workaround is to require a method instead, which does dispatch:

pact Named {
    fn name(self) -> str;
}

Methods that take Self can’t be called through a pact. Self in a parameter means “the same type as the receiver”, and a value known only by its pact doesn’t say what that is – a.plus(b) with both typed Plus could pair a V with a W. The call is rejected wherever the receiver is only known by its bound, even when the arguments happen to match:

fn combine(a: Plus, b: Plus) -> Plus {
    a.plus(b) // compile error: pact method `plus` can't be called through `Plus`
}

Call it on a concrete type instead (fn combine(a: V, b: V) -> V). The rule is per method, not per pact: the pact’s other methods stay callable through the bound, a Self that appears only in the return type is fine (the result comes back as the bound), and inside a default body self.plus(self) works because every Self there is the same type.

Self:: doesn’t resolve inside a default body. A default body is compiled once and shared by every implementer, so Self there is a type that hasn’t been decided yet, and there’s no single constant or associated function for Self:: to reach. Self::CONST and Self::assoc_fn() are both rejected inside one. Lowercase self – fields, methods, parameters – works normally, and Self:: resolves as expected inside an ordinary impl block.

No generics. A pact bound is the only form of abstraction over types. There are no type parameters, associated types, or blanket impls.

Privacy

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

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

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

Within a module, there are no walls

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

Private fields

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

// in another module:
let c = Config { name = "x", secret = "y" }; // compile error: `secret` is private to its module
let s = c.secret;                            // compile error: `secret` is private to its module

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

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

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

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

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

Modules

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

Declaring a module

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

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

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

Modules can nest by qualifying the path with :::

module graphics::colors;

Module files don’t run

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

Using a module

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

use colors;

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

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

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

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

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

Scripts

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

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

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

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

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

Scripts run; modules organize

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

Projects

A project is a directory. Its scripts and modules can sit anywhere in it, subdirectories included, without any setup. mimas run script.mim will compile all the modules in that script’s project and then execute its code. mimas run ./some/dir treats the directory as the project, and can be used if it has only one script file to choose from.

mimas run my_project           # runs the one script in my_project
mimas run my_project/game.mim  # or picks one of many

A script’s project is the highest folder holding a .mim file in the cargo package or git repository around it, along with every folder below that. A script outside of both only sees the .mim files next to it. Another cargo package or a cargo target directory is never part of a project. The language server follows the same rules.

Cargo Projects

A project inside a cargo package is usually run by that package’s Rust host, which can give its scripts functions, types, and constants of its own. By default, mimas has the export-api feature enabled. With this on, the first time your host compiles scripts, it writes a manifest of everything it installed (the standard library and your host’s own items) to target/mimas/<binary_name>.json. Each binary and example writes its own, so hosts sharing a target dir don’t overwrite each other. This only happens while the host runs from inside its cargo target dir, so shipped builds never write one, and neither do tests or benches. You can turn the automatic write off with default-features = false, and you’re always free to export it yourself with mimas::write_api (such as a copy you ship for modders):

let library = mimas::Vm::new().install_library(mimas::library::std);
mimas::write_api(&library, "scripts/api.json".as_ref())?;

mimas check, mimas build and the language server find the manifest on their own, checking a project’s scripts against the newest one written by a binary of its package. Until the host has run, they warn and fall back to the standard library alone. After you change your host’s API, run it again to update the manifest. A manifest only works with the version of mimas that wrote it.

Memory

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

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

Where the heavy lifting belongs

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

Running out of memory

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

Notable Exclusions

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

Pointers & references

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

Union types

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

Generics

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

A borrow checker

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

A let / mut split

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

Library Reference

This is the reference for everything mimas ships with. It lists each function, method, and constant, what it takes and returns, and how it behaves. For the language itself, see the Language Reference.

The standard library comes in three parts:

  • The prelude: functions like print and panic that are in scope in every script.
  • Methods on the built-in types: len on arrays, to_upper on strings, and so on. These are always available through dot syntax.
  • Modules under std: everything else, like file access and JSON parsing. Bring these in with use, such as use std::fs;.

The bevy page is separate from the standard library. It covers what the Bevy plugin adds for scripts in a Bevy app.

Prelude

ItemSummary
printWrites value to standard output, followed by a newline
panicStops the script with a runtime error
todoStops the script with a runtime error that marks unfinished code
dbgPrints value to standard output in its debug form, then returns it

Functions

print

fn print(value)

Writes value to standard output, followed by a newline. A string prints as its plain text, and any other value prints the same way it would inside an f-string.

print("hello");           // hello
print([1, 2, 3]);         // [1, 2, 3]
print(f"{1 + 2} apples"); // 3 apples

panic

fn panic(msg: T?) -> !

Stops the script with a runtime error. The error reads panic: followed by msg, or explicit panic when there’s no message.

panic never returns, and its never type lets it stand in for a value of any type:

let config = ~{ port = 8080 };
let port = config["port"] ?? panic("no port configured");

For a failure the caller should be able to recover from, return a result and raise instead.

todo

fn todo(msg: T?) -> !

Stops the script with a runtime error that marks unfinished code. The error reads todo: followed by msg, or just todo when there’s no message.

Like panic, it never returns, and it can stand in for a body you haven’t written yet:

fn load_save(path: str) -> ~{int} {
    todo("read the save format")
}

dbg

fn dbg(value: T) -> T

Prints value to standard output in its debug form, then returns it. The line starts with dbg value:, and strings keep their quotes.

Since it returns its argument, dbg can wrap any expression without changing the result:

let total = dbg(2 + 3) * 10; // prints `dbg value: 5`, and total is 50
let name = dbg("ada");       // prints `dbg value: "ada"`

Arrays

ItemSummary
newCreates an empty array
new_filledCreates an array of len elements, each a copy of value
lenReturns the number of elements
containsReturns whether any element equals value
pushAppends value to the end of the array
insertInserts value at index, moving the element there and everything after it one place toward the end
popRemoves the last element and returns it, or returns null if the array is empty
shufflePuts the elements in a random order
extendAppends every element of other, in order
enumerateReturns a new array pairing each element with its index
flatReturns a new array with the elements of each inner array, in order
chooseReturns a random element, or null if the array is empty
joinReturns the strings joined together with separator between each one
is_emptyReturns whether the array has no elements
maxReturns the maximum value present in the array
minReturns the minimum value present in the array
sumReturns the sum of all values in the array
sort_by_intSorts the array in ascending order of keys, where keys[i] is the key for element i
sort_by_floatSorts the array in ascending order of keys, where keys[i] is the key for element i
argsortCompares all elements in the array and returns a new array with the indicies sorted
reorderReplaces the array’s contents with the elements at indices, in that order
dedupedReturns a new array without repeated elements, keeping the first occurrence of each value in its original order
reversedReturns a new array with the elements in reverse order
to_dictReturns a dictionary built from (key, value) pairs, the reverse of a dictionary’s pairs

Associated Functions

new

fn new() -> [T]

Creates an empty array. This is the same as writing [], and like [] it needs a type annotation if nothing else tells the compiler what it will hold.

let xs: [int] = array::new();
xs.push(1); // xs is now [1]

new_filled

fn new_filled(value: T, len: int) -> [T]

Creates an array of len elements, each a copy of value. The copies are deep, so filling with an array gives each slot its own array.

let grid = array::new_filled([0, 0], 2);
grid[0].push(1);
// grid is [[0, 0, 1], [0, 0]]

Methods

len

fn len(self) -> int

Returns the number of elements.

let n = [3, 1, 2].len(); // 3

contains

fn contains(self, value: T) -> bool

Returns whether any element equals value. This is the same check as the in operator.

let xs = [1, 2, 3];
let a = xs.contains(2); // true
let b = 2 in xs;        // true

push

fn push(self, value: T)

Appends value to the end of the array.

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

insert

fn insert(self, index: int, value: T)

Inserts value at index, moving the element there and everything after it one place toward the end. An index equal to the array’s length appends.

An index that is negative or past the end is a runtime error.

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

pop

fn pop(self) -> T?

Removes the last element and returns it, or returns null if the array is empty.

let xs = [1, 2];
let last = xs.pop(); // 2, and xs is now [1]

shuffle

fn shuffle(self)

Puts the elements in a random order.

let deck = [1, 2, 3, 4];
deck.shuffle();

extend

fn extend(self, other: [T])

Appends every element of other, in order. other is unchanged.

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

enumerate

fn enumerate(self) -> [(int, T)]

Returns a new array pairing each element with its index.

for (i, name) in ["ant", "bee"].enumerate() {
    print(f"{i}: {name}"); // 0: ant, then 1: bee
}

flat

fn flat(self: [[T]]) -> [T]

Returns a new array with the elements of each inner array, in order. Only one level is removed: a [[[int]]] flattens to a [[int]].

let xs = [[1, 2], [], [3]].flat(); // [1, 2, 3]

choose

fn choose(self) -> T?

Returns a random element, or null if the array is empty.

let loot = ["sword", "shield", "potion"];
let drop = loot.choose() ?? "nothing";

join

fn join(self: [str], separator: str) -> str

Returns the strings joined together with separator between each one. Only [str] has join, so convert other arrays first:

let a = ["a", "b", "c"].join(", "); // "a, b, c"
let b = (for n in [1, 2, 3] collect n.to_str()).join("-"); // "1-2-3"

is_empty

fn is_empty(self) -> bool

Returns whether the array has no elements.

let xs: [int] = [];
let empty = xs.is_empty(); // true

max

fn max(self: [int]) -> int?
fn max(self: [float]) -> float?

Returns the maximum value present in the array.

let a: [int] = [0, 1, 2];
let int_max = a.max(); // 2

let b: [float] = [0.0, 1.0, 2.0];
let float_max = b.max(); // 2.0

min

fn min(self: [int]) -> int?
fn min(self: [float]) -> float?

Returns the minimum value present in the array.

let a: [int] = [0, 1, 2];
let int_min = a.min(); // 0

let b: [float] = [0.0, 1.0, 2.0];
let float_min = b.min(); // 0.0

sum

fn sum(self: [int]) -> int
fn sum(self: [float]) -> float

Returns the sum of all values in the array.

let a: [int] = [0, 1, 2];
let int_sum = a.sum(); // 3

let b: [float] = [0.0, 1.0, 2.0];
let float_sum = b.sum(); // 3.0

sort_by_int

fn sort_by_int(self, keys: [int])

Sorts the array in ascending order of keys, where keys[i] is the key for element i. Equal keys keep their original order. keys is unchanged.

let names = ["cat", "ant", "bee"];
names.sort_by_int([3, 1, 2]); // names is now ["ant", "bee", "cat"]

keys should be as long as the array. An element without a key is dropped from the array, and extra keys are ignored.

sort_by_float

fn sort_by_float(self, keys: [float])

Sorts the array in ascending order of keys, where keys[i] is the key for element i. Equal keys keep their original order. keys is unchanged.

let names = ["far", "near", "mid"];
names.sort_by_float([9.5, 0.5, 3.0]); // names is now ["near", "mid", "far"]

keys should be as long as the array. An element without a key is dropped from the array, and extra keys are ignored.

argsort

fn argsort(self: [int]) -> [int]
fn argsort(self: [float]) -> [int]

Compares all elements in the array and returns a new array with the indicies sorted. For example, if the maximum value in this array is at index 3, the first element of the returned array will be 3.

let a = [5, 0, 2, 4];
let a_sorted = [0, 3, 2, 1];

let b = [5.0, 0.0, 2.0, 4.0];
let b_sorted = [0, 3, 2, 1];

reorder

fn reorder(self, indices: [int])

Replaces the array’s contents with the elements at indices, in that order. The indices don’t have to cover every element once: the array ends up as long as indices, and an index can repeat.

An index that is negative or past the end is a runtime error, and the array is left unchanged.

let xs = ["a", "b", "c"];
xs.reorder([2, 0, 1]); // xs is now ["c", "a", "b"]
xs.reorder([0, 0]);    // xs is now ["c", "c"]

deduped

fn deduped(self) -> [T]

Returns a new array without repeated elements, keeping the first occurrence of each value in its original order. Elements are compared with ==, the same check as contains. Unlike Rust’s dedup, the repeats don’t have to be next to each other. The array itself is unchanged.

let xs = [3, 1, 3, 2, 1];
let ys = xs.deduped(); // [3, 1, 2]

reversed

fn reversed(self) -> [T]

Returns a new array with the elements in reverse order. The array itself is unchanged.

let xs = [1, 2, 3];
let ys = xs.reversed(); // [3, 2, 1]

for x in xs.reversed() {
    print(x); // 3, then 2, then 1
}

to_dict

fn to_dict(self: [(str, T)]) -> ~{T}

Returns a dictionary built from (key, value) pairs, the reverse of a dictionary’s pairs. When a key appears more than once, the dictionary holds its last value, in the position where the key first appeared.

let scores = [("ada", 3), ("bob", 5)].to_dict(); // ~{ ada = 3, bob = 5 }

let names = ["ada", "grace"];
let lengths = (for name in names collect (name, name.len())).to_dict();
// lengths is ~{ ada = 3, grace = 5 }

Dictionaries

ItemSummary
getReturns the value stored under key, or null if there isn’t one
get_or_insertReturns the entry under the key
lenReturns the number of entries
contains_keyReturns whether the dictionary has an entry for key
insertSets key to value and returns the value it replaced, or null if the key is new
removeRemoves the entry for key and returns its value, or returns null if there was no such entry
pairsReturns every entry as a (key, value) tuple, in the order the keys were first inserted

Methods

get

fn get(self, key: str) -> T?

Returns the value stored under key, or null if there isn’t one. This is the same as indexing with dict[key].

let ages = ~{ ada = 36 };
let a = ages.get("ada"); // 36
let b = ages.get("bob"); // null

get_or_insert

fn get_or_insert(self, key: str, default: T) -> T

Returns the entry under the key. If none is found, default is inserted at key and is returned instead.

let ages = ~{ ada = 36 };
let a = ages.get("ada"); // 36
let b = ages.get_or_insert("bob", 28); // 28

len

fn len(self) -> int

Returns the number of entries.

let n = ~{ a = 1, b = 2 }.len(); // 2

contains_key

fn contains_key(self, key: str) -> bool

Returns whether the dictionary has an entry for key. Unlike indexing, this tells a missing key apart from one whose value is null.

let slots: ~{int?} = ~{ a = null };
let a = slots.contains_key("a"); // true, though slots["a"] is null
let b = slots.contains_key("b"); // false

insert

fn insert(self, key: str, value: T) -> T?

Sets key to value and returns the value it replaced, or null if the key is new. A new key is added at the end of the iteration order. Replacing a value doesn’t move its key.

Assigning with dict[key] = value does the same without returning anything.

let stock = ~{ apples = 3 };
let old = stock.insert("apples", 5); // 3
let new = stock.insert("pears", 2);  // null

remove

fn remove(self, key: str) -> T?

Removes the entry for key and returns its value, or returns null if there was no such entry. The remaining entries keep their order.

let stock = ~{ apples = 3, pears = 2 };
let a = stock.remove("apples"); // 3, and stock is now ~{ pears = 2 }
let b = stock.remove("plums");  // null

pairs

fn pairs(self) -> [(str, T)]

Returns every entry as a (key, value) tuple, in the order the keys were first inserted.

let scores = ~{ ada = 3, bob = 5 };
let entries = scores.pairs(); // [("ada", 3), ("bob", 5)]

A for loop over the dictionary visits the same pairs without building an array:

for (name, score) in scores {
    print(f"{name} has {score}");
}

Floats

ItemSummary
floorReturns the largest whole number less than or equal to the number, as a float
to_strReturns the number as a string, using the fewest digits that still read back as the same float
ceilReturns the smallest whole number greater than or equal to the number, as a float
roundReturns the nearest whole number, as a float
to_intReturns the number as an int, dropping anything after the decimal point
absReturns the absolute value
maxReturns the larger of the number and other
minReturns the smaller of the number and other
sqrtReturns the square root
formatReturns the number as a string with exactly places digits after the decimal point
clampReturns the number moved into the range from low to high, inclusive
signumReturns the sign of the number as 1.0 or -1.0
sinReturns the sine of an angle in radians
cosReturns the cosine of an angle in radians
tanReturns the tangent of an angle in radians
asinReturns the arcsine in radians, between -PI / 2.0 and PI / 2.0
acosReturns the arccosine in radians, between 0.0 and PI
atanReturns the arctangent in radians, between -PI / 2.0 and PI / 2.0
atan2Returns the angle in radians from the positive x axis to the point (x, y), where y is the number this is called on
powReturns the number raised to the power exponent
expReturns E raised to the power of the number
lnReturns the natural logarithm (base E)
randomReturns a random float that is at least 0.0 and less than len

Methods

floor

fn floor(self) -> float

Returns the largest whole number less than or equal to the number, as a float.

let a = 2.7.floor();    // 2.0
let b = (-2.7).floor(); // -3.0

to_str

fn to_str(self) -> str

Returns the number as a string, using the fewest digits that still read back as the same float. A whole number has no .0. For a fixed number of decimal places, use format.

let a = 0.1.to_str();         // "0.1"
let b = 2.0.to_str();         // "2"
let c = (0.1 + 0.2).to_str(); // "0.30000000000000004"

ceil

fn ceil(self) -> float

Returns the smallest whole number greater than or equal to the number, as a float.

let a = 2.2.ceil();    // 3.0
let b = (-2.2).ceil(); // -2.0

round

fn round(self) -> float

Returns the nearest whole number, as a float. A number exactly halfway between two whole numbers rounds away from zero.

let a = 2.4.round();    // 2.0
let b = 2.5.round();    // 3.0
let c = (-2.5).round(); // -3.0

to_int

fn to_int(self) -> int

Returns the number as an int, dropping anything after the decimal point. Round first with round to get the nearest int instead.

let a = 2.9.to_int();         // 2
let b = (-2.9).to_int();      // -2
let c = 2.9.round().to_int(); // 3

abs

fn abs(self) -> float

Returns the absolute value.

let a = (-1.5).abs(); // 1.5

max

fn max(self, other: float) -> float

Returns the larger of the number and other.

let a = 1.5.max(2.5); // 2.5

min

fn min(self, other: float) -> float

Returns the smaller of the number and other.

let a = 1.5.min(2.5); // 1.5

sqrt

fn sqrt(self) -> float

Returns the square root. A negative number gives NaN.

let a = 9.0.sqrt(); // 3.0

format

fn format(self, places: int) -> str

Returns the number as a string with exactly places digits after the decimal point. The last digit is rounded, with a tie going to the even digit (2.5.format(0) is "2", but 3.5.format(0) is "4").

let a = 3.14159.format(2); // "3.14"
let b = 2.0.format(3);     // "2.000"

clamp

fn clamp(self, low: float, high: float) -> float

Returns the number moved into the range from low to high, inclusive. A number already in the range comes back unchanged.

let a = 1.5.clamp(0.0, 1.0);    // 1.0
let b = (-0.2).clamp(0.0, 1.0); // 0.0

signum

fn signum(self) -> float

Returns the sign of the number as 1.0 or -1.0. Zero counts as positive (0.0.signum() is 1.0), unless it’s -0.0.

let a = (-3.2).signum(); // -1.0
let b = 5.0.signum();    // 1.0

sin

fn sin(self) -> float

Returns the sine of an angle in radians. To work in degrees, multiply by PI / 180.0 first.

use std::math::PI;

let a = (PI / 2.0).sin();          // 1.0
let b = (90.0 * PI / 180.0).sin(); // 1.0

cos

fn cos(self) -> float

Returns the cosine of an angle in radians.

use std::math::PI;

let a = PI.cos(); // -1.0

tan

fn tan(self) -> float

Returns the tangent of an angle in radians.

let a = 0.0.tan(); // 0.0

asin

fn asin(self) -> float

Returns the arcsine in radians, between -PI / 2.0 and PI / 2.0. A number outside -1.0 to 1.0 gives NaN.

let a = 1.0.asin(); // 1.5707963267948966 (PI / 2.0)

acos

fn acos(self) -> float

Returns the arccosine in radians, between 0.0 and PI. A number outside -1.0 to 1.0 gives NaN.

let a = 1.0.acos(); // 0.0

atan

fn atan(self) -> float

Returns the arctangent in radians, between -PI / 2.0 and PI / 2.0. To get an angle from a point or direction, atan2 is usually the better choice.

let a = 1.0.atan(); // 0.7853981633974483 (PI / 4.0)

atan2

fn atan2(self, x: float) -> float

Returns the angle in radians from the positive x axis to the point (x, y), where y is the number this is called on. The result is between -PI and PI.

Unlike (y / x).atan(), it uses the signs of both coordinates to find the right quadrant, and an x of 0.0 is fine.

let a = 1.0.atan2(1.0);  // 0.7853981633974483 (PI / 4.0)
let b = 1.0.atan2(-1.0); // 2.356194490192345 (3.0 * PI / 4.0)

pow

fn pow(self, exponent: float) -> float

Returns the number raised to the power exponent.

let a = 2.0.pow(10.0); // 1024.0
let b = 9.0.pow(0.5);  // 3.0

exp

fn exp(self) -> float

Returns E raised to the power of the number.

let a = 1.0.exp(); // 2.718281828459045

ln

fn ln(self) -> float

Returns the natural logarithm (base E). 0.0 gives negative infinity, and a negative number gives NaN.

use std::math::E;

let a = E.ln();   // 1.0
let b = 1.0.ln(); // 0.0

Associated Functions

random

fn random(len: float) -> float

Returns a random float that is at least 0.0 and less than len.

len must be greater than 0.0.

if float::random(1.0) < 0.25 {
    print("a one in four chance");
}

Integers

ItemSummary
absReturns the absolute value
minReturns the smaller of the number and other
maxReturns the larger of the number and other
clampReturns the number moved into the range from low to high, inclusive
to_strReturns the number written out in base 10, with a leading - if it’s negative
to_floatReturns the number as a float
randomReturns a random int that is at least 0 and less than len

Methods

abs

fn abs(self) -> int

Returns the absolute value.

let a = (-7).abs(); // 7

min

fn min(self, other: int) -> int

Returns the smaller of the number and other.

let a = 3.min(8); // 3

max

fn max(self, other: int) -> int

Returns the larger of the number and other.

let a = 3.max(8);        // 8
let hp = (5 - 9).max(0); // 0

clamp

fn clamp(self, low: int, high: int) -> int

Returns the number moved into the range from low to high, inclusive. A number already in the range comes back unchanged.

let a = 15.clamp(0, 10);   // 10
let b = (-3).clamp(0, 10); // 0
let c = 4.clamp(0, 10);    // 4

to_str

fn to_str(self) -> str

Returns the number written out in base 10, with a leading - if it’s negative. An f-string does the same inside a larger string.

let a = (-42).to_str(); // "-42"
let b = f"{7} lives";   // "7 lives"

to_float

fn to_float(self) -> float

Returns the number as a float. Arithmetic that mixes int and float converts on its own, but passing an int where a float is expected doesn’t, and needs this first:

fn half(x: float) -> float {
    x / 2.0
}

let n = 3;
let a = half(n.to_float());  // 1.5
let b = n.to_float().sqrt(); // 1.7320508075688772

Associated Functions

random

fn random(len: int) -> int

Returns a random int that is at least 0 and less than len. That makes int::random(xs.len()) a random index into xs.

len must be greater than 0.

let roll = int::random(6) + 1; // 1 to 6

Strings

ItemSummary
lenReturns the number of characters
containsReturns whether needle appears anywhere in the string
starts_withReturns whether the string begins with prefix
ends_withReturns whether the string ends with suffix
to_upperReturns the string with every letter in uppercase
to_lowerReturns the string with every letter in lowercase, following Unicode’s rules
trimReturns the string without the whitespace at its start and end
to_snakeReturns the string in snake case: lowercase words joined by underscores
splitReturns the pieces of the string between each occurrence of separator
findSearches the string for the first match of the regular expression pattern
find_allReturns every match of the regular expression pattern, each in the form find returns
linesReturns the string’s lines, without their line endings
capitalizeReturns the string with its first character in uppercase
to_intParses the string as a base-10 int, or returns null if it isn’t one
ordReturns the Unicode code point of the first character, or null if the string is empty
is_emptyReturns whether the string has no characters
replaceReturns the string with every occurrence of needle replaced by replacement
repeatReturns the string repeated count times

Methods

len

fn len(self) -> int

Returns the number of characters. This counts Unicode characters, not bytes: "héllo".len() is 5.

let n = "hello".len(); // 5

contains

fn contains(self, needle: str) -> bool

Returns whether needle appears anywhere in the string. This is the same check as the in operator.

let a = "hello".contains("ell"); // true
let b = "ell" in "hello";        // true

starts_with

fn starts_with(self, prefix: str) -> bool

Returns whether the string begins with prefix.

let a = "mimas".starts_with("mi"); // true

ends_with

fn ends_with(self, suffix: str) -> bool

Returns whether the string ends with suffix.

let a = "readme.md".ends_with(".md"); // true

to_upper

fn to_upper(self) -> str

Returns the string with every letter in uppercase. This follows Unicode’s rules, which can turn one character into several.

let a = "Hello".to_upper();  // "HELLO"
let b = "straße".to_upper(); // "STRASSE"

to_lower

fn to_lower(self) -> str

Returns the string with every letter in lowercase, following Unicode’s rules.

let a = "Hello".to_lower(); // "hello"

trim

fn trim(self) -> str

Returns the string without the whitespace at its start and end. Whitespace inside the string stays.

let a = "  two words \n".trim(); // "two words"

to_snake

fn to_snake(self) -> str

Returns the string in snake case: lowercase words joined by underscores. Spaces, punctuation, and changes from lowercase to uppercase all mark where one word ends and the next begins.

let a = "PlayerHealth".to_snake(); // "player_health"
let b = "max hp".to_snake();       // "max_hp"
let c = "HTTPServer".to_snake();   // "http_server"

split

fn split(self, separator: str) -> [str]

Returns the pieces of the string between each occurrence of separator. Two separators in a row leave an empty string between them.

let a = "a,b,c".split(",");     // ["a", "b", "c"]
let b = "a,,b".split(",");      // ["a", "", "b"]
let c = "no commas".split(","); // ["no commas"]

find

fn find(self, pattern: str) -> [str]?

Searches the string for the first match of the regular expression pattern. Returns the whole match followed by the text of each capture group, or null if nothing matched or pattern isn’t a valid regular expression.

A capture group that didn’t take part in the match is left out of the array, which moves the groups after it down a position.

The pattern syntax is that of the Rust regex crate. Inside a mimas string, each backslash in the pattern is written twice ("\\d+" matches a run of digits).

let date = "2024-06-01".find("(\\d+)-(\\d+)-(\\d+)");
// date is ["2024-06-01", "2024", "06", "01"]
let none = "abc".find("\\d"); // null

find_all

fn find_all(self, pattern: str) -> [[str]]?

Returns every match of the regular expression pattern, each in the form find returns. Matches don’t overlap. The array is empty when nothing matched, and the result is null only when pattern isn’t a valid regular expression.

let pairs = "x=1, y=22".find_all("(\\w)=(\\d+)");
// pairs is [["x=1", "x", "1"], ["y=22", "y", "22"]]

lines

fn lines(self) -> [str]

Returns the string’s lines, without their line endings. A line can end in \n or \r\n, and a line ending at the very end of the string doesn’t add an empty line.

let a = "one\ntwo\r\nthree\n".lines(); // ["one", "two", "three"]

capitalize

fn capitalize(self) -> str

Returns the string with its first character in uppercase. The rest of the string is unchanged.

let a = "élan".capitalize();  // "Élan"
let b = "hELLO".capitalize(); // "HELLO"

to_int

fn to_int(self) -> int?

Parses the string as a base-10 int, or returns null if it isn’t one. Whitespace at either end is ignored, and a leading + or - is allowed.

let a = " -12 ".to_int(); // -12
let b = "12px".to_int();  // null
let c = "4.2".to_int();   // null

ord

fn ord(self) -> int?

Returns the Unicode code point of the first character, or null if the string is empty.

let a = "A".ord(); // 65
let b = "".ord();  // null

is_empty

fn is_empty(self) -> bool

Returns whether the string has no characters. A string of spaces isn’t empty.

let a = "".is_empty();  // true
let b = " ".is_empty(); // false

replace

fn replace(self, needle: str, replacement: str) -> str

Returns the string with every occurrence of needle replaced by replacement.

let a = "a-b-c".replace("-", "+"); // "a+b+c"

repeat

fn repeat(self, count: int) -> str

Returns the string repeated count times. A count of 0 gives an empty string.

A negative count is a runtime error.

let a = "ab".repeat(3);   // "ababab"
let line = "-".repeat(20);

Booleans

ItemSummary
randomReturns true or false, each with equal chance

Associated Functions

random

fn random() -> bool

Returns true or false, each with equal chance.

let side = if bool::random() "heads" else "tails";

std::fs

ItemSummary
readReturns the contents of the file at path as a string
writeWrites contents to the file at path, replacing anything the file held before, and returns true
list_dirReturns the paths of the files and directories directly inside the directory at path
walkReturns the path of every file and directory under path at any depth, starting with path itself
removeDeletes the file at path and returns true
remove_dirDeletes the directory at path and everything inside it, then returns true
make_dirCreates a directory at path, along with any parent directories that are missing, and returns true
cwdReturns the absolute path of the current working directory

Functions

read

fn read(path: str) -> str!

Returns the contents of the file at path as a string. Raises if the file can’t be read, such as when it doesn’t exist or isn’t valid UTF-8.

use std::fs;

let notes = fs::read("notes.txt")!;
let config = fs::read("config.txt") absolve |err| "";

write

fn write(path: str, contents: str) -> bool!

Writes contents to the file at path, replacing anything the file held before, and returns true. The file is created if it doesn’t exist, but the directory it goes in has to exist already (see make_dir). Raises if the file can’t be written.

use std::fs;

fs::write("scores.txt", "ada 3\nbob 5\n")!;

list_dir

fn list_dir(path: str) -> [str]!

Returns the paths of the files and directories directly inside the directory at path. Each path starts with path itself, and their order depends on the operating system. Raises if the directory can’t be read.

use std::fs;

for entry in fs::list_dir("saves")! {
    print(entry); // saves/slot1, saves/slot2, ...
}

walk goes into subdirectories as well.

walk

fn walk(path: str) -> [str]!

Returns the path of every file and directory under path at any depth, starting with path itself. A directory’s contents come right after it, and their order within the directory depends on the operating system. Raises if anything under path can’t be read.

use std::fs;

for path in fs::walk("assets")! {
    if path.ends_with(".png") {
        print(path);
    }
}

remove

fn remove(path: str) -> bool!

Deletes the file at path and returns true. Raises if it can’t, including when path is a directory (remove_dir deletes those).

use std::fs;

fs::remove("scores.txt")!;

remove_dir

fn remove_dir(path: str) -> bool!

Deletes the directory at path and everything inside it, then returns true. Raises if it can’t, such as when the directory doesn’t exist.

use std::fs;

fs::remove_dir("saves")!;

make_dir

fn make_dir(path: str) -> bool!

Creates a directory at path, along with any parent directories that are missing, and returns true. A directory that already exists is left as it is. Raises if the directory can’t be created.

use std::fs;

fs::make_dir("saves/slot1")!;
fs::write("saves/slot1/state.txt", "level 3")!;

cwd

fn cwd() -> str!

Returns the absolute path of the current working directory. Raises if it can’t be read, such as when the directory has been deleted.

Relative paths given to std::fs start from this directory, which is where the program was launched from. That isn’t necessarily where the script is: std::sys::file() gives the script’s own path.

use std::fs;

print(fs::cwd()!); // /home/ada/projects/game

std::math

ItemSummary
PIThe ratio of a circle’s circumference to its diameter, about 3.14159
TAUA full turn in radians, equal to 2.0 * PI (about 6.28319)
EEuler’s number, the base of the natural logarithm, about 2.71828

Constants

PI

const PI: float

The ratio of a circle’s circumference to its diameter, about 3.14159. An angle of PI radians is half a turn.

TAU

const TAU: float

A full turn in radians, equal to 2.0 * PI (about 6.28319).

E

const E: float

Euler’s number, the base of the natural logarithm, about 2.71828.

Types

Vec2

A 2D vector with float components x and y. Create one with Vec2::new(x, y) or a struct literal such as Vec2 { x = 1.0, y = 2.0 }.

Vec3

A 3D vector with float components x, y, and z. Create one with a struct literal such as Vec3 { x = 0.0, y = 1.0, z = 0.0 }.

Quat

A rotation in 3D space, stored as a quaternion with float components x, y, z, and w. Scripts usually create one with Quat::from_rotation_z and pass it to the host, such as for the rotation of a Bevy transform.

Vec2

struct Vec2 { x: float, y: float }

A 2D vector with float components x and y. Create one with Vec2::new(x, y) or a struct literal such as Vec2 { x = 1.0, y = 2.0 }.

The components are stored as 32-bit floats, and can read back slightly different from the value that was set (0.1 reads back as 0.10000000149011612).

Implementations

new

fn new(x: float, y: float) -> Vec2

Creates a vector from its x and y components. A struct literal such as Vec2 { x = 3.0, y = 4.0 } does the same.

use std::math::Vec2;

let v = Vec2::new(3.0, 4.0);
let y = v.y; // 4.0

from_angle

fn from_angle(radians: float) -> Vec2

Creates a vector of length 1.0 pointing at an angle of radians, measured from the positive x axis toward the positive y axis. Its components are the angle’s cosine and sine.

use std::math::Vec2;

let right = Vec2::from_angle(0.0);
let x = right.x; // 1.0

length

fn length(self) -> float

normalize

fn normalize(self) -> Vec2

add

fn add(self, other: Vec2) -> Vec2

Returns the sum of the two vectors, adding them component by component.

use std::math::Vec2;

let v = Vec2::new(3.0, 4.0).add(Vec2::new(1.0, 1.0));
let x = v.x; // 4.0

sub

fn sub(self, other: Vec2) -> Vec2

Returns this vector minus other, component by component. b.sub(a) is the vector that points from a to b.

use std::math::Vec2;

let player = Vec2::new(1.0, 1.0);
let enemy = Vec2::new(4.0, 5.0);
let offset = enemy.sub(player);
let x = offset.x; // 3.0

scale

fn scale(self, by: float) -> Vec2

Returns the vector with both components multiplied by by.

use std::math::Vec2;

let half = Vec2::new(3.0, 4.0).scale(0.5);
let x = half.x; // 1.5

dot

fn dot(self, other: Vec2) -> float

Returns the dot product, x * other.x + y * other.y. For two vectors of length 1.0 it’s the cosine of the angle between them, and a result of 0.0 means they’re perpendicular.

use std::math::Vec2;

let a = Vec2::new(3.0, 4.0).dot(Vec2::new(1.0, 0.0)); // 3.0

distance

fn distance(self, other: Vec2) -> float

Returns the straight-line distance between the two points.

use std::math::Vec2;

let d = Vec2::new(0.0, 0.0).distance(Vec2::new(3.0, 4.0)); // 5.0

angle

fn angle(self) -> float

Returns the angle of the vector in radians, measured from the positive x axis toward the positive y axis. The result is between -PI and PI. Vec2::from_angle goes the other way.

use std::math::Vec2;

let a = Vec2::new(1.0, 0.0).angle(); // 0.0

lerp

fn lerp(self, other: Vec2, t: float) -> Vec2

Returns the point that is t of the way from this vector to other, so 0.5 gives the midpoint. t isn’t limited to 0.0 through 1.0: 1.5 goes past other by half the distance again.

use std::math::Vec2;

let start = Vec2::new(0.0, 0.0);
let end = Vec2::new(10.0, 20.0);
let quarter = start.lerp(end, 0.25);
let y = quarter.y; // 5.0

Vec3

struct Vec3 { x: float, y: float, z: float }

A 3D vector with float components x, y, and z. Create one with a struct literal such as Vec3 { x = 0.0, y = 1.0, z = 0.0 }.

Like Vec2, the components are stored as 32-bit floats.

Implementations

length

fn length(self) -> float

normalize

fn normalize(self) -> Vec3

Quat

struct Quat { x: float, y: float, z: float, w: float }

A rotation in 3D space, stored as a quaternion with float components x, y, z, and w. Scripts usually create one with Quat::from_rotation_z and pass it to the host, such as for the rotation of a Bevy transform.

Implementations

from_rotation_z

fn from_rotation_z(radians: float) -> Quat

Creates a rotation of radians around the z axis. For 2D work, this is a turn within the x-y plane.

use std::math::{PI, Quat};

let quarter_turn = Quat::from_rotation_z(PI / 2.0);

rotate_z

fn rotate_z(self, radians: float) -> Quat

Returns the rotation turned a further radians around its own z axis.

use std::math::{PI, Quat};

let facing = Quat::from_rotation_z(0.0);
let turned = facing.rotate_z(PI / 2.0); // a quarter turn

std::parse

ItemSummary
from_jsonParses text as JSON and returns it as a Value
to_jsonReturns value written as compact JSON, with no spaces or line breaks

Functions

from_json

fn from_json(text: str) -> Value!

Parses text as JSON and returns it as a Value. Raises if text isn’t valid JSON, with a message giving the line and column of the problem.

use std::parse;

let data = parse::from_json("{\"name\": \"ada\", \"hp\": 30}")!;
let name = data.as_dict()!["name"]!.as_str()!; // "ada"

to_json

fn to_json(value: Value) -> str!

Returns value written as compact JSON, with no spaces or line breaks. Object keys are written in alphabetical order, and a whole number has no decimal point (3.0 is written as 3). A NaN or infinite number, which JSON can’t represent, is written as null.

use std::parse;
use std::parse::Value;

let player = Value::Object(~{
    name = Value::String("ada"),
    level = Value::Number(3.0),
});
let text = parse::to_json(player)!; // "{\"level\":3,\"name\":\"ada\"}"

Types

Value

A JSON value, as from_json returns it and to_json takes it. Match on it to handle each kind of value, or use the as_ methods to get the kind you expect.

Value

enum Value {
    /// JSON's `null`.
    Null,
    /// `true` or `false`.
    Bool(bool),
    /// A number. JSON doesn't tell integers and decimals apart, and both are stored as a `float`.
    Number(float),
    /// A string.
    String(str),
    /// An array of values.
    Array([Value]),
    /// An object, keyed by string. The keys of an object from `from_json` are in no particular
    /// order.
    Object(~{Value}),
}

A JSON value, as from_json returns it and to_json takes it. Match on it to handle each kind of value, or use the as_ methods to get the kind you expect.

use std::parse;
use std::parse::Value;

let data = parse::from_json("{\"alive\": true}")!;
let alive = match data.as_dict()!["alive"]! {
    Value::Bool(b) => b,
    _ => false,
};

Implementations

as_dict

fn as_dict(self) -> ~{Value}?

Returns the entries of an Object, or null for any other kind of value. The keys are in no particular order.

use std::parse;

let data = parse::from_json("{\"hp\": 30}")!;
let hp = data.as_dict()!["hp"]!.as_float()!; // 30.0

as_array

fn as_array(self) -> [Value]?

Returns the elements of an Array, or null for any other kind of value.

use std::parse;

let list = parse::from_json("[1, 2, 3]")!;
let n = list.as_array()!.len(); // 3

as_str

fn as_str(self) -> str?

Returns the text of a String, or null for any other kind of value.

use std::parse;

let a = parse::from_json("\"hi\"")!.as_str(); // "hi"
let b = parse::from_json("12")!.as_str();     // null

as_float

fn as_float(self) -> float?

Returns the number in a Number, or null for any other kind of value. Every JSON number comes back as a float. Call to_int on the result when you need an int.

use std::parse;

let a = parse::from_json("12")!.as_float();           // 12.0
let b = parse::from_json("12")!.as_float()!.to_int(); // 12

std::process

ItemSummary
runRuns the program cmd with args, waits for it to finish, and returns what it wrote to standard output
run_attachedRuns the program cmd with args, waits for it to finish, and returns its exit code

Functions

run

fn run(cmd: str, args: [str], stdin: str?) -> str!

Runs the program cmd with args, waits for it to finish, and returns what it wrote to standard output. When stdin is given, it’s written to the program’s standard input.

cmd is looked up on the PATH, but there’s no shell in between. Each argument is passed to the program exactly as written. The output keeps its trailing newline, which trim removes.

Raises if the program can’t be started or its output isn’t valid UTF-8. A non-zero exit status raises as well, with the program’s standard error in the message.

use std::process;

let greeting = process::run("echo", ["hello"])!.trim(); // "hello"
let sorted = process::run("sort", [], "b\na\n")!;       // "a\nb\n"

run_attached

fn run_attached(cmd: str, args: [str]) -> int!

Runs the program cmd with args, waits for it to finish, and returns its exit code.

Unlike run, the program uses the script’s own standard input and output. Anything it prints appears directly (usually in the terminal) instead of coming back as a string, and a non-zero exit code is returned like any other. Raises if the program can’t be started or is stopped by a signal.

use std::process;

let code = process::run_attached("cargo", ["test"])!;
if code != 0 {
    print("tests failed");
}

std::sys

ItemSummary
argReturns the command-line argument at index, or null if there isn’t one
exitEnds the program immediately with the exit code code
stdinReads standard input until it ends and returns all of it as one string
fileReturns the absolute path of the source file that contains the call

Functions

arg

fn arg(index: int) -> str?

Returns the command-line argument at index, or null if there isn’t one. When the script is run with the mimas command, index 0 is the script’s path as it was typed, and the arguments after -- start at index 1.

use std::sys;

// run as `mimas tool.mim -- input.txt`
let path = sys::arg(1) ?? "default.txt"; // "input.txt"

exit

fn exit(code: int) -> !

Ends the program immediately with the exit code code. Nothing after the call runs.

use std::sys;

if sys::arg(1) == null {
    print("usage: tool <file>");
    sys::exit(1);
}

exit ends the whole process. When mimas is embedded in a larger program, that program exits as well.

stdin

fn stdin() -> str!

Reads standard input until it ends and returns all of it as one string. Raises if the input can’t be read or isn’t valid UTF-8.

Input piped in from another program ends when that program finishes. In a terminal, it ends when you press Ctrl+D (Ctrl+Z then Enter on Windows).

use std::sys;

// run as `echo "a b c" | mimas count.mim`
let words = sys::stdin()!.trim().split(" ");
print(words.len()); // 3

file

fn file() -> str

Returns the absolute path of the source file that contains the call. For a script compiled from a string instead of a file, it’s the name the host gave that source.

Use it to find files stored next to the script (relative paths in std::fs start from the working directory instead).

use std::sys;

print(sys::file()); // /home/ada/projects/game/main.mim

bevy

What the Bevy plugin adds for scripts. Bevy’s own types (like bevy::Transform) live in this module too, but they come from your app’s type registry and aren’t listed here. Components, resources, and messages covers what scripts can call on them.

ItemSummary
bevy::startRegisters f to run once for each entity with the script, before its first update
bevy::updateRegisters f to run every frame
bevy::fixed_updateRegisters f to run every fixed tick
bevy::stopRegisters f to run when an entity’s script stops
bevy::Entity::spawnSpawns an entity with no components and returns it
bevy::Entity::despawnDespawns the entity, and returns false if it was already gone
bevy::Entity::existsReturns true if the entity hasn’t been despawned
bevy::ecs::meReturns the entity the running hook is for
bevy::ecs::attach_scriptGives entity the script at path
bevy::time::deltaReturns the seconds since the last frame
bevy::time::elapsedReturns the seconds since the app started
bevy::input::pressedReturns true while the key button is held down
bevy::input::just_pressedReturns true during the frame the key button went down
bevy::input::just_releasedReturns true during the frame the key button came up
bevy::input::mouse_pressedReturns true while the mouse button button is held down
bevy::input::mouse_just_pressedReturns true during the frame the mouse button button went down
bevy::input::mouse_just_releasedReturns true during the frame the mouse button button came up
bevy::input::cursorReturns the cursor’s position in 2D world space, as the first camera sees it
bevy::log::infoLogs message at the info level through Bevy’s logger
bevy::log::warnLogs message at the warn level through Bevy’s logger
bevy::log::errorLogs message at the error level through Bevy’s logger

Hooks

Each of these registers a function for the plugin to run. f can be a function by name, a method, or a closure. Its parameters are filled by type on every run, each with a component of the entity or a resource, and a T? parameter accepts an entity that has no T. Trailing parameters with defaults keep them.

Call these from a script’s top-level code, which runs once each time the script compiles. Registering faults from inside a hook, when f isn’t a function, or when a parameter is neither a component nor a resource. Hooks explains how they run.

fn fall(t: bevy::Transform) {
    t.translation.y -= 9.8 * bevy::time::delta();
}

bevy::update(fall);

start

fn start(f)

Registers f to run once for each entity with the script, before its first update. If it faults, or the entity is missing a component it needs, it’s tried again the next frame.

update

fn update(f)

Registers f to run every frame, in Bevy’s Update schedule.

fixed_update

fn fixed_update(f)

Registers f to run every fixed tick, in Bevy’s FixedUpdate schedule.

stop

fn stop(f)

Registers f to run when an entity’s script stops, which is when its Script is removed or replaced, or when the entity despawns. A despawned entity has no components left, so f should only take resources.

bevy::Entity

struct Entity(int)

An entity in the Bevy world, held as the bits of its id. Scripts get one from spawn, bevy::ecs::me, a component’s entities(), or an Entity field of another type.

spawn

fn spawn() -> Entity

Spawns an entity with no components and returns it.

let brick = bevy::Entity::spawn();
let placed = bevy::Transform::default();
placed.translation.x = 120.0;
bevy::Transform::insert(brick, placed);

despawn

fn despawn(self) -> bool

Despawns the entity, and returns false if it was already gone.

exists

fn exists(self) -> bool

Returns true if the entity hasn’t been despawned.

bevy::ecs

me

fn me() -> Entity

Returns the entity the running hook is for. Faults outside a hook, like in a script’s top-level code.

attach_script

fn attach_script(entity: Entity, path: str)

Gives entity the script at path (relative to assets/), replacing any script it already had. Faults if the entity doesn’t exist.

let ball = bevy::Entity::spawn();
bevy::ecs::attach_script(ball, "scripts/ball.mim");

bevy::time

delta

fn delta() -> float

Returns the seconds since the last frame. Inside fixed_update, it’s the length of a fixed tick instead.

elapsed

fn elapsed() -> float

Returns the seconds since the app started. Inside fixed_update, it follows the fixed clock.

bevy::input

Keys and mouse buttons are Bevy’s own enums, bevy::KeyCode and bevy::MouseButton.

bevy::update(|t: bevy::Transform| {
    if bevy::input::pressed(bevy::KeyCode::KeyA) {
        t.translation.x -= 300.0 * bevy::time::delta();
    }
});

pressed

fn pressed(button: KeyCode) -> bool

Returns true while the key button is held down.

just_pressed

fn just_pressed(button: KeyCode) -> bool

Returns true during the frame the key button went down.

just_released

fn just_released(button: KeyCode) -> bool

Returns true during the frame the key button came up.

mouse_pressed

fn mouse_pressed(button: MouseButton) -> bool

Returns true while the mouse button button is held down.

mouse_just_pressed

fn mouse_just_pressed(button: MouseButton) -> bool

Returns true during the frame the mouse button button went down.

mouse_just_released

fn mouse_just_released(button: MouseButton) -> bool

Returns true during the frame the mouse button button came up.

cursor

fn cursor() -> Vec2?

Returns the cursor’s position in 2D world space, as the first camera sees it. It’s null while the cursor is outside the primary window. The result is a Vec2 from std::math.

bevy::log

info

fn info(message: str)

Logs message at the info level through Bevy’s logger.

warn

fn warn(message: str)

Logs message at the warn level through Bevy’s logger.

error

fn error(message: str)

Logs message at the error level through Bevy’s logger.

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.

Your API in the editor

The language server checks scripts against your API once your host has run through cargo. See Host APIs.

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

Basics

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

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

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

#[mimas]
struct MyType(usize);

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

// mimas
let hello: str = HELLO;

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

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

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

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

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

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

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

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

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

Write-back mutation

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

Magic is dangerous!

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

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

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

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

Working With Types

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

The ’gc lifetime

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

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

The conversion table

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

Integers are range-checked at the boundary

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

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

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

f32 silently rounds

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

Collections

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

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

Allocation costs

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

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

Anonymous types

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

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

Anons are local to their call

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

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

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

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

Tread carefully with Val!

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

As an example, this successfully compiles and executes:

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

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

Runtime Errors

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

Unrecoverable errors

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

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

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

Raisable errors

To return an actual mimas Result, you use Raisable.

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

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

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

Fixtures & Context

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

use vm::Ctx;

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

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

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

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

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

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

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

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

Fixtures have two rules that shape how you use them:

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

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

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

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

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

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

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

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

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

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

A few things worth noticing:

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

What about &mut state?

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

Freeze

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

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

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

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

struct Database(Vec<String>);

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

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

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

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

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

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

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

A few things worth noticing:

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

Info

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

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

Function Values

Scripts are able to pass their own functions or closures back to the host. The host protects it from garbage collection while its held, and is able to call it at will (ex: for a callback system).

Functions and closures can be passed to the host like any other value. Generally, its easiest to do so with anon::T.

Calling signature on a mimas function hands back the FnHeader that the compiler worked out for it (the parameters with their types and defaults as well as the return type). The header is borrowed straight out of the loaded program rather than copied, so a check like the one below costs nothing.

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

use mimas::{
    Ctx, Val, mimas,
    vm::{RtErr, Stashed, anon},
};

/// Functions the script has handed over, waiting on the host to run them.
#[derive(Default)]
struct Pending(RefCell<Vec<Stashed>>);

#[mimas]
fn execute_call<'gc>(ctx: Ctx<'gc>, f: anon::T<'gc>) -> Result<(), RtErr> {
    let Some(header) = f.0.signature(ctx) else {
        return Err(RtErr::InvalidArgument(
            "`execute_call` takes a function".into(),
        ));
    };
    ctx.fixture::<Pending>().0.borrow_mut().push(ctx.stash(f.0));
    Ok(())
}

A native can’t run the function itself. It holds a Ctx, not the Vm, and the Vm is already mid-dispatch on the native. Taking a function value always means handing it to the host, which runs it once it has control back.

Holding onto one

ctx.stash turns a value into a Stashed: a handle with no 'gc lifetime, so the host can keep it in a fixture, a struct field, or anything else you like. It survives collection, and keeps the value alive for as long as the handle is around. Dropping it lets the value be collected again.

A handle belongs to the Vm that made it. You cannot use a stashed value on a Vm other than the one who made it, nor on a later recompile on said Vm.

Calling it back

vm.call_value runs a stashed function and converts what it returned, the same way vm.call does for one looked up by name.

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

let pending: Vec<Stashed> = vm.fixture::<Pending>().0.borrow_mut().drain(..).collect();
for f in &pending {
    vm.call_value::<()>(f, ()).unwrap();
}

A few things worth noticing:

  • The script hands its functions over when its top-level code runs, which is why vm.run() comes first. Draining the fixture into a Vec keeps it unborrowed while the Vm runs.
  • Arguments work as they do for vm.call: a tuple, with any trailing parameter you leave off filled from the function’s own default.
  • vm.call_value_then is the then variant, mirroring vm.call_then: it takes a closure run with the arena still open, for reading whatever a native put aside during the call.
  • A fault leaves the Vm usable. The frames unwind to where they were, so the next call starts clean.

Passing script values as arguments

Everything so far called a function that needed nothing from the host. A method needs one more thing: something to call it on.

// rust
/// The method the script wants run each tick, and the value to run it on.
#[derive(Default)]
struct Tick(RefCell<Option<(Stashed, Stashed)>>);

#[mimas]
fn on_tick<'gc>(ctx: Ctx<'gc>, f: anon::T<'gc>, receiver: anon::U<'gc>) {
    *ctx.fixture::<Tick>().0.borrow_mut() = Some((ctx.stash(f.0), ctx.stash(receiver.0)));
}

The two parameters take separate anon slots. Every anon::T in one signature is the same type, so a second anon::T would tie the receiver to the function’s own type.

struct Tally { n: int }
impl Tally { 
    fn bump(self, by: int) -> int { 
        self.n += by; self.n 
    } 
}

let tally = Tally { n = 1 };
on_tick(Tally::bump, tally);

A &Stashed is an argument type, so the receiver goes back in as the first argument:

// rust
let (f, receiver) = vm.fixture::<Tick>().0.borrow_mut().take().unwrap();
assert_eq!(vm.call_value::<i64>(&f, (&receiver, 2)).unwrap(), 3);

self is nothing special here: it’s parameter 0 of bump’s header, so (&receiver, 2) fills it exactly like any other argument. Two parameters on the header, one argument at the script’s call site.

Usually a closure is simpler

When the host has nothing to add to the call, let the script capture the receiver and hand over one closure instead:

execute_call(|| tally.bump(2));

That leaves one handle to keep and nothing to pair up. Reach for the method and receiver shape when the host supplies the arguments, or when it calls the same method on several different values.

Giving one back to the script

ctx.fetch reads a stashed value back as a Val anywhere you have a Ctx. A native can return one, which lets a script get a function out of the host and run it itself:

// rust
#[mimas]
fn handler<'gc>(ctx: Ctx<'gc>) -> Val<'gc> {
    match ctx.fixture::<Pending>().0.borrow().first() {
        Some(f) => ctx.fetch(f),
        None => Val::Null,
    }
}
let f = handler();
f();

The compiler can’t check that call

What the script gets back is an untyped value, so nothing checks the call where it’s written – this is the Val hazard. A mistake that would normally be a compile error becomes a runtime fault: calling with the wrong number of arguments, or calling something that isn’t a function at all, raises an error pointing at the call rather than being caught up front. Utilize the information in the FnHeader to handle the error case.

A stashed closure keeps its captures

A closure holds everything it captured, and stashing it extends that for the life of the handle. A closure that captured a large array keeps that array out of the collector’s hands until you drop the Stashed.

let rows = load_everything(); // stays alive as long as the handle does
execute_call(|| print(rows[0]));

Advanced Usage

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

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

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

The native macro

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

// rust
use mimas::native;

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

Installer functions

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

// rust
use mimas::Api;

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

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

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

A few things worth noticing:

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

The Api surface

Everything an installer can do:

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

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

Struct/Enum Conversion

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

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

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

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

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

Order matters

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

Methods on core types

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

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

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

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

Associated functions

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

// rust
use mimas::Ty;

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

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

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

Overloading

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

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

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

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

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

Bevy

The bevy feature runs mimas scripts inside a Bevy 0.19 app. Scripts are assets, so they hot reload with Bevy’s file_watcher feature, and they’re type-checked against your app’s components and resources when they load.

The core scaffolding for this plugin is solid, but practical use will likely require further expansion of its abilities and support for bevy types/features. Please feel welcome to contribute these yourself or open an issue to request them!

Example

examples/bevy is a breakout game: three scripts on three entities, sharing a Game resource and a GameEvent message, plus a controls module they all use.

You can run it locally with:

cargo run --manifest-path examples/bevy/Cargo.toml

Controls

A: Move paddle left

D: Move paddle right

Space: Serve/Restart round

Space serves, A and D steer.

Why not bevy_mod_scripting?

bevy_mod_scripting hosts Lua and Rhai in Bevy. mimas isn’t a good fit for it right now, for a few reasons:

  • mimas’s garbage collector is single-threaded, but bevy_mod_scripting requires a script’s context to be Send. A backend would need an unsafe wrapper and a guarantee that every native is thread-safe.
  • bevy_mod_scripting gives scripts live handles into the world, so every field read or write runs through reflection. mimas’s field access has no way to call a getter or setter, so it can’t work that way. Our plugin copies Bevy’s data into scripts and writes changes back instead, which has its own advantages.
  • The other backends let +, -, and the other operators work on Bevy types like Vec3 by calling functions registered on that type. mimas has no operator overloading yet, so scripts would have to call those as methods, like a.add(b).

Additionally, bevy_mod_scripting’s API is built for dynamic languages. mimas is statically typed, so this plugin leans into that instead. A hook takes its components and resources as typed parameters, and a wrong field or type is caught when the script compiles. We’ll explore that below!

Setup

[dependencies]
bevy = "0.19"
mimas = { version = "0.2", features = ["bevy"] }
use bevy::prelude::*;
use mimas::bevy::prelude::*;

#[derive(Component, Reflect)]
#[reflect(Component)]
struct Velocity {
    x: f32,
    y: f32,
}

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, MimasPlugin::default()))
        .add_systems(Startup, |mut commands: Commands, assets: Res<AssetServer>| {
            commands.spawn((
                Velocity { x: 50.0, y: 0.0 },
                Transform::default(),
                Script(assets.load("scripts/mover.mim")),
            ));
        })
        .run();
}
// assets/scripts/mover.mim
fn move_along(v: Velocity, t: bevy::Transform) {
    t.translation.x += v.x * bevy::time::delta();
}

bevy::update(move_along);

Velocity reaches the script without being registered for it. Any component or resource that derives bevy’s Reflect with #[reflect(Component)] or #[reflect(Resource)] is reachable as long as mimas can represent its fields. Bevy’s own types live in a bevy module, like bevy::Transform and bevy::Visibility, and your app’s types sit at the root.

Running the app through cargo also writes these types out for the language server, so hovering Velocity in a script shows its /// docs, and Bevy’s own types show theirs.

Scripts and modules

MimasPlugin::default() loads every .mim file under assets/scripts at startup. MimasPlugin::folder(path) loads another folder instead, and MimasPlugin::new([path, ..]) loads just the scripts you name, which is what a web build needs, since a folder can’t be listed over HTTP. A file that starts with a module declaration compiles into every other script and never runs on its own. Every other file compiles into its own Vm, shared by the entities whose Script points at it.

Adding, editing, or removing any file recompiles every script. A recompile that fails keeps the previous version running.

Hooks

A script registers the functions it wants run by calling one of four natives in the bevy module. The call goes in the script’s top-level code, which runs once each time the script compiles. Registering from inside a hook is an error.

Register withRuns
bevy::start(f)once per entity, before its first update
bevy::update(f)every frame
bevy::fixed_update(f)every fixed tick
bevy::stop(f)when the Script is removed or its entity despawns

Any function value works. A root function by name, a method or associated function off an impl, or a closure written inline can all be used as a hook. A method’s self is its first parameter, filled by type like any other.

fn steer(t: bevy::Transform) { /* ... */ }

bevy::update(steer);
bevy::fixed_update(Ball::fixed_update);
bevy::start(|game: Game| game.reset());

A hook can be registered more than once, and its functions run in registration order. Nothing registered at a hook means nothing runs there.

Inside a hook, bevy::ecs::me() is the entity it runs for. Parameters are filled by type, like a Bevy system’s: a component of that entity, or a resource. A T? parameter accepts an entity without the component, while a required component the entity lacks skips the hook. stop can only take resources, since the entity is gone by then.

A closure keeps whatever it captured alive for as long as the script is loaded, so a value it closed over lives from one hook call to the next.

A hook works on copies. Whatever it changes on a parameter, or on something it got from get, is written back when the hook returns. Field access in between is plain slot access, with no reflection. A value that comes back unchanged isn’t written, so change detection only fires for real changes. A hook that faults skips the write back, though calls like insert that act on the world directly still take effect.

Components, resources, and messages

KindIn scripts
component TT::get(e) -> T?, T::insert(e, value), T::remove(e) -> T?, T::entities() -> [bevy::Entity]
resource TT::get() -> T?, T::insert(value)
message TT::read() -> [T], T::write(value)
any T with #[reflect(Default)]T::default() -> T

Messages are the one thing you register, with app.script_message::<T>(), because Bevy can only read them through their concrete type. Each scripted entity reads every message once.

A second get in the same hook returns the same copy, and insert or remove wins over an earlier get. get only works inside a hook, since its copy is written back when the hook returns.

Natives of your own go in through app.script_installer, and reach the world through ScriptCtx:

use mimas::{Ctx, bevy::prelude::*};

app.script_installer(|api| {
    api.module("game").add_named(
        "spawn_at",
        |ctx: Ctx, x: f32| -> mimas::bevy::Entity {
            ctx.world(|world| world.spawn(Transform::from_xyz(x, 0.0, 0.0)).id().into())
        },
    );
});

Built in

The plugin’s own natives, like bevy::ecs::me(), bevy::time::delta(), and bevy::input::pressed, are listed in the bevy reference. Bevy’s Vec2, Vec3, and Quat reach scripts as the types in std::math.

Errors

Compile errors and runtime faults print to stderr as full diagnostics, log as one line, and arrive as ScriptError messages. Each registered function reports its first fault and none after it, however many entities or frames it faults on, until its script reloads.

Known limits

  • Scripts can use numbers, strings, bool, Vec2, Vec3, Quat, Entity, Option and Vec of those, and structs and enums built from them. A type with any other field, like a Handle<T>, map, array, tuple, or Duration, is left out, and scripts can’t name it. Open an issue if you need one!
  • Types that contain themselves, directly or through each other, are skipped.
  • A u64 or usize above i64::MAX reaches scripts as a negative int with the same bits, and writes back unchanged.
  • An entity runs one script at a time. Giving it another Script swaps the old one out, running its stop hooks first.
  • A script can replace a resource, but not create one.
  • Values cross between Bevy and scripts through reflection, which costs more than a hand-written conversion.
  • mimas pins the same glam minor version as Bevy, so a host that depends on glam directly has to match it.
  • Scripts run one hook at a time on the main thread, with no step limit, so a stuck loop stalls the frame.