R'

A subset of Radiance

R’, pronounced “R prime” is a lower level unsafe subset of the Radiance programming language, that serves as the foundation for building the Radiant operating system and bootstrapping the full Radiance language. By providing direct memory access, pointer arithmetic, and hardware control capabilities, R’ enables the construction of essential system components like the kernel, device drivers, and runtime environment, on top of which Radiance applications can be built.

The R’ subset will therefore be designed and implemented first, after which the full language will be built using R’ as the implementation language. R’ is focused on simplicity of implementation, predictability, and familiarity with other system languages.

Compared to Radiance

Compared to Radiance, R’ has:

This keeps the language small and simpler to reason about.

Basic syntax

R’ programs consist of statements that end with semicolons. Blocks of code are enclosed in curly braces.

// This is a single-line comment.
let x: i32 = 42;

if x > 0 {
    x = x * 2;
}
use std;

fn main() -> i32 {
    std::io::print("Hello World!");

    return 0;
}

Reserved words

R’ has 43 reserved words. These words cannot be used as module, variable or function names.

mod        use    fn        if      else      while   loop  throw
extern     pub    return    break   continue  switch  case  throws
and        or     not       true    false     for     catch
let        mut    enum      struct  in        try
undefined  nil    align     static  const     as

Primitive types

The set of primitive types is the following. Primitive type names are part of the reserved word set.

u8 u16 u32 i8 i16 i32 bool void

Note that there are no floats or 64-bit types in R’.

Primitive type literals

Booleans – bool.

true false

Integers – i8 i16 i32 u8 u16 u32.

8 41 -9 48193 0

Characters – u8.

'a' 'z', '\n'

Nil. Used when there is an absence of value.

nil

Bindings

R’ has immutable let and mutable mut bindings.

// Immutable binding.
let f: u8 = 42;

// Mutable binding.
mut g: u8 = 24;

Values can be re-assigned if declared with mut.

mut x: u8 = 16;

x = x * 2;
x = x + 1;

Bindings must always include a type annotation separated by a : from the identifier. Note that assignment is not an expression, unlike in C.

If a value is to be left uninitialized, it can be assigned the undefined value.

let xs: [u8; 256] = undefined;

Bindings can also have their storage aligned to a value greater than the alignment of the type being bound.

let xs: [u8; 256] align(16) = undefined;

Operators

R’ has arithmetic operators:

+ - * / %

Comparison operators, that yield bool:

== != < > <= >=

Bitwise operators, that yield any integer type:

& | ^ ~

And logical operators that take boolean operands and yield a bool.

and or xor not

Comments

As seen above, comments start with // and go until the end of the line.

// This is a comment.
// The comment continues on the next line.

If statements

if x > 0 {
    // Code executed if `x > 0`.
} else if x < 0 {
    // Code executed if `x < 0`.
} else {
    // Code executed if `x == 0`.
}

Switch statements

Switch statements work similarly to other languages, and unlike C’s switch, there is no fallthrough.

switch x {
    case 1 => {
        // `x` is `1`.
    },
    case 2, 3 => {
        // `x` is `2` or `3`.
    }
    default => {
        // `x` is another value.
    }
}

Loops

The simplest loop is the unconditional loop, introduced with the loop keyword. The break statement can be used to exit a loop, and continue can be used to jump back to the beginning of the loop.

mut i: i32 = 0;

loop {
    i = i + 1;

    if i > 8 {
        break; // Exit the loop.
    }

    if i % 2 == 0 {
        continue; // Skip to next iteration.
    }
}

While loops

While loops specify a condition under which the loop should continue.

mut i: i32 = 0;

while i < 8 {
    i = i + 1;
}

For loops

For loops are used for iterating over a collection.

let numbers: [i32; 5] = [1, 2, 3, 4, 5];

for num in numbers {
    // `num` takes each value in the array.
}

for num in 1..6 {
    // This loop is equivalent to the one above.
}

for loops have an optional else clause that is executed if the loop body was not entered due to the iterator being empty.

let numbers: [i32; 5] = [1, 2, 3, 4, 5];

for num in numbers {
    // `num` takes each value in the array.
} else {
    std::io::print("No numbers to iterate over!");
}

Functions

Functions are defined using the fn keyword.

// Function with no parameters and a `i32` return type.
fn answer() -> i32 {
    return 42;
}

// Function with parameters.
fn add(a: i32, b: i32) -> i32 {
    return a + b;
}

// Function with no return value.
fn processOp(op: u32) {
    // An optional `return` statement with no value can be used
    // to return early.
    return;
}

Functions are called using () syntax.

