Build a Full-Featured Text Editor From Scratch — Architecture, Design Patterns & Complete Checklist | 0xKiire

47 min read Original article ↗

Table of Contents

  1. Understand the Architecture: A Bird’s-Eye View
  2. The Buffer — Storing Text Efficiently
  3. The Piece Table — The Heart of Editing
  4. The Cursor & Selection Model
  5. The Command Pattern — Undo & Redo
  6. The Observer Pattern — Event System
  7. The View Layer — Rendering the Buffer
  8. The Viewport & Scrolling
  9. Syntax Highlighting
  10. The Mode System (Modal Editing)
  11. The Plugin Architecture
  12. The Language Server Protocol (LSP) Client
  13. LSP Features — Autocomplete, Diagnostics & Code Actions
  14. The File System & Buffer Manager
  15. Search & Replace
  16. Configuration System
  17. The Workspace & Project Model
  18. The Keybinding System
  19. Test Suites
  20. 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 Buffer interface is deliberately split. Read-only consumers (the renderer, syntax highlighter) only receive a ReadableBuffer sub-interface. Only the command system receives the full mutable Buffer. This prevents accidental mutation from the wrong layer.

Checklist

  • Define the Buffer interface with clean separation of reads vs. writes.
  • Define a ReadableBuffer sub-interface for consumers that must not mutate.
  • Ensure offset_to_line_col and line_col_to_offset are O(log n) or better (use a cached line-start index).
  • Maintain a line_starts array: a sorted list of byte offsets where each line begins.
  • Update line_starts incrementally 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 PieceTable with an original string and an append-only add string.
  • 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_starts index 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, and Selection structs.
  • Implement bidirectional conversion: position_to_offset() and offset_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 head while keeping anchor.
  • Implement CursorSet with 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 Command class 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 to UndoStack.
  • LSP: Any EditCommand can be stored in the undo stack. A NoOpCommand (for read-only buffers) is a valid substitution.

Checklist

  • Define the EditCommand interface with execute, undo, can_merge, merge.
  • Implement InsertCommand and DeleteCommand using PieceSnapshot for undo.
  • Implement CompositeCommand for grouping (GoF Composite).
  • Implement UndoStack with configurable depth limit.
  • Implement command merging for consecutive character inserts.
  • Implement begin_transaction / commit_transaction for bulk operations.
  • Implement ReplaceCommand as an InsertCommand + DeleteCommand composite.
  • 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: Buffer emits events through the EventBus abstraction; it has no knowledge of who subscribes.
  • SRP: Each event handler has one responsibility — reacting to one specific event type.

Checklist

  • Define all EventType variants and their corresponding event structs.
  • Implement EventBus with subscribe/unsubscribe/emit.
  • Implement SubscriptionToken that auto-unsubscribes on destruction (RAII).
  • Make EventBus injectable (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_CHANGED events 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 BufferView that reads from a ReadableBuffer and a Viewport (never writes to the buffer directly).
  • Implement the LineDecoration interface and at least: SelectionDecoration, DiagnosticDecoration.
  • Implement LineRenderer with a sorted decoration list.
  • Implement GlyphCache (Flyweight) for character measurement.
  • Implement a RenderContext struct 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 Viewport struct.
  • Implement scroll_to_cursor() with configurable padding (“scrolloff”).
  • Implement scroll_by_lines(n) and scroll_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_lines and visible_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:

  1. Maintain a cache of highlight results per line.
  2. On BUFFER_CHANGED, invalidate only affected lines (and potentially lines below, for multi-line constructs like block comments).
  3. 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 SyntaxHighlighter strategy interface.
  • Implement a basic RegexHighlighter for at least one language (good for testing the pipeline).
  • Implement TextMate grammar loading (JSON/XML .tmLanguage format) — this unlocks hundreds of community grammars.
  • Define the Token struct with TextMate scope naming.
  • Implement theme rule resolution with scope specificity ordering.
  • Implement HighlightCache with dirty-line tracking.
  • Subscribe HighlightCache.invalidate() to BUFFER_CHANGED events.
  • 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 EditorMode interface with on_key, on_enter, on_exit, cursor_style.
  • Implement NormalMode, InsertMode (required minimum for modal editing).
  • Implement CommandMode (the : command line) as an EditorMode.
  • Implement VisualMode and VisualLineMode.
  • Implement ModeController that manages transitions.
  • Emit MODE_CHANGED events 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 EditMode but 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 Plugin interface with activate(context) and deactivate().
  • Define PluginContext as the only API surface for plugins (no internal imports).
  • Implement PluginRegistry with pluggable loaders (Factory Method).
  • Implement at least one PluginLoader (a script loader is simplest to start).
  • Implement a CommandRegistry that plugins use to register new commands.
  • Implement a DecorationRegistry that 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 LanguageClient interface (Facade).
  • Implement JsonRpcConnection with request/response matching by ID.
  • Implement StdioTransport (spawn process, read Content-Length framed messages).
  • Implement the initialize / initialized handshake.
  • 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 + exit sequence on editor close.

13. LSP Features — Autocomplete, Diagnostics & Code Actions

Diagnostics (Errors, Warnings, Hints)

The server sends textDocument/publishDiagnostics notifications asynchronously. The editor must:

  1. Receive the notification.
  2. Store the diagnostics for that file.
  3. Emit a DIAGNOSTIC_RECEIVED event.
  4. 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 DiagnosticStore and subscribe to publishDiagnostics notifications.
  • Wire DiagnosticDecoration (Step 7) to re-render when diagnostics update.
  • Implement CompletionController with async request + cancellation.
  • Implement the CompletionPopup with keyboard navigation (Tab/Enter to accept, Escape to dismiss).
  • Implement auto-trigger heuristics (trigger characters, idle timer).
  • Implement FuzzyRanker strategy 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 BufferManager with open/save/close and deduplication.
  • Implement BufferFactory (Factory Method) for different buffer types.
  • Implement FileSystem abstraction with a LocalFileSystem and a VirtualFileSystem (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 SearchStrategy interface with LiteralSearch and RegexSearch implementations.
  • Implement MatchIterator with forward and backward traversal.
  • Implement search-result highlighting as a SearchMatchDecoration (Step 7).
  • Implement SearchController with 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 FuzzySearch for 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 ConfigNode and ConfigRegistry with layered lookup.
  • Implement config file loading from JSON/TOML/YAML (pick one format).
  • Implement config schema declaration and validation.
  • Emit CONFIG_CHANGED events when a value changes.
  • Subscribe relevant components to CONFIG_CHANGED (renderer listens for editor.fontSize; syntax highlighter listens for editor.theme).
  • Implement per-language config override ([language "rust"] block).
  • Implement per-workspace config file (.editorconfig standard 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 TreeIterator for depth-first traversal.
  • Implement Workspace that builds its tree by scanning the file system.
  • Implement .gitignore and .ignore filtering when scanning.
  • Implement detect_project_type() and auto-start the appropriate LSP.
  • Implement workspace-wide symbol search via workspace/symbol LSP 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 KeyEvent and KeyChord structs.
  • Define the KeyHandler interface (Chain of Responsibility).
  • Implement at least three handlers in the chain: popup handler, mode handler, global handler.
  • Implement KeybindingRegistry with multi-key chord support.
  • Implement contextual bindings (when conditions: editorTextFocus, inlineCompletionVisible).
  • Implement CommandRegistry that 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 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

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