I built a scripting language in Rust! Meet Onion 🧅 — A new language blending powerful metaprogramming, fearless concurrency, and functional paradigms.
Hey everyone, fellow Rustaceans, and language design enthusiasts!
I'm incredibly excited to finally share my passion project with you all: a brand new scripting language I call Onion.
Please check out the repo on GitHub, and I'd be honored if you gave it a Star ⭐!
- GitHub Repo:
https://github.com/sjrsjz/onion-lang
My goal was to create a language that seamlessly fuses some of my favorite concepts: the raw power of metaprogramming, intuitive concurrency without GIL, the elegance of functional programming, and a super clean syntax. After countless nights of coding and design, I think it's time to peel back the layers.
This is a deep dive, so we'll go from what Onion can do, all the way down to how it's built with Rust under the hood.
Part 1: What can Onion do? (A Tour of the Core Features)
Let's jump straight into the code to get a feel for Onion.
1. Fine-Grained Mutability Control
In Onion, mutability is a property of the container, not the value itself. This gives you precise control over what can be changed, preventing unintended side effects.
@required 'stdlib';
obj := [
mut 0, // We create a mutable container pointing to a heap object. The pointer itself is immutable, but we can replace the value inside the container.
1,
];
// Use `sync` to create a new synchronous scheduler that prevents the VM from halting on an error.
stdlib.io.println((sync () -> {
obj[0] = 42; // SUCCESS: We can modify the contents of the 'mut' container.
})());
stdlib.io.println("obj's first element is now:", obj[0]);
stdlib.io.println((sync () -> {
obj[1] = 100; // FAILURE! obj[1] is an immutable integer.
})());
stdlib.io.println("obj's second element is still:", obj[1]);
ref := obj[0]; // 'ref' now points to the same mutable container as obj[0].
ref = 99; // This modifies the value inside the container.
stdlib.io.println("obj's first element is now:", obj[0]); // 99, because ref == mut 42
const_data := const obj[0]; // Create an immutable snapshot of the value inside the container.
stdlib.io.println((sync () -> {
const_data = 100; // FAILURE! You can't modify a const snapshot.
})());
2. Compile-Time Metaprogramming: The Ultimate Power
This is one of Onion's killer features. Using the @ sigil, you can execute code, define macros, and even dynamically construct Abstract Syntax Trees (ASTs) at compile time.
@required 'stdlib';
@def(add => (x?, y?) -> x + y);
const_value := @add(1, 2);
stdlib.io.println("has add : ", @ifdef "add");
stdlib.io.println("add(1, 2) = ", const_value);
@undef "add";
// const_value := @add(1, 2); // This line would now fail to compile.
@ast.let("x") << (1,); // This generates the code `x := 1`
stdlib.io.println("x", x);
// Manually build an AST for a lambda function
lambda := @ast.lambda_def(false, ()) << (
("x", "y"),
ast.operation("+") << (
ast.variable("x"),
ast.variable("y")
)
);
stdlib.io.println("lambda(1, 2) = ", lambda(1, 2));
// Or, even better, serialize an expression to bytes (`$`) and deserialize it back into an AST
lambda2 := @ast.deserialize(
$(x?, y?) -> x * y // `$` serializes the following expression to bytes
);
stdlib.io.println("lambda2(3, 4) = ", lambda2(3, 4));
@include "./sub_module.onion";
stdlib.io.println(foo());
stdlib.io.println(@bar());
// An abstract macro that generates a function `T -> body`
@def(
curry => "T_body_pair" -> ast.deserialize(
$()->()
) << (
keyof T_body_pair,
ast.deserialize(
valueof T_body_pair
)
)
);
// Equivalent to: "U" -> "V" -> U / V
curry_test := @curry(
U => $@curry(
V => $U / V
)
);
stdlib.io.println("curry_test(10)(2) = ", curry_test(10)(2));
3. Elegant & Safe Functions: Contracts, Tuples, and Flexible Syntax
Onion's functional core is designed for both elegance and safety. In Onion, f(x), f[x], and f x are all equivalent ways to call a function. You can attach any boolean-returning function as a "guard" to a parameter, enabling Programming by Contract, and handle tuples with ease.
// Traditional functional style
f := "x" -> x + 1; // same as `(x?) -> x + 1`
// All of these are identical, as `()` and `[]` are just for operator precedence.
assert f(1) == 2;
assert f[1] == 2;
assert f 1 == 2;
// We can add constraints to parameters
guard := "x" -> x > 0;
f := (x => guard) -> x + 1; // or f := ("x" : guard) -> x + 1;
assert f(1) == 2;
// f(0) // This would throw a runtime constraint violation.
// A boolean `true` means the constraint always passes. `x?` is shorthand for `x => true`.
f := (x?) -> x + 1;
assert f(1) == 2;
// Functions can accept tuples as parameters.
f := ("x", "y") -> x + y;
assert f(1, 2) == 3;
// The VM unpacks tuple arguments automatically.
packaged := (1, 2);
assert f(packaged) == 3;
assert f packaged == 3;
// Note: (x?,) -> {} (single-element tuple) is different from (x?) -> {} (single value).
// The former requires a tuple argument to unpack, preventing errors.
// Constraints can apply to tuples and even be nested.
f := (x => guard, (y => guard, z => guard)) -> x + y + z;
assert f(1, (2, 3)) == 6;
// You can inspect a function's parameters at runtime!
stdlib.io.println("Function parameters:", keyof f);
4. Objects and Prototypes: The Dual Role of Pairs
Central to Onion's object model is the Pair (key: value), which has a dual identity.
First, it's a key-value mapping. Collections of pairs inside tuple create struct-like objects, perfect for data representation, like handling JSON.
@required 'stdlib';
// A complex object made of key-value pairs
// notes that `{}` just create new variable context, Onion use comma to build tuple
complex_data := {
"user": {
"id": 1001,
"profile": {
"name": "bob",
"email": "bob@example.com"
}
},
"metadata": {
"version": "1.0", // requires a comma to create a tuple
}
};
// This structure maps directly and cleanly to JSON
json_output := stdlib.json.stringify_pretty(complex_data);
stdlib.io.println("Complex object as JSON:");
stdlib.io.println(json_output);
Second, it forms a prototype chain. Using the : syntax, an object can inherit from a "parent" prototype. When a property isn't found on an object, the VM searches its prototype, enabling powerful, flexible inheritance. The most powerful application of this is Onion's interface system.
5. Interfaces: Dynamic Typing through Prototypes
Onion's interface system is a brilliant application of the prototype model. You define a set of behaviors and then "stamp" that behavior onto new objects, which can then be validated with contract-based checks.
@required 'stdlib';
// `a => b` is just grammar sugar of `"a" : b`
interface := (interface_definition?) -> {
pointer := mut interface_definition;
return (
// `new` creates a structure and sets its prototype to the interface definition
new => (structure?) -> structure : pointer,
// `check` validates if an object's prototype is this specific interface
check => (instance?) -> {
(valueof instance) is pointer
},
)
};
my_interface := interface {
method1 => () -> stdlib.io.println("Method 1 called"),
method2 => (arg?) -> stdlib.io.println("Method 2 called with argument:", arg),
method3 => () -> stdlib.io.println(self.data),
};
my_interface_2 := interface {
method1 => () -> stdlib.io.println("Method 1 called"),
method2 => (arg?) -> stdlib.io.println("Method 2 called with argument:", arg),
method3 => () -> stdlib.io.println(self.data),
};
my_instance := my_interface.new {
data => "This is some data",
};
my_instance_2 := my_interface_2.new {
data => "This is some data",
};
stdlib.io.println("Is my_instance an instance of my_interface? ", my_interface.check(my_instance));
stdlib.io.println("Is my_instance an instance of my_interface_2? ", my_interface_2.check(my_instance));
my_instance.method1();
stdlib.io.println("Calling method2 with 'Hello':");
my_instance.method2("Hello");
stdlib.io.println("Calling method3:");
my_instance.method3();
// The `check` function can now be used as a contract guard!
instance_guard_test := (x => my_interface.check) -> {
stdlib.io.println("Instance guard test passed with:", x.data);
};
instance_guard_test(my_instance); // This should work
// instance_guard_test(my_instance_2); // This should fail, as it's not an instance of my_interface
6. First-Class Concurrency & Async Data Streams
The Onion VM is built for fearless concurrency. Using async, spawn, and the pipeline operator |>, you can build clean, asynchronous data flows.
@required 'stdlib';
pool := () -> {
return (0..5).elements() |> (x?) -> {
stdlib.time.sleep_seconds(1);
return spawn () -> {
n := mut 0;
while (n < 10) {
n = n + 1;
stdlib.time.sleep_seconds(1);
};
return x;
};
};
};
// Our generator-based VM allows nesting sync/async calls seamlessly
tasks := (async pool)();
stdlib.io.println("results:", valueof tasks);
(0..5).elements() |> (i?) -> {
stdlib.io.println("task:", i, "result", valueof (valueof tasks)[i]);
};
Part 2: How does it work? (The Rust Core)
If you're interested in the nuts and bolts, this part is for you.
1. The Compiler: A Matryoshka Doll with an Embedded VM
The Onion compilation pipeline is: Source Code -> AST -> Compile-Time Evaluation -> IR -> VM Bytecode. The metaprogramming magic comes from that Compile-Time Evaluation stage. I implemented a ComptimeSolver, which is essentially a complete, sandboxed Onion VM embedded inside the compiler. When the compiler hits an @ node, it pauses, compiles and runs the node's code in the embedded VM, and substitutes the result back into the AST.
2. The Virtual Machine: Built on Immutability
The Onion VM's core philosophy is immutability. All core objects are immutable. The mut keyword points to a thread-safe RwLock cell. When you "change" a mut variable, you are actually swapping the OnionObject inside the cell, not modifying data in-place. This provides the convenience of mutability while maintaining a thread-safe, immutable-by-default architecture.
Deep Dive: The Onion VM's Highly Composable, Generator-based Scheduling
The key to Onion's concurrency and functional elegance is its generator-based VM architecture.
At its heart, the VM doesn't run functions to completion in one go. Instead, every executable unit—from a simple operation to a complex scheduler—implements a Runnable trait with a step() method. The VM is essentially a simple loop that repeatedly calls step() on the current task to advance its state.
This design is what makes Onion's schedulers highly composable. A scheduler is just another Runnable that manages a collection of other Runnable tasks. Because the interface is universal, you can seamlessly nest different scheduling strategies inside one another.
You saw this in action with (async pool)(): An AsyncScheduler (from the async keyword) executes the pool function (synchronous logic), which contains a MapScheduler (from the |> operator), which in turn spawns new tasks back into the parent AsyncScheduler. This effortless nesting of async -> sync -> map -> async is only possible because everything is a uniform, step-able task. This architecture allows for building incredibly sophisticated and clear data and control flows.
Why create Onion?
I want Onion to be a fun, expressive, and powerful language, perfect for:
- Writing Domain-Specific Languages (DSLs) that require heavy metaprogramming.
- Serving as a fun and powerful standalone scripting language.
- And, of course, for the pure joy of programming and language design!
This is still an evolving passion project. It definitely has rough edges and areas to improve. I would be absolutely thrilled to hear your thoughts, feedback, and suggestions.