Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 05 / Text Editor GUI in Rust / ~25 minutes

Open Any File

Three stages — and the stage in the middle is a bug you already shipped.

Your editor opens exactly one file, whose name is baked into the binary. That's the last thing standing between it and being a tool someone could actually use. Fixing it takes one line.

The interesting part is what that line exposes. Once the editor can be pointed at any path, it can be pointed at a path that doesn't exist yet — and it turns out you've been handling that case badly since lesson 2, in a way that would destroy the file.

"Missing" and "broken" are not the same answer.
A file that isn't there yet is a new file. Treating it as an error is what puts an error message where your document should be.

Where you're starting

Lesson 4's 89 lines, with the Ln/Col readout. Check againstlesson 4's checkpoint.

adding   removing   existing code, shown only for context

Take the path from the command line

cargo run -- somefile.rs opens that file.

Three edits, all small.

editor/src/main.rstop of the file

use eframe::egui;
use std::env;
use std::fs;
use std::io;

Alphabetical, and rustfmt will sort a contiguous block of use lines for you. Keeping them ordered is the sort of small consistency a reviewer notices without being able to say why the file feels tidy.

Second — read the argument at the top of main:

editor/src/main.rstop of main

fn main() -> eframe::Result {
    let path = env::args()
        .nth(1)
        .unwrap_or_else(|| "scratch.md".to_owned());
    let options = eframe::NativeOptions {

Three things in three lines:

  • env::args() is an iterator over the command-line arguments — the same kind of thing as self.text.chars() in lesson 4.
  • .nth(1) takes the second one, because argument zero is the program's own name. It returns Option<String>: there might not be a second argument, and lesson 4'sOption is how Rust says so.
  • .unwrap_or_else(…) is the Option equivalent of "or else use this". Some(p) gives you p; None runs the closure and uses that instead.
Not the unwrap you were warned aboutunwrap_or_else shares a prefix with unwrap() and nothing else.unwrap() crashes when there's nothing there. unwrap_or_else is the opposite — it existsbecause there might be nothing there, and says what to do about it. The family is worth knowing: unwrap_or(v) for a ready-made value,unwrap_or_else(f) when the fallback costs something to build,unwrap_or_default() for the type's default.

Third — use it instead of the literal:

editor/src/main.rsat the bottom of main

        Box::new(|_cc| Ok(Box::new(Editor::open("scratch.md")))),
        Box::new(|_cc| Ok(Box::new(Editor::open(&path)))),

cargo run -- src/main.rs
→ your editor, showing its own source again — by request this time
cargo run
→ no argument, so scratch.md as before

The -- separates cargo's arguments from your program's. Without it, cargo would try to interpret src/main.rs itself.

It can open its own source again — be carefulCtrl+S still writes to whatever is open. Pointing it at src/main.rs is fine for looking; just don't type in the window and save unless you meant to.

Open a file that doesn't exist

Find the bug. No code change — just look at what happens.

Now that any path works, try a path that isn't there yet:

cargo run -- notes.md
→ the window opens containing:

could not open notes.md

No such file or directory (os error 2)

Before reading on: that text is sitting in the editing buffer. What happens the moment you press Ctrl+S?

It writes the error message to notes.md as the file's contents. You asked to start a new document and the editor invented a file containing a complaint about that document not existing.

Go on — press Ctrl+S and then look:

cat editor/notes.md
→ could not open notes.md

→ No such file or directory (os error 2)

Delete that file before you go onYour editor has just created editor/notes.md containing the error message. Stage 3 opens the same path again and expects it to be missing — leave the file there and you'll get its contents instead of the empty new document the stage promises, and the fix will look like it didn't work.

Run: rm editor/notes.md

This has been true sincelesson 2, stage 2, where theErr arm put the error message into text. At the time that was the right call: it was the smallest thing that kept the window open, and it demonstrated that failure could be data rather than a crash. But text is thedocument. Anything you put there is something the user might save.

The actual mistake, in one sentenceTwo different situations — "this file is broken" and "this file is new" — were being given the same answer, and the answer was wrong for both.

Tell the two apart

A missing file opens as an empty new document. A broken one says so, without polluting the buffer.

One edit, replacing the body of open:

editor/src/main.rsin Editor::open

    /// Open a file into a new editor. A failure becomes the buffer contents,
    /// so the window always opens.
    /// Open a file into a new editor. A file that doesn't exist yet is not an
    /// error — it's a new file, and starts empty.
    fn open(path: &str) -> Self {
        let text = match fs::read_to_string(path) {
            Ok(contents) => contents,
            Err(error) => format!("could not open {path}\n\n{error}"),
        };
        let (text, status) = match fs::read_to_string(path) {
            Ok(contents) => (contents, String::new()),
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                (String::new(), format!("new file: {path}"))
            }
            Err(error) => (String::new(), format!("could not open {path}: {error}")),
        };
        Self {
            path: path.to_owned(),
            text,
            status: String::new(),
            status,
        }
    }

Three things worth naming here.

The match guard. Err(error) if error.kind() == … — theif after a pattern is a guard. The arm matches only when the pattern fits and the condition holds. Arms are tried top to bottom, so the specific NotFound case gets first refusal and the bare Err below catches everything else: permissions, a directory where a file should be, invalid UTF-8.

ErrorKind. An io::Error isn't just a message; .kind() returns an enum you can match on. That's what lets you make a decision about why something failed rather than pattern-matching on English text, which breaks the moment someone runs your program in another locale.

The tuple. The match now produces two values at once and they're destructured on the way out — the sametuple destructuring asline_col, doing real work: each arm decides both what the buffer holds and what the user is told, and the compiler checks every arm answers both.

Note what the buffer holds in every arm now: file contents, or nothing at all. Never a message. That's the invariant worth remembering —the document is never a place to put diagnostics, because the document is the thing you save.

cargo run -- notes.md
→ an empty window, status: new file: notes.md
→ type something, Ctrl+S, and cat editor/notes.md — your text, and only your text

That's the NotFound arm. To exercise the other one, point the editor at something that exists but isn't a readable file — a directory:

mkdir editor/adir
cargo run -- adir
→ status: could not open adir: Is a directory (os error 21), buffer empty

Not missing, so not a new file — the general Err arm catches it, reports it, and still leaves the document untouched.

Tidy up before lesson 6Both of those were throwaway. Remove them, or lesson 6's measurements will be taken on a directory full of clutter.

Run: rm -r editor/adir editor/notes.md

cargo fmt && cargo clippy
Both should be silent.

Where you ended up

97 lines in total — but only two parts of the file changed this lesson, and they sit at opposite ends of it. Here they are separately, with the untouched middle described rather than reprinted.

editor/src/main.rsthe top of the file

use eframe::egui;
use std::env;
use std::fs;
use std::io;

struct Editor {
    path: String,
    text: String,
    status: String,
}

impl Editor {
    /// Open a file into a new editor. A file that doesn't exist yet is not an
    /// error — it's a new file, and starts empty.
    fn open(path: &str) -> Self {
        let (text, status) = match fs::read_to_string(path) {
            Ok(contents) => (contents, String::new()),
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                (String::new(), format!("new file: {path}"))
            }
            Err(error) => (String::new(), format!("could not open {path}: {error}")),
        };
        Self {
            path: path.to_owned(),
            text,
            status,
        }
    }
About fifty lines follow here, all unchanged — don't delete themThe impl Editor block carries on after open withsave and line_col, then closes. After it comes the whole ofimpl eframe::App for Editor. Every line of that is exactly aslesson 4 left it, which is the only reason it isn't printed again.

editor/src/main.rsthe bottom of the file

fn main() -> eframe::Result {
    let path = env::args()
        .nth(1)
        .unwrap_or_else(|| "scratch.md".to_owned());

    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]),
        ..Default::default()
    };

    eframe::run_native(
        "editor",
        options,
        Box::new(|_cc| Ok(Box::new(Editor::open(&path)))),
    )
}

