The Oxide Programming Language

Welcome to The Oxide Programming Language, an introductory book about Oxide.

Oxide is an alternative syntax for Rust that provides familiar Swift/Kotlin-inspired conventions while producing identical binary output to equivalent Rust code. It maintains all of Rust's safety guarantees, ownership model, and performance while making systems programming more accessible.

Who This Book Is For

This book is for developers transitioning from Swift, Kotlin, TypeScript, or C# who want to leverage Rust's performance and safety guarantees without navigating its unfamiliar syntax.

How to Use This Book

This book follows the same structure as The Rust Programming Language book. If you're already familiar with Rust, you can skip to the specific chapters that cover Oxide's syntax differences.

Installation

Let's get Oxide set up on your computer! We'll install the necessary tools to compile and run Oxide programs.

What is Oxide?

Oxide is an alternative syntax for Rust that compiles to identical binary output via a rustc fork. If you're coming from Swift, Kotlin, TypeScript, or C#, Oxide's syntax will feel familiar while giving you access to Rust's powerful safety guarantees and performance.

Key points about Oxide:

  • Rust with familiar syntax: Oxide uses Swift/Kotlin-inspired conventions as an alternative to Rust's conventions
  • 100% compatible with Rust: Both .ox files (Oxide) and .rs files (Rust) can coexist in the same project
  • Same performance: Oxide compiles to the exact same machine code as Rust—zero runtime overhead
  • All of Rust's power: You get ownership, borrowing, the type system, traits, generics, and everything else that makes Rust safe

Let's look at a quick example of what Oxide looks like:

// Oxide: familiar and readable
fn greet(name: str): String {
    "Hello, \(name)!"
}

Compared to equivalent Rust:

#![allow(unused)]
fn main() {
// Rust: using Rust's conventions
fn greet(name: &str) -> String {
    format!("Hello, {}", name)
}
}

Both compile to identical code. The difference is the syntax.

Prerequisites

Before installing Oxide, you'll need:

  1. Rust toolchain - The Rust compiler (rustc), Cargo package manager, and Rust standard library
  2. A text editor or IDE - Any editor works; VS Code with Rust Analyzer is recommended
  3. A terminal - Oxide development happens on the command line

Since Oxide compiles via a rustc fork, having the standard Rust toolchain installed is essential, even though you'll primarily use Oxide syntax.

Installation

Step 1: Install Rust

First, install Rust using rustup. Open your terminal and run:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

This downloads and installs rustup, which manages your Rust toolchain. Follow the on-screen prompts.

On Windows, download and run rustup-init.exe from https://rustup.rs.

After installation, verify Rust is installed:

rustc --version
cargo --version

You should see version numbers for both.

Step 2: Install Oxide (Future Reference)

Note: The Oxide compiler is currently in development. This section describes how you'll install it once it's released.

Once available, you'll install the Oxide compiler via Cargo:

cargo install oxidec

This installs the oxidec command, which is the Oxide compiler. It's a wrapper around the rustc fork that handles .ox files transparently.

Verify the installation:

oxidec --version

You should see the Oxide compiler version.

For Now: Setting Up Your First Project

While the compiler is under development, you can explore Oxide syntax and concepts by:

  1. Reading this book and the examples
  2. Experimenting with the ideas in Rust code
  3. Preparing your workflow for when Oxide releases

Setting Up an Oxide Project

Creating a new Oxide project is straightforward using Cargo with the --oxide flag:

cargo new --oxide hello_oxide
cd hello_oxide

This creates an Oxide project with src/main.ox containing:

fn main() {
    println!("Hello, world!")
}

Development Status: Oxide v1.0 is currently in development. The Oxide compiler (a fork of rustc) and the --oxide flag for Cargo are being implemented and will be available when the Oxide toolchain is released. For early testing before the release, you can manually create a project with cargo new and rename src/main.rs to src/main.ox.

Here's what a basic project structure looks like:

hello_oxide/
├── Cargo.toml
└── src/
    ├── main.ox       # Your Oxide code
    └── lib.rs        # Optional: Rust library code (can coexist)

When you run cargo build or cargo run, Oxide files (.ox) and Rust files (.rs) work together seamlessly. Both compile to identical machine code.

Building and Running

Build and run your Oxide project:

cargo build
cargo run

The Oxide compiler processes your .ox files and compiles them via the rustc fork.

Troubleshooting

"command not found: oxidec"

Ensure you've installed the Oxide compiler with cargo install oxidec and that ~/.cargo/bin is in your PATH.

"Can't find Rust toolchain"

The Oxide compiler requires the Rust toolchain. Verify Rust is installed with rustc --version. If not, follow Step 1 above.

"Unknown file extension: .ox"

Make sure your file has the .ox extension (lowercase). Oxide files must use this specific extension to be recognized.

IDE/Editor support

If your editor doesn't recognize .ox files:

  • VS Code: Install the Oxide extension (coming soon)
  • Other editors: Configure them to treat .ox files as Rust for syntax highlighting until Oxide support is available

Moving Forward

You're now ready to start learning Oxide! The next chapter will guide you through writing your first program. Whether you're new to systems programming or transitioning from another language, Oxide provides syntax that may feel familiar while giving you access to Rust's excellent type system and safety guarantees.

If you get stuck, remember:

  • The examples in this book are all valid Oxide code
  • You can always reference the Rust equivalent to understand the underlying semantics
  • Rust's extensive ecosystem documentation applies directly to Oxide code
  • Both Oxide and Rust are valid choices—Oxide simply offers an alternative syntax

Happy coding!

Hello, World!

It's traditional to begin learning a new programming language by writing a little program that prints the text "Hello, world!" to the screen, so we'll do that here! You'll also write your first Oxide program.

Creating a Project Directory

First, create a directory where you'll store your Oxide code. It doesn't matter to Oxide where your code lives, but for the exercises and projects in this book, we suggest creating a projects directory in your home directory and keeping all your projects there.

Open a terminal and run the following commands to create a projects directory and a hello_world project within it.

On Linux, macOS, and PowerShell on Windows, enter this:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

On Windows CMD, enter this:

> mkdir %USERPROFILE%\projects
> cd /d %USERPROFILE%\projects
> mkdir hello_world
> cd hello_world

Writing and Running the Program

Next, make a new source file and call it main.ox. Oxide files always end with the .ox extension. If you're using more than one word in your filename, the convention is to use an underscore to separate them. For example, you'd use hello_world.ox rather than helloworld.ox.

Now open the main.ox file you just created and enter the code in Listing 1-1:

Filename: main.ox

fn main() {
    println!("Hello, world!")
}

Listing 1-1: A program that prints "Hello, world!"

Save the file and go back to your terminal window in the ~/projects/hello_world directory. On Linux or macOS, enter the following commands to compile and run the file:

$ oxidec main.ox
$ ./main
Hello, world!

On Windows, enter:

> oxidec main.ox
> .\main.exe
Hello, world!

Regardless of your operating system, the string Hello, world! should print to the terminal. If you didn't see this output, refer to the Troubleshooting section for ways to get help.

Anatomy of an Oxide Program

Let's review this "Hello, world!" program in detail. Here's the first piece:

fn main() {

}

These lines define a function named main. The main function is special: it's always the first code that runs in every executable Oxide program. We declare it using the keyword fn, and it takes no parameters and returns nothing. If there were parameters, they would go inside the parentheses (). Also notice that the function body is wrapped in curly brackets {}. Oxide requires curly brackets around all function bodies.

Next is this line:

    println!("Hello, world!")

