Project 1: Modal Text Editor (Mini-Vim)
Build a terminal-based modal text editor with explicit state transitions, safe control flow, and crash-safe file writes.
Quick Reference
| Attribute | Value |
|---|---|
| Difficulty | Advanced |
| Time Estimate | 2-3 weeks |
| Main Programming Language | C (Alternatives: Rust, Zig) |
| Alternative Programming Languages | Rust, Zig, C++ |
| Coolness Level | Level 3: Genuinely Clever |
| Business Potential | Level 2: Micro-SaaS / Pro Tool |
| Prerequisites | C fundamentals, basic terminal I/O, file operations |
| Key Topics | State machines, terminal raw mode, text buffers, invariants |
1. Learning Objectives
By completing this project, you will:
- Model editor modes as an explicit finite state machine with legal transitions.
- Design a control-flow discipline where every path preserves invariants and cleans up resources.
- Implement a robust text buffer with cursor invariants and safe edits.
- Build a terminal I/O pipeline (raw mode input, screen rendering, status bar).
- Implement crash-safe save semantics using temporary files and atomic rename.
- Validate correctness with deterministic demos and error-case transcripts.
2. All Theory Needed (Per-Concept Breakdown)
2.1 Modal State Machines and Control Flow Discipline
Fundamentals
A modal editor is a classic finite state machine: the same input has different meanings depending on the current mode. In a systems context, the state is not just a UI convenience; it is the guardrail that prevents invalid behavior. The state machine should be explicit, centralized, and enforced at every transition. A keypress in NORMAL mode might move a cursor; the same keypress in INSERT mode should insert a character. If this behavior is scattered across the codebase, you will inevitably create illegal state combinations and reentrancy bugs. Control flow discipline complements the state machine: every function must have defined preconditions, postconditions, and cleanup rules so that errors never leave the editor in a half-updated state. Think of the editor as a small operating system: input is an event stream, your editor state is the kernel state, and every event must advance the state machine without violating invariants like “cursor within buffer bounds” or “dirty flag reflects unsaved changes.”
Deep Dive into the concept
A modal editor exposes the hardest part of systems programming: correctness is not a single path, it is a web of possible paths. The state machine gives structure to that web. Start by defining a small finite set of states, such as NORMAL, INSERT, and COMMAND. Each state has a set of legal events and a set of invariants. For example, the NORMAL state accepts navigation events, deletion commands, and mode switches. The INSERT state accepts printable characters and ESC to return to NORMAL. The COMMAND state accepts a command-line buffer and ENTER to execute. The system should never accept a command that is illegal in the current state, and illegal inputs should result in a defined behavior (ignore, beep, or show error) rather than silent corruption.
Control flow discipline prevents the editor from becoming inconsistent when an error occurs mid-transition. Consider the :w command. You must build a temporary file, write the buffer, flush and fsync, then atomically rename. Any of those steps can fail. A disciplined control flow uses a single cleanup block to release file handles, free temporary buffers, and restore terminal state if the command aborts. This is exactly the same pattern used in low-level libraries and kernels: acquire resources in a known order, track which resources were acquired, and release them in reverse order for every exit path.
Modal editors also make reentrancy real. A command like :%s/foo/bar/g can trigger multiple buffer modifications in a loop. If your modification routine assumes it will not be called while a modification is in progress, you create a subtle bug. The state machine helps by making reentrancy explicit: either you disallow it (by entering a BUSY state) or you design your command execution to be reentrant-safe by separating “planning” from “commit.” A clean approach is to design command execution as a transaction: parse command, compute edits, apply edits, update cursor and dirty flag. Each step has defined preconditions and postconditions.
Invariants are the silent pillars of the system. The cursor must always be within bounds of the buffer; the buffer must always be valid UTF-8 if you choose to enforce that; the dirty flag must reflect whether buffer content differs from disk. These invariants must hold after every event. This means your event loop is not just a loop; it is a loop that enforces a set of statements that must remain true. You do this by explicitly checking and clamping cursor positions, validating edits before applying them, and updating state in a fixed order. A reliable mental model is: state transition = (validate event) -> (apply action) -> (repair invariants) -> (commit new state).
Error propagation is part of the state machine. If an operation fails (disk full, permission denied, terminal resized), that is not just an error code, it is an event that should move the editor into a known state such as ERROR or NORMAL with a visible status message. The editor should never end in a half-typed command or half-written file without an explicit state that explains why. This is particularly important for terminal programs, where a crash can leave the terminal in raw mode. A robust editor ensures that exit paths always restore the terminal to canonical mode, even if a command fails. That restoration is part of the state machine, not an afterthought.
Finally, state machines let you test exhaustively. If you enumerate all legal transitions, you can create tests that simulate sequences of events and verify that invariants hold. This is where the real power lies: you can test correctness of control flow independent of rendering or file I/O. When you can say “for any sequence of inputs, the cursor is in bounds and the buffer is consistent,” you have achieved the core promise of systems programming.
How this fit on projects
This concept is the backbone of the editor. You will explicitly encode modes, validate transitions, and ensure that error paths return you to a consistent state. It directly drives the input dispatch loop and the command execution pipeline.
Definitions & key terms
- State: The explicit mode of the editor (NORMAL, INSERT, COMMAND).
- Transition: A legal move from one state to another caused by an input event.
- Invariant: A condition that must always hold (cursor within bounds, dirty flag accurate).
- Reentrancy: Ability to call a routine while a previous call is still active.
- Cleanup path: The code path that releases resources for every exit scenario.
Mental model diagram (ASCII)
key: 'i' key: ESC
┌──────────────┐ ┌──────────────┐
│ NORMAL │ ───────▶ │ INSERT │
└──────┬───────┘ └──────┬───────┘
│ key: ':' │ key: ':' ignored
▼ ▼
┌──────────────┐ ENTER/ESC ┌──────────────┐
│ COMMAND │ ─────────▶ │ NORMAL │
└──────────────┘ └──────────────┘
Invariant: exactly one state active; cursor always within buffer bounds.
How it works (step-by-step)
- Define an enum for modes and a struct for editor state.
- Read input events in a loop.
- Switch on current state and validate the event.
- If legal, apply the action and update state variables.
- Re-assert invariants (clamp cursor, update dirty flag).
- If illegal, ignore or emit a status message; do not mutate state.
- On exit, ensure terminal is restored and resources freed.
Failure modes: illegal transitions (INSERT->COMMAND), cursor out of bounds after deletion, dirty flag not updated on undo/redo, terminal left in raw mode after crash.
Minimal concrete example
typedef enum { MODE_NORMAL, MODE_INSERT, MODE_COMMAND } Mode;
typedef struct {
Mode mode;
int cx, cy;
int dirty;
} EditorState;
void handle_key(EditorState *E, int key) {
switch (E->mode) {
case MODE_NORMAL:
if (key == 'i') E->mode = MODE_INSERT;
else if (key == ':') E->mode = MODE_COMMAND;
else /* navigation */ { /* update cx, cy */ }
break;
case MODE_INSERT:
if (key == 27) E->mode = MODE_NORMAL; /* ESC */
else /* insert char */ { E->dirty = 1; }
break;
case MODE_COMMAND:
if (key == 27 || key == '\n') E->mode = MODE_NORMAL;
else /* command buffer append */ { }
break;
}
}
Common misconceptions
- “A few flags are enough.” Flags create impossible combinations. Use a single state enum.
- “Illegal inputs can be ignored silently.” Users need explicit feedback to trust correctness.
- “Cleanup is only for program exit.” Every failing command needs a cleanup path.
Check-your-understanding questions
- Why is it dangerous to represent mode with multiple booleans?
- What invariant must hold after every cursor movement?
- What should happen if a save command fails halfway?
- How can you prevent command execution from being reentrant?
Check-your-understanding answers
- Multiple booleans can represent impossible states (e.g., NORMAL and INSERT true). That makes behavior ambiguous and untestable.
- The cursor must be inside the buffer: 0 <= cx <= line_len and 0 <= cy < line_count.
- The editor must return to a consistent state, keep the dirty flag set, and report the error while closing any open file descriptors.
- Use a BUSY state or design command execution as an atomic transaction that cannot be re-entered.
Real-world applications
- Vim and Emacs modal subsystems
- Terminal file managers with stateful input modes
- Text-based configuration editors in embedded systems
Where you’ll apply it
- In this project: see §3.2 Functional Requirements, §4.1 High-Level Design, and §5.10 Phase 2.
- Also used in: P02-http-1-1-parser.md (parser states), P05-embedded-sensor-state-machine.md (embedded states).
References
- “Effective C” by Robert C. Seacord, Chapter 8 (error handling patterns)
- “The Linux Programming Interface” by Michael Kerrisk, Chapter 62 (terminal modes)
- “Code: The Hidden Language” by Charles Petzold, Chapter 14 (state machines)
Key insights
An explicit state machine turns vague control flow into a testable graph of legal behaviors.
Summary
Modal editors are state machines. If you make state explicit and enforce invariants after every event, you prevent the hardest class of bugs: temporal and reentrancy failures.
Homework/Exercises to practice the concept
- Draw a state diagram for NORMAL/INSERT/COMMAND with legal events and mark illegal transitions.
- Write a small function that validates a proposed transition and returns an error if illegal.
- List three invariants your editor must never violate.
Solutions to the homework/exercises
- NORMAL -> INSERT on ‘i’, NORMAL -> COMMAND on ‘:’, INSERT -> NORMAL on ESC, COMMAND -> NORMAL on ESC/ENTER; all other transitions are illegal.
- Use a switch on current state and event; return 0 for allowed transitions, -1 for disallowed.
- Cursor in bounds, dirty flag accurate, terminal restored on exit paths.
2.2 Terminal Text Editor I/O Pipeline (Input, Buffer, Rendering, Save)
Fundamentals
A terminal editor is a pipeline: raw input enters, is translated into actions, those actions mutate a text buffer, and the updated state is rendered to the screen. Each stage has its own constraints. Raw input is not line-buffered; you must configure the terminal into raw mode and interpret escape sequences for arrow keys. The text buffer must support efficient insertions and deletions without corrupting memory. Rendering must be fast enough to feel interactive and must correctly represent the buffer, cursor, and status bar. Finally, saving must be crash-safe: write to a temporary file, fsync, then rename atomically. Because the terminal is a shared resource, you must always restore it to canonical mode on exit or error. Understanding this pipeline is what turns a “toy” editor into a reliable tool.
Deep Dive into the concept
Terminal I/O is deceptively complex. By default, terminals are in canonical mode: input is line-buffered and processed by the kernel. A modal editor must disable this behavior with termios so that each keypress is delivered immediately. Raw mode also disables echo and special processing, so your program is now responsible for drawing every character and handling backspace. That means the editor must maintain a full-screen representation of the buffer and update it explicitly. The rendering stage is not merely printing text; it is a deterministic function from editor state to a screen buffer. It should include a status bar, a message line, and cursor placement. A good design builds an off-screen buffer and writes it in one call to avoid flicker.
The text buffer is the heart of the editor. A naive implementation uses a single contiguous array and shifts bytes on every insertion, which becomes slow for large files. A gap buffer or a piece table is better: a gap buffer keeps a movable “gap” where insertions occur in O(1) amortized time, while a piece table stores references to original and added text without copying. For a first version, a gap buffer is easier and sufficient. Regardless of structure, you must maintain invariants: cursor positions must map to valid positions in the buffer, line lengths must be tracked, and the buffer must not contain invalid indexes. This is where systems discipline matters: a single off-by-one can corrupt memory or draw garbage.
Saving must be crash-safe. Writing directly to the target file is risky: if the process crashes mid-write, you will corrupt the file. The classic solution is to write to a temporary file in the same directory, flush and fsync, then rename. POSIX guarantees that rename is atomic, so the file will be either fully old or fully new. You also need to consider permissions and file ownership: preserve the original file mode, and handle the case where the file does not yet exist. Failure handling is part of the pipeline: if write fails due to disk full, the editor should leave the dirty flag set and show a clear message. If rename fails, the temp file should be cleaned up.
Input handling also requires escape sequence parsing. Arrow keys, Home/End, and PageUp/PageDown are delivered as multi-byte sequences (for example, ESC [ A). The editor must parse these sequences without losing synchronization, a simplified version of a streaming parser. You should treat input as a stream where partial sequences are possible; if you read ESC and then nothing else, you should wait briefly or treat it as an ESC key. This is a small-scale version of the HTTP parser you will build in Project 2.
Rendering is the final stage and must be deterministic. The easiest approach is full redraw on each input, which is acceptable for small files. For larger files, you can optimize by tracking dirty rows and redrawing only those. Even with full redraw, you must be careful about terminal resize events (SIGWINCH). When the window size changes, update the viewport and clamp the cursor. In addition, your output should be structured so that if the process crashes or exits, the terminal is restored to a clean state. A robust editor uses an atexit handler and a signal handler to ensure cleanup.
All these pieces together define the editor’s I/O pipeline. If any stage is weak, the editor will feel flaky: dropped input, wrong cursor position, flickering screen, or corrupted files. The discipline of modeling each stage explicitly and connecting them with invariants is what elevates the project from a demo to a reliable system.
How this fit on projects
This concept explains how raw input becomes editor actions, how actions modify the buffer, how the buffer is rendered, and how file persistence is made crash-safe. It is the end-to-end data flow you will implement.
Definitions & key terms
- Raw mode: Terminal configuration where input is unbuffered and not preprocessed.
- Gap buffer: A text buffer with a movable gap for efficient edits.
- Piece table: Buffer representation that references original and added text.
- Viewport: The portion of the buffer visible on screen.
- Atomic rename: File replacement via rename that is guaranteed to be all-or-nothing.
Mental model diagram (ASCII)
[Keypress Bytes] -> [Escape Parser] -> [Action]
| |
v v
[Editor State] -> [Text Buffer]
| |
v v
[Render Buffer] -> [Terminal]
|
v
[Save Path]
temp file -> fsync -> rename
How it works (step-by-step)
- Enable raw mode (termios) and register cleanup handler.
- Read bytes from stdin; parse escape sequences into events.
- Dispatch events based on mode and update buffer/cursor.
- Enforce invariants: clamp cursor, update dirty flag.
- Render buffer and status bar into an output buffer.
- Write output buffer to terminal in one system call.
- On save, write temp file, fsync, rename atomically.
- On exit, restore terminal mode even if errors occur.
Failure modes: misparsed escape sequences, cursor beyond line length, corrupted buffer on insert/delete, terminal stuck in raw mode, truncated file after failed save.
Minimal concrete example
// Save file safely
int save_file(const char *path, const char *data, size_t len) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return -1;
if (write(fd, data, len) != (ssize_t)len) { close(fd); unlink(tmp); return -1; }
if (fsync(fd) != 0) { close(fd); unlink(tmp); return -1; }
close(fd);
if (rename(tmp, path) != 0) { unlink(tmp); return -1; }
return 0;
}
Common misconceptions
- “Terminal input is just characters.” It includes multi-byte escape sequences that must be parsed.
- “Writing directly to the file is fine.” A crash can corrupt user data; use atomic save.
- “Full redraw is too slow.” For small files, full redraw is acceptable and simpler.
Check-your-understanding questions
- Why do you need raw mode to implement a modal editor?
- What is the difference between a gap buffer and a piece table?
- Why is rename considered atomic, and why does that matter?
- How can a terminal resize break your cursor invariants?
Check-your-understanding answers
- Raw mode delivers each keypress immediately and disables line buffering and echo so the editor controls input handling.
- A gap buffer keeps a contiguous array with a gap for O(1) inserts at the cursor, while a piece table references original and appended buffers to avoid copying.
- Rename guarantees the file is either fully old or fully new, which prevents partial writes from corrupting data.
- When the window shrinks, the visible viewport and cursor might point outside the new bounds unless clamped.
Real-world applications
- Terminal editors (vim, nano, helix)
- Full-screen terminal apps (htop, less)
- Text-based configuration tools on embedded systems
Where you’ll apply it
- In this project: see §3.7 Real World Outcome, §4.2 Key Components, and §5.2 Project Structure.
- Also used in: P02-http-1-1-parser.md (stream parsing), P04-undo-redo-engine.md (buffered history).
References
- “The Linux Programming Interface” by Michael Kerrisk, Chapter 62 (termios)
- “Crafting Interpreters” by Robert Nystrom, section on scanners (stream parsing mindset)
- “Vim” source code (gap buffer and screen redraw strategies)
Key insights
A terminal editor is a pipeline: input parsing, buffer mutation, rendering, and crash-safe persistence.
Summary
If you understand raw input, buffer structures, and atomic saves, the editor becomes a deterministic pipeline with well-defined failure behavior.
Homework/Exercises to practice the concept
- Write a tiny program that prints raw key codes and escape sequences.
- Implement a gap buffer with insert and delete at cursor.
- Implement a save routine that writes to a temp file and renames it.
Solutions to the homework/exercises
- Configure termios with ICANON and ECHO disabled, then read bytes and print their hex values.
- Represent buffer as two arrays or one array with a gap; move the gap on cursor movement.
- Use open/write/fsync/rename and clean up the temp file on failure.
3. Project Specification
3.1 What You Will Build
A terminal-based modal text editor with three explicit modes (NORMAL, INSERT, COMMAND). It can open and edit real files, display a status bar, and save changes safely. The editor must never corrupt files, never leave the terminal in raw mode on exit, and must enforce cursor invariants on every keypress.
Included:
- Mode indicator and status bar
- Text buffer with insertion, deletion, and navigation
- Basic commands (:w, :q, :wq, :q!)
- Crash-safe saving with temp file + rename
Excluded:
- Syntax highlighting
- Plugins or scripting
- Multi-file tabs
3.2 Functional Requirements
- Mode system: Explicit state variable and legal transitions on
i,ESC,:. - Input dispatch: Keypress events interpreted differently by mode.
- Text buffer: Supports insert, delete, and newline operations.
- Cursor invariants: Cursor always within buffer bounds.
- Status bar: Shows mode, file name, line/column, dirty flag.
- Command mode: Supports
:w,:q,:wq,:q!,:linejump. - Safe save: Write to temp file, fsync, rename atomically.
- Graceful exit: Terminal restored on all exit paths.
3.3 Non-Functional Requirements
- Performance: Responsive for files up to 200 KB with full redraws.
- Reliability: No memory leaks; no terminal corruption after exit.
- Usability: Clear error messages for invalid commands.
3.4 Example Usage / Output
$ ./myvi README.md
-- NORMAL MODE -- README.md Ln 1, Col 1 Modified: No
[i] -> INSERT
(typed text appears)
[ESC] -> NORMAL
: w
"README.md" written
: q
$
3.5 Data Formats / Schemas / Protocols
- On-disk file format: Plain text, preserved as-is (no metadata).
- Command syntax:
:followed by verb (w,q,wq,q!, or line number). - Internal buffer: List of lines or gap buffer; must map cursor to row/col.
3.6 Edge Cases
- Empty file or new file with zero lines
- Last line without trailing newline
- Attempting
:qwith unsaved changes - Terminal resized smaller than status bar width
- Cursor move beyond end-of-line after deletion
3.7 Real World Outcome
This is a deterministic CLI tool. Use a fixed demo file so output is reproducible.
3.7.1 How to Run (Copy/Paste)
cc -std=c11 -Wall -Wextra -O2 -o myvi src/main.c
./myvi demo.txt
3.7.2 Golden Path Demo (Deterministic)
Prepare a deterministic file:
printf "Line one\nLine two\n" > demo.txt
Then run and follow this exact sequence: i, type X, ESC, :w, ENTER, :q, ENTER.
Expected behavior: Line one becomes Line oneX and file saves successfully.
3.7.3 CLI Transcript (Success + Failure)
$ ./myvi demo.txt
-- NORMAL MODE -- demo.txt Ln 1, Col 1 Modified: No
# user presses i, types X, presses ESC
-- NORMAL MODE -- demo.txt Ln 1, Col 9 Modified: Yes
:w
"demo.txt" 2 lines written
:q
$ # exit code 0
$ ./myvi demo.txt
:q
Error: No write since last change (use :q! to override)
# exit code 1
Exit codes:
0success1user error (invalid command, unsaved changes)2system error (I/O failure, allocation failure)
4. Solution Architecture
4.1 High-Level Design
+-----------------+ +------------------+ +------------------+
| Input Reader | --> | Mode Dispatcher | --> | Editor Actions |
+-----------------+ +------------------+ +------------------+
| | |
v v v
[Escape Parser] [State Machine] [Text Buffer]
| |
v v
+-----------------+ +------------------+
| Render Engine | ----------------------> | Terminal Output |
+-----------------+ +------------------+
4.2 Key Components
| Component | Responsibility | Key Decisions | |———–|—————-|—————| | Input Reader | Read raw bytes, parse escape sequences | Use raw mode and minimal buffering | | State Machine | Enforce modes and legal transitions | Single enum for mode, central dispatcher | | Text Buffer | Store and edit file content | Gap buffer or line array | | Renderer | Draw screen, status bar, cursor | Full redraw with off-screen buffer | | Saver | Crash-safe file persistence | temp file + fsync + rename |
4.3 Data Structures (No Full Code)
typedef struct {
char *buf;
size_t len;
size_t gap_start;
size_t gap_end;
} GapBuffer;
typedef struct {
int row, col;
} Cursor;
typedef struct {
Mode mode;
GapBuffer text;
Cursor cursor;
int dirty;
char filename[256];
} EditorState;
4.4 Algorithm Overview
Key Algorithm: Event Loop with Invariants
- Read input event.
- Dispatch based on mode.
- Apply edit or navigation.
- Repair invariants (clamp cursor, update dirty flag).
- Render updated view.
Complexity Analysis:
- Time: O(1) amortized per edit with gap buffer, O(n) for full redraw
- Space: O(n) for buffer content
5. Implementation Guide
5.1 Development Environment Setup
cc --version
make --version
5.2 Project Structure
project-root/
├── src/
│ ├── main.c
│ ├── editor.c
│ ├── buffer.c
│ ├── render.c
│ └── commands.c
├── include/
│ ├── editor.h
│ └── buffer.h
├── tests/
│ └── test_buffer.c
├── Makefile
└── README.md
5.3 The Core Question You’re Answering
“How do I make every keypress safe and deterministic even when the editor is in different modes?”
5.4 Concepts You Must Understand First
Stop and research these before coding:
- State machines in C (enum + switch, legal transitions)
- Termios raw mode (disable canonical input, echo, signals)
- Atomic file save (write temp, fsync, rename)
- Gap buffer basics (why it beats naive arrays)
5.5 Questions to Guide Your Design
- How will you ensure illegal transitions never occur?
- What invariant checks run after every edit?
- How will you redraw without flicker?
- How will you handle errors mid-save?
5.6 Thinking Exercise
Trace this scenario:
- NORMAL mode, cursor at end of line
- Press
i, insert 3 characters - Press
ESC, delete a character - Press
:qwithout saving
What states are visited? Which invariants are checked each step?
5.7 The Interview Questions They’ll Ask
- “How do you ensure the cursor never leaves the buffer?”
- “Why is rename() used for crash-safe saves?”
- “How do you parse arrow keys in raw mode?”
- “What makes a function reentrant?”
5.8 Hints in Layers
Hint 1: Start with mode switching Implement only NORMAL/INSERT and print the mode in the status bar.
Hint 2: Add a minimal buffer Store lines in a dynamic array before switching to a gap buffer.
Hint 3: Render using an output buffer Assemble the full screen in memory and write once per frame.
Hint 4: Make save atomic Never write directly to the target file. Always write temp + rename.
5.9 Books That Will Help
| Topic | Book | Chapter | |——-|——|———| | Terminal I/O | “The Linux Programming Interface” | Ch. 62 | | Error handling | “Effective C” | Ch. 8 | | State machines | “Code: The Hidden Language” | Ch. 14 | | Data structures | “Algorithms in C” | Ch. 1-3 |
5.10 Implementation Phases
Phase 1: Foundation (3-4 days)
Goals: Raw mode + mode switching + render status bar. Tasks:
- Implement termios raw mode with cleanup handler.
- Render a blank screen with status bar.
- Toggle modes with
iandESC. Checkpoint: Mode indicator updates correctly.
Phase 2: Core Functionality (1-2 weeks)
Goals: Editing, navigation, save. Tasks:
- Implement buffer insert/delete and cursor movement.
- Add command mode with
:w,:q,:wq. - Implement atomic save routine. Checkpoint: Can edit and save a file without corruption.
Phase 3: Polish & Edge Cases (3-5 days)
Goals: Errors, resizing, cleanup. Tasks:
- Add resize handling (SIGWINCH).
- Add error messages for invalid commands.
- Ensure terminal always restored on exit. Checkpoint: All edge cases handled and terminal never broken.
5.11 Key Implementation Decisions
| Decision | Options | Recommendation | Rationale | |———-|———|—————-|———–| | Buffer representation | Array, gap buffer, piece table | Gap buffer | Simple and efficient for single-cursor edits | | Rendering strategy | Full redraw, dirty rows | Full redraw | Simpler and fast enough for target file sizes | | Save method | Overwrite, temp + rename | Temp + rename | Crash-safe, atomic |
6. Testing Strategy
6.1 Test Categories
| Category | Purpose | Examples | |———-|———|———-| | Unit Tests | Buffer operations | Insert/delete at edges | | Integration Tests | Mode transitions | NORMAL->INSERT->NORMAL | | Edge Case Tests | Error paths | Save failure, invalid commands |
6.2 Critical Test Cases
- Insert at end of file: cursor remains within bounds.
- Delete at start of line: no underflow.
- Save failure: dirty flag remains set and temp file cleaned.
6.3 Test Data
"" (empty)
"one line"
"line1\nline2\n"
7. Common Pitfalls & Debugging
7.1 Frequent Mistakes
| Pitfall | Symptom | Solution | |——–|———|———-| | Cursor out of bounds | Crash or garbled screen | Clamp after every edit | | Raw mode not restored | Broken terminal | Use atexit + signal handlers | | Save corruption | Partial file writes | Temp file + fsync + rename |
7.2 Debugging Strategies
- Log state transitions: print mode changes and cursor positions.
- Add invariant asserts: abort if cursor is invalid.
7.3 Performance Traps
Full redraws on huge files can be slow; limit scope to <200 KB or implement dirty-row redraws later.
8. Extensions & Challenges
8.1 Beginner Extensions
- Add
ufor undo with a simple stack. - Add
/search for a string.
8.2 Intermediate Extensions
- Implement a gap buffer for efficiency.
- Add visual selection mode.
8.3 Advanced Extensions
- Implement multi-cursor editing.
- Add persistent undo saved to disk.
9. Real-World Connections
9.1 Industry Applications
- Text editors: Vim, Helix, and Kakoune all rely on explicit modes.
- Terminal tools: Full-screen TUIs use the same raw-mode pipeline.
9.2 Related Open Source Projects
- kilo: A minimal C editor used as a learning reference.
- vim: Mature modal editor with decades of evolution.
9.3 Interview Relevance
- State machines: common in systems interviews for correctness reasoning.
- Crash-safe persistence: demonstrates durability and atomicity concepts.
10. Resources
10.1 Essential Reading
- “The Linux Programming Interface” by Michael Kerrisk (termios, file I/O)
- “Effective C” by Robert C. Seacord (error handling)
10.2 Video Resources
- Terminal raw mode tutorials (search for termios demonstrations)
10.3 Tools & Documentation
- man termios: Raw mode settings
- man rename: Atomic file replacement
10.4 Related Projects in This Series
- Project 2: HTTP/1.1 Parser prepares you for stream parsing.
- Project 4: Undo/Redo Engine deepens temporal state management.
11. Self-Assessment Checklist
11.1 Understanding
- I can explain why a modal editor is a finite state machine.
- I can explain why atomic rename prevents data corruption.
- I can explain how raw mode changes input behavior.
11.2 Implementation
- All functional requirements are met.
- All test cases pass.
- Terminal is restored on all exits.
11.3 Growth
- I can identify an invariant that prevented a bug.
- I documented at least one design trade-off.
- I can explain the editor in an interview.
12. Submission / Completion Criteria
Minimum Viable Completion:
- Mode switching works and is visible.
- Insert/delete edits the buffer safely.
- Save uses temp + rename and reports errors.
Full Completion:
- All commands function, and cursor invariants never break.
- Error handling is correct for invalid commands and I/O failures.
Excellence (Going Above & Beyond):
- Undo/redo implemented with a stable history model.
- Editor survives terminal resize without glitches.
13. Additional Content Rules (Compliance)
- Deterministic demo provided in §3.7.
- Failure path demo included with exit codes.
- State transitions cross-linked in §2.1 and §5.10.