If your file is 97 lines and cargo clippy is silent, you're in the right place.

Check yourself

Why put a guard on the first Err arm instead of matching ErrorKind on its own?

  • The later arm still needs the error itself
  • Guards run faster than nested match arms
  • ErrorKind cannot be compared for equality
  • Match arms may not be ordered by specificity

The general arm formats {error} into its message, so the error value has to stay bound. The guard lets one arm ask about the kind while both arms keep hold of the error. Arms are tried in order, so the specific case must come first.↑ Review: the match guard (stage 3)

What did the old open actually do wrong when a file was missing?

  • Put a message where the document belongs
  • Crashed the program before the window opened
  • Silently created an empty file on disk
  • Reported the wrong filename in the message

The error text went into text — the buffer you save. So the first Ctrl+S wrote a complaint about the missing file into that very file. Diagnostics belong in the status line; the document is only ever the document.↑ Review: the bug in action (stage 2)

From lesson 4: why does line_col walk chars() instead of slicing the string?

  • egui counts characters, not bytes
  • Slicing a string is far slower than iterating
  • Strings cannot be sliced before their first newline
  • Iterating avoids borrowing the buffer mutably

The cursor arrives as a CharIndex. Slicing uses byte offsets, so on any non-ASCII text the two disagree — quietly misplacing the cursor, or panicking outright if the index lands inside a multi-byte character.↑ Review: byte vs char offsets (lesson 4, stage 3)

Read this before lesson 6

Primary source:std::option::Option — the API documentation.

Not a chapter this time, deliberately. Scroll the method list and read the one-line summaries: map, and_then, filter,ok_or, unwrap_or_default. You've now usedunwrap_or_else and matched on Some/None by hand; most of the hand-written matches you'd be tempted to write already exist as a named method. Learning to skim std's docs is a skill in itself, and Option is the friendliest page to practise on.