This line does all the work in this little program. It calls the println! macro with the string "Hello, world!" as an argument. The ! indicates that println! is a macro rather than a normal function. (We'll learn more about macros in detail in Chapter 19.) The macro prints the string to the screen.

Semicolons and Optional Syntax

You might notice this program doesn't have a semicolon at the end of the println! line. In Oxide, semicolons are optional in most contexts. They're not required at the end of function calls or statements. You can write it either way:

fn main() {
    println!("Hello, world!")
}

Or with semicolons, if you prefer:

fn main() {
    println!("Hello, world!");
}

Both are valid Oxide. The choice is stylistic—pick what feels most natural to you.

Comparison with Rust

If you're familiar with Rust, you might notice some differences. In Rust, you would write:

fn main() {
    println!("Hello, world!");
}

The key differences between Oxide and Rust in this example:

  1. File extension: Oxide uses .ox instead of .rs
  2. Semicolons: Oxide makes them optional; Rust requires them
  3. Compiler: Oxide uses oxidec (the Oxide compiler, which is a rustc fork); Rust uses rustc

Everything else—the function syntax, the println! macro, and the overall program structure—is identical to Rust. This is because Oxide is fundamentally Rust with a different surface syntax.

Compiling and Running Are Separate Steps

You've just seen how to run a new program, but let me explain the process more fully.

Before running an Oxide program, you must compile it using the Oxide compiler, even for simple programs. The command is oxidec followed by your source filename:

$ oxidec main.ox

If you're on Windows, use:

> oxidec main.ox

This command compiles your main.ox file and creates an executable called main (or main.exe on Windows). You can then run the executable:

On Linux or macOS:

$ ./main
Hello, world!

On Windows:

> .\main.exe
Hello, world!

Even for one-line programs, you need to explicitly compile before running. This follows Rust's ahead-of-time compilation approach, which differs from interpreted languages like Python or JavaScript where you can run code directly. The benefit is performance: compiled code runs much faster than interpreted code, and you catch errors at compile time rather than runtime.

Troubleshooting

The most common problems beginners encounter:

Command not found: oxidec

This usually means the Oxide compiler isn't installed or isn't in your system's PATH. Refer to the Installation chapter to properly install Oxide.

Compilation errors

If you see errors when running oxidec, double-check that:

  • Your file is saved as main.ox (or another .ox filename)
  • You're running the command from the directory containing your source file
  • Your code matches Listing 1-1 exactly, including the curly brackets and parentheses

Program output doesn't appear

  • On macOS or Linux, make sure you're using ./main (with the dot-slash) to run the executable
  • On Windows, make sure you're using .\main.exe (with the backslash)
  • If the window closes immediately on Windows, try running it from PowerShell or Command Prompt directly

Congratulations! You've officially written your first Oxide program. Next, we'll look at Oxide's package manager and build system, Cargo, which makes creating more complex programs much easier.

Hello, Cargo!

Cargo is Rust's excellent build system and package manager, and it makes Rust projects much easier to manage. You can use Cargo to create new projects, build them, test them, and distribute them. The good news is that Cargo works seamlessly with Oxide—.ox files are treated identically to .rs files. Oxide benefits directly from Cargo's powerful tooling and ecosystem.

What is Cargo?

Cargo handles several important tasks for you:

  • Building your code with cargo build
  • Running your code with cargo run
  • Testing your code with cargo test
  • Generating documentation with cargo doc
  • Publishing libraries to crates.io
  • Managing dependencies through Cargo.toml

Without Cargo, you'd need to manually compile your code with rustc, manage compilation flags, handle dependencies by hand, and coordinate all these tasks yourself. With Cargo, everything is automated and standardized.

Creating an Oxide Project

To create a new Oxide project, use Cargo with the --oxide flag:

$ cargo new --oxide hello_oxide
     Created binary (application) `hello_oxide` package
$ cd hello_oxide

This generates an Oxide project with src/main.ox containing:

fn main() {
    println!("Hello, world!")
}

Development Status: Oxide v1.0 is currently in development. The --oxide flag will be available in Cargo once the Oxide toolchain is released. For early testing before the release, you can manually create a project with cargo new and rename src/main.rs to src/main.ox.

Let's see what Cargo generated:

$ cd hello_oxide
$ ls -la
drwxr-xr-x  .git
drwxr-xr-x  .gitignore
-rw-r--r--  Cargo.toml
drwxr-xr-x  src

Let's look at the directory structure:

$ tree .
.
├── Cargo.toml
└── src
    └── main.ox

Understanding Cargo.toml

The Cargo.toml file is the manifest for your project. It contains metadata about your package and its dependencies. Here's what was created for us:

[package]
name = "hello_oxide"
version = "0.1.0"
edition = "2021"

[dependencies]

The [package] section contains metadata:

  • name: The name of your project
  • version: The current version of your code
  • edition: The Rust edition (Oxide projects use the same editions as Rust)

The [dependencies] section is where you'd list any external crates your project depends on. We don't have any dependencies yet.

Understanding the src Directory

Cargo expects your source files to be in the src directory. With the --oxide flag, Cargo creates main.ox with a simple "Hello, World!" program:

fn main() {
    println!("Hello, world!")
}

This is valid Oxide code that compiles and runs just like Rust. Let's make it feel more Oxide-like by using string interpolation:

fn main() {
    let language = "Oxide"
    println!("Hello from \(language)!")
}

The differences from the original:

  • Added a variable to demonstrate Oxide's string interpolation: "\(language)"
  • Omitted semicolons (optional in Oxide)
  • The fn main() and println!() syntax work identically in both Oxide and Rust

Building and Running Your Oxide Project

Now let's build the project using Cargo:

$ cargo build
   Compiling hello_oxide v0.1.0
    Finished dev [unoptimized + debuginfo] target/debug/hello_oxide

Congratulations! You've successfully compiled your first Oxide program with Cargo. The executable has been created in target/debug/hello_oxide (or target/debug/hello_oxide.exe on Windows).

To run it, use cargo run:

$ cargo run
   Compiling hello_oxide v0.1.0
    Finished dev [unoptimized + debuginfo] target/debug/hello_oxide
     Running `target/debug/hello_oxide`
Hello, Oxide!

The cargo run command compiles the code and then runs the resulting executable—all in one command. It's very convenient for projects you're actively working on.

A Note About Cargo.lock

When you first build your project, Cargo creates a Cargo.lock file. This file keeps track of the exact versions of dependencies you've built with. For binary projects (like this one), you should commit Cargo.lock to version control so everyone working on the project uses the same dependency versions. For libraries, it's typically not committed.

Cargo Check

If you want to verify that your code compiles without actually producing an executable, you can use cargo check:

$ cargo check
   Checking hello_oxide v0.1.0
    Finished dev [unoptimized + debuginfo] target/debug/hello_oxide

This command is much faster than cargo build because it stops after type-checking and doesn't generate code. It's perfect for getting quick feedback as you're writing code.

Using Cargo with Mixed Oxide and Rust

One powerful feature of Oxide is that you can freely mix .ox and .rs files in the same Cargo project. Cargo doesn't care which extension you use—both are compiled and linked together seamlessly.

For example, if you have existing Rust code you want to reuse, or if you're gradually migrating a project to Oxide, you can simply keep both file types in the same src/ directory:

src/
├── main.ox          # Oxide code
├── utils.rs         # Rust code
├── lib.ox           # Oxide library code
└── integrations.rs  # Rust integrations

Cargo will compile all of them together:

$ cargo build
   Compiling hello_oxide v0.1.0
    Finished dev [unoptimized + debuginfo] target/debug/hello_oxide

You can call Rust functions from Oxide and vice versa—they're compiled to the same intermediate representation and linked together. This makes it easy to adopt Oxide incrementally.

Thinking in Terms of Cargo

As you work with Rust and Oxide projects, here's a mental model for Cargo:

Cargo is to systems programming as npm is to Node.js or pip is to Python. It's the standard way projects are organized, built, and distributed. Rust's community designed Cargo exceptionally well, and Oxide benefits from this thoughtful tooling.

Every Rust and Oxide project you'll encounter follows the same structure:

  • Source code in src/
  • Dependencies listed in Cargo.toml
  • Binaries in target/debug/ or target/release/
  • Tests alongside your source code

Learning Cargo now means you'll immediately understand the structure of any Rust or Oxide project you encounter. This standardization is one of Rust's great strengths.

Building for Release

So far, we've been building in development mode with cargo build. This mode is great for development because compilation is fast and includes debug information. However, it doesn't optimize your code, so the resulting executable is slower.

When you're ready to ship your code, or when you care about performance, use the --release flag:

$ cargo build --release
   Compiling hello_oxide v0.1.0
    Finished release [optimized] target/release/hello_oxide

The executable will be in target/release/hello_oxide. Release builds take longer to compile but run much faster. You can also use:

$ cargo run --release
   Compiling hello_oxide v0.1.0
    Finished release [optimized] target/release/hello_oxide
     Running `target/release/hello_oxide`
Hello, Oxide!

For benchmarking or production deployment, always use --release.

Summary

Congratulations! You've learned the basics of working with Cargo and Oxide:

  • Cargo new creates a new project structure
  • Cargo build compiles your project
  • Cargo run compiles and runs your project
  • Cargo check quickly verifies your code compiles
  • .ox files work seamlessly with Cargo just like .rs files
  • Mixed projects are fully supported—use both .ox and .rs files in the same crate
  • Release builds provide optimizations for production use

You now have everything you need to start building Oxide projects. In the next chapter, we'll explore the fundamental concepts of the language itself.

Programming a Guessing Game

Let's jump into Oxide by working through a hands-on project together! This chapter introduces several common Oxide concepts by showing you how to use them in a real program. You'll learn about var, input/output, string interpolation, match expressions, and more!

The project is a classic beginner programming problem: we'll implement a guessing game. Here's how it works: the program generates a random integer between 1 and 100, then prompts you to enter a guess. After you enter a guess, the program indicates whether the guess is too low or too high. If your guess is correct, the game prints a congratulatory message and exits.

Setting Up a New Project

Let's set up a new Oxide project using Cargo with the --oxide flag:

$ cargo new --oxide guessing_game
$ cd guessing_game

This creates a new Oxide project with src/main.ox ready to go.

Development Status: Oxide v1.0 is currently in development. The --oxide flag will be available in Cargo once the Oxide toolchain is released. For early testing before the release, you can manually create a project with cargo new and rename src/main.rs to src/main.ox.

Now let's look at what Cargo generated. Open Cargo.toml:

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"

[dependencies]

And here's the initial src/main.ox:

fn main() {
    println!("Hello, world!")
}

Let's test it with cargo run:

$ cargo run
   Compiling guessing_game v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 1.23s
     Running `target/debug/guessing_game`
Hello, world!

Great! The cargo run command compiles and runs the project in one step. Now let's make it into a guessing game.

Processing a Guess

The first part of the guessing game program will ask for user input, process that input, and check that the input is in the expected form. To start, we'll allow the player to input a guess.

Filename: src/main.ox

import std.io

fn main() {
    println!("Guess the number!")

    println!("Please input your guess.")

    var guess = String.new()

    io.stdin()
        .readLine(&mut guess)
        .expect("Failed to read line")

    println!("You guessed: \(guess)")
}

This code contains a lot of information, so let's go through it line by line.

Getting User Input

To obtain user input and then print the result, we need the io library from the standard library:

import std.io

In Oxide, we use import with dot notation. This replaces Rust's use std::io; syntax. Note that :: does not exist in Oxide - dot notation is the only path separator.

The main function is the entry point:

fn main() {

Next, we use println! to print a prompt:

println!("Guess the number!")
println!("Please input your guess.")

Storing Values with Variables

Now we'll create a variable to store the user input:

var guess = String.new()

In Oxide, we use var to create a mutable variable. This is equivalent to Rust's let mut. The variable guess is bound to a new, empty String. In Oxide, String is the same type as in Rust—a growable, UTF-8 encoded text type.

Receiving User Input

Next, we call readLine on the standard input handle:

io.stdin()
    .readLine(&mut guess)

The stdin function returns an instance of std.io.Stdin, a type representing a handle to the standard input. The .readLine(&mut guess) method reads user input into the string we pass to it.

We pass &mut guess as an argument. The & indicates this is a reference, which gives you a way to let multiple parts of your code access one piece of data without copying it into memory multiple times. References are immutable by default, so we need &mut to make it mutable.

Handling Potential Failure with Result

We're still working on this line:

    .expect("Failed to read line")

The readLine method returns a Result value. Result is an enum with variants Ok and Err. The Ok variant indicates the operation was successful, and inside Ok is the successfully generated value. The Err variant means the operation failed, and contains information about how or why the operation failed.

The expect method will cause the program to crash and display the message you passed to it if the Result is an Err value. If you don't call expect, the program will compile with a warning.

Printing Values with String Interpolation

Finally, we print the guess:

println!("You guessed: \(guess)")

Oxide supports string interpolation using \(expression) syntax. This is one of Oxide's ergonomic features that makes string formatting more concise. It's equivalent to Rust's format! macro.

Let's test this code:

$ cargo run
   Compiling guessing_game v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 2.34s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

Great! The first part is working.

Generating a Secret Number

Next, we need to generate a secret number that the user will try to guess. The secret number should be different every time so the game is fun to play more than once. Let's use a random number between 1 and 100.

Rust's standard library doesn't include random number functionality, so we'll use the rand crate. Add it to Cargo.toml:

Filename: Cargo.toml

[dependencies]
rand = "0.8.5"

Now update src/main.ox:

Filename: src/main.ox

import std.io
import rand.Rng

fn main() {
    println!("Guess the number!")

    let secretNumber = rand.thread_rng().gen_range(1..=100)

    println!("The secret number is: \(secretNumber)")

    println!("Please input your guess.")

    var guess = String.new()

    io.stdin()
        .readLine(&mut guess)
        .expect("Failed to read line")

    println!("You guessed: \(guess)")
}

We add import rand.Rng. The Rng trait defines methods that random number generators implement.

We call rand.thread_rng() to get a random number generator, then call gen_range with the range 1..=100 (inclusive on both ends).

Note that we use let (not var) for secretNumber because we won't be changing it.

Let's run it:

$ cargo run
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 3.45s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 42
Please input your guess.
25
You guessed: 25

You should see a different random number, and it should change each time you run the program.

Comparing the Guess to the Secret Number

Now that we have user input and a random number, we can compare them:

Filename: src/main.ox

import std.io
import std.cmp.Ordering
import rand.Rng

fn main() {
    println!("Guess the number!")

    let secretNumber = rand.thread_rng().gen_range(1..=100)

    println!("The secret number is: \(secretNumber)")

    println!("Please input your guess.")

    var guess = String.new()

    io.stdin()
        .readLine(&mut guess)
        .expect("Failed to read line")

    let guess: Int = guess.trim().parse().expect("Please type a number!")

    println!("You guessed: \(guess)")

    match guess.cmp(&secretNumber) {
        Ordering.Less -> println!("Too small!"),
        Ordering.Greater -> println!("Too big!"),
        Ordering.Equal -> println!("You win!"),
    }
}

We import Ordering, which is an enum with variants Less, Greater, and Equal.

We convert the guess string to a number:

let guess: Int = guess.trim().parse().expect("Please type a number!")

We create a new variable also named guess. Oxide, like Rust, allows us to shadow the previous value. The trim method removes whitespace, and parse converts the string to a number. We specify the type as Int (Oxide's alias for i32).

Then we use a match expression to compare the guess to the secret number:

match guess.cmp(&secretNumber) {
    Ordering.Less -> println!("Too small!"),
    Ordering.Greater -> println!("Too big!"),
    Ordering.Equal -> println!("You win!"),
}

In Oxide, we use -> for match arms (Rust's => is invalid in Oxide), and dot notation for enum variants (Ordering.Less). Rust's double colon syntax (Ordering::Less) does not exist in Oxide - it will cause a syntax error.

Let's try it:

$ cargo run
   Compiling guessing_game v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 1.23s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
76
You guessed: 76
Too big!

Nice! But we can only make one guess. Let's fix that with a loop.

Allowing Multiple Guesses with Looping

The loop keyword creates an infinite loop:

Filename: src/main.ox

import std.io
import std.cmp.Ordering
import rand.Rng

fn main() {
    println!("Guess the number!")

    let secretNumber = rand.thread_rng().gen_range(1..=100)

    loop {
        println!("Please input your guess.")

        var guess = String.new()

        io.stdin()
            .readLine(&mut guess)
            .expect("Failed to read line")

        let guess: Int = guess.trim().parse().expect("Please type a number!")

        println!("You guessed: \(guess)")

        match guess.cmp(&secretNumber) {
            Ordering.Less -> println!("Too small!"),
            Ordering.Greater -> println!("Too big!"),
            Ordering.Equal -> {
                println!("You win!")
                break
            },
        }
    }
}

We've moved everything after the secret number generation into a loop. When the guess equals the secret number, we print "You win!" and break to exit the loop.

$ cargo run
   Compiling guessing_game v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 1.45s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
50
You guessed: 50
Too small!
Please input your guess.
75
You guessed: 75
Too big!
Please input your guess.
62
You guessed: 62
You win!

Excellent! Now it loops until you guess correctly.

Handling Invalid Input

Let's make the game more robust by handling invalid input gracefully:

Filename: src/main.ox

import std.io
import std.cmp.Ordering
import rand.Rng

fn main() {
    println!("Guess the number!")

    let secretNumber = rand.thread_rng().gen_range(1..=100)

    loop {
        println!("Please input your guess.")

        var guess = String.new()

        io.stdin()
            .readLine(&mut guess)
            .expect("Failed to read line")

        let guess: Int = match guess.trim().parse() {
            Ok(num) -> num,
            Err(_) -> {
                println!("Please type a number!")
                continue
            },
        }

        println!("You guessed: \(guess)")

        match guess.cmp(&secretNumber) {
            Ordering.Less -> println!("Too small!"),
            Ordering.Greater -> println!("Too big!"),
            Ordering.Equal -> {
                println!("You win!")
                break
            },
        }
    }
}

Instead of crashing with expect, we now use a match expression to handle the Result from parse. If parse returns Ok, we extract the number. If it returns Err, we print a message and continue to the next iteration of the loop.

Let's test it:

$ cargo run
   Compiling guessing_game v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 1.23s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
abc
Please type a number!
Please input your guess.
50
You guessed: 50
Too small!
Please input your guess.
62
You guessed: 62
You win!

Perfect! Now let's remove the debug line that prints the secret number:

Filename: src/main.ox

import std.io
import std.cmp.Ordering
import rand.Rng

fn main() {
    println!("Guess the number!")

    let secretNumber = rand.thread_rng().gen_range(1..=100)

    loop {
        println!("Please input your guess.")

        var guess = String.new()

        io.stdin()
            .readLine(&mut guess)
            .expect("Failed to read line")

        let guess: Int = match guess.trim().parse() {
            Ok(num) -> num,
            Err(_) -> {
                println!("Please type a number!")
                continue
            },
        }

        println!("You guessed: \(guess)")

        match guess.cmp(&secretNumber) {
            Ordering.Less -> println!("Too small!"),
            Ordering.Greater -> println!("Too big!"),
            Ordering.Equal -> {
                println!("You win!")
                break
            },
        }
    }
}

Summary

This project was a hands-on way to introduce you to many new Oxide concepts: var, match, functions, external crates, and more. In the next chapters, you'll learn about these concepts in more detail.

This project demonstrated Oxide's practical syntax while building on Rust's excellent type system and error handling. The guessing game uses the same robust foundation as any Rust program—ownership, borrowing, and the type system—but with syntax that may feel more familiar if you're coming from languages like Swift, Kotlin, or TypeScript.

In Chapter 3, you'll learn about concepts that most programming languages have, such as variables, data types, and functions, and see how they work in Oxide.

Variables and Mutability

Variables are fundamental to any programming language. In Oxide, every variable binding follows clear rules about mutability that help prevent bugs and make your code easier to reason about. If you're familiar with Rust, you'll find Oxide's approach very similar, but with syntax inspired by languages like Swift and Kotlin.

Immutable Bindings with let

When you declare a variable with let, it creates an immutable binding. This means once you assign a value, you cannot change it:

fn main() {
    let x = 5
    println!("The value of x is: \(x)")
}

If you try to reassign an immutable variable, the compiler will stop you:

fn main() {
    let x = 5
    println!("The value of x is: \(x)")
    x = 6  // Error: cannot assign twice to immutable variable
}

This might seem restrictive at first, but immutability is a powerful feature. When a value cannot change, you can trust that it stays the same throughout your program. This eliminates entire categories of bugs and makes code easier to understand, especially in larger projects.

Rust comparison: The let keyword works identically to Rust. The only visible difference is that semicolons are optional in Oxide.

#![allow(unused)]
fn main() {
// Rust
let x = 5;
println!("The value of x is: {}", x);
}

Mutable Bindings with var

Of course, sometimes you need values that can change. Oxide uses the var keyword for mutable bindings:

fn main() {
    var x = 5
    println!("The value of x is: \(x)")
    x = 6
    println!("The value of x is: \(x)")
}

This outputs:

The value of x is: 5
The value of x is: 6

With var, you can modify the value as many times as needed. Use var when you know a value will change, like a counter in a loop or an accumulator.

Rust comparison: Oxide's var is equivalent to Rust's let mut. The choice of var is intentional, as it's a familiar keyword from Swift, Kotlin, JavaScript, and many other languages.

#![allow(unused)]
fn main() {
// Rust equivalent
let mut x = 5;
x = 6;
}

Constants

Constants are values that are bound to a name and cannot change throughout the entire program. Unlike let bindings, constants:

  • Must always have a type annotation
  • Must be set to a constant expression (evaluated at compile time)
  • Can be declared in any scope, including the global scope
  • Use the const keyword and SCREAMING_SNAKE_CASE by convention
const THREE_HOURS_IN_SECONDS: Int = 60 * 60 * 3

fn main() {
    println!("Three hours is \(THREE_HOURS_IN_SECONDS) seconds")
}

Constants are useful for values that many parts of your code need to know about, like the maximum number of players in a game or the speed of light. Naming hardcoded values as constants helps future readers understand the significance of the value.

Rust comparison: Constants work identically to Rust. The only difference is using Oxide's type aliases (Int instead of i32).

#![allow(unused)]
fn main() {
// Rust
const THREE_HOURS_IN_SECONDS: i32 = 60 * 60 * 3;
}

Shadowing

You can declare a new variable with the same name as a previous variable. This is called shadowing, and the new variable shadows the previous one:

fn main() {
    let x = 5

    let x = x + 1

    {
        let x = x * 2
        println!("The value of x in the inner scope is: \(x)")
    }

    println!("The value of x is: \(x)")
}

This outputs:

The value of x in the inner scope is: 12
The value of x is: 6

Shadowing is different from marking a variable as mutable with var. If we try to reassign without let, we get a compile-time error. By using let, we can perform transformations on a value but have the variable be immutable after those transformations.

Another advantage of shadowing is that you can change the type of a value while reusing the same name:

fn main() {
    let spaces = "   "
    let spaces = spaces.len()
    println!("Number of spaces: \(spaces)")
}

The first spaces is a &str, and the second spaces is a UIntSize (the return type of .len()). This is allowed because we're creating a new variable with let.

If we tried to use var and reassign, we'd get a type mismatch error:

fn main() {
    var spaces = "   "
    spaces = spaces.len()  // Error: mismatched types
}

Type Annotations

Oxide can usually infer the type of a variable from its value, but you can also be explicit:

fn main() {
    let count: Int = 42
    let name: String = "Alice".toString()
    let active: Bool = true

    var score: Float = 0.0
    score = 99.5
}

Type annotations use a colon followed by the type, consistent with languages like TypeScript, Kotlin, and Swift. Oxide provides intuitive type aliases for primitives:

Oxide TypeRust Equivalent
Inti32
Floatf64
Boolbool
UIntSizeusize

We'll explore all the available types in the next section.

Rust comparison: The annotation syntax is the same as Rust, just with Oxide's type names.

#![allow(unused)]
fn main() {
// Rust
let count: i32 = 42;
let active: bool = true;
}

When to Use let vs var

As a general guideline:

  • Default to let: Start with immutable bindings. This makes your code safer and easier to reason about.
  • Use var when needed: If you find you need to modify a value, change it to var.

The compiler will tell you if you've marked something as immutable but try to change it. Following this approach helps you take advantage of the safety that Oxide provides while still having the flexibility to use mutable state when appropriate.

Summary

  • Use let for immutable bindings that cannot change after initialization
  • Use var for mutable bindings that you need to modify
  • Use const for compile-time constants with global scope
  • Shadowing allows reusing names while keeping immutability benefits
  • Type annotations use the format name: Type

Now that you understand how variables work, let's look at the different data types available in Oxide.

Data Types

Every value in Oxide has a data type, which tells the compiler what kind of data is being specified so it knows how to work with that data. Oxide is statically typed, meaning the compiler must know the types of all variables at compile time. The compiler can usually infer types from values and how we use them, but when many types are possible, we must add a type annotation.

Scalar Types

A scalar type represents a single value. Oxide has four primary scalar types: integers, floating-point numbers, Booleans, and characters. Oxide provides intuitive type aliases that will feel familiar if you're coming from Swift, Kotlin, or TypeScript.

Integer Types

An integer is a number without a fractional component. Oxide provides signed and unsigned integers of various sizes:

Oxide TypeRust EquivalentSizeRange
Int8i88-bit-128 to 127
Int16i1616-bit-32,768 to 32,767
Int32i3232-bit-2.1B to 2.1B
Int64i6464-bitVery large
Inti3232-bitDefault signed integer
IntSizeisizearchPointer-sized signed
UInt8u88-bit0 to 255
UInt16u1616-bit0 to 65,535
UInt32u3232-bit0 to 4.3B
UInt64u6464-bitVery large
UIntu3232-bitDefault unsigned integer
UIntSizeusizearchPointer-sized unsigned

The Int type (which maps to Rust's i32) is the default choice for integers and is generally the fastest, even on 64-bit systems. Use UIntSize when indexing collections, as it matches the size of memory addresses on your system.

fn main() {
    let age: Int = 30
    let temperature: Int = -15
    let count: UIntSize = 1000

    // Type inference works too
    let inferred = 42  // Defaults to Int
}

Integer Literals

You can write integer literals in various forms:

LiteralExample
Decimal98_222
Hex0xff
Octal0o77
Binary0b1111_0000

Note that underscores can be inserted for readability: 1_000_000 is the same as 1000000.

Floating-Point Types

Oxide has two floating-point types for numbers with decimal points:

Oxide TypeRust EquivalentSizePrecision
Float32f3232-bit~6-7 digits
Float64f6464-bit~15-16 digits
Floatf6464-bitDefault floating-point

The Float type (which maps to Rust's f64) is the default because modern CPUs handle double-precision floats nearly as fast as single-precision, and it provides more accuracy.

fn main() {
    let pi: Float = 3.14159
    let temperature = 98.6  // Inferred as Float
    let precise: Float64 = 2.718281828459045

    // Scientific notation
    let avogadro: Float = 6.022e23
}

Numeric Operations

Oxide supports the standard mathematical operations: addition, subtraction, multiplication, division, and remainder:

fn main() {
    // Addition
    let sum = 5 + 10

    // Subtraction
    let difference = 95.5 - 4.3

    // Multiplication
    let product = 4 * 30

    // Division
    let quotient = 56.7 / 32.2
    let truncated = 5 / 3  // Results in 1 (integer division)

    // Remainder
    let remainder = 43 % 5
}

The Boolean Type

Oxide's Boolean type has two possible values: true and false. Booleans are one byte in size and are specified using the Bool type:

fn main() {
    let isActive: Bool = true
    let isComplete = false  // Inferred as Bool

    // Booleans are often the result of comparisons
    let isGreater = 5 > 3  // true
}

Rust comparison: Oxide uses Bool instead of bool, following the convention of capitalizing type names.

The Character Type

The char type represents a single Unicode scalar value. Character literals use single quotes:

fn main() {
    let letter = 'a'
    let emoji = '😀'
    let heart = '❤'
}

The char type is four bytes and represents a Unicode Scalar Value, which means it can represent much more than just ASCII.

Compound Types

Compound types can group multiple values into one type. Oxide has two primitive compound types: tuples and arrays.

Tuples

A tuple groups together values of different types into one compound type. Tuples have a fixed length; once declared, they cannot grow or shrink.

fn main() {
    let tup: (Int, Float, Bool) = (500, 6.4, true)

    // Destructuring
    let (x, y, z) = tup
    println!("The value of y is: \(y)")

    // Access by index
    let five_hundred = tup.0
    let six_point_four = tup.1
    let is_true = tup.2
}

The tuple without any values, (), is called the unit type and represents an empty value or empty return type.

Arrays

Arrays contain multiple values of the same type with a fixed length. Use square brackets for array literals:

fn main() {
    let numbers: [Int; 5] = [1, 2, 3, 4, 5]
    let months: [&str; 12] = [
        "January", "February", "March", "April",
        "May", "June", "July", "August",
        "September", "October", "November", "December"
    ]

    // Initialize with same value
    let zeros: [Int; 5] = [0; 5]  // [0, 0, 0, 0, 0]

    // Accessing elements
    let first = numbers[0]
    let second = numbers[1]
}

Arrays are useful when you want data on the stack rather than the heap, or when you need a fixed number of elements. For a collection that can grow or shrink, use Vec<T> instead.

The String Type

Oxide has two string types:

  • str: A string slice, usually seen as &str. This is an immutable reference to string data.
  • String: A growable, heap-allocated string.
fn main() {
    // String literal (type is &str)
    let greeting = "Hello, world!"

    // Create an owned String
    let name: String = "Alice".toString()

    // String with interpolation
    let message = "Hello, \(name)!"
    println!("\(message)")
}

String interpolation with \(expression) is a key Oxide feature. Any expression inside \() is evaluated and converted to a string:

fn main() {
    let count = 42
    let price = 19.99

    println!("Count: \(count), Price: $\(price)")
    println!("Total: $\(count as Float * price)")
}

Rust comparison: Rust uses format!("{}", x) for string formatting. Oxide's \(x) syntax is inspired by Swift and is more concise.

#![allow(unused)]
fn main() {
// Rust
println!("Count: {}, Price: ${}", count, price);
}

Nullable Types

Oxide has first-class support for nullable (optional) types using the ? suffix. A T? type can hold either a value of type T or null:

fn main() {
    let maybeNumber: Int? = 42
    let nothing: String? = null

    // Check if value exists
    if let number = maybeNumber {
        println!("Got number: \(number)")
    }

    // Provide a default with ??
    let value = maybeNumber ?? 0

    // Force unwrap with !! (use carefully!)
    let forced = maybeNumber!!
}

The T? syntax is equivalent to Rust's Option<T>, and null is equivalent to None:

OxideRust
Int?Option<i32>
String?Option<String>
nullNone
Some(x)Some(x)
x ?? yx.unwrap_or(y)
x!!x.unwrap()
fn findUser(id: Int): User? {
    if id == 1 {
        Some(User { name: "Alice".toString() })
    } else {
        null
    }
}

fn main() {
    let user = findUser(1) ?? User { name: "Guest".toString() }
    println!("Hello, \(user.name)")
}

Collection Types

Oxide uses Rust's standard collection types directly:

Vectors

Vec<T> is a growable array type:

fn main() {
    // Create a vector with the vec! macro
    var numbers: Vec<Int> = vec![1, 2, 3]

    // Add elements
    numbers.push(4)
    numbers.push(5)

    // Access elements
    let first = numbers[0]
    let maybe_tenth: Int? = numbers.get(10).copied()

    // Iterate
    for num in numbers.iter() {
        println!("\(num)")
    }
}

HashMaps

HashMap<K, V> stores key-value pairs:

import std.collections.HashMap

fn main() {
    var scores: HashMap<String, Int> = HashMap.new()

    scores.insert("Blue".toString(), 10)
    scores.insert("Red".toString(), 50)

    let blue_score = scores.get(&"Blue".toString())

    for (team, score) in scores.iter() {
        println!("\(team): \(score)")
    }
}

Note: Oxide v1.0 uses Rust's collection names directly (Vec, HashMap) rather than providing aliases like Array or Dict. This helps you learn the actual Rust types you'll encounter in the ecosystem.

Type Inference

Oxide has strong type inference. The compiler can usually figure out types from context:

fn main() {
    let x = 5          // Int
    let y = 3.14       // Float
    let z = true       // Bool
    let s = "hello"    // &str

    var items = vec![] // Vec<???> - needs annotation or usage
    items.push(1)      // Now compiler knows it's Vec<Int>
}

When the compiler cannot infer the type, you need to provide an annotation:

fn main() {
    // Compiler needs help here
    let guess: Int = "42".parse().unwrap()

    // Or specify the type in the turbofish
    let guess = "42".parse<Int>().unwrap()
}

Summary

Oxide provides intuitive type names that feel familiar to developers from many language backgrounds while mapping directly to Rust's type system:

  • Integers: Int, Int64, UInt, UIntSize, etc.
  • Floats: Float, Float32, Float64
  • Boolean: Bool
  • Character: char
  • Tuples: (T, U, V)
  • Arrays: [T; N]
  • Strings: &str, String with \(expr) interpolation
  • Nullable: T? with null, ??, and !! operators
  • Collections: Vec<T>, HashMap<K, V>

Since Oxide types ARE Rust types (just with different names), you get full compatibility with the entire Rust ecosystem.

Functions

Functions are pervasive in Oxide code. You've already seen the main function, which is the entry point of many programs. The fn keyword allows you to declare new functions.

Oxide uses camelCase for function and variable names, in contrast to Rust's snake_case. This follows the conventions of Swift, Kotlin, and TypeScript. When you call Rust code from Oxide, name conversion happens automatically.

Defining Functions

Functions are defined with fn, followed by a name, parameters in parentheses, and a body in curly braces:

fn main() {
    println!("Hello, world!")
    anotherFunction()
}

fn anotherFunction() {
    println!("Another function.")
}

Functions can be defined before or after main; Oxide doesn't care where you define them, as long as they're in scope.

Parameters

Functions can have parameters, which are special variables that are part of the function's signature. When a function has parameters, you provide concrete values (called arguments) when you call it.

fn main() {
    greet("Alice")
    printSum(5, 3)
}

fn greet(name: &str) {
    println!("Hello, \(name)!")
}

fn printSum(a: Int, b: Int) {
    println!("\(a) + \(b) = \(a + b)")
}

Parameters must have type annotations. This is a deliberate design decision; requiring types in function signatures means the compiler rarely needs type annotations elsewhere.

Rust comparison: The syntax is identical, except Oxide uses camelCase for function names and its own type aliases.

#![allow(unused)]
fn main() {
// Rust
fn print_sum(a: i32, b: i32) {
    println!("{} + {} = {}", a, b, a + b);
}
}

Return Values

Functions can return values. Declare the return type after a colon (:) following the parameter list:

fn five(): Int {
    5
}

fn add(a: Int, b: Int): Int {
    a + b
}

fn main() {
    let x = five()
    let sum = add(10, 20)
    println!("x = \(x), sum = \(sum)")
}

The return value is the final expression in the function body. You can also return early using the return keyword:

fn absoluteValue(x: Int): Int {
    if x < 0 {
        return -x
    }
    x
}

Rust comparison: Oxide uses : for return types instead of Rust's ->. This aligns with TypeScript, Kotlin, and Swift conventions.

#![allow(unused)]
fn main() {
// Rust
fn add(a: i32, b: i32) -> i32 {
    a + b
}
}

Statements and Expressions

Function bodies are made up of a series of statements optionally ending in an expression. Understanding the difference is important:

  • Statements perform actions but don't return a value
  • Expressions evaluate to a resulting value
fn main() {
    // This is a statement (variable declaration)
    let y = 6

    // This block is an expression that evaluates to 4
    let x = {
        let temp = 3
        temp + 1  // No semicolon - this is the block's value
    }

    println!("x = \(x)")  // Prints: x = 4
}

Note that temp + 1 has no semicolon. Adding a semicolon would turn it into a statement, and the block would return () (the unit type) instead.

Visibility

By default, functions are private to their module. Use public to make them accessible from other modules:

public fn createUser(name: &str): User {
    User { name: name.toString() }
}

fn helperFunction() {
    // This is only accessible within this module
}

Rust comparison: Oxide uses public instead of pub. The word is spelled out for clarity.

#![allow(unused)]
fn main() {
// Rust
pub fn create_user(name: &str) -> User {
    User { name: name.to_string() }
}
}

Generic Functions

Functions can be generic over types:

import std.fmt.Display

fn identity<T>(value: T): T {
    value
}

fn printPair<T, U>(first: T, second: U)
where
    T: Display,
    U: Display,
{
    println!("(\(first), \(second))")
}

fn main() {
    let x = identity(42)
    let s = identity("hello")
    printPair(1, "one")
}

Generic constraints can be specified inline or with a where clause, just like in Rust.

Functions That Return Nothing

Functions that don't return a value implicitly return (), the unit type. You can omit the return type:

fn greet(name: &str) {
    println!("Hello, \(name)!")
}

// This is equivalent:
fn greetExplicit(name: &str): () {
    println!("Hello, \(name)!")
}

Functions That Never Return

Some functions never return, like those that always panic or loop forever. Use the Never type for these:

fn diverges(): Never {
    panic!("This function never returns!")
}

fn infiniteLoop(): Never {
    loop {
        // Do something forever
    }
}

Closures

Closures are anonymous functions you can store in variables or pass as arguments. Oxide uses a Swift-inspired syntax with curly braces:

fn main() {
    // No parameters
    let sayHello = { println!("Hello!") }

    // One parameter
    let double = { x -> x * 2 }

    // Multiple parameters
    let add = { x, y -> x + y }

    // With type annotations
    let parse = { s: &str -> s.parse<Int>().unwrap() }

    sayHello()
    println!("Double 5: \(double(5))")
    println!("3 + 4: \(add(3, 4))")
}

Rust comparison: Oxide uses { params -> body } instead of Rust's |params| body.

#![allow(unused)]
fn main() {
// Rust
let double = |x| x * 2;
let add = |x, y| x + y;
}

Implicit it Parameter

In trailing closures (closures passed as the last argument to a function), you can use the implicit it parameter for single-argument closures:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5]

    // Using implicit `it`
    let doubled = numbers.iter().map { it * 2 }.collect<Vec<Int>>()

    // Equivalent with explicit parameter
    let doubled = numbers.iter().map { x -> x * 2 }.collect<Vec<Int>>()

    // Filter with `it`
    let evens = numbers.iter().filter { it % 2 == 0 }

    // More complex usage
    let users = vec![user1, user2, user3]
    let activeNames = users
        .iter()
        .filter { it.isActive }
        .map { it.name.clone() }
        .collect<Vec<String>>()
}

Important: The implicit it is only available in trailing closure position. You cannot use it in variable bindings:

// NOT allowed - it only works in trailing closures
let f = { it * 2 }  // Error!

// Use explicit parameter instead
let f = { x -> x * 2 }  // OK

Trailing Closure Syntax

When the last argument to a function is a closure, you can write it outside the parentheses:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5]

    // Trailing closure syntax
    numbers.forEach { println!("\(it)") }

    // Equivalent to:
    numbers.forEach({ println!("\(it)") })

    // With other arguments
    numbers.iter().fold(0, { acc, x -> acc + x })
}

Multi-Statement Closures

Closures can contain multiple statements:

fn main() {
    let process = { item ->
        let validated = validate(item)
        let transformed = transform(validated)
        transformed
    }

    let result = process(myItem)
}

Async Functions

Async functions allow non-blocking I/O operations. Oxide uses prefix await instead of Rust's postfix .await:

async fn fetchData(url: &str): Result<String, Error> {
    let response = await client.get(url).send()?
    let body = await response.text()?
    Ok(body)
}

async fn main(): Result<(), Error> {
    let data = await fetchData("https://example.com")?
    println!("Got: \(data)")
    Ok(())
}

Rust comparison: Oxide uses await expr (prefix) instead of Rust's expr.await (postfix).

#![allow(unused)]
fn main() {
// Rust
async fn fetch_data(url: &str) -> Result<String, Error> {
    let response = client.get(url).send().await?;
    let body = response.text().await?;
    Ok(body)
}
}

The prefix await reads naturally left-to-right and matches the convention in Swift, Kotlin, JavaScript, and Python.

Function Pointers

You can store functions in variables and pass them around:

fn add(a: Int, b: Int): Int {
    a + b
}

fn multiply(a: Int, b: Int): Int {
    a * b
}

fn applyOperation(a: Int, b: Int, op: (Int, Int) -> Int): Int {
    op(a, b)
}

fn main() {
    let result1 = applyOperation(5, 3, add)
    let result2 = applyOperation(5, 3, multiply)

    println!("5 + 3 = \(result1)")
    println!("5 * 3 = \(result2)")
}

Rust comparison: Function pointer types use : instead of -> for the return type.

#![allow(unused)]
fn main() {
// Rust
fn apply_operation(a: i32, b: i32, op: fn(i32, i32) -> i32) -> i32 {
    op(a, b)
}
}

Summary

  • Functions are declared with fn and use camelCase names
  • Parameters require type annotations
  • Return types follow : (not ->)
  • Use public (not pub) for public visibility
  • Closures use { params -> body } syntax
  • The implicit it parameter works in trailing closures
  • Async functions use prefix await

Functions in Oxide are designed to be familiar to developers from modern language backgrounds while maintaining full compatibility with Rust's function system.

Comments

Comments are essential for documenting your code. Good comments explain why code exists, not just what it does. Oxide's comment syntax is identical to Rust's, which will be familiar if you've used C, C++, Java, or JavaScript.

Line Comments

The most common comment form is the line comment, which starts with // and continues to the end of the line:

fn main() {
    // This is a line comment
    let x = 5  // This comment follows code

    // Comments can span
    // multiple lines
    // like this

    let y = 10
}

Use line comments liberally to explain non-obvious logic:

fn calculateDiscount(price: Float, memberYears: Int): Float {
    // Base discount for all members
    var discount = 0.05

    // Long-term members get additional rewards
    // The formula was approved by marketing in Q3 2024
    if memberYears >= 5 {
        discount += 0.02 * (memberYears - 4).min(5) as Float
    }

    price * discount
}

Block Comments

For longer explanations, use block comments that start with /* and end with */:

fn main() {
    /* This is a block comment.
       It can span multiple lines
       and is useful for longer explanations. */

    let x = 5

    /*
     * Some developers prefer to format
     * block comments with asterisks
     * on each line for readability.
     */
}

Block comments can also be nested, which is useful when commenting out code that already contains comments:

fn main() {
    /*
    This outer comment contains:
    /* An inner comment */
    And continues after it.
    */
    println!("Hello!")
}

In practice, line comments (//) are more commonly used than block comments.

Documentation Comments

Oxide supports special documentation comments that can be processed by documentation tools. These come in two forms.

Outer Documentation Comments (///)

Use /// to document the item that follows (functions, structs, enums, etc.):

/// Calculates the factorial of a non-negative integer.
///
/// # Arguments
///
/// * `n` - The number to calculate factorial for
///
/// # Returns
///
/// The factorial of `n`, or `null` if `n` is negative
///
/// # Examples
///
/// ```oxide
/// let result = factorial(5)
/// assert_eq!(result, Some(120))
/// ```
public fn factorial(n: Int): Int? {
    if n < 0 {
        return null
    }
    if n <= 1 {
        return Some(1)
    }
    Some(n * factorial(n - 1)??)
}

Documentation comments support Markdown formatting, so you can include:

  • Headers with #
  • Code blocks with triple backticks
  • Lists with * or -
  • Bold with **text**
  • Links with [text](url)

Inner Documentation Comments (//!)

Use //! at the beginning of a file or module to document the module itself:

//! # String Utilities
//!
//! This module provides helper functions for string manipulation.
//!
//! ## Features
//!
//! - Case conversion
//! - Trimming and padding
//! - Search and replace
//!
//! ## Example
//!
//! ```oxide
//! import mylib.strings
//!
//! let result = strings.toTitleCase("hello world")
//! assert_eq!(result, "Hello World")
//! ```

public fn toTitleCase(s: &str): String {
    // Implementation here
    s.toString()
}

public fn capitalize(s: &str): String {
    // Implementation here
    s.toString()
}

Inner documentation comments are typically placed at the very top of a file, before any code.

Common Documentation Sections

By convention, documentation for public APIs follows a standard structure:

/// Brief one-line description of the function.
///
/// More detailed explanation if needed. This can span multiple
/// paragraphs and include any relevant background information.
///
/// # Arguments
///
/// * `param1` - Description of the first parameter
/// * `param2` - Description of the second parameter
///
/// # Returns
///
/// Description of what the function returns.
///
/// # Errors
///
/// Description of when this function returns an error.
///
/// # Panics
///
/// Description of when this function might panic.
///
/// # Safety
///
/// For unsafe functions, describe safety requirements.
///
/// # Examples
///
/// ```oxide
/// let result = myFunction(arg1, arg2)
/// ```
public fn myFunction(param1: Int, param2: &str): Result<String, Error> {
    // Implementation
}

Not all sections are needed for every function. Use the sections that are relevant:

  • # Arguments - When parameters aren't self-explanatory
  • # Returns - For non-obvious return values
  • # Errors - For functions returning Result
  • # Panics - When the function can panic
  • # Safety - Required for unsafe functions
  • # Examples - Highly recommended for public APIs

Documenting Structs and Enums

Document each field or variant:

/// Represents a user in the system.
///
/// Users are the primary actors in our application and
/// can perform various actions based on their role.
#[derive(Debug, Clone)]
public struct User {
    /// Unique identifier for the user.
    id: Int,

    /// Display name shown in the UI.
    name: String,

    /// Email address for notifications.
    /// Must be verified before the user can post.
    email: String,

    /// Whether the user has admin privileges.
    isAdmin: Bool,
}

/// Possible states for an order.
public enum OrderStatus {
    /// Order has been placed but not yet processed.
    Pending,

    /// Order is being prepared for shipment.
    Processing,

    /// Order has been shipped to the customer.
    /// Contains the tracking number.
    Shipped { trackingNumber: String },

    /// Order has been delivered successfully.
    Delivered,

    /// Order was cancelled.
    /// Contains the reason for cancellation.
    Cancelled { reason: String },
}

Best Practices

Write Comments for Your Future Self

Code that seems obvious today might be confusing in six months:

// BAD: States what the code does (obvious from reading it)
// Increment counter by 1
counter += 1

// GOOD: Explains why
// We count from 1 because the API expects 1-indexed results
counter += 1

Keep Comments Up to Date

Outdated comments are worse than no comments. When you change code, update the corresponding comments:

// BAD: Comment doesn't match code
// Returns the user's full name
fn getUsername(user: &User): String {
    user.email.clone()  // Actually returns email!
}

// GOOD: Comment matches code
// Returns the user's email as their display identifier
fn getUsername(user: &User): String {
    user.email.clone()
}

Use Comments to Explain "Why", Not "What"

// BAD: Describes what the code does
// Loop through users and filter by active status
let activeUsers = users.iter().filter { it.isActive }

// GOOD: Explains the business reason
// Only active users should receive the weekly newsletter
let activeUsers = users.iter().filter { it.isActive }

Document Public APIs Thoroughly

Internal code can have lighter documentation, but public APIs deserve comprehensive docs:

/// Parses a date string in ISO 8601 format.
///
/// Accepts dates in the format `YYYY-MM-DD`. The time component
/// is optional and defaults to midnight UTC if not provided.
///
/// # Arguments
///
/// * `input` - A string slice containing the date to parse
///
/// # Returns
///
/// A `DateTime` if parsing succeeds, or `null` if the input
/// is not a valid ISO 8601 date string.
///
/// # Examples
///
/// ```oxide
/// let date = parseIsoDate("2024-03-15")
/// assert!(date.isSome())
///
/// let invalid = parseIsoDate("not a date")
/// assert!(invalid.isNone())
/// ```
public fn parseIsoDate(input: &str): DateTime? {
    // Implementation
}

Summary

  • Use // for line comments (most common)
  • Use /* */ for block comments (can be nested)
  • Use /// to document the following item
  • Use //! to document the containing module
  • Documentation comments support Markdown
  • Follow standard sections: Arguments, Returns, Errors, Panics, Examples
  • Comment the "why", not the "what"
  • Keep comments synchronized with code

Good documentation makes your code more maintainable and helps others (including your future self) understand your intent.

Control Flow

Control flow constructs let you run code conditionally or repeatedly. Oxide provides several ways to control execution: if expressions, match expressions, guard statements, and various loops. While the semantics match Rust exactly, some syntax is designed to be more approachable.

if Expressions

An if expression lets you branch your code based on conditions:

fn main() {
    let number = 7

    if number < 5 {
        println!("condition was true")
    } else {
        println!("condition was false")
    }
}

The condition must be a Bool. Unlike some languages, Oxide won't automatically convert non-Boolean types:

fn main() {
    let number = 3

    // This won't compile!
    if number {  // Error: expected Bool, found Int
        println!("number was three")
    }

    // This works:
    if number != 0 {
        println!("number was not zero")
    }
}

Multiple Conditions with else if

Chain conditions with else if:

fn main() {
    let number = 6

    if number % 4 == 0 {
        println!("number is divisible by 4")
    } else if number % 3 == 0 {
        println!("number is divisible by 3")
    } else if number % 2 == 0 {
        println!("number is divisible by 2")
    } else {
        println!("number is not divisible by 4, 3, or 2")
    }
}

Using if in a let Statement

Because if is an expression, you can use it on the right side of a let:

fn main() {
    let condition = true
    let number = if condition { 5 } else { 6 }

    println!("The value of number is: \(number)")
}

Both branches must return the same type:

fn main() {
    let condition = true

    // This won't compile!
    let number = if condition { 5 } else { "six" }  // Error: incompatible types
}

if let for Pattern Matching

The if let syntax combines pattern matching with conditionals. It's especially useful for nullable types:

fn main() {
    let maybeNumber: Int? = Some(42)

    // Auto-unwrap: the Some() wrapper is implicit for T?
    if let number = maybeNumber {
        println!("Got number: \(number)")
    }

    // Explicit Some() also works
    if let Some(number) = maybeNumber {
        println!("Got number: \(number)")
    }

    // With else branch
    if let value = maybeNumber {
        println!("Value: \(value)")
    } else {
        println!("No value present")
    }
}

Rust comparison: In Oxide, if let x = nullable automatically wraps the pattern in Some() when the right-hand side is a nullable type. In Rust, you must always write if let Some(x) = nullable.

#![allow(unused)]
fn main() {
// Rust requires explicit Some()
if let Some(number) = maybe_number {
    println!("Got number: {}", number);
}
}

guard Statements

The guard statement is for early returns when conditions aren't met. The else block must diverge (return, break, continue, or panic):

fn processUser(user: User?): Result<String, Error> {
    guard let user = user else {
        return Err(anyhow!("User not found"))
    }
    // `user` is now available and non-null

    guard user.isActive else {
        return Err(anyhow!("User is not active"))
    }
    // We know user is active here

    Ok("Processing \(user.name)")
}

guard is particularly useful for validation at the start of functions:

fn divide(a: Int, b: Int): Result<Int, String> {
    guard b != 0 else {
        return Err("Cannot divide by zero".toString())
    }

    Ok(a / b)
}

fn processItems(items: Vec<Item>): Result<Summary, Error> {
    guard !items.isEmpty() else {
        return Err(anyhow!("No items to process"))
    }

    // Continue with non-empty items...
    Ok(summarize(items))
}

Rust comparison: Oxide's guard let x = expr else { } is equivalent to Rust's let Some(x) = expr else { }. The guard condition else { } form is similar to an inverted if.

#![allow(unused)]
fn main() {
// Rust
let Some(user) = user else {
    return Err(anyhow!("User not found"));
};

// Or for conditions:
if items.is_empty() {
    return Err(anyhow!("No items"));
}
}

match Expressions

The match expression compares a value against a series of patterns. Oxide uses -> for match arms (instead of Rust's =>) and else for the wildcard pattern (instead of _):

fn main() {
    let number = 3

    match number {
        1 -> println!("one"),
        2 -> println!("two"),
        3 -> println!("three"),
        else -> println!("something else"),
    }
}

Matching Multiple Patterns

Use | to match multiple values:

fn main() {
    let number = 2

    match number {
        1 | 2 -> println!("one or two"),
        3 -> println!("three"),
        else -> println!("other"),
    }
}

Matching Ranges

Use ..= for inclusive ranges:

fn main() {
    let number = 7

    match number {
        1..=5 -> println!("one through five"),
        6..=10 -> println!("six through ten"),
        else -> println!("something else"),
    }
}

Matching with Guards

Add conditions to patterns with if:

fn main() {
    let pair = (2, -2)

    match pair {
        (x, y) if x == y -> println!("twins"),
        (x, y) if x + y == 0 -> println!("opposites"),
        (x, _) if x % 2 == 0 -> println!("first is even"),
        else -> println!("no match"),
    }
}

Matching Enums

Match is essential for working with enums:

enum Status {
    Active,
    Inactive,
    Pending { reason: String },
}

fn describeStatus(status: Status): String {
    match status {
        Status.Active -> "User is active".toString(),
        Status.Inactive -> "User is inactive".toString(),
        Status.Pending { reason: r } -> "Pending: \(r)".toString(),
    }
}

Rust comparison: Oxide uses dot notation (Status.Active) for enum variants. Rust's double colon syntax (Status::Active) does not exist in Oxide and will cause a syntax error.

Matching Nullable Types

Use null in pattern position to match None:

fn describe(value: Int?): String {
    match value {
        Some(n) if n > 0 -> "positive: \(n)".toString(),
        Some(n) if n < 0 -> "negative: \(n)".toString(),
        Some(0) -> "zero".toString(),
        null -> "no value".toString(),
    }
}

Match as Expression

Like if, match is an expression and returns a value:

fn main() {
    let number = 3

    let description = match number {
        1 -> "one",
        2 -> "two",
        3 -> "three",
        else -> "many",
    }

    println!("Number is \(description)")
}

Multi-Statement Arms

Use blocks for complex match arms:

fn process(value: Int): String {
    match value {
        0 -> "zero".toString(),
        n if n > 0 -> {
            let doubled = n * 2
            let squared = n * n
            "positive: doubled=\(doubled), squared=\(squared)".toString()
        },
        else -> {
            println!("Warning: negative value")
            "negative".toString()
        },
    }
}

Loops

Oxide provides three loop constructs: loop, while, and for.

Infinite Loops with loop

The loop keyword creates an infinite loop:

fn main() {
    var counter = 0

    loop {
        counter += 1
        println!("Count: \(counter)")

        if counter >= 5 {
            break
        }
    }
}

Returning Values from Loops

You can return a value from a loop using break:

fn main() {
    var counter = 0

    let result = loop {
        counter += 1

        if counter == 10 {
            break counter * 2
        }
    }

    println!("Result: \(result)")  // Prints: Result: 20
}

Loop Labels

Use labels to break or continue outer loops:

fn main() {
    var count = 0

    'outer: loop {
        println!("count = \(count)")
        var remaining = 10

        loop {
            println!("remaining = \(remaining)")

            if remaining == 9 {
                break
            }
            if count == 2 {
                break 'outer
            }
            remaining -= 1
        }

        count += 1
    }

    println!("End count = \(count)")
}

Conditional Loops with while

Execute code while a condition is true:

fn main() {
    var number = 3

    while number != 0 {
        println!("\(number)!")
        number -= 1
    }

    println!("LIFTOFF!")
}

while let for Conditional Pattern Matching

Similar to if let, but loops while the pattern matches:

fn main() {
    var stack: Vec<Int> = vec![1, 2, 3]

    while let value = stack.pop() {
        println!("Popped: \(value)")
    }
}

Iterating with for

The for loop iterates over collections:

fn main() {
    let numbers = [10, 20, 30, 40, 50]

    for number in numbers {
        println!("Value: \(number)")
    }
}

Iterating with Ranges

fn main() {
    // 1 to 4 (exclusive end)
    for i in 1..5 {
        println!("\(i)")
    }

    // 1 to 5 (inclusive end)
    for i in 1..=5 {
        println!("\(i)")
    }

    // Reverse order
    for i in (1..=5).rev() {
        println!("\(i)")
    }
}

Iterating with Index

Use enumerate() to get both index and value:

fn main() {
    let names = vec!["Alice", "Bob", "Charlie"]

    for (index, name) in names.iter().enumerate() {
        println!("\(index): \(name)")
    }
}

Loop Control

Use break and continue to control loop execution:

fn main() {
    for i in 1..=10 {
        if i == 3 {
            continue  // Skip 3
        }
        if i == 8 {
            break  // Stop at 8
        }
        println!("\(i)")
    }
}

Combining Control Flow

Control flow constructs can be combined for complex logic:

fn processUsers(users: Vec<User>): Vec<String> {
    var results: Vec<String> = vec![]

    for user in users.iter() {
        // Skip inactive users
        guard user.isActive else {
            continue
        }

        // Handle different user types
        let message = match user.role {
            Role.Admin -> "Admin: \(user.name)".toString(),
            Role.Moderator -> "Mod: \(user.name)".toString(),
            Role.User -> {
                if let email = user.email {
                    "User: \(user.name) <\(email)>".toString()
                } else {
                    "User: \(user.name)".toString()
                }
            },
        }

        results.push(message)
    }

    results
}

Summary

ConstructPurposeOxide Syntax
ifConditional branchingif cond { } else { }
if letPattern match + conditionalif let x = nullable { }
guardEarly return on failureguard cond else { return }
matchMulti-way pattern matchingmatch x { P -> e, else -> d }
loopInfinite looploop { }
whileConditional loopwhile cond { }
while letPattern match loopwhile let x = iter.next() { }
forIterationfor item in collection { }

Key Oxide differences from Rust:

  • Match arms use -> (Rust's => is invalid in Oxide)
  • Match wildcard is else (Rust's _ can still be used, but else is idiomatic)
  • guard provides clean early-return syntax
  • if let x = nullable auto-unwraps without Some()
  • Enum variants use dot notation (Enum.Variant); Rust's :: syntax does not exist in Oxide

These constructs give you precise control over program flow while maintaining Oxide's goal of being approachable and readable.

What is Ownership?

Ownership is the defining feature of Oxide. It's the system that allows Oxide to make memory safety guarantees without needing a garbage collector. Understanding ownership is crucial to becoming proficient in Oxide. In this chapter, we'll explore ownership and related features that help manage memory automatically.

Oxide inherits Rust's brilliant ownership system with one simple observation: Rust was right. Rather than reinvent the wheel, Oxide provides Oxide's familiar syntax while keeping ownership semantics identical to Rust. This means you get the same safety guarantees, the same zero-cost abstractions, and the same performance—just with a syntax that feels more natural if you're coming from Swift, Kotlin, or TypeScript.

The Ownership Rules

Oxide has three fundamental ownership rules:

  1. Each value in Oxide has a variable that is its owner
  2. There can only be one owner at a time
  3. When the owner goes out of scope, the value is dropped (freed)

These rules prevent memory leaks and use-after-free bugs at compile time. Let's explore each with examples.

Variable Scope

A scope is the range within a program where an item is valid. Consider this example:

fn main() {
    {  // s is not yet declared
        let s = "hello"  // s is valid from this point forward
        println!("\(s)")  // s is still valid
    }  // this scope is now over, and s is no longer valid
    // println!("\(s)")  // Error: s is out of scope
}

When s comes into scope, it is valid. It remains valid until it goes out of scope. At that point, the string's memory is automatically freed.

The String Type and the Move Semantic

Let's look at how ownership works with dynamically allocated data. We'll use String as our example because it's more interesting than the &str literals we've seen so far:

fn main() {
    let s1 = String.from("hello")
    let s2 = s1  // s1's data is MOVED to s2

    // println!("\(s1)")  // Error: s1 no longer owns the data
    println!("\(s2)")     // OK: s2 owns the data
}

When we assign s1 to s2, the ownership transfers. This is different from copying the data—it's moving ownership. s1 is no longer valid, and s2 owns the string data.

Why move instead of copy? Moving is Oxide's way of ensuring that only one owner exists at a time. This prevents multiple parts of your code from trying to manage the same memory, which would be a recipe for bugs.

Here's what happens in memory:

  1. s1 is created and points to allocated memory containing "hello"
  2. s2 = s1 moves the pointer, length, and capacity from s1 to s2
  3. s1 is invalidated (its ownership is lost)
  4. When s2 goes out of scope, the memory is freed

This is sometimes called a "shallow copy" in other languages, but Oxide goes further by making the original binding invalid. There's no risk of both variables trying to free the same memory.

Ownership and Functions

The same ownership rules apply when passing values to functions:

fn main() {
    let s = String.from("hello")
    takesOwnership(s)
    // println!("\(s)")  // Error: s has been moved into the function
}

fn takesOwnership(someString: String) {
    println!("\(someString)")
}  // someString goes out of scope and the string is dropped

When s is passed to takesOwnership, ownership of the string is transferred to the function's parameter. Once the function returns, the string is dropped.

If you want to use s after calling the function, you need to get the ownership back:

fn main() {
    let s = String.from("hello")
    let s = takesAndReturnsOwnership(s)
    println!("\(s)")  // OK: we got ownership back
}

fn takesAndReturnsOwnership(someString: String): String {
    someString  // ownership is returned
}

This pattern—taking ownership and returning it—is cumbersome. This is why Oxide has references and borrowing, which we'll explore in the next section. For now, understand that ownership follows these simple rules everywhere in your code.

Ownership and Copying

Some types in Oxide are simple enough that their values can be copied bit-by-bit without issues. These types implement the Copy trait. If a type implements Copy, ownership is not moved—the value is copied instead:

fn main() {
    let x = 5
    let y = x  // x is COPIED, not moved

    println!("x = \(x), y = \(y)")  // Both are valid!
}

Integer types, floating-point numbers, booleans, and characters all implement Copy because they're small and live on the stack. More complex types like String do not implement Copy because their data lives on the heap.

As a rule of thumb:

  • Stack types (integers, floats, bools, chars) implement Copy
  • Heap types (strings, vectors, collections) do NOT implement Copy

Implicit Returns and Ownership

In Oxide, the last expression in a function is the return value. This interacts with ownership in an important way:

fn createString(): String {
    let s = String.from("hello")
    s  // ownership is transferred to the caller
}

fn main() {
    let result = createString()
    println!("\(result)")  // OK: createString returned ownership
}

The value s goes out of scope at the end of createString, but because it's being returned, ownership is transferred to the caller. The memory is not dropped.

Why Ownership Matters

Oxide's ownership system provides several critical benefits:

  1. Memory Safety: Ownership prevents use-after-free and double-free bugs
  2. No Garbage Collector: Memory is freed automatically without runtime overhead
  3. No Runtime Errors: Memory errors are caught at compile time, not in production
  4. Zero-Cost Abstractions: The safety checks have no runtime cost

The elegance of Rust's ownership system is that it aligns with how we actually think about resources. When a function takes ownership, it's clear that the function is responsible for that resource. When it returns something, it transfers responsibility to the caller. This natural model prevents accidental resource leaks.

Rust's Ownership: The Gold Standard

Rust's ownership system is considered one of the greatest achievements in programming language design. It solved a problem that plagued systems programming for decades: how to provide memory safety without garbage collection. Oxide embraces this system completely.

If you've used languages with garbage collectors, this might feel unfamiliar at first. But once you understand the rules, you'll find that ownership makes code clearer and safer. You're not fighting the language—the language is helping you express your intent precisely.

Summary

  • Each value has one owner at a time
  • Ownership transfers when assigning to a new variable or passing to a function
  • When the owner goes out of scope, the value is dropped (memory is freed)
  • Types that implement Copy are copied instead of moved
  • The ownership rules are the same as Rust—we kept what worked perfectly

The ownership system might seem strict, but it's what makes Oxide safe by default. In the next section, we'll learn about references and borrowing, which lets you use data without taking ownership of it.

References and Borrowing

Ownership is powerful, but constantly moving values in and out of functions gets tedious. Fortunately, Oxide has a feature for using values without transferring ownership: references.

A reference allows you to refer to a value without taking ownership of it. Instead of passing the value itself, you pass a reference to it. When the function returns, ownership remains with the original owner.

Creating and Using References

You create a reference using the ampersand (&) operator:

fn main() {
    let s = String.from("hello")

    let length = calculateLength(&s)

    println!("'{}' has length {}", s, length)  // s is still valid!
}

fn calculateLength(s: &String): UIntSize {
    s.len()
}  // s goes out of scope, but it doesn't own the String, so nothing happens

The &s syntax creates a reference to s. The calculateLength function receives a reference (&String) instead of the string itself. Since calculateLength doesn't own the string, the string is not dropped when the function returns.

Notice that we can still use s after calling calculateLength. The original owner retains ownership; we only loaned it to the function.

Mutable References

By default, references are immutable. If you try to modify the data through a reference, the compiler stops you:

fn main() {
    let s = String.from("hello")

    // changeString(&s)  // Error: cannot mutate through immutable reference
    changeString(&mut s)  // Error: s is immutable
}

fn changeString(s: &String) {
    // s.push_str(" world")  // Error: cannot mutate through immutable reference
}

To modify data through a reference, you need a mutable reference, declared with &mut:

fn main() {
    var s = String.from("hello")

    changeString(&mut s)

    println!("\(s)")  // "hello world"
}

fn changeString(s: &mut String) {
    s.push_str(" world")
}

Important: The original binding must be mutable (var s) to allow mutable references. You cannot create a mutable reference to an immutable binding.

The Rules of Borrowing

Oxide's borrow checker enforces two critical rules:

  1. Either multiple immutable references OR one mutable reference at a time
  2. References must always be valid (no use-after-free)

Let's explore these rules with examples.

Multiple Immutable References

You can have multiple immutable references to the same data:

fn main() {
    let s = String.from("hello")

    let r1 = &s
    let r2 = &s
    let r3 = &s

    println!("\(r1), \(r2), \(r3)")  // All three references work fine
}

This is safe because all readers are immutable. Multiple readers cannot corrupt data.

Immutable and Mutable References Cannot Coexist

Once you create a mutable reference, you cannot have any immutable references:

fn main() {
    var s = String.from("hello")

    let r1 = &s
    let r2 = &s
    // var r3 = &mut s  // Error: cannot borrow as mutable while already borrowed as immutable

    println!("\(r1), \(r2)")
}

The compiler prevents the mutable reference because r1 and r2 are still in use. If we allowed &mut s, code using r1 or r2 might suddenly see the data change, which would be surprising and dangerous.

Using Scope to Release Borrows

A borrow ends when the reference is last used, not necessarily when it goes out of scope:

fn main() {
    var s = String.from("hello")

    let r1 = &s
    let r2 = &s
    println!("\(r1), \(r2)")  // Last use of r1 and r2

    // r1 and r2 are no longer needed after this point
    var r3 = &mut s  // OK: r1 and r2's borrows have ended
    r3.push_str(" world")

    println!("\(r3)")
}

This is called non-lexical lifetimes (NLL). Rust introduced this feature to make borrowing less restrictive. Oxide inherits it, which means you often get mutable access sooner than you might expect.

Mutable References Are Exclusive

Only one mutable reference can exist at a time:

fn main() {
    var s = String.from("hello")

    var r1 = &mut s
    // var r2 = &mut s  // Error: cannot have two mutable references

    r1.push_str(" world")
    println!("\(r1)")
}

This rule prevents data races and ensures that if you modify data, no other code can see it in an inconsistent state.

The &str Lightweight Reference

We've seen &str in function parameters. This is a reference to a string slice, which we'll explore in the next section. For now, understand that &str borrows a string—it's lighter weight than &String because it doesn't own any heap allocation:

fn main() {
    let s = String.from("hello world")

    let word = firstWord(&s)

    println!("\(word)")  // "hello"
}

fn firstWord(s: &str): &str {
    let bytes = s.as_bytes()

    for (i, &item) in bytes.iter().enumerate() {
        if item == ' ' {
            return &s[0..i]
        }
    }

    &s[..]
}

Using &str is more flexible than &String because it accepts both String references and string literals.

Why Borrowing Matters

The borrowing system solves the ownership problem we encountered earlier. Instead of constantly moving values and returning them, you can borrow them:

// Without borrowing: tedious
fn main() {
    let s1 = String.from("hello")
    let (s1, len) = calculateLength(s1)
    println!("'{}' has length {}", s1, len)
}

fn calculateLength(s: String): (String, UIntSize) {
    let length = s.len()
    (s, length)
}

// With borrowing: clean
fn main() {
    let s1 = String.from("hello")
    let len = calculateLength(&s1)
    println!("'{}' has length {}", s1, len)
}

fn calculateLength(s: &String): UIntSize {
    s.len()
}

Borrowing lets functions use data without taking responsibility for it.

Mutable References Enable Controlled Mutation

Mutable references are Oxide's way of saying "this function needs to modify this data." They make your code's intent clear:

fn main() {
    var user = User { name: "Alice".toString() }
    updateUserName(&mut user, "Bob")
    println!("\(user.name)")  // "Bob"
}

fn updateUserName(user: &mut User, newName: &str) {
    user.name = newName.toString()
}

The &mut syntax makes it obvious that the function will modify the argument. This is much clearer than passing a regular parameter and having side effects.

Lifetime Annotations

Sometimes the compiler needs you to explicitly specify how long a reference is valid. This is called a lifetime. We'll explore lifetimes in depth in a later chapter, but here's a simple example:

fn longest<'a>(x: &'a str, y: &'a str): &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let s1 = "short"
    let s2 = "a much longer string"
    let result = longest(s1, s2)
    println!("\(result)")
}

The 'a notation tells the compiler that the returned reference lives as long as both input references. This ensures the return value is always valid.

You don't need to understand lifetimes yet. Just know that Oxide sometimes requires you to be explicit about how long references live, which is another safety feature.

Rust Comparison

The referencing and borrowing system is identical to Rust. The same &T and &mut T syntax, the same rules, the same benefits:

#![allow(unused)]
fn main() {
// Rust - identical to Oxide
fn calculate_length(s: &String) -> usize {
    s.len()
}

fn change_string(s: &mut String) {
    s.push_str(" world");
}
}

Summary

  • References allow using values without taking ownership
  • Immutable references (&T) are the default
  • Mutable references (&mut T) allow modification, with restrictions
  • Oxide enforces: either many immutable references OR one mutable reference
  • Borrows end when the reference is no longer used (non-lexical lifetimes)
  • References prevent data races and use-after-free bugs at compile time
  • Lifetime annotations sometimes explicit about how long references live

Borrowing is one of Oxide's most powerful features. It lets you express ownership clearly while remaining flexible about how data flows through your program. Combined with ownership, it enables memory safety without garbage collection—the same achievement Rust pioneered.

In the next section, we'll explore a special kind of reference: slices.

The Slice Type

A slice is a reference to a contiguous sequence of elements in a collection. Unlike references to the whole collection, slices let you reference a specific portion of it. Slices are incredibly useful and appear throughout Oxide code.

String Slices

A string slice is a reference to part of a String:

fn main() {
    let s = String.from("hello world")

    let hello = &s[0..5]
    let world = &s[6..11]

    println!("\(hello)")  // "hello"
    println!("\(world)")  // "world"
}

Rather than taking a reference to the entire string, &s[0..5] creates a reference to a portion of the string. The range syntax [starting_index..ending_index] includes starting_index but excludes ending_index.

Rust comparison: String slices work identically to Rust. The type annotation is &str.

#![allow(unused)]
fn main() {
// Rust - identical syntax
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
}

Slice Shorthand Syntax

You can omit the starting index if it's 0 or the ending index if it's the length:

fn main() {
    let s = String.from("hello")

    let slice1 = &s[0..2]  // "he"
    let slice2 = &s[..2]   // "he" - same, omit start

    let slice3 = &s[3..5]  // "lo"
    let slice4 = &s[3..]   // "lo" - same, omit end

    let slice5 = &s[..]    // "hello" - entire string
}

This shorthand makes slice syntax less verbose for common cases.

Using Slices in Functions

Slices are particularly useful in function parameters because they're more flexible than &String:

fn firstWord(s: &str): &str {
    let bytes = s.as_bytes()

    for (i, &item) in bytes.iter().enumerate() {
        if item == ' ' as u8 {
            return &s[0..i]
        }
    }

    &s[..]
}

fn main() {
    let myString = String.from("hello world")
    let word = firstWord(&myString)
    println!("\(word)")  // "hello"

    // Also works with string literals
    let word = firstWord("hello world")
    println!("\(word)")  // "hello"
}

Notice that firstWord takes &str, not &String. This makes it more flexible. We can pass:

  • A reference to a String: &myString (automatically coerced to &str)
  • A string literal: "hello world" (which is already &str)

If the function required &String, we couldn't pass string literals directly.

The Power of &str Over &String

Using &str in your API makes your code more flexible:

// Restrictive: only accepts String references
fn processBad(s: &String): UIntSize {
    s.len()
}

// Flexible: accepts string slices and String references
fn processGood(s: &str): UIntSize {
    s.len()
}

fn main() {
    let myString = String.from("hello")
    let literal = "world"

    // processBad(literal)  // Error: literal is &str, not &String
    processGood(&myString)  // OK: &String coerces to &str
    processGood(literal)    // OK: already &str
}

This is a key principle in Oxide and Rust: prefer &str over &String, &[T] over &Vec<T>, etc. Your APIs are more useful when they accept slices rather than owned collections.

Array Slices

Slices work with arrays and vectors, not just strings:

fn main() {
    let arr = [1, 2, 3, 4, 5]

    // Slice part of the array
    let slice = &arr[1..4]  // [2, 3, 4]

    // Iterate over the slice
    for item in slice {
        println!("\(item)")
    }
}

With vectors, slices are even more useful:

fn main() {
    let v = vec![1, 2, 3, 4, 5]

    let slice = &v[2..]  // [3, 4, 5]

    println!("Length of slice: \(slice.len())")

    for &item in slice {
        println!("\(item)")
    }
}

Slices and Borrowing

Slices respect the borrowing rules. You cannot create a mutable reference to a collection while slices exist:

fn main() {
    var s = String.from("hello world")

    let word = firstWord(&s)  // immutable borrow

    // s.clear()  // Error: cannot borrow as mutable while borrowed as immutable

    println!("\(word)")  // word's borrow ends here

    s.clear()  // OK now: word is no longer used
}

fn firstWord(s: &str): &str {
    let bytes = s.as_bytes()

    for (i, &item) in bytes.iter().enumerate() {
        if item == ' ' as u8 {
            return &s[0..i]
        }
    }

    &s[..]
}

This prevents a common bug: modifying a collection while holding a reference to its contents. The slice is only valid as long as the underlying data doesn't change.

The Slice Type Syntax

The type of a slice is &[T], where T is the element type:

fn main() {
    let s = String.from("hello")
    let slice: &str = &s[..]  // &str is shorthand for &[char] (roughly)

    let arr = [1, 2, 3]
    let slice: &[Int] = &arr[..]  // &[Int] slice

    let v = vec![1, 2, 3]
    let slice: &[Int] = &v[..]  // &[Int] slice from vector
}

The syntax &[T] represents "a reference to a contiguous sequence of T". Notice that &str is special—it's the slice type for strings, optimized for UTF-8 text.

Practical Example: Splitting Words

Let's build something practical. A function that splits a string into words and returns an array of slices:

fn splitWords(s: &str): Vec<&str> {
    var words = vec![]
    var start = 0

    for (i, &c) in s.as_bytes().iter().enumerate() {
        if c == ' ' as u8 {
            words.push(&s[start..i])
            start = i + 1
        }
    }

    if start < s.len() {
        words.push(&s[start..])
    }

    words
}

fn main() {
    let sentence = "the quick brown fox"
    let words = splitWords(sentence)

    for word in words {
        println!("Word: \(word)")
    }
}

Output:

Word: the
Word: quick
Word: brown
Word: fox

This function takes a string slice, and returns a vector of slices pointing into the original string. No data is copied—only references are created.

Why Slices Are Important

Slices embody three key principles:

  1. Zero-Cost Abstractions: Slices have no runtime overhead. They're just a pointer and length.
  2. Safety: Slices prevent out-of-bounds access at compile time.
  3. Flexibility: Functions accepting slices work with owned collections, string literals, and stack arrays.

Because of these properties, slices appear everywhere in Oxide code. They're the idiomatic way to work with sequences.

Common Slice Methods

Slices provide useful methods for working with sequences:

fn main() {
    let v = vec![1, 2, 3, 4, 5]
    let slice = &v[1..4]  // [2, 3, 4]

    // Length
    println!("Length: \(slice.len())")  // 3

    // Access elements
    println!("First: \(slice[0])")      // 2
    println!("Last: \(slice[2])")       // 4

    // Iteration
    for &item in slice {
        println!("Item: \(item)")
    }

    // Check if empty
    if slice.is_empty() {
        println!("Empty slice")
    } else {
        println!("Not empty")
    }
}

Summary

  • Slices are references to contiguous portions of collections
  • String slices are denoted &str; array/vector slices are &[T]
  • Create slices with range syntax: &collection[start..end]
  • Use shorthand: &s[..] for the whole collection, &s[..n] to omit end, etc.
  • Slices are more flexible than references to whole collections; prefer them in APIs
  • Slices respect borrowing rules: no modification while slices exist
  • Slices have zero runtime cost and no bounds checking overhead

Slices combine safety with flexibility. They're one of the features that makes Oxide (and Rust) pleasant to use. By understanding ownership, references, and slices, you now have the foundation to write safe, efficient code.

The ownership system—ownership itself, borrowing, and slices—is complete. These three concepts form the bedrock of Oxide's memory safety story.

Defining and Instantiating Structs

Structs are one of the fundamental ways to create custom types in Oxide. A struct, short for "structure," lets you package together related values under a single name. If you're coming from object-oriented languages, a struct is similar to a class's data attributes.

Defining Structs

To define a struct, use the struct keyword followed by the struct name and curly braces containing the field definitions. Each field has a name and a type, using Oxide's camelCase naming convention for field names.

struct User {
    active: Bool,
    username: String,
    email: String,
    signInCount: UInt64,
}

Notice that:

  • Field names use camelCase (e.g., signInCount, not sign_in_count)
  • Types use Oxide's type aliases (Bool, UInt64) or standard types (String)
  • The struct body uses curly braces, just like Rust

Public Structs and Fields

To make a struct accessible from other modules, use the public keyword:

public struct User {
    public active: Bool,
    public username: String,
    email: String,           // Private by default
    signInCount: UInt64,     // Private by default
}

The public keyword replaces Rust's pub for visibility. You can apply it to both the struct itself and individual fields.

Creating Instances

To create an instance of a struct, specify the struct name followed by curly braces containing the field values:

fn main() {
    let user = User {
        active: true,
        username: "alice123".toString(),
        email: "alice@example.com".toString(),
        signInCount: 1,
    }
}

The order of fields doesn't matter when creating an instance, but all fields must be provided (unless they have default values through other mechanisms).

Mutable Instances

If you need to modify a struct instance after creation, use var instead of let:

fn main() {
    var user = User {
        active: true,
        username: "alice123".toString(),
        email: "alice@example.com".toString(),
        signInCount: 1,
    }

    // Now we can modify fields
    user.email = "newemail@example.com".toString()
    user.signInCount += 1
}

Note that in Oxide, the entire instance must be mutable to change any field. You cannot mark only certain fields as mutable.

Accessing Field Values

Use dot notation to access struct fields:

fn main() {
    let user = User {
        active: true,
        username: "alice123".toString(),
        email: "alice@example.com".toString(),
        signInCount: 1,
    }

    println!("Username: \(user.username)")
    println!("Email: \(user.email)")
    println!("Sign-in count: \(user.signInCount)")
}

Oxide's string interpolation with \(expression) makes it easy to embed field values directly in output strings.

Field Init Shorthand

When variable names match field names, you can use the shorthand syntax:

fn createUser(username: String, email: String): User {
    User {
        active: true,
        username,   // Same as username: username
        email,      // Same as email: email
        signInCount: 1,
    }
}

This shorthand reduces repetition when the parameter names match the field names.

Struct Update Syntax

When creating a new instance that reuses most values from an existing instance, use the struct update syntax with ..:

fn main() {
    let user1 = User {
        active: true,
        username: "alice123".toString(),
        email: "alice@example.com".toString(),
        signInCount: 1,
    }

    let user2 = User {
        email: "bob@example.com".toString(),
        ..user1  // Use remaining fields from user1
    }

    // user2 has bob's email but alice's username, active status, and signInCount
}

The ..user1 must come last in the struct literal. It copies all remaining fields from the source instance.

Important ownership note: The struct update syntax moves data from the source. After the update, user1 cannot be used if any of its fields were moved (like String fields). However, if only copyable types (like Bool or UInt64) are transferred, the source remains valid.

Tuple Structs

Oxide supports tuple structs, which are structs without named fields. These are useful when you want to give a tuple a distinct type name:

struct Color(Int, Int, Int)
struct Point(Int, Int, Int)

fn main() {
    let black = Color(0, 0, 0)
    let origin = Point(0, 0, 0)

    // Access fields by index
    let red = black.0
    let green = black.1
    let blue = black.2
}

Even though Color and Point have the same field types, they are different types. A function expecting a Color won't accept a Point.

Unit-Like Structs

You can also define structs with no fields, called unit-like structs:

struct AlwaysEqual

fn main() {
    let subject = AlwaysEqual
}

Unit-like structs are useful when you need to implement a trait on a type but don't need to store any data.

Adding Attributes

Structs commonly use derive attributes to automatically implement traits:

#[derive(Debug, Clone, PartialEq)]
public struct User {
    active: Bool,
    username: String,
    email: String,
    signInCount: UInt64,
}

The #[derive(...)] attribute automatically generates implementations for common traits:

  • Debug: Enables printing with {:?} format specifier
  • Clone: Enables creating deep copies with .clone()
  • PartialEq: Enables comparison with == and !=

Complete Example

Here's a complete example showing struct definition and usage:

#[derive(Debug, Clone)]
public struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    println!("Rectangle: {:?}", rect)
    println!("Width: \(rect.width)")
    println!("Height: \(rect.height)")

    // Create a modified copy
    let wider = Rectangle {
        width: 60,
        ..rect
    }

    println!("Wider rectangle: {:?}", wider)
}

Rust Comparison

AspectRustOxide
Visibilitypubpublic
Field namingsnake_casecamelCase
Struct syntaxstruct { }struct { } (same)
Tuple structstruct Point(i32, i32)struct Point(Int, Int)
Typesi32, bool, u64Int, Bool, UInt64

The underlying semantics are identical. Oxide structs are Rust structs with different naming conventions and type aliases. The compiled binary is exactly the same as equivalent Rust code.

Summary

Structs let you create custom types that package related data together:

  • Use struct with curly braces for named fields
  • Use camelCase for field names
  • Use public for visibility
  • Create instances with struct literals
  • Use var for mutable instances
  • Derive common traits with #[derive(...)]

In the next section, we'll build a complete program using structs to see how they work in practice.

An Example Program Using Structs

To understand when structs are useful, let's build a program that calculates the area of a rectangle. We'll start with simple variables and progressively refactor to use structs, demonstrating how they improve code organization and clarity.

Starting with Simple Variables

Here's a basic approach using individual variables:

fn main() {
    let width = 30
    let height = 50

    println!(
        "The area of the rectangle is \(area(width, height)) square pixels."
    )
}

fn area(width: Int, height: Int): Int {
    width * height
}

This works, but the area function has two parameters that conceptually belong together. The relationship between width and height isn't explicit in the code.

Refactoring with Tuples

We can group the dimensions using a tuple:

fn main() {
    let rect = (30, 50)

    println!(
        "The area of the rectangle is \(area(rect)) square pixels."
    )
}

fn area(dimensions: (Int, Int)): Int {
    dimensions.0 * dimensions.1
}

This groups the data, but now we've lost meaning. Is dimensions.0 the width or the height? The tuple doesn't convey this information, making the code harder to understand.

Refactoring with Structs

Structs solve this by giving meaningful names to both the type and its fields:

struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    println!(
        "The area of the rectangle is \(area(&rect)) square pixels."
    )
}

fn area(rectangle: &Rectangle): Int {
    rectangle.width * rectangle.height
}

Now the code clearly shows that width and height are dimensions of a Rectangle. The function signature area(rectangle: &Rectangle) immediately conveys what the function operates on.

Notice that area takes a reference &Rectangle. This means:

  • The function borrows the rectangle rather than taking ownership
  • The original rect remains valid after the function call
  • No data is copied, just a reference to the existing rectangle

Adding Debug Output

When developing, you often want to print struct values for debugging. Let's see what happens if we try to print our rectangle:

struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    println!("rect is \(rect)")  // This won't compile!
}

This fails because Rectangle doesn't implement the Display trait that string interpolation requires. For debugging purposes, we can use the Debug trait:

#[derive(Debug)]
struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    // Use {:?} for Debug formatting
    println!("rect is {:?}", rect)

    // Use {:#?} for pretty-printed Debug output
    println!("rect is {:#?}", rect)
}

Output:

rect is Rectangle { width: 30, height: 50 }
rect is Rectangle {
    width: 30,
    height: 50,
}

The #[derive(Debug)] attribute automatically generates an implementation of the Debug trait, enabling the {:?} and {:#?} format specifiers.

Using dbg! Macro

For quick debugging, the dbg! macro is even more convenient:

#[derive(Debug)]
struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let scale = 2
    let rect = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    }

    dbg!(&rect)
}

