LTCTWA is a low-level statically-typed C-like language that compiles to WebAssembly text format.
There is an interactive demo available on GitHub Pages.
The language is written in typescript and has to be run with Deno.
Compiling a source file into WAST:
deno run --allow-read ./src/index.ts ./source-file.ltctwa > ./result-file.wast
You will need a separate compiler to convert the result into WASM binary. WABT is a good tool for this task.
Running unit tests:
deno test --allow-read
Create a file named main.ltctwa
and paste the following contents into it:
func export inc(arg: i32): i32 {
return arg + 1;
}
Use deno run --allow-read ./src/index.ts ./main.ltctwa > ./output.wast
to
compile to WebAssembly text format.
This will produce the following output:
(module
(func
$inc
(export "inc")
(param i32)
(result i32)
local.get 0
i32.const 1
i32.add
return
)
)
The next step is to compile the result into WebAssembly binary, which can be
done with wat2wasm ./dist/compiled.wast -o ./dist/main.wasm
.
Now, the resulting code may be imported and executed from JavaScript:
async function main() {
const wasm = await getWasm();
const result = wasm.exports.inc(15);
console.log(result);
}
async function getWasm() {
const bytes = await fetch("main.wasm").then((res) => res.arrayBuffer());
const module = await WebAssembly.compile(bytes);
return WebAssembly.instantiate(module);
}
There are 7 basic types:
- Signed integers:
i32
,i64
- Unsigned integers:
i32
,i64
; - Floats:
f32
,f64
; - Void (only available for function returns):
void
There are also pointer types, which can only point to sections of memory, not to other variables.
Pointer type is prepended with a $
symbol: $i32
, $$u64
(pointer to
pointer) etc.
There is no void pointer for generic sections of memory; a pointer to i32
can
be used instead.
A program consists of a series of statements that are terminated by the
semicolon (;
).
There are 5 kinds of statements:
- Conditional Statement (
if
) - Loop Statement (
while
) - Return Statement (
return
) - VariableDeclaration Statement (operator
=
) - Expression Statement (any expression terminated with a semicolon)
There are 7 kinds of expressions:
- Identifier Expression (variables/constants/parameters access)
- Numeric Expression (numeric literals)
- Function Call Expression (functionName(arg1value, arg2value))
- Unary Operator Expression (-val, !val, ...)
- Binary Operator Expression (val1 + val2, val1 << val2, ...)
- Type Conversion Expression (a special kind of binary operator expression: val -> type)
- Composite Expression (any expression inside parentheses)
Values of non-void types can be created with numeric literals:
var signed: i32 = -1;
var unsigned: u32 = 1u;
var long: i32 = 1l;
var unsignedLong: i32 = 1ul;
var float: f32 = 1.;
var double: f64 = 1.l;
Only single-line comments are supported. Use //
for comments:
// This is a comment
var someVar: i32 = 15; // Also a comment
Implicit type conversions are not allowed. All type changes have to be performed
explicitly using the type conversion operator ->
:
const float: f32 = 5. + 1 -> f32;
const float: f32 = 5. + 1; // This would fail
Type conversions can also be chained. This is useful for converting negative floats to unsigned integer, as this will cause a runtime error in WASM:
const unsignedLong: u64 = -1. -> i64 -> u64;
There are three kinds of functions: plain (internal, not available in JS), export (available both in module and JS), import (imported from JS).
Each of them has to have an explicit result type.
Examples:
func plainFunction(arg1: i32, arg2: i32): i32 {
return arg1 & arg2;
}
func export exportFunction(arg1: i32, arg2: i32): i32 {
return arg1 & arg2;
}
func import(console::log) console_log(arg: i32): void;
Import object is used to pass import function's body from JS.
For example, to use import function from above it has to be passed like this:
WebAssembly.instantiate(module, {
console: {
log: console.log,
},
});
Each function parameter has to have an explicit non-void type.
All parameters are constant.
All variables and constants have to be initialized from the start, and have to have an explicit type:
var someVariable: i32 = 3;
const someConstant: f32 = someFloatArgument;
Variables, constants and parameters can be re-declared with a different type:
func redeclarationFunc(arg: i32): void {
const arg: f32 = arg -> f32;
const arg: u64 = 15ul;
var someVar: i32 = 3;
var someVar: i64 = someVar -> i64;
}
Variables are block-scoped:
const someValue: i32 = 1;
if (1) {
const someValue: f32 = 2.;
console_log(someValue); // The value is now 2.0
}
If a variable is not declared in the current scope, its value is taken from the outer one:
var someVar: i32 = 1;
if (1) {
console_log(someVar); // The value is still 1
// If we change it here, the outer variable will be affected
someVar = 2;
const someVar: i32 = 3;
console_log(someVar); // Inside this block of code, someVar's value is now 3
}
console_log(someVar); // But the outer value is still 2
Operators precedence table with explanation:
Precedence | Operator(s) | Description | Accepted value(s) | Return value |
---|---|---|---|---|
1 | ! @ - |
Logical NOT Pointer dereference Unary minus |
Integers Pointers Non-void |
Same as operands |
2 | * / |
Multiplication Division |
Non-void | Same as operands |
3 | + - |
Addition Subtraction |
Non-void | Same as operands |
4 | > < >= <= |
Greater than Less than Greater than or equal to Less than or equal to |
Non-void | i32 |
5 | == != |
Equal to Not equal to |
Non-void | i32 |
6 | << >> |
Left shift Right shift |
Integers | Same as operands |
7 | & |
Logical AND | Integers | Same as operands |
8 | ^ |
Logical XOR | Integers | Same as operands |
9 | | |
Logical OR | Integers | Same as operands |
10 | = |
Assignment | Identifier = Non-void |
void |
11 | -> |
Type conversion | Non-void -> type |
type |
Pointer type is also included in the non-void values category.
Just as with variable assignment, there is no implicit conversion, so both operands have to be the same type. The only two exceptions to this rule are assignment and type conversion operators.
There are three control flow statements: conditional, loop, and return statements.
Examples:
func loopTest(arg: u32): i32 {
var someVar: i32 = 1;
// Conditional and loop statements accept a value of type `i32`
while (someVar < arg) {
if (someVar == 256) {
return 256;
}
someVar = someVar * 2;
}
return someVar;
}
Pointers are represented as i32
internally.
Pointer arithmetic is available, but all adresses are counted in bytes. Example:
// All values have to be converted into pointers
var pointerToInt: $i32 = 0 -> $i32;
var pointerToNextInt: $i32 = pointerToInt + 4 -> $i32;
In C, second variable's value would have been calculated as pointerToInt + 1
.
The expression would be the same as
(byte*)pointerToInt + (1 * sizeof(*pointerToInt))
.
In LTCTWA, such implicit calculation is absent.
To manipulate a value that is pointed to, dereference operator "@
" is used:
var somePointer: $i32 = 100 -> $i32;
@somePointer = 1;
@(somePointer + 4) = 2;
const two: i32 = @(104 -> $i32);
var pointerToPointer: $$i32 = 200;
@pointerToPointer = somePointer;
const one = @@pointerToPointer;
const two = @(@pointerToPointer + 4 -> $i32);
There is no way to get an adress of a variable on the stack, only adresses of memory can be used.
Just like functions, there are three ways to declare memory: plain (only used within a module), export (may be accessed from JS), and import (imported from JS).
There may only be one memory declaration per module.
Syntax examples:
memory(1u);
// This can be accessed in JS as `instance.exports.exportMem`
memory(1u) export(exportMem);
// For this to work an instance of `WebAssembly.Memory` has to be passed to the import object
memory(1u) import(memoryNamespace::memoryName);