Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c3c73e5b9 | |||
| bbb61c2fdd | |||
| 9629655206 | |||
| 099c15771f | |||
| 788638f34e | |||
| de6b4d47f3 |
@@ -0,0 +1,9 @@
|
||||
AllCops:
|
||||
TargetRubyVersion: 2.6
|
||||
NewCops: enable
|
||||
|
||||
Style/StringLiterals:
|
||||
EnforcedStyle: double_quotes
|
||||
|
||||
Style/StringLiteralsInInterpolation:
|
||||
EnforcedStyle: double_quotes
|
||||
@@ -4,433 +4,57 @@ A modal Rust editor with a replaceable frontend and subprocess-isolated Ruby plu
|
||||
|
||||
## 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.
|
||||
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 to run vamp itself.
|
||||
|
||||
```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.
|
||||
Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty buffer. `cargo run -- --help` lists the command-line options.
|
||||
|
||||
### Default editing keys
|
||||
## Getting started
|
||||
|
||||
| Mode | Keys |
|
||||
Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:w` and Enter to save, or `:w path` to name an unnamed buffer. Type `:q` and Enter to quit; it refuses unsaved changes in any open buffer. `:q!` explicitly discards changes and quits.
|
||||
|
||||
| Task | Default keys |
|
||||
| --- | --- |
|
||||
| All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels |
|
||||
| Normal, Visual | Space Space opens the palette through the default leader |
|
||||
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers |
|
||||
| Normal, Visual | Space o opens the floating tree in INSERT/filter mode; Space e opens/focuses the docked compact tree |
|
||||
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
|
||||
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
|
||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
|
||||
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
|
||||
| Normal | `J` (Shift+j) joins each targeted line with the next line |
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `v` starts Visual selections; `p`/`P` paste the configured clipboard after/before each caret (system by default); Esc keeps only the primary caret |
|
||||
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
|
||||
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selections; `v`/Esc cancels highlighting |
|
||||
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
|
||||
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret; Esc returns to Normal, retaining carets |
|
||||
| Command | Enter executes; Backspace edits; Esc cancels |
|
||||
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
||||
| Move in Normal | `h`/`j`/`k`/`l` or arrows; `w`/`b`/`e` for words |
|
||||
| Enter/leave Insert | `i` / Esc |
|
||||
| Save in Normal | Space then `w`, or `:w` then Enter |
|
||||
| Undo/redo in Normal | `u` / `U` (Shift+u) |
|
||||
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
|
||||
| Select/copy/cut | `v` in Normal, move to extend, then `y`/`d` in Visual |
|
||||
| Paste in Normal | `p`/`P` after/before the caret; system clipboard by default |
|
||||
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
|
||||
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
|
||||
|
||||
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
|
||||
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
|
||||
|
||||
Word motions separate runs of Unicode letters/numbers/underscore from runs of punctuation or emoji. Classification uses each grapheme's first character; combining sequences and ZWJ emoji stay intact. WORD motions treat any non-whitespace run as one WORD.
|
||||
## Configuration
|
||||
|
||||
`w`/`W` move to the next start. `b`/`B` move to the current start, or the previous start when already at one. `e`/`E` move to the current end when before it, otherwise the next end. Motions cross lines but never wrap. Truly empty lines are stops for start motions; whitespace-only lines are skipped. End motions skip empty lines while looking for a word. With no further target, motions stop at the buffer edge, including trailing whitespace or an empty final line.
|
||||
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 and uses defaults.
|
||||
|
||||
These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move all insertion carets without splitting their undo group. Motions never edit text, add history, or clear an active search highlight.
|
||||
Start with [`examples/config.rb`](examples/config.rb). The [configuration reference](docs/configuration.md) covers preferences, bindings, and native LSP setup. Language servers are **disabled by default**; install a trusted server and explicitly enable it.
|
||||
|
||||
Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Search counts wrap through the available matches.
|
||||
|
||||
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
|
||||
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader prefixes retain the count until their leaf action resolves. Other commands consume the prefix and execute once, including line-start/end, edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and Vim operator grammar such as `3dw` is not supported.
|
||||
|
||||
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. Visual selection is characterwise and inclusive of the endpoints; its read-only ranges are end-exclusive. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines appear as a blue blank cell after the line. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
|
||||
|
||||
Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences.
|
||||
|
||||
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. `p`/`P` paste after/before each current grapheme, including multiline text. Each caret lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
|
||||
|
||||
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only empty final-row selections enters Insert without overwriting either clipboard.
|
||||
|
||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode.
|
||||
|
||||
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only empty final-row selections does not overwrite either register. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
||||
|
||||
### Clipboard policy
|
||||
|
||||
`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either.
|
||||
|
||||
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations:
|
||||
|
||||
```ruby
|
||||
config.clipboard_mode = "separate"
|
||||
config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual
|
||||
config.bind "ctrl+g", config.core.paste_system_before
|
||||
```
|
||||
|
||||
For other applications, multi-selection exports join fragments with LF. Vamp retains their original boundaries privately while the OS text exactly matches its export, so its own copies still paste pairwise. Different external text invalidates that metadata and becomes one literal fragment—even when it contains newlines—broadcasting to all targets. Identical external replacement text is inherently indistinguishable from an unchanged export. No empty-system fallback to stale internal text occurs.
|
||||
|
||||
System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back.
|
||||
|
||||
macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group. Tests and executable smoke use isolated providers and never read or overwrite the user's desktop clipboard.
|
||||
|
||||
### Repeat last edit
|
||||
|
||||
`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, Normal deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once, consistent with other non-motion commands.
|
||||
|
||||
In Normal, Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Equal spans broadcast; unequal spans require matching target counts. In Visual, the current explicit ranges replace the stored targeting. Target-building commands themselves do not recreate old cursors.
|
||||
|
||||
Clipboard paste captures the fragments actually pasted, so a later yank or external clipboard change does not change repeat's payload; dot paste never rereads the OS. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. Dot cut/change follows the current clipboard policy and stages publication until replay validation finishes. A refusal or provider error preserves editor text, caret/primary state, register caches, history, and recipe; already-published OS data cannot be transactionally restored.
|
||||
|
||||
Each repeat returns to Normal and creates one undo entry restoring the full caret set and primary. Original typing/paste/typing boundaries remain separate; dot replays the selected committed group as one transaction. Typed text retains scalar insertion boundaries, preserving combining/ZWJ interactions with surrounding graphemes; bracketed paste remains one bulk literal operation. Trailing Insert movement is not repeated.
|
||||
|
||||
The last recipe is session-wide across buffers, but pending Insert groups and undo/redo remain buffer-local. No-op/identical edits, rejected input, navigation, yank, explorer filtering, saving, undo/redo, and plugin effects do not replace it. Successful buffer parking and ordinary explicit Ex execution commit pending native typing at existing boundaries; failed buffer requests do not split that group. Plugins never execute through dot. Repeat does not consume or recursively replace its recipe. Rebind with `config.core.repeat_last_edit`, including Normal/Visual leader routes.
|
||||
|
||||
### Search
|
||||
|
||||
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow.
|
||||
|
||||
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again.
|
||||
|
||||
Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`.
|
||||
|
||||
`:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. 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.
|
||||
|
||||
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
||||
|
||||
### Buffers
|
||||
|
||||
One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID, adjusting them only as needed to keep the primary visible after resizing. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer.
|
||||
|
||||
| Command | Behavior |
|
||||
| --- | --- |
|
||||
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
|
||||
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty |
|
||||
| `:buffer N` | Switch to buffer ID `N` |
|
||||
| `:bnext` / `:bprev` | Switch in ascending-ID order, wrapping at either end |
|
||||
| `:bdelete [N]` | Close the active or named buffer; refuse unsaved changes |
|
||||
| `:bdelete! [N]` | Explicitly discard changes and close the active or named buffer |
|
||||
|
||||
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
|
||||
|
||||
Switching from Insert commits its pending undo group and parks that buffer in Normal. Explicit buffer switching from Visual retains that buffer's directed ranges and Visual mode; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. The new commands appear in the command palette and can be bound by exact command name in Normal mode. When several buffers are open, status shows the active ID and total count.
|
||||
|
||||
### File picker and explorer
|
||||
|
||||
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
|
||||
|
||||
The dock defaults to the right of the editor workspace. Set `config.file_explorer_side = "left"` (or `"right"`, `"top"`, `"bottom"`) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space.
|
||||
|
||||
Both view preferences default to `"tree"`: `config.file_picker_view` and `config.file_explorer_view` independently accept `"list"` or `"tree"`. Set the floating preference to `"list"` to retain the old flat picker. Ctrl+t toggles the current focused instance without rescanning. List retains searchable full relative paths. Tree shows folders first, two-cell indentation, and basename-only files. Folder names end in `/` and use cyan text; files use the normal foreground color. Fold-state arrows are omitted. Maximal single-child directory chains share one row, such as `src/client/http/`; a directory containing one file still gets its own row.
|
||||
|
||||
Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately.
|
||||
|
||||
| File-view input | Behavior |
|
||||
| --- | --- |
|
||||
| Type / Backspace | Type query text in List/explorer INSERT / remove one whole grapheme; explorer NORMAL uses hjkl for navigation |
|
||||
| j/k (NORMAL); Ctrl+j/k or Up/Down | Wrap forward/backward through visible rows |
|
||||
| l (NORMAL); Ctrl+l/Right | NORMAL expands/enters a folder; INSERT selects its visible child without changing the filter |
|
||||
| h (NORMAL); Ctrl+h/Left | NORMAL collapses/selects parent; INSERT selects its visible parent without changing the filter |
|
||||
| Enter | Toggle a folder, or open the selected file using existing buffer deduplication |
|
||||
| `i` (explorer NORMAL) | Enter explorer INSERT/filter mode without inserting `i` |
|
||||
| Esc | INSERT → NORMAL with the filter and selection retained; NORMAL → buffer focus or floating close |
|
||||
| Ctrl+u | Clear the filter in NORMAL or INSERT without changing mode/focus; preserve the selected path where possible |
|
||||
| Tab | Return to editor focus from a dock |
|
||||
| Ctrl+w | Close either presentation |
|
||||
| Ctrl+r | Rescan while retaining query, surviving expansion, and selection |
|
||||
| Ctrl+p | Open the command palette, closing floating search or blurring a dock |
|
||||
| Ctrl+t | Toggle List/Tree while retaining view state |
|
||||
|
||||
Opening a file from a dock leaves it visible and returns focus to the buffer. Space e refocuses it. Choosing a folder during filtering returns to explorer NORMAL and toggles that folder; it never opens a text buffer for a directory. Selections use actual OS-path identities across filtering, view switches, and refresh; a removed or hidden selection falls back to the nearest visible ancestor, then the first row. Query input never edits the underlying buffer; bracketed paste is ignored while file search is focused. Cancellation preserves buffer mode, selections, and pending Insert history. Invalid, binary, unreadable, or disappeared file selections report an error and keep focus without changing the active buffer.
|
||||
|
||||
Clearing returns to the full catalog and reveals the selected file's ancestry in NORMAL when needed. With no current result, it uses the last available selection; removed paths fall back to a visible ancestor, then the first row. Clearing an already empty query is harmless. Filtered folders can be collapsed/expanded in NORMAL without losing the query. Temporary filtered collapse overrides survive mode/view changes and refresh of surviving directories; editing or clearing the query resets them so new matches can be revealed. Choosing a directory during filtering transitions to NORMAL and toggles it while retaining the query.
|
||||
|
||||
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes.
|
||||
|
||||
### Command palette
|
||||
|
||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists core actions and registered Ex/plugin commands with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||
|
||||
Search is case-insensitive: every whitespace-separated query token must occur in the command name, human-readable label, or owner. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
||||
|
||||
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. Accepting an Ex/plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch. Choosing `q!` therefore does not immediately discard changes.
|
||||
|
||||
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
|
||||
|
||||
### Selection-list bindings
|
||||
|
||||
The palette, floating file picker, and focused docked explorer share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item; `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key:
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+f", config.core.down, mode: config.modes.selection
|
||||
config.bind "ctrl+b", config.core.up, mode: config.modes.selection
|
||||
config.bind "ctrl+j", nil, mode: config.modes.selection
|
||||
```
|
||||
|
||||
Selection bindings take precedence while a file or command list is focused. Unbound printable keys filter in List/explorer INSERT, but not explorer NORMAL; a disabled key is consumed without filtering or moving. Only `up`, `down`, and `nil` are accepted—no leader sequences, edits, or plugin commands. Invalid declarations leave existing routes unchanged. Navigation does not change text, carets, history, or the buffer mode. Both list footers show effective keys, including remaps and disabled directions.
|
||||
|
||||
Tree views use `config.modes.tree`, accepting `up`, `down`, `left`, `right`, or `nil`. Explorer NORMAL defaults to h/j/k/l for parent/next/previous/child, with the existing control/arrow routes retained. Tree routes override shared selection routes. In explorer INSERT, printable tree keys are not intercepted: h/j/k/l become query text, while control/special-key tree routes still navigate. Ctrl+h/l select visible parents/children in the filtered projection without changing the query or saved folder expansion. Unmatched keys inherit shared selection bindings. Cached hints prioritize plain keys in NORMAL and omit them in INSERT. For example:
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+f", config.core.right, mode: config.modes.tree
|
||||
config.bind "ctrl+h", nil, mode: config.modes.tree
|
||||
```
|
||||
|
||||
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
|
||||
|
||||
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside lists, existing editing bindings are unchanged: Ctrl+k still clones above in Normal/Visual, and explicit Ctrl+j defaults insert a newline in Insert or submit Command/Search prompts. Enter and Ctrl+j can now be rebound independently.
|
||||
|
||||
### Leader key
|
||||
|
||||
Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", "bnext"`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings.
|
||||
|
||||
Set `config.leader = ","` to change the trigger for every default and configured leader route. Suffix keys stay unchanged: the default palette binding becomes comma then Space, save becomes comma then `w`, and buffer-next becomes comma then `b` then `n`. Choose an unused single key in the existing notation; Escape, ASCII digits, malformed keys, and a new trigger already directly bound in Normal/Visual are rejected without replacing the current choice. A direct binding on the current trigger—including `nil`—takes precedence per mode and hides unreachable leader routes from the palette.
|
||||
|
||||
Esc cancels a pending sequence and count without leaving Visual or collapsing carets. Unknown suffixes consume the key and cancel safely, never falling through to an edit. Paste, external command execution, buffer switching, and binding/leader changes clear pending state. A count before a leader motion applies normally; digits inside a sequence are literal suffix keys. Plugins execute once. Outside Normal/Visual, the leader has no prefix behavior, so Space still types normally in Insert and prompts.
|
||||
|
||||
Leader bindings require Normal/Visual and the target's existing mode eligibility; Ex/plugin targets remain Normal-only. A sequence cannot be both a complete binding and another binding's prefix: ambiguous declarations are refused atomically. Disabled leaves remain reserved. Only leader-prefixed sequences are supported—not arbitrary multi-key mappings, timers, or macros.
|
||||
|
||||
## Module seams
|
||||
|
||||
One crate; no workspace or terminal-coordinate types in the editing backend:
|
||||
|
||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and semantic byte spans; it has no terminal color/geometry or editing dependency.
|
||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, and versioned asynchronous update contracts. It has no LSP protocol types.
|
||||
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, and definition/hover results. Its private configuration resolver supplies presets without restricting language IDs.
|
||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation, and retires failed plugin ownership.
|
||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||
|
||||
Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()` even without input, and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `poll()` reports whether background activity requires redrawing. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
|
||||
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
|
||||
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
|
||||
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
|
||||
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
|
||||
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
|
||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes and owner/server retirement clear them. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport.
|
||||
|
||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||
|
||||
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
|
||||
|
||||
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column.
|
||||
|
||||
### Syntax highlighting
|
||||
|
||||
Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and compiled-in grammar/query packages. The selected language set is Rust, Ruby, Python, JSON, Bash, Makefile, Markdown, JavaScript/JSX, TypeScript/TSX, HTML, CSS, YAML, TOML, C, C++, and Go. Detection uses filename/extension or the in-memory first-line shebang, including common special filenames such as Makefile, Gemfile, and Cargo.lock; no file reread occurs. Unsupported formats use the normal foreground. This migration intentionally replaces the broader former grammar bundle: languages outside this explicit set, such as Lua/Haskell/Scala/Perl, are plain text until added. The detected language appears in status, and save-as redetects it.
|
||||
|
||||
Colors retain the terminal palette: comments dark gray, strings green, keywords magenta, numbers yellow, types/markup cyan, and functions light blue. Grammar queries handle multiline/nested constructs, even when their opening is above the viewport. Markdown uses its inline grammar and named fenced-code injections; HTML script/style content uses JavaScript/CSS. Known tagged-template and raw-string injections use the same selected grammar registry; unknown injected languages retain host highlighting or plain text. Highlighting styles whole graphemes and expanded tab cells; partially clipped wide placeholders remain unchanged. Search, Visual selections, and secondary carets retain foreground/background precedence.
|
||||
|
||||
Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. Syntect, its scope checkpoints, and its dependencies are removed; there is no second baseline engine, runtime grammar download, or LSP scaffold.
|
||||
|
||||
### Core feature registry
|
||||
|
||||
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, or disable core. User configuration can override any keybinding without replacing the core implementation. `: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.
|
||||
|
||||
### Native LSP
|
||||
|
||||
LSP runs in Rust, independently of Ruby plugin hosts. **Every language server is disabled by default.** Ruby evaluates configuration only at startup; no LSP plugin needs loading. Install a trusted server yourself and explicitly opt in:
|
||||
|
||||
```ruby
|
||||
Vamp.configure do |config|
|
||||
config.lsp["rust"] = true # rust-analyzer, extensions: ["rs"]
|
||||
config.lsp["python"] = false # disabled (also the default when absent)
|
||||
|
||||
config.lsp["my-language"] = {
|
||||
command: ["my-language-server", "--stdio"],
|
||||
extensions: ["myl"],
|
||||
initialization_options: {},
|
||||
settings: { "my-language-server" => { "exampleOption" => true } }
|
||||
}
|
||||
config.bind "leader d", config.core.lsp.definition
|
||||
config.bind "leader D", config.core.lsp.diagnostics
|
||||
config.bind "K", config.core.lsp.hover
|
||||
end
|
||||
```
|
||||
|
||||
Keys are arbitrary nonempty strings, used as the protocol's `languageId`; they need no Tree-sitter grammar or hardcoded language enum. Absent/`false` entries are disabled. `true` enables a built-in preset: currently `"rust"` uses `rust-analyzer`/`rs`, and `"python"` uses `pylsp`/`py`. Unknown keys need a configuration object with nonempty `command` argv and `extensions` (without leading dots). Objects also explicitly enable servers; known presets supply omitted command/extensions. For example, `config.lsp["rust"] = { command: ["/custom/path/rust-analyzer"] }` overrides the executable while retaining `rs`.
|
||||
|
||||
Only `command`, `extensions`, `initialization_options`, and `settings` are accepted. Both option/settings values must be JSON-compatible objects and default to `{}`. Invalid declarations reject configuration with a notice rather than activating a guessed server. Server commands are executable/argument arrays, never shell commands; use an absolute path if the executable is not on PATH. No server installation or restart is automatic.
|
||||
|
||||
Servers start lazily only when a matching buffer opens. Extension matching is case-sensitive; if enabled languages overlap, the first language in sorted key order wins. One worker runs per language configuration/project root, with at most 16 enabled languages/server-project keys. Start vamp from your project root: its startup working directory is the workspace; automatic root discovery is not implemented.
|
||||
|
||||
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
|
||||
|
||||
`:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed.
|
||||
|
||||
For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects: `definition`, `diagnostics`, and `hover`. Each routes to the same native implementation as its Ex command; no LSP keys are bound by default. These actions are also available in the command palette. The namespace is distinct from `config.lsp[language_id]` server configuration; flat Ruby LSP action members have been removed without aliases.
|
||||
|
||||
Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`); underscore Ex names are removed without aliases. The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
|
||||
|
||||
Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts.
|
||||
|
||||
`:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it.
|
||||
|
||||
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. Multiline content is preserved; Markdown is shown as plain text, not rendered markup. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
|
||||
|
||||
Native LSP currently provides synchronization, diagnostics, go-to-definition, and hover—not completion, rename, formatting, code actions, workspace edits, or semantic highlighting. Tree-sitter remains the syntax highlighter. The former Ruby LSP plugin has been removed; migrate its `servers` entries to `config.lsp[language_id]` objects without the `language_id` field.
|
||||
|
||||
## 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.leader = "space"
|
||||
config.clipboard_mode = "system"
|
||||
config.file_explorer_side = "right"
|
||||
config.file_explorer_view = "tree"
|
||||
config.file_picker_view = "tree"
|
||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
config.bind "ctrl+r", config.core.redo
|
||||
config.bind "shift+j", config.core.join_lines
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||
config.bind "ctrl+f", config.core.search
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.search
|
||||
config.bind "ctrl+v", config.core.select
|
||||
config.bind "ctrl+c", config.core.copy, mode: config.modes.visual
|
||||
config.bind "ctrl+x", config.core.cut, mode: config.modes.visual
|
||||
config.bind "ctrl+r", config.core.change, mode: config.modes.visual
|
||||
end
|
||||
```
|
||||
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. `config.modes.selection` configures shared list navigation; `config.modes.tree` overrides it in tree browsing. Omitting `mode` selects Normal. The leader defaults to `"space"` and is applied before configured bindings.
|
||||
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
||||
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
|
||||
Ruby builds `config.core` from the Rust action catalog before loading configuration or plugin scripts. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen. Action names are not duplicated in Ruby, so Rust catalog additions/renames immediately change the Ruby interface without code generation or handwritten exports.
|
||||
|
||||
Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike.
|
||||
|
||||
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
||||
|
||||
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, and `shift+a` through `shift+z`. `shift+j` and `J` address the same key.
|
||||
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
||||
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
|
||||
| Available modes | Methods on `config.core` |
|
||||
| --- | --- |
|
||||
| Normal | `insert`, `append`, `open_below`, `open_above` |
|
||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||
| Normal | `search`, `paste_before`, `paste_after` |
|
||||
| Normal | `paste_system_before`, `paste_system_after` |
|
||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `repeat_last_edit` |
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Insert, Visual | `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual, Tree | `left`, `right` |
|
||||
| Normal, Insert, Visual, Selection, Tree | `down`, `up` |
|
||||
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||
| Normal, Insert | `delete` |
|
||||
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |
|
||||
| Visual | `copy_system` |
|
||||
| Normal, Insert, Command, Search, Visual | `normal`, `palette` |
|
||||
| Insert, Command, Search | `backspace` |
|
||||
| Insert | `newline` |
|
||||
| Command, Search | `submit` |
|
||||
|
||||
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
|
||||
|
||||
```ruby
|
||||
Vamp.configure do |config|
|
||||
config.bind "n", config.core.insert
|
||||
config.bind "i", nil
|
||||
config.bind "space", config.core.command
|
||||
config.bind ":", nil
|
||||
end
|
||||
```
|
||||
|
||||
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous leader prefixes, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its single-key and leader bindings, without restoring any default keys they replaced.
|
||||
|
||||
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command/Search/Visual mode or entering commands, restart with `--no-config` to restore defaults.
|
||||
|
||||
## 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 primary-only context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, `editor.path`, `editor.buffer_id`, `editor.revision`, `editor.root`, and `editor.request_id`. 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. Commands run once regardless of target count; successful text effects collapse to the primary before editing once. The plugin API does not expose selections.
|
||||
|
||||
`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.
|
||||
|
||||
### Asynchronous plugins
|
||||
|
||||
`config.plugin "plugin.rb", options: { ... }` passes immutable JSON-compatible options as `Vamp.options`. Declare `Vamp.plugin "name", requires: ["text", "diagnostics"], async: true` to opt into asynchronous command results and document notifications. Existing plugins remain synchronous.
|
||||
|
||||
`Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.
|
||||
|
||||
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
|
||||
|
||||
Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.
|
||||
|
||||
`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.
|
||||
|
||||
### Messaging
|
||||
|
||||
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed text effects (`insert`, `replace`, `message`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
||||
|
||||
Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`.
|
||||
|
||||
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.
|
||||
Ruby configuration and plugins run with your account's filesystem/process permissions. Subprocess isolation is **not a security sandbox**; load only trusted scripts.
|
||||
|
||||
## Current limits
|
||||
|
||||
- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, or graphical frontend. Native opt-in LSP currently supports diagnostics, definitions, and plain-text hover. Native system clipboard access currently supports macOS and Linux; Separate mode retains internal editing on unsupported/headless environments.
|
||||
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
|
||||
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
|
||||
- One visible buffer at a time; no splits/tabs or graphical frontend.
|
||||
- Modal editing, not full Vim compatibility: characterwise Visual selections, but no line/block selection, named registers, or Vim operators. Search is literal, case-sensitive, and single-line.
|
||||
- Native opt-in LSP supports diagnostics, go-to-definition, and plain-text hover—not completion, rename, formatting, or code actions.
|
||||
- System clipboard access supports macOS and Linux. Separate clipboard mode keeps internal editing available on unsupported/headless environments.
|
||||
|
||||
## Verify
|
||||
Performance and subprocess limits are documented in [development](docs/development.md#performance-and-process-limits).
|
||||
|
||||
```sh
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
ruby -c src/ruby_host.rb
|
||||
```
|
||||
## Documentation
|
||||
|
||||
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. Backend paste checks cover ignored modes, literal Unicode/newline insertion, rejection atomicity, and typing/paste/typing undo and redo. Session checks join real Ruby execution to capability/collision rejection, complete command/binding retirement after invalid effects or host failure, surviving plugins, core editing/saving, and broken-config fallback. The executable was also exercised in PTY sessions for editing/saving, rejected-effect retirement, plugin crash and hang recovery, modal bracketed paste, rejected-paste feedback, undo/redo, scrolling, resize, and terminal restoration.
|
||||
|
||||
Unix terminal-hook checks run in isolated child processes and real PTYs to keep global hooks out of the parallel test runner. They cover entry failure, normal return, I/O error, panic cleanup before caller diagnostics, original panic-payload propagation, and later panics producing no stale terminal-restoration output.
|
||||
|
||||
LSP integration checks launch real framed-protocol fixture subprocesses directly from native Rust: fragmented/adjacent frames, initialization/custom settings, arbitrary language IDs, opt-in matching-only startup, negotiated synchronization, open/change/save/close/shutdown, UTF-16 and encoded Unicode paths, dirty targets without enabled LSPs, stale/superseded requests and diagnostics, moved carets, unsupported requests, crashes, malformed frames, unavailable servers, and surviving native editing. Executable PTY smoke exercised idle diagnostic redraw, active-server edits, definition navigation into a dirty non-LSP buffer, Unicode cursor placement, settings, saving, and clean shutdown; its server's parent PID confirmed there was no Ruby LSP host. No desktop clipboard or user files are touched. Windows transport/process cleanup is implemented but not exercised on this macOS workstation.
|
||||
|
||||
Hover regression checks cover typed actions, UTF-16 primary positions, legal content forms, terminal-control sanitization, delayed initialization, cancelled/superseded replies, invalid ranges, missing capabilities/results, popup scrolling/dismissal, tiny Unicode-clipped layouts, and preserved native editing. Executable PTY smoke exercised a multiline hover popup, scrolling without moving the hardware cursor, Escape dismissal, and subsequent insert/save/clean shutdown.
|
||||
| Guide | Contents |
|
||||
| --- | --- |
|
||||
| [Editing](docs/editing.md) | Keys, motions/counts, selections, repeat, clipboard, search, buffers, explorer, palette, syntax highlighting |
|
||||
| [Configuration](docs/configuration.md) | Ruby startup, preferences, action/mode reference, key notation, leader/list/tree bindings, native LSP |
|
||||
| [Plugins](docs/plugins.md) | Ruby API, capabilities, effects, asynchronous callbacks, isolation, messaging protocol |
|
||||
| [Architecture](docs/architecture.md) | Module ownership, session/frontend contracts, terminal lifecycle, syntax caches, native workers |
|
||||
| [Development](docs/development.md) | Verification commands, test organization, performance and process limits |
|
||||
| [Project context](CONTEXT.md) | Terminology and configuration-interface conventions |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Architecture
|
||||
|
||||
[README](../README.md)
|
||||
|
||||
Contributor-facing module boundaries and implementation contracts. Project terminology and interface conventions live in [CONTEXT.md](../CONTEXT.md); verification is in [development](development.md).
|
||||
|
||||
## Module ownership
|
||||
|
||||
One crate; no workspace or terminal-coordinate types in the editing backend:
|
||||
|
||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and semantic byte spans; it has no terminal color/geometry or editing dependency.
|
||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, and versioned asynchronous update contracts. It has no LSP protocol types.
|
||||
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, and definition/hover results. Its private configuration resolver supplies presets without restricting language IDs.
|
||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation, and retires failed plugin ownership.
|
||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||
|
||||
## Frontend interface
|
||||
|
||||
Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()` even without input, and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `poll()` reports whether background activity requires redrawing. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
|
||||
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
|
||||
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
|
||||
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
|
||||
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
|
||||
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
|
||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes and owner/server retirement clear them. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport.
|
||||
|
||||
## Terminal lifecycle and layout
|
||||
|
||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||
|
||||
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
|
||||
|
||||
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column.
|
||||
|
||||
## Syntax caches
|
||||
|
||||
Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no second highlighting engine or runtime grammar download.
|
||||
|
||||
## Clipboard providers
|
||||
|
||||
macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group.
|
||||
|
||||
## Native LSP
|
||||
|
||||
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
|
||||
|
||||
## Ruby action catalog
|
||||
|
||||
Ruby builds `config.core` from the Rust action catalog before loading configuration or plugin scripts. Dotted identifiers create nested namespaces automatically; each namespace and Action value is frozen. Action names are not duplicated in Ruby, so Rust catalog additions/renames immediately change the Ruby interface without code generation or handwritten exports.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Configuration
|
||||
|
||||
[README](../README.md)
|
||||
|
||||
Startup configuration, preferences, bindings, and opt-in native language servers. See the [example configuration](../examples/config.rb) and [plugin API](plugins.md).
|
||||
|
||||
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.
|
||||
|
||||
Rejected configuration opens a centered floating **Configuration error** window, sized like the floating file explorer. It shows the selected config path, full failure reason, and explains that default settings and bindings are in use. The diagnostic remains visible even if background messages arrive; editing keys and paste are ignored until **Enter** or **Escape** dismisses it. Use arrows (or Ctrl+h/j/k/l) to scroll long paths/reasons horizontally or vertically, and **Home** to reset the view. Correct the config and restart vamp to apply it. Invalid individual preferences/bindings that do not reject the whole configuration still report ordinary notices.
|
||||
|
||||
```ruby
|
||||
# @param config [Vamp::Config]
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.clipboard_mode = "system"
|
||||
config.file_explorer_side = "right"
|
||||
config.file_explorer_view = "tree"
|
||||
config.file_picker_view = "tree"
|
||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
config.bind "leader g", "greet"
|
||||
config.bind "ctrl+r", config.core.redo
|
||||
config.bind "shift+j", config.core.join_lines
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||
config.bind "ctrl+f", config.core.search
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.search
|
||||
config.bind "ctrl+v", config.core.select
|
||||
config.bind "ctrl+c", config.core.copy, mode: config.modes.visual
|
||||
config.bind "ctrl+x", config.core.cut, mode: config.modes.visual
|
||||
config.bind "ctrl+r", config.core.change, mode: config.modes.visual
|
||||
end
|
||||
```
|
||||
|
||||
## Preferences
|
||||
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. `config.modes.selection` configures shared list navigation; `config.modes.tree` overrides it in tree browsing. Omitting `mode` selects Normal. The leader defaults to `"space"` and is applied before configured bindings.
|
||||
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
||||
|
||||
## Actions and modes
|
||||
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
|
||||
Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike.
|
||||
|
||||
## Key notation
|
||||
|
||||
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
||||
|
||||
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, and `shift+a` through `shift+z`. `shift+j` and `J` address the same key.
|
||||
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
||||
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
|
||||
## Action reference
|
||||
|
||||
| Available modes | Methods on `config.core` |
|
||||
| --- | --- |
|
||||
| Normal | `insert`, `append`, `open_below`, `open_above` |
|
||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||
| Normal | `search`, `paste_before`, `paste_after` |
|
||||
| Normal | `paste_system_before`, `paste_system_after` |
|
||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `repeat_last_edit` |
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Insert, Visual | `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual, Tree | `left`, `right` |
|
||||
| Normal, Insert, Visual, Selection, Tree | `down`, `up` |
|
||||
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||
| Normal, Insert | `delete` |
|
||||
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |
|
||||
| Visual | `copy_system` |
|
||||
| Normal, Insert, Command, Search, Visual | `normal`, `palette` |
|
||||
| Insert, Command, Search | `backspace` |
|
||||
| Insert | `newline` |
|
||||
| Command, Search | `submit` |
|
||||
|
||||
## Binding overrides and failures
|
||||
|
||||
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
|
||||
|
||||
```ruby
|
||||
Vamp.configure do |config|
|
||||
config.bind "n", config.core.insert
|
||||
config.bind "i", nil
|
||||
config.bind "space", config.core.command
|
||||
config.bind ":", nil
|
||||
end
|
||||
```
|
||||
|
||||
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous leader prefixes, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its single-key and leader bindings, without restoring any default keys they replaced.
|
||||
|
||||
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command/Search/Visual mode or entering commands, restart with `--no-config` to restore defaults.
|
||||
|
||||
## Selection-list bindings
|
||||
|
||||
The palette, floating file picker, and focused docked explorer share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item; `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key:
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+f", config.core.down, mode: config.modes.selection
|
||||
config.bind "ctrl+b", config.core.up, mode: config.modes.selection
|
||||
config.bind "ctrl+j", nil, mode: config.modes.selection
|
||||
```
|
||||
|
||||
Selection bindings take precedence while a file or command list is focused. Unbound printable keys filter in List/explorer INSERT, but not explorer NORMAL; a disabled key is consumed without filtering or moving. Only `up`, `down`, and `nil` are accepted—no leader sequences, edits, or plugin commands. Invalid declarations leave existing routes unchanged. Navigation does not change text, carets, history, or the buffer mode. Both list footers show effective keys, including remaps and disabled directions.
|
||||
|
||||
Tree views use `config.modes.tree`, accepting `up`, `down`, `left`, `right`, or `nil`. Explorer NORMAL defaults to h/j/k/l for parent/next/previous/child, with the existing control/arrow routes retained. Tree routes override shared selection routes. In explorer INSERT, printable tree keys are not intercepted: h/j/k/l become query text, while control/special-key tree routes still navigate. Ctrl+h/l select visible parents/children in the filtered projection without changing the query or saved folder expansion. Unmatched keys inherit shared selection bindings. Cached hints prioritize plain keys in NORMAL and omit them in INSERT. For example:
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+f", config.core.right, mode: config.modes.tree
|
||||
config.bind "ctrl+h", nil, mode: config.modes.tree
|
||||
```
|
||||
|
||||
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
|
||||
|
||||
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside lists, existing editing bindings are unchanged: Ctrl+k still clones above in Normal/Visual, and explicit Ctrl+j defaults insert a newline in Insert or submit Command/Search prompts. Enter and Ctrl+j can now be rebound independently.
|
||||
|
||||
## Leader key
|
||||
|
||||
Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", "bnext"`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings.
|
||||
|
||||
Set `config.leader = ","` to change the trigger for every default and configured leader route. Suffix keys stay unchanged: the default palette binding becomes comma then Space, save becomes comma then `w`, and buffer-next becomes comma then `b` then `n`. Choose an unused single key in the existing notation; Escape, ASCII digits, malformed keys, and a new trigger already directly bound in Normal/Visual are rejected without replacing the current choice. A direct binding on the current trigger—including `nil`—takes precedence per mode and hides unreachable leader routes from the palette.
|
||||
|
||||
Esc cancels a pending sequence and count without leaving Visual or collapsing carets. Unknown suffixes consume the key and cancel safely, never falling through to an edit. Paste, external command execution, buffer switching, and binding/leader changes clear pending state. A count before a leader motion applies normally; digits inside a sequence are literal suffix keys. Plugins execute once. Outside Normal/Visual, the leader has no prefix behavior, so Space still types normally in Insert and prompts.
|
||||
|
||||
Leader bindings require Normal/Visual and the target's existing mode eligibility; Ex/plugin targets remain Normal-only. A sequence cannot be both a complete binding and another binding's prefix: ambiguous declarations are refused atomically. Disabled leaves remain reserved. Only leader-prefixed sequences are supported—not arbitrary multi-key mappings, timers, or macros.
|
||||
|
||||
## Native LSP
|
||||
|
||||
LSP runs in Rust, independently of Ruby plugin hosts. **Every language server is disabled by default.** Ruby evaluates configuration only at startup; no LSP plugin needs loading. Install a trusted server yourself and explicitly opt in:
|
||||
|
||||
```ruby
|
||||
Vamp.configure do |config|
|
||||
config.lsp["rust"] = true # rust-analyzer, extensions: ["rs"]
|
||||
config.lsp["ruby"] = true # solargraph stdio, extensions: ["rb"]
|
||||
config.lsp["python"] = false # disabled (also the default when absent)
|
||||
|
||||
config.lsp["my-language"] = {
|
||||
command: ["my-language-server", "--stdio"],
|
||||
extensions: ["myl"],
|
||||
initialization_options: {},
|
||||
settings: { "my-language-server" => { "exampleOption" => true } }
|
||||
}
|
||||
config.bind "leader d", config.core.lsp.definition
|
||||
config.bind "leader D", config.core.lsp.diagnostics
|
||||
config.bind "K", config.core.lsp.hover
|
||||
end
|
||||
```
|
||||
|
||||
Keys are arbitrary nonempty strings, used as the protocol's `languageId`; they need no Tree-sitter grammar or hardcoded language enum. Absent/`false` entries are disabled. `true` enables a built-in preset: `"rust"` uses `rust-analyzer`/`rs`, `"python"` uses `pylsp`/`py`, and `"ruby"` uses `solargraph stdio`/`rb`. Unknown keys need a configuration object with nonempty `command` argv and `extensions` (without leading dots). Objects also explicitly enable servers; known presets supply omitted command/extensions. For example, `config.lsp["rust"] = { command: ["/custom/path/rust-analyzer"] }` overrides the executable while retaining `rs`.
|
||||
|
||||
Only `command`, `extensions`, `initialization_options`, and `settings` are accepted. Both option/settings values must be JSON-compatible objects and default to `{}`. Invalid declarations reject configuration with a notice rather than activating a guessed server. Server commands are executable/argument arrays, never shell commands; use an absolute path if the executable is not on PATH. No server installation or restart is automatic.
|
||||
|
||||
Install Solargraph yourself with `gem install solargraph` in the project's Ruby environment. The Ruby preset launches `["solargraph", "stdio"]`, following [Solargraph's stdio integration](https://solargraph.org/guides/language-server), and uses [YARD annotations](https://solargraph.org/guides/yard) for type information. Explicit `command` overrides replace the entire argv: for a bundled installation, use `config.lsp["ruby"] = { command: ["bundle", "exec", "solargraph", "stdio"] }`; Vamp retains the `rb` extension but does not append `stdio` to your override.
|
||||
|
||||
Servers start lazily only when a matching buffer opens. Extension matching is case-sensitive; if enabled languages overlap, the first language in sorted key order wins. One worker runs per language configuration/project root, with at most 16 enabled languages/server-project keys. Start vamp from your project root: its startup working directory is the workspace; automatic root discovery is not implemented.
|
||||
|
||||
See [native LSP architecture](architecture.md#native-lsp) for synchronization and worker behavior.
|
||||
|
||||
`:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed.
|
||||
|
||||
For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects: `definition`, `diagnostics`, and `hover`. Each routes to the same native implementation as its Ex command; no LSP keys are bound by default. These actions are also available in the command palette. The namespace is distinct from `config.lsp[language_id]` server configuration.
|
||||
|
||||
Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
|
||||
|
||||
Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts.
|
||||
|
||||
`:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it.
|
||||
|
||||
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. Multiline content is preserved; Markdown is shown as plain text, not rendered markup. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
|
||||
|
||||
Native LSP currently provides synchronization, diagnostics, go-to-definition, and hover—not completion, rename, formatting, code actions, workspace edits, or semantic highlighting. Tree-sitter remains the syntax highlighter.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Development
|
||||
|
||||
[README](../README.md)
|
||||
|
||||
Local verification and implementation constraints. See [architecture](architecture.md) for ownership and frontend contracts.
|
||||
|
||||
## Verify
|
||||
|
||||
```sh
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
for file in src/ruby_*.rb examples/config.rb examples/plugins/greeting.rb; do
|
||||
ruby -c "$file" || exit
|
||||
done
|
||||
rubocop
|
||||
```
|
||||
|
||||
## Ruby types
|
||||
|
||||
Use YARD `@param`, `@return`, and `@yieldparam` annotations for Ruby APIs, and `@type` for locals where inference needs help. Examples annotate DSL block parameters before the call, without adding runtime assignments or dependencies. `Vamp::Action` / `Vamp::Mode` field types and the dynamically defined mode methods use Solargraph-supported directives.
|
||||
|
||||
Core action methods are generated from Rust's authoritative catalog at host startup. Their namespace is annotated, but static Solargraph analysis cannot enumerate those generated members: CLI `typecheck` can misidentify `config.core.select` as inherited `Kernel#select`. Do not maintain a second action list in Ruby comments; use the [configuration action reference](configuration.md#action-reference). Runtime catalog validation remains authoritative.
|
||||
|
||||
## Ruby implementation and style
|
||||
|
||||
Rust embeds the Ruby implementation at compile time: [`ruby_values.rb`](../src/ruby_values.rb) owns value validation and core action/mode objects, [`ruby_config.rb`](../src/ruby_config.rb) owns configuration and editor contexts, [`ruby_plugin.rb`](../src/ruby_plugin.rb) owns registration and callbacks, and [`ruby_host.rb`](../src/ruby_host.rb) owns framed subprocess I/O. Rebuild the editor after changing these files; installed binaries do not load implementation files from the filesystem.
|
||||
|
||||
`.rubocop.yml` uses RuboCop defaults with double-quoted strings (including interpolation) and Ruby 2.6-compatible syntax. Ruby implementation and example files use frozen string literals. Narrow inline style exceptions retain physical `STDIN`/`STDOUT`/`STDERR` capture/redirection and native exception state at the process boundary; plugin output must not corrupt the protocol, and errors including `SystemExit` and `Interrupt` must discard effects and report a failure.
|
||||
|
||||
## Test organization
|
||||
|
||||
Rust tests live alongside their modules; native LSP integration checks are in [`tests/lsp.rs`](../tests/lsp.rs).
|
||||
|
||||
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
||||
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
|
||||
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover cleanup on entry failure, normal return, I/O errors, and panics, including original panic-payload propagation.
|
||||
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures.
|
||||
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state.
|
||||
|
||||
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
|
||||
|
||||
## Performance and process limits
|
||||
|
||||
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
|
||||
|
||||
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# Editing guide
|
||||
|
||||
[README](../README.md)
|
||||
|
||||
Default controls and editing behavior. For custom bindings, see [configuration](configuration.md); for language-server commands, see [native LSP](configuration.md#native-lsp).
|
||||
|
||||
## Default editing keys
|
||||
|
||||
| Mode | Keys |
|
||||
| --- | --- |
|
||||
| All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels |
|
||||
| Normal, Visual | Space Space opens the palette through the default leader |
|
||||
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers |
|
||||
| Normal, Visual | Space o opens the floating tree in INSERT/filter mode; Space e opens/focuses the docked compact tree |
|
||||
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
|
||||
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
|
||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
|
||||
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
|
||||
| Normal | `J` (Shift+j) joins each targeted line with the next line |
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `v` starts Visual selections; `p`/`P` paste the configured clipboard after/before each caret (system by default); Esc keeps only the primary caret |
|
||||
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
|
||||
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selections; `v`/Esc cancels highlighting |
|
||||
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
|
||||
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret; Esc returns to Normal, retaining carets |
|
||||
| Command | Enter executes; Backspace edits; Esc cancels |
|
||||
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
||||
|
||||
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
|
||||
|
||||
## Word motions
|
||||
|
||||
Word motions separate runs of Unicode letters/numbers/underscore from runs of punctuation or emoji. Classification uses each grapheme's first character; combining sequences and ZWJ emoji stay intact. WORD motions treat any non-whitespace run as one WORD.
|
||||
|
||||
`w`/`W` move to the next start. `b`/`B` move to the current start, or the previous start when already at one. `e`/`E` move to the current end when before it, otherwise the next end. Motions cross lines but never wrap. Truly empty lines are stops for start motions; whitespace-only lines are skipped. End motions skip empty lines while looking for a word. With no further target, motions stop at the buffer edge, including trailing whitespace or an empty final line.
|
||||
|
||||
These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move all insertion carets without splitting their undo group. Motions never edit text, add history, or clear an active search highlight.
|
||||
|
||||
## Motion counts
|
||||
|
||||
Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Search counts wrap through the available matches.
|
||||
|
||||
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
|
||||
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader prefixes retain the count until their leaf action resolves. Other commands consume the prefix and execute once, including line-start/end, edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and Vim operator grammar such as `3dw` is not supported.
|
||||
|
||||
## Selections and multi-caret editing
|
||||
|
||||
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. Visual selection is characterwise and inclusive of the endpoints; its read-only ranges are end-exclusive. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines appear as a blue blank cell after the line. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
|
||||
|
||||
Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences.
|
||||
|
||||
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. `p`/`P` paste after/before each current grapheme, including multiline text. Each caret lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
|
||||
|
||||
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only empty final-row selections enters Insert without overwriting either clipboard.
|
||||
|
||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode.
|
||||
|
||||
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only empty final-row selections does not overwrite either register. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
||||
|
||||
## Clipboard policy
|
||||
|
||||
`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either.
|
||||
|
||||
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations:
|
||||
|
||||
```ruby
|
||||
config.clipboard_mode = "separate"
|
||||
config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual
|
||||
config.bind "ctrl+g", config.core.paste_system_before
|
||||
```
|
||||
|
||||
For other applications, multi-selection exports join fragments with LF. Vamp retains their original boundaries privately while the OS text exactly matches its export, so its own copies still paste pairwise. Different external text invalidates that metadata and becomes one literal fragment—even when it contains newlines—broadcasting to all targets. Identical external replacement text is inherently indistinguishable from an unchanged export. No empty-system fallback to stale internal text occurs.
|
||||
|
||||
System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back.
|
||||
|
||||
System clipboard access supports macOS through `osascript`, Linux Wayland through `wl-paste`/`wl-copy`, and Linux X11 through `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
|
||||
|
||||
## Repeat last edit
|
||||
|
||||
`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, Normal deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once, consistent with other non-motion commands.
|
||||
|
||||
In Normal, Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Equal spans broadcast; unequal spans require matching target counts. In Visual, the current explicit ranges replace the stored targeting. Target-building commands themselves do not recreate old cursors.
|
||||
|
||||
Clipboard paste captures the fragments actually pasted, so a later yank or external clipboard change does not change repeat's payload; dot paste never rereads the OS. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. Dot cut/change follows the current clipboard policy and stages publication until replay validation finishes. A refusal or provider error preserves editor text, caret/primary state, register caches, history, and recipe; already-published OS data cannot be transactionally restored.
|
||||
|
||||
Each repeat returns to Normal and creates one undo entry restoring the full caret set and primary. Original typing/paste/typing boundaries remain separate; dot replays the selected committed group as one transaction. Typed text retains scalar insertion boundaries, preserving combining/ZWJ interactions with surrounding graphemes; bracketed paste remains one bulk literal operation. Trailing Insert movement is not repeated.
|
||||
|
||||
The last recipe is session-wide across buffers, but pending Insert groups and undo/redo remain buffer-local. No-op/identical edits, rejected input, navigation, yank, explorer filtering, saving, undo/redo, and plugin effects do not replace it. Successful buffer parking and ordinary explicit Ex execution commit pending native typing at existing boundaries; failed buffer requests do not split that group. Plugins never execute through dot. Repeat does not consume or recursively replace its recipe. Rebind with `config.core.repeat_last_edit`, including Normal/Visual leader routes.
|
||||
|
||||
## Search
|
||||
|
||||
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow.
|
||||
|
||||
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again.
|
||||
|
||||
## Commands, saving, and pasted input
|
||||
|
||||
Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`.
|
||||
|
||||
`:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. 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.
|
||||
|
||||
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
||||
|
||||
## Buffers
|
||||
|
||||
One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID, adjusting them only as needed to keep the primary visible after resizing. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer.
|
||||
|
||||
| Command | Behavior |
|
||||
| --- | --- |
|
||||
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
|
||||
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty |
|
||||
| `:buffer N` | Switch to buffer ID `N` |
|
||||
| `:bnext` / `:bprev` | Switch in ascending-ID order, wrapping at either end |
|
||||
| `:bdelete [N]` | Close the active or named buffer; refuse unsaved changes |
|
||||
| `:bdelete! [N]` | Explicitly discard changes and close the active or named buffer |
|
||||
|
||||
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
|
||||
|
||||
Switching from Insert commits its pending undo group and parks that buffer in Normal. Explicit buffer switching from Visual retains that buffer's directed ranges and Visual mode; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. The new commands appear in the command palette and can be bound by exact command name in Normal mode. When several buffers are open, status shows the active ID and total count.
|
||||
|
||||
## File picker and explorer
|
||||
|
||||
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
|
||||
|
||||
The floating explorer is centered, capped at 100 columns by 24 rows, and shrinks to fit smaller terminals. The tree mode appears in the title; the help row does not repeat it.
|
||||
|
||||
The dock defaults to the right of the editor workspace. See [configuration preferences](configuration.md#preferences) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space.
|
||||
|
||||
Both presentations default to Tree; [configuration preferences](configuration.md#preferences) can select List independently for each. Ctrl+t toggles the current focused instance without rescanning. List retains searchable full relative paths. Tree shows folders first, two-cell indentation, and basename-only files. Folder names end in `/` and use cyan text; files use the normal foreground color. Fold-state arrows are omitted. Maximal single-child directory chains share one row, such as `src/client/http/`; a directory containing one file still gets its own row.
|
||||
|
||||
Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately.
|
||||
|
||||
| File-view input | Behavior |
|
||||
| --- | --- |
|
||||
| Type / Backspace | Type query text in List/explorer INSERT / remove one whole grapheme; explorer NORMAL uses hjkl for navigation |
|
||||
| j/k (NORMAL); Ctrl+j/k or Up/Down | Wrap forward/backward through visible rows |
|
||||
| l (NORMAL); Ctrl+l/Right | NORMAL expands/enters a folder; INSERT selects its visible child without changing the filter |
|
||||
| h (NORMAL); Ctrl+h/Left | NORMAL collapses/selects parent; INSERT selects its visible parent without changing the filter |
|
||||
| Enter | Toggle a folder, or open the selected file using existing buffer deduplication |
|
||||
| `i` (explorer NORMAL) | Enter explorer INSERT/filter mode without inserting `i` |
|
||||
| Esc | INSERT → NORMAL with the filter and selection retained; NORMAL → buffer focus or floating close |
|
||||
| Ctrl+u | Clear the filter in NORMAL or INSERT without changing mode/focus; preserve the selected path where possible |
|
||||
| Tab | Return to editor focus from a dock |
|
||||
| Ctrl+w | Close either presentation |
|
||||
| Ctrl+r | Rescan while retaining query, surviving expansion, and selection |
|
||||
| Ctrl+p | Open the command palette, closing floating search or blurring a dock |
|
||||
| Ctrl+t | Toggle List/Tree while retaining view state |
|
||||
|
||||
Opening a file from a dock leaves it visible and returns focus to the buffer. Space e refocuses it. Choosing a folder during filtering returns to explorer NORMAL and toggles that folder; it never opens a text buffer for a directory. Selections use actual OS-path identities across filtering, view switches, and refresh; a removed or hidden selection falls back to the nearest visible ancestor, then the first row. Query input never edits the underlying buffer; bracketed paste is ignored while file search is focused. Cancellation preserves buffer mode, selections, and pending Insert history. Invalid, binary, unreadable, or disappeared file selections report an error and keep focus without changing the active buffer.
|
||||
|
||||
Clearing returns to the full catalog and reveals the selected file's ancestry in NORMAL when needed. With no current result, it uses the last available selection; removed paths fall back to a visible ancestor, then the first row. Clearing an already empty query is harmless. Filtered folders can be collapsed/expanded in NORMAL without losing the query. Temporary filtered collapse overrides survive mode/view changes and refresh of surviving directories; editing or clearing the query resets them so new matches can be revealed. Choosing a directory during filtering transitions to NORMAL and toggles it while retaining the query.
|
||||
|
||||
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes.
|
||||
|
||||
## Command palette
|
||||
|
||||
Ctrl+p opens a searchable overlay without leaving the current mode. It lists core actions and registered Ex/plugin commands with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
|
||||
|
||||
Search is case-insensitive: every whitespace-separated query token must occur in the command name, human-readable label, or owner. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
||||
|
||||
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. Accepting an Ex/plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch. Choosing `q!` therefore does not immediately discard changes.
|
||||
|
||||
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
|
||||
|
||||
## Leader sequences
|
||||
|
||||
Space begins a leader sequence in Normal/Visual by default. Press the suffix keys in order; nested prefixes wait without a timeout. The status bar shows the pending sequence. Esc cancels the sequence and pending count without leaving Visual or collapsing carets. See [leader configuration](configuration.md#leader-key) for remapping and declaration rules.
|
||||
|
||||
## Syntax highlighting
|
||||
|
||||
Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and compiled-in grammar/query packages. The selected language set is Rust, Ruby, Python, JSON, Bash, Makefile, Markdown, JavaScript/JSX, TypeScript/TSX, HTML, CSS, YAML, TOML, C, C++, and Go. Detection uses filename/extension or the in-memory first-line shebang, including common special filenames such as Makefile, Gemfile, and Cargo.lock; no file reread occurs. Unsupported formats use the normal foreground. The detected language appears in status, and save-as redetects it.
|
||||
|
||||
Colors retain the terminal palette: comments dark gray, strings green, keywords magenta, numbers yellow, types/markup cyan, and functions light blue. Grammar queries handle multiline/nested constructs, even when their opening is above the viewport. Markdown uses its inline grammar and named fenced-code injections; HTML script/style content uses JavaScript/CSS. Known tagged-template and raw-string injections use the same selected grammar registry; unknown injected languages retain host highlighting or plain text. Highlighting styles whole graphemes and expanded tab cells; partially clipped wide placeholders remain unchanged. Search, Visual selections, and secondary carets retain foreground/background precedence.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Ruby plugins
|
||||
|
||||
[README](../README.md)
|
||||
|
||||
Writing trusted Ruby extensions. Load plugins through [Ruby configuration](configuration.md); see [architecture](architecture.md) for module ownership.
|
||||
|
||||
```ruby
|
||||
Vamp.plugin "greeting", requires: ["text"]
|
||||
|
||||
# @param editor [Vamp::Editor]
|
||||
# @param args [String]
|
||||
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`](../examples/plugins/greeting.rb) also provides `:upper` to uppercase the buffer.
|
||||
|
||||
Callbacks receive an immutable primary-only context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, `editor.path`, `editor.buffer_id`, `editor.revision`, `editor.root`, and `editor.request_id`. 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. Commands run once regardless of target count; successful text effects collapse to the primary before editing once. The plugin API does not expose selections.
|
||||
|
||||
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does.
|
||||
|
||||
`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.
|
||||
|
||||
Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload. See [development limits](development.md#performance-and-process-limits) for host deadlines and frame bounds.
|
||||
|
||||
## Capabilities and registration
|
||||
|
||||
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, or disable core. User configuration can override any keybinding without replacing the core implementation. `: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.
|
||||
|
||||
## Asynchronous plugins
|
||||
|
||||
`config.plugin "plugin.rb", options: { ... }` passes immutable JSON-compatible options as `Vamp.options`. Declare `Vamp.plugin "name", requires: ["text", "diagnostics"], async: true` to opt into asynchronous command results and document notifications. Existing plugins remain synchronous.
|
||||
|
||||
`Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.
|
||||
|
||||
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
|
||||
|
||||
Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.
|
||||
|
||||
`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.
|
||||
|
||||
## Messaging
|
||||
|
||||
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed text effects (`insert`, `replace`, `message`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
||||
|
||||
Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`.
|
||||
|
||||
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.
|
||||
+4
-6
@@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# @param config [Vamp::Config]
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
||||
@@ -6,15 +9,10 @@ Vamp.configure do |config|
|
||||
config.file_picker_view = "tree"
|
||||
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
||||
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
||||
config.bind "h", config.core.left, mode: config.modes.tree
|
||||
config.bind "j", config.core.down, mode: config.modes.tree
|
||||
config.bind "k", config.core.up, mode: config.modes.tree
|
||||
config.bind "l", config.core.right, mode: config.modes.tree
|
||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||
config.plugin "plugins/greeting.rb"
|
||||
# Native LSP is disabled by default; install a server and explicitly opt in.
|
||||
# config.lsp["rust"] = true # built-in rust-analyzer preset
|
||||
# config.lsp["ruby"] = true # built-in Solargraph stdio preset (gem install solargraph)
|
||||
# config.lsp["my-language"] = {
|
||||
# command: ["my-language-server", "--stdio"], extensions: ["myl"],
|
||||
# initialization_options: {}, settings: {}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Vamp.plugin "greeting", requires: ["text"]
|
||||
|
||||
# @param editor [Vamp::Editor]
|
||||
# @param args [String]
|
||||
Vamp.command "greet" do |editor, args|
|
||||
# @type [String]
|
||||
name = args.empty? ? "world" : args
|
||||
editor.insert "Hello, #{name}!\n"
|
||||
editor.message "Greeting inserted by Ruby"
|
||||
end
|
||||
|
||||
# @param editor [Vamp::Editor]
|
||||
# @param _args [String]
|
||||
Vamp.command "upper" do |editor, _args|
|
||||
editor.replace editor.text.upcase
|
||||
editor.message "Buffer uppercased by Ruby"
|
||||
|
||||
@@ -62,6 +62,13 @@ pub struct Palette {
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
pub struct StartupDiagnostic {
|
||||
pub lines: Vec<String>,
|
||||
pub top: usize,
|
||||
pub left: usize,
|
||||
max_column: usize,
|
||||
}
|
||||
|
||||
pub struct Hover {
|
||||
text: String,
|
||||
line_starts: Vec<usize>,
|
||||
@@ -878,6 +885,7 @@ pub struct Editor {
|
||||
leader_display: String,
|
||||
palette: Option<Palette>,
|
||||
hover: Option<Hover>,
|
||||
startup_diagnostic: Option<StartupDiagnostic>,
|
||||
project_root: PathBuf,
|
||||
file_picker: Option<FilePicker>,
|
||||
file_explorer_side: DockSide,
|
||||
@@ -936,6 +944,7 @@ impl Editor {
|
||||
leader_display: String::new(),
|
||||
palette: None,
|
||||
hover: None,
|
||||
startup_diagnostic: None,
|
||||
project_root: std::env::current_dir()?,
|
||||
file_picker: None,
|
||||
file_explorer_side: DockSide::Right,
|
||||
@@ -1217,6 +1226,25 @@ impl Editor {
|
||||
self.palette.as_ref()
|
||||
}
|
||||
|
||||
pub fn startup_diagnostic(&self) -> Option<&StartupDiagnostic> {
|
||||
self.startup_diagnostic.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn show_startup_diagnostic(&mut self, text: String) {
|
||||
let lines = split_lines(&text);
|
||||
let max_column = lines
|
||||
.iter()
|
||||
.map(|line| line.graphemes(true).count())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
self.startup_diagnostic = Some(StartupDiagnostic {
|
||||
lines,
|
||||
top: 0,
|
||||
left: 0,
|
||||
max_column,
|
||||
});
|
||||
}
|
||||
|
||||
/// Borrowed popup state, visible only while its source remains current.
|
||||
pub fn hover(&self) -> Option<&Hover> {
|
||||
self.hover.as_ref().filter(|hover| {
|
||||
@@ -1981,6 +2009,30 @@ impl Editor {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
if let Some(diagnostic) = &mut self.startup_diagnostic {
|
||||
match input {
|
||||
Input::Enter | Input::Escape => self.startup_diagnostic = None,
|
||||
Input::Up | Input::Ctrl('k') => diagnostic.top = diagnostic.top.saturating_sub(1),
|
||||
Input::Down | Input::Ctrl('j') => {
|
||||
diagnostic.top = diagnostic
|
||||
.top
|
||||
.saturating_add(1)
|
||||
.min(diagnostic.lines.len() - 1)
|
||||
}
|
||||
Input::Left | Input::Ctrl('h') => {
|
||||
diagnostic.left = diagnostic.left.saturating_sub(1)
|
||||
}
|
||||
Input::Right | Input::Ctrl('l') => {
|
||||
diagnostic.left = diagnostic.left.saturating_add(1).min(diagnostic.max_column)
|
||||
}
|
||||
Input::Home => {
|
||||
diagnostic.top = 0;
|
||||
diagnostic.left = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if self.handle_hover(&input) {
|
||||
return None;
|
||||
}
|
||||
|
||||
+5
-11
@@ -1,5 +1,4 @@
|
||||
use crate::lsp::ServerConfig;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
|
||||
@@ -11,9 +10,10 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
|
||||
if language_id.is_empty() || language_id.chars().any(char::is_control) {
|
||||
return Err("LSP language keys must not be empty or contain control characters".into());
|
||||
}
|
||||
let preset = match language_id.as_str() {
|
||||
"rust" => Some(("rust-analyzer", "rs")),
|
||||
"python" => Some(("pylsp", "py")),
|
||||
let preset: Option<(&[&str], &str)> = match language_id.as_str() {
|
||||
"rust" => Some((&["rust-analyzer"], "rs")),
|
||||
"python" => Some((&["pylsp"], "py")),
|
||||
"ruby" => Some((&["solargraph", "stdio"], "rb")),
|
||||
_ => None,
|
||||
};
|
||||
let mut options = match value {
|
||||
@@ -46,7 +46,7 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
|
||||
let command = match options.remove("command") {
|
||||
Some(value) => command(value, &language_id)?,
|
||||
None => match preset {
|
||||
Some((command, _)) => vec![command.into()],
|
||||
Some((command, _)) => command.iter().map(|argument| (*argument).into()).collect(),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"LSP language {language_id:?} requires command argv"
|
||||
@@ -81,12 +81,6 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Vec<ServerConfig>, D::Error> {
|
||||
resolve(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn command(value: Value, language: &str) -> Result<Vec<String>, String> {
|
||||
let error = || {
|
||||
format!(
|
||||
|
||||
@@ -37,6 +37,7 @@ Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||
Set VAMP_RUBY to choose the Ruby executable.
|
||||
|
||||
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
|
||||
Presets: rust -> rust-analyzer, python -> pylsp, ruby -> solargraph stdio
|
||||
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
|
||||
LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover
|
||||
Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)";
|
||||
|
||||
+126
-28
@@ -10,7 +10,15 @@ use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{collections::HashSet, env};
|
||||
|
||||
const RUNNER: &str = include_str!("ruby_host.rb");
|
||||
const RUNNER: &str = concat!(
|
||||
include_str!("ruby_values.rb"),
|
||||
"\n",
|
||||
include_str!("ruby_config.rb"),
|
||||
"\n",
|
||||
include_str!("ruby_plugin.rb"),
|
||||
"\n",
|
||||
include_str!("ruby_host.rb"),
|
||||
);
|
||||
const MAX_FRAME: usize = 4 * 1024 * 1024;
|
||||
const MAX_QUEUE: usize = 64;
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
@@ -95,7 +103,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
});
|
||||
}
|
||||
config.bindings = bindings;
|
||||
config.lsp = lsp;
|
||||
config.lsp =
|
||||
crate::lsp_config::resolve(lsp).map_err(|error| format!("Ruby config: {error}"))?;
|
||||
config.leader = leader;
|
||||
config.file_explorer_side = file_explorer_side;
|
||||
config.file_picker_view = file_picker_view;
|
||||
@@ -379,8 +388,9 @@ enum Response {
|
||||
Config {
|
||||
version: u8,
|
||||
plugins: Vec<PluginConfig>,
|
||||
#[serde(deserialize_with = "crate::lsp_config::deserialize")]
|
||||
lsp: Vec<crate::lsp::ServerConfig>,
|
||||
// Keep this wire field required; resolve its configuration after decoding.
|
||||
#[serde(deserialize_with = "serde_json::Value::deserialize")]
|
||||
lsp: serde_json::Value,
|
||||
bindings: Vec<Binding>,
|
||||
leader: String,
|
||||
file_explorer_side: String,
|
||||
@@ -967,19 +977,21 @@ end"#,
|
||||
r#"Vamp.configure do |config|
|
||||
config.lsp["rust"] = true
|
||||
config.lsp["python"] = true
|
||||
config.lsp["ruby"] = true
|
||||
end"#,
|
||||
);
|
||||
let config = load_config(&path).unwrap();
|
||||
assert_eq!(config.lsp.len(), 2);
|
||||
for (language, command, extension) in
|
||||
[("rust", "rust-analyzer", "rs"), ("python", "pylsp", "py")]
|
||||
{
|
||||
for (language, command, extension) in [
|
||||
("rust", &["rust-analyzer"][..], "rs"),
|
||||
("python", &["pylsp"][..], "py"),
|
||||
("ruby", &["solargraph", "stdio"][..], "rb"),
|
||||
] {
|
||||
let server = config
|
||||
.lsp
|
||||
.iter()
|
||||
.find(|server| server.language_id == language)
|
||||
.unwrap();
|
||||
assert_eq!(server.command, [command]);
|
||||
assert_eq!(server.command, command);
|
||||
assert_eq!(server.extensions, [extension]);
|
||||
assert_eq!(server.initialization_options, serde_json::json!({}));
|
||||
assert_eq!(server.settings, serde_json::json!({}));
|
||||
@@ -994,6 +1006,7 @@ end"#,
|
||||
settings: {"rust-analyzer" => {check: {command: "clippy"}}}
|
||||
}
|
||||
config.lsp["python"] = {command: ["custom pylsp", "--stdio"]}
|
||||
config.lsp["ruby"] = {command: ["bundle", "exec", "solargraph", "stdio"]}
|
||||
config.lsp["my-language.c++"] = {
|
||||
command: ["custom server", "--flag", ""],
|
||||
extensions: ["custom", "لغة"],
|
||||
@@ -1002,7 +1015,6 @@ end"#,
|
||||
end"#,
|
||||
);
|
||||
let config = load_config(&path).unwrap();
|
||||
assert_eq!(config.lsp.len(), 3);
|
||||
let rust = config
|
||||
.lsp
|
||||
.iter()
|
||||
@@ -1027,6 +1039,13 @@ end"#,
|
||||
assert_eq!(python.extensions, ["py"]);
|
||||
assert_eq!(python.initialization_options, serde_json::json!({}));
|
||||
assert_eq!(python.settings, serde_json::json!({}));
|
||||
let ruby = config
|
||||
.lsp
|
||||
.iter()
|
||||
.find(|server| server.language_id == "ruby")
|
||||
.unwrap();
|
||||
assert_eq!(ruby.command, ["bundle", "exec", "solargraph", "stdio"]);
|
||||
assert_eq!(ruby.extensions, ["rb"]);
|
||||
let custom = config
|
||||
.lsp
|
||||
.iter()
|
||||
@@ -1085,33 +1104,42 @@ end"#,
|
||||
}
|
||||
let path = fixture.file(
|
||||
"invalid.rb",
|
||||
r#"Vamp.configure { |config| config.lsp["unlisted"] = true }"#,
|
||||
r#"Vamp.configure { |config| config.lsp["unsupported-language"] = true }"#,
|
||||
);
|
||||
let error = load_config(&path).unwrap_err();
|
||||
assert!(!error.contains("Malformed Ruby protocol frame"), "{error}");
|
||||
assert!(
|
||||
error.contains("unlisted") && error.contains("preset"),
|
||||
error.contains("\"unsupported-language\"")
|
||||
&& error.contains("command")
|
||||
&& error.contains("extensions"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_wire_configuration_is_validated_before_being_consumed() {
|
||||
fn lsp_wire_configuration_is_resolved_after_decoding() {
|
||||
let mut frame = serde_json::json!({
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"clipboard_mode": "system"
|
||||
});
|
||||
assert!(matches!(
|
||||
serde_json::from_value::<Response>(frame.clone()).unwrap(),
|
||||
Response::Config { lsp, .. } if lsp.is_empty()
|
||||
));
|
||||
let Response::Config { lsp, .. } =
|
||||
serde_json::from_value::<Response>(frame.clone()).unwrap()
|
||||
else {
|
||||
panic!("expected config frame");
|
||||
};
|
||||
assert!(crate::lsp_config::resolve(lsp).unwrap().is_empty());
|
||||
frame["lsp"] = serde_json::json!({"rust": true});
|
||||
assert!(matches!(
|
||||
serde_json::from_value::<Response>(frame.clone()).unwrap(),
|
||||
Response::Config { lsp, .. }
|
||||
if lsp.len() == 1 && lsp[0].command == ["rust-analyzer"]
|
||||
));
|
||||
let Response::Config { lsp, .. } =
|
||||
serde_json::from_value::<Response>(frame.clone()).unwrap()
|
||||
else {
|
||||
panic!("expected config frame");
|
||||
};
|
||||
assert_eq!(
|
||||
crate::lsp_config::resolve(lsp).unwrap()[0].command,
|
||||
["rust-analyzer"]
|
||||
);
|
||||
for value in [
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([]),
|
||||
@@ -1127,10 +1155,12 @@ end"#,
|
||||
serde_json::json!({"rust": {"unexpected": true}}),
|
||||
] {
|
||||
frame["lsp"] = value;
|
||||
assert!(
|
||||
serde_json::from_value::<Response>(frame.clone()).is_err(),
|
||||
"{frame}"
|
||||
);
|
||||
let Response::Config { lsp, .. } =
|
||||
serde_json::from_value::<Response>(frame.clone()).unwrap()
|
||||
else {
|
||||
panic!("expected config frame");
|
||||
};
|
||||
assert!(crate::lsp_config::resolve(lsp).is_err(), "{frame}");
|
||||
}
|
||||
let languages: serde_json::Map<String, serde_json::Value> = (0..17)
|
||||
.map(|index| {
|
||||
@@ -1141,11 +1171,50 @@ end"#,
|
||||
})
|
||||
.collect();
|
||||
frame["lsp"] = serde_json::Value::Object(languages);
|
||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||
let Response::Config { lsp, .. } =
|
||||
serde_json::from_value::<Response>(frame.clone()).unwrap()
|
||||
else {
|
||||
panic!("expected config frame");
|
||||
};
|
||||
assert!(crate::lsp_config::resolve(lsp).is_err());
|
||||
frame.as_object_mut().unwrap().remove("lsp");
|
||||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_config_wire_remains_a_protocol_error() {
|
||||
let fixture = Fixture::new();
|
||||
for mutation in [
|
||||
"return '['",
|
||||
"frame.delete('lsp')",
|
||||
"frame['leader'] = 7",
|
||||
"frame['unexpected'] = true",
|
||||
] {
|
||||
let source = format!(
|
||||
r#"
|
||||
Vamp.configure {{ |config| config.lsp["ruby"] = true }}
|
||||
module JSON
|
||||
class << self
|
||||
alias_method :original_generate, :generate
|
||||
def generate(frame)
|
||||
if frame['type'] == 'config'
|
||||
{mutation}
|
||||
end
|
||||
original_generate(frame)
|
||||
end
|
||||
end
|
||||
end
|
||||
"#
|
||||
);
|
||||
let path = fixture.file("config.rb", &source);
|
||||
let error = load_config(&path).unwrap_err();
|
||||
assert!(
|
||||
error.starts_with("Malformed Ruby protocol frame: "),
|
||||
"{mutation}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_hang_callback_failure_and_fresh_host_recovery() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1182,6 +1251,27 @@ end"#,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonstandard_callback_exceptions_are_reported_without_effects() {
|
||||
let fixture = Fixture::new();
|
||||
for (callback, expected) in [
|
||||
("exit 19", "SystemExit: exit"),
|
||||
("raise Interrupt, 'interrupted'", "Interrupt: interrupted"),
|
||||
] {
|
||||
let (mut plugin, _) = fixture.plugin(&format!(
|
||||
"Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, _| editor.insert 'must not escape'; {callback} }}"
|
||||
));
|
||||
let error = plugin.invoke("fail", "", &context()).unwrap_err();
|
||||
assert!(error.contains(expected), "{error}");
|
||||
assert!(
|
||||
plugin
|
||||
.invoke("fail", "", &context())
|
||||
.unwrap_err()
|
||||
.contains("disabled")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_and_config_hangs_are_bounded() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1620,7 +1710,15 @@ Vamp.command('flood') { |_, _| 256.times { Vamp.publish [{type: 'message', text:
|
||||
.queue(&serde_json::json!({"type": "shutdown", "version": 1}))
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||
while !marker.exists() {
|
||||
while plugin
|
||||
.host
|
||||
.child
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.try_wait()
|
||||
.unwrap()
|
||||
.is_none()
|
||||
{
|
||||
assert!(Instant::now() < deadline);
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Vamp
|
||||
# Collects and validates the Ruby startup configuration DSL.
|
||||
class Config
|
||||
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
||||
attr_reader :lsp
|
||||
|
||||
# @return [void]
|
||||
def initialize
|
||||
@plugins = []
|
||||
@lsp = {}
|
||||
@bindings = []
|
||||
@leader = "space"
|
||||
@file_explorer_side = "right"
|
||||
@file_picker_view = "tree"
|
||||
@file_explorer_view = "tree"
|
||||
@clipboard_mode = "system"
|
||||
end
|
||||
|
||||
# @param path [String] relative to the configuration file
|
||||
# @param options [Hash{String, Symbol => Object}] JSON-compatible plugin options
|
||||
# @return [nil]
|
||||
def plugin(path, options: {})
|
||||
path = Vamp.string!(path, "plugin path")
|
||||
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
|
||||
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
||||
|
||||
@plugins << { "path" => path, "options" => Vamp.json_value!(options) }
|
||||
nil
|
||||
end
|
||||
|
||||
# @param key [String]
|
||||
# @return [String]
|
||||
def leader=(key)
|
||||
@leader = nonempty_string!(key, "leader key")
|
||||
end
|
||||
|
||||
# @param side [String]
|
||||
# @return [String]
|
||||
def file_explorer_side=(side)
|
||||
@file_explorer_side = nonempty_string!(side, "file explorer side")
|
||||
end
|
||||
|
||||
# @param view [String]
|
||||
# @return [String]
|
||||
def file_picker_view=(view)
|
||||
@file_picker_view = nonempty_string!(view, "file picker view")
|
||||
end
|
||||
|
||||
# @param view [String]
|
||||
# @return [String]
|
||||
def file_explorer_view=(view)
|
||||
@file_explorer_view = nonempty_string!(view, "file explorer view")
|
||||
end
|
||||
|
||||
# @param mode [String]
|
||||
# @return [String]
|
||||
def clipboard_mode=(mode)
|
||||
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
||||
end
|
||||
|
||||
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
|
||||
def core
|
||||
Core
|
||||
end
|
||||
|
||||
# @return [Module<Vamp::Modes>] frozen namespace whose methods return Mode values
|
||||
def modes
|
||||
Modes
|
||||
end
|
||||
|
||||
# @param key [String] one key or a leader-prefixed sequence
|
||||
# @param command [Vamp::Action, String, nil] core action, external command, or disabled route
|
||||
# @param mode [Vamp::Mode]
|
||||
# @return [nil]
|
||||
def bind(key, command, mode: Modes.normal)
|
||||
key = Vamp.string!(key, "binding key")
|
||||
raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode)
|
||||
|
||||
command = binding_target(command)
|
||||
raise ArgumentError, "binding key must not be empty" if key.empty?
|
||||
raise ArgumentError, "binding command must not be empty" if command == ""
|
||||
|
||||
@bindings << { "mode" => mode.name, "key" => key, "command" => command }
|
||||
nil
|
||||
end
|
||||
|
||||
# @return [Hash{String => Object}] validated startup configuration frame
|
||||
def response
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
||||
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @param value [String]
|
||||
# @param label [String]
|
||||
# @return [String]
|
||||
def nonempty_string!(value, label)
|
||||
value = Vamp.string!(value, label)
|
||||
raise ArgumentError, "#{label} must not be empty" if value.empty?
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
# @param command [Vamp::Action, String, nil]
|
||||
# @return [String, nil]
|
||||
def binding_target(command)
|
||||
case command
|
||||
when Action
|
||||
command.name
|
||||
when String
|
||||
external_command(command)
|
||||
when nil
|
||||
nil
|
||||
else
|
||||
raise TypeError, "binding command must be an Action, String, or nil"
|
||||
end
|
||||
end
|
||||
|
||||
# @param command [String]
|
||||
# @return [String]
|
||||
def external_command(command)
|
||||
name = Vamp.string!(command, "binding command")
|
||||
raise TypeError, "use config.core for built-in actions" if name.start_with?("core.")
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
# @return [Hash{String => Boolean, Hash{String => Object}}]
|
||||
def lsp_options
|
||||
@lsp.to_h do |language, value|
|
||||
language = lsp_language(language)
|
||||
[language, lsp_value(value, language)]
|
||||
end
|
||||
end
|
||||
|
||||
# @param language [String]
|
||||
# @return [String]
|
||||
def lsp_language(language)
|
||||
language = Vamp.string!(language, "LSP language key")
|
||||
invalid = language.empty? || language.match?(/[[:cntrl:]]/)
|
||||
raise ArgumentError, "LSP language keys must not be empty or contain control characters" if invalid
|
||||
|
||||
language
|
||||
end
|
||||
|
||||
# @param value [Boolean, Hash{String, Symbol => Object}]
|
||||
# @param language [String]
|
||||
# @return [Boolean, Hash{String => Object}]
|
||||
def lsp_value(value, language)
|
||||
case value
|
||||
when TrueClass, FalseClass
|
||||
value
|
||||
when Hash
|
||||
Vamp.json_value!(value)
|
||||
else
|
||||
raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Provides an immutable editor snapshot and queues effects for an invocation.
|
||||
class Editor
|
||||
# @return [String] immutable context text, uppercase mode, or workspace path
|
||||
attr_reader :text, :mode, :root
|
||||
# @return [Integer] one-based grapheme row/column or nonnegative document ID/revision
|
||||
attr_reader :row, :column, :buffer_id, :revision
|
||||
# @return [String, nil] document path; nil for an unnamed buffer
|
||||
attr_reader :path
|
||||
# @return [Integer] positive invocation ID
|
||||
attr_reader :request_id
|
||||
# @return [Array<Hash{String => String}>] queued insert/replace/message effects
|
||||
attr_reader :effects
|
||||
|
||||
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
||||
# @param request_id [Integer] positive invocation ID
|
||||
# @return [void]
|
||||
def initialize(context, request_id)
|
||||
validate_context(context)
|
||||
assign_context_strings(context)
|
||||
assign_context_numbers(context, request_id)
|
||||
validate_context_position
|
||||
validate_context_revision
|
||||
@effects = []
|
||||
@effect_bytes = 0
|
||||
end
|
||||
|
||||
# @param text [String]
|
||||
# @return [nil]
|
||||
def insert(text)
|
||||
queue("insert", text)
|
||||
end
|
||||
|
||||
# @param text [String]
|
||||
# @return [nil]
|
||||
def replace(text)
|
||||
queue("replace", text)
|
||||
end
|
||||
|
||||
# @param text [String]
|
||||
# @return [nil]
|
||||
def message(text)
|
||||
queue("message", text)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @param context [Hash{String => String, Integer, nil}]
|
||||
# @return [void]
|
||||
def validate_context(context)
|
||||
return if context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
||||
|
||||
raise ArgumentError, "invalid editor context"
|
||||
end
|
||||
|
||||
# @param context [Hash{String => String, Integer, nil}]
|
||||
# @return [void]
|
||||
def assign_context_strings(context)
|
||||
@text = Vamp.string!(context["text"], "context text").freeze
|
||||
@mode = Vamp.string!(context["mode"], "context mode").freeze
|
||||
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze
|
||||
@root = Vamp.string!(context["root"], "context root").freeze
|
||||
end
|
||||
|
||||
# @param context [Hash{String => String, Integer, nil}]
|
||||
# @param request_id [Integer]
|
||||
# @return [void]
|
||||
def assign_context_numbers(context, request_id)
|
||||
@buffer_id = context["buffer_id"]
|
||||
@revision = context["revision"]
|
||||
@request_id = request_id
|
||||
@row = context["row"]
|
||||
@column = context["column"]
|
||||
end
|
||||
|
||||
# @return [void]
|
||||
def validate_context_position
|
||||
return if @row.is_a?(Integer) && @row.positive? && @column.is_a?(Integer) && @column.positive?
|
||||
|
||||
raise ArgumentError, "context row and column must be positive integers"
|
||||
end
|
||||
|
||||
# @return [void]
|
||||
def validate_context_revision
|
||||
return if @buffer_id.is_a?(Integer) && @buffer_id >= 0 && @revision.is_a?(Integer) && @revision >= 0
|
||||
|
||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||
end
|
||||
|
||||
# @param type [String] insert, replace, or message
|
||||
# @param text [String]
|
||||
# @return [nil]
|
||||
def queue(type, text)
|
||||
text = Vamp.string!(text, "effect text")
|
||||
@effect_bytes += text.bytesize
|
||||
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
||||
|
||||
@effects << { "type" => type, "text" => text }
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
+103
-404
@@ -1,434 +1,133 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "json"
|
||||
|
||||
# Keep protocol IO separate even from explicit STDOUT.write and ordinary puts.
|
||||
protocol_out = STDOUT.dup
|
||||
protocol_out.binmode
|
||||
protocol_out.sync = true
|
||||
protocol_in = STDIN.dup
|
||||
protocol_in.binmode
|
||||
STDOUT.reopen(STDERR)
|
||||
$stdout = STDERR
|
||||
Thread.abort_on_exception = true
|
||||
|
||||
module Vamp
|
||||
MAX_FRAME = 4 * 1024 * 1024
|
||||
|
||||
def self.string!(value, label)
|
||||
raise TypeError, "#{label} must be a String" unless value.is_a?(String)
|
||||
raise ArgumentError, "#{label} must be valid UTF-8" unless value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
||||
value.dup.force_encoding(Encoding::UTF_8)
|
||||
end
|
||||
|
||||
def self.json_value!(value)
|
||||
case value
|
||||
when Hash
|
||||
value.to_h do |key, item|
|
||||
raise TypeError, "option keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
||||
[string!(key.to_s, "option key"), json_value!(item)]
|
||||
end
|
||||
when Array
|
||||
value.map { |item| json_value!(item) }
|
||||
when String
|
||||
string!(value, "option value")
|
||||
when Integer, TrueClass, FalseClass, NilClass
|
||||
value
|
||||
when Float
|
||||
raise ArgumentError, "option numbers must be finite" unless value.finite?
|
||||
value
|
||||
else
|
||||
raise TypeError, "options must contain only JSON values"
|
||||
end
|
||||
end
|
||||
|
||||
def self.deep_freeze(value)
|
||||
case value
|
||||
when Hash
|
||||
value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
||||
when Array
|
||||
value.each { |item| deep_freeze(item) }
|
||||
end
|
||||
value.freeze
|
||||
end
|
||||
|
||||
Action = Struct.new(:name)
|
||||
Mode = Struct.new(:name)
|
||||
|
||||
Core = Module.new
|
||||
|
||||
def self.install_actions(names)
|
||||
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
|
||||
namespaces = [Core]
|
||||
names.each do |name|
|
||||
name = string!(name, "core action name")
|
||||
unless name.match?(/\Acore(?:\.[a-z_][a-z0-9_]*)+\z/)
|
||||
raise ArgumentError, "invalid core action name: #{name}"
|
||||
end
|
||||
parts = name.split(".").drop(1)
|
||||
namespace = Core
|
||||
parts[0...-1].each do |part|
|
||||
if namespace.singleton_methods(false).include?(part.to_sym)
|
||||
child = namespace.public_send(part)
|
||||
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module)
|
||||
else
|
||||
child = Module.new
|
||||
namespace.define_singleton_method(part) { child }
|
||||
namespaces << child
|
||||
end
|
||||
namespace = child
|
||||
end
|
||||
leaf = parts.last
|
||||
raise ArgumentError, "conflicting core action name: #{name}" if namespace.singleton_methods(false).include?(leaf.to_sym)
|
||||
action = Action.send(:new, name.freeze).freeze
|
||||
namespace.define_singleton_method(leaf) { action }
|
||||
end
|
||||
namespaces.each(&:freeze)
|
||||
end
|
||||
|
||||
module Modes
|
||||
%w[normal insert command search visual selection tree].each do |name|
|
||||
mode = Mode.new(name.freeze).freeze
|
||||
define_singleton_method(name) { mode }
|
||||
end
|
||||
end
|
||||
|
||||
Action.private_class_method :new
|
||||
Mode.private_class_method :new
|
||||
|
||||
class Config
|
||||
attr_reader :lsp
|
||||
|
||||
# Owns the subprocess wire protocol, isolated from user stdout and callbacks.
|
||||
class Protocol
|
||||
# @return [void]
|
||||
def initialize
|
||||
@plugins = []
|
||||
@lsp = {}
|
||||
@bindings = []
|
||||
@leader = "space"
|
||||
@file_explorer_side = "right"
|
||||
@file_picker_view = "tree"
|
||||
@file_explorer_view = "tree"
|
||||
@clipboard_mode = "system"
|
||||
# Capture physical streams; globals may be replaced by user code.
|
||||
@output = STDOUT.dup # rubocop:disable Style/GlobalStdStream
|
||||
@output.binmode
|
||||
@output.sync = true
|
||||
@input = STDIN.dup # rubocop:disable Style/GlobalStdStream
|
||||
@input.binmode
|
||||
@mutex = Mutex.new
|
||||
# Explicit STDOUT.write must also stay off the duplicated protocol pipe.
|
||||
STDOUT.reopen(STDERR) # rubocop:disable Style/GlobalStdStream
|
||||
$stdout = STDERR # rubocop:disable Style/GlobalStdStream
|
||||
Thread.abort_on_exception = true
|
||||
end
|
||||
|
||||
def plugin(path, options: {})
|
||||
path = Vamp.string!(path, "plugin path")
|
||||
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
|
||||
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
||||
@plugins << { "path" => path, "options" => Vamp.json_value!(options) }
|
||||
nil
|
||||
end
|
||||
|
||||
def leader=(key)
|
||||
key = Vamp.string!(key, "leader key")
|
||||
raise ArgumentError, "leader key must not be empty" if key.empty?
|
||||
@leader = key
|
||||
end
|
||||
|
||||
def file_explorer_side=(side)
|
||||
side = Vamp.string!(side, "file explorer side")
|
||||
raise ArgumentError, "file explorer side must not be empty" if side.empty?
|
||||
@file_explorer_side = side
|
||||
end
|
||||
|
||||
def file_picker_view=(view)
|
||||
view = Vamp.string!(view, "file picker view")
|
||||
raise ArgumentError, "file picker view must not be empty" if view.empty?
|
||||
@file_picker_view = view
|
||||
end
|
||||
|
||||
def file_explorer_view=(view)
|
||||
view = Vamp.string!(view, "file explorer view")
|
||||
raise ArgumentError, "file explorer view must not be empty" if view.empty?
|
||||
@file_explorer_view = view
|
||||
end
|
||||
|
||||
def clipboard_mode=(mode)
|
||||
mode = Vamp.string!(mode, "clipboard mode")
|
||||
raise ArgumentError, "clipboard mode must not be empty" if mode.empty?
|
||||
@clipboard_mode = mode
|
||||
end
|
||||
|
||||
def core
|
||||
Core
|
||||
end
|
||||
|
||||
def modes
|
||||
Modes
|
||||
end
|
||||
|
||||
def bind(key, command, mode: Modes.normal)
|
||||
key = Vamp.string!(key, "binding key")
|
||||
raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode)
|
||||
command = case command
|
||||
when Action
|
||||
command.name
|
||||
when String
|
||||
name = Vamp.string!(command, "binding command")
|
||||
raise TypeError, "use config.core for built-in actions" if name.start_with?("core.")
|
||||
name
|
||||
when nil
|
||||
nil
|
||||
else
|
||||
raise TypeError, "binding command must be an Action, String, or nil"
|
||||
end
|
||||
raise ArgumentError, "binding key must not be empty" if key.empty?
|
||||
raise ArgumentError, "binding command must not be empty" if command == ""
|
||||
@bindings << { "mode" => mode.name, "key" => key, "command" => command }
|
||||
nil
|
||||
end
|
||||
|
||||
def response
|
||||
lsp = @lsp.to_h do |language, value|
|
||||
language = Vamp.string!(language, "LSP language key")
|
||||
if language.empty? || language.match?(/[[:cntrl:]]/)
|
||||
raise ArgumentError, "LSP language keys must not be empty or contain control characters"
|
||||
end
|
||||
value = case value
|
||||
when TrueClass, FalseClass
|
||||
value
|
||||
when Hash
|
||||
Vamp.json_value!(value)
|
||||
else
|
||||
raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash"
|
||||
end
|
||||
[language, value]
|
||||
end
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
||||
"lsp" => lsp, "bindings" => @bindings, "leader" => @leader,
|
||||
"file_explorer_side" => @file_explorer_side,
|
||||
"file_picker_view" => @file_picker_view,
|
||||
"file_explorer_view" => @file_explorer_view,
|
||||
"clipboard_mode" => @clipboard_mode }
|
||||
end
|
||||
end
|
||||
|
||||
class Editor
|
||||
attr_reader :text, :row, :column, :mode, :path, :effects, :buffer_id, :revision, :root, :request_id
|
||||
|
||||
def initialize(context, request_id)
|
||||
unless context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
||||
raise ArgumentError, "invalid editor context"
|
||||
end
|
||||
@text = Vamp.string!(context["text"], "context text").freeze
|
||||
@mode = Vamp.string!(context["mode"], "context mode").freeze
|
||||
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze
|
||||
@root = Vamp.string!(context["root"], "context root").freeze
|
||||
@buffer_id = context["buffer_id"]
|
||||
@revision = context["revision"]
|
||||
@request_id = request_id
|
||||
@row = context["row"]
|
||||
@column = context["column"]
|
||||
unless @row.is_a?(Integer) && @row > 0 && @column.is_a?(Integer) && @column > 0
|
||||
raise ArgumentError, "context row and column must be positive integers"
|
||||
end
|
||||
unless @buffer_id.is_a?(Integer) && @buffer_id >= 0 && @revision.is_a?(Integer) && @revision >= 0
|
||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||
end
|
||||
@effects = []
|
||||
@effect_bytes = 0
|
||||
end
|
||||
|
||||
def insert(text)
|
||||
queue("insert", text)
|
||||
end
|
||||
|
||||
def replace(text)
|
||||
queue("replace", text)
|
||||
end
|
||||
|
||||
def message(text)
|
||||
queue("message", text)
|
||||
# @param arguments [Array<String>] mode, source path, and startup JSON
|
||||
# @return [Object] the shutdown callback's result
|
||||
def run(arguments)
|
||||
start(arguments)
|
||||
send_frame(Vamp.startup)
|
||||
process_requests if @mode == "plugin"
|
||||
Vamp.shutdown
|
||||
ensure
|
||||
# Includes SystemExit/Interrupt, without rescuing or hiding callback errors.
|
||||
error = $! # rubocop:disable Style/SpecialGlobalVars -- Native exception state at the process boundary.
|
||||
report_error(error) if error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def queue(type, text)
|
||||
text = Vamp.string!(text, "effect text")
|
||||
@effect_bytes += text.bytesize
|
||||
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
||||
@effects << { "type" => type, "text" => text }
|
||||
nil
|
||||
# @param arguments [Array<String>]
|
||||
# @return [Boolean] the source load result
|
||||
def start(arguments)
|
||||
@mode, path, encoded_startup = arguments
|
||||
valid = %w[config plugin].include?(@mode) && path && encoded_startup
|
||||
raise ArgumentError, "expected config or plugin mode, path, and startup data" unless valid
|
||||
raise ArgumentError, "startup frame too large" if encoded_startup.bytesize > MAX_FRAME
|
||||
|
||||
Vamp.setup(@mode, JSON.parse(encoded_startup), method(:send_frame))
|
||||
load path
|
||||
end
|
||||
end
|
||||
|
||||
def self.setup(mode, startup, sender)
|
||||
unless startup.is_a?(Hash) && startup.keys.sort == %w[core_actions options] && startup["options"].is_a?(Hash)
|
||||
raise TypeError, "invalid Ruby startup catalog/options"
|
||||
# @param frame [Hash{String => Object}]
|
||||
# @return [Integer] bytes written by the terminating newline
|
||||
def send_frame(frame)
|
||||
@mutex.synchronize do
|
||||
payload = JSON.generate(frame)
|
||||
raise ArgumentError, "outgoing frame too large" if payload.bytesize > MAX_FRAME
|
||||
|
||||
@output.write(payload)
|
||||
@output.write("\n")
|
||||
end
|
||||
end
|
||||
install_actions(startup["core_actions"])
|
||||
options = startup["options"]
|
||||
@mode = mode
|
||||
@config = Config.new
|
||||
@name = nil
|
||||
@requires = []
|
||||
@commands = {}
|
||||
@async = false
|
||||
@document_callback = nil
|
||||
@shutdown_callback = nil
|
||||
@options = deep_freeze(json_value!(options))
|
||||
@sender = sender
|
||||
end
|
||||
|
||||
def self.options
|
||||
@options
|
||||
end
|
||||
# @return [void]
|
||||
def process_requests
|
||||
loop do
|
||||
@request_id = nil
|
||||
line = @input.gets(MAX_FRAME + 2)
|
||||
break if line.nil?
|
||||
|
||||
def self.configure
|
||||
raise ArgumentError, "configure is only available in config files" unless @mode == "config"
|
||||
raise ArgumentError, "configure requires a block" unless block_given?
|
||||
yield @config
|
||||
end
|
||||
|
||||
def self.plugin(name, requires: [], async: false)
|
||||
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
|
||||
raise ArgumentError, "plugin already declared" unless @name.nil?
|
||||
name = string!(name, "plugin name")
|
||||
raise ArgumentError, "plugin name must not be empty" if name.empty?
|
||||
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
|
||||
@requires = requires.map { |value| string!(value, "capability") }
|
||||
raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?)
|
||||
raise TypeError, "async must be true or false" unless async == true || async == false
|
||||
@async = async
|
||||
@name = name
|
||||
nil
|
||||
end
|
||||
|
||||
def self.command(name, &callback)
|
||||
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
|
||||
name = string!(name, "command name")
|
||||
raise ArgumentError, "command name must not be empty" if name.empty?
|
||||
raise ArgumentError, "command requires a block" unless callback
|
||||
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
|
||||
@commands[name] = callback
|
||||
nil
|
||||
end
|
||||
|
||||
def self.on_document(&callback)
|
||||
raise ArgumentError, "document callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise ArgumentError, "on_document requires a block" unless callback
|
||||
raise ArgumentError, "document callback already registered" if @document_callback
|
||||
@document_callback = callback
|
||||
nil
|
||||
end
|
||||
|
||||
def self.on_shutdown(&callback)
|
||||
raise ArgumentError, "shutdown callbacks require an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise ArgumentError, "on_shutdown requires a block" unless callback
|
||||
raise ArgumentError, "shutdown callback already registered" if @shutdown_callback
|
||||
@shutdown_callback = callback
|
||||
nil
|
||||
end
|
||||
|
||||
def self.shutdown
|
||||
callback, @shutdown_callback = @shutdown_callback, nil
|
||||
callback&.call
|
||||
end
|
||||
|
||||
def self.publish(effects, request_id: nil)
|
||||
raise ArgumentError, "publish requires an asynchronous plugin" unless @mode == "plugin" && @name && @async
|
||||
raise TypeError, "published effects must be an Array of Hashes" unless effects.is_a?(Array) && effects.all? { |effect| effect.is_a?(Hash) }
|
||||
unless request_id.nil? || (request_id.is_a?(Integer) && request_id > 0 && request_id <= 18_446_744_073_709_551_615)
|
||||
raise ArgumentError, "request_id must be a positive unsigned 64-bit integer or nil"
|
||||
request = parse_request(line)
|
||||
remember_request(request)
|
||||
dispatch(request)
|
||||
break if request["type"] == "shutdown"
|
||||
end
|
||||
end
|
||||
@sender.call({ "version" => 1, "type" => "update", "request_id" => request_id,
|
||||
"effects" => json_value!(effects) })
|
||||
nil
|
||||
end
|
||||
|
||||
def self.document(request)
|
||||
unless @async && request.is_a?(Hash) && request.keys.sort == %w[document event type version] &&
|
||||
request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "document" &&
|
||||
%w[open change save close].include?(request["event"])
|
||||
raise ArgumentError, "invalid document event frame"
|
||||
end
|
||||
document = request["document"]
|
||||
unless document.is_a?(Hash) && document.keys.sort == %w[id path revision root text] &&
|
||||
document["id"].is_a?(Integer) && document["id"] >= 0 &&
|
||||
document["revision"].is_a?(Integer) && document["revision"] >= 0
|
||||
raise ArgumentError, "invalid document snapshot"
|
||||
end
|
||||
document["text"] = string!(document["text"], "document text")
|
||||
document["root"] = string!(document["root"], "document root")
|
||||
document["path"] = string!(document["path"], "document path") unless document["path"].nil?
|
||||
@document_callback&.call(deep_freeze({ "event" => request["event"], "document" => document }))
|
||||
nil
|
||||
end
|
||||
|
||||
def self.startup
|
||||
return @config.response if @mode == "config"
|
||||
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
|
||||
{ "version" => 1, "type" => "manifest", "name" => @name,
|
||||
"requires" => @requires, "commands" => @commands.keys, "async" => @async }
|
||||
end
|
||||
|
||||
def self.invoke(request)
|
||||
unless request.is_a?(Hash) && request.keys.sort == %w[args command context id type version] &&
|
||||
request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "invoke" &&
|
||||
request["id"].is_a?(Integer) && request["id"] > 0
|
||||
raise ArgumentError, "invalid invocation frame"
|
||||
end
|
||||
command = string!(request["command"], "command")
|
||||
args = string!(request["args"], "args")
|
||||
callback = @commands[command]
|
||||
raise ArgumentError, "unknown command #{command}" unless callback
|
||||
editor = Editor.new(request["context"], request["id"])
|
||||
callback.call(editor, args)
|
||||
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
|
||||
end
|
||||
end
|
||||
|
||||
protocol_mutex = Mutex.new
|
||||
send_frame = lambda do |frame|
|
||||
protocol_mutex.synchronize do
|
||||
payload = JSON.generate(frame)
|
||||
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
|
||||
protocol_out.write(payload)
|
||||
protocol_out.write("\n")
|
||||
end
|
||||
end
|
||||
|
||||
request_id = nil
|
||||
begin
|
||||
mode, path, encoded_startup = ARGV
|
||||
raise ArgumentError, "expected config or plugin mode, path, and startup data" unless %w[config plugin].include?(mode) && path && encoded_startup
|
||||
raise ArgumentError, "Ruby startup data exceeds 4 MiB" if encoded_startup.bytesize > Vamp::MAX_FRAME
|
||||
Vamp.setup(mode, JSON.parse(encoded_startup), send_frame)
|
||||
load path
|
||||
send_frame.call(Vamp.startup)
|
||||
if mode == "plugin"
|
||||
loop do
|
||||
request_id = nil
|
||||
line = protocol_in.gets(Vamp::MAX_FRAME + 2)
|
||||
break if line.nil?
|
||||
unless line.end_with?("\n") && line.bytesize <= Vamp::MAX_FRAME + 1
|
||||
# @param line [String]
|
||||
# @return [Object] parsed JSON, validated by the selected request handler
|
||||
def parse_request(line)
|
||||
unless line.end_with?("\n") && line.bytesize - 1 <= MAX_FRAME
|
||||
raise ArgumentError, "invalid or oversized protocol frame"
|
||||
end
|
||||
request = JSON.parse(line)
|
||||
request_id = request["id"] if request.is_a?(Hash) && request["id"].is_a?(Integer) && request["id"] > 0
|
||||
|
||||
JSON.parse(line)
|
||||
end
|
||||
|
||||
# @param request [Object]
|
||||
# @return [Integer, nil] ID to attach to a failed invocation
|
||||
def remember_request(request)
|
||||
return unless request.is_a?(Hash)
|
||||
|
||||
id = request["id"]
|
||||
@request_id = id if id.is_a?(Integer) && id.positive?
|
||||
end
|
||||
|
||||
# @param request [Object]
|
||||
# @return [Object]
|
||||
def dispatch(request)
|
||||
case request.is_a?(Hash) && request["type"]
|
||||
when "invoke"
|
||||
send_frame.call(Vamp.invoke(request))
|
||||
send_frame(Vamp.invoke(request))
|
||||
when "document"
|
||||
Vamp.document(request)
|
||||
when "shutdown"
|
||||
unless request.keys.sort == %w[type version] && request["version"].is_a?(Integer) && request["version"] == 1
|
||||
raise ArgumentError, "invalid shutdown frame"
|
||||
end
|
||||
break
|
||||
validate_shutdown(request)
|
||||
else
|
||||
raise ArgumentError, "unknown protocol frame"
|
||||
end
|
||||
end
|
||||
|
||||
# @param request [Hash{String => Object}]
|
||||
# @return [nil]
|
||||
def validate_shutdown(request)
|
||||
return if request.keys.sort == %w[type version] && request["version"].is_a?(Integer) && request["version"] == 1
|
||||
|
||||
raise ArgumentError, "invalid shutdown frame"
|
||||
end
|
||||
|
||||
# @param error [Exception]
|
||||
# @return [void]
|
||||
def report_error(error)
|
||||
# No callback effects survive an exception. Bound and sanitize the message.
|
||||
message = "#{error.class}: #{error.message}".encode("UTF-8", invalid: :replace, undef: :replace)
|
||||
frame = { "version" => 1, "type" => "error", "message" => message.each_char.take(2048).join }
|
||||
frame["id"] = @request_id unless @request_id.nil?
|
||||
send_frame(frame)
|
||||
ensure
|
||||
# If even reporting fails, Rust observes EOF and invalidates the host.
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
Vamp.shutdown
|
||||
rescue Exception => error
|
||||
# No callback effects are emitted when loading or invoking raises.
|
||||
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
||||
message = message.each_char.take(2048).join
|
||||
frame = { "version" => 1, "type" => "error", "message" => message }
|
||||
frame["id"] = request_id unless request_id.nil?
|
||||
begin
|
||||
send_frame.call(frame)
|
||||
rescue Exception
|
||||
# Rust observes EOF if even the error cannot be framed.
|
||||
end
|
||||
exit 1
|
||||
end
|
||||
|
||||
Vamp::Protocol.new.run(ARGV)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Ruby DSL and protocol callbacks share the host's singleton state.
|
||||
module Vamp
|
||||
# Checks protocol envelopes and normalizes document snapshots before callbacks.
|
||||
module FrameValidation
|
||||
# @param startup [Object] action catalog and options
|
||||
# @return [void]
|
||||
def self.startup!(startup)
|
||||
return if hash_shape?(startup, %w[core_actions options]) && startup["options"].is_a?(Hash)
|
||||
|
||||
raise TypeError, "invalid Ruby startup catalog/options"
|
||||
end
|
||||
|
||||
# @param request [Object] invocation frame
|
||||
# @return [void]
|
||||
def self.invocation!(request)
|
||||
return if envelope?(request, "invoke", %w[args command context id type version]) &&
|
||||
integer?(request["id"], 1)
|
||||
|
||||
raise ArgumentError, "invalid invocation frame"
|
||||
end
|
||||
|
||||
# @param request [Object] document event frame
|
||||
# @return [void]
|
||||
def self.document!(request)
|
||||
return if envelope?(request, "document", %w[document event type version]) &&
|
||||
%w[open change save close].include?(request["event"])
|
||||
|
||||
raise ArgumentError, "invalid document event frame"
|
||||
end
|
||||
|
||||
# @param document [Object] document snapshot normalized in place
|
||||
# @return [Hash{String => String, Integer, nil}]
|
||||
def self.snapshot!(document)
|
||||
unless hash_shape?(document, %w[id path revision root text]) &&
|
||||
integer?(document["id"], 0) && integer?(document["revision"], 0)
|
||||
raise ArgumentError, "invalid document snapshot"
|
||||
end
|
||||
|
||||
normalize_snapshot_strings(document)
|
||||
document
|
||||
end
|
||||
|
||||
# @param effects [Object] asynchronous effects
|
||||
# @return [void]
|
||||
def self.effects!(effects)
|
||||
return if effects.is_a?(Array) && effects.all?(Hash)
|
||||
|
||||
raise TypeError, "published effects must be an Array of Hashes"
|
||||
end
|
||||
|
||||
# @param request_id [Object] update's optional invocation ID
|
||||
# @return [void]
|
||||
def self.request_id!(request_id)
|
||||
return if request_id.nil? || (integer?(request_id, 1) && request_id <= 18_446_744_073_709_551_615)
|
||||
|
||||
raise ArgumentError, "request_id must be a positive unsigned 64-bit integer or nil"
|
||||
end
|
||||
|
||||
class << self
|
||||
private
|
||||
|
||||
# @param document [Hash{String => String, Integer, nil}]
|
||||
# @return [String, nil]
|
||||
def normalize_snapshot_strings(document)
|
||||
document["text"] = Vamp.string!(document["text"], "document text")
|
||||
document["root"] = Vamp.string!(document["root"], "document root")
|
||||
document["path"] = Vamp.string!(document["path"], "document path") unless document["path"].nil?
|
||||
end
|
||||
|
||||
# @param value [Object]
|
||||
# @param keys [Array<String>] sorted required keys
|
||||
# @return [Boolean]
|
||||
def hash_shape?(value, keys)
|
||||
value.is_a?(Hash) && value.keys.sort == keys
|
||||
end
|
||||
|
||||
# @param value [Object]
|
||||
# @param minimum [Integer] inclusive lower bound
|
||||
# @return [Boolean]
|
||||
def integer?(value, minimum)
|
||||
value.is_a?(Integer) && value >= minimum
|
||||
end
|
||||
|
||||
# @param request [Object]
|
||||
# @param type [String] expected frame kind
|
||||
# @param keys [Array<String>] sorted required keys
|
||||
# @return [Boolean]
|
||||
def envelope?(request, type, keys)
|
||||
hash_shape?(request, keys) && request["version"].is_a?(Integer) &&
|
||||
request["version"] == 1 && request["type"] == type
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# @param mode [String] config or plugin
|
||||
# @param startup [Hash{String => Object}] action catalog and plugin options
|
||||
# @param sender [Proc, Method] writes a Hash{String => Object} protocol frame
|
||||
# @return [Proc, Method] the installed sender
|
||||
def self.setup(mode, startup, sender)
|
||||
FrameValidation.startup!(startup)
|
||||
install_actions(startup["core_actions"])
|
||||
@mode = mode
|
||||
reset_registrations
|
||||
@options = deep_freeze(json_value!(startup["options"]))
|
||||
@sender = sender
|
||||
end
|
||||
|
||||
# @return [Hash{String => Object}] deeply frozen plugin options
|
||||
def self.options
|
||||
@options
|
||||
end
|
||||
|
||||
# @yieldparam config [Vamp::Config]
|
||||
# @yieldreturn [Object]
|
||||
# @return [Object] the block result
|
||||
def self.configure
|
||||
raise ArgumentError, "configure is only available in config files" unless @mode == "config"
|
||||
raise ArgumentError, "configure requires a block" unless block_given?
|
||||
|
||||
yield @config
|
||||
end
|
||||
|
||||
class << self
|
||||
private
|
||||
|
||||
# Clears declarations and callbacks before loading a script.
|
||||
# @return [nil]
|
||||
def reset_registrations
|
||||
@config = Config.new
|
||||
@name = nil
|
||||
@requires = []
|
||||
@commands = {}
|
||||
@async = false
|
||||
@document_callback = nil
|
||||
@shutdown_callback = nil
|
||||
end
|
||||
end
|
||||
|
||||
# Plugin declarations and hooks operate on the extending host's singleton state.
|
||||
module PluginRegistration
|
||||
# @param name [String]
|
||||
# @param requires [Array<String>] frontend capabilities
|
||||
# @param async [Boolean]
|
||||
# @return [nil]
|
||||
def plugin(name, requires: [], async: false)
|
||||
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
|
||||
raise ArgumentError, "plugin already declared" unless @name.nil?
|
||||
|
||||
name = registration_name!(name, "plugin")
|
||||
@requires = capabilities!(requires)
|
||||
raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?)
|
||||
raise TypeError, "async must be true or false" unless [true, false].include?(async)
|
||||
|
||||
@async = async
|
||||
@name = name
|
||||
nil
|
||||
end
|
||||
|
||||
# @param name [String] external command name
|
||||
# @param callback [Proc]
|
||||
# @yieldparam editor [Vamp::Editor] immutable context and queued effects
|
||||
# @yieldparam args [String] remaining command text
|
||||
# @yieldreturn [Object] ignored
|
||||
# @return [nil]
|
||||
def command(name, &callback)
|
||||
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
|
||||
|
||||
name = registration_name!(name, "command")
|
||||
raise ArgumentError, "command requires a block" unless callback
|
||||
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
|
||||
|
||||
@commands[name] = callback
|
||||
nil
|
||||
end
|
||||
|
||||
# @param callback [Proc]
|
||||
# @yieldparam event [Hash{String => String, Hash{String => String, Integer, nil}}] frozen event/document
|
||||
# @yieldreturn [Object] ignored
|
||||
# @return [nil]
|
||||
def on_document(&callback)
|
||||
require_async!("document callbacks require")
|
||||
raise ArgumentError, "on_document requires a block" unless callback
|
||||
raise ArgumentError, "document callback already registered" if @document_callback
|
||||
|
||||
@document_callback = callback
|
||||
nil
|
||||
end
|
||||
|
||||
# @param callback [Proc]
|
||||
# @yieldreturn [Object] ignored
|
||||
# @return [nil]
|
||||
def on_shutdown(&callback)
|
||||
require_async!("shutdown callbacks require")
|
||||
raise ArgumentError, "on_shutdown requires a block" unless callback
|
||||
raise ArgumentError, "shutdown callback already registered" if @shutdown_callback
|
||||
|
||||
@shutdown_callback = callback
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @param name [Object]
|
||||
# @param kind [String] plugin or command
|
||||
# @return [String] validated UTF-8 name
|
||||
def registration_name!(name, kind)
|
||||
name = string!(name, "#{kind} name")
|
||||
raise ArgumentError, "#{kind} name must not be empty" if name.empty?
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
# @param requires [Object]
|
||||
# @return [Array<String>] validated UTF-8 capability names
|
||||
def capabilities!(requires)
|
||||
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
|
||||
|
||||
requires.map { |value| string!(value, "capability") }
|
||||
end
|
||||
|
||||
# @param operation [String] prefix of the unchanged asynchronous-only error
|
||||
# @return [void]
|
||||
def require_async!(operation)
|
||||
return if @mode == "plugin" && @name && @async
|
||||
|
||||
raise ArgumentError, "#{operation} an asynchronous plugin"
|
||||
end
|
||||
end
|
||||
|
||||
extend PluginRegistration
|
||||
|
||||
# @return [Object, nil] callback result, or nil when no hook is registered
|
||||
def self.shutdown
|
||||
callback = @shutdown_callback
|
||||
@shutdown_callback = nil
|
||||
callback&.call
|
||||
end
|
||||
|
||||
# @param effects [Array<Hash{String, Symbol => Object}>] JSON-compatible async update effects
|
||||
# @param request_id [Integer, nil] positive unsigned 64-bit invocation ID, or background update
|
||||
# @return [nil]
|
||||
def self.publish(effects, request_id: nil)
|
||||
require_async!("publish requires")
|
||||
FrameValidation.effects!(effects)
|
||||
FrameValidation.request_id!(request_id)
|
||||
@sender.call({ "version" => 1, "type" => "update", "request_id" => request_id,
|
||||
"effects" => json_value!(effects) })
|
||||
nil
|
||||
end
|
||||
|
||||
# @param request [Hash{String => Object}] incoming document event frame
|
||||
# @return [nil]
|
||||
def self.document(request)
|
||||
raise ArgumentError, "invalid document event frame" unless @async
|
||||
|
||||
FrameValidation.document!(request)
|
||||
document = FrameValidation.snapshot!(request["document"])
|
||||
@document_callback&.call(deep_freeze({ "event" => request["event"], "document" => document }))
|
||||
nil
|
||||
end
|
||||
|
||||
# @return [Hash{String => Object}] configuration or plugin manifest frame
|
||||
def self.startup
|
||||
return @config.response if @mode == "config"
|
||||
|
||||
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
|
||||
|
||||
{ "version" => 1, "type" => "manifest", "name" => @name,
|
||||
"requires" => @requires, "commands" => @commands.keys, "async" => @async }
|
||||
end
|
||||
|
||||
# @param request [Hash{String => Object}] invocation frame
|
||||
# @return [Hash{String => Object}] result frame with queued effects
|
||||
def self.invoke(request)
|
||||
FrameValidation.invocation!(request)
|
||||
command = string!(request["command"], "command")
|
||||
args = string!(request["args"], "args")
|
||||
callback = @commands[command]
|
||||
raise ArgumentError, "unknown command #{command}" unless callback
|
||||
|
||||
editor = Editor.new(request["context"], request["id"])
|
||||
callback.call(editor, args)
|
||||
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,186 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Shared value validation and frozen editor-owned configuration objects.
|
||||
module Vamp
|
||||
MAX_FRAME = 4 * 1024 * 1024
|
||||
|
||||
# Validates a boundary value and returns a UTF-8 copy.
|
||||
# @param value [Object]
|
||||
# @param label [String]
|
||||
# @return [String]
|
||||
def self.string!(value, label)
|
||||
raise TypeError, "#{label} must be a String" unless value.is_a?(String)
|
||||
|
||||
copy = value.dup.force_encoding(Encoding::UTF_8)
|
||||
raise ArgumentError, "#{label} must be valid UTF-8" unless copy.valid_encoding?
|
||||
|
||||
copy
|
||||
end
|
||||
|
||||
# Recursively validates JSON values and normalizes hash keys to strings.
|
||||
# @param value [Object]
|
||||
# @return [Hash{String => Object}, Array<Object>, String, Integer, Float, Boolean, nil]
|
||||
def self.json_value!(value)
|
||||
case value
|
||||
when Hash
|
||||
json_hash(value)
|
||||
when Array
|
||||
value.map { |item| json_value!(item) }
|
||||
else
|
||||
json_scalar(value)
|
||||
end
|
||||
end
|
||||
|
||||
# Freezes the supplied value and every nested hash/array member in place.
|
||||
# @param value [Object]
|
||||
# @return [Object] the same value
|
||||
def self.deep_freeze(value)
|
||||
case value
|
||||
when Hash
|
||||
value.each do |key, item|
|
||||
deep_freeze(key)
|
||||
deep_freeze(item)
|
||||
end
|
||||
when Array
|
||||
value.each { |item| deep_freeze(item) }
|
||||
end
|
||||
value.freeze
|
||||
end
|
||||
|
||||
# @!override Vamp::Action#name
|
||||
# @return [String] the catalog's dotted action name
|
||||
Action = Struct.new(:name)
|
||||
# @!override Vamp::Mode#name
|
||||
# @return [String] the lowercase binding mode
|
||||
Mode = Struct.new(:name)
|
||||
|
||||
# Action methods and nested modules come exclusively from the Rust catalog.
|
||||
# @type [Module<Vamp::Core>]
|
||||
Core = Module.new
|
||||
|
||||
# @param names [Array<String>] dotted core action names
|
||||
# @return [Array<Module>] frozen action namespaces
|
||||
def self.install_actions(names)
|
||||
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
|
||||
|
||||
namespaces = [Core]
|
||||
names.each { |name| install_action(name, namespaces) }
|
||||
namespaces.each(&:freeze)
|
||||
end
|
||||
|
||||
# Frozen binding mode values, distinct from a buffer's editing state.
|
||||
module Modes
|
||||
# @!method self.normal
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.insert
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.command
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.search
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.visual
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.selection
|
||||
# @return [Vamp::Mode]
|
||||
# @!method self.tree
|
||||
# @return [Vamp::Mode]
|
||||
%w[normal insert command search visual selection tree].each do |name|
|
||||
mode = Mode.new(name.freeze).freeze
|
||||
define_singleton_method(name) { mode }
|
||||
end
|
||||
end
|
||||
|
||||
Action.private_class_method :new
|
||||
Mode.private_class_method :new
|
||||
|
||||
# @param value [Hash{String, Symbol => Object}]
|
||||
# @return [Hash{String => Object}]
|
||||
def self.json_hash(value)
|
||||
value.to_h do |key, item|
|
||||
raise TypeError, "option keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
||||
|
||||
[string!(key.to_s, "option key"), json_value!(item)]
|
||||
end
|
||||
end
|
||||
|
||||
# @param value [Object]
|
||||
# @return [String, Integer, Float, Boolean, nil]
|
||||
def self.json_scalar(value)
|
||||
case value
|
||||
when String
|
||||
string!(value, "option value")
|
||||
when Integer, TrueClass, FalseClass, NilClass, Float
|
||||
json_literal(value)
|
||||
else
|
||||
raise TypeError, "options must contain only JSON values"
|
||||
end
|
||||
end
|
||||
|
||||
# @param value [Integer, Float, Boolean, nil]
|
||||
# @return [Integer, Float, Boolean, nil]
|
||||
def self.json_literal(value)
|
||||
raise ArgumentError, "option numbers must be finite" if value.is_a?(Float) && !value.finite?
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
# @param name [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @return [Symbol] the installed action method name
|
||||
def self.install_action(name, namespaces)
|
||||
name = action_name(name)
|
||||
parts = name.split(".").drop(1)
|
||||
namespace = parts[0...-1].reduce(Core) { |parent, part| action_namespace(parent, part, namespaces, name) }
|
||||
leaf = parts.last
|
||||
available_action!(namespace, leaf, name)
|
||||
action = Action.send(:new, name.freeze).freeze
|
||||
namespace.define_singleton_method(leaf) { action }
|
||||
end
|
||||
|
||||
# @param name [Object]
|
||||
# @return [String]
|
||||
def self.action_name(name)
|
||||
name = string!(name, "core action name")
|
||||
raise ArgumentError, "invalid core action name: #{name}" unless name.match?(/\Acore(?:\.[a-z_][a-z0-9_]*)+\z/)
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
# @param parent [Module]
|
||||
# @param part [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @param name [String]
|
||||
# @return [Module]
|
||||
def self.action_namespace(parent, part, namespaces, name)
|
||||
return new_action_namespace(parent, part, namespaces) unless parent.singleton_methods(false).include?(part.to_sym)
|
||||
|
||||
child = parent.public_send(part)
|
||||
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module)
|
||||
|
||||
child
|
||||
end
|
||||
|
||||
# @param parent [Module]
|
||||
# @param part [String]
|
||||
# @param namespaces [Array<Module>]
|
||||
# @return [Module]
|
||||
def self.new_action_namespace(parent, part, namespaces)
|
||||
child = Module.new
|
||||
parent.define_singleton_method(part) { child }
|
||||
namespaces << child
|
||||
child
|
||||
end
|
||||
|
||||
# @param namespace [Module]
|
||||
# @param leaf [String]
|
||||
# @param name [String]
|
||||
# @return [nil]
|
||||
def self.available_action!(namespace, leaf, name)
|
||||
return unless namespace.singleton_methods(false).include?(leaf.to_sym)
|
||||
|
||||
raise ArgumentError, "conflicting core action name: #{name}"
|
||||
end
|
||||
|
||||
private_class_method :json_hash, :json_scalar, :json_literal, :install_action, :action_name,
|
||||
:action_namespace, :new_action_namespace, :available_action!
|
||||
end
|
||||
+66
-21
@@ -46,8 +46,8 @@ pub struct EditorSession {
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
/// File errors fail opening; config and plugin errors leave core editing
|
||||
/// available and are reported in the editor's message.
|
||||
/// File errors fail opening; rejected configuration leaves core editing
|
||||
/// available and presents a persistent startup diagnostic.
|
||||
pub fn open(
|
||||
file: Option<PathBuf>,
|
||||
capabilities: Vec<String>,
|
||||
@@ -57,7 +57,10 @@ impl EditorSession {
|
||||
let mut notices = Vec::new();
|
||||
let config = match config {
|
||||
Some(path) => load_config(path).unwrap_or_else(|error| {
|
||||
notices.push(format!("config disabled: {error}"));
|
||||
editor.show_startup_diagnostic(format!(
|
||||
"{}\n\nConfiguration was rejected; using default settings and bindings.\n\n{error}",
|
||||
path.display()
|
||||
));
|
||||
Config::default()
|
||||
}),
|
||||
None => Config::default(),
|
||||
@@ -2317,7 +2320,17 @@ end
|
||||
let mut session = fixture.open(&format!(
|
||||
"Vamp.configure do |config|\n config.leader = \",\"\n config.bind \"i\", nil\n {expression}\nend"
|
||||
));
|
||||
assert!(session.editor().message().contains(error), "{expression}");
|
||||
assert!(
|
||||
session
|
||||
.editor()
|
||||
.startup_diagnostic()
|
||||
.unwrap()
|
||||
.lines
|
||||
.iter()
|
||||
.any(|line| line.contains(error)),
|
||||
"{expression}"
|
||||
);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('界'));
|
||||
session.handle(Input::Escape);
|
||||
@@ -2338,24 +2351,56 @@ end
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_config_falls_back_to_core_without_failing_the_session() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.open("raise 'broken config'");
|
||||
assert!(
|
||||
session
|
||||
fn rejected_config_diagnostic_persists_until_dismissed_and_default_editing_survives() {
|
||||
for (config, reason, dismiss) in [
|
||||
("raise 'broken config'", "broken config", Input::Enter),
|
||||
(
|
||||
"Vamp.configure { |config| config.lsp['unsupported-language'] = true }",
|
||||
"unsupported-language",
|
||||
Input::Escape,
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.open(config);
|
||||
let diagnostic = session
|
||||
.editor()
|
||||
.message()
|
||||
.contains("config disabled: Ruby config: RuntimeError: broken config")
|
||||
);
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('x'));
|
||||
session.handle(Input::Escape);
|
||||
session.execute("wq");
|
||||
assert!(session.editor().should_quit());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"x"
|
||||
);
|
||||
.startup_diagnostic()
|
||||
.expect("config failure must be visible");
|
||||
let text = diagnostic.lines.join("\n");
|
||||
assert!(text.contains(&fixture.0.join("config.rb").display().to_string()));
|
||||
assert!(text.contains(reason));
|
||||
session.editor.set_message("unrelated background message");
|
||||
session.poll();
|
||||
for input in [
|
||||
Input::Char('i'),
|
||||
Input::Paste("accidental edit".into()),
|
||||
Input::Ctrl('p'),
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
assert_eq!(
|
||||
session
|
||||
.editor()
|
||||
.startup_diagnostic()
|
||||
.unwrap()
|
||||
.lines
|
||||
.join("\n"),
|
||||
text
|
||||
);
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
assert_eq!(session.editor().lines(), &[""]);
|
||||
session.handle(dismiss);
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('x'));
|
||||
session.handle(Input::Escape);
|
||||
session.execute("wq");
|
||||
assert!(session.editor().should_quit());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"x"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
|
||||
|
||||
+113
-83
@@ -29,7 +29,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{
|
||||
editor::{Editor, Hover, Input, Mode, Palette},
|
||||
editor::{Editor, Hover, Input, Mode, Palette, StartupDiagnostic},
|
||||
files::{DockSide, FilePicker, FilePresentation, FileView},
|
||||
plugin::{Diagnostic, DiagnosticSeverity},
|
||||
session::EditorSession,
|
||||
@@ -605,18 +605,7 @@ fn draw(
|
||||
}
|
||||
if let Some(picker) = editor.file_picker() {
|
||||
if picker.presentation == FilePresentation::Floating {
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(84)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.height.min(18)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
let panel = floating_panel(area);
|
||||
frame.render_widget(Clear, panel);
|
||||
draw_file_list(frame, picker, panel, editor.file_navigation_hint());
|
||||
} else if let Some(panel) = dock {
|
||||
@@ -634,6 +623,71 @@ fn draw(
|
||||
if let Some(hover) = editor.hover() {
|
||||
draw_hover(frame, hover, viewport);
|
||||
}
|
||||
if let Some(diagnostic) = editor.startup_diagnostic() {
|
||||
draw_startup_diagnostic(frame, diagnostic, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn floating_panel(area: Rect) -> Rect {
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(100)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.height.min(24)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
panel
|
||||
}
|
||||
|
||||
fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) {
|
||||
let panel = floating_panel(area);
|
||||
frame.render_widget(Clear, panel);
|
||||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||
let block = Block::default()
|
||||
.borders(if bordered {
|
||||
Borders::ALL
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.border_style(Style::default().fg(Color::Red))
|
||||
.title(" Configuration error ");
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
}
|
||||
let [text_area, help_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(content.height >= 3)),
|
||||
])
|
||||
.areas(content);
|
||||
let lines: Vec<Line> = diagnostic
|
||||
.lines
|
||||
.iter()
|
||||
.skip(diagnostic.top)
|
||||
.take(usize::from(text_area.height))
|
||||
.map(|line| {
|
||||
let line = display_text(line);
|
||||
Line::from(visible_line(
|
||||
&line,
|
||||
display_column(&line, diagnostic.left),
|
||||
usize::from(text_area.width),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), text_area);
|
||||
frame.render_widget(
|
||||
Paragraph::new("Arrows scroll · Home reset · Enter/Esc continue")
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help_area,
|
||||
);
|
||||
if text_area.width > 0 && text_area.height > 0 {
|
||||
frame.set_cursor_position((text_area.x, text_area.y));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect) {
|
||||
@@ -777,7 +831,7 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio
|
||||
])
|
||||
.areas(content);
|
||||
let query = if browsing && picker.query.is_empty() {
|
||||
"NORMAL · i filter · ^U clear · ^T view".into()
|
||||
"i filter · ^U clear · ^T view".into()
|
||||
} else if browsing {
|
||||
format!("filter: {} · ^U clear", display_text(&picker.query))
|
||||
} else {
|
||||
@@ -1746,74 +1800,6 @@ end
|
||||
assert_eq!(viewports.syntax.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_editor_text_stays_out_of_docked_and_floating_picker_surfaces() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.file("panels.rs", &vec!["// note"; 30].join("\n"));
|
||||
let area = Rect::new(3, 2, 100, 24);
|
||||
for (name, side) in [
|
||||
("left", Some(DockSide::Left)),
|
||||
("right", Some(DockSide::Right)),
|
||||
("top", Some(DockSide::Top)),
|
||||
("bottom", Some(DockSide::Bottom)),
|
||||
("floating", None),
|
||||
] {
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
if side.is_some() {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
}
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char(if side.is_some() { 'e' } else { 'o' }));
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(110, 30),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
|
||||
let (text, panel) = if let Some(side) = side {
|
||||
let (text, panel) = dock_layout(workspace, side, true);
|
||||
(text, panel.unwrap())
|
||||
} else {
|
||||
(workspace, Rect::new(area.x + 8, area.y + 3, 84, 18))
|
||||
};
|
||||
let mut expected = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(110, 30),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(panel),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
expected
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
editor.file_picker().unwrap(),
|
||||
panel,
|
||||
editor.file_navigation_hint(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let expected = expected.backend().buffer();
|
||||
for y in panel.y..panel.bottom() {
|
||||
for x in panel.x..panel.right() {
|
||||
assert_eq!(buffer[(x, y)], expected[(x, y)], "{name}: {x},{y}");
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(text.x + 3, text.y)].symbol(), "/");
|
||||
assert_eq!(buffer[(text.x + 3, text.y)].fg, Color::DarkGray);
|
||||
assert_eq!(buffer[(text.x, text.y)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
|
||||
assert_eq!(buffer[(0, 0)].fg, Color::Reset);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() {
|
||||
for (code, modifiers, expected) in [
|
||||
@@ -2770,7 +2756,7 @@ end
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left, &mut syntax))
|
||||
.unwrap();
|
||||
let panel = Rect::new(area.x + 13, area.y + 4, 84, 18);
|
||||
let panel = Rect::new(area.x + 5, area.y + 1, 100, 24);
|
||||
assert_eq!((top, left), before);
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
@@ -2784,6 +2770,50 @@ end
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_diagnostic_scrolls_long_unicode_reasons_and_dismisses_without_editing() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.file("buffer.txt", "keep");
|
||||
let config = directory.file("config.rb", "raise \"#{'x' * 160}界END\\nlast reason\"");
|
||||
let mut session =
|
||||
EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(110, 26)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Configuration error"));
|
||||
assert!(text.contains("config.rb"));
|
||||
assert!(text.contains("default settings and bindings"));
|
||||
assert!(text.contains("last reason"));
|
||||
assert!(!text.contains("界END"));
|
||||
for _ in 0..100 {
|
||||
session.handle(Input::Right);
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
let scrolled = screen_text(&terminal);
|
||||
assert!(
|
||||
scrolled.contains("界") && scrolled.contains("END"),
|
||||
"{scrolled}"
|
||||
);
|
||||
for (width, height) in [(4, 4), (1, 1)] {
|
||||
let mut tiny =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
tiny.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
}
|
||||
session.handle(Input::Enter);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("Configuration error"));
|
||||
assert_eq!(session.editor().lines(), &["keep"]);
|
||||
assert_eq!(session.editor().cursor(), crate::editor::Cursor::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() {
|
||||
let directory = Directory::new();
|
||||
|
||||
Reference in New Issue
Block a user