Output:

[src/main.ox:9:16] 30 * scale = 60
[src/main.ox:13:5] &rect = Rectangle {
    width: 60,
    height: 50,
}

The dbg! macro:

  • Prints the file and line number
  • Shows the expression being evaluated
  • Returns ownership of the value (so it can be used inline)
  • Outputs to stderr rather than stdout

Notice that we use dbg!(&rect) with a reference to avoid moving ownership.

Complete Working Example

Here's the complete program with all improvements:

#[derive(Debug)]
struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    let area = calculateArea(&rect)

    println!("Rectangle: {:#?}", rect)
    println!("Area: \(area) square pixels")

    // Example with multiple rectangles
    let rectangles = vec![
        Rectangle { width: 10, height: 20 },
        Rectangle { width: 30, height: 50 },
        Rectangle { width: 5, height: 15 },
    ]

    println!("\nAll rectangles:")
    for rect in rectangles.iter() {
        println!("  {:?} -> area: \(calculateArea(rect))", rect)
    }
}

fn calculateArea(rectangle: &Rectangle): Int {
    rectangle.width * rectangle.height
}

Output:

Rectangle: Rectangle {
    width: 30,
    height: 50,
}
Area: 1500 square pixels

All rectangles:
  Rectangle { width: 10, height: 20 } -> area: 200
  Rectangle { width: 30, height: 50 } -> area: 1500
  Rectangle { width: 5, height: 15 } -> area: 75

