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

634 lines
20 KiB
Markdown

# 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](#runtime-polymorphism-without-dynamic-dispatch).
- **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](#ownership--memory-safety).
---
## Lexical Structure
```rust
// single line comment...
/* multi
line
comment */
```
---
## Variables, Constants & Statics
```rust
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
```rust
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:
```rust
let arr: [u32; 6] = [0, 1, 2, 3, 4, 5];
// = [1; 6]; // alternative syntax for [1, 1, 1, 1, 1, 1]
```
### Pointers
```rust
// 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](#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:
```rust
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:
```rust
if condition => do_y();
```
The same rule applies to `else`, and the two forms compose — a branch can use
whichever form fits it:
```rust
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:
```rust
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:
```rust
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:
```rust
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
```rust
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.
```rust
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.
```rust
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](#bare-trait-names-as-types) below for the rule.
---
## Traits
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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)
```rust
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>`
```rust
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.
```rust
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.
```rust
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>`.
```rust
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:
```rust
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:
```rust
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:
```rust
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](#runtime-polymorphism-without-dynamic-dispatch)
— 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:
```rust
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.