River is an experimental assembly-like programming language.
You can view the current main branch at https://riverlanguage.org
River is a programming language that that has three core goals:
- Fast and easy compilation - should be functionally instant on any modern machine for any program of any size, and easy to write your own compiler or backend with no programming language experience.
- Not text based - the language is built up from atomic tokens not ASCII text and needs to be written using a special editor. The goal is to avoid a number of issues caused by ASCII text such as typos, arguments about formatting / indentation, name resolution, and compilation complexity.
- Customisable control flow - the majority of the control flow and language structure is defined by the user as part of the program with the macro concept, instead of being built into the language.
At the moment River only supports keyboard interactions for the main editor.
The "cursor" is simply the currently highlighted instruction fragment.
Use the arrow keys to move the cursor between instructions and fragments - keyboard shortcuts will apply to the current selection.
The Enter
key is used to create a new instruction after the current one, a lot like a newline in a text file. Shift + Enter
creates a new instruction before the current one.
When you see values separated by a vertical bar inside the cursor, this indicates that there are values available for auto completion. In the case of a finite number of selections (such as instruction types) they are hotkeyed by the first letter.
(the instruction types above would be hotkeyed with s
, d
, a
, c
etc)
In the case of searchable values such as variable names, you can type alpha numeric keys to narrow down the list, and the enter
key will select the first matched value.
You can select multiple lines by holding the shift key and using the up
and down
arrow.
Once you've selected multiple lines, you can delete them all by pressing the backspace
key, or create a new macro from the selected lines by pressing the m
key.
As an assembly like programming language, river has no control structures, no functions and a very small set of instructions for doing work.
River is formatted with 1 instruction per line, and instructions are made up of fragments
which are seperated by spaces.
e.g the following is a def
instruction, with three fragments:
def index 32
The 5 basic instructions in river are:
usage: def [variable name] [8 | 16 | 32 | 64]
e.g. def index 32
Used to define variables and indicate their size in bits on the stack. Currently there are only 4 sizes of unsigned int available to use, but eventually fragment 3 will be replaced with a type
.
usage: assign [var] [= | + | - | * | / | %] [var | const]
e.g. assign var 0 = const 5
Used to modify a value defined with def
. Note that this modification, like in assembly, happens in place. The original value is not preserved.
For example, assign var 0 + const 1
would increment the variable at position 0 on the stack by 1
. The second operand can either be another variable or a constant value.
usage: compare [var | const] [= | != | < | <= | > | >=] [var | const]
e.g. compare var 0 < const 20
Performs a mathematical comparison and only runs the next instruction if the comparison returns true
. For example:
compare const 10 > const 20
assign var 0 = 10
The assign
instruction here will never execute because 10 > 20
will never be true
.
usage: scope [open | close]
e.g. scope open
Scopes in river are used to define boundaries for memory management and jump
instructions, and visibility of variables (they are lexical scopes). Any def
instructions that occur after a scope open
will be deallocated at the corresponding scope close
.
usage: jump [start | end]
e.g. jump start
Think goto
, but safer and easier to use. jump
either moves execution back to the beginning of the current scope
, or to the end of the current scope
. Used to construct control flow similar to while
or for
.
When an instruction needs to reference a value, such as in compare
or assign
, you can provide either:
- a variable with the keyword
var
followed by the variable's position on the stack, e.g.var 0
- a constant with the keyword
const
followed by the constant value (at this time only unsigned integers are supported) e.g.const 5
Constants only ever exist in registers and aren't saved to main memory, whereas allvar
values are saved to memory.
e.g. compare var 0 > const 10
tests whether the variable at position 0 on the stack has a value that is greater than 10.
Note that the editor allows you to use inline macros to provide expression-line value fragments.
River features an extremely basic memory management strategy. There is no dynamic allocation and no distinction between the stack and the heap - all defined variables are added to a linear growable memory structure at the beginning of their enclosing scope, and then are deallocated at the end of their enclosing scope.
scope open // 16 bytes (2x 64 bit values) allocated here, placed on top of the stack
def foo 64 // var 0
def var 64 // var 1
assign var 0 = const 5
assign var 1 = var 0
assign var 1 + const 5
os stdout var 0
os stdout var 1
scope close // 16 bytes deallocated here - highest 16 bytes removed from the stack
You can imagine it simply like a giant stack inside a single function. This means that all variable sizes must be known at compile time - array sizes & string sizes must be declared, and there is no such thing as dynamically resizable structures.
This of course can feel restrictive, but it makes the code much easier for the compiler to reason about, and allows us to apply things like range checks at compile time.
The actual river code in .rvr
files isn't great to look at. There's no indentation, and variables are only referenced by their position on the stack.
A program to print even numbers under 20:
def index 32 // Define a 32 bit variable at position 0 on the stack
def mod 32 // Define a 32 bit variable at position 1 on the stack
assign var 0 = const 0 // index = 0
scope open // Open a new scope to anchor jumps
assign var 1 = var 0 // mod = index
assign var 1 % const 2 // mod %= 2
compare var 1 == const 0 // if (mod === 0)
os stdout var 0 // print(index)
assign var 0 + const 1 // index += 1
compare var 0 < const 20 // if (index < 20)
jump start // jump to the most recent scope open
scope close // close the scope and wind back the stack pointer
The same program in the river editor with highlighting, indentation and variable names, with the output from the executed program on the right:
It looks better, but it's still reasonably painful and slow to write without the help of higher level language constructs such as a for
loop.
The way that river provides these language features is through a concept called macros.
Macros in river are essentially just a smart way of copy pasting code around. They have two main features:
- When they appear in code, macros are folded down into a single line and represented by the name of the macro.
- Macros can use a concept called "placeholders" which allow you to replace specific parts of instructions in the macro. They're kind of like function arguments.
Here is an example of a standard macro defined in river which is called for
, used to imitate the behaviour of a for loop in other languages.
You can see that the macro does a number of things, such as defining and incrementing variables. Notice the placeholder values denoted by the _
and the purple highlighting. Placeholders are values that are exposed and replaceable even when the macro is folded. This macro also includes a placeholder block denoted by _block
that allows you to add multiple instructions at that position.
When we apply the for
macro to our code, it first appears like this:
Notice how there are 3 exposed placeholder values after for
? These function as replacements for the values you would expect in a standard loop of the format:
for (let i = initial; i < max; i += increment) {
Below the folded macro line there are braces {
and }
indicating that there is a block placeholder, and you can see line 5
is exposed for you to place code into.
Here is our previous code to print even numbers under 20, but this time using the for
macro:
This is better, but it still feels a bit awkward to have to pre-declare a variable to hold mod
and use multiple assign
instructions. This is where we can use inline macros to condense things even more.
Standard macros in River like for
are folded down into their own line, and wrap their contents in a scope
so that they don't expose any variables to outside code.
Inline Macros define a variable in the outer scope, and are rendered inline in place of a variable fragment.
As an example, here is the definition of a very simple expr
macro that can be used to construct expressions:
Anywhere that you can use a variable fragment, such as assign
or compare
or even in the placeholder of another macro, you can use an inline macro.
Here is an example of using the expr
inline in place of a variable fragment in an assign
instruction.
Here is how you would write a standard expression assignment of aVariable = 2 + 3
from another programming language.
You can even nest inline macros inside eachother.
As a result, we can now take our program that printed even numbers under 20, and condense it even further using inline macros.
It's starting to look a lot more like a programming language. You can see that we've saved quite a few lines by using the escape
key to toggle macro folding on and off.
You can run your .rvr code in two ways:
- The editor has a built in "virtual machine" which you can use to execute your code and see the output, in the
VM
tab. - The
Assembly
tab provides live output of the corresponding assembly for your chosen target architecture and platform. At the moment river compiles to nasm assembly, but I'm not sure if it'll stay like that in future.
Currently supported platforms:
- x64 (Windows)
- x64 (Mac OS X)
- WebAssembly
You can see the corresponding assembly instructions highlighted in the asm tab as you're browsing the code:
Running WASM is a bit of a pain, but you can test out your code here: https://webassembly.github.io/wabt/demo/wat2wasm/
Using the following javascript:
const wasmInstance = new WebAssembly.Instance(wasmModule, {
console: { log: (num) => console.log(num) },
});
const { untitled } = wasmInstance.exports;
untitled();
River is currently full of bugs and incomplete functionality. Some of the next features for development are:
- Examples (project euler or something)
- Saving / loading files
- WebAssembly browser / wasmtime flavours
- Types (float, signed, bool first)
- Structs or something similar to structs
- Arrays and potentially tuple types
- More standard macros
- Binary rvr file representation