Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 14 / Part 1: Don't lose my work / ~35 minutes

The Index That Outlived Its Buffer

Four stages — and the first one compiles perfectly and then crashes.

You can open several files. You cannot close any of them. That's the gap this lesson fills, and closing a buffer turns out to be the most interesting small feature in the editor — because it's where a warning fromlesson 9 finally comes true.

Lesson 9 stored the current file as active: usize rather than a reference, and said this about it: "nothing stops you using a stale one". Nothing has, so far, because nothing has ever removed a buffer. This lesson removes one.

The borrow checker guarantees your references are valid.
It has never had an opinion about whether your indices are.

Where you're starting

Lesson 13's editor, with the dirty dot. Check againstlesson 13's checkpoint.

adding   removing   existing code, shown only for context

A button that closes a tab

An × on every tab. It works once, then the editor dies.

The obvious implementation, written the obvious way. One edit — inside the tab loop, just after the label:

editor/src/main.rsin fn ui, the tab loop

                    if ui.selectable_label(selected, label).clicked() {
                        self.active = index;
                    }
                    if ui.small_button("×").clicked() {
                        self.buffers.remove(index);
                    }
                }

printf 'fn a() {}\n' > a.rs; printf 'fn b() {}\n' > b.rs; printf 'fn c() {}\n' > c.rs
cargo run -- a.rs b.rs c.rs
→ three tabs, each with an ×
→ click the × on the first tab

thread 'main' panicked at src/main.rs:297:34:
index out of bounds: the len is 2 but the index is 2

Why the compiler let you do that

Read the loop again with that error in mind:

for index in 0..self.buffers.len() {   // evaluated once: 0..3
    let buffer = &self.buffers[index];   // panics when index is 2 and len is 2
    // …
    self.buffers.remove(index);           // the Vec is now one shorter
}

0..self.buffers.len() is evaluated once, before the first iteration. It becomes the range 0..3 and stays that way. Remove an element on the first pass and the loop still faithfully visits indices 1 and 2, of a vector that now ends at 1.

This is exactly what lesson 9 warned aboutLesson 9 chose active: usize over &mut Buffer because a struct can't own a Vec and hold a reference into it — safe Rust rejects that outright, with E0106 and then E0505. The cost of the index, stated at the time: "nothing stops you using a stale one".

Here's the bill. A reference into a Vec would have made this a compile error — the borrow checker will not let you call remove while a reference into it is alive. An index is just a number, and numbers don't know what they point at.

Rust moved the failure from compile time to run time, and made it loud rather than silent: a bounds check, a panic, a line number. In C this would have read whatever memory happened to be there. That's the trade the index bought, and it's a good one — but it is a trade, not a free pass.

Decide in the loop, act after it

Closing a tab works, and the editor stays up.

You can't safely change the vector while walking it, so don't. Note what you want to happen, finish the walk, and do it afterwards — when nothing is iterating any more.Four edits.

First — somewhere to note it, just above the tab row:

