Skip to content

Repository files navigation

PieceTable Editor

A high-performance, feature-complete terminal text editor built from scratch in modern C++17 — no external dependencies, no runtime libraries, no shortcuts.

License: MIT C++ Standard Build Tests Platform Compiler Sanitizers No Dependencies Threads PRs Welcome Changelog Cite


Every component was engineered deliberately: algorithmic trade-offs are documented, benchmarked, and justified; every class has a single clearly-defined responsibility enforced by the type system; and the concurrency model is explicit about what is shared and what is not.


Table of Contents

  1. Features
  2. Project Structure
  3. Architecture
  4. Component Reference
  5. Engineering Design Notes
  6. Tests & Performance
  7. Keybindings
  8. Build & Run
  9. Memory Safety
  10. Community & Contributing
  11. Changelog
  12. Citation
  13. License

Features

Category Capability
File I/O Open, edit, and save any plain-text or source-code file; Ctrl+A prompts for a new filename
Undo / Redo Per-keystroke undo/redo via the Command pattern — O(edit) memory, not O(document)
Incremental search Live match highlighting across every visible line as you type (Ctrl+F)
Regex search Toggle with Ctrl+R; Thompson NFA — O(
Syntax highlighting C, C++, and Python: keywords, strings, line/block comments, preprocessor directives, numbers
Selection & clipboard Mark a range with Ctrl+Space; copy/cut/paste across line boundaries via offset-based range API
Version history Every save snapshotted with a std::time_t timestamp; Ctrl+D shows LCS diff since the last save
Background autosave Dedicated std::thread; disk write happens after mutex release — zero typing latency impact
Dirty tracking FNV-based content hash — the editor knows precisely whether anything changed since the last save
Viewport scrolling Soft-wrapped viewport follows the cursor; terminal resize handled via SIGWINCH
Flicker-free rendering Entire frame accumulated in a FrameBuffer and flushed in a single write() syscall
No external deps Zero dependencies beyond the C++17 standard library and POSIX

Project Structure

piecetable-editor/
│
├── include/                      # One header per class / module
│   ├── AutosaveWorker.hpp        #   Background autosave thread (condition variable wake)
│   ├── Clipboard.hpp             #   In-process single-slot string store
│   ├── DiffEngine.hpp            #   O(n·m) LCS line diff; DiffOp / DiffLine types
│   ├── EditCommand.hpp           #   CursorOwner interface + abstract EditCommand + 4 concrete subclasses
│   ├── GapBuffer.hpp             #   Contiguous-array editor backend (benchmark comparator)
│   ├── InputHandler.hpp          #   Raw-byte → TextEditor call mapper (namespace, no state)
│   ├── PieceTable.hpp            #   Two-buffer piece-list text store; Piece / SourceBuffer types
│   ├── RegexEngine.hpp           #   Thompson NFA regex engine; Match type
│   ├── ScreenRenderer.hpp        #   ANSI escape emitter (namespace); FrameBuffer type
│   ├── Selection.hpp             #   Value-type selection (anchor + active + normalized())
│   ├── SyntaxHighlighter.hpp     #   Stateless per-line tokenizer; TokenType / SyntaxToken / Language
│   ├── Terminal.hpp              #   POSIX raw mode + SIGWINCH (namespace, no state)
│   ├── TextDocument.hpp          #   Row/col abstraction + incremental lineStarts_ vector
│   ├── TextEditor.hpp            #   Orchestrator; CursorOwner impl; all editor state
│   ├── UndoRedoStack.hpp         #   Two std::vector<unique_ptr<EditCommand>> stacks
│   └── VersionHistory.hpp        #   Full-snapshot history; Snapshot struct with savedAt timestamp
│
├── src/                          # Implementations (one .cpp per .hpp)
│   ├── AutosaveWorker.cpp
│   ├── DiffEngine.cpp
│   ├── EditCommand.cpp
│   ├── GapBuffer.cpp
│   ├── InputHandler.cpp
│   ├── PieceTable.cpp
│   ├── RegexEngine.cpp           # ~400 lines: NFA parser + simulator
│   ├── ScreenRenderer.cpp        # ~330 lines: syntax-aware ANSI frame builder
│   ├── SyntaxHighlighter.cpp     # ~280 lines: C/C++/Python tokenizer
│   ├── Terminal.cpp
│   ├── TextDocument.cpp
│   ├── TextEditor.cpp            # ~560 lines: all editor operations
│   ├── UndoRedoStack.cpp
│   ├── VersionHistory.cpp
│   └── main.cpp                  # 18 lines: raw mode, SIGWINCH, openFile, run()
│
├── tests/
│   └── tests.cpp                 # 660 lines: 50 unit tests + benchmark harness
│
├── Makefile
├── CHANGELOG.md
├── CITATION.cff
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── SECURITY.md
└── SUPPORT.md

Source-file sizes at a glance:

File Size
src/RegexEngine.cpp ~12.6 KB
src/TextEditor.cpp ~17.8 KB
src/ScreenRenderer.cpp ~10.5 KB
src/SyntaxHighlighter.cpp ~8.8 KB
src/InputHandler.cpp ~4.8 KB
src/PieceTable.cpp ~6.9 KB
src/GapBuffer.cpp ~3.7 KB
tests/tests.cpp ~24.2 KB

Architecture

The three top-level boundaries are enforced by the type system, not convention:

  • ScreenRenderer::render takes const TextEditor& — the compiler rejects any write.
  • InputHandler has no FrameBuffer in scope — it structurally cannot emit output.

Diagram 1 — Component Architecture

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#e0f2fe', 'primaryBorderColor': '#0284c7', 'primaryTextColor': '#0c4a6e', 'secondaryColor': '#f0fdf4', 'tertiaryColor': '#fef9c3', 'lineColor': '#475569', 'fontSize': '14px'}}}%%
flowchart TB
    classDef entry    fill:#fef9c3,stroke:#ca8a04,color:#713f12
    classDef core     fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
    classDef render   fill:#f0fdf4,stroke:#16a34a,color:#14532d
    classDef input    fill:#ede9fe,stroke:#7c3aed,color:#3b0764
    classDef posix    fill:#fce7f3,stroke:#db2777,color:#831843
    classDef state    fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
    classDef conc     fill:#ffedd5,stroke:#ea580c,color:#7c2d12

    subgraph entry_box["main.cpp — Entry Point"]
        M1["enableRawMode()"]:::entry
        M2["SIGWINCH handler"]:::entry
        M3["openFile(argv)"]:::entry
        M4["editor.run()"]:::entry
    end

    subgraph editor_box["TextEditor — Orchestrator"]
        subgraph state_box["Owned State"]
            TD["TextDocument\nPieceTable + lineStarts_"]:::state
            UR["UndoRedoStack\nundo / redo stacks"]:::state
            SEL["Selection\nanchor + active"]:::state
            CB["Clipboard\nsingle-slot string"]:::state
            VH["VersionHistory\nfull-text snapshots"]:::state
        end
        subgraph conc_box["Concurrency"]
            AW["AutosaveWorker\nstd::thread + condvar\natomic dirty flag"]:::conc
            MX["documentMutex_"]:::conc
        end
    end

    subgraph render_box["Read-only — const TextEditor&"]
        SR["ScreenRenderer"]:::render
        FB["FrameBuffer\nstd::string accumulator"]:::render
        SH["SyntaxHighlighter\nstateless tokenizer"]:::render
    end

    subgraph input_box["Write-path — TextEditor&"]
        IH["InputHandler"]:::input
    end

    subgraph posix_box["OS / POSIX"]
        T["Terminal\nraw mode · ioctl · SIGWINCH"]:::posix
        DISK["Filesystem\n.bak autosave"]:::posix
    end

    M1 --> M2 --> M3 --> M4
    M4 --> editor_box
    editor_box -->|"const TextEditor&"| SR
    SR --> SH
    SR --> FB
    FB -->|"single write() syscall"| T
    IH -->|"TextEditor& calls"| editor_box
    T -->|"raw bytes"| IH
    AW -->|"lock · snapshot · unlock"| MX
    AW -->|"write after unlock"| DISK
    MX --- TD
Loading

Diagram 2 — Full Class Hierarchy

%%{init: {'theme': 'default'}}%%
classDiagram
    direction TB

    class CursorOwner {
        <<interface>>
        +setCursor(col, row)
    }

    class PieceTable {
        -string originalText_
        -string appendedText_
        -Piece[] pieces_
        -size_t documentLength_
        +loadFromFile(path)
        +insert(position, text)
        +erase(position, length)
        +text() string
        +charAt(index) char
        -coalesceAdjacentPieces()
    }

    class Piece {
        +SourceBuffer source
        +size_t offset
        +size_t length
    }

    class GapBuffer {
        -char[] buf_
        -size_t gapStart_
        -size_t gapEnd_
        +insert(position, text)
        +erase(position, length)
        +charAt(index) char
        -moveGapTo(pos)
    }

    class TextDocument {
        -PieceTable storage_
        -size_t[] lineStarts_
        +lineCount() int
        +lineText(row) string
        +insertChar(row, col, c)
        +insertNewline(row, splitCol)
        +toOffset(row, col) size_t
        +textInRange(start, end) string
    }

    class EditCommand {
        <<abstract>>
        +undo()
        +redo()
    }

    class InsertCharCommand {
        -int row_, col_
        -char character_
        -CursorPos before_, after_
        +undo()
        +redo()
    }

    class DeleteCharCommand {
        -int row_, col_
        -char deletedCharacter_
        +undo()
        +redo()
    }

    class MergeLinesCommand {
        -int row_, previousLineLength_
        +undo()
        +redo()
    }

    class InsertNewlineCommand {
        -int row_, splitColumn_
        +undo()
        +redo()
    }

    class UndoRedoStack {
        -EditCommand[] undoStack_
        -EditCommand[] redoStack_
        +push(command)
        +undo()
        +redo()
        +canUndo() bool
        +canRedo() bool
    }

    class Selection {
        +bool active
        +int anchorRow, anchorCol
        +int activeRow, activeCol
        +normalized(sR, sC, eR, eC)
    }

    class Clipboard {
        -string contents_
        +set(text)
        +contents() string
    }

    class DiffEngine {
        <<static>>
        +diffLines(old, new) DiffLine[]
        +splitIntoLines(text) string[]
    }

    class VersionHistory {
        -Snapshot[] snapshots_
        +recordSnapshot(content)
        +diffAgainstLatest(content) DiffLine[]
        +diffBetween(older, newer) DiffLine[]
    }

    class Snapshot {
        +time_t savedAt
        +string content
    }

    class SyntaxHighlighter {
        <<static>>
        +fromFilename(name) Language
        +tokenize(line, lang, inBlock) SyntaxToken[]
        +ansiColor(type) char*
    }

    class RegexEngine {
        <<static>>
        +findAll(pattern, text) Match[]
        +isValid(pattern) bool
    }

    class AutosaveWorker {
        -thread worker_
        -bool running_
        -bool dirty_
        -condition_variable wakeSignal_
        +start()
        +stop()
        +notifyDirty()
    }

    class ScreenRenderer {
        <<namespace>>
        +render(editor)
    }

    class InputHandler {
        <<namespace>>
        +processNextKeypress(editor)
    }

    class TextEditor {
        -TextDocument document_
        -UndoRedoStack undoRedo_
        -Selection selection_
        -Clipboard clipboard_
        -VersionHistory versionHistory_
        -mutex documentMutex_
        -string filename_
        -bool dirty_
        +run()
        +insertCharacter(c)
        +undo()
        +redo()
        +save()
        +openFile(path)
        +beginSearch()
        +toggleRegexSearch()
        +setCursor(col, row)
    }

    CursorOwner <|.. TextEditor : implements
    EditCommand <|-- InsertCharCommand
    EditCommand <|-- DeleteCharCommand
    EditCommand <|-- MergeLinesCommand
    EditCommand <|-- InsertNewlineCommand

    TextDocument *-- PieceTable : storage_
    PieceTable *-- Piece : pieces_

    TextEditor *-- TextDocument
    TextEditor *-- UndoRedoStack
    TextEditor *-- Selection
    TextEditor *-- Clipboard
    TextEditor *-- VersionHistory
    TextEditor *-- AutosaveWorker

    UndoRedoStack o-- EditCommand : stacks

    InsertCharCommand --> TextDocument
    InsertCharCommand --> CursorOwner
    DeleteCharCommand --> TextDocument
    MergeLinesCommand --> TextDocument
    InsertNewlineCommand --> TextDocument

    VersionHistory *-- Snapshot
    VersionHistory ..> DiffEngine : uses
    ScreenRenderer ..> TextEditor : reads const ref
    ScreenRenderer ..> SyntaxHighlighter : tokenizes
    InputHandler ..> TextEditor : drives
    RegexEngine ..> TextEditor : used by search
Loading

Diagram 3 — Data Model (ER)

%%{init: {'theme': 'default'}}%%
erDiagram
    TextEditor {
        int cursorCol
        int cursorRow
        string filename
        bool dirty
        uint64 savedContentHash
        string searchQuery
        bool searchActive
        bool regexSearch
    }
    TextDocument {
        size_t[] lineStarts
    }
    PieceTable {
        string originalText
        string appendedText
        size_t documentLength
    }
    Piece {
        SourceBuffer source
        size_t offset
        size_t length
    }
    UndoRedoStack {
        EditCommand[] undoStack
        EditCommand[] redoStack
    }
    EditCommand {
        int row
        int col
        CursorPos before
        CursorPos after
    }
    VersionHistory {
        Snapshot[] snapshots
    }
    Snapshot {
        time_t savedAt
        string content
    }
    AutosaveWorker {
        chrono_seconds interval
        bool running
        bool dirty
    }

    TextEditor ||--|| TextDocument : "owns"
    TextEditor ||--|| UndoRedoStack : "owns"
    TextEditor ||--|| VersionHistory : "owns"
    TextEditor ||--|| AutosaveWorker : "owns"
    TextDocument ||--|| PieceTable : "storage_"
    PieceTable ||--o{ Piece : "pieces_"
    UndoRedoStack ||--o{ EditCommand : "undo and redo stacks"
    VersionHistory ||--o{ Snapshot : "snapshots_"
Loading

Editor lifecycle — from launch to quit:

Phase Event Details
Startup Terminal raw mode enabled enableRawMode() saves termios; SIGWINCH handler registered
File opened PieceTable loads originalText_; lineStarts_ built by full scan (once); savedContentHash_ computed
AutosaveWorker started std::thread launched; condition_variable initialized; 30-second timer begins
Interactive Editing First frame rendered ScreenRenderer builds FrameBuffer; single write() syscall flushes frame
Character typed InputHandler reads raw byte; InsertCharCommand pushed to undo stack; lineStarts_ shifted O(lines after cursor); notifyDirty() flips atomic flag
Ctrl+S Save File written to disk; Snapshot{savedAt, content} recorded; savedContentHash_ updated
Autosave fires Worker wakes after 30 s; mutex locked → text copied → mutex released; .bak written after mutex release
Ctrl+Z Undo EditCommand::undo() reverses edit; cursor restored to CursorPos before_
Ctrl+F Search refreshMatches() called per keystroke; RegexEngine::findAll() if regex mode
Ctrl+D Diff DiffEngine::diffLines() over saved snapshot; LCS DP O(n×m) over line vectors
Shutdown Ctrl+Q pressed QuitPrompt shown if dirty_ == true
User confirms AutosaveWorker::stop() joins thread; disableRawMode() restores termios; process exits

Diagram 4 — Editor Mode State Machine

%%{init: {'theme': 'default'}}%%
stateDiagram-v2
    [*] --> Normal : editor.run() starts

    Normal --> Search : Ctrl+F beginSearch()
    Normal --> SelectionActive : Ctrl+Space beginSelection()
    Normal --> Dirty : any insert or delete or paste
    Normal --> [*] : Ctrl+Q and not dirty

    Dirty --> Dirty : more edits
    Dirty --> Saved : Ctrl+S save()
    Dirty --> QuitPrompt : Ctrl+Q

    Saved --> Normal : continue editing
    Saved --> [*] : Ctrl+Q

    QuitPrompt --> Dirty : user declines
    QuitPrompt --> [*] : user confirms quit

    Search --> RegexSearch : Ctrl+R toggleRegexSearch()
    Search --> Normal : Escape or Enter
    Search --> Search : character typed refreshMatches()

    RegexSearch --> Search : Ctrl+R again
    RegexSearch --> Normal : Escape or Enter

    SelectionActive --> Normal : Ctrl+Space again or Escape
    SelectionActive --> ClipboardOp : Ctrl+C or Ctrl+X
    ClipboardOp --> Normal : clipboard updated selection cleared

    Normal --> Paste : Ctrl+V
    Paste --> Dirty : text inserted at cursor

    Normal --> UndoRedo : Ctrl+Z or Ctrl+Y
    UndoRedo --> Normal : command applied

    Normal --> DiffView : Ctrl+D
    DiffView --> Normal : any key
Loading

Component Reference

PieceTable

include/PieceTable.hpp · src/PieceTable.cpp

The same technique used by VS Code and Atom. Two immutable-ish backing buffers plus an ordered list of Piece values; editing means editing the list, not copying characters.

Types:

enum class SourceBuffer { Original, Appended };

struct Piece {
    SourceBuffer source;
    size_t offset;   // start offset within the source buffer
    size_t length;   // number of characters this piece contributes
};

Public API:

class PieceTable {
public:
    void        loadFromFile(const std::string& filePath);
    void        insert(size_t position, const std::string& text);
    void        erase(size_t position, size_t length);
    std::string text()                              const;
    size_t      size()                              const;
    char        charAt(size_t index)                const;
    std::string substring(size_t start, size_t len) const;
};

Diagram 5 — PieceTable Insert Algorithm

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#dbeafe', 'primaryBorderColor': '#3b82f6', 'primaryTextColor': '#1e3a8a', 'lineColor': '#64748b', 'tertiaryColor': '#f0fdf4'}}}%%
flowchart TD
    classDef decision  fill:#fef9c3,stroke:#ca8a04,color:#713f12
    classDef fastpath  fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef midpath   fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
    classDef splitpath fill:#ede9fe,stroke:#7c3aed,color:#3b0764
    classDef final     fill:#f0fdf4,stroke:#16a34a,color:#14532d
    classDef term      fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff

    A(["insert(position, text)"]):::term --> B{"position == size?"}:::decision

    B -->|"Yes — O(1) append"| C["appendedText_ += text\nextend last piece length\n1 integer update"]:::fastpath
    C --> Z(["Done"]):::term

    B -->|"No — mid-document"| D["Walk pieces_ list\naccumulate until offset >= position"]:::midpath
    D --> E{"Piece boundary?"}:::decision

    E -->|"Yes — no split"| F["Insert new Piece\npointing into appendedText_"]:::midpath
    E -->|"No — split"| G["Split piece P into\nP_left and P_right"]:::splitpath
    G --> H["Insert new Piece\nbetween P_left and P_right"]:::splitpath

    F --> I["appendedText_ += text"]:::final
    H --> I
    I --> J["documentLength_ += text.size()"]:::final
    J --> Z
Loading

GapBuffer

include/GapBuffer.hpp · src/GapBuffer.cpp

Not used at runtime. Implemented as a benchmark comparator. kDefaultGap = 1 << 12 (4 KB).

Physical layout:

[ pre-gap text (gapStart_ bytes) ] [ gap — 4KB unused capacity ] [ post-gap text ]

Complexity:

Operation Cost
Insert at gap O(k) amortized — no memory movement
Insert elsewhere O(dist_to_gap) reposition + O(k) copy
Erase at gap O(dist_to_gap) reposition, then O(1)
charAt / text() O(1) / O(n)

Public API (mirrors PieceTable for transparent swap):

class GapBuffer {
public:
    void        insert(size_t position, const std::string& text);
    void        erase(size_t position, size_t length);
    std::string text()                              const;
    size_t      size()                              const;
    char        charAt(size_t index)                const;
    std::string substring(size_t start, size_t len) const;
};

TextDocument

include/TextDocument.hpp · src/TextDocument.cpp

Row/column abstraction over PieceTable. The key contribution: incremental lineStarts_ maintenance — no full rescan per keystroke.

Incremental update cost:

Edit Cost
Insert non-\n character O(lines after cursor)
Insert \n O(lines after cursor)
Backspace at column 0 (merge) O(lines after cursor)
Multi-line paste / cut O(total lines) — full rescan (rare)

Public API:

class TextDocument {
public:
    int         lineCount()                                        const;
    std::string lineText(int row)                                  const;
    int         lineLength(int row)                                const;
    std::string fullText()                                         const;
    void        appendLine(const std::string& text);
    void        insertChar(int row, int col, char c);
    void        deleteChar(int row, int col);
    void        insertNewline(int row, int splitCol);
    void        mergeWithNextLine(int row);
    size_t      toOffset(int row, int col)                         const;
    std::string textInRange(size_t startOffset, size_t endOffset)  const;
    void        eraseRange(size_t startOffset, size_t endOffset);
    void        insertAt(size_t offset, const std::string& text);
    void        replaceAllText(const std::string& newText);
};

EditCommand Hierarchy

include/EditCommand.hpp · src/EditCommand.cpp

Command subclasses depend only on CursorOwner and TextDocument&. They cannot reach into search state, clipboard, autosave, or any other editor concern. Adding a new command type requires zero changes to TextEditor.

Interfaces:

class CursorOwner {
public:
    virtual void setCursor(int col, int row) = 0;
};

struct CursorPos { int col, row; };

class EditCommand {
public:
    virtual void undo() = 0;
    virtual void redo() = 0;
};

Concrete subclasses:

Class Records Undo action
InsertCharCommand (row, col, char, before, after) Delete char; restore before cursor
DeleteCharCommand (row, col, deletedChar, before, after) Re-insert deletedChar; restore before cursor
MergeLinesCommand (row, prevLineLength, before, after) Re-split the merged line; restore before cursor
InsertNewlineCommand (row, splitColumn, before, after) Merge the two lines back; restore before cursor

UndoRedoStack

include/UndoRedoStack.hpp · src/UndoRedoStack.cpp

Uses std::vector<std::unique_ptr<EditCommand>> (not std::stack) for both stacks, giving reserve semantics. Pushing a new command clears the redo stack.

class UndoRedoStack {
public:
    void push(std::unique_ptr<EditCommand> command);
    void undo();
    void redo();
    bool canUndo() const;
    bool canRedo() const;
};

Selection

include/Selection.hpp

A pure value type — no virtual methods, no editor logic. normalized() handles backward selections.

struct Selection {
    bool active = false;
    int anchorRow = 0, anchorCol = 0;
    int activeRow = 0, activeCol = 0;

    void clear();
    void begin(int row, int col);
    void moveTo(int row, int col);
    bool isEmpty() const;
    void normalized(int& startRow, int& startCol,
                    int& endRow,   int& endCol) const;
};

Clipboard

include/Clipboard.hpp

In-process single-slot store. OS clipboard integration (xclip/pbcopy) would be added here.

class Clipboard {
public:
    void              set(const std::string& text);
    const std::string& contents() const;
    bool              empty()     const;
};

DiffEngine

include/DiffEngine.hpp · src/DiffEngine.cpp

O(n·m) LCS dynamic program over std::vector<std::string> of lines.

enum class DiffOp { Unchanged, Added, Removed };
struct DiffLine { DiffOp op; std::string text; };

class DiffEngine {
public:
    static std::vector<DiffLine> diffLines(
        const std::vector<std::string>& oldLines,
        const std::vector<std::string>& newLines);
    static std::vector<std::string> splitIntoLines(const std::string& text);
};

VersionHistory

include/VersionHistory.hpp · src/VersionHistory.cpp

Full-text snapshots per save. diffBetween enables arbitrary historical comparisons.

struct Snapshot {
    std::time_t savedAt;
    std::string content;
};

class VersionHistory {
public:
    void                   recordSnapshot(const std::string& content);
    size_t                 snapshotCount()              const;
    const Snapshot&        snapshotAt(size_t index)     const;
    bool                   hasHistory()                 const;
    std::vector<DiffLine>  diffAgainstLatest(const std::string& current) const;
    std::vector<DiffLine>  diffBetween(size_t older, size_t newer)       const;
};

SyntaxHighlighter

include/SyntaxHighlighter.hpp · src/SyntaxHighlighter.cpp

Stateless per-line tokenizer. The caller (ScreenRenderer) threads inBlockComment state across rows.

enum class TokenType { Normal, Keyword, Preprocessor, String, Number, Comment };

struct SyntaxToken {
    int start;    // 0-indexed column
    int length;
    TokenType type;
};

class SyntaxHighlighter {
public:
    enum class Language { None, Cpp, Python };
    static Language         fromFilename(const std::string& filename);
    static std::vector<SyntaxToken> tokenize(const std::string& line,
                                             Language lang,
                                             bool& inBlockComment);
    static const char*      ansiColor(TokenType type);
};
Language Extensions
Cpp .cpp .cc .cxx .hpp .hxx .h .c
Python .py
None anything else

RegexEngine

include/RegexEngine.hpp · src/RegexEngine.cpp

Thompson NFA construction + simulation. Guarantees O(|pattern| × |text|) worst-case.

struct Match { int col; int length; };

class RegexEngine {
public:
    static std::vector<Match> findAll(const std::string& pattern,
                                      const std::string& text);
    static bool isValid(const std::string& pattern);
};
Syntax Meaning
. Any character except newline
* + ? Greedy quantifiers
| Alternation
(...) Grouping
[abc] [a-z] [^abc] Character classes
^ $ Start/end anchors
\d \w \s Shorthands; \D \W \S negated

Diagram 6 — Thompson NFA Compile & Simulate

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#ede9fe', 'primaryBorderColor': '#7c3aed', 'primaryTextColor': '#3b0764', 'lineColor': '#64748b', 'tertiaryColor': '#f0fdf4'}}}%%
flowchart TD
    classDef input    fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
    classDef fragment fill:#ede9fe,stroke:#7c3aed,color:#3b0764
    classDef decision fill:#fef9c3,stroke:#ca8a04,color:#713f12
    classDef sim      fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef output   fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff

    subgraph compile["Step 1 — Compile Pattern to NFA"]
        A(["pattern string"]):::input --> B["Recursive-descent parser"]:::input
        B --> C{"Next token"}:::decision

        C -->|"literal char"| D["cat fragment\nstate → match char → state"]:::fragment
        C -->|"dot"| E["cat fragment\nmatch any non-newline"]:::fragment
        C -->|"pipe"| F["alt fragment\ntwo epsilon branches"]:::fragment
        C -->|"star"| G["star fragment\nloop or skip via epsilon"]:::fragment
        C -->|"plus"| H["plus fragment\none mandatory then loop"]:::fragment
        C -->|"question"| I["quest fragment\nmatch or skip via epsilon"]:::fragment
        C -->|"open paren"| J["Recurse into sub-expression\nthen apply outer quantifier"]:::fragment
        C -->|"bracket class"| K["Build char-set matcher\nfor ranges and negations"]:::fragment
        C -->|"backslash"| L["Map shorthand\nd=digit w=word s=space"]:::fragment
        C -->|"anchor"| M["anchor_start or anchor_end flag"]:::fragment

        D --> O["Assembled NFA\nglued with epsilon transitions"]:::sim
        E --> O
        F --> O
        G --> O
        H --> O
        I --> O
        J --> O
        K --> O
        L --> O
        M --> O
    end

    subgraph simulate["Step 2 — Simulate NFA"]
        O --> P(["text string"]):::input
        P --> Q["Compute epsilon-closure\nof NFA start state"]:::sim
        Q --> R["current_states = closure(start)"]:::sim

        R --> S{"For each char in text"}:::decision
        S --> T["Advance: match char in\ncurrent_states → next_states"]:::sim
        T --> U["epsilon-closure of next_states"]:::sim
        U --> V{"Accept state reached?"}:::decision
        V -->|"Yes"| W["Record match\ncol and length"]:::sim
        V -->|"No"| X["Continue"]:::sim
        W --> X
        X --> S
        S -->|"End of text"| Y(["Return vector of Match"]):::output
    end
Loading

Reference: Russ Cox — "Regular Expression Matching Can Be Simple And Fast"https://swtch.com/~rsc/regexp/regexp1.html


AutosaveWorker

include/AutosaveWorker.hpp · src/AutosaveWorker.cpp

Concurrency primitives:

Primitive Purpose
documentMutex_ (ref) Guards document during snapshot copy
running_ (atomic<bool>) Stop signal from stop()
dirty_ (atomic<bool>) Flip from notifyDirty() — cost ≈ one atomic store
wakeSignal_ (condition_variable) Wakes worker early when dirty

Injected callbacks:

using SnapshotProvider = std::function<std::string()>;    // called under lock
using DiskWriter       = std::function<bool(const std::string&)>; // called after unlock
using SaveNotifier     = std::function<void(const std::string&)>; // on success

Constructor:

AutosaveWorker(std::mutex& documentMutex,
               SnapshotProvider snapshotProvider,
               DiskWriter diskWriter,
               SaveNotifier onSaved,
               std::chrono::seconds interval = std::chrono::seconds(10));

Diagram 7 — Autosave Concurrency

%%{init: {'theme': 'default'}}%%
sequenceDiagram
    participant MT as Main Thread (typing)
    participant MX as documentMutex_
    participant AW as AutosaveWorker thread
    participant FS as Filesystem

    Note over MT,AW: Two threads — one shared mutex

    MT ->> MT: insertCharacter(c) — no lock needed
    MT ->> AW: notifyDirty() — atomic store, near-zero cost

    loop worker wake cycle
        AW ->> AW: wait_for(wakeSignal_, 30s)
        AW ->> AW: dirty_.exchange(false) returns true
        AW ->> MX: lock_guard acquire
        AW ->> MT: snapshotProvider() — fast memory copy
        MT -->> AW: string snapshot
        AW ->> MX: lock_guard release
        Note over MX: Lock released BEFORE slow I/O
        AW ->> FS: diskWriter(snapshot) — writes .bak
        FS -->> AW: success
        AW ->> AW: onSaved_ callback
    end

    Note over MT: Main thread NEVER blocked by filesystem latency
Loading

Terminal

include/Terminal.hpp · src/Terminal.cpp

Free functions with no state beyond the saved termios struct.

namespace Terminal {
    void enableRawMode();
    void disableRawMode();
    int  windowSize(int& rows, int& cols);
    void clearScreen();
    void moveCursorTo(int row, int col);
    int  cursorPosition(int& row, int& col);
}

FrameBuffer & ScreenRenderer

include/ScreenRenderer.hpp · src/ScreenRenderer.cpp

struct FrameBuffer {
    std::string data;
    void append(const char* s, size_t len);
    void appendStr(const std::string& s);
};

namespace ScreenRenderer {
    void render(const TextEditor& editor); // const — cannot mutate editor
}

Diagram 8 — ScreenRenderer Frame Pipeline

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#f0fdf4', 'primaryBorderColor': '#16a34a', 'primaryTextColor': '#14532d', 'lineColor': '#64748b', 'tertiaryColor': '#dbeafe'}}}%%
flowchart TD
    classDef setup    fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
    classDef loop     fill:#f0fdf4,stroke:#16a34a,color:#14532d
    classDef token    fill:#ede9fe,stroke:#7c3aed,color:#3b0764
    classDef decision fill:#fef9c3,stroke:#ca8a04,color:#713f12
    classDef flush    fill:#1e3a8a,stroke:#1e3a8a,color:#ffffff
    classDef term     fill:#374151,stroke:#374151,color:#ffffff

    A(["render(const TextEditor&)"]):::term --> B["Create empty FrameBuffer"]:::setup
    B --> C["Hide cursor\nESC[?25l"]:::setup
    C --> D["Move to home\nESC[H"]:::setup
    D --> E["Pre-scan rows above viewport\nto determine inBlockComment\nfor first visible row"]:::setup

    E --> F{"For each visible row\n0 to screenRows_"}:::decision
    F --> G["lineText = editor.lineText(row)"]:::loop
    G --> H["SyntaxHighlighter::tokenize(line, lang, inBlockComment)"]:::loop
    H --> I{"For each SyntaxToken"}:::decision
    I --> J["Append ANSI color code"]:::token
    J --> K["Append token text"]:::token
    K --> L["Append ANSI reset"]:::token
    L --> I
    I -->|"All tokens done"| M["Append CRLF · clear to EOL"]:::loop
    M --> F

    F -->|"All rows done"| N["Render status bar\nfilename · dirty[+] · row:col · mode"]:::setup
    N --> O{"Search active?"}:::decision
    O -->|"Yes"| P["Render search bar with query"]:::setup
    O -->|"No"| Q["Skip"]:::setup
    P --> Q

    Q --> R["Show cursor\nESC[?25h"]:::flush
    R --> S["Position cursor\nESC[row;colH"]:::flush
    S --> T["write(STDOUT_FILENO, framebuffer)\nSingle syscall — no flicker"]:::flush
    T --> Z(["Done"]):::term
Loading

InputHandler

include/InputHandler.hpp · src/InputHandler.cpp

No state. Key bindings can change without touching any editing logic.

namespace InputHandler {
    void processNextKeypress(TextEditor& editor);
}

TextEditor

include/TextEditor.hpp · src/TextEditor.cpp

The orchestrator. Implements CursorOwner. Does not know how to draw or decode a keypress.

Public API summary:

// Editing
void insertCharacter(char c);
void insertNewline();
void deleteCharacterBeforeCursor();
void undo();
void redo();

// Cursor
void moveCursorLeft(); void moveCursorRight();
void moveCursorUp();   void moveCursorDown();

// Selection / Clipboard
void beginSelection();
void extendSelectionTo(int row, int col);
void clearSelection();
void copySelectionToClipboard();
void cutSelectionToClipboard();
void pasteFromClipboard();

// File I/O
void save();   void saveAs();   void openFile(const std::string& path);

// Search
void beginSearch();   void findNext();   void findPrevious();
void toggleRegexSearch();

// Version history
void showVersionHistory();

// CursorOwner
void setCursor(int col, int row) override;

Engineering Design Notes

1. Text Storage — Piece Table

Data structures considered:

Structure Insert Memory Notes
std::string / std::vector<char> O(n) Contiguous Simple; slow for large files
Linked list of chars O(1) insert Fragmented Cache-hostile; massive pointer overhead
Piece Table O(1) amortized append; O(k) mid-doc Low Two buffers + piece list
Gap Buffer O(k) at gap; O(n) to reposition Contiguous Fast sequential; slow random
Rope O(log n) all ops Tree nodes Complex; justified at gigabyte scale only

For sequential typing (dominant workload) Piece Table wins. No text is ever copied or moved on append — one integer update. coalesceAdjacentPieces() after each erase prevents unbounded piece-list growth.


2. Gap Buffer — Benchmark Comparator

The Gap Buffer stores text contiguously with a movable gap at the most recent edit point. Insert-at-gap is O(k) with no memory movement. Reposition-gap is O(distance) — exactly where Piece Table wins on sequential workloads.


3. TextDocument — Incremental Line Index

lineStarts_ is updated incrementally — O(lines after cursor) per single-character edit, not O(file size). Only multi-line range operations fall back to a full rescan; they are rare enough that the added complexity of an incremental path is not worthwhile.


4. Undo / Redo — Command Pattern

Each EditCommand stores CursorPos before_ and CursorPos after_ so both undo() and redo() restore the cursor precisely without any additional editor state access. Command subclasses depend on only TextDocument& and CursorOwner — they cannot reach into search state, clipboard, or autosave.


5. Rendering, Input, Editing — Separated by Type

Class / namespace Type constraint Cannot
TextEditor Owns all mutable state Emit ANSI codes; decode raw bytes
ScreenRenderer const TextEditor& Mutate the document — compiler rejects it
InputHandler TextEditor& (write path only) Access FrameBuffer; render anything

ScreenRenderer accumulates ANSI sequences in a FrameBuffer and flushes in a single write() syscall — partial frames never appear on screen.


6. Background Autosave — Concurrency Design

The critical invariant: documentMutex_ is held only long enough to call snapshotProvider() (a fast in-memory copy). The actual diskWriter() call happens after the mutex is released. The main thread is never blocked by filesystem latency.


7. Selection and Clipboard

Selection::normalized() handles backward selections so callers never need to think about direction. Copy/cut/paste use TextDocument's offset-based range API — multi-line selections work without row-by-row special-casing. Clipboard is a single-slot in-process store; OS clipboard integration is a localized change.


8. Version History and Diff Engine

DiffEngine uses the O(n·m) LCS DP rather than Myers' O(ND) — simpler and perfectly adequate at editor-buffer scale. VersionHistory stores full-text Snapshot objects with savedAt timestamps. diffBetween(older, newer) enables arbitrary historical comparisons.


9. Syntax Highlighting

Stateless per-line APIScreenRenderer holds bool inBlockComment and passes it by reference. Trivially thread-safe. Python triple-quoted strings are deliberately not tracked (cross-line state without proportional benefit). Token coverage is guaranteed: no gaps, no overlaps — verified by test_syntax_tokens_cover_entire_line.


10. Regex Engine — Thompson NFA

std::regex uses recursive backtracking — certain patterns cause O(2^n) behaviour, unacceptable for a search-as-you-type UI. The Thompson NFA maintains the epsilon-closure set of active states; advancing the entire set per input character guarantees O(|pattern| × |text|) with no catastrophic backtracking.


Tests & Performance

Unit Tests — 50 / 50 Passing

Diagram 9 — Test Distribution

%%{init: {'theme': 'default'}}%%
pie title "50 Unit Tests by Subsystem"
    "RegexEngine Thompson NFA" : 15
    "GapBuffer" : 10
    "SyntaxHighlighter" : 9
    "TextDocument" : 5
    "DiffEngine and VersionHistory" : 4
    "TextEditor undo redo search" : 3
    "PieceTable" : 3
    "Selection and Clipboard" : 1
Loading

PieceTable — 3 tests

Test Verifies
test_piece_table_insert Sequential insert at two offsets builds "Hello World"
test_piece_table_erase erase(5,1) on "Hello World" yields "HelloWorld"
test_piece_table_midstream_insert_and_erase Mid-document split + erase: ACEABCDEABE

GapBuffer — 10 tests

Test Verifies
test_gapbuffer_append_sequential Character-by-character sequential append
test_gapbuffer_insert_at_beginning Front-of-buffer insert
test_gapbuffer_insert_in_middle Mid-buffer insert (ACEABCDE)
test_gapbuffer_erase_front erase(0,6) on "Hello World" yields "World"
test_gapbuffer_erase_middle erase(5,1) removes the space
test_gapbuffer_erase_then_insert "aXb" → erase X → insert c"acb"
test_gapbuffer_charAt charAt(0)=='H', charAt(4)=='o'
test_gapbuffer_size Size tracks insert and erase correctly
test_gapbuffer_empty_string_insert insert(0,"") must not crash or change state
test_gapbuffer_insert_past_end Index 9999 clamps to end: "abc""abcX"

TextDocument — 5 tests

Test Verifies
test_document_insert_char insertChar(0,5,'!') appends ! to line 0
test_document_newline_split insertNewline(0,5) on "HelloWorld""Hello" / "World"
test_document_merge_lines mergeWithNextLine(0) on two lines → one merged line
test_document_line_starts_stay_correct_after_many_edits lineStarts_ invariant after mixed inserts and merges
test_row_col_to_offset toOffset(1,2) returns 6 for "abc\ndef"

TextEditor — 3 tests

Test Verifies
test_undo_insert Two inserts then two undos restores empty buffer
test_undo_then_redo Undo then redo restores both characters
test_search_finds_all_matches Literal search finds both occurrences of "world" across two lines

DiffEngine / VersionHistory — 4 tests

Test Verifies
test_diff_detects_pure_addition Added count == 1 when one line appended
test_diff_detects_pure_removal Removed count == 1 when one line deleted
test_diff_identical_inputs_are_all_unchanged All 3 entries Unchanged on identical inputs
test_version_history_diff_against_latest Returns 1 Added for an appended third line

Selection / Clipboard — 1 test

Test Verifies
test_copy_paste_round_trip Copy first 5 chars, paste; first 5 chars of result match "hello"

SyntaxHighlighter — 9 tests

Test Verifies
test_syntax_cpp_keyword int in "int main() {"Keyword
test_syntax_cpp_line_comment // comment → Comment token
test_syntax_cpp_block_comment_span inBlockComment true after unclosed /*; next line all Comment
test_syntax_cpp_string_literal "hello"String token
test_syntax_cpp_preprocessor #include <vector> → single Preprocessor token
test_syntax_python_keyword def in Python mode → Keyword
test_syntax_none_language_passthrough Language::None covers entire line as Normal
test_syntax_tokens_cover_entire_line Token lengths sum exactly to line.size()
test_syntax_language_detection .cpp→Cpp, .hpp→Cpp, .py→Python, .md→None

RegexEngine — 15 tests

Test Pattern Input Expected
test_regex_literal_match hello "say hello world" col=4, len=5
test_regex_dot_star a.*b "xafoob" col=1, len=5
test_regex_plus_quantifier a+ "baaac" col=1, len=3
test_regex_question_quantifier colou?r "The colour and the color" 2 matches
test_regex_alternation cat|dog "I have a cat and a dog" 2 matches
test_regex_character_class [0-9]+ "abc 42 and 007" 2 matches: len 2, len 3
test_regex_negated_class [^aeiou ]+ "cat" non-empty match set
test_regex_digit_shorthand \d+ "score: 1234 and 56" 2 matches: len 4, len 2
test_regex_word_shorthand \w+ "hello world" 2 matches
test_regex_no_match xyz "hello world" empty vector
test_regex_anchored_start ^hello "hello world" col=0; ^world → empty
test_regex_anchored_end world$ "hello world" 1 match; hello$ → empty
test_regex_grouping (ab)+ "ababab" col=0, len=6
test_regex_validity_check a+b, [a-z]+, "" all isValid() == true
test_regex_string_empty abc "" empty vector

Performance Benchmarks

Setup: N=50,000 operations each · WSL2 · g++ -O2 · std::chrono::high_resolution_clock. Winner declared when ≥10% faster.

Diagram 10 — Benchmark Results

%%{init: {'theme': 'default'}}%%
xychart-beta
    title "PieceTable vs GapBuffer — N=50,000 ops (ms)"
    x-axis ["A: Sequential append", "B: Random mid insert", "C: Sequential erase", "D: Ping-pong"]
    y-axis "Time (ms)" 0 --> 3000
    bar [2.38, 2933, 2.56, 856]
    line [2.78, 2.75, 0.33, 13.84]
Loading

Bar = PieceTable · Line = GapBuffer · Lower is faster. Scenario B: PieceTable 2,933 ms vs GapBuffer 2.75 ms (×1,000 slower) — the Y-axis compresses this visually.

Scenario PieceTable GapBuffer Winner
A: Sequential append (cursor always at end) 2.38 ms 2.78 ms PieceTable
B: Random mid-document insert 2,933 ms 2.75 ms GapBuffer ×1,000×
C: Sequential erase from front 2.56 ms 0.33 ms GapBuffer ×8×
D: Alternating front/back insert (ping-pong) 856 ms 13.84 ms GapBuffer ×62×

Conclusion: sequential typing (Scenario A) is the dominant workload. Piece Table is the correct choice — no text is ever copied or moved on append.


Keybindings

Key Action
Arrow keys Move cursor (bounds-checked)
Enter Insert newline
Backspace Delete before cursor; merges lines at column 0
Ctrl+Z Undo
Ctrl+Y Redo
Ctrl+F Open incremental search
Ctrl+R Toggle regex search mode ([regex] in status bar)
Ctrl+N Next search match
Ctrl+B Previous search match
Ctrl+Space Begin / anchor selection
Ctrl+C Copy selection
Ctrl+X Cut selection
Ctrl+V Paste from clipboard
Ctrl+D Show LCS diff vs. last save
Ctrl+S Save
Ctrl+A Save as
Ctrl+Q Quit (prompts if unsaved)

Build & Run

Requirements: Linux or WSL2 · g++ ≥ 10 · make

git clone https://github.com/PRADEEPERIYASAMY/piecetable-editor.git
cd piecetable-editor

make               # build editor binary
./editor file.cpp  # open a file
./editor           # open with no file — name later with Ctrl+A
make test          # run all 50 tests + benchmark
make debug         # build with ASan + UBSan
make clean
Target Effect
make / make all Compile 14 modules + main.cpp, link editor binary
make debug Rebuild with -g -O0 -fsanitize=address,undefined
make test Compile tests/tests.cpp, run 50 unit tests + benchmarks
make clean Remove src/*.o, editor, tests/run_tests

Memory Safety

make debug
./editor yourfile.cpp   # interactive under sanitizers
./tests/run_tests       # full suite under sanitizers
Sanitizer Catches
ASan Use-after-free, heap/stack buffer overflows, uninitialized memory
UBSan Signed integer overflow, invalid shifts, null dereference

Both are especially valuable for PieceTable manual offset arithmetic, RegexEngine NFA state-set bookkeeping, and AutosaveWorker shared-state access. The full test suite passes cleanly with zero sanitizer errors.


Community & Contributing

Document Purpose
CONTRIBUTING.md Dev setup, coding standards, Conventional Commits, PR process
CODE_OF_CONDUCT.md Contributor Covenant v2.1 and 4-tier enforcement
SECURITY.md Private vulnerability reporting via GitHub Security Advisories
SUPPORT.md Where to get help, build failure checklist, out-of-scope items
CHANGELOG.md Version history following Keep a Changelog
CITATION.cff CFF 1.2.0 citation metadata

GitHub issue templates: Bug Report · Feature Request · Pull Request Template


Changelog

All notable changes to this project are documented in CHANGELOG.md, following the Keep a Changelog format and Semantic Versioning.


Citation

If you use this project in academic work, please cite it as:

@software{eriyasamy2025piecetable,
  author    = {Eriyasamy, Pradeep},
  title     = {PieceTable Editor — A High-Performance C++ Terminal Editor},
  year      = {2025},
  url       = {https://github.com/PRADEEPERIYASAMY/piecetable-editor},
  license   = {MIT}
}

Full machine-readable metadata is available in CITATION.cff (CFF 1.2.0).


License

Distributed under the MIT License. See LICENSE for the full text.

You are free to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of this software, provided the original copyright notice and permission notice appear in all copies.


Security

To report a vulnerability privately, please use GitHub Security Advisories. See SECURITY.md for the full disclosure policy.


Support

For questions and build help, see SUPPORT.md or open a GitHub Discussion.


Made with ❤️ by Pradeep Eriyasamy

Built from scratch · No dependencies · Pure C++17

License: MIT C++ Standard PRs Welcome

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages