Skip to content

Instantly share code, notes, and snippets.

@darilrt
Last active June 11, 2025 05:30
Show Gist options
  • Save darilrt/4cf6b98f6011eb76ee288b84032dd0cc to your computer and use it in GitHub Desktop.
Save darilrt/4cf6b98f6011eb76ee288b84032dd0cc to your computer and use it in GitHub Desktop.

🌱 Stak (WIP)

This is an statically typed programming language focused on simplicity, safety, and performance. It uses immutable-by-default semantics, pass-by-value for primitives, and a reference-based memory model with automatic reference counting (refcount).

The syntax is inspired by modern languages but does not attempt to replicate any existing one. It is a pragmatic language designed to empower developers without exposing them to low-level memory concerns.

✨ Core Principles

  • Immutable by default: Variables and function parameters are immutable unless explicitly marked as mut.
  • Clear pass-by-value vs pass-by-reference model.
  • No raw pointers, manual memory management, or low-level access.
  • Reference-counted memory model with automatic cleanup.

Index

Table of Contents

📦 Variables

let x = 5;            // Immutable variable
let mut y = 10;       // Mutable variable
y = 15;               // OK: y is mutable

let z: i32 = 20;      // Immutable variable with explicit type
let w: mut i32 = 25;  // Mutable variable with explicit type

🔒 Constants

The language supports immutable constants defined with the const keyword. Constants must have a type annotation and are evaluated at compile time.

const PI: f64 = 3.14159; // Immutable constant

📊 Data Types

The language includes a minimal of built-in types, covering primitives:

Type Description Default Passing
u8 Unsigned 8-bit integer By value
u32 Unsigned 32-bit integer By value
u64 Unsigned 64-bit integer By value
i8 Signed 8-bit integer By value
u16 Unsigned 16-bit integer By value
i16 Signed 16-bit integer By value
i32 Signed 32-bit integer By value
i64 Signed 64-bit integer By value
f32 32-bit floating point By value
f64 64-bit floating point By value
bool Boolean (true/false) By value

If you need more complex types like strings or arrays, they are provided as part of the standard library.

🔁 Cast

The language supports explicit casting between numeric types:

let a: i32 = 10;
let b: f64 = a as f64; // Cast i32 to f64
let c: u32 = b as u32; // Cast f64 to u32
From → To Allowed? Behavior
Integer → Float Converts with precision loss
Float → Integer Truncates
Signed ↔ Unsigned Wraps/truncates
Float ↔ Float Narrowing may lose precision
Integer ↔ Integer Truncates if target is smaller
Non-compatible types (e.g. Stringi32) Disallowed unless via conversion functions

🧮 Arithmetic Operations

The language supports classic arithmetic operations on numeric types and follows standard operator precedence:

Operator Description Example
+ Addition x + y
- Subtraction x - y
* Multiplication x * y
/ Division x / y
% Modulus x % y
** Exponentiation x ** y
== Equality x == y
!= Inequality x != y
< Less than x < y
<= Less than or equal x <= y
> Greater than x > y
>= Greater than or equal x >= y
! Logical NOT !x
&& Logical AND x && y
|| Logical OR x || y
& Bitwise AND x & y
| Bitwise OR x | y

🔄 Control Flow

if x < y {
    // do something
} else {
    // do something else
}

for i in 1..=5 {
    println(i);
}

while condition {
    // do something
}

loop {
    // infinite loop
    if some_condition {
        break; // exit the loop
    }
}

// Loop with labels
outer: loop {
    inner: loop {
        if some_condition {
            break outer; // exit the outer loop
        }
    }
}
// This can be used with for and while loops as well

🔧 Functions

def add(a: i32, b: i32) -> i32 {
    return a + b;
}

let sum = add(x, y); // sum = 20

🔒 Parameter mutability

  • Function parameters are immutable by default so you cannot reassign them.
  • You must declare it with the mut keyword to allow internal mutability.
  • This only applies to structured types and not primitives.
def mut_arg(a: mut T) {
    a.mut_value();
}

Passing an immutable variable to a mut parameter will result in a compile-time error.

📐 Type

The language supports defining custom types using the type keyword. This allows you to create structured data types similar to structs in other languages and provides a way alias existing types.

// This is a type alias for an existing type
type MyType = i32;

// This is a structured type definition
type Point {
    x: MyType,
    y: MyType,
}

let p = Point { x: 10, y: 20 };
let x_coord = p.x;
let y_coord = p.y;

🛠️ Methods

// This is a static method definition
def Point.new(x: i32, y: i32) -> Point {
    return {x, y};
}

// This is an inmutable instance method definition
def Point.distance(self) -> f64 {
    return ((self.x ** 2 + self.y ** 2) as f64).sqrt();
}