editor/src/main.rsin fn ui, directly above the tab row

            let mut closing = None;

            ui.horizontal(|ui| {
                for index in 0..self.buffers.len() {
It must be in the same scope as the tab rowclosing is written inside the loop and read after it, so it has to be declared in the block that contains ui.horizontal — wherever that is in your file. Put it one scope too deep and you getcannot find value `closing` in this scope, twice.

Second — the button records the request instead of acting on it:

editor/src/main.rsin fn ui, the tab loop

                    if ui.small_button("×").clicked() {
                        self.buffers.remove(index);
                        closing = Some(index);
                    }

Third — and it's carried out after the loop has finished:

editor/src/main.rsin fn ui, just after the tab row closes

            });

            if let Some(index) = closing {
                self.close(index);
            }

            let buffer = &mut self.buffers[self.active];

Fourth — and the method it calls. It goes in impl Editor, above fn open — note that impl Buffer has an fn opentoo, and putting close above the wrong one gives you a method that can't seeself.buffers or self.active at all:

editor/src/main.rsin impl Editor, above fn open

    /// Close a buffer, keeping `active` pointing at the file it was on.
    fn close(&mut self, index: usize) {
        if self.buffers.len() == 1 {
            return;
        }

        self.buffers.remove(index);

        if index < self.active {
            self.active -= 1;
        }
    }

Collect, then act

Option<usize> is doing real work here. It holds two facts at once —whether anything was clicked and which tab — in a way that makes it impossible to read one without the other. That's the sameOption from lesson 4, used for a different kind of absence.

The pattern generalises well beyond egui: when a loop discovers it wants to change the thing it's looping over, record the intent and apply it afterwards.You'll reach for it again in the file tree, where clicking a folder changes the tree you're in the middle of drawing.

The two guards in close are worth reading. Refusing to close the last buffer isn't politeness — self.buffers[self.active] runs on every frame, and an empty Vec makes that a panic. Until there's a way to create a new empty buffer, which is part 2, one file must always be open.

cargo run -- a.rs b.rs c.rs
→ close the first tab: the editor survives, two tabs remain
→ close tabs until one is left: the last × does nothing

Now close the tab you're actually editingClick the last tab to make it active, then close it with its own ×.

The editor panics again — another index out of bounds, this time fromlet buffer = &mut self.buffers[self.active];. closeshortened the vector, and self.active is still pointing at where the last buffer used to be.

Prove the arithmetic

Four tests. One of them is red.

close does index arithmetic, and index arithmetic is the kind of thing that's right for the case you thought about and wrong for the case you didn't. It's alsopure — no egui, no window, no frame — which sincelesson 10 you know means testable. Two edits — and the second only makes sense once you've run the first. Here's the first, at the bottom of mod tests:

editor/src/main.rsin mod tests, at the end

    fn editor_with(names: &[&str]) -> Editor {
        let paths: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
        Editor::open(&paths)
    }

    #[test]
    fn closing_a_tab_before_the_active_one_keeps_the_same_file() {
        let mut editor = editor_with(&["a.rs", "b.rs", "c.rs"]);
        editor.active = 2;
        editor.close(0);
        assert_eq!(editor.buffers[editor.active].name(), "c.rs");
    }

    #[test]
    fn closing_the_active_tab_selects_the_one_that_took_its_place() {
        let mut editor = editor_with(&["a.rs", "b.rs", "c.rs"]);
        editor.active = 1;
        editor.close(1);
        assert_eq!(editor.buffers[editor.active].name(), "c.rs");
    }

    #[test]
    fn closing_the_active_last_tab_leaves_the_index_in_range() {
        let mut editor = editor_with(&["a.rs", "b.rs", "c.rs"]);
        editor.active = 2;
        editor.close(2);
        assert!(editor.active < editor.buffers.len());
        assert_eq!(editor.buffers[editor.active].name(), "b.rs");
    }

    #[test]
    fn the_only_tab_cannot_be_closed() {
        let mut editor = editor_with(&["a.rs"]);
        editor.close(0);
        assert_eq!(editor.buffers.len(), 1);
    }
}

Editor::open on files that don't exist gives you three perfectly good empty buffers — that's lesson 5's decision that a missing file is a new file, not an error, quietly making these tests possible without touching the disk.

cargo test

test tests::closing_a_tab_before_the_active_one_keeps_the_same_file ... ok
test tests::closing_the_active_tab_selects_the_one_that_took_its_place ... ok
test tests::the_only_tab_cannot_be_closed ... ok
test tests::closing_the_active_last_tab_leaves_the_index_in_range ... FAILED

---- tests::closing_the_active_last_tab_leaves_the_index_in_range stdout ----
assertion failed: editor.active < editor.buffers.len()

test result: FAILED. 13 passed; 1 failed

The case the arithmetic missed

close handles one case: a tab before the active one goes away, so everything after it shifts down by one and active shifts with it. That's the case you picture when you write it, and it's correct.

It says nothing about closing the tab at the end while you're on it. Nothing shifts — index < self.active is false when they're equal — soactive stays at 2, and the vector now ends at 1. The index didn't move; the vector moved out from under it.

The second edit — clamp the index to what's actually there:

editor/src/main.rsin Editor::close, at the end

        if index < self.active {
            self.active -= 1;
        }

        self.active = self.active.min(self.buffers.len() - 1);
    }

min is doing the work of an if without the branch:active becomes whichever is smaller, itself or the last valid index. In every case the first rule already handled, it changes nothing. In the case it didn't, it pulls the index back onto the end of the vector.

Why len() - 1 is safe here, and only hereSubtracting from a usize that's already zero doesn't give you −1 — it panics in a debug build and wraps to a colossal number in release, which is worse. It's safe on this line only because of the guard four lines above: the function returned early if there was one buffer, so by the time you reach this line there are at least two, andlen() - 1 is at least 1. Delete that guard and this line becomes a bug.

cargo test
→ test result: ok. 14 passed; 0 failed

cargo run -- a.rs b.rs c.rs
→ select the last tab, close it: you land on the one before it
→ close tabs in any order you like; the editor stays up

Four tests, one bug, ninety secondsYou would have found this eventually — by closing the last tab and watching the editor vanish. The test found it before you ran the program once, and it will keep finding it if anyone ever rewrites that arithmetic. This is the second time in this tutorial that writing tests for a "trivial" function has turned one up; the first wasname() in lesson 10.

Close it from the keyboard

Ctrl+W closes the file you're looking at.

Nobody reaches for the mouse to close a tab. The work is already done — there's a method that closes a buffer by index, and self.active is an index.One edit, just above the block that applies closing:

