Reading and Writing Files

In Oxide (like Rust), working with the file system requires the std.fs module. This section covers reading files, a fundamental operation for our grep-like program.

Reading a File

Let's start with the most basic operation: reading a file's contents into a string.

Create a sample file named poem.txt:

I'm nobody! Who are you?
Are you nobody too?
Then there's a pair of us!
Don't tell! they'd banish us, you know.

How dreary to be somebody!
How public, like a Frog
To tell one's name the livelong June
To an admiring Bog!

Now, let's write code to read this file. Update src/main.ox:

import std.fs

fn main() {
    let filename = "poem.txt"
    let contents = std.fs.readToString(filename)

    println!("File contents:\n\(contents)")
}

Run the program:

cargo run

Output:

File contents:
I'm nobody! Who are you?
Are you nobody too?
...

Handling Errors with Result

What happens if the file doesn't exist? The readToString function returns a Result<String, Error>, which means it can fail. We need to handle this using the ? operator.

import std.fs

fn main() {
    let filename = "poem.txt"
    let contents = std.fs.readToString(filename)?

    println!("File contents:\n\(contents)")
}

But wait—main doesn't return Result, it returns nothing. We need to change that:

import std.fs

fn main(): Result<(), Box<dyn Error>> {
    let filename = "poem.txt"
    let contents = std.fs.readToString(filename)?

    println!("File contents:\n\(contents)")
    Ok(())
}

Now if the file doesn't exist, the error message will be printed by Rust's error handling system:

Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Using Variables from Arguments

In a real program, the filename shouldn't be hardcoded. Let's accept it as a command-line argument.

First, let's use std.env to get command-line arguments:

import std.fs
import std.env
import std.error.Error

fn main(): Result<(), Box<dyn Error>> {
    let args = std.env.args().collect<Vec<String>>()

    if args.len() < 2 {
        eprintln!("usage: oxgrep <query> <filename>")
        std.process.exit(1)
    }

    let query = args[1].clone()
    let filename = args[2].clone()

    let contents = std.fs.readToString(&filename)?

    println!("In file \(filename)")
    println!("Contents: \(contents)")

    Ok(())
}

Now you can run:

cargo run -- to poem.txt

Output:

In file poem.txt
Contents:
I'm nobody! Who are you?
...

Processing File Contents

Now that we can read files, let's search for matching lines. We'll create a function to do the heavy lifting:

import std.fs
import std.env
import std.error.Error

fn search(query: &str, contents: &str): Vec<String> {
    var results = Vec.new()

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line.toString())
        }
    }

    results
}

fn main(): Result<(), Box<dyn Error>> {
    let args = std.env.args().collect<Vec<String>>()

    if args.len() < 3 {
        eprintln!("usage: oxgrep <query> <filename>")
        std.process.exit(1)
    }

    let query = args[1].clone()
    let filename = args[2].clone()

    let contents = std.fs.readToString(&filename)?

    let results = search(&query, &contents)

    for line in results {
        println!("{}", line)
    }

    Ok(())
}

Let's test it:

cargo run -- is poem.txt

Output:

I'm nobody! Who are you?
Are you nobody too?
Then there's a pair of us!
How public, like a Frog

Perfect! But this approach is inefficient for large files because it stores all matching lines in memory. For a real grep tool, we'd print lines as we find them. However, for learning purposes, this demonstrates the concept clearly.

Improving Efficiency

For large files, a better approach is to print directly without storing results:

import std.fs
import std.env
import std.error.Error

fn main(): Result<(), Box<dyn Error>> {
    let args = std.env.args().collect<Vec<String>>()

    if args.len() < 3 {
        eprintln!("usage: oxgrep <query> <filename>")
        std.process.exit(1)
    }

    let query = args[1].clone()
    let filename = args[2].clone()

    let contents = std.fs.readToString(&filename)?

    for line in contents.lines() {
        if line.contains(&query) {
            println!("{}", line)
        }
    }

    Ok(())
}

This version is simpler and more memory-efficient—perfect for real applications.

Writing Files

While our grep tool only reads files, it's useful to know how to write files. Use std.fs.write:

import std.fs

fn main(): Result<(), Box<dyn Error>> {
    let content = "Hello, World!"
    std.fs.write("output.txt", content)?

    println!("File written successfully")
    Ok(())
}

For more complex file operations (appending, seeking), use OpenOptions:

import std.fs

fn main(): Result<(), Box<dyn Error>> {
    // Append to a file
    var file = std.fs.OpenOptions.new()
        .append(true)
        .open("log.txt")?

    std.io.Write.writeAll(&mut file, b"Log entry\n")?

    Ok(())
}

Summary

File I/O in Oxide follows Rust's patterns:

  • std.fs.readToString - Read an entire file into a String
  • std.fs.write - Write content to a file
  • std.fs.OpenOptions - For more control over how files are opened
  • Result-based errors - Always handle potential failures with ? or match
  • Iterating over lines - Use .lines() to process files line-by-line

Next, we'll make our program more user-friendly by handling command-line arguments better.