// This is a mutable instance method definition
def Point.set_x(mut self, new_x: i32) {
    self.x = new_x;
}

🔐 Calling methods

  • Methods that do not modify the instance can be called on immutable variables.
  • Methods that modify the instance require the instance to be mut.
let mut p = Point.new(5, 5);
p.set_x(100); // OK

let q = Point.new(1, 1);
q.set_x(20); // ❌ Error: q is immutable

🧩 Traits

Traits define shared behavior across different types. They are similar to interfaces in other languages, but follow Rust’s semantics:

  • Static dispatch by default (no runtime overhead).
  • Behaviors are checked at compile time.
  • Optional dynamic dispatch using trait objects.
trait Drawable {
    def draw(self);
}

Traits may contain one or more method signatures:

trait Shape {
    def area(self) -> f64;
    def perimeter(self) -> f64;
}

🧬 Implementing Traits

To implement a trait for a type, use the following syntax:

def Circle.draw(self) for Drawable {
    // implementation
}

Or with multiple methods:

def Circle.area(self) -> f64 for Shape {
    return 3.14 * self.radius.pow(2);
}

def Circle.perimeter(self) -> f64 for Shape {
    return 2 * 3.14 * self.radius;
}

📞 Calling Trait Methods

Trait methods can be called directly on values as long as they implement the trait:

let c = Circle { radius: 5.0 };
c.draw(); // Calls the draw method from Drawable trait
let area = c.area(); // Calls the area method from Shape trait
let perimeter = c.perimeter(); // Calls the perimeter method from Shape trait

🧠 Static and Dynamic Dispatch

Static dispatch is the default behavior in this language, meaning that method calls are resolved at compile time. This allows for optimizations and avoids runtime overhead.

def draw_shape(shape: Drawable) {
    shape.draw(); // Calls the draw method of the specific type implementing Drawable
}

Static dispatch ensures that the method call is resolved at compile time, providing better performance.

📦 Dynamic Dispatch

Dynamic dispatch can be achieved using trait objects, allowing for runtime polymorphism. This is useful when you want to work with different types that implement the same trait. This is done using dyn keyword:

def draw_shapes(shapes: [dyn Drawable]) {
    for shape in shapes {
        shape.draw(); // Calls the draw method of each specific type
    }
}

Dynamic dispatch allows for flexibility at the cost of some performance, as method resolution happens at runtime.

⚔️ Trait Method Conflicts

If multiple traits define methods with the same name, you can disambiguate which implementation to call using fully qualified syntax:

trait Drawable {
    def show(self);
}

trait Debug {
    def show(self);
}

def Circle.show(self) for Drawable {
    print("Drawing circle");
}

def Circle.show(self) for Debug {
    print("Circle { radius: ... }");
}

let c = Circle { radius: 10.0 };

c.show();                  // ❌ Error: ambiguous method call
Drawable.show(c);          // ✅ Calls the Drawable trait method
Debug.show(c);             // ✅ Calls the Debug trait method

When multiple traits implement the same method, you must specify which trait's method to call using the trait name.

🧩 Generic Types

You can also define generic types using the type keyword:

type Box[T] {
    value: T;
}

def Box[T].from(value: T) -> Box[T] {
    return { value };
}

def Box[T].get_value(self) -> T {
    return self.value;
}

let mut int_box: Box[i32] = Box.from(42);
let mut str_box: Box[String] = Box.from("Hello");
let int_value = int_box.get_value(); // Returns 42
let str_value = str_box.get_value(); // Returns "Hello"

Memory Management

The system is based on ownership, lifetime analysis, and reference counting, eliminating the need for a traditional garbage collector and preventing common memory errors.

Core Principles

Stack Allocation by Default: Values are allocated on the stack by default, ensuring optimal performance for short-lived data.

type Point { x: i32, y: i32 }
let p1: Point = Point { x: 10, y: 20 }; // p1 is allocated on the stack

Unified References (ref T): A single reference type, ref T, is used to point to data. The compiler understands the context and handles the reference appropriately—either as a stack borrow or as a counted reference to the heap. References are never null.

let p1_ref: ref Point = p1;

Explicit Heap Allocation with new: For data that must outlive the current scope or be shared, the new keyword is used to allocate it on the heap. This returns a ref T, which is a reference-counted (RC) pointer.

let p2: ref Point = new Point { x: 30, y: 40 };
// p2 is a reference-counted pointer to a Point in the heap
// p2 can be returned from functions or stored in long-lived structures
  • Reference counting is automatic: it increments when a heap reference is copied and decrements when a reference goes out of scope. The object is deallocated when the count reaches zero.
  • There is no implicit promotion from stack to heap; heap allocation is always explicit via new.