let x: i32 = answer();  // `42`
let y: i32 = add(3, 2); // `5`
processOp(0);           // Does not return a value. Cannot be assigned.

Functions can be stored and passed to other functions.

fn request(r: Req, callback: fn (Res) -> bool) {
    ...
}
request(req, handleResponse);

Arrays

Arrays are fixed-size collections of elements of the same type.

array type = "[" element type ";" array length "]"
// Declaration and initialization.
mut numbers: [i32; 5] = [1, 2, 3, 4, 5];

// Accessing elements (zero-indexed).
let first: i32 = numbers[0]; // 1
let last: i32 = numbers[4];  // 5

// Array elements can be assigned if the array is declared as mutable.
numbers[2] = 42;

// Arrays can have their length taken.
numbers.len // 5

Arrays are passed and returned by value from functions.

Pointers

Pointers provide a way to refer to a value without copying it.

let x: i32 = 42;
let p: *i32 = &x; // `p` is a pointer to `x`.

Pointers can be dereferenced using the * operator:

let y: i32 = *p; // y = 42

Mutable pointers can be used to pass a mutable reference to a value.

let p: *mut i32 = &mut x;

The underlying value of a mutable pointer can be modified via dereferencing:

*p = 100; // `x` is now `100`

Slices

let numbers: [i32; 5] = [0, 1, 2, 3, 4];

// Full slice.
let full: *[i32] = &numbers[..]; // Elements `[0, 1, 2, 3, 4]`

// Partial slice with start and end indices.
let middle: *[i32] = &numbers[1..4]; // Elements `[1, 2, 3]`

// Slice from start to a specific index.
let prefix: *[i32] = &numbers[..3]; // Elements `[0, 1, 2]`

// Slice from a specific index to the end.
let suffix: *[i32] = &numbers[2..]; // Elements `[2, 3, 4]`

// Slice length.
suffix.len // 3

// Slice pointer. Points to first element of slice and has type *[u8].
suffix.ptr

Mutable slices, like mutable pointers allow modification of the referenced values.

let slice: *mut [i32] = &mut numbers[..];
slice[3] = 6; // `numbers` is now `&[0, 1, 2, 6, 4]`.

Strings

Strings are u8 slices, or *[u8]. Here is a string literal:

"Hello World!\n"

Structs

Structs are custom data types that group related values called fields.

// A struct definition.
struct Point {
    x: i32,
    y: i32,
}

Struct literal syntax allows for struct initialization.

// Creating a struct instance via a struct literal.
mut p: Point = Point { x: 10, y: 20 };

Structs are accessed via the . field access syntax.

p.y = p.x * 2;

Structs can have default field values, making their initialization optional:

struct Size {
    w: u32 = 1, // Set default value to `1`.
    h: u32 = 1, // Set default value to `1`.
}

// `Size { w: 2, h: 1 }`.
let s: Size = Size { w: 2 };

Structs are passed and returned by value from functions.

Anonymous structs allow for inline ad-hoc struct definitions.

// An anonymous struct binding.
let s: struct { x: i32, y: i32 } = { 1, 2 };

Enums

Enumerations define a type with a fixed set of possible values, called variants. Unlike C enumerations, they are strongly typed in R’ and can’t be compred to integers without casting.

// Enum definition with three variants.
enum Channel {
    Red,
    Green,
    Blue,
}

// Creating enum values.
let c: Channel = Channel::Red;

// Comparing enum values.
if c == Channel::Red {
    // Do something if channel is red.
}

Switch statements can be used to match all enum variants:

switch channel {
    case Channel::Red => ...
    case Channel::Green => ...
    case Channel::Blue => ...
}

Associated data

The enum type can also be used to represent tagged unions.

enum Response {
    Success: Data,
    Failure: struct {
        errorCode: u32,
        message: str
    }
    Offline: void,
}

The switch statement is useful for handling all union cases:

// Exhaustively match all union variants.
// Binds the associated value to an identifier via `as`.
switch resp {
    case Response::Success(d) => {
        // Data value assigned to `d`.
    }
    case Response::Failure(f) => {
        handleFailure(f.errorCode, f.message);
    }
    case Response::Offline => {
        // Handle offline.
    }
}

If only one variant needs to be matched, if case can be used:

if case Response::success(d) = resp {
    // Handle success case.
}

Constants

Constants are values defined at compile time. They must be initialized with a constant expression.

const MAX_ENTITIES: u32 = 4096;
const DEFAUL_CHANNEL: Channel = Channel::red;

Static

Static variables, unlike constants have storage at a fixed memory location. However, unlike regular variables, static variables live for the entire duration of the program.

static COUNTER: u32 = 0;

