Installation & Getting Started
Requirements
Steplo is built using Rust, so you must have Rust installed to run the compiler.
Installation
## Clone the repo
git clone https://github.com/tetrogem/steplo.git
cd steplo
## (Optional) Create an output folder, or use an already existing folder
## Note: The local output folder named `out` is .gitignored, so it's safe to create/use!
mkdir out
## Compile a Steplo program
cargo run examples/hel.lo out --target scratch
This will generate a .sb3 file in the out/ directory.
You can now open the .sb3 in the Scratch editor and run your Steplo-compiled project!
Compiler Options
Run the following to see all CLI options:
cargo run -- -h
Next Steps
The rest of the chapters in the book will walk you through all of Steplo's features!
Hello World!
Let's start by learning how to create a simple Hello World program!
Start by creating a new file named helloworld.lo anywhere on your computer.
In Steplo, all programs need to have a single main procedure, located anywhere within the source code. A main procedure can be declared as follows:
main {
}
All of the code to be executed when the program starts goes in the body of the main procedure, which is surrounded by braces.
Let's try calling the built-in out function to print to stdout:
main {
out("Hello world!");
}
Now we can compile this program! The compiled Scratch program can be written to any directory on your system, specified by the compile command. In this example, we'll use a directory named out. To compile, we can then call:
cargo run helloworld.lo out --target scratch
This will create helloworld.s.sb3 inside of out, which we can load into Scratch to see our working program!
Compilations Targets
Steplo has two different targets it can compile to: Scratch and TurboWarp.
You can specify which platform you'd like to target with the --target flag:
# compiles targetting Scratch
cargo run helloworld.lo out --target scratch
# compiles targetting TurboWarp
cargo run helloworld.lo out --target turbowarp
How a Steplo project is compiled to a .sb3 file will change based on which platform is being targetted. These changes will be to optimize the program to run most optimially on the targetted platform.
A project compiled targetting Scratch will be outputted with a .s.sb3 extension, and compilations targetting TurboWarp will have the .t.sb3 extension.
Of course, since Scratch and TurboWarp both use .sb3 files, you can run a .t.sb3 on Scratch and a .s.sb3 on TurboWarp, but they will likely have worse performance than running the corresponding extension for that platform.
Variables
Stack Variables
Next, let's learn how to create variables on the stack. Stack variables are able to have their values modified, as well as their addresses stored as references. Stack variables live for the duration of the procedure they were declared for; That means once the procedure ends, any references to its stack variables will be invalid! Steplo does not currently perform checks for if references are valid for you, so this is something you'll need to keep in mind while programming.
Let's start by declaring a variable on our main procedure:
main {
let hello: str = "";
}
All stack variables are declared with the let keyword as a statement within a procedure. They also must have a type! In this case, we've created a variable hello with the type str, which is the primitive string type. Variables must also be initialized! You cannot create a variable without specifying its initial value. This ensures the variable can always be read to return a valid instance of its type.
We can then use this value in-place of the literal we printed in the previous chapter, passing it to out as a parameter:
main {
let hello: str = "Hello world!";
out(hello);
}
This program should have the same output as the previous "Hello World!" program!
You can also reassign a variable to a different value:
main {
let hello: str = "Hello world!";
hello = "Goodbye!";
}
You can declare as many stack variables as you want in a single procedure, and they can be declared anywhere within the procedure:
main {
let hello: str = "Hello";
out(hello);
let world: str = "World!";
out(world);
}
Variables can also be shadowed, where a variable can be declared with the same name as a previous variable:
main {
let hello: str = "Hello";
out(hello);
let hello: num = 5;
out((num * 2));
}
Shadowing variables do not need to have the same type as the previous variable with the same name, and it is not the same as reassigning a variable. In practice, you are creating a brand new variable, which only shares its name with the previous one. The shadowed variable is no longer accessible after shadowing, as its name now points to the new variable.
Variables are also block scoped:
main {
let hello: str = "Hello"; // `hello` exists in this block and all blocks within it
{
let world: str = "World!"; // `world` exists in this block and all blocks within it
out(hello);
out(world);
}
out(hello);
out(world); // Error! `world` is not in scope
}
Static Variables
Variables can also be declared as a static item in the top-level of a program, with the static keyword:
static GLOBAL: int = 0;
main {}
These variables are global, and can be accessed by any procedure within a program without being directly passed into them as arguments. One limitation with static variables though is they must be initialized with a constant value (e.g. a literal). Using other expressions, such as function calls or control flow items, are not currently supported. Though, static variables can later be re-assigned to any valid expression during program execution:
static GLOBAL: int = 0;
main {
GLOBAL = double(2);
}
fn double(x: int) -> int (x * 2)
Types
Primitive Types
| Name | Size (Cells) | Extends | Description |
|---|---|---|---|
| val | 1 | The base primitive type of Steplo | |
| bool | 1 | val | A boolean value, either true or false |
| str | 1 | val | A string value, representing text |
| num | 1 | val | 64-bit floating-point numbers (equivalent to TypeScript's number type or Rust's f64 type) |
| int | 1 | num | A 64-bit floating-point signed integer |
| uint | 1 | int | A 64-bit floating-point unsigned integer |
main {
let string: str = "hello world!";
let float: num = 12.34;
let integer: int = -7;
let index: uint = 10;
let maybe: bool = true;
}
Enum Types
Functionally, enums work similar to primitive types, except they are user-defined. Each enum is given a set of variants that are applicable to its type. For example, an enum for the state of a video player may be represented as:
enum PlayerState { Playing | Paused | Stopped }
You can assign to variables of an enum type with variant literals, which consist of the enum name, a # (tag symbol), and then the name of the variant:
main {
let state: PlayerState = PlayerState#Playing;
state = PlayerState#Paused;
state = PlayerState#Stopped;
}
In some cases where the type can be inferred by the compiler, the enum name can be omitted from the variant literal:
main {
// state is a PlayerState
// so each variant literal below must also be a PlayerState
let state: PlayerState = #Playing;
state = #Paused;
state = #Stopped;
}
Enums also have an inherent order, to allow for use with the greater than (>) and less than (<) operators. The order of the variants will be determined by the order they were declared on the enum declaration. So in the previous PlayerState example, #Playing < #Paused < #Stopped would be true. All enums are 1 cell in size, as they are internally represented by a uint.
Reference Types
All reference types start with & and can be reference to any other type. They are all 1 cell in size, no matter the size of the type they are pointing to. They are represented in memory by the memory address of the value they are pointing to. No reference types are subtypes of any other reference type.
main {
let integer: int = 10;
let int_ref: &int = &integer;
}
Array Types
Array types are denoted with the following syntax: [<element>; <length>], where <element> is the type of each element in the array, and <length> is a uint denoting how many elements the array will contain. Arrays are all fixed-size, they cannot grow or shrink. All elements in an array must be of the same type. For example, an array of 10 ints would be typed as: [int; 10].
main {
let nums: [num; 5] = [1, 2, 3, 4, 5];
}
Sometimes you'll have very long arrays, where typing each individual element to set isn't feasible, especially when you'd likely want to set all of the elements to a common "default" value. Luckily, you can use the spread ... operator in an array literal to do this for you:
main {
let long_arr: [int; 999] = [0...]; // sets all elements to 0
long_arr = [1, 2, 3, 4, 0...]; // sets the first 4 elements, then the rest to 0
let short_arr: [int; 3] = [1, 2, 0...]; // works on all array lengths!
short_arr = [1, 2, 3, 0...]; // a spread element can still be declared even if it'd go unused
}
Struct Types
Struct types are denoted with braces surrounding key/value pairs: { <field>: <type> }, where <field> is the name of a struct field, and <type> is the type of that field. Unlike arrays, fields in a struct can be of different types. The order and names of struct fields matter as well; If these mismatch between two types, they are not assignable to eachother.
main {
coords: { x: num, y: num } = { x: 10, y: -5.5 };
user: { id: uint, name: str } = { id: 1234, name: "Steplo" };
}
The any Type
Any is a special type that is a supertype of any type that is 1 cell in size. This includes all primitives, references, and arrays with a single 1 cell-sized element.
main {
let integer: int = 10;
let anything: any = "hello!";
anything = 10;
anything = true;
anything = &integer;
}
Type Aliases
Retyping complex compound types (e.g., arrays, structs) is tedious and error-prone. Instead of retyping an entire type definition each time it is used, a type alias can be declared, allowing for a shorter name to be used in-place of a more complex type:
type Link = {
name: str,
redirect: str,
};
type Message = {
created_time: uint,
sender_name: str,
likes: uint,
content: {
body: str,
links: [Link; 2],
},
};
main {
let msg: Message = {
created_time: 123456789,
sender_name: "Steplo",
likes: 4,
content: {
body: "Hello world!",
links: [
{ name: "GitHub", redirect: "https://github.com/tetrogem/steplo" },
{ name: "Book", redirect: "https://steplo.tetro.dev/" },
],
},
};
}
Supertypes, Subtypes, & Type Coercion
Types are able to automatically be coerced into their supertypes.
main {
let bar: uint = 10;
let foo: int = bar; // `uint` is a subtype of `int`, so it can be coerced to type `int`
}
Array Types
An array type A is a subtype of another array type B if:
- The element type of
Ais a subtype of the element type ofB - The two array types are the same length
main {
let ints: [int; 5] = [1, 2, 3, 4, 5];
let nums: [num; 5] = ints;
}
Struct Types
A struct type A is a subtype of another struct type B if:
A&Bshare the same number of fields, with the same names, in the same order- The type of a given field in
Ais a subtype of that same field's type inB
main {
let coords: { x: num, y: num } = { x: 1.1, y: -2 };
let vals: { x: val, y: val } = coords;
}
Reference Types
A reference type &A is a subtype of another reference type &B only if A & B are isomorphic, meaning A is a subtype of B and B is a subtype of A.
Unary Operaters
Unary operators can create expressions that take one other expression as input. An example of a binary operator is the not ! operator:
main {
out(!true); // prints false
}
All the currently existing unary operators and the types they evaluate to:
| Operator | Operand Type | = Evaluated Type |
|---|---|---|
| ! (not) | bool | bool |
Binary Operaters
Binary operators can create expressions that take two other expressions as input. An example of a binary operator is the add + operator:
main {
out((1 + 2)); // prints 3
}
Currently, all binary operator expressions must be wrapped in parentheses to denote the order of evaluation. Operator precedence will be added in the future, allowing for parentheses to be omitted to utilize a more typical default order of operations.
All the currently existing binary operators and the types they evaluate to:
| Left Type | Operator | Right Type | = Evaluated Type |
|---|---|---|---|
| uint | + (add) | uint | uint |
| int | + (add) | int | int |
| num | + (add) | num | num |
| int | - (subtract) | int | int |
| num | - (subtract) | num | num |
| uint | * (multiply) | uint | uint |
| int | * (multiply) | int | int |
| num | * (multiply) | num | num |
| num | / (divide) | num | num |
| num | % (mod) | num | num |
| bool | && (and) | bool | bool |
| bool | || (or) | bool | bool |
| str | ~ (join) | val | val |
Comparison Operators
Comparison operators can be used between any two times that are comparable with eachother.
- Strings are comparable with other strings
- Numbers are comparable with other numbers
- Booleans are comparable with other booleans
- Enum variants are comparable with variants of the same enum
Booleans are not able to be compared ordinally. (Greater than / Less than)
| Operator | = Evaluated Type |
|---|---|
| == (equal to) | bool |
| != (not equal to) | bool |
| > (greater than) | bool |
| < (less than) | bool |
| >= (greater than or equal to) | bool |
| <= (less than or equal to) | bool |
Functions & References
Functions
You can declare functions to be able to reuse code throughout your program.
main {
// prints "Hello world!" 3 times
hello();
hello();
hello();
}
fn hello() {
out("Hello world!");
}
You can also declare arguments for functions that must be passed to the function to call it:
main {
// prints "Hello Steplo!"
hello("Steplo");
}
fn hello(name: val) {
out((("Hello " ~ name) ~ "!"));
}
Functions evaluate and return their body expression. If they do not return the unit struct {}, their return types must be defined with ->:
main {
let doubled = double(5); // 10
let rem = remainder(6, 4); // 2
}
fn double(x: num) -> num (x * 2)
fn remainder(numer: num, denom: num) -> num {
let quotient_floor = num_floor((numer / denom));
(numer - (quotient_floor * denom))
}
References
You can also pass references into functions in order to mutate values on other procedures' stacks. References are created with the reference & operator. You can then assign to the memory address pointed to by a reference with the dereference * operator. This allows you to create functions that return values:
main {
add(&sum, 1, 2);
out(sum); // prints 3
}
fn add(result: &int, a: int, b: int) {
*result = (a + b);
}
Functions can declare their own stack variables, too. (Remember though, these variables are only valid for the duration of the function's runtime, so don't return references to a function's stack variables to its caller!)
main {
let sum: int = 0;
add(&sum, 1, 2);
out(sum); // prints 3
}
fn add(result: &int, a: int, b: int) {
let sum = (a + b);
*result = sum;
}
Built-In Functions
The following items are automatically added to and available in every Steplo program.
Public
These items are APIs intended to be used by developers in their applications.
enum Key { Space | UpArrow | DownArrow | RightArrow | LeftArrow | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | S | T | U | V | W | X | Y | Z | Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 }
Represents a specific detectable keyboard key.
type KeyEvent = { key: Key, time: num }
Represents a key event sent by the user. key is the key interacted with, and time is the time the key was interacted with in seconds since 00:00:00 1 January 2000 (UTC).
fn out(value: val)
Prints the given value to stdout.
fn in() -> str
Prompts the user for text input, and pauses the program's execution until it's received. Returns the text inputted by the user.
fn num_random(min: num, max: num) -> num
Returns a random num between min and max (inclusive). The returned number may always generate with decimals, even if both min and max are integers.
fn int_random(min: int, max: int) -> int
Returns a random int between min and max (inclusive).
fn uint_random(min: uint, max: uint) -> uint
Returns a random uint between min and max (inclusive).
fn uint_round(x: uint) -> uint
Rounds the given uint, while maintaining the invariant that it is unsigned. I honestly don't know why I implemented this function though, the number you pass in will already be an integer.
fn num_round(x: num) -> int
Rounds the given num, safely converting it to an int.
fn uint_ceil(x: uint) -> uint
Returns the ceiling of the given uint, while maintaining the invariant that it is unsigned. These uint rounding functions may be removed in a future release unless someone can actually find a use for them.
fn num_ceil(x: num) -> int
Returns the ceiling of the given num, safely converting it to an int.
fn int_abs(x: int) -> uint
Returns the absolute value of the given int, while maintaining the invariant that it is an integer. As the absolute value is always positive, this function can safely return a uint.
fn num_abs(x: num) -> int
Returns the absolute value of the given num, safely converting it to an int.
fn stdout_clear()
Clears stdout.
fn stdout_read(index: uint) -> str
Reads and returns the value of line number index (starting at 0) of stdout.
fn stdout_write(value: val, index: uint)
Overwrites the value of line number index (starting at 0) of stdout with value.
fn stdout_len() -> uint
Returns the number of lines currently printed to stdout.
fn key_events_len() -> uint
Returns the length of the Key Events queue (how many unpolled key events exist).
fn key_events_has_next() -> bool
Returns true if the Key Events queue is not empty.
fn key_events_next() -> KeyEvent
Pops and returns the next KeyEvent from the Key Events queue.
fn wait_s(duration_s: num)
Pauses execution of the program for approximately duration_s seconds. The implementation of this function is subject to change in the future to allow for more accurate waiting durations.
fn timer_s() -> num
Returns the number of seconds since the start of the program.
fn since_2000_days() -> num
Returns the number of days since 00:00:00 1 January 2000 (UTC).
Internal
These items are used to implement Public APIs and are intended for internal use by the compiler only. They are publicly accessible in Steplo programs, but are not recommended for use (as they could break invariants held by Public APIs if not used properly)
fn key_events_key_queue_clear()
Clears the Key Events' Key Queue. Not clearing the Key Events' Time Queue at the same time will cause them to possibly go out of sync. See key_events_len, key_events_has_next, and key_events_next for the Public version of this API.
fn key_events_key_queue_delete(index: uint)
Delete the element at index index in the Key Events' Key Queue. Not deleting the same element index in the Key Events' Time Queue at the same time will cause them to possibly go out of sync. See key_events_len, key_events_has_next, and key_events_next for the Public version of this API.
fn key_events_key_queue_read(index: uint) -> Key
Returns the element at index index in the Key Events' Key Queue. Not deleting the same element index in the Key Events' Time Queue at the same time will cause them to possibly go out of sync. See key_events_len, key_events_has_next, and key_events_next for the Public version of this API.
fn key_events_key_queue_len() -> uint
Returns the length of the Key Events' Key Queue. See key_events_len, key_events_has_next, and key_events_next for the Public version of this API.
fn key_events_time_queue_clear()
Clears the Key Events' Time Queue. Not clearing the Key Events' Key Queue at the same time will cause them to possibly go out of sync. See key_events_len, key_events_has_next, and key_events_next for the Public version of this API.
fn key_events_time_queue_delete(index: uint)
Delete the element at index index in the Key Events' Time Queue. Not deleting the same element index in the Key Events' Key Queue at the same time will cause them to possibly go out of sync. See key_events_len, key_events_has_next, and key_events_next for the Public version of this API.
fn key_events_time_queue_read(index: uint) -> num
Return the element at index index in the Key Events' Time Queue. The returned value will be a time in seconds since 00:00:00 1 January 2000 (UTC). Not deleting the same element index in the Key Events' Key Queue at the same time will cause them to possibly go out of sync. See key_events_len, key_events_has_next, and key_events_next for the Public version of this API.
fn key_events_time_queue_len() -> uint
Returns the length of the Key Events' Time Queue. See key_events_len, key_events_has_next, and key_events_next for the Public version of this API.
Control Flow
If Expressions
If expressions will only evaluate and resolve to their body if their condition is true. The condition must be a bool, and does not have to be in parentheses (though because a binary operator expression is used below, which do require parentheses in all positions, it looks like it does)
main {
let x: int = 20;
if (x > 10) {
out("Greater than 10!"); // condition is met, so this is printed
}
if (x > 50) {
out("Greater than 50!"); // condition is not met, so this is not printed
}
out("Done!"); // this always prints
}
Else Expressions
main {
let x: int = 5;
if (x > 10) {
out("Greater than 10!");
} else {
out("Less than (or equal to) 10!"); // condition is not met, so the `else` statement executes and prints
}
out("Done!"); // this always prints
}
Else If Expressions
main {
let x: int = 10;
if (x > 10) {
out("Greater than 10!");
} else if (x == 10) {
out("Equal to 10!"); // condition is met, so this `else if` statement executes and prints
} else {
out("Less than 10!");
}
out("Done!"); // this always prints
}
While Expressions
While expressions will loop and evaluate their body as long as their condition is true. Their condition is rechecked to be true after each loop. The condition must be a bool, and does not have to be in parentheses (though because a binary operator expression is used below, it looks like it does)
main {
let i: int = 10;
while (i > 0) {
out(i);
i = (i - 1);
}
// this program will output: `10 9 8 7 6 5 4 3 2 1`
}
Match Expressions
Match expressions allow for exhaustively evaluating a certain case based on the current variant of an enum value. Since they must be exhaustive, unlike if expressions, there must be a case inside of the switch for each enum variant that the value could be; Omitting any will cause the code to no longer compile.
enum PlayerState { Playing | Paused | Stopped }
main {
let state: PlayerState = #Paused;
// will print "The video is paused!"
match state {
#Playing -> out("The video is playing!")
#Paused -> out("The video is paused!")
#Stopped -> {
out("The video is stopped!");
out("Please select a new video to watch.");
}
}
}
Control Flow as Expressions
All control flow items in Steplo are expressions. This means they can be evaluated and used in the position of any other expression, e.g. variable assignment, operands, or function arguments.
enum PlayerState { Playing | Paused | Stopped }
main {
let parity: str = if ((5 % 2) == 0) { "even" } else { "odd" };
let state: PlayerState = #Paused;
let message: str = match state {
#Playing -> "The video is playing!"
#Paused -> "The video is paused!"
#Stopped -> {
out("Please select a new video to watch.");
"The video is stopped!"
}
};
out(message);
}
Type Casting & Transmutation
Casting
You can cast a type A to another type B if they both have the same underlying representation, and the cast would not break any invariants of type A or type B.
Unlike type coercion, casts must be explicitly declared with the typecast <type> operator.
The underlying representation of a type is how it is stored in memory as primitive types. For example, an array of two uints [uint; 2] and a struct containing only two uint fields { foo: uint, bar: uint } would both be stored in memory as 2 cells, both containing uints. Because of this, they can be casted between each other:
main {
let arr: [uint; 2] = [123, 456];
let str: { foo: uint, bar: uint } = <{ foo: uint, bar: uint }>arr;
arr = <[uint; 2]>str;
}
There are some types that you cannot cast to or from though, even if they have the same underlying representations. Most notably, this includes reference types. This is because casting would break the invariant of a reference being a memory address to a specific type (note: the programmer is still responsible for upholding that the reference is alive and hasn't been freed before use). As all references are underlyingly represented by uints, casting would allow for a reference to any type to be casted to a reference of any other type, which is not safe behavior.
main {
let x: num = 10;
let x_ref: &num = &x;
// this is not allowed!
let str_ref: &str = <&str>num_ref;
}
Instead, if you must convert between reference types, you would need to perform a transmutation.
Transmuting
Any type can be transmuted to any other type of the same size. Unlike casting, no additional checks are done to ensure this conversion makes sense or is safe. You can transmute with the transmute <<type>> operator:
main {
let x: int = 10;
let x_ref: &int = &x;
let y_ref: &num = <<&num>>x_ref;
*y_ref = 12.34;
out(x); // `x` is now no longer an integer, without ever directly assigning a non-integer to it with a transmutation
}
Undefined Initialization
Steplo usually requires all variables to be initialized on definition, to ensure they allows contain a valid value for their type when used. In some cases though, this value may be expensive to create, and would only be set temporarily to statisfy the compiler. In these cases, you can explicitly tell the compiler to instead not initialize the memory of a variable when declared. It is then up to the programmer though to ensure they use the variable safely. Undefined initialization can be done by assigning a variable to undefined.
main {
let expensive: [arr; 9999] = undefined;
}