Deriving Multiple Traits

In practice, you'll often derive several traits together:

#[derive(Debug, Clone, PartialEq, Eq)]
struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 }
    let rect2 = rect1.clone()  // Create a copy
    let rect3 = Rectangle { width: 40, height: 50 }

    println!("rect1 == rect2: \(rect1 == rect2)")  // true
    println!("rect1 == rect3: \(rect1 == rect3)")  // false
}

Common derivable traits:

  • Debug: Enables {:?} formatting for debugging
  • Clone: Enables .clone() for deep copying
  • PartialEq: Enables == and != comparison
  • Eq: Indicates that equality is reflexive, symmetric, and transitive
  • Hash: Enables use as a key in HashMap

Why Use Structs?

This example demonstrates several benefits of structs:

  1. Semantic clarity: Rectangle is more meaningful than (Int, Int)
  2. Self-documenting code: Field names like width and height explain themselves
  3. Type safety: A Rectangle can't be confused with other (Int, Int) tuples
  4. Extensibility: Easy to add more fields or functionality later
  5. Maintainability: Changes to the struct definition are centralized

Moving Toward Methods

The calculateArea function works, but it's disconnected from the Rectangle type. Conceptually, calculating area is something a rectangle does, not something done to a rectangle.

In the next section, we'll learn about methods, which let us define functions that are directly associated with a struct:

