
steavscript (steav2), a scripting language with khmer keywords
this project is under active development, things are subjected to changes! :br
disclaimer: the keywords are khmer words written in latin letters, i am not responsible for your eyes bleeding.
i've been wanting to write a programming language for a long time now. like most people who want to write a language, i started out with crafting interpreters (the lox book), and like most people who read that book, i went "yeah i totally get it" and then understood about 60% of it. so the only way to learn the other 40% was to build one. that one was steavscript, a lox-faithful tree-walking interpreter. it worked, but it was a toy, so a few days ago i rewrote it from scratch as steav2 (same .sts extension, same keywords, everything underneath redesigned). this post is about the first version of that rewrite, v1.
why though?
honestly? i want to write a ray tracer and i want to script it. materials, scenes, cameras, shaders, i don't want any of that hardcoded in the engine. and since i was already making a language, i thought it'd be funny (and fun) if the keywords were in my own tongue. every language i use has english keywords, if, else, while, return, so what if it didn't?
the keywords
here is the table, because it is the best part:
| steav2 | meaning |
|---|---|
bongSlanhOun |
the required opening statement (more on that below) |
akthe |
var / let |
ber |
if |
minjengte |
else |
somhab |
for |
nvpeldae |
while |
rupamun |
func |
yok |
import |
jea |
as |
morvenh |
return |
sampoan |
struct |
tnak |
class |
jongyeytha |
print |
ng |
and |
reu |
or |
ok |
true |
ort |
false |
sone |
nil |
nis |
this |
(ok and ort are my favorite, true and false are literally "yes" and "no". even the types got renamed: LekKut is an int, LekThom a double, Ahsor a string.)
bongSlanhOun
every steav2 program has to start with bongSlanhOun. if the first token isn't bongSlanhOun nothing runs, you just get yelled at (na `bongSlanhOun`?!!!) and the interpreter exits with 65.
bongSlanhOun "hello";
jongyeytha "hello world";
here is the funny part: the expression after it is never evaluated. it is never even checked. it's purely ceremonial, you can put anything in there, and any more bongSlanhOuns later in the file are ignored. it's basically a "i love you" that the language demands from you before it does any work for you, and does not care what you say. (if you know, you know)
why a rewrite?
the original steavscript was a tree-walker. it parses your code into a tree, then walks the tree and does what each node says. every time. and every variable access is a lookup by name in an environment. that's totally fine for a toy, but it is not fine when a script runs once per ray, per sample, millions of times per frame.
so steav2 is a proper pipeline now:
source -> tokens (scanner) -> AST (parser) -> type-checked AST -> bytecode (compiler) -> VM
the VM is a stack-based bytecode loop, clox-style. local variables become stack slots addressed by index, never by name, so no hashmap lookups at runtime. i can even dump the bytecode of a program without running it with steav --dump, which is really nice for staring at what your code actually turned into.
real types!
this is the biggest change. steav2 is statically typed, and the type errors come out before your script runs, not halfway through a render. function signatures are always explicit, but local variables infer their type from whatever you assign to them:
rupamun half(x: LekThom) -> LekThom {
morvenh x / 2;
}
akthe a = 5; // inferred
akthe b: LekThom? = sone; // explicit, nothing to infer from
numbers never mix by accident either, LekKut + LekThom is a type error, you have to say what you mean: LekThom(i). i have a whole file of deliberate mistakes (examples/sts/mistakes.sts) and the checker reports every single one of them before anything runs.
sampoan vs tnak
there are two kinds of user-defined types, split by how they're meant to be used.
sampoan is a value type (a struct). it is small, it gets copied when you assign it or pass it, and it never touches the heap or the garbage collector. a Vec3 is just 3 slots sitting on the stack.
sampoan Particle {
akthe pos: Vec3;
akthe vel: Vec3;
rupamun step(dt: LekThom) -> Particle {
morvenh Particle(pos + vel * dt, vel);
}
}
akthe p = Particle(zero(), Vec3(1, 2, 0));
akthe q = p;
q.pos.x = 100; // q is a copy, p is untouched
tnak is a reference type (a class). it lives on the heap, is shared by reference, and is garbage collected (mark-and-sweep, again clox-style). it's for things that are naturally shared, like a Material, a Mesh or a Scene:
tnak Sphere {
akthe center: Vec3;
akthe radius: LekThom;
rupamun init(center: Vec3, radius: LekThom) {
nis.center = center;
nis.radius = radius;
}
}
arrays ([T]) are a reference type as well, so a Scene can hold a shared, growable [Sphere].
operator overloading
operators are real functions, and you write them yourself:
sampoan Money { akthe cents: LekKut; }
rupamun +(a: Money, b: Money) -> Money { morvenh Money(a.cents + b.cents); }
rupamun *(a: Money, n: LekKut) -> Money { morvenh Money(a.cents * n); }
rupamun <(a: Money, b: Money) -> Boolean { morvenh a.cents < b.cents; }
one thing i'm kinda proud of (and kinda stubborn about): there's no automatic commutativity. Vec3 * LekThom and LekThom * Vec3 are two different overloads and you have to declare both. more typing, but there's no hidden rule in the middle of overload resolution to trip over. and < doesn't mean anything on a struct until you decide what it means.
optionals
anything that may or may not produce a value is T?, the classic example being "did this ray hit anything":
rupamun hit(ray: Ray, tMin: LekThom, tMax: LekThom) -> HitRecord? {
// ...
}
akthe result = scene.hit(ray, 0.001, INFINITY);
ber (result != sone) {
jongyeytha result.t; // plain HitRecord in here, not HitRecord?
}
once you compare against sone the compiler narrows the type inside that branch, so no unwrapping ceremony. use it without checking and it's a type error.
the standard library
three modules, math, random and vector, which you pull in with yok:
yok vector; // Vec3, dot, cross... in scope unqualified
yok vector jea vec; // or vec.Vec3, vec.dot ...
vector gives you Vec2, Vec3 and Vec4, ported from keo's math code. they behave like ordinary sampoans to the language, but every operation on them (dot, cross, reflect, refract, lerp...) is a C++ function that the compiler binds to, so one call is a single opcode that jumps straight into C++, no bytecode frame. random is a PCG32.
you can also split your own program over multiple files: yok "helpers.sts";. import cycles are caught at compile time.
does it actually work
yes! there is a ray tracer in it (ray tracing in one weekend style, with diffuse, metal and glass materials) that writes a PPM file:
./build/steav examples/sts/raytracer.sts > out.ppm
the materials, rays and scatter functions are all written in steavscript, which was the entire point. there's also a set of tests under tests/ that run scripts and compare their output to .expected files, and a bunch of failing programs that check the error messages.
the extras
because a language with no editor support is miserable, there's also:
- steav2-lsp, a language server that reports live errors from the real scanner, parser and type checker (so the editor and the CLI never disagree), plus hover docs and completion
- a vim/neovim syntax file and filetype setup
- a WASM build that powers the browser playground, so you can write and run
.stswithout installing anything
what's next
some things are deliberately left out of v1: inheritance, if-let binding and generics. there's a TODO.md in the repo with the rest. the end goal is still getting this thing embedded into a renderer so a scene can be a script.
you can check out the project here
and you can try it right in your browser, no install needed, in the playground! it runs the real vm compiled to webassembly. the original tree-walking steavscript is here, if you want to see where it all started.
..that's all for now , until next time!