editor/src/main.rsin fn ui, after the tab row

            });

            let close_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::W));
            if close_requested {
                closing = Some(self.active);
            }

            if let Some(index) = closing {
                self.close(index);
            }

That's the same shape as Ctrl+S from lesson 3, and it feeds the same closing variable the button does. Two ways to ask for the same thing, one place that carries it out — which is why the mouse path and the keyboard path can't drift apart.

modifiers.command rather than ctrl is deliberate, and it's been that way since lesson 3: egui maps it to Ctrl on Linux and Windows and to ⌘ on macOS, so the binding is right on all three without acfg.

cargo run -- a.rs b.rs c.rs
→ press Ctrl+W: the current tab closes and the next one takes over
→ keep pressing: the last one stays put

It still throws away your work, and now it's easier to doType into a buffer so the tab shows its •, then press Ctrl+W. It closes, and the edits are gone — no prompt, exactly as inlesson 13's stage 1. The editor now knows the buffer was modified; it just isn't asking.

That's lesson 15, and it's the last one of this part.

Where you ended up

508 lines, fourteen tests green. Two regions changed: Editor gained a method, and the tab row learned to ask for things.

editor/src/main.rsthe top of impl Editor

impl Editor {
    /// Close a buffer, keeping `active` pointing at the file it was on.
    fn close(&mut self, index: usize) {
        if self.buffers.len() == 1 {
            return;
        }

        self.buffers.remove(index);

        if index < self.active {
            self.active -= 1;
        }

        self.active = self.active.min(self.buffers.len() - 1);
    }
Between these two: everything else, unchangedEditor::open, the whole of Text and Buffer, the scanner, the status panel and main are exactly aslesson 13 left them.

editor/src/main.rsin fn ui, the central panel down to the text box

            let mut closing = None;

            ui.horizontal(|ui| {
                for index in 0..self.buffers.len() {
                    let selected = index == self.active;
                    let buffer = &self.buffers[index];
                    let label = if buffer.dirty {
                        format!("• {}", buffer.name())
                    } else {
                        buffer.name().to_owned()
                    };
                    if ui.selectable_label(selected, label).clicked() {
                        self.active = index;
                    }
                    if ui.small_button("×").clicked() {
                        closing = Some(index);
                    }
                }
            });

            let close_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::W));
            if close_requested {
                closing = Some(self.active);
            }

            if let Some(index) = closing {
                self.close(index);
            }

            let buffer = &mut self.buffers[self.active];

Check yourself

Why did removing a buffer inside the tab loop compile?

  • An index is a number; nothing ties it to the vector's length
  • Vec::remove takes &self, not &mut self
  • The loop re-evaluates its range on every iteration
  • egui closures suppress borrow checking inside them

The borrow checker tracks references, and there was no live reference into the vector at that point. 0..len() was evaluated once, so the loop kept visiting indices that no longer existed — a logic error, caught at run time by a bounds check rather than at compile time.↑ Review: why the compiler let you (stage 1)

What does Option<usize> buy over a usize and a bool?

  • You cannot read the index without handling "nothing"
  • It uses less memory than the two separate values
  • It allows the index to be modified inside the loop
  • Pattern matching on it is faster than an if

The two facts — whether anything was requested and which tab — are welded together, so there's no way to use a stale index by forgetting to check the flag. SameOption as lesson 4's cursor, a different kind of absence.↑ Review: collect, then act (stage 2)

Which case did if index < self.active alone fail to handle?

  • Closing the last tab while it is the active one
  • Closing a tab after the active one
  • Closing the first tab of three
  • Closing a tab whose buffer has unsaved edits

When index == self.active nothing shifts, soactive stayed where it was — pointing one past the end of a vector that had just shrunk. The clamp pulls it back onto the last valid index.↑ Review: the case the arithmetic missed (stage 3)

Why is self.buffers.len() - 1 safe on that line?

  • An earlier guard returned when there was only one buffer
  • usize subtraction saturates at zero in Rust
  • min evaluates its argument only when needed
  • The vector is never empty because main pushes one

Underflowing a usize panics in debug and wraps to an enormous number in release. The line is only reachable when at least two buffers remain, because the function returned early otherwise — remove that guard and this becomes a bug.↑ Review: why len() − 1 is safe (stage 3)

Read this before lesson 15

Primary source:Vec::retainand its neighbours in the Vec docs. close usesremove, which is right for one known index — but the family around it (retain, swap_remove, drain) is worth ten minutes, because each one exists to avoid exactly the bug stage 1 hit.

swap_remove is the interesting one: it's O(1) instead of O(n) because it moves the last element into the hole rather than shifting everything down. It would be wrong here — it would shuffle your tabs — and knowing why it's wrong is worth more than knowing it exists.