Modules

Modules are used to organize code. They effectively function as both compilation units, and namespaces. Modules can have sub-modules, and expose public functions and constants. Each source file defines a module, named after the file. Sub-modules are defined using directories and declared with the mod keyword. Hence, the following hierarchy:

std/
└─ net/
   └─ tcp.r

Defines the following module hierarchy:

std::net::tcp

Modules control the visibility of their members via the pub keyword, which signifies public visibility. Members not marked as pub are private.

For example, inside std/net/tcp.r we could define a public function as such:

pub fn connect(addr: Ip) {
    ...
}

To use this function, the use keyword is employed with a module name.

use std;
...
std::tcp::connect(addr);

We can directly import sub-modules, which avoids having to repeat the full module path on every call site:

use std::tcp;
...
tcp::connect(addr); // `std` can be omitted, since `tcp` is imported.

Module declarations create the necessary symbols for importing modules. In std/net.r, we would declare the tcp module as so:

pub mod tcp;

Optional types

To deal with the absence of a value, R’ has the concept of optional types, or optionals. The syntax for optionals is ?T, where T is any regular type. For example ?i32 represents an optional i32. Optional types either carry the base value T, or nil.

Given a function lookup that returns an optional Path:

fn lookup(key: u32) -> ?Path {
    ...
}

We can branch on the presence or absence of a value with if let:

if let path = lookup(k) {
    // This branch is evaluated if `lookup` returns a value,
    // with `path` bound to it.
} else {
    // This branch is evaluated if `lookup` returns `nil`.
}

An optional guard can be added to the if let condition.

if let path = lookup(k); path != "" {
    // Path was found and is not empty.
}

We can also loop while a function returns a value, breaking as soon as it returns nil, with a similar construct:

while let r = nextResult() {
    // Evaluated as long as `r` is not `nil`.
}

An optional guard can be added to the while let condition.

while let r = nextResult(); r.status > 0 {
    // Evaluated as long as `r` is not `nil`.
}

while loops have an optional else clause as well that is executed when the loop condition is never met.

while let r = nextResult() {
    // Evaluated as long as `r` is not `nil`.
} else {
    std::io::printLn("No results found!");
}

Chaining:

// Assigns nil to `value` if it encounters a nil in the chain.
let value: ?*[u8] = getObject(name)?.path?.baseName();

Coalescing:

// Assigns `Path::new("/")` if `optionalPath` is nil.
let value: Path = optionalPath ?? Path::new("/");

A regular switch statement can also be used:

switch optional {
    case value {
        // Handle presence of value.
    } default {
        // Handle absence of value.
    }
}

Errors

R’ has built-in error handling similar to languages like Swift and Zig.

/// A network error.
enum NetworkError {
    DialError,
    PermissionError,
    Disconnected,
}

/// Function that sends a request and can fail with `NetworkError`.
fn request(req: Req) -> Res throws NetworkError {
    ...
    if not online {
        // Throw an error.
        throw NetworkError::Disconnected;
    }
    ...
    return res;
}
// Try the request, and propagate the error if it fails.
let resp: Res = try request(req);

// Try the request, and panic if it fails.
let resp: Res = try! request(req);

// Try the request, and convert result into an optional.
if let resp = try? request(req) {
    // Success.
}

// Try the request, and handle the error if it fails.
let resp: Res = try request(req) catch err {
    return Status::Failed;
};

Type casting

Casting between numeric types is possible using the as keyword.

let x: i16 = 13;
let y: i8 = x as i8;

If the integer doesn’t fit in the destination type, it is truncated.

Compiler built-ins

R’ provides several predefined constants that are available in the language environment. These constants are set at compile time.

@file        // The current file path, eg. `/lib/core/io.rad`
@module      // The current module path, eg. `core::io`
@function    // The current function name, eg. `print`
@line        // The current line number (1-indexed)
@column      // The current column number (1-indexed)

Functions that work on types are also provided. These are evaluated at compile time.

@sizeOf(T)    // Get the size of a type or expression
@alignOf(T)   // Get the alignment of a type or expression

Integer overflow

Integer overflow and underflow wrap around according to RISC-V arithmetic behavior, where values that exceed the type’s range wrap to the opposite end of the representable range.

// 32-bit signed integer overflow.
let max: i32 = 2147483647;
let overflow: i32 = max + 1;  // -2147483648

// 32-bit signed integer underflow.
let min: i32 = -2147483648;
let underflow: i32 = min - 1;  // 2147483647

// 32-bit unsigned integer overflow.
let umax: u32 = 4294967295u;
let uoverflow: u32 = u_max + 1;  // 0

Naming conventions