Compiler-Enforced Lifetime Analysis: The compiler performs static lifetime analysis for all stack references (ref T pointing to stack data). This ensures no reference can outlive the data it points to, preventing dangling pointers.

fn get_dangling_ref() -> ref Point {
    let p_local: Point = Point { x: 1, y: 1 };
    return p_local; // COMPILATION ERROR: p_local is on the stack and cannot escape as a reference.
                    // The compiler will suggest returning 'Point' by value or using 'new Point { ... }'.
}

Explicit Mutability: Mutability must be declared explicitly using the mut keyword for both values and references.

let p_mut: mut Point = Point { x: 0, y: 0 };
let p_mut_ref: mut ref Point = p_mut;
p_mut_ref.x = 5; // Modifies p_mut via the mutable reference

let p_heap_mut: mut ref Point = new Point { x: 0, y: 0 };
p_heap_mut.y = 10; // Modifies the heap-allocated Point

Copying and Cloning Data (Copy, Clone traits):

  • To duplicate the underlying value (not the reference), types can implement the Copy trait (for simple bitwise-copyable types) or the Clone trait (for more complex duplication).
  • When using new with an existing stack value, Copy or Clone is required to transfer the value to the heap:
// Assuming Point implements Clone
let p_stack: Point = Point { x: 1, y: 2 };
let p_heap_clone: ref Point = new p_stack.clone(); // Clone p_stack into the heap

// If Point implements Copy (and the compiler infers it)
// let p_heap_copy: ref Point = new p_stack; // Copy p_stack into the heap
  • For an assignment let a = b; where b is ref T:

    • If b is a stack reference, a becomes another stack reference to the same data.
    • If b is a heap reference (RC), a becomes another reference to the same heap object, and the reference count is incremented.
    • To get a copy of the data pointed to by a heap reference, use b.clone() or b.copy() (if the type implements the respective trait).

Heap Object Semantics and Reference Counting

When an object is allocated on the heap using new T, the result is of type mut ref T, a mutable reference to an object in dynamic memory. This reference type automatically carries an internal reference counter (RC) that manages the object’s lifecycle.

The system allows a downcast from a mutable reference to an immutable one (ref T) when mutability is no longer required. This conversion is implicit in contexts where immutability is expected.

Reference Count Incrementation

When assigning a heap reference (ref T or mut ref T) to a new variable, the reference count is automatically incremented:

let foo: ref T = new T;
let bar = foo; // Reference count is incremented by 1

Each copy of the reference to the same heap object shares the counter. It is incremented with each new copy and decremented when references go out of scope or are explicitly dropped.


Memory Deallocation and the Drop Trait

When the reference count reaches zero, the object is automatically deallocated. Before freeing the memory, the drop function defined by the Drop trait is invoked, allowing for custom cleanup logic (e.g., closing files, freeing external resources, etc.).

Constraint: Objects can only be allocated on the heap using new T if the type T implements the Drop trait. This ensures that all heap-allocated objects have an explicit destruction path, avoiding resource leaks or undefined behavior.


Progressive Complexity for Advanced Scenarios

The base memory model is designed to be simple and safe for most use cases. For more complex scenarios, specialized tools are provided, which programmers may opt into with full awareness of the added responsibilities:

  1. Concurrency (Arc<T>, Mutex<T>):

    • The default ref T (when pointing to heap) uses non-atomic reference counting, safe for single-threaded use.
    • For safe sharing across threads, types like Arc<T> (Atomic Reference Counting) must be used for shared ownership, and Mutex<T> (or other synchronization primitives) for shared mutability.
    • These types are provided via the standard library and make concurrency intent explicit.
  2. C Interoperability (FFI) and Raw Pointers (Ptr<T>, unsafe):

    • For interacting with C code or low-level operations requiring raw pointers, the Ptr<T> type is provided.
  3. Weak References (Weak<T>):

    • To break reference cycles in data structures using reference-counted heap allocations, the Weak<T> type is provided. A Weak<T> does not increment the reference count and allows checking if the data still exists before accessing it.

✅ Full Example

trait Incrementable {
    def increment(mut self);
}

type Counter {
    value: i32;
}

def Counter.new() -> Counter {
    return { value: 0 };
}

def Counter.increment(mut self) for Incrementable {
    self.value += 1;
}

def Counter.get_value(self) -> i32 {
    return self.value;
}

let mut counter: Incrementable = Counter.new();
counter.increment(); // Increment the counter

let current_value = counter.get_value(); // Get the current value
print("Current value: {}", current_value); // Output: Current value: 1

⚙️ Compilation

Design documentation

Index

Introduction

Stak is a modern programming language designed for simplicity, performance, and safety. It combines features from various programming paradigms to provide a versatile tool for developers. Focused on application and backend development, Stak aims to streamline the development process while ensuring high performance and maintainability.

