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 keywords

A keyword is a word bound to a function. You bind them with the very lines a script would write, handed to engine.bind. So everything in Your own keywords and Keywords with blocks is yours to use from the host too, and what you bind there is how your program’s own vocabulary gets made.

A statement, with options

    // The function behind the keyword, with a parameter for each option.
    let line = "FUNCTION line(span, color = \"white\", thick = 1, dashed = FALSE)";
    engine.typed(line, move |span: Value, color: String, thick: i64, dashed: bool| {
        let dashes = if dashed { ", dashed" } else { "" };
        lines.lock().unwrap().push(format!("{span} in {color}, {thick} thick{dashes}"));
    })?;
    // Each modifier is a function that returns the parameters it fills.
    // In a namespace of their own, the words stay free for variables.
    engine.function("FUNCTION Pen.thick(size)", |call| {
        Value::map(BTreeMap::from([("thick".into(), call.args[0].clone())]))
    })?;
    engine.function("FUNCTION Pen.dashed()", |_| {
        Value::map(BTreeMap::from([("dashed".into(), Value::Integer(1))]))
    })?;
    engine.bind("BIND LINE line ALLOW(Pen)")?;
LINE (0, 0) -> (9, 9), "red" !! thick(3) dashed
LINE (1, 1) -> (2, 2)

The function has a parameter for every option, each with a default. A modifier is a function that returns a map naming the parameters it fills. ALLOW(Pen) lets the keyword take every function in the Pen namespace as a modifier, by its short name.

Putting modifiers in a namespace matters more than it looks. A real engine has hundreds of option words: speed, size, name, repeat. If each were a plain function, no script could ever have a variable called speed. In a namespace, the short word only means something after !!.

A function takes modifiers the same way, without a keyword: put ALLOW on its declaration, FUNCTION open(path, append = FALSE) ALLOW(Files), and a script writes file = OPEN("scores.txt") !! append. Use that for anything that gives a value back. A parameter named in ALLOW with ... after it, ALLOW(values...), takes every other word after !! as a map, for names you can only know when the call runs.

Keywords that own a block

There are four kinds, and they differ in what becomes of the block.

The block runs once for each value

    // BLOCKBIND: the block runs once for each value the function gives.
    // An iterator gives them one at a time, as the block asks.
    engine.typed("FUNCTION countdown(from)", |from: i64| {
        Value::iterator((1..=from).rev().map(Value::Integer))
    })?;
    engine.bind("BLOCKBIND COUNTDOWN countdown")?;
COUNTDOWN n = 3
    PRINT n
END COUNTDOWN

The function gives back what to step through: a list, a range, a map, or an iterator. Value::iterator wraps any Rust iterator, and I ask it for one value per pass, so it can be as long as it likes, or not known in advance. FOR itself is nothing more than this.

The block runs once, with something around it

    // SCOPEBIND: the block runs once. The first function makes the block's
    // value; the one after END is handed it when the block is over.
    engine.typed("FUNCTION menu_open(title)", |title: String| {
        Value::map(BTreeMap::from([
            ("title".into(), Value::string(&title)),
            ("items".into(), Value::array(Vec::new())),
        ]))
    })?;
    // Anything called inside the block can ask for that value.
    engine.function("FUNCTION menu_item(label)", |call| -> Result<Value, Error> {
        if let Value::Map(menu) = call.scope("MENU")?
            && let Some(Value::Array(items)) = menu.read().unwrap().get("items")
        {
            items.write().unwrap().push_back(call.args[0].clone());
        }
        Ok(Value::Null)
    })?;
    let shown = printed.clone();
    engine.function("FUNCTION menu_close(menu)", move |call| {
        shown.lock().unwrap().push(format!("menu: {}", call.args[0]));
        Value::Null
    })?;
    engine.bind("SCOPEBIND MENU menu_open END menu_close\nBIND ITEM menu_item")?;
MENU "Start"
    ITEM "play"
    ITEM "quit"
END MENU

The first function runs before the block and returns the block’s value. The function after END is handed that value when the block is over. In between, any function called inside the block — however deep — can ask for it with call.scope("MENU"). Outside a MENU block that’s an error, and the ? stops the script with a message saying ITEM belongs inside one.

This is how you give scripts a little language for describing things: a menu, a level, a particle effect, a song.

The block is kept for later

    // HANDLERBIND: the block is not run. It arrives as the last argument, a
    // function to keep and call later.
    let handlers: Arc<Mutex<BTreeMap<String, Value>>> = Arc::default();
    let kept = handlers.clone();
    engine.function("FUNCTION on_event(name, handler)", move |call| {
        kept.lock().unwrap().insert(call.args[0].to_string(), call.args[1].clone());
        Value::Null
    })?;
    engine.bind("HANDLERBIND ON on_event GIVES(amount)")?;
bank = 100
ON "coin"
    bank = bank + amount
    PRINT "banked", bank
    RETURN bank
END ON

The block is handed over. It arrives as the function’s last argument: a function value, which you keep. GIVES(amount) names what the block will be told each time it’s called.

When the moment comes, call it:

    // Later, when a coin is picked up: the block is called with the value
    // GIVES named, on a thread of its own.
    let handler = handlers.lock().unwrap()["coin"].clone();
    let mut thread = script.call(&handler, vec![Value::Integer(5)])?;
    while thread.run(10_000)? != Status::Finished {}
    let returned = thread.result();

script.call gives you a thread, which you run like any other. The block keeps a copy of the locals that were around it when it was written, and can read and change the script’s shared variables, as bank is here.

The block is text

    // TEXTBIND: the block is not read. Its lines arrive as the last
    // argument, as one string.
    let statements: Arc<Mutex<BTreeMap<String, String>>> = Arc::default();
    let kept = statements.clone();
    engine.function("FUNCTION define_sql(name, statement)", move |call| {
        kept.lock().unwrap().insert(call.args[0].to_string(), call.args[1].to_string());
        Value::Null
    })?;
    engine.bind("TEXTBIND SQL define_sql AS \"sql\"")?;
SQL "top"
    SELECT name, points FROM scores
    WHERE points >= @least
END SQL

I don’t read the lines inside. They arrive as the function’s last argument, one string, moved left as far as the least indented line. Use this when your program already has a language of its own for something: queries, shaders, level maps, dialogue.

Two things make it pleasant to work with:

  • call.line is the keyword’s line, and the text starts on the one after. When you find a mistake on line 2 of the text, you can say which line of the script that is.
  • AS "sql" is passed on to editors. document.texts() lists each block with its place in the file and its kind, so an editor that knows SQL can color it. See the editor chapter.

Crash-tan’s Tip: Before adding a keyword, try the plain function. A script can already write shake 0.5 for a function called shake. A keyword earns its place when it has two words, takes modifiers, or owns a block.