Skip to content
Simon816 edited this page May 20, 2020 · 10 revisions

Command Block Language (CBL)

CBL is a high level programming language with similarities to C++. It has special syntax to make common tasks easier to write.

CBL is designed so the end user never has to write commands directly, the compiler is smart enough to figure out how to take an expression and generate a sequence of commands to implement it.

API

See the CBL API page for the API documentation. This also covers the built-in types.

Syntax

The syntax/grammar for CBL is listed on the CBL Syntax page.

Concepts and Demos

Perhaps a good way to introduce CBL is with some examples to demonstrate the various features.

Not everything is covered here, see the CBL Syntax page for a more detailed look at the language's grammar.

Basics

A simple function is defined like so:

void main() {
}

This creates an empty file main.mcfunction in the datapack.

Lets do something a bit more interesting.

int add(int a, int b) {
    return a + b;
}

This function takes two integer parameters, sums them, then returns the result. The mechanics for how parameters and return variables are handled is described in Command IR.

Now lets declare some variables and call add.

int add(int a, int b) {
    return a + b;
}

void main() {
   int i = 2;
   int j = 3;

   int k = add(i, j); 
}

What if we wanted to share the result in a global variable? Just define the variable at the global scope:

int add(int a, int b) {
    return a + b;
}

int k;

void main() {
    k = add(2, 3);
}

CBL has support for arrays. Here we create an int array of length 4, store some numbers in it and sum up the total:

void main() {
    int arr[4];
    int i;

    for (i = 0; i < 4; i++) {
        arr[i] = i * 2;
    }
    
    int sum = 0;
    for (i = 0; i < 4; i++) {
        sum += arr[i];
    }
}

There are two "modes" of parameter passing: pass by value and pass by reference.

Passing by reference allows the original variable to be mutated after calling a function. For example

void changeMe(int &x) {
    x = x * 2;
}

void main() {
    int value = 10;
    changeMe(value);
    // value is now 20
}

Types

So far, we've just been dealing with the int type, but what about more complex types?

The type declaration allows defining custom structured types:

type MyType {
    int x;
    int y;
}

MyType is a type consisting of two integers x and y. It can be passed into functions just like any other type.

int sum(MyType mt) {
    return mt.x + mt.y;
}

Note that by default, parameter passing is by-value, so the following will not mutate myVar:

void changeX(MyType mt, int x) {
    mt.x = x;
}

void main() {
    MyType myVar;
    changeX(myVar, 10);
}

changeX needs mt to be pass-by-reference:

void changeX(MyType &mt, int x) {
    mt.x = x;
}

Types aren't limited to just storing data. Functions can be bundled too, OOP style.

type MyType {
    int x;
    int y;
    void changeX(int x) {
        this.x = x;
    }
}

void main() {
    MyType myVar;
    myVar.changeX(5);
}

Now there exists a this variable which implictly references the current instanciation of MyType. Internally, the changeX function is rewritten to be just like the earlier example, passing MyType by reference: void changeX(MyType &this, int x).

Want to define changeX outside of the type declaration?

type MyType {
    int x;
    int y;
    void changeX(int x);
}

void main() {
    MyType myVar;
    myVar.changeX(5);
}

void MyType::changeX(int x) {
    this.x = x;
}

The function body for changeX needn't be defined prior to main() using it, only a declaration is needed.

If changeX is never defined, it is exported as an unresolved symbol. Trying to run the linker will fail due to an unresolved symbol. The implementation of changeX could be defined in a separate file, passing the other file to the linker will resolve the symbol.

Type constructors

So far, we've been using the implicitly defined default constructor. However, perhaps we want to control object construction.

type MyType {
    int x;
    int y;
    constructor() {
        this.x = 1;
        this.y = 1;
    }
    void changeX(int x);
}

Note that the way this constructor is defined, x and y are first default-constructed, then copy-assigned a new value. To call the int copy-constructor directly, the following syntax is used:

type MyType {
    int x;
    int y;
    constructor(): x(1), y(1) {
    }
    void changeX(int x);
}

Perhaps MyType requires a parameter to it's constructor:

type MyType {
    int x;
    int y;
    constructor(int valueForX): x(valueForX), y(1) {
    }
    void changeX(int x);
}

Without a default constructor (one that takes no arguments), it is not possible to create an instance of MyType without passing valueForX.

void main() {
    MyType mt; // Error, valueForX not given
    MyType mt(2); // This is fine
}

By default, types have a copy-constructor: a constructor which takes the type as input and copies to itself.

type MyType {
    int x;
    int y;
    // This constructor is added automatically if a user-defined copy-constructor does not exist.
    constructor(MyType other) {
        this.x = other.x;
        this.y = other.y;
    }
}

