Table of Contents
- Understand the Architecture: A Bird’s-Eye View
- The Buffer — Storing Text Efficiently
- The Piece Table — The Heart of Editing
- The Cursor & Selection Model
- The Command Pattern — Undo & Redo
- The Observer Pattern — Event System
- The View Layer — Rendering the Buffer
- The Viewport & Scrolling
- Syntax Highlighting
- The Mode System (Modal Editing)
- The Plugin Architecture
- The Language Server Protocol (LSP) Client
- LSP Features — Autocomplete, Diagnostics & Code Actions
- The File System & Buffer Manager
- Search & Replace
- Configuration System
- The Workspace & Project Model
- The Keybinding System
- Test Suites
- Recommended Build Order
SOLID Principles: Your Constant Compass
Throughout this guide, every architectural decision is evaluated against the five SOLID principles. Internalize these before writing a single line:
| Principle | One-line definition | What it prevents in a text editor |
|---|---|---|
| Single Responsibility | Every class has one reason to change | Buffer logic leaking into rendering; cursor logic mixed with file I/O |
| Open/Closed | Open for extension, closed for modification | Adding a new language requires editing the core syntax engine |
| Liskov Substitution | Subtypes must be substitutable for their base type | A ReadOnlyBuffer that silently ignores writes, breaking callers |
| Interface Segregation | No client should depend on methods it doesn’t use | Forcing a simple file loader to implement LSP notification methods |
| Dependency Inversion | Depend on abstractions, not concretions | The editor core importing a specific LSP library directly |
These are not theoretical — each step below calls out exactly which principle is at stake.
1. Understand the Architecture: A Bird’s-Eye View
The Layered Architecture
A professional text editor separates concerns into discrete, testable layers. Each layer communicates with adjacent layers only through well-defined interfaces (DIP). No layer reaches across to a non-adjacent layer.
┌─────────────────────────────────────────────────────────────────┐
│ USER INTERFACE LAYER │
│ Keybindings │ Command Palette │ Status Bar │ Tab Bar │
├─────────────────────────────────────────────────────────────────┤
│ VIEW LAYER │
│ Viewport │ Line Renderer │ Cursor Renderer │ Decorations │
├─────────────────────────────────────────────────────────────────┤
│ FEATURE LAYER │
│ Syntax Highlighting │ LSP Client │ Search │ Autocomplete │
├─────────────────────────────────────────────────────────────────┤
│ EDITOR CORE │
│ Buffer (Piece Table) │ Cursor Model │ Selection │ Undo │
├─────────────────────────────────────────────────────────────────┤
│ INFRASTRUCTURE LAYER │
│ File System │ Process Spawner │ Config Loader │ Plugins │
└─────────────────────────────────────────────────────────────────┘
Component Relationships (Dependency Graph)
Config ──────────────────► Editor
│
┌──────────────┤
│ │
BufferManager KeyBindings
│
┌──────┤
│ │
Buffer Buffer (one per open file)
│
┌────┴────────┐
│ │
PieceTable UndoStack
│
Commands[]
GoF Patterns Used in This Project (Quick Map)
| Pattern | Category | Where used |
|---|---|---|
| Command | Behavioral | Undo/Redo system |
| Observer | Behavioral | Event bus between components |
| State | Behavioral | Editor modes (Normal, Insert, Visual) |
| Strategy | Behavioral | Syntax highlighting engines, diff algorithms |
| Decorator | Structural | Buffer decorations, diagnostic overlays |
| Composite | Structural | Workspace tree, document AST |
| Facade | Structural | LSP client API |
| Factory Method | Creational | Buffer creation, language detector |
| Singleton | Creational | Event bus, config registry (use sparingly) |
| Iterator | Behavioral | Buffer line/character traversal |
| Chain of Responsibility | Behavioral | Keybinding processing pipeline |
| Flyweight | Structural | Token color caching, glyph reuse |
Checklist
- Draw the full layer diagram for your implementation before writing any code.
- Define each layer’s public interface (the abstraction) before implementing it.
- Establish the rule: upper layers depend on lower layers; lower layers never import upper layers.
- Choose your rendering target early: terminal (TUI), native GUI (OpenGL/Metal/D2D), or web (Canvas/WebGL). This affects only the View layer — the core remains identical.
- Decide on your concurrency model: async I/O for LSP, synchronous editing core.
2. The Buffer — Storing Text Efficiently
Why a Naive String Doesn’t Work
The most obvious representation — a single string or array of characters — fails badly for a text editor:
// Naive: insert 'X' at position 50,000 in a 1 MB file
buffer = buffer[0..50000] + 'X' + buffer[50000..]
// This copies ~950,000 characters on every single keystroke.
// At 60 WPM, the editor becomes unusable within seconds.
Three Real-World Approaches
| Structure | Insert | Delete | Access by line | Memory | Used by |
|---|---|---|---|---|---|
| Gap Buffer | O(1) near gap, O(n) far | O(1) near gap | O(n) | Low | Emacs |
| Rope | O(log n) | O(log n) | O(log n) | Medium | Xi editor |
| Piece Table | O(1) amortized | O(1) amortized | O(log n) | Very Low | VS Code, Word |
This guide uses the Piece Table — it is the most elegant, handles large files effortlessly, and maps beautifully to the Command pattern for undo/redo.
The Buffer Abstraction (Interface Segregation Principle)
Before implementing anything, define the interface. Clients depend only on the abstract Buffer, not on the concrete PieceTableBuffer (Dependency Inversion Principle).
INTERFACE Buffer:
// Queries (read-only; can be called by any component)
get_char_at(offset) → character
get_line(line_number) → string
get_line_count() → integer
get_length() → integer
get_text(start_offset, length) → string
offset_to_line_col(offset) → (line, column)
line_col_to_offset(line, col) → offset
// Mutations (only called through Commands in Step 5)
insert(offset, text) → void
delete(offset, length) → void
replace(offset, length, text) → void
// Events (fires Observer notifications; see Step 6)
on_change(callback) → subscription_handle
ISP note: The
Bufferinterface is deliberately split. Read-only consumers (the renderer, syntax highlighter) only receive aReadableBuffersub-interface. Only the command system receives the full mutableBuffer. This prevents accidental mutation from the wrong layer.
Checklist
- Define the
Bufferinterface with clean separation of reads vs. writes. - Define a
ReadableBuffersub-interface for consumers that must not mutate. - Ensure
offset_to_line_colandline_col_to_offsetare O(log n) or better (use a cached line-start index). - Maintain a
line_startsarray: a sorted list of byte offsets where each line begins. - Update
line_startsincrementally on each mutation rather than recomputing from scratch. - Handle all newline conventions:
\n(Unix),\r\n(Windows),\r(old Mac). - Handle multi-byte Unicode (UTF-8): never split a codepoint, track both byte offsets and codepoint offsets.
3. The Piece Table — The Heart of Editing
What the Piece Table Is
The Piece Table stores text as a set of references (“pieces”) into two immutable buffers: the original buffer (the file as it was when opened, never modified) and the add buffer (all new text ever typed, append-only). Every edit creates or modifies pieces; no text is ever moved or deleted from these buffers.
Two immutable text buffers:
┌───────────────────────────────────────┐
│ ORIGINAL: "Hello, World!\nGoodbye.\n" │ ← Never modified after load
└───────────────────────────────────────┘
┌───────────────────────────────────────┐
│ ADD: "Cruel \nDear " │ ← Append-only; grows as you type
└───────────────────────────────────────┘
The document's logical text = ordered list of Pieces:
┌─────────────────────────────────────────────────────────┐
│ Piece 1: ORIGINAL, offset=0, length=7 → "Hello, " │
│ Piece 2: ADD, offset=0, length=6 → "Cruel " │
│ Piece 3: ORIGINAL, offset=7, length=6 → "World!" │
│ Piece 4: ORIGINAL, offset=13, length=1 → "\n" │
└─────────────────────────────────────────────────────────┘
Reading pieces in order gives: "Hello, Cruel World!\n"
Core Data Structures
STRUCT Piece:
source: ENUM { ORIGINAL, ADD }
offset: integer // byte offset into the source buffer
length: integer // number of bytes in this piece
STRUCT PieceTable:
original: string // loaded from disk, immutable
add: string // append-only new text
pieces: RedBlackTree<Piece> // ordered by logical position
// key = cumulative length up to this piece
Using a Red-Black Tree (or B-Tree) rather than a linked list gives O(log n) insert, delete, and access by logical position. A simple linked list is acceptable for files under ~10 MB.
Insert Operation
FUNCTION insert(offset, text):
// 1. Append new text to the add buffer
add_buffer_offset = len(add_buffer)
add_buffer += text
// 2. Find which piece 'offset' falls inside
(piece, offset_within_piece) = find_piece_at(offset)
// 3. Split the existing piece at offset_within_piece
left_piece = Piece(piece.source, piece.offset, offset_within_piece)
new_piece = Piece(ADD, add_buffer_offset, len(text))
right_piece = Piece(piece.source,
piece.offset + offset_within_piece,
piece.length - offset_within_piece)
// 4. Replace the original piece with [left, new, right] in the tree
pieces.replace(piece, [left_piece, new_piece, right_piece])
// 5. Fire change event (Step 6)
emit_change(ChangeEvent(INSERT, offset, text))
Delete Operation
FUNCTION delete(offset, length):
// Find all pieces that overlap [offset, offset+length)
affected_pieces = find_pieces_in_range(offset, offset + length)
// For partial overlaps at the edges, trim those pieces
// For fully-covered pieces, remove them entirely
new_pieces = []
for piece in affected_pieces:
left_trim = max(0, offset - piece_start(piece))
right_trim = max(0, piece_end(piece) - (offset + length))
if left_trim > 0:
new_pieces.add(Piece(piece.source, piece.offset, left_trim))
if right_trim > 0:
new_pieces.add(Piece(piece.source,
piece.offset + piece.length - right_trim,
right_trim))
pieces.replace_range(affected_pieces, new_pieces)
emit_change(ChangeEvent(DELETE, offset, length))
Why This Is Perfect for Undo/Redo
Undo is as simple as storing the previous piece list state (or the inverse operation). Because the original and add buffers are never modified, you never need to “un-delete” bytes — you just restore the piece arrangement. This will be fully exploited in Step 5.
Checklist
- Implement
PieceTablewith anoriginalstring and an append-onlyaddstring. - Implement
find_piece_at(offset)that returns a piece and the offset within it. - Implement
insert(offset, text)with piece splitting. - Implement
delete(offset, length)with piece trimming. - Implement
get_text(start, length)by iterating pieces and concatenating spans. - Maintain and update the
line_startsindex after every mutation. - Use a Red-Black Tree or skip list if targeting files > 10 MB; a doubly-linked list is acceptable otherwise.
- Add
pieces_snapshot()that returns a cheap copy of the piece list (used by undo). - Write exhaustive unit tests: insert at start, end, middle; delete spanning multiple pieces; insert/delete/insert sequences.
4. The Cursor & Selection Model
What the Cursor Model Does
The cursor is not just a blinking line — it is the central state of the editing experience. It must track position, support multiple cursors, model selections, and respond to buffer changes by adjusting its position.
Position vs. Offset
The cursor deals in two coordinate systems that must be kept synchronized:
STRUCT Position:
line: integer // 0-indexed line number
column: integer // 0-indexed column in that line (in codepoints, not bytes)
STRUCT Offset:
byte_offset: integer // absolute byte offset in the buffer
Always store the cursor’s canonical position as a Position (line, column) rather than a raw byte offset. When the buffer changes above the cursor, only the line needs adjusting. When changes happen on the cursor’s line, column adjusts. Using a raw byte offset forces expensive re-parsing after every distant edit.
The Selection Model
A selection is a pair of positions: an anchor (where the selection started) and a head (the current cursor position). Text between anchor and head is “selected”. If anchor == head, there is no selection (cursor only).
STRUCT Selection:
anchor: Position // Fixed; where selection started
head: Position // Moves as the cursor moves
mode: ENUM { CHARACTER, LINE, BLOCK } // for block/column selection
FUNCTION is_empty() → (anchor == head)
FUNCTION normalized() → (min, max) of (anchor, head)
FUNCTION to_range() → Range(start_offset, end_offset)
Multiple Cursors
Multiple cursors (popularized by Sublime Text) are modeled as an ordered set of Selection objects. Every editing command applies to all cursors independently, in reverse-offset order (so that earlier cursors’ edits don’t shift the offsets of later ones).
STRUCT CursorSet:
cursors: List<Selection> // kept sorted by head position
primary: integer // index of the "main" cursor
FUNCTION add_cursor(pos)
FUNCTION remove_cursor(index)
FUNCTION merge_overlapping() // collapse cursors whose selections overlap
FUNCTION apply_each(command) // apply editing command to all cursors, reverse order
Cursor Adjustment After Buffer Changes
When text is inserted or deleted at some position, cursors at or after that position must shift. This is handled by the Observer pattern (Step 6): the cursor set subscribes to buffer change events and adjusts automatically.
FUNCTION on_buffer_change(event):
for cursor in cursors:
if event.type == INSERT:
if cursor.head >= event.offset:
cursor.head = cursor.head + event.length
cursor.anchor = cursor.anchor + event.length (if also after offset)
if event.type == DELETE:
if cursor.head > event.offset + event.length:
cursor.head = cursor.head - event.length
elif cursor.head > event.offset:
cursor.head = event.offset // cursor was inside deleted range
Checklist
- Define
Position,Offset, andSelectionstructs. - Implement bidirectional conversion:
position_to_offset()andoffset_to_position(). - Implement cursor movement:
move_left/right/up/down,move_word_left/right,move_to_line_start/end,move_to_file_start/end. - Implement selection extension: same as movement, but moves
headwhile keepinganchor. - Implement
CursorSetwith multi-cursor add, remove, and merge. - Subscribe cursor adjustment to buffer change events.
- Implement “desired column” (sticky column): when moving vertically, remember the column you came from so short lines don’t permanently shift your column.
- Handle column selection (block/rectangular selection).
5. The Command Pattern — Undo & Redo
GoF Pattern: Command
Intent: Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations. — Gang of Four, Design Patterns, p.233
This is the most important pattern in the entire editor. Every mutation to the buffer — every keystroke, every paste, every find-and-replace — is a Command object. The UndoStack is simply a stack of executed commands.
The Command Interface
INTERFACE EditCommand:
execute(buffer) → void // Apply the edit to the buffer
undo(buffer) → void // Reverse the edit on the buffer
description() → string // Human-readable name: "Insert 'hello'"
can_merge(other: EditCommand) → boolean // Can this command be merged with the next?
merge(other: EditCommand) → EditCommand // Merge two commands into one
can_merge is the key to “feel” — rapid typing should collapse into one undoable action (“typed ‘hello’”) rather than five individual character insertions.
Concrete Commands
CLASS InsertCommand IMPLEMENTS EditCommand:
FIELDS:
offset: integer
text: string
// Saved state for undo:
previous_pieces: PieceSnapshot // cheap copy of piece list before insert
execute(buffer):
previous_pieces = buffer.pieces_snapshot()
buffer.insert(offset, text)
undo(buffer):
buffer.restore_pieces(previous_pieces)
can_merge(other):
// Merge if the other is also an InsertCommand immediately after this one
// and neither involves a newline (newlines always break undo groups)
RETURN (other IS InsertCommand)
AND (other.offset == self.offset + len(self.text))
AND ('\n' NOT IN self.text)
AND ('\n' NOT IN other.text)
merge(other):
RETURN InsertCommand(self.offset, self.text + other.text)
CLASS DeleteCommand IMPLEMENTS EditCommand:
FIELDS:
offset: integer
length: integer
deleted_text: string // saved for undo
previous_pieces: PieceSnapshot
execute(buffer):
deleted_text = buffer.get_text(offset, length)
previous_pieces = buffer.pieces_snapshot()
buffer.delete(offset, length)
undo(buffer):
buffer.restore_pieces(previous_pieces)
CLASS CompositeCommand IMPLEMENTS EditCommand: // GoF: Composite Pattern
FIELDS:
commands: List<EditCommand>
description: string
execute(buffer):
FOR cmd IN commands: cmd.execute(buffer)
undo(buffer):
FOR cmd IN REVERSE(commands): cmd.undo(buffer)
The Undo Stack
CLASS UndoStack:
FIELDS:
undo_stack: List<EditCommand>
redo_stack: List<EditCommand>
MAX_DEPTH: integer // e.g., 10,000
FUNCTION execute(command, buffer):
command.execute(buffer)
// Try to merge with the top of the undo stack
IF undo_stack is not empty AND undo_stack.top.can_merge(command):
merged = undo_stack.pop().merge(command)
undo_stack.push(merged)
ELSE:
undo_stack.push(command)
// Any new action discards the redo history
redo_stack.clear()
// Enforce depth limit
IF len(undo_stack) > MAX_DEPTH:
undo_stack.remove_bottom()
FUNCTION undo(buffer):
IF undo_stack is empty: RETURN
command = undo_stack.pop()
command.undo(buffer)
redo_stack.push(command)
FUNCTION redo(buffer):
IF redo_stack is empty: RETURN
command = redo_stack.pop()
command.execute(buffer)
undo_stack.push(command)
Transaction Grouping
Some operations — like find-and-replace across 500 occurrences — should be a single undoable action. Transactions wrap multiple commands into a CompositeCommand:
FUNCTION begin_transaction(description):
transaction_commands = []
in_transaction = true
FUNCTION commit_transaction():
composite = CompositeCommand(transaction_commands, description)
execute(composite, buffer)
in_transaction = false
FUNCTION execute(command, buffer):
IF in_transaction:
transaction_commands.add(command)
command.execute(buffer)
ELSE:
// Normal path above
SOLID Analysis
- SRP: Each
Commandclass has exactly one reason to change: when the semantics of that specific edit changes. - OCP: Adding new operations (e.g.,
IndentCommand,SortLinesCommand) requires only a new class, not modifications toUndoStack. - LSP: Any
EditCommandcan be stored in the undo stack. ANoOpCommand(for read-only buffers) is a valid substitution.
Checklist
- Define the
EditCommandinterface withexecute,undo,can_merge,merge. - Implement
InsertCommandandDeleteCommandusingPieceSnapshotfor undo. - Implement
CompositeCommandfor grouping (GoF Composite). - Implement
UndoStackwith configurable depth limit. - Implement command merging for consecutive character inserts.
- Implement
begin_transaction/commit_transactionfor bulk operations. - Implement
ReplaceCommandas anInsertCommand+DeleteCommandcomposite. - Ensure the redo stack is cleared on any new non-undo action.
- Test undo/redo across 1000+ operations without memory explosion.
6. The Observer Pattern — Event System
GoF Pattern: Observer
Intent: Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. — Gang of Four, Design Patterns, p.293
Every component in the editor — the renderer, the syntax highlighter, the LSP client, the cursor set, the status bar — needs to react to changes in other components. Hard-coding these dependencies (e.g., buffer.insert() directly calling renderer.invalidate()) violates both SRP and DIP. An event bus decouples producers from consumers entirely.
Event Types
ENUM EventType:
BUFFER_CHANGED // text was inserted/deleted/replaced
CURSOR_MOVED // a cursor position changed
SELECTION_CHANGED // a selection was created or changed
FILE_OPENED // a new file was loaded into a buffer
FILE_SAVED // a buffer was written to disk
FILE_CLOSED // a buffer was closed
MODE_CHANGED // editor mode changed (normal → insert, etc.)
CONFIG_CHANGED // a configuration value changed
DIAGNOSTIC_RECEIVED // LSP sent a diagnostic (error, warning, etc.)
COMPLETION_READY // LSP completion results are available
THEME_CHANGED // color theme was switched
FOCUS_CHANGED // which buffer/pane is focused
The Event Structs
STRUCT BufferChangedEvent:
buffer_id: BufferID
change_type: ENUM { INSERT, DELETE, REPLACE }
offset: integer
old_length: integer
new_text: string
version: integer // monotonically increasing; used by LSP for sync
STRUCT CursorMovedEvent:
buffer_id: BufferID
cursors: List<Selection>
STRUCT DiagnosticEvent:
buffer_id: BufferID
uri: string
diagnostics: List<Diagnostic> // each has range, severity, message
The Event Bus
CLASS EventBus: // GoF Singleton — but injected, not globally accessed
FIELDS:
subscribers: Map<EventType, List<Callable>>
FUNCTION subscribe(event_type, callback) → SubscriptionToken:
subscribers[event_type].add(callback)
RETURN token that can be used to unsubscribe
FUNCTION unsubscribe(token):
Remove callback associated with token
FUNCTION emit(event):
FOR callback IN subscribers[event.type]:
callback(event) // synchronous; use async queuing for heavy work
FUNCTION emit_async(event):
queue.enqueue(event) // processed on next event loop tick
Subscription Lifecycle (Avoiding Memory Leaks)
Every subscriber must unsubscribe when it is destroyed. Use RAII (or equivalent) to guarantee this:
CLASS BufferView:
FIELDS:
subscriptions: List<SubscriptionToken>
CONSTRUCTOR(buffer, event_bus):
subscriptions.add(event_bus.subscribe(BUFFER_CHANGED, self.on_buffer_change))
subscriptions.add(event_bus.subscribe(CURSOR_MOVED, self.on_cursor_move))
DESTRUCTOR:
FOR token IN subscriptions: event_bus.unsubscribe(token)
SOLID Analysis
- OCP: New consumers (a new plugin, a new status bar widget) subscribe to events without touching any existing producer.
- DIP:
Bufferemits events through theEventBusabstraction; it has no knowledge of who subscribes. - SRP: Each event handler has one responsibility — reacting to one specific event type.
Checklist
- Define all
EventTypevariants and their corresponding event structs. - Implement
EventBuswith subscribe/unsubscribe/emit. - Implement
SubscriptionTokenthat auto-unsubscribes on destruction (RAII). - Make
EventBusinjectable (not a true global singleton) so components can be unit-tested with mock buses. - Add async/deferred emission for expensive operations (syntax re-highlighting, LSP sync) to avoid blocking the editing loop.
- Add event batching: coalesce rapid
BUFFER_CHANGEDevents before re-triggering the syntax highlighter. - Add debug logging mode that prints all emitted events (invaluable for debugging).
7. The View Layer — Rendering the Buffer
Separation of Model and View (MVC)
The Buffer and Cursor are the Model — pure data, no knowledge of how they are displayed. The BufferView is the View — it reads from the model and draws to the screen. A Controller (the keybinding system, Step 18) translates user input into commands.
This enforces SRP: the buffer never needs to change because the font size changed.
The Rendering Pipeline
BufferView.render():
1. Determine visible line range from viewport (Step 8)
2. For each visible line:
a. Get line text from buffer
b. Apply syntax highlight tokens → list of (text_span, color)
c. Apply diagnostic decorations (underlines, squiggles)
d. Apply selection highlights
e. Draw each span with its color to the screen
3. Draw the cursor(s) on top
4. Draw the gutter (line numbers, breakpoint markers, git change indicators)
5. Draw the scrollbar
The Decoration System (GoF: Decorator Pattern)
Intent: Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality. — Gang of Four, Design Patterns, p.175
Rather than adding boolean flags to every line (has_error, is_selected, is_breakpoint…), decorations are layered objects applied on top of the base rendered text. Each decoration is independent and composable.
INTERFACE LineDecoration:
priority(): integer // Lower = drawn first (underneath)
applies_to(line_number): boolean
decorate(line_text, render_context): void
CLASS SelectionDecoration IMPLEMENTS LineDecoration:
// Draws a highlight rectangle behind selected characters
CLASS DiagnosticDecoration IMPLEMENTS LineDecoration:
// Draws a squiggly underline under ranges with errors/warnings
CLASS SearchMatchDecoration IMPLEMENTS LineDecoration:
// Highlights search result ranges in the current buffer
CLASS GitChangeDecoration IMPLEMENTS LineDecoration:
// Draws colored bars in the gutter for added/modified/deleted lines
The LineRenderer applies all registered decorations in priority order:
CLASS LineRenderer:
decorations: List<LineDecoration> // sorted by priority
FUNCTION render_line(line_number, line_text):
render_context = RenderContext(line_text)
FOR decoration IN decorations:
IF decoration.applies_to(line_number):
decoration.decorate(line_text, render_context)
draw(render_context)
Flyweight for Glyph Rendering (GoF: Flyweight Pattern)
Intent: Use sharing to efficiently support large numbers of fine-grained objects. — Gang of Four, Design Patterns, p.195
Characters at different positions share the same glyph metrics (width, height, advance). Measuring and caching each character’s dimensions in a GlyphCache avoids re-rendering the same character shape thousands of times per frame.
CLASS GlyphCache: // GoF Flyweight
cache: Map<(codepoint, font, size), GlyphMetrics>
FUNCTION get_metrics(codepoint, font, size):
IF NOT in cache:
cache[key] = measure_glyph(codepoint, font, size)
RETURN cache[key]
Checklist
- Implement
BufferViewthat reads from aReadableBufferand aViewport(never writes to the buffer directly). - Implement the
LineDecorationinterface and at least:SelectionDecoration,DiagnosticDecoration. - Implement
LineRendererwith a sorted decoration list. - Implement
GlyphCache(Flyweight) for character measurement. - Implement a
RenderContextstruct that accumulates draw calls (spans, backgrounds, underlines) before flushing to the screen. - Ensure rendering is idempotent — calling
render()twice with no state change produces the same output. - Implement dirty-region tracking: only re-render lines that have actually changed.
- Implement the gutter: line numbers, git diff indicators, breakpoints, fold arrows.
What the Viewport Is
The viewport is a window into the buffer — it defines which lines and columns are currently visible on screen. It is not part of the buffer (the buffer has no concept of “visible”) — it is state owned by the BufferView.
STRUCT Viewport:
first_visible_line: integer // top line visible in the view
first_visible_column: integer // leftmost column visible (for horizontal scroll)
visible_lines: integer // how many lines fit on screen (height / line_height)
visible_columns: integer // how many columns fit (width / char_width)
Scroll-Into-View Logic
When the cursor moves outside the current viewport, the viewport must scroll to keep it visible. The standard heuristic is:
FUNCTION scroll_to_cursor(cursor_position):
// Vertical: ensure cursor line is in [first + padding, first + visible - padding]
padding = 3 // "scrolloff" lines — keep cursor away from edges
IF cursor_position.line < viewport.first_visible_line + padding:
viewport.first_visible_line = cursor_position.line - padding
IF cursor_position.line > viewport.first_visible_line + viewport.visible_lines - padding:
viewport.first_visible_line = cursor_position.line - viewport.visible_lines + padding
// Horizontal: similar logic for column
// Clamp to valid range
viewport.first_visible_line = clamp(viewport.first_visible_line, 0, max_scroll_line)
Virtual Scrolling for Large Files
For files with millions of lines, you must never render or even iterate all lines. The viewport’s first_visible_line and visible_lines directly determine what to render:
FUNCTION get_visible_lines():
start = viewport.first_visible_line
end = min(start + viewport.visible_lines, buffer.get_line_count())
RETURN range(start, end)
// At no point is the entire buffer iterated.
Checklist
- Define the
Viewportstruct. - Implement
scroll_to_cursor()with configurable padding (“scrolloff”). - Implement
scroll_by_lines(n)andscroll_by_pages(n). - Implement
scroll_to_line(n)(used by “go to line” feature). - Implement virtual scrolling: render only lines in
[first_visible, first_visible + visible_count). - Implement horizontal scrolling for long lines (or word-wrap as an alternative).
- Calculate scrollbar thumb size and position from
(viewport_height / total_content_height). - Handle window resize events: recompute
visible_linesandvisible_columns, re-scroll to keep cursor in view.
9. Syntax Highlighting
GoF Pattern: Strategy
Intent: Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from the clients that use it. — Gang of Four, Design Patterns, p.315
Different languages need different highlighting algorithms. The SyntaxHighlighter interface is the Strategy; concrete implementations (regex-based, TextMate grammar, Tree-sitter) are interchangeable strategies.
INTERFACE SyntaxHighlighter:
highlight(text: string, from_line: integer, to_line: integer)
→ List<Token>
// Incremental update: only re-highlight affected lines
update(change: BufferChangedEvent)
→ List<integer> // list of line numbers that need re-rendering
CLASS RegexHighlighter IMPLEMENTS SyntaxHighlighter:
// Simple: one regex per token type. Fast but can't handle nesting.
CLASS TextMateGrammarHighlighter IMPLEMENTS SyntaxHighlighter:
// Uses .tmLanguage grammar files. Handles basic scoping via regex stacks.
// Used by VS Code for most languages.
CLASS TreeSitterHighlighter IMPLEMENTS SyntaxHighlighter:
// Parses into a full concrete syntax tree (CST). Incremental reparsing.
// Handles complex nesting (strings inside regexes, heredocs, etc.)
// Best quality but requires a grammar per language.
Token Model
STRUCT Token:
start_offset: integer // byte offset in the line
end_offset: integer
scope: string // e.g., "keyword.control", "string.quoted.double"
// scopes follow TextMate naming convention
STRUCT ThemeRule:
scope_pattern: string // e.g., "keyword.*" or "string"
foreground: Color
background: Color
bold: boolean
italic: boolean
Token-to-Color Resolution (GoF: Chain of Responsibility)
A theme defines rules ordered from most specific to least specific. Resolving a token’s color is a chain-of-responsibility walk:
FUNCTION resolve_color(token_scope, theme_rules):
// Walk rules from most specific to most general
FOR rule IN theme_rules SORTED BY specificity DESCENDING:
IF token_scope MATCHES rule.scope_pattern:
RETURN rule.foreground
RETURN default_foreground_color
Incremental Highlighting
Re-highlighting the entire file on every keystroke is unacceptable. The strategy is:
- Maintain a cache of highlight results per line.
- On
BUFFER_CHANGED, invalidate only affected lines (and potentially lines below, for multi-line constructs like block comments). - Re-highlight only the currently visible lines; defer the rest.
CLASS HighlightCache:
FIELDS:
line_tokens: Map<integer, List<Token>> // line → tokens
dirty_lines: Set<integer>
FUNCTION invalidate(from_line, to_line):
FOR line IN range(from_line, to_line + 1):
dirty_lines.add(line)
FUNCTION get_tokens(line):
IF line IN dirty_lines:
line_tokens[line] = highlighter.highlight(get_line_text(line))
dirty_lines.remove(line)
RETURN line_tokens[line]
Checklist
- Define the
SyntaxHighlighterstrategy interface. - Implement a basic
RegexHighlighterfor at least one language (good for testing the pipeline). - Implement TextMate grammar loading (JSON/XML
.tmLanguageformat) — this unlocks hundreds of community grammars. - Define the
Tokenstruct with TextMate scope naming. - Implement theme rule resolution with scope specificity ordering.
- Implement
HighlightCachewith dirty-line tracking. - Subscribe
HighlightCache.invalidate()toBUFFER_CHANGEDevents. - Only re-highlight visible lines on each render frame.
- Propagate invalidation downward through multi-line constructs (block comments, template strings).
10. The Mode System (Modal Editing)
GoF Pattern: State
Intent: Allow an object to alter its behavior when its internal state changes. The object will appear to change its class. — Gang of Four, Design Patterns, p.305
Modal editors (Vim, Helix) treat the editor as a finite state machine. The same keypress does completely different things depending on the current mode. Without the State pattern, this becomes an unmaintainable forest of if mode == NORMAL / else if mode == INSERT / ... branches everywhere.
INTERFACE EditorMode:
name(): string
on_key(key_event): void // handle a keypress in this mode
on_enter(): void // called when entering this mode
on_exit(): void // called when leaving this mode
cursor_style(): ENUM { BLOCK, BAR, UNDERLINE }
CLASS NormalMode IMPLEMENTS EditorMode:
// 'i' → switch to InsertMode
// 'h','j','k','l' → move cursor
// 'dd' → delete line (accumulates motion + operator)
// ':' → switch to CommandMode
// 'v' → switch to VisualMode
CLASS InsertMode IMPLEMENTS EditorMode:
// Printable characters → insert into buffer via InsertCommand
// Escape → switch back to NormalMode
// Arrow keys → move cursor
CLASS VisualMode IMPLEMENTS EditorMode:
// 'y' → yank (copy) selection
// 'd' → delete selection
// '>' / '<' → indent / dedent
CLASS CommandMode IMPLEMENTS EditorMode:
// ':w' → save file
// ':q' → quit
// '/<pattern>' → enter search
The Mode Controller
CLASS ModeController:
FIELDS:
modes: Map<ModeName, EditorMode>
current_mode: EditorMode
FUNCTION switch_to(mode_name):
current_mode.on_exit()
current_mode = modes[mode_name]
current_mode.on_enter()
emit(ModeChangedEvent(mode_name))
FUNCTION on_key(key_event):
current_mode.on_key(key_event)
Operator-Motion Model (Vim Grammar)
Vim’s editing language is [count][operator][motion]. This is itself a small state machine within NormalMode:
CLASS NormalMode:
pending_operator: ENUM { NONE, DELETE, YANK, CHANGE, ... }
pending_count: integer
on_key(key):
IF key IS digit:
pending_count = pending_count * 10 + key
ELIF key IS operator (d, y, c, ...):
pending_operator = operator
ELIF key IS motion (w, b, e, $, 0, ...):
range = resolve_motion(key, pending_count)
IF pending_operator != NONE:
apply_operator(pending_operator, range)
pending_operator = NONE; pending_count = 0
ELSE:
move_cursor(range.end)
Checklist
- Define the
EditorModeinterface withon_key,on_enter,on_exit,cursor_style. - Implement
NormalMode,InsertMode(required minimum for modal editing). - Implement
CommandMode(the:command line) as anEditorMode. - Implement
VisualModeandVisualLineMode. - Implement
ModeControllerthat manages transitions. - Emit
MODE_CHANGEDevents so the status bar and cursor renderer can update. - Implement the operator-motion accumulator in
NormalMode. - Handle mode-specific keybinding overrides (plugins may add new modes).
- For non-modal editors: use a single
EditModebut still benefit from the State pattern for sub-states like “in autocomplete”, “in search”, “in command palette”.
11. The Plugin Architecture
GoF Pattern: Factory Method + Observer
The plugin system must allow third-party code to extend the editor — adding new commands, new decorations, new modes, new language support — without modifying the core. This is the Open/Closed Principle at the architectural level.
The Plugin Interface
INTERFACE Plugin:
id(): string // Unique identifier: "myorg.my-plugin"
name(): string
version(): string
activate(context: PluginContext): void // Called when plugin loads
deactivate(): void // Called when plugin unloads
CLASS PluginContext:
// The context is the plugin's gateway to the editor's APIs
// It is the only thing plugins can access — they cannot import the editor core directly
FIELDS:
event_bus: EventBus // Subscribe to events
command_registry: CommandRegistry // Register new commands
keybinding_registry: KeybindingRegistry
decoration_registry: DecorationRegistry
status_bar: StatusBar
config: ConfigAPI
editor: EditorAPI // Limited editor control
The Plugin Registry (GoF: Factory Method)
CLASS PluginRegistry:
FIELDS:
plugins: Map<string, Plugin>
loaders: List<PluginLoader> // GoF Factory Method
FUNCTION register_loader(loader: PluginLoader):
loaders.add(loader)
FUNCTION load_plugin(plugin_id):
FOR loader IN loaders:
IF loader.can_load(plugin_id):
plugin = loader.load(plugin_id) // Factory Method
context = PluginContext(...)
plugin.activate(context)
plugins[plugin_id] = plugin
RETURN
INTERFACE PluginLoader:
can_load(plugin_id): boolean
load(plugin_id): Plugin
CLASS NativePluginLoader IMPLEMENTS PluginLoader // .so / .dll shared libraries
CLASS ScriptPluginLoader IMPLEMENTS PluginLoader // Lua, JavaScript, Python plugins
CLASS WasmPluginLoader IMPLEMENTS PluginLoader // WebAssembly sandboxed plugins
Why Multiple Loaders?
Different loader types provide different security/performance trade-offs. A WasmPluginLoader sandboxes plugins — they cannot access the filesystem or network directly. A ScriptPluginLoader runs Lua/JS with controlled API exposure. The editor core doesn’t care which loader handles a given plugin (OCP, DIP).
Checklist
- Define the
Plugininterface withactivate(context)anddeactivate(). - Define
PluginContextas the only API surface for plugins (no internal imports). - Implement
PluginRegistrywith pluggable loaders (Factory Method). - Implement at least one
PluginLoader(a script loader is simplest to start). - Implement a
CommandRegistrythat plugins use to register new commands. - Implement a
DecorationRegistrythat plugins use to register new line decorations. - Implement plugin lifecycle: load order, dependency resolution, error isolation.
- Ensure a crashing plugin cannot take down the editor (run in a separate thread/process if possible).
- Implement plugin settings: each plugin can declare configuration schema; the config system (Step 16) merges them.
12. The Language Server Protocol (LSP) Client
GoF Pattern: Facade + Proxy
Facade Intent: Provide a unified interface to a set of interfaces in a subsystem. — Gang of Four, Design Patterns, p.185
The LSP is a complex JSON-RPC protocol with dozens of methods, lifecycle events, and capability negotiation. The rest of the editor should not know about any of this — it should talk to a simple LanguageClient interface. The LSPClientImpl is a Facade over the raw protocol.
Additionally, the client is a Proxy — it lazily starts the language server process on demand and manages the connection transparently.
What LSP Is
The Language Server Protocol (LSP) was designed by Microsoft so that language intelligence (Go-to-definition, autocomplete, find references, rename, diagnostics) could be implemented once per language, and shared across all editors. The editor and the language server communicate over a local socket or stdin/stdout using JSON-RPC 2.0.
Editor Process Language Server Process
(LSP Client) (e.g., rust-analyzer, pyright, clangd)
│ │
│── initialize ──────────────────────────►│
│◄─ initializeResult ────────────────────│
│── initialized (notification) ─────────►│
│ │
│── textDocument/didOpen ───────────────►│
│── textDocument/didChange ─────────────►│
│◄─ textDocument/publishDiagnostics ─────│ (notification from server)
│ │
│── textDocument/completion ────────────►│
│◄─ CompletionList ──────────────────────│
│ │
│── textDocument/definition ────────────►│
│◄─ Location[] ──────────────────────────│
The LSP Client Interface (Facade)
INTERFACE LanguageClient:
// Lifecycle
start(): async void
stop(): async void
is_ready(): boolean
// Document synchronization
open_document(uri, language_id, version, text): async void
change_document(uri, version, changes): async void
close_document(uri): async void
save_document(uri, text): async void
// Language features (these send requests and return results)
get_completions(uri, position): async CompletionList
get_hover(uri, position): async HoverResult
get_definition(uri, position): async List<Location>
get_references(uri, position): async List<Location>
get_document_symbols(uri): async List<Symbol>
get_workspace_symbols(query): async List<Symbol>
get_code_actions(uri, range, context): async List<CodeAction>
apply_code_action(action): async void
get_rename_edits(uri, position, name): async WorkspaceEdit
format_document(uri, options): async List<TextEdit>
The Transport Layer
INTERFACE LSPTransport:
send(message: JsonRpcMessage): void
on_message(callback): void
close(): void
CLASS StdioTransport IMPLEMENTS LSPTransport:
// Spawns the server process; communicates via stdin/stdout
// Message format: "Content-Length: N\r\n\r\n{json...}"
CLASS SocketTransport IMPLEMENTS LSPTransport:
// Connects to a TCP socket (for remote language servers)
JSON-RPC Message Handling
CLASS JsonRpcConnection:
FIELDS:
transport: LSPTransport
pending_requests: Map<RequestID, Promise>
next_id: integer
FUNCTION send_request(method, params) → Promise:
id = next_id++
message = { "jsonrpc": "2.0", "id": id, "method": method, "params": params }
promise = new Promise()
pending_requests[id] = promise
transport.send(message)
RETURN promise // resolves when the response arrives
FUNCTION send_notification(method, params):
message = { "jsonrpc": "2.0", "method": method, "params": params }
transport.send(message)
FUNCTION on_message_received(message):
IF message has "id" AND message has "result":
// It's a response to our request
pending_requests[message.id].resolve(message.result)
pending_requests.remove(message.id)
ELIF message has "method" AND message has NO "id":
// It's a notification from the server (e.g., diagnostics)
dispatch_notification(message.method, message.params)
Server Lifecycle and Capability Negotiation
FUNCTION start():
process = spawn_server_process(server_command)
transport = StdioTransport(process.stdin, process.stdout)
connection = JsonRpcConnection(transport)
// The initialize handshake negotiates what features the server supports
capabilities = await connection.send_request("initialize", {
processId: current_process_id(),
rootUri: workspace_root_uri,
capabilities: CLIENT_CAPABILITIES // what we (the editor) can handle
})
server_capabilities = capabilities.result.capabilities
// Now we know if the server supports: completions, hover, go-to-def, etc.
connection.send_notification("initialized", {})
is_ready = true
Checklist
- Define the
LanguageClientinterface (Facade). - Implement
JsonRpcConnectionwith request/response matching by ID. - Implement
StdioTransport(spawn process, readContent-Lengthframed messages). - Implement the
initialize/initializedhandshake. - Implement
textDocument/didOpen,didChange,didClose,didSave(document sync). - Handle server-sent notifications:
textDocument/publishDiagnostics. - Implement automatic server restart on crash with exponential backoff.
- Support multiple simultaneous language servers (one per language).
- Register language servers by file extension / language ID in config.
- Implement
shutdown+exitsequence on editor close.
13. LSP Features — Autocomplete, Diagnostics & Code Actions
Diagnostics (Errors, Warnings, Hints)
The server sends textDocument/publishDiagnostics notifications asynchronously. The editor must:
- Receive the notification.
- Store the diagnostics for that file.
- Emit a
DIAGNOSTIC_RECEIVEDevent. - The
DiagnosticDecoration(Step 7) picks this up and renders squiggles on the next frame.
STRUCT Diagnostic:
range: Range // start and end position in the document
severity: ENUM { ERROR=1, WARNING=2, INFORMATION=3, HINT=4 }
code: string // e.g., "E0308" for Rust type mismatch
source: string // e.g., "rust-analyzer"
message: string // the human-readable error text
related: List<DiagnosticRelated> // other locations involved (e.g., conflicting type)
CLASS DiagnosticStore:
FIELDS:
diagnostics: Map<URI, List<Diagnostic>>
FUNCTION update(uri, diagnostics):
self.diagnostics[uri] = diagnostics
event_bus.emit(DiagnosticEvent(uri, diagnostics))
FUNCTION get(uri) → List<Diagnostic>:
RETURN self.diagnostics.get(uri, [])
FUNCTION get_at_position(uri, position) → List<Diagnostic>:
RETURN [d FOR d IN get(uri) IF d.range.contains(position)]
Autocomplete (GoF: Strategy for Ranking)
Completion is triggered either automatically (on typing ., (, or after a configurable delay) or manually (Ctrl+Space). The request is async; the editor must remain interactive while waiting.
CLASS CompletionController:
FIELDS:
language_client: LanguageClient
popup: CompletionPopup
pending_request: CancellationToken // cancel stale requests
FUNCTION trigger(uri, position):
// Cancel any previous in-flight completion request
IF pending_request is not null: pending_request.cancel()
pending_request = language_client.get_completions(uri, position)
results = await pending_request
IF results is cancelled: RETURN
ranked = rank_completions(results.items, current_word_prefix)
popup.show(ranked)
FUNCTION rank_completions(items, prefix):
// GoF Strategy: swap ranking algorithm (fuzzy match, prefix match, etc.)
RETURN completion_ranker.rank(items, prefix)
INTERFACE CompletionRanker: // Strategy
rank(items, prefix) → List<CompletionItem>
CLASS FuzzyRanker IMPLEMENTS CompletionRanker
CLASS PrefixRanker IMPLEMENTS CompletionRanker
CLASS CompletionPopup:
FIELDS:
items: List<CompletionItem>
selected_idx: integer
visible: boolean
FUNCTION show(items):
self.items = items
selected_idx = 0
visible = true
FUNCTION select_next(): selected_idx = (selected_idx + 1) % len(items)
FUNCTION select_prev(): selected_idx = (selected_idx - 1 + len(items)) % len(items)
FUNCTION accept():
item = items[selected_idx]
apply_completion(item) // may use item.textEdit or item.insertText
visible = false
FUNCTION dismiss(): visible = false
Hover (Documentation on Demand)
FUNCTION show_hover(uri, position):
result = await language_client.get_hover(uri, position)
IF result is null: RETURN
// result.contents is Markdown text
popup.show_markdown(result.contents, near_position=position)
Go-to-Definition / Find References
FUNCTION go_to_definition(uri, position):
locations = await language_client.get_definition(uri, position)
IF len(locations) == 1:
open_file_at(locations[0].uri, locations[0].range.start)
ELIF len(locations) > 1:
show_peek_window(locations) // show a list to pick from
FUNCTION find_references(uri, position):
locations = await language_client.get_references(uri, position)
open_references_panel(locations)
Code Actions (Quick Fixes)
FUNCTION show_code_actions(uri, position):
// Typically triggered when cursor is on a diagnostic or by Ctrl+.
context = CodeActionContext(diagnostics=get_diagnostics_at(position))
actions = await language_client.get_code_actions(uri, cursor_range, context)
show_action_picker(actions)
FUNCTION apply_code_action(action):
IF action.edit is not null:
apply_workspace_edit(action.edit) // may touch multiple files
IF action.command is not null:
language_client.execute_command(action.command)
Checklist
- Implement
DiagnosticStoreand subscribe topublishDiagnosticsnotifications. - Wire
DiagnosticDecoration(Step 7) to re-render when diagnostics update. - Implement
CompletionControllerwith async request + cancellation. - Implement the
CompletionPopupwith keyboard navigation (Tab/Enter to accept, Escape to dismiss). - Implement auto-trigger heuristics (trigger characters, idle timer).
- Implement
FuzzyRankerstrategy for ranking completion items. - Implement hover popup with Markdown rendering.
- Implement go-to-definition and go-to-type-definition.
- Implement find-all-references panel.
- Implement rename: get workspace edits, apply as a single undoable transaction.
- Implement format-document: apply
List<TextEdit>as a transaction. - Implement code actions with a quick-fix lightbulb icon in the gutter.
- Implement inlay hints (inline type annotations, parameter names) if the server supports them.
14. The File System & Buffer Manager
GoF Pattern: Factory Method
The BufferManager is the single source of truth for all open files. Opening a file always goes through it — never directly. It ensures the same file is never opened twice (deduplication), manages saving, handles external modifications, and creates new buffers via a factory method.
CLASS BufferManager:
FIELDS:
buffers: Map<BufferID, Buffer>
uri_to_id: Map<URI, BufferID>
next_id: integer
FUNCTION open(uri: URI) → Buffer:
// Deduplication: return existing buffer if already open
IF uri IN uri_to_id:
RETURN buffers[uri_to_id[uri]]
// Factory Method: create the right kind of buffer for this URI
buffer = buffer_factory.create(uri)
// Load content from disk
text = file_system.read(uri)
buffer.set_initial_content(text)
id = next_id++
buffers[id] = buffer
uri_to_id[uri] = id
event_bus.emit(FileOpenedEvent(id, uri))
RETURN buffer
FUNCTION save(buffer_id: BufferID):
buffer = buffers[buffer_id]
text = buffer.get_all_text()
file_system.write(buffer.uri, text)
buffer.mark_clean()
event_bus.emit(FileSavedEvent(buffer_id))
FUNCTION close(buffer_id: BufferID):
buffer = buffers[buffer_id]
IF buffer.is_dirty():
prompt_save_or_discard(buffer)
buffers.remove(buffer_id)
uri_to_id.remove(buffer.uri)
event_bus.emit(FileClosedEvent(buffer_id))
INTERFACE BufferFactory: // GoF Factory Method
create(uri: URI) → Buffer
CLASS StandardBufferFactory IMPLEMENTS BufferFactory:
// Creates a PieceTableBuffer for regular text files
CLASS ReadOnlyBufferFactory IMPLEMENTS BufferFactory:
// Creates a read-only buffer for things like terminal output, help pages
File System Abstraction (DIP)
The BufferManager depends on an abstract FileSystem, not on the OS file API directly. This enables testing (mock FS), virtual files, and remote file systems:
INTERFACE FileSystem:
read(uri: URI) → string
write(uri: URI, text) → void
exists(uri: URI) → boolean
watch(uri: URI, callback) → WatchHandle // notify of external changes
list_dir(uri: URI) → List<DirEntry>
CLASS LocalFileSystem IMPLEMENTS FileSystem
CLASS RemoteFileSystem IMPLEMENTS FileSystem // SSH, SFTP
CLASS VirtualFileSystem IMPLEMENTS FileSystem // In-memory, for testing
Handling External Modifications
When a file is changed on disk while open in the editor, the editor must detect this and offer to reload:
FUNCTION on_file_changed_externally(uri):
buffer_id = uri_to_id.get(uri)
IF buffer_id is null: RETURN // Not open in editor
IF buffer.is_dirty():
show_conflict_dialog(buffer) // "File changed on disk. Reload and lose changes?"
ELSE:
reload_buffer(buffer_id) // Safe to reload silently
Checklist
- Implement
BufferManagerwith open/save/close and deduplication. - Implement
BufferFactory(Factory Method) for different buffer types. - Implement
FileSystemabstraction with aLocalFileSystemand aVirtualFileSystem(for tests). - Implement file watching and external-modification detection.
- Track buffer “dirty” state (modified since last save); display in tab bar.
- Implement “save all” and “close all” operations.
- Handle encoding detection on open (UTF-8, UTF-16 BOM, Latin-1).
- Handle line ending normalization on open and restoration on save.
- Implement “new file” (creates a buffer with no URI until first save).
15. Search & Replace
GoF Pattern: Strategy (Search Algorithms) + Iterator (Match Traversal)
Search and replace is one of the most-used features of any editor. The architecture separates the search algorithm (what to match) from the traversal (how to walk matches in the buffer).
The Search Interface
INTERFACE SearchStrategy: // GoF Strategy
find_all(text: string, pattern: SearchQuery) → List<Match>
find_next(text, pattern, from_offset) → Match | null
STRUCT SearchQuery:
pattern: string
is_regex: boolean
is_case_sensitive: boolean
is_whole_word: boolean
STRUCT Match:
start_offset: integer
end_offset: integer
groups: List<string> // regex capture groups
CLASS LiteralSearch IMPLEMENTS SearchStrategy // fast; Boyer-Moore-Horspool
CLASS RegexSearch IMPLEMENTS SearchStrategy // uses regex engine
CLASS FuzzySearch IMPLEMENTS SearchStrategy // for command palette, file open
Match Iterator (GoF: Iterator)
CLASS MatchIterator: // GoF Iterator
FIELDS:
buffer: ReadableBuffer
strategy: SearchStrategy
query: SearchQuery
position: integer // current scan position
wrap_count: integer // how many times we've wrapped around
FUNCTION next() → Match | null:
match = strategy.find_next(buffer, query, position)
IF match is null:
IF wrap_count > 0: RETURN null // full loop, no results
wrap_count++
position = 0
RETURN next() // wrap around
position = match.end_offset
RETURN match
FUNCTION prev() → Match | null:
// Search backwards by scanning from 0 to current position
// and returning the last match found before current
The Search Controller
CLASS SearchController:
FIELDS:
buffer: ReadableBuffer
iterator: MatchIterator
all_matches: List<Match> // for highlighting all occurrences
highlight_decoration: SearchMatchDecoration
FUNCTION search(query: SearchQuery):
all_matches = strategy.find_all(buffer.get_all_text(), query)
highlight_decoration.set_matches(all_matches)
event_bus.emit(BUFFER_CHANGED) // triggers re-render with highlights
iterator = MatchIterator(buffer, strategy, query)
FUNCTION find_next():
match = iterator.next()
IF match: scroll_and_select(match)
FUNCTION replace_current(replacement: string):
match = current_match
IF match is null: RETURN
command = ReplaceCommand(match.start_offset,
match.end_offset - match.start_offset,
expand_replacement(replacement, match.groups))
undo_stack.execute(command, buffer)
FUNCTION replace_all(replacement: string):
undo_stack.begin_transaction("Replace All")
FOR match IN REVERSE(all_matches): // reverse to preserve offsets
command = ReplaceCommand(match.start_offset,
match.end_offset - match.start_offset,
expand_replacement(replacement, match.groups))
undo_stack.execute(command, buffer)
undo_stack.commit_transaction()
Checklist
- Define
SearchStrategyinterface withLiteralSearchandRegexSearchimplementations. - Implement
MatchIteratorwith forward and backward traversal. - Implement search-result highlighting as a
SearchMatchDecoration(Step 7). - Implement
SearchControllerwith search, find-next, find-prev, replace, replace-all. - Implement wrap-around searching with a “search wrapped” status bar message.
- Implement regex back-reference expansion in replacements (
\1,$1). - Implement
FuzzySearchfor the command palette and fuzzy file open. - Make replace-all a single undoable transaction (Step 5).
- Implement search across the entire project (multi-file search) using the file system.
16. Configuration System
GoF Pattern: Composite (Config Trees) + Observer (Change Propagation)
Configuration in a real editor has multiple layers that override each other:
DEFAULT CONFIG ← USER CONFIG ← WORKSPACE CONFIG ← LANGUAGE-SPECIFIC CONFIG
Each layer is a tree of key-value pairs. Looking up a value walks the tree from most specific to least specific.
STRUCT ConfigValue:
VARIANT: string | integer | float | boolean | List | Map
CLASS ConfigNode:
FIELDS:
values: Map<string, ConfigValue>
children: Map<string, ConfigNode> // GoF Composite
FUNCTION get(key_path: string) → ConfigValue | null:
// key_path: "editor.tabSize" → descend through children
parts = key_path.split('.')
node = self
FOR part IN parts[:-1]:
node = node.children.get(part)
IF node is null: RETURN null
RETURN node.values.get(parts[-1])
CLASS ConfigRegistry:
FIELDS:
layers: List<(LayerName, ConfigNode)> // ordered: default first, most specific last
FUNCTION get(key: string) → ConfigValue:
// Walk layers from most specific to least specific
FOR layer IN REVERSE(layers):
value = layer.config_node.get(key)
IF value is not null: RETURN value
RETURN null
FUNCTION set(layer_name, key, value):
layer = get_layer(layer_name)
layer.set(key, value)
event_bus.emit(ConfigChangedEvent(key, value))
FUNCTION load_file(layer_name, path):
json = file_system.read(path)
config = parse_json(json)
layers[layer_name] = ConfigNode.from_dict(config)
Config Schema and Validation
Each component declares its configuration schema. Unknown keys and type mismatches are detected on load, not at runtime:
STRUCT ConfigSchema:
key: string
type: ENUM { STRING, INTEGER, BOOLEAN, ARRAY, OBJECT }
default: ConfigValue
description: string
valid_values: List | null // for enum-like configs
// Registered by each component and plugin:
config_registry.register_schema("editor.tabSize",
ConfigSchema(type=INTEGER, default=4, description="Number of spaces per tab"))
Checklist
- Define
ConfigNodeandConfigRegistrywith layered lookup. - Implement config file loading from JSON/TOML/YAML (pick one format).
- Implement config schema declaration and validation.
- Emit
CONFIG_CHANGEDevents when a value changes. - Subscribe relevant components to
CONFIG_CHANGED(renderer listens foreditor.fontSize; syntax highlighter listens foreditor.theme). - Implement per-language config override (
[language "rust"]block). - Implement per-workspace config file (
.editorconfigstandard is a good start). - Implement config reloading on file save without editor restart.
17. The Workspace & Project Model
GoF Pattern: Composite (File Tree) + Iterator (Tree Traversal)
A workspace is a root directory plus zero or more project configurations. The editor needs a tree view of the file system and the ability to run workspace-wide operations (project-wide search, go-to-symbol, etc.).
INTERFACE WorkspaceNode: // GoF Composite
name(): string
uri(): URI
is_leaf(): boolean
CLASS FileNode IMPLEMENTS WorkspaceNode: // Leaf
is_leaf() → true
CLASS DirectoryNode IMPLEMENTS WorkspaceNode: // Composite
children: List<WorkspaceNode>
is_leaf() → false
FUNCTION add(node): children.add(node)
FUNCTION remove(node): children.remove(node)
CLASS Workspace:
FIELDS:
root: DirectoryNode
name: string
root_uri: URI
open_buffers: List<BufferID>
FUNCTION find(pattern: string) → Iterator<WorkspaceNode>:
// GoF Iterator over tree nodes matching a pattern
RETURN TreeIterator(root, predicate=lambda n: fuzzy_match(n.name(), pattern))
Project Detection
The editor automatically detects project type from marker files:
FUNCTION detect_project_type(root_uri) → ProjectType:
IF file_exists(root_uri + "/Cargo.toml"): RETURN RUST
IF file_exists(root_uri + "/package.json"): RETURN NODE
IF file_exists(root_uri + "/pyproject.toml"): RETURN PYTHON
IF file_exists(root_uri + "/go.mod"): RETURN GO
RETURN UNKNOWN
FUNCTION get_language_server_for_project(project_type) → string:
RETURN {
RUST: "rust-analyzer",
NODE: "typescript-language-server",
PYTHON: "pyright",
GO: "gopls",
}.get(project_type)
Checklist
- Implement
WorkspaceNode,FileNode,DirectoryNode(Composite). - Implement
TreeIteratorfor depth-first traversal. - Implement
Workspacethat builds its tree by scanning the file system. - Implement
.gitignoreand.ignorefiltering when scanning. - Implement
detect_project_type()and auto-start the appropriate LSP. - Implement workspace-wide symbol search via
workspace/symbolLSP request. - Watch the file system for new/deleted/renamed files and update the tree.
- Implement the file explorer panel using the Composite tree.
18. The Keybinding System
GoF Pattern: Chain of Responsibility
Intent: Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it. — Gang of Four, Design Patterns, p.223
A keypress must pass through multiple handlers in order. An autocomplete popup’s Tab key should be handled before the normal “insert tab” binding. A mode’s bindings override global bindings. Unhandled keys fall through the chain.
Key Event and Chord
STRUCT KeyEvent:
key: string // e.g., "a", "Enter", "F5", "Tab"
modifiers: Set<Modifier> // CTRL, ALT, SHIFT, META
is_repeat: boolean
STRUCT KeyChord:
keys: List<KeyEvent> // e.g., [Ctrl+K, Ctrl+F] for a two-key chord
The Keybinding Chain
INTERFACE KeyHandler: // GoF Chain of Responsibility
handle(event: KeyEvent) → boolean // true = consumed; false = pass to next
next_handler: KeyHandler | null
FUNCTION pass_to_next(event):
IF next_handler is not null: RETURN next_handler.handle(event)
RETURN false
CLASS CompletionPopupKeyHandler IMPLEMENTS KeyHandler:
handle(event):
IF popup.visible:
IF event.key == "Tab" or event.key == "Enter":
popup.accept(); RETURN true
IF event.key == "Escape":
popup.dismiss(); RETURN true
IF event.key == "ArrowUp":
popup.select_prev(); RETURN true
IF event.key == "ArrowDown":
popup.select_next(); RETURN true
RETURN pass_to_next(event)
CLASS ModeKeyHandler IMPLEMENTS KeyHandler:
handle(event):
IF mode_controller.current_mode.on_key(event):
RETURN true
RETURN pass_to_next(event)
CLASS GlobalKeyHandler IMPLEMENTS KeyHandler:
handle(event):
command = keybinding_registry.lookup(event)
IF command is not null:
command_registry.execute(command)
RETURN true
RETURN pass_to_next(event)
The Keybinding Registry
CLASS KeybindingRegistry:
FIELDS:
bindings: List<Keybinding>
chord_state: List<KeyEvent> // tracks multi-key chord in progress
STRUCT Keybinding:
chord: KeyChord
command_id: string
when: Condition | null // contextual condition
FUNCTION lookup(event: KeyEvent) → CommandID | null:
chord_state.add(event)
// Check if any binding starts with chord_state
matching = [b FOR b IN bindings IF b.chord STARTS WITH chord_state]
IF matching is empty:
chord_state.clear()
RETURN null
exact_matches = [b FOR b IN matching IF b.chord == chord_state
AND (b.when is null OR b.when.eval())]
IF exact_matches is not empty:
chord_state.clear()
RETURN exact_matches[0].command_id
// Partial match: waiting for next key in chord
RETURN CHORD_IN_PROGRESS
FUNCTION register(chord, command_id, when=null):
bindings.add(Keybinding(chord, command_id, when))
Checklist
- Define
KeyEventandKeyChordstructs. - Define the
KeyHandlerinterface (Chain of Responsibility). - Implement at least three handlers in the chain: popup handler, mode handler, global handler.
- Implement
KeybindingRegistrywith multi-key chord support. - Implement contextual bindings (
whenconditions:editorTextFocus,inlineCompletionVisible). - Implement
CommandRegistrythat maps string IDs to callables (decouples keybindings from implementations). - Allow plugins to register new keybindings (via
PluginContext). - Implement a “Show Keybindings” panel.
- Implement key remapping (users can override any binding in config).
19. Test Suites
Test Suite 1: Buffer & Undo/Redo
These tests validate the core data structure and editing mechanics. They should run entirely without a UI, screen, or file system.
TEST GROUP: PieceTable
TEST "insert at beginning":
buffer = PieceTableBuffer("World")
buffer.insert(0, "Hello, ")
ASSERT buffer.get_all_text() == "Hello, World"
TEST "insert at end":
buffer = PieceTableBuffer("Hello")
buffer.insert(5, ", World")
ASSERT buffer.get_all_text() == "Hello, World"
TEST "insert in middle":
buffer = PieceTableBuffer("Hell World")
buffer.insert(4, "o,")
ASSERT buffer.get_all_text() == "Hello, World"
TEST "delete from beginning":
buffer = PieceTableBuffer("XXXHello")
buffer.delete(0, 3)
ASSERT buffer.get_all_text() == "Hello"
TEST "delete from middle":
buffer = PieceTableBuffer("Hello, Cruel World")
buffer.delete(7, 6) // delete "Cruel "
ASSERT buffer.get_all_text() == "Hello, World"
TEST "delete spanning multiple pieces":
buffer = PieceTableBuffer("Hello, World")
buffer.insert(7, "Cruel ") // creates multiple pieces
buffer.delete(7, 6) // delete across piece boundary
ASSERT buffer.get_all_text() == "Hello, World"
TEST "line count is correct":
buffer = PieceTableBuffer("line1\nline2\nline3")
ASSERT buffer.get_line_count() == 3
TEST "get_line returns correct content":
buffer = PieceTableBuffer("alpha\nbeta\ngamma")
ASSERT buffer.get_line(0) == "alpha"
ASSERT buffer.get_line(1) == "beta"
ASSERT buffer.get_line(2) == "gamma"
TEST "offset_to_line_col is correct":
buffer = PieceTableBuffer("ab\ncd\nef")
ASSERT buffer.offset_to_line_col(0) == (0, 0)
ASSERT buffer.offset_to_line_col(3) == (1, 0)
ASSERT buffer.offset_to_line_col(4) == (1, 1)
TEST "get_text extracts correct range":
buffer = PieceTableBuffer("Hello, World!")
ASSERT buffer.get_text(7, 5) == "World"
TEST "large interleaved inserts and deletes":
buffer = PieceTableBuffer("")
FOR i FROM 0 TO 999:
buffer.insert(buffer.get_length(), "x")
FOR i FROM 0 TO 499:
buffer.delete(0, 1)
ASSERT buffer.get_length() == 500
ASSERT all characters == 'x'
TEST GROUP: UndoStack
TEST "undo insert":
buffer = PieceTableBuffer("Hello")
undo_stack = UndoStack()
undo_stack.execute(InsertCommand(5, ", World"), buffer)
ASSERT buffer.get_all_text() == "Hello, World"
undo_stack.undo(buffer)
ASSERT buffer.get_all_text() == "Hello"
TEST "undo delete":
buffer = PieceTableBuffer("Hello, World")
undo_stack = UndoStack()
undo_stack.execute(DeleteCommand(5, 7), buffer)
ASSERT buffer.get_all_text() == "Hello"
undo_stack.undo(buffer)
ASSERT buffer.get_all_text() == "Hello, World"
TEST "redo after undo":
buffer = PieceTableBuffer("Hello")
undo_stack = UndoStack()
undo_stack.execute(InsertCommand(5, "!"), buffer)
undo_stack.undo(buffer)
ASSERT buffer.get_all_text() == "Hello"
undo_stack.redo(buffer)
ASSERT buffer.get_all_text() == "Hello!"
TEST "new command clears redo stack":
buffer = PieceTableBuffer("Hello")
undo_stack = UndoStack()
undo_stack.execute(InsertCommand(5, "!"), buffer)
undo_stack.undo(buffer)
undo_stack.execute(InsertCommand(5, "?"), buffer)
undo_stack.redo(buffer) // redo stack is empty now
ASSERT buffer.get_all_text() == "Hello?"
TEST "consecutive character inserts are merged":
buffer = PieceTableBuffer("")
undo_stack = UndoStack()
undo_stack.execute(InsertCommand(0, "h"), buffer)
undo_stack.execute(InsertCommand(1, "i"), buffer)
undo_stack.execute(InsertCommand(2, "!"), buffer)
ASSERT len(undo_stack.undo_stack) == 1 // merged into one
undo_stack.undo(buffer)
ASSERT buffer.get_all_text() == ""
TEST "newline breaks merge boundary":
buffer = PieceTableBuffer("")
undo_stack = UndoStack()
undo_stack.execute(InsertCommand(0, "a"), buffer)
undo_stack.execute(InsertCommand(1, "\n"), buffer)
undo_stack.execute(InsertCommand(2, "b"), buffer)
ASSERT len(undo_stack.undo_stack) == 3 // not merged
TEST "transaction is single undo step":
buffer = PieceTableBuffer("aaa bbb ccc")
undo_stack = UndoStack()
undo_stack.begin_transaction("Replace all")
undo_stack.execute(ReplaceCommand(0, 3, "XXX"), buffer)
undo_stack.execute(ReplaceCommand(4, 3, "XXX"), buffer)
undo_stack.execute(ReplaceCommand(8, 3, "XXX"), buffer)
undo_stack.commit_transaction()
ASSERT buffer.get_all_text() == "XXX XXX XXX"
undo_stack.undo(buffer)
ASSERT buffer.get_all_text() == "aaa bbb ccc"
ASSERT len(undo_stack.undo_stack) == 0 // only one step
Test Suite 2: Cursor, LSP Integration & Search
TEST GROUP: Cursor Model
TEST "cursor moves right by one character":
buffer = PieceTableBuffer("Hello")
cursor = Cursor(Position(0, 0))
cursor.move_right(buffer)
ASSERT cursor.position == Position(0, 1)
TEST "cursor moves to next line at end of line":
buffer = PieceTableBuffer("Hi\nBye")
cursor = Cursor(Position(0, 2))
cursor.move_right(buffer)
ASSERT cursor.position == Position(1, 0)
TEST "cursor adjusts after insert before it":
buffer = PieceTableBuffer("World")
cursor = Cursor(Position(0, 0))
// Simulate subscribing cursor to buffer events
buffer.insert(0, "Hello, ")
// Cursor was at offset 0; insert was also at 0, so cursor should still
// point to 'W', which is now at offset 7
ASSERT cursor.position == Position(0, 7)
TEST "cursor adjusts after delete before it":
buffer = PieceTableBuffer("Hello, World")
cursor = Cursor(Position(0, 7)) // pointing at 'W'
buffer.delete(0, 7)
ASSERT cursor.position == Position(0, 0) // 'W' is now at offset 0
TEST "multiple cursors merge when overlapping":
cursor_set = CursorSet()
cursor_set.add_cursor(Position(0, 5))
cursor_set.add_cursor(Position(0, 5)) // duplicate
cursor_set.merge_overlapping()
ASSERT len(cursor_set.cursors) == 1
TEST GROUP: Search & Replace
TEST "literal search finds all matches":
buffer = PieceTableBuffer("foo bar foo baz foo")
results = LiteralSearch().find_all(buffer.get_all_text(),
SearchQuery("foo", is_regex=false))
ASSERT len(results) == 3
ASSERT results[0].start_offset == 0
ASSERT results[1].start_offset == 8
ASSERT results[2].start_offset == 16
TEST "regex search with capture group":
buffer = PieceTableBuffer("2024-01-15 and 2023-12-31")
results = RegexSearch().find_all(buffer.get_all_text(),
SearchQuery("(\d{4})-(\d{2})-(\d{2})", is_regex=true))
ASSERT len(results) == 2
ASSERT results[0].groups == ["2024", "01", "15"]
TEST "replace all is single undo step":
buffer = PieceTableBuffer("cat cat cat")
undo_stack = UndoStack()
controller = SearchController(buffer, undo_stack)
controller.search(SearchQuery("cat"))
controller.replace_all("dog")
ASSERT buffer.get_all_text() == "dog dog dog"
undo_stack.undo(buffer)
ASSERT buffer.get_all_text() == "cat cat cat"
TEST "search wraps around end of file":
buffer = PieceTableBuffer("foo bar foo")
iterator = MatchIterator(buffer, LiteralSearch(), SearchQuery("foo"))
m1 = iterator.next() // first "foo"
m2 = iterator.next() // second "foo"
m3 = iterator.next() // wraps; finds first "foo" again
ASSERT m3.start_offset == m1.start_offset
TEST GROUP: LSP Client (Using Mock Transport)
TEST "initialize request is sent on start":
mock_transport = MockTransport()
client = LSPClientImpl(mock_transport)
client.start()
sent = mock_transport.get_sent_messages()
ASSERT sent[0].method == "initialize"
ASSERT "capabilities" IN sent[0].params
TEST "textDocument/didOpen sent when document opens":
mock_transport = MockTransport()
client = LSPClientImpl(mock_transport)
client.start()
mock_transport.simulate_response("initialize", { "capabilities": {} })
client.open_document("file:///test.py", "python", 1, "x = 1")
sent = mock_transport.get_sent_messages()
open_notif = FIND message in sent WHERE method == "textDocument/didOpen"
ASSERT open_notif.params.textDocument.uri == "file:///test.py"
ASSERT open_notif.params.textDocument.languageId == "python"
TEST "diagnostics are stored on publishDiagnostics notification":
mock_transport = MockTransport()
client = LSPClientImpl(mock_transport)
store = DiagnosticStore()
client.on_notification("textDocument/publishDiagnostics",
store.update)
mock_transport.simulate_notification("textDocument/publishDiagnostics", {
"uri": "file:///test.py",
"diagnostics": [{ "range": ..., "severity": 1, "message": "undefined name 'x'" }]
})
diags = store.get("file:///test.py")
ASSERT len(diags) == 1
ASSERT diags[0].message == "undefined name 'x'"
TEST "stale completion request is cancelled":
mock_transport = SlowMockTransport(delay=100ms)
client = LSPClientImpl(mock_transport)
controller = CompletionController(client)
controller.trigger("file:///test.py", Position(0, 1))
controller.trigger("file:///test.py", Position(0, 2)) // cancels first
// Only one response should be processed
WAIT 200ms
ASSERT mock_transport.get_sent_messages() contains a cancel notification
20. Recommended Build Order
Work in this sequence so you always have a usable (partial) editor at each stage:
Phase 1 — You Can Open and View a File
[x] Step 2: Buffer abstraction + PieceTable (no UI yet; test with unit tests)
[x] Step 3: Piece table insert/delete/get_text
[x] Step 7: VGA-equivalent: output buffer contents to a terminal (bare print loop)
[x] Step 8: Viewport: show the right 40 lines, not the whole file
[x] Step 6: Event bus (wire buffer changes to "needs re-render" flag)
Phase 2 — You Can Edit and Navigate
[x] Step 4: Cursor model (single cursor, move left/right/up/down)
[x] Step 5: Command pattern + UndoStack (insert/delete via commands)
[x] Step 18: Basic keybinding system (arrow keys, Ctrl+Z, Ctrl+Y, printable chars)
[x] Step 14: BufferManager (open file from disk, save to disk)
Phase 3 — You Are a Real Editor
[x] Step 9: Syntax highlighting (regex highlighter for one language)
[x] Step 10: Mode system (at minimum: Insert mode vs. Normal mode)
[x] Step 15: Search & replace (literal search, n/N navigation)
[x] Step 4: Multiple cursors and selections
Phase 4 — You Have IDE Features
[x] Step 12: LSP client transport + lifecycle (initialize, didOpen, didChange)
[x] Step 13: Diagnostics (publishDiagnostics → squiggle decorations)
[x] Step 13: Autocomplete popup (completions + keyboard navigation)
[x] Step 13: Go-to-definition, hover
Phase 5 — You Are a Full-Featured Editor
[x] Step 11: Plugin architecture (load plugins, expose PluginContext API)
[x] Step 16: Configuration system (layered config, hot reload)
[x] Step 17: Workspace model (file tree, project detection, workspace symbol search)
[x] Step 13: Remaining LSP features (rename, code actions, format, inlay hints)
Quick Reference: Design Patterns Index
| GoF Pattern | Category | Step Where Applied | Problem It Solves |
|---|---|---|---|
| Command | Behavioral | Step 5 | Encapsulates edits; enables undo/redo |
| Observer | Behavioral | Step 6 | Decouples buffer from renderer, LSP, cursor |
| State | Behavioral | Step 10 | Clean modal editing without giant if-else chains |
| Strategy | Behavioral | Steps 9, 13, 15 | Swappable highlighting, ranking, search algorithms |
| Chain of Responsibility | Behavioral | Step 18 | Layered keybinding handling without coupling |
| Iterator | Behavioral | Steps 15, 17 | Walk matches and file trees without exposing internals |
| Composite | Structural | Steps 5, 16, 17 | Nest commands, config trees, file trees uniformly |
| Decorator | Structural | Step 7 | Layer syntax, selection, diagnostics without flags |
| Facade | Structural | Step 12 | Hide JSON-RPC protocol complexity behind clean LSP API |
| Flyweight | Structural | Step 7 | Share glyph metrics across thousands of characters |
| Factory Method | Creational | Steps 11, 14 | Create right buffer/plugin type without hardcoding |
| Proxy | Creational | Step 12 | Lazy language server startup, transparent connection |
Quick Reference: SOLID Violations to Actively Avoid
| Violation | Symptom | Fix |
|---|---|---|
| SRP: Buffer knows about rendering | buffer.draw() exists |
Move all draw calls to BufferView |
OCP: Adding a language requires editing Highlighter |
if lang == "python" chains in core |
Use Strategy pattern; register language grammars |
LSP: ReadOnlyBuffer crashes on insert() |
Silent no-op or thrown exception surprises callers | Separate ReadableBuffer and MutableBuffer interfaces |
| ISP: Plugin must implement unused LSP methods | class MyPlugin implements FullLSPInterface |
Expose only PluginContext with what plugins actually need |
DIP: Editor imports FileSystem directly |
import LinuxFileSystem in editor core |
Depend on FileSystem interface; inject concrete at startup |
Useful Resources
- Gang of Four — Design Patterns: Elements of Reusable Object-Oriented Software (Gamma, Helm, Johnson, Vlissides, 1994) — the primary reference for every pattern cited here
- Rope data structure — Boehm, Atkinson, Plass (1995): https://citeseer.ist.psu.edu/viewdoc/download?doi=10.1.1.14.9450
- Piece Table explained — https://code.visualstudio.com/blogs/2018/03/23/text-buffer-reimplementation
- Language Server Protocol specification — https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
- TextMate grammar format — https://macromates.com/manual/en/language_grammars
- Tree-sitter (incremental parsing) — https://tree-sitter.github.io/tree-sitter/
- Xi editor architecture notes — https://xi-editor.io/docs/
- Helix editor source (excellent modern Rust editor) — https://github.com/helix-editor/helix
- CodeMirror 6 architecture (excellent reference implementation) — https://codemirror.net/docs/guide/
- Robert C. Martin — Clean Architecture (2017) — reinforces SOLID principles at the system level