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

Giving me functions

A function you give me is the same kind of thing as one a script writes. Scripts can call it, pass it around, bind a keyword to it, and read its documentation. The only difference is where its body lives.

The declaration

You write the declaration as text, in my own syntax:

/// Keeps a number between two others.
/// !example: PRINT clamp(250)
FUNCTION clamp(n, low = 0, high = 100)

That one string gives me the name scripts call it by, its parameters and their defaults, and its documentation. The Rust closure’s own name never reaches a script.

Closures that take Rust types

typed is the easy way. Write the closure with ordinary Rust parameters and I’ll convert each argument, and stop the script with a proper message if one is the wrong kind.

    // Defaults are written in the declaration; the closure always gets a value.
    engine.typed("FUNCTION clamp(n, low = 0, high = 100)", |n: i64, low: i64, high: i64| {
        n.clamp(low, high)
    })?;
    // `Option` takes NULL, `Vec` takes an array or the rest of the arguments,
    // and a pair is a coordinate.
    engine.typed("FUNCTION shout(text, mark = NULL)", |text: String, mark: Option<String>| {
        format!("{}{}", text.to_uppercase(), mark.unwrap_or_else(|| "!".to_string()))
    })?;
    engine.typed("FUNCTION total(numbers...)", |numbers: Vec<f64>| numbers.iter().sum::<f64>())?;
    engine.typed("FUNCTION mirror(spot)", |(x, y): (i64, i64)| (-x, y))?;
In the closureTakes
i64A whole number
f64Any number
boolAnything, judged the way IF judges it
StringText
(i64, i64) or (f64, f64)A coordinate
Option<T>NULL, or a T
Vec<T>A list, or a rest... parameter
ValueAnything at all

It can return any of those, () for NULL, or a Value. Up to eight parameters.

If a script calls clamp("lots"), it stops on that line with: “clamp needs a whole number for n, and it was given a string!” You wrote none of that.

Closures that take the call

function hands you the call as it is: every argument as a Value, with the defaults already filled in.

    // `function` hands over the call itself: every argument as a `Value`,
    // the parameters' names, and the file and line the call was written on.
    engine.function("FUNCTION describe(value)", |call| {
        let said = format!("{} on line {} is {}", call.params[0], call.line, call.args[0].type_name());
        Value::string(&said)
    })?;

A Call has:

FieldIs
argsOne value for each parameter. A rest... parameter is one array.
paramsThe parameters’ names
functionThe function’s name, for messages
file, lineWhere the call was written
filesThe files you gave me (see Files)

Closures have to be Send + Sync: scripts may call them from several threads at once. Share state the usual way, with an Arc and a lock or an atomic.

When it doesn’t work out

Remember the two kinds of “wrong” from When things go wrong? A host function chooses between them the same way a script’s does.

    use std::sync::Arc;

    use crashbasic::{Error, Reply, Value};

    // A result the script is expected to look at: an error handed back as a
    // value. The doc comment declares it, with the words a person will read.
    let find = "/// Finds a saved game.
/// !save-missing: There's no save called `{name}`!
/// !tip: Saves are made with `SAVE`.
FUNCTION find_save(name)";
    engine.function(find, |call| match call.args[0].to_string().as_str() {
        "slot1" => Value::string("level 3"),
        name => {
            let values = vec![("name".to_string(), name.to_string())];
            let (code, file) = ("save-missing".to_string(), call.file.to_string());
            Value::Error(Arc::new(Error { code, file, line: call.line, values }))
        }
    })?;

    // A mistake in the script: `Reply::fail` stops it on the calling line.
    let volume = "/// Sets how loud things are.
/// !volume-out-of-range: A volume of `{given}` is no good!
/// !tip: Give a number from 0 to 10.
FUNCTION set_volume(level)";
    engine.function(volume, |call| match call.args[0] {
        Value::Integer(level) if (0..=10).contains(&level) => Reply::Value(Value::Null),
        ref other => Reply::fail("volume-out-of-range", &[("given", &other.to_string())]),
    })?;
  • A result — “no such save” — goes back as an error value. The script can check it with ISERROR and carry on. If it doesn’t check and uses the value anyway, it stops there.
  • A mistake — a volume of 11 — stops the script at once, with Reply::fail.

Either way the error is declared in the doc comment: its name, what I should say, and a tip. That’s where the words a person reads come from, and it’s why they show up in the generated reference.

When a script does stop, run gives you the error, and the script turns it into those words:

    // stopped: the error `run` or `run_all` gave back.
    let said = script.report(&stopped);
main.crash:5: A volume of `11` is no good!
    Crash-tan's Tip: Give a number from 0 to 10.

Things read like variables

Some things a script only wants to read: the time, the screen’s width, how many lives are left. Declare those with value and they’re read with no parentheses, and worked out fresh each time.

    use std::sync::atomic::{AtomicI64, Ordering};

    let lives = Arc::new(AtomicI64::new(3));

    // A value is read with no parentheses, and worked out each time.
    let read = lives.clone();
    engine.value("/// How many lives are left.\nFUNCTION lives() AS INTEGER", move |_| {
        Value::Integer(read.load(Ordering::Relaxed))
    })?;
    engine.value("FUNCTION screen.width() AS INTEGER", |_| Value::Integer(320))?;

    // An accessor is a function scripts can also assign through. The
    // second closure is given the same arguments, and the value assigned.
    let settings: Arc<Mutex<BTreeMap<String, Value>>> = Arc::default();
    let (get, set) = (settings.clone(), settings.clone());
    engine.accessor(
        "/// A setting, kept between runs.\nFUNCTION setting(name)",
        move |call| get.lock().unwrap().get(&call.args[0].to_string()).cloned().unwrap_or(Value::Null),
        move |call, value| {
            set.lock().unwrap().insert(call.args[0].to_string(), value.clone());
            Value::Null
        },
    )?;
PRINT lives, lives - 1, screen.width > 300
setting("name") = "Ada"
PRINT setting("name")

A value can’t be assigned to, but a script can take its name. If a script makes a variable called lives, the variable wins everywhere in that script and your lives is out of its reach. The same goes for your functions. A script’s author shouldn’t have to know every name you’ve declared before choosing theirs.

Something scripts both read and assign is an accessor: a function with a second closure that takes the value being assigned.

Crash-tan’s Tip: Write the doc comment. One line above the declaration and the function explains itself in the editor, in the reference, and in my error messages. It’s the cheapest documentation you will ever write.