Function parameter matching

It is possible to create function overloads that differ by parameters only:

type MyType {
    int x;
    int y;
    void setValue(int value) {
        this.x = this.y = value;
    }
    void setValue(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

void main() {
    MyType mt;
    mt.setValue(2); // calls the first setValue
    mt.setValue(1, 2); // calls the second setValue
}

When calling setValue, all functions named setValue on MyType are considered candidates. Functions with a different number of parameters than the given arguments are quickly discarded. Then, if a function matching the argument types exactly is found, that becomes the nominated function. Otherwise, for each remaining candidate, the input arguments are taken in turn to be coerced to the correct type. If an argument cannot be coerced, the candidate is dropped and the next one is considered. An error is thrown if no successful candidates are found.

Type coercion is very basic at the moment. int and decimal can be coerced to each other, and a type can be coerced to any of its parent types (if it has a parent).

Operator overloading

It is possible to define operations on types that use the built-in operator symbols:

type MyType {
    int x;
    int y;
    constructor () {}
    constructor(int x, int y): x(x), y(y) {}
    MyType operator +(int scalar) {
        return MyType(this.x + scalar, this.y + scalar);
    }
}

void main() {
    MyType t1;
    MyType t2 = t1 + 10;
}

If multiple overloads of the same operator are declared, overload resolution is the same as ordinary function calls.

Generic Types

CBL supports generic typing, similar to template in C++.

type Vec2<T> {

    T x;
    T y;
    
    void add(T scalar) {
        this.x += scalar;
        this.y += scalar;
    }
}

void main() {
    Vec2<int> intVec;
    Vec2<decimal> decVec;
    
    intVec.add(2);
    decVec.add(2.0);
}

Vec2<T> is a generic type, T may refer to any type. Vec2<int> instantiates Vec2<T> with type int. The code in Vec2<T> is quite literally copied, replacing T with int. The copy is done once only, future references to Vec2<int> will use the existing copied code. (Implementation detail: T is not replaced, rather, it becomes an alias for int during the copy operation).

Type Hierarchy

It is possible to "extend" other types by setting a parent type, like so:

type Base {
    int a;
    void doSomething();
}

type Derived : Base {
    int b;
    void doOtherThing();
}

Note that it is not possible to override functions from parent types i.e. Derived cannot override doSomething(). This is because dynamic dispatch would be required (using a lookup table at runtime) which has not been implemented.

Singleton types

Singleton types are types where only one instance can exist. They can be compared to "namespaces" in other languages (but are not named as such to avoid confusion with Minecraft's namespace concept).

singleton SingleType {

    int var = 10;
    
    void foo() {
        var = 20;
    }

}

void main() {
    SingleType.foo();
}

The body inside the singleton is like the global scope; variables and functions can be declared. However things declared within the scope are prefixed with the name of the singleton. As seen in the example, function foo() can reference var without needing to qualify the name as SingleType.var.

Functions

Besides the regular function (e.g. void main()), there are a few more types of functions:

Inline Functions

inline int add(int a, int b) {
    return a + b;
}

void main() {
    int sum = add(1, 2);
}

An inline function has the effect of copying its body into wherever a call to it is made. Arguments and return values are mapped to the call site.

In this example, the code is transformed into:

void main() {
    int sum = 1 + 2;
}

Note that an inline function is not a macro (see below). The inline function is validated and compiled in its own function body. Only during the optimization phase does the body get substituted.

Macro Functions

Macro functions are snapshots of code that are substituted directly into the code at their call site

macro int add(int a, int b) {
    return a + b;
}

void main() {
    int sum = add(1, 2);
}

Unlike inline functions, which are inlined by the optimizer, a macro's body is substituted in the parse tree, meaning the body is compiled in the context where it is called from. Parameters and return variables are converted to variable assignments, i.e. in this example, the code is transformed into:

void main() {
    int a = 1;
    int b = 2;
    int ret_int = a + b;
    int sum = ret_int;
}

Macros are used to allow special types such as _IRType to work. _IRType cannot be passed as function arguments, therefore macros are used to appear like function calls that hide implementation details.

Constexpr Functions

Similar to macro types, except the body is executed at compile time to result in a constant value at runtime.

constexpr int mulNTimes(int a, int b, int n) {
    int total = 0;
    while(n--) {
        total += a * b;
    }
    return total;
}

void main() {
    int sum = mulNTimes(2, 3, 5);
}

In this nonsensical example, the mulNTimes function multiplies a and b together n times, summing the total (i.e. a * b * n). The compiler runs this code, generating the value 30 therefore rewriting the function as:

void main() {
    int sum = 30;
}

Async Functions

See CBL Syntax#await-expression