Inspiration and Goals

Stak draws inspiration from languages like Python, Rust, and Go, aiming to create a language that is easy to learn yet powerful enough for complex applications. The primary goals of Stak include:

  • Simplicity: A clean and readable syntax that reduces boilerplate code.
  • Performance: High execution speed and low memory overhead, making it suitable for performance-critical applications.
  • Safety: Strong typing and memory safety features to prevent common programming errors.
  • Concurrency: Built-in support for concurrent programming to leverage multi-core processors effectively.
  • Rich Standard Library: A comprehensive standard library that provides essential functionality, reducing the need for external dependencies.

The biggest difference between Stak and other languages is the memory management system, that try to be simple to the most common use cases, but when needed, it can be use types that are more complex and that can be used to create more complex data structures.

Also stak takes a lot of inspiration from the Rust, Python and Zig programming languages, for example the use of the match statement, types like Option and Result, some syntax sugar, and the use of let to declare variables.

Variables and Data Types

Primitive Types

The language includes a minimal of built-in types, covering primitives:

Type Description Default Passing
u8 Unsigned 8-bit integer By value
u32 Unsigned 32-bit integer By value
u64 Unsigned 64-bit integer By value
i8 Signed 8-bit integer By value
u16 Unsigned 16-bit integer By value
i16 Signed 16-bit integer By value
i32 Signed 32-bit integer By value
i64 Signed 64-bit integer By value
f32 32-bit floating point By value
f64 64-bit floating point By value
bool Boolean (true/false) By value

Varables and Constants

Variables in Stak are declared using the let keyword, which allows for both mutable (using mut keyword) and immutable variables.

let x = 10; // Mutable variable
let mut y = 20; // Mutable variable

If the type is not specified, the compiler will infer it based on the assigned value. However, you can explicitly specify the type if needed:

let x: i32 = 10; // Explicitly typed variable
let y: f64 = 20.5; // Explicitly typed variable

Variables can be reassigned if they are mutable:

let mut x = 10; // Mutable variable
x = 20; // Reassigning a mutable variable

Constants are declared using the const keyword are immutable and evaluated at compile time, need to have a type specified:

const PI: f64 = 3.14159; // Constant declaration

Control Flow

Conditionals

Stak supports conditional statements using if, else if, and else. The syntax is straightforward, similar to many C-like languages:

let x = 10;
if x > 0 {
    print("x is positive");
} else if x < 0 {
    print("x is negative");
} else {
    print("x is zero");
}

Loops

Stak provides several looping constructs, including for, while, and loop. The for loop iterates over a range or collection, while while continues until a condition is false. The loop construct creates an infinite loop that can be exited with a break statement.

let mut i = 0;
while i < 10 {
    print(i);
    i += 1; // Increment i
}

for i in 0..10 { // The variable `i` is not the same as the previous one
    print(i); // Prints numbers from 0 to 9
}

loop {
    print("This is an infinite loop");
    break; // Exiting the loop
}

Match Statement

The match statement in Stak is a powerful control flow construct that allows pattern matching against values. It is similar to the switch statement in other languages but more expressive, allowing for complex patterns and destructuring. Inspired by Rust, it provides a way to handle different cases in a clean and concise manner.

let value = 42;

match value {
    0 => print("Zero"),
    1..=10 => print("Between 1 and 10"),
    11..=20 => print("Between 11 and 20"),
    _ => print("Greater than 20 or less than 0"),
}

The match statement can also be used with enums, allowing for elegant handling of different enum variants:

enum Status {
    Success,
    Error(String),
}

let status = Status.Error("Something went wrong".to_string());

match status {
    Status.Success => print("Operation was successful"),
    Status.Error(msg) => print("Error occurred: {}", msg),
}

Functions

Functions in Stak are defined using the def keyword, followed by the function name, parameters, and the return type. The syntax is designed to be clear and concise, allowing for easy definition of both simple and complex functions.

def add(a: i32, b: i32) -> i32 {
    return a + b; // Return statement is optional
}

def greet(name: String) {
    print("Hello, {}", name);
}

Functions can also have default parameters, allowing for more flexible function calls:

def multiply(a: i32, b: i32 = 2) -> i32 {
    return a * b; // Default value for b is 2
}

let result = multiply(5); // Calls multiply with b as 2
let result2 = multiply(5, 3); // Calls multiply with b as 3

Also the parameters can be called by name, allowing for more readable function calls, especially when there are multiple parameters:

def create_user(name: String, age: i32, email: String) {
    print("User created: {}, Age: {}, Email: {}", name, age, email);
}

create_user(
  name: "Alice", 
  age: 30, 
  email: "[email protected]"
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment