Files
DSA/doc/reference/dscs-reference.md
T
2026-08-20 22:22:09 +01:00

20 KiB

DSCS Reference (Draft v0.3)

DSCS — Damn Simple Code (Systems)

Introduction

DSCS is the low-level half of the DSC language family — a simple, explicit systems language with the capabilities of a language like C, but with improved safety and ergonomics. It compiles down to DSA assembly and is meant to be usable directly for things like kernels and drivers.

DSCS is also the foundation DSCX (the higher-level, OOP-flavoured member of the family) transpiles down to: every valid DSCS program is a valid DSCX program. This document covers DSCS on its own.

Design goals

  • A simple, explicit systems language with all the capabilities of a language like C, with improved safety and ergonomics.
  • Static dispatch only. Generics are monomorphized at compile time; there is no built-in dyn Trait, no vtables, and no hidden indirect calls. Where genuine runtime polymorphism is needed, you build it explicitly — see Runtime Polymorphism.
  • Manual memory management. No garbage collector, no automatic reference counting. Allocators are passed explicitly wherever allocation happens.
  • No borrow checker, no lifetimes. DSCS trusts the programmer. See Ownership & Memory Safety.

Lexical Structure

// single line comment...
/* multi 
  line 
  comment */

Variables, Constants & Statics

fn main() {
    let local: Type; // local variables
}

const CONST: Type = val; // strictly cannot change, computed at compile time.
static STATIC: Type = val; // a global value that can change. fixed size.

Primitive Types & Literals

fn types() {
    let boolean: bool = false; // or true;
    let integer: u32 = 37; // or i32, i16, i8, u16, u8;
    let hex: u8 = 0xAA;
    let oct: u8 = 0o5;
    let binary: u8 = 0b01000011;
}

Arrays

Because the size is stored with the type, the compiler can infer the length:

let arr: [u32; 6] = [0, 1, 2, 3, 4, 5];
// = [1; 6]; // alternative syntax for [1, 1, 1, 1, 1, 1]

Pointers

// pointers. these are not references, so care should be taken to use them safely.
let ptr: *u32 = &integer;
let mut_ptr: *mut u32 = &mut integer;

*T is read-only; *mut T is read/write. Full rules governing when a pointer is safe to use are covered in Ownership & Memory Safety — raw pointers carry no compiler-enforced validity guarantee at all.


Control Flow

if/else are expressions — the same "no trailing semicolon on the last line is the return value" rule applies as in function bodies:

let sign = if x > 0 { 1 } else if x < 0 { -1 } else { 0 };

For a single-statement body, => can be used instead of braces, aligning with match's own arm syntax:

if condition => do_y();

The same rule applies to else, and the two forms compose — a branch can use whichever form fits it:

if condition => do_y(); else { a(); b(); }

As an expression, the arrow form has no trailing semicolon on either branch — else (a keyword) is what tells the parser where a branch's expression ends, same as any other expression grammar stopping at a token it can't extend through:

let sign = if x > 0 => 1 else if x < 0 => -1 else => 0;

