feat: implement modular modal editor and isolated Ruby plugins

This commit is contained in:
Valdemar Lorenzen
2026-10-05 13:48:01 +02:00
parent 6e1de421f1
commit 396ffc4f3a
11 changed files with 3450 additions and 2 deletions
+98
View File
@@ -0,0 +1,98 @@
# vamp
A modal Rust editor with a replaceable frontend and subprocess-isolated Ruby plugins.
## Run
Requires current stable Rust and an interactive terminal. Ruby 2.6+ with its standard-library JSON support is required only for configuration/plugins; no Ruby gems are needed.
```sh
cargo run -- --no-config notes.txt
cargo run -- --config examples/config.rb notes.txt
```
Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty buffer. Use `:w path` to save it. `cargo run -- --help` lists the command-line options.
### Editing
| Mode | Keys |
| --- | --- |
| Normal | `i` insert before cursor; `a` insert after cursor; `o`/`O` open line below/above |
| Normal | `h`/`j`/`k`/`l` or arrows; `0`/`$` or Home/End |
| Normal | `x`/Delete delete; `u` undo; Ctrl-r redo; `:` enter command mode |
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End; Esc returns to normal |
| Command | Enter executes; Backspace edits; Esc cancels |
Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`.
`:q` refuses unsaved changes. `:wq` quits only after a successful save. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
## Module seams
One crate, three modules, no workspace or frontend-specific types in the editing backend:
- `editor`: owns buffer, cursor, modes, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its interface accepts `Input`, exposes state, and returns an `Invocation` when a plugin command is selected.
- `tui`: translates terminal events to `Input`, renders state, and advertises `text` and `styled_text` capabilities. Another frontend can drive the same backend; the backend does not import terminal libraries.
- `ruby`: loads configuration and runs one Ruby subprocess per plugin. It returns feature manifests and edit effects; it does not own the buffer or terminal.
- The executable wires the modules together and disables a faulted plugin without changing core editing availability.
Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, disable core, or bind over essential modal editing keys. `:plugins` lists active features and frontend capabilities.
A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, `Vamp.plugin "canvas", requires: ["graphics"]` will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions.
## Ruby configuration
By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/config.rb` when that environment variable is unset. `--config path` selects a file explicitly; `--no-config` bypasses Ruby entirely. Set `VAMP_RUBY` to select a different Ruby executable.
```ruby
Vamp.configure do |config|
config.plugin "plugins/greeting.rb"
config.bind "g", "greet"
end
```
Plugin paths resolve relative to the config file, not the shell's working directory. Bindings are single-character Normal-mode keys targeting registered command names. Configuration errors fall back to built-in editing and appear in the status message; one plugin's load failure does not prevent other plugins loading.
## Ruby plugins
```ruby
Vamp.plugin "greeting", requires: ["text"]
Vamp.command "greet" do |editor, args|
name = args.empty? ? "world" : args
editor.insert "Hello, #{name}!\n"
editor.message "Greeting inserted by Ruby"
end
```
Run `:greet vamp`, or press `g` with the example configuration. `examples/plugins/greeting.rb` also provides `:upper` to uppercase the buffer.
Callbacks receive an immutable context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, and `editor.path`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace.
`editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
### Messaging
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands) or `config` (plugin paths and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed effects (`insert`, `replace`, `message`). Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
JSON uses Ruby's standard library. Protocol Buffers is not installed or required; there is no frontend/network wire protocol yet. The frontend currently calls the Rust library directly.
## Current limits
- One text buffer; Normal, Insert, and Command modes. No Visual mode, Vim counts/operators, splits, search, LSP, or graphical frontend.
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
- Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing.
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
## Verify
```sh
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check
ruby -c src/ruby_host.rb
```
Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. The executable was also exercised in PTY sessions for editing/saving, plugin crash and hang recovery, bracketed paste, scrolling, resize, and terminal restoration.