extension Rectangle {
    fn area(): Int {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle { width: 30, height: 50 }
    println!("Area: \(rect.area())")  // More natural!
}

This syntax, using extension blocks and implicit self, is one of Oxide's major features and is covered in detail in the next section.

Summary

In this section, we saw how to:

  • Refactor code to use structs for better organization
  • Derive the Debug trait for printing struct values
  • Use dbg! for quick debugging
  • Access struct fields through references
  • Appreciate the benefits of structured data

Next, we'll explore method syntax to make our struct-related functions even more intuitive and powerful.

Method Syntax

Methods are functions defined within the context of a type. In Oxide, we define methods using extension blocks, which associate functions with a struct, enum, or trait. This is one of Oxide's major features that differs significantly from Rust's impl blocks.

Defining Methods with Extension Blocks

Let's transform our calculateArea function from the previous section into a method on Rectangle:

#[derive(Debug)]
struct Rectangle {
    width: Int,
    height: Int,
}

extension Rectangle {
    fn area(): Int {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    println!("Area: \(rect.area()) square pixels")
}

Key points:

  • extension Rectangle { } replaces Rust's impl Rectangle { }
  • Methods have implicit access to self
  • No self parameter is written in the method signature
  • Call methods with dot notation: rect.area()

Understanding self in Oxide Methods

In Oxide, self is implicit in non-static methods. The method modifier determines how self is accessed:

ModifierSelf TypeDescription
(none)&selfImmutable borrow (default)
mutating&mut selfMutable borrow
consumingselfTakes ownership
static(none)No self parameter

This is a fundamental difference from Rust, where you explicitly write &self, &mut self, or self as the first parameter.

Default Methods: Immutable Borrow

When you define a method without any modifier, it receives an immutable borrow of self:

extension Rectangle {
    // Default: borrows self immutably (&self)
    fn area(): Int {
        self.width * self.height
    }