Dangling-else rule: when if statements nest without an explicit else on the inner one, else binds to the nearest unmatched if — the same convention C and C++ use. This is a well-known ambiguity class (it's the specific reason Rust mandates braces on if bodies), reintroduced here deliberately in exchange for the more compact single-statement form; use explicit braces on the inner if whenever the binding could be unclear:

if a => if b => x(); else => y();  // else binds to `if b`, not `if a`
if a { if b => x(); } else => y(); // braces make the outer binding explicit instead

while loops:

let mut i: u32 = 0;
while i < 10 {
    i += 1;
}

There's no for loop construct defined yet — worth deciding later whether it's built as sugar over while or added as its own construct.


Structs

struct Point { 
    // declare a field with the "field" keyword, and end with a semicolon
    field x: u32;
    field y: u32;
    
    // make a field public with the pub keyword.
    pub field id: u32;
    
    // consts can also be stored within struct definitions. 
    // this associated constant can be accessed with Point::SOME_SIZE
    const SOME_SIZE: u32 = 0;
    
    // you can also use associated statics.
    // as with statics in other languages, 
    // you should use an atomic type when dealing with them in concurrent code, 
    // even on a single core architecture like DSA.
    static COUNT: u32 = 0;
    
    pub fn new(x: u32, y: u32) -> Self {
        // not thread safe!
        let id = COUNT;
        COUNT += 1;
        
        Self {
            x,
            y,
            id
        }
    }
    
    // methods can be declared inside struct definitions.
    // methods starting with a *self access the struct immutably
    pub fn get_x(*self) -> u32 {
        // implicit return on the last expression of a function body;
        self.x 
    }

    // the *mut self argument means the method can mutate the instance of the struct it is performed on.
    pub fn set_y(*mut self, y: u32) {
        self.y = y;
    }

    // returns a unit vector from the origin in the direction of the point.
    // taking self as a parameter implicitly moves self into the function body.
    // this means the value is no longer in the scope of the caller and is therefore inaccessible. 
    // any pointers to the value that still exist may be dangling and should not be used.
    pub fn unit(self) -> Self {
        Self {
            x: self.x / (self.x + self.y),
            y: self.y / (self.x + self.y),
        }
    }
}

Enums

You can use enums to represent various different types at the same time — useful for state machines.

enum Shape {
    variant Circle(u32);
    variant Rectangle(u32, u32);
    variant Square(u32);
    variant Point; // doesn't have a size so we use the unit variant.

    const PI: u32 = 3; // the lack of floating point values isn't ideal.

    pub fn is_square(*self) -> bool {
        // pattern matching with match!
        match *self {
            Self::Square(_) => true,
            _ => false,
        }
    }
}

Generics

DSCS supports simple generics with static dispatch — every generic function or struct is monomorphized once per concrete type it's used with at compile time. There is no runtime type erasure and no hidden indirection.

struct Vec<T> {
    field data: *mut T;
    field capacity: u32;
    field length: u32;
    
    // with_capacity takes a generic T and an allocator, and returns an instance of
    // Self holding that type T. DSCS is a language with manual memory management, so
    // you must pass an allocator whenever you intend to allocate dynamic memory.
    fn with_capacity<A: Allocator>(alloc: *mut A, capacity: u32) -> Self {
        let ptr = alloc.alloc(capacity) as *mut T;
        Self {
            data: ptr,
            capacity,
            length: 0
        }
    }
    
    fn length(*self) -> u32 {
        self.length
    }
    
    fn resize(*mut self, alloc: *mut A, new_size: u32) {
        self.data = alloc.realloc(self.data as *mut void, new_size) as *mut T;
        self.capacity = new_size;
    }
    
    // consumes self and frees its memory.
    fn drop(self, alloc: *mut A) {
        alloc.free(self.data as *mut *void);
    }
}

Note on the original draft: the earlier version of this example passed alloc: *mut Allocator directly, with Allocator used as a bare trait type rather than an explicit generic bound. This is intentional, not an oversight — see Bare Trait Names as Types below for the rule.


Traits

trait Allocator {
    // allocates a new block of memory of the given size and returns a pointer to it.
    // a void pointer must be explicitly cast by the caller to the type that they intend to use it as, before it can be dereferenced.
    // dereferencing a void pointer is a compiler error.
    fn alloc(*mut self, size: u32) -> *mut void;
    
    // frees the memory allocated to `ptr`
    // this also sets the pointer to a null pointer so that the user can check it has been freed.
    // while we cannot set it to zero by dereferencing it, 
    // we can cast it to a *null which implicitly sets the pointer to zero.
    fn free(*mut self, ptr: *mut *void);
    
    // reallocates the block at `ptr` to be `new_size` bytes, and returns a pointer
    // to the (possibly moved) block.
    fn realloc(*mut self, ptr: *mut void, new_size: u32) -> *mut void;
}

trait DivisibleByN<T: Integer> {
    // the integer_div method belongs to the Integer trait.
    fn is_divisible(*self, n: u32) -> bool {
        self.integer_div(n) == 0
    }
}

Because traits are always statically dispatched, a bound like <T: Integer> or <A: Allocator> is resolved and monomorphized entirely at compile time — calling a trait method never involves a runtime lookup.

Bare Trait Names as Types

Since DSCS has no dynamic dispatch, a bare trait name used directly in a type position is unambiguous — there's no competing dyn Trait reading it could be confused with — so it's allowed as shorthand for an implicit static-dispatch generic parameter, the same idea as Rust's impl Trait in argument position, just without the impl keyword:

fn alloc_point(a: *mut Allocator) -> Box<Point> { ... }
// exactly equivalent to:
fn alloc_point<A: Allocator>(a: *mut A) -> Box<Point> { ... }

Each occurrence gets its own independent, anonymous type parameter — this matches Rust's actual impl Trait semantics exactly, including the consequence that two bare-trait-typed parameters in the same signature are not required to be the same concrete type:

fn transfer(src: *mut Allocator, dst: *mut Allocator) { ... }
// src and dst may be DIFFERENT concrete Allocator types — nothing here forces them
// to agree.

If two or more parameters need to share the same concrete type, use an explicit named generic bound instead:

fn transfer<A: Allocator>(src: *mut A, dst: *mut A) { ... }
// src and dst are now forced to be the same concrete allocator type.

This sugar is currently defined for parameter position only. Return-position bare trait names (Rust's impl Trait return-position equivalent) raise a separate question — an opaque return type's exact size wouldn't be visible to the caller, which matters more in a systems language than it does in Rust — and aren't covered by this rule yet.


Function Types

Functions are values too — this matters in systems programming, since interrupt handlers need to be installed as data the machine can jump to:

fn add(x: u32, y: u32) -> u32 {
    return x + y;
}

// in systems level programming we sometimes need to write ISRs to handle interrupts.
fn invalid_instruction_fault() {
    // handle the interrupt    
}
// but we can't just put a function in our code and expect interrupts to go to it. 
// the machine needs to know where to find our interrupt handlers.

// this means functions also need to be a type!
// C got around this using void pointers, but we can do better here.

// A function pointer has the type `Func<(ArgTypes...), RetType>`, with the extra
// hardcoded constraint that the argument list must be written as a tuple type, even
// for a single argument.
let handler: Func<(), void> = invalid_instruction_fault;
let func: Func<(u32, u32), u32> = add;

func(1, 2); // == 3

A Func<...> value is nothing more than the address of the function it points to — one machine word, Copy, no heap allocation. It cannot capture or close over local variables; it's purely a code address. If you need a callable value that also carries its own state, see the next section.


Runtime Polymorphism (without dynamic dispatch)

Sometimes you genuinely need runtime polymorphism — for example, a driver table where the concrete device type isn't known until the kernel is running. DSCS has no built-in dyn Trait for this. Instead, you build the vtable yourself, as an ordinary struct of function pointers:

struct ShapeOps {
    field area: Func<(*void), u32>;
}

fn circle_area(self: *void) -> u32 {
    let c = self as *Circle;
    3 * (*c).radius * (*c).radius
}

const CIRCLE_OPS: ShapeOps = ShapeOps { area: circle_area };

// A caller holding a `*void` payload pointer and a `ShapeOps` table can now call
// `(ops.area)(payload)` without ever knowing the concrete type behind the pointer —
// this is exactly what `dyn Trait` does in other languages, just fully visible and
// under your control instead of compiler-generated.

This is also the pattern to reach for whenever you need something closure-shaped: a Func<...> paired with its own *void (or concretely-typed) state pointer, bundled into a small struct.


Memory Management

You have a few different ways to manage memory.

Manual alloc/free (discouraged)

fn main() {
    let mut alloc = ArenaAllocator::new(HEAP_START);

    // the pattern below is commonly used in C, however it's strongly discouraged in this language!
    // the data is not a valid instance of SomeType, and is not guaranteed to be zero initialised.
    // please don't do this, it makes it very easy for UB to happen!
    let ptr = alloc.alloc(Point::size()) as *mut Point; 
    ptr.id = 1; 
    
    alloc.free(ptr as *mut *void); // don't use pointer now!
}

defer

Deferring execution of a statement means it's executed as soon as the enclosing function returns — regardless of which return path was taken. Multiple defer statements in the same function run in reverse order (last deferred, first executed), the same as Zig.

Box<T>

fn main() {
    let mut point = Box::new(alloc, Point::new(2, 3));
    // Box::drop(self, alloc) uses the passed allocator to free its contents when called.
    defer point.drop(alloc);

    // Box is implemented as a transparent type so we can simply use Box<T> as if it were a *mut T!
    // method calls on a Box<T> auto-dereference through it exactly as they would
    // through a raw *mut T receiver.
    point.set_y(5);
}

Box<T> has exactly one owner. Cloning a Box<T> is a deep clone — it allocates fresh memory and copies the underlying value, which is why cloning a Box requires passing an allocator explicitly, same as any other allocation.

Rc<T>

Rc<T> is for values that need multiple simultaneous owners. Unlike Box<T>, cloning an Rc<T> is cheap: it doesn't touch or copy the underlying data at all, it just increments a reference count and hands back a new, independent Rc<T> handle to the same memory.

fn main() {
    let a = Rc::new(alloc, Point::new(1, 2));
    let b = a.clone(); // cheap: bumps the refcount, no allocation, no data copy

    defer a.drop(); // decrements the refcount
    defer b.drop(); // decrements the refcount; frees the Point once it reaches zero
}

Like Box<T>, each individual Rc<T> handle is still linear — once you've dropped or moved a given handle, that specific handle is gone and must not be used again. Nothing stops you from calling .clone() first, though, to mint another independent handle before doing so.


Ownership & Memory Safety

DSCS's ownership model is loosely inspired by Rust's, with one major simplification: there are no references, only pointers. *T/*mut T are not tracked or validity-checked by the compiler at all.

The rules that do apply:

  • Passing a value by value (e.g. fn unit(self) -> Self, not *self) moves it into the function. After the call, the caller's copy is gone — any pointers still pointing at it may now be dangling and must not be used.
  • Once a value has been dropped (via defer, an explicit .drop() call, or a raw alloc.free(...)) or moved out of its original binding, you must not create new pointers to it, and any pointers created before that point are no longer valid to dereference.
  • There is no borrow checker and no lifetimes. The compiler does not verify any of the above for you — it trusts the programmer entirely. This is a deliberate simplicity/performance tradeoff relative to Rust: DSCX, the higher-level language built on top of DSCS, is where automatic safety is intended to live.
fn dangling_example(alloc: *mut Allocator) -> *u32 {
    let boxed = Box::new(alloc, 5);
    let p: *u32 = &*boxed; // p now points at boxed's memory
    boxed.drop(alloc);     // the memory is freed here

    p // DANGLING: p now points at freed memory. DSCS will not stop you from
      // returning or dereferencing it — this is undefined behaviour.
}

Error Handling

Like Rust, error handling is done with Result<T, E> and optionals with Option<T>.

fn parse_digit(c: u8) -> Result<u32, ParseError> {
    if c >= '0' && c <= '9' {
        Result::Ok((c - '0') as u32)
    } else {
        Result::Err(ParseError::NotADigit)
    }
}

fn find(arr: [u32; 6], target: u32) -> Option<u32> {
    let mut i: u32 = 0;
    while i < 6 {
        if arr[i] == target {
            return Option::Some(i);
        }
        i += 1;
    }
    Option::None
}

An owned value in DSCS can never be null or none. Only a pointer can be null, and only once it's been explicitly cast:

let p: *u32 = *null; // must be explicit; there is no implicit null for owned values

Concurrency

DSCS has no threads and no language-level async/await. Concurrency is cooperative and single-threaded, built from two small pieces — Poll<T> and Task are ordinary library-level types, not compiler magic:

enum Poll<T> {
    variant Ready(T);
    variant Pending;
}

trait Task {
    type Output;
    fn poll(*mut self) -> Poll<Self::Output>;
}

struct Keyboard;
impl Task for Keyboard {
    type Output = u8;
    fn poll(*mut self) -> Poll<Self::Output> {
        if KEYBOARD.has_byte() { Poll::Ready(KEYBOARD.read()) } else { Poll::Pending }
    }
}

A scheduler is ordinary DSCS code that polls a task on a loop, halting the CPU (via the hlt intrinsic — real hardware idling, not a busy spin) whenever there's nothing left to do:

fn run_scheduler(mut task: Keyboard) {
    loop {
        match task.poll() {
            Poll::Ready(byte) => { /* handle it, then reschedule or exit */ }
            Poll::Pending => hlt(), // idle until the next interrupt wakes us
        }
    }
}

A scheduler juggling several different concrete task types at once needs the same manual-vtable pattern from Runtime Polymorphism — a struct pairing a Func<(*void), Poll<...>> with its own state pointer — since DSCS has no dyn Trait to erase each task's concrete type automatically.

Interrupt Service Routines

An ISR should do as little as possible — typically just flip a flag that the corresponding Task's poll() checks on its next pass. It must never run task logic directly:

static UART_READY: bool = false;

fn uart_isr() {
    UART_READY = true;
}

struct UartRead;
impl Task for UartRead {
    type Output = u8;
    fn poll(*mut self) -> Poll<u8> {
        if UART_READY {
            UART_READY = false;
            Poll::Ready(uart_read_byte())
        } else {
            Poll::Pending
        }
    }
}

Because an ISR can preempt the scheduler at any instruction boundary — even on a single-core machine like DSA — any state touched by both an ISR and ordinary code (like UART_READY above) needs interrupts masked around the access if it isn't a single atomic instruction, to avoid a torn read/write.