feat: implement modular modal editor and isolated Ruby plugins
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user