    fn perimeter(): Int {
        2 * (self.width + self.height)
    }

    fn isSquare(): Bool {
        self.width == self.height
    }
}

These methods can read from self but cannot modify it. This is the most common method type and makes sense for any operation that doesn't change the object's state.

Rust Equivalent

The Oxide code above translates to this Rust code:

#![allow(unused)]
fn main() {
impl Rectangle {
    fn area(&self) -> i32 {
        self.width * self.height
    }

    fn perimeter(&self) -> i32 {
        2 * (self.width + self.height)
    }

    fn is_square(&self) -> bool {
        self.width == self.height
    }
}
}

Mutating Methods: Mutable Borrow

Use the mutating modifier when a method needs to modify self:

extension Rectangle {
    mutating fn scale(factor: Int) {
        self.width *= factor
        self.height *= factor
    }

    mutating fn setWidth(newWidth: Int) {
        self.width = newWidth
    }

    mutating fn setHeight(newHeight: Int) {
        self.height = newHeight
    }

    mutating fn double() {
        self.scale(2)  // Can call other mutating methods
    }
}

fn main() {
    var rect = Rectangle { width: 10, height: 20 }
    println!("Before: {:?}", rect)

    rect.scale(3)
    println!("After scale(3): {:?}", rect)

    rect.setWidth(100)
    println!("After setWidth(100): {:?}", rect)
}

Output:

Before: Rectangle { width: 10, height: 20 }
After scale(3): Rectangle { width: 30, height: 60 }
After setWidth(100): Rectangle { width: 100, height: 60 }

Important notes:

  • You can only call mutating methods on mutable bindings (var, not let)
  • The mutating keyword clearly signals that the method modifies state
  • This pattern is inspired by Swift's mutating keyword

Rust Equivalent

#![allow(unused)]
fn main() {
impl Rectangle {
    fn scale(&mut self, factor: i32) {
        self.width *= factor;
        self.height *= factor;
    }

    fn set_width(&mut self, new_width: i32) {
        self.width = new_width;
    }
}
}

Consuming Methods: Taking Ownership

Use the consuming modifier when a method takes ownership of self:

extension Rectangle {
    consuming fn intoSquare(): Rectangle {
        let size = (self.width + self.height) / 2
        Rectangle { width: size, height: size }
    }

    consuming fn decompose(): (Int, Int) {
        (self.width, self.height)
    }

    consuming fn destroy() {
        // self is dropped at the end of this method
        println!("Rectangle destroyed!")
    }
}

fn main() {
    let rect = Rectangle { width: 30, height: 50 }
    let (w, h) = rect.decompose()
    println!("Width: \(w), Height: \(h)")

    // rect can no longer be used - ownership was consumed
    // println!("{:?}", rect)  // ERROR: value moved
}

Consuming methods are used when:

  • Transforming a value into something else
  • Extracting owned data from a struct
  • Intentionally consuming a resource (like closing a file handle)

The naming convention often uses "into" prefix (like intoSquare) to signal that the original value will be consumed.

Rust Equivalent

#![allow(unused)]
fn main() {
impl Rectangle {
    fn into_square(self) -> Rectangle {
        let size = (self.width + self.height) / 2;
        Rectangle { width: size, height: size }
    }

    fn decompose(self) -> (i32, i32) {
        (self.width, self.height)
    }
}
}

Static Methods: No Self Parameter

Use the static modifier for functions that don't operate on an instance:

extension Rectangle {
    static fn new(width: Int, height: Int): Rectangle {
        Rectangle { width, height }
    }

    static fn square(size: Int): Rectangle {
        Rectangle { width: size, height: size }
    }

    static fn zero(): Rectangle {
        Rectangle { width: 0, height: 0 }
    }

    static fn fromDimensions(dimensions: (Int, Int)): Rectangle {
        Rectangle {
            width: dimensions.0,
            height: dimensions.1,
        }
    }
}

fn main() {
    let rect1 = Rectangle.new(30, 50)
    let rect2 = Rectangle.square(25)
    let rect3 = Rectangle.zero()

    println!("rect1: {:?}", rect1)
    println!("rect2: {:?}", rect2)
    println!("rect3: {:?}", rect3)
}

Static methods are called on the type itself using dot notation: Rectangle.new(30, 50) rather than rect.new(30, 50).

Common uses for static methods:

  • Constructors (like new, default, fromXxx)
  • Factory methods that create instances
  • Utility functions related to the type

Using Self in Static Methods

Inside an extension block, Self (capital S) refers to the type being extended:

extension Rectangle {
    static fn square(size: Int): Self {
        Self { width: size, height: size }
    }

    static fn default(): Self {
        Self.zero()  // Can call other static methods
    }
}

Rust Equivalent

#![allow(unused)]
fn main() {
impl Rectangle {
    fn new(width: i32, height: i32) -> Rectangle {
        Rectangle { width, height }
    }

    fn square(size: i32) -> Self {
        Self { width: size, height: size }
    }
}
}

Note: In Rust, these are called "associated functions" when they don't take self. Oxide uses static to make this explicit.

Methods with Additional Parameters

Methods can take additional parameters beyond the implicit self:

extension Rectangle {
    fn canHold(other: &Rectangle): Bool {
        self.width > other.width && self.height > other.height
    }

    mutating fn resizeTo(width: Int, height: Int) {
        self.width = width
        self.height = height
    }

    fn areaRatio(other: &Rectangle): Float {
        self.area() as Float / other.area() as Float
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 }
    let rect2 = Rectangle { width: 10, height: 20 }

    println!("rect1 can hold rect2: \(rect1.canHold(&rect2))")
    println!("Area ratio: \(rect1.areaRatio(&rect2))")
}

Multiple Extension Blocks

You can split methods across multiple extension blocks:

struct Rectangle {
    width: Int,
    height: Int,
}

// Constructors
extension Rectangle {
    static fn new(width: Int, height: Int): Self {
        Self { width, height }
    }

    static fn square(size: Int): Self {
        Self { width: size, height: size }
    }
}

// Geometry calculations
extension Rectangle {
    fn area(): Int {
        self.width * self.height
    }

    fn perimeter(): Int {
        2 * (self.width + self.height)
    }
}

// Mutations
extension Rectangle {
    mutating fn scale(factor: Int) {
        self.width *= factor
        self.height *= factor
    }
}

This helps organize methods by category, though it's also fine to keep everything in a single block.

Implementing Traits with Extension Blocks

Extension blocks also implement traits using the syntax extension Type: Trait:

import std.fmt.{ Display, Formatter, Result }

#[derive(Clone)]
struct Rectangle {
    width: Int,
    height: Int,
}

extension Rectangle: Display {
    fn fmt(f: &mut Formatter): Result {
        write!(f, "Rectangle(\(self.width) x \(self.height))")
    }
}

fn main() {
    let rect = Rectangle { width: 30, height: 50 }
    println!("\(rect)")  // Uses Display implementation
}

This replaces Rust's impl Trait for Type syntax. The colon reads naturally: "extend Rectangle with Display capability."

Multiple Trait Implementations

import std.fmt.{ Display, Formatter, Result }
import std.cmp.{ Ord, Ordering }

extension Rectangle: Display {
    fn fmt(f: &mut Formatter): Result {
        write!(f, "\(self.width)x\(self.height)")
    }
}

extension Rectangle: PartialOrd {
    fn partialCmp(other: &Self): Ordering? {
        self.area().partialCmp(&other.area())
    }
}

Visibility in Extension Blocks

Use public to make methods accessible from other modules:

public struct Rectangle {
    public width: Int,
    public height: Int,
}

extension Rectangle {
    public static fn new(width: Int, height: Int): Self {
        Self { width, height }
    }

    public fn area(): Int {
        self.width * self.height
    }

    // Private helper method
    fn validate(): Bool {
        self.width > 0 && self.height > 0
    }

    public mutating fn scale(factor: Int) {
        if self.validate() {
            self.width *= factor
            self.height *= factor
        }
    }
}

Complete Example: A Point Struct

Here's a comprehensive example showing all method modifiers:

#[derive(Debug, Clone, PartialEq)]
public struct Point {
    x: Float,
    y: Float,
}

extension Point {
    // Static: constructors
    public static fn new(x: Float, y: Float): Self {
        Self { x, y }
    }

    public static fn origin(): Self {
        Self { x: 0.0, y: 0.0 }
    }

    public static fn fromAngle(angle: Float, distance: Float): Self {
        Self {
            x: distance * angle.cos(),
            y: distance * angle.sin(),
        }
    }

    // Default (&self): read-only operations
    public fn distanceFromOrigin(): Float {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    public fn distanceTo(other: &Point): Float {
        let dx = self.x - other.x
        let dy = self.y - other.y
        (dx * dx + dy * dy).sqrt()
    }

    public fn midpointTo(other: &Point): Point {
        Point {
            x: (self.x + other.x) / 2.0,
            y: (self.y + other.y) / 2.0,
        }
    }

    // Mutating (&mut self): modifications
    public mutating fn translate(dx: Float, dy: Float) {
        self.x += dx
        self.y += dy
    }

    public mutating fn scale(factor: Float) {
        self.x *= factor
        self.y *= factor
    }

    public mutating fn normalize() {
        let dist = self.distanceFromOrigin()
        if dist != 0.0 {
            self.x /= dist
            self.y /= dist
        }
    }

    // Consuming (self): ownership transfer
    public consuming fn intoPolar(): (Float, Float) {
        let r = self.distanceFromOrigin()
        let theta = self.y.atan2(self.x)
        (r, theta)
    }

    public consuming fn add(other: Point): Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    // Using static methods
    var point = Point.new(3.0, 4.0)
    let origin = Point.origin()

    // Using default (immutable) methods
    println!("Distance from origin: \(point.distanceFromOrigin())")
    println!("Distance to origin: \(point.distanceTo(&origin))")

    // Using mutating methods
    point.translate(1.0, 1.0)
    println!("After translate: {:?}", point)

    point.scale(2.0)
    println!("After scale: {:?}", point)

    // Using consuming method
    let polar = point.intoPolar()
    println!("Polar coordinates: r=\(polar.0), theta=\(polar.1)")

    // point is now consumed and cannot be used
}

Summary of Method Modifiers

ModifierSelf AccessUse CaseExample
(none)&selfReading data, calculationsfn area(): Int
mutating&mut selfModifying statemutating fn scale(f: Int)
consumingselfOwnership transfer, transformsconsuming fn into(): T
static(none)Constructors, utilitiesstatic fn new(): Self

Comparison with Rust

AspectRustOxide
Implementation blockimpl Type { }extension Type { }
Trait implementationimpl Trait for Type { }extension Type: Trait { }
Immutable borrowfn foo(&self)fn foo()
Mutable borrowfn foo(&mut self)mutating fn foo()
Take ownershipfn foo(self)consuming fn foo()
No selffn foo() (associated fn)static fn foo()
Method callobj.method()obj.method() (same)
Static callType::method()Type.method()

Note: Rust's :: path separator does not exist in Oxide. Using Type::method() in Oxide code will cause a syntax error. Oxide uses . as its only path separator.

The key insight is that Oxide makes the method's relationship to self explicit through modifiers rather than through the first parameter. This makes the intent clearer when reading method signatures.

Summary

Extension blocks are Oxide's way of adding methods to types:

  • Use extension Type { } for inherent methods
  • Use extension Type: Trait { } for trait implementations
  • Method modifiers (mutating, consuming, static) replace explicit self parameters
  • Default methods borrow immutably (&self)
  • mutating methods can modify state (&mut self)
  • consuming methods take ownership (self)
  • static methods have no self and are called on the type
  • Use public for visibility, just like with structs

This syntax makes method signatures more readable and the relationship between methods and their data more explicit, while compiling to exactly the same code as equivalent Rust.

Defining an Enum

Enums allow you to define a type by enumerating its possible variants. Where structs give you a way of grouping together related fields and data, enums give you a way of saying a value is one of a possible set of values. For example, we may want to say that Shape is one of a set of possible shapes that also includes Circle and Triangle. Oxide lets us express these possibilities as an enum.

Let's look at a situation we might want to express in code and see why enums are useful and more appropriate than structs in this case. Say we need to work with IP addresses. Currently, two major standards are used for IP addresses: version four and version six. Because these are the only possibilities for an IP address that our program will come across, we can enumerate all possible variants, which is where enumeration gets its name.

Any IP address can be either a version four or a version six address, but not both at the same time. That property of IP addresses makes the enum data structure appropriate because an enum value can only be one of its variants. Both version four and version six addresses are still fundamentally IP addresses, so they should be treated as the same type when the code is handling situations that apply to any kind of IP address.

We can express this concept in code by defining an IpAddrKind enumeration and listing the possible kinds an IP address can be, V4 and V6. These are the variants of the enum:

enum IpAddrKind {
    V4,
    V6,
}

IpAddrKind is now a custom data type that we can use elsewhere in our code.

Enum Values

We can create instances of each of the two variants of IpAddrKind like this:

let four = IpAddrKind.V4
let six = IpAddrKind.V6

Note that the variants of the enum are namespaced under its identifier using dot notation: IpAddrKind.V4 and IpAddrKind.V6. This is useful because now both values are of the same type: IpAddrKind. We can then, for instance, define a function that takes any IpAddrKind:

fn route(ipKind: IpAddrKind) {
    // handle routing
}

And we can call this function with either variant:

route(IpAddrKind.V4)
route(IpAddrKind.V6)

Using enums has even more advantages. Thinking more about our IP address type, at the moment we don't have a way to store the actual IP address data; we only know what kind it is. Given that you just learned about structs, you might be tempted to tackle this problem with structs:

enum IpAddrKind {
    V4,
    V6,
}

struct IpAddr {
    kind: IpAddrKind,
    address: String,
}

let home = IpAddr {
    kind: IpAddrKind.V4,
    address: "127.0.0.1".toString(),
}

let loopback = IpAddr {
    kind: IpAddrKind.V6,
    address: "::1".toString(),
}

Here, we've defined a struct IpAddr that has two fields: a kind field that is of type IpAddrKind (the enum we defined previously) and an address field of type String. We have two instances of this struct.

However, representing the same concept using just an enum is more concise: rather than an enum inside a struct, we can put data directly into each enum variant. This new definition of the IpAddr enum says that both V4 and V6 variants will have associated String values:

enum IpAddr {
    V4(String),
    V6(String),
}

let home = IpAddr.V4("127.0.0.1".toString())
let loopback = IpAddr.V6("::1".toString())

We attach data to each variant of the enum directly, so there is no need for an extra struct. Here, it's also easier to see another detail of how enums work: the name of each enum variant that we define also becomes a function that constructs an instance of the enum. That is, IpAddr.V4() is a function call that takes a String argument and returns an instance of the IpAddr type.

There's another advantage to using an enum rather than a struct: each variant can have different types and amounts of associated data. Version four IP addresses will always have four numeric components that will have values between 0 and 255. If we wanted to store V4 addresses as four UInt8 values but still express V6 addresses as one String value, we wouldn't be able to with a struct. Enums handle this case with ease:

enum IpAddr {
    V4(UInt8, UInt8, UInt8, UInt8),
    V6(String),
}

let home = IpAddr.V4(127, 0, 0, 1)
let loopback = IpAddr.V6("::1".toString())

We've shown several different ways to define data structures to store version four and version six IP addresses. However, as it turns out, wanting to store IP addresses and encode which kind they are is so common that the standard library has a definition we can use! Let's look at how the standard library defines IpAddr: it has the exact enum and variants that we've defined and used, but it embeds the address data inside the variants in the form of two different structs, which are defined differently for each variant.

Enums with Named Fields

Enum variants can also have named fields, similar to structs:

#[derive(Debug, Clone)]
public enum Message {
    Quit,
    Move { x: Int, y: Int },
    Write(String),
    ChangeColor(Int, Int, Int),
}

This enum has four variants with different types:

  • Quit has no data associated with it at all.
  • Move has named fields, like a struct.
  • Write includes a single String.
  • ChangeColor includes three Int values.

Defining an enum with variants such as the ones above is similar to defining different kinds of struct definitions, except the enum doesn't use the struct keyword and all the variants are grouped together under the Message type.

Creating instances of these variants:

let quit = Message.Quit
let moveMsg = Message.Move { x: 10, y: 20 }
let write = Message.Write("hello".toString())
let color = Message.ChangeColor(255, 128, 0)

Defining Methods on Enums

We're also able to define methods on enums using extension blocks. Here's a method named call that we could define on our Message enum:

extension Message {
    fn call() {
        match self {
            Message.Quit -> println!("Quit"),
            Message.Move { x, y } -> println!("Move to (\(x), \(y))"),
            Message.Write(text) -> println!("Write: \(text)"),
            Message.ChangeColor(r, g, b) -> println!("Color: (\(r), \(g), \(b))"),
        }
    }
}

let m = Message.Write("hello".toString())
m.call()

The body of the method uses self to get the value that we called the method on. In this example, we've created a variable m that has the value Message.Write("hello".toString()), and that is what self will be in the body of the call method when m.call() runs.

The Nullable Type: Using T? Instead of Option

Oxide provides a built-in way to express the concept of a value being present or absent using nullable types. Instead of writing Option<T> as you would in Rust, Oxide uses the more concise T? syntax. This is so common and useful that it's built into the language itself.

The nullable type encodes the very common scenario in which a value could be something or it could be nothing. For example, if you request the first item of a non-empty list, you would get a value. If you request the first item of an empty list, you would get nothing.

Expressing this concept in terms of the type system means the compiler can check whether you've handled all the cases you should be handling; this functionality can prevent bugs that are extremely common in other programming languages.

Here's how you use nullable types in Oxide:

let someNumber: Int? = Some(5)
let someString: String? = Some("a string".toString())

let absentNumber: Int? = null
let absentString: String? = null

The type of someNumber is Int?. The type of someString is String?. Because we've specified a type annotation, Oxide knows these are nullable types.

When we have a Some value, we know that a value is present and the value is held within the Some. When we have a null value, in some sense it means the same thing as null in other languages: we don't have a valid value.

So why is T? any better than having null? In short, because Int? and Int are different types, the compiler won't let us use an Int? value as if it were definitely an Int. For example, this code won't compile because it's trying to add an Int? to an Int:

let x: Int = 5
let y: Int? = Some(5)

let sum = x + y  // Error! Can't add Int and Int?

When we have a value of a type like Int in Oxide, the compiler will ensure we always have a valid value. We can proceed confidently without having to check for null before using that value. Only when we have a Int? do we have to worry about possibly not having a value, and the compiler will make sure we handle that case before using the value.

In other words, you have to convert a T? to a T before you can perform T operations with it. Generally, this helps catch one of the most common issues with null: assuming that something isn't null when it actually is.

Eliminating the risk of incorrectly assuming a not-null value helps you to be more confident in your code. In order to have a value that can possibly be null, you must explicitly opt in by making the type of that value T?. Then, when you use that value, you are required to explicitly handle the case when the value is null. Everywhere that a value has a type that isn't a T?, you can safely assume that the value isn't null.

So how do you get the T value out of a Some variant when you have a value of type T? so that you can use that value? The T? type has a large number of methods that are useful in a variety of situations; you can find them in the Rust documentation for Option<T>. Becoming familiar with the methods on Option<T> will be extremely useful in your journey with Oxide.

In general, in order to use a T? value, you want to have code that will handle each variant. You want some code that will run only when you have a Some(T) value, and this code is allowed to use the inner T. You want some other code to run only if you have a null value, and that code doesn't have a T value available. The match expression is a control flow construct that does just this when used with enums: it will run different code depending on which variant of the enum it has, and that code can use the data inside the matching variant.

The match Expression

Oxide has an extremely powerful control flow construct called match that allows you to compare a value against a series of patterns and then execute code based on which pattern matches. Patterns can be made up of literal values, variable names, wildcards, and many other things. The power of match comes from the expressiveness of the patterns and the fact that the compiler confirms that all possible cases are handled.

Think of a match expression as being like a coin-sorting machine: coins slide down a track with variously sized holes along it, and each coin falls through the first hole it encounters that it fits into. In the same way, values go through each pattern in a match, and at the first pattern the value "fits," the value falls into the associated code block to be used during execution.

Speaking of coins, let's use them as an example using match! We can write a function that takes an unknown US coin and, in a similar way as the counting machine, determines which coin it is and returns its value in cents:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn valueInCents(coin: Coin): Int {
    match coin {
        Coin.Penny -> 1,
        Coin.Nickel -> 5,
        Coin.Dime -> 10,
        Coin.Quarter -> 25,
    }
}

Let's break down the match in the valueInCents function. First we list the match keyword followed by an expression, which in this case is the value coin. This seems very similar to a conditional expression used with if, but there's a big difference: with if, the condition needs to evaluate to a Boolean value, but here it can be any type. The type of coin in this example is the Coin enum that we defined.

Next are the match arms. An arm has two parts: a pattern and some code. The first arm here has a pattern that is the value Coin.Penny and then the -> that separates the pattern and the code to run. The code in this case is just the value 1. Each arm is separated from the next with a comma.

When the match expression executes, it compares the resultant value against the pattern of each arm, in order. If a pattern matches the value, the code associated with that pattern is executed. If that pattern doesn't match the value, execution continues to the next arm, much as in a coin-sorting machine.

The code associated with each arm is an expression, and the resultant value of the expression in the matching arm is the value that gets returned for the entire match expression.

We don't typically use curly brackets if the match arm code is short, as it is in the previous example where each arm just returns a value. If you want to run multiple lines of code in a match arm, you must use curly brackets, and the comma following the arm is then optional:

fn valueInCents(coin: Coin): Int {
    match coin {
        Coin.Penny -> {
            println!("Lucky penny!")
            1
        },
        Coin.Nickel -> 5,
        Coin.Dime -> 10,
        Coin.Quarter -> 25,
    }
}

Patterns That Bind to Values

Another useful feature of match arms is that they can bind to the parts of the values that match the pattern. This is how we can extract values out of enum variants.

As an example, let's change one of our enum variants to hold data inside it. From 1999 through 2008, the United States minted quarters with different designs for each of the 50 states on one side. No other coins got state designs, so only quarters have this extra value. We can add this information to our enum by changing the Quarter variant to include a UsState value stored inside it:

#[derive(Debug, Clone, Copy)]
enum UsState {
    Alabama,
    Alaska,
    Arizona,
    Arkansas,
    California,
    // ... etc
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

Let's imagine that a friend is trying to collect all 50 state quarters. While we sort our loose change by coin type, we'll also call out the name of the state associated with each quarter so that if it's one our friend doesn't have, they can add it to their collection.

In the match expression for this code, we add a variable called state to the pattern that matches values of the variant Coin.Quarter. When a Coin.Quarter matches, the state variable will bind to the value of that quarter's state. Then we can use state in the code for that arm:

fn valueInCents(coin: Coin): Int {
    match coin {
        Coin.Penny -> 1,
        Coin.Nickel -> 5,
        Coin.Dime -> 10,
        Coin.Quarter(state) -> {
            println!("State quarter from \(state:?)")
            25
        },
    }
}

If we were to call valueInCents(Coin.Quarter(UsState.Alaska)), coin would be Coin.Quarter(UsState.Alaska). When we compare that value with each of the match arms, none of them match until we reach Coin.Quarter(state). At that point, the binding for state will be the value UsState.Alaska. We can then use that binding in the println! expression, thus getting the inner state value out of the Coin enum variant for Quarter.

Matching with Nullable Types

In the previous section, we wanted to get the inner T value out of the Some case when using T?; we can also handle T? using match, as we did with the Coin enum! Instead of comparing coins, we'll compare the variants of T?, but the way the match expression works remains the same.

Let's say we want to write a function that takes a Int? and, if there's a value inside, adds 1 to that value. If there isn't a value inside, the function should return the null value and not attempt to perform any operations.

This function is very easy to write, thanks to match:

fn plusOne(x: Int?): Int? {
    match x {
        null -> null,
        Some(i) -> Some(i + 1),
    }
}

let five: Int? = Some(5)
let six = plusOne(five)
let none = plusOne(null)

Let's examine the first execution of plusOne in more detail. When we call plusOne(five), the variable x in the body of plusOne will have the value Some(5). We then compare that against each match arm:

null -> null,

The Some(5) value doesn't match the pattern null, so we continue to the next arm:

Some(i) -> Some(i + 1),

Does Some(5) match Some(i)? It does! We have the same variant. The i binds to the value contained in Some, so i takes the value 5. The code in the match arm is then executed, so we add 1 to the value of i and create a new Some value with our total 6 inside.

Now let's consider the second call of plusOne, where x is null. We enter the match and compare to the first arm:

null -> null,

It matches! There's no value to add to, so the program stops and returns the null value on the right side of ->. Because the first arm matched, no other arms are compared.

Combining match and enums is useful in many situations. You'll see this pattern a lot in Oxide code: match against an enum, bind a variable to the data inside, and then execute code based on it. It's a bit tricky at first, but once you get used to it, you'll wish you had it in all languages. It's consistently a user favorite.

Matches Are Exhaustive

There's one other aspect of match we need to discuss: the arms' patterns must cover all possibilities. Consider this version of our plusOne function, which has a bug and won't compile:

fn plusOne(x: Int?): Int? {
    match x {
        Some(i) -> Some(i + 1),
    }
}

We didn't handle the null case, so this code will cause a bug. Luckily, it's a bug the compiler knows how to catch. If we try to compile this code, we'll get an error indicating that we haven't handled all possible cases.

Matches in Oxide are exhaustive: we must exhaust every last possibility in order for the code to be valid. Especially in the case of T?, when the compiler ensures we explicitly handle the null case, it protects us from assuming we have a value when we might have null, thus making the mistake discussed earlier impossible.

Catch-all Patterns with else

Using enums, we can also take special actions for a few particular values, but for all other values take one default action. Let's look at an example where we want to implement game logic for a dice roll:

fn handleDiceRoll(diceRoll: Int) {
    match diceRoll {
        3 -> addFancyHat(),
        7 -> removeFancyHat(),
        else -> reroll(),
    }
}

fn addFancyHat() {}
fn removeFancyHat() {}
fn reroll() {}

For the first two arms, the patterns are the literal values 3 and 7. For the last arm that covers every other possible value, the pattern is the keyword else. This is Oxide's catch-all pattern (equivalent to _ in Rust). The code that runs for the else arm calls the reroll function.

This code compiles, even though we haven't listed all the possible values an Int can have, because the else pattern will match all values not specifically listed. The catch-all pattern meets the requirement that match must be exhaustive. Note that we have to put the catch-all arm last because the patterns are evaluated in order. If we put the catch-all arm earlier, the other arms would never run.

Catch-all with a Bound Variable

Sometimes you want to use the matched value in your catch-all arm. You can bind the value to a variable by using a name other than else:

fn handleDiceRoll(diceRoll: Int) {
    match diceRoll {
        3 -> addFancyHat(),
        7 -> removeFancyHat(),
        other -> movePlayer(other),
    }
}

fn movePlayer(spaces: Int) {
    println!("Moving \(spaces) spaces")
}

Here, we're using the variable other to capture all values that don't match 3 or 7, and we use that value in the arm's code.

Ignoring Values with else

When you want a catch-all but don't need the value, use else:

fn handleDiceRoll(diceRoll: Int) {
    match diceRoll {
        3 -> addFancyHat(),
        7 -> removeFancyHat(),
        else -> {},  // Do nothing
    }
}

Here, we're telling the compiler explicitly that we aren't going to use any other value, by using else with an empty block.

Matching Multiple Patterns

You can match multiple patterns in a single arm using the | operator:

fn describeLetter(letter: char): String {
    match letter {
        'a' | 'e' | 'i' | 'o' | 'u' -> "vowel".toString(),
        'a'..='z' -> "consonant".toString(),
        else -> "not a lowercase letter".toString(),
    }
}

Matching with Guards

Sometimes pattern matching alone isn't expressive enough. Match guards allow you to add an additional condition to a pattern:

fn checkNumber(x: Int?) {
    match x {
        Some(n) if n < 0 -> println!("Negative: \(n)"),
        Some(n) if n > 0 -> println!("Positive: \(n)"),
        Some(n) -> println!("Zero"),
        null -> println!("No value"),
    }
}

The if n < 0 part is called a match guard. It's an additional condition on a match arm that must also be true for that arm to be chosen. Match guards are useful for expressing more complex ideas than a pattern alone allows.

Destructuring Enums with Named Fields

When matching enums with named fields, you can destructure them using struct-like syntax:

enum Message {
    Quit,
    Move { x: Int, y: Int },
    Write(String),
    ChangeColor(Int, Int, Int),
}

fn processMessage(msg: Message) {
    match msg {
        Message.Quit -> {
            println!("Quit received")
        },
        Message.Move { x, y } -> {
            println!("Moving to x=\(x), y=\(y)")
        },
        Message.Write(text) -> {
            println!("Text message: \(text)")
        },
        Message.ChangeColor(r, g, b) -> {
            println!("Changing color to RGB(\(r), \(g), \(b))")
        },
    }
}

You can also rename the bound variables:

match msg {
    Message.Move { x: horizontal, y: vertical } -> {
        println!("Moving horizontally: \(horizontal), vertically: \(vertical)")
    },
    else -> {},
}

Nested Patterns

Patterns can be nested to match complex data structures:

enum Color {
    Rgb(Int, Int, Int),
    Hsv(Int, Int, Int),
}

enum Message {
    Quit,
    ChangeColor(Color),
}

fn processMessage(msg: Message) {
    match msg {
        Message.ChangeColor(Color.Rgb(r, g, b)) -> {
            println!("RGB: \(r), \(g), \(b)")
        },
        Message.ChangeColor(Color.Hsv(h, s, v)) -> {
            println!("HSV: \(h), \(s), \(v)")
        },
        Message.Quit -> println!("Quit"),
    }
}

The match expression is one of Oxide's most powerful features. It's used extensively throughout Oxide code for control flow, error handling, and working with optional values. As you become more familiar with pattern matching, you'll find yourself reaching for match whenever you need to handle multiple cases based on the structure of your data.

if let and while let

The if let syntax lets you combine if and let into a less verbose way to handle values that match one pattern while ignoring the rest. Consider the following program that matches on a Int? value in the config_max variable but only wants to execute code if the value is a Some variant:

let configMax: Int? = Some(3)

match configMax {
    Some(max) -> println!("The maximum is configured to be \(max)"),
    null -> {},
}

If the value is Some, we print out the value in the Some variant by binding the value to the variable max in the pattern. We don't want to do anything with the null value. To satisfy the match expression, we have to add null -> {} after processing just one variant, which is annoying boilerplate code to add.

Instead, we could write this in a shorter way using if let. The following code behaves the same as the match above:

let configMax: Int? = Some(3)

if let Some(max) = configMax {
    println!("The maximum is configured to be \(max)")
}

The syntax if let takes a pattern and an expression separated by an equal sign. It works the same way as a match, where the expression is given to the match and the pattern is its first arm. In this case, the pattern is Some(max), and the max binds to the value inside the Some. We can then use max in the body of the if let block in the same way we used max in the corresponding match arm. The code in the if let block isn't run if the value doesn't match the pattern.

Using if let means less typing, less indentation, and less boilerplate code. However, you lose the exhaustive checking that match enforces. Choosing between match and if let depends on what you're doing in your particular situation and whether gaining conciseness is an appropriate trade-off for losing exhaustive checking.

In other words, you can think of if let as syntax sugar for a match that runs code when the value matches one pattern and then ignores all other values.

Auto-Unwrapping for Nullable Types

Oxide provides a powerful feature for working with nullable types: automatic unwrapping in if let expressions. When the right-hand side of an if let is a nullable type (T?), you don't need to explicitly write Some(...) in your pattern. Oxide will automatically unwrap the value for you.

Consider this code:

let maybeUser: User? = findUser(id)

// Traditional way with explicit Some
if let Some(user) = maybeUser {
    println!("Hello, \(user.name)")
}

// Oxide auto-unwrap - simpler and more readable
if let user = maybeUser {
    println!("Hello, \(user.name)")
}

Both forms are equivalent and compile to the same code. The auto-unwrap syntax (if let user = maybeUser) is more concise and reads naturally: "if there's a user, use it."

This is particularly useful when working with function return values:

fn findUserById(id: Int): User? {
    // ... lookup logic
    null
}

fn processUser(id: Int) {
    if let user = findUserById(id) {
        println!("Found user: \(user.name)")
        sendWelcomeEmail(user)
    }
}

The auto-unwrap also works with chained method calls:

if let email = user.profile?.email {
    sendNotification(email)
}

Using else with if let

We can include an else with an if let. The block of code that goes with the else is the same as the block of code that would go with the null case in the match expression:

let coin = Coin.Quarter(UsState.Alaska)

if let Coin.Quarter(state) = coin {
    println!("State quarter from \(state:?)")
} else {
    println!("Not a quarter")
}

This is equivalent to:

match coin {
    Coin.Quarter(state) -> println!("State quarter from \(state:?)"),
    else -> println!("Not a quarter"),
}

Combining if let with else if

You can chain if let expressions with else if and else if let:

fn describeValue(value: Int?) {
    if let n = value {
        if n > 100 {
            println!("Large number: \(n)")
        } else if n > 0 {
            println!("Positive number: \(n)")
        } else if n < 0 {
            println!("Negative number: \(n)")
        } else {
            println!("Zero")
        }
    } else {
        println!("No value")
    }
}

Or matching against multiple nullable types:

fn processCoordinates(x: Int?, y: Int?) {
    if let xVal = x {
        if let yVal = y {
            println!("Point: (\(xVal), \(yVal))")
        } else {
            println!("Only X coordinate: \(xVal)")
        }
    } else if let yVal = y {
        println!("Only Y coordinate: \(yVal)")
    } else {
        println!("No coordinates")
    }
}

if let with Conditions

You can combine if let with additional conditions using &&:

let user: User? = findUser(id)

if let user = user && user.isActive {
    greet(user)
}

This is equivalent to:

if let Some(user) = user {
    if user.isActive {
        greet(user)
    }
}

The combined syntax is more concise and clearly expresses the intent: "if we have a user AND they are active, greet them."

while let

Similar to if let, Oxide provides while let for looping as long as a pattern continues to match. This is particularly useful when working with iterators or any sequence that returns nullable values.

var stack: Vec<Int> = vec![1, 2, 3]

while let Some(top) = stack.pop() {
    println!("Popped: \(top)")
}

This code pops values from the stack and prints them until the stack is empty. The pop method returns Int?, returning Some(value) when there's a value and null when the stack is empty.

With auto-unwrap syntax:

var stack: Vec<Int> = vec![1, 2, 3]

while let top = stack.pop() {
    println!("Popped: \(top)")
}

Practical Examples

Working with Configuration

struct Config {
    databaseUrl: String?,
    maxConnections: Int?,
    timeout: Int?,
}

fn loadConfig(): Config {
    Config {
        databaseUrl: Some("postgres://localhost/db".toString()),
        maxConnections: Some(10),
        timeout: null,
    }
}

fn initializeDatabase() {
    let config = loadConfig()

    if let url = config.databaseUrl {
        println!("Connecting to: \(url)")

        let connections = config.maxConnections ?? 5
        println!("Max connections: \(connections)")

        if let timeout = config.timeout {
            println!("Timeout: \(timeout)s")
        } else {
            println!("No timeout configured, using default")
        }
    } else {
        println!("No database URL configured!")
    }
}
struct SearchResult {
    title: String,
    url: String,
    snippet: String?,
}

fn search(query: str): SearchResult? {
    // ... search logic
    Some(SearchResult {
        title: "Example".toString(),
        url: "https://example.com".toString(),
        snippet: Some("An example result".toString()),
    })
}

fn displaySearchResult(query: str) {
    if let result = search(query) {
        println!("Found: \(result.title)")
        println!("URL: \(result.url)")

        if let snippet = result.snippet {
            println!("Snippet: \(snippet)")
        }
    } else {
        println!("No results found for '\(query)'")
    }
}

Iterating with while let

struct Node {
    value: Int,
    next: Box<Node>?,
}

fn sumLinkedList(head: Box<Node>?): Int {
    var sum = 0
    var current = head

    while let node = current {
        sum += node.value
        current = node.next.clone()
    }

    sum
}

Handling User Input

fn readValidNumber(): Int? {
    // Simulating user input
    Some(42)
}

fn processInput() {
    while let number = readValidNumber() {
        if number == 0 {
            println!("Exiting...")
            break
        }
        println!("Processing: \(number)")
    }
}

When to Use if let vs match

Use if let when:

  • You only care about one specific pattern
  • You want concise code for simple cases
  • The "else" case is trivial or can be ignored

Use match when:

  • You need to handle multiple patterns explicitly
  • You want the compiler to ensure you've handled all cases
  • The logic for different patterns is complex
// Good use of if let - only care about Some case
if let user = findUser(id) {
    greet(user)
}

// Good use of match - need to handle all cases explicitly
match command {
    Command.Start -> startServer(),
    Command.Stop -> stopServer(),
    Command.Restart -> {
        stopServer()
        startServer()
    },
    Command.Status -> printStatus(),
}

The if let and while let constructs provide a more ergonomic way to work with nullable types and pattern matching when you don't need the full power of match. Combined with Oxide's auto-unwrap feature for nullable types, they make working with optional values concise and readable.

Appendix A: Keywords

The Oxide programming language uses a combination of Oxide-specific keywords and keywords shared with Rust. This appendix lists all keywords and provides information about their usage.

Oxide-Specific Keywords

These keywords are unique to Oxide or have different semantics from their Rust counterparts:

KeywordCategoryRust EquivalentDescription
varBindinglet mutMutable variable binding. Declares a variable that can be reassigned.
publicVisibilitypubPublic visibility modifier. Makes items accessible from outside the current module.
importModuleuseModule imports. Brings items from other modules into scope.
externalModule(with module) mod X;External module declaration. Declares a module whose body is in a separate file.
moduleModulemodModule definition. Defines a module either inline or as an external reference.
extensionImplementationimplType extension/implementation. Adds methods to a type or implements a trait for a type.
guardControl Flow(no direct equivalent)Early return guard. Ensures a condition holds or executes a diverging else block.
mutatingMethod Modifier&mut selfMutable method modifier. Indicates the method borrows self mutably.
consumingMethod ModifierselfConsuming method modifier. Indicates the method takes ownership of self.
staticMethod Modifier(no self)Static method modifier. Indicates the method has no self parameter.
nullLiteralNoneNull literal. Represents the absence of a value in nullable types (T?).

Detailed Usage

var - Mutable Variable Binding

var counter = 0
counter += 1  // Allowed because counter is mutable

var items: Vec<String> = vec![]
items.push("hello")

See Chapter 3.1: Variables and Mutability for more details.

public - Public Visibility

public fn createUser(name: str): User {
    User { name: name.toString() }
}

public struct Config {
    path: PathBuf,
    verbose: Bool,
}

import - Module Imports

import std.collections.HashMap
import std.fs
import anyhow.{ Result, Error }
import crate.engine.{ Action, scan }

external module - External Module Declaration

// Declares a module in a separate file (engine.ox or engine/mod.ox)
external module engine
external module config

module - Inline Module Definition

module tests {
    import super.*

    #[test]
    fn testSomething() {
        assert!(true)
    }
}

extension - Type Extension

extension Config {
    public fn validate(): Bool {
        self.path.exists()
    }
}

// Trait implementation
import std.fmt.{ Display, Formatter, Result }

extension Config: Display {
    fn fmt(f: &mut Formatter): Result {
        write!(f, "Config: \(self.path)")
    }
}

See Chapter 5.3: Method Syntax for more details.

guard - Early Return Guard

guard condition else {
    return  // Must diverge!
}

guard let user = findUser(id) else {
    return Err(anyhow!("User not found"))
}
// user is now available

See Chapter 3.5: Control Flow for more details.

mutating, consuming, static - Method Modifiers

extension Config {
    // Default: &self (immutable borrow)
    fn validate(): Bool { self.path.exists() }

    // mutating: &mut self
    mutating fn setPath(path: PathBuf) { self.path = path }

    // consuming: self (takes ownership)
    consuming fn destroy() { drop(self) }

    // static: no self parameter
    static fn load(): Config? { Self.fromFile("config.toml") }
}

null - Null Literal

let name: String? = null
let value: Int? = null

match optional {
    Some(x) -> process(x),
    null -> handleNull(),
}

See Chapter 6.3: Concise Control Flow with if let for more details.

Shared Keywords

These keywords are shared with Rust and have the same or very similar semantics:

KeywordCategoryDescription
letBindingImmutable variable binding
fnFunctionFunction definition
structTypeStructure type definition
enumTypeEnumeration type definition
traitTypeTrait definition
typeTypeType alias definition
constBindingCompile-time constant
asyncAsyncAsynchronous function modifier
awaitAsyncAwait a future (prefix in Oxide, postfix in Rust)
ifControl FlowConditional expression
elseControl FlowAlternative branch / match wildcard
matchControl FlowPattern matching expression
forControl FlowFor loop
whileControl FlowWhile loop
loopControl FlowInfinite loop
breakControl FlowBreak out of a loop
continueControl FlowContinue to next loop iteration
returnControl FlowReturn from a function
selfReferenceReference to the current instance
SelfTypeType alias for the implementing type
superModuleParent module reference
crateModuleCrate root reference
whereGenericGeneric type constraints
asConversionType casting
inControl FlowUsed in for loops
unsafeSafetyUnsafe code block or function
dynTypeDynamic trait object
moveClosureMove semantics for closures
refPatternReference pattern binding
mutModifierMutable reference or pattern binding
trueLiteralBoolean true
falseLiteralBoolean false
implImplementationRust's implementation keyword (use extension in Oxide)
pubVisibilityRust's public visibility (use public in Oxide)
useModuleRust's import keyword (use import in Oxide)
modModuleRust's module keyword (use module in Oxide)
externFFIExternal function declaration

Special Note: await

While await is shared with Rust, its position differs:

// Oxide: prefix await
let response = await client.get(url).send()?
let data = await response.json()?
#![allow(unused)]
fn main() {
// Rust: postfix .await
let response = client.get(url).send().await?;
let data = response.json().await?;
}

Oxide uses prefix await because it reads more naturally from left to right and matches the syntax of Swift, Kotlin, JavaScript, and Python.

Special Note: else in Match

In Oxide, else serves double duty as both the alternative branch in if expressions and as the wildcard pattern in match expressions:

match command {
    Command.Run -> executeRun(),
    Command.Build -> executeBuild(),
    else -> showHelp(),  // Wildcard - equivalent to Rust's _
}

Reserved Keywords

The following keywords are reserved for potential future use:

KeywordNotes
abstractReserved
becomeReserved
boxReserved
doReserved
finalReserved
macroReserved
overrideReserved
privReserved
tryReserved
typeofReserved
unsizedReserved
virtualReserved
yieldReserved

These keywords cannot be used as identifiers even though they do not currently have a defined meaning in Oxide.

Raw Identifiers

If you need to use a keyword as an identifier (for example, when interfacing with Rust code that uses reserved keywords as names), you can use the raw identifier syntax:

let r#type = "keyword"  // Uses 'type' as an identifier

Quick Reference

Keyword Mapping: Oxide to Rust

OxideRust
var x = ...let mut x = ...
public fnpub fn
import a.buse a::b;
Type.method()Type::method()
external module xmod x;
module x { }mod x { }
extension T { }impl T { }
extension T: Trait { }impl Trait for T { }
guard c else { }if !c { } or let-else
mutating fnfn(&mut self)
consuming fnfn(self)
static fnfn() (no self)
nullNone
await exprexpr.await
match { else -> }match { _ => }

IMPORTANT: The Rust syntax in the right column is NOT valid in Oxide. These are grammar changes, not style preferences. For example, :: does not exist in Oxide - using it will cause a syntax error. Oxide uses . as its only path separator.

Categories at a Glance

Bindings: let, var, const

Functions: fn, async, return

Types: struct, enum, trait, type, Self

Methods: extension, mutating, consuming, static, self

Control Flow: if, else, match, for, while, loop, break, continue, guard

Modules: import, module, external, public, super, crate

Async: async, await

Safety: unsafe

Literals: true, false, null

Modifiers: mut, ref, move, dyn, where, as, in

Appendix B: Operators and Symbols

This appendix provides a quick reference for all operators in Oxide, including their precedence and Oxide-specific operators.

Operator Precedence Table

Operators are listed from highest precedence (evaluated first) to lowest (evaluated last).

PrecedenceOperatorsDescriptionAssociativity
1 (highest).Path/field accessLeft
2() [] ? !!Call, index, try, force unwrapLeft
3await ! - * & &mutPrefix operatorsRight
4asType castLeft
5* / %Multiplication, division, remainderLeft
6+ -Addition, subtractionLeft
7<< >>Bit shiftsLeft
8&Bitwise ANDLeft
9^Bitwise XORLeft
10|Bitwise ORLeft
11== != < > <= >=ComparisonsRequires parentheses
12&&Logical ANDLeft
13||Logical ORLeft
14??Null coalescingLeft
15.. ..=Range operatorsRequires parentheses
16= += -= *= /= %= &= |= ^= <<= >>=AssignmentRight
17 (lowest)return break continueControl flowRight

Operators by Category

Arithmetic Operators

OperatorDescriptionExample
+Addition5 + 3
-Subtraction10 - 4
*Multiplication6 * 7
/Division20 / 4
%Remainder17 % 5
-Negation (unary)-42

Comparison Operators

OperatorDescriptionExample
==Equal tox == 5
!=Not equal tox != y
<Less thanx < y
>Greater thanx > y
<=Less than or equalx <= 5
>=Greater than or equalx >= 10

Logical Operators

OperatorDescriptionExample
&&Logical AND (short-circuit)a && b
||Logical OR (short-circuit)a || b
!Logical NOT!a

Bitwise Operators

OperatorDescriptionExample
&Bitwise ANDa & b
|Bitwise ORa | b
^Bitwise XORa ^ b
<<Left shifta << 2
>>Right shifta >> 2

Reference Operators

OperatorDescriptionExample
&Create shared reference&value
&mutCreate mutable reference&mut value
*Dereference*ptr

Assignment Operators

OperatorEquivalent
=Assign
+=x = x + y
-=x = x - y
*=x = x * y
/=x = x / y
%=x = x % y
&= |= ^= <<= >>=Bitwise compound assignment

Range Operators

OperatorDescriptionExample
..Exclusive range0..5 (0 to 4)
..=Inclusive range1..=5 (1 to 5)

Oxide-Specific Operators

Null Coalescing (??)

Provides a default value when the left-hand side is null.

let name = userName ?? "Anonymous"
let config = Config.load() ?? Config.default()

CRITICAL: ?? works with Option<T> ONLY, NOT Result<T, E>

// This will NOT compile:
let value: Result<Int, Error> = Err(someError)
let result = value ?? 0  // ERROR: ?? only works with Option<T>

Why? Result<T, E> contains typed error information that should not be silently discarded. Using ?? would hide important error details.

For Result, use explicit methods:

let value = riskyOperation().unwrapOr(default)
let value = riskyOperation().unwrapOrElse { err -> handleError(err) }

Rust equivalent: .unwrap_or() or .unwrap_or_else()

Force Unwrap (!!)

Forcefully unwraps an optional value. Panics if null.

let user = findUser(id)!!  // Panics if null

Rust equivalent: .unwrap()

Warning: Use sparingly. Prefer if let, pattern matching, or ?? when possible.

Prefix Await

Oxide uses prefix await (unlike Rust's postfix .await).

let data = await fetchData()
let response = await client.get(url).send()?

Precedence: await binds tighter than ?, so await expr? means (await expr)?

Rust equivalent: expr.await

Try Operator (?)

Unchanged from Rust. Propagates errors in Result or Option returning functions.

fn readConfig(): Result<Config, Error> {
    let content = std.fs.readToString("config.toml")?
    Ok(parseConfig(content)?)
}

Symbols and Delimiters

SymbolUsage
{ }Blocks, closures, struct/enum bodies
( )Grouping, function parameters, tuples
[ ]Array literals, indexing
< >Generic type parameters
#[ ]Attributes
.Field access, method call, path separator
:Type annotation, return type
->Closure params, match arms
,List separator
;Statement terminator (optional)

Summary: Differences from Rust

OxideRustDescription
??.unwrap_or()Null coalescing (Option only)
!!.unwrap()Force unwrap
await exprexpr.awaitPrefix await
.::Path separator
:->Return type annotation
->=>Match arm, closure params

IMPORTANT: The Rust operators in the right column (::, -> for return types, => for match arms) are not valid Oxide syntax. These are grammar changes, not style preferences. Using :: in Oxide code will result in a syntax error. Oxide uses . as its only path separator.