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.
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 one 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 |hello: val| {
}
All stack variables are declared in the two pipes | |
at the start of a procedure. The also must have a type! In this case, we've created a variable hello
with the type val
. val
is the supertype of all other primitive types in Steplo.
Now, let's assign a value to our variable:
main |hello: val| {
hello = "Hello world!";
}
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 |hello: val| {
hello = "Hello world!";
out(hello);
}
This program should have the same output as the previous "Hello World!" program!
Note: Variables are not initialized with a value automatically; If you use a variable before setting it yourself, the exact value it will contain is undefined.
You can also declare multiple variables in a single procedure:
main |hello: val, world: val| {
hello = "Hello";
world = "World!";
out(hello);
out(world);
}
Types
Primitive Types
Name | Size (Cells) | Extends | Description |
---|---|---|---|
val | 1 | The base primitive type of Steplo, equivalent to strings in other languages | |
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 |
bool | 1 | val | A boolean value, either true or false |
main |string: val, float: num, integer: int, index: usize, maybe: bool| {
string = "hello world!";
float = 12.34;
integer = -7;
index = 10;
maybe = true;
}
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 |integer: int, int_ref: &int| {
integer = 10;
int_ref = &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 |nums: [num; 5]| {
nums = [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 |long_arr: [int; 999], short_arr: [int; 3]| {
long_arr = [0...]; // sets all elements to 0
long_arr = [1, 2, 3, 4, 0...]; // sets the first 4 elements, then the rest to 0
short_arr = [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 }, user: { id: uint, name: val }| {
coords = { x: 10, y: -5.5 };
user = { 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 |anything: any, integer: int| {
integer = 10;
anything = "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: val,
redirect: val,
};
type Message = {
created_time: uint,
sender_name: val,
likes: uint,
content: {
body: val,
links: [Link; 2],
},
};
main |msg: Message| {
msg = {
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 |foo: int, bar: uint| {
bar = 10;
foo = 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
A
is a subtype of the element type ofB
- The two array types are the same length
main |nums: [num; 5], ints: [int; 5]| {
ints = [1, 2, 3, 4, 5];
nums = ints;
}
Struct Types
A struct type A
is a subtype of another struct type B
if:
A
&B
share the same number of fields, with the same names, in the same order- The type of a given field in
A
is a subtype of that same field's type inB
main |coords: { x: num, y: num }, strs: { x: val, y: val }| {
coords = { x: 1.1, y: -2 };
strs = 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 |
any | == (equal to) | any | bool |
any | != (not equal to) | any | bool |
any | > (greater than) | any | bool |
any | < (less than) | any | bool |
any | >= (greater than or equal to) | any | bool |
any | <= (less than or equal to) | any | bool |
bool | && (and) | bool | bool |
bool | || (or) | bool | bool |
val | ~ (join) | val | val |
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();
}
func 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");
}
func hello(name: val) || {
out((("Hello " ~ name) ~ "!"));
}
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 |sum: int| {
add(&sum, 1, 2);
out(sum); // prints 3
}
func add(return: &int, a: int, b: int) || {
*return = (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 |sum: int| {
add(&sum, 1, 2);
out(sum); // prints 3
}
func add(return: &int, a: int, b: int) |sum: int| {
sum = (a + b);
*return = sum;
}
Built-In Functions
The following functions are automatically added to and available in every Steplo program:
func out(value: any)
Prints the given value to stdout.
func in(return: &val)
Prompts the user for text input, and pauses the program's execution until it's received.
random_num(return: &num, min: num, max: 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.
random_int(return: &int, min: int, max: int)
Returns a random int
between min
and max
(inclusive).
random_uint(return: &uint, min: uint, max: uint)
Returns a random uint
between min
and max
(inclusive).
stdout_clear()
Clears stdout.
stdout_read(return: &val, index: uint)
Reads the value of line number index
(starting at 0) of stdout.
stdout_write(value: val, index: uint)
Overwrites the value of line number index
(starting at 0) of stdout with value
.
stdout_len(return: &uint)
Returns the number of lines currently printed to stdout.
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.
timer_s(return: &num)
Returns the number of seconds since the start of the program.
Control Flow
If Statements
If statements will only execute their body as long as 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, it looks like it does)
main |x: int| {
x = 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 Statements
main |x: int| {
x = 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 Statements
main |x: int| {
x = 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 Statements
While statements will loop and execute 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 |i: int| {
i = 10;
while (i > 0) {
out(i);
i = (i - 1);
}
// this program will output: `10 9 8 7 6 5 4 3 2 1`
}
Type Casting & Transmutation
Casting
Casting is the inverse of type coercion, and must be manually declared and guaranteed by the programmer. For example, if we coerced an int
to a val
like so:
main |foo: val, bar: int| {
bar = 10;
foo = bar; // automatically coerces `int` to `val`
}
We cannot automatically do the opposite to get our int
back from the val
variable:
main |foo: val, bar: int| {
bar = 10;
foo = bar;
bar = foo; // error: `foo` may be a `val` that is not an `int` (e.g, a `num`, `bool`, or string)
}
Since in this case, we know bar is 10
, we can tell the compiler we are sure the value of foo
is in fact assignable to an int
with a typecast. You can typecast with the cast <type>
operator:
main |foo: val, bar: int| {
bar = 10;
foo = bar;
bar = <int>foo; // no error!
}
This is dangerous though; In the future, code could be added that breaks this guarentee, which could lead to runtimes errors the compiler won't warn about:
main |foo: val, bar: int| {
bar = 10;
foo = bar;
foo = "hello world"!;
bar = <int>foo; // this isn't actually an int anymore, but the compiler gives no warning!
}
When is Casting Allowed?
You can't cast all types to all other types. You can only cast type A
to type B
if the following conditions are met:
B
is a subtype ofA
OR The in-memory representation ofA
is a subtype ofB
B
contains no reference types
For example: Casting type val
to int
is ok, because:
- ✅
int
is a subtype ofval
- ✅
val
is not a reference type
Another example: Casting type &int
to val
is ok, because:
- ✅ The in-memory representation of
&int
(which is auint
) is a subtype ofval
- ✅
val
is not a reference type
Now for an example of a cast that is not allowed: You cannot cast from a &any
to a &int
, because:
- ❌
&int
is not a subtype of&any
- ❌
&int
is a reference type
You also cannot cast from an any
to a &int
, because:
- ✅
&int
is a subtype ofany
- ❌
&int
is a reference type
This last cast is disallowed as treating an arbitrary address as a &int
may break the invariants of the type actually located at that memory address. Casts ensure that they only may possible break the resulting expression being casted, not affecting other values in the program. In order to tell the compiler you'd like to consider a reference to be a different type, you'd need to employ transmuting.
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. You can transmute with the transmute <<type>>
operator:
main |integer: int, int_ref: &int, float_ref: &num| {
integer = 10;
int_ref = &integer;
float_ref = <<&num>>int_ref;
*float_ref = 12.34;
out(integer); // integer is now a float without ever directly assigning a float to it with a cast
}