Project Announcement: Onion, a functional scripting language with an immutability-first design, built in Rust

⚓ Rust    📅 2025-08-04    👤 surdeus    👁️ 10      

surdeus

Warning

This post was published 121 days ago. The information described in this article may have changed.

Hi everyone,

I'm excited to announce a personal project I've been working on: Onion, a new programming language built from the ground up entirely in Rust.


What is Onion? :onion:

Onion is a modern, functional programming language designed around a few core principles: immutability-first, an expressive and safe function system, and a layered concurrency model.

Instead of just listing features, I'd like to walk you through some of the core design decisions that make Onion different, and how they were implemented in Rust.

  • GitHub Repository: https://github.com/sjrsjz/onion-lang

First, a glimpse of what Onion code looks like, showcasing its function composition and stream processing:

@required "stdlib"; // use comptime `required` function to declare a external variable

// Lazy filtering with the | operator
evens := [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | (x?) -> x % 2 == 0;

// Membership checks are evaluated lazily
stdlib.io.println("Is 4 an even number?", 4 in evens);   // true
stdlib.io.println("Is 5 an even number?", 5 in evens);   // false

// To get a concrete array, use .collect()
collected_evens := evens.collect();
stdlib.io.println("Collected evens:", collected_evens);

// Stream processing with the |> operator (Mapping)
squared := [1, 2, 3] |> (x?) -> x * x;

// The stream is not lazy, so we can directly use it
stdlib.io.println("Squared numbers:", squared);

Core Design Pillars & The Rust Implementation

1. Immutability-First by Design

This is the most fundamental principle. In Onion, all values are fundamentally immutable. This is not just a convention; it's enforced by the VM's core object model.

  • The OnionObject Enum: The design is directly visible in the central OnionObject enum, which relies heavily on Arc<T> for cheap, safe sharing of data. There are no &mut T fields anywhere in the core types.

    // The core object enum in the VM
    pub enum OnionObject {
        Integer(i64),
        Float(f64),
        String(Arc<str>), // All heap-allocated values are shared-immutable
        Tuple(Arc<OnionTuple>),
        // ...
        Mut(GCArcWeak<OnionObjectCell>), // The *only* source of mutability
    }
    
  • The mut Keyword's True Nature: So how does mutation work? The mut keyword doesn't make an object mutable. Instead, mut x creates an OnionObject::Mut, which is an immutable, garbage-collected smart pointer to a single, mutable OnionObjectCell. The only possible "mutation" is to make this pointer point to a completely new OnionObject.

    As the source code comment states:

    "If we need to 'mutate' an object, it is IMPOSSIBLE to mutate the object itself, we can only let the Mut object point to a new object."

    This brings a Rust-like discipline to a dynamic language, preventing entire classes of bugs caused by shared mutable state.

2. Expressive and Safe Function System

Onion's function system is designed for flexibility and safety, taking inspiration from functional languages like Lisp and Haskell.

  • Flexible Invocation Syntax: Parentheses are optional for function calls. f(x) and f x are treated as equivalent, allowing for a cleaner, more compositional style.

  • Parameter Constraints (Guards): This is where safety comes in. You can attach a "guard" function to any parameter. The VM will execute this guard at runtime before entering the function body. Think of it as a form of runtime-validated trait bounds or dynamic contracts.

    // Define a guard function
    Positive := (x?) -> x > 0; // OR "x" -> x > 0 OR (x => true) -> x > 0
    
    // Apply it to a parameter. The syntax `x?` is sugar for a guard that always returns true.
    add_positive := (a => Positive, b => Positive) -> a + b;
    
    add_positive(5, 3); // OK
    // add_positive(-1, 5); // Fails at runtime with a constraint violation error
    
  • Automatic Tuple Unpacking & Nested Constraints: If a function is defined with tuple-like parameters, passing a tuple as an argument will cause the VM to automatically unpack it into the corresponding parameter slots. This, combined with the fact that guards can be nested, allows for powerful and descriptive function signatures.

    // A function expecting an integer and a nested tuple of two integers
    f := (x => Positive, (y => Positive, z => Positive)) -> x + y + z;
    
    // These calls are valid:
    f(1, (2, 3));      // Result: 6
    
    // This call is also valid due to tuple unpacking
    packaged_args := (1, (2, 3));
    f(packaged_args);  // Result: 6
    

3. A Unique Prototypal Object Model

Instead of classes, Onion uses a simple but powerful prototypal object model built from composing Tuples and Pairs (key: value).

  • Inheritance via :: You link an object to its prototype using the : operator: my_instance : my_prototype.

  • How self Works: This is a key differentiator. In Onion, self isn't a magic keyword that appears at call time. When you access a function on an instance (e.g., instance.method), the VM creates a new Lambda object on the fly. This new object pairs the original function's definition with a reference to the instance, which becomes self.

    This mechanism is explicit in the OnionObject::Lambda type definition:

    Lambda((Arc<OnionLambdaDefinition>, Arc<OnionObject>)), // (definition, self_object)
    

    This means self is lexically captured at the moment of access, leading to more predictable behavior.

4. First-Class Metaprogramming

Onion has a compile-time macro system that allows you to directly manipulate the Abstract Syntax Tree (AST). This isn't just string substitution; it's structural code generation.

  • Example: You can use @ to execute Onion code at compile time. For example, build a lambda.

    // @ast lets you build Abstract Syntax Trees programmatically
    lambda := @ast.lambda_def(false, ()) << (
        ("x", "y"), // Parameters
        ast.operation("+") << ( // Body
            ast.variable("x"),
            ast.variable("y")
        )
    );
    // The `lambda` variable is now a function equivalent to `(x?, y?) -> x + y`
    stdlib.io.println(lambda(10, 20)); // Prints 30
    

5. The Layered Concurrency Model

This builds upon the foundation of immutability. Because data is fundamentally non-mutable, sharing it across threads is inherently safer.

  • GIL-Free Multi-threading: The launch keyword uses std::thread::spawn to create a new OS thread. Each thread gets a completely new and isolated VM context and GC instance. This is a simple but powerful approach that entirely sidesteps the need for a GIL.
  • Cooperative Async Scheduling: The async/spawn system uses a priority-based scheduler, which I'll detail below.

A Deeper Dive: The Scheduler Implementation

For those interested in the VM's internals, the execution flow is built upon one central trait: Runnable.

The Core Abstractions: Runnable and StepResult

Everything that can be executed implements the Runnable trait. Its step method returns a StepResult enum, which dictates the VM's next action (Continue, NewRunnable, Return, SpawnRunnable, Error).

1. The Synchronous Scheduler: A Classic Call Stack

The standard Scheduler is simple: it manages a Vec<Box<dyn Runnable>> which acts as a call stack. NewRunnable pushes to the stack, and Return pops from it, passing the result to the new top of the stack via the receive method. It's a classic state machine implementation of a function call system.

2. The AsyncScheduler: A Priority-Based Event Loop

This is where things get interesting. The AsyncScheduler manages a VecDeque<Task>.

  • Cooperative & Non-blocking: It iterates through the queue, stepping each task. It never waits.
  • Priority & Exponential Backoff: Each task has a priority. The scheduling frequency is based on this priority (step % (2^(priority+1) - 1)).
    • When a task returns RuntimeError::Pending (e.g., waiting on valueof), its priority is lowered. This is an elegant exponential backoff: the scheduler wastes fewer cycles on tasks that are just waiting.
    • When a task makes progress, its priority is reset to the highest level.

This layered model, implemented with Rust's traits and enums, allows Onion to handle everything from simple function calls to complex, non-blocking concurrent workloads within a single, coherent framework.


What I'm Looking For

This project has been a solo journey so far, and I've reached the point where I would love to get external perspectives. I'm particularly interested in feedback on:

  • Language Design: What are your initial thoughts on the immutability-first philosophy, the function system, and the prototypal object model? Do these ideas resonate?
  • VM Implementation: Are there any glaring anti-patterns or potential performance bottlenecks in the VM code? I'm especially curious about feedback on the GC, object model, and scheduling logic.
  • Contribution: The project is open for contributions! If you're interested in compilers, VMs, or language design, I would be thrilled to have you join.

Thank you for taking the time to read this. I'm excited to hear what you think and to continue this journey with the wisdom of the Rust community.

4 posts - 3 participants

Read full topic

🏷️ Rust_feed