18 KiB
Lime Implementation Plan
Lime is a clean, modern terminal/TUI text editor written in Rust. It should feel approachable like Nano, efficient and clean like Vim, and easy to use like VS Code, while using a standard non-modal cursor editing model.
This plan intentionally avoids time estimates so it can be handed directly to an implementation agent.
1. Product Direction
Initial product goal:
lime path/to/file.rs
opens a clean single-file terminal editor with:
- Standard typing/editing behavior
- Save and quit shortcuts
- Unsaved-changes confirmation
- Syntax highlighting
- Line numbers
- Status bar and help bar
- File search/open popup with
Ctrl-F - Recursive file discovery from the current working directory
- Large-file warning before opening very large files
- Good behavior on macOS and Linux
Lime should not be modal like Vim. It should be keyboard-first, but obvious and friendly.
2. Recommended Technical Stack
Use a Rust-native terminal stack:
crosstermfor terminal control and input eventsratatuifor layout, widgets, status bars, popups, and drawingropeyfor text storagetree-sitterfor syntax highlightingignorefor recursive file scanning with.gitignoresupportnucleo-matcherfor fuzzy file matching
Do not build a custom terminal renderer at first. ratatui + crossterm gives enough control for a polished TUI while keeping implementation manageable.
Suggested dependencies:
[dependencies]
anyhow = "1"
thiserror = "1"
clap = { version = "4", features = ["derive"] }
ropey = "1"
crossterm = "0.28"
ratatui = "0.29"
unicode-segmentation = "1"
unicode-width = "0.2"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-python = "0.23"
tree-sitter-json = "0.23"
tree-sitter-toml-ng = "0.6"
tree-sitter-md = "0.3"
ignore = "0.4"
nucleo-matcher = "0.3"
notify = "7"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
dirs = "5"
Optional later:
arboard = "3"
similar = "2"
tempfile = "3"
insta = "1"
3. Repository Structure
Use a Cargo workspace from the beginning:
lime/
Cargo.toml
README.md
LICENSE
PLAN.md
crates/
lime-core/
Cargo.toml
src/
lib.rs
buffer.rs
cursor.rs
selection.rs
edit.rs
history.rs
movement.rs
search.rs
file.rs
command.rs
lime-syntax/
Cargo.toml
src/
lib.rs
language.rs
highlighter.rs
theme.rs
lime-ui/
Cargo.toml
src/
lib.rs
app.rs
terminal.rs
layout.rs
editor_view.rs
status_bar.rs
command_bar.rs
file_picker.rs
prompt.rs
theme.rs
input.rs
lime-cli/
Cargo.toml
src/
main.rs
Root Cargo.toml:
[workspace]
members = [
"crates/lime-core",
"crates/lime-syntax",
"crates/lime-ui",
"crates/lime-cli"
]
resolver = "2"
The installed binary should be named lime.
4. Crate Responsibilities
lime-core
Pure editor logic.
Must not depend on terminal UI, Ratatui, themes, syntax highlighting, or input events.
Responsibilities:
- Text buffer
- Cursor state
- Selections, even if selection editing comes later
- Editing operations
- Undo/redo
- File load/save
- Dirty state
- Search inside the current buffer
- Line/column movement
- Large-file metadata and open policy types
lime-syntax
Syntax parsing and highlighting.
Responsibilities:
- Language detection
- Tree-sitter parser setup
- Highlight spans for visible ranges
- Theme scopes
- Graceful fallback for unknown languages or parser failures
lime-ui
Terminal application.
Responsibilities:
- Raw mode
- Alternate screen
- Event loop
- Keybindings
- Rendering
- Editor viewport
- Popups
- File picker
- Status bar
- Help bar
- Prompts and warnings
lime-cli
CLI entry point.
Responsibilities:
- Parse CLI arguments
- Resolve starting path
- Load config
- Start app
- Ensure terminal cleanup on error or panic
5. Core Data Model
Use ropey::Rope for buffer storage.
pub struct TextBuffer {
rope: Rope,
path: Option<PathBuf>,
dirty: bool,
line_ending: LineEnding,
}
Track positions as line/column character positions, not byte offsets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cursor {
pub position: Position,
pub preferred_column: Option<usize>,
}
Use preferred_column for vertical movement so up/down preserve the intended visual column across lines of different lengths.
Also define:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextRange {
pub start: Position,
pub end: Position,
}
pub enum LineEnding {
Lf,
Crlf,
}
6. Editing Commands
Create a command abstraction in lime-core:
pub enum EditorCommand {
InsertChar(char),
InsertText(String),
Newline,
Backspace,
Delete,
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
MoveLineStart,
MoveLineEnd,
MoveFileStart,
MoveFileEnd,
PageUp,
PageDown,
Save,
Undo,
Redo,
Search(String),
}
The UI should convert terminal key events into app actions or editor commands. The core applies commands and returns effects.
pub enum CommandResult {
None,
Modified,
Saved,
CursorMoved,
NeedsPath,
}
7. Undo/Redo
Implement undo/redo early.
Represent edits as reversible transactions:
pub struct EditTransaction {
pub edits: Vec<TextEdit>,
pub before_cursor: Cursor,
pub after_cursor: Cursor,
}
pub enum TextEdit {
Insert {
at: Position,
text: String,
},
Delete {
range: TextRange,
deleted_text: String,
},
}
Typing consecutive characters should be grouped into one undo transaction until one of these happens:
- Cursor movement
- Newline
- Backspace/delete
- Save
- Different command type
- Focus changes to a popup/prompt
8. Large File Handling
Before reading a file, inspect metadata.
Recommended thresholds:
<= 5 MB: open normally
5-25 MB: open with warning prompt
> 25 MB: require explicit confirmation
> 100 MB: refuse by default unless --force
CLI/TUI warning example:
This file is 84 MB. Lime may be slower with very large files.
Open anyway? [y/N]
Core policy type:
pub struct FileOpenPolicy {
pub warn_threshold_bytes: u64,
pub confirm_threshold_bytes: u64,
pub hard_threshold_bytes: u64,
pub force: bool,
}
The file picker should also use this policy before replacing the current buffer.
9. Terminal UI Layout
Default layout:
┌────────────────────────────────────────────┐
│ 1 │ fn main() { │
│ 2 │ println!("hello"); │
│ 3 │ } │
│ │ │
│ │ │
├────────────────────────────────────────────┤
│ lime file.rs rust Ln 2, Col 5 modified │
│ Ctrl-S Save Ctrl-F Files Ctrl-Q Quit │
└────────────────────────────────────────────┘
Main regions:
- Editor viewport
- Status bar
- Help/action bar
- Optional popup overlay
Use ratatui::Layout for the main shell.
10. Rendering Strategy
Render only visible lines.
UI viewport state:
pub struct Viewport {
pub top_line: usize,
pub left_col: usize,
pub height: usize,
pub width: usize,
}
Editor rendering should:
- Render visible lines only
- Draw line numbers
- Draw cursor
- Draw selected text later
- Apply syntax spans when available
- Horizontally scroll for long lines
- Vertically scroll when the cursor nears viewport edges
- Avoid allocating the entire file each frame
Gutter width:
let gutter_width = total_lines.to_string().len() + 2;
11. Input Model
Recommended shortcuts:
Ctrl-S Save
Ctrl-Q Quit
Ctrl-F File picker
Ctrl-G Go to line
Ctrl-Z Undo
Ctrl-Y Redo
Ctrl-A Start of line
Ctrl-E End of line
Ctrl-R Search in current file
Esc Close popup/cancel prompt
Arrow keys Move cursor
PageUp Page up
PageDown Page down
Home Start of line
End End of line
Backspace Delete backward
Delete Delete forward
Enter Newline
Tab Insert spaces according to config
Use an app-level action enum:
pub enum AppAction {
Editor(EditorCommand),
Save,
Quit,
OpenFilePicker,
OpenGoToLine,
OpenSearch,
ClosePopup,
Confirm,
Cancel,
}
Do not hardcode behavior directly inside the event loop. Route input through a mapping layer.
12. File Picker
Ctrl-F opens a centered popup listing files recursively from the current directory.
Behavior:
- Scan current working directory or selected workspace root
- Respect
.gitignore - Skip hidden files by default
- Skip common bulky directories:
.gittargetnode_modulesdist.nextbuild
- Fuzzy filter as the user types
- Arrow up/down changes selection
- Enter opens selected file
- Escape closes picker
- Warn if current file has unsaved changes before replacing it
- Warn before opening very large files
Popup layout:
╭─ Open File ─────────────────────────────╮
│ search: main │
│ │
│ > crates/lime-cli/src/main.rs │
│ crates/lime-core/src/buffer.rs │
│ README.md │
╰─────────────────────────────────────────╯
State:
pub struct FilePickerState {
pub query: String,
pub all_files: Vec<PathBuf>,
pub matches: Vec<FileMatch>,
pub selected: usize,
}
pub struct FileMatch {
pub path: PathBuf,
pub score: u32,
}
13. Syntax Highlighting
Start with these languages:
- Rust
- JavaScript
- TypeScript
- Python
- JSON
- TOML
- Markdown
Language detection:
pub enum Language {
Rust,
JavaScript,
TypeScript,
Python,
Json,
Toml,
Markdown,
PlainText,
}
Detection should use file extension first.
Highlighting should be resilient:
- Unknown language falls back to plain text
- Parser failure falls back to plain text
- Initial implementation may highlight visible ranges only
- Incremental parsing can come later
Theme model:
pub struct Theme {
pub background: Color,
pub foreground: Color,
pub gutter: Color,
pub cursor: Color,
pub selection: Color,
pub keyword: Color,
pub string: Color,
pub comment: Color,
pub function: Color,
pub type_name: Color,
pub number: Color,
pub error: Color,
}
Use a clean default dark theme with calm contrast.
14. Save/Quit Behavior
If there are no unsaved changes, Ctrl-Q exits immediately.
If the buffer is dirty:
Unsaved changes. Save before quitting? [s]ave [d]iscard [c]ancel
If saving an unnamed buffer:
Save as: _
Save operation should:
- Write to a temporary file
- Flush data
- Rename over original path
- Preserve line endings where practical
- Clear dirty flag after success
- Show a status message after success or failure
15. Config
Keep config simple at first.
Locations:
macOS: ~/Library/Application Support/lime/config.toml
Linux: ~/.config/lime/config.toml
Initial config:
theme = "lime-dark"
tab_width = 4
insert_spaces = true
show_line_numbers = true
confirm_large_files = true
Rust model:
pub struct Config {
pub theme: String,
pub tab_width: usize,
pub insert_spaces: bool,
pub show_line_numbers: bool,
pub confirm_large_files: bool,
}
If config is missing or invalid, use defaults and show a non-blocking warning.
16. Error Handling
Use anyhow at app/CLI boundaries.
Use thiserror for library errors.
Example:
#[derive(thiserror::Error, Debug)]
pub enum LimeError {
#[error("file is too large: {size} bytes")]
FileTooLarge { size: u64 },
#[error("buffer has no file path")]
MissingPath,
#[error("invalid cursor position")]
InvalidPosition,
}
Terminal cleanup must always happen.
Use a guard:
pub struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
// disable raw mode
// leave alternate screen
// show cursor
}
}
Never leave the terminal in raw mode after an error or panic.
17. CLI Design
Basic usage:
lime
lime file.rs
lime .
lime --force huge.log
lime --config path/to/config.toml
CLI args:
#[derive(Parser)]
pub struct Cli {
pub path: Option<PathBuf>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub config: Option<PathBuf>,
}
Behavior:
- No path: open empty unnamed buffer
- File path: open file
- Directory path: open empty buffer with file picker rooted at that directory
- Nonexistent file: create a new buffer with that path
- Huge file: warn/confirm unless
--force
18. Testing Strategy
lime-core
Most tests should live here.
Test:
- Insert characters
- Insert text
- Insert newline
- Backspace in middle of line
- Backspace at start of line joins lines
- Delete in middle of line
- Delete at end of line joins lines
- Cursor movement left/right/up/down
- Line start/end
- File start/end
- Undo/redo
- Search
- Save/load roundtrip
- Unicode text
- Tabs
- Long lines
- Empty file behavior
Example test:
#[test]
fn backspace_joins_lines() {
let mut editor = Editor::from_text("hello\nworld");
editor.set_cursor(Position { line: 1, column: 0 });
editor.backspace();
assert_eq!(editor.text(), "helloworld");
assert_eq!(editor.cursor().position, Position { line: 0, column: 5 });
}
lime-syntax
Test:
- Language detection
- Parser setup does not panic
- Highlight spans are valid
- Unknown files fall back to plain text
lime-ui
Test pure/non-terminal pieces:
- Keybinding mapping
- Viewport scrolling
- File picker filtering
- Status string formatting
- Layout calculations
- Prompt state transitions
Avoid depending heavily on full terminal snapshot tests in the first version.
19. Implementation Order
Step 1: Create workspace
Create all workspace crates and make cargo test pass.
Deliverables:
- Root
Cargo.toml lime-corelime-syntaxlime-uilime-cli- Minimal compiling binary named
lime
Step 2: Implement lime-core
Implement:
TextBufferPositionTextRangeCursor- Load from string
- Load from file
- Save to file
- Insert char/text
- Newline
- Backspace
- Delete
- Cursor movement
- Dirty flag
Add comprehensive tests.
Step 3: Implement undo/redo
Add:
EditTransaction- Undo stack
- Redo stack
- Transaction grouping for typing
- Tests for edit operations and grouped undo behavior
Step 4: Implement basic terminal app
In lime-ui, implement:
- Raw mode
- Alternate screen
- Panic-safe cleanup
- Event loop
- Render empty editor
- Render file content
- Status bar
- Help bar
- Cursor rendering
At this point:
cargo run -p lime-cli -- README.md
should open a navigable file.
Step 5: Wire editing input
Add keyboard handling for:
- Text input
- Enter
- Tab
- Backspace
- Delete
- Arrow movement
- Home/end
- Page up/down
- Save
- Quit
After this step, Lime should be a usable single-file editor.
Step 6: Add viewport scrolling
Implement:
- Vertical scroll
- Horizontal scroll
- Cursor-follow behavior
- Page up/down
- Long-line handling
Do not render the whole file.
Step 7: Add file open policy
Before reading files:
- Check size
- Show warning prompt in TUI
- Support
--force - Refuse extreme files by default
Step 8: Add file picker
Implement:
- Recursive file scan
- Fuzzy matching
- Popup rendering
- Query input
- Selection movement
- Opening selected files
- Dirty-buffer confirmation before replacing current file
Bind to Ctrl-F.
Step 9: Add syntax highlighting
Implement lime-syntax.
Start with:
- Language detection
- Rust highlighting
- Plain-text fallback
Then add:
- JavaScript
- TypeScript
- Python
- JSON
- TOML
- Markdown
Integrate highlighting into editor rendering.
Step 10: Add quality-of-life features
Add:
- Go to line popup
- Search in current file
- Unsaved quit prompt
- Save-as prompt
- Status messages
- Error display
- Config loading
- Tab width setting
- Insert spaces setting
20. Minimum Definition of Done
The first complete version should support:
lime file.rs
and include:
- Clean terminal UI
- Standard text editing
- Natural cursor movement
- Save with
Ctrl-S - Quit with
Ctrl-Q - Unsaved changes warning
- Syntax highlighting for known languages
- File picker with
Ctrl-F - Recursive current-directory browsing
- Fuzzy file filtering
- Moderately large file handling without freezing
- Warning before opening very large files
- Passing core editing tests
- macOS and Linux support
21. Explicit Non-Goals For First Version
Do not implement these yet:
- Plugin system
- Extension marketplace
- LSP
- Multi-pane editor
- Integrated terminal
- Git UI
- Debugger
- AI features
- Remote editing
- Modal Vim emulation
These can be added later only after the core editor feels excellent.