Compare commits
10 Commits
981011096e
...
18dc8cc155
| Author | SHA1 | Date | |
|---|---|---|---|
| 18dc8cc155 | |||
| dbafaf6266 | |||
| be6be22b76 | |||
| f093091002 | |||
| 529d1fd5b3 | |||
| e72f89a7ae | |||
| 30aa70672e | |||
| 92679ecbe1 | |||
| e5d545a4ca | |||
| 7c1e4c3ca0 |
+12
@@ -22,3 +22,15 @@ The presentation and input experience through which a user interacts with an edi
|
|||||||
|
|
||||||
**Capability**:
|
**Capability**:
|
||||||
A named frontend ability required by a feature for compatibility, not a permission granted to that feature.
|
A named frontend ability required by a feature for compatibility, not a permission granted to that feature.
|
||||||
|
|
||||||
|
## Configuration interfaces
|
||||||
|
|
||||||
|
- Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings.
|
||||||
|
- `src/editor.rs`'s `CORE_ACTIONS` is authoritative for core action names and eligibility. Ruby action methods and nested namespaces are generated from its startup catalog; add or rename actions there rather than maintaining a second Ruby list.
|
||||||
|
- Use strings for open-ended identifiers: language IDs, paths, and external plugin command names. Interactive Ex command names remain textual.
|
||||||
|
- Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
config.bind "leader n", config.core.word_next, mode: config.modes.normal
|
||||||
|
config.lsp["my-language"] = { command: ["my-language-server"], extensions: ["myl"] }
|
||||||
|
```
|
||||||
|
|||||||
Generated
+1077
-9
File diff suppressed because it is too large
Load Diff
+18
-1
@@ -6,8 +6,25 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "underline-color"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tree-sitter = "0.27.1"
|
||||||
|
tree-sitter-bash = "0.25.1"
|
||||||
|
tree-sitter-c = "0.24.2"
|
||||||
|
tree-sitter-cpp = "0.23.4"
|
||||||
|
tree-sitter-css = "0.25.0"
|
||||||
|
tree-sitter-go = "0.25.0"
|
||||||
|
tree-sitter-html = "0.23.2"
|
||||||
|
tree-sitter-javascript = "0.25.0"
|
||||||
|
tree-sitter-json = "0.24.8"
|
||||||
|
tree-sitter-make = "1.1.1"
|
||||||
|
tree-sitter-md = { version = "0.5.3", default-features = false }
|
||||||
|
tree-sitter-python = "0.25.0"
|
||||||
|
tree-sitter-ruby = "0.23.1"
|
||||||
|
tree-sitter-rust = "0.24.2"
|
||||||
|
tree-sitter-toml-ng = "0.7.0"
|
||||||
|
tree-sitter-typescript = "0.23.2"
|
||||||
|
tree-sitter-yaml = "0.7.2"
|
||||||
unicode-segmentation = "1.12"
|
unicode-segmentation = "1.12"
|
||||||
unicode-width = "0.2"
|
unicode-width = "0.2"
|
||||||
|
|||||||
@@ -200,17 +200,20 @@ Leader bindings require Normal/Visual and the target's existing mode eligibility
|
|||||||
|
|
||||||
## Module seams
|
## Module seams
|
||||||
|
|
||||||
One crate, six modules, no workspace or terminal-coordinate types in the editing backend:
|
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.
|
- `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.
|
- `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.
|
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||||
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, executes selected commands against the active buffer, and retires both host and command ownership on invocation or effect-validation failure.
|
- `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.
|
||||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports by stable buffer ID, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
|
- `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.
|
- `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.
|
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||||
|
|
||||||
Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. 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 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 `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 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 use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
|
||||||
@@ -218,6 +221,8 @@ Frontends inspect `count()` for the optional pending motion prefix; parsing and
|
|||||||
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and 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 `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 `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 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.
|
||||||
|
|
||||||
@@ -225,10 +230,63 @@ The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a o
|
|||||||
|
|
||||||
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.
|
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.
|
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.
|
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
|
## 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.
|
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.
|
||||||
@@ -263,6 +321,10 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
|||||||
|
|
||||||
`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.
|
`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:
|
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`.
|
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
||||||
@@ -276,6 +338,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
|
|||||||
| Normal | `insert`, `append`, `open_below`, `open_above` |
|
| Normal | `insert`, `append`, `open_below`, `open_above` |
|
||||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||||
|
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||||
| Normal | `search`, `paste_before`, `paste_after` |
|
| Normal | `search`, `paste_before`, `paste_after` |
|
||||||
| Normal | `paste_system_before`, `paste_system_after` |
|
| Normal | `paste_system_before`, `paste_system_after` |
|
||||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||||
@@ -322,23 +385,37 @@ end
|
|||||||
|
|
||||||
Run `:greet vamp`, or press `g` with the example configuration. `examples/plugins/greeting.rb` also provides `:upper` to uppercase the buffer.
|
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`, and `editor.path`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace. Commands run once regardless of target count; successful text effects collapse to the primary before editing once. The plugin API does not expose selections.
|
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.
|
`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.
|
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
|
### Messaging
|
||||||
|
|
||||||
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands) or `config` (plugin paths and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed effects (`insert`, `replace`, `message`). Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
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.
|
JSON uses Ruby's standard library. Protocol Buffers is not installed or required; there is no frontend/network wire protocol yet. The frontend currently calls the Rust library directly.
|
||||||
|
|
||||||
## Current limits
|
## 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, LSP, or graphical frontend. Native system clipboard access currently supports macOS and Linux; Separate mode retains internal editing on unsupported/headless environments.
|
- 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.
|
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||||
- Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing.
|
- 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.
|
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
@@ -353,3 +430,7 @@ ruby -c src/ruby_host.rb
|
|||||||
Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. 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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ Vamp.configure do |config|
|
|||||||
config.bind "ctrl+h", config.core.left, 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.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||||
config.plugin "plugins/greeting.rb"
|
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["my-language"] = {
|
||||||
|
# command: ["my-language-server", "--stdio"], extensions: ["myl"],
|
||||||
|
# initialization_options: {}, settings: {}
|
||||||
|
# }
|
||||||
|
# config.bind "leader d", config.core.lsp.definition
|
||||||
|
# config.bind "leader D", config.core.lsp.diagnostics
|
||||||
|
# config.bind "K", config.core.lsp.hover
|
||||||
config.bind "g", "greet"
|
config.bind "g", "greet"
|
||||||
config.bind "leader g", "greet"
|
config.bind "leader g", "greet"
|
||||||
config.bind "ctrl+r", config.core.redo
|
config.bind "ctrl+r", config.core.redo
|
||||||
|
|||||||
+641
-6
@@ -1,5 +1,6 @@
|
|||||||
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
||||||
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
|
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
|
||||||
|
use crate::plugin::{Diagnostic, TextPosition};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
@@ -61,6 +62,35 @@ pub struct Palette {
|
|||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct Hover {
|
||||||
|
text: String,
|
||||||
|
line_starts: Vec<usize>,
|
||||||
|
/// First visible logical line.
|
||||||
|
pub top: usize,
|
||||||
|
/// First visible extended grapheme, independent of terminal geometry.
|
||||||
|
pub left: usize,
|
||||||
|
max_column: usize,
|
||||||
|
buffer_id: usize,
|
||||||
|
revision: u64,
|
||||||
|
caret: Cursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hover {
|
||||||
|
pub fn text(&self) -> &str {
|
||||||
|
&self.text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lines(&self) -> impl ExactSizeIterator<Item = &str> {
|
||||||
|
self.line_starts.iter().enumerate().map(|(index, &start)| {
|
||||||
|
let end = self
|
||||||
|
.line_starts
|
||||||
|
.get(index + 1)
|
||||||
|
.map_or(self.text.len(), |next| next - 1);
|
||||||
|
&self.text[start..end]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PaletteEntry {
|
pub struct PaletteEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -117,6 +147,9 @@ pub struct Invocation {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
|
pub buffer_id: usize,
|
||||||
|
pub revision: u64,
|
||||||
|
pub root: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
/// One-based row, including the empty row after a trailing newline.
|
/// One-based row, including the empty row after a trailing newline.
|
||||||
pub row: usize,
|
pub row: usize,
|
||||||
@@ -250,6 +283,9 @@ enum Action {
|
|||||||
Palette,
|
Palette,
|
||||||
FilePicker,
|
FilePicker,
|
||||||
FileExplorer,
|
FileExplorer,
|
||||||
|
LspDefinition,
|
||||||
|
LspHover,
|
||||||
|
LspDiagnostics,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Names and mode eligibility are shared by configured bindings and the palette.
|
// Names and mode eligibility are shared by configured bindings and the palette.
|
||||||
@@ -427,8 +463,28 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
|
|||||||
Action::FileExplorer,
|
Action::FileExplorer,
|
||||||
&[Mode::Normal, Mode::Visual],
|
&[Mode::Normal, Mode::Visual],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"core.lsp.definition",
|
||||||
|
Action::LspDefinition,
|
||||||
|
&[Mode::Normal],
|
||||||
|
),
|
||||||
|
("core.lsp.hover", Action::LspHover, &[Mode::Normal]),
|
||||||
|
(
|
||||||
|
"core.lsp.diagnostics",
|
||||||
|
Action::LspDiagnostics,
|
||||||
|
&[Mode::Normal],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Serializes the authoritative action names without copying the catalog.
|
||||||
|
pub(crate) struct CoreActionCatalog;
|
||||||
|
|
||||||
|
impl Serialize for CoreActionCatalog {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.collect_seq(CORE_ACTIONS.iter().map(|(name, _, _)| *name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Action {
|
impl Action {
|
||||||
fn named(mode: Mode, name: &str) -> Option<Self> {
|
fn named(mode: Mode, name: &str) -> Option<Self> {
|
||||||
CORE_ACTIONS
|
CORE_ACTIONS
|
||||||
@@ -689,10 +745,27 @@ pub struct BufferInfo<'a> {
|
|||||||
pub active: bool,
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrowed document state, including hidden and unnamed buffers.
|
||||||
|
pub struct DocumentInfo<'a> {
|
||||||
|
pub id: usize,
|
||||||
|
pub revision: u64,
|
||||||
|
pub path: Option<&'a Path>,
|
||||||
|
pub lines: &'a [String],
|
||||||
|
pub save_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnnotationSet {
|
||||||
|
revision: u64,
|
||||||
|
items: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
struct Buffer {
|
struct Buffer {
|
||||||
id: usize,
|
id: usize,
|
||||||
lines: Vec<String>,
|
lines: Vec<String>,
|
||||||
saved_lines: Vec<String>,
|
saved_lines: Vec<String>,
|
||||||
|
text_revision: u64,
|
||||||
|
save_revision: u64,
|
||||||
|
diagnostics: BTreeMap<String, AnnotationSet>,
|
||||||
targets: Vec<Target>,
|
targets: Vec<Target>,
|
||||||
primary: usize,
|
primary: usize,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
@@ -731,6 +804,9 @@ impl Buffer {
|
|||||||
id,
|
id,
|
||||||
saved_lines: lines.clone(),
|
saved_lines: lines.clone(),
|
||||||
lines,
|
lines,
|
||||||
|
text_revision: 0,
|
||||||
|
save_revision: 0,
|
||||||
|
diagnostics: BTreeMap::new(),
|
||||||
targets: vec![Target::caret(Cursor::default())],
|
targets: vec![Target::caret(Cursor::default())],
|
||||||
primary: 0,
|
primary: 0,
|
||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
@@ -758,6 +834,21 @@ impl Buffer {
|
|||||||
active,
|
active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn document(&self) -> DocumentInfo<'_> {
|
||||||
|
DocumentInfo {
|
||||||
|
id: self.id,
|
||||||
|
revision: self.text_revision,
|
||||||
|
path: self.path.as_deref(),
|
||||||
|
lines: &self.lines,
|
||||||
|
save_revision: self.save_revision,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_changed(&mut self) {
|
||||||
|
self.text_revision = self.text_revision.wrapping_add(1);
|
||||||
|
self.diagnostics.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Editor {
|
pub struct Editor {
|
||||||
@@ -786,6 +877,7 @@ pub struct Editor {
|
|||||||
leader_suffix: Vec<Input>,
|
leader_suffix: Vec<Input>,
|
||||||
leader_display: String,
|
leader_display: String,
|
||||||
palette: Option<Palette>,
|
palette: Option<Palette>,
|
||||||
|
hover: Option<Hover>,
|
||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
file_picker: Option<FilePicker>,
|
file_picker: Option<FilePicker>,
|
||||||
file_explorer_side: DockSide,
|
file_explorer_side: DockSide,
|
||||||
@@ -843,6 +935,7 @@ impl Editor {
|
|||||||
leader_suffix: Vec::new(),
|
leader_suffix: Vec::new(),
|
||||||
leader_display: String::new(),
|
leader_display: String::new(),
|
||||||
palette: None,
|
palette: None,
|
||||||
|
hover: None,
|
||||||
project_root: std::env::current_dir()?,
|
project_root: std::env::current_dir()?,
|
||||||
file_picker: None,
|
file_picker: None,
|
||||||
file_explorer_side: DockSide::Right,
|
file_explorer_side: DockSide::Right,
|
||||||
@@ -857,8 +950,21 @@ impl Editor {
|
|||||||
name: "core".into(),
|
name: "core".into(),
|
||||||
required_capabilities: Vec::new(),
|
required_capabilities: Vec::new(),
|
||||||
commands: [
|
commands: [
|
||||||
"w", "q", "q!", "wq", "plugins", "edit", "buffers", "buffer", "bnext",
|
"w",
|
||||||
"bprev", "bdelete", "bdelete!",
|
"q",
|
||||||
|
"q!",
|
||||||
|
"wq",
|
||||||
|
"plugins",
|
||||||
|
"edit",
|
||||||
|
"buffers",
|
||||||
|
"buffer",
|
||||||
|
"bnext",
|
||||||
|
"bprev",
|
||||||
|
"bdelete",
|
||||||
|
"bdelete!",
|
||||||
|
"lsp.definition",
|
||||||
|
"lsp.hover",
|
||||||
|
"lsp.diagnostics",
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
@@ -935,9 +1041,171 @@ impl Editor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn documents(&self) -> impl Iterator<Item = DocumentInfo<'_>> + '_ {
|
||||||
|
self.parked
|
||||||
|
.range(..self.active.id)
|
||||||
|
.map(|(_, buffer)| buffer.document())
|
||||||
|
.chain(std::iter::once(self.active.document()))
|
||||||
|
.chain(
|
||||||
|
self.parked
|
||||||
|
.range(self.active.id..)
|
||||||
|
.map(|(_, buffer)| buffer.document()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project_root(&self) -> &Path {
|
||||||
|
&self.project_root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buffer(&self, id: usize) -> Option<&Buffer> {
|
||||||
|
if id == self.active.id {
|
||||||
|
Some(&self.active)
|
||||||
|
} else {
|
||||||
|
self.parked.get(&id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diagnostics(&self) -> impl Iterator<Item = &Diagnostic> {
|
||||||
|
self.active
|
||||||
|
.diagnostics
|
||||||
|
.values()
|
||||||
|
.filter(|annotations| annotations.revision == self.active.text_revision)
|
||||||
|
.flat_map(|annotations| &annotations.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diagnostic_at_cursor(&self) -> Option<&Diagnostic> {
|
||||||
|
let cursor = self.cursor();
|
||||||
|
let position = TextPosition {
|
||||||
|
row: cursor.row,
|
||||||
|
column: cursor.column,
|
||||||
|
};
|
||||||
|
self.diagnostics().find(|diagnostic| {
|
||||||
|
diagnostic.start <= position
|
||||||
|
&& (position < diagnostic.end
|
||||||
|
|| (diagnostic.start == diagnostic.end && position == diagnostic.start))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_diagnostics(
|
||||||
|
&self,
|
||||||
|
buffer_id: usize,
|
||||||
|
revision: u64,
|
||||||
|
items: &[Diagnostic],
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let Some(buffer) = self.buffer(buffer_id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if buffer.text_revision != revision {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let mut bytes = 0usize;
|
||||||
|
for diagnostic in items {
|
||||||
|
for position in [diagnostic.start, diagnostic.end] {
|
||||||
|
if buffer
|
||||||
|
.lines
|
||||||
|
.get(position.row)
|
||||||
|
.is_none_or(|line| position.column > grapheme_count(line))
|
||||||
|
{
|
||||||
|
return Err("Diagnostic position is outside the document".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostic.end < diagnostic.start {
|
||||||
|
return Err("Diagnostic range is reversed".into());
|
||||||
|
}
|
||||||
|
if diagnostic.message.chars().any(char::is_control) {
|
||||||
|
return Err("Diagnostic messages cannot contain controls".into());
|
||||||
|
}
|
||||||
|
bytes = bytes
|
||||||
|
.checked_add(diagnostic.message.len())
|
||||||
|
.ok_or("Diagnostics exceed 4 MiB")?;
|
||||||
|
if bytes > MAX_EDIT_BYTES {
|
||||||
|
return Err("Diagnostics exceed 4 MiB".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_diagnostics(
|
||||||
|
&mut self,
|
||||||
|
owner: &str,
|
||||||
|
buffer_id: usize,
|
||||||
|
revision: u64,
|
||||||
|
items: Vec<Diagnostic>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
if !self.validate_diagnostics(buffer_id, revision, &items)? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
Ok(self.replace_validated_diagnostics(owner, buffer_id, revision, items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only session's fully prevalidated batch may use this storage path.
|
||||||
|
pub(crate) fn replace_validated_diagnostics(
|
||||||
|
&mut self,
|
||||||
|
owner: &str,
|
||||||
|
buffer_id: usize,
|
||||||
|
revision: u64,
|
||||||
|
items: Vec<Diagnostic>,
|
||||||
|
) -> bool {
|
||||||
|
let buffer = if buffer_id == self.active.id {
|
||||||
|
&mut self.active
|
||||||
|
} else if let Some(buffer) = self.parked.get_mut(&buffer_id) {
|
||||||
|
buffer
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if buffer.text_revision != revision {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if items.is_empty() {
|
||||||
|
return buffer.diagnostics.remove(owner).is_some();
|
||||||
|
}
|
||||||
|
if buffer.diagnostics.get(owner).is_some_and(|annotations| {
|
||||||
|
annotations.revision == revision && annotations.items == items
|
||||||
|
}) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
.diagnostics
|
||||||
|
.insert(owner.to_owned(), AnnotationSet { revision, items });
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_plugin_diagnostics(&mut self, owner: &str) {
|
||||||
|
self.active.diagnostics.remove(owner);
|
||||||
|
for buffer in self.parked.values_mut() {
|
||||||
|
buffer.diagnostics.remove(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure.
|
||||||
|
pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> {
|
||||||
|
self.clear_hover();
|
||||||
|
if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) {
|
||||||
|
return Err("Navigation path must be nonempty and contain no controls".into());
|
||||||
|
}
|
||||||
|
self.edit_buffer(path, false)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.park_source();
|
||||||
|
self.active.mode = Mode::Normal;
|
||||||
|
let row = position.row.min(self.active.lines.len() - 1);
|
||||||
|
let column = position
|
||||||
|
.column
|
||||||
|
.min(grapheme_count(&self.active.lines[row]).saturating_sub(1));
|
||||||
|
self.active.targets = vec![Target::caret(Cursor { row, column })];
|
||||||
|
self.active.primary = 0;
|
||||||
|
self.active.search_match = None;
|
||||||
|
self.active.visual_motions = None;
|
||||||
|
self.normalize_targets();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn lines(&self) -> &[String] {
|
pub fn lines(&self) -> &[String] {
|
||||||
&self.active.lines
|
&self.active.lines
|
||||||
}
|
}
|
||||||
|
/// Changes only when this buffer's text changes, independent of cursor/view state.
|
||||||
|
pub fn text_revision(&self) -> u64 {
|
||||||
|
self.active.text_revision
|
||||||
|
}
|
||||||
pub fn cursor(&self) -> Cursor {
|
pub fn cursor(&self) -> Cursor {
|
||||||
self.active.targets[self.active.primary].active
|
self.active.targets[self.active.primary].active
|
||||||
}
|
}
|
||||||
@@ -949,6 +1217,91 @@ impl Editor {
|
|||||||
self.palette.as_ref()
|
self.palette.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrowed popup state, visible only while its source remains current.
|
||||||
|
pub fn hover(&self) -> Option<&Hover> {
|
||||||
|
self.hover.as_ref().filter(|hover| {
|
||||||
|
hover.buffer_id == self.active.id
|
||||||
|
&& hover.revision == self.text_revision()
|
||||||
|
&& hover.caret == self.cursor()
|
||||||
|
&& self.mode() == Mode::Normal
|
||||||
|
&& self.palette.is_none()
|
||||||
|
&& !self
|
||||||
|
.file_picker
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|picker| picker.focused)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_hover(&mut self, text: String) -> Result<(), String> {
|
||||||
|
validate_hover_text(&text)?;
|
||||||
|
if self.mode() != Mode::Normal
|
||||||
|
|| self.palette.is_some()
|
||||||
|
|| self
|
||||||
|
.file_picker
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|picker| picker.focused)
|
||||||
|
{
|
||||||
|
return Err("Hover requires Normal editor focus".into());
|
||||||
|
}
|
||||||
|
self.hover = if text.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let text = match normalize_newlines(&text) {
|
||||||
|
Cow::Borrowed(_) => text,
|
||||||
|
Cow::Owned(normalized) => normalized,
|
||||||
|
};
|
||||||
|
let mut line_starts = Vec::new();
|
||||||
|
let mut offset = 0;
|
||||||
|
let mut max_column = 0;
|
||||||
|
for line in text.split('\n') {
|
||||||
|
line_starts.push(offset);
|
||||||
|
offset += line.len() + 1;
|
||||||
|
max_column = max_column.max(grapheme_count(line).saturating_sub(1));
|
||||||
|
}
|
||||||
|
Some(Hover {
|
||||||
|
text,
|
||||||
|
line_starts,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
max_column,
|
||||||
|
buffer_id: self.active.id,
|
||||||
|
revision: self.text_revision(),
|
||||||
|
caret: self.cursor(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_hover(&mut self) {
|
||||||
|
self.hover = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_hover(&mut self, input: &Input) -> bool {
|
||||||
|
if self.hover().is_none() {
|
||||||
|
self.clear_hover();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let hover = self.hover.as_mut().expect("current hover exists");
|
||||||
|
match input {
|
||||||
|
Input::Up | Input::Ctrl('k') => hover.top = hover.top.saturating_sub(1),
|
||||||
|
Input::Down | Input::Ctrl('j') => {
|
||||||
|
hover.top = hover.top.saturating_add(1).min(hover.line_starts.len() - 1);
|
||||||
|
}
|
||||||
|
Input::Left | Input::Ctrl('h') => hover.left = hover.left.saturating_sub(1),
|
||||||
|
Input::Right | Input::Ctrl('l') => {
|
||||||
|
hover.left = hover.left.saturating_add(1).min(hover.max_column);
|
||||||
|
}
|
||||||
|
Input::Home => hover.left = 0,
|
||||||
|
Input::End => hover.left = hover.max_column,
|
||||||
|
Input::Escape => self.clear_hover(),
|
||||||
|
_ => {
|
||||||
|
self.clear_hover();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn file_picker(&self) -> Option<&FilePicker> {
|
pub fn file_picker(&self) -> Option<&FilePicker> {
|
||||||
self.file_picker.as_ref()
|
self.file_picker.as_ref()
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1450,9 @@ impl Editor {
|
|||||||
|
|
||||||
pub fn context(&self) -> Context {
|
pub fn context(&self) -> Context {
|
||||||
Context {
|
Context {
|
||||||
|
buffer_id: self.active.id,
|
||||||
|
revision: self.active.text_revision,
|
||||||
|
root: self.project_root.to_string_lossy().into_owned(),
|
||||||
text: self.active.lines.join("\n"),
|
text: self.active.lines.join("\n"),
|
||||||
row: self.cursor().row + 1,
|
row: self.cursor().row + 1,
|
||||||
column: self.cursor().column + 1,
|
column: self.cursor().column + 1,
|
||||||
@@ -1169,6 +1525,7 @@ impl Editor {
|
|||||||
self.set_message("Core features cannot be disabled");
|
self.set_message("Core features cannot be disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.clear_plugin_diagnostics(name);
|
||||||
if let Some(feature) = self.features.remove(name) {
|
if let Some(feature) = self.features.remove(name) {
|
||||||
for bindings in self.bindings.values_mut() {
|
for bindings in self.bindings.values_mut() {
|
||||||
bindings.retain(|_, binding| {
|
bindings.retain(|_, binding| {
|
||||||
@@ -1624,6 +1981,9 @@ impl Editor {
|
|||||||
Input::Ctrl(ch) => control_key(ch),
|
Input::Ctrl(ch) => control_key(ch),
|
||||||
input => input,
|
input => input,
|
||||||
};
|
};
|
||||||
|
if self.handle_hover(&input) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let picker_focused = self
|
let picker_focused = self
|
||||||
.file_picker
|
.file_picker
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1747,6 +2107,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn act(&mut self, action: Action) -> Option<Invocation> {
|
fn act(&mut self, action: Action) -> Option<Invocation> {
|
||||||
|
self.clear_hover();
|
||||||
let count = self.count.take().unwrap_or(1);
|
let count = self.count.take().unwrap_or(1);
|
||||||
self.clear_leader();
|
self.clear_leader();
|
||||||
match action {
|
match action {
|
||||||
@@ -1761,6 +2122,9 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
|
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
|
||||||
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
|
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
|
||||||
|
Action::LspDefinition => return self.execute("lsp.definition"),
|
||||||
|
Action::LspHover => return self.execute("lsp.hover"),
|
||||||
|
Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
|
||||||
Action::Insert
|
Action::Insert
|
||||||
| Action::InsertFirstNonblank
|
| Action::InsertFirstNonblank
|
||||||
| Action::AppendLineEnd
|
| Action::AppendLineEnd
|
||||||
@@ -1972,6 +2336,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn park_source(&mut self) {
|
fn park_source(&mut self) {
|
||||||
|
self.clear_hover();
|
||||||
if self.active.mode == Mode::Insert {
|
if self.active.mode == Mode::Insert {
|
||||||
self.finish_insert();
|
self.finish_insert();
|
||||||
}
|
}
|
||||||
@@ -2149,6 +2514,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
||||||
|
self.clear_hover();
|
||||||
self.count = None;
|
self.count = None;
|
||||||
self.clear_leader();
|
self.clear_leader();
|
||||||
self.palette = None;
|
self.palette = None;
|
||||||
@@ -2180,6 +2546,16 @@ impl Editor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
match name {
|
match name {
|
||||||
|
"lsp.definition" | "lsp.hover" | "lsp.diagnostics" if !args.is_empty() => {
|
||||||
|
self.set_message(format!("The {name} command takes no arguments"));
|
||||||
|
}
|
||||||
|
"lsp.definition" | "lsp.hover" | "lsp.diagnostics" => {
|
||||||
|
return Some(Invocation {
|
||||||
|
plugin: "core".into(),
|
||||||
|
command: name.into(),
|
||||||
|
args: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
"w" | "wq" => {
|
"w" | "wq" => {
|
||||||
if self.save(if args.is_empty() {
|
if self.save(if args.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -2256,6 +2632,7 @@ impl Editor {
|
|||||||
})
|
})
|
||||||
.then(|| self.snapshot());
|
.then(|| self.snapshot());
|
||||||
if before.is_some() {
|
if before.is_some() {
|
||||||
|
self.clear_hover();
|
||||||
self.count = None;
|
self.count = None;
|
||||||
self.clear_leader();
|
self.clear_leader();
|
||||||
self.clear_selection();
|
self.clear_selection();
|
||||||
@@ -2267,7 +2644,11 @@ impl Editor {
|
|||||||
match effect {
|
match effect {
|
||||||
Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)),
|
Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)),
|
||||||
Effect::Replace(text) => {
|
Effect::Replace(text) => {
|
||||||
self.active.lines = split_lines(&text);
|
let lines = split_lines(&text);
|
||||||
|
if lines != self.active.lines {
|
||||||
|
self.active.text_changed();
|
||||||
|
}
|
||||||
|
self.active.lines = lines;
|
||||||
self.active.search_match = None;
|
self.active.search_match = None;
|
||||||
self.normalize_targets();
|
self.normalize_targets();
|
||||||
}
|
}
|
||||||
@@ -2320,6 +2701,7 @@ impl Editor {
|
|||||||
match atomic_save(&path, &self.active.lines) {
|
match atomic_save(&path, &self.active.lines) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.active.saved_lines.clone_from(&self.active.lines);
|
self.active.saved_lines.clone_from(&self.active.lines);
|
||||||
|
self.active.save_revision = self.active.save_revision.wrapping_add(1);
|
||||||
self.active.path = Some(path);
|
self.active.path = Some(path);
|
||||||
self.active.identity = Some(identity);
|
self.active.identity = Some(identity);
|
||||||
self.set_message("Written");
|
self.set_message("Written");
|
||||||
@@ -3165,6 +3547,9 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn restore(&mut self, snapshot: Snapshot) {
|
fn restore(&mut self, snapshot: Snapshot) {
|
||||||
|
if self.active.lines != snapshot.lines {
|
||||||
|
self.active.text_changed();
|
||||||
|
}
|
||||||
self.active.lines = snapshot.lines;
|
self.active.lines = snapshot.lines;
|
||||||
self.active.targets = snapshot.targets;
|
self.active.targets = snapshot.targets;
|
||||||
self.active.primary = snapshot.primary;
|
self.active.primary = snapshot.primary;
|
||||||
@@ -3377,6 +3762,7 @@ impl Editor {
|
|||||||
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up);
|
destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up);
|
||||||
}
|
}
|
||||||
if text != old {
|
if text != old {
|
||||||
|
self.active.text_changed();
|
||||||
self.active.search_match = None;
|
self.active.search_match = None;
|
||||||
}
|
}
|
||||||
self.active.lines = text.split('\n').map(str::to_owned).collect();
|
self.active.lines = text.split('\n').map(str::to_owned).collect();
|
||||||
@@ -3666,9 +4052,19 @@ fn word_tokens(
|
|||||||
|
|
||||||
fn valid_command(command: &str) -> bool {
|
fn valid_command(command: &str) -> bool {
|
||||||
let command = command.strip_suffix('!').unwrap_or(command);
|
let command = command.strip_suffix('!').unwrap_or(command);
|
||||||
let mut chars = command.chars();
|
command.split('.').all(|segment| {
|
||||||
chars.next().is_some_and(|ch| ch.is_ascii_alphabetic())
|
let mut chars = segment.chars();
|
||||||
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
|
chars.next().is_some_and(|ch| ch.is_ascii_alphabetic())
|
||||||
|
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> {
|
||||||
|
if text.len() > MAX_EDIT_BYTES {
|
||||||
|
Err("Hover exceeds 4 MiB".into())
|
||||||
|
} else {
|
||||||
|
validate_text(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_paste_text(text: &str) -> Result<(), String> {
|
fn validate_paste_text(text: &str) -> Result<(), String> {
|
||||||
@@ -3868,6 +4264,86 @@ mod tests {
|
|||||||
editor
|
editor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_scroll_and_escape_preserve_carets_and_edits_dismiss_normally() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("alpha\nbeta".into())])
|
||||||
|
.unwrap();
|
||||||
|
set_carets(&mut editor, &[(0, 1), (1, 2)], 1);
|
||||||
|
let targets = editor.active.targets.clone();
|
||||||
|
let primary = editor.active.primary;
|
||||||
|
let text = editor.context().text;
|
||||||
|
let revision = editor.text_revision();
|
||||||
|
let history = (editor.active.undo.len(), editor.active.redo.len());
|
||||||
|
for command in [
|
||||||
|
":lsp_hover",
|
||||||
|
":lsp_definition",
|
||||||
|
":lsp_diagnostics",
|
||||||
|
":lsp.hover unexpected extra",
|
||||||
|
":lsp.definition unexpected extra",
|
||||||
|
":lsp.diagnostics unexpected extra",
|
||||||
|
] {
|
||||||
|
assert!(editor.execute(command).is_none(), "{command}");
|
||||||
|
}
|
||||||
|
editor
|
||||||
|
.bind("normal", "K", Some("core.lsp.hover".into()))
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, "9");
|
||||||
|
assert_eq!(
|
||||||
|
editor.handle(Input::Char('K')),
|
||||||
|
Some(Invocation {
|
||||||
|
plugin: "core".into(),
|
||||||
|
command: "lsp.hover".into(),
|
||||||
|
args: String::new(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
editor
|
||||||
|
.show_hover("界e\u{301}👩🚀\tend\r\nsecond\nlast".into())
|
||||||
|
.unwrap();
|
||||||
|
assert!(editor.show_hover("\u{1b}[31m".into()).is_err());
|
||||||
|
assert_eq!(editor.hover().unwrap().lines().nth(1), Some("second"));
|
||||||
|
for input in [
|
||||||
|
Input::Down,
|
||||||
|
Input::Ctrl('j'),
|
||||||
|
Input::Down,
|
||||||
|
Input::Right,
|
||||||
|
Input::Ctrl('l'),
|
||||||
|
] {
|
||||||
|
assert_eq!(editor.handle(input), None);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
(editor.hover().unwrap().top, editor.hover().unwrap().left),
|
||||||
|
(2, 2)
|
||||||
|
);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert!(editor.hover().is_none());
|
||||||
|
assert_eq!(editor.active.targets, targets);
|
||||||
|
assert_eq!(editor.active.primary, primary);
|
||||||
|
assert_eq!(editor.context().text, text);
|
||||||
|
assert_eq!(editor.text_revision(), revision);
|
||||||
|
assert_eq!(
|
||||||
|
(editor.active.undo.len(), editor.active.redo.len()),
|
||||||
|
history
|
||||||
|
);
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.selection_count(), 1);
|
||||||
|
editor.show_hover("documentation".into()).unwrap();
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
assert!(editor.hover().is_none());
|
||||||
|
assert_eq!(editor.mode(), Mode::Insert);
|
||||||
|
editor.handle(Input::Char('X'));
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.context().text, "alpha\nbeXta");
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, text);
|
||||||
|
editor.show_hover("documentation".into()).unwrap();
|
||||||
|
editor.apply(vec![Effect::Replace("new".into())]).unwrap();
|
||||||
|
assert!(editor.hover().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod system_clipboard {
|
mod system_clipboard {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -9058,6 +9534,20 @@ mod tests {
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert!(editor.register(plugin("invalid", &["bad name"])).is_err());
|
assert!(editor.register(plugin("invalid", &["bad name"])).is_err());
|
||||||
|
for name in [
|
||||||
|
".hover",
|
||||||
|
"lsp.",
|
||||||
|
"lsp..hover",
|
||||||
|
"lsp.3hover",
|
||||||
|
"lsp.hover!extra",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.register(plugin("invalid", &["newcmd", name]))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err());
|
||||||
|
}
|
||||||
assert!(editor.register(plugin("", &["emptyname"])).is_err());
|
assert!(editor.register(plugin("", &["emptyname"])).is_err());
|
||||||
let mut feature = plugin("forged", &["override"]);
|
let mut feature = plugin("forged", &["override"]);
|
||||||
feature.core = true;
|
feature.core = true;
|
||||||
@@ -9397,4 +9887,149 @@ mod tests {
|
|||||||
assert_eq!(fs::read_to_string(&path).unwrap(), "changed");
|
assert_eq!(fs::read_to_string(&path).unwrap(), "changed");
|
||||||
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn annotation(start: (usize, usize), end: (usize, usize), message: &str) -> Diagnostic {
|
||||||
|
Diagnostic {
|
||||||
|
start: TextPosition {
|
||||||
|
row: start.0,
|
||||||
|
column: start.1,
|
||||||
|
},
|
||||||
|
end: TextPosition {
|
||||||
|
row: end.0,
|
||||||
|
column: end.1,
|
||||||
|
},
|
||||||
|
severity: crate::plugin::DiagnosticSeverity::Warning,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotations_are_owner_scoped_atomic_and_grapheme_bounded() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("a😀e\u{301}\n".into())])
|
||||||
|
.unwrap();
|
||||||
|
let id = editor.buffer_id();
|
||||||
|
let revision = editor.text_revision();
|
||||||
|
let first = annotation((0, 1), (0, 3), "first");
|
||||||
|
editor
|
||||||
|
.replace_diagnostics("one", id, revision, vec![first.clone()])
|
||||||
|
.unwrap();
|
||||||
|
editor
|
||||||
|
.replace_diagnostics("two", id, revision, vec![annotation((1, 0), (1, 0), "end")])
|
||||||
|
.unwrap();
|
||||||
|
for invalid in [
|
||||||
|
annotation((0, 0), (0, 4), "scalar column"),
|
||||||
|
annotation((0, 3), (0, 2), "reversed"),
|
||||||
|
annotation((2, 0), (2, 0), "missing row"),
|
||||||
|
annotation((0, 0), (0, 1), "control\u{1b}"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.replace_diagnostics("one", id, revision, vec![first.clone(), invalid])
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(editor.diagnostics().count(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
editor.active.diagnostics["one"].items.as_slice(),
|
||||||
|
std::slice::from_ref(&first)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!editor
|
||||||
|
.replace_diagnostics(
|
||||||
|
"one",
|
||||||
|
id,
|
||||||
|
revision - 1,
|
||||||
|
vec![annotation((99, 0), (99, 0), "stale")]
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!editor
|
||||||
|
.replace_diagnostics("one", 999, revision, vec![first.clone()])
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
editor.active.targets = vec![Target::caret(Cursor { row: 0, column: 1 })];
|
||||||
|
assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "first");
|
||||||
|
editor.active.targets = vec![Target::caret(Cursor { row: 1, column: 0 })];
|
||||||
|
assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "end");
|
||||||
|
editor.clear_plugin_diagnostics("one");
|
||||||
|
assert_eq!(editor.diagnostics().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotations_do_not_split_typing_undo_and_clear_on_text_change() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
editor.handle(Input::Char('a'));
|
||||||
|
let revision = editor.text_revision();
|
||||||
|
editor
|
||||||
|
.replace_diagnostics(
|
||||||
|
"one",
|
||||||
|
editor.buffer_id(),
|
||||||
|
revision,
|
||||||
|
vec![annotation((0, 0), (0, 1), "typed")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(editor.active.insert_start.is_some());
|
||||||
|
assert!(editor.active.undo.is_empty());
|
||||||
|
editor.handle(Input::Char('b'));
|
||||||
|
assert_eq!(editor.diagnostics().count(), 0);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.lines(), [""]);
|
||||||
|
assert!(editor.active.undo.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_reuses_dirty_hidden_text_and_failure_preserves_source() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let source = directory.path("source");
|
||||||
|
let target = directory.path("target");
|
||||||
|
fs::write(&source, "source").unwrap();
|
||||||
|
fs::write(&target, "a😀e\u{301}\n").unwrap();
|
||||||
|
let mut editor = Editor::open(Some(source.clone()), vec!["text".into()]).unwrap();
|
||||||
|
editor.set_clipboard_mode("separate").unwrap();
|
||||||
|
editor.execute(&format!("edit {}", target.display()));
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("dirty😀e\u{301}\n".into())])
|
||||||
|
.unwrap();
|
||||||
|
let target_id = editor.buffer_id();
|
||||||
|
editor.execute("buffer 1");
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
editor.handle(Input::Char('!'));
|
||||||
|
let before = editor.context().text;
|
||||||
|
let cursor = editor.cursor();
|
||||||
|
let undo = editor.active.undo.len();
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.navigate_to(
|
||||||
|
directory.path("missing"),
|
||||||
|
TextPosition { row: 0, column: 0 }
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(editor.buffer_id(), 1);
|
||||||
|
assert_eq!(editor.context().text, before);
|
||||||
|
assert_eq!(editor.cursor(), cursor);
|
||||||
|
assert_eq!(editor.mode(), Mode::Insert);
|
||||||
|
assert_eq!(editor.active.undo.len(), undo);
|
||||||
|
assert!(editor.active.insert_start.is_some());
|
||||||
|
editor
|
||||||
|
.navigate_to(
|
||||||
|
target,
|
||||||
|
TextPosition {
|
||||||
|
row: 99,
|
||||||
|
column: 99,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(editor.buffer_id(), target_id);
|
||||||
|
assert_eq!(editor.lines(), ["dirty😀e\u{301}", ""]);
|
||||||
|
assert_eq!(editor.buffer_count(), 2);
|
||||||
|
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
assert_eq!(fs::read_to_string(source).unwrap(), "source");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod lsp;
|
||||||
|
mod lsp_config;
|
||||||
|
pub mod plugin;
|
||||||
pub mod ruby;
|
pub mod ruby;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod syntax;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
|
|||||||
+2024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
|||||||
|
use crate::lsp::ServerConfig;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
|
||||||
|
let Value::Object(languages) = value else {
|
||||||
|
return Err("LSP configuration must be a language-keyed object".into());
|
||||||
|
};
|
||||||
|
let mut servers = Vec::new();
|
||||||
|
for (language_id, value) in languages {
|
||||||
|
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")),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut options = match value {
|
||||||
|
Value::Bool(false) => continue,
|
||||||
|
Value::Bool(true) => {
|
||||||
|
if preset.is_none() {
|
||||||
|
return Err(format!(
|
||||||
|
"LSP language {language_id:?} has no built-in preset; provide command and extensions"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
serde_json::Map::new()
|
||||||
|
}
|
||||||
|
Value::Object(options) => options,
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"LSP language {language_id:?} must be true, false, or a configuration object"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for key in options.keys() {
|
||||||
|
if !matches!(
|
||||||
|
key.as_str(),
|
||||||
|
"command" | "extensions" | "initialization_options" | "settings"
|
||||||
|
) {
|
||||||
|
return Err(format!(
|
||||||
|
"LSP language {language_id:?} has unknown option {key:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let command = match options.remove("command") {
|
||||||
|
Some(value) => command(value, &language_id)?,
|
||||||
|
None => match preset {
|
||||||
|
Some((command, _)) => vec![command.into()],
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"LSP language {language_id:?} requires command argv"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let extensions = match options.remove("extensions") {
|
||||||
|
Some(value) => extensions(value, &language_id)?,
|
||||||
|
None => match preset {
|
||||||
|
Some((_, extension)) => vec![extension.into()],
|
||||||
|
None => return Err(format!("LSP language {language_id:?} requires extensions")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let initialization_options = object(
|
||||||
|
options.remove("initialization_options"),
|
||||||
|
&language_id,
|
||||||
|
"initialization_options",
|
||||||
|
)?;
|
||||||
|
let settings = object(options.remove("settings"), &language_id, "settings")?;
|
||||||
|
servers.push(ServerConfig {
|
||||||
|
language_id,
|
||||||
|
command,
|
||||||
|
extensions,
|
||||||
|
initialization_options,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
if servers.len() > 16 {
|
||||||
|
return Err("LSP configuration supports at most 16 enabled languages".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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!(
|
||||||
|
"LSP language {language:?} command must be nonempty argv with a nonempty executable and no NUL bytes"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let Value::Array(values) = value else {
|
||||||
|
return Err(error());
|
||||||
|
};
|
||||||
|
if values.is_empty() {
|
||||||
|
return Err(error());
|
||||||
|
}
|
||||||
|
let mut command = Vec::with_capacity(values.len());
|
||||||
|
for value in values {
|
||||||
|
let Value::String(argument) = value else {
|
||||||
|
return Err(error());
|
||||||
|
};
|
||||||
|
if argument.contains('\0') || (command.is_empty() && argument.is_empty()) {
|
||||||
|
return Err(error());
|
||||||
|
}
|
||||||
|
command.push(argument);
|
||||||
|
}
|
||||||
|
Ok(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extensions(value: Value, language: &str) -> Result<Vec<String>, String> {
|
||||||
|
let error = || {
|
||||||
|
format!(
|
||||||
|
"LSP language {language:?} extensions must be a nonempty array of suffixes without dots, separators, whitespace, or control characters"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let Value::Array(values) = value else {
|
||||||
|
return Err(error());
|
||||||
|
};
|
||||||
|
if values.is_empty() {
|
||||||
|
return Err(error());
|
||||||
|
}
|
||||||
|
let mut extensions = Vec::with_capacity(values.len());
|
||||||
|
for value in values {
|
||||||
|
let Value::String(extension) = value else {
|
||||||
|
return Err(error());
|
||||||
|
};
|
||||||
|
if extension.is_empty()
|
||||||
|
|| extension.chars().any(|character| {
|
||||||
|
matches!(character, '.' | '/' | '\\')
|
||||||
|
|| character.is_whitespace()
|
||||||
|
|| character.is_control()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(error());
|
||||||
|
}
|
||||||
|
extensions.push(extension);
|
||||||
|
}
|
||||||
|
Ok(extensions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(value: Option<Value>, language: &str, option: &str) -> Result<Value, String> {
|
||||||
|
match value {
|
||||||
|
None => Ok(Value::Object(serde_json::Map::new())),
|
||||||
|
Some(value) if value.is_object() => Ok(value),
|
||||||
|
Some(_) => Err(format!(
|
||||||
|
"LSP language {language:?} {option} must be a JSON object"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -34,7 +34,12 @@ Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
|||||||
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
||||||
|
|
||||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||||
Set VAMP_RUBY to choose the Ruby executable.";
|
Set VAMP_RUBY to choose the Ruby executable.
|
||||||
|
|
||||||
|
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
|
||||||
|
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)";
|
||||||
|
|
||||||
struct Options {
|
struct Options {
|
||||||
file: Option<PathBuf>,
|
file: Option<PathBuf>,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct DocumentSnapshot {
|
||||||
|
pub id: usize,
|
||||||
|
pub revision: u64,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub root: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DocumentEventKind {
|
||||||
|
Open,
|
||||||
|
Change,
|
||||||
|
Save,
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct DocumentEvent {
|
||||||
|
pub event: DocumentEventKind,
|
||||||
|
pub document: DocumentSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DiagnosticSeverity {
|
||||||
|
Error,
|
||||||
|
Warning,
|
||||||
|
Information,
|
||||||
|
Hint,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero-based extended-grapheme positions, independent of any server encoding.
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TextPosition {
|
||||||
|
pub row: usize,
|
||||||
|
pub column: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Diagnostic {
|
||||||
|
pub start: TextPosition,
|
||||||
|
pub end: TextPosition,
|
||||||
|
pub severity: DiagnosticSeverity,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum UpdateEffect {
|
||||||
|
Diagnostics {
|
||||||
|
buffer_id: usize,
|
||||||
|
revision: u64,
|
||||||
|
items: Vec<Diagnostic>,
|
||||||
|
},
|
||||||
|
Navigate {
|
||||||
|
buffer_id: usize,
|
||||||
|
revision: u64,
|
||||||
|
path: PathBuf,
|
||||||
|
row: usize,
|
||||||
|
column: usize,
|
||||||
|
},
|
||||||
|
Hover {
|
||||||
|
buffer_id: usize,
|
||||||
|
revision: u64,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Message {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PluginUpdate {
|
||||||
|
pub request_id: Option<u64>,
|
||||||
|
pub effects: Vec<UpdateEffect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PluginEvent {
|
||||||
|
Response {
|
||||||
|
id: u64,
|
||||||
|
effects: Vec<crate::editor::Effect>,
|
||||||
|
},
|
||||||
|
Update(PluginUpdate),
|
||||||
|
}
|
||||||
+965
-54
File diff suppressed because it is too large
Load Diff
+189
-33
@@ -8,6 +8,7 @@ protocol_in = STDIN.dup
|
|||||||
protocol_in.binmode
|
protocol_in.binmode
|
||||||
STDOUT.reopen(STDERR)
|
STDOUT.reopen(STDERR)
|
||||||
$stdout = STDERR
|
$stdout = STDERR
|
||||||
|
Thread.abort_on_exception = true
|
||||||
|
|
||||||
module Vamp
|
module Vamp
|
||||||
MAX_FRAME = 4 * 1024 * 1024
|
MAX_FRAME = 4 * 1024 * 1024
|
||||||
@@ -18,24 +19,69 @@ module Vamp
|
|||||||
value.dup.force_encoding(Encoding::UTF_8)
|
value.dup.force_encoding(Encoding::UTF_8)
|
||||||
end
|
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)
|
Action = Struct.new(:name)
|
||||||
Mode = Struct.new(:name)
|
Mode = Struct.new(:name)
|
||||||
|
|
||||||
module Core
|
Core = Module.new
|
||||||
%w[
|
|
||||||
insert insert_first_nonblank append append_line_end open_below open_above
|
def self.install_actions(names)
|
||||||
left right down up join_lines line_start line_end delete undo redo command
|
raise TypeError, "core action catalog must be an Array" unless names.is_a?(Array)
|
||||||
normal newline backspace submit palette repeat_last_edit
|
namespaces = [Core]
|
||||||
search search_next search_previous
|
names.each do |name|
|
||||||
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
name = string!(name, "core action name")
|
||||||
select copy cut change paste_before paste_after
|
unless name.match?(/\Acore(?:\.[a-z_][a-z0-9_]*)+\z/)
|
||||||
copy_system paste_system_before paste_system_after
|
raise ArgumentError, "invalid core action name: #{name}"
|
||||||
select_lines clone_above clone_below select_next skip_occurrence select_all
|
end
|
||||||
file_picker file_explorer
|
parts = name.split(".").drop(1)
|
||||||
].each do |name|
|
namespace = Core
|
||||||
action = Action.new("core.#{name}".freeze).freeze
|
parts[0...-1].each do |part|
|
||||||
define_singleton_method(name) { action }
|
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
|
end
|
||||||
|
namespaces.each(&:freeze)
|
||||||
end
|
end
|
||||||
|
|
||||||
module Modes
|
module Modes
|
||||||
@@ -49,8 +95,11 @@ module Vamp
|
|||||||
Mode.private_class_method :new
|
Mode.private_class_method :new
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
|
attr_reader :lsp
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@plugins = []
|
@plugins = []
|
||||||
|
@lsp = {}
|
||||||
@bindings = []
|
@bindings = []
|
||||||
@leader = "space"
|
@leader = "space"
|
||||||
@file_explorer_side = "right"
|
@file_explorer_side = "right"
|
||||||
@@ -59,10 +108,11 @@ module Vamp
|
|||||||
@clipboard_mode = "system"
|
@clipboard_mode = "system"
|
||||||
end
|
end
|
||||||
|
|
||||||
def plugin(path)
|
def plugin(path, options: {})
|
||||||
path = Vamp.string!(path, "plugin path")
|
path = Vamp.string!(path, "plugin path")
|
||||||
raise ArgumentError, "plugin path must not be empty" if path.empty?
|
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
|
||||||
@plugins << path
|
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
||||||
|
@plugins << { "path" => path, "options" => Vamp.json_value!(options) }
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -126,8 +176,23 @@ module Vamp
|
|||||||
end
|
end
|
||||||
|
|
||||||
def response
|
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,
|
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
||||||
"bindings" => @bindings, "leader" => @leader,
|
"lsp" => lsp, "bindings" => @bindings, "leader" => @leader,
|
||||||
"file_explorer_side" => @file_explorer_side,
|
"file_explorer_side" => @file_explorer_side,
|
||||||
"file_picker_view" => @file_picker_view,
|
"file_picker_view" => @file_picker_view,
|
||||||
"file_explorer_view" => @file_explorer_view,
|
"file_explorer_view" => @file_explorer_view,
|
||||||
@@ -136,20 +201,27 @@ module Vamp
|
|||||||
end
|
end
|
||||||
|
|
||||||
class Editor
|
class Editor
|
||||||
attr_reader :text, :row, :column, :mode, :path, :effects
|
attr_reader :text, :row, :column, :mode, :path, :effects, :buffer_id, :revision, :root, :request_id
|
||||||
|
|
||||||
def initialize(context)
|
def initialize(context, request_id)
|
||||||
unless context.is_a?(Hash) && context.keys.sort == %w[column mode path row text]
|
unless context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
||||||
raise ArgumentError, "invalid editor context"
|
raise ArgumentError, "invalid editor context"
|
||||||
end
|
end
|
||||||
@text = Vamp.string!(context["text"], "context text").freeze
|
@text = Vamp.string!(context["text"], "context text").freeze
|
||||||
@mode = Vamp.string!(context["mode"], "context mode").freeze
|
@mode = Vamp.string!(context["mode"], "context mode").freeze
|
||||||
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").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"]
|
@row = context["row"]
|
||||||
@column = context["column"]
|
@column = context["column"]
|
||||||
unless @row.is_a?(Integer) && @row > 0 && @column.is_a?(Integer) && @column > 0
|
unless @row.is_a?(Integer) && @row > 0 && @column.is_a?(Integer) && @column > 0
|
||||||
raise ArgumentError, "context row and column must be positive integers"
|
raise ArgumentError, "context row and column must be positive integers"
|
||||||
end
|
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 = []
|
@effects = []
|
||||||
@effect_bytes = 0
|
@effect_bytes = 0
|
||||||
end
|
end
|
||||||
@@ -177,12 +249,26 @@ module Vamp
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.setup(mode)
|
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"
|
||||||
|
end
|
||||||
|
install_actions(startup["core_actions"])
|
||||||
|
options = startup["options"]
|
||||||
@mode = mode
|
@mode = mode
|
||||||
@config = Config.new
|
@config = Config.new
|
||||||
@name = nil
|
@name = nil
|
||||||
@requires = []
|
@requires = []
|
||||||
@commands = {}
|
@commands = {}
|
||||||
|
@async = false
|
||||||
|
@document_callback = nil
|
||||||
|
@shutdown_callback = nil
|
||||||
|
@options = deep_freeze(json_value!(options))
|
||||||
|
@sender = sender
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.options
|
||||||
|
@options
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.configure
|
def self.configure
|
||||||
@@ -191,7 +277,7 @@ module Vamp
|
|||||||
yield @config
|
yield @config
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.plugin(name, requires: [])
|
def self.plugin(name, requires: [], async: false)
|
||||||
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
|
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
|
||||||
raise ArgumentError, "plugin already declared" unless @name.nil?
|
raise ArgumentError, "plugin already declared" unless @name.nil?
|
||||||
name = string!(name, "plugin name")
|
name = string!(name, "plugin name")
|
||||||
@@ -199,6 +285,8 @@ module Vamp
|
|||||||
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
|
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
|
||||||
@requires = requires.map { |value| string!(value, "capability") }
|
@requires = requires.map { |value| string!(value, "capability") }
|
||||||
raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?)
|
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
|
@name = name
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -213,11 +301,62 @@ module Vamp
|
|||||||
nil
|
nil
|
||||||
end
|
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"
|
||||||
|
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
|
def self.startup
|
||||||
return @config.response if @mode == "config"
|
return @config.response if @mode == "config"
|
||||||
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
|
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
|
||||||
{ "version" => 1, "type" => "manifest", "name" => @name,
|
{ "version" => 1, "type" => "manifest", "name" => @name,
|
||||||
"requires" => @requires, "commands" => @commands.keys }
|
"requires" => @requires, "commands" => @commands.keys, "async" => @async }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.invoke(request)
|
def self.invoke(request)
|
||||||
@@ -230,24 +369,28 @@ module Vamp
|
|||||||
args = string!(request["args"], "args")
|
args = string!(request["args"], "args")
|
||||||
callback = @commands[command]
|
callback = @commands[command]
|
||||||
raise ArgumentError, "unknown command #{command}" unless callback
|
raise ArgumentError, "unknown command #{command}" unless callback
|
||||||
editor = Editor.new(request["context"])
|
editor = Editor.new(request["context"], request["id"])
|
||||||
callback.call(editor, args)
|
callback.call(editor, args)
|
||||||
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
|
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
protocol_mutex = Mutex.new
|
||||||
send_frame = lambda do |frame|
|
send_frame = lambda do |frame|
|
||||||
payload = JSON.generate(frame)
|
protocol_mutex.synchronize do
|
||||||
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
|
payload = JSON.generate(frame)
|
||||||
protocol_out.write(payload)
|
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
|
||||||
protocol_out.write("\n")
|
protocol_out.write(payload)
|
||||||
|
protocol_out.write("\n")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
request_id = nil
|
request_id = nil
|
||||||
begin
|
begin
|
||||||
mode, path = ARGV
|
mode, path, encoded_startup = ARGV
|
||||||
raise ArgumentError, "expected config or plugin mode and a path" unless %w[config plugin].include?(mode) && path
|
raise ArgumentError, "expected config or plugin mode, path, and startup data" unless %w[config plugin].include?(mode) && path && encoded_startup
|
||||||
Vamp.setup(mode)
|
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
|
load path
|
||||||
send_frame.call(Vamp.startup)
|
send_frame.call(Vamp.startup)
|
||||||
if mode == "plugin"
|
if mode == "plugin"
|
||||||
@@ -260,9 +403,22 @@ begin
|
|||||||
end
|
end
|
||||||
request = JSON.parse(line)
|
request = JSON.parse(line)
|
||||||
request_id = request["id"] if request.is_a?(Hash) && request["id"].is_a?(Integer) && request["id"] > 0
|
request_id = request["id"] if request.is_a?(Hash) && request["id"].is_a?(Integer) && request["id"] > 0
|
||||||
send_frame.call(Vamp.invoke(request))
|
case request.is_a?(Hash) && request["type"]
|
||||||
|
when "invoke"
|
||||||
|
send_frame.call(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
|
||||||
|
else
|
||||||
|
raise ArgumentError, "unknown protocol frame"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
Vamp.shutdown
|
||||||
rescue Exception => error
|
rescue Exception => error
|
||||||
# No callback effects are emitted when loading or invoking raises.
|
# No callback effects are emitted when loading or invoking raises.
|
||||||
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
||||||
|
|||||||
+967
-14
File diff suppressed because it is too large
Load Diff
+1539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,422 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub(super) struct LanguageSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub language: tree_sitter::Language,
|
||||||
|
pub highlights: &'static str,
|
||||||
|
pub injections: &'static str,
|
||||||
|
pub locals: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
// These grammars extend the upstream base queries, rather than replacing them.
|
||||||
|
static JS_HIGHLIGHTS: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
tree_sitter_javascript::HIGHLIGHT_QUERY,
|
||||||
|
tree_sitter_javascript::JSX_HIGHLIGHT_QUERY,
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
});
|
||||||
|
static TS_HIGHLIGHTS: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
tree_sitter_javascript::HIGHLIGHT_QUERY,
|
||||||
|
tree_sitter_typescript::HIGHLIGHTS_QUERY,
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
});
|
||||||
|
static TSX_HIGHLIGHTS: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
JS_HIGHLIGHTS.as_str(),
|
||||||
|
tree_sitter_typescript::HIGHLIGHTS_QUERY,
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
});
|
||||||
|
static TS_LOCALS: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
tree_sitter_javascript::LOCALS_QUERY,
|
||||||
|
tree_sitter_typescript::LOCALS_QUERY,
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
});
|
||||||
|
static CPP_HIGHLIGHTS: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
tree_sitter_c::HIGHLIGHT_QUERY,
|
||||||
|
tree_sitter_cpp::HIGHLIGHT_QUERY,
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
});
|
||||||
|
|
||||||
|
// The MIT-licensed upstream C++ crate bundles, but does not export, injections.scm.
|
||||||
|
// This minimal AST query uses its raw-string delimiter/content nodes:
|
||||||
|
// https://github.com/tree-sitter/tree-sitter-cpp/blob/v0.23.4/queries/injections.scm
|
||||||
|
const CPP_INJECTIONS: &str = "
|
||||||
|
(raw_string_literal
|
||||||
|
delimiter: (raw_string_delimiter) @injection.language
|
||||||
|
(raw_string_content) @injection.content)
|
||||||
|
";
|
||||||
|
|
||||||
|
fn javascript_injections() -> &'static str {
|
||||||
|
// The trailing upstream hbs/@glimmer rule uses a nonstandard #offset! directive,
|
||||||
|
// not injection.* captures. Glimmer is outside the selected language set;
|
||||||
|
// retain all standard tagged-template, regex and JSDoc injection queries.
|
||||||
|
let query = tree_sitter_javascript::INJECTIONS_QUERY;
|
||||||
|
query
|
||||||
|
.split_once("\n; Parse Ember/")
|
||||||
|
.map_or(query, |(query, _)| query)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spec(
|
||||||
|
name: &'static str,
|
||||||
|
language: tree_sitter::Language,
|
||||||
|
highlights: &'static str,
|
||||||
|
injections: &'static str,
|
||||||
|
locals: &'static str,
|
||||||
|
) -> LanguageSpec {
|
||||||
|
LanguageSpec {
|
||||||
|
name,
|
||||||
|
language,
|
||||||
|
highlights,
|
||||||
|
injections,
|
||||||
|
locals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
spec(
|
||||||
|
"Rust",
|
||||||
|
tree_sitter_rust::LANGUAGE.into(),
|
||||||
|
tree_sitter_rust::HIGHLIGHTS_QUERY,
|
||||||
|
tree_sitter_rust::INJECTIONS_QUERY,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"Ruby",
|
||||||
|
tree_sitter_ruby::LANGUAGE.into(),
|
||||||
|
tree_sitter_ruby::HIGHLIGHTS_QUERY,
|
||||||
|
"",
|
||||||
|
tree_sitter_ruby::LOCALS_QUERY,
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"Python",
|
||||||
|
tree_sitter_python::LANGUAGE.into(),
|
||||||
|
tree_sitter_python::HIGHLIGHTS_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"JSON",
|
||||||
|
tree_sitter_json::LANGUAGE.into(),
|
||||||
|
tree_sitter_json::HIGHLIGHTS_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"Bash",
|
||||||
|
tree_sitter_bash::LANGUAGE.into(),
|
||||||
|
tree_sitter_bash::HIGHLIGHT_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"Makefile",
|
||||||
|
tree_sitter_make::LANGUAGE.into(),
|
||||||
|
tree_sitter_make::HIGHLIGHTS_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"Markdown",
|
||||||
|
tree_sitter_md::LANGUAGE.into(),
|
||||||
|
tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
|
||||||
|
tree_sitter_md::INJECTION_QUERY_BLOCK,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"JavaScript",
|
||||||
|
tree_sitter_javascript::LANGUAGE.into(),
|
||||||
|
JS_HIGHLIGHTS.as_str(),
|
||||||
|
javascript_injections(),
|
||||||
|
tree_sitter_javascript::LOCALS_QUERY,
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"TypeScript",
|
||||||
|
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
|
||||||
|
TS_HIGHLIGHTS.as_str(),
|
||||||
|
javascript_injections(),
|
||||||
|
TS_LOCALS.as_str(),
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"HTML",
|
||||||
|
tree_sitter_html::LANGUAGE.into(),
|
||||||
|
tree_sitter_html::HIGHLIGHTS_QUERY,
|
||||||
|
tree_sitter_html::INJECTIONS_QUERY,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"CSS",
|
||||||
|
tree_sitter_css::LANGUAGE.into(),
|
||||||
|
tree_sitter_css::HIGHLIGHTS_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"YAML",
|
||||||
|
tree_sitter_yaml::LANGUAGE.into(),
|
||||||
|
tree_sitter_yaml::HIGHLIGHTS_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"TOML",
|
||||||
|
tree_sitter_toml_ng::LANGUAGE.into(),
|
||||||
|
tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"C",
|
||||||
|
tree_sitter_c::LANGUAGE.into(),
|
||||||
|
tree_sitter_c::HIGHLIGHT_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"C++",
|
||||||
|
tree_sitter_cpp::LANGUAGE.into(),
|
||||||
|
CPP_HIGHLIGHTS.as_str(),
|
||||||
|
CPP_INJECTIONS,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"Go",
|
||||||
|
tree_sitter_go::LANGUAGE.into(),
|
||||||
|
tree_sitter_go::HIGHLIGHTS_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"TypeScript",
|
||||||
|
tree_sitter_typescript::LANGUAGE_TSX.into(),
|
||||||
|
TSX_HIGHLIGHTS.as_str(),
|
||||||
|
javascript_injections(),
|
||||||
|
TS_LOCALS.as_str(),
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
"Markdown inline",
|
||||||
|
tree_sitter_md::INLINE_LANGUAGE.into(),
|
||||||
|
tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
|
||||||
|
tree_sitter_md::INJECTION_QUERY_INLINE,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
pub(super) fn all() -> impl Iterator<Item = &'static LanguageSpec> {
|
||||||
|
LANGUAGES.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn by_name(name: &str) -> Option<&'static LanguageSpec> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.eq_ignore_ascii_case("tsx") {
|
||||||
|
return Some(&LANGUAGES[16]);
|
||||||
|
}
|
||||||
|
if name.eq_ignore_ascii_case("markdown_inline") || name.eq_ignore_ascii_case("markdown-inline")
|
||||||
|
{
|
||||||
|
return Some(&LANGUAGES[17]);
|
||||||
|
}
|
||||||
|
const ALIASES: &[(&str, &str)] = &[
|
||||||
|
("rs", "Rust"),
|
||||||
|
("rb", "Ruby"),
|
||||||
|
("py", "Python"),
|
||||||
|
("sh", "Bash"),
|
||||||
|
("shell", "Bash"),
|
||||||
|
("shellscript", "Bash"),
|
||||||
|
("make", "Makefile"),
|
||||||
|
("mk", "Makefile"),
|
||||||
|
("md", "Markdown"),
|
||||||
|
("js", "JavaScript"),
|
||||||
|
("jsx", "JavaScript"),
|
||||||
|
("node", "JavaScript"),
|
||||||
|
("ecmascript", "JavaScript"),
|
||||||
|
("ts", "TypeScript"),
|
||||||
|
("htm", "HTML"),
|
||||||
|
("xhtml", "HTML"),
|
||||||
|
("yml", "YAML"),
|
||||||
|
("cc", "C++"),
|
||||||
|
("cpp", "C++"),
|
||||||
|
("cxx", "C++"),
|
||||||
|
("golang", "Go"),
|
||||||
|
];
|
||||||
|
let name = ALIASES
|
||||||
|
.iter()
|
||||||
|
.find(|(alias, _)| alias.eq_ignore_ascii_case(name))
|
||||||
|
.map_or(name, |(_, name)| *name);
|
||||||
|
all().find(|spec| spec.name.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn detect(
|
||||||
|
path: Option<&Path>,
|
||||||
|
first_line: Option<&str>,
|
||||||
|
) -> Option<&'static LanguageSpec> {
|
||||||
|
path.and_then(detect_path)
|
||||||
|
.or_else(|| first_line.and_then(detect_shebang))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_path(path: &Path) -> Option<&'static LanguageSpec> {
|
||||||
|
let filename = path.file_name()?.to_str()?;
|
||||||
|
let language = match filename {
|
||||||
|
"Makefile" | "makefile" | "GNUmakefile" | "BSDmakefile" => "Makefile",
|
||||||
|
"Gemfile" | "Rakefile" | "Guardfile" | "Vagrantfile" | "Brewfile" | "Podfile"
|
||||||
|
| "Fastfile" | "Appfile" | "Berksfile" | ".irbrc" => "Ruby",
|
||||||
|
"SConstruct" | "SConscript" => "Python",
|
||||||
|
".bashrc" | ".bash_profile" | ".bash_login" | ".bash_logout" | ".profile" => "Bash",
|
||||||
|
"Cargo.lock" | "Pipfile" | "poetry.lock" | "uv.lock" => "TOML",
|
||||||
|
_ => {
|
||||||
|
let extension = path.extension()?.to_str()?;
|
||||||
|
if matches!(extension, "C" | "H") {
|
||||||
|
return by_name("C++");
|
||||||
|
}
|
||||||
|
let extension = if extension.bytes().any(|byte| byte.is_ascii_uppercase()) {
|
||||||
|
Cow::Owned(extension.to_ascii_lowercase())
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(extension)
|
||||||
|
};
|
||||||
|
match extension.as_ref() {
|
||||||
|
"rs" => "Rust",
|
||||||
|
"rb" | "rake" | "gemspec" => "Ruby",
|
||||||
|
"py" | "pyw" | "pyi" => "Python",
|
||||||
|
"json" => "JSON",
|
||||||
|
"sh" | "bash" => "Bash",
|
||||||
|
"mk" | "mak" | "make" => "Makefile",
|
||||||
|
"md" | "markdown" | "mdown" | "mkd" | "mdwn" => "Markdown",
|
||||||
|
"js" | "jsx" | "mjs" | "cjs" => "JavaScript",
|
||||||
|
"ts" | "mts" | "cts" => "TypeScript",
|
||||||
|
"tsx" => "tsx",
|
||||||
|
"html" | "htm" | "xhtml" => "HTML",
|
||||||
|
"css" => "CSS",
|
||||||
|
"yaml" | "yml" => "YAML",
|
||||||
|
"toml" => "TOML",
|
||||||
|
"c" | "h" => "C",
|
||||||
|
"cc" | "cpp" | "cxx" | "c++" | "hh" | "hpp" | "hxx" | "h++" | "ipp" | "inl"
|
||||||
|
| "tpp" => "C++",
|
||||||
|
"go" => "Go",
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
by_name(language)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_shebang(line: &str) -> Option<&'static LanguageSpec> {
|
||||||
|
let line = line
|
||||||
|
.strip_prefix('\u{feff}')
|
||||||
|
.unwrap_or(line)
|
||||||
|
.strip_prefix("#!")?;
|
||||||
|
let mut words = line.split_whitespace();
|
||||||
|
let mut program = words.next()?;
|
||||||
|
if Path::new(program).file_name()?.to_str()? == "env" {
|
||||||
|
program = loop {
|
||||||
|
let word = words.next()?;
|
||||||
|
if matches!(word, "-u" | "--unset" | "-C" | "--chdir" | "--argv0") {
|
||||||
|
words.next()?;
|
||||||
|
} else if !word.starts_with('-') && !word.contains('=') {
|
||||||
|
break word;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let program = Path::new(program).file_name()?.to_str()?;
|
||||||
|
for (prefix, language) in [("python", "Python"), ("ruby", "Ruby")] {
|
||||||
|
if let Some(suffix) = program.strip_prefix(prefix)
|
||||||
|
&& (suffix.is_empty()
|
||||||
|
|| (suffix.starts_with(|c: char| c.is_ascii_digit())
|
||||||
|
&& suffix.chars().all(|c| c.is_ascii_digit() || c == '.')))
|
||||||
|
{
|
||||||
|
return by_name(language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match program {
|
||||||
|
"sh" | "bash" => by_name("Bash"),
|
||||||
|
"node" | "nodejs" => by_name("JavaScript"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_files_and_auxiliary_grammars_are_registered() {
|
||||||
|
assert_eq!(all().count(), 18);
|
||||||
|
assert!(all().all(|spec| !spec.highlights.is_empty()));
|
||||||
|
for (filename, language) in [
|
||||||
|
("main.rs", "Rust"),
|
||||||
|
("Gemfile", "Ruby"),
|
||||||
|
("main.py", "Python"),
|
||||||
|
("package.json", "JSON"),
|
||||||
|
(".bashrc", "Bash"),
|
||||||
|
("GNUmakefile", "Makefile"),
|
||||||
|
("README.md", "Markdown"),
|
||||||
|
("main.jsx", "JavaScript"),
|
||||||
|
("main.ts", "TypeScript"),
|
||||||
|
("index.html", "HTML"),
|
||||||
|
("style.css", "CSS"),
|
||||||
|
("config.yml", "YAML"),
|
||||||
|
("Cargo.toml", "TOML"),
|
||||||
|
("Cargo.lock", "TOML"),
|
||||||
|
("main.c", "C"),
|
||||||
|
("main.cpp", "C++"),
|
||||||
|
("main.C", "C++"),
|
||||||
|
("main.go", "Go"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
detect(Some(Path::new(filename)), None).map(|spec| spec.name),
|
||||||
|
Some(language)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let ts = detect(Some(Path::new("main.ts")), None).unwrap();
|
||||||
|
let tsx = detect(Some(Path::new("main.tsx")), None).unwrap();
|
||||||
|
assert_eq!(ts.name, tsx.name);
|
||||||
|
assert!(!std::ptr::eq(ts, tsx));
|
||||||
|
assert_ne!(ts.language, tsx.language);
|
||||||
|
assert!(std::ptr::eq(tsx, by_name("TSX").unwrap()));
|
||||||
|
assert!(by_name("markdown_inline").is_some());
|
||||||
|
for filename in [
|
||||||
|
"notes.txt",
|
||||||
|
"main.hs",
|
||||||
|
"main.scala",
|
||||||
|
"main.lua",
|
||||||
|
"Dockerfile",
|
||||||
|
] {
|
||||||
|
assert!(detect(Some(Path::new(filename)), None).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shebangs_support_bom_env_options_and_filename_precedence() {
|
||||||
|
for (line, language) in [
|
||||||
|
("#!/usr/bin/python3", "Python"),
|
||||||
|
("\u{feff}#!/usr/bin/env -S python3.12 -u", "Python"),
|
||||||
|
("#!/usr/bin/env -u PYTHONHOME FOO=bar python3", "Python"),
|
||||||
|
("#!/usr/bin/ruby3.3", "Ruby"),
|
||||||
|
("#!/bin/bash", "Bash"),
|
||||||
|
("#! /usr/bin/env node", "JavaScript"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
detect(None, Some(line)).map(|spec| spec.name),
|
||||||
|
Some(language)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
detect(Some(Path::new("main.rs")), Some("#!/bin/bash"))
|
||||||
|
.unwrap()
|
||||||
|
.name,
|
||||||
|
"Rust"
|
||||||
|
);
|
||||||
|
assert!(detect(None, Some("#!/usr/bin/python-not-real")).is_none());
|
||||||
|
assert!(detect(None, Some("#!/usr/bin/perl")).is_none());
|
||||||
|
assert!(detect(None, Some("plain text")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1105
-56
File diff suppressed because it is too large
Load Diff
+961
@@ -0,0 +1,961 @@
|
|||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use vamp::{
|
||||||
|
editor::{Cursor, Input, Mode},
|
||||||
|
plugin::DiagnosticSeverity,
|
||||||
|
session::EditorSession,
|
||||||
|
};
|
||||||
|
|
||||||
|
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
// A real subprocess speaking framed LSP to the native client.
|
||||||
|
// Files are explicit test-controlled gates; no wall-clock ordering assumptions.
|
||||||
|
const SERVER: &str = r#"
|
||||||
|
require 'json'
|
||||||
|
require 'uri'
|
||||||
|
STDIN.binmode
|
||||||
|
STDOUT.binmode
|
||||||
|
STDOUT.sync = true
|
||||||
|
mode, log, release, source, target, alternate = ARGV
|
||||||
|
|
||||||
|
def uri(path)
|
||||||
|
'file://' + URI::DEFAULT_PARSER.escape(File.expand_path(path), /[^A-Za-z0-9\-._~\/]/)
|
||||||
|
end
|
||||||
|
|
||||||
|
def emit(frame)
|
||||||
|
body = JSON.generate({'jsonrpc' => '2.0'}.merge(frame)).encode('UTF-8').b
|
||||||
|
header = "Content-Length: #{body.bytesize}\r\nContent-Type: application/vscode-jsonrpc; charset=utf8\r\n\r\n".b
|
||||||
|
# Both fragmented headers/bodies and multiple immediately adjacent frames.
|
||||||
|
STDOUT.write(header.byteslice(0, 7))
|
||||||
|
STDOUT.write(header.byteslice(7..))
|
||||||
|
STDOUT.write(body.byteslice(0, 3))
|
||||||
|
STDOUT.write(body.byteslice(3..))
|
||||||
|
end
|
||||||
|
|
||||||
|
def result(id, value)
|
||||||
|
emit({'id' => id, 'result' => value})
|
||||||
|
end
|
||||||
|
|
||||||
|
def range(row, start, finish)
|
||||||
|
{'start' => {'line' => row, 'character' => start},
|
||||||
|
'end' => {'line' => row, 'character' => finish}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def location(path, row = 0, column = 3)
|
||||||
|
{'uri' => uri(path), 'range' => range(row, column, column + (row == 0 ? 1 : 0))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def diagnostics(document, message, old: false, unversioned: false)
|
||||||
|
diagnostic = {'range' => old ? range(999, 0, 0) : range(0, 2, 4),
|
||||||
|
'severity' => 1, 'message' => message}
|
||||||
|
items = [diagnostic]
|
||||||
|
items << {'range' => range(1, 0, 0), 'severity' => 4, 'message' => 'newline boundary'} unless old
|
||||||
|
params = {'uri' => document['uri'], 'diagnostics' => items}
|
||||||
|
params['version'] = document['version'] - (old ? 1 : 0) unless unversioned
|
||||||
|
emit({'method' => 'textDocument/publishDiagnostics', 'params' => params})
|
||||||
|
end
|
||||||
|
|
||||||
|
initialize_id = nil
|
||||||
|
initialized_sent = false
|
||||||
|
held = []
|
||||||
|
definition_count = 0
|
||||||
|
source_uri = uri(source)
|
||||||
|
loop do
|
||||||
|
if initialize_id && !initialized_sent && (!['gated', 'hover-gated'].include?(mode) || File.exist?(release))
|
||||||
|
sync = case mode
|
||||||
|
when 'numeric' then 1
|
||||||
|
when 'none' then {'openClose' => false, 'change' => 0, 'save' => false}
|
||||||
|
when 'incremental' then {'openClose' => true, 'change' => 2, 'save' => {'includeText' => true}}
|
||||||
|
else {'openClose' => true, 'change' => 1, 'save' => {'includeText' => true}}
|
||||||
|
end
|
||||||
|
result(initialize_id, {'capabilities' => {'positionEncoding' => 'utf-16',
|
||||||
|
'textDocumentSync' => sync, 'definitionProvider' => mode == 'unsupported' ? false : {'workDoneProgress' => false},
|
||||||
|
'hoverProvider' => mode == 'hover-unsupported' ? false : {'workDoneProgress' => false}}})
|
||||||
|
initialized_sent = true
|
||||||
|
end
|
||||||
|
if !held.empty? && File.exist?(release) && ['stale', 'hover-stale'].include?(mode)
|
||||||
|
held.each do |request|
|
||||||
|
result(request['id'], request['method'] == 'textDocument/hover' ? {'contents' => 'stale hover'} : location(target))
|
||||||
|
end
|
||||||
|
held.clear
|
||||||
|
emit({'method' => 'window/showMessage', 'params' => {'type' => 3, 'message' => 'stale response complete'}})
|
||||||
|
end
|
||||||
|
next unless IO.select([STDIN], nil, nil, 0.01)
|
||||||
|
headers = {}
|
||||||
|
loop do
|
||||||
|
line = STDIN.gets("\r\n")
|
||||||
|
exit if line.nil?
|
||||||
|
break if line == "\r\n"
|
||||||
|
name, value = line.chomp("\r\n").split(':', 2)
|
||||||
|
headers[name.downcase] = value.strip
|
||||||
|
end
|
||||||
|
length = Integer(headers.fetch('content-length'))
|
||||||
|
body = STDIN.read(length)
|
||||||
|
raise 'client body length mismatch' unless body && body.bytesize == length
|
||||||
|
frame = JSON.parse(body)
|
||||||
|
File.open(log, 'a') { |file| file.puts(JSON.generate(frame)); file.flush }
|
||||||
|
case frame['method']
|
||||||
|
when 'initialize'
|
||||||
|
initialize_id = frame.fetch('id')
|
||||||
|
emit({'id' => 'pre-init-message', 'method' => 'window/showMessageRequest',
|
||||||
|
'params' => {'type' => 3, 'message' => 'server initialization', 'actions' => []}})
|
||||||
|
when 'initialized'
|
||||||
|
emit({'id' => 'settings', 'method' => 'workspace/configuration',
|
||||||
|
'params' => {'items' => [{'section' => 'rust-analyzer'}, {'section' => 'missing'}]}})
|
||||||
|
emit({'id' => 0, 'method' => '$/unsupported', 'params' => {}})
|
||||||
|
when 'textDocument/didOpen'
|
||||||
|
document = frame.fetch('params').fetch('textDocument')
|
||||||
|
if document['uri'] == source_uri
|
||||||
|
if mode == 'malformed'
|
||||||
|
STDOUT.write("Content-Length: nope\r\n\r\n")
|
||||||
|
elsif mode == 'dead'
|
||||||
|
exit! 7
|
||||||
|
else
|
||||||
|
diagnostics(document, "initial\e diagnostic", unversioned: mode == 'unversioned')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
when 'textDocument/didChange'
|
||||||
|
document = frame.fetch('params').fetch('textDocument')
|
||||||
|
if document['uri'] == source_uri && !['malformed', 'dead'].include?(mode)
|
||||||
|
diagnostics(document, 'obsolete must drop', old: true)
|
||||||
|
diagnostics(document, "revision #{document['version']}")
|
||||||
|
end
|
||||||
|
when 'textDocument/definition'
|
||||||
|
definition_count += 1
|
||||||
|
case mode
|
||||||
|
when 'stale'
|
||||||
|
held << frame
|
||||||
|
when 'superseded'
|
||||||
|
held << frame
|
||||||
|
if held.length == 2
|
||||||
|
result(held[0]['id'], location(alternate))
|
||||||
|
result(held[1]['id'], location(target))
|
||||||
|
held.clear
|
||||||
|
end
|
||||||
|
when 'remote'
|
||||||
|
result(frame['id'], {'uri' => 'https://example.invalid/source.rs', 'range' => range(0, 0, 1)})
|
||||||
|
when 'bounds'
|
||||||
|
result(frame['id'], location(target, 999, 0))
|
||||||
|
when 'missing'
|
||||||
|
result(frame['id'], location(target + '.missing'))
|
||||||
|
else
|
||||||
|
case definition_count
|
||||||
|
when 1
|
||||||
|
result(frame['id'], location(target))
|
||||||
|
when 2
|
||||||
|
link = {'targetUri' => uri(target), 'targetRange' => range(1, 0, 0),
|
||||||
|
'targetSelectionRange' => range(1, 0, 0)}
|
||||||
|
result(frame['id'], [link, location(alternate, 0, 0)])
|
||||||
|
else
|
||||||
|
result(frame['id'], nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
when 'textDocument/hover'
|
||||||
|
if mode == 'hover-stale'
|
||||||
|
held << frame
|
||||||
|
elsif mode == 'hover-superseded'
|
||||||
|
held << frame
|
||||||
|
if held.length == 2
|
||||||
|
result(held[0]['id'], {'contents' => 'old hover'})
|
||||||
|
result(held[1]['id'], {'contents' => 'new hover'})
|
||||||
|
held.clear
|
||||||
|
end
|
||||||
|
else
|
||||||
|
contents = case mode
|
||||||
|
when 'hover-markdown' then {'kind' => 'markdown', 'value' => "**Header**\n```rust\nfn main() {}\n```"}
|
||||||
|
when 'hover-marked' then ['explanation', {'language' => 'rust', 'value' => "fn main() {\n}"}, 'tail']
|
||||||
|
when 'hover-string' then "one\nsecond"
|
||||||
|
when 'hover-empty' then nil
|
||||||
|
when 'hover-malformed' then {'kind' => 'html', 'value' => 'bad'}
|
||||||
|
else {'kind' => 'plaintext', 'value' => "hover first\r\n😀e\u{301}\tsecond\e\u{85}\nlast"}
|
||||||
|
end
|
||||||
|
hover = contents.nil? ? nil : {'contents' => contents, 'range' => range(mode == 'hover-invalid-range' ? 999 : 0, 2, 4)}
|
||||||
|
result(frame['id'], hover)
|
||||||
|
end
|
||||||
|
when 'shutdown'
|
||||||
|
result(frame['id'], nil)
|
||||||
|
when 'exit'
|
||||||
|
exit 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
"#;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
root: PathBuf,
|
||||||
|
source: PathBuf,
|
||||||
|
target: PathBuf,
|
||||||
|
alternate: PathBuf,
|
||||||
|
log: PathBuf,
|
||||||
|
release: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-lsp-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed),
|
||||||
|
));
|
||||||
|
fs::create_dir(&root).unwrap();
|
||||||
|
let root = fs::canonicalize(root).unwrap();
|
||||||
|
let fixture = Self {
|
||||||
|
source: root.join("source space λ.rs"),
|
||||||
|
target: root.join("target space 😀.rs"),
|
||||||
|
alternate: root.join("alternate.rs"),
|
||||||
|
log: root.join("protocol.jsonl"),
|
||||||
|
release: root.join("release"),
|
||||||
|
root,
|
||||||
|
};
|
||||||
|
fs::write(&fixture.source, "A😀e\u{301}Z\n").unwrap();
|
||||||
|
fs::write(&fixture.target, "disk\n").unwrap();
|
||||||
|
fs::write(&fixture.alternate, "other\n").unwrap();
|
||||||
|
fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&self, mode: &str) -> EditorSession {
|
||||||
|
let server = self.root.join("server.rb");
|
||||||
|
fs::write(&server, SERVER).unwrap();
|
||||||
|
let command = if mode == "spawn" {
|
||||||
|
json!([self.root.join("unavailable-language-server")])
|
||||||
|
} else {
|
||||||
|
json!([
|
||||||
|
"ruby",
|
||||||
|
server,
|
||||||
|
mode,
|
||||||
|
self.log,
|
||||||
|
self.release,
|
||||||
|
self.source,
|
||||||
|
self.target,
|
||||||
|
self.alternate
|
||||||
|
])
|
||||||
|
};
|
||||||
|
let options = json!({
|
||||||
|
"extensions": if mode == "irrelevant" { vec!["myl"] } else { vec!["rs"] },
|
||||||
|
"command": command,
|
||||||
|
"initialization_options": {"fixture": true},
|
||||||
|
"settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}},
|
||||||
|
});
|
||||||
|
let config = format!(
|
||||||
|
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.lsp['fixture-language'] = JSON.parse({})\n config.bind 'leader d', config.core.lsp.definition\n config.bind 'leader D', config.core.lsp.diagnostics\n config.bind 'K', config.core.lsp.hover\nend\n",
|
||||||
|
serde_json::to_string(&options.to_string()).unwrap(),
|
||||||
|
);
|
||||||
|
let config_path = self.root.join("config.rb");
|
||||||
|
fs::write(&config_path, config).unwrap();
|
||||||
|
EditorSession::open(
|
||||||
|
Some(self.source.clone()),
|
||||||
|
vec!["text".into(), "diagnostics".into()],
|
||||||
|
Some(&config_path),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frames(&self) -> Vec<Value> {
|
||||||
|
fs::read_to_string(&self.log)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
// A concurrent final line can be incomplete; the next poll reads it again.
|
||||||
|
.filter_map(|line| serde_json::from_str(line).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_count(&self, method: &str) -> usize {
|
||||||
|
self.frames()
|
||||||
|
.iter()
|
||||||
|
.filter(|frame| frame["method"] == method)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(&self) {
|
||||||
|
fs::write(&self.release, "release").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process-isolated deadline only: all semantic ordering uses protocol/file gates.
|
||||||
|
fn until(
|
||||||
|
session: &mut EditorSession,
|
||||||
|
fixture: &Fixture,
|
||||||
|
predicate: impl Fn(&EditorSession) -> bool,
|
||||||
|
) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
session.poll();
|
||||||
|
if predicate(session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"LSP fixture did not reach state: {}\n{}",
|
||||||
|
session.editor().message(),
|
||||||
|
serde_json::to_string_pretty(&fixture.frames()).unwrap(),
|
||||||
|
);
|
||||||
|
thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_target(session: &mut EditorSession, fixture: &Fixture) -> usize {
|
||||||
|
session.execute(&format!("edit {}", fixture.target.display()));
|
||||||
|
let id = session.editor().buffer_id();
|
||||||
|
session.handle(Input::Char('i'));
|
||||||
|
session.handle(Input::Paste("😀e\u{301}".into()));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
|
||||||
|
assert!(session.editor().dirty());
|
||||||
|
session.execute("buffer 1");
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("basic");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
&& fixture
|
||||||
|
.frames()
|
||||||
|
.iter()
|
||||||
|
.any(|frame| frame["id"] == "settings")
|
||||||
|
&& fixture.frames().iter().any(|frame| frame["id"] == 0)
|
||||||
|
});
|
||||||
|
let diagnostic = session.editor().diagnostics().next().unwrap();
|
||||||
|
assert_eq!(diagnostic.start.row, 0);
|
||||||
|
assert_eq!(diagnostic.start.column, 1); // inside emoji => whole emoji
|
||||||
|
assert_eq!(diagnostic.end.column, 3); // inside e + combining accent => whole cluster
|
||||||
|
assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
|
||||||
|
assert_eq!(diagnostic.message, "initial diagnostic");
|
||||||
|
assert_eq!(session.editor().diagnostics().nth(1).unwrap().start.row, 1);
|
||||||
|
assert!(session.editor().diagnostic_at_cursor().is_none());
|
||||||
|
session.handle(Input::Right);
|
||||||
|
assert!(session.editor().diagnostic_at_cursor().is_some());
|
||||||
|
|
||||||
|
let frames = fixture.frames();
|
||||||
|
let initialize = &frames[0];
|
||||||
|
assert_eq!(initialize["method"], "initialize");
|
||||||
|
assert_eq!(
|
||||||
|
initialize["params"]["capabilities"]["general"]["positionEncodings"],
|
||||||
|
json!(["utf-16"])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
initialize["params"]["initializationOptions"],
|
||||||
|
json!({"fixture": true})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
initialize["params"]["capabilities"]["textDocument"]["publishDiagnostics"]["versionSupport"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
initialize["params"]["workspaceFolders"][0]["uri"],
|
||||||
|
initialize["params"]["rootUri"]
|
||||||
|
);
|
||||||
|
let open_index = frames
|
||||||
|
.iter()
|
||||||
|
.position(|frame| frame["method"] == "textDocument/didOpen")
|
||||||
|
.unwrap();
|
||||||
|
let initialized_index = frames
|
||||||
|
.iter()
|
||||||
|
.position(|frame| frame["method"] == "initialized")
|
||||||
|
.unwrap();
|
||||||
|
assert!(initialized_index < open_index);
|
||||||
|
assert_eq!(
|
||||||
|
frames[open_index]["params"]["textDocument"]["languageId"],
|
||||||
|
"fixture-language"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
frames[open_index]["params"]["textDocument"]["text"],
|
||||||
|
"A😀e\u{301}Z\n"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
frames[open_index]["params"]["textDocument"]["uri"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("%20")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
frames[open_index]["params"]["textDocument"]["uri"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("%CE%BB")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
frames
|
||||||
|
.iter()
|
||||||
|
.find(|frame| frame["id"] == "pre-init-message")
|
||||||
|
.unwrap()["result"],
|
||||||
|
Value::Null
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
frames
|
||||||
|
.iter()
|
||||||
|
.find(|frame| frame["id"] == "settings")
|
||||||
|
.unwrap()["result"],
|
||||||
|
json!([{"cargo": {"allFeatures": true}}, null])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
frames.iter().find(|frame| frame["id"] == 0).unwrap()["error"]["code"],
|
||||||
|
-32601
|
||||||
|
);
|
||||||
|
|
||||||
|
let target_id = edit_target(&mut session, &fixture);
|
||||||
|
session.handle(Input::Home);
|
||||||
|
for _ in 0..3 {
|
||||||
|
session.handle(Input::Right);
|
||||||
|
}
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
session.handle(Input::Char('d'));
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().buffer_id() == target_id
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().buffer_count(), 2);
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
|
||||||
|
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||||
|
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
|
||||||
|
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
|
||||||
|
let definition = fixture
|
||||||
|
.frames()
|
||||||
|
.into_iter()
|
||||||
|
.find(|frame| frame["method"] == "textDocument/definition")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
definition["params"]["position"],
|
||||||
|
json!({"line": 0, "character": 5})
|
||||||
|
);
|
||||||
|
|
||||||
|
session.execute("buffer 1");
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().buffer_id() == target_id
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains("first of 2 definitions")
|
||||||
|
);
|
||||||
|
session.execute("buffer 1");
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().message().contains("no definition found")
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().buffer_id(), 1);
|
||||||
|
|
||||||
|
session.handle(Input::Char('A'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diagnostics()
|
||||||
|
.any(|diagnostic| diagnostic.message.starts_with("revision "))
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!session
|
||||||
|
.editor()
|
||||||
|
.diagnostics()
|
||||||
|
.any(|diagnostic| diagnostic.message.contains("obsolete"))
|
||||||
|
);
|
||||||
|
session.execute("w");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/didSave") == 1
|
||||||
|
});
|
||||||
|
let save = fixture
|
||||||
|
.frames()
|
||||||
|
.into_iter()
|
||||||
|
.find(|frame| frame["method"] == "textDocument/didSave")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(save["params"]["text"], "A😀e\u{301}Z!\n");
|
||||||
|
session.execute("bdelete 1");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/didClose") == 1
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().buffer_id(), target_id);
|
||||||
|
assert!(session.editor().dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_initialization_and_incremental_sync_do_not_block_native_typing() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("gated");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("initialize") == 1
|
||||||
|
});
|
||||||
|
session.handle(Input::Char('A'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
|
||||||
|
assert_eq!(fixture.method_count("textDocument/didOpen"), 0);
|
||||||
|
session.execute("lsp.diagnostics");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().message().contains("server initializing")
|
||||||
|
});
|
||||||
|
fixture.release();
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
let opens: Vec<_> = fixture
|
||||||
|
.frames()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|frame| frame["method"] == "textDocument/didOpen")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(opens.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
opens[0]["params"]["textDocument"]["text"],
|
||||||
|
"A😀e\u{301}Z!\n"
|
||||||
|
);
|
||||||
|
assert_eq!(opens[0]["params"]["textDocument"]["version"], 2);
|
||||||
|
drop(session);
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("incremental");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.handle(Input::Char('A'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/didChange") == 1
|
||||||
|
});
|
||||||
|
let change = fixture
|
||||||
|
.frames()
|
||||||
|
.into_iter()
|
||||||
|
.find(|frame| frame["method"] == "textDocument/didChange")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
change["params"]["contentChanges"][0]["range"],
|
||||||
|
json!({"start": {"line": 0, "character": 0}, "end": {"line": 1, "character": 0}})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
change["params"]["contentChanges"][0]["text"],
|
||||||
|
"A😀e\u{301}Z!\n"
|
||||||
|
);
|
||||||
|
assert_eq!(change["params"]["textDocument"]["version"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("stale");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/definition") == 1
|
||||||
|
});
|
||||||
|
session.handle(Input::Char('A'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
assert_eq!(session.editor().mode(), Mode::Insert);
|
||||||
|
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/didChange") == 1
|
||||||
|
});
|
||||||
|
fixture.release();
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains("stale response complete")
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().buffer_id(), 1);
|
||||||
|
assert_eq!(session.editor().mode(), Mode::Insert);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.handle(Input::Char('u'));
|
||||||
|
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
|
||||||
|
drop(session);
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("stale");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/definition") == 1
|
||||||
|
});
|
||||||
|
session.execute(&format!("edit {}", fixture.alternate.display()));
|
||||||
|
let active_id = session.editor().buffer_id();
|
||||||
|
fixture.release();
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains("stale response complete")
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().buffer_id(), active_id);
|
||||||
|
assert_eq!(session.editor().path(), Some(fixture.alternate.as_path()));
|
||||||
|
assert_eq!(session.editor().buffer_count(), 2);
|
||||||
|
drop(session);
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("superseded");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().path() == Some(fixture.target.as_path())
|
||||||
|
});
|
||||||
|
assert_eq!(fixture.method_count("textDocument/definition"), 2);
|
||||||
|
assert_eq!(session.editor().buffer_count(), 2);
|
||||||
|
assert!(
|
||||||
|
!session
|
||||||
|
.editor()
|
||||||
|
.buffers()
|
||||||
|
.any(|buffer| buffer.path == Some(fixture.alternate.as_path()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() {
|
||||||
|
for mode in [
|
||||||
|
"spawn",
|
||||||
|
"malformed",
|
||||||
|
"dead",
|
||||||
|
"unsupported",
|
||||||
|
"remote",
|
||||||
|
"bounds",
|
||||||
|
"missing",
|
||||||
|
] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open(mode);
|
||||||
|
if matches!(mode, "spawn" | "malformed" | "dead") {
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().message().contains(if mode == "spawn" {
|
||||||
|
"failed to start"
|
||||||
|
} else {
|
||||||
|
"server stopped"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||||
|
} else {
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains(if mode == "unsupported" {
|
||||||
|
"does not support"
|
||||||
|
} else {
|
||||||
|
"definition failed"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if mode == "unsupported" {
|
||||||
|
assert_eq!(fixture.method_count("textDocument/definition"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(session.editor().buffer_id(), 1, "{mode}");
|
||||||
|
session.handle(Input::Char('A'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""], "{mode}");
|
||||||
|
session.handle(Input::Char('u'));
|
||||||
|
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""], "{mode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_optional_configuration_numeric_sync_and_unversioned_diagnostics() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let session = EditorSession::open(
|
||||||
|
Some(fixture.source.clone()),
|
||||||
|
vec!["text".into(), "diagnostics".into()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session.editor().diagnostics().count(), 0);
|
||||||
|
assert!(!fixture.log.exists());
|
||||||
|
drop(session);
|
||||||
|
|
||||||
|
for mode in ["numeric", "unversioned"] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open(mode);
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.handle(Input::Char('A'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diagnostics()
|
||||||
|
.any(|diagnostic| diagnostic.message == "revision 2")
|
||||||
|
});
|
||||||
|
let change = fixture
|
||||||
|
.frames()
|
||||||
|
.into_iter()
|
||||||
|
.find(|frame| frame["method"] == "textDocument/didChange")
|
||||||
|
.unwrap();
|
||||||
|
assert!(change["params"]["contentChanges"][0].get("range").is_none());
|
||||||
|
assert_eq!(change["params"]["textDocument"]["version"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("none");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture
|
||||||
|
.frames()
|
||||||
|
.iter()
|
||||||
|
.any(|frame| frame["id"] == "settings")
|
||||||
|
});
|
||||||
|
session.handle(Input::Char('A'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.execute("w");
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
session.handle(Input::Char('D'));
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains("diagnostics update automatically")
|
||||||
|
});
|
||||||
|
assert_eq!(fixture.method_count("textDocument/didOpen"), 0);
|
||||||
|
assert_eq!(fixture.method_count("textDocument/didChange"), 0);
|
||||||
|
assert_eq!(fixture.method_count("textDocument/didSave"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_definition_reuses_dirty_targets_without_an_enabled_target_language() {
|
||||||
|
let mut fixture = Fixture::new();
|
||||||
|
let target = fixture.target.with_extension("txt");
|
||||||
|
fs::rename(&fixture.target, &target).unwrap();
|
||||||
|
fixture.target = target;
|
||||||
|
let mut session = fixture.open("basic");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
let target_id = edit_target(&mut session, &fixture);
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().buffer_id() == target_id
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
|
||||||
|
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
|
||||||
|
assert!(session.editor().dirty());
|
||||||
|
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
|
||||||
|
assert_eq!(fixture.method_count("textDocument/didOpen"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_definition_does_not_move_a_caret_that_has_moved_since_the_request() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("stale");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp.definition");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/definition") == 1
|
||||||
|
});
|
||||||
|
session.handle(Input::Right);
|
||||||
|
fixture.release();
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains("stale response complete")
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().buffer_id(), 1);
|
||||||
|
assert_eq!(session.editor().buffer_count(), 1);
|
||||||
|
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_only_starts_an_explicitly_configured_server_when_a_matching_buffer_opens() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("irrelevant");
|
||||||
|
session.execute("lsp.diagnostics");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().message().contains("no running server")
|
||||||
|
});
|
||||||
|
assert!(!fixture.log.exists());
|
||||||
|
let custom = fixture.root.join("custom.myl");
|
||||||
|
fs::write(&custom, "custom language\n").unwrap();
|
||||||
|
session.execute(&format!("edit {}", custom.display()));
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/didOpen") == 1
|
||||||
|
});
|
||||||
|
let frames = fixture.frames();
|
||||||
|
let open = frames
|
||||||
|
.iter()
|
||||||
|
.find(|frame| frame["method"] == "textDocument/didOpen")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
open["params"]["textDocument"]["languageId"],
|
||||||
|
"fixture-language"
|
||||||
|
);
|
||||||
|
assert_eq!(open["params"]["textDocument"]["text"], "custom language\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_session_shutdown_finishes_the_server_protocol_before_returning() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("basic");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
drop(session);
|
||||||
|
assert_eq!(fixture.method_count("shutdown"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_hover_typed_action_preserves_multiline_content_and_primary_utf16_position() {
|
||||||
|
for (mode, expected) in [
|
||||||
|
("hover-plain", "hover first\n😀e\u{301} second \nlast"),
|
||||||
|
("hover-markdown", "**Header**\n```rust\nfn main() {}\n```"),
|
||||||
|
("hover-marked", "explanation\n\nfn main() {\n}\n\ntail"),
|
||||||
|
("hover-string", "one\nsecond"),
|
||||||
|
] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open(mode);
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
for _ in 0..3 {
|
||||||
|
session.handle(Input::Right);
|
||||||
|
}
|
||||||
|
let before = session.editor().cursor();
|
||||||
|
session.handle(Input::Char('K'));
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().hover().is_some()
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().hover().unwrap().text(), expected);
|
||||||
|
assert_eq!(session.editor().cursor(), before);
|
||||||
|
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||||
|
assert!(!session.editor().dirty());
|
||||||
|
let frames = fixture.frames();
|
||||||
|
let hover = frames
|
||||||
|
.iter()
|
||||||
|
.find(|frame| frame["method"] == "textDocument/hover")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
hover["params"]["position"],
|
||||||
|
json!({"line": 0, "character": 5})
|
||||||
|
);
|
||||||
|
let initialize = frames
|
||||||
|
.iter()
|
||||||
|
.find(|frame| frame["method"] == "initialize")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
initialize["params"]["capabilities"]["textDocument"]["hover"]["contentFormat"],
|
||||||
|
json!(["plaintext", "markdown"])
|
||||||
|
);
|
||||||
|
session.handle(Input::Down);
|
||||||
|
assert_eq!(session.editor().cursor(), before);
|
||||||
|
assert_eq!(session.editor().hover().unwrap().top, 1);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert!(session.editor().hover().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_hover_queues_before_initialize_and_ignores_cancelled_and_superseded_requests() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("hover-gated");
|
||||||
|
session.execute("lsp.diagnostics");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().message().contains("server initializing")
|
||||||
|
});
|
||||||
|
assert_eq!(fixture.method_count("textDocument/hover"), 0);
|
||||||
|
session.execute("lsp.hover");
|
||||||
|
fixture.release();
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().hover().is_some()
|
||||||
|
});
|
||||||
|
drop(session);
|
||||||
|
|
||||||
|
for input in [Input::Escape, Input::Right, Input::Char('i')] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("hover-stale");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp.hover");
|
||||||
|
until(&mut session, &fixture, |_| {
|
||||||
|
fixture.method_count("textDocument/hover") == 1
|
||||||
|
});
|
||||||
|
session.handle(input);
|
||||||
|
fixture.release();
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains("stale response complete")
|
||||||
|
});
|
||||||
|
assert!(session.editor().hover().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open("hover-superseded");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp.hover");
|
||||||
|
session.execute("lsp.hover");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().hover().is_some()
|
||||||
|
});
|
||||||
|
assert_eq!(session.editor().hover().unwrap().text(), "new hover");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_hover_absent_unsupported_and_invalid_results_preserve_native_editing() {
|
||||||
|
for mode in [
|
||||||
|
"hover-empty",
|
||||||
|
"hover-unsupported",
|
||||||
|
"hover-malformed",
|
||||||
|
"hover-invalid-range",
|
||||||
|
] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.open(mode);
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session.editor().diagnostics().count() == 2
|
||||||
|
});
|
||||||
|
session.execute("lsp.hover");
|
||||||
|
until(&mut session, &fixture, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains(if mode == "hover-empty" {
|
||||||
|
"no hover information"
|
||||||
|
} else if mode == "hover-unsupported" {
|
||||||
|
"does not support hover"
|
||||||
|
} else {
|
||||||
|
"hover failed"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert!(session.editor().hover().is_none());
|
||||||
|
assert_eq!(
|
||||||
|
fixture.method_count("textDocument/hover"),
|
||||||
|
usize::from(mode != "hover-unsupported")
|
||||||
|
);
|
||||||
|
session.handle(Input::Char('A'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
|
||||||
|
session.handle(Input::Char('u'));
|
||||||
|
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
|
||||||
|
assert_eq!(fixture.method_count("textDocument/definition"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user