⏏️

Quince

A WASM programming language

This is still in progress and subject to change, maybe eternally.

This is an idea for a programming language I have had in mind for a while. What if the language compiled to WASM because it wanted to?

Languages that compile to WASM, generally and as far as I can tell, fall into three categories.

  1. Rust, Zig, and the like: languages that support WASM as yet another backend.
  2. As a vehicle to run on the web. In lieu of compiling to, say, JavaScript.
  3. It is an implementation detail and the author would really rather just write LLVM IR instead.

There is Grain, where exporting functions is not a priority, (which is crazy to me, but I digress.) There is also Onyx and Wa. These languages are all interesting, are all cool, but they all use WASM as means to an end.

One language that does seem closest to what I have in mind is AssemblyScript, although frankly I could never get past the npm init step in their Getting Started. It does seem a bit tied up in TypeScript land with TypeScript expectations.

For Quince, WASM is the end. WASM features are (well, in theory) exposed directly to the user. To import a function, write import(env) fn. To declare a global, declare a binding in global scope. To export linear memory, write export mem memory.1 It also makes use of stabilized WASM features, like GC,1 multiple memories,1 SIMD,1 threads,1 the works. The eventual goal is for the user to be able to implement WASI (or WASIX, or whatever) in userland, without relying on compiler magicks and hidden vairables.


A small taste

Here is the work-in-progress/stream-of-thought repo.

The compiler is extremely barebones right now. There is only one type, int, although it does support multiple returns. Here is Fibonacci implemented in the language.

Each of the below snippets are complete modules, which you can assemble in your favorite WASM assembler and run in your favorite runtime. I have been using Binaryen and Deno, respectively.

Hello World

I have not implemented memory or strings yet, so it is done the old fashioned way.

import(env) fn putchar(c: int) void;
import(env) fn flush() void;

export fn greet() int {
	defer flush();

	putchar(c: 72);
	putchar(c: 101);
	putchar(c: 108);
	putchar(c: 108);
	putchar(c: 111);
	putchar(c: 44);
	putchar(c: 32);
	putchar(c: 87);
	putchar(c: 111);
	putchar(c: 114);
	putchar(c: 108);
	putchar(c: 100);
	putchar(c: 33);

	return 0;
}

🔽

(func $putchar:c
	(import "env" "putchar")
	(param $0 i32)
)
(func $flush
	(import "env" "flush")
)
(func $greet
	(export "greet")
	(result i32)

	i32.const 72
	call $putchar:c
	i32.const 101
	call $putchar:c
	i32.const 108
	call $putchar:c
	i32.const 108
	call $putchar:c
	i32.const 111
	call $putchar:c
	i32.const 44
	call $putchar:c
	i32.const 32
	call $putchar:c
	i32.const 87
	call $putchar:c
	i32.const 111
	call $putchar:c
	i32.const 114
	call $putchar:c
	i32.const 108
	call $putchar:c
	i32.const 100
	call $putchar:c
	i32.const 33
	call $putchar:c
	i32.const 0
	call $flush
	return
)

Globals

I implemented those most recently.

import(env) const WIND: int;
const COUNTER_WIND: int = WIND * 2;

export fn echo() int {
	return COUNTER_WIND;
}

🔽

(global $0
	(import "env" "WIND")
	i32
)
(global $1
	i32
	global.get $0
	i32.const 2
	i32.mul
)
(func $echo
	(export "echo")
	(result i32)

	global.get $1
	return
)

Lazy Compilation

Functions only so far.

import(env) fn random() int;
export fn do_something(input: int) int {
	return 16;
}
fn unused_function() int {
	return non_existent_func();
}

🔽

(func $do_something:input
	(export "do_something")
	(param $0 i32)
	(result i32)

	i32.const 16
	return
)

Peephole optimizations and Constant Folding

I got a bit carried away with small easy picking optimizations.

module random;
export fn random() int {
	return -(300/(3*15));
}

export fn mystery(lhs: int, rhs: int) int {
	return if ((lhs <= rhs) and (true and false)) -100 else 100;
}

🔽

(module $random
	(func $random
		(export "random")
		(result i32)

		i32.const -6
		return
	)
	(func $mystery:lhs:rhs
		(export "mystery")
		(param $0 i32)
		(param $1 i32)
		(result i32)

		i32.const 100
		return
	)
)

Loops

Only while so far. I don't think I wll add more.

export fn find_first_divisor(n: int) int {
	var i = 2;
	return while ((i * i) <= n) {
    		if ((n % i) == 0) break i;
    		i += 1;
	} else n;
}

🔽

(func $find_first_divisor:n
	(export "find_first_divisor")
	(param $0 i32)
	(result i32)
	(local $1 i32)

	i32.const 2
	local.set $1
	loop $0_block_c (result i32)
		local.get $1
		local.get $1
		i32.mul
		local.get $0
		i32.le_s
		if $0_block (result i32)
			block $1_block
				local.get $0
				local.get $1
				i32.rem_s
				if $2_block
				else $2_block
					local.get $1
					br $0_block
				end $2_block
				local.get $1
				i32.const 1
				i32.add
				local.set $1
			end $1_block
			br $0_block_c
		else $0_block
			local.get $0
		end $0_block
	end $0_block_c
	return
)

Tail Calls

Landing become before Rust.

export fn factorial(n: int) int {
	become fac_aux(n: n, acc: 1);
}

fn fac_aux(n: int, acc: int) int {
	if (n <= 1) return acc;
	become fac_aux(n: n - 1, acc: n * acc);
}

🔽

(func $factorial:n
	(export "factorial")
	(param $0 i32)
	(result i32)

	local.get $0
	i32.const 1
	return_call $fac_aux:n:acc
)
(func $fac_aux:n:acc
	(param $1 i32)
	(param $2 i32)
	(result i32)

	local.get $1
	i32.const 1
	i32.le_s
	if $0_block
		local.get $2
		return
	end $0_block
	local.get $1
	i32.const 1
	i32.sub
	local.get $1
	local.get $2
	i32.mul
	return_call $fac_aux:n:acc
)

  1. To be implemented. ↩2 ↩3 ↩4 ↩5